Skip to content
Merged
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ dist/
.env
hypeman/**
bin/hypeman
demos/
.DS_Store
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
14 changes: 14 additions & 0 deletions pkg/cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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...)
Expand Down
42 changes: 22 additions & 20 deletions pkg/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
Expand Down
115 changes: 115 additions & 0 deletions pkg/cmd/fork.go
Original file line number Diff line number Diff line change
@@ -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: "<source> <name>",
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> <name>")
}

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"`
}
56 changes: 56 additions & 0 deletions pkg/cmd/fork_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
2 changes: 1 addition & 1 deletion pkg/cmd/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
79 changes: 79 additions & 0 deletions pkg/cmd/inspect.go
Original file line number Diff line number Diff line change
@@ -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: "<instance>",
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 <instance>")
}

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
}
Loading