Skip to content
Open
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
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.23.0
github.com/kernel/hypeman-go v0.23.1-0.20260810220603-4761d6828471
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
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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.23.0 h1:07JJgYhApTAJFofx1iGRbiIn2O4un8Pm7L27WI4OiSM=
github.com/kernel/hypeman-go v0.23.0/go.mod h1:of8qI/nef2OPLzt0EMlIRbMdJHEvuc4yWG8g/ioNg48=
github.com/kernel/hypeman-go v0.23.1-0.20260810220603-4761d6828471 h1:cTNudg4k6HHBy6htfeNIGvn2r80p1DcrQ9mEBbKB2pM=
github.com/kernel/hypeman-go v0.23.1-0.20260810220603-4761d6828471/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=
Expand Down
94 changes: 70 additions & 24 deletions pkg/cmd/ingresscmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,20 @@ var ingressCreateCmd = cli.Command{
Name: "redirect-http",
Usage: "Auto-create HTTP to HTTPS redirect (only applies when --tls is enabled)",
},
&cli.StringFlag{
Name: "request-header-auth-header",
Usage: "Request header that must match before proxying (reserved authentication, cookie, host, framing, proxy, and hop-by-hop headers are not allowed)",
},
&cli.StringFlag{
Name: "request-header-auth-value",
Usage: "Exact header value required before proxying",
},
&cli.StringSliceFlag{
Name: "rule",
Usage: "Add a routing rule (can be repeated): hostname[:host-port]=instance:port[,tls][,redirect-http]. " +
Usage: "Add a routing rule (can be repeated): " + ingressRuleSpecFormat + ". " +
"Omit the instance to target the positional <instance>. When any --rule is given, the single-rule " +
"shorthand flags (--hostname/--port/--host-port/--tls/--redirect-http) must not be used.",
"shorthand flags (--hostname/--port/--host-port/--tls/--redirect-http/--request-header-auth-header/" +
"--request-header-auth-value) must not be used.",
},
&cli.StringFlag{
Name: "name",
Expand Down Expand Up @@ -120,7 +129,7 @@ func handleIngressCreate(ctx context.Context, cmd *cli.Command) error {
var rules []hypeman.IngressRuleParam
var primaryHostname string
if len(ruleSpecs) > 0 {
for _, flag := range []string{"hostname", "port", "host-port", "tls", "redirect-http"} {
for _, flag := range []string{"hostname", "port", "host-port", "tls", "redirect-http", "request-header-auth-header", "request-header-auth-value"} {
if cmd.IsSet(flag) {
return fmt.Errorf("--rule cannot be combined with --%s; provide all rules via --rule", flag)
}
Expand All @@ -141,20 +150,26 @@ func handleIngressCreate(ctx context.Context, cmd *cli.Command) error {
if !cmd.IsSet("port") {
return fmt.Errorf("--port is required (or use --rule)")
}
rules = []hypeman.IngressRuleParam{
{
Match: hypeman.IngressMatchParam{
Hostname: hostname,
Port: hypeman.Int(int64(cmd.Int("host-port"))),
},
Target: hypeman.IngressTargetParam{
Instance: instance,
Port: int64(cmd.Int("port")),
},
Tls: hypeman.Bool(cmd.Bool("tls")),
RedirectHTTP: hypeman.Bool(cmd.Bool("redirect-http")),
rule := hypeman.IngressRuleParam{
Match: hypeman.IngressMatchParam{
Hostname: hostname,
Port: hypeman.Int(int64(cmd.Int("host-port"))),
},
Target: hypeman.IngressTargetParam{
Instance: instance,
Port: int64(cmd.Int("port")),
},
Tls: hypeman.Bool(cmd.Bool("tls")),
RedirectHTTP: hypeman.Bool(cmd.Bool("redirect-http")),
}
auth, err := requestHeaderAuthFromFlags(cmd.String("request-header-auth-header"), cmd.String("request-header-auth-value"))
if err != nil {
return err
}
if auth != nil {
rule.RequestHeaderAuth = *auth
}
rules = []hypeman.IngressRuleParam{rule}
primaryHostname = hostname
}

Expand Down Expand Up @@ -328,13 +343,17 @@ func handleIngressDelete(ctx context.Context, cmd *cli.Command) error {
return nil
}

// parseIngressRuleSpec parses a routing rule specification string.
// Format: hostname[:host-port]=instance:port[,tls][,redirect-http]
// When the instance is omitted (e.g. "host:80=:8080"), fallbackInstance is used.
// ingressRuleSpecFormat documents the --rule grammar shared by the flag usage
// text and the parser error message.
const ingressRuleSpecFormat = "hostname[:host-port]=instance:port[,tls][,redirect-http][,request-header-auth=HEADER:VALUE]"

// parseIngressRuleSpec parses a routing rule specification string in the
// ingressRuleSpecFormat grammar. When the instance is omitted (e.g.
// "host:80=:8080"), fallbackInstance is used.
func parseIngressRuleSpec(spec, fallbackInstance string) (hypeman.IngressRuleParam, error) {
matchPart, targetPart, ok := strings.Cut(spec, "=")
if !ok {
return hypeman.IngressRuleParam{}, fmt.Errorf("expected format hostname[:host-port]=instance:port[,tls][,redirect-http]")
return hypeman.IngressRuleParam{}, fmt.Errorf("expected format %s", ingressRuleSpecFormat)
}

hostname, hostPortStr, hasHostPort := strings.Cut(matchPart, ":")
Expand Down Expand Up @@ -376,13 +395,23 @@ func parseIngressRuleSpec(spec, fallbackInstance string) (hypeman.IngressRulePar
}

for _, opt := range targetSegments[1:] {
switch opt {
case "tls":
switch {
case opt == "":
continue
case opt == "tls":
rule.Tls = hypeman.Bool(true)
case "redirect-http":
case opt == "redirect-http":
rule.RedirectHTTP = hypeman.Bool(true)
case "":
continue
case strings.HasPrefix(opt, requestHeaderAuthOption+"="):
header, value, _ := strings.Cut(strings.TrimPrefix(opt, requestHeaderAuthOption+"="), ":")
auth, err := requestHeaderAuthFromFlags(header, value)
if err != nil {
return hypeman.IngressRuleParam{}, err
}
if auth == nil {
return hypeman.IngressRuleParam{}, fmt.Errorf("%s must be HEADER:VALUE", requestHeaderAuthOption)
}
rule.RequestHeaderAuth = *auth
default:
return hypeman.IngressRuleParam{}, fmt.Errorf("unknown option %q", opt)
}
Expand All @@ -391,6 +420,23 @@ func parseIngressRuleSpec(spec, fallbackInstance string) (hypeman.IngressRulePar
return rule, nil
}

const requestHeaderAuthOption = "request-header-auth"

// requestHeaderAuthFromFlags builds the request header auth param, returning nil
// when neither half was supplied. Both halves are required by the API.
func requestHeaderAuthFromFlags(header, value string) (*hypeman.IngressRuleRequestHeaderAuthParam, error) {
if header == "" && value == "" {
return nil, nil
}
if header == "" {
return nil, fmt.Errorf("request header auth requires a header name")
}
if value == "" {
return nil, fmt.Errorf("request header auth requires a value for header %q", header)
}
return &hypeman.IngressRuleRequestHeaderAuthParam{Header: header, Value: value}, nil
}

// generateIngressName generates an ingress name from hostname
func generateIngressName(hostname string) string {
// Replace dots with dashes
Expand Down
52 changes: 51 additions & 1 deletion pkg/cmd/ingresscmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,33 @@ func TestParseIngressRuleSpec(t *testing.T) {
assert.Equal(t, int64(8080), rule.Target.Port)
})

t.Run("parses request header auth option", func(t *testing.T) {
rule, err := parseIngressRuleSpec("api.example.com=web:8080,tls,request-header-auth=X-Ingress-Token:s3cret", "fallback")
require.NoError(t, err)
assert.Equal(t, "X-Ingress-Token", rule.RequestHeaderAuth.Header)
assert.Equal(t, "s3cret", rule.RequestHeaderAuth.Value)
})

t.Run("keeps colons in the request header auth value", func(t *testing.T) {
rule, err := parseIngressRuleSpec("api.example.com=web:8080,request-header-auth=X-Token:user:pass", "fallback")
require.NoError(t, err)
assert.Equal(t, "X-Token", rule.RequestHeaderAuth.Header)
assert.Equal(t, "user:pass", rule.RequestHeaderAuth.Value)
})

t.Run("rejects request header auth without a value", func(t *testing.T) {
_, err := parseIngressRuleSpec("api.example.com=web:8080,request-header-auth=X-Token", "fallback")
require.EqualError(t, err, `request header auth requires a value for header "X-Token"`)
})

t.Run("rejects empty request header auth", func(t *testing.T) {
_, err := parseIngressRuleSpec("api.example.com=web:8080,request-header-auth=", "fallback")
require.EqualError(t, err, "request-header-auth must be HEADER:VALUE")
})

t.Run("rejects missing target separator", func(t *testing.T) {
_, err := parseIngressRuleSpec("api.example.com:80", "fallback")
require.EqualError(t, err, "expected format hostname[:host-port]=instance:port[,tls][,redirect-http]")
require.EqualError(t, err, "expected format "+ingressRuleSpecFormat)
})

t.Run("rejects empty hostname", func(t *testing.T) {
Expand All @@ -59,3 +83,29 @@ func TestParseIngressRuleSpec(t *testing.T) {
require.EqualError(t, err, `unknown option "gzip"`)
})
}

func TestRequestHeaderAuthFromFlags(t *testing.T) {
t.Run("returns nil when neither half is set", func(t *testing.T) {
auth, err := requestHeaderAuthFromFlags("", "")
require.NoError(t, err)
assert.Nil(t, auth)
})

t.Run("builds the param when both halves are set", func(t *testing.T) {
auth, err := requestHeaderAuthFromFlags("X-Ingress-Token", "s3cret")
require.NoError(t, err)
require.NotNil(t, auth)
assert.Equal(t, "X-Ingress-Token", auth.Header)
assert.Equal(t, "s3cret", auth.Value)
})

t.Run("rejects a value without a header", func(t *testing.T) {
_, err := requestHeaderAuthFromFlags("", "s3cret")
require.EqualError(t, err, "request header auth requires a header name")
})

t.Run("rejects a header without a value", func(t *testing.T) {
_, err := requestHeaderAuthFromFlags("X-Ingress-Token", "")
require.EqualError(t, err, `request header auth requires a value for header "X-Ingress-Token"`)
})
}
2 changes: 2 additions & 0 deletions pkg/cmd/ps.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ func formatHypervisor(hv hypeman.InstanceHypervisor) string {
return "ch"
case hypeman.InstanceHypervisorQemu:
return "qemu"
case hypeman.InstanceHypervisorQemuMicrovm:
return "microvm"
case hypeman.InstanceHypervisorFirecracker:
return "fc"
case hypeman.InstanceHypervisorVz:
Expand Down
5 changes: 5 additions & 0 deletions pkg/cmd/ps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ func TestFormatHypervisor(t *testing.T) {
hypervisor: hypeman.InstanceHypervisorQemu,
expected: "qemu",
},
{
name: "qemu-microvm",
hypervisor: hypeman.InstanceHypervisorQemuMicrovm,
expected: "microvm",
},
{
name: "firecracker",
hypervisor: hypeman.InstanceHypervisorFirecracker,
Expand Down
9 changes: 7 additions & 2 deletions pkg/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ Examples:
# Run with QEMU hypervisor
hypeman run --hypervisor qemu myimage:latest

# Run on QEMU's minimal microvm board
hypeman run --hypervisor qemu-microvm myimage:latest

# Run with bandwidth limits
hypeman run --bandwidth-down 1Gbps --bandwidth-up 500Mbps myimage:latest`,
Flags: []cli.Flag{
Expand Down Expand Up @@ -99,7 +102,7 @@ Examples:
// Hypervisor flag
&cli.StringFlag{
Name: "hypervisor",
Usage: `Hypervisor to use: "cloud-hypervisor", "firecracker", "qemu", or "vz"`,
Usage: `Hypervisor backend to use: "cloud-hypervisor", "firecracker", "qemu", "qemu-microvm", or "vz". qemu-microvm uses QEMU's minimal Linux amd64 board and does not support PCI devices, hotplug memory, or more than eight virtio-mmio devices`,
},
// Resource limit flags
&cli.StringFlag{
Expand Down Expand Up @@ -338,10 +341,12 @@ func handleRun(ctx context.Context, cmd *cli.Command) error {
params.Hypervisor = hypeman.InstanceNewParamsHypervisorFirecracker
case "qemu":
params.Hypervisor = hypeman.InstanceNewParamsHypervisorQemu
case "qemu-microvm", "microvm":
params.Hypervisor = hypeman.InstanceNewParamsHypervisorQemuMicrovm
case "vz":
params.Hypervisor = hypeman.InstanceNewParamsHypervisorVz
default:
return fmt.Errorf("invalid hypervisor: %s (must be 'cloud-hypervisor', 'firecracker', 'qemu', or 'vz')", hypervisor)
return fmt.Errorf("invalid hypervisor: %s (must be 'cloud-hypervisor', 'firecracker', 'qemu', 'qemu-microvm', or 'vz')", hypervisor)
}
}

Expand Down
12 changes: 8 additions & 4 deletions pkg/cmd/snapshotcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ var snapshotRestoreCmd = cli.Command{
Flags: []cli.Flag{
&cli.StringFlag{
Name: "target-hypervisor",
Usage: `Optional hypervisor override: "cloud-hypervisor", "firecracker", "qemu", or "vz"`,
Usage: `Optional hypervisor override: "cloud-hypervisor", "firecracker", "qemu", "qemu-microvm", or "vz"`,
},
&cli.StringFlag{
Name: "target-state",
Expand Down Expand Up @@ -136,7 +136,7 @@ var snapshotForkCmd = cli.Command{
Flags: []cli.Flag{
&cli.StringFlag{
Name: "target-hypervisor",
Usage: `Optional hypervisor override: "cloud-hypervisor", "firecracker", "qemu", or "vz"`,
Usage: `Optional hypervisor override: "cloud-hypervisor", "firecracker", "qemu", "qemu-microvm", or "vz"`,
},
&cli.StringFlag{
Name: "target-state",
Expand Down Expand Up @@ -509,10 +509,12 @@ func parseSnapshotTargetHypervisor(raw string) (hypeman.InstanceSnapshotRestoreP
return hypeman.InstanceSnapshotRestoreParamsTargetHypervisorFirecracker, nil
case "qemu":
return hypeman.InstanceSnapshotRestoreParamsTargetHypervisorQemu, nil
case "qemu-microvm", "microvm":
return hypeman.InstanceSnapshotRestoreParamsTargetHypervisorQemuMicrovm, nil
case "vz":
return hypeman.InstanceSnapshotRestoreParamsTargetHypervisorVz, nil
default:
return "", fmt.Errorf("invalid target hypervisor: %s (must be cloud-hypervisor, firecracker, qemu, or vz)", raw)
return "", fmt.Errorf("invalid target hypervisor: %s (must be cloud-hypervisor, firecracker, qemu, qemu-microvm, or vz)", raw)
}
}

Expand All @@ -537,9 +539,11 @@ func parseSnapshotForkTargetHypervisor(raw string) (hypeman.SnapshotForkParamsTa
return hypeman.SnapshotForkParamsTargetHypervisorFirecracker, nil
case "qemu":
return hypeman.SnapshotForkParamsTargetHypervisorQemu, nil
case "qemu-microvm", "microvm":
return hypeman.SnapshotForkParamsTargetHypervisorQemuMicrovm, nil
case "vz":
return hypeman.SnapshotForkParamsTargetHypervisorVz, nil
default:
return "", fmt.Errorf("invalid target hypervisor: %s (must be cloud-hypervisor, firecracker, qemu, or vz)", raw)
return "", fmt.Errorf("invalid target hypervisor: %s (must be cloud-hypervisor, firecracker, qemu, qemu-microvm, or vz)", raw)
}
}
59 changes: 59 additions & 0 deletions pkg/cmd/snapshotcmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"testing"

"github.com/kernel/hypeman-go"
"github.com/kernel/hypeman-go/shared"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -26,3 +27,61 @@ func TestParseSnapshotCompressionAlgorithm(t *testing.T) {
require.EqualError(t, err, "invalid compression algorithm: gzip (must be 'zstd' or 'lz4')")
})
}

func TestParseSnapshotTargetHypervisor(t *testing.T) {
tests := []struct {
raw string
expected hypeman.InstanceSnapshotRestoreParamsTargetHypervisor
}{
{"cloud-hypervisor", hypeman.InstanceSnapshotRestoreParamsTargetHypervisorCloudHypervisor},
{"ch", hypeman.InstanceSnapshotRestoreParamsTargetHypervisorCloudHypervisor},
{"firecracker", hypeman.InstanceSnapshotRestoreParamsTargetHypervisorFirecracker},
{"fc", hypeman.InstanceSnapshotRestoreParamsTargetHypervisorFirecracker},
{"qemu", hypeman.InstanceSnapshotRestoreParamsTargetHypervisorQemu},
{"qemu-microvm", hypeman.InstanceSnapshotRestoreParamsTargetHypervisorQemuMicrovm},
{"MicroVM", hypeman.InstanceSnapshotRestoreParamsTargetHypervisorQemuMicrovm},
{"vz", hypeman.InstanceSnapshotRestoreParamsTargetHypervisorVz},
}

for _, tt := range tests {
t.Run(tt.raw, func(t *testing.T) {
hypervisor, err := parseSnapshotTargetHypervisor(tt.raw)
require.NoError(t, err)
assert.Equal(t, tt.expected, hypervisor)
})
}

t.Run("rejects unsupported hypervisors", func(t *testing.T) {
_, err := parseSnapshotTargetHypervisor("xen")
require.EqualError(t, err, "invalid target hypervisor: xen (must be cloud-hypervisor, firecracker, qemu, qemu-microvm, or vz)")
})
}

func TestParseSnapshotForkTargetHypervisor(t *testing.T) {
tests := []struct {
raw string
expected hypeman.SnapshotForkParamsTargetHypervisor
}{
{"cloud-hypervisor", hypeman.SnapshotForkParamsTargetHypervisorCloudHypervisor},
{"ch", hypeman.SnapshotForkParamsTargetHypervisorCloudHypervisor},
{"firecracker", hypeman.SnapshotForkParamsTargetHypervisorFirecracker},
{"fc", hypeman.SnapshotForkParamsTargetHypervisorFirecracker},
{"qemu", hypeman.SnapshotForkParamsTargetHypervisorQemu},
{"qemu-microvm", hypeman.SnapshotForkParamsTargetHypervisorQemuMicrovm},
{"MicroVM", hypeman.SnapshotForkParamsTargetHypervisorQemuMicrovm},
{"vz", hypeman.SnapshotForkParamsTargetHypervisorVz},
}

for _, tt := range tests {
t.Run(tt.raw, func(t *testing.T) {
hypervisor, err := parseSnapshotForkTargetHypervisor(tt.raw)
require.NoError(t, err)
assert.Equal(t, tt.expected, hypervisor)
})
}

t.Run("rejects unsupported hypervisors", func(t *testing.T) {
_, err := parseSnapshotForkTargetHypervisor("xen")
require.EqualError(t, err, "invalid target hypervisor: xen (must be cloud-hypervisor, firecracker, qemu, qemu-microvm, or vz)")
})
}
Loading