Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- added `project describe` command

## [v0.19.0] - 2025-06-18

### Changed
Expand Down
3 changes: 2 additions & 1 deletion internal/client/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ func (r *Request) APIPath(apiPath string) *Request {
return r
}

r.apiPath = parsedURI.Path
r.apiPath = parsedURI.EscapedPath()

// comment out this, because not every request support the trailing /
// hopefully in the future they will
// if !strings.HasSuffix(r.apiPath, "/") {
Expand Down
9 changes: 9 additions & 0 deletions internal/clioptions/clioptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type CLIOptions struct {
Environment string

Revision string
Version string
DeployType string
NoSemVer bool
TriggerID string
Expand Down Expand Up @@ -132,6 +133,14 @@ func (o *CLIOptions) AddCompanyFlags(flags *pflag.FlagSet) {
flags.StringVar(&o.CompanyID, "company-id", "", "the ID of the company")
}

func (o *CLIOptions) AddRevisionFlags(flags *pflag.FlagSet) {
flags.StringVar(&o.Revision, "revision", "", "the revision name of the configuration")
}

func (o *CLIOptions) AddVersionFlags(flags *pflag.FlagSet) {
flags.StringVar(&o.Version, "version", "", "the version name of the configuration")
}

func (o *CLIOptions) AddEnvironmentFlags(flags *pflag.FlagSet) {
flags.StringVar(&o.Environment, "environment", "", "the environment scope for the command")
}
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ resources that make up the applications of a specific company.
project.ListCmd(o),
project.IAMCmd(o),
project.ImportCmd(o),
project.DescribeCmd(o),
)

return projectCmd
Expand Down
127 changes: 127 additions & 0 deletions internal/cmd/project/describe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// 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 project

import (
"context"
"fmt"
"io"
"net/url"

"github.com/mia-platform/miactl/internal/client"
"github.com/mia-platform/miactl/internal/clioptions"
"github.com/mia-platform/miactl/internal/encoding"
"github.com/spf13/cobra"
)

const (
describeProjectCmdUsage = "describe"
describeProjectCmdShort = "Describe a Project configuration"
describeProjectCmdLong = `Describe the configuration of the specified Project.`
)

type describeProjectOptions struct {
ProjectID string
RevisionName string
VersionName string
OutputFormat string
}

// DescribeCmd returns a cobra command for describing a project configuration
func DescribeCmd(options *clioptions.CLIOptions) *cobra.Command {
cmd := &cobra.Command{
Use: describeProjectCmdUsage,
Short: describeProjectCmdShort,
Long: describeProjectCmdLong,
RunE: func(cmd *cobra.Command, _ []string) error {
restConfig, err := options.ToRESTConfig()
cobra.CheckErr(err)

client, err := client.APIClientForConfig(restConfig)
cobra.CheckErr(err)

cmdOptions := describeProjectOptions{
RevisionName: options.Revision,
VersionName: options.Version,
ProjectID: restConfig.ProjectID,
OutputFormat: options.OutputFormat,
}

return describeProject(cmd.Context(), client, cmdOptions, cmd.ErrOrStderr())
},
}

flags := cmd.Flags()
options.AddProjectFlags(flags)
options.AddRevisionFlags(flags)
options.AddVersionFlags(flags)
options.AddOutputFormatFlag(flags, "json")

return cmd
}

func describeProject(ctx context.Context, client *client.APIClient, options describeProjectOptions, writer io.Writer) error {
if len(options.ProjectID) == 0 {
return fmt.Errorf("missing project name, please provide a project name as argument")
}

ref, err := getConfigRef(options.RevisionName, options.VersionName)
if err != nil {
return err
}

endpoint := fmt.Sprintf("/api/backend/projects/%s/%s/configuration", options.ProjectID, ref)
response, err := client.
Get().
APIPath(endpoint).
Do(ctx)
if err != nil {
return fmt.Errorf("failed to get project %s, ref %s: %w", options.ProjectID, ref, err)
}
if err := response.Error(); err != nil {
return err
}

projectConfig := make(map[string]any, 0)
if err := response.ParseResponse(&projectConfig); err != nil {
return fmt.Errorf("cannot parse project configuration: %w", err)
}

bytes, err := encoding.MarshalData(projectConfig, options.OutputFormat, encoding.MarshalOptions{Indent: true})
if err != nil {
return err
}

fmt.Fprintln(writer, string(bytes))
return nil
}

func getConfigRef(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 {
encodedRevisionName := url.PathEscape(revisionName)
return fmt.Sprintf("revisions/%s", encodedRevisionName), nil
}
if len(versionName) > 0 {
encodedVersionName := url.PathEscape(versionName)
return fmt.Sprintf("versions/%s", encodedVersionName), nil
}

return "", fmt.Errorf("missing revision/version name, please provide one as argument")
}
218 changes: 218 additions & 0 deletions internal/cmd/project/describe_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
// 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 project

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/mia-platform/miactl/internal/client"
"github.com/mia-platform/miactl/internal/clioptions"
"github.com/mia-platform/miactl/internal/encoding"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sigs.k8s.io/kustomize/kyaml/yaml"
)

func TestCreateDescribeCmd(t *testing.T) {
t.Run("test command creation", func(t *testing.T) {
opts := clioptions.NewCLIOptions()
cmd := DescribeCmd(opts)
require.NotNil(t, cmd)
})
}

func TestDescribeProjectCmd(t *testing.T) {
testCases := map[string]struct {
options describeProjectOptions
revisionName string
versionName string
expectError bool
expectedErrorMsg string
testServer *httptest.Server
outputTextJSON string
}{
"error missing project id": {
options: describeProjectOptions{},
expectError: true,
expectedErrorMsg: "missing project name, please provide a project name as argument",
testServer: describeTestServer(t, func(_ http.ResponseWriter, _ *http.Request) bool {
return false
}),
},
"error missing revision/version": {
options: describeProjectOptions{
ProjectID: "test-project",
},
expectError: true,
expectedErrorMsg: "missing revision/version name, please provide one as argument",
testServer: describeTestServer(t, func(_ http.ResponseWriter, _ *http.Request) bool {
return false
}),
},
"error both 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",
testServer: describeTestServer(t, func(_ http.ResponseWriter, _ *http.Request) bool {
return false
}),
},
"valid project with revision": {
options: describeProjectOptions{
ProjectID: "test-project",
RevisionName: "test-json-revision",
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/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
}
return false
}),
outputTextJSON: `{"name": "test-project", "revision": "test-json-revision"}`,
},
"valid project with version": {
options: describeProjectOptions{
ProjectID: "test-project",
VersionName: "test-version",
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 {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"name": "test-project", "revision": "test-version"}`))
return true
}
return false
}),
outputTextJSON: `{"name": "test-project", "revision": "test-version"}`,
},
"valid project with yaml output format": {
options: describeProjectOptions{
ProjectID: "test-project",
RevisionName: "test-yaml-revision",
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 {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"name": "test-project", "revision": "test-yaml-revision"}`))
return true
}
return false
}),
outputTextJSON: `{"name": "test-project", "revision": "test-yaml-revision"}`,
},
"revision with slash": {
options: describeProjectOptions{
ProjectID: "test-project",
RevisionName: "some/revision",
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 {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"name": "test-project", "revision": "test-yaml-revision"}`))
return true
}
return false
}),
outputTextJSON: `{"name": "test-project", "revision": "test-yaml-revision"}`,
},
"version with slash": {
options: describeProjectOptions{
ProjectID: "test-project",
VersionName: "version/1.2.3",
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 {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"name": "test-project", "revision": "test-yaml-revision"}`))
return true
}
return false
}),
outputTextJSON: `{"name": "test-project", "revision": "test-yaml-revision"}`,
},
}

for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
server := testCase.testServer
defer server.Close()

ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()

client, err := client.APIClientForConfig(&client.Config{
Host: server.URL,
})
require.NoError(t, err)

outputBuffer := bytes.NewBuffer([]byte{})

err = describeProject(ctx, client, testCase.options, outputBuffer)

if testCase.expectError {
require.Error(t, err)
require.EqualError(t, err, testCase.expectedErrorMsg)
} else {
require.NoError(t, err)

if testCase.options.OutputFormat == encoding.JSON {
require.JSONEq(t, testCase.outputTextJSON, outputBuffer.String(), "output should match expected JSON")
} else {
foundMap := map[string]interface{}{}
err := yaml.Unmarshal(outputBuffer.Bytes(), &foundMap)
require.NoError(t, err)

expectedMap := map[string]interface{}{}
err = json.Unmarshal([]byte(testCase.outputTextJSON), &expectedMap)
require.NoError(t, err)

require.Equal(t, expectedMap, foundMap)
}
}
})
}
}

func describeTestServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request) bool) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if handler(w, r) {
return
}

t.Logf("unexpected request: %#v\n%#v", r.URL, r)
w.WriteHeader(http.StatusNotFound)
assert.Fail(t, "unexpected request")
}))
}
Loading