diff --git a/cmd/cycloid/components/config_get.go b/cmd/cycloid/components/config_get.go index 346790d64..10a8da8e7 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 67693a6d7..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 443c35251..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 8696f2b95..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 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 000000000..7681770d7 --- /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 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 9a1410aa7..ef65818aa 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/internal/cyargs/stacks.go b/internal/cyargs/stacks.go index 54579c295..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" @@ -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.