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
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.24.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
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.24.0 h1:kWssdYGVmnzVAYJcfbowieCazGxITUebrYil+BEBfag=
github.com/kernel/hypeman-go v0.24.0/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
26 changes: 23 additions & 3 deletions pkg/cmd/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,29 @@ import (
)

var pushCmd = cli.Command{
Name: "push",
Usage: "Push a local Docker image to hypeman",
ArgsUsage: "<image> [target-name]",
Name: "push",
Aliases: []string{"pushes"},
Usage: "Push a local Docker image to hypeman",
ArgsUsage: "<image> [target-name]",
Description: `Push a local Docker image into the hypeman image cache.

Subcommands manage outbound pushes, which export a cached hypeman image to a
remote registry (e.g. AWS ECR, Docker Hub):
hypeman push create <image> <target> Push a hypeman image to a remote registry
hypeman push list List outbound image push jobs
hypeman push get <id> Get push details

Examples:
# Push a local Docker image into hypeman
hypeman push nginx:latest

# Export a cached hypeman image to a remote registry
hypeman push create nginx:latest registry.example.com/nginx:latest`,
Commands: []*cli.Command{
&pushCreateCmd,
&pushListCmd,
&pushGetCmd,
},
Action: handlePush,
HideHelpCommand: true,
}
Expand Down
Loading
Loading