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
31 changes: 30 additions & 1 deletion internal/config/servers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package config

import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
Expand All @@ -15,13 +17,32 @@ 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)
Tags map[string]string `yaml:"tags,omitempty" json:"tags,omitempty"` // per-host env vars injected during deploy
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"`
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
120 changes: 120 additions & 0 deletions internal/config/servers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"testing"
)

Expand Down Expand Up @@ -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")
Expand Down
Loading