diff --git a/AUDIT_OPEN.md b/AUDIT_OPEN.md index 13a1e13..d23f187 100644 --- a/AUDIT_OPEN.md +++ b/AUDIT_OPEN.md @@ -2550,3 +2550,31 @@ Gates: `go build ./...` && `go vet ./...` clean; `go test ./... -count=1` internal/network (touched this session) plus internal/backup and internal/openbao ok; gofmt clean on touched files (pre-existing strays untouched). + +## 2026-09-24 nineteenth-wave defect sweep (lane L4, cli-defects) + +The real defects the nineteenth wave's lanes found and left open, plus two +more found while proving them. Each landed with a pin test in the same +commit; live proofs on the colima fixture. + +| Item | Outcome | Commit | Evidence | +|---|---|---|---| +| `teploy log` does not resolve named servers (R02 docs lane) | **fixed** | 39197d0 | Root cause was shared, not log-specific: `config.ResolveServer`'s `--host` branch took the value verbatim, so every `--app --host ` command (log, status, logs, health, rollback --app, ...) dialed the literal name. A `--host` naming a servers.yml entry now resolves to it (`--user`/`--key` still win; unregistered values and a missing servers.yml stay raw). Pins: config + `runLog`. Live: `teploy log --app quickstart --host colima-vm` connected to the entry's `tyler@127.0.0.1:`. No written workaround existed in this repo's docs; NEXT_SESSION's mention is the only record. | +| tcp health gates pass against docker-proxy with a dead backend (C03 follow-up) | **fixed** | 4664094 | The probe connects and holds up to 1s: a byte or an open connection is ready; the proxy's immediate close (backend refused), a refused connect, or a bad host is not. `deploy.TCPProbeCommand` is shared by the deploy gate, auto's 404/3xx fallback, and heal. Live on colima: sleep-only container on a published port — old probe rc=0, new rc=1; the deploy gate refuses it; the httpd fixture passes. Pin runs the real command against accept-then-close / silent / banner / closed listeners (red on the old probe on Linux; skipped on bash<4, where a read timeout is indistinguishable from EOF, so it fails closed). | +| (found proving the above) port-mismatch diagnosis blamed Docker's embedded DNS | **fixed** | 79b8b5c | A container with nothing listening was diagnosed as "the app is listening on port 46107": the 127.0.0.11 resolver inside every user-network container. Skipped now; the correct "nothing is listening" finding fires. The slow-boot hint named a nonexistent `health: { timeout: 90s }`; corrected to `timeout_seconds`. | +| ID-created containers report the ID, not the tag (Ship wave-9) | **fixed**, not display-only | ff72cb2 | `docker.ResolveImageTags` (one batched `docker image inspect`, none when no ID-form image): `status` text/--json, `app status --json`, `server status --json` report image = first tag + image_id + image_tags. **Comparison impact found and fixed:** rollback overwrote the target release record's ImageRef with the container's docker ps image, so after a rollback the state carried an unpullable short ID (DR bundle restore resolves the image from ImageRef); the release record now wins, else the resolved tag. Prune's rmi and the predecessor snapshot keep the raw value by design. Corpus rev 7 (additive): optional image_id/image_tags. Live: quickstart asserts the status tag. | +| `teploy env set` does not feed secret-backed vars (Ship wave-9) | **by design on precedence, guarded** | 65962bd | The server `.env` is the first env file; teploy.yml `env:` + decrypted secrets + resolved `secret:` refs ride the later attempt file and win. So `env set` on a secret-backed key was a silent no-op. It now refuses (nothing written) for keys in the secret store or `secret:` references in teploy.yml, naming the remedy; a plain teploy.yml `env:` key that shadows it gets a warning. README states the precedence. | +| X05: no maintained deployable CLI fixture app | **fixed** | 4ed08f0 | `examples/quickstart/app` (landed by C09, acf475f) is the maintained fixture; `make quickstart` now covers deploy -> verify -> redeploy -> health -> status-by-tag -> rollback -> tcp-gate refusal of a never-listening qs3 with v1 still serving. Fixture fixes: the sh-wrapped httpd ignored SIGTERM as PID 1 (every retirement waited out the 10s kill: Exited 137, ~11.5s deploys -> TERM trap, ~1.5s, Exited 0); a real 200 `/health`. Green on colima. | + +**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`. + +Gates: `go test ./... -count=1` 26/26 packages ok (macOS); +internal/deploy full suite PASS on Linux (colima, bash 5.2); +`GOOS=linux go vet ./... && GOOS=linux go build ./...` clean; `make +quickstart` green on colima. diff --git a/README.md b/README.md index c6b7f89..c5c4f73 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,10 @@ processes: # http — status-based only: GET path, 200 = ready. A 404/redirect FAILS # (no fallback). Best when the app has a real health endpoint. # tcp — a TCP dial against the published port; nothing is fetched. -# For apps with no HTTP surface (game servers, TCP brokers). +# The connection is held ~1s: a listener that closes it at once +# (Docker's proxy does, when nothing in the container listens) +# is NOT ready. For apps with no HTTP surface (game servers, TCP +# brokers). # Setting `path` alongside is rejected — nothing would fetch it. # auto — compatibility default (also what an omitted mode means): HTTP # GET first; a 404/3xx falls back to a TCP dial. The historical @@ -385,6 +388,11 @@ teploy secret rm KEY # delete an encrypted secret (local store) teploy secret get / list / rotate # secret management ``` +At deploy, secrets (and `secret:` references in `env:`) override +`teploy.yml` `env:`, which overrides the server `.env` that `env set` +writes. `env set` therefore refuses a key that is a secret (use +`secret set`) and warns when `teploy.yml` `env:` shadows the key. + ### Fleet ``` teploy server add # add server to ~/.teploy/servers.yml diff --git a/contracts/MANIFEST.md b/contracts/MANIFEST.md index 2234f12..cf342d1 100644 --- a/contracts/MANIFEST.md +++ b/contracts/MANIFEST.md @@ -10,6 +10,7 @@ Neutron/Nucleus dependency and a public mirror. | Corpus rev | Emitting CLI | Machine Interface | Notes | |---|---|---|---| +| 7 | main (L4 cli-defects: ID-created container image resolution) | 2 | Additive. The container object in app-list-envelope and server-status-envelope gains optional `image_id` (full sha256) and `image_tags` (string array), emitted only for containers created by image ID (A52 creates web/worker containers from the immutable ID). For those, `image` now carries the first repo tag instead of the bare 12-hex short ID docker ps reports - restoring the pre-A52 meaning (Ship wave-9: ID-created containers never matched the artifact tag). Name-created containers and existing fixtures are unchanged (no fixture regenerated: the corpus fixtures use name-form images). No MI bump. | | 6 | main (tailnet preview mode, DELEGATED_DECISIONS §10) | 2 | Additive. preview-state schema gains optional record/list-row fields on both eras (`domain`, `url`, `base_domain`, `http_only`, `allow_ips`) with the invariant url scheme = `http://` iff `http_only` (else `https://`); two valid fixtures GENERATED from the real `preview list --json` row encoder (`previewListRows`, `contracts_golden_test.go`): canonical-list-row (default mode, no exposure keys, https url) and canonical-list-row-tailnet (base_domain + http_only + allow_ips, http url), each wrapped with the artifact's `era`/`app` classification keys (the wire row carries neither). The hand-authored identity fixtures (canonical, legacy, ambiguous) are unchanged. version-handshake gains the `preview-exposure` capability token (additive). No MI bump. | | 5 | main (X02 S2 tail: server-status fixtures + schema correction) | 2 | server-status-envelope fixtures landed (was "pending live capture"): valid x2 (full healthy observation, partial-caddy-unavailable — the class a target without a caddy container produces) + legacy pre-MI (machine_interface absent, the 42243e2-era shape). Encoder-derived: generated from the REAL `collectServerStatus` via a mock SSH executor (`contracts_golden_test.go`, TEPLOY_UPDATE_CONTRACTS) — synthetic values, real encoder and parse stages; the wire shape was verified against a live `server status --json` run before pinning. Defect fixed in the same commit: the schema had copied the appStatus root since its S2 draft (its own defect-fix commit 08cfb1b said so) and never described the actual serverStatusDTO wire format (server/host/uptime/load/memory/disks/docker/caddy) — rewritten to the real root with strict required-key coverage of the DTO's no-omitempty fields. Additive to consumers (a schema that matched nothing before now matches the wire); no MI bump. | | 4 | main (X02 S2 tail: server-list reshape) | 2 | **The MI 2 bump** (D8 non-additive): `server list --json` now emits the envelope `{machine_interface, servers[], observed_at}` carrying the per-server fields unchanged (name + id/host/user/role/tags/vpn_ip); the pre-reshape bare map-of-servers root is GONE on the wire and is pinned as the artifact's legacy class. New artifact server-list-envelope (schema + valid + legacy fixtures); version-handshake schema maximum 1→2 and its valid fixture renamed mi1→mi2 (app-list valid likewise — both envelopes now report MI 2). Capability tokens unchanged. Coordinated consumer: teploy-dash decodes both shapes during the transition (MaxSupportedMachineInterface 2). | diff --git a/contracts/schema/app-list-envelope.schema.json b/contracts/schema/app-list-envelope.schema.json index a12014a..f5481e3 100644 --- a/contracts/schema/app-list-envelope.schema.json +++ b/contracts/schema/app-list-envelope.schema.json @@ -70,6 +70,16 @@ "image": { "type": "string" }, + "image_id": { + "type": "string", + "description": "Set only for a container created by image ID: the full sha256 image ID. image then carries the first repo tag (or the ID when untagged/unresolvable)." + }, + "image_tags": { + "type": "array", + "items": { + "type": "string" + } + }, "state": { "type": "string" }, diff --git a/contracts/schema/server-status-envelope.schema.json b/contracts/schema/server-status-envelope.schema.json index 24f33d9..1dcf020 100644 --- a/contracts/schema/server-status-envelope.schema.json +++ b/contracts/schema/server-status-envelope.schema.json @@ -187,6 +187,16 @@ "image": { "type": "string" }, + "image_id": { + "type": "string", + "description": "Set only for a container created by image ID: the full sha256 image ID. image then carries the first repo tag (or the ID when untagged/unresolvable)." + }, + "image_tags": { + "type": "array", + "items": { + "type": "string" + } + }, "state": { "type": "string" }, diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md index 7a624c0..38d9b61 100644 --- a/examples/quickstart/README.md +++ b/examples/quickstart/README.md @@ -8,18 +8,25 @@ documentation, and it runs. make quickstart # from the repo root ``` -`run.sh` deploys `app/` (the maintained fixture: a busybox httpd app with -a `teploy.yml`) to the **local colima VM's own SSH endpoint**, then: +`run.sh` deploys `app/` (the maintained CLI fixture app — busybox httpd +serving `index.html` and a 200 `/health`, with a `teploy.yml`; X05's CLI +fixture) to the **local colima VM's own SSH endpoint**, then: 1. builds the CLI from this checkout, 2. bootstraps the target once (`/deployments` directory; the only target-side setup, via the VM's passwordless sudo), -3. deploys version `qs1` — build-on-target, health-gated start, host +3. deploys version `qs1` — build-on-target, tcp-gated start, host ingress on `127.0.0.1:18080`, 4. verifies the app answers with the v1 content, 5. redeploys as `qs2` with changed content and verifies the switch, -6. shows `teploy status`, then removes everything it created (app, - containers, images, its own known_hosts lines). +6. runs `teploy health`, and checks `teploy status` names the image by + tag (`quickstart-build-qs2`), not the bare image ID, +7. `teploy rollback` and verifies v1 is served again, +8. deploys `qs3`, a container that runs but never listens, and verifies + the tcp readiness gate REFUSES it (docker-proxy accepts on the port + either way) while v1 keeps serving, +9. removes everything it created (app, containers, images, its own + known_hosts lines). Requirements: docker CLI, a **running** colima VM (the script never starts one — `colima start` yourself), ssh/ssh-keyscan/curl/python3. diff --git a/examples/quickstart/app/Dockerfile b/examples/quickstart/app/Dockerfile index 53ee879..17063ec 100644 --- a/examples/quickstart/app/Dockerfile +++ b/examples/quickstart/app/Dockerfile @@ -1,5 +1,10 @@ FROM busybox:1.37 COPY index.html /www/index.html +# /health answers 200 so `teploy health` (HTTP first) passes on the real +# endpoint, not the 404->TCP fallback. +RUN echo ok > /www/health # teploy injects PORT (the published port) as an env var; listen there so -# the health gate and the published port see the same listener. -CMD ["sh", "-c", "httpd -f -p ${PORT:-80} -h /www"] +# the health gate and the published port see the same listener. PID 1 gets +# no default signal handling, so the explicit TERM trap is what lets docker +# stop retire the container at once instead of waiting out the kill timeout. +CMD ["sh", "-c", "trap 'exit 0' TERM; httpd -f -p ${PORT:-80} -h /www & wait"] diff --git a/examples/quickstart/run.sh b/examples/quickstart/run.sh index 3c52cb1..2868300 100755 --- a/examples/quickstart/run.sh +++ b/examples/quickstart/run.sh @@ -11,8 +11,10 @@ # # What it proves: a new user path from `git clean` checkout to a # responding application — config in teploy.yml, build on the target, -# health-gated start, published port, idempotent redeploy, status, -# removal. Exit 0 only if every step held. +# health-gated start, published port, on-demand health, redeploy, status +# naming the image TAG, rollback, the tcp readiness gate refusing a +# container that runs but never listens (with the served version left +# alone), and removal. Exit 0 only if every step held. set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" @@ -33,7 +35,7 @@ cleanup() { "$TEPLOY_BIN" remove --purge --yes --app quickstart --host "$SSH_HOST" \ --user "$SSH_USER" --key "$SSH_KEY" >/dev/null 2>&1 ssh -i "$SSH_KEY" -p "$SSH_PORT" -o BatchMode=yes "$SSH_USER@$SSH_HOST" \ - 'docker rm -f quickstart-web-qs1 quickstart-web-qs2 >/dev/null 2>&1; docker rmi quickstart-build-qs1 quickstart-build-qs2 >/dev/null 2>&1; sudo rm -rf /deployments/quickstart' 2>/dev/null + 'docker rm -f quickstart-web-qs1 quickstart-web-qs2 quickstart-web-qs3 >/dev/null 2>&1; docker rmi quickstart-build-qs1 quickstart-build-qs2 quickstart-build-qs3 >/dev/null 2>&1; sudo rm -rf /deployments/quickstart' 2>/dev/null fi if [ -n "$CONTAINER_KEY_FILE" ] && [ -f "$HOME/.ssh/known_hosts" ]; then # Remove only the lines this run appended. @@ -117,9 +119,50 @@ BODY="$(curl -fsS -m 10 "http://127.0.0.1:$APP_PORT/")" grep -q "quickstart v2" <<<"$BODY" || { echo "FAIL: v2 content not served after redeploy: $BODY" >&2; exit 1; } log "verified: redeploy switched the served content to v2" -# --- status ------------------------------------------------------------------ -log "teploy status sees the deployment" -"${TEPLOY[@]}" status --app quickstart 2>&1 | sed 's/^/ /' +serves() { + local body + body="$(curl -fsS -m 10 "http://127.0.0.1:$APP_PORT/")" || return 1 + grep -q "$1" <<<"$body" +} -log "quickstart complete: deployed, verified, redeployed, verified again" +# --- on-demand health ---------------------------------------------------------- +log "teploy health probes the live app" +"${TEPLOY[@]}" health --app quickstart 2>&1 | sed 's/^/ /' + +# --- status names the image by tag ------------------------------------------- +log "teploy status sees the deployment (image reported by tag, not bare ID)" +STATUS="$("${TEPLOY[@]}" status --app quickstart 2>&1)" +sed 's/^/ /' <<<"$STATUS" +grep -q "quickstart-build-qs2" <<<"$STATUS" \ + || { echo "FAIL: status did not report the qs2 image tag" >&2; exit 1; } + +# --- rollback ----------------------------------------------------------------- +log "rolling back to qs1" +"${TEPLOY[@]}" rollback --app quickstart 2>&1 | sed 's/^/ /' +serves "quickstart v1" || { echo "FAIL: rollback did not restore the v1 content" >&2; exit 1; } +log "verified: rollback serves v1 again" + +# --- tcp gate refuses a dead backend ---------------------------------------- +# A container that runs but never listens: docker-proxy still accepts on the +# published port, so a connect-only probe would pass it. +log "deploying qs3: runs, never listens — the tcp readiness gate must refuse it" +cat >"$WORK_DIR/app/Dockerfile" <<'DEAD' +FROM busybox:1.37 +CMD ["sleep", "3600"] +DEAD +# health: is the last block of the fixture's teploy.yml; a short deadline +# keeps the refusal quick. +printf ' timeout_seconds: 5\n' >>"$WORK_DIR/app/teploy.yml" +tail -1 "$WORK_DIR/app/teploy.yml" | grep -q '^ timeout_seconds: 5$' || { echo "FAIL: could not set the qs3 deadline" >&2; exit 1; } +if (cd "$WORK_DIR/app" && "${TEPLOY[@]}" deploy --version qs3) >"$WORK_DIR/qs3.log" 2>&1; then + sed 's/^/ /' "$WORK_DIR/qs3.log" + echo "FAIL: the tcp gate passed a container with nothing listening" >&2; exit 1 +fi +sed 's/^/ /' "$WORK_DIR/qs3.log" | tail -6 +grep -q "mode tcp" "$WORK_DIR/qs3.log" || { echo "FAIL: qs3 did not fail at the tcp gate" >&2; exit 1; } +log "verified: the tcp gate refused qs3" +serves "quickstart v1" || { echo "FAIL: the refused deploy left v1 not serving" >&2; exit 1; } +log "verified: v1 still serves after the refused deploy" + +log "quickstart complete: deploy, health, redeploy, status, rollback, dead-backend refusal" log "cleanup follows (teploy remove --purge, known_hosts lines, temp dir)" diff --git a/internal/cli/env.go b/internal/cli/env.go index 1c53724..1ccf44e 100644 --- a/internal/cli/env.go +++ b/internal/cli/env.go @@ -7,10 +7,15 @@ import ( "io" "os" "os/signal" + "sort" "strings" "github.com/spf13/cobra" + "github.com/useteploy/teploy/internal/config" "github.com/useteploy/teploy/internal/env" + "github.com/useteploy/teploy/internal/openbao" + "github.com/useteploy/teploy/internal/secret" + "github.com/useteploy/teploy/internal/ssh" ) func newEnvCmd(flags *Flags) *cobra.Command { @@ -90,6 +95,10 @@ func runEnvSet(flags *Flags, appName string, pairs map[string]string) error { } defer executor.Close() + if err := checkEnvSetShadowing(ctx, executor, appCfg, pairs, os.Stderr); err != nil { + return err + } + mgr := env.NewManager(executor) if err := mgr.Set(ctx, appCfg.App, pairs); err != nil { return err @@ -101,6 +110,51 @@ func runEnvSet(flags *Flags, appName string, pairs map[string]string) error { return nil } +// checkEnvSetShadowing refuses an `env set` whose value could never reach +// the container. At deploy the server .env is the FIRST env file; teploy.yml +// env: plus decrypted secrets (`teploy secret set`) and resolved vault +// references ride a later attempt env file and win. So `env set` on a +// secret-backed key was a silent no-op (Ship wave-9: "env set does not feed +// secret-backed vars; secret set does") — by design on precedence (secrets +// win over plaintext), a defect in reporting success. Secret-backed keys are +// refused with the remedy; a key teploy.yml's env: sets in plain text is +// shadowed the same way and is warned about (only knowable when teploy.yml +// was loaded, i.e. without --app). +func checkEnvSetShadowing(ctx context.Context, executor ssh.Executor, appCfg *config.AppConfig, pairs map[string]string, warn io.Writer) error { + stored, err := secret.NewManager(executor).List(ctx, appCfg.App) + if err != nil { + return fmt.Errorf("checking the secret store before env set: %w", err) + } + inStore := make(map[string]bool, len(stored)) + for _, k := range stored { + inStore[k] = true + } + vaultRefs := openbao.CollectRefs(appCfg.Env) + + keys := make([]string, 0, len(pairs)) + for k := range pairs { + keys = append(keys, k) + } + sort.Strings(keys) + var refused []string + for _, k := range keys { + switch { + case inStore[k] && !secret.IsManagementKey(k): + refused = append(refused, fmt.Sprintf("%s is a secret for %s (teploy secret set); the decrypted secret overrides .env at deploy, so env set would be ignored — use `teploy secret set %s=...` (or `teploy secret rm %s` first to manage it as plain env)", k, appCfg.App, k, k)) + case vaultRefs[k] != [2]string{}: + refused = append(refused, fmt.Sprintf("%s is a secret: reference in teploy.yml env: (%s); the value resolved from OpenBao overrides .env at deploy, so env set would be ignored — change the secret in OpenBao, or drop the reference from teploy.yml to manage it as plain env", k, appCfg.Env[k])) + default: + if _, inYAML := appCfg.Env[k]; inYAML { + fmt.Fprintf(warn, "warning: teploy.yml env: also sets %s and wins over .env at deploy — this value is shadowed until that entry is removed\n", k) + } + } + } + if len(refused) > 0 { + return fmt.Errorf("env set refused (nothing written):\n %s", strings.Join(refused, "\n ")) + } + return nil +} + func newEnvGetCmd(flags *Flags) *cobra.Command { var appName string cmd := &cobra.Command{ diff --git a/internal/cli/env_secret_shadow_test.go b/internal/cli/env_secret_shadow_test.go new file mode 100644 index 0000000..524dd71 --- /dev/null +++ b/internal/cli/env_secret_shadow_test.go @@ -0,0 +1,62 @@ +package cli + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/useteploy/teploy/internal/config" + "github.com/useteploy/teploy/internal/ssh" +) + +func secretStoreMock(names string) *ssh.MockExecutor { + return ssh.NewMockExecutor("h", + ssh.MockCommand{Match: "if [ ! -e '/deployments/demo/secrets'", Output: "present\n"}, + ssh.MockCommand{Match: "find '/deployments/demo/secrets'", Output: names}, + ) +} + +// Ship wave-9: `env set` on a secret-backed key reported success but the +// decrypted secret overrides .env at deploy, so the value never reached the +// container. It must refuse with the remedy and write nothing. +func TestEnvSet_RefusesSecretBackedKey(t *testing.T) { + mock := secretStoreMock("DB_PASSWORD.age\n") + err := checkEnvSetShadowing(context.Background(), mock, &config.AppConfig{App: "demo"}, + map[string]string{"DB_PASSWORD": "x", "PLAIN": "y"}, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "DB_PASSWORD") || !strings.Contains(err.Error(), "teploy secret set DB_PASSWORD") { + t.Fatalf("want a refusal naming the key and the remedy, got %v", err) + } + if strings.Contains(err.Error(), "PLAIN") { + t.Errorf("plain key refused too: %v", err) + } +} + +func TestEnvSet_RefusesYAMLSecretRef(t *testing.T) { + mock := secretStoreMock("") + cfg := &config.AppConfig{App: "demo", Env: map[string]string{"API_KEY": "secret:api#key"}} + err := checkEnvSetShadowing(context.Background(), mock, cfg, map[string]string{"API_KEY": "x"}, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "API_KEY") || !strings.Contains(err.Error(), "OpenBao") { + t.Fatalf("want a refusal for a secret: reference, got %v", err) + } +} + +func TestEnvSet_WarnsWhenTeployYAMLShadows(t *testing.T) { + mock := secretStoreMock("") + cfg := &config.AppConfig{App: "demo", Env: map[string]string{"LOG_LEVEL": "info"}} + var warn bytes.Buffer + if err := checkEnvSetShadowing(context.Background(), mock, cfg, map[string]string{"LOG_LEVEL": "debug", "OTHER": "1"}, &warn); err != nil { + t.Fatalf("plain yml key must warn, not refuse: %v", err) + } + if !strings.Contains(warn.String(), "LOG_LEVEL") || strings.Contains(warn.String(), "OTHER") { + t.Fatalf("warning = %q", warn.String()) + } +} + +func TestEnvSet_NoSecretStoreAllowsEverything(t *testing.T) { + mock := ssh.NewMockExecutor("h", ssh.MockCommand{Match: "if [ ! -e '/deployments/demo/secrets'", Output: "absent\n"}) + var warn bytes.Buffer + if err := checkEnvSetShadowing(context.Background(), mock, &config.AppConfig{App: "demo"}, map[string]string{"A": "1"}, &warn); err != nil || warn.Len() != 0 { + t.Fatalf("err=%v warn=%q", err, warn.String()) + } +} diff --git a/internal/cli/heal.go b/internal/cli/heal.go index bb8b0ef..6fbb457 100644 --- a/internal/cli/heal.go +++ b/internal/cli/heal.go @@ -12,6 +12,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/useteploy/teploy/internal/deploy" "github.com/useteploy/teploy/internal/docker" "github.com/useteploy/teploy/internal/ssh" "github.com/useteploy/teploy/internal/state" @@ -419,7 +420,11 @@ func probeHealthy(ctx context.Context, exec ssh.Executor, port int, path string) return true } if code == "404" || strings.HasPrefix(code, "3") { - _, terr := exec.Run(ctx, fmt.Sprintf("bash -c '/dev/null", port)) + cmd, ok := deploy.TCPProbeCommand("localhost", port) + if !ok { + return false + } + _, terr := exec.Run(ctx, cmd) return terr == nil } return false diff --git a/internal/cli/log_named_server_test.go b/internal/cli/log_named_server_test.go new file mode 100644 index 0000000..88b7f35 --- /dev/null +++ b/internal/cli/log_named_server_test.go @@ -0,0 +1,39 @@ +package cli + +import ( + "io" + "strings" + "testing" + + "github.com/useteploy/teploy/internal/config" +) + +// TestLog_HostFlagResolvesNamedServer pins the R02 docs-lane defect: +// `teploy log --app demo --host box1` dialed the literal hostname "box1" +// instead of the servers.yml entry registered under that name. +func TestLog_HostFlagResolvesNamedServer(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("TEPLOY_HOST", "") + t.Setenv("TEPLOY_USER", "") + t.Setenv("TEPLOY_SSH_KEY", "") + + serversPath, err := config.DefaultServersPath() + if err != nil { + t.Fatal(err) + } + if err := config.AddServer(serversPath, "box1", "127.0.0.1:1", "deploy", "", ""); err != nil { + t.Fatal(err) + } + + var runErr error + out := captureStdout(t, func() { + runErr = runLog(&Flags{Host: "box1"}, "demo", 20, io.Discard) + }) + if runErr == nil { + t.Fatal("expected a connect failure against the unreachable fixture address") + } + if !strings.Contains(out, "Connecting to deploy@127.0.0.1:1") { + t.Fatalf("log did not resolve the named server; stdout=%q", out) + } +} diff --git a/internal/cli/machine.go b/internal/cli/machine.go index 8ac1001..624bee6 100644 --- a/internal/cli/machine.go +++ b/internal/cli/machine.go @@ -29,15 +29,19 @@ type releaseStatusDTO struct { Ports []int `json:"ports"` } +// containerDTO: image_id/image_tags are set when the container was created +// by image ID; image then carries the first repo tag (docker.ResolveImageTags). type containerDTO struct { - ID string `json:"id"` - Name string `json:"name"` - Image string `json:"image"` - State string `json:"state"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` - Process string `json:"process"` - Version string `json:"version"` + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + ImageID string `json:"image_id,omitempty"` + ImageTags []string `json:"image_tags,omitempty"` + State string `json:"state"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + Process string `json:"process"` + Version string `json:"version"` } type processDTO struct { @@ -173,10 +177,12 @@ func collectAppStatus(ctx context.Context, executor ssh.Executor, app string, ob result.PreviousRelease = releaseStatusDTO{Version: previousVersion, Ports: nonNilInts(current.PreviousPorts)} } - containers, err := docker.NewClient(executor).ListContainers(ctx, app) + dk := docker.NewClient(executor) + containers, err := dk.ListContainers(ctx, app) if err != nil { result.Errors = append(result.Errors, machineError{Scope: "containers", Message: err.Error()}) } else { + containers = dk.ResolveImageTags(ctx, containers) result.Containers = containerDTOs(containers) result.Processes = processDTOs(result.Containers) if len(containers) > 0 && result.Type == "" { @@ -211,6 +217,7 @@ func containerDTOs(containers []docker.Container) []containerDTO { for _, container := range containers { result = append(result, containerDTO{ ID: container.ID, Name: container.Name, Image: container.Image, + ImageID: container.ImageID, ImageTags: container.ImageTags, State: container.State, Status: container.Status, CreatedAt: container.CreatedAt, Process: container.Labels["teploy.process"], Version: container.Labels["teploy.version"], }) @@ -393,6 +400,7 @@ func collectServerStatus(ctx context.Context, executor ssh.Executor, server stri if err != nil { result.Errors = append(result.Errors, machineError{Scope: "docker.containers", Message: err.Error()}) } else { + containers = docker.NewClient(executor).ResolveImageTags(ctx, containers) result.Docker.Containers = containerDTOs(containers) } } diff --git a/internal/cli/root.go b/internal/cli/root.go index 57eed3e..c8785ff 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -43,7 +43,7 @@ func NewRootCmd(version string) *cobra.Command { }, } - root.PersistentFlags().StringVar(&flags.Host, "host", "", "server host (overrides servers.yml)") + root.PersistentFlags().StringVar(&flags.Host, "host", "", "server host or servers.yml name (overrides the configured server)") root.PersistentFlags().StringVar(&flags.User, "user", "", "SSH user (default: root)") root.PersistentFlags().StringVar(&flags.Key, "key", "", "path to SSH private key") root.PersistentFlags().StringVar(&flags.ProjectDir, "project-dir", "", "run as if teploy was started in this directory") diff --git a/internal/cli/status.go b/internal/cli/status.go index fef93f2..7a91241 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -71,6 +71,7 @@ func writeStatus(ctx context.Context, flags *Flags, appCfg *config.AppConfig, ex if err != nil { return err } + containers = dk.ResolveImageTags(ctx, containers) if flags.JSON { return json.NewEncoder(out).Encode(map[string]interface{}{ @@ -103,9 +104,9 @@ func writeStatus(ctx context.Context, flags *Flags, appCfg *config.AppConfig, ex return nil } - fmt.Fprintf(out, "\n%-35s %-25s %-10s %s\n", "CONTAINER", "IMAGE", "STATE", "STATUS") + fmt.Fprintf(out, "\n%-35s %-35s %-10s %s\n", "CONTAINER", "IMAGE", "STATE", "STATUS") for _, c := range containers { - fmt.Fprintf(out, "%-35s %-25s %-10s %s\n", c.Name, c.Image, c.State, c.Status) + fmt.Fprintf(out, "%-35s %-35s %-10s %s\n", c.Name, c.Image, c.State, c.Status) } return nil } diff --git a/internal/cli/status_image_tag_test.go b/internal/cli/status_image_tag_test.go new file mode 100644 index 0000000..70104fe --- /dev/null +++ b/internal/cli/status_image_tag_test.go @@ -0,0 +1,37 @@ +package cli + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/useteploy/teploy/internal/ssh" +) + +// Ship wave-9 lesson: `app status --json` reported an ID-created web +// container's image as the bare short ID, so a consumer matching it against +// the artifact tag never matched. image carries the tag; image_id the ID. +func TestAppStatus_IDCreatedContainerReportsTag(t *testing.T) { + id := "sha256:0123456789ab" + strings.Repeat("0", 52) + mock := ssh.NewMockExecutor("h", + ssh.MockCommand{Match: "docker ps --all --filter label=teploy.app='demo'", + Output: `{"ID":"c1","Names":"demo-web-v1","Image":"0123456789ab","State":"running","Status":"Up","Labels":{"teploy.app":"demo","teploy.process":"web","teploy.version":"v1"}}`}, + ssh.MockCommand{Match: "docker image inspect --format", Output: id + ` ["demo-build-v1:latest"]`}, + ) + result := collectAppStatus(context.Background(), mock, "demo", time.Unix(0, 0).UTC()) + if len(result.Containers) != 1 { + t.Fatalf("containers = %+v (errors %+v)", result.Containers, result.Errors) + } + data, err := json.Marshal(result.Containers[0]) + if err != nil { + t.Fatal(err) + } + got := string(data) + for _, want := range []string{`"image":"demo-build-v1:latest"`, `"image_id":"` + id + `"`, `"image_tags":["demo-build-v1:latest"]`} { + if !strings.Contains(got, want) { + t.Errorf("container JSON %s missing %s", got, want) + } + } +} diff --git a/internal/config/app.go b/internal/config/app.go index 76a277b..aa02517 100644 --- a/internal/config/app.go +++ b/internal/config/app.go @@ -202,7 +202,9 @@ type AppHealthConfig struct { // // http — status-based only: HTTP GET path, 200 = ready. A 404/3xx // FAILS the gate (no fallback). - // tcp — a TCP dial against the published port; nothing is fetched. + // tcp — a TCP dial against the published port, held ~1s; nothing + // is fetched. An immediate close (docker-proxy with a dead + // backend) is not ready. // Setting path alongside is rejected (nothing would fetch it). // auto — compatibility (the default when unset): HTTP GET first, a // 404/3xx falls back to the TCP dial — the exact behavior diff --git a/internal/config/servers.go b/internal/config/servers.go index a058c1d..8477a22 100644 --- a/internal/config/servers.go +++ b/internal/config/servers.go @@ -76,6 +76,26 @@ func LoadServers(path string) (*ServersConfig, error) { func ResolveServer(name string, flagHost, flagUser, flagKey string) (host, user, keyPath string, err error) { // 1. Flags override everything if flagHost != "" { + // A --host value that names a servers.yml entry resolves to that + // entry, the same way a positional server name or teploy.yml's + // server: does. It used to be taken verbatim, so `teploy log --app + // demo --host box1` (and every other --app/--host command) dialed + // the literal hostname "box1" (R02 docs lane). --user/--key still + // win over the entry's user. + if entry, ok := lookupNamedServer(flagHost); ok { + user = flagUser + if user == "" { + user = entry.User + } + if user == "" { + user = "root" + } + keyPath = flagKey + if keyPath == "" { + keyPath = os.Getenv("TEPLOY_SSH_KEY") + } + return entry.Host, user, keyPath, nil + } host = flagHost user = flagUser if user == "" { @@ -141,6 +161,25 @@ func ResolveServer(name string, flagHost, flagUser, flagKey string) (host, user, return server.Host, user, envKey, nil } +// lookupNamedServer reports the servers.yml entry registered under name. +// A missing or unreadable servers.yml is "not named": the --host flag path +// that uses it must keep accepting raw hosts exactly as before. +func lookupNamedServer(name string) (Server, bool) { + serversPath, err := DefaultServersPath() + if err != nil { + return Server{}, false + } + cfg, err := LoadServers(serversPath) + if err != nil { + return Server{}, false + } + entry, ok := cfg.Servers[name] + if !ok || entry.Host == "" { + return Server{}, false + } + return entry, true +} + // EffectiveUser resolves the SSH user to connect as, layering teploy.yml's // `user:` on top of ResolveServer's result. ResolveServer defaults a // literal-IP/hostname server: (one not in servers.yml) to "root" and has no diff --git a/internal/config/servers_test.go b/internal/config/servers_test.go index e30fc97..02b23b1 100644 --- a/internal/config/servers_test.go +++ b/internal/config/servers_test.go @@ -1163,3 +1163,47 @@ func TestEffectiveUser(t *testing.T) { }) } } + +// TestResolveServer_HostFlagResolvesNamedServer pins the R02 defect: a +// --host value naming a servers.yml entry resolves to the entry (host and +// user) instead of being dialed as a literal hostname; --user still wins, +// and a --host that is not a registered name stays a raw host. +func TestResolveServer_HostFlagResolvesNamedServer(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("TEPLOY_HOST", "") + t.Setenv("TEPLOY_USER", "") + t.Setenv("TEPLOY_SSH_KEY", "") + + serversPath, err := DefaultServersPath() + if err != nil { + t.Fatal(err) + } + if err := AddServer(serversPath, "box1", "10.0.0.5:2222", "deploy", "", ""); err != nil { + t.Fatalf("AddServer: %v", err) + } + + host, user, _, err := ResolveServer("box1", "box1", "", "") + if err != nil { + t.Fatal(err) + } + if host != "10.0.0.5:2222" || user != "deploy" { + t.Fatalf("--host box1 resolved to %s@%s, want deploy@10.0.0.5:2222", user, host) + } + + _, user, key, err := ResolveServer("box1", "box1", "admin", "/k") + if err != nil { + t.Fatal(err) + } + if user != "admin" || key != "/k" { + t.Fatalf("--user/--key must win over the entry: got user=%s key=%s", user, key) + } + + host, user, _, err = ResolveServer("x", "203.0.113.9", "", "") + if err != nil { + t.Fatal(err) + } + if host != "203.0.113.9" || user != "root" { + t.Fatalf("raw --host must stay raw: got %s@%s", user, host) + } +} diff --git a/internal/deploy/deploy_test.go b/internal/deploy/deploy_test.go index c77562f..9c5013e 100644 --- a/internal/deploy/deploy_test.go +++ b/internal/deploy/deploy_test.go @@ -554,7 +554,7 @@ func TestHealthCheck_TCPFallback(t *testing.T) { // curl returns 404 — no /health endpoint. ssh.MockCommand{Match: "curl -s -o /dev/null", Output: "404"}, // TCP check succeeds. - ssh.MockCommand{Match: "bash -c '/dev/tcp", Output: ""}, ) d := &Deployer{exec: mock, out: &bytes.Buffer{}} @@ -574,7 +574,7 @@ func TestHealthCheck_RedirectFallback(t *testing.T) { // curl returns 301 — app redirects /health (e.g. WordPress canonical). ssh.MockCommand{Match: "curl -s -o /dev/null", Output: "301"}, // TCP check succeeds. - ssh.MockCommand{Match: "bash -c '/dev/tcp", Output: ""}, ) d := &Deployer{exec: mock, out: &bytes.Buffer{}} diff --git a/internal/deploy/health.go b/internal/deploy/health.go index 5ab3680..9d3b033 100644 --- a/internal/deploy/health.go +++ b/internal/deploy/health.go @@ -259,15 +259,47 @@ func drainSummary(drainSeconds int) string { return fmt.Sprintf("%ds window before predecessor retirement", drainSeconds) } -// checkTCP verifies that a TCP connection can be established to the port. -// The /dev/tcp redirection runs inside a single-quoted bash -c argument, so -// neither the host nor the port can break out of it. +// checkTCP verifies that the port reaches a live listener. +// +// A bare connect is not enough (C03 follow-up): Docker's userland proxy +// (docker-proxy) owns the published port and accepts every connection +// itself, then dials the container — so a connect succeeds even when +// nothing inside the container is listening. What gives the dead backend +// away is what happens NEXT: the proxy's backend dial is refused and it +// closes the client side at once. So the probe connects, then waits +// briefly for one byte: data (a server-speaks-first protocol) or the +// connection staying open for the window (the usual client-speaks-first +// server) is ready; an immediate EOF/reset is not. A listener that accepts +// and immediately closes without a byte therefore reads as not ready. func (d *Deployer) checkTCP(ctx context.Context, host string, port int) bool { - host = strings.Trim(host, "[]") - if host != "localhost" && net.ParseIP(host) == nil { + cmd, ok := TCPProbeCommand(host, port) + if !ok { return false } - cmd := fmt.Sprintf("bash -c '/dev/null", host, port) _, err := d.exec.Run(ctx, cmd) return err == nil } + +// TCPProbeCommand renders the host-side TCP readiness probe described on +// checkTCP; exits 0 only when the port reaches a live listener. The /dev/tcp +// redirection runs inside a single-quoted bash -c argument, and host must be +// an IP literal or "localhost", so neither the host nor the port can break +// out of it. ok=false means the host was rejected (fail closed). +func TCPProbeCommand(host string, port int) (string, bool) { + host = strings.Trim(host, "[]") + if host != "localhost" && net.ParseIP(host) == nil { + return "", false + } + if port < 1 || port > 65535 { + return "", false + } + return fmt.Sprintf("bash -c 'exec 3<>/dev/tcp/%s/%d || exit 1; "+ + "read -r -t %d -n 1 _b <&3; rc=$?; [ $rc -eq 0 ] || [ $rc -gt 128 ]' 2>/dev/null", + host, port, tcpProbeHoldSeconds), true +} + +// tcpProbeHoldSeconds is how long the TCP probe holds the connection +// waiting for a byte or EOF. docker-proxy closes a dead backend's +// connection within milliseconds; one second leaves ample margin on a +// loaded host while keeping each attempt short. +const tcpProbeHoldSeconds = 1 diff --git a/internal/deploy/health_tcp_backend_test.go b/internal/deploy/health_tcp_backend_test.go new file mode 100644 index 0000000..d656df6 --- /dev/null +++ b/internal/deploy/health_tcp_backend_test.go @@ -0,0 +1,111 @@ +package deploy + +import ( + "net" + "os/exec" + "strconv" + "strings" + "testing" + "time" +) + +// runTCPProbe executes the real probe command through a local shell, the +// way the remote session runs it. +func runTCPProbe(t *testing.T, port int) bool { + t.Helper() + cmd, ok := TCPProbeCommand("127.0.0.1", port) + if !ok { + t.Fatal("probe command rejected a valid host") + } + return exec.Command("sh", "-c", cmd).Run() == nil +} + +// requireModernBash skips where bash predates 4.0: bash 3.2 (macOS's +// /bin/bash) returns 1 on a read timeout, indistinguishable from EOF, so a +// silent live listener reads as dead there (fail closed). Deploy targets +// are Linux with bash 4+; the Linux CI leg runs this pin. +func requireModernBash(t *testing.T) { + t.Helper() + out, err := exec.Command("bash", "-c", "echo ${BASH_VERSINFO[0]}").Output() + if err != nil { + t.Skip("bash not available") + } + if major, err := strconv.Atoi(strings.TrimSpace(string(out))); err != nil || major < 4 { + t.Skipf("bash %q < 4: read timeout is indistinguishable from EOF", strings.TrimSpace(string(out))) + } +} + +// listen starts a local listener whose accepted connections are handled by +// onConn, and returns its port. +func listen(t *testing.T, onConn func(net.Conn)) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go onConn(c) + } + }() + return ln.Addr().(*net.TCPAddr).Port +} + +// TestTCPProbe_DeadBackendBehindProxyFails pins the C03 follow-up: Docker's +// userland proxy accepts on the published port and then closes the client +// at once when the container's listener is gone. A connect-only probe read +// that as ready; the gate must not. +func TestTCPProbe_DeadBackendBehindProxyFails(t *testing.T) { + requireModernBash(t) + proxyDeadBackend := listen(t, func(c net.Conn) { c.Close() }) + if runTCPProbe(t, proxyDeadBackend) { + t.Fatal("tcp probe passed against an accept-then-close (docker-proxy, dead backend) port") + } + + liveSilent := listen(t, func(c net.Conn) { + time.Sleep(3 * time.Second) + c.Close() + }) + if !runTCPProbe(t, liveSilent) { + t.Fatal("tcp probe failed a live client-speaks-first listener") + } + + liveBanner := listen(t, func(c net.Conn) { + c.Write([]byte("SSH-2.0-x\r\n")) + time.Sleep(3 * time.Second) + c.Close() + }) + if !runTCPProbe(t, liveBanner) { + t.Fatal("tcp probe failed a live server-speaks-first listener") + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + closed := ln.Addr().(*net.TCPAddr).Port + ln.Close() + if runTCPProbe(t, closed) { + t.Fatal("tcp probe passed against a closed port") + } +} + +func TestTCPProbeCommand_FailsClosedOnBadInput(t *testing.T) { + for _, h := range []string{"box1", "a'b", "127.0.0.1;id"} { + if _, ok := TCPProbeCommand(h, 80); ok { + t.Errorf("host %q accepted", h) + } + } + if _, ok := TCPProbeCommand("localhost", 0); ok { + t.Error("port 0 accepted") + } + cmd, ok := TCPProbeCommand("[::1]", 8080) + if !ok || !strings.Contains(cmd, "/dev/tcp/::1/8080") { + t.Errorf("ipv6 probe = %q, %v", cmd, ok) + } +} diff --git a/internal/deploy/rollback.go b/internal/deploy/rollback.go index 3055cf0..5ed6c41 100644 --- a/internal/deploy/rollback.go +++ b/internal/deploy/rollback.go @@ -537,7 +537,16 @@ func Rollback(ctx context.Context, exec ssh.Executor, out io.Writer, cfg Rollbac if current.PreviousRelease != nil && current.PreviousRelease.Hash == target { newState.ApplyRelease(current.PreviousRelease) } - newState.ImageRef = targetWeb[0].Image + // The target release record's ImageRef (applied just above) is the + // requested reference and wins. Without one, fall back to the + // container's image — resolved to its tag, because web containers are + // created by immutable image ID (A52) and docker ps then reports the + // bare short ID: recording that made ImageRef an unpullable 12-hex + // string after a rollback (a DR restore on a fresh host resolves the + // image from ImageRef). + if newState.ImageRef == "" { + newState.ImageRef = dk.ResolveImageTags(ctx, targetWeb[:1])[0].Image + } if digest, digestErr := dk.ContainerImageDigest(ctx, targetWeb[0].Name); digestErr == nil { newState.ImageDigest = digest } diff --git a/internal/deploy/rollback_imageref_test.go b/internal/deploy/rollback_imageref_test.go new file mode 100644 index 0000000..edffb31 --- /dev/null +++ b/internal/deploy/rollback_imageref_test.go @@ -0,0 +1,75 @@ +package deploy + +import ( + "bytes" + "context" + "fmt" + "strings" + "testing" + + "github.com/useteploy/teploy/internal/ssh" +) + +// rollbackMocks is TestRollback's server model with the web containers' +// docker ps Image and the state's previous_release parameterized. +func rollbackMocks(image, previousRelease string, extra ...ssh.MockCommand) *ssh.MockExecutor { + stateContent := `{"schema_version":2,"deployment_type":"container","ingress_mode":"caddy","domain":"myapp.com","updated_at":"2026-07-22T10:00:00Z","image_ref":"myapp:v2","operation_id":"deploy-v2","generation":7,` + previousRelease + `"current_port":49153,"current_hash":"v2","previous_port":49152,"previous_hash":"v1"}` + cmds := append(extra, + ssh.MockCommand{Match: "if [ ! -e '/deployments/myapp/state.json' ]", Output: "present\n" + stateContent}, + ssh.MockCommand{Match: "mkdir -p /deployments/myapp", Output: ""}, + ssh.MockCommand{Match: "mkdir /deployments/myapp/.lock", Output: ""}, + ssh.MockCommand{Match: "cat /deployments/myapp/.lock/info", Err: fmt.Errorf("none")}, + ssh.MockCommand{Match: "docker ps --all --filter label=teploy.app='myapp'", + Output: `{"ID":"aaa","Names":"myapp-web-v1","Image":"` + image + `","State":"exited","Status":"Exited","Labels":"teploy.app=myapp,teploy.version=v1,teploy.process=web"}` + "\n" + + `{"ID":"bbb","Names":"myapp-web-v2","Image":"fedcba987654","State":"running","Status":"Up 1h","Labels":"teploy.app=myapp,teploy.version=v2,teploy.process=web"}`, + }, + ssh.MockCommand{Match: "docker inspect 'myapp-web-v1'", Output: `[{"Image":"sha256:0123456789ab` + strings.Repeat("0", 52) + `","Config":{"Image":"sha256:0123456789ab` + strings.Repeat("0", 52) + `","Labels":{"teploy.app":"myapp"}},"HostConfig":{"NetworkMode":"teploy","PortBindings":{"3000/tcp":[{"HostIp":"127.0.0.1","HostPort":"49152"}]},"RestartPolicy":{"Name":"no"}},"NetworkSettings":{"Networks":{"teploy":{"Aliases":["myapp"]}}}}]`}, + ssh.MockCommand{Match: "docker rm -f 'myapp-web-v1'", Output: ""}, + ssh.MockCommand{Match: "docker run", Output: ""}, + ssh.MockCommand{Match: "curl", Output: "200"}, + ssh.MockCommand{Match: "docker inspect -f '{{range $p, $b := .NetworkSettings.Ports}}{{range $b}}{{.HostIp}}", Output: "127.0.0.1 "}, + ssh.MockCommand{Match: "docker inspect -f '{{range $p, $b := .NetworkSettings.Ports}}", Output: "49153"}, + ssh.MockCommand{Match: "docker inspect -f '{{range $p, $_ := .NetworkSettings.Ports}}", Output: "3000/tcp"}, + ssh.MockCommand{Match: "caddy", Output: ""}, + ssh.MockCommand{Match: "cat /deployments/caddy/Caddyfile", Output: "{\n\tadmin 0.0.0.0:2019\n}\n"}, + ssh.MockCommand{Match: "mv /tmp/teploy_caddyfile.tmp", Output: ""}, + ssh.MockCommand{Match: "mkdir /deployments/caddy/.lock", Output: ""}, + ssh.MockCommand{Match: "a=$(docker exec caddy md5sum", Output: "TEPLOY_CADDY_OK"}, + ssh.MockCommand{Match: "docker exec caddy caddy reload", Output: ""}, + ssh.MockCommand{Match: "rmdir /deployments/caddy/.lock", Output: ""}, + ssh.MockCommand{Match: "docker stop", Output: ""}, + ssh.MockCommand{Match: "mkdir -p", Output: ""}, + ssh.MockCommand{Match: "cat /tmp", Output: ""}, + ssh.MockCommand{Match: "UPLOAD:", Output: ""}, + ) + return ssh.NewMockExecutor("1.2.3.4", cmds...) +} + +// Ship wave-9 lesson, rollback leg: web containers are created by image ID +// (A52), so docker ps reports a bare short ID. A rollback with no release +// record for the target recorded that ID as ImageRef — an unpullable +// 12-hex string. It must record the image's tag. +func TestRollback_ImageRefResolvesIDCreatedContainerToTag(t *testing.T) { + full := "sha256:0123456789ab" + strings.Repeat("0", 52) + mock := rollbackMocks("0123456789ab", "", + ssh.MockCommand{Match: "docker image inspect --format", Output: full + ` ["myapp-build-v1:latest"]` + "\n"}, + ) + if err := Rollback(context.Background(), mock, &bytes.Buffer{}, rollbackCfg()); err != nil { + t.Fatalf("rollback: %v", err) + } + if got := writtenState(t, mock, "myapp").ImageRef; got != "myapp-build-v1:latest" { + t.Fatalf("ImageRef after rollback = %q, want the tag myapp-build-v1:latest", got) + } +} + +// The target's release record, when present, is the requested reference +// and wins over whatever the container reports. +func TestRollback_ImageRefPrefersReleaseRecord(t *testing.T) { + mock := rollbackMocks("myapp:latest", `"previous_release":{"hash":"v1","image_ref":"myapp:v1"},`) + if err := Rollback(context.Background(), mock, &bytes.Buffer{}, rollbackCfg()); err != nil { + t.Fatalf("rollback: %v", err) + } + if got := writtenState(t, mock, "myapp").ImageRef; got != "myapp:v1" { + t.Fatalf("ImageRef after rollback = %q, want the release record's myapp:v1", got) + } +} diff --git a/internal/diagnose/diagnose.go b/internal/diagnose/diagnose.go index 557544b..4ad1387 100644 --- a/internal/diagnose/diagnose.go +++ b/internal/diagnose/diagnose.go @@ -199,7 +199,7 @@ func nothingListeningRule(c Context) *Finding { return &Finding{ Summary: "the container is running but nothing is listening on any TCP port yet", Try: []string{ - "if the app boots slowly (migrations, JIT warmup), raise the health timeout in teploy.yml (`health: { timeout: 90s }`)", + "if the app boots slowly (migrations, JIT warmup), raise the health timeout in teploy.yml (`health: { timeout_seconds: 90 }`)", "confirm the process actually starts an HTTP server (worker-only images should be a `processes:` entry, not the web process)", }, } @@ -217,6 +217,10 @@ func permissionRule(c Context) *Finding { } } +// dockerEmbeddedDNS is the address Docker's embedded DNS server binds +// inside containers on user-defined networks. +const dockerEmbeddedDNS = "127.0.0.11" + // ParseListeners parses `ss -tlnH` or `netstat -tln` output from inside a // container into listeners. ok is false when the output carries no usable // evidence (tool missing / empty) — callers must then treat the listener @@ -243,6 +247,16 @@ func ParseListeners(out string) (listeners []Listener, ok bool) { continue } addr := f[:idx] + ok = true + if addr == dockerEmbeddedDNS { + // Docker's embedded resolver on user-defined networks + // listens on 127.0.0.11: inside every container. + // It is not the app: counting it made a container with + // nothing listening read as "the app is listening on + // port 40137" (found proving the C03 tcp-gate fix on + // colima). Its presence still proves the tool ran. + break + } loopback := addr == "127.0.0.1" || addr == "::1" || addr == "[::1]" if existing, dup := seen[port]; dup { // A port bound on both loopback and a public address is reachable. @@ -253,7 +267,6 @@ func ParseListeners(out string) (listeners []Listener, ok bool) { l := &Listener{Port: port, LoopbackOnly: loopback} seen[port] = l } - ok = true break // first host:port field per line is the local address } } diff --git a/internal/diagnose/diagnose_test.go b/internal/diagnose/diagnose_test.go index b1fe70a..c0cc08d 100644 --- a/internal/diagnose/diagnose_test.go +++ b/internal/diagnose/diagnose_test.go @@ -161,3 +161,23 @@ func TestParseListenersEmpty(t *testing.T) { t.Fatal("tool-missing output must not claim knowledge") } } + +// Docker's embedded DNS (127.0.0.11:) is in every container on a +// user-defined network; it must not be reported as the app's port. Live +// capture from colima: a container that never listens. +func TestParseListenersIgnoresDockerEmbeddedDNS(t *testing.T) { + out := "Active Internet connections (only servers)\nProto Recv-Q Send-Q Local Address Foreign Address State \ntcp 0 0 127.0.0.11:40137 0.0.0.0:* LISTEN\n" + ls, ok := ParseListeners(out) + if !ok || len(ls) != 0 { + t.Fatalf("want known-and-empty, got %v ok=%v", ls, ok) + } + fs := Diagnose(Context{State: "running", ExitCode: -1, ConfiguredPort: 18096, Listeners: ls, ListenersKnown: ok}) + for _, f := range fs { + if strings.Contains(f.Summary, "40137") { + t.Fatalf("embedded DNS reported as the app's port: %s", f.Summary) + } + } + if len(fs) == 0 || !strings.Contains(fs[0].Summary, "nothing is listening") { + t.Fatalf("want the nothing-listening finding, got %+v", fs) + } +} diff --git a/internal/docker/docker.go b/internal/docker/docker.go index a49369e..5076c35 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -37,6 +37,12 @@ type Container struct { Status string // human-readable, e.g. "Up 2 hours" CreatedAt string // raw docker timestamp, e.g. "2026-05-28 21:33:29 -0700 PDT" — lexicographically sortable for same-TZ comparisons Labels map[string]string + // ImageID and ImageTags are set by ResolveImageTags for a container + // created by image ID (A52: web/worker containers run from the + // immutable ID), whose docker ps Image is that ID rather than a name. + // Image then carries the first repo tag; ImageID keeps the ID. + ImageID string `json:",omitempty"` + ImageTags []string `json:",omitempty"` } // RunConfig holds the parameters for starting a new container. @@ -564,6 +570,93 @@ func (c *Client) ContainerImageDigest(ctx context.Context, container string) (st return digest, nil } +// IsImageID reports whether ref is an image ID (full "sha256:<64 hex>", +// bare 64 hex, or docker ps's 12-hex short form) rather than a name. +func IsImageID(ref string) bool { + h := strings.TrimPrefix(ref, "sha256:") + if len(h) != 12 && len(h) != 64 { + return false + } + for _, r := range h { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') { + return false + } + } + return true +} + +// ResolveImageTags returns a copy of containers in which every container +// created by image ID reports that image's first repo tag as Image, with the +// ID kept in ImageID and every tag in ImageTags. Containers created by name are untouched and cost nothing +// (no docker call when no ID-form image is present). Best-effort by design: +// an untagged or already-removed image, or a failed inspect, leaves Image as +// the ID — reporting must never fail because the name could not be found. +// +// Found by the Ship programme (wave 9): containers created by image ID +// reported the ID, never the artifact tag, so a consumer matching the +// deployed image against the tag it shipped never matched. +func (c *Client) ResolveImageTags(ctx context.Context, containers []Container) []Container { + var ids []string + seen := map[string]bool{} + for _, ct := range containers { + if IsImageID(ct.Image) && !seen[ct.Image] { + seen[ct.Image] = true + ids = append(ids, ct.Image) + } + } + if len(ids) == 0 { + return containers + } + containers = append([]Container(nil), containers...) + quoted := make([]string, len(ids)) + for i, id := range ids { + quoted[i] = ssh.ShellQuote(id) + } + // A missing image makes inspect exit non-zero while still printing the + // found ones; keep what it printed. + out, _ := c.exec.Run(ctx, "docker image inspect --format '{{.Id}} {{json .RepoTags}}' "+strings.Join(quoted, " ")+" 2>/dev/null || true") + type img struct { + id string + tags []string + } + var images []img + for _, line := range strings.Split(out, "\n") { + id, raw, ok := strings.Cut(strings.TrimSpace(line), " ") + if !ok || !IsImageID(id) || len(strings.TrimPrefix(id, "sha256:")) != 64 { + continue + } + var tags []string + if json.Unmarshal([]byte(raw), &tags) != nil { + continue + } + images = append(images, img{id: id, tags: tags}) + } + for i, ct := range containers { + if !IsImageID(ct.Image) { + continue + } + want := strings.TrimPrefix(ct.Image, "sha256:") + for _, im := range images { + if !strings.HasPrefix(strings.TrimPrefix(im.id, "sha256:"), want) { + continue + } + containers[i].ImageID = im.id + var tags []string + for _, t := range im.tags { + if t != "" && t != ":" { + tags = append(tags, t) + } + } + if len(tags) > 0 { + containers[i].Image = tags[0] + containers[i].ImageTags = tags + } + break + } + } + return containers +} + // Remove removes a stopped container. func (c *Client) Remove(ctx context.Context, name string) error { if _, err := c.exec.Run(ctx, "docker rm "+ssh.ShellQuote(name)); err != nil { diff --git a/internal/docker/image_tags_test.go b/internal/docker/image_tags_test.go new file mode 100644 index 0000000..56abc20 --- /dev/null +++ b/internal/docker/image_tags_test.go @@ -0,0 +1,80 @@ +package docker + +import ( + "context" + "strings" + "testing" + + "github.com/useteploy/teploy/internal/ssh" +) + +func TestIsImageID(t *testing.T) { + full := strings.Repeat("ab", 32) + for ref, want := range map[string]bool{ + "0123456789ab": true, + full: true, + "sha256:" + full: true, + "myapp:v1": false, + "0123456789": false, + "0123456789AB": false, + "sha256:0123456789": false, + } { + if got := IsImageID(ref); got != want { + t.Errorf("IsImageID(%q) = %v, want %v", ref, got, want) + } + } +} + +// Ship wave-9 lesson: containers created by image ID report the ID, never +// the artifact tag. Resolution reports the tag and keeps the ID. +func TestResolveImageTags(t *testing.T) { + idA := "sha256:0123456789ab" + strings.Repeat("0", 52) + idB := "sha256:fedcba987654" + strings.Repeat("0", 52) + mock := ssh.NewMockExecutor("h", + ssh.MockCommand{Match: "docker image inspect --format", Output: idA + ` ["app-build-v1:latest","registry/app:v1"]` + "\n" + idB + " []\n"}, + ) + in := []Container{ + {Name: "app-web-v1", Image: "0123456789ab"}, + {Name: "app-web-v0", Image: "fedcba987654"}, // untagged: keeps the ID + {Name: "app-web-gone", Image: "aaaaaaaaaaaa"}, // image removed: keeps the ID + {Name: "app-postgres", Image: "postgres:16"}, // created by name: untouched + } + out := NewClient(mock).ResolveImageTags(context.Background(), in) + + if out[0].Image != "app-build-v1:latest" || out[0].ImageID != idA || len(out[0].ImageTags) != 2 { + t.Errorf("ID-created container not resolved: %+v", out[0]) + } + if out[1].Image != "fedcba987654" || out[1].ImageID != idB || out[1].ImageTags != nil { + t.Errorf("untagged image: %+v", out[1]) + } + if out[2].Image != "aaaaaaaaaaaa" || out[2].ImageID != "" { + t.Errorf("missing image must keep the ID: %+v", out[2]) + } + if out[3].Image != "postgres:16" || out[3].ImageID != "" { + t.Errorf("name-created container changed: %+v", out[3]) + } + if in[0].Image != "0123456789ab" { + t.Error("input slice mutated") + } + if len(mock.Calls) != 1 || !strings.Contains(mock.Calls[0], "'0123456789ab'") { + t.Errorf("want one batched inspect, got %v", mock.Calls) + } +} + +// No ID-form image -> no docker call at all. +func TestResolveImageTags_NoIDsNoCall(t *testing.T) { + mock := ssh.NewMockExecutor("h") + out := NewClient(mock).ResolveImageTags(context.Background(), []Container{{Image: "nginx:1.27"}}) + if len(mock.Calls) != 0 || out[0].Image != "nginx:1.27" { + t.Fatalf("calls=%v out=%+v", mock.Calls, out) + } +} + +// A failed inspect is best-effort: IDs stay, nothing errors. +func TestResolveImageTags_InspectFailureKeepsIDs(t *testing.T) { + mock := ssh.NewMockExecutor("h", ssh.MockCommand{Match: "docker image inspect", Output: "garbage line\n"}) + out := NewClient(mock).ResolveImageTags(context.Background(), []Container{{Image: "0123456789ab"}}) + if out[0].Image != "0123456789ab" || out[0].ImageID != "" { + t.Fatalf("out=%+v", out[0]) + } +} diff --git a/internal/preview/preview.go b/internal/preview/preview.go index 3eb6c85..1c7a2aa 100644 --- a/internal/preview/preview.go +++ b/internal/preview/preview.go @@ -15,6 +15,7 @@ import ( "time" "github.com/useteploy/teploy/internal/caddy" + "github.com/useteploy/teploy/internal/deploy" "github.com/useteploy/teploy/internal/docker" "github.com/useteploy/teploy/internal/ssh" ) @@ -656,10 +657,11 @@ func (m *Manager) probeOnce(ctx context.Context, port int) bool { // probeTCP reports whether a TCP connection to localhost:port succeeds — // the listening-but-no-/health fallback. func (m *Manager) probeTCP(ctx context.Context, port int) bool { - if port < 1 || port > 65535 { + cmd, ok := deploy.TCPProbeCommand("localhost", port) + if !ok { return false } - _, err := m.exec.Run(ctx, fmt.Sprintf("bash -c '/dev/null", port)) + _, err := m.exec.Run(ctx, cmd) return err == nil } diff --git a/internal/preview/preview_test.go b/internal/preview/preview_test.go index 9e7ca15..42b9211 100644 --- a/internal/preview/preview_test.go +++ b/internal/preview/preview_test.go @@ -6,10 +6,12 @@ import ( "encoding/json" "errors" "fmt" + "io" "strings" "testing" "time" + "github.com/useteploy/teploy/internal/deploy" "github.com/useteploy/teploy/internal/ssh" ) @@ -686,3 +688,24 @@ func TestListIncludesLegacyAndCanonical(t *testing.T) { t.Errorf("legacy record must be listed unmodified (no invented ID): %+v", byBranch[dashBranch]) } } + +// TestProbeTCP_RequiresLiveListener: the preview readiness fallback uses the +// deploy gate's held-connection probe, not a bare /dev/tcp connect — a bare +// connect succeeds against docker-proxy even when the backend is dead. +func TestProbeTCP_RequiresLiveListener(t *testing.T) { + want, ok := deploy.TCPProbeCommand("localhost", 8080) + if !ok { + t.Fatal("TCPProbeCommand rejected localhost:8080") + } + live := ssh.NewMockExecutor("1.2.3.4", ssh.MockCommand{Match: want}) + if !NewManager(live, io.Discard).probeTCP(context.Background(), 8080) { + t.Fatal("probeTCP must pass when the held-connection probe exits 0") + } + connectOnly := ssh.NewMockExecutor("1.2.3.4", ssh.MockCommand{Match: "bash -c '