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
10 changes: 6 additions & 4 deletions AUDIT_OPEN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <server> <cmd> # run a command on the server (SSH)
teploy app exec -- <cmd> # run a command in the app container (migrations, etc.)
Expand Down
6 changes: 6 additions & 0 deletions docs/first-success.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ teploy log # deploy history: deploys, rollbacks, failures
And from any machine that can reach the app: `curl http(s)://<domain or
host:port>/`.

`teploy health` uses the app's configured health mode, path and deadline.
With `--app <name> --host <server>`, 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
Expand Down
25 changes: 24 additions & 1 deletion internal/cli/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand All @@ -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
}
77 changes: 77 additions & 0 deletions internal/cli/health_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
8 changes: 7 additions & 1 deletion internal/deploy/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading