diff --git a/lib/compose/desired.go b/lib/compose/desired.go index 37b769d..982ef00 100644 --- a/lib/compose/desired.go +++ b/lib/compose/desired.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "sort" - "strings" "github.com/kernel/hypeman-go" ) @@ -155,78 +154,45 @@ func updateDesiredInstanceImage(instances []desiredInstance, composeName, servic } func buildComposeRestartPolicy(restart *composeRestartSpec) hypeman.RestartPolicyParam { - policy := hypeman.RestartPolicyParam{} - if restart.Policy != "" { - policy.Policy = hypeman.RestartPolicyPolicy(strings.ReplaceAll(restart.Policy, "-", "_")) - } - if restart.Backoff != "" { - policy.Backoff = hypeman.String(restart.Backoff) + in := RestartPolicyInput{ + Policy: restart.Policy, + Backoff: restart.Backoff, + StableAfter: restart.StableAfter, } if restart.MaxAttempts > 0 { - policy.MaxAttempts = hypeman.Int(int64(restart.MaxAttempts)) - } - if restart.StableAfter != "" { - policy.StableAfter = hypeman.String(restart.StableAfter) + v := int64(restart.MaxAttempts) + in.MaxAttempts = &v } - return policy + return BuildRestartPolicyParam(in) } func buildComposeHealthCheck(check *composeCheckSpec) hypeman.HealthCheckParam { - health := hypeman.HealthCheckParam{} - if check.Type != "" { - health.Type = hypeman.HealthCheckType(strings.ToLower(check.Type)) + in := HealthCheckInput{ + Type: check.Type, + Interval: check.Interval, + Timeout: check.Timeout, + StartPeriod: check.StartPeriod, + FailureThreshold: int64(check.FailureThreshold), + SuccessThreshold: int64(check.SuccessThreshold), } if check.HTTP != nil { - health.Type = defaultHealthCheckType(health.Type, hypeman.HealthCheckTypeHTTP) - health.HTTP = hypeman.HealthCheckHTTPParam{ - Port: int64(check.HTTP.Port), - } - if check.HTTP.Path != "" { - health.HTTP.Path = hypeman.String(check.HTTP.Path) - } - if check.HTTP.Scheme != "" { - health.HTTP.Scheme = hypeman.HealthCheckHTTPScheme(strings.ToLower(check.HTTP.Scheme)) - } - if check.HTTP.ExpectedStatus > 0 { - health.HTTP.ExpectedStatus = hypeman.Int(int64(check.HTTP.ExpectedStatus)) + in.HTTP = &HealthCheckHTTPInput{ + Port: int64(check.HTTP.Port), + Path: check.HTTP.Path, + Scheme: check.HTTP.Scheme, + ExpectedStatus: int64(check.HTTP.ExpectedStatus), } } if check.TCP != nil { - health.Type = defaultHealthCheckType(health.Type, hypeman.HealthCheckTypeTcp) - health.Tcp = hypeman.HealthCheckTcpParam{Port: int64(check.TCP.Port)} + in.TCP = &HealthCheckTCPInput{Port: int64(check.TCP.Port)} } if check.Exec != nil { - health.Type = defaultHealthCheckType(health.Type, hypeman.HealthCheckTypeExec) - health.Exec = hypeman.HealthCheckExecParam{ - Command: check.Exec.Command, - } - if check.Exec.WorkingDir != "" { - health.Exec.WorkingDir = hypeman.String(check.Exec.WorkingDir) + in.Exec = &HealthCheckExecInput{ + Command: check.Exec.Command, + WorkingDir: check.Exec.WorkingDir, } } - if check.Interval != "" { - health.Interval = hypeman.String(check.Interval) - } - if check.Timeout != "" { - health.Timeout = hypeman.String(check.Timeout) - } - if check.StartPeriod != "" { - health.StartPeriod = hypeman.String(check.StartPeriod) - } - if check.FailureThreshold > 0 { - health.FailureThreshold = hypeman.Int(int64(check.FailureThreshold)) - } - if check.SuccessThreshold > 0 { - health.SuccessThreshold = hypeman.Int(int64(check.SuccessThreshold)) - } - return health -} - -func defaultHealthCheckType(current, fallback hypeman.HealthCheckType) hypeman.HealthCheckType { - if current != "" { - return current - } - return fallback + return BuildHealthCheckParam(in) } func buildComposeIngressInput(instanceName, ingressName string, spec composeIngressRuleSpec) hypeman.IngressNewParams { diff --git a/lib/compose/policy.go b/lib/compose/policy.go new file mode 100644 index 0000000..e07f7e9 --- /dev/null +++ b/lib/compose/policy.go @@ -0,0 +1,123 @@ +package compose + +import ( + "strings" + + "github.com/kernel/hypeman-go" +) + +// HealthCheckInput is a neutral, plain-value description of a workload health +// check. It is shared by compose, the imperative run command, and the update +// subcommands so the param-construction logic does not drift. +type HealthCheckInput struct { + Type string + Interval string + Timeout string + StartPeriod string + FailureThreshold int64 + SuccessThreshold int64 + HTTP *HealthCheckHTTPInput + TCP *HealthCheckTCPInput + Exec *HealthCheckExecInput +} + +type HealthCheckHTTPInput struct { + Port int64 + Path string + Scheme string + ExpectedStatus int64 +} + +type HealthCheckTCPInput struct { + Port int64 +} + +type HealthCheckExecInput struct { + Command []string + WorkingDir string +} + +// RestartPolicyInput is a neutral, plain-value description of a restart policy. +// MaxAttempts is a pointer so an explicit 0 (unlimited) is distinguishable from +// "not provided": nil omits the field, &0 sends an explicit 0. +type RestartPolicyInput struct { + Policy string + Backoff string + MaxAttempts *int64 + StableAfter string +} + +func BuildHealthCheckParam(in HealthCheckInput) hypeman.HealthCheckParam { + health := hypeman.HealthCheckParam{} + if in.Type != "" { + health.Type = hypeman.HealthCheckType(strings.ToLower(in.Type)) + } + if in.HTTP != nil { + health.Type = defaultHealthCheckType(health.Type, hypeman.HealthCheckTypeHTTP) + health.HTTP = hypeman.HealthCheckHTTPParam{ + Port: in.HTTP.Port, + } + if in.HTTP.Path != "" { + health.HTTP.Path = hypeman.String(in.HTTP.Path) + } + if in.HTTP.Scheme != "" { + health.HTTP.Scheme = hypeman.HealthCheckHTTPScheme(strings.ToLower(in.HTTP.Scheme)) + } + if in.HTTP.ExpectedStatus > 0 { + health.HTTP.ExpectedStatus = hypeman.Int(in.HTTP.ExpectedStatus) + } + } + if in.TCP != nil { + health.Type = defaultHealthCheckType(health.Type, hypeman.HealthCheckTypeTcp) + health.Tcp = hypeman.HealthCheckTcpParam{Port: in.TCP.Port} + } + if in.Exec != nil { + health.Type = defaultHealthCheckType(health.Type, hypeman.HealthCheckTypeExec) + health.Exec = hypeman.HealthCheckExecParam{ + Command: in.Exec.Command, + } + if in.Exec.WorkingDir != "" { + health.Exec.WorkingDir = hypeman.String(in.Exec.WorkingDir) + } + } + if in.Interval != "" { + health.Interval = hypeman.String(in.Interval) + } + if in.Timeout != "" { + health.Timeout = hypeman.String(in.Timeout) + } + if in.StartPeriod != "" { + health.StartPeriod = hypeman.String(in.StartPeriod) + } + if in.FailureThreshold > 0 { + health.FailureThreshold = hypeman.Int(in.FailureThreshold) + } + if in.SuccessThreshold > 0 { + health.SuccessThreshold = hypeman.Int(in.SuccessThreshold) + } + return health +} + +func BuildRestartPolicyParam(in RestartPolicyInput) hypeman.RestartPolicyParam { + policy := hypeman.RestartPolicyParam{} + if in.Policy != "" { + policy.Policy = hypeman.RestartPolicyPolicy(strings.ReplaceAll(in.Policy, "-", "_")) + } + if in.Backoff != "" { + policy.Backoff = hypeman.String(in.Backoff) + } + if in.MaxAttempts != nil { + policy.MaxAttempts = hypeman.Int(*in.MaxAttempts) + } + if in.StableAfter != "" { + policy.StableAfter = hypeman.String(in.StableAfter) + } + return policy +} + +func defaultHealthCheckType(current, fallback hypeman.HealthCheckType) hypeman.HealthCheckType { + if current != "" { + return current + } + return fallback +} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index ce12444..2129b93 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -92,6 +92,7 @@ func init() { &snapshotCmd, &volumeCmd, &resourcesCmd, + &healthCmd, &deviceCmd, &composeCmd, { diff --git a/pkg/cmd/healthcmd.go b/pkg/cmd/healthcmd.go new file mode 100644 index 0000000..e5205d7 --- /dev/null +++ b/pkg/cmd/healthcmd.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var healthCmd = cli.Command{ + Name: "health", + Usage: "Check API server health", + Description: `Report the health of the hypeman API server. + +Examples: + # Check health (default) + hypeman health + + # Check health as JSON + hypeman health --format json`, + Action: handleHealth, + HideHelpCommand: true, +} + +func handleHealth(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.Health.Check(ctx, opts...) + if err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + obj := gjson.ParseBytes(res) + + if format == "auto" || format == "" { + fmt.Println(obj.Get("status").String()) + return nil + } + + return ShowJSON(os.Stdout, "health", obj, format, transform) +} diff --git a/pkg/cmd/ingresscmd.go b/pkg/cmd/ingresscmd.go index b61dca2..2a76098 100644 --- a/pkg/cmd/ingresscmd.go +++ b/pkg/cmd/ingresscmd.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strconv" "strings" "github.com/kernel/hypeman-go" @@ -31,16 +32,14 @@ var ingressCreateCmd = cli.Command{ ArgsUsage: "", Flags: []cli.Flag{ &cli.StringFlag{ - Name: "hostname", - Aliases: []string{"H"}, - Usage: "Hostname to match (exact match on Host header)", - Required: true, + Name: "hostname", + Aliases: []string{"H"}, + Usage: "Hostname to match (exact match on Host header)", }, &cli.IntFlag{ - Name: "port", - Aliases: []string{"p"}, - Usage: "Target port on the instance", - Required: true, + Name: "port", + Aliases: []string{"p"}, + Usage: "Target port on the instance", }, &cli.IntFlag{ Name: "host-port", @@ -55,6 +54,12 @@ var ingressCreateCmd = cli.Command{ Name: "redirect-http", Usage: "Auto-create HTTP to HTTPS redirect (only applies when --tls is enabled)", }, + &cli.StringSliceFlag{ + Name: "rule", + Usage: "Add a routing rule (can be repeated): hostname[:host-port]=instance:port[,tls][,redirect-http]. " + + "Omit the instance to target the positional . When any --rule is given, the single-rule " + + "shorthand flags (--hostname/--port/--host-port/--tls/--redirect-http) must not be used.", + }, &cli.StringFlag{ Name: "name", Usage: "Ingress name (auto-generated from hostname if not provided)", @@ -109,16 +114,53 @@ func handleIngressCreate(ctx context.Context, cmd *cli.Command) error { } instance := args[0] - hostname := cmd.String("hostname") - port := cmd.Int("port") - hostPort := cmd.Int("host-port") - tls := cmd.Bool("tls") - redirectHTTP := cmd.Bool("redirect-http") name := cmd.String("name") + ruleSpecs := cmd.StringSlice("rule") + var rules []hypeman.IngressRuleParam + var primaryHostname string + if len(ruleSpecs) > 0 { + for _, flag := range []string{"hostname", "port", "host-port", "tls", "redirect-http"} { + if cmd.IsSet(flag) { + return fmt.Errorf("--rule cannot be combined with --%s; provide all rules via --rule", flag) + } + } + for _, spec := range ruleSpecs { + rule, err := parseIngressRuleSpec(spec, instance) + if err != nil { + return fmt.Errorf("invalid rule %q: %w", spec, err) + } + rules = append(rules, rule) + } + primaryHostname = rules[0].Match.Hostname + } else { + hostname := cmd.String("hostname") + if hostname == "" { + return fmt.Errorf("--hostname is required (or use --rule)") + } + 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")), + }, + } + primaryHostname = hostname + } + // Auto-generate name from hostname if not provided if name == "" { - name = generateIngressName(hostname) + name = generateIngressName(primaryHostname) } client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) @@ -129,21 +171,8 @@ func handleIngressCreate(ctx context.Context, cmd *cli.Command) error { } params := hypeman.IngressNewParams{ - Name: name, - Rules: []hypeman.IngressRuleParam{ - { - Match: hypeman.IngressMatchParam{ - Hostname: hostname, - Port: hypeman.Int(int64(hostPort)), - }, - Target: hypeman.IngressTargetParam{ - Instance: instance, - Port: int64(port), - }, - Tls: hypeman.Bool(tls), - RedirectHTTP: hypeman.Bool(redirectHTTP), - }, - }, + Name: name, + Rules: rules, } tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) for _, malformed := range malformedTags { @@ -299,6 +328,69 @@ 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. +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]") + } + + hostname, hostPortStr, hasHostPort := strings.Cut(matchPart, ":") + if hostname == "" { + return hypeman.IngressRuleParam{}, fmt.Errorf("hostname cannot be empty") + } + hostPort := int64(80) + if hasHostPort { + parsed, err := strconv.ParseInt(hostPortStr, 10, 64) + if err != nil { + return hypeman.IngressRuleParam{}, fmt.Errorf("invalid host port %q: %w", hostPortStr, err) + } + hostPort = parsed + } + + targetSegments := strings.Split(targetPart, ",") + targetSpec := targetSegments[0] + targetInstance, portStr, ok := strings.Cut(targetSpec, ":") + if !ok { + return hypeman.IngressRuleParam{}, fmt.Errorf("target must be instance:port") + } + if targetInstance == "" { + targetInstance = fallbackInstance + } + port, err := strconv.ParseInt(portStr, 10, 64) + if err != nil { + return hypeman.IngressRuleParam{}, fmt.Errorf("invalid target port %q: %w", portStr, err) + } + + rule := hypeman.IngressRuleParam{ + Match: hypeman.IngressMatchParam{ + Hostname: hostname, + Port: hypeman.Int(hostPort), + }, + Target: hypeman.IngressTargetParam{ + Instance: targetInstance, + Port: port, + }, + } + + for _, opt := range targetSegments[1:] { + switch opt { + case "tls": + rule.Tls = hypeman.Bool(true) + case "redirect-http": + rule.RedirectHTTP = hypeman.Bool(true) + case "": + continue + default: + return hypeman.IngressRuleParam{}, fmt.Errorf("unknown option %q", opt) + } + } + + return rule, nil +} + // generateIngressName generates an ingress name from hostname func generateIngressName(hostname string) string { // Replace dots with dashes diff --git a/pkg/cmd/ingresscmd_test.go b/pkg/cmd/ingresscmd_test.go new file mode 100644 index 0000000..e125475 --- /dev/null +++ b/pkg/cmd/ingresscmd_test.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseIngressRuleSpec(t *testing.T) { + t.Run("full spec with host port, tls, and redirect", func(t *testing.T) { + rule, err := parseIngressRuleSpec("api.example.com:443=web:8080,tls,redirect-http", "fallback") + require.NoError(t, err) + assert.Equal(t, "api.example.com", rule.Match.Hostname) + assert.Equal(t, int64(443), rule.Match.Port.Value) + assert.Equal(t, "web", rule.Target.Instance) + assert.Equal(t, int64(8080), rule.Target.Port) + assert.True(t, rule.Tls.Value) + assert.True(t, rule.RedirectHTTP.Value) + }) + + t.Run("defaults host port to 80 when omitted", func(t *testing.T) { + rule, err := parseIngressRuleSpec("api.example.com=web:8080", "fallback") + require.NoError(t, err) + assert.Equal(t, int64(80), rule.Match.Port.Value) + assert.False(t, rule.Tls.Valid()) + assert.False(t, rule.RedirectHTTP.Valid()) + }) + + t.Run("falls back to positional instance when omitted", func(t *testing.T) { + rule, err := parseIngressRuleSpec("api.example.com:80=:8080", "fallback") + require.NoError(t, err) + assert.Equal(t, "fallback", rule.Target.Instance) + assert.Equal(t, int64(8080), rule.Target.Port) + }) + + 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]") + }) + + t.Run("rejects empty hostname", func(t *testing.T) { + _, err := parseIngressRuleSpec("=web:8080", "fallback") + require.EqualError(t, err, "hostname cannot be empty") + }) + + t.Run("rejects target without port", func(t *testing.T) { + _, err := parseIngressRuleSpec("api.example.com=web", "fallback") + require.EqualError(t, err, "target must be instance:port") + }) + + t.Run("rejects non-numeric target port", func(t *testing.T) { + _, err := parseIngressRuleSpec("api.example.com=web:http", "fallback") + require.ErrorContains(t, err, `invalid target port "http"`) + }) + + t.Run("rejects unknown option", func(t *testing.T) { + _, err := parseIngressRuleSpec("api.example.com=web:8080,gzip", "fallback") + require.EqualError(t, err, `unknown option "gzip"`) + }) +} diff --git a/pkg/cmd/policyflags.go b/pkg/cmd/policyflags.go new file mode 100644 index 0000000..d3aa66d --- /dev/null +++ b/pkg/cmd/policyflags.go @@ -0,0 +1,175 @@ +package cmd + +import ( + "fmt" + + "github.com/kernel/hypeman-cli/lib/compose" + "github.com/urfave/cli/v3" +) + +func healthCheckFlags(prefix string) []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: prefix + "type", + Usage: `Health probe type: "none", "http", "tcp", or "exec"`, + }, + &cli.StringFlag{ + Name: prefix + "interval", + Usage: `Delay between checks (e.g., "10s")`, + }, + &cli.StringFlag{ + Name: prefix + "timeout", + Usage: `Per-check timeout (e.g., "2s")`, + }, + &cli.StringFlag{ + Name: prefix + "start-period", + Usage: `Startup grace period before failures count (e.g., "30s")`, + }, + &cli.IntFlag{ + Name: prefix + "failure-threshold", + Usage: "Consecutive failed checks required to mark the workload unhealthy", + }, + &cli.IntFlag{ + Name: prefix + "success-threshold", + Usage: "Consecutive successful checks required to mark the workload healthy", + }, + &cli.IntFlag{ + Name: prefix + "http-port", + Usage: "Port to probe for an HTTP health check", + }, + &cli.StringFlag{ + Name: prefix + "http-path", + Usage: "HTTP path to request for an HTTP health check", + }, + &cli.StringFlag{ + Name: prefix + "http-scheme", + Usage: `HTTP scheme for an HTTP health check: "http" or "https"`, + }, + &cli.IntFlag{ + Name: prefix + "http-expected-status", + Usage: "Exact status code required for a successful HTTP probe", + }, + &cli.IntFlag{ + Name: prefix + "tcp-port", + Usage: "Port to open for a TCP health check", + }, + &cli.StringSliceFlag{ + Name: prefix + "exec", + Usage: "Command and arguments for an exec health check (can be repeated)", + }, + &cli.StringFlag{ + Name: prefix + "exec-working-dir", + Usage: "Working directory for an exec health check", + }, + } +} + +func restartPolicyFlags(prefix string) []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: prefix + "policy", + Usage: `Restart behavior: "never", "always", or "on_failure"`, + }, + &cli.StringFlag{ + Name: prefix + "backoff", + Usage: `Delay before each restart attempt (e.g., "5s")`, + }, + &cli.IntFlag{ + Name: prefix + "max-attempts", + Usage: "Consecutive restart attempts before blocking retries (0 means unlimited)", + }, + &cli.StringFlag{ + Name: prefix + "stable-after", + Usage: `Running this long resets the consecutive restart attempt count (e.g., "10m")`, + }, + } +} + +func parseHealthCheckInput(cmd *cli.Command, prefix string) (compose.HealthCheckInput, bool, error) { + typeFlag := prefix + "type" + intervalFlag := prefix + "interval" + timeoutFlag := prefix + "timeout" + startPeriodFlag := prefix + "start-period" + failureThresholdFlag := prefix + "failure-threshold" + successThresholdFlag := prefix + "success-threshold" + httpPortFlag := prefix + "http-port" + httpPathFlag := prefix + "http-path" + httpSchemeFlag := prefix + "http-scheme" + httpExpectedStatusFlag := prefix + "http-expected-status" + tcpPortFlag := prefix + "tcp-port" + execFlag := prefix + "exec" + execWorkingDirFlag := prefix + "exec-working-dir" + + // A probe sub-block is engaged by its required-field flag (http-port, tcp-port, + // exec command); those fields are api:"required", so a secondary flag alone + // (--http-path/-scheme/-expected-status or --exec-working-dir) would build a probe + // with a zero/empty required value. Reject that explicitly. + httpSet := cmd.IsSet(httpPortFlag) || cmd.IsSet(httpPathFlag) || cmd.IsSet(httpSchemeFlag) || cmd.IsSet(httpExpectedStatusFlag) + if httpSet && !cmd.IsSet(httpPortFlag) { + return compose.HealthCheckInput{}, false, fmt.Errorf("--%shttp-port is required when configuring an HTTP health check", prefix) + } + if cmd.IsSet(execWorkingDirFlag) && !cmd.IsSet(execFlag) { + return compose.HealthCheckInput{}, false, fmt.Errorf("--%sexec is required when setting --%sexec-working-dir", prefix, prefix) + } + tcpSet := cmd.IsSet(tcpPortFlag) + execSet := cmd.IsSet(execFlag) + + set := cmd.IsSet(typeFlag) || cmd.IsSet(intervalFlag) || cmd.IsSet(timeoutFlag) || + cmd.IsSet(startPeriodFlag) || cmd.IsSet(failureThresholdFlag) || cmd.IsSet(successThresholdFlag) || + httpSet || tcpSet || execSet + if !set { + return compose.HealthCheckInput{}, false, nil + } + + in := compose.HealthCheckInput{ + Type: cmd.String(typeFlag), + Interval: cmd.String(intervalFlag), + Timeout: cmd.String(timeoutFlag), + StartPeriod: cmd.String(startPeriodFlag), + FailureThreshold: int64(cmd.Int(failureThresholdFlag)), + SuccessThreshold: int64(cmd.Int(successThresholdFlag)), + } + if httpSet { + in.HTTP = &compose.HealthCheckHTTPInput{ + Port: int64(cmd.Int(httpPortFlag)), + Path: cmd.String(httpPathFlag), + Scheme: cmd.String(httpSchemeFlag), + ExpectedStatus: int64(cmd.Int(httpExpectedStatusFlag)), + } + } + if tcpSet { + in.TCP = &compose.HealthCheckTCPInput{Port: int64(cmd.Int(tcpPortFlag))} + } + if execSet { + in.Exec = &compose.HealthCheckExecInput{ + Command: cmd.StringSlice(execFlag), + WorkingDir: cmd.String(execWorkingDirFlag), + } + } + return in, true, nil +} + +func parseRestartPolicyInput(cmd *cli.Command, prefix string) (compose.RestartPolicyInput, bool) { + policyFlag := prefix + "policy" + backoffFlag := prefix + "backoff" + maxAttemptsFlag := prefix + "max-attempts" + stableAfterFlag := prefix + "stable-after" + + set := cmd.IsSet(policyFlag) || cmd.IsSet(backoffFlag) || cmd.IsSet(maxAttemptsFlag) || cmd.IsSet(stableAfterFlag) + if !set { + return compose.RestartPolicyInput{}, false + } + + in := compose.RestartPolicyInput{ + Policy: cmd.String(policyFlag), + Backoff: cmd.String(backoffFlag), + StableAfter: cmd.String(stableAfterFlag), + } + // Send max_attempts only when explicitly provided, so a PATCH with --max-attempts 0 + // clears the limit to unlimited rather than being omitted (a no-op) by omitzero. + if cmd.IsSet(maxAttemptsFlag) { + v := int64(cmd.Int(maxAttemptsFlag)) + in.MaxAttempts = &v + } + return in, true +} diff --git a/pkg/cmd/policyflags_test.go b/pkg/cmd/policyflags_test.go new file mode 100644 index 0000000..38ff36e --- /dev/null +++ b/pkg/cmd/policyflags_test.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/kernel/hypeman-cli/lib/compose" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func runHealthParse(t *testing.T, args ...string) (compose.HealthCheckInput, bool, error) { + t.Helper() + var ( + gotIn compose.HealthCheckInput + gotSet bool + gotErr error + ) + cmd := &cli.Command{ + Name: "x", + Flags: healthCheckFlags(""), + Action: func(_ context.Context, c *cli.Command) error { + gotIn, gotSet, gotErr = parseHealthCheckInput(c, "") + return nil + }, + } + require.NoError(t, cmd.Run(context.Background(), append([]string{"x"}, args...))) + return gotIn, gotSet, gotErr +} + +func TestParseHealthCheckInput(t *testing.T) { + t.Run("http flags without http-port are rejected", func(t *testing.T) { + _, _, err := runHealthParse(t, "--http-path", "/healthz") + require.Error(t, err) + assert.Contains(t, err.Error(), "http-port is required") + }) + + t.Run("http-port engages the http probe", func(t *testing.T) { + in, set, err := runHealthParse(t, "--http-port", "8080", "--http-path", "/healthz") + require.NoError(t, err) + require.True(t, set) + require.NotNil(t, in.HTTP) + assert.Equal(t, int64(8080), in.HTTP.Port) + assert.Equal(t, "/healthz", in.HTTP.Path) + }) + + t.Run("exec-working-dir without exec is rejected", func(t *testing.T) { + _, _, err := runHealthParse(t, "--exec-working-dir", "/srv") + require.Error(t, err) + assert.Contains(t, err.Error(), "exec is required") + }) + + t.Run("no flags reports unset without error", func(t *testing.T) { + _, set, err := runHealthParse(t) + require.NoError(t, err) + assert.False(t, set) + }) +} + +func runRestartParse(t *testing.T, args ...string) (*int64, bool) { + t.Helper() + var ( + gotMax *int64 + gotSet bool + ) + cmd := &cli.Command{ + Name: "x", + Flags: restartPolicyFlags(""), + Action: func(_ context.Context, c *cli.Command) error { + in, set := parseRestartPolicyInput(c, "") + gotMax, gotSet = in.MaxAttempts, set + return nil + }, + } + require.NoError(t, cmd.Run(context.Background(), append([]string{"x"}, args...))) + return gotMax, gotSet +} + +func TestParseRestartPolicyInputMaxAttempts(t *testing.T) { + t.Run("explicit 0 is sent (unlimited)", func(t *testing.T) { + max, set := runRestartParse(t, "--max-attempts", "0") + require.True(t, set) + require.NotNil(t, max) + assert.Equal(t, int64(0), *max) + }) + + t.Run("omitted when not provided", func(t *testing.T) { + max, set := runRestartParse(t, "--policy", "always") + require.True(t, set) + assert.Nil(t, max) + }) +} diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go index 5251557..2c39e14 100644 --- a/pkg/cmd/run.go +++ b/pkg/cmd/run.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/kernel/hypeman-cli/lib/compose" "github.com/kernel/hypeman-go" "github.com/kernel/hypeman-go/option" "github.com/kernel/hypeman-go/shared" @@ -188,6 +189,11 @@ Examples: HideHelpCommand: true, } +func init() { + runCmd.Flags = append(runCmd.Flags, healthCheckFlags("health-")...) + runCmd.Flags = append(runCmd.Flags, restartPolicyFlags("restart-")...) +} + func handleRun(ctx context.Context, cmd *cli.Command) error { args := cmd.Args().Slice() if len(args) < 1 { @@ -267,6 +273,16 @@ func handleRun(ctx context.Context, cmd *cli.Command) error { if autoStandbySet { params.AutoStandby = autoStandbyPolicy } + healthInput, healthOk, err := parseHealthCheckInput(cmd, "health-") + if err != nil { + return err + } + if healthOk { + params.HealthCheck = compose.BuildHealthCheckParam(healthInput) + } + if restartInput, ok := parseRestartPolicyInput(cmd, "restart-"); ok { + params.RestartPolicy = compose.BuildRestartPolicyParam(restartInput) + } // Network configuration networkEnabled := cmd.Bool("network") diff --git a/pkg/cmd/update.go b/pkg/cmd/update.go index 4cd1587..447f438 100644 --- a/pkg/cmd/update.go +++ b/pkg/cmd/update.go @@ -5,6 +5,7 @@ import ( "fmt" "os" + "github.com/kernel/hypeman-cli/lib/compose" "github.com/kernel/hypeman-go" "github.com/kernel/hypeman-go/option" "github.com/tidwall/gjson" @@ -18,10 +19,14 @@ var updateCmd = cli.Command{ Currently supported: hypeman update auto-standby --enabled --idle-timeout 10m - hypeman update egress-credentials --env KEY=VALUE`, + hypeman update egress-credentials --env KEY=VALUE + hypeman update health-check --type http --http-port 8080 + hypeman update restart-policy --policy on_failure --max-attempts 5`, Commands: []*cli.Command{ &updateAutoStandbyCmd, &updateEgressCredentialsCmd, + &updateHealthCheckCmd, + &updateRestartPolicyCmd, }, HideHelpCommand: true, } @@ -67,6 +72,24 @@ var updateEgressCredentialsCmd = cli.Command{ HideHelpCommand: true, } +var updateHealthCheckCmd = cli.Command{ + Name: "health-check", + Usage: "Update the workload health check policy for an instance", + ArgsUsage: "", + Flags: healthCheckFlags(""), + Action: handleUpdateHealthCheck, + HideHelpCommand: true, +} + +var updateRestartPolicyCmd = cli.Command{ + Name: "restart-policy", + Usage: "Update the restart supervision policy for an instance", + ArgsUsage: "", + Flags: restartPolicyFlags(""), + Action: handleUpdateRestartPolicy, + HideHelpCommand: true, +} + func handleUpdateAutoStandby(ctx context.Context, cmd *cli.Command) error { args := cmd.Args().Slice() if len(args) < 1 { @@ -120,6 +143,109 @@ func handleUpdateAutoStandby(ctx context.Context, cmd *cli.Command) error { return nil } +func handleUpdateHealthCheck(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 update health-check [flags]") + } + + input, set, err := parseHealthCheckInput(cmd, "") + if err != nil { + return err + } + if !set { + return fmt.Errorf("at least one health-check flag is required") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + params := hypeman.InstanceUpdateParams{ + HealthCheck: compose.BuildHealthCheckParam(input), + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + if format != "auto" { + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Instances.Update(ctx, instanceID, params, opts...) + if err != nil { + return err + } + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "update health-check", obj, format, transform) + } + + fmt.Fprintf(os.Stderr, "Updating health-check for %s...\n", args[0]) + + instance, err := client.Instances.Update(ctx, instanceID, params, opts...) + if err != nil { + return err + } + fmt.Println(instance.ID) + return nil +} + +func handleUpdateRestartPolicy(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 update restart-policy [flags]") + } + + input, set := parseRestartPolicyInput(cmd, "") + if !set { + return fmt.Errorf("at least one restart-policy flag is required") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + params := hypeman.InstanceUpdateParams{ + RestartPolicy: compose.BuildRestartPolicyParam(input), + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + if format != "auto" { + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Instances.Update(ctx, instanceID, params, opts...) + if err != nil { + return err + } + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "update restart-policy", obj, format, transform) + } + + fmt.Fprintf(os.Stderr, "Updating restart-policy for %s...\n", args[0]) + + instance, err := client.Instances.Update(ctx, instanceID, params, opts...) + if err != nil { + return err + } + fmt.Println(instance.ID) + return nil +} + func handleUpdate(ctx context.Context, cmd *cli.Command) error { args := cmd.Args().Slice() if len(args) < 1 { diff --git a/pkg/cmd/volumecmd.go b/pkg/cmd/volumecmd.go index 43dcea2..f9b6fe2 100644 --- a/pkg/cmd/volumecmd.go +++ b/pkg/cmd/volumecmd.go @@ -3,10 +3,12 @@ package cmd import ( "context" "fmt" + "io" "os" "github.com/kernel/hypeman-go" "github.com/kernel/hypeman-go/option" + "github.com/kernel/hypeman-go/packages/param" "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) @@ -48,6 +50,10 @@ var volumeCreateCmd = cli.Command{ Name: "tag", Usage: "Set volume tag key-value pair (KEY=VALUE, can be repeated)", }, + &cli.StringFlag{ + Name: "from-archive", + Usage: "Pre-populate the volume from a tar.gz archive (path, or - for stdin)", + }, }, Action: handleVolumeCreate, HideHelpCommand: true, @@ -132,20 +138,19 @@ var volumeDetachCmd = cli.Command{ func handleVolumeCreate(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) - params := hypeman.VolumeNewParams{ - Name: cmd.String("name"), - SizeGB: int64(cmd.Int("size")), - } + name := cmd.String("name") + sizeGB := int64(cmd.Int("size")) - if id := cmd.String("id"); id != "" { - params.ID = hypeman.Opt(id) + var id param.Opt[string] + if v := cmd.String("id"); v != "" { + id = hypeman.Opt(v) } tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) for _, malformed := range malformedTags { fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed) } - if len(tags) > 0 { - params.Tags = tags + if len(tags) == 0 { + tags = nil } var opts []option.RequestOption @@ -155,9 +160,36 @@ func handleVolumeCreate(ctx context.Context, cmd *cli.Command) error { var res []byte opts = append(opts, option.WithResponseBodyInto(&res)) - _, err := client.Volumes.New(ctx, params, opts...) - if err != nil { - return err + + if archive := cmd.String("from-archive"); archive != "" { + body := io.Reader(os.Stdin) + if archive != "-" { + file, err := os.Open(archive) + if err != nil { + return err + } + defer file.Close() + body = file + } + params := hypeman.VolumeNewFromArchiveParams{ + Name: name, + SizeGB: sizeGB, + ID: id, + Tags: tags, + } + if _, err := client.Volumes.NewFromArchive(ctx, body, params, opts...); err != nil { + return err + } + } else { + params := hypeman.VolumeNewParams{ + Name: name, + SizeGB: sizeGB, + ID: id, + Tags: tags, + } + if _, err := client.Volumes.New(ctx, params, opts...); err != nil { + return err + } } format := cmd.Root().String("format")