From de73a22303dd8c8c3ad399cb4a7ef7e87589ade3 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:10:42 -0700 Subject: [PATCH 01/10] feat(preview): tailnet preview mode - --base-domain, --http-only, --allow-ip (corpus rev 6) Implements the teploy-cli half of Tyler's 2026-09-24 preview ruling (DELEGATED_DECISIONS_2026-09-23 section 10): previews served on plain HTTP under .sslip.io, gated to tailnet addresses. - preview deploy flags: --base-domain (hostname base instead of the app domain; hyphen or dot sslip form), --http-only (explicit http:// site address, no tls directive - Caddy never attempts ACME), --allow-ip (repeatable/comma; maps to caddy.Firewall.AllowIPs). Validated before connecting and again in Manager.Deploy before any mutation. - caddy.TLS gains HTTPOnly (wins over Cert/Key/Internal). - Mode persisted in the preview record as base_domain / http_only / allow_ips (all omitempty). Updates inherit each field unless the deploy overrides it (--http-only=false, --allow-ip "" clears), so a blue/green swap never re-enables HTTPS or drops the allowlist. Records without the fields behave exactly as before (app-domain host, automatic HTTPS, no gate). C06 identity (PreviewID/previewIDHex, route key, state path) and the blue/green machinery are unchanged. - preview list --json rows gain url (http:// iff http_only, else https://); domain kept; empty list still []. Text list and deploy output print the real scheme. - preview-exposure capability token (additive). - contracts rev 6 (additive): preview-state schema gains optional record/list-row fields on both eras with the url-scheme invariant; two list-row fixtures generated from the real encoder; version-handshake fixture regenerated for the new token. No MI bump. Tests: route written HTTP-only + gated, update inherits mode, field-by- field overrides, default/legacy records unchanged, State round-trip incl. pre-field and slug-era records, invalid input refused before mutation, flag parsing through the real cobra command, list JSON url scheme, caddy HTTPOnly rendering. Real Caddy (caddy:2-alpine via podman) accepted the rendered blocks: adapt OK with no tls app, :80 only; non-allowed source 403, allowed source proxied. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 14 + README.md | 13 + contracts/MANIFEST.md | 3 +- .../valid/canonical-list-row-tailnet.json | 20 ++ .../valid/canonical-list-row.json | 15 + .../fixtures/version-handshake/valid/mi2.json | 1 + contracts/schema/preview-state.schema.json | 19 +- docs/supported-workloads.md | 2 +- internal/caddy/caddy.go | 16 +- internal/caddy/caddy_test.go | 18 ++ internal/cli/contracts_golden_test.go | 46 +++ internal/cli/machineinterface.go | 6 + internal/cli/machineinterface_test.go | 3 +- internal/cli/preview.go | 107 ++++++- internal/cli/preview_exposure_test.go | 126 ++++++++ internal/preview/exposure_test.go | 271 ++++++++++++++++++ internal/preview/preview.go | 157 +++++++++- 17 files changed, 804 insertions(+), 33 deletions(-) create mode 100644 contracts/fixtures/preview-state/valid/canonical-list-row-tailnet.json create mode 100644 contracts/fixtures/preview-state/valid/canonical-list-row.json create mode 100644 internal/cli/preview_exposure_test.go create mode 100644 internal/preview/exposure_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index db6d326..fd89371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,20 @@ All notable changes to teploy are documented here. Format follows [Keep a Change ### Added +- **Tailnet preview mode.** `teploy preview deploy` takes + `--base-domain ` (hostname base instead of the app domain, e.g. + `100-64-1-2.sslip.io`), `--http-only` (plain HTTP site, no ACME) and + `--allow-ip ` (repeatable; everything else gets 403). The mode + is stored in the preview record (`base_domain`, `http_only`, + `allow_ips`, all omitted for default previews) and inherited by later + deploys of the branch unless overridden (`--http-only=false`, + `--allow-ip ""`), so a blue/green update never silently re-enables + HTTPS or drops the allowlist. `preview list --json` rows gain `url` + (`http://` for HTTP-only, else `https://`); `domain` is unchanged. The + `preview-exposure` capability token is advertised. Preview identity + (`-p-`) and blue/green are unchanged; records without the + new fields behave exactly as before. + - **Plan/apply with drift invalidation (C05).** `teploy plan` now renders the full effect set — routing (domain/ingress/port/publishes), environment keys, storage volumes, resource limits, accessories — diff --git a/README.md b/README.md index 18d411f..c6b7f89 100644 --- a/README.md +++ b/README.md @@ -438,6 +438,19 @@ teploy preview deploy fix/login-500 --image app-build-abc1234 --ttl 24h Requires Teploy-managed Caddy: previews provision a `preview-.` route on demand, which an external ingress cannot do. +Tailnet-only previews skip the domain and the certificate: the hostname sits +under the target's Tailscale IP via sslip.io, Caddy serves it on plain HTTP, +and only tailnet addresses get through. + +``` +teploy preview deploy fix/login-500 --image app-build-abc1234 \ + --base-domain 100-64-1-2.sslip.io --http-only --allow-ip 100.64.0.0/10 +# http://preview-fix-login-500-.100-64-1-2.sslip.io +``` + +The mode is recorded with the preview, so redeploying the branch keeps it. +`preview list --json` rows carry a `url` with the scheme actually served. + ### Backups ``` teploy backup create # backup volumes to S3 diff --git a/contracts/MANIFEST.md b/contracts/MANIFEST.md index 54e8618..2234f12 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 | |---|---|---|---| +| 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). | | 3 (amended) | main (C05 plan-record corpus + defect fix) | 1 | C05 added the plan-record artifact + plan-apply token (see git history); amendment: server-status schema now carries its own $defs (its $refs never resolved), and app-list fixtures emit [] where the encoder emits [] (null fixtures failed schema + the real dash decode - found by dash's new contracts CI job, fixed here). | @@ -28,7 +29,7 @@ Neutron/Nucleus dependency and a public mirror. | error-envelope | yes | valid x2 + invalid code | teploy-cli | | release-record | yes | valid container | teploy-cli | | attempt-name | yes (pattern) | valid + invalid examples | teploy-cli | -| preview-state | yes (canonical/legacy) | valid + legacy + ambiguous | teploy-cli | +| preview-state | yes (canonical/legacy; optional record/list-row fields rev 6) | valid (hand-authored identity + 2 generated list rows) + legacy + ambiguous | teploy-cli | | observation-envelope | yes (§2.4 canonical, rev 2) | valid x4 (fresh, stale, unknown-unreachable, unreachable-last-known; dash encoder) | teploy-dash | | plan-record | yes | valid x2 (build unresolved-awaiting-build, prebuilt resolved-by-digest) + invalid tampered-id | teploy-cli | | operation-record | yes | pending S5/S6 (dash) | teploy-dash | diff --git a/contracts/fixtures/preview-state/valid/canonical-list-row-tailnet.json b/contracts/fixtures/preview-state/valid/canonical-list-row-tailnet.json new file mode 100644 index 0000000..c988476 --- /dev/null +++ b/contracts/fixtures/preview-state/valid/canonical-list-row-tailnet.json @@ -0,0 +1,20 @@ +{ + "allow_ips": [ + "100.64.0.0/10" + ], + "app": "myapp", + "base_domain": "100-64-1-2.sslip.io", + "branch": "feature/login", + "container": "myapp-preview-p-08e81639-abc1234", + "created_at": "2026-09-24T12:00:00Z", + "domain": "preview-feature-login-08e81639.100-64-1-2.sslip.io", + "era": "canonical", + "expires_at": "2026-09-27T12:00:00Z", + "http_only": true, + "id": "myapp-p-08e81639", + "image": "myapp-build-abc1234", + "port": 49200, + "repo": "github.com/example/myapp", + "route": "myapp-preview-p-08e81639", + "url": "http://preview-feature-login-08e81639.100-64-1-2.sslip.io" +} diff --git a/contracts/fixtures/preview-state/valid/canonical-list-row.json b/contracts/fixtures/preview-state/valid/canonical-list-row.json new file mode 100644 index 0000000..d4b546a --- /dev/null +++ b/contracts/fixtures/preview-state/valid/canonical-list-row.json @@ -0,0 +1,15 @@ +{ + "app": "myapp", + "branch": "feature/login", + "container": "myapp-preview-p-08e81639-abc1234", + "created_at": "2026-09-24T12:00:00Z", + "domain": "preview-feature-login-08e81639.myapp.com", + "era": "canonical", + "expires_at": "2026-09-27T12:00:00Z", + "id": "myapp-p-08e81639", + "image": "myapp-build-abc1234", + "port": 49200, + "repo": "github.com/example/myapp", + "route": "myapp-preview-p-08e81639", + "url": "https://preview-feature-login-08e81639.myapp.com" +} diff --git a/contracts/fixtures/version-handshake/valid/mi2.json b/contracts/fixtures/version-handshake/valid/mi2.json index 353b730..4292447 100644 --- a/contracts/fixtures/version-handshake/valid/mi2.json +++ b/contracts/fixtures/version-handshake/valid/mi2.json @@ -10,6 +10,7 @@ "plan-apply", "preview-blue-green", "preview-canonical-id", + "preview-exposure", "provenance-records", "readiness-receipts", "repair-debt", diff --git a/contracts/schema/preview-state.schema.json b/contracts/schema/preview-state.schema.json index cf421de..d417385 100644 --- a/contracts/schema/preview-state.schema.json +++ b/contracts/schema/preview-state.schema.json @@ -1,9 +1,25 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://teploy.github.io/contracts/schema/preview-state.schema.json", - "title": "preview identity eras (C06): canonical -p-, legacy slug, ambiguous", + "title": "preview identity eras (C06): canonical -p-, legacy slug, ambiguous; optional record/list-row fields (rev 6)", + "$defs": { + "recordFields": { + "$comment": "rev 6, additive: fields a preview record / `preview list --json` row may carry. Absent exposure fields = default preview (app-domain host, automatic HTTPS, no IP gate). url's scheme is the one the route serves: http:// iff http_only.", + "properties": { + "domain": {"type": "string", "minLength": 1}, + "url": {"type": "string", "pattern": "^https?://[^/]+$"}, + "base_domain": {"type": "string", "minLength": 1}, + "http_only": {"type": "boolean"}, + "allow_ips": {"type": "array", "items": {"type": "string", "minLength": 1}} + }, + "if": {"required": ["http_only"], "properties": {"http_only": {"const": true}}}, + "then": {"properties": {"url": {"pattern": "^http://"}}}, + "else": {"properties": {"url": {"pattern": "^https://"}}} + } + }, "oneOf": [ {"$comment": "canonical era", "type": "object", + "allOf": [{"$ref": "#/$defs/recordFields"}], "required": ["id", "era", "app", "branch"], "properties": { "id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*-p-[a-f0-9]{8}$"}, @@ -11,6 +27,7 @@ "app": {"type": "string"}, "branch": {"type": "string"}}}, {"$comment": "legacy slug-keyed era; readable, adoptable when unambiguous", "type": "object", + "allOf": [{"$ref": "#/$defs/recordFields"}], "required": ["id", "era", "app"], "properties": { "id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*-p-[a-z0-9][a-z0-9-]*$"}, diff --git a/docs/supported-workloads.md b/docs/supported-workloads.md index d3bb1c0..edd9b98 100644 --- a/docs/supported-workloads.md +++ b/docs/supported-workloads.md @@ -16,7 +16,7 @@ otherwise. | Static site (`type: static`) | Supported | rsync to the server, served by the managed Caddy. Requires Caddy ingress; `ingress: host`/`external` and `tls:` are rejected for static. | | Compose file as an importer (subset) | Supported (subset) | `docker-compose.yml` in the project dir imports when no `teploy.yml` exists. Every supplied field is preserved, translated, or rejected with a named error — see [migration.md](migration.md) for the classification summary. | | Templates (`teploy template install`) | Supported | One-command deploys of reviewed community apps (Postgres+Adminer, WordPress, Immich, ...). Catalog: `teploy template list`. | -| Preview environments (`teploy preview`) | Supported | Branch slugs on `preview-.` against a pre-built image (`teploy build`). Requires Teploy-managed Caddy. | +| Preview environments (`teploy preview`) | Supported | Branch slugs on `preview-.` against a pre-built image (`teploy build`). Requires Teploy-managed Caddy. Tailnet-only mode: `--base-domain .sslip.io --http-only --allow-ip 100.64.0.0/10`. | | Accessories (Postgres, Redis, MySQL, Mariaadb, Mongo, ClickHouse, Meilisearch, Elasticsearch, Memcached, RabbitMQ, NATS, or any standalone image) | Supported | Managed alongside the app with `--restart always`, volumes, ports, env. | | Multi-image stacks (several independently built services) | Refused | One image per app is the model. A Compose file whose service builds from a different context than the web service refuses at config load: `unsupported independent build in compose import: ... — teploy runs one image per app and cannot preserve a separately built service`. Model as separate teploy apps, or prebuilt images. | | Multiple web candidates in one Compose file | Refused | `ambiguous compose import: multiple non-accessory services publish ports (...)`. Remove ports from non-app services or write `teploy.yml`. | diff --git a/internal/caddy/caddy.go b/internal/caddy/caddy.go index 98a1e92..6a53f95 100644 --- a/internal/caddy/caddy.go +++ b/internal/caddy/caddy.go @@ -116,12 +116,21 @@ type TLS struct { // config.TLSConfig.Internal, which is where this is actually set from // teploy.yml. Internal bool + // HTTPOnly serves every host of the block on plain HTTP: each site + // address gets an explicit http:// scheme and no tls directive is + // rendered, so Caddy never attempts ACME for it. Takes precedence over + // Cert/Key/Internal. Used by tailnet previews (a 100.x address behind a + // wildcard DNS name can never complete a public ACME challenge). + HTTPOnly bool } // directive returns the indented `tls` line for a site block — `tls // internal` for Internal, `tls ` for a custom cert, or "" when // neither is configured (automatic HTTPS). func (t TLS) directive() string { + if t.HTTPOnly { + return "" + } if t.Internal { return "\ttls internal\n" } @@ -154,12 +163,13 @@ func IsPubliclyRoutable(host string) bool { // which case the operator has explicitly opted in and automatic-HTTPS // avoidance would just be wrong. See IsPubliclyRoutable for why this // matters: without it, Caddy attempts (and hangs on) a real ACME challenge -// for addresses that can never complete one. +// for addresses that can never complete one. tls.HTTPOnly forces the +// http:// scheme on every host, public or not. func siteAddresses(hosts []string, tls TLS) []string { - wantsTLS := tls.Internal || (tls.Cert != "" && tls.Key != "") + wantsTLS := !tls.HTTPOnly && (tls.Internal || (tls.Cert != "" && tls.Key != "")) out := make([]string, len(hosts)) for i, h := range hosts { - if !wantsTLS && !IsPubliclyRoutable(h) { + if tls.HTTPOnly || (!wantsTLS && !IsPubliclyRoutable(h)) { out[i] = "http://" + h } else { out[i] = h diff --git a/internal/caddy/caddy_test.go b/internal/caddy/caddy_test.go index 003033e..74a64e0 100644 --- a/internal/caddy/caddy_test.go +++ b/internal/caddy/caddy_test.go @@ -561,6 +561,24 @@ func TestReverseProxyBlock_CustomCertKeepsRealHost(t *testing.T) { } } +// TLS.HTTPOnly (tailnet previews): a PUBLIC hostname gets the explicit +// http:// scheme and no tls directive, so Caddy never attempts ACME for it; +// it wins over Internal/Cert, and composes with the firewall allowlist. +func TestReverseProxyBlock_HTTPOnly(t *testing.T) { + got := reverseProxyBlock([]string{"preview-main-563059ce.100.64.1.2.sslip.io"}, "myapp-preview-p-563059ce-v1", 3000, + TLS{HTTPOnly: true, Internal: true, Cert: "/c.crt", Key: "/c.key"}, "", nil, Firewall{AllowIPs: []string{"100.64.0.0/10"}}, Access{}) + want := "http://preview-main-563059ce.100.64.1.2.sslip.io {\n" + + "\t@teploy_fw_notallow not remote_ip 100.64.0.0/10\n" + + "\thandle @teploy_fw_notallow {\n\t\trespond 403\n\t}\n" + + "\thandle {\n\t\treverse_proxy myapp-preview-p-563059ce-v1:3000\n\t}\n}" + if got != want { + t.Errorf("reverseProxyBlock with HTTPOnly:\nwant: %q\ngot: %q", want, got) + } + if addrs := siteAddresses([]string{"a.example.com", "10.0.0.1"}, TLS{HTTPOnly: true}); addrs[0] != "http://a.example.com" || addrs[1] != "http://10.0.0.1" { + t.Errorf("siteAddresses with HTTPOnly = %v, want http:// on every host", addrs) + } +} + func TestMaintenanceBlock_NonPublicDomainGetsPlainHTTP(t *testing.T) { got := maintenanceBlock([]string{"192.168.1.114"}, SitePolicy{}) if !strings.HasPrefix(got, "http://192.168.1.114 {") { diff --git a/internal/cli/contracts_golden_test.go b/internal/cli/contracts_golden_test.go index 4ac7f9c..ae7beda 100644 --- a/internal/cli/contracts_golden_test.go +++ b/internal/cli/contracts_golden_test.go @@ -19,6 +19,7 @@ import ( "time" "github.com/useteploy/teploy/internal/config" + "github.com/useteploy/teploy/internal/preview" "github.com/useteploy/teploy/internal/releasemeta" "github.com/useteploy/teploy/internal/ssh" ) @@ -305,3 +306,48 @@ func TestContractsPlanRecordGolden(t *testing.T) { tampered.ConfigDigest = "0000000000000000000000000000000000000000000000000000000000000000" writeFixture(t, "plan-record/invalid/tampered-id.json", tampered) } + +// TestContractsPreviewStateListRowGolden drives the REAL `preview list +// --json` row encoder (previewListRows over preview.State) for a default +// and a tailnet-mode canonical preview (corpus rev 6). The row is wrapped +// with the artifact's era classification keys (era, app) — the wire row +// itself carries neither. The hand-authored identity fixtures (canonical, +// legacy, ambiguous) are unchanged. +func TestContractsPreviewStateListRowGolden(t *testing.T) { + created := time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC) + base := preview.State{ + ID: preview.PreviewID("myapp", "feature/login"), + Branch: "feature/login", + Repo: "github.com/example/myapp", + Route: "myapp-preview-p-08e81639", + Port: 49200, + Container: "myapp-preview-p-08e81639-abc1234", + Image: "myapp-build-abc1234", + CreatedAt: created, + ExpiresAt: created.Add(72 * time.Hour), + } + def := base + def.Domain = "preview-feature-login-08e81639.myapp.com" + tailnet := base + tailnet.Domain = "preview-feature-login-08e81639.100-64-1-2.sslip.io" + tailnet.BaseDomain = "100-64-1-2.sslip.io" + tailnet.HTTPOnly = true + tailnet.AllowIPs = []string{"100.64.0.0/10"} + + for name, s := range map[string]preview.State{ + "preview-state/valid/canonical-list-row.json": def, + "preview-state/valid/canonical-list-row-tailnet.json": tailnet, + } { + data, err := json.Marshal(previewListRows([]preview.State{s})[0]) + if err != nil { + t.Fatalf("marshal %s: %v", name, err) + } + var row map[string]any + if err := json.Unmarshal(data, &row); err != nil { + t.Fatalf("unmarshal %s: %v", name, err) + } + row["era"] = "canonical" + row["app"] = "myapp" + writeFixture(t, name, row) + } +} diff --git a/internal/cli/machineinterface.go b/internal/cli/machineinterface.go index f96b5b1..bf43f54 100644 --- a/internal/cli/machineinterface.go +++ b/internal/cli/machineinterface.go @@ -95,6 +95,11 @@ const ( // drifted — when anything moved since the plan (C05). Applied // releases carry provenance.plan_id. CapPlanApply = "plan-apply" + // `preview deploy --base-domain/--http-only/--allow-ip`: tailnet + // preview mode, persisted in the preview record (inherited by + // updates); `preview list --json` rows carry url with the served + // scheme (DELEGATED_DECISIONS §10). + CapPreviewExposure = "preview-exposure" ) // MachineCapabilities returns every capability token this build @@ -118,6 +123,7 @@ func MachineCapabilities() []string { CapServerStatusMachine, CapDoctorDiagnostics, CapPlanApply, + CapPreviewExposure, } sort.Strings(tokens) return tokens diff --git a/internal/cli/machineinterface_test.go b/internal/cli/machineinterface_test.go index 289af33..29f851b 100644 --- a/internal/cli/machineinterface_test.go +++ b/internal/cli/machineinterface_test.go @@ -92,6 +92,7 @@ func TestCapabilityTokenRegistry(t *testing.T) { "plan-apply", "preview-blue-green", "preview-canonical-id", + "preview-exposure", "provenance-records", "readiness-receipts", "repair-debt", @@ -125,7 +126,7 @@ func TestCapabilityTokenRegistry(t *testing.T) { CapHealthModes, CapProvenanceRecords, CapReadinessReceipts, CapPreviewCanonicalID, CapRepairDebt, CapPreviewBlueGreen, CapErrorEnvelope, CapAppListMachine, CapServerStatusMachine, - CapDoctorDiagnostics, CapPlanApply, + CapDoctorDiagnostics, CapPlanApply, CapPreviewExposure, } { if !member[token] { t.Fatalf("capability constant %q is not advertised", token) diff --git a/internal/cli/preview.go b/internal/cli/preview.go index 23ff0e4..afecce0 100644 --- a/internal/cli/preview.go +++ b/internal/cli/preview.go @@ -30,9 +30,29 @@ func newPreviewCmd(flags *Flags) *cobra.Command { return cmd } +// previewDeployOpts are the parsed `preview deploy` flags. The exposure +// fields are tri-state: a flag not given leaves its field unset (nil) so an +// update inherits the preview's recorded mode instead of resetting it. +type previewDeployOpts struct { + ttl string + image string + baseDomain string + httpOnly *bool + allowIPs []string +} + func newPreviewDeployCmd(flags *Flags) *cobra.Command { - var ttl string - var image string + return newPreviewDeployCmdWith(func(branch string, opts previewDeployOpts) error { + return runPreviewDeploy(flags, branch, opts) + }) +} + +// newPreviewDeployCmdWith builds the command around run (the test seam: +// flag parsing and validation are the real ones). +func newPreviewDeployCmdWith(run func(branch string, opts previewDeployOpts) error) *cobra.Command { + var opts previewDeployOpts + var httpOnly bool + var allowIPs []string cmd := &cobra.Command{ Use: "deploy ", @@ -46,20 +66,65 @@ touching production, which "teploy deploy" cannot do. Example: git checkout feat/new-landing teploy build --json # prints the image tag - teploy preview deploy feat-new-landing --ttl 24h --image `, + teploy preview deploy feat-new-landing --ttl 24h --image + +Tailnet-only preview (plain HTTP, reachable only from Tailscale addresses): + teploy preview deploy feat-new-landing --image \ + --base-domain 100-64-1-2.sslip.io --http-only --allow-ip 100.64.0.0/10 + +--base-domain, --http-only and --allow-ip are recorded with the preview; +a later deploy of the same branch keeps them unless it passes them again +(--http-only=false turns HTTP-only off, --allow-ip "" clears the list).`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runPreviewDeploy(flags, args[0], ttl, image) + if err := finishPreviewDeployOpts(cmd, &opts, httpOnly, allowIPs); err != nil { + return err + } + return run(args[0], opts) }, } - cmd.Flags().StringVar(&ttl, "ttl", "72h", "time-to-live before auto-expiry") - cmd.Flags().StringVar(&image, "image", "", "image to run (default: teploy.yml's image, else -build-)") + cmd.Flags().StringVar(&opts.ttl, "ttl", "72h", "time-to-live before auto-expiry") + cmd.Flags().StringVar(&opts.image, "image", "", "image to run (default: teploy.yml's image, else -build-)") + cmd.Flags().StringVar(&opts.baseDomain, "base-domain", "", "hostname base instead of the app domain (e.g. 100-64-1-2.sslip.io)") + cmd.Flags().BoolVar(&httpOnly, "http-only", false, "serve the preview over plain HTTP (no certificate)") + cmd.Flags().StringSliceVar(&allowIPs, "allow-ip", nil, "only this IP/CIDR may reach the preview (repeatable)") return cmd } -func runPreviewDeploy(flags *Flags, branch, ttlStr, image string) error { +// finishPreviewDeployOpts turns the raw exposure flags into opts, keeping +// "not given" distinct from "given as false/empty", and validates them +// before anything connects. +func finishPreviewDeployOpts(cmd *cobra.Command, opts *previewDeployOpts, httpOnly bool, allowIPs []string) error { + opts.baseDomain = strings.ToLower(strings.TrimSpace(opts.baseDomain)) + if cmd.Flags().Changed("base-domain") { + if err := preview.ValidateBaseDomain(opts.baseDomain); err != nil { + return err + } + } + opts.httpOnly = nil + if cmd.Flags().Changed("http-only") { + v := httpOnly + opts.httpOnly = &v + } + opts.allowIPs = nil + if cmd.Flags().Changed("allow-ip") { + opts.allowIPs = []string{} + for _, ip := range allowIPs { + if ip = strings.TrimSpace(ip); ip != "" { + opts.allowIPs = append(opts.allowIPs, ip) + } + } + if err := preview.ValidateAllowIPs(opts.allowIPs); err != nil { + return err + } + } + return nil +} + +func runPreviewDeploy(flags *Flags, branch string, opts previewDeployOpts) error { + ttlStr, image := opts.ttl, opts.image appCfg, err := config.LoadApp(".") if err != nil { return err @@ -136,6 +201,10 @@ func runPreviewDeploy(flags *Flags, branch, ttlStr, image string) error { Version: version, TTL: ttl, Repo: repo, + + BaseDomain: opts.baseDomain, + HTTPOnly: opts.httpOnly, + AllowIPs: opts.allowIPs, }) if n := buildNotifier(appCfg); n != nil { @@ -188,10 +257,7 @@ func runPreviewList(flags *Flags) error { return err } if flags.JSON { - if previews == nil { - previews = []preview.State{} - } - return json.NewEncoder(os.Stdout).Encode(previews) + return json.NewEncoder(os.Stdout).Encode(previewListRows(previews)) } if len(previews) == 0 { @@ -204,13 +270,30 @@ func runPreviewList(flags *Flags) error { if time.Now().UTC().After(p.ExpiresAt) { expired = " (expired)" } - fmt.Printf(" %s → https://%s%s\n", p.Branch, p.Domain, expired) + fmt.Printf(" %s → %s%s\n", p.Branch, p.URL(), expired) fmt.Printf(" Container: %s Port: %d Expires: %s\n", p.Container, p.Port, p.ExpiresAt.Format(time.RFC3339)) } return nil } +// previewListRow is one `preview list --json` row: the preview record +// plus its url, carrying the scheme the route actually serves (http:// for +// an HTTP-only preview). +type previewListRow struct { + preview.State + URL string `json:"url"` +} + +// previewListRows is the `preview list --json` encoder input: never null. +func previewListRows(previews []preview.State) []previewListRow { + rows := make([]previewListRow, 0, len(previews)) + for _, p := range previews { + rows = append(rows, previewListRow{State: p, URL: p.URL()}) + } + return rows +} + func newPreviewDestroyCmd(flags *Flags) *cobra.Command { return &cobra.Command{ Use: "destroy ", diff --git a/internal/cli/preview_exposure_test.go b/internal/cli/preview_exposure_test.go new file mode 100644 index 0000000..5277856 --- /dev/null +++ b/internal/cli/preview_exposure_test.go @@ -0,0 +1,126 @@ +package cli + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "github.com/useteploy/teploy/internal/preview" +) + +// parsePreviewDeployFlags runs the real `preview deploy` command's flag +// parsing and option finishing, stopping before anything connects. +func parsePreviewDeployFlags(t *testing.T, args ...string) (previewDeployOpts, error) { + t.Helper() + var got previewDeployOpts + ran := false + cmd := newPreviewDeployCmdWith(func(branch string, opts previewDeployOpts) error { + ran, got = true, opts + return nil + }) + cmd.SetArgs(append([]string{"feature/login"}, args...)) + cmd.SilenceUsage, cmd.SilenceErrors = true, true + err := cmd.Execute() + if err == nil && !ran { + t.Fatal("command did not run") + } + return got, err +} + +func TestPreviewDeployFlags(t *testing.T) { + // No exposure flags: every exposure field unset, so an update inherits. + o, err := parsePreviewDeployFlags(t, "--image", "app-build-abc") + if err != nil { + t.Fatal(err) + } + if o.baseDomain != "" || o.httpOnly != nil || o.allowIPs != nil || o.image != "app-build-abc" || o.ttl != "72h" { + t.Errorf("defaults: %+v", o) + } + + // The tailnet invocation Ship sends. + o, err = parsePreviewDeployFlags(t, "--base-domain", "100-64-1-2.sslip.io", "--http-only", + "--allow-ip", "100.64.0.0/10", "--allow-ip", "fd7a:115c:a1e0::/48") + if err != nil { + t.Fatal(err) + } + if o.baseDomain != "100-64-1-2.sslip.io" || o.httpOnly == nil || !*o.httpOnly || + !reflect.DeepEqual(o.allowIPs, []string{"100.64.0.0/10", "fd7a:115c:a1e0::/48"}) { + t.Errorf("tailnet flags: %+v", o) + } + + // Dot form, uppercase normalized; comma list; explicit false; explicit + // empty allowlist (clears on update). + o, err = parsePreviewDeployFlags(t, "--base-domain", "100.64.1.2.SSLIP.io", "--http-only=false", + "--allow-ip", "100.64.0.0/10,10.0.0.1") + if err != nil { + t.Fatal(err) + } + if o.baseDomain != "100.64.1.2.sslip.io" || o.httpOnly == nil || *o.httpOnly || + !reflect.DeepEqual(o.allowIPs, []string{"100.64.0.0/10", "10.0.0.1"}) { + t.Errorf("dot form / false / comma list: %+v", o) + } + o, err = parsePreviewDeployFlags(t, "--allow-ip", "") + if err != nil { + t.Fatal(err) + } + if o.allowIPs == nil || len(o.allowIPs) != 0 { + t.Errorf(`--allow-ip "" must be an explicit empty list, got %#v`, o.allowIPs) + } + + for _, bad := range [][]string{ + {"--allow-ip", "100.64.0.0/33"}, + {"--allow-ip", "not-an-ip"}, + {"--base-domain", "localhost"}, + {"--base-domain", "bad domain.io"}, + {"--base-domain", ""}, + } { + if _, err := parsePreviewDeployFlags(t, bad...); err == nil { + t.Errorf("%v accepted", bad) + } + } +} + +// `preview list --json` rows carry url with the scheme the route serves, +// and keep domain; an empty list encodes as [], never null. +func TestPreviewListRowsURL(t *testing.T) { + exp := time.Date(2026, 9, 27, 0, 0, 0, 0, time.UTC) + rows := previewListRows([]preview.State{ + {ID: "myapp-p-08e81639", Branch: "feature/login", Domain: "preview-feature-login-08e81639.100-64-1-2.sslip.io", + HTTPOnly: true, AllowIPs: []string{"100.64.0.0/10"}, BaseDomain: "100-64-1-2.sslip.io", ExpiresAt: exp}, + {ID: "myapp-p-563059ce", Branch: "main", Domain: "preview-main-563059ce.myapp.com", ExpiresAt: exp}, + {Branch: "old", Domain: "preview-old.myapp.com", ExpiresAt: exp}, // legacy slug-era record + }) + data, err := json.Marshal(rows) + if err != nil { + t.Fatal(err) + } + var decoded []map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + want := []struct{ url, domain string }{ + {"http://preview-feature-login-08e81639.100-64-1-2.sslip.io", "preview-feature-login-08e81639.100-64-1-2.sslip.io"}, + {"https://preview-main-563059ce.myapp.com", "preview-main-563059ce.myapp.com"}, + {"https://preview-old.myapp.com", "preview-old.myapp.com"}, + } + for i, w := range want { + if decoded[i]["url"] != w.url || decoded[i]["domain"] != w.domain { + t.Errorf("row %d: url=%v domain=%v, want %s / %s", i, decoded[i]["url"], decoded[i]["domain"], w.url, w.domain) + } + } + if decoded[0]["http_only"] != true || decoded[0]["base_domain"] != "100-64-1-2.sslip.io" { + t.Errorf("tailnet row lost its mode fields: %v", decoded[0]) + } + for _, key := range []string{"http_only", "allow_ips", "base_domain"} { + if _, ok := decoded[1][key]; ok { + t.Errorf("default row must not carry %q: %v", key, decoded[1]) + } + } + + empty, _ := json.Marshal(previewListRows(nil)) + if strings.TrimSpace(string(empty)) != "[]" { + t.Errorf("empty list = %s, want []", empty) + } +} diff --git a/internal/preview/exposure_test.go b/internal/preview/exposure_test.go new file mode 100644 index 0000000..e2ea92d --- /dev/null +++ b/internal/preview/exposure_test.go @@ -0,0 +1,271 @@ +package preview + +// Tailnet preview mode (DELEGATED_DECISIONS_2026-09-23 §10): an explicit +// base domain, HTTP-only routes and an IP allowlist, persisted in the +// record so updates, list, prune and destroy keep them — and records +// without the fields behave exactly as before. + +import ( + "bytes" + "context" + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "github.com/useteploy/teploy/internal/ssh" +) + +const ( + tailnetBase = "100-64-1-2.sslip.io" + tailnetCIDR = "100.64.0.0/10" + tailnetDomain = "preview-feature-login-" + loginIDHex + "." + tailnetBase +) + +func boolPtr(b bool) *bool { return &b } + +func tailnetCfg(branch, version string) DeployConfig { + cfg := deployCfg(branch, version) + cfg.BaseDomain = tailnetBase + cfg.HTTPOnly = boolPtr(true) + cfg.AllowIPs = []string{tailnetCIDR} + return cfg +} + +func readState(t *testing.T, mock *ssh.MockExecutor, branch string) State { + t.Helper() + var s State + if err := json.Unmarshal(mock.Files[previewStatePath("myapp", branch)], &s); err != nil { + t.Fatalf("reading record: %v", err) + } + return s +} + +// managedBlock returns the Caddyfile region for a route key. +func managedBlock(t *testing.T, mock *ssh.MockExecutor, key string) string { + t.Helper() + caddyfile := string(mock.Files["/deployments/caddy/Caddyfile"]) + begin := strings.Index(caddyfile, "# TEPLOY BEGIN "+key+"\n") + end := strings.Index(caddyfile, "# TEPLOY END "+key+"\n") + if begin < 0 || end < begin { + t.Fatalf("no managed block for %s in:\n%s", key, caddyfile) + } + return caddyfile[begin:end] +} + +// The route is written HTTP-only (explicit http:// site address, no tls +// line) with the allowlist as its firewall, under the explicit base; the +// record carries the mode and the output names the http:// URL. +func TestDeploy_TailnetModeWritesHTTPOnlyGatedRoute(t *testing.T) { + mock := ssh.NewMockExecutor("1.2.3.4", previewDeployMocks()...) + var buf bytes.Buffer + mgr := NewManager(mock, &buf) + + mustDeploy(t, mgr, tailnetCfg(loginBranch, "v1")) + + block := managedBlock(t, mock, "myapp-preview-p-"+loginIDHex) + for _, want := range []string{ + "http://" + tailnetDomain + " {", + "@teploy_fw_notallow not remote_ip " + tailnetCIDR, + "reverse_proxy myapp-preview-p-" + loginIDHex + "-v1:80", + } { + if !strings.Contains(block, want) { + t.Errorf("route missing %q:\n%s", want, block) + } + } + if strings.Contains(block, "\ttls ") { + t.Errorf("HTTP-only route must not carry a tls directive:\n%s", block) + } + + s := readState(t, mock, loginBranch) + if s.Domain != tailnetDomain || s.BaseDomain != tailnetBase || !s.HTTPOnly || !reflect.DeepEqual(s.AllowIPs, []string{tailnetCIDR}) { + t.Errorf("record does not carry the mode: %+v", s) + } + // C06 identity untouched by the mode. + if s.ID != "myapp-p-"+loginIDHex || s.Route != "myapp-preview-p-"+loginIDHex { + t.Errorf("canonical identity changed: id=%q route=%q", s.ID, s.Route) + } + if !strings.Contains(buf.String(), "Preview deployed: http://"+tailnetDomain+"\n") { + t.Errorf("output must name the http:// URL, got:\n%s", buf.String()) + } +} + +// A blue/green update that does not repeat the flags keeps the mode: same +// hostname, still HTTP-only, still gated — never silently back to HTTPS or +// open. +func TestDeploy_UpdateInheritsTailnetMode(t *testing.T) { + mock := ssh.NewMockExecutor("1.2.3.4", previewDeployMocks()...) + var buf bytes.Buffer + mgr := NewManager(mock, &buf) + + mustDeploy(t, mgr, tailnetCfg(loginBranch, "v1")) + mustDeploy(t, mgr, deployCfg(loginBranch, "v2")) // no exposure overrides + + block := managedBlock(t, mock, "myapp-preview-p-"+loginIDHex) + if !strings.HasPrefix(strings.SplitN(block, "\n", 2)[1], "http://"+tailnetDomain+" {") { + t.Errorf("update re-enabled HTTPS or moved the hostname:\n%s", block) + } + if !strings.Contains(block, "not remote_ip "+tailnetCIDR) { + t.Errorf("update dropped the allowlist:\n%s", block) + } + if !strings.Contains(block, "-v2:80") { + t.Errorf("route does not point at the v2 candidate:\n%s", block) + } + s := readState(t, mock, loginBranch) + if s.Container != "myapp-preview-p-"+loginIDHex+"-v2" || s.Domain != tailnetDomain || + s.BaseDomain != tailnetBase || !s.HTTPOnly || !reflect.DeepEqual(s.AllowIPs, []string{tailnetCIDR}) { + t.Errorf("update record lost the mode: %+v", s) + } +} + +// Explicit overrides on an update win field by field: turning HTTP-only +// off keeps the recorded base and allowlist; an empty non-nil allowlist +// clears it. +func TestDeploy_UpdateOverridesFieldByField(t *testing.T) { + mock := ssh.NewMockExecutor("1.2.3.4", previewDeployMocks()...) + var buf bytes.Buffer + mgr := NewManager(mock, &buf) + + mustDeploy(t, mgr, tailnetCfg(loginBranch, "v1")) + cfg := deployCfg(loginBranch, "v2") + cfg.HTTPOnly = boolPtr(false) + mustDeploy(t, mgr, cfg) + + block := managedBlock(t, mock, "myapp-preview-p-"+loginIDHex) + if strings.Contains(block, "http://") || !strings.Contains(block, tailnetDomain+" {") { + t.Errorf("--http-only=false must serve the same host with automatic HTTPS:\n%s", block) + } + if !strings.Contains(block, "not remote_ip "+tailnetCIDR) { + t.Errorf("allowlist not inherited:\n%s", block) + } + + cfg = deployCfg(loginBranch, "v3") + cfg.AllowIPs = []string{} + mustDeploy(t, mgr, cfg) + block = managedBlock(t, mock, "myapp-preview-p-"+loginIDHex) + if strings.Contains(block, "remote_ip") { + t.Errorf("empty allowlist override must clear the gate:\n%s", block) + } + s := readState(t, mock, loginBranch) + if s.HTTPOnly || len(s.AllowIPs) != 0 || s.BaseDomain != tailnetBase { + t.Errorf("record after overrides: %+v", s) + } +} + +// Back-compat: a default deploy writes none of the new keys (the record is +// byte-shaped like before), and updating a record written before the +// fields existed keeps automatic HTTPS, no gate, and the app-domain host. +func TestDeploy_DefaultAndLegacyRecordsUnchanged(t *testing.T) { + mock := ssh.NewMockExecutor("1.2.3.4", previewDeployMocks()...) + var buf bytes.Buffer + mgr := NewManager(mock, &buf) + + mustDeploy(t, mgr, deployCfg(loginBranch, "v1")) + raw := string(mock.Files[previewStatePath("myapp", loginBranch)]) + for _, key := range []string{"base_domain", "http_only", "allow_ips"} { + if strings.Contains(raw, key) { + t.Errorf("default record must not carry %q:\n%s", key, raw) + } + } + if !strings.Contains(buf.String(), "Preview deployed: https://preview-feature-login-"+loginIDHex+".myapp.com\n") { + t.Errorf("default output must name the https:// URL, got:\n%s", buf.String()) + } + + // A pre-field canonical record on disk, then an update over it. + domain := "preview-feature-login-" + loginIDHex + ".myapp.com" + mock.Files[previewStatePath("myapp", loginBranch)] = []byte(canonicalRecordJSON("myapp", loginIDHex, loginBranch, + "myapp-preview-p-"+loginIDHex+"-v1", domain, time.Now().Add(time.Hour))) + mustDeploy(t, mgr, deployCfg(loginBranch, "v2")) + + block := managedBlock(t, mock, "myapp-preview-p-"+loginIDHex) + if !strings.HasPrefix(strings.SplitN(block, "\n", 2)[1], domain+" {") { + t.Errorf("legacy-record update must keep automatic HTTPS on the app-domain host:\n%s", block) + } + if strings.Contains(block, "remote_ip") { + t.Errorf("legacy-record update grew a gate:\n%s", block) + } + s := readState(t, mock, loginBranch) + if s.BaseDomain != "" || s.HTTPOnly || s.AllowIPs != nil || s.URL() != "https://"+domain { + t.Errorf("legacy-record update changed the mode: %+v", s) + } +} + +// State round-trip: the mode survives marshal/unmarshal; a record written +// before the fields existed decodes to the default mode and an https URL; +// the slug-era legacy record likewise. +func TestState_ExposureRoundTrip(t *testing.T) { + in := State{ + ID: "myapp-p-" + loginIDHex, Branch: loginBranch, Route: "myapp-preview-p-" + loginIDHex, + Domain: tailnetDomain, Port: 49200, Container: "c", Image: "i", + CreatedAt: time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC), ExpiresAt: time.Date(2026, 9, 27, 0, 0, 0, 0, time.UTC), + BaseDomain: tailnetBase, HTTPOnly: true, AllowIPs: []string{tailnetCIDR, "fd7a:115c:a1e0::/48"}, + } + data, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + var out State + if err := json.Unmarshal(data, &out); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(in, out) { + t.Errorf("round-trip lost data:\nin: %+v\nout: %+v", in, out) + } + if out.URL() != "http://"+tailnetDomain { + t.Errorf("URL = %q, want http://", out.URL()) + } + + for name, raw := range map[string]string{ + "canonical-pre-field": canonicalRecordJSON("myapp", loginIDHex, loginBranch, "c", "preview-x.myapp.com", time.Now()), + "legacy-slug": legacyRecordJSON(loginBranch, "c"), + } { + var s State + if err := json.Unmarshal([]byte(raw), &s); err != nil { + t.Fatalf("%s: %v", name, err) + } + if s.BaseDomain != "" || s.HTTPOnly || s.AllowIPs != nil || !strings.HasPrefix(s.URL(), "https://") { + t.Errorf("%s decoded with a non-default mode: %+v url=%s", name, s, s.URL()) + } + } +} + +// Invalid overrides refuse before anything is touched on the server. +func TestDeploy_InvalidExposureRefusesBeforeMutation(t *testing.T) { + for name, mutate := range map[string]func(*DeployConfig){ + "allow-ip": func(c *DeployConfig) { c.AllowIPs = []string{"100.64.0.0/33"} }, + "allow-ip-junk": func(c *DeployConfig) { c.AllowIPs = []string{"1.2.3.4 }"} }, + "base-domain": func(c *DeployConfig) { c.BaseDomain = "Bad Domain {" }, + "no-base": func(c *DeployConfig) { c.Domain = "" }, + } { + t.Run(name, func(t *testing.T) { + mock := ssh.NewMockExecutor("1.2.3.4", previewDeployMocks()...) + mgr := NewManager(mock, &bytes.Buffer{}) + cfg := deployCfg(loginBranch, "v1") + mutate(&cfg) + if err := mgr.Deploy(context.Background(), cfg); err == nil { + t.Fatal("invalid exposure must fail the deploy") + } + for _, c := range mock.Calls { + if !strings.HasPrefix(c, "cat ") { + t.Errorf("mutated before validating: %q", c) + } + } + }) + } +} + +// Both sslip.io spellings are accepted as a base; a base without a dot or +// with uppercase/space is refused. +func TestValidateBaseDomain(t *testing.T) { + for _, ok := range []string{"100-64-1-2.sslip.io", "100.64.1.2.sslip.io", "myapp.com"} { + if err := ValidateBaseDomain(ok); err != nil { + t.Errorf("ValidateBaseDomain(%q): %v", ok, err) + } + } + for _, bad := range []string{"", "localhost", "Upper.sslip.io", "a..b", "-a.b", "a.b.", "a b.c", "http://a.b"} { + if err := ValidateBaseDomain(bad); err == nil { + t.Errorf("ValidateBaseDomain(%q) accepted", bad) + } + } +} diff --git a/internal/preview/preview.go b/internal/preview/preview.go index 6e98388..3eb6c85 100644 --- a/internal/preview/preview.go +++ b/internal/preview/preview.go @@ -50,6 +50,31 @@ type State struct { // `teploy preview prune` (and the deploy piggyback) destroy records // whose deadline has passed. ExpiresAt time.Time `json:"expires_at"` + + // Exposure mode (tailnet previews, DELEGATED_DECISIONS §10). All three + // are omitempty: a record without them is a default preview — hostname + // under the app's domain, automatic HTTPS, no IP gate — exactly the + // behavior of records written before these fields existed. Updates + // inherit them unless the deploy overrides them (resolveExposure), so + // a blue/green swap never silently re-enables HTTPS or drops the + // allowlist. + // + // BaseDomain is the explicit hostname base the preview was deployed + // under (--base-domain); empty means the app's domain. + BaseDomain string `json:"base_domain,omitempty"` + // HTTPOnly: the route serves plain HTTP (no certificate, no ACME). + HTTPOnly bool `json:"http_only,omitempty"` + // AllowIPs: when non-empty, only these IPs/CIDRs reach the route. + AllowIPs []string `json:"allow_ips,omitempty"` +} + +// URL is the preview's address with the scheme its route actually serves: +// http:// for an HTTP-only preview, https:// otherwise. +func (s State) URL() string { + if s.HTTPOnly { + return "http://" + s.Domain + } + return "https://" + s.Domain } // DeployConfig holds parameters for creating a preview. @@ -70,6 +95,95 @@ type DeployConfig struct { // (see State.Repo). Empty is allowed: the repo is provenance, not part // of the preview ID. Repo string + + // Exposure overrides. Each one left unset inherits the existing + // record's value on an update (and the default on a first deploy), so + // re-deploying a branch without repeating the flags keeps its mode. + // + // BaseDomain replaces Domain as the hostname base (e.g. + // "100-64-1-2.sslip.io"); "" = inherit, else Domain. + BaseDomain string + // HTTPOnly: nil = inherit, else the route is (not) plain HTTP. + HTTPOnly *bool + // AllowIPs: nil = inherit; non-nil replaces the allowlist (an empty + // non-nil slice clears it). Entries are IPs or CIDRs. + AllowIPs []string +} + +// exposure is the resolved route mode for one Deploy. +type exposure struct { + baseDomain string // base the hostname is built under + recorded string // State.BaseDomain ("" = the app domain) + httpOnly bool + allowIPs []string +} + +var validBaseDomain = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`) + +// ValidateBaseDomain checks an explicit preview base domain: a lowercase +// multi-label DNS name, short enough that the preview label still fits. +func ValidateBaseDomain(base string) error { + if !validBaseDomain.MatchString(base) || len(base) > 253-64 { + return fmt.Errorf("invalid preview base domain %q: want a lowercase DNS name like 100-64-1-2.sslip.io", base) + } + return nil +} + +// ValidateAllowIPs checks that every entry is a bare IP address or a CIDR +// (the same rule teploy.yml's firewall.allow_ips enforces) — the values +// are rendered verbatim into the Caddyfile. +func ValidateAllowIPs(ips []string) error { + for _, ip := range ips { + if strings.Contains(ip, "/") { + if _, _, err := net.ParseCIDR(ip); err != nil { + return fmt.Errorf("invalid allow-ip %q: not a valid CIDR", ip) + } + } else if net.ParseIP(ip) == nil { + return fmt.Errorf("invalid allow-ip %q: not a valid IP address or CIDR", ip) + } + } + return nil +} + +// resolveExposure merges the deploy's overrides over the existing record's +// recorded mode (nil existing = first deploy: defaults). +func resolveExposure(cfg DeployConfig, existing *State) (exposure, error) { + var e exposure + switch { + case cfg.BaseDomain != "": + e.recorded = cfg.BaseDomain + case existing != nil: + e.recorded = existing.BaseDomain + } + if e.recorded != "" { + if err := ValidateBaseDomain(e.recorded); err != nil { + return e, err + } + e.baseDomain = e.recorded + } else { + e.baseDomain = cfg.Domain + } + if e.baseDomain == "" { + return e, fmt.Errorf("no preview base domain: the app has no domain and no --base-domain was given") + } + + if cfg.HTTPOnly != nil { + e.httpOnly = *cfg.HTTPOnly + } else if existing != nil { + e.httpOnly = existing.HTTPOnly + } + + src := cfg.AllowIPs + if src == nil && existing != nil { + src = existing.AllowIPs + } + if len(src) > 0 { + e.allowIPs = append([]string(nil), src...) + } + if err := ValidateAllowIPs(e.allowIPs); err != nil { + return e, err + } + return e, nil } // Manager handles preview environment lifecycle. @@ -324,6 +438,13 @@ func (m *Manager) Deploy(ctx context.Context, cfg DeployConfig) error { if err != nil { return err } + // The exposure mode is resolved (and validated) before anything is + // mutated, against the record as found: an update inherits whatever + // the deploy does not override. + exp, err := resolveExposure(cfg, existing) + if err != nil { + return err + } if existing != nil && existingPath == legacyPreviewStatePath(cfg.App, cfg.Branch) { adopted := *existing adopted.ID = PreviewID(cfg.App, cfg.Branch) @@ -340,7 +461,7 @@ func (m *Manager) Deploy(ctx context.Context, cfg DeployConfig) error { } idHex := previewIDHex(cfg.App, cfg.Branch) - domain := previewDomain(cfg.App, cfg.Branch, cfg.Domain) + domain := previewDomain(cfg.App, cfg.Branch, exp.baseDomain) // The process (container name component AND network alias) carries the // version: each candidate gets its own alias, so the stable route can // point at exactly one generation — a shared alias would round-robin @@ -351,6 +472,9 @@ func (m *Manager) Deploy(ctx context.Context, cfg DeployConfig) error { fmt.Fprintf(m.out, "Deploying preview for branch %q...\n", cfg.Branch) fmt.Fprintf(m.out, " Domain: %s\n", domain) + if len(exp.allowIPs) > 0 { + fmt.Fprintf(m.out, " Allow: %s\n", strings.Join(exp.allowIPs, " ")) + } // Ensure preview directory exists. if _, err := m.exec.Run(ctx, "mkdir -p "+previewDir(cfg.App)); err != nil { @@ -440,24 +564,29 @@ func (m *Manager) Deploy(ctx context.Context, cfg DeployConfig) error { // Switch the preview domain's route to the candidate. The route KEY // (and with it the canonical identity) is stable; only the upstream // container moves. Preview subdomains use Caddy automatic HTTPS (no - // custom cert). - if err := m.caddy.SetRoute(ctx, routeApp, domain, containerName, internalPort, caddy.TLS{}, "", nil, caddy.Firewall{}, caddy.Access{}); err != nil { + // custom cert) unless the preview is HTTP-only; an allowlist becomes + // the route's firewall. + if err := m.caddy.SetRoute(ctx, routeApp, domain, containerName, internalPort, + caddy.TLS{HTTPOnly: exp.httpOnly}, "", nil, caddy.Firewall{AllowIPs: exp.allowIPs}, caddy.Access{}); err != nil { return abortCandidate(err, "setting preview route — the previous preview is still serving") } // Write state. now := time.Now().UTC() state := State{ - ID: PreviewID(cfg.App, cfg.Branch), - Branch: cfg.Branch, - Repo: cfg.Repo, - Route: routeApp, - Domain: domain, - Port: port, - Container: containerName, - Image: cfg.Image, - CreatedAt: now, - ExpiresAt: now.Add(cfg.TTL), + ID: PreviewID(cfg.App, cfg.Branch), + Branch: cfg.Branch, + Repo: cfg.Repo, + Route: routeApp, + Domain: domain, + Port: port, + Container: containerName, + Image: cfg.Image, + CreatedAt: now, + ExpiresAt: now.Add(cfg.TTL), + BaseDomain: exp.recorded, + HTTPOnly: exp.httpOnly, + AllowIPs: exp.allowIPs, } if err := m.writeRecord(ctx, &state, previewStatePath(cfg.App, cfg.Branch)); err != nil { return fmt.Errorf("writing preview state: %w", err) @@ -476,7 +605,7 @@ func (m *Manager) Deploy(ctx context.Context, cfg DeployConfig) error { m.caddy.RemoveRoute(ctx, predecessorRoute) } - fmt.Fprintf(m.out, " Preview deployed: https://%s\n", domain) + fmt.Fprintf(m.out, " Preview deployed: %s\n", state.URL()) fmt.Fprintf(m.out, " Expires: %s\n", state.ExpiresAt.Format(time.RFC3339)) return nil } From fe79c0927f70851e54fe4462e2aa95bddcaf7caf Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:45:13 -0700 Subject: [PATCH 02/10] autodeploy: a delivery reported running can no longer be superseded before the worker starts admit's running case parked the item in the pending slot and spawned the worker; a second delivery arriving before the worker goroutine took it saw workerLive && pending != nil and superseded it. The webhook had already told the sender that delivery was running; it never ran. Found as a ~3% flake of TestAdmission_NoGoroutinePileup under -race (GitHub CI on the mirror sync PR; 6/200 locally). The running item now goes to the worker directly. Pinned deterministically: TestAdmission_RunningDeliveryIsNeverSupersedable fails 5/5 on the old code, passes 50/50 under -race; all admission tests 300/300 under -race after the fix. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cli/autodeploy_admission_test.go | 37 +++++++++++++++++++++++ internal/cli/autodeploy_serve.go | 28 +++++++++-------- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/internal/cli/autodeploy_admission_test.go b/internal/cli/autodeploy_admission_test.go index 5af719f..d23cd3e 100644 --- a/internal/cli/autodeploy_admission_test.go +++ b/internal/cli/autodeploy_admission_test.go @@ -589,3 +589,40 @@ func TestAdmission_ResumeCarriesCommit(t *testing.T) { t.Errorf("resumed deploy pinned to commit %q, want %q (the ledger-recorded commit, not the tip)", gotCommit, sha) } } + +// TestAdmission_RunningDeliveryIsNeverSupersedable: a delivery admitted as +// "running" goes straight to the worker, so the next admit — however soon, +// even before the worker goroutine is scheduled — is queued, not a +// superseder of the delivery already reported as running. (Found flaking +// TestAdmission_NoGoroutinePileup at ~3% under -race: the running item sat +// in the pending slot until the worker took it.) +func TestAdmission_RunningDeliveryIsNeverSupersedable(t *testing.T) { + block := make(chan struct{}) + run := newCountingRun().blocking(block) + ledger := &memLedger{} + q := newAdmissionQueue(ledger, run.run, func(string, ...any) {}) + + now := time.Now().UTC() + first := autodeploy.AdmissionRecord{Kind: autodeploy.AdmissionKindAdmitted, ID: "first", Digest: "d1", App: "myapp", Commit: "c1", Received: now} + if got := q.admit(first, nil, false); got != dispositionRunning { + t.Fatalf("first admit = %q, want running", got) + } + if _, pending := q.snapshot(); pending != nil { + t.Fatalf("running delivery %q sits in the pending slot; the next admit could supersede it", pending.rec.ID) + } + second := autodeploy.AdmissionRecord{Kind: autodeploy.AdmissionKindAdmitted, ID: "second", Digest: "d2", App: "myapp", Commit: "c2", Received: now} + if got := q.admit(second, nil, false); got != dispositionQueued { + t.Fatalf("second admit = %q, want queued", got) + } + if got := len(ledger.byKind(autodeploy.AdmissionKindSuperseded)); got != 0 { + t.Fatalf("superseded records = %d, want 0", got) + } + + close(block) + run.waitCall(t) + run.waitCall(t) + run.waitIdle(t, q) + if run.count() != 2 { + t.Fatalf("deploy invocations = %d, want 2 (first, then second)", run.count()) + } +} diff --git a/internal/cli/autodeploy_serve.go b/internal/cli/autodeploy_serve.go index fcb2682..e85fd89 100644 --- a/internal/cli/autodeploy_serve.go +++ b/internal/cli/autodeploy_serve.go @@ -436,21 +436,28 @@ func (q *admissionQueue) admit(rec autodeploy.AdmissionRecord, changedFiles []st q.pending = item return dispositionQueued default: - q.pending = item + // The item goes to the worker directly, never through the pending + // slot: a delivery reported "running" must not be supersedable by + // the next admit before the worker goroutine is scheduled. q.workerLive = true - go q.worker() + go q.worker(item) return dispositionRunning } } -// worker is the ONLY deploy runner: one goroutine at a time, draining the -// pending slot. It exits when the queue is empty; the next admit restarts -// it — so rapid deliveries during a long deploy never spawn per-delivery -// goroutines. -func (q *admissionQueue) worker() { +// worker is the ONLY deploy runner: one goroutine at a time, running its +// first item and then draining the pending slot. It exits when the queue is +// empty; the next admit restarts it — so rapid deliveries during a long +// deploy never spawn per-delivery goroutines. +func (q *admissionQueue) worker(item *queuedAdmission) { for { + if q.run != nil { + q.run(item.changedFiles, item.filesKnown, item.rec.Commit) + } + q.markProcessed(item.rec) + q.mu.Lock() - item := q.pending + item = q.pending if item == nil { q.workerLive = false q.mu.Unlock() @@ -458,11 +465,6 @@ func (q *admissionQueue) worker() { } q.pending = nil q.mu.Unlock() - - if q.run != nil { - q.run(item.changedFiles, item.filesKnown, item.rec.Commit) - } - q.markProcessed(item.rec) } } From 87986af5767e287f52661d1291579ec20b3c55f9 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:05:19 -0700 Subject: [PATCH 03/10] fix(config): --host resolves servers.yml names (teploy log --app --host box1) ResolveServer's flag branch took --host verbatim, so every --app/--host command (log, status, logs, rollback --app, ...) dialed the literal name instead of the registered entry. Found by the R02 docs lane against `teploy log`. A --host naming a servers.yml entry now resolves to the entry's host/user; --user/--key still win; unregistered values and a missing/unreadable servers.yml keep the raw-host behavior. Pins: config ResolveServer named/override/raw cases; teploy log --app --host box1 connects to the entry's user@host. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cli/log_named_server_test.go | 39 ++++++++++++++++++++++++ internal/cli/root.go | 2 +- internal/config/servers.go | 39 ++++++++++++++++++++++++ internal/config/servers_test.go | 44 +++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 internal/cli/log_named_server_test.go 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/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/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) + } +} From 7e2654a6150e12a66d0413f383aaac5c066debdb Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:10:46 -0700 Subject: [PATCH 04/10] fix(deploy): tcp readiness must reach a live listener, not docker-proxy A connect-only probe passed against any published port: Docker's userland proxy accepts on the host side and only then dials the container, so a container with nothing listening still passed the tcp gate (and auto's 404/3xx fallback, and heal's). The probe now connects and holds up to 1s: a byte or the connection staying open is ready; the proxy's immediate close (dead backend), a refused connect, or a bad host is not. One command builder (deploy.TCPProbeCommand) shared by the deploy gate and heal. Proven live on colima: a sleep-only container published on :18096 with health.mode tcp - old probe rc=0, new probe rc=1, the deploy gate now times out; the busybox-httpd quickstart fixture still passes its tcp gate. Pin (health_tcp_backend_test.go) runs the real command against accept-then-close / silent-live / banner-live / closed listeners; red on the old probe on Linux, skipped on bash<4 (macOS bash 3.2 reports a read timeout as EOF - fail closed). internal/preview's probeTCP has the same shape and is NOT changed here (L1 lane owns internal/preview). Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 5 +- internal/cli/heal.go | 7 +- internal/config/app.go | 4 +- internal/deploy/deploy_test.go | 4 +- internal/deploy/health.go | 44 ++++++-- internal/deploy/health_tcp_backend_test.go | 111 +++++++++++++++++++++ 6 files changed, 164 insertions(+), 11 deletions(-) create mode 100644 internal/deploy/health_tcp_backend_test.go diff --git a/README.md b/README.md index c6b7f89..1cc71f8 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 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/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/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) + } +} From 439d7cec429604c4ea9afd2339f6f0f68cfbf81d Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:10:46 -0700 Subject: [PATCH 05/10] fix(diagnose): ignore Docker's embedded DNS listener; correct timeout hint Found proving the tcp-gate fix live: a container with nothing listening was diagnosed as 'the app is listening on port 46107' - Docker's embedded resolver (127.0.0.11:) inside every container on a user-defined network. It is now skipped (its presence still proves the listener tool ran), so the correct 'nothing is listening' finding fires. The slow-boot hint named a nonexistent key (health: { timeout: 90s }); the grammar is timeout_seconds. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/diagnose/diagnose.go | 17 +++++++++++++++-- internal/diagnose/diagnose_test.go | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) 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) + } +} From d3c9bf1240be07332f291073adaa870dde50ee2b Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:40:55 -0700 Subject: [PATCH 06/10] fix(status): ID-created containers report their tag, keep the ID (corpus rev 7) A52 creates web/worker containers from the immutable image ID, so docker ps reports a bare 12-hex short ID as the container image. Ship's wave-9 lesson: ID-created containers never matched the artifact tag. Not display-only. Surfaces fixed: - teploy status (text + --json), app status --json and server status --json: docker.ResolveImageTags batches one `docker image inspect` over the ID-form images (no call when none) and reports image = first repo tag, image_id = full sha256, image_tags = all tags. Untagged, removed or uninspectable images keep the ID (best effort; reporting never fails on it). - Rollback recorded ImageRef from the target container's docker ps Image, OVERWRITING the target release record's requested ref: after a rollback of an ID-created container the state carried an unpullable short ID (DR bundle restore resolves the image from ImageRef). The release record's ref now wins; without one the container image is resolved to its tag. Unchanged by design: prune's best-effort rmi and the predecessor snapshot keep the raw docker ps value (they act on identity, not names). Corpus rev 7 (additive, no MI bump): optional image_id/image_tags on the app-list and server-status container objects; no fixture changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- contracts/MANIFEST.md | 1 + .../schema/app-list-envelope.schema.json | 10 ++ .../schema/server-status-envelope.schema.json | 10 ++ internal/cli/machine.go | 26 ++++-- internal/cli/status.go | 1 + internal/cli/status_image_tag_test.go | 37 ++++++++ internal/deploy/rollback.go | 11 ++- internal/deploy/rollback_imageref_test.go | 75 +++++++++++++++ internal/docker/docker.go | 93 +++++++++++++++++++ internal/docker/image_tags_test.go | 80 ++++++++++++++++ 10 files changed, 334 insertions(+), 10 deletions(-) create mode 100644 internal/cli/status_image_tag_test.go create mode 100644 internal/deploy/rollback_imageref_test.go create mode 100644 internal/docker/image_tags_test.go 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/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/status.go b/internal/cli/status.go index fef93f2..74fc43a 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{}{ 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/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/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]) + } +} From 98c810af8b9365202b9f9ca47a6f862a93b1265b Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:42:53 -0700 Subject: [PATCH 07/10] fix(env): env set refuses secret-backed keys instead of a silent no-op Ship wave-9: `teploy env set` did not feed secret-backed vars; `secret set` did. Root cause is precedence, which is by design: the server .env (written by env set) is the FIRST --env-file at deploy; teploy.yml env: plus decrypted secrets and resolved secret: references ride the later attempt env file and win. The defect was env set reporting success for a value that could never reach the container. env set now checks before writing: a key in the app's secret store, or a teploy.yml env: key holding a secret: (OpenBao) reference, is refused (nothing written) with the remedy (secret set / secret rm / change it in OpenBao); a plain teploy.yml env: key that shadows it gets a warning. README states the precedence. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 5 +++ internal/cli/env.go | 54 ++++++++++++++++++++++ internal/cli/env_secret_shadow_test.go | 62 ++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 internal/cli/env_secret_shadow_test.go diff --git a/README.md b/README.md index 1cc71f8..c5c4f73 100644 --- a/README.md +++ b/README.md @@ -388,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/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()) + } +} From a95dced87a4fc1d40a6f55b118af983c66fdfb91 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:45:29 -0700 Subject: [PATCH 08/10] feat(quickstart): maintained CLI fixture covers health, rollback, tcp-gate refusal (X05) examples/quickstart/app is the maintained deployable CLI fixture (X05 needed one in-repo; observe's fixtures/x05 has no Dockerfile). make quickstart now runs deploy -> verify -> redeploy -> teploy health -> status (asserts the image is reported by TAG, the ID-created-container fix) -> rollback (asserts v1 served again) -> a qs3 container that runs but never listens, asserting the tcp readiness gate refuses it and v1 keeps serving (the docker-proxy fix, end to end). Fixture fixes found running it: the sh-wrapped httpd ignored SIGTERM as PID 1, so every retirement waited out the 10s kill timeout (Exited 137; deploys took ~11.5s) - a TERM trap makes stops immediate (deploys ~1.5s, Exited 0); a real 200 /health so teploy health exercises HTTP, not the 404->TCP fallback. status' IMAGE column widened for tag names. Verified green on the local colima fixture. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/quickstart/README.md | 17 ++++++--- examples/quickstart/app/Dockerfile | 9 +++-- examples/quickstart/run.sh | 57 ++++++++++++++++++++++++++---- internal/cli/status.go | 4 +-- 4 files changed, 71 insertions(+), 16 deletions(-) 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/status.go b/internal/cli/status.go index 74fc43a..7a91241 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -104,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 } From 0ed799e2f1ee77d8943b6c85e4d42568273b08f8 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:47:31 -0700 Subject: [PATCH 09/10] docs(audit): record the nineteenth-wave defect sweep (L4) Co-Authored-By: Claude Opus 5.5 (1M context) --- AUDIT_OPEN.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) 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. From 004743cc24667c7135c80964a8c25adbd7877cd7 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:49:37 -0700 Subject: [PATCH 10/10] preview: readiness TCP fallback uses the held-connection probe (L4 finding) probeTCP was the connect-only /dev/tcp check L4 replaced in the deploy gate: docker-proxy accepts the connect even when the backend is dead, so a preview could be declared ready with nothing serving. It now runs deploy.TCPProbeCommand, the same live-listener probe the deploy gate uses. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/preview/preview.go | 6 ++++-- internal/preview/preview_test.go | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) 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 '