diff --git a/CHANGELOG.md b/CHANGELOG.md index c1cce01c..7213accf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- added `project describe` command -- added `project apply` command +- added `project describe` command. It supports `--revision`, `--version` flags for enhanced workflow projects and `--branch`, `--tag` flags for standard workflow projects. +- added `project apply` command. It supports `--revision` flag as only ehnanced workflow is supported for project appy ## [v0.19.0] - 2025-06-18 diff --git a/internal/clioptions/clioptions.go b/internal/clioptions/clioptions.go index ff0b8914..2b592eb1 100644 --- a/internal/clioptions/clioptions.go +++ b/internal/clioptions/clioptions.go @@ -44,6 +44,8 @@ type CLIOptions struct { Revision string Version string + Branch string + Tag string DeployType string NoSemVer bool TriggerID string @@ -143,6 +145,14 @@ func (o *CLIOptions) AddVersionFlags(flags *pflag.FlagSet) { flags.StringVar(&o.Version, "version", "", "the version name of the configuration") } +func (o *CLIOptions) AddBranchFlags(flags *pflag.FlagSet) { + flags.StringVar(&o.Branch, "branch", "", "the branch name of the configuration") +} + +func (o *CLIOptions) AddTagFlags(flags *pflag.FlagSet) { + flags.StringVar(&o.Tag, "tag", "", "the tag name of the configuration") +} + func (o *CLIOptions) AddEnvironmentFlags(flags *pflag.FlagSet) { flags.StringVar(&o.Environment, "environment", "", "the environment scope for the command") } diff --git a/internal/cmd/project/apply.go b/internal/cmd/project/apply.go index 7bba9d48..cad39fa8 100644 --- a/internal/cmd/project/apply.go +++ b/internal/cmd/project/apply.go @@ -116,7 +116,7 @@ func validateApplyProjectOptions(options applyProjectOptions) error { } func applyConfiguration(ctx context.Context, client *client.APIClient, options applyProjectOptions) error { - ref, err := configuration.GetEncodedRevisionRef(options.RevisionName) + ref, err := configuration.NewRef(configuration.RevisionRefType, options.RevisionName) if err != nil { return err } @@ -142,7 +142,7 @@ func applyConfiguration(ctx context.Context, client *client.APIClient, options a return fmt.Errorf("cannot encode project configuration: %w", err) } - endpoint := fmt.Sprintf("/api/backend/projects/%s/%s/configuration", options.ProjectID, ref) + endpoint := fmt.Sprintf("/api/backend/projects/%s/%s/configuration", options.ProjectID, ref.EncodedLocationPath()) response, err := client. Post(). APIPath(endpoint). diff --git a/internal/cmd/project/describe.go b/internal/cmd/project/describe.go index 36939179..75146278 100644 --- a/internal/cmd/project/describe.go +++ b/internal/cmd/project/describe.go @@ -17,6 +17,7 @@ package project import ( "context" + "errors" "fmt" "io" @@ -31,12 +32,17 @@ const ( describeProjectCmdUsage = "describe" describeProjectCmdShort = "Describe a Project configuration" describeProjectCmdLong = `Describe the configuration of the specified Project.` + + ErrMultipleIdentifiers = "multiple identifiers specified, please provide only one" + ErrMissingIdentifier = "missing revision/version/branch/tag name, please provide one as argument" ) type describeProjectOptions struct { - ProjectID string RevisionName string VersionName string + BranchName string + TagName string + ProjectID string OutputFormat string } @@ -56,6 +62,8 @@ func DescribeCmd(options *clioptions.CLIOptions) *cobra.Command { cmdOptions := describeProjectOptions{ RevisionName: options.Revision, VersionName: options.Version, + BranchName: options.Branch, + TagName: options.Tag, ProjectID: restConfig.ProjectID, OutputFormat: options.OutputFormat, } @@ -68,6 +76,8 @@ func DescribeCmd(options *clioptions.CLIOptions) *cobra.Command { options.AddProjectFlags(flags) options.AddRevisionFlags(flags) options.AddVersionFlags(flags) + options.AddBranchFlags(flags) + options.AddTagFlags(flags) options.AddOutputFormatFlag(flags, "json") return cmd @@ -78,12 +88,12 @@ func describeProject(ctx context.Context, client *client.APIClient, options desc return fmt.Errorf("missing project name, please provide a project name as argument") } - ref, err := configuration.GetEncodedRef(options.RevisionName, options.VersionName) + ref, err := GetRefFromOptions(options) if err != nil { return err } - endpoint := fmt.Sprintf("/api/backend/projects/%s/%s/configuration", options.ProjectID, ref) + endpoint := fmt.Sprintf("/api/backend/projects/%s/%s/configuration/", options.ProjectID, ref.EncodedLocationPath()) response, err := client. Get(). APIPath(endpoint). @@ -113,3 +123,46 @@ func describeProject(ctx context.Context, client *client.APIClient, options desc fmt.Fprintln(writer, string(bytes)) return nil } + +func GetRefFromOptions(options describeProjectOptions) (configuration.Ref, error) { + refType := "" + refName := "" + + if len(options.RevisionName) > 0 { + refType = configuration.RevisionRefType + refName = options.RevisionName + } + + if len(options.VersionName) > 0 { + if len(refType) > 0 { + return configuration.Ref{}, errors.New(ErrMultipleIdentifiers) + } + + refType = configuration.VersionRefType + refName = options.VersionName + } + + if len(options.BranchName) > 0 { + if len(refType) > 0 { + return configuration.Ref{}, errors.New(ErrMultipleIdentifiers) + } + + refType = configuration.BranchRefType + refName = options.BranchName + } + + if len(options.TagName) > 0 { + if len(refType) > 0 { + return configuration.Ref{}, errors.New(ErrMultipleIdentifiers) + } + + refType = configuration.TagRefType + refName = options.TagName + } + + if len(refType) == 0 { + return configuration.Ref{}, errors.New(ErrMissingIdentifier) + } + + return configuration.NewRef(refType, refName) +} diff --git a/internal/cmd/project/describe_test.go b/internal/cmd/project/describe_test.go index f368db0c..5b83763b 100644 --- a/internal/cmd/project/describe_test.go +++ b/internal/cmd/project/describe_test.go @@ -58,28 +58,57 @@ func TestDescribeProjectCmd(t *testing.T) { return false }), }, - "error missing revision/version": { + "error missing revision/version/branch/tag": { options: describeProjectOptions{ ProjectID: "test-project", }, expectError: true, - expectedErrorMsg: "missing revision/version name, please provide one as argument", + expectedErrorMsg: "missing revision/version/branch/tag name, please provide one as argument", testServer: describeTestServer(t, func(_ http.ResponseWriter, _ *http.Request) bool { return false }), }, - "error both revision/version specified": { + "error multiple revision/version specified": { options: describeProjectOptions{ ProjectID: "test-project", RevisionName: "test-revision", VersionName: "test-version", }, expectError: true, - expectedErrorMsg: "both revision and version specified, please provide only one", + expectedErrorMsg: "multiple identifiers specified, please provide only one", testServer: describeTestServer(t, func(_ http.ResponseWriter, _ *http.Request) bool { return false }), }, + "error multiple branch/revision specified": { + options: describeProjectOptions{ + ProjectID: "test-project", + RevisionName: "test-revision", + BranchName: "test-branch", + }, + expectError: true, + expectedErrorMsg: "multiple identifiers specified, please provide only one", + testServer: describeTestServer(t, func(_ http.ResponseWriter, _ *http.Request) bool { + return false + }), + }, + "valid project with branch": { + options: describeProjectOptions{ + ProjectID: "test-project", + BranchName: "test-json-branch", + OutputFormat: "json", + }, + revisionName: "test-revision", + testServer: describeTestServer(t, func(w http.ResponseWriter, r *http.Request) bool { + if r.URL.Path == "/api/backend/projects/test-project/branches/test-json-branch/configuration/" && r.Method == http.MethodGet { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"name": "test-project", "branch": "test-json-branch"}`)) + return true + } + return false + }), + outputTextJSON: `{"config": {"name": "test-project", "branch": "test-json-branch"}}`, + }, "valid project with revision": { options: describeProjectOptions{ ProjectID: "test-project", @@ -88,7 +117,7 @@ func TestDescribeProjectCmd(t *testing.T) { }, revisionName: "test-revision", testServer: describeTestServer(t, func(w http.ResponseWriter, r *http.Request) bool { - if r.URL.Path == "/api/backend/projects/test-project/revisions/test-json-revision/configuration" && r.Method == http.MethodGet { + if r.URL.Path == "/api/backend/projects/test-project/revisions/test-json-revision/configuration/" && r.Method == http.MethodGet { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"name": "test-project", "revision": "test-json-revision"}`)) return true @@ -97,6 +126,22 @@ func TestDescribeProjectCmd(t *testing.T) { }), outputTextJSON: `{"config": {"name": "test-project", "revision": "test-json-revision"}}`, }, + "valid project with tag": { + options: describeProjectOptions{ + ProjectID: "test-project", + TagName: "test-tag", + OutputFormat: "json", + }, + testServer: describeTestServer(t, func(w http.ResponseWriter, r *http.Request) bool { + if r.URL.Path == "/api/backend/projects/test-project/branches/test-tag/configuration/" && r.Method == http.MethodGet { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"name": "test-project", "tag": "test-tag"}`)) + return true + } + return false + }), + outputTextJSON: `{"config": {"name": "test-project", "tag": "test-tag"}}`, + }, "valid project with version": { options: describeProjectOptions{ ProjectID: "test-project", @@ -104,7 +149,7 @@ func TestDescribeProjectCmd(t *testing.T) { OutputFormat: "json", }, testServer: describeTestServer(t, func(w http.ResponseWriter, r *http.Request) bool { - if r.URL.Path == "/api/backend/projects/test-project/versions/test-version/configuration" && r.Method == http.MethodGet { + if r.URL.Path == "/api/backend/projects/test-project/versions/test-version/configuration/" && r.Method == http.MethodGet { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"name": "test-project", "revision": "test-version"}`)) return true @@ -120,7 +165,7 @@ func TestDescribeProjectCmd(t *testing.T) { OutputFormat: "yaml", }, testServer: describeTestServer(t, func(w http.ResponseWriter, r *http.Request) bool { - if r.URL.Path == "/api/backend/projects/test-project/revisions/test-yaml-revision/configuration" && r.Method == http.MethodGet { + if r.URL.Path == "/api/backend/projects/test-project/revisions/test-yaml-revision/configuration/" && r.Method == http.MethodGet { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"name": "test-project", "revision": "test-yaml-revision"}`)) return true @@ -136,7 +181,7 @@ func TestDescribeProjectCmd(t *testing.T) { OutputFormat: "yaml", }, testServer: describeTestServer(t, func(w http.ResponseWriter, r *http.Request) bool { - if r.URL.Path == "/api/backend/projects/test-project/revisions/some%2Frevision/configuration" && r.Method == http.MethodGet { + if r.URL.Path == "/api/backend/projects/test-project/revisions/some%2Frevision/configuration/" && r.Method == http.MethodGet { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"name": "test-project", "revision": "test-yaml-revision"}`)) return true @@ -152,7 +197,7 @@ func TestDescribeProjectCmd(t *testing.T) { OutputFormat: "yaml", }, testServer: describeTestServer(t, func(w http.ResponseWriter, r *http.Request) bool { - if r.URL.Path == "/api/backend/projects/test-project/versions/version%2F1.2.3/configuration" && r.Method == http.MethodGet { + if r.URL.Path == "/api/backend/projects/test-project/versions/version%2F1.2.3/configuration/" && r.Method == http.MethodGet { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"name": "test-project", "revision": "test-yaml-revision"}`)) return true diff --git a/internal/resources/configuration/ref.go b/internal/resources/configuration/ref.go new file mode 100644 index 00000000..b1575f94 --- /dev/null +++ b/internal/resources/configuration/ref.go @@ -0,0 +1,65 @@ +// Copyright Mia srl +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package configuration + +import ( + "fmt" + "net/url" +) + +type RefTypes map[string]bool + +const ( + RevisionRefType = "revisions" + VersionRefType = "versions" + BranchRefType = "branches" + TagRefType = "tags" +) + +var validRefTypes = RefTypes{RevisionRefType: true, VersionRefType: true, BranchRefType: true, TagRefType: true} + +type Ref struct { + refType string + refName string +} + +func NewRef(refType, refName string) (Ref, error) { + if !validRefTypes[refType] { + return Ref{}, fmt.Errorf("unknown reference type: %s", refType) + } + if len(refName) == 0 { + return Ref{}, fmt.Errorf("missing reference name, please provide a reference name") + } + return Ref{ + refType: refType, + refName: refName, + }, nil +} + +// EncodedLocationPath returns the encoded path to be used when fetching configuration data +// +// e.g., "/api/projects///configuration" +func (r Ref) EncodedLocationPath() string { + switch r.refType { + case RevisionRefType, VersionRefType: + return fmt.Sprintf("%s/%s", r.refType, url.PathEscape(r.refName)) + case BranchRefType, TagRefType: + // Legacy projects use /branches endpoint only + return fmt.Sprintf("branches/%s", url.PathEscape(r.refName)) + default: + return "" + } +} diff --git a/internal/resources/configuration/ref_test.go b/internal/resources/configuration/ref_test.go new file mode 100644 index 00000000..6a5670c6 --- /dev/null +++ b/internal/resources/configuration/ref_test.go @@ -0,0 +1,84 @@ +// Copyright Mia srl +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package configuration + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewRef(t *testing.T) { + tests := []struct { + refType string + refName string + expectError bool + expectedErrorMsg string + }{ + {"revisions", "rev1", false, ""}, + {"versions", "v1", false, ""}, + {"branches", "branch1", false, ""}, + {"tags", "tag1", false, ""}, + {"revisions", "with/slash", false, ""}, + {"versions", "with/slash", false, ""}, + {"branches", "with/slash", false, ""}, + {"tags", "with/slash", false, ""}, + + {"invalidType", "name", true, "unknown reference type: invalidType"}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%s/%s", tt.refType, tt.refName), func(t *testing.T) { + ref, err := NewRef(tt.refType, tt.refName) + if tt.expectError { + require.EqualError(t, err, tt.expectedErrorMsg) + return + } + require.NoError(t, err) + require.Equal(t, tt.refType, ref.refType) + require.Equal(t, tt.refName, ref.refName) + }) + } +} + +func TestGetEncodedResourceLocation(t *testing.T) { + tests := []struct { + refType string + refName string + expected string + expectError bool + expectedErrorMsg string + }{ + {"revisions", "rev1", "revisions/rev1", false, ""}, + {"versions", "v1", "versions/v1", false, ""}, + {"branches", "branch1", "branches/branch1", false, ""}, + {"tags", "tag1", "branches/tag1", false, ""}, + {"revisions", "with/slash", "revisions/with%2Fslash", false, ""}, + {"versions", "with/slash", "versions/with%2Fslash", false, ""}, + {"branches", "with/slash", "branches/with%2Fslash", false, ""}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + ref, err := NewRef(tt.refType, tt.refName) + require.NoError(t, err) + + encodedString := ref.EncodedLocationPath() + require.Equal(t, encodedString, tt.expected) + }) + } +} diff --git a/internal/resources/configuration/utils.go b/internal/resources/configuration/utils.go deleted file mode 100644 index 7bd2442c..00000000 --- a/internal/resources/configuration/utils.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright Mia srl -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package configuration - -import ( - "fmt" - "net/url" -) - -func GetEncodedRef(revisionName, versionName string) (string, error) { - if len(revisionName) > 0 && len(versionName) > 0 { - return "", fmt.Errorf("both revision and version specified, please provide only one") - } - - if len(revisionName) > 0 { - return GetEncodedRevisionRef(revisionName) - } - - if len(versionName) > 0 { - return GetEncodedVersionRef(versionName) - } - - return "", fmt.Errorf("missing revision/version name, please provide one as argument") -} - -func GetEncodedRevisionRef(revisionName string) (string, error) { - if len(revisionName) == 0 { - return "", fmt.Errorf("missing revision name, please provide a revision name") - } - - encodedRevisionName := url.PathEscape(revisionName) - return fmt.Sprintf("revisions/%s", encodedRevisionName), nil -} - -func GetEncodedVersionRef(revisionName string) (string, error) { - if len(revisionName) == 0 { - return "", fmt.Errorf("missing version name, please provide a version name") - } - - encodedRevisionName := url.PathEscape(revisionName) - return fmt.Sprintf("versions/%s", encodedRevisionName), nil -} diff --git a/internal/resources/configuration/utils_test.go b/internal/resources/configuration/utils_test.go deleted file mode 100644 index 722be275..00000000 --- a/internal/resources/configuration/utils_test.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright Mia srl -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package configuration - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestGetEncodedRef(t *testing.T) { - tests := []struct { - name string - revisionName string - versionName string - expected string - expectError string - }{ - { - name: "both revision and version specified", - revisionName: "rev1", - versionName: "v1", - expectError: "both revision and version specified, please provide only one", - }, - { - name: "only revision specified", - revisionName: "rev1", - expected: "revisions/rev1", - }, - { - name: "only version specified", - versionName: "v1", - expected: "versions/v1", - }, - { - name: "neither revision nor version specified", - expectError: "missing revision/version name, please provide one as argument", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := GetEncodedRef(tt.revisionName, tt.versionName) - if tt.expectError != "" { - require.EqualError(t, err, tt.expectError) - return - } - require.NoError(t, err) - require.Equal(t, tt.expected, result) - }) - } -}