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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion AUDIT_OPEN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1778,7 +1778,9 @@ performed.
**S2 + error-site migration list (recorded follow-ups):**

- `server list --json` reshape to an envelope root (coordinated dash
decode change — the one non-additive MI bump candidate).
decode change — the one non-additive MI bump candidate). **LANDED
2026-09-23 as the MI 2 bump** (see the X02 S2-tail slice at the end of
this file).
- target-unreachable: the ssh.Connect failure returns across commands
(app list/server status "connecting to", deploy step 6).
- conflict: `preview.AmbiguousPreviewError` (typed and ready — one
Expand Down Expand Up @@ -2283,3 +2285,39 @@ it), multi-host partial-wave readiness states (canary aggregate gating),
WebSocket/SSE drain verification beyond the long-request proof, and the
registered interactions (tcp mode × the Caddy LB active check; preview's
gate has no drain surface).

## Programme slice (2026-09-23) — X02 S2 tail: server-list envelope (the MI 2 bump)

The S1 exclusion resolved: `server list --json` now emits the machine
envelope `{machine_interface, servers[], observed_at}` (serverListDTO,
internal/cli/server.go) carrying every per-server field the bare-map era
emitted — the map key promoted to `name`, plus id/host/user/role/tags/
vpn_ip unchanged — sorted by name, `servers` never null on an empty
fleet. The pre-reshape bare map-of-servers root is GONE on the wire;
that removal is non-additive, and per D8 a non-additive change to one
command's envelope bumps the interface for the whole binary:
`MachineInterface = 2` (machineinterface.go documents the delta — MI 2 =
MI 1 + this reshape, NOTHING else; no capability token added, removed,
or redefined — TestCapabilityTokenRegistry pins the unchanged set, and
the version-handshake golden's token list is byte-identical apart from
`machine_interface: 2`).

Corpus rev 4 (same-commit rule): new artifact server-list-envelope
(schema pinning machine_interface [2,2] and the `srv-<16hex>` id
pattern; valid fixture from the REAL writeServerList encoder including
an id-less legacy entry; the bare map pinned as the legacy class,
generated by the same map encoder that era used); version-handshake
schema maximum 1→2; version-handshake + app-list valid fixtures renamed
mi1→mi2 (both envelopes now report MI 2).

Evidence: decode-side tests — envelope shape (root is the envelope, a
server name can no longer appear as a root key, per-server fields
carried, sorted, observed_at), stable id present/absent (omitempty) +
empty-fleet `servers: []`, cobra end-to-end `server list --json`, human
table output byte-unchanged; TestVersionCommandJSONEndToEnd now pins
machine_interface 2 literally. Gates: build/vet clean;
`go test ./... -count=1` all packages ok; contracts regenerate + plain
pin run both clean; gofmt clean on touched files (pre-existing strays
untouched). Coordinated consumer: teploy-dash branch decodes both
shapes (envelope ≤ MI 2 and the legacy bare map) behind
MaxSupportedMachineInterface 2; dash CI pin moves to this corpus.
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to teploy are documented here. Format follows [Keep a Change

## [Unreleased]

### Changed

- **Machine interface 2: `server list --json` emits the machine
envelope.** The output is now
`{"machine_interface":2,"servers":[{"name","id","host","user","role","tags","vpn_ip"}],"observed_at"}`
— every per-server field the old bare map carried, with the map key
promoted to a `name` field — where it previously emitted a bare
map-of-servers at the root. Removing the bare map from the wire is a
non-additive machine-interface change: the whole binary now reports
`machine_interface: 2` in every `--json` envelope (MI 2 = MI 1 plus
this reshape; no capability token changed). Machine consumers pinned
to MI 1 must move to the envelope; teploy-dash ships a decoder for
both shapes in the same release.

### Added

- **Plan/apply with drift invalidation (C05).** `teploy plan` now renders
Expand Down
2 changes: 2 additions & 0 deletions 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 |
|---|---|---|---|
| 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). |
| 1 | post-v0.1.37 main (S2 skeleton) | 1 | First goldens: version handshake, app-list envelope (MI + pre-MI legacy), error envelope (config-invalid, internal, invalid-code), release-record, attempt-name grammar, preview-state eras. |
| 2 | post-v0.1.37 main (S6) | 1 | observation-envelope: schema corrected from the S2 draft shape to the ADR §2.4 canonical form (resource/collected_at/freshness tri-state/error/source/last_known) before any consumer existed; fixtures generated from teploy-dash's real constructors (fresh, stale, unknown-unreachable, unreachable-last-known). |
Expand All @@ -21,6 +22,7 @@ Neutron/Nucleus dependency and a public mirror.
|---|---|---|---|
| version-handshake | yes | valid (real `writeVersion` encoder) | teploy-cli |
| app-list-envelope | yes | valid (real DTO tags) + legacy pre-MI | teploy-cli |
| server-list-envelope | yes (MI 2 reshape) | valid (real `writeServerList` encoder) + legacy bare-map | teploy-cli |
| server-status-envelope | yes (appStatus root) | pending S2 tail (live `server status` capture) | teploy-cli |
| error-envelope | yes | valid x2 + invalid code | teploy-cli |
| release-record | yes | valid container | teploy-cli |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"machine_interface": 1,
"machine_interface": 2,
"host": "srv.example.com",
"apps": [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"machine_interface": 1,
"machine_interface": 2,
"code": "kaboom",
"message": "x"
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"machine_interface": 1,
"machine_interface": 2,
"code": "config-invalid",
"message": "invalid teploy configuration",
"detail": "teploy.yml: services.0.name: required"
Expand Down
2 changes: 1 addition & 1 deletion contracts/fixtures/error-envelope/valid/internal.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"machine_interface": 1,
"machine_interface": 2,
"code": "internal",
"message": "command failed",
"detail": "dial tcp: connection refused"
Expand Down
15 changes: 15 additions & 0 deletions contracts/fixtures/server-list-envelope/legacy/bare-map.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"prod": {
"id": "srv-0123456789abcdef",
"host": "192.0.2.10",
"user": "deploy",
"role": "app",
"tags": {
"region": "us-east"
},
"vpn_ip": "100.64.0.7"
},
"staging": {
"host": "192.0.2.20"
}
}
21 changes: 21 additions & 0 deletions contracts/fixtures/server-list-envelope/valid/mi2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"machine_interface": 2,
"observed_at": "2026-09-23T12:00:00Z",
"servers": [
{
"host": "192.0.2.10",
"id": "srv-0123456789abcdef",
"name": "prod",
"role": "app",
"tags": {
"region": "us-east"
},
"user": "deploy",
"vpn_ip": "100.64.0.7"
},
{
"host": "192.0.2.20",
"name": "staging"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@
"server-update",
"template-var-stdin"
],
"machine_interface": 1,
"machine_interface": 2,
"version": "v0.0.0-contracts"
}
60 changes: 60 additions & 0 deletions contracts/schema/server-list-envelope.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://teploy.github.io/contracts/schema/server-list-envelope.schema.json",
"title": "teploy server list --json (MI 2 serverListDTO — the reshape that minted MI 2)",
"type": "object",
"required": ["machine_interface", "servers", "observed_at"],
"additionalProperties": false,
"properties": {
"machine_interface": {
"type": "integer",
"minimum": 2,
"maximum": 2
},
"servers": {
"type": "array",
"items": {
"$ref": "#/$defs/serverEntry"
}
},
"observed_at": {
"type": "string",
"format": "date-time"
}
},
"$defs": {
"serverEntry": {
"type": "object",
"required": ["name", "host"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"id": {
"type": "string",
"pattern": "^srv-[0-9a-f]{16}$"
},
"host": {
"type": "string"
},
"user": {
"type": "string"
},
"role": {
"type": "string"
},
"tags": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"vpn_ip": {
"type": "string"
}
}
}
}
}
4 changes: 2 additions & 2 deletions contracts/schema/version-handshake.schema.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://teploy.github.io/contracts/schema/version-handshake.schema.json",
"title": "teploy version --json handshake (X02 S1, MI 1)",
"title": "teploy version --json handshake (X02 S1, MI 1-2)",
"type": "object",
"required": ["version", "machine_interface", "capabilities"],
"additionalProperties": false,
"properties": {
"version": {"type": "string", "minLength": 1},
"machine_interface": {"type": "integer", "minimum": 1, "maximum": 1},
"machine_interface": {"type": "integer", "minimum": 1, "maximum": 2},
"capabilities": {
"type": "array",
"items": {"type": "string", "minLength": 1},
Expand Down
45 changes: 39 additions & 6 deletions internal/cli/contracts_golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"testing"
"time"

"github.com/useteploy/teploy/internal/config"
"github.com/useteploy/teploy/internal/releasemeta"
)

Expand Down Expand Up @@ -58,25 +59,57 @@ func TestContractsVersionHandshakeGolden(t *testing.T) {
if err := json.Unmarshal(buf.Bytes(), &v); err != nil {
t.Fatalf("unmarshal: %v", err)
}
writeFixture(t, "version-handshake/valid/mi1.json", v)
writeFixture(t, "version-handshake/valid/mi2.json", v)
}

// TestContractsServerListEnvelopeGolden drives the REAL writeServerList
// encoder (the same path `server list --json` runs) for the valid fixture,
// and the pre-reshape bare-map encoder for the legacy class. MI 2 minted
// by this reshape: the bare map-of-servers root is gone on the wire.
func TestContractsServerListEnvelopeGolden(t *testing.T) {
servers := map[string]config.Server{
"prod": {
ID: "srv-0123456789abcdef",
Host: "192.0.2.10",
User: "deploy",
Role: "app",
Tags: map[string]string{"region": "us-east"},
VpnIP: "100.64.0.7",
},
"staging": {Host: "192.0.2.20"}, // id-less legacy entry inside the envelope
}
var buf bytes.Buffer
if err := writeServerList(&buf, servers, true, time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)); err != nil {
t.Fatalf("writeServerList: %v", err)
}
var v any
if err := json.Unmarshal(buf.Bytes(), &v); err != nil {
t.Fatalf("unmarshal: %v", err)
}
writeFixture(t, "server-list-envelope/valid/mi2.json", v)

// Legacy: the bare map-of-servers root a pre-MI-2 CLI emitted — the
// S1 exclusion, now a first-class legacy class. Same map encoder that
// era used (json tags unchanged on config.Server).
writeFixture(t, "server-list-envelope/legacy/bare-map.json", servers)
}

// TestContractsAppListEnvelopeGolden emits an appListDTO with one
// representative app through the same json tags the command marshals.
// Offline stand-in: the DTO values are constructed, the ENCODER is real.
func TestContractsAppListEnvelopeGolden(t *testing.T) {
ts := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
writeFixture(t, "app-list-envelope/valid/mi1.json", appListDTO{
writeFixture(t, "app-list-envelope/valid/mi2.json", appListDTO{
MachineInterface: MachineInterface,
Host: "srv.example.com",
Errors: []machineError{},
Apps: []appStatusDTO{{
App: "myapp", Domain: "myapp.example.com", Type: "container",
Ingress: "caddy", CurrentRelease: releaseStatusDTO{Version: "3", Ports: []int{3000}},
PreviousRelease: releaseStatusDTO{Version: "2", Ports: []int{3000}},
Containers: []containerDTO{{ID: "9f31c02", Name: "myapp-web-3", Image: "nginx:1.27", State: "running", Status: "Up 4 minutes", CreatedAt: "2026-09-23T11:55:00Z", Process: "web", Version: "3"}},
Processes: []processDTO{},
Lock: nil, ObservedAt: ts, Errors: []machineError{},
Containers: []containerDTO{{ID: "9f31c02", Name: "myapp-web-3", Image: "nginx:1.27", State: "running", Status: "Up 4 minutes", CreatedAt: "2026-09-23T11:55:00Z", Process: "web", Version: "3"}},
Processes: []processDTO{},
Lock: nil, ObservedAt: ts, Errors: []machineError{},
}},
ObservedAt: ts,
})
Expand Down Expand Up @@ -133,7 +166,7 @@ func TestContractsAttemptNameGolden(t *testing.T) {
"9f31c02.0123456789abcdef",
})
writeFixture(t, "attempt-name/invalid/examples.json", []string{
"deadb17ecafef00d", // missing the hash half
"deadb17ecafef00d", // missing the hash half
"ABC1234.deadb17ecafef00d", // uppercase
"abc1234.DeadB17eCafef00d", // uppercase hex half
"abc1234.deadb17ecafef00", // 15 hex chars
Expand Down
28 changes: 23 additions & 5 deletions internal/cli/machine_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,21 +81,30 @@ func TestMachineJSONListOutputs(t *testing.T) {
})

t.Run("server", func(t *testing.T) {
observedAt := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
var out bytes.Buffer
if err := writeServerList(&out, nil, true); err != nil {
if err := writeServerList(&out, nil, true, observedAt); err != nil {
t.Fatal(err)
}
assertJSONEqual(t, out.Bytes(), map[string]any{})
assertJSONEqual(t, out.Bytes(), map[string]any{
"machine_interface": MachineInterface,
"servers": []any{},
"observed_at": observedAt.Format(time.RFC3339Nano),
})

out.Reset()
servers := map[string]config.Server{
"prod": {Host: "192.0.2.10", User: "deploy", Role: "app"},
}
if err := writeServerList(&out, servers, true); err != nil {
if err := writeServerList(&out, servers, true, observedAt); err != nil {
t.Fatal(err)
}
assertJSONEqual(t, out.Bytes(), map[string]any{
"prod": map[string]any{"host": "192.0.2.10", "user": "deploy", "role": "app"},
"machine_interface": MachineInterface,
"servers": []any{map[string]any{
"name": "prod", "host": "192.0.2.10", "user": "deploy", "role": "app",
}},
"observed_at": observedAt.Format(time.RFC3339Nano),
})
})

Expand Down Expand Up @@ -128,7 +137,16 @@ func TestServerListJSONWithoutConfigFile(t *testing.T) {
if err := runServerList(&Flags{JSON: true}, &out); err != nil {
t.Fatalf("runServerList: %v", err)
}
assertJSONEqual(t, out.Bytes(), map[string]any{})
var decoded struct {
MachineInterface int `json:"machine_interface"`
Servers []any `json:"servers"`
}
if err := json.Unmarshal(out.Bytes(), &decoded); err != nil {
t.Fatalf("invalid JSON %q: %v", out.Bytes(), err)
}
if decoded.MachineInterface != MachineInterface || decoded.Servers == nil || len(decoded.Servers) != 0 {
t.Fatalf("server list without config = %s, want an empty MI-%d envelope", out.String(), MachineInterface)
}
}

func TestLogsCommandFollowModes(t *testing.T) {
Expand Down
Loading
Loading