diff --git a/AUDIT_OPEN.md b/AUDIT_OPEN.md index 2ca712c..50dc452 100644 --- a/AUDIT_OPEN.md +++ b/AUDIT_OPEN.md @@ -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 @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index d0ffc63..db6d326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/contracts/MANIFEST.md b/contracts/MANIFEST.md index 070d1be..b3375e6 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 | |---|---|---|---| +| 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). | @@ -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 | diff --git a/contracts/fixtures/app-list-envelope/valid/mi1.json b/contracts/fixtures/app-list-envelope/valid/mi2.json similarity index 97% rename from contracts/fixtures/app-list-envelope/valid/mi1.json rename to contracts/fixtures/app-list-envelope/valid/mi2.json index e81fa42..100917b 100644 --- a/contracts/fixtures/app-list-envelope/valid/mi1.json +++ b/contracts/fixtures/app-list-envelope/valid/mi2.json @@ -1,5 +1,5 @@ { - "machine_interface": 1, + "machine_interface": 2, "host": "srv.example.com", "apps": [ { diff --git a/contracts/fixtures/error-envelope/invalid/unknown-code.json b/contracts/fixtures/error-envelope/invalid/unknown-code.json index e44fd46..96111e9 100644 --- a/contracts/fixtures/error-envelope/invalid/unknown-code.json +++ b/contracts/fixtures/error-envelope/invalid/unknown-code.json @@ -1,5 +1,5 @@ { - "machine_interface": 1, + "machine_interface": 2, "code": "kaboom", "message": "x" } diff --git a/contracts/fixtures/error-envelope/valid/config-invalid.json b/contracts/fixtures/error-envelope/valid/config-invalid.json index b71e650..69105db 100644 --- a/contracts/fixtures/error-envelope/valid/config-invalid.json +++ b/contracts/fixtures/error-envelope/valid/config-invalid.json @@ -1,5 +1,5 @@ { - "machine_interface": 1, + "machine_interface": 2, "code": "config-invalid", "message": "invalid teploy configuration", "detail": "teploy.yml: services.0.name: required" diff --git a/contracts/fixtures/error-envelope/valid/internal.json b/contracts/fixtures/error-envelope/valid/internal.json index 4fb51dd..38bfe2c 100644 --- a/contracts/fixtures/error-envelope/valid/internal.json +++ b/contracts/fixtures/error-envelope/valid/internal.json @@ -1,5 +1,5 @@ { - "machine_interface": 1, + "machine_interface": 2, "code": "internal", "message": "command failed", "detail": "dial tcp: connection refused" diff --git a/contracts/fixtures/server-list-envelope/legacy/bare-map.json b/contracts/fixtures/server-list-envelope/legacy/bare-map.json new file mode 100644 index 0000000..22c3e5e --- /dev/null +++ b/contracts/fixtures/server-list-envelope/legacy/bare-map.json @@ -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" + } +} diff --git a/contracts/fixtures/server-list-envelope/valid/mi2.json b/contracts/fixtures/server-list-envelope/valid/mi2.json new file mode 100644 index 0000000..c3c88bc --- /dev/null +++ b/contracts/fixtures/server-list-envelope/valid/mi2.json @@ -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" + } + ] +} diff --git a/contracts/fixtures/version-handshake/valid/mi1.json b/contracts/fixtures/version-handshake/valid/mi2.json similarity index 94% rename from contracts/fixtures/version-handshake/valid/mi1.json rename to contracts/fixtures/version-handshake/valid/mi2.json index 785f22a..353b730 100644 --- a/contracts/fixtures/version-handshake/valid/mi1.json +++ b/contracts/fixtures/version-handshake/valid/mi2.json @@ -18,6 +18,6 @@ "server-update", "template-var-stdin" ], - "machine_interface": 1, + "machine_interface": 2, "version": "v0.0.0-contracts" } diff --git a/contracts/schema/server-list-envelope.schema.json b/contracts/schema/server-list-envelope.schema.json new file mode 100644 index 0000000..64184a2 --- /dev/null +++ b/contracts/schema/server-list-envelope.schema.json @@ -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" + } + } + } + } +} diff --git a/contracts/schema/version-handshake.schema.json b/contracts/schema/version-handshake.schema.json index 6703587..60a6bff 100644 --- a/contracts/schema/version-handshake.schema.json +++ b/contracts/schema/version-handshake.schema.json @@ -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}, diff --git a/internal/cli/contracts_golden_test.go b/internal/cli/contracts_golden_test.go index 5b89ca3..1a2d21a 100644 --- a/internal/cli/contracts_golden_test.go +++ b/internal/cli/contracts_golden_test.go @@ -16,6 +16,7 @@ import ( "testing" "time" + "github.com/useteploy/teploy/internal/config" "github.com/useteploy/teploy/internal/releasemeta" ) @@ -58,7 +59,39 @@ 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 @@ -66,7 +99,7 @@ func TestContractsVersionHandshakeGolden(t *testing.T) { // 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{}, @@ -74,9 +107,9 @@ func TestContractsAppListEnvelopeGolden(t *testing.T) { 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, }) @@ -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 diff --git a/internal/cli/machine_contract_test.go b/internal/cli/machine_contract_test.go index aca8483..8d53785 100644 --- a/internal/cli/machine_contract_test.go +++ b/internal/cli/machine_contract_test.go @@ -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), }) }) @@ -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) { diff --git a/internal/cli/machineinterface.go b/internal/cli/machineinterface.go index 044ed98..f96b5b1 100644 --- a/internal/cli/machineinterface.go +++ b/internal/cli/machineinterface.go @@ -2,16 +2,17 @@ package cli import "sort" -// Machine-interface contract, version 1 (X02 S1 — versioned resource and -// operation contracts, _internal/X02_RESOURCE_CONTRACT_ADR_2026-09-22.md +// Machine-interface contract, version 2 (X02 S1/S2 — versioned resource +// and operation contracts, _internal/X02_RESOURCE_CONTRACT_ADR_2026-09-22.md // §2.1-2.2, adopted by DELEGATED_DECISIONS_2026-09-23 D8/D9). // // MachineInterface is the version of teploy's machine-readable output // contract. It rides at the root of every --json envelope a machine -// consumer parses — `version --json`, `app list --json`, and -// `server status --json` — so a consumer (teploy-dash) can fail closed on -// an interface newer than the one it supports BEFORE submitting any -// mutation, instead of discovering the skew after the fact. +// consumer parses — `version --json`, `app list --json`, +// `server status --json`, and `server list --json` — so a consumer +// (teploy-dash) can fail closed on an interface newer than the one it +// supports BEFORE submitting any mutation, instead of discovering the +// skew after the fact. // // Versioning rules (D8): // - additive changes (new fields, new capability tokens) do NOT bump; @@ -19,12 +20,22 @@ import "sort" // change, capability-token removal or redefinition) bump it. // // MI 1 is assigned to the envelope shapes as implemented at v0.1.37 plus -// this field itself — assigning it is the compatibility commitment X01 -// lacked. Known exclusion: `server list --json` emits a bare -// map-of-servers root with no envelope object, so it cannot carry the -// field additively; reshaping it is recorded as S2 follow-up work -// (requires a coordinated dash decode change). -const MachineInterface = 1 +// the field itself — assigning it is the compatibility commitment X01 +// lacked. MI 1's one recorded exclusion (`server list --json` emitted a +// bare map-of-servers root with no envelope object, so the field could +// not be added) is resolved by MI 2. +// +// MI 2 = MI 1 + the server-list envelope reshape, and NOTHING else: +// `server list --json` now emits {machine_interface, servers[], +// observed_at} with the per-server fields carried over (name, host, +// user, role, tags, vpn_ip, id), where it previously emitted the bare +// map-of-servers root. Removing the bare map from the wire is +// non-additive, and per D8 a non-additive change to ONE command's +// envelope bumps the interface for the whole binary — every envelope +// (version, app list, server status, server list, error) now reports +// machine_interface 2. No capability token was added, removed, or +// redefined; no other envelope shape changed. +const MachineInterface = 2 // Capability tokens advertised by `teploy version --json` (X02 §2.2). // THIS BLOCK IS THE REGISTRY — the single source of the token set. diff --git a/internal/cli/machineinterface_test.go b/internal/cli/machineinterface_test.go index 4e7d780..289af33 100644 --- a/internal/cli/machineinterface_test.go +++ b/internal/cli/machineinterface_test.go @@ -71,8 +71,8 @@ func TestVersionCommandJSONEndToEnd(t *testing.T) { if err := json.Unmarshal(out.Bytes(), &decoded); err != nil { t.Fatalf("version --json output not JSON: %q: %v", out.String(), err) } - if decoded["machine_interface"] != float64(1) { - t.Fatalf("machine_interface = %v, want 1", decoded["machine_interface"]) + if decoded["machine_interface"] != float64(2) { + t.Fatalf("machine_interface = %v, want 2 (MI 2 = MI 1 + the server-list envelope reshape; nothing else)", decoded["machine_interface"]) } } diff --git a/internal/cli/server.go b/internal/cli/server.go index 02f8aeb..64889df 100644 --- a/internal/cli/server.go +++ b/internal/cli/server.go @@ -8,6 +8,7 @@ import ( "os" "sort" "text/tabwriter" + "time" "github.com/spf13/cobra" "github.com/useteploy/teploy/internal/config" @@ -211,27 +212,51 @@ func runServerList(flags *Flags, out io.Writer) error { } servers = map[string]config.Server{} } - return writeServerList(out, servers, flags.JSON) + return writeServerList(out, servers, flags.JSON, time.Now().UTC()) } -func writeServerList(out io.Writer, servers map[string]config.Server, jsonOutput bool) error { +// serverListEntryDTO is one server in the `server list --json` envelope: +// the bare-map era's map key promoted to a `name` field, plus every field +// of the config.Server record that era emitted (id/host/user/role/tags/ +// vpn_ip). The MI 2 reshape changed the root only; the per-server payload +// is carried over unchanged. +type serverListEntryDTO struct { + Name string `json:"name"` + config.Server +} + +// serverListDTO is the `server list --json` envelope (MI 2, the reshape +// that minted it — X02 §2.1: the pre-reshape bare map-of-servers root had +// no object to carry machine_interface additively). +type serverListDTO struct { + MachineInterface int `json:"machine_interface"` + Servers []serverListEntryDTO `json:"servers"` + ObservedAt time.Time `json:"observed_at"` +} + +func writeServerList(out io.Writer, servers map[string]config.Server, jsonOutput bool, observedAt time.Time) error { + names := make([]string, 0, len(servers)) + for name := range servers { + names = append(names, name) + } + sort.Strings(names) + if jsonOutput { - if servers == nil { - servers = map[string]config.Server{} + entries := make([]serverListEntryDTO, 0, len(names)) + for _, name := range names { + entries = append(entries, serverListEntryDTO{Name: name, Server: servers[name]}) } - return json.NewEncoder(out).Encode(servers) + return json.NewEncoder(out).Encode(serverListDTO{ + MachineInterface: MachineInterface, + Servers: entries, + ObservedAt: observedAt, + }) } if len(servers) == 0 { fmt.Fprintln(out, "No servers configured. Use 'teploy server add' to add one.") return nil } - names := make([]string, 0, len(servers)) - for name := range servers { - names = append(names, name) - } - sort.Strings(names) - w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "NAME\tHOST\tUSER\tROLE") for _, name := range names { diff --git a/internal/cli/server_test.go b/internal/cli/server_test.go index 19fecc3..af46c20 100644 --- a/internal/cli/server_test.go +++ b/internal/cli/server_test.go @@ -1,11 +1,14 @@ package cli import ( + "bytes" + "encoding/json" "errors" "io" "os" "path/filepath" "testing" + "time" "github.com/useteploy/teploy/internal/config" ) @@ -149,3 +152,135 @@ func TestServerUpdateCmd_BadRoleRejected(t *testing.T) { t.Errorf("rejected update modified the config: %+v", cfg.Servers["prod"]) } } + +// serverListFixedTime is the deterministic clock for the envelope tests. +var serverListFixedTime = time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) + +// TestServerListJSONEnvelopeShape pins the MI 2 reshape decode-side: the +// root IS the envelope {machine_interface, servers, observed_at} and IS +// NOT the bare map-of-servers the pre-MI-2 CLI emitted (that shape is gone +// on the wire — the reason this is a contract bump, not a field add). +func TestServerListJSONEnvelopeShape(t *testing.T) { + path := seedServersFile(t) + servers, err := config.ListServers(path) + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := writeServerList(&buf, servers, true, serverListFixedTime); err != nil { + t.Fatalf("writeServerList: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { + t.Fatalf("server list --json is not valid JSON: %q: %v", buf.String(), err) + } + if decoded["machine_interface"] != float64(MachineInterface) { + t.Fatalf("machine_interface = %v, want %d", decoded["machine_interface"], MachineInterface) + } + if _, bare := decoded["prod"]; bare { + t.Fatalf("bare-map root survived the reshape (a server name is a root key): %s", buf.String()) + } + entries, ok := decoded["servers"].([]any) + if !ok || len(entries) != 2 { + t.Fatalf("servers = %#v, want the two seeded entries", decoded["servers"]) + } + first, _ := entries[0].(map[string]any) + if first["name"] != "prod" || first["host"] != "1.2.3.4" || first["user"] != "deploy" || first["role"] != "app" { + t.Fatalf("first entry did not carry the per-server fields: %#v", first) + } + if first["vpn_ip"] != "100.64.0.7" { + t.Fatalf("vpn_ip not carried over by the reshape: %#v", first) + } + if entries[1].(map[string]any)["name"] != "staging" { + t.Fatalf("entries not sorted by name: %s", buf.String()) + } + if decoded["observed_at"] != serverListFixedTime.Format(time.RFC3339Nano) { + t.Fatalf("observed_at = %v", decoded["observed_at"]) + } +} + +// TestServerListJSONEnvelopeStableIDAndEmpty pins the S4 stable id riding +// the envelope (present when the record has one, absent for id-less +// legacy entries — omitempty, never an empty string) and the empty-fleet +// shape: `servers` is an array (empty, not null), never a null map. +func TestServerListJSONEnvelopeStableIDAndEmpty(t *testing.T) { + withID := map[string]config.Server{ + "prod": {ID: "srv-0123456789abcdef", Host: "192.0.2.10", User: "deploy"}, + } + var buf bytes.Buffer + if err := writeServerList(&buf, withID, true, serverListFixedTime); err != nil { + t.Fatal(err) + } + var decoded struct { + Servers []struct { + Name string `json:"name"` + ID string `json:"id"` + } `json:"servers"` + } + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { + t.Fatal(err) + } + if len(decoded.Servers) != 1 || decoded.Servers[0].Name != "prod" || decoded.Servers[0].ID != "srv-0123456789abcdef" { + t.Fatalf("stable id not carried: %s", buf.String()) + } + + buf.Reset() + if err := writeServerList(&buf, nil, true, serverListFixedTime); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { + t.Fatal(err) + } + if decoded.Servers == nil || len(decoded.Servers) != 0 { + t.Fatalf("empty fleet must decode as an empty (non-null) array: %s", buf.String()) + } +} + +// TestServerListCommandJSONEndToEnd runs the real cobra command against a +// private HOME so the wiring (flags.JSON, HOME resolution, encoder) is +// exercised together. +func TestServerListCommandJSONEndToEnd(t *testing.T) { + seedServersFile(t) + + var out bytes.Buffer + root := NewRootCmd("test") + root.SetOut(&out) + root.SetErr(io.Discard) + root.SetArgs([]string{"server", "list", "--json"}) + if err := root.Execute(); err != nil { + t.Fatalf("server list --json: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(out.Bytes(), &decoded); err != nil { + t.Fatalf("output not JSON: %q: %v", out.String(), err) + } + if decoded["machine_interface"] != float64(MachineInterface) { + t.Fatalf("machine_interface = %v, want %d", decoded["machine_interface"], MachineInterface) + } + if _, ok := decoded["servers"].([]any); !ok { + t.Fatalf("servers missing from the envelope: %s", out.String()) + } +} + +// TestServerListHumanUnchanged pins the human table output: the MI 2 +// reshape touched the machine surface only. +func TestServerListHumanUnchanged(t *testing.T) { + path := seedServersFile(t) + servers, err := config.ListServers(path) + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := writeServerList(&buf, servers, false, serverListFixedTime); err != nil { + t.Fatal(err) + } + want := "NAME HOST USER ROLE\n" + + "prod 1.2.3.4 deploy app\n" + + "staging 5.6.7.8 root app\n" + if buf.String() != want { + t.Fatalf("human output changed:\n got: %q\nwant: %q", buf.String(), want) + } +}