diff --git a/cmd/cycloid/components/config_get.go b/cmd/cycloid/components/config_get.go index 346790d6..10a8da8e 100644 --- a/cmd/cycloid/components/config_get.go +++ b/cmd/cycloid/components/config_get.go @@ -19,6 +19,7 @@ func NewComponentConfigGetCommand() *cobra.Command { Example: "cy config get -p project -e env -c component", } cyargs.AddCyContext(cmd) + cyargs.AddStackVersionFlags(cmd) return cmd } @@ -31,6 +32,18 @@ func getComponentConfig(cmd *cobra.Command, args []string) error { api := common.NewAPI() m := middleware.NewMiddleware(api) - config, _, err := m.GetComponentConfig(org, project, env, component) + var tag, branch, hash string + versionID, ok, err := cyargs.GetStackVersionID(cmd) + if err != nil { + return err + } + if !ok { + tag, branch, hash, err = cyargs.ResolveStackVersionArg(cmd, m, org, "") + if err != nil { + return err + } + } + + config, _, err := m.GetComponentConfig(org, project, env, component, tag, branch, hash, versionID) return cyout.PrintWithOptions(cmd, config, err, "failed to fetch config of component '"+component+"'", printer.Options{}) } diff --git a/cmd/cycloid/components/create.go b/cmd/cycloid/components/create.go index 67693a6d..eba0a508 100644 --- a/cmd/cycloid/components/create.go +++ b/cmd/cycloid/components/create.go @@ -100,7 +100,7 @@ func createComponent(cmd *cobra.Command, args []string) error { // Fetch base forms value from current component var currentConfig = make(models.FormVariables) if currentComponent.UseCase != "" { - currentConfig, _, err = m.GetComponentConfig(org, project, env, component) + currentConfig, _, err = m.GetComponentConfig(org, project, env, component, "", "", "", 0) if err != nil { return cyout.PrintWithOptions(cmd, nil, err, "failed to update component '"+component+"', cannot get current config.", printer.Options{}) } diff --git a/cmd/cycloid/components/update.go b/cmd/cycloid/components/update.go index 443c3525..8ca9955a 100644 --- a/cmd/cycloid/components/update.go +++ b/cmd/cycloid/components/update.go @@ -116,7 +116,7 @@ func updateComponent(cmd *cobra.Command, args []string) error { var currentConfig = make(models.FormVariables) if currentComponent.UseCase != "" { - currentConfig, _, err = m.GetComponentConfig(org, project, env, component) + currentConfig, _, err = m.GetComponentConfig(org, project, env, component, "", "", "", 0) if err != nil { return cyout.PrintWithOptions(cmd, nil, err, "failed to update component '"+component+"', cannot get current config.", printer.Options{}) } diff --git a/cmd/cycloid/middleware/middleware.go b/cmd/cycloid/middleware/middleware.go index eb740cc4..119f4f26 100644 --- a/cmd/cycloid/middleware/middleware.go +++ b/cmd/cycloid/middleware/middleware.go @@ -180,7 +180,7 @@ type Middleware interface { GetComponent(org, project, env, component string) (*models.Component, *http.Response, error) MigrateComponent(org, project, env, component, targetProject, targetEnv, newCanonical, newName string) (*models.Component, *http.Response, error) DeleteComponent(org, project, env, component string, opts DeleteOptions) (*http.Response, error) - GetComponentConfig(org, project, env, component string) (models.FormVariables, *http.Response, error) + GetComponentConfig(org, project, env, component, versionTag, versionBranch, versionCommitHash string, versionID uint32) (models.FormVariables, *http.Response, error) GetComponentStackConfig(org, project, env, component, useCase, versionTag, versionBranch, versionCommitHash string) (models.ServiceCatalogConfigs, *http.Response, error) DeleteRole(org, role string) (*http.Response, error) diff --git a/cmd/cycloid/middleware/offline/component_config_test.go b/cmd/cycloid/middleware/offline/component_config_test.go new file mode 100644 index 00000000..7681770d --- /dev/null +++ b/cmd/cycloid/middleware/offline/component_config_test.go @@ -0,0 +1,77 @@ +package offline_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cycloidio/cycloid-cli/cmd/cycloid/common" + "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware" + "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware/offline/mockserver" +) + +func TestGetComponentConfig_RawVersionID(t *testing.T) { + var capturedQuery string + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, "/config") { + capturedQuery = r.URL.RawQuery + } + _, _ = w.Write([]byte(`{"data":{}}`)) + })) + defer srv.Close() + + api := common.NewAPI(common.WithURL(srv.URL), common.WithToken("test-token")) + m := middleware.NewMiddleware(api) + + _, _, err := m.GetComponentConfig("org", "proj", "env", "comp", "", "", "", 14) + require.NoError(t, err) + assert.Equal(t, "service_catalog_source_version_id=14", capturedQuery) + assert.Equal(t, 1, callCount, "raw version ID must not trigger extra API calls") +} + +func TestGetComponentConfig_BranchResolvesVersion(t *testing.T) { + var capturedQuery string + srv := mockserver.ComponentConfigServer(t, &capturedQuery) + defer srv.Close() + + api := common.NewAPI(common.WithURL(srv.URL), common.WithToken("test-token")) + m := middleware.NewMiddleware(api) + + _, _, err := m.GetComponentConfig("myorg", "myproj", "myenv", "mycomp", "", mockserver.BranchName, "", 0) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("service_catalog_source_version_id=%d", mockserver.BranchVersionID), capturedQuery) +} + +func TestGetComponentConfig_TagResolvesVersion(t *testing.T) { + var capturedQuery string + srv := mockserver.ComponentConfigServer(t, &capturedQuery) + defer srv.Close() + + api := common.NewAPI(common.WithURL(srv.URL), common.WithToken("test-token")) + m := middleware.NewMiddleware(api) + + _, _, err := m.GetComponentConfig("myorg", "myproj", "myenv", "mycomp", mockserver.TagName, "", "", 0) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("service_catalog_source_version_id=%d", mockserver.TagVersionID), capturedQuery) +} + +func TestGetComponentConfig_AutoResolvesLatestVersion(t *testing.T) { + var capturedQuery string + srv := mockserver.ComponentConfigServer(t, &capturedQuery) + defer srv.Close() + + api := common.NewAPI(common.WithURL(srv.URL), common.WithToken("test-token")) + m := middleware.NewMiddleware(api) + + _, _, err := m.GetComponentConfig("myorg", "myproj", "myenv", "mycomp", "", "", "", 0) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("service_catalog_source_version_id=%d", mockserver.BranchVersionID), capturedQuery) +} diff --git a/cmd/cycloid/middleware/offline/mockserver/mockserver.go b/cmd/cycloid/middleware/offline/mockserver/mockserver.go new file mode 100644 index 00000000..e91d738c --- /dev/null +++ b/cmd/cycloid/middleware/offline/mockserver/mockserver.go @@ -0,0 +1,70 @@ +package mockserver + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// Version IDs and names served by ComponentConfigServer. +const ( + BranchVersionID = uint32(99) + TagVersionID = uint32(77) + BranchName = "main" + TagName = "v1.2.3" + StackRef = "myorg:my-stack" + CatalogRepo = "my-catalog-repo" +) + +// ComponentConfigServer returns a test server that mocks the full resolution chain +// for GetComponentConfig: GetComponent → GetStack → GetCatalogRepository → +// ListStackVersions → config GET. The capturedQuery pointer receives the raw query +// string of the final config request. +func ComponentConfigServer(t *testing.T, capturedQuery *string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := r.URL.Path + + writeJSON := func(v any) { + _ = json.NewEncoder(w).Encode(map[string]any{"data": v}) + } + + switch { + case strings.HasSuffix(path, "/components/mycomp/config"): + *capturedQuery = r.URL.RawQuery + writeJSON(map[string]any{}) + + case strings.HasSuffix(path, "/components/mycomp"): + writeJSON(map[string]any{ + "service_catalog": map[string]any{ + "ref": StackRef, + "service_catalog_source_canonical": CatalogRepo, + }, + }) + + case strings.Contains(path, "service_catalogs") && strings.HasSuffix(path, "/versions"): + writeJSON([]map[string]any{ + {"id": BranchVersionID, "type": "branch", "name": BranchName, "commit_hash": "abc123"}, + {"id": TagVersionID, "type": "tag", "name": TagName, "commit_hash": "def456"}, + }) + + case strings.Contains(path, "service_catalogs"): + writeJSON(map[string]any{ + "ref": StackRef, + "service_catalog_source_canonical": CatalogRepo, + }) + + case strings.Contains(path, "service_catalog_sources"): + writeJSON(map[string]any{ + "canonical": CatalogRepo, + "branch": BranchName, + }) + + default: + http.NotFound(w, r) + } + })) +} diff --git a/cmd/cycloid/middleware/organization_components.go b/cmd/cycloid/middleware/organization_components.go index 9a1410aa..ef65818a 100644 --- a/cmd/cycloid/middleware/organization_components.go +++ b/cmd/cycloid/middleware/organization_components.go @@ -10,12 +10,29 @@ import ( "github.com/cycloidio/cycloid-cli/internal/ptr" ) -func (m *middleware) GetComponentConfig(org, project, env, component string) (models.FormVariables, *http.Response, error) { +func (m *middleware) GetComponentConfig(org, project, env, component, versionTag, versionBranch, versionCommitHash string, versionID uint32) (models.FormVariables, *http.Response, error) { var result models.FormVariables + + if versionID == 0 { + comp, _, err := m.GetComponent(org, project, env, component) + if err != nil { + return nil, nil, fmt.Errorf("failed to get component to resolve stack version: %w", err) + } + versionID, _, err = m.resolveStackVersion(org, *comp.ServiceCatalog.Ref, versionTag, versionBranch, versionCommitHash) + if err != nil { + return nil, nil, fmt.Errorf("failed to resolve stack version: %w", err) + } + } + + query := url.Values{ + "service_catalog_source_version_id": []string{strconv.FormatUint(uint64(versionID), 10)}, + } + resp, err := m.GenericRequest(Request{ Method: "GET", Organization: &org, Route: []string{"organizations", org, "projects", project, "environments", env, "components", component, "config"}, + Query: query, }, &result) if err != nil { return nil, resp, err diff --git a/cmd/cycloid/projects/create.go b/cmd/cycloid/projects/create.go index b6025396..e1186567 100644 --- a/cmd/cycloid/projects/create.go +++ b/cmd/cycloid/projects/create.go @@ -74,7 +74,7 @@ func create(cmd *cobra.Command, args []string) error { return err } - configRepository, err := cyargs.GetDefaultConfigRepository(cmd) + configRepository, err := cyargs.GetConfigRepository(cmd) if err != nil { return err } diff --git a/cmd/cycloid/projects/update.go b/cmd/cycloid/projects/update.go index b722dca3..4d0b4b51 100644 --- a/cmd/cycloid/projects/update.go +++ b/cmd/cycloid/projects/update.go @@ -68,7 +68,7 @@ func update(cmd *cobra.Command, args []string) error { return err } - configRepository, err := cyargs.GetDefaultConfigRepository(cmd) + configRepository, err := cyargs.GetConfigRepository(cmd) if err != nil { return err } diff --git a/internal/cyargs/config_catalog_repositories.go b/internal/cyargs/config_catalog_repositories.go index 0ec5353e..f89f1f21 100644 --- a/internal/cyargs/config_catalog_repositories.go +++ b/internal/cyargs/config_catalog_repositories.go @@ -1,12 +1,8 @@ package cyargs import ( - "fmt" - "slices" - "github.com/spf13/cobra" - "github.com/cycloidio/cycloid-cli/client/models" "github.com/cycloidio/cycloid-cli/cmd/cycloid/common" "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware" ) @@ -71,7 +67,7 @@ func CompleteCatalogRepository(cmd *cobra.Command, args []string, toComplete str func AddConfigRepositoryFlag(cmd *cobra.Command) string { flagName := "config-repository" - cmd.Flags().String(flagName, "", "canonical of a config repository, if empty will use the default one in the current org.") + cmd.Flags().String(flagName, "", "canonical of a config repository") cmd.RegisterFlagCompletionFunc(flagName, CompleteConfigRepository) return flagName } @@ -108,8 +104,6 @@ func CompleteConfigRepository(cmd *cobra.Command, args []string, toComplete stri return configRepositories, cobra.ShellCompDirectiveNoFileComp } -// GetConfigRepository return the config repository flag, if empty, will try to return -// the current org default config repository func GetConfigRepository(cmd *cobra.Command) (string, error) { configRepository, err := cmd.Flags().GetString("config-repository") if err != nil { @@ -118,35 +112,3 @@ func GetConfigRepository(cmd *cobra.Command) (string, error) { return configRepository, err } - -func GetDefaultConfigRepository(cmd *cobra.Command) (string, error) { - api := common.NewAPI() - m := middleware.NewMiddleware(api) - - org, err := GetOrg(cmd) - if err != nil { - return "", fmt.Errorf("failed to get default config repository, missing org argument: %w", err) - } - - configRepository, _ := GetConfigRepository(cmd) - if configRepository != "" { - return configRepository, nil - } - - // TODO: This behavior will be pushed to backend - // track issue: https://linear.app/cycloid/issue/BE-807/make-the-createproject-route-use-the-default-catalog-if - catalogRepos, _, err := m.ListConfigRepositories(org) - if err != nil { - return "", fmt.Errorf("failed to get the default config repository: %w", err) - } - - index := slices.IndexFunc(catalogRepos, func(c *models.ConfigRepository) bool { - return *c.Default - }) - if index == -1 { - docURL := "https://docs.cycloid.io/reference/config-and-catalog-repository/" - return "", fmt.Errorf("error: seems like your org %q does not have a default config repository, please add one using this doc: %q", org, docURL) - } - - return *catalogRepos[index].Canonical, nil -} diff --git a/internal/cyargs/stacks.go b/internal/cyargs/stacks.go index 54579c29..2af9a9ce 100644 --- a/internal/cyargs/stacks.go +++ b/internal/cyargs/stacks.go @@ -2,6 +2,7 @@ package cyargs import ( "fmt" + "strconv" "strings" "github.com/spf13/cobra" @@ -136,7 +137,7 @@ func GetStack(cmd *cobra.Command) (string, error) { // backward compatibility but are deprecated and will be removed in a future major release. func AddStackVersionFlags(cmd *cobra.Command) { cmd.Flags().String("stack-version", "", "stack version (tag, branch, or commit hash). "+ - "Use type prefixes to avoid ambiguity: tag:, branch:, sha:") + "Use type prefixes to avoid ambiguity: tag:, branch:, sha:, version:") cmd.RegisterFlagCompletionFunc("stack-version", CompleteStackVersionUnified) // Legacy flags — kept for backward compatibility. @@ -219,6 +220,10 @@ func ResolveStackVersionArg(cmd *cobra.Command, m middleware.Middleware, org, st return "", value, "", nil case "sha", "commit": return "", "", value, nil + case "version": + return "", "", "", fmt.Errorf( + "--stack-version=version: is only supported by 'cy component config get'; " + + "use tag:, branch:, or sha: for other commands") } // Unknown prefix: fall through and attempt bare resolution on the full value. } @@ -266,6 +271,24 @@ func ResolveStackVersionArg(cmd *cobra.Command, m middleware.Middleware, org, st } } +// GetStackVersionID parses --stack-version=version: and returns the numeric catalog +// version ID. Returns (id, true, nil) when that form is used, (0, false, nil) otherwise. +func GetStackVersionID(cmd *cobra.Command) (uint32, bool, error) { + v, err := cmd.Flags().GetString("stack-version") + if err != nil || v == "" { + return 0, false, err + } + idStr, ok := strings.CutPrefix(v, "version:") + if !ok { + return 0, false, nil + } + id64, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + return 0, false, fmt.Errorf("--stack-version=version:: expected a numeric ID, got %q", idStr) + } + return uint32(id64), true, nil +} + // CompleteStackVersionUnified provides prefix-aware completion for --stack-version. // // - No prefix typed (e.g. "v1"): lists all versions with type in description.