From 2a140701f879c8f7d155af77236390837087cb3d Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:30:31 +0000 Subject: [PATCH 1/3] CLI: Update hypeman SDK to 913f5b9d8432 and add image pull credentials The SDK now accepts Docker-style registry credentials on ImageNewParams, so a private-registry image can be pulled without the server holding credentials for that registry. Expose them as --username/--password/--registry-token on `hypeman image create` and `hypeman pull`. `hypeman push create` already had the same three flags for the outbound push credentials, so both call sites now share one flag set and one builder. A full enumeration of the 61 SDK methods in api.md and their param struct fields against the CLI command tree found no other coverage gaps. Co-authored-by: Cursor --- go.mod | 2 +- go.sum | 4 +- pkg/cmd/imagecmd.go | 6 ++- pkg/cmd/pull.go | 3 ++ pkg/cmd/pushcmd.go | 32 ++----------- pkg/cmd/registrycredentials.go | 57 ++++++++++++++++++++++ pkg/cmd/registrycredentials_test.go | 74 +++++++++++++++++++++++++++++ 7 files changed, 145 insertions(+), 33 deletions(-) create mode 100644 pkg/cmd/registrycredentials.go create mode 100644 pkg/cmd/registrycredentials_test.go diff --git a/go.mod b/go.mod index f8f4aab..64fe2ac 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,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.24.0 + github.com/kernel/hypeman-go v0.24.1-0.20260814152312-913f5b9d8432 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 abc73dd..6687176 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,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.24.0 h1:kWssdYGVmnzVAYJcfbowieCazGxITUebrYil+BEBfag= -github.com/kernel/hypeman-go v0.24.0/go.mod h1:of8qI/nef2OPLzt0EMlIRbMdJHEvuc4yWG8g/ioNg48= +github.com/kernel/hypeman-go v0.24.1-0.20260814152312-913f5b9d8432 h1:p2zzyxdjm4gEjorDwu+GIGfpRLhIb8Wv+F83uzxy1TQ= +github.com/kernel/hypeman-go v0.24.1-0.20260814152312-913f5b9d8432/go.mod h1:of8qI/nef2OPLzt0EMlIRbMdJHEvuc4yWG8g/ioNg48= 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/imagecmd.go b/pkg/cmd/imagecmd.go index fe04aa1..7528105 100644 --- a/pkg/cmd/imagecmd.go +++ b/pkg/cmd/imagecmd.go @@ -170,6 +170,9 @@ func handleImageCreateLike(ctx context.Context, cmd *cli.Command, usageLine, out for _, malformed := range malformedTags { fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed) } + if credentials, ok := registryCredentialsFromCommand(cmd); ok { + params.Credentials = credentials + } var opts []option.RequestOption if cmd.Root().Bool("debug") { @@ -199,7 +202,7 @@ func handleImageCreateLike(ctx context.Context, cmd *cli.Command, usageLine, out } func imageCreateFlags() []cli.Flag { - return []cli.Flag{ + flags := []cli.Flag{ &cli.StringSliceFlag{ Name: "tag", Usage: "Set image tag key-value pair (KEY=VALUE, can be repeated)", @@ -209,6 +212,7 @@ func imageCreateFlags() []cli.Flag { Usage: `Target platform as os/arch[/variant] (e.g., "linux/amd64"). Defaults to the host platform`, }, } + return append(flags, registryCredentialFlags()...) } func buildImageNewParams(name string, tagSpecs []string, platform string) (hypeman.ImageNewParams, []string) { diff --git a/pkg/cmd/pull.go b/pkg/cmd/pull.go index dd7c7f6..e1a3f1b 100644 --- a/pkg/cmd/pull.go +++ b/pkg/cmd/pull.go @@ -31,6 +31,9 @@ func handlePull(ctx context.Context, cmd *cli.Command) error { for _, malformed := range malformedTags { fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed) } + if credentials, ok := registryCredentialsFromCommand(cmd); ok { + params.Credentials = credentials + } client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) diff --git a/pkg/cmd/pushcmd.go b/pkg/cmd/pushcmd.go index 64af10a..9f4f80c 100644 --- a/pkg/cmd/pushcmd.go +++ b/pkg/cmd/pushcmd.go @@ -26,24 +26,12 @@ Examples: # Push with credentials borrowed for this push only hypeman push create alpine:latest registry.example.com/myapp:v1 --username alice --password s3cret`, - Flags: []cli.Flag{ + Flags: append([]cli.Flag{ &cli.BoolFlag{ Name: "insecure", Usage: "Allow pushing to plain-HTTP registries", }, - &cli.StringFlag{ - Name: "username", - Usage: "Registry username", - }, - &cli.StringFlag{ - Name: "password", - Usage: "Registry password or access token", - }, - &cli.StringFlag{ - Name: "registry-token", - Usage: "Bearer token for an Authorization header", - }, - }, + }, registryCredentialFlags()...), Action: handlePushCreate, HideHelpCommand: true, } @@ -130,21 +118,7 @@ func buildPushNewParams(image, target string, insecure bool, username, password, params.CreatePushRequest.Insecure = hypeman.Opt(true) } - credentials := hypeman.PushCredentialsParam{} - haveCredentials := false - if username != "" { - credentials.Username = hypeman.Opt(username) - haveCredentials = true - } - if password != "" { - credentials.Password = hypeman.Opt(password) - haveCredentials = true - } - if registryToken != "" { - credentials.RegistryToken = hypeman.Opt(registryToken) - haveCredentials = true - } - if haveCredentials { + if credentials, ok := buildRegistryCredentials(username, password, registryToken); ok { params.CreatePushRequest.Credentials = credentials } diff --git a/pkg/cmd/registrycredentials.go b/pkg/cmd/registrycredentials.go new file mode 100644 index 0000000..dfefce2 --- /dev/null +++ b/pkg/cmd/registrycredentials.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "github.com/kernel/hypeman-go" + "github.com/urfave/cli/v3" +) + +// registryCredentialFlags are the Docker-style registry credentials that the +// server borrows for a single image pull or push request. They are shared by +// every command that talks to a remote registry on the caller's behalf. +func registryCredentialFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "username", + Usage: "Registry username", + }, + &cli.StringFlag{ + Name: "password", + Usage: "Registry password or access token", + }, + &cli.StringFlag{ + Name: "registry-token", + Usage: "Bearer token for an Authorization header", + }, + } +} + +func registryCredentialsFromCommand(cmd *cli.Command) (hypeman.PushCredentialsParam, bool) { + return buildRegistryCredentials( + cmd.String("username"), + cmd.String("password"), + cmd.String("registry-token"), + ) +} + +// buildRegistryCredentials reports false when nothing was supplied, so callers +// can leave the field unset and let the server use its own registry +// credentials. +func buildRegistryCredentials(username, password, registryToken string) (hypeman.PushCredentialsParam, bool) { + credentials := hypeman.PushCredentialsParam{} + supplied := false + + if username != "" { + credentials.Username = hypeman.Opt(username) + supplied = true + } + if password != "" { + credentials.Password = hypeman.Opt(password) + supplied = true + } + if registryToken != "" { + credentials.RegistryToken = hypeman.Opt(registryToken) + supplied = true + } + + return credentials, supplied +} diff --git a/pkg/cmd/registrycredentials_test.go b/pkg/cmd/registrycredentials_test.go new file mode 100644 index 0000000..89b0d6e --- /dev/null +++ b/pkg/cmd/registrycredentials_test.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/kernel/hypeman-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestBuildRegistryCredentials(t *testing.T) { + t.Run("reports nothing supplied when all values are empty", func(t *testing.T) { + credentials, supplied := buildRegistryCredentials("", "", "") + + assert.False(t, supplied) + assert.False(t, credentials.Username.Valid()) + assert.False(t, credentials.Password.Valid()) + assert.False(t, credentials.RegistryToken.Valid()) + }) + + t.Run("sets only the values that were supplied", func(t *testing.T) { + credentials, supplied := buildRegistryCredentials("", "", "token") + + require.True(t, supplied) + assert.False(t, credentials.Username.Valid()) + assert.False(t, credentials.Password.Valid()) + require.True(t, credentials.RegistryToken.Valid()) + assert.Equal(t, "token", credentials.RegistryToken.Value) + }) + + t.Run("sets every value", func(t *testing.T) { + credentials, supplied := buildRegistryCredentials("alice", "s3cret", "token") + + require.True(t, supplied) + require.True(t, credentials.Username.Valid()) + assert.Equal(t, "alice", credentials.Username.Value) + require.True(t, credentials.Password.Valid()) + assert.Equal(t, "s3cret", credentials.Password.Value) + require.True(t, credentials.RegistryToken.Valid()) + assert.Equal(t, "token", credentials.RegistryToken.Value) + }) +} + +// TestImageCreateFlagsCarryRegistryCredentials covers the wiring between the +// shared image-create flags and ImageNewParams.Credentials, which lets +// `hypeman pull` and `hypeman image create` pull from a private registry. +func TestImageCreateFlagsCarryRegistryCredentials(t *testing.T) { + var params hypeman.ImageNewParams + + command := &cli.Command{ + Name: "create", + Flags: imageCreateFlags(), + Action: func(_ context.Context, cmd *cli.Command) error { + params, _ = buildImageNewParams(cmd.Args().First(), nil, "") + if credentials, ok := registryCredentialsFromCommand(cmd); ok { + params.Credentials = credentials + } + return nil + }, + } + + require.NoError(t, command.Run(context.Background(), []string{ + "create", "--username", "alice", "--password", "s3cret", "alpine:latest", + })) + + require.Equal(t, "alpine:latest", params.Name) + require.True(t, params.Credentials.Username.Valid()) + assert.Equal(t, "alice", params.Credentials.Username.Value) + require.True(t, params.Credentials.Password.Valid()) + assert.Equal(t, "s3cret", params.Credentials.Password.Value) + assert.False(t, params.Credentials.RegistryToken.Valid()) +} From 2bd90ec4deb8b9dbd2532b5948aa94b042e31aff Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:04:33 +0000 Subject: [PATCH 2/3] CLI: Update hypeman SDK to 7f21c67 and add capabilities command Bump github.com/kernel/hypeman-go to 7f21c67d750f6dd66c6b6af04e88c710841f2daf, which adds the GET /capabilities resource. Expose it as `hypeman capabilities` so users can discover which runtimes and features a host actually supports instead of hard-coding hypervisor knowledge. Co-authored-by: Cursor --- README.md | 19 ++++ go.mod | 2 +- go.sum | 4 +- pkg/cmd/capabilitiescmd.go | 150 ++++++++++++++++++++++++++++++++ pkg/cmd/capabilitiescmd_test.go | 71 +++++++++++++++ pkg/cmd/cmd.go | 1 + 6 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 pkg/cmd/capabilitiescmd.go create mode 100644 pkg/cmd/capabilitiescmd_test.go diff --git a/README.md b/README.md index 4a925d3..df0817b 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,25 @@ The CLI also provides resource-based commands for more advanced usage: hypeman [resource] [command] [flags] ``` +## Host Capabilities + +Check what the server build supports on this host before relying on a runtime or feature: + +```bash +# Show server/API version, host OS/arch, runtimes, image platforms, and networking +hypeman capabilities + +# Show capabilities as JSON +hypeman capabilities --format json + +# Show only the runtimes this host supports +hypeman capabilities --transform runtimes +``` + +Each runtime is listed with an `available` flag and its own feature IDs (for example +`snapshots`, `standby`, `fork`, `gpu-passthrough`), so a runtime is only launchable when +its `available` flag is `yes`. + ## Resource Management ### Viewing Server Resources diff --git a/go.mod b/go.mod index 64fe2ac..e4cd043 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,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.24.1-0.20260814152312-913f5b9d8432 + github.com/kernel/hypeman-go v0.24.1-0.20260817185642-7f21c67d750f 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 6687176..6dd60f2 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,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.24.1-0.20260814152312-913f5b9d8432 h1:p2zzyxdjm4gEjorDwu+GIGfpRLhIb8Wv+F83uzxy1TQ= -github.com/kernel/hypeman-go v0.24.1-0.20260814152312-913f5b9d8432/go.mod h1:of8qI/nef2OPLzt0EMlIRbMdJHEvuc4yWG8g/ioNg48= +github.com/kernel/hypeman-go v0.24.1-0.20260817185642-7f21c67d750f h1:vgFyvKK4pXteI49Dd+0jTea0ZAK2/0Acy055MKu0ZXI= +github.com/kernel/hypeman-go v0.24.1-0.20260817185642-7f21c67d750f/go.mod h1:of8qI/nef2OPLzt0EMlIRbMdJHEvuc4yWG8g/ioNg48= 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/capabilitiescmd.go b/pkg/cmd/capabilitiescmd.go new file mode 100644 index 0000000..f21af24 --- /dev/null +++ b/pkg/cmd/capabilitiescmd.go @@ -0,0 +1,150 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var capabilitiesCmd = cli.Command{ + Name: "capabilities", + Aliases: []string{"capability"}, + Usage: "Show machine-readable host capabilities", + Description: `Report server and API version, host OS/architecture, every runtime available on +this host with its per-runtime feature IDs, the configured default runtime and +whether it is available, guest networking model and host gateway, supported +image platforms, and stable server-level feature IDs. + +Runtime-derived values reflect the actual host (for example, snapshot and +standby support on macOS is gated on the host OS version), so clients can gate +behavior on capabilities without hard-coding hypervisor knowledge. + +Examples: + # Show capabilities (default table format) + hypeman capabilities + + # Show capabilities as JSON + hypeman capabilities --format json + + # Show only the runtimes this host supports + hypeman capabilities --transform runtimes`, + Action: handleCapabilities, + HideHelpCommand: true, +} + +func handleCapabilities(ctx context.Context, cmd *cli.Command) error { + 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.Capabilities.Get(ctx, opts...) + if err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + if format == "auto" || format == "" { + return showCapabilities(os.Stdout, res) + } + + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "capabilities", obj, format, transform) +} + +func showCapabilities(w io.Writer, data []byte) error { + obj := gjson.ParseBytes(data) + + server := obj.Get("server") + fmt.Fprintln(w, "SERVER") + fmt.Fprintf(w, " Version: %s\n", orDash(server.Get("version").String())) + fmt.Fprintf(w, " API version: %s\n", orDash(server.Get("api_version").String())) + + host := obj.Get("host") + fmt.Fprintln(w) + fmt.Fprintln(w, "HOST") + fmt.Fprintf(w, " OS: %s\n", orDash(host.Get("os").String())) + fmt.Fprintf(w, " Arch: %s\n", orDash(host.Get("arch").String())) + + defaultRuntime := obj.Get("default_runtime") + fmt.Fprintln(w) + fmt.Fprintln(w, "DEFAULT RUNTIME") + fmt.Fprintf(w, " Name: %s\n", orDash(defaultRuntime.Get("name").String())) + fmt.Fprintf(w, " Available: %s\n", yesNo(defaultRuntime.Get("available").Bool())) + + runtimes := obj.Get("runtimes") + if runtimes.IsArray() && len(runtimes.Array()) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "RUNTIMES") + table := NewTableWriter(w, "NAME", "AVAILABLE", "FEATURES") + table.TruncOrder = []int{2} + runtimes.ForEach(func(_, value gjson.Result) bool { + table.AddRow( + value.Get("name").String(), + yesNo(value.Get("available").Bool()), + orDash(joinStrings(value.Get("features"))), + ) + return true + }) + table.Render() + } + + images := obj.Get("images") + fmt.Fprintln(w) + fmt.Fprintln(w, "IMAGES") + fmt.Fprintf(w, " Default platform: %s\n", orDash(images.Get("default_platform").String())) + fmt.Fprintf(w, " Platforms: %s\n", orDash(joinStrings(images.Get("platforms")))) + + network := obj.Get("network") + fmt.Fprintln(w) + fmt.Fprintln(w, "NETWORK") + fmt.Fprintf(w, " Model: %s\n", orDash(network.Get("model").String())) + fmt.Fprintf(w, " Gateway: %s\n", orDash(network.Get("gateway").String())) + fmt.Fprintf(w, " Subnet: %s\n", orDash(network.Get("subnet").String())) + fmt.Fprintf(w, " Guest to guest: %s\n", yesNo(network.Get("guest_to_guest").Bool())) + + fmt.Fprintln(w) + fmt.Fprintln(w, "SERVER FEATURES") + fmt.Fprintf(w, " %s\n", orDash(joinStrings(obj.Get("features")))) + + return nil +} + +func joinStrings(arr gjson.Result) string { + if !arr.IsArray() { + return "" + } + values := make([]string, 0, len(arr.Array())) + arr.ForEach(func(_, value gjson.Result) bool { + values = append(values, value.String()) + return true + }) + return strings.Join(values, ", ") +} + +func orDash(s string) string { + if s == "" { + return "-" + } + return s +} + +func yesNo(b bool) string { + if b { + return "yes" + } + return "no" +} diff --git a/pkg/cmd/capabilitiescmd_test.go b/pkg/cmd/capabilitiescmd_test.go new file mode 100644 index 0000000..7caea94 --- /dev/null +++ b/pkg/cmd/capabilitiescmd_test.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCapabilitiesCmdStructure(t *testing.T) { + assert.Equal(t, "capabilities", capabilitiesCmd.Name) + assert.Contains(t, capabilitiesCmd.Aliases, "capability") + assert.NotNil(t, capabilitiesCmd.Action) +} + +func TestShowCapabilities(t *testing.T) { + payload := []byte(`{ + "default_runtime": {"available": true, "name": "cloud-hypervisor"}, + "features": ["instances", "images", "devices"], + "host": {"arch": "amd64", "os": "linux"}, + "images": {"default_platform": "linux/amd64", "platforms": ["linux/amd64", "linux/arm64"]}, + "network": {"guest_to_guest": false, "model": "bridge", "gateway": "192.168.100.1", "subnet": "192.168.100.0/24"}, + "runtimes": [ + {"available": true, "features": ["snapshots", "standby"], "name": "cloud-hypervisor"}, + {"available": false, "features": [], "name": "qemu"} + ], + "server": {"api_version": "1.2.3", "version": "abc1234"} + }`) + + var buf bytes.Buffer + require.NoError(t, showCapabilities(&buf, payload)) + out := buf.String() + + assert.Contains(t, out, "Version: abc1234") + assert.Contains(t, out, "API version: 1.2.3") + assert.Contains(t, out, "OS: linux") + assert.Contains(t, out, "Arch: amd64") + assert.Contains(t, out, "Name: cloud-hypervisor") + assert.Contains(t, out, "cloud-hypervisor yes") + assert.Contains(t, out, "snapshots, standby") + assert.Contains(t, out, "qemu no") + assert.Contains(t, out, "Default platform: linux/amd64") + assert.Contains(t, out, "Platforms: linux/amd64, linux/arm64") + assert.Contains(t, out, "Model: bridge") + assert.Contains(t, out, "Gateway: 192.168.100.1") + assert.Contains(t, out, "Subnet: 192.168.100.0/24") + assert.Contains(t, out, "Guest to guest: no") + assert.Contains(t, out, "instances, images, devices") +} + +func TestShowCapabilitiesOmitsMissingOptionalFields(t *testing.T) { + payload := []byte(`{ + "default_runtime": {"available": false, "name": "vz"}, + "features": [], + "host": {"arch": "arm64", "os": "darwin"}, + "images": {"default_platform": "linux/arm64", "platforms": ["linux/arm64"]}, + "network": {"guest_to_guest": true, "model": "nat"}, + "runtimes": [], + "server": {"api_version": "1.2.3", "version": "unknown"} + }`) + + var buf bytes.Buffer + require.NoError(t, showCapabilities(&buf, payload)) + out := buf.String() + + assert.Contains(t, out, "Gateway: -") + assert.Contains(t, out, "Subnet: -") + assert.NotContains(t, out, "RUNTIMES") + assert.Contains(t, out, "SERVER FEATURES\n -") +} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 14f266b..18284aa 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -94,6 +94,7 @@ func init() { &volumeCmd, &resourcesCmd, &healthCmd, + &capabilitiesCmd, &deviceCmd, &composeCmd, { From 603216e305926332c985cdd4723070c8d67d18d9 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:48:51 +0000 Subject: [PATCH 3/3] CLI: Update hypeman SDK to ea5a7f8d and add instance expiration flags Bumps github.com/kernel/hypeman-go to ea5a7f8d0d069b51e10189a8d0196f6155aaa4a8. A full enumeration of api.md against the pkg/cmd command tree found every SDK method already reachable from the CLI, but the SDK bump adds instance TTL / absolute expiration to InstanceNewParams and InstanceUpdateParams, which had no CLI surface. Expose them as --ttl and --expires-at on `hypeman run` and a new `hypeman update expiration` subcommand, rejecting the mutually exclusive combination and malformed values before the API round trip. Co-authored-by: Cursor --- go.mod | 2 +- go.sum | 4 +- pkg/cmd/policyflags.go | 54 +++++++++++++++++++++++++++ pkg/cmd/policyflags_test.go | 69 ++++++++++++++++++++++++++++++++++ pkg/cmd/run.go | 14 ++++++- pkg/cmd/update.go | 74 +++++++++++++++++++++++++++++++++++++ 6 files changed, 213 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 5b3e2e6..24be96d 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,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.25.0 + github.com/kernel/hypeman-go v0.25.1-0.20260821204035-ea5a7f8d0d06 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 1602efc..e02a2cc 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,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.25.0 h1:l0aXeZ7I2hyXVzY+mFV0djxavEybJx50J9GgLFhC0Kk= -github.com/kernel/hypeman-go v0.25.0/go.mod h1:of8qI/nef2OPLzt0EMlIRbMdJHEvuc4yWG8g/ioNg48= +github.com/kernel/hypeman-go v0.25.1-0.20260821204035-ea5a7f8d0d06 h1:omo0k58atUA1t6BSfAHX6tJn+10f+uRXsTUzXYwzJ0A= +github.com/kernel/hypeman-go v0.25.1-0.20260821204035-ea5a7f8d0d06/go.mod h1:of8qI/nef2OPLzt0EMlIRbMdJHEvuc4yWG8g/ioNg48= 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/policyflags.go b/pkg/cmd/policyflags.go index d3aa66d..9ce75f8 100644 --- a/pkg/cmd/policyflags.go +++ b/pkg/cmd/policyflags.go @@ -2,8 +2,11 @@ package cmd import ( "fmt" + "time" "github.com/kernel/hypeman-cli/lib/compose" + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/packages/param" "github.com/urfave/cli/v3" ) @@ -85,6 +88,57 @@ func restartPolicyFlags(prefix string) []cli.Flag { } } +func expirationFlags(prefix string) []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: prefix + "ttl", + Usage: `Relative lifetime in Go duration format (e.g., "90m"); "0s" disables automatic expiration`, + }, + &cli.StringFlag{ + Name: prefix + "expires-at", + Usage: `Absolute expiration time in RFC3339 format (e.g., "2026-01-02T15:04:05Z")`, + }, + } +} + +type expirationInput struct { + TTL param.Opt[string] + ExpiresAt param.Opt[time.Time] +} + +func parseExpirationInput(cmd *cli.Command, prefix string) (expirationInput, bool, error) { + ttlFlag := prefix + "ttl" + expiresAtFlag := prefix + "expires-at" + + ttlSet := cmd.IsSet(ttlFlag) + expiresAtSet := cmd.IsSet(expiresAtFlag) + // The API rejects requests carrying both fields, so fail before the round trip. + if ttlSet && expiresAtSet { + return expirationInput{}, false, fmt.Errorf("--%sttl and --%sexpires-at are mutually exclusive", prefix, prefix) + } + if !ttlSet && !expiresAtSet { + return expirationInput{}, false, nil + } + + var in expirationInput + if ttlSet { + ttl := cmd.String(ttlFlag) + if _, err := time.ParseDuration(ttl); err != nil { + return expirationInput{}, false, fmt.Errorf(`invalid --%sttl %q: expected a Go duration such as "90m" or "0s"`, prefix, ttl) + } + in.TTL = hypeman.Opt(ttl) + } + if expiresAtSet { + raw := cmd.String(expiresAtFlag) + expiresAt, err := time.Parse(time.RFC3339, raw) + if err != nil { + return expirationInput{}, false, fmt.Errorf(`invalid --%sexpires-at %q: expected an RFC3339 timestamp such as "2026-01-02T15:04:05Z"`, prefix, raw) + } + in.ExpiresAt = hypeman.Opt(expiresAt) + } + return in, true, nil +} + func parseHealthCheckInput(cmd *cli.Command, prefix string) (compose.HealthCheckInput, bool, error) { typeFlag := prefix + "type" intervalFlag := prefix + "interval" diff --git a/pkg/cmd/policyflags_test.go b/pkg/cmd/policyflags_test.go index 38ff36e..4f902c9 100644 --- a/pkg/cmd/policyflags_test.go +++ b/pkg/cmd/policyflags_test.go @@ -3,6 +3,7 @@ package cmd import ( "context" "testing" + "time" "github.com/kernel/hypeman-cli/lib/compose" "github.com/stretchr/testify/assert" @@ -58,6 +59,74 @@ func TestParseHealthCheckInput(t *testing.T) { }) } +func runExpirationParse(t *testing.T, args ...string) (expirationInput, bool, error) { + t.Helper() + var ( + gotIn expirationInput + gotSet bool + gotErr error + ) + cmd := &cli.Command{ + Name: "x", + Flags: expirationFlags(""), + Action: func(_ context.Context, c *cli.Command) error { + gotIn, gotSet, gotErr = parseExpirationInput(c, "") + return nil + }, + } + require.NoError(t, cmd.Run(context.Background(), append([]string{"x"}, args...))) + return gotIn, gotSet, gotErr +} + +func TestParseExpirationInput(t *testing.T) { + t.Run("ttl is passed through verbatim", func(t *testing.T) { + in, set, err := runExpirationParse(t, "--ttl", "90m") + require.NoError(t, err) + require.True(t, set) + assert.Equal(t, "90m", in.TTL.Value) + assert.False(t, in.ExpiresAt.Valid()) + }) + + t.Run("zero ttl disables expiration", func(t *testing.T) { + in, set, err := runExpirationParse(t, "--ttl", "0s") + require.NoError(t, err) + require.True(t, set) + assert.Equal(t, "0s", in.TTL.Value) + }) + + t.Run("expires-at is parsed as RFC3339", func(t *testing.T) { + in, set, err := runExpirationParse(t, "--expires-at", "2026-01-02T15:04:05Z") + require.NoError(t, err) + require.True(t, set) + require.True(t, in.ExpiresAt.Valid()) + assert.Equal(t, time.Date(2026, time.January, 2, 15, 4, 5, 0, time.UTC), in.ExpiresAt.Value) + assert.False(t, in.TTL.Valid()) + }) + + t.Run("ttl and expires-at are mutually exclusive", func(t *testing.T) { + _, _, err := runExpirationParse(t, "--ttl", "1h", "--expires-at", "2026-01-02T15:04:05Z") + require.EqualError(t, err, "--ttl and --expires-at are mutually exclusive") + }) + + t.Run("malformed ttl is rejected", func(t *testing.T) { + _, _, err := runExpirationParse(t, "--ttl", "2 hours") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --ttl") + }) + + t.Run("malformed expires-at is rejected", func(t *testing.T) { + _, _, err := runExpirationParse(t, "--expires-at", "tomorrow") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --expires-at") + }) + + t.Run("no flags reports unset without error", func(t *testing.T) { + _, set, err := runExpirationParse(t) + require.NoError(t, err) + assert.False(t, set) + }) +} + func runRestartParse(t *testing.T, args ...string) (*int64, bool) { t.Helper() var ( diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go index eb87336..38dd2a3 100644 --- a/pkg/cmd/run.go +++ b/pkg/cmd/run.go @@ -46,7 +46,10 @@ Examples: hypeman run --hypervisor qemu-microvm myimage:latest # Run with bandwidth limits - hypeman run --bandwidth-down 1Gbps --bandwidth-up 500Mbps myimage:latest`, + hypeman run --bandwidth-down 1Gbps --bandwidth-up 500Mbps myimage:latest + + # Run with an automatic expiration two hours after creation + hypeman run --ttl 2h myimage:latest`, Flags: []cli.Flag{ &cli.StringFlag{ Name: "name", @@ -193,6 +196,7 @@ Examples: } func init() { + runCmd.Flags = append(runCmd.Flags, expirationFlags("")...) runCmd.Flags = append(runCmd.Flags, healthCheckFlags("health-")...) runCmd.Flags = append(runCmd.Flags, restartPolicyFlags("restart-")...) } @@ -290,6 +294,14 @@ func handleRun(ctx context.Context, cmd *cli.Command) error { if restartInput, ok := parseRestartPolicyInput(cmd, "restart-"); ok { params.RestartPolicy = compose.BuildRestartPolicyParam(restartInput) } + expiration, expirationSet, err := parseExpirationInput(cmd, "") + if err != nil { + return err + } + if expirationSet { + params.Ttl = expiration.TTL + params.ExpiresAt = expiration.ExpiresAt + } // Network configuration networkEnabled := cmd.Bool("network") diff --git a/pkg/cmd/update.go b/pkg/cmd/update.go index 447f438..46bd8a0 100644 --- a/pkg/cmd/update.go +++ b/pkg/cmd/update.go @@ -20,11 +20,13 @@ var updateCmd = cli.Command{ Currently supported: hypeman update auto-standby --enabled --idle-timeout 10m hypeman update egress-credentials --env KEY=VALUE + hypeman update expiration --ttl 2h hypeman update health-check --type http --http-port 8080 hypeman update restart-policy --policy on_failure --max-attempts 5`, Commands: []*cli.Command{ &updateAutoStandbyCmd, &updateEgressCredentialsCmd, + &updateExpirationCmd, &updateHealthCheckCmd, &updateRestartPolicyCmd, }, @@ -72,6 +74,24 @@ var updateEgressCredentialsCmd = cli.Command{ HideHelpCommand: true, } +var updateExpirationCmd = cli.Command{ + Name: "expiration", + Usage: "Update the automatic expiration deadline for an instance", + ArgsUsage: "", + Description: `Set or clear the automatic expiration deadline for an instance. + +TTL values are relative to when the update is committed, and the API rejects +expiration updates made after the current deadline has already passed. + +Examples: + hypeman update expiration my-instance --ttl 2h + hypeman update expiration my-instance --expires-at 2026-01-02T15:04:05Z + hypeman update expiration my-instance --ttl 0s`, + Flags: expirationFlags(""), + Action: handleUpdateExpiration, + HideHelpCommand: true, +} + var updateHealthCheckCmd = cli.Command{ Name: "health-check", Usage: "Update the workload health check policy for an instance", @@ -143,6 +163,60 @@ func handleUpdateAutoStandby(ctx context.Context, cmd *cli.Command) error { return nil } +func handleUpdateExpiration(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 expiration [flags]") + } + + expiration, set, err := parseExpirationInput(cmd, "") + if err != nil { + return err + } + if !set { + return fmt.Errorf("one of --ttl or --expires-at is required") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + params := hypeman.InstanceUpdateParams{ + Ttl: expiration.TTL, + ExpiresAt: expiration.ExpiresAt, + } + + 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, "update expiration", obj, format, transform) + } + + fmt.Fprintf(os.Stderr, "Updating expiration for %s...\n", args[0]) + + instance, err := client.Instances.Update(ctx, instanceID, params, opts...) + if err != nil { + return err + } + fmt.Println(instance.ID) + return nil +} + func handleUpdateHealthCheck(ctx context.Context, cmd *cli.Command) error { args := cmd.Args().Slice() if len(args) < 1 {