Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 <ip|cidr>` (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
(`<app>-p-<hex8>`) 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 —
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,19 @@ teploy preview deploy fix/login-500 --image app-build-abc1234 --ttl 24h
Requires Teploy-managed Caddy: previews provision a `preview-<branch>.<domain>`
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-<id>.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
Expand Down
3 changes: 2 additions & 1 deletion contracts/MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand All @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
15 changes: 15 additions & 0 deletions contracts/fixtures/preview-state/valid/canonical-list-row.json
Original file line number Diff line number Diff line change
@@ -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"
}
1 change: 1 addition & 0 deletions contracts/fixtures/version-handshake/valid/mi2.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"plan-apply",
"preview-blue-green",
"preview-canonical-id",
"preview-exposure",
"provenance-records",
"readiness-receipts",
"repair-debt",
Expand Down
19 changes: 18 additions & 1 deletion contracts/schema/preview-state.schema.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
{
"$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 <app>-p-<hex8>, legacy slug, ambiguous",
"title": "preview identity eras (C06): canonical <app>-p-<hex8>, 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}$"},
"era": {"const": "canonical"},
"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-]*$"},
Expand Down
2 changes: 1 addition & 1 deletion docs/supported-workloads.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<branch>.<domain>` against a pre-built image (`teploy build`). Requires Teploy-managed Caddy. |
| Preview environments (`teploy preview`) | Supported | Branch slugs on `preview-<branch>.<domain>` against a pre-built image (`teploy build`). Requires Teploy-managed Caddy. Tailnet-only mode: `--base-domain <tailnet-ip>.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`. |
Expand Down
16 changes: 13 additions & 3 deletions internal/caddy/caddy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cert> <key>` 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"
}
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions internal/caddy/caddy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {") {
Expand Down
46 changes: 46 additions & 0 deletions internal/cli/contracts_golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
}
6 changes: 6 additions & 0 deletions internal/cli/machineinterface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -118,6 +123,7 @@ func MachineCapabilities() []string {
CapServerStatusMachine,
CapDoctorDiagnostics,
CapPlanApply,
CapPreviewExposure,
}
sort.Strings(tokens)
return tokens
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/machineinterface_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading