From f15355a9e62348c9ae366ac2a605676f321aabe5 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:41:03 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(config,server):=20X02=20S4=20=E2=80=94?= =?UTF-8?q?=20stable=20server=20IDs=20(additive=20id=20field,=20mint=20at?= =?UTF-8?q?=20add,=20preserved=20by=20rename/update/re-add)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit servers.yml records gain an id (srv-<16hex>, crypto/rand), minted once at server add for genuinely new entries only. RenameServer/UpdateServer carry the whole entry so preservation is structural. AddServer's upsert preserves the existing id — and deliberately leaves legacy id-less entries id-less: minting there would silently re-key dash's name-hash fallback identity underneath live references (X02 ADR §1.3's silent-re-key prohibition). Legacy servers keep working through dash's fallback; adoption stays the consumer's explicit decision. Pinned: mint shape + uniqueness, re-add preserves id, legacy stays id-less on upsert, rename/update preserve id byte-identical. --- internal/config/servers.go | 31 ++++++++- internal/config/servers_test.go | 120 ++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/internal/config/servers.go b/internal/config/servers.go index 99d076a..a058c1d 100644 --- a/internal/config/servers.go +++ b/internal/config/servers.go @@ -1,6 +1,8 @@ package config import ( + "crypto/rand" + "encoding/hex" "errors" "fmt" "os" @@ -15,6 +17,13 @@ import ( // consumers — notably the teploy-dash frontend, which is case-sensitive — can // read. Without them Go would emit capitalized field names (Host/User/…). type Server struct { + // ID is the server's stable identity (X02 §1.3): minted once when the + // entry is created, preserved by rename/update and by AddServer's + // upsert, and never re-derived. Empty on legacy entries written before + // the field existed — consumers (teploy-dash) fall back to their + // name-derived hash for those; minting on re-add would silently re-key + // every id-based reference, which is the exact hazard the field removes. + ID string `yaml:"id,omitempty" json:"id,omitempty"` Host string `yaml:"host" json:"host"` User string `yaml:"user,omitempty" json:"user,omitempty"` // default: root Role string `yaml:"role,omitempty" json:"role,omitempty"` // app, lb, or empty (single-server) @@ -22,6 +31,18 @@ type Server struct { VpnIP string `yaml:"vpn_ip,omitempty" json:"vpn_ip,omitempty"` // VPN mesh IP (tailscale, headscale, netbird) } +// newServerID mints a random stable server identity: "srv-" + 16 hex chars. +// Random (not name-derived) because the ID names a registration, not content +// that must be re-derivable — a rename must not change it, which no hash of +// the name can guarantee (X02 §1.2). +func newServerID() (string, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("generating server id: %w", err) + } + return "srv-" + hex.EncodeToString(b[:]), nil +} + // ServersConfig is the top-level structure of ~/.teploy/servers.yml. type ServersConfig struct { Servers map[string]Server `yaml:"servers"` @@ -303,13 +324,14 @@ func AddServer(path, name, host, user, role, vpnIP string) error { // hand-edited in servers.yml) and cleared VpnIP/Role/User. Tags drive // per-host env injection at deploy time, so losing them broke deploys. Keep // existing values; only overwrite an optional field when a new value is given. - existing := cfg.Servers[name] + existing, existed := cfg.Servers[name] merged := Server{ Host: host, User: user, Role: role, VpnIP: vpnIP, Tags: existing.Tags, // settable only via servers.yml — never drop on re-add + ID: existing.ID, // preserved on re-add; minted below only for new entries } if merged.Host == "" { merged.Host = existing.Host @@ -323,6 +345,13 @@ func AddServer(path, name, host, user, role, vpnIP string) error { if merged.VpnIP == "" { merged.VpnIP = existing.VpnIP } + if !existed { + id, err := newServerID() + if err != nil { + return err + } + merged.ID = id + } cfg.Servers[name] = merged return saveServers(path, cfg) diff --git a/internal/config/servers_test.go b/internal/config/servers_test.go index c81e251..e30fc97 100644 --- a/internal/config/servers_test.go +++ b/internal/config/servers_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "testing" ) @@ -269,6 +270,125 @@ func TestAddServer(t *testing.T) { } } +// serverIDRE is the X02 §1.3 stable-id shape: "srv-" + 16 lowercase hex. +var serverIDRE = regexp.MustCompile(`^srv-[0-9a-f]{16}$`) + +func TestAddServer_MintsIDForNewEntry(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "servers.yml") + + if err := AddServer(path, "prod", "1.2.3.4", "root", "app", ""); err != nil { + t.Fatal(err) + } + cfg, err := LoadServers(path) + if err != nil { + t.Fatal(err) + } + id := cfg.Servers["prod"].ID + if !serverIDRE.MatchString(id) { + t.Fatalf("expected minted srv-<16hex> id, got %q", id) + } + + // Distinct entries mint distinct ids — random, not name-derived. + if err := AddServer(path, "other", "1.2.3.4", "root", "app", ""); err != nil { + t.Fatal(err) + } + cfg, _ = LoadServers(path) + if cfg.Servers["other"].ID == id { + t.Fatalf("two servers share id %q", id) + } +} + +func TestAddServer_ReAddPreservesID(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "servers.yml") + + if err := AddServer(path, "prod", "1.2.3.4", "root", "app", ""); err != nil { + t.Fatal(err) + } + before, _ := LoadServers(path) + id := before.Servers["prod"].ID + + // Upsert with a changed host must not re-mint: id-based references + // would be silently re-keyed (X02 §1.3's silent-re-key prohibition). + if err := AddServer(path, "prod", "10.0.0.1", "admin", "lb", ""); err != nil { + t.Fatal(err) + } + after, _ := LoadServers(path) + if got := after.Servers["prod"].ID; got != id { + t.Fatalf("re-add re-keyed id: before %q after %q", id, got) + } +} + +func TestAddServer_LegacyEntryStaysIDLess(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "servers.yml") + legacy := "servers:\n prod:\n host: 1.2.3.4\n role: app\n" + if err := os.WriteFile(path, []byte(legacy), 0644); err != nil { + t.Fatal(err) + } + + // A pre-existing id-less entry stays id-less on upsert: minting here + // would flip dash's fallback (name-hash) identity underneath live + // references — adoption is the consumer's explicit decision, not a + // side effect of `server add`. + if err := AddServer(path, "prod", "10.0.0.1", "", "", ""); err != nil { + t.Fatal(err) + } + cfg, _ := LoadServers(path) + if got := cfg.Servers["prod"].ID; got != "" { + t.Fatalf("legacy entry gained id %q on re-add — silent re-key", got) + } +} + +func TestRenameServer_PreservesID(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "servers.yml") + + if err := AddServer(path, "prod", "1.2.3.4", "root", "app", ""); err != nil { + t.Fatal(err) + } + before, _ := LoadServers(path) + id := before.Servers["prod"].ID + if id == "" { + t.Fatal("expected a minted id to preserve") + } + + if err := RenameServer(path, "prod", "production"); err != nil { + t.Fatal(err) + } + after, _ := LoadServers(path) + if got := after.Servers["production"].ID; got != id { + t.Fatalf("rename changed id: before %q after %q", id, got) + } + if _, exists := after.Servers["prod"]; exists { + t.Fatal("old name still present after rename") + } +} + +func TestUpdateServer_PreservesID(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "servers.yml") + + if err := AddServer(path, "prod", "1.2.3.4", "root", "app", ""); err != nil { + t.Fatal(err) + } + before, _ := LoadServers(path) + id := before.Servers["prod"].ID + + newVpn := "100.64.1.2" + if err := UpdateServer(path, "prod", ServerUpdates{VpnIP: &newVpn}); err != nil { + t.Fatal(err) + } + after, _ := LoadServers(path) + if got := after.Servers["prod"].ID; got != id { + t.Fatalf("update changed id: before %q after %q", id, got) + } + if after.Servers["prod"].VpnIP != newVpn { + t.Fatalf("update lost vpn_ip: got %q", after.Servers["prod"].VpnIP) + } +} + func TestAddServer_Append(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "servers.yml") From c413685f3fa4e9e28cbbecaf786fdd9542b85719 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:31:18 -0700 Subject: [PATCH 2/2] =?UTF-8?q?feat(contracts):=20X02=20S6=20=E2=80=94=20o?= =?UTF-8?q?bservation-envelope=20schema=20corrected=20to=20the=20=C2=A72.4?= =?UTF-8?q?=20canonical=20shape=20+=20dash-generated=20goldens=20(corpus?= =?UTF-8?q?=20rev=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema now carries the adopted D13 form (resource{type,id}, last_success_at, collected_at, freshness tri-state fresh|stale|unknown, error, source, last_known{server,host,apps}); the S2 skeleton's status-enum draft is corrected before any consumer existed. Fixtures generated from teploy-dash's real constructors via its contracts golden test (teploy-dash internal/server/observation_contracts_test.go): fresh, stale, unknown-unreachable, and unreachable-last-known (the rule-3 merge — a failed probe keeps the previous payload by id; freshness is last-success age, never the probe outcome — pinned stale-reading in the same fixture). --- contracts/MANIFEST.md | 3 +- .../observation-envelope/valid/fresh.json | 33 ++++++++++++++++++ .../observation-envelope/valid/stale.json | 33 ++++++++++++++++++ .../valid/unknown-unreachable.json | 14 ++++++++ .../valid/unreachable-last-known.json | 34 +++++++++++++++++++ .../schema/observation-envelope.schema.json | 22 +++++++----- 6 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 contracts/fixtures/observation-envelope/valid/fresh.json create mode 100644 contracts/fixtures/observation-envelope/valid/stale.json create mode 100644 contracts/fixtures/observation-envelope/valid/unknown-unreachable.json create mode 100644 contracts/fixtures/observation-envelope/valid/unreachable-last-known.json diff --git a/contracts/MANIFEST.md b/contracts/MANIFEST.md index 0d53a19..f619242 100644 --- a/contracts/MANIFEST.md +++ b/contracts/MANIFEST.md @@ -11,6 +11,7 @@ Neutron/Nucleus dependency and a public mirror. | Corpus rev | Emitting CLI | Machine Interface | Notes | |---|---|---|---| | 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). | ## Artifact status @@ -23,7 +24,7 @@ Neutron/Nucleus dependency and a public mirror. | 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 | -| observation-envelope | yes | pending S6 (dash encoder) | teploy-dash | +| observation-envelope | yes (§2.4 canonical, rev 2) | valid x4 (fresh, stale, unknown-unreachable, unreachable-last-known; dash encoder) | teploy-dash | | operation-record | yes | pending S5/S6 (dash) | teploy-dash | ## Rules diff --git a/contracts/fixtures/observation-envelope/valid/fresh.json b/contracts/fixtures/observation-envelope/valid/fresh.json new file mode 100644 index 0000000..bf5a0c8 --- /dev/null +++ b/contracts/fixtures/observation-envelope/valid/fresh.json @@ -0,0 +1,33 @@ +{ + "resource": { + "type": "server", + "id": "srv-aaaaaaaaaaaaaaaa" + }, + "last_success_at": "2026-09-23T12:00:00Z", + "collected_at": "2026-09-23T12:00:00Z", + "freshness": "fresh", + "source": "cli", + "last_known": { + "server": "prod", + "host": "10.0.0.1", + "apps": [ + { + "app": "web", + "server": "prod", + "domain": "web.example.com", + "current_hash": "", + "previous_hash": "", + "port": 0, + "status": "running", + "deployed_at": "0001-01-01T00:00:00Z", + "containers": null, + "locked": false, + "maintenance": false, + "observed_at": "0001-01-01T00:00:00Z", + "errors": null, + "source": "cli", + "name": "web" + } + ] + } +} diff --git a/contracts/fixtures/observation-envelope/valid/stale.json b/contracts/fixtures/observation-envelope/valid/stale.json new file mode 100644 index 0000000..4b06009 --- /dev/null +++ b/contracts/fixtures/observation-envelope/valid/stale.json @@ -0,0 +1,33 @@ +{ + "resource": { + "type": "server", + "id": "srv-aaaaaaaaaaaaaaaa" + }, + "last_success_at": "2026-09-23T11:57:00Z", + "collected_at": "2026-09-23T12:00:00Z", + "freshness": "stale", + "source": "cli", + "last_known": { + "server": "prod", + "host": "10.0.0.1", + "apps": [ + { + "app": "web", + "server": "prod", + "domain": "web.example.com", + "current_hash": "", + "previous_hash": "", + "port": 0, + "status": "running", + "deployed_at": "0001-01-01T00:00:00Z", + "containers": null, + "locked": false, + "maintenance": false, + "observed_at": "0001-01-01T00:00:00Z", + "errors": null, + "source": "cli", + "name": "web" + } + ] + } +} diff --git a/contracts/fixtures/observation-envelope/valid/unknown-unreachable.json b/contracts/fixtures/observation-envelope/valid/unknown-unreachable.json new file mode 100644 index 0000000..27daef9 --- /dev/null +++ b/contracts/fixtures/observation-envelope/valid/unknown-unreachable.json @@ -0,0 +1,14 @@ +{ + "resource": { + "type": "server", + "id": "srv-bbbbbbbbbbbbbbbb" + }, + "collected_at": "2026-09-23T12:00:00Z", + "freshness": "unknown", + "error": "dial tcp 10.0.0.2:22: connect: connection refused", + "last_known": { + "server": "edge", + "host": "10.0.0.2", + "apps": [] + } +} diff --git a/contracts/fixtures/observation-envelope/valid/unreachable-last-known.json b/contracts/fixtures/observation-envelope/valid/unreachable-last-known.json new file mode 100644 index 0000000..8133cb5 --- /dev/null +++ b/contracts/fixtures/observation-envelope/valid/unreachable-last-known.json @@ -0,0 +1,34 @@ +{ + "resource": { + "type": "server", + "id": "srv-aaaaaaaaaaaaaaaa" + }, + "last_success_at": "2026-09-23T11:57:00Z", + "collected_at": "2026-09-23T12:00:00Z", + "freshness": "stale", + "error": "ssh: connect to host 10.0.0.1 port 22: timed out", + "source": "cli", + "last_known": { + "server": "prod", + "host": "10.0.0.1", + "apps": [ + { + "app": "web", + "server": "prod", + "domain": "web.example.com", + "current_hash": "", + "previous_hash": "", + "port": 0, + "status": "running", + "deployed_at": "0001-01-01T00:00:00Z", + "containers": null, + "locked": false, + "maintenance": false, + "observed_at": "0001-01-01T00:00:00Z", + "errors": null, + "source": "cli", + "name": "web" + } + ] + } +} diff --git a/contracts/schema/observation-envelope.schema.json b/contracts/schema/observation-envelope.schema.json index c7ca99a..2bbc23f 100644 --- a/contracts/schema/observation-envelope.schema.json +++ b/contracts/schema/observation-envelope.schema.json @@ -1,16 +1,20 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://teploy.github.io/contracts/schema/observation-envelope.schema.json", - "title": "observation envelope - standard shape for any observed resource (X02 S2.4; PRODUCER = teploy-dash)", - "$comment": "Schema shared via this corpus but the emitting code lives in teploy-dash (S6). Fixtures for this artifact are dash-side until S6 lands its encoder.", + "title": "observation envelope - standard shape for any observed resource (X02 §2.4; PRODUCER = teploy-dash)", + "$comment": "Canonical shape adopted by the ADR §2.4 / decision D13. The S2 skeleton carried a status-enum draft shape before any consumer existed; corpus rev 2 corrects it to the adopted form. Fixtures are generated from teploy-dash's real constructors (internal/server/observation_contracts_test.go, TEPLOY_UPDATE_CONTRACTS=1).", "type": "object", - "required": ["resource", "observed_at", "status"], + "required": ["resource", "collected_at", "freshness", "last_known"], "properties": { - "resource": {"type": "object", "required": ["kind", "id"], - "properties": {"kind": {"type": "string"}, "id": {"type": "string"}}}, - "observed_at": {"type": "string", "format": "date-time"}, - "status": {"type": "string", "enum": ["evaluated", "unavailable", "invalid", "unknown"]}, - "last_known": {"type": ["object", "null"]}, - "reason": {"type": "string"} + "resource": {"type": "object", "required": ["type", "id"], + "properties": {"type": {"type": "string"}, "id": {"type": "string"}}}, + "last_success_at": {"type": "string", "format": "date-time"}, + "collected_at": {"type": "string", "format": "date-time"}, + "freshness": {"type": "string", "enum": ["fresh", "stale", "unknown"]}, + "error": {"type": "string"}, + "source": {"type": "string"}, + "last_known": {"type": "object", + "required": ["server", "apps"], + "properties": {"server": {"type": "string"}, "host": {"type": "string"}, "apps": {"type": "array"}}} } }