carlos / platform Public

feat(provision): instance records, app instances manifest, validation and store ops

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d40839f89c086e5857a3fed499f51928279b8b51 parent 26c0a30
2 files changed, +602 −0
  • internal/provision/provision.go +289 −0
  • internal/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)
+ }
+}