diff --git a/go.mod b/go.mod index 6cfe756..7dc66d5 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/google/go-containerregistry v0.20.7 github.com/gorilla/websocket v1.5.3 github.com/itchyny/json2yaml v0.1.4 - github.com/kernel/hypeman-go v0.15.0 + github.com/kernel/hypeman-go v0.16.1-0.20260323172303-508a8c69feb3 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/file v1.2.1 diff --git a/go.sum b/go.sum index a49e49f..cf9eb56 100644 --- a/go.sum +++ b/go.sum @@ -76,8 +76,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnV github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8= github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI= -github.com/kernel/hypeman-go v0.15.0 h1:igcd8tArES1FMbuzEGjQ9HqPF3Zwi6yAvHoE4dkKx4Y= -github.com/kernel/hypeman-go v0.15.0/go.mod h1:guRrhyP9QW/ebUS1UcZ0uZLLJeGAAhDNzSi68U4M9hI= +github.com/kernel/hypeman-go v0.16.1-0.20260323172303-508a8c69feb3 h1:g6qT9G/Qrxqqdl9gjqTnhDAHlePxV68OyQjlqXA6WX4= +github.com/kernel/hypeman-go v0.16.1-0.20260323172303-508a8c69feb3/go.mod h1:guRrhyP9QW/ebUS1UcZ0uZLLJeGAAhDNzSi68U4M9hI= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index a4c8c88..bb8947b 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -85,6 +85,10 @@ Examples: Name: "memory", Usage: "Memory limit for builder VM in MB (default 2048)", }, + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Set build tag key-value pair (KEY=VALUE, can be repeated)", + }, }, Commands: []*cli.Command{ &buildListCmd, @@ -186,6 +190,17 @@ func handleBuild(ctx context.Context, cmd *cli.Command) error { if cmd.IsSet("memory") { params.MemoryMB = hypeman.Opt(int64(cmd.Int("memory"))) } + tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed) + } + if len(tags) > 0 { + tagsJSON, err := marshalStringMap(tags) + if err != nil { + return fmt.Errorf("failed to encode tags: %w", err) + } + params.Tags = hypeman.Opt(tagsJSON) + } // Start build build, err := client.Builds.New(ctx, params, opts...) @@ -359,24 +374,28 @@ var buildListCmd = cli.Command{ Aliases: []string{"q"}, Usage: "Only display build IDs", }, + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Filter by tag key-value pair (KEY=VALUE, can be repeated)", + }, }, Action: handleBuildList, HideHelpCommand: true, } var buildGetCmd = cli.Command{ - Name: "get", - Usage: "Get build details", - ArgsUsage: "", - Action: handleBuildGet, + Name: "get", + Usage: "Get build details", + ArgsUsage: "", + Action: handleBuildGet, HideHelpCommand: true, } var buildCancelCmd = cli.Command{ - Name: "cancel", - Usage: "Cancel a build", - ArgsUsage: "", - Action: handleBuildCancel, + Name: "cancel", + Usage: "Cancel a build", + ArgsUsage: "", + Action: handleBuildCancel, HideHelpCommand: true, } @@ -390,11 +409,19 @@ func handleBuildList(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") transform := cmd.Root().String("transform") + params := hypeman.BuildListParams{} + tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag filter: %s\n", malformed) + } + if len(tags) > 0 { + params.Tags = tags + } if format != "auto" { var res []byte opts = append(opts, option.WithResponseBodyInto(&res)) - _, err := client.Builds.List(ctx, opts...) + _, err := client.Builds.List(ctx, params, opts...) if err != nil { return err } @@ -402,7 +429,7 @@ func handleBuildList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(os.Stdout, "build list", obj, format, transform) } - builds, err := client.Builds.List(ctx, opts...) + builds, err := client.Builds.List(ctx, params, opts...) if err != nil { return err } diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 693dfc8..fb2ddff 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -75,6 +75,8 @@ func init() { &pushCmd, &runCmd, &psCmd, + &statsCmd, + &updateCmd, &inspectCmd, &logsCmd, &rmCmd, @@ -85,6 +87,7 @@ func init() { &forkCmd, &imageCmd, &ingressCmd, + &snapshotCmd, &volumeCmd, &resourcesCmd, &deviceCmd, diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index e50b2e0..efb84df 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "fmt" "io" "log" @@ -38,6 +39,30 @@ func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { return opts } +func parseKeyValueSpecs(specs []string) (map[string]string, []string) { + values := make(map[string]string) + var malformed []string + + for _, spec := range specs { + parts := strings.SplitN(spec, "=", 2) + if len(parts) != 2 || parts[0] == "" { + malformed = append(malformed, spec) + continue + } + values[parts[0]] = parts[1] + } + + return values, malformed +} + +func marshalStringMap(input map[string]string) (string, error) { + payload, err := json.Marshal(input) + if err != nil { + return "", err + } + return string(payload), nil +} + var debugMiddlewareOption = option.WithMiddleware( func(r *http.Request, mn option.MiddlewareNext) (*http.Response, error) { logger := log.Default() diff --git a/pkg/cmd/cp.go b/pkg/cmd/cp.go index 43a653b..c8f18a6 100644 --- a/pkg/cmd/cp.go +++ b/pkg/cmd/cp.go @@ -535,7 +535,6 @@ func copyDirContentsToInstance(ctx context.Context, baseURL, apiKey, instanceID, return nil } - // createDirOnInstanceWithUidGid creates a directory on the instance with explicit uid/gid func createDirOnInstanceWithUidGid(ctx context.Context, baseURL, apiKey, instanceID, dstPath string, mode fs.FileMode, uid, gid uint32) error { wsURL, err := buildCpWsURL(baseURL, instanceID) @@ -980,5 +979,3 @@ func copyFromInstanceToStdout(ctx context.Context, baseURL, apiKey, instanceID, } return nil } - - diff --git a/pkg/cmd/devicecmd.go b/pkg/cmd/devicecmd.go index 5b102de..338ce9f 100644 --- a/pkg/cmd/devicecmd.go +++ b/pkg/cmd/devicecmd.go @@ -77,14 +77,24 @@ Examples: Name: "name", Usage: "Optional name for the device (auto-generated if not provided)", }, + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Set device tag key-value pair (KEY=VALUE, can be repeated)", + }, }, Action: handleDeviceRegister, HideHelpCommand: true, } var deviceListCmd = cli.Command{ - Name: "list", - Usage: "List registered devices", + Name: "list", + Usage: "List registered devices", + Flags: []cli.Flag{ + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Filter by tag key-value pair (KEY=VALUE, can be repeated)", + }, + }, Action: handleDeviceList, HideHelpCommand: true, } @@ -208,6 +218,13 @@ func handleDeviceRegister(ctx context.Context, cmd *cli.Command) error { if name := cmd.String("name"); name != "" { params.Name = hypeman.Opt(name) } + tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed) + } + if len(tags) > 0 { + params.Tags = tags + } var opts []option.RequestOption if cmd.Root().Bool("debug") { @@ -244,7 +261,15 @@ func handleDeviceList(ctx context.Context, cmd *cli.Command) error { var res []byte opts = append(opts, option.WithResponseBodyInto(&res)) - _, err := client.Devices.List(ctx, opts...) + params := hypeman.DeviceListParams{} + tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag filter: %s\n", malformed) + } + if len(tags) > 0 { + params.Tags = tags + } + _, err := client.Devices.List(ctx, params, opts...) if err != nil { return err } diff --git a/pkg/cmd/format.go b/pkg/cmd/format.go index 9716caa..9d74e4d 100644 --- a/pkg/cmd/format.go +++ b/pkg/cmd/format.go @@ -18,7 +18,7 @@ import ( type TableWriter struct { w io.Writer headers []string - widths []int // natural widths (max of header and cell values) + widths []int // natural widths (max of header and cell values) rows [][]string // TruncOrder specifies column indices in truncation priority order. @@ -297,4 +297,3 @@ func ResolveInstance(ctx context.Context, client *hypeman.Client, identifier str return "", fmt.Errorf("ambiguous instance identifier %q matches: %s", identifier, strings.Join(ids, ", ")) } } - diff --git a/pkg/cmd/imagecmd.go b/pkg/cmd/imagecmd.go index c178ef7..ffd57bf 100644 --- a/pkg/cmd/imagecmd.go +++ b/pkg/cmd/imagecmd.go @@ -16,6 +16,7 @@ var imageCmd = cli.Command{ Name: "image", Usage: "Manage images", Commands: []*cli.Command{ + &imageCreateCmd, &imageListCmd, &imageGetCmd, &imageDeleteCmd, @@ -23,6 +24,20 @@ var imageCmd = cli.Command{ HideHelpCommand: true, } +var imageCreateCmd = cli.Command{ + Name: "create", + Usage: "Pull and convert an OCI image", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Set image tag key-value pair (KEY=VALUE, can be repeated)", + }, + }, + Action: handleImageCreate, + HideHelpCommand: true, +} + var imageListCmd = cli.Command{ Name: "list", Usage: "List images", @@ -32,25 +47,29 @@ var imageListCmd = cli.Command{ Aliases: []string{"q"}, Usage: "Only display image names", }, + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Filter by tag key-value pair (KEY=VALUE, can be repeated)", + }, }, Action: handleImageList, HideHelpCommand: true, } var imageGetCmd = cli.Command{ - Name: "get", - Usage: "Get image details", - ArgsUsage: "", - Action: handleImageGet, + Name: "get", + Usage: "Get image details", + ArgsUsage: "", + Action: handleImageGet, HideHelpCommand: true, } var imageDeleteCmd = cli.Command{ - Name: "delete", - Aliases: []string{"rm"}, - Usage: "Delete an image", - ArgsUsage: "", - Action: handleImageDelete, + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete an image", + ArgsUsage: "", + Action: handleImageDelete, HideHelpCommand: true, } @@ -64,11 +83,19 @@ func handleImageList(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") transform := cmd.Root().String("transform") + params := hypeman.ImageListParams{} + tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag filter: %s\n", malformed) + } + if len(tags) > 0 { + params.Tags = tags + } if format != "auto" { var res []byte opts = append(opts, option.WithResponseBodyInto(&res)) - _, err := client.Images.List(ctx, opts...) + _, err := client.Images.List(ctx, params, opts...) if err != nil { return err } @@ -76,7 +103,7 @@ func handleImageList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(os.Stdout, "image list", obj, format, transform) } - images, err := client.Images.List(ctx, opts...) + images, err := client.Images.List(ctx, params, opts...) if err != nil { return err } @@ -121,6 +148,53 @@ func handleImageList(ctx context.Context, cmd *cli.Command) error { return nil } +func handleImageCreate(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("image name required\nUsage: hypeman image create ") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + params := hypeman.ImageNewParams{ + Name: args[0], + } + + tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed) + } + if len(tags) > 0 { + params.Tags = tags + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + if format != "auto" { + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Images.New(ctx, params, opts...) + if err != nil { + return err + } + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "image create", obj, format, transform) + } + + result, err := client.Images.New(ctx, params, opts...) + if err != nil { + return err + } + fmt.Println(result.Name) + return nil +} + func handleImageGet(ctx context.Context, cmd *cli.Command) error { args := cmd.Args().Slice() if len(args) < 1 { diff --git a/pkg/cmd/ingresscmd.go b/pkg/cmd/ingresscmd.go index 6da8a52..bec07a2 100644 --- a/pkg/cmd/ingresscmd.go +++ b/pkg/cmd/ingresscmd.go @@ -58,6 +58,10 @@ var ingressCreateCmd = cli.Command{ Name: "name", Usage: "Ingress name (auto-generated from hostname if not provided)", }, + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Set ingress tag key-value pair (KEY=VALUE, can be repeated)", + }, }, Action: handleIngressCreate, HideHelpCommand: true, @@ -72,16 +76,20 @@ var ingressListCmd = cli.Command{ Aliases: []string{"q"}, Usage: "Only display ingress IDs", }, + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Filter by tag key-value pair (KEY=VALUE, can be repeated)", + }, }, Action: handleIngressList, HideHelpCommand: true, } var ingressGetCmd = cli.Command{ - Name: "get", - Usage: "Get ingress details", - ArgsUsage: "", - Action: handleIngressGet, + Name: "get", + Usage: "Get ingress details", + ArgsUsage: "", + Action: handleIngressGet, HideHelpCommand: true, } @@ -136,6 +144,13 @@ func handleIngressCreate(ctx context.Context, cmd *cli.Command) error { }, }, } + tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed) + } + if len(tags) > 0 { + params.Tags = tags + } fmt.Fprintf(os.Stderr, "Creating ingress %s...\n", name) @@ -158,12 +173,20 @@ func handleIngressList(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") transform := cmd.Root().String("transform") + params := hypeman.IngressListParams{} + tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag filter: %s\n", malformed) + } + if len(tags) > 0 { + params.Tags = tags + } // If a specific format is requested (not "auto"), output in that format if format != "auto" { var res []byte opts = append(opts, option.WithResponseBodyInto(&res)) - _, err := client.Ingresses.List(ctx, opts...) + _, err := client.Ingresses.List(ctx, params, opts...) if err != nil { return err } @@ -171,7 +194,7 @@ func handleIngressList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(os.Stdout, "ingress list", obj, format, transform) } - ingresses, err := client.Ingresses.List(ctx, opts...) + ingresses, err := client.Ingresses.List(ctx, params, opts...) if err != nil { return err } diff --git a/pkg/cmd/lifecycle.go b/pkg/cmd/lifecycle.go index cc51159..91b229b 100644 --- a/pkg/cmd/lifecycle.go +++ b/pkg/cmd/lifecycle.go @@ -7,6 +7,7 @@ import ( "github.com/kernel/hypeman-go" "github.com/kernel/hypeman-go/option" + "github.com/kernel/hypeman-go/shared" "github.com/urfave/cli/v3" ) @@ -37,9 +38,23 @@ var startCmd = cli.Command{ } var standbyCmd = cli.Command{ - Name: "standby", - Usage: "Put an instance into standby (pause and snapshot)", - ArgsUsage: "", + Name: "standby", + Usage: "Put an instance into standby (pause and snapshot)", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "compression-enabled", + Usage: "Enable memory compression for this standby operation", + }, + &cli.StringFlag{ + Name: "compression-algorithm", + Usage: `Compression algorithm: "zstd" or "lz4"`, + }, + &cli.IntFlag{ + Name: "compression-level", + Usage: "Compression level (zstd: 1-19, lz4: 0-9)", + }, + }, Action: handleStandby, HideHelpCommand: true, } @@ -137,9 +152,30 @@ func handleStandby(ctx context.Context, cmd *cli.Command) error { opts = append(opts, debugMiddlewareOption) } + params := hypeman.InstanceStandbyParams{} + if cmd.IsSet("compression-enabled") || cmd.IsSet("compression-algorithm") || cmd.IsSet("compression-level") { + compression := shared.SnapshotCompressionConfigParam{ + Enabled: cmd.Bool("compression-enabled"), + } + if !cmd.IsSet("compression-enabled") { + compression.Enabled = true + } + if cmd.IsSet("compression-level") { + compression.Level = hypeman.Opt(int64(cmd.Int("compression-level"))) + } + if algorithm := cmd.String("compression-algorithm"); algorithm != "" { + parsedAlgorithm, err := parseSnapshotCompressionAlgorithm(algorithm) + if err != nil { + return err + } + compression.Algorithm = parsedAlgorithm + } + params.Compression = compression + } + fmt.Fprintf(os.Stderr, "Putting %s into standby...\n", args[0]) - instance, err := client.Instances.Standby(ctx, instanceID, opts...) + instance, err := client.Instances.Standby(ctx, instanceID, params, opts...) if err != nil { return err } diff --git a/pkg/cmd/ps.go b/pkg/cmd/ps.go index 739d6fb..82811d0 100644 --- a/pkg/cmd/ps.go +++ b/pkg/cmd/ps.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "strings" "github.com/kernel/hypeman-go" "github.com/kernel/hypeman-go/option" @@ -30,8 +29,9 @@ var psCmd = cli.Command{ Usage: "Filter instances by state (e.g., Running, Stopped, Standby)", }, &cli.StringSliceFlag{ - Name: "metadata", - Usage: "Filter by metadata key-value pair (KEY=VALUE, can be repeated)", + Name: "tag", + Aliases: []string{"metadata"}, + Usage: "Filter by tag key-value pair (KEY=VALUE, can be repeated)", }, }, Action: handlePs, @@ -52,12 +52,12 @@ func handlePs(ctx context.Context, cmd *cli.Command) error { params.State = hypeman.InstanceListParamsState(stateFilter) } - metadataFilters, malformedMetadata := parseMetadataFilters(cmd.StringSlice("metadata")) - for _, malformed := range malformedMetadata { - fmt.Fprintf(os.Stderr, "Warning: ignoring malformed metadata filter: %s\n", malformed) + tagFilters, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag filter: %s\n", malformed) } - if len(metadataFilters) > 0 { - params.Metadata = metadataFilters + if len(tagFilters) > 0 { + params.Tags = tagFilters } instances, err := client.Instances.List( @@ -71,7 +71,7 @@ func handlePs(ctx context.Context, cmd *cli.Command) error { showAll := cmd.Bool("all") quietMode := cmd.Bool("quiet") - serverSideFilterActive := stateFilter != "" || len(metadataFilters) > 0 + serverSideFilterActive := stateFilter != "" || len(tagFilters) > 0 // Filter instances client-side only when no server-side filter is active var filtered []hypeman.Instance @@ -146,19 +146,3 @@ func formatHypervisor(hv hypeman.InstanceHypervisor) string { return string(hv) } } - -func parseMetadataFilters(specs []string) (map[string]string, []string) { - metadata := make(map[string]string) - var malformed []string - - for _, spec := range specs { - parts := strings.SplitN(spec, "=", 2) - if len(parts) != 2 || parts[0] == "" { - malformed = append(malformed, spec) - continue - } - metadata[parts[0]] = parts[1] - } - - return metadata, malformed -} diff --git a/pkg/cmd/ps_test.go b/pkg/cmd/ps_test.go index 65b8509..29cbdba 100644 --- a/pkg/cmd/ps_test.go +++ b/pkg/cmd/ps_test.go @@ -85,9 +85,9 @@ func TestFormatHypervisor(t *testing.T) { } } -func TestParseMetadataFilters(t *testing.T) { +func TestParseKeyValueSpecs(t *testing.T) { t.Run("parses valid entries", func(t *testing.T) { - metadata, malformed := parseMetadataFilters([]string{ + values, malformed := parseKeyValueSpecs([]string{ "team=backend", "env=staging", }) @@ -96,11 +96,11 @@ func TestParseMetadataFilters(t *testing.T) { assert.Equal(t, map[string]string{ "team": "backend", "env": "staging", - }, metadata) + }, values) }) - t.Run("returns malformed entries and only valid metadata", func(t *testing.T) { - metadata, malformed := parseMetadataFilters([]string{ + t.Run("returns malformed entries and only valid values", func(t *testing.T) { + values, malformed := parseKeyValueSpecs([]string{ "team=backend", "missing-delimiter", "=empty-key", @@ -110,7 +110,7 @@ func TestParseMetadataFilters(t *testing.T) { assert.Equal(t, map[string]string{ "team": "backend", "region": "us-east-1", - }, metadata) + }, values) assert.Equal(t, []string{ "missing-delimiter", "=empty-key", diff --git a/pkg/cmd/pull.go b/pkg/cmd/pull.go index cf5c686..212c420 100644 --- a/pkg/cmd/pull.go +++ b/pkg/cmd/pull.go @@ -11,10 +11,10 @@ import ( ) var pullCmd = cli.Command{ - Name: "pull", - Usage: "Pull an image from a registry", - ArgsUsage: "", - Action: handlePull, + Name: "pull", + Usage: "Pull an image from a registry", + ArgsUsage: "", + Action: handlePull, HideHelpCommand: true, } @@ -56,4 +56,3 @@ func handlePull(ctx context.Context, cmd *cli.Command) error { return nil } - diff --git a/pkg/cmd/resourcecmd.go b/pkg/cmd/resourcecmd.go index de2cce4..ffa9baf 100644 --- a/pkg/cmd/resourcecmd.go +++ b/pkg/cmd/resourcecmd.go @@ -29,10 +29,39 @@ Examples: # Show only GPU information hypeman resources --transform gpu`, + Commands: []*cli.Command{ + &resourcesReclaimMemoryCmd, + }, Action: handleResources, HideHelpCommand: true, } +var resourcesReclaimMemoryCmd = cli.Command{ + Name: "reclaim-memory", + Usage: "Request guest memory reclaim from reclaim-eligible instances", + Flags: []cli.Flag{ + &cli.Int64Flag{ + Name: "reclaim-bytes", + Usage: "Total bytes of guest memory to reclaim across eligible VMs", + Required: true, + }, + &cli.BoolFlag{ + Name: "dry-run", + Usage: "Compute reclaim plan without applying balloon changes", + }, + &cli.StringFlag{ + Name: "hold-for", + Usage: `Duration to keep reclaim hold active (e.g., "5m", "30s")`, + }, + &cli.StringFlag{ + Name: "reason", + Usage: "Operator-provided reason attached to logs and traces", + }, + }, + Action: handleResourcesReclaimMemory, + HideHelpCommand: true, +} + func handleResources(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) @@ -61,6 +90,59 @@ func handleResources(ctx context.Context, cmd *cli.Command) error { return ShowJSON(os.Stdout, "resources", obj, format, transform) } +func handleResourcesReclaimMemory(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + reclaimBytes := cmd.Int64("reclaim-bytes") + if reclaimBytes <= 0 { + return fmt.Errorf("reclaim-bytes must be greater than 0") + } + + request := hypeman.MemoryReclaimRequestParam{ + ReclaimBytes: reclaimBytes, + } + if cmd.IsSet("dry-run") { + request.DryRun = hypeman.Opt(cmd.Bool("dry-run")) + } + if holdFor := cmd.String("hold-for"); holdFor != "" { + request.HoldFor = hypeman.Opt(holdFor) + } + if reason := cmd.String("reason"); reason != "" { + request.Reason = hypeman.Opt(reason) + } + + params := hypeman.ResourceReclaimMemoryParams{ + MemoryReclaimRequest: request, + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Resources.ReclaimMemory(ctx, params, opts...) + if err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + obj := gjson.ParseBytes(res) + + if format == "auto" || format == "" { + fmt.Printf("Requested reclaim: %d bytes\n", obj.Get("requested_reclaim_bytes").Int()) + fmt.Printf("Planned reclaim: %d bytes\n", obj.Get("planned_reclaim_bytes").Int()) + fmt.Printf("Applied reclaim: %d bytes\n", obj.Get("applied_reclaim_bytes").Int()) + fmt.Printf("Host pressure: %s\n", obj.Get("host_pressure_state").String()) + fmt.Printf("Actions: %d\n", len(obj.Get("actions").Array())) + return nil + } + + return ShowJSON(os.Stdout, "resources reclaim-memory", obj, format, transform) +} + func showResourcesTable(data []byte) error { obj := gjson.ParseBytes(data) diff --git a/pkg/cmd/rm.go b/pkg/cmd/rm.go index 3066978..68b1ee9 100644 --- a/pkg/cmd/rm.go +++ b/pkg/cmd/rm.go @@ -120,4 +120,3 @@ func handleRm(ctx context.Context, cmd *cli.Command) error { return lastErr } - diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go index 2ebce80..96cc6e3 100644 --- a/pkg/cmd/run.go +++ b/pkg/cmd/run.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "net/url" "os" @@ -10,6 +11,7 @@ import ( "github.com/kernel/hypeman-go" "github.com/kernel/hypeman-go/option" + "github.com/kernel/hypeman-go/shared" "github.com/urfave/cli/v3" ) @@ -47,6 +49,10 @@ Examples: Aliases: []string{"e"}, Usage: "Set environment variable (KEY=VALUE, can be repeated)", }, + &cli.StringFlag{ + Name: "credentials-json", + Usage: "Credential policy map as JSON (keyed by guest-visible env var)", + }, &cli.StringFlag{ Name: "memory", Usage: `Base memory size (e.g., "1GB", "512MB")`, @@ -99,6 +105,14 @@ Examples: Name: "bandwidth-up", Usage: `Upload bandwidth limit (e.g., "1Gbps", "125MB/s")`, }, + &cli.BoolFlag{ + Name: "network-egress-enabled", + Usage: "Enable host-mediated outbound egress policy", + }, + &cli.StringFlag{ + Name: "network-egress-mode", + Usage: `Egress enforcement mode: "all" or "http_https_only"`, + }, // Boot option flags &cli.BoolFlag{ Name: "skip-guest-agent", @@ -108,6 +122,18 @@ Examples: Name: "skip-kernel-headers", Usage: "Skip kernel headers installation during boot for faster startup (DKMS will not work)", }, + &cli.BoolFlag{ + Name: "snapshot-compression-enabled", + Usage: "Enable snapshot memory compression for this instance policy", + }, + &cli.StringFlag{ + Name: "snapshot-compression-algorithm", + Usage: `Snapshot compression algorithm: "zstd" or "lz4"`, + }, + &cli.IntFlag{ + Name: "snapshot-compression-level", + Usage: "Snapshot compression level (zstd: 1-19, lz4: 0-9)", + }, // Entrypoint and CMD overrides &cli.StringSliceFlag{ Name: "entrypoint", @@ -119,9 +145,9 @@ Examples: }, // Metadata flags &cli.StringSliceFlag{ - Name: "metadata", - Aliases: []string{"l"}, - Usage: "Set metadata key-value pair (KEY=VALUE, can be repeated)", + Name: "tag", + Aliases: []string{"metadata", "l"}, + Usage: "Set tag key-value pair (KEY=VALUE, can be repeated)", }, // Volume mount flags &cli.StringSliceFlag{ @@ -198,13 +224,22 @@ func handleRun(ctx context.Context, cmd *cli.Command) error { if len(env) > 0 { params.Env = env } + if rawCredentials := cmd.String("credentials-json"); rawCredentials != "" { + credentials := map[string]hypeman.InstanceNewParamsCredential{} + if err := json.Unmarshal([]byte(rawCredentials), &credentials); err != nil { + return fmt.Errorf("invalid credentials-json: %w", err) + } + params.Credentials = credentials + } // Network configuration networkEnabled := cmd.Bool("network") bandwidthDown := cmd.String("bandwidth-down") bandwidthUp := cmd.String("bandwidth-up") + egressEnabledSet := cmd.IsSet("network-egress-enabled") + egressMode := cmd.String("network-egress-mode") - if !networkEnabled || bandwidthDown != "" || bandwidthUp != "" { + if !networkEnabled || bandwidthDown != "" || bandwidthUp != "" || egressEnabledSet || egressMode != "" { params.Network = hypeman.InstanceNewParamsNetwork{ Enabled: hypeman.Opt(networkEnabled), } @@ -214,6 +249,13 @@ func handleRun(ctx context.Context, cmd *cli.Command) error { if bandwidthUp != "" { params.Network.BandwidthUpload = hypeman.Opt(bandwidthUp) } + if egressEnabledSet || egressMode != "" { + egress, err := buildNetworkEgress(cmd.Bool("network-egress-enabled"), egressEnabledSet, egressMode) + if err != nil { + return err + } + params.Network.Egress = egress + } } // GPU configuration @@ -269,19 +311,39 @@ func handleRun(ctx context.Context, cmd *cli.Command) error { params.Cmd = cmdArgs } - // Metadata - metadataSpecs := cmd.StringSlice("metadata") - if len(metadataSpecs) > 0 { - metadata := make(map[string]string) - for _, m := range metadataSpecs { - parts := strings.SplitN(m, "=", 2) - if len(parts) == 2 { - metadata[parts[0]] = parts[1] - } else { - fmt.Fprintf(os.Stderr, "Warning: ignoring malformed metadata: %s\n", m) + // Tags + tagSpecs := cmd.StringSlice("tag") + if len(tagSpecs) > 0 { + tags, malformed := parseKeyValueSpecs(tagSpecs) + for _, invalid := range malformed { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", invalid) + } + if len(tags) > 0 { + params.Tags = tags + } + } + + // Snapshot policy compression + if cmd.IsSet("snapshot-compression-enabled") || cmd.IsSet("snapshot-compression-algorithm") || cmd.IsSet("snapshot-compression-level") { + compression := shared.SnapshotCompressionConfigParam{ + Enabled: cmd.Bool("snapshot-compression-enabled"), + } + if !cmd.IsSet("snapshot-compression-enabled") { + compression.Enabled = true + } + if cmd.IsSet("snapshot-compression-level") { + compression.Level = hypeman.Opt(int64(cmd.Int("snapshot-compression-level"))) + } + if algorithm := cmd.String("snapshot-compression-algorithm"); algorithm != "" { + parsedAlgorithm, err := parseSnapshotCompressionAlgorithm(algorithm) + if err != nil { + return fmt.Errorf("invalid snapshot compression algorithm: %w", err) } + compression.Algorithm = parsedAlgorithm + } + params.SnapshotPolicy = hypeman.SnapshotPolicyParam{ + Compression: compression, } - params.Metadata = metadata } // Volume mounts @@ -320,6 +382,28 @@ func handleRun(ctx context.Context, cmd *cli.Command) error { return nil } +func buildNetworkEgress(enabled bool, enabledSet bool, mode string) (hypeman.InstanceNewParamsNetworkEgress, error) { + egress := hypeman.InstanceNewParamsNetworkEgress{} + if enabledSet { + egress.Enabled = hypeman.Opt(enabled) + } else if mode != "" { + egress.Enabled = hypeman.Opt(true) + } + + if mode != "" { + switch mode { + case "all", "http_https_only": + egress.Enforcement = hypeman.InstanceNewParamsNetworkEgressEnforcement{ + Mode: mode, + } + default: + return hypeman.InstanceNewParamsNetworkEgress{}, fmt.Errorf("invalid network-egress-mode: %s (must be 'all' or 'http_https_only')", mode) + } + } + + return egress, nil +} + // isNotFoundError checks if err is a 404 not found error func isNotFoundError(err error, target **hypeman.Error) bool { if apiErr, ok := err.(*hypeman.Error); ok { diff --git a/pkg/cmd/run_test.go b/pkg/cmd/run_test.go new file mode 100644 index 0000000..8aea90c --- /dev/null +++ b/pkg/cmd/run_test.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildNetworkEgress(t *testing.T) { + t.Run("defaults enabled to true when mode is set", func(t *testing.T) { + egress, err := buildNetworkEgress(false, false, "all") + require.NoError(t, err) + require.True(t, egress.Enabled.Valid()) + assert.True(t, egress.Enabled.Value) + assert.Equal(t, "all", egress.Enforcement.Mode) + }) + + t.Run("honors explicit disabled flag when mode is set", func(t *testing.T) { + egress, err := buildNetworkEgress(false, true, "http_https_only") + require.NoError(t, err) + require.True(t, egress.Enabled.Valid()) + assert.False(t, egress.Enabled.Value) + assert.Equal(t, "http_https_only", egress.Enforcement.Mode) + }) + + t.Run("rejects unsupported modes", func(t *testing.T) { + _, err := buildNetworkEgress(false, false, "smtp_only") + require.EqualError(t, err, "invalid network-egress-mode: smtp_only (must be 'all' or 'http_https_only')") + }) +} diff --git a/pkg/cmd/snapshotcmd.go b/pkg/cmd/snapshotcmd.go new file mode 100644 index 0000000..c6634d5 --- /dev/null +++ b/pkg/cmd/snapshotcmd.go @@ -0,0 +1,542 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/kernel/hypeman-go/shared" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var snapshotCmd = cli.Command{ + Name: "snapshot", + Usage: "Manage instance snapshots", + Commands: []*cli.Command{ + &snapshotCreateCmd, + &snapshotRestoreCmd, + &snapshotListCmd, + &snapshotGetCmd, + &snapshotDeleteCmd, + &snapshotForkCmd, + }, + HideHelpCommand: true, +} + +var snapshotCreateCmd = cli.Command{ + Name: "create", + Usage: "Create a snapshot for an instance", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "kind", + Usage: `Snapshot kind: "Standby" or "Stopped" (default: Standby)`, + }, + &cli.StringFlag{ + Name: "name", + Usage: "Optional snapshot name", + }, + &cli.BoolFlag{ + Name: "compression-enabled", + Usage: "Enable snapshot memory compression", + }, + &cli.StringFlag{ + Name: "compression-algorithm", + Usage: `Snapshot compression algorithm: "zstd" or "lz4"`, + }, + &cli.IntFlag{ + Name: "compression-level", + Usage: "Snapshot compression level (zstd: 1-19, lz4: 0-9)", + }, + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Set snapshot tag key-value pair (KEY=VALUE, can be repeated)", + }, + }, + Action: handleSnapshotCreate, + HideHelpCommand: true, +} + +var snapshotRestoreCmd = cli.Command{ + Name: "restore", + Usage: "Restore an instance from a snapshot", + ArgsUsage: " ", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "target-hypervisor", + Usage: `Optional hypervisor override: "cloud-hypervisor", "firecracker", "qemu", or "vz"`, + }, + &cli.StringFlag{ + Name: "target-state", + Usage: `Optional final state: "Stopped", "Standby", or "Running"`, + }, + }, + Action: handleSnapshotRestore, + HideHelpCommand: true, +} + +var snapshotListCmd = cli.Command{ + Name: "list", + Usage: "List snapshots", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "quiet", + Aliases: []string{"q"}, + Usage: "Only display snapshot IDs", + }, + &cli.StringFlag{ + Name: "name", + Usage: "Filter snapshots by snapshot name", + }, + &cli.StringFlag{ + Name: "source-instance-id", + Usage: "Filter snapshots by source instance ID", + }, + &cli.StringFlag{ + Name: "kind", + Usage: `Filter by kind: "Standby" or "Stopped"`, + }, + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Filter by tag key-value pair (KEY=VALUE, can be repeated)", + }, + }, + Action: handleSnapshotList, + HideHelpCommand: true, +} + +var snapshotGetCmd = cli.Command{ + Name: "get", + Usage: "Get snapshot details", + ArgsUsage: "", + Action: handleSnapshotGet, + HideHelpCommand: true, +} + +var snapshotDeleteCmd = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete a snapshot", + ArgsUsage: "", + Action: handleSnapshotDelete, + HideHelpCommand: true, +} + +var snapshotForkCmd = cli.Command{ + Name: "fork", + Usage: "Fork a new instance from a snapshot", + ArgsUsage: " ", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "target-hypervisor", + Usage: `Optional hypervisor override: "cloud-hypervisor", "firecracker", "qemu", or "vz"`, + }, + &cli.StringFlag{ + Name: "target-state", + Usage: `Optional final state: "Stopped", "Standby", or "Running"`, + }, + }, + Action: handleSnapshotFork, + HideHelpCommand: true, +} + +func handleSnapshotCreate(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance ID or name required\nUsage: hypeman snapshot create ") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + kind, err := parseSnapshotKind(cmd.String("kind"), hypeman.SnapshotKindStandby) + if err != nil { + return err + } + + params := hypeman.InstanceSnapshotNewParams{ + Kind: kind, + } + if name := cmd.String("name"); name != "" { + params.Name = hypeman.Opt(name) + } + + tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed) + } + if len(tags) > 0 { + params.Tags = tags + } + + if cmd.IsSet("compression-enabled") || cmd.IsSet("compression-algorithm") || cmd.IsSet("compression-level") { + compression := shared.SnapshotCompressionConfigParam{ + Enabled: cmd.Bool("compression-enabled"), + } + if !cmd.IsSet("compression-enabled") { + compression.Enabled = true + } + if cmd.IsSet("compression-level") { + compression.Level = hypeman.Opt(int64(cmd.Int("compression-level"))) + } + if algorithm := cmd.String("compression-algorithm"); algorithm != "" { + parsedAlgorithm, err := parseSnapshotCompressionAlgorithm(algorithm) + if err != nil { + return err + } + compression.Algorithm = parsedAlgorithm + } + params.Compression = compression + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + if format != "auto" { + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Instances.Snapshots.New(ctx, instanceID, params, opts...) + if err != nil { + return err + } + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "snapshot create", obj, format, transform) + } + + snapshot, err := client.Instances.Snapshots.New(ctx, instanceID, params, opts...) + if err != nil { + return err + } + fmt.Println(snapshot.ID) + return nil +} + +func handleSnapshotRestore(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 2 { + return fmt.Errorf("instance and snapshot ID required\nUsage: hypeman snapshot restore ") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + snapshotID := args[1] + + params := hypeman.InstanceSnapshotRestoreParams{ + ID: instanceID, + } + if value := cmd.String("target-hypervisor"); value != "" { + hypervisor, err := parseSnapshotTargetHypervisor(value) + if err != nil { + return err + } + params.TargetHypervisor = hypervisor + } + if value := cmd.String("target-state"); value != "" { + state, err := parseSnapshotTargetState(value) + if err != nil { + return err + } + params.TargetState = state + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + if format != "auto" { + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Instances.Snapshots.Restore(ctx, snapshotID, params, opts...) + if err != nil { + return err + } + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "snapshot restore", obj, format, transform) + } + + instance, err := client.Instances.Snapshots.Restore(ctx, snapshotID, params, opts...) + if err != nil { + return err + } + fmt.Println(instance.ID) + return nil +} + +func handleSnapshotList(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + params := hypeman.SnapshotListParams{} + if name := cmd.String("name"); name != "" { + params.Name = hypeman.Opt(name) + } + if sourceInstanceID := cmd.String("source-instance-id"); sourceInstanceID != "" { + params.SourceInstanceID = hypeman.Opt(sourceInstanceID) + } + if kindInput := cmd.String("kind"); kindInput != "" { + kind, err := parseSnapshotKind(kindInput, "") + if err != nil { + return err + } + params.Kind = kind + } + tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag filter: %s\n", malformed) + } + if len(tags) > 0 { + params.Tags = tags + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + if format != "auto" { + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Snapshots.List(ctx, params, opts...) + if err != nil { + return err + } + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "snapshot list", obj, format, transform) + } + + snapshots, err := client.Snapshots.List(ctx, params, opts...) + if err != nil { + return err + } + + if cmd.Bool("quiet") { + for _, snapshot := range *snapshots { + fmt.Println(snapshot.ID) + } + return nil + } + + if len(*snapshots) == 0 { + fmt.Fprintln(os.Stderr, "No snapshots found.") + return nil + } + + table := NewTableWriter(os.Stdout, "ID", "NAME", "KIND", "SOURCE", "CREATED") + table.TruncOrder = []int{0, 3} + for _, snapshot := range *snapshots { + name := snapshot.Name + if name == "" { + name = "-" + } + table.AddRow( + TruncateID(snapshot.ID), + name, + string(snapshot.Kind), + snapshot.SourceInstanceName, + FormatTimeAgo(snapshot.CreatedAt), + ) + } + table.Render() + return nil +} + +func handleSnapshotGet(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("snapshot ID required\nUsage: hypeman snapshot get ") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Snapshots.Get(ctx, args[0], opts...) + if err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "snapshot get", obj, format, transform) +} + +func handleSnapshotDelete(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("snapshot ID required\nUsage: hypeman snapshot delete ") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + if err := client.Snapshots.Delete(ctx, args[0], opts...); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Deleted snapshot %s\n", args[0]) + return nil +} + +func handleSnapshotFork(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 2 { + return fmt.Errorf("snapshot ID and target name required\nUsage: hypeman snapshot fork ") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + snapshotID := args[0] + targetName := args[1] + + params := hypeman.SnapshotForkParams{ + Name: targetName, + } + if value := cmd.String("target-hypervisor"); value != "" { + hypervisor, err := parseSnapshotForkTargetHypervisor(value) + if err != nil { + return err + } + params.TargetHypervisor = hypervisor + } + if value := cmd.String("target-state"); value != "" { + state, err := parseSnapshotForkTargetState(value) + if err != nil { + return err + } + params.TargetState = state + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + if format != "auto" { + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Snapshots.Fork(ctx, snapshotID, params, opts...) + if err != nil { + return err + } + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "snapshot fork", obj, format, transform) + } + + instance, err := client.Snapshots.Fork(ctx, snapshotID, params, opts...) + if err != nil { + return err + } + fmt.Println(instance.ID) + return nil +} + +func parseSnapshotKind(raw string, fallback hypeman.SnapshotKind) (hypeman.SnapshotKind, error) { + switch strings.ToLower(raw) { + case "": + if fallback == "" { + return "", nil + } + return fallback, nil + case "standby": + return hypeman.SnapshotKindStandby, nil + case "stopped": + return hypeman.SnapshotKindStopped, nil + default: + return "", fmt.Errorf("invalid snapshot kind: %s (must be Standby or Stopped)", raw) + } +} + +func parseSnapshotCompressionAlgorithm(raw string) (shared.SnapshotCompressionConfigAlgorithm, error) { + switch strings.ToLower(raw) { + case "zstd": + return shared.SnapshotCompressionConfigAlgorithmZstd, nil + case "lz4": + return shared.SnapshotCompressionConfigAlgorithmLz4, nil + default: + return "", fmt.Errorf("invalid compression algorithm: %s (must be 'zstd' or 'lz4')", raw) + } +} + +func parseSnapshotTargetState(raw string) (hypeman.InstanceSnapshotRestoreParamsTargetState, error) { + switch strings.ToLower(raw) { + case "stopped": + return hypeman.InstanceSnapshotRestoreParamsTargetStateStopped, nil + case "standby": + return hypeman.InstanceSnapshotRestoreParamsTargetStateStandby, nil + case "running": + return hypeman.InstanceSnapshotRestoreParamsTargetStateRunning, nil + default: + return "", fmt.Errorf("invalid target state: %s (must be Stopped, Standby, or Running)", raw) + } +} + +func parseSnapshotTargetHypervisor(raw string) (hypeman.InstanceSnapshotRestoreParamsTargetHypervisor, error) { + switch strings.ToLower(raw) { + case "cloud-hypervisor", "ch": + return hypeman.InstanceSnapshotRestoreParamsTargetHypervisorCloudHypervisor, nil + case "firecracker", "fc": + return hypeman.InstanceSnapshotRestoreParamsTargetHypervisorFirecracker, nil + case "qemu": + return hypeman.InstanceSnapshotRestoreParamsTargetHypervisorQemu, nil + case "vz": + return hypeman.InstanceSnapshotRestoreParamsTargetHypervisorVz, nil + default: + return "", fmt.Errorf("invalid target hypervisor: %s (must be cloud-hypervisor, firecracker, qemu, or vz)", raw) + } +} + +func parseSnapshotForkTargetState(raw string) (hypeman.SnapshotForkParamsTargetState, error) { + switch strings.ToLower(raw) { + case "stopped": + return hypeman.SnapshotForkParamsTargetStateStopped, nil + case "standby": + return hypeman.SnapshotForkParamsTargetStateStandby, nil + case "running": + return hypeman.SnapshotForkParamsTargetStateRunning, nil + default: + return "", fmt.Errorf("invalid target state: %s (must be Stopped, Standby, or Running)", raw) + } +} + +func parseSnapshotForkTargetHypervisor(raw string) (hypeman.SnapshotForkParamsTargetHypervisor, error) { + switch strings.ToLower(raw) { + case "cloud-hypervisor", "ch": + return hypeman.SnapshotForkParamsTargetHypervisorCloudHypervisor, nil + case "firecracker", "fc": + return hypeman.SnapshotForkParamsTargetHypervisorFirecracker, nil + case "qemu": + return hypeman.SnapshotForkParamsTargetHypervisorQemu, nil + case "vz": + return hypeman.SnapshotForkParamsTargetHypervisorVz, nil + default: + return "", fmt.Errorf("invalid target hypervisor: %s (must be cloud-hypervisor, firecracker, qemu, or vz)", raw) + } +} diff --git a/pkg/cmd/snapshotcmd_test.go b/pkg/cmd/snapshotcmd_test.go new file mode 100644 index 0000000..baf6f35 --- /dev/null +++ b/pkg/cmd/snapshotcmd_test.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "testing" + + "github.com/kernel/hypeman-go/shared" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseSnapshotCompressionAlgorithm(t *testing.T) { + t.Run("accepts mixed-case zstd", func(t *testing.T) { + algorithm, err := parseSnapshotCompressionAlgorithm("ZsTd") + require.NoError(t, err) + assert.Equal(t, shared.SnapshotCompressionConfigAlgorithmZstd, algorithm) + }) + + t.Run("accepts mixed-case lz4", func(t *testing.T) { + algorithm, err := parseSnapshotCompressionAlgorithm("LZ4") + require.NoError(t, err) + assert.Equal(t, shared.SnapshotCompressionConfigAlgorithmLz4, algorithm) + }) + + t.Run("rejects unsupported algorithms", func(t *testing.T) { + _, err := parseSnapshotCompressionAlgorithm("gzip") + require.EqualError(t, err, "invalid compression algorithm: gzip (must be 'zstd' or 'lz4')") + }) +} diff --git a/pkg/cmd/stats.go b/pkg/cmd/stats.go new file mode 100644 index 0000000..fd3fafc --- /dev/null +++ b/pkg/cmd/stats.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var statsCmd = cli.Command{ + Name: "stats", + Usage: "Show live resource stats for an instance", + ArgsUsage: "", + Action: handleStats, + HideHelpCommand: true, +} + +func handleStats(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance ID or name required\nUsage: hypeman stats ") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err = client.Instances.Stats(ctx, instanceID, opts...) + if err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "instance stats", obj, format, transform) +} diff --git a/pkg/cmd/update.go b/pkg/cmd/update.go new file mode 100644 index 0000000..aaa0a8c --- /dev/null +++ b/pkg/cmd/update.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var updateCmd = cli.Command{ + Name: "update", + Usage: "Update specific mutable instance configuration", + Description: `Update mutable instance settings that have dedicated update flows. + +Currently supported: + hypeman update egress-credentials --env KEY=VALUE`, + Commands: []*cli.Command{ + &updateEgressCredentialsCmd, + }, + HideHelpCommand: true, +} + +var updateEgressCredentialsCmd = cli.Command{ + Name: "egress-credentials", + Usage: "Rotate env-backed credentials for existing mediated egress bindings", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringSliceFlag{ + Name: "env", + Aliases: []string{"e"}, + Usage: "Update a bound credential env value (KEY=VALUE, can be repeated)", + }, + }, + Action: handleUpdate, + HideHelpCommand: true, +} + +func handleUpdate(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance ID or name required\nUsage: hypeman update egress-credentials --env KEY=VALUE") + } + + env, malformed := parseKeyValueSpecs(cmd.StringSlice("env")) + for _, invalid := range malformed { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed env entry: %s\n", invalid) + } + if len(env) == 0 { + return fmt.Errorf("at least one bound credential --env KEY=VALUE entry is required") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + params := hypeman.InstanceUpdateParams{ + Env: env, + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + if format != "auto" { + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Instances.Update(ctx, instanceID, params, opts...) + if err != nil { + return err + } + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "instance update egress-credentials", obj, format, transform) + } + + instance, err := client.Instances.Update(ctx, instanceID, params, opts...) + if err != nil { + return err + } + fmt.Println(instance.ID) + return nil +} diff --git a/pkg/cmd/volumecmd.go b/pkg/cmd/volumecmd.go index 7476778..3445b17 100644 --- a/pkg/cmd/volumecmd.go +++ b/pkg/cmd/volumecmd.go @@ -43,6 +43,10 @@ var volumeCreateCmd = cli.Command{ Name: "id", Usage: "Optional custom identifier (auto-generated if not provided)", }, + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Set volume tag key-value pair (KEY=VALUE, can be repeated)", + }, }, Action: handleVolumeCreate, HideHelpCommand: true, @@ -57,25 +61,29 @@ var volumeListCmd = cli.Command{ Aliases: []string{"q"}, Usage: "Only display volume IDs", }, + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Filter by tag key-value pair (KEY=VALUE, can be repeated)", + }, }, Action: handleVolumeList, HideHelpCommand: true, } var volumeGetCmd = cli.Command{ - Name: "get", - Usage: "Get volume details", - ArgsUsage: "", - Action: handleVolumeGet, + Name: "get", + Usage: "Get volume details", + ArgsUsage: "", + Action: handleVolumeGet, HideHelpCommand: true, } var volumeDeleteCmd = cli.Command{ - Name: "delete", - Aliases: []string{"rm"}, - Usage: "Delete a volume", - ArgsUsage: "", - Action: handleVolumeDelete, + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete a volume", + ArgsUsage: "", + Action: handleVolumeDelete, HideHelpCommand: true, } @@ -131,6 +139,13 @@ func handleVolumeCreate(ctx context.Context, cmd *cli.Command) error { if id := cmd.String("id"); id != "" { params.ID = hypeman.Opt(id) } + tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed) + } + if len(tags) > 0 { + params.Tags = tags + } var opts []option.RequestOption if cmd.Root().Bool("debug") { @@ -167,11 +182,19 @@ func handleVolumeList(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") transform := cmd.Root().String("transform") + params := hypeman.VolumeListParams{} + tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag filter: %s\n", malformed) + } + if len(tags) > 0 { + params.Tags = tags + } if format != "auto" { var res []byte opts = append(opts, option.WithResponseBodyInto(&res)) - _, err := client.Volumes.List(ctx, opts...) + _, err := client.Volumes.List(ctx, params, opts...) if err != nil { return err } @@ -179,7 +202,7 @@ func handleVolumeList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(os.Stdout, "volume list", obj, format, transform) } - volumes, err := client.Volumes.List(ctx, opts...) + volumes, err := client.Volumes.List(ctx, params, opts...) if err != nil { return err }