diff --git a/AUDIT_OPEN.md b/AUDIT_OPEN.md index 4db1676..816df3a 100644 --- a/AUDIT_OPEN.md +++ b/AUDIT_OPEN.md @@ -2569,10 +2569,12 @@ commit; live proofs on the colima fixture. **Recorded, not done here:** `internal/preview`'s `probeTCP` has the same connect-only shape as the old gate. Left alone because the L1 lane owned internal/preview this wave; the fix is to switch it to -`deploy.TCPProbeCommand`. `teploy health` probes with the default auto -mode and not the app's configured `health.mode`/path. That is a -pre-existing inconsistency, now visible because the fixture has a real -`/health`. +`deploy.TCPProbeCommand`. The separate `teploy health` mode/path inconsistency +was corrected September 25: local commands use the manifest's health contract; +`--app` uses the current release's recorded contract. An absent or unreadable +record refuses the state-only probe with guidance to use the app directory. +HTTP-only failures never fall back to a successful TCP connection. Regression +tests cover HTTP paths, deadlines, TCP-only probes and unavailable records. Gates: `go test ./... -count=1` 26/26 packages ok (macOS); internal/deploy full suite PASS on Linux (colima, bash 5.2); diff --git a/README.md b/README.md index c5c4f73..21b1c62 100644 --- a/README.md +++ b/README.md @@ -343,7 +343,7 @@ teploy stop / start / restart # container lifecycle teploy logs [--tail N] [--process web] # stream container logs teploy status # show running containers teploy stats # CPU/RAM per container -teploy health # run health check on live app +teploy health # run configured readiness check on live app teploy log # deploy history teploy exec # run a command on the server (SSH) teploy app exec -- # run a command in the app container (migrations, etc.) diff --git a/docs/first-success.md b/docs/first-success.md index 63b7c4e..ef8e65b 100644 --- a/docs/first-success.md +++ b/docs/first-success.md @@ -120,6 +120,12 @@ teploy log # deploy history: deploys, rollbacks, failures And from any machine that can reach the app: `curl http(s):///`. +`teploy health` uses the app's configured health mode, path and deadline. +With `--app --host `, it reads those settings from the current +release record instead. If an older release has no recorded health settings, +run the command from its app directory; the state-only command refuses to +guess a readiness contract. + ## 5. Change something, deploy again, roll back Commit a change and deploy; `teploy status` now shows current and previous diff --git a/internal/cli/health.go b/internal/cli/health.go index dd60133..64be0f1 100644 --- a/internal/cli/health.go +++ b/internal/cli/health.go @@ -10,8 +10,11 @@ import ( "time" "github.com/spf13/cobra" + "github.com/useteploy/teploy/internal/config" "github.com/useteploy/teploy/internal/deploy" "github.com/useteploy/teploy/internal/docker" + "github.com/useteploy/teploy/internal/releasemeta" + "github.com/useteploy/teploy/internal/ssh" "github.com/useteploy/teploy/internal/state" ) @@ -72,7 +75,11 @@ func runHealth(flags *Flags, appName string) error { // `bind:` is not reachable at localhost, so a localhost-only probe reports // a perfectly healthy app as failed — the same trap that made every deploy // of a bound app an outage until the deployer learned to read the bind. - if err := deployer.HealthCheckAt(ctx, current.CurrentPort, docker.ContainerName(appCfg.App, "web", current.CurrentHash)); err != nil { + health, err := healthConfigForCommand(ctx, executor, appCfg, current.CurrentHash, appName != "") + if err == nil { + err = deployer.HealthCheckAtWithConfig(ctx, current.CurrentPort, docker.ContainerName(appCfg.App, "web", current.CurrentHash), health) + } + if err != nil { if flags.JSON { 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 { return encodeErr @@ -89,3 +96,19 @@ func runHealth(flags *Flags, appName string) error { fmt.Println("Health check passed") return nil } + +func healthConfigForCommand(ctx context.Context, executor ssh.Executor, app *config.AppConfig, version string, stateOnly bool) (deploy.HealthConfig, error) { + if !stateOnly { + return healthConfigFrom(app.Health), nil + } + // --app has no local manifest: use the current release's recorded contract. + record, err := releasemeta.Read(ctx, executor, app.App, version) + if err != nil { + return deploy.HealthConfig{}, err + } + if record == nil || record.Health == nil { + return deploy.HealthConfig{}, fmt.Errorf("health configuration for %s@%s is unavailable; run health from its app directory", app.App, version) + } + h := record.Health + return healthConfigFrom(config.AppHealthConfig{Mode: h.Mode, Path: h.Path, TimeoutSeconds: h.TimeoutSeconds, IntervalSeconds: h.IntervalSeconds}), nil +} diff --git a/internal/cli/health_test.go b/internal/cli/health_test.go new file mode 100644 index 0000000..88b18e6 --- /dev/null +++ b/internal/cli/health_test.go @@ -0,0 +1,77 @@ +package cli + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/useteploy/teploy/internal/config" + "github.com/useteploy/teploy/internal/deploy" + "github.com/useteploy/teploy/internal/ssh" +) + +func TestHealthCommandHonorsHTTPContract(t *testing.T) { + for _, stateOnly := range []bool{false, true} { + mock := ssh.NewMockExecutor("host", + ssh.MockCommand{Match: "if [ ! -e", Output: `present +{"schema_version":1,"app":"demo","hash":"v1","health":{"mode":"http","path":"/ready","timeout_seconds":1,"interval_seconds":1}}`}, + ssh.MockCommand{Match: "curl", Output: "404"}, + ssh.MockCommand{Match: "bash -c", Output: ""}, + ) + cfg, err := healthConfigForCommand(context.Background(), mock, &config.AppConfig{App: "demo", Health: config.AppHealthConfig{Mode: "http", Path: "/ready", TimeoutSeconds: 1, IntervalSeconds: 1}}, "v1", stateOnly) + if err != nil { + t.Fatal(err) + } + if cfg.Timeout != time.Second || cfg.Interval != time.Second { + t.Fatalf("lost timing contract: %+v", cfg) + } + cfg.Timeout, cfg.Interval = 30*time.Millisecond, time.Millisecond + err = deploy.NewDeployer(mock, nil).HealthCheckAtWithConfig(context.Background(), 8080, "", cfg) + if err == nil { + t.Fatal("HTTP 404 must fail even when TCP succeeds") + } + var sawPath bool + for _, call := range mock.Calls { + if strings.Contains(call, "curl") && strings.Contains(call, "/ready") { + sawPath = true + } + if strings.HasPrefix(call, "bash -c") { + t.Fatal("HTTP-only health used TCP fallback") + } + } + if !sawPath { + t.Fatal("configured readiness path was not probed") + } + } +} + +func TestHealthCommandRefusesUnknownRecordedContract(t *testing.T) { + for _, output := range []string{"absent", "present\n{broken", `present +{"schema_version":1,"app":"demo","hash":"v1"}`} { + mock := ssh.NewMockExecutor("host", ssh.MockCommand{Match: "if [ ! -e", Output: output}) + _, err := healthConfigForCommand(context.Background(), mock, &config.AppConfig{App: "demo"}, "v1", true) + if err == nil { + t.Fatalf("unknown contract accepted: %q", output) + } + } +} + +func TestHealthCommandTCPDoesNotProbeHTTP(t *testing.T) { + mock := ssh.NewMockExecutor("host", ssh.MockCommand{Match: "bash -c", Output: ""}) + cfg, err := healthConfigForCommand(context.Background(), mock, &config.AppConfig{Health: config.AppHealthConfig{Mode: "tcp"}}, "v1", false) + if err != nil { + t.Fatal(err) + } + if err := deploy.NewDeployer(mock, nil).HealthCheckAtWithConfig(context.Background(), 8080, "", cfg); err != nil { + t.Fatal(err) + } + if len(mock.Calls) == 0 { + t.Fatal("no probe ran") + } + for _, call := range mock.Calls { + if strings.Contains(call, "curl") { + t.Fatal("TCP health unexpectedly probed HTTP") + } + } +} diff --git a/internal/deploy/health.go b/internal/deploy/health.go index 9d3b033..6ce1bf3 100644 --- a/internal/deploy/health.go +++ b/internal/deploy/health.go @@ -139,11 +139,17 @@ func (d *Deployer) HealthCheckPublic(ctx context.Context, port int) error { // actually published on rather than assuming localhost. Falls back to the // localhost behavior when the address cannot be read. func (d *Deployer) HealthCheckAt(ctx context.Context, port int, containerName string) error { + return d.HealthCheckAtWithConfig(ctx, port, containerName, defaultHealthConfig()) +} + +// HealthCheckAtWithConfig probes the published address with the selected +// readiness contract, including its mode, path and total deadline. +func (d *Deployer) HealthCheckAtWithConfig(ctx context.Context, port int, containerName string, cfg HealthConfig) error { bindHost := "" if containerName != "" { bindHost = docker.NewClient(d.exec).HostBindIP(ctx, containerName) } - return d.healthCheck(ctx, port, defaultHealthConfig(), bindHost) + return d.healthCheck(ctx, port, cfg, bindHost) } // checkHealth performs a single AUTO-mode attempt (the compatibility