Skip to content

Commit 9913a5a

Browse files
feat: project describe command (#255)
1 parent 0eb03e2 commit 9913a5a

File tree

8 files changed

+368
-8
lines changed

8 files changed

+368
-8
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- added `project describe` command
13+
1014
## [v0.19.0] - 2025-06-18
1115

1216
### Changed

internal/client/request.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ func (r *Request) APIPath(apiPath string) *Request {
104104
return r
105105
}
106106

107-
r.apiPath = parsedURI.Path
107+
r.apiPath = parsedURI.EscapedPath()
108+
108109
// comment out this, because not every request support the trailing /
109110
// hopefully in the future they will
110111
// if !strings.HasSuffix(r.apiPath, "/") {

internal/clioptions/clioptions.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ type CLIOptions struct {
4343
Environment string
4444

4545
Revision string
46+
Version string
4647
DeployType string
4748
NoSemVer bool
4849
TriggerID string
@@ -132,6 +133,14 @@ func (o *CLIOptions) AddCompanyFlags(flags *pflag.FlagSet) {
132133
flags.StringVar(&o.CompanyID, "company-id", "", "the ID of the company")
133134
}
134135

136+
func (o *CLIOptions) AddRevisionFlags(flags *pflag.FlagSet) {
137+
flags.StringVar(&o.Revision, "revision", "", "the revision name of the configuration")
138+
}
139+
140+
func (o *CLIOptions) AddVersionFlags(flags *pflag.FlagSet) {
141+
flags.StringVar(&o.Version, "version", "", "the version name of the configuration")
142+
}
143+
135144
func (o *CLIOptions) AddEnvironmentFlags(flags *pflag.FlagSet) {
136145
flags.StringVar(&o.Environment, "environment", "", "the environment scope for the command")
137146
}

internal/cmd/project.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ resources that make up the applications of a specific company.
4343
project.ListCmd(o),
4444
project.IAMCmd(o),
4545
project.ImportCmd(o),
46+
project.DescribeCmd(o),
4647
)
4748

4849
return projectCmd

internal/cmd/project/describe.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright Mia srl
2+
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package project
17+
18+
import (
19+
"context"
20+
"fmt"
21+
"io"
22+
"net/url"
23+
24+
"github.com/mia-platform/miactl/internal/client"
25+
"github.com/mia-platform/miactl/internal/clioptions"
26+
"github.com/mia-platform/miactl/internal/encoding"
27+
"github.com/spf13/cobra"
28+
)
29+
30+
const (
31+
describeProjectCmdUsage = "describe"
32+
describeProjectCmdShort = "Describe a Project configuration"
33+
describeProjectCmdLong = `Describe the configuration of the specified Project.`
34+
)
35+
36+
type describeProjectOptions struct {
37+
ProjectID string
38+
RevisionName string
39+
VersionName string
40+
OutputFormat string
41+
}
42+
43+
// DescribeCmd returns a cobra command for describing a project configuration
44+
func DescribeCmd(options *clioptions.CLIOptions) *cobra.Command {
45+
cmd := &cobra.Command{
46+
Use: describeProjectCmdUsage,
47+
Short: describeProjectCmdShort,
48+
Long: describeProjectCmdLong,
49+
RunE: func(cmd *cobra.Command, _ []string) error {
50+
restConfig, err := options.ToRESTConfig()
51+
cobra.CheckErr(err)
52+
53+
client, err := client.APIClientForConfig(restConfig)
54+
cobra.CheckErr(err)
55+
56+
cmdOptions := describeProjectOptions{
57+
RevisionName: options.Revision,
58+
VersionName: options.Version,
59+
ProjectID: restConfig.ProjectID,
60+
OutputFormat: options.OutputFormat,
61+
}
62+
63+
return describeProject(cmd.Context(), client, cmdOptions, cmd.ErrOrStderr())
64+
},
65+
}
66+
67+
flags := cmd.Flags()
68+
options.AddProjectFlags(flags)
69+
options.AddRevisionFlags(flags)
70+
options.AddVersionFlags(flags)
71+
options.AddOutputFormatFlag(flags, "json")
72+
73+
return cmd
74+
}
75+
76+
func describeProject(ctx context.Context, client *client.APIClient, options describeProjectOptions, writer io.Writer) error {
77+
if len(options.ProjectID) == 0 {
78+
return fmt.Errorf("missing project name, please provide a project name as argument")
79+
}
80+
81+
ref, err := getConfigRef(options.RevisionName, options.VersionName)
82+
if err != nil {
83+
return err
84+
}
85+
86+
endpoint := fmt.Sprintf("/api/backend/projects/%s/%s/configuration", options.ProjectID, ref)
87+
response, err := client.
88+
Get().
89+
APIPath(endpoint).
90+
Do(ctx)
91+
if err != nil {
92+
return fmt.Errorf("failed to get project %s, ref %s: %w", options.ProjectID, ref, err)
93+
}
94+
if err := response.Error(); err != nil {
95+
return err
96+
}
97+
98+
projectConfig := make(map[string]any, 0)
99+
if err := response.ParseResponse(&projectConfig); err != nil {
100+
return fmt.Errorf("cannot parse project configuration: %w", err)
101+
}
102+
103+
bytes, err := encoding.MarshalData(projectConfig, options.OutputFormat, encoding.MarshalOptions{Indent: true})
104+
if err != nil {
105+
return err
106+
}
107+
108+
fmt.Fprintln(writer, string(bytes))
109+
return nil
110+
}
111+
112+
func getConfigRef(revisionName, versionName string) (string, error) {
113+
if len(revisionName) > 0 && len(versionName) > 0 {
114+
return "", fmt.Errorf("both revision and version specified, please provide only one")
115+
}
116+
117+
if len(revisionName) > 0 {
118+
encodedRevisionName := url.PathEscape(revisionName)
119+
return fmt.Sprintf("revisions/%s", encodedRevisionName), nil
120+
}
121+
if len(versionName) > 0 {
122+
encodedVersionName := url.PathEscape(versionName)
123+
return fmt.Sprintf("versions/%s", encodedVersionName), nil
124+
}
125+
126+
return "", fmt.Errorf("missing revision/version name, please provide one as argument")
127+
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright Mia srl
2+
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package project
17+
18+
import (
19+
"bytes"
20+
"context"
21+
"encoding/json"
22+
"net/http"
23+
"net/http/httptest"
24+
"testing"
25+
"time"
26+
27+
"github.com/mia-platform/miactl/internal/client"
28+
"github.com/mia-platform/miactl/internal/clioptions"
29+
"github.com/mia-platform/miactl/internal/encoding"
30+
"github.com/stretchr/testify/assert"
31+
"github.com/stretchr/testify/require"
32+
"sigs.k8s.io/kustomize/kyaml/yaml"
33+
)
34+
35+
func TestCreateDescribeCmd(t *testing.T) {
36+
t.Run("test command creation", func(t *testing.T) {
37+
opts := clioptions.NewCLIOptions()
38+
cmd := DescribeCmd(opts)
39+
require.NotNil(t, cmd)
40+
})
41+
}
42+
43+
func TestDescribeProjectCmd(t *testing.T) {
44+
testCases := map[string]struct {
45+
options describeProjectOptions
46+
revisionName string
47+
versionName string
48+
expectError bool
49+
expectedErrorMsg string
50+
testServer *httptest.Server
51+
outputTextJSON string
52+
}{
53+
"error missing project id": {
54+
options: describeProjectOptions{},
55+
expectError: true,
56+
expectedErrorMsg: "missing project name, please provide a project name as argument",
57+
testServer: describeTestServer(t, func(_ http.ResponseWriter, _ *http.Request) bool {
58+
return false
59+
}),
60+
},
61+
"error missing revision/version": {
62+
options: describeProjectOptions{
63+
ProjectID: "test-project",
64+
},
65+
expectError: true,
66+
expectedErrorMsg: "missing revision/version name, please provide one as argument",
67+
testServer: describeTestServer(t, func(_ http.ResponseWriter, _ *http.Request) bool {
68+
return false
69+
}),
70+
},
71+
"error both revision/version specified": {
72+
options: describeProjectOptions{
73+
ProjectID: "test-project",
74+
RevisionName: "test-revision",
75+
VersionName: "test-version",
76+
},
77+
expectError: true,
78+
expectedErrorMsg: "both revision and version specified, please provide only one",
79+
testServer: describeTestServer(t, func(_ http.ResponseWriter, _ *http.Request) bool {
80+
return false
81+
}),
82+
},
83+
"valid project with revision": {
84+
options: describeProjectOptions{
85+
ProjectID: "test-project",
86+
RevisionName: "test-json-revision",
87+
OutputFormat: "json",
88+
},
89+
revisionName: "test-revision",
90+
testServer: describeTestServer(t, func(w http.ResponseWriter, r *http.Request) bool {
91+
if r.URL.Path == "/api/backend/projects/test-project/revisions/test-json-revision/configuration" && r.Method == http.MethodGet {
92+
w.WriteHeader(http.StatusOK)
93+
_, _ = w.Write([]byte(`{"name": "test-project", "revision": "test-json-revision"}`))
94+
return true
95+
}
96+
return false
97+
}),
98+
outputTextJSON: `{"name": "test-project", "revision": "test-json-revision"}`,
99+
},
100+
"valid project with version": {
101+
options: describeProjectOptions{
102+
ProjectID: "test-project",
103+
VersionName: "test-version",
104+
OutputFormat: "json",
105+
},
106+
testServer: describeTestServer(t, func(w http.ResponseWriter, r *http.Request) bool {
107+
if r.URL.Path == "/api/backend/projects/test-project/versions/test-version/configuration" && r.Method == http.MethodGet {
108+
w.WriteHeader(http.StatusOK)
109+
_, _ = w.Write([]byte(`{"name": "test-project", "revision": "test-version"}`))
110+
return true
111+
}
112+
return false
113+
}),
114+
outputTextJSON: `{"name": "test-project", "revision": "test-version"}`,
115+
},
116+
"valid project with yaml output format": {
117+
options: describeProjectOptions{
118+
ProjectID: "test-project",
119+
RevisionName: "test-yaml-revision",
120+
OutputFormat: "yaml",
121+
},
122+
testServer: describeTestServer(t, func(w http.ResponseWriter, r *http.Request) bool {
123+
if r.URL.Path == "/api/backend/projects/test-project/revisions/test-yaml-revision/configuration" && r.Method == http.MethodGet {
124+
w.WriteHeader(http.StatusOK)
125+
_, _ = w.Write([]byte(`{"name": "test-project", "revision": "test-yaml-revision"}`))
126+
return true
127+
}
128+
return false
129+
}),
130+
outputTextJSON: `{"name": "test-project", "revision": "test-yaml-revision"}`,
131+
},
132+
"revision with slash": {
133+
options: describeProjectOptions{
134+
ProjectID: "test-project",
135+
RevisionName: "some/revision",
136+
OutputFormat: "yaml",
137+
},
138+
testServer: describeTestServer(t, func(w http.ResponseWriter, r *http.Request) bool {
139+
if r.URL.Path == "/api/backend/projects/test-project/revisions/some%2Frevision/configuration" && r.Method == http.MethodGet {
140+
w.WriteHeader(http.StatusOK)
141+
_, _ = w.Write([]byte(`{"name": "test-project", "revision": "test-yaml-revision"}`))
142+
return true
143+
}
144+
return false
145+
}),
146+
outputTextJSON: `{"name": "test-project", "revision": "test-yaml-revision"}`,
147+
},
148+
"version with slash": {
149+
options: describeProjectOptions{
150+
ProjectID: "test-project",
151+
VersionName: "version/1.2.3",
152+
OutputFormat: "yaml",
153+
},
154+
testServer: describeTestServer(t, func(w http.ResponseWriter, r *http.Request) bool {
155+
if r.URL.Path == "/api/backend/projects/test-project/versions/version%2F1.2.3/configuration" && r.Method == http.MethodGet {
156+
w.WriteHeader(http.StatusOK)
157+
_, _ = w.Write([]byte(`{"name": "test-project", "revision": "test-yaml-revision"}`))
158+
return true
159+
}
160+
return false
161+
}),
162+
outputTextJSON: `{"name": "test-project", "revision": "test-yaml-revision"}`,
163+
},
164+
}
165+
166+
for name, testCase := range testCases {
167+
t.Run(name, func(t *testing.T) {
168+
server := testCase.testServer
169+
defer server.Close()
170+
171+
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
172+
defer cancel()
173+
174+
client, err := client.APIClientForConfig(&client.Config{
175+
Host: server.URL,
176+
})
177+
require.NoError(t, err)
178+
179+
outputBuffer := bytes.NewBuffer([]byte{})
180+
181+
err = describeProject(ctx, client, testCase.options, outputBuffer)
182+
183+
if testCase.expectError {
184+
require.Error(t, err)
185+
require.EqualError(t, err, testCase.expectedErrorMsg)
186+
} else {
187+
require.NoError(t, err)
188+
189+
if testCase.options.OutputFormat == encoding.JSON {
190+
require.JSONEq(t, testCase.outputTextJSON, outputBuffer.String(), "output should match expected JSON")
191+
} else {
192+
foundMap := map[string]interface{}{}
193+
err := yaml.Unmarshal(outputBuffer.Bytes(), &foundMap)
194+
require.NoError(t, err)
195+
196+
expectedMap := map[string]interface{}{}
197+
err = json.Unmarshal([]byte(testCase.outputTextJSON), &expectedMap)
198+
require.NoError(t, err)
199+
200+
require.Equal(t, expectedMap, foundMap)
201+
}
202+
}
203+
})
204+
}
205+
}
206+
207+
func describeTestServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request) bool) *httptest.Server {
208+
t.Helper()
209+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
210+
if handler(w, r) {
211+
return
212+
}
213+
214+
t.Logf("unexpected request: %#v\n%#v", r.URL, r)
215+
w.WriteHeader(http.StatusNotFound)
216+
assert.Fail(t, "unexpected request")
217+
}))
218+
}

0 commit comments

Comments
 (0)