Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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.28.0
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
Expand Down
94 changes: 2 additions & 92 deletions go.sum

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions lib/compose/reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ func (r *Runner) applyDelete(ctx context.Context, action *Action) error {
return err
}
case "instance":
if err := r.client.Instances.Delete(ctx, action.instanceID, r.opts...); err != nil && !isHTTPNotFound(err) {
if err := r.client.Instances.Delete(ctx, action.instanceID, hypeman.InstanceDeleteParams{}, r.opts...); err != nil && !isHTTPNotFound(err) {
return err
}
case "volume":
Expand Down Expand Up @@ -382,7 +382,7 @@ func (r *Runner) applyReplace(ctx context.Context, action *Action, opts UpOption
switch action.Type {
case "instance":
if action.instanceID != "" {
if err := r.client.Instances.Delete(ctx, action.instanceID, r.opts...); err != nil && !isHTTPNotFound(err) {
if err := r.client.Instances.Delete(ctx, action.instanceID, hypeman.InstanceDeleteParams{}, r.opts...); err != nil && !isHTTPNotFound(err) {
return err
}
}
Expand Down
150 changes: 150 additions & 0 deletions pkg/cmd/capabilitiescmd.go
Original file line number Diff line number Diff line change
@@ -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 == "") && transform == "" {
return showCapabilities(os.Stdout, res)
}
Comment thread
cursor[bot] marked this conversation as resolved.

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"
}
71 changes: 71 additions & 0 deletions pkg/cmd/capabilitiescmd_test.go
Original file line number Diff line number Diff line change
@@ -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 -")
}
1 change: 1 addition & 0 deletions pkg/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ func init() {
&volumeCmd,
&resourcesCmd,
&healthCmd,
&capabilitiesCmd,
&deviceCmd,
&composeCmd,
{
Expand Down
67 changes: 66 additions & 1 deletion pkg/cmd/imagecmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ var imageCmd = cli.Command{
&imageCreateCmd,
&imageListCmd,
&imageGetCmd,
&imageTagCmd,
&imageDeleteCmd,
},
HideHelpCommand: true,
Expand Down Expand Up @@ -60,6 +61,19 @@ var imageGetCmd = cli.Command{
HideHelpCommand: true,
}

var imageTagCmd = cli.Command{
Name: "tag",
Usage: "Create or update a local image tag",
ArgsUsage: "<source> <target>",
Description: `Point a new tag at an image that is already stored locally.

<source> is an existing image name or digest, and <target> is an OCI reference
with an explicit tag. The tag reuses the source content instead of pulling the
image again.`,
Action: handleImageTag,
HideHelpCommand: true,
}

var imageDeleteCmd = cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Expand Down Expand Up @@ -170,6 +184,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") {
Expand Down Expand Up @@ -199,7 +216,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)",
Expand All @@ -209,6 +226,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) {
Expand Down Expand Up @@ -255,6 +273,53 @@ func handleImageGet(ctx context.Context, cmd *cli.Command) error {
return ShowJSON(os.Stdout, "image get", obj, format, transform)
}

func handleImageTag(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args().Slice()
if len(args) < 2 {
return fmt.Errorf("source image and target reference required\nUsage: hypeman image tag <source> <target>")
}

source := args[0]
target := args[1]

if err := validateTaggedImageReference(target); err != nil {
return err
}

client := hypeman.NewClient(getDefaultRequestOptions(cmd)...)

var opts []option.RequestOption
if cmd.Root().Bool("debug") {
opts = append(opts, debugMiddlewareOption)
}

params := hypeman.ImageTagParams{
TagImageRequest: hypeman.TagImageRequestParam{Target: target},
}

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.Tag(ctx, url.PathEscape(source), params, opts...)
if err != nil {
return err
}
obj := gjson.ParseBytes(res)
return ShowJSON(os.Stdout, "image tag", obj, format, transform)
}

tagged, err := client.Images.Tag(ctx, url.PathEscape(source), params, opts...)
if err != nil {
return err
}

fmt.Println(tagged.Name)
return nil
}

func handleImageDelete(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args().Slice()
if len(args) < 1 {
Expand Down
Loading
Loading