Skip to content

Commit aa0dfd3

Browse files
rgarciaclaude
andauthored
Expose remaining hypeman-go SDK surface in the CLI (#56)
* Expose remaining hypeman-go SDK surface in the CLI Closes the gap between the SDK's published methods/params and what the CLI exposed. The CLI previously called 50 of 52 SDK methods and left several params (health-check, restart-policy, multi-rule ingress) reachable only via compose. - add `update health-check` and `update restart-policy` subcommands - add `run --health-* / --restart-*` flags for imperative parity with compose - share one health-check/restart-policy mapping path across compose, run, and update (new lib/compose/policy.go) so the logic can't drift - add `volume create --from-archive <path|->` (Volumes.NewFromArchive) - add repeatable `ingress create --rule` for multi-rule ingresses - add top-level `health` command (Health.Check) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Fix HTTP-probe zero-port and restart max-attempts PATCH semantics - require --http-port (and --exec) before engaging the HTTP/exec health probe, so a secondary flag alone (--http-path/-scheme/-expected-status, --exec-working-dir) can't build a probe with a zero/empty api:"required" field - send restart max_attempts only when explicitly set, so `--max-attempts 0` (documented as unlimited) clears the limit on a PATCH instead of being omitted as a no-op; compose's omit-when-zero behavior is preserved - add policyflags tests for both Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: rgarcia <72655+rgarcia@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2f1db7f commit aa0dfd3

11 files changed

Lines changed: 839 additions & 99 deletions

File tree

lib/compose/desired.go

Lines changed: 24 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"encoding/json"
77
"fmt"
88
"sort"
9-
"strings"
109

1110
"github.com/kernel/hypeman-go"
1211
)
@@ -155,78 +154,45 @@ func updateDesiredInstanceImage(instances []desiredInstance, composeName, servic
155154
}
156155

157156
func buildComposeRestartPolicy(restart *composeRestartSpec) hypeman.RestartPolicyParam {
158-
policy := hypeman.RestartPolicyParam{}
159-
if restart.Policy != "" {
160-
policy.Policy = hypeman.RestartPolicyPolicy(strings.ReplaceAll(restart.Policy, "-", "_"))
161-
}
162-
if restart.Backoff != "" {
163-
policy.Backoff = hypeman.String(restart.Backoff)
157+
in := RestartPolicyInput{
158+
Policy: restart.Policy,
159+
Backoff: restart.Backoff,
160+
StableAfter: restart.StableAfter,
164161
}
165162
if restart.MaxAttempts > 0 {
166-
policy.MaxAttempts = hypeman.Int(int64(restart.MaxAttempts))
167-
}
168-
if restart.StableAfter != "" {
169-
policy.StableAfter = hypeman.String(restart.StableAfter)
163+
v := int64(restart.MaxAttempts)
164+
in.MaxAttempts = &v
170165
}
171-
return policy
166+
return BuildRestartPolicyParam(in)
172167
}
173168

174169
func buildComposeHealthCheck(check *composeCheckSpec) hypeman.HealthCheckParam {
175-
health := hypeman.HealthCheckParam{}
176-
if check.Type != "" {
177-
health.Type = hypeman.HealthCheckType(strings.ToLower(check.Type))
170+
in := HealthCheckInput{
171+
Type: check.Type,
172+
Interval: check.Interval,
173+
Timeout: check.Timeout,
174+
StartPeriod: check.StartPeriod,
175+
FailureThreshold: int64(check.FailureThreshold),
176+
SuccessThreshold: int64(check.SuccessThreshold),
178177
}
179178
if check.HTTP != nil {
180-
health.Type = defaultHealthCheckType(health.Type, hypeman.HealthCheckTypeHTTP)
181-
health.HTTP = hypeman.HealthCheckHTTPParam{
182-
Port: int64(check.HTTP.Port),
183-
}
184-
if check.HTTP.Path != "" {
185-
health.HTTP.Path = hypeman.String(check.HTTP.Path)
186-
}
187-
if check.HTTP.Scheme != "" {
188-
health.HTTP.Scheme = hypeman.HealthCheckHTTPScheme(strings.ToLower(check.HTTP.Scheme))
189-
}
190-
if check.HTTP.ExpectedStatus > 0 {
191-
health.HTTP.ExpectedStatus = hypeman.Int(int64(check.HTTP.ExpectedStatus))
179+
in.HTTP = &HealthCheckHTTPInput{
180+
Port: int64(check.HTTP.Port),
181+
Path: check.HTTP.Path,
182+
Scheme: check.HTTP.Scheme,
183+
ExpectedStatus: int64(check.HTTP.ExpectedStatus),
192184
}
193185
}
194186
if check.TCP != nil {
195-
health.Type = defaultHealthCheckType(health.Type, hypeman.HealthCheckTypeTcp)
196-
health.Tcp = hypeman.HealthCheckTcpParam{Port: int64(check.TCP.Port)}
187+
in.TCP = &HealthCheckTCPInput{Port: int64(check.TCP.Port)}
197188
}
198189
if check.Exec != nil {
199-
health.Type = defaultHealthCheckType(health.Type, hypeman.HealthCheckTypeExec)
200-
health.Exec = hypeman.HealthCheckExecParam{
201-
Command: check.Exec.Command,
202-
}
203-
if check.Exec.WorkingDir != "" {
204-
health.Exec.WorkingDir = hypeman.String(check.Exec.WorkingDir)
190+
in.Exec = &HealthCheckExecInput{
191+
Command: check.Exec.Command,
192+
WorkingDir: check.Exec.WorkingDir,
205193
}
206194
}
207-
if check.Interval != "" {
208-
health.Interval = hypeman.String(check.Interval)
209-
}
210-
if check.Timeout != "" {
211-
health.Timeout = hypeman.String(check.Timeout)
212-
}
213-
if check.StartPeriod != "" {
214-
health.StartPeriod = hypeman.String(check.StartPeriod)
215-
}
216-
if check.FailureThreshold > 0 {
217-
health.FailureThreshold = hypeman.Int(int64(check.FailureThreshold))
218-
}
219-
if check.SuccessThreshold > 0 {
220-
health.SuccessThreshold = hypeman.Int(int64(check.SuccessThreshold))
221-
}
222-
return health
223-
}
224-
225-
func defaultHealthCheckType(current, fallback hypeman.HealthCheckType) hypeman.HealthCheckType {
226-
if current != "" {
227-
return current
228-
}
229-
return fallback
195+
return BuildHealthCheckParam(in)
230196
}
231197

232198
func buildComposeIngressInput(instanceName, ingressName string, spec composeIngressRuleSpec) hypeman.IngressNewParams {

lib/compose/policy.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package compose
2+
3+
import (
4+
"strings"
5+
6+
"github.com/kernel/hypeman-go"
7+
)
8+
9+
// HealthCheckInput is a neutral, plain-value description of a workload health
10+
// check. It is shared by compose, the imperative run command, and the update
11+
// subcommands so the param-construction logic does not drift.
12+
type HealthCheckInput struct {
13+
Type string
14+
Interval string
15+
Timeout string
16+
StartPeriod string
17+
FailureThreshold int64
18+
SuccessThreshold int64
19+
HTTP *HealthCheckHTTPInput
20+
TCP *HealthCheckTCPInput
21+
Exec *HealthCheckExecInput
22+
}
23+
24+
type HealthCheckHTTPInput struct {
25+
Port int64
26+
Path string
27+
Scheme string
28+
ExpectedStatus int64
29+
}
30+
31+
type HealthCheckTCPInput struct {
32+
Port int64
33+
}
34+
35+
type HealthCheckExecInput struct {
36+
Command []string
37+
WorkingDir string
38+
}
39+
40+
// RestartPolicyInput is a neutral, plain-value description of a restart policy.
41+
// MaxAttempts is a pointer so an explicit 0 (unlimited) is distinguishable from
42+
// "not provided": nil omits the field, &0 sends an explicit 0.
43+
type RestartPolicyInput struct {
44+
Policy string
45+
Backoff string
46+
MaxAttempts *int64
47+
StableAfter string
48+
}
49+
50+
func BuildHealthCheckParam(in HealthCheckInput) hypeman.HealthCheckParam {
51+
health := hypeman.HealthCheckParam{}
52+
if in.Type != "" {
53+
health.Type = hypeman.HealthCheckType(strings.ToLower(in.Type))
54+
}
55+
if in.HTTP != nil {
56+
health.Type = defaultHealthCheckType(health.Type, hypeman.HealthCheckTypeHTTP)
57+
health.HTTP = hypeman.HealthCheckHTTPParam{
58+
Port: in.HTTP.Port,
59+
}
60+
if in.HTTP.Path != "" {
61+
health.HTTP.Path = hypeman.String(in.HTTP.Path)
62+
}
63+
if in.HTTP.Scheme != "" {
64+
health.HTTP.Scheme = hypeman.HealthCheckHTTPScheme(strings.ToLower(in.HTTP.Scheme))
65+
}
66+
if in.HTTP.ExpectedStatus > 0 {
67+
health.HTTP.ExpectedStatus = hypeman.Int(in.HTTP.ExpectedStatus)
68+
}
69+
}
70+
if in.TCP != nil {
71+
health.Type = defaultHealthCheckType(health.Type, hypeman.HealthCheckTypeTcp)
72+
health.Tcp = hypeman.HealthCheckTcpParam{Port: in.TCP.Port}
73+
}
74+
if in.Exec != nil {
75+
health.Type = defaultHealthCheckType(health.Type, hypeman.HealthCheckTypeExec)
76+
health.Exec = hypeman.HealthCheckExecParam{
77+
Command: in.Exec.Command,
78+
}
79+
if in.Exec.WorkingDir != "" {
80+
health.Exec.WorkingDir = hypeman.String(in.Exec.WorkingDir)
81+
}
82+
}
83+
if in.Interval != "" {
84+
health.Interval = hypeman.String(in.Interval)
85+
}
86+
if in.Timeout != "" {
87+
health.Timeout = hypeman.String(in.Timeout)
88+
}
89+
if in.StartPeriod != "" {
90+
health.StartPeriod = hypeman.String(in.StartPeriod)
91+
}
92+
if in.FailureThreshold > 0 {
93+
health.FailureThreshold = hypeman.Int(in.FailureThreshold)
94+
}
95+
if in.SuccessThreshold > 0 {
96+
health.SuccessThreshold = hypeman.Int(in.SuccessThreshold)
97+
}
98+
return health
99+
}
100+
101+
func BuildRestartPolicyParam(in RestartPolicyInput) hypeman.RestartPolicyParam {
102+
policy := hypeman.RestartPolicyParam{}
103+
if in.Policy != "" {
104+
policy.Policy = hypeman.RestartPolicyPolicy(strings.ReplaceAll(in.Policy, "-", "_"))
105+
}
106+
if in.Backoff != "" {
107+
policy.Backoff = hypeman.String(in.Backoff)
108+
}
109+
if in.MaxAttempts != nil {
110+
policy.MaxAttempts = hypeman.Int(*in.MaxAttempts)
111+
}
112+
if in.StableAfter != "" {
113+
policy.StableAfter = hypeman.String(in.StableAfter)
114+
}
115+
return policy
116+
}
117+
118+
func defaultHealthCheckType(current, fallback hypeman.HealthCheckType) hypeman.HealthCheckType {
119+
if current != "" {
120+
return current
121+
}
122+
return fallback
123+
}

pkg/cmd/cmd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ func init() {
9292
&snapshotCmd,
9393
&volumeCmd,
9494
&resourcesCmd,
95+
&healthCmd,
9596
&deviceCmd,
9697
&composeCmd,
9798
{

pkg/cmd/healthcmd.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
8+
"github.com/kernel/hypeman-go"
9+
"github.com/kernel/hypeman-go/option"
10+
"github.com/tidwall/gjson"
11+
"github.com/urfave/cli/v3"
12+
)
13+
14+
var healthCmd = cli.Command{
15+
Name: "health",
16+
Usage: "Check API server health",
17+
Description: `Report the health of the hypeman API server.
18+
19+
Examples:
20+
# Check health (default)
21+
hypeman health
22+
23+
# Check health as JSON
24+
hypeman health --format json`,
25+
Action: handleHealth,
26+
HideHelpCommand: true,
27+
}
28+
29+
func handleHealth(ctx context.Context, cmd *cli.Command) error {
30+
client := hypeman.NewClient(getDefaultRequestOptions(cmd)...)
31+
32+
var opts []option.RequestOption
33+
if cmd.Root().Bool("debug") {
34+
opts = append(opts, debugMiddlewareOption)
35+
}
36+
37+
var res []byte
38+
opts = append(opts, option.WithResponseBodyInto(&res))
39+
_, err := client.Health.Check(ctx, opts...)
40+
if err != nil {
41+
return err
42+
}
43+
44+
format := cmd.Root().String("format")
45+
transform := cmd.Root().String("transform")
46+
47+
obj := gjson.ParseBytes(res)
48+
49+
if format == "auto" || format == "" {
50+
fmt.Println(obj.Get("status").String())
51+
return nil
52+
}
53+
54+
return ShowJSON(os.Stdout, "health", obj, format, transform)
55+
}

0 commit comments

Comments
 (0)