diff --git a/.gitignore b/.gitignore index ca1a805d..1d3bbafd 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ contrib go.work .aptlydata .idea/ +.vscode/ +cli diff --git a/cmd/cmd.go b/cmd/cmd.go index 6d99b80d..ffc90aa8 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -218,19 +218,37 @@ func cliCommandFlagSet(c cliCommand) (*pflag.FlagSet, error) { for i := 0; i < cv.NumField(); i++ { cTypeField := cv.Type().Field(i) + fieldValue := cv.Field(i) if v, ok := cTypeField.Tag.Lookup("cli"); ok && v == "-" { continue } - if _, ok := cTypeField.Tag.Lookup("cli-cmd"); ok { continue } - if _, ok := cTypeField.Tag.Lookup("cli-arg"); ok { continue } + // Handle embedded structs (inheritance) + if cTypeField.Type.Kind() == reflect.Struct && cTypeField.Anonymous { + embedded := fieldValue + if embedded.CanAddr() { + embedded = embedded.Addr() + } + // Only recurse if the embedded struct implements cliCommand + if embedded.Type().Implements(reflect.TypeOf((*cliCommand)(nil)).Elem()) { + embeddedFlags, err := cliCommandFlagSet(embedded.Interface().(cliCommand)) + if err != nil { + return nil, err + } + embeddedFlags.VisitAll(func(flag *pflag.Flag) { + fs.AddFlag(flag) + }) + } + continue + } + flagName := strcase.ToKebab(cTypeField.Name) if v, ok := cTypeField.Tag.Lookup("cli-flag"); ok { flagName = v @@ -246,27 +264,22 @@ func cliCommandFlagSet(c cliCommand) (*pflag.FlagSet, error) { flagUsage = v } - flagDefaultValue := cv.Field(i).Interface() + flagDefaultValue := fieldValue.Interface() switch t := cTypeField.Type.Kind(); t { case reflect.String: fs.StringP(flagName, flagShort, fmt.Sprint(flagDefaultValue), flagUsage) - case reflect.Int64: fs.Int64P(flagName, flagShort, flagDefaultValue.(int64), flagUsage) - case reflect.Bool: fs.BoolP(flagName, flagShort, flagDefaultValue.(bool), flagUsage) - case reflect.Slice: if cTypeField.Type.Elem().Kind() != reflect.String { return nil, cliCommandImplemError{ fmt.Sprintf("unsupported type []%s for field %s.%s", t, cv.Type(), cTypeField.Name), } } - fs.StringSliceP(flagName, flagShort, flagDefaultValue.([]string), flagUsage) - case reflect.Map: if cTypeField.Type.Elem().Kind() != reflect.String { return nil, cliCommandImplemError{ @@ -278,9 +291,7 @@ func cliCommandFlagSet(c cliCommand) (*pflag.FlagSet, error) { ), } } - fs.StringToStringP(flagName, flagShort, flagDefaultValue.(map[string]string), flagUsage) - default: return nil, cliCommandImplemError{fmt.Sprintf("unsupported type %s", t)} } diff --git a/cmd/instance_create.go b/cmd/instance_create.go index 2ceab69d..e4cd6130 100644 --- a/cmd/instance_create.go +++ b/cmd/instance_create.go @@ -23,7 +23,7 @@ import ( v3 "github.com/exoscale/egoscale/v3" ) -type instanceCreateCmd struct { +type InstanceCreateCmd struct { cliCommandSettings `cli-cmd:"-"` _ bool `cli-cmd:"create"` @@ -48,11 +48,11 @@ type instanceCreateCmd struct { Zone v3.ZoneName `cli-short:"z" cli-usage:"instance zone"` } -func (c *instanceCreateCmd) cmdAliases() []string { return gCreateAlias } +func (c *InstanceCreateCmd) cmdAliases() []string { return gCreateAlias } -func (c *instanceCreateCmd) cmdShort() string { return "Create a Compute instance" } +func (c *InstanceCreateCmd) cmdShort() string { return "Create a Compute instance" } -func (c *instanceCreateCmd) cmdLong() string { +func (c *InstanceCreateCmd) cmdLong() string { return fmt.Sprintf(`This command creates a Compute instance. Supported Compute instance type families: %s @@ -65,13 +65,13 @@ Supported output template annotations: %s`, strings.Join(output.TemplateAnnotations(&instanceShowOutput{}), ", ")) } -func (c *instanceCreateCmd) cmdPreRun(cmd *cobra.Command, args []string) error { +func (c *InstanceCreateCmd) cmdPreRun(cmd *cobra.Command, args []string) error { cmdSetZoneFlagFromDefault(cmd) cmdSetTemplateFlagFromDefault(cmd) return cliCommandDefaultPreRun(c, cmd, args) } -func (c *instanceCreateCmd) cmdRun(_ *cobra.Command, _ []string) error { //nolint:gocyclo +func (c *InstanceCreateCmd) cmdRun(_ *cobra.Command, _ []string) error { //nolint:gocyclo var ( singleUseSSHPrivateKey *rsa.PrivateKey singleUseSSHPublicKey ssh.PublicKey @@ -324,7 +324,7 @@ func (c *instanceCreateCmd) cmdRun(_ *cobra.Command, _ []string) error { //nolin } func init() { - cobra.CheckErr(registerCLICommand(instanceCmd, &instanceCreateCmd{ + cobra.CheckErr(registerCLICommand(instanceCmd, &InstanceCreateCmd{ cliCommandSettings: defaultCLICmdSettings(), DiskSize: 50, diff --git a/cmd/instance_delete.go b/cmd/instance_delete.go index 01f734d2..980439a9 100644 --- a/cmd/instance_delete.go +++ b/cmd/instance_delete.go @@ -12,7 +12,7 @@ import ( v3 "github.com/exoscale/egoscale/v3" ) -type instanceDeleteCmd struct { +type InstanceDeleteCmd struct { cliCommandSettings `cli-cmd:"-"` _ bool `cli-cmd:"delete"` @@ -23,18 +23,18 @@ type instanceDeleteCmd struct { Zone string `cli-short:"z" cli-usage:"instance zone"` } -func (c *instanceDeleteCmd) cmdAliases() []string { return gRemoveAlias } +func (c *InstanceDeleteCmd) cmdAliases() []string { return gRemoveAlias } -func (c *instanceDeleteCmd) cmdShort() string { return "Delete a Compute instance" } +func (c *InstanceDeleteCmd) cmdShort() string { return "Delete a Compute instance" } -func (c *instanceDeleteCmd) cmdLong() string { return "" } +func (c *InstanceDeleteCmd) cmdLong() string { return "" } -func (c *instanceDeleteCmd) cmdPreRun(cmd *cobra.Command, args []string) error { +func (c *InstanceDeleteCmd) cmdPreRun(cmd *cobra.Command, args []string) error { cmdSetZoneFlagFromDefault(cmd) return cliCommandDefaultPreRun(c, cmd, args) } -func (c *instanceDeleteCmd) cmdRun(_ *cobra.Command, _ []string) error { +func (c *InstanceDeleteCmd) cmdRun(_ *cobra.Command, _ []string) error { ctx := gContext client, err := switchClientZoneV3( ctx, @@ -103,7 +103,7 @@ func (c *instanceDeleteCmd) cmdRun(_ *cobra.Command, _ []string) error { } func init() { - cobra.CheckErr(registerCLICommand(instanceCmd, &instanceDeleteCmd{ + cobra.CheckErr(registerCLICommand(instanceCmd, &InstanceDeleteCmd{ cliCommandSettings: defaultCLICmdSettings(), })) } diff --git a/cmd/instance_ssh.go b/cmd/instance_ssh.go index eb9cea45..b8ed90dc 100644 --- a/cmd/instance_ssh.go +++ b/cmd/instance_ssh.go @@ -17,7 +17,7 @@ import ( exoapi "github.com/exoscale/egoscale/v2/api" ) -type instanceSSHCmd struct { +type InstanceSSHCmd struct { cliCommandSettings `cli-cmd:"-"` sshInfo struct { @@ -26,7 +26,8 @@ type instanceSSHCmd struct { } `cli-cmd:"-"` _ bool `cli-cmd:"ssh"` - Instance string `cli-arg:"#" cli-usage:"INSTANCE-NAME|ID"` + Instance string `cli-arg:"#" cli-usage:"INSTANCE-NAME|ID"` + CommandArgument string `cli-arg:"?" cli-usage:"COMMAND ARGUMENT"` IPv6 bool `cli-flag:"ipv6" cli-short:"6" cli-help:"connect to the instance via its IPv6 address"` Login string `cli-short:"l" cli-help:"SSH username to use for logging in (default: instance template default username)"` @@ -36,7 +37,7 @@ type instanceSSHCmd struct { Zone string `cli-short:"z" cli-usage:"instance zone"` } -func (c *instanceSSHCmd) buildSSHCommand() []string { +func (c *InstanceSSHCmd) buildSSHCommand() []string { cmd := []string{"ssh"} if _, err := os.Stat(c.sshInfo.keyFile); err == nil { @@ -63,11 +64,11 @@ func (c *instanceSSHCmd) buildSSHCommand() []string { return cmd } -func (c *instanceSSHCmd) cmdAliases() []string { return nil } +func (c *InstanceSSHCmd) cmdAliases() []string { return nil } -func (c *instanceSSHCmd) cmdShort() string { return "Log into a Compute instance via SSH" } +func (c *InstanceSSHCmd) cmdShort() string { return "Log into a Compute instance via SSH" } -func (c *instanceSSHCmd) cmdLong() string { +func (c *InstanceSSHCmd) cmdLong() string { return `This command connects to a Compute instance via SSH (requires the ssh(1) command). To pass custom SSH options: @@ -76,12 +77,12 @@ To pass custom SSH options: ` } -func (c *instanceSSHCmd) cmdPreRun(cmd *cobra.Command, args []string) error { +func (c *InstanceSSHCmd) cmdPreRun(cmd *cobra.Command, args []string) error { cmdSetZoneFlagFromDefault(cmd) return cliCommandDefaultPreRun(c, cmd, args) } -func (c *instanceSSHCmd) cmdRun(_ *cobra.Command, _ []string) error { +func (c *InstanceSSHCmd) cmdRun(_ *cobra.Command, _ []string) error { ctx := exoapi.WithEndpoint(gContext, exoapi.NewReqEndpoint(account.CurrentAccount.Environment, c.Zone)) instance, err := globalstate.EgoscaleClient.FindInstance(ctx, c.Zone, c.Instance) @@ -119,6 +120,10 @@ func (c *instanceSSHCmd) cmdRun(_ *cobra.Command, _ []string) error { sshCmd := c.buildSSHCommand() + if c.CommandArgument != "" { + sshCmd = append(sshCmd, c.CommandArgument) + } + switch { case c.PrintConfig: out := bytes.NewBuffer(nil) @@ -150,7 +155,7 @@ func (c *instanceSSHCmd) cmdRun(_ *cobra.Command, _ []string) error { } func init() { - cobra.CheckErr(registerCLICommand(instanceCmd, &instanceSSHCmd{ + cobra.CheckErr(registerCLICommand(instanceCmd, &InstanceSSHCmd{ cliCommandSettings: defaultCLICmdSettings(), })) } diff --git a/cmd/lab.go b/cmd/lab.go new file mode 100644 index 00000000..62b88fb8 --- /dev/null +++ b/cmd/lab.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var labCmd = &cobra.Command{ + Use: "lab", + Short: "Experimental commands", + Long: `These commands provide work-in-progress functionalities that may or +may not be promoted to production someday. + +/!\ IMPORTANT: Exoscale provides no guarantees regarding the stability of the +commands provided in this section, and their syntax can change without prior +notice.`, +} + +func init() { + RootCmd.AddCommand(labCmd) +} diff --git a/cmd/lab_ai.go b/cmd/lab_ai.go new file mode 100644 index 00000000..a1763666 --- /dev/null +++ b/cmd/lab_ai.go @@ -0,0 +1,14 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var labAICmd = &cobra.Command{ + Use: "ai", + Short: "AI services management", +} + +func init() { + labCmd.AddCommand(labAICmd) +} diff --git a/cmd/lab_ai_job.go b/cmd/lab_ai_job.go new file mode 100644 index 00000000..95416536 --- /dev/null +++ b/cmd/lab_ai_job.go @@ -0,0 +1,229 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/exoscale/cli/pkg/output" + "github.com/spf13/cobra" +) + +var labAIJobCmd = &cobra.Command{ + Use: "job", + Short: "AI jobs management", +} + +func init() { + labAICmd.AddCommand(labAIJobCmd) +} + +// Create command + +type labAIJobCreateCmd struct { + cliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"create"` + + Name string `cli-arg:"#" cli-usage:"NAME"` + + ContainerImage string `cli-flag:"container-image" cli-usage:"container image to use for the AI job"` + HuggingFaceSecret string `cli-flag:"hf-secret" cli-usage:"HuggingFace secret to use for the AI job"` + Model string `cli-flag:"model" cli-usage:"model to use for the AI job"` + JobName string `cli-flag:"job-name" cli-usage:"name of the AI job to create"` + + InstanceCreateCmd +} + +func (c *labAIJobCreateCmd) cmdAliases() []string { return gCreateAlias } + +func (c *labAIJobCreateCmd) cmdShort() string { return "Create an AI job Instance" } + +var gpuInstanceTypeFamilies = []string{ + "gpu", + "gpu2", + "gpu3", +} + +var gpuInstanceTypeSizes = []string{ + "medium", + "large", + "huge", +} + +func (c *labAIJobCreateCmd) cmdLong() string { + return fmt.Sprintf(`This command creates an AI Job Instance. + +Supported Compute instance type families: %s + +Supported Compute instance type sizes: %s + +Supported output template annotations: %s`, + strings.Join(gpuInstanceTypeFamilies, ", "), + strings.Join(gpuInstanceTypeSizes, ", "), + strings.Join(output.TemplateAnnotations(&instanceShowOutput{}), ", ")) +} + +func (c *labAIJobCreateCmd) cmdPreRun(cmd *cobra.Command, args []string) error { + if err := c.InstanceCreateCmd.cmdPreRun(cmd, args); err != nil { + return err + } + + return cmdCheckRequiredFlags(cmd, []string{"hf-secret"}) +} + +const ( + defaultAIJobName = "ai-job" + // Creates a PVC to store the fine-tuning check points + aiJobPVCTemplate = ` +#cloud-config +write_files: + - path: /var/lib/rancher/k3s/server/manifests/ai-job.yaml + content: | + apiVersion: ai.re-cinq.com/v1 + kind: Job + metadata: + name: ` + defaultAIJobName + ` + spec: + image: %s + model: %s + diskSize: %d + huggingFaceSecret: %s + owner: 'root:root' + permissions: '0640' +` +) + +func (c *labAIJobCreateCmd) cmdRun(cmd *cobra.Command, args []string) error { //nolint:gocyclo + aiJobConfig := fmt.Sprintf(aiJobPVCTemplate, + c.ContainerImage, + c.Model, + c.DiskSize/2, + c.HuggingFaceSecret, + ) + + cloudInitFile, err := os.CreateTemp("", "cloud-init") + if err != nil { + return err + } + defer os.Remove(cloudInitFile.Name()) + + _, err = cloudInitFile.Write([]byte(aiJobConfig)) + if err != nil { + return err + } + c.CloudInitFile = cloudInitFile.Name() + + return c.InstanceCreateCmd.cmdRun(cmd, args) +} + +func init() { + cobra.CheckErr(registerCLICommand(labAIJobCmd, &labAIJobCreateCmd{ + cliCommandSettings: defaultCLICmdSettings(), + + // Here is some default values. + InstanceCreateCmd: InstanceCreateCmd{ + cliCommandSettings: defaultCLICmdSettings(), + DiskSize: 50, + InstanceType: fmt.Sprintf("%s.%s", "gpu", "small"), + TemplateVisibility: defaultTemplateVisibility, + Template: "Exoscale Lab AI runner", + }, + })) +} + +// Delete command + +type labAIJobDeleteCmd struct { + cliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"delete"` + + InstanceDeleteCmd +} + +func (c *labAIJobDeleteCmd) cmdAliases() []string { return gRemoveAlias } + +func (c *labAIJobDeleteCmd) cmdShort() string { return "Delete an AI job Instance" } + +func (c *labAIJobDeleteCmd) cmdLong() string { return "" } + +func (c *labAIJobDeleteCmd) cmdPreRun(cmd *cobra.Command, args []string) error { + return c.InstanceDeleteCmd.cmdPreRun(cmd, args) +} + +func (c *labAIJobDeleteCmd) cmdRun(cmd *cobra.Command, args []string) error { + return c.InstanceDeleteCmd.cmdRun(cmd, args) +} + +func init() { + cobra.CheckErr(registerCLICommand(labAIJobCmd, &labAIJobDeleteCmd{ + cliCommandSettings: defaultCLICmdSettings(), + InstanceDeleteCmd: InstanceDeleteCmd{ + cliCommandSettings: defaultCLICmdSettings(), + }, + })) +} + +// Show command + +// type labAIJobShowOutput struct { +// Instance string +// InstanceID string +// AIJobStatus string `json:"ai_job_status"` +// } + +// func (o *labAIJobShowOutput) Type() string { return "AI Job instance" } +// func (o *labAIJobShowOutput) ToJSON() { output.JSON(o) } +// func (o *labAIJobShowOutput) ToText() { output.Text(o) } +// func (o *labAIJobShowOutput) ToTable() { output.Table(o) } + +var ( + // Job status command + jobStatusCommand = "sudo kubectl get job %s" +) + +type labAIJobShowCmd struct { + cliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"show"` + + AIJobInstance string `cli-arg:"#" cli-usage:"NAME|ID"` + + JobName string `cli-flag:"job" cli-usage:"job name of the AI job to show"` + + InstanceSSHCmd +} + +func (c *labAIJobShowCmd) cmdAliases() []string { return gShowAlias } + +func (c *labAIJobShowCmd) cmdShort() string { return "Show an AI job status" } + +func (c *labAIJobShowCmd) cmdLong() string { + return fmt.Sprintf(`This command shows an AI job status. +Supported output template annotations: %s`, + strings.Join(output.TemplateAnnotations(&instanceShowOutput{}), ", ")) +} + +func (c *labAIJobShowCmd) cmdPreRun(cmd *cobra.Command, args []string) error { + jobStatusCommand = fmt.Sprintf(jobStatusCommand, defaultAIJobName) + args = append(args, jobStatusCommand) + cmd.SetArgs(args) + + return c.InstanceSSHCmd.cmdPreRun(cmd, args) +} + +func (c *labAIJobShowCmd) cmdRun(cmd *cobra.Command, args []string) error { //nolint:gocyclo + return c.InstanceSSHCmd.cmdRun(cmd, args) +} + +func init() { + cobra.CheckErr(registerCLICommand(labAIJobCmd, &labAIJobShowCmd{ + InstanceSSHCmd: InstanceSSHCmd{ + //Login: "debian", TODO to be tested with your template. + cliCommandSettings: defaultCLICmdSettings(), + }, + JobName: defaultAIJobName, + cliCommandSettings: defaultCLICmdSettings(), + })) +}