Skip to content

Commit 09bb457

Browse files
authored
Honor configured and recorded health contracts (#27)
1 parent 2041c61 commit 09bb457

6 files changed

Lines changed: 121 additions & 7 deletions

File tree

‎AUDIT_OPEN.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2569,10 +2569,12 @@ commit; live proofs on the colima fixture.
25692569
**Recorded, not done here:** `internal/preview`'s `probeTCP` has the same
25702570
connect-only shape as the old gate. Left alone because the L1 lane owned
25712571
internal/preview this wave; the fix is to switch it to
2572-
`deploy.TCPProbeCommand`. `teploy health` probes with the default auto
2573-
mode and not the app's configured `health.mode`/path. That is a
2574-
pre-existing inconsistency, now visible because the fixture has a real
2575-
`/health`.
2572+
`deploy.TCPProbeCommand`. The separate `teploy health` mode/path inconsistency
2573+
was corrected September 25: local commands use the manifest's health contract;
2574+
`--app` uses the current release's recorded contract. An absent or unreadable
2575+
record refuses the state-only probe with guidance to use the app directory.
2576+
HTTP-only failures never fall back to a successful TCP connection. Regression
2577+
tests cover HTTP paths, deadlines, TCP-only probes and unavailable records.
25762578

25772579
Gates: `go test ./... -count=1` 26/26 packages ok (macOS);
25782580
internal/deploy full suite PASS on Linux (colima, bash 5.2);

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ teploy stop / start / restart # container lifecycle
343343
teploy logs [--tail N] [--process web] # stream container logs
344344
teploy status # show running containers
345345
teploy stats # CPU/RAM per container
346-
teploy health # run health check on live app
346+
teploy health # run configured readiness check on live app
347347
teploy log # deploy history
348348
teploy exec <server> <cmd> # run a command on the server (SSH)
349349
teploy app exec -- <cmd> # run a command in the app container (migrations, etc.)

‎docs/first-success.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ teploy log # deploy history: deploys, rollbacks, failures
120120
And from any machine that can reach the app: `curl http(s)://<domain or
121121
host:port>/`.
122122

123+
`teploy health` uses the app's configured health mode, path and deadline.
124+
With `--app <name> --host <server>`, it reads those settings from the current
125+
release record instead. If an older release has no recorded health settings,
126+
run the command from its app directory; the state-only command refuses to
127+
guess a readiness contract.
128+
123129
## 5. Change something, deploy again, roll back
124130

125131
Commit a change and deploy; `teploy status` now shows current and previous

‎internal/cli/health.go‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ import (
1010
"time"
1111

1212
"github.com/spf13/cobra"
13+
"github.com/useteploy/teploy/internal/config"
1314
"github.com/useteploy/teploy/internal/deploy"
1415
"github.com/useteploy/teploy/internal/docker"
16+
"github.com/useteploy/teploy/internal/releasemeta"
17+
"github.com/useteploy/teploy/internal/ssh"
1518
"github.com/useteploy/teploy/internal/state"
1619
)
1720

@@ -72,7 +75,11 @@ func runHealth(flags *Flags, appName string) error {
7275
// `bind:` is not reachable at localhost, so a localhost-only probe reports
7376
// a perfectly healthy app as failed — the same trap that made every deploy
7477
// of a bound app an outage until the deployer learned to read the bind.
75-
if err := deployer.HealthCheckAt(ctx, current.CurrentPort, docker.ContainerName(appCfg.App, "web", current.CurrentHash)); err != nil {
78+
health, err := healthConfigForCommand(ctx, executor, appCfg, current.CurrentHash, appName != "")
79+
if err == nil {
80+
err = deployer.HealthCheckAtWithConfig(ctx, current.CurrentPort, docker.ContainerName(appCfg.App, "web", current.CurrentHash), health)
81+
}
82+
if err != nil {
7683
if flags.JSON {
7784
if encodeErr := json.NewEncoder(os.Stdout).Encode(healthDTO{App: appCfg.App, Host: executor.Host(), Port: current.CurrentPort, Healthy: false, Error: err.Error(), ObservedAt: time.Now().UTC()}); encodeErr != nil {
7885
return encodeErr
@@ -89,3 +96,19 @@ func runHealth(flags *Flags, appName string) error {
8996
fmt.Println("Health check passed")
9097
return nil
9198
}
99+
100+
func healthConfigForCommand(ctx context.Context, executor ssh.Executor, app *config.AppConfig, version string, stateOnly bool) (deploy.HealthConfig, error) {
101+
if !stateOnly {
102+
return healthConfigFrom(app.Health), nil
103+
}
104+
// --app has no local manifest: use the current release's recorded contract.
105+
record, err := releasemeta.Read(ctx, executor, app.App, version)
106+
if err != nil {
107+
return deploy.HealthConfig{}, err
108+
}
109+
if record == nil || record.Health == nil {
110+
return deploy.HealthConfig{}, fmt.Errorf("health configuration for %s@%s is unavailable; run health from its app directory", app.App, version)
111+
}
112+
h := record.Health
113+
return healthConfigFrom(config.AppHealthConfig{Mode: h.Mode, Path: h.Path, TimeoutSeconds: h.TimeoutSeconds, IntervalSeconds: h.IntervalSeconds}), nil
114+
}

‎internal/cli/health_test.go‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
"time"
8+
9+
"github.com/useteploy/teploy/internal/config"
10+
"github.com/useteploy/teploy/internal/deploy"
11+
"github.com/useteploy/teploy/internal/ssh"
12+
)
13+
14+
func TestHealthCommandHonorsHTTPContract(t *testing.T) {
15+
for _, stateOnly := range []bool{false, true} {
16+
mock := ssh.NewMockExecutor("host",
17+
ssh.MockCommand{Match: "if [ ! -e", Output: `present
18+
{"schema_version":1,"app":"demo","hash":"v1","health":{"mode":"http","path":"/ready","timeout_seconds":1,"interval_seconds":1}}`},
19+
ssh.MockCommand{Match: "curl", Output: "404"},
20+
ssh.MockCommand{Match: "bash -c", Output: ""},
21+
)
22+
cfg, err := healthConfigForCommand(context.Background(), mock, &config.AppConfig{App: "demo", Health: config.AppHealthConfig{Mode: "http", Path: "/ready", TimeoutSeconds: 1, IntervalSeconds: 1}}, "v1", stateOnly)
23+
if err != nil {
24+
t.Fatal(err)
25+
}
26+
if cfg.Timeout != time.Second || cfg.Interval != time.Second {
27+
t.Fatalf("lost timing contract: %+v", cfg)
28+
}
29+
cfg.Timeout, cfg.Interval = 30*time.Millisecond, time.Millisecond
30+
err = deploy.NewDeployer(mock, nil).HealthCheckAtWithConfig(context.Background(), 8080, "", cfg)
31+
if err == nil {
32+
t.Fatal("HTTP 404 must fail even when TCP succeeds")
33+
}
34+
var sawPath bool
35+
for _, call := range mock.Calls {
36+
if strings.Contains(call, "curl") && strings.Contains(call, "/ready") {
37+
sawPath = true
38+
}
39+
if strings.HasPrefix(call, "bash -c") {
40+
t.Fatal("HTTP-only health used TCP fallback")
41+
}
42+
}
43+
if !sawPath {
44+
t.Fatal("configured readiness path was not probed")
45+
}
46+
}
47+
}
48+
49+
func TestHealthCommandRefusesUnknownRecordedContract(t *testing.T) {
50+
for _, output := range []string{"absent", "present\n{broken", `present
51+
{"schema_version":1,"app":"demo","hash":"v1"}`} {
52+
mock := ssh.NewMockExecutor("host", ssh.MockCommand{Match: "if [ ! -e", Output: output})
53+
_, err := healthConfigForCommand(context.Background(), mock, &config.AppConfig{App: "demo"}, "v1", true)
54+
if err == nil {
55+
t.Fatalf("unknown contract accepted: %q", output)
56+
}
57+
}
58+
}
59+
60+
func TestHealthCommandTCPDoesNotProbeHTTP(t *testing.T) {
61+
mock := ssh.NewMockExecutor("host", ssh.MockCommand{Match: "bash -c", Output: ""})
62+
cfg, err := healthConfigForCommand(context.Background(), mock, &config.AppConfig{Health: config.AppHealthConfig{Mode: "tcp"}}, "v1", false)
63+
if err != nil {
64+
t.Fatal(err)
65+
}
66+
if err := deploy.NewDeployer(mock, nil).HealthCheckAtWithConfig(context.Background(), 8080, "", cfg); err != nil {
67+
t.Fatal(err)
68+
}
69+
if len(mock.Calls) == 0 {
70+
t.Fatal("no probe ran")
71+
}
72+
for _, call := range mock.Calls {
73+
if strings.Contains(call, "curl") {
74+
t.Fatal("TCP health unexpectedly probed HTTP")
75+
}
76+
}
77+
}

‎internal/deploy/health.go‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,17 @@ func (d *Deployer) HealthCheckPublic(ctx context.Context, port int) error {
139139
// actually published on rather than assuming localhost. Falls back to the
140140
// localhost behavior when the address cannot be read.
141141
func (d *Deployer) HealthCheckAt(ctx context.Context, port int, containerName string) error {
142+
return d.HealthCheckAtWithConfig(ctx, port, containerName, defaultHealthConfig())
143+
}
144+
145+
// HealthCheckAtWithConfig probes the published address with the selected
146+
// readiness contract, including its mode, path and total deadline.
147+
func (d *Deployer) HealthCheckAtWithConfig(ctx context.Context, port int, containerName string, cfg HealthConfig) error {
142148
bindHost := ""
143149
if containerName != "" {
144150
bindHost = docker.NewClient(d.exec).HostBindIP(ctx, containerName)
145151
}
146-
return d.healthCheck(ctx, port, defaultHealthConfig(), bindHost)
152+
return d.healthCheck(ctx, port, cfg, bindHost)
147153
}
148154

149155
// checkHealth performs a single AUTO-mode attempt (the compatibility

0 commit comments

Comments
 (0)