From 3ebd4a96fc649e8ad1dfeef9fa005a0a880e9ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20BARRAS=20HAMPEL?= Date: Thu, 11 Jun 2026 20:11:56 +0200 Subject: [PATCH 1/4] fix(components): pass service_catalog_source_version_id to config GET (CLI-124) The CLI omitted the ?service_catalog_source_version_id query param when fetching component config, causing the API to return a stale version. Auto-resolve the latest version by fetching the component's stack ref and calling resolveStackVersion with empty hints (matching console behavior). An explicit --service-catalog-source-version-id flag is also added to config_get for version pinning. create/update callers now also pass the resolved latest version when pre-loading current config. Co-Authored-By: Claude Sonnet 4.6 --- cmd/cycloid/components/config_get.go | 8 +- cmd/cycloid/components/create.go | 2 +- cmd/cycloid/components/update.go | 2 +- cmd/cycloid/middleware/middleware.go | 2 +- .../offline/component_config_test.go | 110 ++++++++++++++++++ .../middleware/organization_components.go | 22 +++- 6 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 cmd/cycloid/middleware/offline/component_config_test.go diff --git a/cmd/cycloid/components/config_get.go b/cmd/cycloid/components/config_get.go index 346790d64..a784ff507 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) + cmd.Flags().Uint32("service-catalog-source-version-id", 0, "service catalog source version ID (default: latest)") return cmd } @@ -28,9 +29,14 @@ func getComponentConfig(cmd *cobra.Command, args []string) error { return err } + versionID, err := cmd.Flags().GetUint32("service-catalog-source-version-id") + if err != nil { + return err + } + api := common.NewAPI() m := middleware.NewMiddleware(api) - config, _, err := m.GetComponentConfig(org, project, env, component) + config, _, err := m.GetComponentConfig(org, project, env, component, 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 67693a6d7..532ff1adb 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 443c35251..714db724b 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 8696f2b95..47d5cb638 100644 --- a/cmd/cycloid/middleware/middleware.go +++ b/cmd/cycloid/middleware/middleware.go @@ -190,7 +190,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 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 000000000..1c16c039f --- /dev/null +++ b/cmd/cycloid/middleware/offline/component_config_test.go @@ -0,0 +1,110 @@ +package offline_test + +import ( + "encoding/json" + "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" +) + +// TestGetComponentConfig_ExplicitVersionID verifies that when an explicit versionID is +// provided, GetComponentConfig sends exactly ?service_catalog_source_version_id= +// without any extra resolution calls. +func TestGetComponentConfig_ExplicitVersionID(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", 42) + require.NoError(t, err) + assert.Equal(t, "service_catalog_source_version_id=42", capturedQuery) + assert.Equal(t, 1, callCount, "explicit version ID must not trigger extra API calls") +} + +// TestGetComponentConfig_AutoResolvesLatestVersion verifies that when versionID == 0, +// GetComponentConfig resolves the component's latest stack version and passes the +// resolved service_catalog_source_version_id to the config endpoint. +func TestGetComponentConfig_AutoResolvesLatestVersion(t *testing.T) { + const ( + org = "myorg" + project = "myproj" + env = "prod" + component = "mycomp" + stackRef = "myorg:my-stack" + catRepo = "my-catalog-repo" + branch = "main" + wantID = uint32(99) + ) + + var capturedConfigQuery string + + srv := 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/"+component+"/config"): + capturedConfigQuery = r.URL.RawQuery + writeJSON(map[string]any{}) + + case strings.HasSuffix(path, "/components/"+component): + writeJSON(map[string]any{ + "service_catalog": map[string]any{ + "ref": stackRef, + "service_catalog_source_canonical": catRepo, + }, + }) + + case strings.Contains(path, "service_catalogs") && strings.HasSuffix(path, "/versions"): + writeJSON([]map[string]any{ + {"id": wantID, "type": "branch", "name": branch, "commit_hash": "abc123def456"}, + }) + + case strings.Contains(path, "service_catalogs"): + writeJSON(map[string]any{ + "ref": stackRef, + "service_catalog_source_canonical": catRepo, + }) + + case strings.Contains(path, "service_catalog_sources"): + writeJSON(map[string]any{ + "canonical": catRepo, + "branch": branch, + }) + + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + api := common.NewAPI(common.WithURL(srv.URL), common.WithToken("test-token")) + m := middleware.NewMiddleware(api) + + _, _, err := m.GetComponentConfig(org, project, env, component, 0) + require.NoError(t, err) + assert.Equal(t, "service_catalog_source_version_id=99", capturedConfigQuery, + "auto-resolve must pass the resolved version ID (99) to the config endpoint") +} diff --git a/cmd/cycloid/middleware/organization_components.go b/cmd/cycloid/middleware/organization_components.go index 9a1410aa7..6087a840a 100644 --- a/cmd/cycloid/middleware/organization_components.go +++ b/cmd/cycloid/middleware/organization_components.go @@ -10,12 +10,32 @@ 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 string, versionID uint32) (models.FormVariables, *http.Response, error) { var result models.FormVariables + + if versionID == 0 { + // Auto-resolve latest: get the component's stack ref, then find the default catalog version. + // This matches console behavior which always passes ?service_catalog_source_version_id=. + 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) + } + resolved, _, err := m.resolveStackVersion(org, *comp.ServiceCatalog.Ref, "", "", "") + if err != nil { + return nil, nil, fmt.Errorf("failed to resolve latest stack version: %w", err) + } + versionID = resolved + } + + 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 From ee665a83d76c0f309076654d0636142a3371da8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20BARRAS=20HAMPEL?= Date: Thu, 11 Jun 2026 21:59:58 +0200 Subject: [PATCH 2/4] fix(components): use --stack-version flag instead of raw version ID (CLI-124) Replace the internal uint32 --service-catalog-source-version-id flag with the standard --stack-version flag (tag/branch/sha/id) using the same cyargs.AddStackVersionFlags + ResolveStackVersionArg pattern as other stack commands. Internally the middleware still resolves via resolveStackVersion to service_catalog_source_version_id before the config GET. Default (no flag) auto-resolves the catalog branch head, matching console behavior. Co-Authored-By: Claude Sonnet 4.6 --- cmd/cycloid/components/config_get.go | 12 +- cmd/cycloid/components/create.go | 2 +- cmd/cycloid/components/update.go | 2 +- cmd/cycloid/middleware/middleware.go | 2 +- .../offline/component_config_test.go | 109 ++++++++++-------- .../middleware/organization_components.go | 24 ++-- 6 files changed, 82 insertions(+), 69 deletions(-) diff --git a/cmd/cycloid/components/config_get.go b/cmd/cycloid/components/config_get.go index a784ff507..ab64c587e 100644 --- a/cmd/cycloid/components/config_get.go +++ b/cmd/cycloid/components/config_get.go @@ -19,7 +19,7 @@ func NewComponentConfigGetCommand() *cobra.Command { Example: "cy config get -p project -e env -c component", } cyargs.AddCyContext(cmd) - cmd.Flags().Uint32("service-catalog-source-version-id", 0, "service catalog source version ID (default: latest)") + cyargs.AddStackVersionFlags(cmd) return cmd } @@ -29,14 +29,14 @@ func getComponentConfig(cmd *cobra.Command, args []string) error { return err } - versionID, err := cmd.Flags().GetUint32("service-catalog-source-version-id") + api := common.NewAPI() + m := middleware.NewMiddleware(api) + + tag, branch, hash, err := cyargs.ResolveStackVersionArg(cmd, m, org, "") if err != nil { return err } - api := common.NewAPI() - m := middleware.NewMiddleware(api) - - config, _, err := m.GetComponentConfig(org, project, env, component, versionID) + config, _, err := m.GetComponentConfig(org, project, env, component, tag, branch, hash) 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 532ff1adb..4b5ed2174 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, 0) + currentConfig, _, err = m.GetComponentConfig(org, project, env, component, "", "", "") 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 714db724b..ddff51913 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, 0) + currentConfig, _, err = m.GetComponentConfig(org, project, env, component, "", "", "") 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 47d5cb638..44a27e967 100644 --- a/cmd/cycloid/middleware/middleware.go +++ b/cmd/cycloid/middleware/middleware.go @@ -190,7 +190,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, versionID uint32) (models.FormVariables, *http.Response, error) + GetComponentConfig(org, project, env, component, versionTag, versionBranch, versionCommitHash string) (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 index 1c16c039f..eb2c719b9 100644 --- a/cmd/cycloid/middleware/offline/component_config_test.go +++ b/cmd/cycloid/middleware/offline/component_config_test.go @@ -14,48 +14,21 @@ import ( "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware" ) -// TestGetComponentConfig_ExplicitVersionID verifies that when an explicit versionID is -// provided, GetComponentConfig sends exactly ?service_catalog_source_version_id= -// without any extra resolution calls. -func TestGetComponentConfig_ExplicitVersionID(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", 42) - require.NoError(t, err) - assert.Equal(t, "service_catalog_source_version_id=42", capturedQuery) - assert.Equal(t, 1, callCount, "explicit version ID must not trigger extra API calls") -} - -// TestGetComponentConfig_AutoResolvesLatestVersion verifies that when versionID == 0, -// GetComponentConfig resolves the component's latest stack version and passes the -// resolved service_catalog_source_version_id to the config endpoint. -func TestGetComponentConfig_AutoResolvesLatestVersion(t *testing.T) { +// componentConfigServer builds a test server that mocks the resolution chain for +// GetComponentConfig. It returns the stack version whose (type, name) matches +// wantType/wantName for explicit resolution, or returns the branch head when +// catalogRepoBranch matches a branch-type version (default/auto-resolve path). +func componentConfigServer(t *testing.T, capturedQuery *string) *httptest.Server { + t.Helper() const ( - org = "myorg" - project = "myproj" - env = "prod" - component = "mycomp" - stackRef = "myorg:my-stack" - catRepo = "my-catalog-repo" - branch = "main" - wantID = uint32(99) + stackRef = "myorg:my-stack" + catRepo = "my-catalog-repo" + branchName = "main" + branchID = uint32(99) + tagName = "v1.2.3" + tagID = uint32(77) ) - var capturedConfigQuery string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") path := r.URL.Path @@ -65,11 +38,11 @@ func TestGetComponentConfig_AutoResolvesLatestVersion(t *testing.T) { } switch { - case strings.HasSuffix(path, "/components/"+component+"/config"): - capturedConfigQuery = r.URL.RawQuery + case strings.HasSuffix(path, "/components/mycomp/config"): + *capturedQuery = r.URL.RawQuery writeJSON(map[string]any{}) - case strings.HasSuffix(path, "/components/"+component): + case strings.HasSuffix(path, "/components/mycomp"): writeJSON(map[string]any{ "service_catalog": map[string]any{ "ref": stackRef, @@ -79,7 +52,8 @@ func TestGetComponentConfig_AutoResolvesLatestVersion(t *testing.T) { case strings.Contains(path, "service_catalogs") && strings.HasSuffix(path, "/versions"): writeJSON([]map[string]any{ - {"id": wantID, "type": "branch", "name": branch, "commit_hash": "abc123def456"}, + {"id": branchID, "type": "branch", "name": branchName, "commit_hash": "abc123"}, + {"id": tagID, "type": "tag", "name": tagName, "commit_hash": "def456"}, }) case strings.Contains(path, "service_catalogs"): @@ -91,20 +65,61 @@ func TestGetComponentConfig_AutoResolvesLatestVersion(t *testing.T) { case strings.Contains(path, "service_catalog_sources"): writeJSON(map[string]any{ "canonical": catRepo, - "branch": branch, + "branch": branchName, }) default: http.NotFound(w, r) } })) + return srv +} + +// TestGetComponentConfig_BranchResolvesVersion verifies that passing an explicit +// branch name resolves to that branch's version ID in the config request. +func TestGetComponentConfig_BranchResolvesVersion(t *testing.T) { + var capturedQuery string + srv := 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", "", "main", "") + require.NoError(t, err) + assert.Equal(t, "service_catalog_source_version_id=99", capturedQuery, + "branch 'main' should resolve to ID 99") +} + +// TestGetComponentConfig_TagResolvesVersion verifies that passing a tag name +// resolves to that tag's version ID in the config request. +func TestGetComponentConfig_TagResolvesVersion(t *testing.T) { + var capturedQuery string + srv := 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", "v1.2.3", "", "") + require.NoError(t, err) + assert.Equal(t, "service_catalog_source_version_id=77", capturedQuery, + "tag 'v1.2.3' should resolve to ID 77") +} + +// TestGetComponentConfig_AutoResolvesLatestVersion verifies that when no version +// is specified, GetComponentConfig resolves the catalog-repo branch head and +// passes the corresponding service_catalog_source_version_id to the config endpoint. +func TestGetComponentConfig_AutoResolvesLatestVersion(t *testing.T) { + var capturedQuery string + srv := componentConfigServer(t, &capturedQuery) defer srv.Close() api := common.NewAPI(common.WithURL(srv.URL), common.WithToken("test-token")) m := middleware.NewMiddleware(api) - _, _, err := m.GetComponentConfig(org, project, env, component, 0) + _, _, err := m.GetComponentConfig("myorg", "myproj", "myenv", "mycomp", "", "", "") require.NoError(t, err) - assert.Equal(t, "service_catalog_source_version_id=99", capturedConfigQuery, - "auto-resolve must pass the resolved version ID (99) to the config endpoint") + assert.Equal(t, "service_catalog_source_version_id=99", capturedQuery, + "default (no version specified) should resolve to catalog branch head ID 99") } diff --git a/cmd/cycloid/middleware/organization_components.go b/cmd/cycloid/middleware/organization_components.go index 6087a840a..f7c5176bc 100644 --- a/cmd/cycloid/middleware/organization_components.go +++ b/cmd/cycloid/middleware/organization_components.go @@ -10,21 +10,19 @@ import ( "github.com/cycloidio/cycloid-cli/internal/ptr" ) -func (m *middleware) GetComponentConfig(org, project, env, component string, versionID uint32) (models.FormVariables, *http.Response, error) { +func (m *middleware) GetComponentConfig(org, project, env, component, versionTag, versionBranch, versionCommitHash string) (models.FormVariables, *http.Response, error) { var result models.FormVariables - if versionID == 0 { - // Auto-resolve latest: get the component's stack ref, then find the default catalog version. - // This matches console behavior which always passes ?service_catalog_source_version_id=. - 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) - } - resolved, _, err := m.resolveStackVersion(org, *comp.ServiceCatalog.Ref, "", "", "") - if err != nil { - return nil, nil, fmt.Errorf("failed to resolve latest stack version: %w", err) - } - versionID = resolved + // Resolve component's stack ref first, then use it to find the right version. + // When all version args are empty this returns the default (latest branch head), + // matching console behavior (?service_catalog_source_version_id=). + 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{ From b9c1e315ceff77802fa27e636fbe0b0e96820662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20BARRAS=20HAMPEL?= Date: Thu, 11 Jun 2026 22:26:25 +0200 Subject: [PATCH 3/4] fix(components): add version: form to --stack-version for config get (CLI-124) --stack-version=version: bypasses stack ref resolution and passes the catalog source version ID directly to the config GET, useful for debugging by known ID. Other --stack-version forms (tag:/branch:/sha:) and the default auto-resolve-latest path are unchanged. ResolveStackVersionArg returns an informative error if version: is used with commands that cannot accept a raw ID. Co-Authored-By: Claude Sonnet 4.6 --- cmd/cycloid/components/config_get.go | 25 ++++++++++++--- cmd/cycloid/components/create.go | 2 +- cmd/cycloid/components/update.go | 2 +- cmd/cycloid/middleware/middleware.go | 2 +- .../offline/component_config_test.go | 32 +++++++++++++++++-- .../middleware/organization_components.go | 24 +++++++------- internal/cyargs/stacks.go | 6 +++- 7 files changed, 71 insertions(+), 22 deletions(-) diff --git a/cmd/cycloid/components/config_get.go b/cmd/cycloid/components/config_get.go index ab64c587e..a3aee27c5 100644 --- a/cmd/cycloid/components/config_get.go +++ b/cmd/cycloid/components/config_get.go @@ -1,6 +1,10 @@ package components import ( + "fmt" + "strconv" + "strings" + "github.com/spf13/cobra" "github.com/cycloidio/cycloid-cli/cmd/cycloid/common" @@ -32,11 +36,24 @@ func getComponentConfig(cmd *cobra.Command, args []string) error { api := common.NewAPI() m := middleware.NewMiddleware(api) - tag, branch, hash, err := cyargs.ResolveStackVersionArg(cmd, m, org, "") - if err != nil { - return err + // version: passes the catalog version ID directly to the API, bypassing + // stack ref resolution. Useful when debugging a known version ID. + var tag, branch, hash string + var versionID uint32 + rawVersion, _ := cmd.Flags().GetString("stack-version") + if idStr, ok := strings.CutPrefix(rawVersion, "version:"); ok { + id64, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + return fmt.Errorf("--stack-version=version:: expected a numeric ID, got %q", idStr) + } + versionID = uint32(id64) + } else { + 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) + 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 4b5ed2174..eba0a5081 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 ddff51913..8ca9955a8 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 44a27e967..42aa99b9b 100644 --- a/cmd/cycloid/middleware/middleware.go +++ b/cmd/cycloid/middleware/middleware.go @@ -190,7 +190,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, versionTag, versionBranch, versionCommitHash 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 index eb2c719b9..52e12ba9f 100644 --- a/cmd/cycloid/middleware/offline/component_config_test.go +++ b/cmd/cycloid/middleware/offline/component_config_test.go @@ -75,6 +75,32 @@ func componentConfigServer(t *testing.T, capturedQuery *string) *httptest.Server return srv } +// TestGetComponentConfig_RawVersionID verifies that when a non-zero versionID is +// provided (from --stack-version=version:), GetComponentConfig sends it directly +// to the config endpoint with no extra resolution API calls. +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, + "raw version ID 14 must be passed directly without resolution") + assert.Equal(t, 1, callCount, "raw version ID must not trigger extra API calls") +} + // TestGetComponentConfig_BranchResolvesVersion verifies that passing an explicit // branch name resolves to that branch's version ID in the config request. func TestGetComponentConfig_BranchResolvesVersion(t *testing.T) { @@ -85,7 +111,7 @@ func TestGetComponentConfig_BranchResolvesVersion(t *testing.T) { api := common.NewAPI(common.WithURL(srv.URL), common.WithToken("test-token")) m := middleware.NewMiddleware(api) - _, _, err := m.GetComponentConfig("myorg", "myproj", "myenv", "mycomp", "", "main", "") + _, _, err := m.GetComponentConfig("myorg", "myproj", "myenv", "mycomp", "", "main", "", 0) require.NoError(t, err) assert.Equal(t, "service_catalog_source_version_id=99", capturedQuery, "branch 'main' should resolve to ID 99") @@ -101,7 +127,7 @@ func TestGetComponentConfig_TagResolvesVersion(t *testing.T) { api := common.NewAPI(common.WithURL(srv.URL), common.WithToken("test-token")) m := middleware.NewMiddleware(api) - _, _, err := m.GetComponentConfig("myorg", "myproj", "myenv", "mycomp", "v1.2.3", "", "") + _, _, err := m.GetComponentConfig("myorg", "myproj", "myenv", "mycomp", "v1.2.3", "", "", 0) require.NoError(t, err) assert.Equal(t, "service_catalog_source_version_id=77", capturedQuery, "tag 'v1.2.3' should resolve to ID 77") @@ -118,7 +144,7 @@ func TestGetComponentConfig_AutoResolvesLatestVersion(t *testing.T) { api := common.NewAPI(common.WithURL(srv.URL), common.WithToken("test-token")) m := middleware.NewMiddleware(api) - _, _, err := m.GetComponentConfig("myorg", "myproj", "myenv", "mycomp", "", "", "") + _, _, err := m.GetComponentConfig("myorg", "myproj", "myenv", "mycomp", "", "", "", 0) require.NoError(t, err) assert.Equal(t, "service_catalog_source_version_id=99", capturedQuery, "default (no version specified) should resolve to catalog branch head ID 99") diff --git a/cmd/cycloid/middleware/organization_components.go b/cmd/cycloid/middleware/organization_components.go index f7c5176bc..631db4824 100644 --- a/cmd/cycloid/middleware/organization_components.go +++ b/cmd/cycloid/middleware/organization_components.go @@ -10,19 +10,21 @@ import ( "github.com/cycloidio/cycloid-cli/internal/ptr" ) -func (m *middleware) GetComponentConfig(org, project, env, component, versionTag, versionBranch, versionCommitHash 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 - // Resolve component's stack ref first, then use it to find the right version. - // When all version args are empty this returns the default (latest branch head), - // matching console behavior (?service_catalog_source_version_id=). - 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) + if versionID == 0 { + // Resolve component's stack ref to find the right catalog version. + // When all version args are empty this returns the default (latest branch head), + // matching console behavior (?service_catalog_source_version_id=). + 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{ diff --git a/internal/cyargs/stacks.go b/internal/cyargs/stacks.go index 54579c295..d624867e6 100644 --- a/internal/cyargs/stacks.go +++ b/internal/cyargs/stacks.go @@ -136,7 +136,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 +219,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. } From 6bf1cea7571f40bbf041f686b85ac4712e0ed80e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barras=20Hampel?= Date: Thu, 11 Jun 2026 22:33:32 +0200 Subject: [PATCH 4/4] refactor(components): move version-id parsing to cyargs, mock server to package (CLI-124) - cyargs.GetStackVersionID parses --stack-version=version: so consumers do not inline flag reading - config_get.go uses GetStackVersionID; drops fmt/strconv/strings imports - verbose WHAT comment removed from GetComponentConfig - ComponentConfigServer moved to offline/mockserver package with exported constants; component_config_test.go references them Co-Authored-By: Claude Sonnet 4.6 --- cmd/cycloid/components/config_get.go | 20 +--- .../offline/component_config_test.go | 96 +++---------------- .../offline/mockserver/mockserver.go | 70 ++++++++++++++ .../middleware/organization_components.go | 3 - internal/cyargs/stacks.go | 19 ++++ 5 files changed, 105 insertions(+), 103 deletions(-) create mode 100644 cmd/cycloid/middleware/offline/mockserver/mockserver.go diff --git a/cmd/cycloid/components/config_get.go b/cmd/cycloid/components/config_get.go index a3aee27c5..10a8da8e7 100644 --- a/cmd/cycloid/components/config_get.go +++ b/cmd/cycloid/components/config_get.go @@ -1,10 +1,6 @@ package components import ( - "fmt" - "strconv" - "strings" - "github.com/spf13/cobra" "github.com/cycloidio/cycloid-cli/cmd/cycloid/common" @@ -36,18 +32,12 @@ func getComponentConfig(cmd *cobra.Command, args []string) error { api := common.NewAPI() m := middleware.NewMiddleware(api) - // version: passes the catalog version ID directly to the API, bypassing - // stack ref resolution. Useful when debugging a known version ID. var tag, branch, hash string - var versionID uint32 - rawVersion, _ := cmd.Flags().GetString("stack-version") - if idStr, ok := strings.CutPrefix(rawVersion, "version:"); ok { - id64, err := strconv.ParseUint(idStr, 10, 32) - if err != nil { - return fmt.Errorf("--stack-version=version:: expected a numeric ID, got %q", idStr) - } - versionID = uint32(id64) - } else { + 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 diff --git a/cmd/cycloid/middleware/offline/component_config_test.go b/cmd/cycloid/middleware/offline/component_config_test.go index 52e12ba9f..7681770d7 100644 --- a/cmd/cycloid/middleware/offline/component_config_test.go +++ b/cmd/cycloid/middleware/offline/component_config_test.go @@ -1,7 +1,7 @@ package offline_test import ( - "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -12,72 +12,9 @@ import ( "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" ) -// componentConfigServer builds a test server that mocks the resolution chain for -// GetComponentConfig. It returns the stack version whose (type, name) matches -// wantType/wantName for explicit resolution, or returns the branch head when -// catalogRepoBranch matches a branch-type version (default/auto-resolve path). -func componentConfigServer(t *testing.T, capturedQuery *string) *httptest.Server { - t.Helper() - const ( - stackRef = "myorg:my-stack" - catRepo = "my-catalog-repo" - branchName = "main" - branchID = uint32(99) - tagName = "v1.2.3" - tagID = uint32(77) - ) - - srv := 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": catRepo, - }, - }) - - case strings.Contains(path, "service_catalogs") && strings.HasSuffix(path, "/versions"): - writeJSON([]map[string]any{ - {"id": branchID, "type": "branch", "name": branchName, "commit_hash": "abc123"}, - {"id": tagID, "type": "tag", "name": tagName, "commit_hash": "def456"}, - }) - - case strings.Contains(path, "service_catalogs"): - writeJSON(map[string]any{ - "ref": stackRef, - "service_catalog_source_canonical": catRepo, - }) - - case strings.Contains(path, "service_catalog_sources"): - writeJSON(map[string]any{ - "canonical": catRepo, - "branch": branchName, - }) - - default: - http.NotFound(w, r) - } - })) - return srv -} - -// TestGetComponentConfig_RawVersionID verifies that when a non-zero versionID is -// provided (from --stack-version=version:), GetComponentConfig sends it directly -// to the config endpoint with no extra resolution API calls. func TestGetComponentConfig_RawVersionID(t *testing.T) { var capturedQuery string callCount := 0 @@ -96,49 +33,39 @@ func TestGetComponentConfig_RawVersionID(t *testing.T) { _, _, err := m.GetComponentConfig("org", "proj", "env", "comp", "", "", "", 14) require.NoError(t, err) - assert.Equal(t, "service_catalog_source_version_id=14", capturedQuery, - "raw version ID 14 must be passed directly without resolution") + assert.Equal(t, "service_catalog_source_version_id=14", capturedQuery) assert.Equal(t, 1, callCount, "raw version ID must not trigger extra API calls") } -// TestGetComponentConfig_BranchResolvesVersion verifies that passing an explicit -// branch name resolves to that branch's version ID in the config request. func TestGetComponentConfig_BranchResolvesVersion(t *testing.T) { var capturedQuery string - srv := componentConfigServer(t, &capturedQuery) + 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", "", "main", "", 0) + _, _, err := m.GetComponentConfig("myorg", "myproj", "myenv", "mycomp", "", mockserver.BranchName, "", 0) require.NoError(t, err) - assert.Equal(t, "service_catalog_source_version_id=99", capturedQuery, - "branch 'main' should resolve to ID 99") + assert.Equal(t, fmt.Sprintf("service_catalog_source_version_id=%d", mockserver.BranchVersionID), capturedQuery) } -// TestGetComponentConfig_TagResolvesVersion verifies that passing a tag name -// resolves to that tag's version ID in the config request. func TestGetComponentConfig_TagResolvesVersion(t *testing.T) { var capturedQuery string - srv := componentConfigServer(t, &capturedQuery) + 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", "v1.2.3", "", "", 0) + _, _, err := m.GetComponentConfig("myorg", "myproj", "myenv", "mycomp", mockserver.TagName, "", "", 0) require.NoError(t, err) - assert.Equal(t, "service_catalog_source_version_id=77", capturedQuery, - "tag 'v1.2.3' should resolve to ID 77") + assert.Equal(t, fmt.Sprintf("service_catalog_source_version_id=%d", mockserver.TagVersionID), capturedQuery) } -// TestGetComponentConfig_AutoResolvesLatestVersion verifies that when no version -// is specified, GetComponentConfig resolves the catalog-repo branch head and -// passes the corresponding service_catalog_source_version_id to the config endpoint. func TestGetComponentConfig_AutoResolvesLatestVersion(t *testing.T) { var capturedQuery string - srv := componentConfigServer(t, &capturedQuery) + srv := mockserver.ComponentConfigServer(t, &capturedQuery) defer srv.Close() api := common.NewAPI(common.WithURL(srv.URL), common.WithToken("test-token")) @@ -146,6 +73,5 @@ func TestGetComponentConfig_AutoResolvesLatestVersion(t *testing.T) { _, _, err := m.GetComponentConfig("myorg", "myproj", "myenv", "mycomp", "", "", "", 0) require.NoError(t, err) - assert.Equal(t, "service_catalog_source_version_id=99", capturedQuery, - "default (no version specified) should resolve to catalog branch head ID 99") + 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 000000000..e91d738c5 --- /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 631db4824..ef65818aa 100644 --- a/cmd/cycloid/middleware/organization_components.go +++ b/cmd/cycloid/middleware/organization_components.go @@ -14,9 +14,6 @@ func (m *middleware) GetComponentConfig(org, project, env, component, versionTag var result models.FormVariables if versionID == 0 { - // Resolve component's stack ref to find the right catalog version. - // When all version args are empty this returns the default (latest branch head), - // matching console behavior (?service_catalog_source_version_id=). 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) diff --git a/internal/cyargs/stacks.go b/internal/cyargs/stacks.go index d624867e6..2af9a9ce1 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" @@ -270,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.