feat(provision): instance records, app instances manifest, validation and store ops
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+602
−0
internal/provision/provision.go+289 −0internal/provision/provision_test.go+313 −0
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| new file mode 100644 |
| index 000000000..788d3333f |
| --- /dev/null |
| +++ b/internal/provision/provision.go |
| @@ -0,0 +1,289 @@ |
| +// Package provision owns instance provisioning as a control-plane data |
| +// write: the bucket record and app manifest shapes, their validation, the |
| +// store operations the console's instance verbs use, and the route |
| +// conventions shared with `carlos add`. The reconcile pass that turns |
| +// records into routes lives in reconcile.go (Task 3). Spec: |
| +// docs/superpowers/specs/2026-08-04-instance-provisioning-design.md. |
| +package provision |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "regexp" |
| + "sort" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Record is the wire shape at apps/<sqid>/<app>/instances/<host>.json — one |
| +// per provisioned instance, create-only so a lost race is a 409 and never a |
| +// silent overwrite. The body must agree with its own key path (Host/App/ |
| +// Account vs the key's segments); GetRecord and ListRecords enforce that, a |
| +// record that lies about identity is never guessed at (design §3.2). |
| +type Record struct { |
| + Schema int `json:"schema"` // 1 |
| + Host string `json:"host"` |
| + Slug string `json:"slug"` |
| + App string `json:"app"` |
| + Account string `json:"account"` |
| + Pool string `json:"pool,omitempty"` |
| + Region string `json:"region,omitempty"` |
| + Channel string `json:"channel"` |
| + CreatedAt time.Time `json:"created_at"` |
| + CreatedBy string `json:"created_by,omitempty"` |
| +} |
| + |
| +// Manifest is the wire shape at apps/<sqid>/<app>/instances.json — the app's |
| +// opt-in to instance provisioning and the policy every record is checked |
| +// against (host suffix, region allow-list, seed argv). Absence means the |
| +// feature does not exist for that app, the same convention litestream's own |
| +// manifest uses (design §3.1). |
| +type Manifest struct { |
| + Schema int `json:"schema"` // 1 |
| + App string `json:"app"` |
| + Domain string `json:"domain"` |
| + Pool string `json:"pool,omitempty"` |
| + Channel string `json:"channel,omitempty"` |
| + Regions []string `json:"regions,omitempty"` |
| + Seed []string `json:"seed,omitempty"` |
| +} |
| + |
| +// ManifestKey, RecordsPrefix and RecordKey are the bucket key conventions |
| +// for a provisioned app — beside release.AppPrefix's channels/ and |
| +// releases/, matching the ledgers.json precedent of a per-app manifest |
| +// sitting beside the collection it governs. |
| +func ManifestKey(acct, app string) string { return release.AppPrefix(acct, app) + "instances.json" } |
| +func RecordsPrefix(acct, app string) string { return release.AppPrefix(acct, app) + "instances/" } |
| +func RecordKey(acct, app, host string) string { |
| + return RecordsPrefix(acct, app) + host + ".json" |
| +} |
| + |
| +// ParseRecordKey reports whether key is an account-qualified instance |
| +// record (apps/<sqid>/<app>/instances/<host>.json) known to belong to a |
| +// materialized account, and if so its account, app and host. Mirrors |
| +// domains.appKeyOwner in miniature: known guards against a key whose |
| +// account has not materialized yet, and app is refused when it collides |
| +// with the release plane's own words ("channels", "releases") — the same |
| +// two-namespace collision appKeyOwner documents. A key with anything but |
| +// exactly one non-empty segment after "instances/" is not a record: the |
| +// manifest (instances.json, no trailing segment) and any nested path both |
| +// refuse. |
| +func ParseRecordKey(key string, known map[string]bool) (acct, app, host string, ok bool) { |
| + rest, ok := strings.CutPrefix(key, "apps/") |
| + if !ok { |
| + return "", "", "", false |
| + } |
| + acct, rest, ok = strings.Cut(rest, "/") |
| + if !ok || !known[acct] { |
| + return "", "", "", false |
| + } |
| + app, rest, ok = strings.Cut(rest, "/") |
| + if !ok || app == "" || app == "channels" || app == "releases" { |
| + return "", "", "", false |
| + } |
| + seg, rest, ok := strings.Cut(rest, "/") |
| + if !ok || seg != "instances" { |
| + return "", "", "", false |
| + } |
| + if rest == "" || strings.Contains(rest, "/") { |
| + return "", "", "", false |
| + } |
| + host, ok = strings.CutSuffix(rest, ".json") |
| + if !ok || host == "" { |
| + return "", "", "", false |
| + } |
| + return acct, app, host, true |
| +} |
| + |
| +// labelRe pins one DNS label the same way domains.accountNameRe pins an |
| +// account name: lowercase a-z, 0-9, hyphen, no leading or trailing hyphen, |
| +// 1-63 characters (the DNS label ceiling). |
| +var labelRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) |
| + |
| +// ValidHost reports whether host is exactly one label followed by |
| +// "."+domain. This is path-traversal defense stated as such: the host |
| +// becomes a bucket key segment (RecordKey) and, on the box, the |
| +// <host>.sock / <host>.db filesystem path segments — the label regex is |
| +// what keeps a hostile host from ever reaching either as anything but an |
| +// opaque, safe path component. |
| +func ValidHost(host, domain string) bool { |
| + if domain == "" || len(host) > 253 { |
| + return false |
| + } |
| + label, ok := strings.CutSuffix(host, "."+domain) |
| + if !ok || label == "" { |
| + return false |
| + } |
| + return labelRe.MatchString(label) |
| +} |
| + |
| +// ValidHostShape checks host's shape alone, with no domain to validate a |
| +// suffix against — for doors that must still refuse a hostile host after |
| +// its app's manifest (and so its domain) is gone: a delete, for instance, |
| +// which must work even if the app disabled instances since the record was |
| +// created. |
| +func ValidHostShape(host string) bool { |
| + if host == "" || len(host) > 253 { |
| + return false |
| + } |
| + for _, label := range strings.Split(host, ".") { |
| + if !labelRe.MatchString(label) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// regionPoolRe is the shared charset for a region or pool code: lowercase |
| +// a-z, 0-9, hyphen, up to 16 characters. Empty is deliberately valid — the |
| +// unconstrained region and the shared (default) pool are both "". |
| +var regionPoolRe = regexp.MustCompile(`^[a-z0-9-]{0,16}$`) |
| + |
| +func ValidRegion(region string) bool { return regionPoolRe.MatchString(region) } |
| +func ValidPool(pool string) bool { return regionPoolRe.MatchString(pool) } |
| + |
| +// GetManifest reads app's instances manifest. Not-found stays |
| +// os.ErrNotExist-wrapped (absence IS the "instances disabled" answer, not |
| +// an error condition the caller must special-case beyond errors.Is). A |
| +// schema this binary doesn't understand is refused rather than silently |
| +// truncated by json.Unmarshal (appconfig/store.go's precedent) — acting on |
| +// a newer manifest would silently discard whatever the unknown fields meant. |
| +func GetManifest(ctx context.Context, s release.Store, acct, app string) (*Manifest, error) { |
| + raw, _, err := s.Get(ctx, ManifestKey(acct, app)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var m Manifest |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + return nil, fmt.Errorf("provision: manifest %s/%s: %w", acct, app, err) |
| + } |
| + if m.Schema > 1 { |
| + return nil, fmt.Errorf("provision: manifest %s/%s: schema %d is newer than this binary understands — upgrade carlos", acct, app, m.Schema) |
| + } |
| + return &m, nil |
| +} |
| + |
| +// PutManifest publishes app's instances manifest — a CAS upsert (read |
| +// etag, put, one retry on a lost race), the WriteAccountManifest shape, |
| +// because unlike a record this object is meant to be overwritten as an |
| +// operator adjusts policy, not created once. |
| +func PutManifest(ctx context.Context, s release.Store, acct, app string, m Manifest) error { |
| + key := ManifestKey(acct, app) |
| + body, err := json.Marshal(m) |
| + if err != nil { |
| + return err |
| + } |
| + put := func() error { |
| + etag := "" |
| + if _, cur, err := s.Get(ctx, key); err == nil { |
| + etag = cur |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + _, err := s.Put(ctx, key, body, etag) |
| + return err |
| + } |
| + if err := put(); errors.Is(err, release.ErrCASConflict) { |
| + return put() // one retry on a lost race; a second loss surfaces |
| + } else if err != nil { |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +// CreateRecord writes a new instance record, create-only: a lost race |
| +// against a second signup for the same host comes back wrapping |
| +// release.ErrCASConflict, never a silent overwrite of the winner's record. |
| +func CreateRecord(ctx context.Context, s release.Store, rec Record) error { |
| + body, err := json.Marshal(rec) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.Put(ctx, RecordKey(rec.Account, rec.App, rec.Host), body, "") |
| + return err |
| +} |
| + |
| +// DeleteRecord retires an instance's route authorization. Object deletion |
| +// only — the parked database and blobs under the regional bucket's prefix |
| +// are deliberately untouched (design §3.2); Store's Delete is idempotent, |
| +// so retiring an already-gone record is not an error. |
| +func DeleteRecord(ctx context.Context, s release.Store, acct, app, host string) error { |
| + return s.Delete(ctx, RecordKey(acct, app, host)) |
| +} |
| + |
| +// GetRecord reads and validates one instance record. A body whose own |
| +// Host/App/Account disagree with the key path it was read from is refused |
| +// rather than trusted — the same "never guessed at" discipline |
| +// AccountManifest's sqid-in-path-and-body carries. |
| +func GetRecord(ctx context.Context, s release.Store, acct, app, host string) (*Record, error) { |
| + raw, _, err := s.Get(ctx, RecordKey(acct, app, host)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var rec Record |
| + if err := json.Unmarshal(raw, &rec); err != nil { |
| + return nil, fmt.Errorf("provision: record %s/%s/%s: %w", acct, app, host, err) |
| + } |
| + if rec.Account != acct || rec.App != app || rec.Host != host { |
| + return nil, fmt.Errorf("provision: record %s/%s/%s: record disagrees with its own path", acct, app, host) |
| + } |
| + return &rec, nil |
| +} |
| + |
| +// ListRecords lists an app's instance records, sorted by host, capped at |
| +// max with truncated reporting whether more existed. The cap is decided on |
| +// the KEY count — how many instances/<host>.json objects exist under the |
| +// prefix — not on how many bodies successfully parse: a handful of |
| +// malformed bodies among the first max keys must not silently pull in |
| +// extra keys past the cap to compensate, and truncated must not depend on |
| +// which records happen to be malformed today. A malformed or |
| +// path-disagreeing body (GetRecord's own checks) is skipped, not fatal. |
| +func ListRecords(ctx context.Context, s release.Store, acct, app string, max int) (recs []Record, truncated bool, err error) { |
| + prefix := RecordsPrefix(acct, app) |
| + keys, err := s.List(ctx, prefix) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + var hosts []string |
| + for _, key := range keys { |
| + rest, ok := strings.CutPrefix(key, prefix) |
| + if !ok || rest == "" || strings.Contains(rest, "/") { |
| + continue // not a record at this level (e.g. a nested key never written here) |
| + } |
| + host, ok := strings.CutSuffix(rest, ".json") |
| + if !ok || host == "" { |
| + continue |
| + } |
| + hosts = append(hosts, host) |
| + } |
| + sort.Strings(hosts) |
| + if len(hosts) > max { |
| + truncated = true |
| + hosts = hosts[:max] |
| + } |
| + for _, host := range hosts { |
| + rec, err := GetRecord(ctx, s, acct, app, host) |
| + if err != nil { |
| + continue |
| + } |
| + recs = append(recs, *rec) |
| + } |
| + return recs, truncated, nil |
| +} |
| + |
| +// SocketDir is where a hibernating instance's control socket lives — the |
| +// same tmpfs every other exec-managed route already uses |
| +// (host/bootstrap.go's tmpfiles.d entry). |
| +const SocketDir = "/run/carlos" |
| + |
| +// DefaultSocket and DefaultDBPath are the route conventions `carlos add` |
| +// and the reconciler's mint (Task 3) both build on — the "honest shared |
| +// core" design §9 names, so a hand-added route and a provisioned one land |
| +// on identical path shapes. |
| +func DefaultSocket(host string) string { return SocketDir + "/" + host + ".sock" } |
| +func DefaultDBPath(dataDir, host string) string { return dataDir + "/" + host + ".db" } |
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go| new file mode 100644 |
| index 000000000..32b62167e |
| --- /dev/null |
| +++ b/internal/provision/provision_test.go |
| @@ -0,0 +1,313 @@ |
| +package provision |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestValidHost(t *testing.T) { |
| + cases := []struct { |
| + host, domain string |
| + want bool |
| + }{ |
| + {"acme.go.tito.io", "go.tito.io", true}, |
| + {"a.go.tito.io", "go.tito.io", true}, |
| + {"acme.go.tito.io", "other.io", false}, // wrong suffix |
| + {"go.tito.io", "go.tito.io", false}, // no label |
| + {"a.b.go.tito.io", "go.tito.io", false}, // two labels |
| + {"Acme.go.tito.io", "go.tito.io", false}, // uppercase refused, not folded |
| + {"-a.go.tito.io", "go.tito.io", false}, // leading hyphen |
| + {"a-.go.tito.io", "go.tito.io", false}, // trailing hyphen |
| + {"../x.go.tito.io", "go.tito.io", false}, // traversal shapes |
| + {"a/b.go.tito.io", "go.tito.io", false}, |
| + {"acme.go.tito.io", "", false}, // empty domain never validates |
| + {strings.Repeat("a", 64) + ".go.tito.io", "go.tito.io", false}, // label > 63 |
| + {strings.Repeat("a", 250) + ".go.tito.io", "go.tito.io", false}, // total > 253 |
| + } |
| + for _, c := range cases { |
| + if got := ValidHost(c.host, c.domain); got != c.want { |
| + t.Errorf("ValidHost(%q, %q) = %v, want %v", c.host, c.domain, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestValidHostShape(t *testing.T) { |
| + cases := []struct { |
| + host string |
| + want bool |
| + }{ |
| + {"acme.go.tito.io", true}, |
| + {"a", true}, |
| + {"a..b", false}, |
| + {"a/b", false}, |
| + {".a", false}, |
| + {strings.Repeat("a", 254), false}, |
| + {"Acme", false}, |
| + } |
| + for _, c := range cases { |
| + if got := ValidHostShape(c.host); got != c.want { |
| + t.Errorf("ValidHostShape(%q) = %v, want %v", c.host, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestValidRegion(t *testing.T) { |
| + cases := []struct { |
| + region string |
| + want bool |
| + }{ |
| + {"ie", true}, |
| + {"", true}, |
| + {"IE", false}, |
| + {"x/y", false}, |
| + {strings.Repeat("a", 17), false}, |
| + } |
| + for _, c := range cases { |
| + if got := ValidRegion(c.region); got != c.want { |
| + t.Errorf("ValidRegion(%q) = %v, want %v", c.region, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestValidPool(t *testing.T) { |
| + cases := []struct { |
| + pool string |
| + want bool |
| + }{ |
| + {"ie", true}, |
| + {"", true}, |
| + {"IE", false}, |
| + {"x/y", false}, |
| + {strings.Repeat("a", 17), false}, |
| + } |
| + for _, c := range cases { |
| + if got := ValidPool(c.pool); got != c.want { |
| + t.Errorf("ValidPool(%q) = %v, want %v", c.pool, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestRecordKeyRoundTrip(t *testing.T) { |
| + key := RecordKey("bad", "tito", "acme.go.tito.io") |
| + want := "apps/bad/tito/instances/acme.go.tito.io.json" |
| + if key != want { |
| + t.Fatalf("RecordKey = %q, want %q", key, want) |
| + } |
| + |
| + known := map[string]bool{"bad": true} |
| + acct, app, host, ok := ParseRecordKey(key, known) |
| + if !ok || acct != "bad" || app != "tito" || host != "acme.go.tito.io" { |
| + t.Fatalf("ParseRecordKey(%q) = (%q, %q, %q, %v), want (bad, tito, acme.go.tito.io, true)", key, acct, app, host, ok) |
| + } |
| + |
| + if _, _, _, ok := ParseRecordKey(key, map[string]bool{}); ok { |
| + t.Errorf("ParseRecordKey with unknown account should refuse, got ok=true") |
| + } |
| + |
| + if _, _, _, ok := ParseRecordKey(ManifestKey("bad", "tito"), known); ok { |
| + t.Errorf("ParseRecordKey on the manifest key should refuse, got ok=true") |
| + } |
| + |
| + if _, _, _, ok := ParseRecordKey("apps/bad/tito/channels/stable.json", known); ok { |
| + t.Errorf("ParseRecordKey on a channel pointer should refuse, got ok=true") |
| + } |
| + |
| + if _, _, _, ok := ParseRecordKey("apps/bad/tito/instances/sub/host.json", known); ok { |
| + t.Errorf("ParseRecordKey with a nested slash after instances/ should refuse, got ok=true") |
| + } |
| +} |
| + |
| +func TestCreateRecordCreateOnly(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + rec := Record{ |
| + Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", Account: "bad", |
| + Channel: "stable", CreatedAt: time.Now().UTC(), |
| + } |
| + |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatalf("first CreateRecord: %v", err) |
| + } |
| + |
| + err := CreateRecord(ctx, s, rec) |
| + if err == nil { |
| + t.Fatal("second CreateRecord: want error, got nil") |
| + } |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + t.Errorf("second CreateRecord error = %v, want wrapping release.ErrCASConflict", err) |
| + } |
| +} |
| + |
| +func TestManifestRoundTrip(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + m := Manifest{ |
| + Schema: 1, App: "tito", Domain: "go.tito.io", Pool: "tito", Channel: "stable", |
| + Regions: []string{"ie"}, Seed: []string{"titogo", "seed"}, |
| + } |
| + |
| + if err := PutManifest(ctx, s, "bad", "tito", m); err != nil { |
| + t.Fatalf("PutManifest: %v", err) |
| + } |
| + |
| + got, err := GetManifest(ctx, s, "bad", "tito") |
| + if err != nil { |
| + t.Fatalf("GetManifest: %v", err) |
| + } |
| + if got.App != m.App || got.Domain != m.Domain || got.Pool != m.Pool || |
| + got.Channel != m.Channel || len(got.Regions) != 1 || got.Regions[0] != "ie" || |
| + len(got.Seed) != 2 { |
| + t.Errorf("GetManifest round trip mismatch: got %+v, want %+v", got, m) |
| + } |
| + |
| + // Update: PutManifest twice must succeed (CAS upsert, not create-only). |
| + m.Channel = "canary/x" |
| + if err := PutManifest(ctx, s, "bad", "tito", m); err != nil { |
| + t.Fatalf("second PutManifest (update): %v", err) |
| + } |
| + got, err = GetManifest(ctx, s, "bad", "tito") |
| + if err != nil { |
| + t.Fatalf("GetManifest after update: %v", err) |
| + } |
| + if got.Channel != "canary/x" { |
| + t.Errorf("GetManifest after update: Channel = %q, want canary/x", got.Channel) |
| + } |
| + |
| + // Absence. |
| + if _, err := GetManifest(ctx, s, "bad", "missing"); !errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("GetManifest on absence: err = %v, want os.ErrNotExist", err) |
| + } |
| + |
| + // A newer-schema body is refused on Get. |
| + newer := []byte(`{"schema":2,"app":"tito","domain":"go.tito.io"}`) |
| + if _, err := s.Put(ctx, ManifestKey("bad", "future"), newer, ""); err != nil { |
| + t.Fatalf("seeding schema-2 manifest: %v", err) |
| + } |
| + if _, err := GetManifest(ctx, s, "bad", "future"); err == nil { |
| + t.Error("GetManifest on schema 2: want error, got nil") |
| + } |
| +} |
| + |
| +func TestListRecords(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + |
| + hosts := []string{"c.go.tito.io", "a.go.tito.io", "b.go.tito.io"} |
| + for _, h := range hosts { |
| + rec := Record{Schema: 1, Host: h, Slug: h[:1], App: "tito", Account: "bad", Channel: "stable", CreatedAt: time.Now().UTC()} |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatalf("CreateRecord(%s): %v", h, err) |
| + } |
| + } |
| + // The manifest sits at the app prefix, not under instances/ — must never |
| + // be mistaken for a record. |
| + m := Manifest{Schema: 1, App: "tito", Domain: "go.tito.io"} |
| + if err := PutManifest(ctx, s, "bad", "tito", m); err != nil { |
| + t.Fatalf("PutManifest: %v", err) |
| + } |
| + |
| + recs, truncated, err := ListRecords(ctx, s, "bad", "tito", 10) |
| + if err != nil { |
| + t.Fatalf("ListRecords: %v", err) |
| + } |
| + if truncated { |
| + t.Error("ListRecords: truncated = true, want false") |
| + } |
| + if len(recs) != 3 { |
| + t.Fatalf("ListRecords: got %d records, want 3", len(recs)) |
| + } |
| + for i, want := range []string{"a.go.tito.io", "b.go.tito.io", "c.go.tito.io"} { |
| + if recs[i].Host != want { |
| + t.Errorf("ListRecords[%d].Host = %q, want %q (not sorted)", i, recs[i].Host, want) |
| + } |
| + } |
| + |
| + recs, truncated, err = ListRecords(ctx, s, "bad", "tito", 2) |
| + if err != nil { |
| + t.Fatalf("ListRecords with max=2: %v", err) |
| + } |
| + if !truncated { |
| + t.Error("ListRecords with max=2: truncated = false, want true") |
| + } |
| + if len(recs) != 2 { |
| + t.Fatalf("ListRecords with max=2: got %d records, want 2", len(recs)) |
| + } |
| + |
| + // A malformed record body is skipped, not fatal. |
| + badKey := RecordKey("bad", "tito", "z.go.tito.io") |
| + if _, err := s.Put(ctx, badKey, []byte("not json"), ""); err != nil { |
| + t.Fatalf("seeding malformed record: %v", err) |
| + } |
| + recs, _, err = ListRecords(ctx, s, "bad", "tito", 10) |
| + if err != nil { |
| + t.Fatalf("ListRecords with a malformed body: %v", err) |
| + } |
| + if len(recs) != 3 { |
| + t.Fatalf("ListRecords with a malformed body: got %d records, want 3 (malformed skipped)", len(recs)) |
| + } |
| +} |
| + |
| +func TestGetRecord(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + rec := Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", Account: "bad", Channel: "stable", CreatedAt: time.Now().UTC()} |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatalf("CreateRecord: %v", err) |
| + } |
| + |
| + got, err := GetRecord(ctx, s, "bad", "tito", "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatalf("GetRecord: %v", err) |
| + } |
| + if got.Host != rec.Host || got.App != rec.App || got.Account != rec.Account { |
| + t.Errorf("GetRecord = %+v, want %+v", got, rec) |
| + } |
| + |
| + if _, err := GetRecord(ctx, s, "bad", "tito", "missing.go.tito.io"); !errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("GetRecord on absence: err = %v, want os.ErrNotExist", err) |
| + } |
| + |
| + // A body that disagrees with its own key path is refused, not guessed at. |
| + body, _ := json.Marshal(Record{Schema: 1, Host: "other.go.tito.io", App: "tito", Account: "bad"}) |
| + if _, err := s.Put(ctx, RecordKey("bad", "tito", "liar.go.tito.io"), body, ""); err != nil { |
| + t.Fatalf("seeding disagreeing record: %v", err) |
| + } |
| + if _, err := GetRecord(ctx, s, "bad", "tito", "liar.go.tito.io"); err == nil { |
| + t.Error("GetRecord on a disagreeing body: want error, got nil") |
| + } |
| +} |
| + |
| +func TestDeleteRecordIdempotent(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + rec := Record{Schema: 1, Host: "acme.go.tito.io", App: "tito", Account: "bad", Channel: "stable"} |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatalf("CreateRecord: %v", err) |
| + } |
| + if err := DeleteRecord(ctx, s, "bad", "tito", "acme.go.tito.io"); err != nil { |
| + t.Fatalf("DeleteRecord: %v", err) |
| + } |
| + if _, err := GetRecord(ctx, s, "bad", "tito", "acme.go.tito.io"); !errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("GetRecord after delete: err = %v, want os.ErrNotExist", err) |
| + } |
| + // Deleting again is not an error (Store's idempotent-delete contract). |
| + if err := DeleteRecord(ctx, s, "bad", "tito", "acme.go.tito.io"); err != nil { |
| + t.Errorf("second DeleteRecord: %v, want nil (idempotent)", err) |
| + } |
| +} |
| + |
| +func TestDefaults(t *testing.T) { |
| + if got := DefaultSocket("h"); got != "/run/carlos/h.sock" { |
| + t.Errorf("DefaultSocket(%q) = %q, want /run/carlos/h.sock", "h", got) |
| + } |
| + if got := DefaultDBPath("/data", "h"); got != "/data/h.db" { |
| + t.Errorf("DefaultDBPath(%q, %q) = %q, want /data/h.db", "/data", "h", got) |
| + } |
| +} |