diff --git a/.gitignore b/.gitignore index 12c4508..2b5398f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ dist/ .env hypeman/** bin/hypeman +demos/ +.DS_Store diff --git a/go.mod b/go.mod index f591003..3df0e42 100644 --- a/go.mod +++ b/go.mod @@ -10,8 +10,9 @@ 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.11.0 + github.com/kernel/hypeman-go v0.14.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 github.com/knadh/koanf/v2 v2.3.2 github.com/muesli/reflow v0.3.0 @@ -52,7 +53,6 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/klauspost/compress v1.18.1 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect - github.com/knadh/koanf/providers/env v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/go.sum b/go.sum index 9c04859..e2aa3a3 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.11.0 h1:hCXNUHtrhGKswJapzyWyozBOXhKK/oreKvm0AXHuE6c= -github.com/kernel/hypeman-go v0.11.0/go.mod h1:guRrhyP9QW/ebUS1UcZ0uZLLJeGAAhDNzSi68U4M9hI= +github.com/kernel/hypeman-go v0.14.0 h1:FeeVJly5TzkAYZdxuSfn/8Sz5qZZtUlPQQvUQOAOhg4= +github.com/kernel/hypeman-go v0.14.0/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 21deedb..a4c8c88 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -77,6 +77,14 @@ Examples: Name: "image-name", Usage: `Custom image name for the build output (pushed to {registry}/{image_name} instead of {registry}/builds/{id})`, }, + &cli.IntFlag{ + Name: "cpus", + Usage: "Number of vCPUs for builder VM (default 2)", + }, + &cli.IntFlag{ + Name: "memory", + Usage: "Memory limit for builder VM in MB (default 2048)", + }, }, Commands: []*cli.Command{ &buildListCmd, @@ -172,6 +180,12 @@ func handleBuild(ctx context.Context, cmd *cli.Command) error { if v := cmd.String("image-name"); v != "" { params.ImageName = hypeman.Opt(v) } + if cmd.IsSet("cpus") { + params.CPUs = hypeman.Opt(int64(cmd.Int("cpus"))) + } + if cmd.IsSet("memory") { + params.MemoryMB = hypeman.Opt(int64(cmd.Int("memory"))) + } // Start build build, err := client.Builds.New(ctx, params, opts...) diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index e2614ca..693dfc8 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -67,26 +67,28 @@ func init() { Usage: "The GJSON transformation for errors.", }, }, - Commands: []*cli.Command{ - &buildCmd, - &execCmd, - &cpCmd, - &pullCmd, - &pushCmd, - &runCmd, - &psCmd, - &logsCmd, - &rmCmd, - &stopCmd, - &startCmd, - &standbyCmd, - &restoreCmd, - &imageCmd, - &ingressCmd, - &volumeCmd, - &resourcesCmd, - &deviceCmd, - { + Commands: []*cli.Command{ + &buildCmd, + &execCmd, + &cpCmd, + &pullCmd, + &pushCmd, + &runCmd, + &psCmd, + &inspectCmd, + &logsCmd, + &rmCmd, + &stopCmd, + &startCmd, + &standbyCmd, + &restoreCmd, + &forkCmd, + &imageCmd, + &ingressCmd, + &volumeCmd, + &resourcesCmd, + &deviceCmd, + { Name: "@manpages", Usage: "Generate documentation for 'man'", UsageText: "hypeman @manpages [-o hypeman.1] [--gzip]", diff --git a/pkg/cmd/fork.go b/pkg/cmd/fork.go new file mode 100644 index 0000000..8ca13a6 --- /dev/null +++ b/pkg/cmd/fork.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var forkCmd = cli.Command{ + Name: "fork", + Usage: "Fork an instance into a new instance", + ArgsUsage: " ", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "from-running", + Usage: "Allow forking from a running source by doing standby -> fork -> restore", + }, + &cli.StringFlag{ + Name: "target-state", + Usage: "Target state for the forked instance: Stopped, Standby, or Running", + }, + }, + Action: handleFork, + HideHelpCommand: true, +} + +func handleFork(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 2 { + return fmt.Errorf("source instance and target name required\nUsage: hypeman fork [flags] ") + } + + source := args[0] + targetName := args[1] + + targetState, err := normalizeForkTargetState(cmd.String("target-state")) + if err != nil { + return err + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + sourceID, err := ResolveInstance(ctx, &client, source) + if err != nil { + return err + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + params := instanceForkParams{ + Name: targetName, + } + if cmd.IsSet("from-running") { + fromRunning := cmd.Bool("from-running") + params.FromRunning = &fromRunning + } + if targetState != "" { + params.TargetState = &targetState + } + + fmt.Fprintf(os.Stderr, "Forking %s to %s...\n", source, targetName) + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + var raw []byte + if format != "auto" { + opts = append(opts, option.WithResponseBodyInto(&raw)) + } + + var forked hypeman.Instance + if err := client.Post(ctx, fmt.Sprintf("instances/%s/fork", sourceID), params, &forked, opts...); err != nil { + return err + } + + if format != "auto" { + obj := gjson.ParseBytes(raw) + return ShowJSON(os.Stdout, "instance fork", obj, format, transform) + } + + // Output instance ID (useful for scripting) + fmt.Println(forked.ID) + fmt.Fprintf(os.Stderr, "Forked %s as %s (state: %s)\n", source, forked.Name, forked.State) + return nil +} + +func normalizeForkTargetState(state string) (string, error) { + switch strings.ToLower(state) { + case "": + return "", nil + case "stopped": + return "Stopped", nil + case "standby": + return "Standby", nil + case "running": + return "Running", nil + default: + return "", fmt.Errorf("invalid target state: %s (must be Stopped, Standby, or Running)", state) + } +} + +type instanceForkParams struct { + Name string `json:"name"` + FromRunning *bool `json:"from_running,omitempty"` + TargetState *string `json:"target_state,omitempty"` +} diff --git a/pkg/cmd/fork_test.go b/pkg/cmd/fork_test.go new file mode 100644 index 0000000..4e1ad25 --- /dev/null +++ b/pkg/cmd/fork_test.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeForkTargetState(t *testing.T) { + tests := []struct { + name string + input string + expected string + shouldErr bool + }{ + { + name: "empty state", + input: "", + expected: "", + }, + { + name: "stopped lowercase", + input: "stopped", + expected: "Stopped", + }, + { + name: "standby mixed case", + input: "StAnDbY", + expected: "Standby", + }, + { + name: "running title case", + input: "Running", + expected: "Running", + }, + { + name: "invalid state", + input: "paused", + shouldErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := normalizeForkTargetState(tt.input) + if tt.shouldErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/cmd/format.go b/pkg/cmd/format.go index 3aa3048..9716caa 100644 --- a/pkg/cmd/format.go +++ b/pkg/cmd/format.go @@ -261,7 +261,7 @@ func randomSuffix(n int) string { // Returns an error if the identifier is ambiguous or not found. func ResolveInstance(ctx context.Context, client *hypeman.Client, identifier string) (string, error) { // List all instances - instances, err := client.Instances.List(ctx) + instances, err := client.Instances.List(ctx, hypeman.InstanceListParams{}) if err != nil { return "", fmt.Errorf("failed to list instances: %w", err) } diff --git a/pkg/cmd/inspect.go b/pkg/cmd/inspect.go new file mode 100644 index 0000000..6941dbc --- /dev/null +++ b/pkg/cmd/inspect.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var inspectCmd = cli.Command{ + Name: "inspect", + Usage: "Get instance details by ID or name", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "show-env", + Usage: "Show environment variable values (default: hidden)", + }, + }, + Action: handleInspect, + HideHelpCommand: true, +} + +func handleInspect(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 inspect ") + } + + 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) + } + + instance, err := client.Instances.Get(ctx, instanceID, opts...) + if err != nil { + return err + } + + if !cmd.Bool("show-env") { + instance.Env = redactEnvValues(instance.Env) + } + + raw, err := json.Marshal(instance) + if err != nil { + return fmt.Errorf("failed to encode instance response: %w", err) + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + obj := gjson.ParseBytes(raw) + return ShowJSON(os.Stdout, "instance inspect", obj, format, transform) +} + +func redactEnvValues(env map[string]string) map[string]string { + if len(env) == 0 { + return env + } + + redacted := make(map[string]string, len(env)) + for key := range env { + redacted[key] = "[hidden]" + } + + return redacted +} diff --git a/pkg/cmd/inspect_test.go b/pkg/cmd/inspect_test.go new file mode 100644 index 0000000..a9f3d9b --- /dev/null +++ b/pkg/cmd/inspect_test.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRedactEnvValues(t *testing.T) { + t.Run("returns empty map as-is", func(t *testing.T) { + var env map[string]string + assert.Nil(t, redactEnvValues(env)) + + empty := map[string]string{} + assert.Equal(t, empty, redactEnvValues(empty)) + }) + + t.Run("redacts all values and preserves keys", func(t *testing.T) { + env := map[string]string{ + "FOO": "bar", + "BAZ": "qux", + } + + redacted := redactEnvValues(env) + + assert.Equal(t, map[string]string{ + "FOO": "[hidden]", + "BAZ": "[hidden]", + }, redacted) + require.Equal(t, "bar", env["FOO"]) + require.Equal(t, "qux", env["BAZ"]) + }) +} diff --git a/pkg/cmd/ps.go b/pkg/cmd/ps.go index 2a957f2..739d6fb 100644 --- a/pkg/cmd/ps.go +++ b/pkg/cmd/ps.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "github.com/kernel/hypeman-go" "github.com/kernel/hypeman-go/option" @@ -24,6 +25,14 @@ var psCmd = cli.Command{ Aliases: []string{"q"}, Usage: "Only display instance IDs", }, + &cli.StringFlag{ + Name: "state", + 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)", + }, }, Action: handlePs, HideHelpCommand: true, @@ -37,8 +46,23 @@ func handlePs(ctx context.Context, cmd *cli.Command) error { opts = append(opts, debugMiddlewareOption) } + params := hypeman.InstanceListParams{} + stateFilter := cmd.String("state") + if stateFilter != "" { + 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) + } + if len(metadataFilters) > 0 { + params.Metadata = metadataFilters + } + instances, err := client.Instances.List( ctx, + params, opts..., ) if err != nil { @@ -47,11 +71,12 @@ func handlePs(ctx context.Context, cmd *cli.Command) error { showAll := cmd.Bool("all") quietMode := cmd.Bool("quiet") + serverSideFilterActive := stateFilter != "" || len(metadataFilters) > 0 - // Filter instances + // Filter instances client-side only when no server-side filter is active var filtered []hypeman.Instance for _, inst := range *instances { - if showAll || inst.State == "Running" { + if showAll || serverSideFilterActive || inst.State == "Running" { filtered = append(filtered, inst) } } @@ -66,7 +91,7 @@ func handlePs(ctx context.Context, cmd *cli.Command) error { // Table output if len(filtered) == 0 { - if !showAll { + if !showAll && !serverSideFilterActive { fmt.Fprintln(os.Stderr, "No running instances. Use -a to show all.") } return nil @@ -110,6 +135,8 @@ func formatHypervisor(hv hypeman.InstanceHypervisor) string { return "ch" case hypeman.InstanceHypervisorQemu: return "qemu" + case hypeman.InstanceHypervisorFirecracker: + return "fc" case hypeman.InstanceHypervisorVz: return "vz" default: @@ -119,3 +146,19 @@ 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 3e48553..65b8509 100644 --- a/pkg/cmd/ps_test.go +++ b/pkg/cmd/ps_test.go @@ -5,6 +5,7 @@ import ( "github.com/kernel/hypeman-go" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestFormatGPU(t *testing.T) { @@ -59,6 +60,11 @@ func TestFormatHypervisor(t *testing.T) { hypervisor: hypeman.InstanceHypervisorQemu, expected: "qemu", }, + { + name: "firecracker", + hypervisor: hypeman.InstanceHypervisorFirecracker, + expected: "fc", + }, { name: "empty defaults to ch", hypervisor: "", @@ -78,3 +84,36 @@ func TestFormatHypervisor(t *testing.T) { }) } } + +func TestParseMetadataFilters(t *testing.T) { + t.Run("parses valid entries", func(t *testing.T) { + metadata, malformed := parseMetadataFilters([]string{ + "team=backend", + "env=staging", + }) + + require.Empty(t, malformed) + assert.Equal(t, map[string]string{ + "team": "backend", + "env": "staging", + }, metadata) + }) + + t.Run("returns malformed entries and only valid metadata", func(t *testing.T) { + metadata, malformed := parseMetadataFilters([]string{ + "team=backend", + "missing-delimiter", + "=empty-key", + "region=us-east-1", + }) + + assert.Equal(t, map[string]string{ + "team": "backend", + "region": "us-east-1", + }, metadata) + assert.Equal(t, []string{ + "missing-delimiter", + "=empty-key", + }, malformed) + }) +} diff --git a/pkg/cmd/rm.go b/pkg/cmd/rm.go index 4d7884a..3066978 100644 --- a/pkg/cmd/rm.go +++ b/pkg/cmd/rm.go @@ -42,7 +42,7 @@ func handleRm(ctx context.Context, cmd *cli.Command) error { // If --all, get all instance IDs var identifiers []string if all { - instances, err := client.Instances.List(ctx) + instances, err := client.Instances.List(ctx, hypeman.InstanceListParams{}) if err != nil { return fmt.Errorf("failed to list instances: %w", err) } diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go index 771d557..2ebce80 100644 --- a/pkg/cmd/run.go +++ b/pkg/cmd/run.go @@ -84,7 +84,7 @@ Examples: // Hypervisor flag &cli.StringFlag{ Name: "hypervisor", - Usage: `Hypervisor to use: "cloud-hypervisor", "qemu", or "vz"`, + Usage: `Hypervisor to use: "cloud-hypervisor", "firecracker", "qemu", or "vz"`, }, // Resource limit flags &cli.StringFlag{ @@ -236,12 +236,14 @@ func handleRun(ctx context.Context, cmd *cli.Command) error { switch hypervisor { case "cloud-hypervisor", "ch": params.Hypervisor = hypeman.InstanceNewParamsHypervisorCloudHypervisor + case "firecracker", "fc": + params.Hypervisor = hypeman.InstanceNewParamsHypervisorFirecracker case "qemu": params.Hypervisor = hypeman.InstanceNewParamsHypervisorQemu case "vz": params.Hypervisor = hypeman.InstanceNewParamsHypervisorVz default: - return fmt.Errorf("invalid hypervisor: %s (must be 'cloud-hypervisor', 'qemu', or 'vz')", hypervisor) + return fmt.Errorf("invalid hypervisor: %s (must be 'cloud-hypervisor', 'firecracker', 'qemu', or 'vz')", hypervisor) } }