Skip to content

Commit 2f22b41

Browse files
committed
feature: create command
1 parent 378705e commit 2f22b41

File tree

3 files changed

+676
-1
lines changed

3 files changed

+676
-1
lines changed
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
package create
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"net/http"
9+
"os"
10+
"time"
11+
12+
"github.com/goccy/go-yaml"
13+
"github.com/spf13/cobra"
14+
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
15+
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
16+
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
17+
"github.com/stackitcloud/stackit-cli/internal/pkg/flags"
18+
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
19+
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
20+
"github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client"
21+
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
22+
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
23+
)
24+
25+
const (
26+
nameFlag = "name"
27+
diskFormatFlag = "disk-format"
28+
localFilePathFlag = "local-file-path"
29+
30+
bootMenuFlag = "boot-menu"
31+
cdromBusFlag = "cdrom-bus"
32+
diskBusFlag = "disk-bus"
33+
nicModelFlag = "nic-model"
34+
operatingSystemFlag = "os"
35+
operatingSystemDistroFlag = "os-distro"
36+
operatingSystemVersionFlag = "os-version"
37+
rescueBusFlag = "rescue-bus"
38+
rescueDeviceFlag = "rescue-device"
39+
secureBootFlag = "secure-boot"
40+
uefiFlag = "uefi"
41+
videoModelFlag = "video-model"
42+
virtioScsiFlag = "virtio-scsi"
43+
44+
labelsFlag = "labels"
45+
46+
minDiskSizeFlag = "min-disk-size"
47+
minRamFlag = "min-ram"
48+
ownerFlag = "owner"
49+
protectedFlag = "protected"
50+
)
51+
52+
type imageConfig struct {
53+
BootMenu *bool
54+
CdromBus *string
55+
DiskBus *string
56+
NicModel *string
57+
OperatingSystem *string
58+
OperatingSystemDistro *string
59+
OperatingSystemVersion *string
60+
RescueBus *string
61+
RescueDevice *string
62+
SecureBoot *bool
63+
Uefi *bool
64+
VideoModel *string
65+
VirtioScsi *bool
66+
}
67+
type inputModel struct {
68+
*globalflags.GlobalFlagModel
69+
70+
Name string
71+
DiskFormat string
72+
LocalFilePath string
73+
Labels *map[string]string
74+
Config *imageConfig
75+
MinDiskSize *int64
76+
MinRam *int64
77+
Owner *string
78+
Protected *bool
79+
Scope *string
80+
Status *string
81+
}
82+
83+
func NewCmd(p *print.Printer) *cobra.Command {
84+
cmd := &cobra.Command{
85+
Use: "create",
86+
Short: "Creates images",
87+
Long: "Creates images.",
88+
Args: args.NoArgs,
89+
Example: examples.Build(
90+
examples.NewExample(`Create a named imaged`, `$ stackit beta image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image`),
91+
examples.NewExample(`Create a named image with labels`, `$ stackit beta image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image--labels dev,amd64`),
92+
),
93+
RunE: func(cmd *cobra.Command, _ []string) error {
94+
ctx := context.Background()
95+
model, err := parseInput(p, cmd)
96+
if err != nil {
97+
return err
98+
}
99+
100+
// Configure API client
101+
apiClient, err := client.ConfigureClient(p)
102+
if err != nil {
103+
return err
104+
}
105+
106+
// we open input file first to fail fast, if it is not readable
107+
file, err := os.Open(model.LocalFilePath)
108+
if err != nil {
109+
return fmt.Errorf("create image: file %q is not readable: %w", model.LocalFilePath, err)
110+
}
111+
defer file.Close()
112+
113+
if !model.AssumeYes {
114+
prompt := fmt.Sprintf("Are you sure you want to create the image %q?", model.Name)
115+
err = p.PromptForConfirmation(prompt)
116+
if err != nil {
117+
return err
118+
}
119+
}
120+
121+
// Call API
122+
request := buildRequest(ctx, model, apiClient)
123+
124+
result, err := request.Execute()
125+
if err != nil {
126+
return fmt.Errorf("create image: %w", err)
127+
}
128+
url, ok := result.GetUploadUrlOk()
129+
if !ok {
130+
return fmt.Errorf("create image: no upload URL has been provided")
131+
}
132+
if err := uploadFile(ctx, p, file, *url); err != nil {
133+
return err
134+
}
135+
136+
if err := outputResult(p, model, result); err != nil {
137+
return err
138+
}
139+
140+
return nil
141+
},
142+
}
143+
144+
configureFlags(cmd)
145+
return cmd
146+
}
147+
148+
func uploadFile(ctx context.Context, p *print.Printer, file *os.File, url string) error {
149+
var filesize int64
150+
if stat, err := file.Stat(); err != nil {
151+
p.Debug(print.DebugLevel, "create image: cannot open file %q: %w", file.Name(), err)
152+
} else {
153+
filesize = stat.Size()
154+
}
155+
p.Debug(print.DebugLevel, "uploading image to %s", url)
156+
start := time.Now()
157+
// pass the file contents as stream, as they can get arbitrarily large. We do
158+
// _not_ want to load them into an internal buffer. The downside is, that we
159+
// have to set the content-length header manually
160+
uploadRequest, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bufio.NewReader(file))
161+
if err != nil {
162+
return fmt.Errorf("create image: cannot create request: %w", err)
163+
}
164+
uploadRequest.Header.Add("Content-Type", "application/octet-stream")
165+
uploadRequest.ContentLength = filesize
166+
167+
uploadResponse, err := http.DefaultClient.Do(uploadRequest)
168+
if err != nil {
169+
return fmt.Errorf("create image: error contacting server for upload: %w", err)
170+
}
171+
if uploadResponse.StatusCode != http.StatusOK {
172+
return fmt.Errorf("create image: server rejected image upload with %s", uploadResponse.Status)
173+
}
174+
delay := time.Since(start)
175+
p.Debug(print.DebugLevel, "uploaded %d bytes in %v", filesize, delay)
176+
177+
return nil
178+
}
179+
180+
func configureFlags(cmd *cobra.Command) {
181+
cmd.Flags().String(nameFlag, "", "The name of the image.")
182+
cmd.Flags().String(diskFormatFlag, "", "The disk format of the image. ")
183+
cmd.Flags().String(localFilePathFlag, "", "The path to the local disk image file.")
184+
185+
cmd.Flags().Bool(bootMenuFlag, false, "Enables the BIOS bootmenu.")
186+
cmd.Flags().String(cdromBusFlag, "", "Sets CDROM bus controller type.")
187+
cmd.Flags().String(diskBusFlag, "", "Sets Disk bus controller type.")
188+
cmd.Flags().String(nicModelFlag, "", "Sets virtual nic model.")
189+
cmd.Flags().String(operatingSystemFlag, "", "Enables OS specific optimizations.")
190+
cmd.Flags().String(operatingSystemDistroFlag, "", "Operating System Distribution.")
191+
cmd.Flags().String(operatingSystemVersionFlag, "", "Version of the OS.")
192+
cmd.Flags().String(rescueBusFlag, "", "Sets the device bus when the image is used as a rescue image.")
193+
cmd.Flags().String(rescueDeviceFlag, "", "Sets the device when the image is used as a rescue image.")
194+
cmd.Flags().Bool(secureBootFlag, false, "Enables Secure Boot.")
195+
cmd.Flags().Bool(uefiFlag, false, "Enables UEFI boot.")
196+
cmd.Flags().String(videoModelFlag, "", "Sets Graphic device model.")
197+
cmd.Flags().Bool(virtioScsiFlag, false, "Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block.")
198+
199+
cmd.Flags().StringToString(labelsFlag, nil, "Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...'")
200+
201+
cmd.Flags().Int64(minDiskSizeFlag, 0, "Size in Gigabyte.")
202+
cmd.Flags().Int64(minRamFlag, 0, "Size in Megabyte.")
203+
cmd.Flags().Bool(protectedFlag, false, "Protected VM.")
204+
205+
if err := flags.MarkFlagsRequired(cmd, nameFlag, diskFormatFlag, localFilePathFlag); err != nil {
206+
cobra.CheckErr(err)
207+
}
208+
}
209+
210+
func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
211+
globalFlags := globalflags.Parse(p, cmd)
212+
if globalFlags.ProjectId == "" {
213+
return nil, &errors.ProjectIdError{}
214+
}
215+
name := flags.FlagToStringValue(p, cmd, nameFlag)
216+
217+
model := inputModel{
218+
GlobalFlagModel: globalFlags,
219+
Name: name,
220+
DiskFormat: flags.FlagToStringValue(p, cmd, diskFormatFlag),
221+
LocalFilePath: flags.FlagToStringValue(p, cmd, localFilePathFlag),
222+
Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag),
223+
Config: &imageConfig{
224+
BootMenu: flags.FlagToBoolPointer(p, cmd, bootMenuFlag),
225+
CdromBus: flags.FlagToStringPointer(p, cmd, cdromBusFlag),
226+
DiskBus: flags.FlagToStringPointer(p, cmd, diskBusFlag),
227+
NicModel: flags.FlagToStringPointer(p, cmd, nicModelFlag),
228+
OperatingSystem: flags.FlagToStringPointer(p, cmd, operatingSystemFlag),
229+
OperatingSystemDistro: flags.FlagToStringPointer(p, cmd, operatingSystemDistroFlag),
230+
OperatingSystemVersion: flags.FlagToStringPointer(p, cmd, operatingSystemVersionFlag),
231+
RescueBus: flags.FlagToStringPointer(p, cmd, rescueBusFlag),
232+
RescueDevice: flags.FlagToStringPointer(p, cmd, rescueDeviceFlag),
233+
SecureBoot: flags.FlagToBoolPointer(p, cmd, secureBootFlag),
234+
Uefi: flags.FlagToBoolPointer(p, cmd, uefiFlag),
235+
VideoModel: flags.FlagToStringPointer(p, cmd, videoModelFlag),
236+
VirtioScsi: flags.FlagToBoolPointer(p, cmd, virtioScsiFlag),
237+
},
238+
MinDiskSize: flags.FlagToInt64Pointer(p, cmd, minDiskSizeFlag),
239+
MinRam: flags.FlagToInt64Pointer(p, cmd, minRamFlag),
240+
Protected: flags.FlagToBoolPointer(p, cmd, protectedFlag),
241+
}
242+
243+
if p.IsVerbosityDebug() {
244+
modelStr, err := print.BuildDebugStrFromInputModel(model)
245+
if err != nil {
246+
p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err)
247+
} else {
248+
p.Debug(print.DebugLevel, "parsed input values: %s", modelStr)
249+
}
250+
}
251+
252+
return &model, nil
253+
}
254+
255+
func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateImageRequest {
256+
request := apiClient.CreateImage(ctx, model.ProjectId).
257+
CreateImagePayload(createPayload(ctx, model))
258+
return request
259+
}
260+
261+
func createPayload(ctx context.Context, model *inputModel) iaas.CreateImagePayload {
262+
var labelsMap *map[string]any
263+
if model.Labels != nil && len(*model.Labels) > 0 {
264+
// convert map[string]string to map[string]interface{}
265+
labelsMap = utils.Ptr(map[string]interface{}{})
266+
for k, v := range *model.Labels {
267+
(*labelsMap)[k] = v
268+
}
269+
}
270+
payload := iaas.CreateImagePayload{
271+
DiskFormat: &model.DiskFormat,
272+
Name: &model.Name,
273+
Labels: labelsMap,
274+
MinDiskSize: model.MinDiskSize,
275+
MinRam: model.MinRam,
276+
Protected: model.Protected,
277+
}
278+
if model.Config != nil {
279+
payload.Config = &iaas.ImageConfig{
280+
BootMenu: model.Config.BootMenu,
281+
CdromBus: iaas.NewNullableString(model.Config.CdromBus),
282+
DiskBus: iaas.NewNullableString(model.Config.DiskBus),
283+
NicModel: iaas.NewNullableString(model.Config.NicModel),
284+
OperatingSystem: model.Config.OperatingSystem,
285+
OperatingSystemDistro: iaas.NewNullableString(model.Config.OperatingSystemDistro),
286+
OperatingSystemVersion: iaas.NewNullableString(model.Config.OperatingSystemVersion),
287+
RescueBus: iaas.NewNullableString(model.Config.RescueBus),
288+
RescueDevice: iaas.NewNullableString(model.Config.RescueDevice),
289+
SecureBoot: model.Config.SecureBoot,
290+
Uefi: model.Config.Uefi,
291+
VideoModel: iaas.NewNullableString(model.Config.VideoModel),
292+
VirtioScsi: model.Config.VirtioScsi,
293+
}
294+
}
295+
296+
return payload
297+
}
298+
299+
func outputResult(p *print.Printer, model *inputModel, resp *iaas.ImageCreateResponse) error {
300+
switch model.OutputFormat {
301+
case print.JSONOutputFormat:
302+
details, err := json.MarshalIndent(resp, "", " ")
303+
if err != nil {
304+
return fmt.Errorf("marshal image: %w", err)
305+
}
306+
p.Outputln(string(details))
307+
308+
return nil
309+
case print.YAMLOutputFormat:
310+
details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true))
311+
if err != nil {
312+
return fmt.Errorf("marshal image: %w", err)
313+
}
314+
p.Outputln(string(details))
315+
316+
return nil
317+
default:
318+
p.Outputf("Created image %q\n", model.Name)
319+
return nil
320+
}
321+
}

0 commit comments

Comments
 (0)