Skip to content

Commit 34b107e

Browse files
author
Syeda Haya Fatima
committed
Added functionality for model experiments on Stackit CLI
1 parent 5d87a6f commit 34b107e

30 files changed

Lines changed: 2842 additions & 14 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ require (
2626
github.com/stackitcloud/stackit-sdk-go/services/iaas v1.14.1
2727
github.com/stackitcloud/stackit-sdk-go/services/intake v0.11.1
2828
github.com/stackitcloud/stackit-sdk-go/services/logs v0.10.1
29+
github.com/stackitcloud/stackit-sdk-go/services/modelexperiments v0.3.0
2930
github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.12.1
3031
github.com/stackitcloud/stackit-sdk-go/services/opensearch v1.3.0
3132
github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.13.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,8 @@ github.com/stackitcloud/stackit-sdk-go/services/logs v0.10.1 h1:sWvLJF6/7Nh3hONp
620620
github.com/stackitcloud/stackit-sdk-go/services/logs v0.10.1/go.mod h1:tvRejL8w5KpGBbLFPQ+dXOJURgZ3OMbZmwxlKQrGMuA=
621621
github.com/stackitcloud/stackit-sdk-go/services/mariadb v1.3.0 h1:0JuNaVbyuIUEX5Cn88XoC5FVGU92cP1w3ZLAvCuuohQ=
622622
github.com/stackitcloud/stackit-sdk-go/services/mariadb v1.3.0/go.mod h1:joa89Y1dyn0j22FstRcIKfW2ada3FDxNfttxSvq27uY=
623+
github.com/stackitcloud/stackit-sdk-go/services/modelexperiments v0.3.0 h1:g8AX62NqDLJy2dNPPTzfeaBoaR2K+EIwxF8sRiAESL0=
624+
github.com/stackitcloud/stackit-sdk-go/services/modelexperiments v0.3.0/go.mod h1:TW2PYG0kSrfAos3yY8wUxDem0J9ZYSXulnbzBZ4BTaE=
623625
github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.12.1 h1:lLR6Ouu3H21jjyIcAZZc4f2SOBWgHTBEHbn/urtY+9M=
624626
github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.12.1/go.mod h1:0hHEPiOEMAA23EzEl42Rm3FlyKIzkW+LWLvDkuFTZ+Q=
625627
github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.9.1 h1:9n1BrPj6gAuKnyJ1OmsN+MEl2DBg9KJHgUfkijN84Rs=
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package create
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
10+
cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors"
11+
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
12+
"github.com/stackitcloud/stackit-cli/internal/pkg/flags"
13+
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
14+
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
15+
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
16+
"github.com/stackitcloud/stackit-cli/internal/pkg/services/modelexperiments/client"
17+
"github.com/stackitcloud/stackit-cli/internal/pkg/types"
18+
19+
modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api"
20+
)
21+
22+
const (
23+
nameFlag = "name"
24+
descriptionFlag = "description"
25+
labelFlag = "label"
26+
retentionFlag = "deleted-experiment-retention"
27+
)
28+
29+
type inputModel struct {
30+
*globalflags.GlobalFlagModel
31+
32+
Name string
33+
Description *string
34+
Labels *map[string]string
35+
Retention *string
36+
Region string
37+
}
38+
39+
func NewCmd(params *types.CmdParams) *cobra.Command {
40+
cmd := &cobra.Command{
41+
Use: "create",
42+
Short: "Creates an AI Model Experiments instance",
43+
Long: "Creates an AI Model Experiments (MLflow) instance in your STACKIT project.",
44+
Args: args.NoArgs,
45+
Example: examples.Build(
46+
examples.NewExample(
47+
`Create an AI Model Experiments instance with name "my-tracking"`,
48+
`$ stackit ai-model-experiments instance create --name my-tracking`),
49+
examples.NewExample(
50+
`Create an instance with a description and labels`,
51+
`$ stackit ai-model-experiments instance create --name my-tracking --description "team tracking server" --label env=prod`),
52+
),
53+
RunE: func(cmd *cobra.Command, args []string) error {
54+
ctx := context.Background()
55+
56+
model, err := parseInput(params.Printer, cmd, args)
57+
if err != nil {
58+
return err
59+
}
60+
61+
apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion)
62+
if err != nil {
63+
return err
64+
}
65+
66+
projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd)
67+
if err != nil {
68+
params.Printer.Debug(print.ErrorLevel, "get project name: %v", err)
69+
projectLabel = model.ProjectId
70+
}
71+
72+
prompt := fmt.Sprintf("Are you sure you want to create an AI Model Experiments instance for project %q?", projectLabel)
73+
if err := params.Printer.PromptForConfirmation(prompt); err != nil {
74+
return err
75+
}
76+
77+
req := buildCreateInstanceRequest(ctx, model, apiClient)
78+
resp, err := req.Execute()
79+
if err != nil {
80+
return fmt.Errorf("create AI Model Experiments instance: %w", err)
81+
}
82+
83+
return outputResult(params.Printer, model.OutputFormat, projectLabel, resp)
84+
},
85+
}
86+
configureFlags(cmd)
87+
return cmd
88+
}
89+
90+
func configureFlags(cmd *cobra.Command) {
91+
cmd.Flags().StringP(nameFlag, "n", "", "Instance name")
92+
cmd.Flags().String(descriptionFlag, "", "Instance description")
93+
cmd.Flags().StringToString(labelFlag, nil, `Labels as key-value pairs, e.g. "--label env=prod"`)
94+
cmd.Flags().String(retentionFlag, "", `Retention period for deleted experiments before permanent purge, e.g. "30d" (min 1d, max 90d)`)
95+
err := flags.MarkFlagsRequired(cmd, nameFlag)
96+
cobra.CheckErr(err)
97+
}
98+
99+
func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) {
100+
globalFlags := globalflags.Parse(p, cmd)
101+
if globalFlags.ProjectId == "" {
102+
return nil, &cliErr.ProjectIdError{}
103+
}
104+
105+
labels, err := cmd.Flags().GetStringToString(labelFlag)
106+
if err != nil {
107+
return nil, fmt.Errorf("parse %q flag: %w", labelFlag, err)
108+
}
109+
var labelsPtr *map[string]string
110+
if len(labels) > 0 {
111+
labelsPtr = &labels
112+
}
113+
114+
model := inputModel{
115+
GlobalFlagModel: globalFlags,
116+
Name: flags.FlagToStringValue(p, cmd, nameFlag),
117+
Description: flags.FlagToStringPointer(p, cmd, descriptionFlag),
118+
Labels: labelsPtr,
119+
Retention: flags.FlagToStringPointer(p, cmd, retentionFlag),
120+
Region: flags.FlagToStringValue(p, cmd, globalflags.RegionFlag),
121+
}
122+
123+
p.DebugInputModel(model)
124+
return &model, nil
125+
}
126+
127+
func buildCreateInstanceRequest(ctx context.Context, model *inputModel, apiClient *modelexperiments.APIClient) modelexperiments.ApiCreateInstanceRequest {
128+
req := apiClient.DefaultAPI.CreateInstance(ctx, model.ProjectId, model.Region)
129+
130+
payload := modelexperiments.CreateInstancePayload{
131+
Name: model.Name,
132+
}
133+
if model.Description != nil {
134+
payload.Description = model.Description
135+
}
136+
if model.Labels != nil {
137+
payload.Labels = model.Labels
138+
}
139+
if model.Retention != nil {
140+
payload.DeletedExperimentRetention = model.Retention
141+
}
142+
143+
return req.CreateInstancePayload(payload)
144+
}
145+
146+
func outputResult(p *print.Printer, outputFormat, projectLabel string, resp *modelexperiments.CreateInstanceResponse) error {
147+
if resp == nil {
148+
return fmt.Errorf("response instance is nil")
149+
}
150+
151+
return p.OutputResult(outputFormat, resp.Instance, func() error {
152+
p.Outputf("Creating AI Model Experiments instance for project %q. Instance ID: %s\n", projectLabel, resp.Instance.Id)
153+
return nil
154+
})
155+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package create
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/google/go-cmp/cmp"
8+
"github.com/google/go-cmp/cmp/cmpopts"
9+
"github.com/google/uuid"
10+
11+
modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api"
12+
13+
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
14+
"github.com/stackitcloud/stackit-cli/internal/pkg/testparams"
15+
"github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
16+
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
17+
)
18+
19+
var projectIdFlag = globalflags.ProjectIdFlag
20+
21+
type testCtxKey struct{}
22+
23+
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
24+
var testClient = &modelexperiments.APIClient{
25+
DefaultAPI: modelexperiments.DefaultAPIServiceMock{},
26+
}
27+
var testProjectId = uuid.NewString()
28+
29+
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
30+
flagValues := map[string]string{
31+
projectIdFlag: testProjectId,
32+
nameFlag: "example",
33+
globalflags.RegionFlag: "eu01",
34+
}
35+
for _, mod := range mods {
36+
mod(flagValues)
37+
}
38+
return flagValues
39+
}
40+
41+
func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
42+
model := &inputModel{
43+
GlobalFlagModel: &globalflags.GlobalFlagModel{
44+
ProjectId: testProjectId,
45+
Region: "eu01",
46+
Verbosity: globalflags.VerbosityDefault,
47+
},
48+
Name: "example",
49+
Region: "eu01",
50+
}
51+
for _, mod := range mods {
52+
mod(model)
53+
}
54+
return model
55+
}
56+
57+
func fixtureRequest(mods ...func(request *modelexperiments.ApiCreateInstanceRequest)) modelexperiments.ApiCreateInstanceRequest {
58+
request := testClient.DefaultAPI.CreateInstance(testCtx, testProjectId, "eu01")
59+
request = request.CreateInstancePayload(modelexperiments.CreateInstancePayload{
60+
Name: "example",
61+
})
62+
for _, mod := range mods {
63+
mod(&request)
64+
}
65+
return request
66+
}
67+
68+
func TestParseInput(t *testing.T) {
69+
tests := []struct {
70+
description string
71+
argValues []string
72+
flagValues map[string]string
73+
isValid bool
74+
expectedModel *inputModel
75+
}{
76+
{
77+
description: "base",
78+
flagValues: fixtureFlagValues(),
79+
isValid: true,
80+
expectedModel: fixtureInputModel(),
81+
},
82+
{
83+
description: "name missing",
84+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
85+
delete(flagValues, nameFlag)
86+
}),
87+
isValid: false,
88+
},
89+
{
90+
description: "with description and retention",
91+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
92+
flagValues[descriptionFlag] = "team tracking server"
93+
flagValues[retentionFlag] = "30d"
94+
}),
95+
isValid: true,
96+
expectedModel: fixtureInputModel(func(model *inputModel) {
97+
model.Description = utils.Ptr("team tracking server")
98+
model.Retention = utils.Ptr("30d")
99+
}),
100+
},
101+
{
102+
description: "project id missing",
103+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
104+
delete(flagValues, projectIdFlag)
105+
}),
106+
isValid: false,
107+
},
108+
}
109+
110+
for _, tt := range tests {
111+
t.Run(tt.description, func(t *testing.T) {
112+
testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid)
113+
})
114+
}
115+
}
116+
117+
func TestBuildCreateInstanceRequest(t *testing.T) {
118+
tests := []struct {
119+
description string
120+
model *inputModel
121+
expectedRequest modelexperiments.ApiCreateInstanceRequest
122+
}{
123+
{
124+
description: "base",
125+
model: fixtureInputModel(),
126+
expectedRequest: fixtureRequest(),
127+
},
128+
{
129+
description: "with description",
130+
model: fixtureInputModel(func(model *inputModel) {
131+
model.Description = utils.Ptr("team tracking server")
132+
}),
133+
expectedRequest: fixtureRequest(func(request *modelexperiments.ApiCreateInstanceRequest) {
134+
payload := modelexperiments.CreateInstancePayload{
135+
Name: "example",
136+
Description: utils.Ptr("team tracking server"),
137+
}
138+
*request = request.CreateInstancePayload(payload)
139+
}),
140+
},
141+
}
142+
143+
for _, tt := range tests {
144+
t.Run(tt.description, func(t *testing.T) {
145+
request := buildCreateInstanceRequest(testCtx, tt.model, testClient)
146+
147+
diff := cmp.Diff(request, tt.expectedRequest,
148+
cmp.AllowUnexported(tt.expectedRequest),
149+
cmpopts.EquateComparable(testCtx),
150+
)
151+
if diff != "" {
152+
t.Fatalf("Data does not match: %s", diff)
153+
}
154+
})
155+
}
156+
}
157+
158+
func TestOutputResult(t *testing.T) {
159+
tests := []struct {
160+
name string
161+
resp *modelexperiments.CreateInstanceResponse
162+
wantErr bool
163+
}{
164+
{
165+
name: "nil response",
166+
resp: nil,
167+
wantErr: true,
168+
},
169+
}
170+
params := testparams.NewTestParams()
171+
for _, tt := range tests {
172+
t.Run(tt.name, func(t *testing.T) {
173+
if err := outputResult(params.Printer, "", "test-project", tt.resp); (err != nil) != tt.wantErr {
174+
t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
175+
}
176+
})
177+
}
178+
}

0 commit comments

Comments
 (0)