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] =?UTF-8?q?feat(config,server):=20X02=20S4=20=E2=80=94=20s?= =?UTF-8?q?table=20server=20IDs=20(additive=20id=20field,=20mint=20at=20ad?= =?UTF-8?q?d,=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")