From 6759618f2846fdd62015e5f0c79435ae42d73e0e Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Wed, 26 Jul 2023 17:43:14 +0400 Subject: [PATCH] orchard create vm: support --image-pull-policy=Always (#110) --- internal/command/create/vm.go | 17 +++- internal/command/get/cluster_settings.go | 5 +- internal/command/get/get.go | 9 ++ internal/command/get/vm.go | 106 +++++++++++++++++++++++ internal/controller/api_vms.go | 10 +++ internal/structpath/structpath.go | 12 ++- internal/worker/vmmanager/vm.go | 15 +++- pkg/resource/v1/image_pull_policy.go | 26 ++++++ pkg/resource/v1/v1.go | 17 ++-- 9 files changed, 199 insertions(+), 18 deletions(-) create mode 100644 internal/command/get/vm.go create mode 100644 pkg/resource/v1/image_pull_policy.go diff --git a/internal/command/create/vm.go b/internal/command/create/vm.go index a231a0a..435ed93 100644 --- a/internal/command/create/vm.go +++ b/internal/command/create/vm.go @@ -22,6 +22,7 @@ var resources map[string]string var restartPolicy string var startupScript string var hostDirsRaw []string +var imagePullPolicy string func newCreateVMCommand() *cobra.Command { command := &cobra.Command{ @@ -39,15 +40,19 @@ func newCreateVMCommand() *cobra.Command { command.PersistentFlags().BoolVar(&headless, "headless", true, "whether to run without graphics") command.PersistentFlags().StringToStringVar(&resources, "resources", map[string]string{}, "resources to request for this VM") - command.PersistentFlags().StringVar(&restartPolicy, "restart-policy", "Never", - "restart policy for this VM: specify \"Never\" to never restart or \"OnFailure\" "+ - "to only restart when the VM fails") + command.PersistentFlags().StringVar(&restartPolicy, "restart-policy", string(v1.RestartPolicyNever), + fmt.Sprintf("restart policy for this VM: specify %q to never restart or %q "+ + "to only restart when the VM fails", v1.RestartPolicyNever, v1.RestartPolicyOnFailure)) command.PersistentFlags().StringVar(&startupScript, "startup-script", "", "startup script (e.g. --startup-script=\"sync\") or a path to a script file prefixed with \"@\" "+ "(e.g. \"--startup-script=@script.sh\")") command.PersistentFlags().StringSliceVar(&hostDirsRaw, "host-dirs", []string{}, "host directories to mount to the VM, can be specified multiple times and/or be comma-separated "+ "(see \"tart run\"'s --dir argument for syntax)") + command.PersistentFlags().StringVar(&imagePullPolicy, "image-pull-policy", string(v1.ImagePullPolicyIfNotPresent), + fmt.Sprintf("image pull policy for this VM, by default the image is only only pulled if it doesn't "+ + "exist in the cache (%q), specify %q to always try to pull the image", + v1.ImagePullPolicyIfNotPresent, v1.ImagePullPolicyAlways)) return command } @@ -88,6 +93,12 @@ func runCreateVM(cmd *cobra.Command, args []string) error { return fmt.Errorf("%w: %v", ErrVMFailed, err) } + // Convert image pull policy + vm.ImagePullPolicy, err = v1.NewImagePullPolicyFromString(imagePullPolicy) + if err != nil { + return fmt.Errorf("%w: %v", ErrVMFailed, err) + } + // Convert restart policy vm.RestartPolicy, err = v1.NewRestartPolicyFromString(restartPolicy) if err != nil { diff --git a/internal/command/get/cluster_settings.go b/internal/command/get/cluster_settings.go index 9ae0acc..2877cc1 100644 --- a/internal/command/get/cluster_settings.go +++ b/internal/command/get/cluster_settings.go @@ -39,10 +39,7 @@ func runGetClusterSettings(cmd *cobra.Command, args []string) error { return policy.String() }) hostDirPoliciesDescription := strings.Join(hostDirPoliciesAsStrings, ",") - if hostDirPoliciesDescription == "" { - hostDirPoliciesDescription = "none" - } - table.AddRow("hostDir policies", hostDirPoliciesDescription) + table.AddRow("hostDir policies", nonEmptyOrNone(hostDirPoliciesDescription)) fmt.Println(table) diff --git a/internal/command/get/get.go b/internal/command/get/get.go index ad2dc2a..6266140 100644 --- a/internal/command/get/get.go +++ b/internal/command/get/get.go @@ -17,7 +17,16 @@ func NewCommand() *cobra.Command { newGetBootstrapTokenCommand(), newGetClusterSettingsCommand(), newGetServiceAccountCommand(), + newGetVMCommand(), ) return command } + +func nonEmptyOrNone(s string) string { + if s != "" { + return s + } + + return "none" +} diff --git a/internal/command/get/vm.go b/internal/command/get/vm.go new file mode 100644 index 0000000..b7800d6 --- /dev/null +++ b/internal/command/get/vm.go @@ -0,0 +1,106 @@ +package get + +import ( + "fmt" + "github.com/cirruslabs/orchard/internal/structpath" + "github.com/cirruslabs/orchard/pkg/client" + v1 "github.com/cirruslabs/orchard/pkg/resource/v1" + "github.com/dustin/go-humanize" + "github.com/gosuri/uitable" + "github.com/samber/lo" + "github.com/spf13/cobra" + "strings" + "time" +) + +func newGetVMCommand() *cobra.Command { + command := &cobra.Command{ + Use: "vm NAME", + Short: "Retrieve a VM and it's fields", + RunE: runGetVM, + Args: cobra.ExactArgs(1), + } + + return command +} + +func runGetVM(cmd *cobra.Command, args []string) error { + name := args[0] + + client, err := client.New() + if err != nil { + return err + } + + // Ability to retrieve resource fields (e.g. "orchard get vm macos/status") + splits := strings.Split(name, "/") + var path []string + if len(splits) > 1 { + name = splits[0] + path = splits[1:] + } + + vm, err := client.VMs().Get(cmd.Context(), name) + if err != nil { + return err + } + + // Ability to retrieve resource fields (e.g. "orchard get vm macos/status") + if len(path) != 0 { + result, ok := structpath.Lookup(*vm, path) + if !ok { + return fmt.Errorf("%w: failed to find the specified field \"%s\" or the field is not a string", + ErrGetFailed, strings.Join(path, "/")) + } + + fmt.Println(result) + + return nil + } + + table := uitable.New() + + table.AddRow("Name", vm.Name) + createdAtInfo := humanize.RelTime(vm.CreatedAt, time.Now(), "ago", "in the future") + table.AddRow("Created", createdAtInfo) + table.AddRow("Image", vm.Image) + table.AddRow("Image pull policy", vm.ImagePullPolicy) + table.AddRow("CPU", vm.CPU) + table.AddRow("Memory", vm.Memory) + table.AddRow("Softnet enabled", vm.NetSoftnet) + table.AddRow("Bridged networking interface", nonEmptyOrNone(vm.NetBridged)) + table.AddRow("Headless mode", vm.Headless) + table.AddRow("Status", vm.Status) + table.AddRow("Status message", vm.StatusMessage) + table.AddRow("Assigned worker", nonEmptyOrNone(vm.Worker)) + + table.AddRow("Restart policy", vm.RestartPolicy) + restartedAtInfo := "never" + if !vm.RestartedAt.IsZero() { + restartedAtInfo = humanize.RelTime(vm.RestartedAt, time.Now(), "ago", "in the future") + } + table.AddRow("Restarted", restartedAtInfo) + table.AddRow("Restart count", vm.RestartCount) + + var resourcesInfo string + if len(vm.Resources) != 0 { + resourceDescriptions := lo.MapToSlice(vm.Resources, func(key string, value uint64) string { + return fmt.Sprintf("%s: %d", key, value) + }) + resourcesInfo = strings.Join(resourceDescriptions, "\n") + } + table.AddRow("Resources", nonEmptyOrNone(resourcesInfo)) + + var hostDirsInfo string + if len(vm.HostDirs) != 0 { + hostDirsDescriptions := lo.Map(vm.HostDirs, func(hostDir v1.HostDir, index int) string { + return hostDir.String() + }) + hostDirsInfo = strings.Join(hostDirsDescriptions, "\n") + } + table.AddRow("Host directories", nonEmptyOrNone(hostDirsInfo)) + + fmt.Println(table) + + return nil +} diff --git a/internal/controller/api_vms.go b/internal/controller/api_vms.go index 2cf6cd6..e174124 100644 --- a/internal/controller/api_vms.go +++ b/internal/controller/api_vms.go @@ -50,6 +50,16 @@ func (controller *Controller) createVM(ctx *gin.Context) responder.Responder { vm.Resources[v1.ResourceTartVMs] = 1 } + // Validate image pull policy and provide a default value if it's missing + if vm.ImagePullPolicy != "" { + if _, err := v1.NewImagePullPolicyFromString(string(vm.ImagePullPolicy)); err != nil { + return responder.JSON(http.StatusPreconditionFailed, + NewErrorResponse("unsupported image pull policy: %q", vm.ImagePullPolicy)) + } + } else { + vm.ImagePullPolicy = v1.ImagePullPolicyIfNotPresent + } + // Validate restart policy and provide a default value if it's missing if vm.RestartPolicy != "" { if _, err := v1.NewRestartPolicyFromString(string(vm.RestartPolicy)); err != nil { diff --git a/internal/structpath/structpath.go b/internal/structpath/structpath.go index 2defbea..7691549 100644 --- a/internal/structpath/structpath.go +++ b/internal/structpath/structpath.go @@ -1,6 +1,7 @@ package structpath import ( + "fmt" "reflect" "strings" ) @@ -23,9 +24,14 @@ func Lookup(target interface{}, path []string) (s string, ok bool) { } result, ok := currentValue.Interface().(string) - if !ok { - return "", false + if ok { + return result, true } - return result, true + stringerResult, ok := currentValue.Interface().(fmt.Stringer) + if ok { + return stringerResult.String(), true + } + + return "", false } diff --git a/internal/worker/vmmanager/vm.go b/internal/worker/vmmanager/vm.go index 94dd99d..5686814 100644 --- a/internal/worker/vmmanager/vm.go +++ b/internal/worker/vmmanager/vm.go @@ -59,11 +59,22 @@ func NewVM( wg: &sync.WaitGroup{}, } - // Clone the VM so `run` and `ip` are not racing + // Optionally pull and clone the VM so that `run` and `ip` will not be racing vm.logger.Debugf("creating VM") + if vmResource.ImagePullPolicy == v1.ImagePullPolicyAlways { + _, _, err := tart.Tart(ctx, vm.logger, "pull", vm.Resource.Image) + if err != nil { + vm.setErr(fmt.Errorf("failed to pull the VM: %w", err)) + + return vm, nil + } + } + if err := vm.cloneAndConfigure(ctx); err != nil { - return nil, fmt.Errorf("failed to clone the VM: %w", err) + vm.setErr(fmt.Errorf("failed to clone the VM: %w", err)) + + return vm, nil } vm.wg.Add(1) diff --git a/pkg/resource/v1/image_pull_policy.go b/pkg/resource/v1/image_pull_policy.go new file mode 100644 index 0000000..3191e02 --- /dev/null +++ b/pkg/resource/v1/image_pull_policy.go @@ -0,0 +1,26 @@ +package v1 + +import ( + "errors" + "fmt" +) + +var ErrInvalidImagePullPolicy = errors.New("invalid image pull policy") + +type ImagePullPolicy string + +const ( + ImagePullPolicyIfNotPresent ImagePullPolicy = "IfNotPresent" + ImagePullPolicyAlways ImagePullPolicy = "Always" +) + +func NewImagePullPolicyFromString(s string) (ImagePullPolicy, error) { + switch s { + case string(ImagePullPolicyIfNotPresent): + return ImagePullPolicyIfNotPresent, nil + case string(ImagePullPolicyAlways): + return ImagePullPolicyAlways, nil + default: + return "", fmt.Errorf("%w %q", ErrInvalidImagePullPolicy, s) + } +} diff --git a/pkg/resource/v1/v1.go b/pkg/resource/v1/v1.go index 115b918..32b070c 100644 --- a/pkg/resource/v1/v1.go +++ b/pkg/resource/v1/v1.go @@ -19,12 +19,13 @@ type Meta struct { } type VM struct { - Image string `json:"image"` - CPU uint64 `json:"cpu"` - Memory uint64 `json:"memory"` - NetSoftnet bool `json:"net-softnet"` - NetBridged string `json:"net-bridged"` - Headless bool `json:"headless"` + Image string `json:"image"` + ImagePullPolicy ImagePullPolicy `json:"imagePullPolicy"` + CPU uint64 `json:"cpu"` + Memory uint64 `json:"memory"` + NetSoftnet bool `json:"net-softnet"` + NetBridged string `json:"net-bridged"` + Headless bool `json:"headless"` // Status field is used to track the lifecycle of the VM associated with this resource. Status VMStatus `json:"status"` @@ -78,6 +79,10 @@ func (vm VM) TerminalState() bool { type VMStatus string +func (vmStatus VMStatus) String() string { + return string(vmStatus) +} + const ( // VMStatusPending is set by the Controller for all newly-created VM resources. VMStatusPending VMStatus = "pending"