Skip to content

Commit 35712d7

Browse files
committed
feat: add describe command
1 parent a742d27 commit 35712d7

File tree

3 files changed

+370
-1
lines changed

3 files changed

+370
-1
lines changed
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package describe
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"strings"
8+
9+
"github.com/goccy/go-yaml"
10+
"github.com/spf13/cobra"
11+
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
12+
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
13+
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
14+
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
15+
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
16+
"github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client"
17+
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
18+
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
19+
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
20+
)
21+
22+
type inputModel struct {
23+
*globalflags.GlobalFlagModel
24+
ImageId string
25+
}
26+
27+
const imageIdArg = "IMAGE_ID"
28+
29+
func NewCmd(p *print.Printer) *cobra.Command {
30+
cmd := &cobra.Command{
31+
Use: fmt.Sprintf("describe %s", imageIdArg),
32+
Short: "Describes image",
33+
Long: "Describes an image by its internal ID.",
34+
Args: args.SingleArg(imageIdArg, utils.ValidateUUID),
35+
Example: examples.Build(
36+
examples.NewExample(`Describe image "xxx"`, `$ stackit beta image describe xxx`),
37+
),
38+
RunE: func(cmd *cobra.Command, args []string) error {
39+
ctx := context.Background()
40+
model, err := parseInput(p, cmd, args)
41+
if err != nil {
42+
return err
43+
}
44+
45+
// Configure API client
46+
apiClient, err := client.ConfigureClient(p)
47+
if err != nil {
48+
return err
49+
}
50+
51+
// Call API
52+
request := buildRequest(ctx, model, apiClient)
53+
54+
image, err := request.Execute()
55+
if err != nil {
56+
return fmt.Errorf("get image: %w", err)
57+
}
58+
59+
if err := outputResult(p, model, image); err != nil {
60+
return err
61+
}
62+
63+
return nil
64+
},
65+
}
66+
67+
return cmd
68+
}
69+
70+
func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetImageRequest {
71+
request := apiClient.GetImage(ctx, model.ProjectId, model.ImageId)
72+
return request
73+
}
74+
75+
func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) {
76+
globalFlags := globalflags.Parse(p, cmd)
77+
if globalFlags.ProjectId == "" {
78+
return nil, &errors.ProjectIdError{}
79+
}
80+
81+
model := inputModel{
82+
GlobalFlagModel: globalFlags,
83+
ImageId: cliArgs[0],
84+
}
85+
86+
if p.IsVerbosityDebug() {
87+
modelStr, err := print.BuildDebugStrFromInputModel(model)
88+
if err != nil {
89+
p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err)
90+
} else {
91+
p.Debug(print.DebugLevel, "parsed input values: %s", modelStr)
92+
}
93+
}
94+
95+
return &model, nil
96+
}
97+
98+
func outputResult(p *print.Printer, model *inputModel, resp *iaas.Image) error {
99+
switch model.OutputFormat {
100+
case print.JSONOutputFormat:
101+
details, err := json.MarshalIndent(resp, "", " ")
102+
if err != nil {
103+
return fmt.Errorf("marshal image: %w", err)
104+
}
105+
p.Outputln(string(details))
106+
107+
return nil
108+
case print.YAMLOutputFormat:
109+
details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true))
110+
if err != nil {
111+
return fmt.Errorf("marshal image: %w", err)
112+
}
113+
p.Outputln(string(details))
114+
115+
return nil
116+
default:
117+
table := tables.NewTable()
118+
if id := resp.Id; id != nil {
119+
table.AddRow("ID", *id)
120+
}
121+
table.AddSeparator()
122+
123+
if name := resp.Name; name != nil {
124+
table.AddRow("NAME", *name)
125+
table.AddSeparator()
126+
}
127+
if format := resp.DiskFormat; format != nil {
128+
table.AddRow("FORMAT", *format)
129+
table.AddSeparator()
130+
}
131+
if diskSize := resp.MinDiskSize; diskSize != nil {
132+
table.AddRow("DISK SIZE", *diskSize)
133+
table.AddSeparator()
134+
}
135+
if ramSize := resp.MinRam; ramSize != nil {
136+
table.AddRow("RAM SIZE", *ramSize)
137+
table.AddSeparator()
138+
}
139+
if config := resp.Config; config != nil {
140+
if os := config.OperatingSystem; os != nil {
141+
table.AddRow("OPERATING SYSTEM", *os)
142+
table.AddSeparator()
143+
}
144+
if distro := config.OperatingSystemDistro; distro != nil {
145+
table.AddRow("OPERATING SYSTEM DISTRIBUTION", *distro)
146+
table.AddSeparator()
147+
}
148+
if version := config.OperatingSystemVersion; version != nil {
149+
table.AddRow("OPERATING SYSTEM VERSION", *version)
150+
table.AddSeparator()
151+
}
152+
if uefi := config.Uefi; uefi != nil {
153+
table.AddRow("UEFI BOOT", *uefi)
154+
table.AddSeparator()
155+
}
156+
}
157+
158+
if resp.Labels != nil && len(*resp.Labels) > 0 {
159+
labels := []string{}
160+
for key, value := range *resp.Labels {
161+
labels = append(labels, fmt.Sprintf("%s: %s", key, value))
162+
}
163+
table.AddRow("LABELS", strings.Join(labels, "\n"))
164+
table.AddSeparator()
165+
}
166+
167+
if err := table.Display(p); err != nil {
168+
return fmt.Errorf("render table: %w", err)
169+
}
170+
171+
return nil
172+
}
173+
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package describe
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
8+
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
9+
10+
"github.com/google/go-cmp/cmp"
11+
"github.com/google/go-cmp/cmp/cmpopts"
12+
"github.com/google/uuid"
13+
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
14+
)
15+
16+
var projectIdFlag = globalflags.ProjectIdFlag
17+
18+
type testCtxKey struct{}
19+
20+
var (
21+
testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
22+
testClient = &iaas.APIClient{}
23+
testProjectId = uuid.NewString()
24+
testImageId = []string{uuid.NewString()}
25+
)
26+
27+
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
28+
flagValues := map[string]string{
29+
projectIdFlag: testProjectId,
30+
}
31+
for _, mod := range mods {
32+
mod(flagValues)
33+
}
34+
return flagValues
35+
}
36+
37+
func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
38+
model := &inputModel{
39+
GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault},
40+
ImageId: testImageId[0],
41+
}
42+
for _, mod := range mods {
43+
mod(model)
44+
}
45+
return model
46+
}
47+
48+
func fixtureRequest(mods ...func(request *iaas.ApiGetImageRequest)) iaas.ApiGetImageRequest {
49+
request := testClient.GetImage(testCtx, testProjectId, testImageId[0])
50+
for _, mod := range mods {
51+
mod(&request)
52+
}
53+
return request
54+
}
55+
56+
func TestParseInput(t *testing.T) {
57+
tests := []struct {
58+
description string
59+
flagValues map[string]string
60+
isValid bool
61+
args []string
62+
expectedModel *inputModel
63+
}{
64+
{
65+
description: "base",
66+
flagValues: fixtureFlagValues(),
67+
expectedModel: fixtureInputModel(),
68+
args: testImageId,
69+
isValid: true,
70+
},
71+
{
72+
description: "no values",
73+
flagValues: map[string]string{},
74+
args: testImageId,
75+
isValid: false,
76+
},
77+
{
78+
description: "project id missing",
79+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
80+
delete(flagValues, projectIdFlag)
81+
}),
82+
args: testImageId,
83+
isValid: false,
84+
},
85+
{
86+
description: "project id invalid 1",
87+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
88+
flagValues[projectIdFlag] = ""
89+
}),
90+
args: testImageId,
91+
isValid: false,
92+
},
93+
{
94+
description: "project id invalid 2",
95+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
96+
flagValues[projectIdFlag] = "invalid-uuid"
97+
}),
98+
args: testImageId,
99+
isValid: false,
100+
},
101+
{
102+
description: "no image id passed",
103+
flagValues: fixtureFlagValues(),
104+
args: nil,
105+
isValid: false,
106+
},
107+
{
108+
description: "multiple image ids passed",
109+
flagValues: fixtureFlagValues(),
110+
args: []string{uuid.NewString(), uuid.NewString()},
111+
isValid: false,
112+
},
113+
{
114+
description: "invalid image id passed",
115+
flagValues: fixtureFlagValues(),
116+
args: []string{"foobar"},
117+
},
118+
}
119+
120+
for _, tt := range tests {
121+
t.Run(tt.description, func(t *testing.T) {
122+
p := print.NewPrinter()
123+
cmd := NewCmd(p)
124+
if err := globalflags.Configure(cmd.Flags()); err != nil {
125+
t.Errorf("cannot configure global flags: %v", err)
126+
}
127+
for flag, value := range tt.flagValues {
128+
err := cmd.Flags().Set(flag, value)
129+
if err != nil {
130+
if !tt.isValid {
131+
return
132+
}
133+
t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
134+
}
135+
}
136+
137+
if err := cmd.ValidateRequiredFlags(); err != nil {
138+
if !tt.isValid {
139+
return
140+
}
141+
t.Fatalf("error validating flags: %v", err)
142+
}
143+
144+
if err := cmd.ValidateArgs(tt.args); err != nil {
145+
if !tt.isValid {
146+
return
147+
}
148+
}
149+
150+
model, err := parseInput(p, cmd, tt.args)
151+
if err != nil {
152+
if !tt.isValid {
153+
return
154+
}
155+
t.Fatalf("error parsing flags: %v", err)
156+
}
157+
158+
if !tt.isValid {
159+
t.Fatalf("did not fail on invalid input")
160+
}
161+
diff := cmp.Diff(model, tt.expectedModel)
162+
if diff != "" {
163+
t.Fatalf("Data does not match: %s", diff)
164+
}
165+
})
166+
}
167+
}
168+
169+
func TestBuildRequest(t *testing.T) {
170+
tests := []struct {
171+
description string
172+
model *inputModel
173+
expectedRequest iaas.ApiGetImageRequest
174+
}{
175+
{
176+
description: "base",
177+
model: fixtureInputModel(),
178+
expectedRequest: fixtureRequest(),
179+
},
180+
}
181+
182+
for _, tt := range tests {
183+
t.Run(tt.description, func(t *testing.T) {
184+
request := buildRequest(testCtx, tt.model, testClient)
185+
diff := cmp.Diff(request, tt.expectedRequest,
186+
cmp.AllowUnexported(tt.expectedRequest),
187+
cmpopts.EquateComparable(testCtx),
188+
)
189+
if diff != "" {
190+
t.Fatalf("Data does not match: %s", diff)
191+
}
192+
})
193+
}
194+
}

internal/cmd/beta/image/image.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ package security_group
22

33
import (
44
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/create"
5-
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/list"
65
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/delete"
6+
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/describe"
7+
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/list"
78
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
89
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
910

@@ -29,5 +30,6 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) {
2930
create.NewCmd(p),
3031
list.NewCmd(p),
3132
delete.NewCmd(p),
33+
describe.NewCmd(p),
3234
)
3335
}

0 commit comments

Comments
 (0)