seeds: the legacy route owners reader (Plan 7 Task 2)
Fails closed: a present file that is unreadable, malformed, of an unknown version or carries an incomplete entry is an error, never an empty set.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+211
−0
internal/seeds/legacyowners.go+80 −0internal/seeds/legacyowners_test.go+131 −0
CI passed — run details
diff --git a/internal/seeds/legacyowners.go b/internal/seeds/legacyowners.go| new file mode 100644 |
| index 000000000..764c079d7 |
| --- /dev/null |
| +++ b/internal/seeds/legacyowners.go |
| @@ -0,0 +1,80 @@ |
| +package seeds |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// LegacyOwnersKey is the operator assignment of legacy route names |
| +// (seeded-instances spec §6.4, codex 9): which account and app a route |
| +// filed under the operator account belongs to. |
| +const LegacyOwnersKey = "control/legacy-route-owners.json" |
| + |
| +// legacyOwnersVersion is the only file version this reader understands. |
| +const legacyOwnersVersion = 1 |
| + |
| +// LegacyOwner is the account and app a legacy route name is assigned to. |
| +type LegacyOwner struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| +} |
| + |
| +// LegacyOwners maps a route name, as filed under the operator account, to |
| +// the one account and app it belongs to. |
| +type LegacyOwners struct { |
| + V int `json:"v"` |
| + Names map[string]LegacyOwner `json:"names"` |
| +} |
| + |
| +// ReadLegacyOwners reads the assignments. It is an authority check, so it |
| +// fails closed: an absent file is an empty set (nothing is assigned), but a |
| +// file that is present and unreadable, malformed, of an unknown version or |
| +// carrying an incomplete entry is an error and no owners, never "no |
| +// assignment". The etag is "" for an absent file. |
| +func ReadLegacyOwners(ctx context.Context, s release.Store) (*LegacyOwners, string, error) { |
| + body, etag, err := s.Get(ctx, LegacyOwnersKey) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return &LegacyOwners{V: legacyOwnersVersion, Names: map[string]LegacyOwner{}}, "", nil |
| + } |
| + if err != nil { |
| + return nil, "", fmt.Errorf("seeds: read legacy route owners: %w", err) |
| + } |
| + dec := json.NewDecoder(bytes.NewReader(body)) |
| + dec.DisallowUnknownFields() |
| + var o LegacyOwners |
| + if err := dec.Decode(&o); err != nil { |
| + return nil, "", fmt.Errorf("seeds: parse legacy route owners: %w", err) |
| + } |
| + if _, err := dec.Token(); err != io.EOF { |
| + return nil, "", errors.New("seeds: parse legacy route owners: data after the object") |
| + } |
| + if o.V != legacyOwnersVersion { |
| + return nil, "", fmt.Errorf("seeds: legacy route owners: unknown version %d", o.V) |
| + } |
| + if o.Names == nil { |
| + o.Names = map[string]LegacyOwner{} |
| + } |
| + for name, ow := range o.Names { |
| + if name == "" || ow.Account == "" || ow.App == "" { |
| + return nil, "", fmt.Errorf("seeds: legacy route owners: incomplete entry %q", name) |
| + } |
| + } |
| + return &o, etag, nil |
| +} |
| + |
| +// Lookup returns the owner assigned to name. A name that is not in the set |
| +// is not assigned. |
| +func (o *LegacyOwners) Lookup(name string) (LegacyOwner, bool) { |
| + if o == nil { |
| + return LegacyOwner{}, false |
| + } |
| + v, ok := o.Names[name] |
| + return v, ok |
| +} |
diff --git a/internal/seeds/legacyowners_test.go b/internal/seeds/legacyowners_test.go| new file mode 100644 |
| index 000000000..0d7bfef29 |
| --- /dev/null |
| +++ b/internal/seeds/legacyowners_test.go |
| @@ -0,0 +1,131 @@ |
| +package seeds |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func putOwners(t *testing.T, s release.Store, body, ifMatch string) string { |
| + t.Helper() |
| + etag, err := s.Put(context.Background(), LegacyOwnersKey, []byte(body), ifMatch) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return etag |
| +} |
| + |
| +func TestLegacyOwners(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + o, etag, err := ReadLegacyOwners(ctx, s) |
| + if err != nil || etag != "" || o == nil || len(o.Names) != 0 { |
| + t.Fatalf("absent = %+v %q %v", o, etag, err) |
| + } |
| + if _, ok := o.Lookup("shop"); ok { |
| + t.Fatal("a name resolved from an absent file") |
| + } |
| + etag = putOwners(t, s, `{"v":1,"names":{"shop":{"account":"abc","app":"shop"}}}`, "") |
| + o, got, err := ReadLegacyOwners(ctx, s) |
| + if err != nil || got == "" || got != etag { |
| + t.Fatalf("read = %q (put %q) %v", got, etag, err) |
| + } |
| + if got, ok := o.Lookup("shop"); !ok || got != (LegacyOwner{Account: "abc", App: "shop"}) { |
| + t.Fatalf("lookup = %+v %v", got, ok) |
| + } |
| + if _, ok := o.Lookup("other"); ok { |
| + t.Fatal("an unassigned name resolved") |
| + } |
| + if _, _, err := ReadLegacyOwners(ctx, s); err != nil { |
| + t.Fatal(err) |
| + } |
| + putOwners(t, s, `{garbage`, etag) |
| + if _, _, err := ReadLegacyOwners(ctx, s); err == nil { |
| + t.Fatal("garbage read as no assignments") |
| + } |
| +} |
| + |
| +func TestLegacyOwnersEmptySet(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + putOwners(t, s, `{"v":1}`, "") |
| + o, etag, err := ReadLegacyOwners(context.Background(), s) |
| + if err != nil || etag == "" || o == nil || o.Names == nil || len(o.Names) != 0 { |
| + t.Fatalf("empty v1 file = %+v %q %v", o, etag, err) |
| + } |
| + if _, ok := o.Lookup("shop"); ok { |
| + t.Fatal("a name resolved from an empty set") |
| + } |
| +} |
| + |
| +// Every present-but-unusable file is an error and no owners, never an empty |
| +// set: the swap treats "no assignment" as a refusal it can explain, and a |
| +// read it cannot trust as a failure of its own. |
| +func TestLegacyOwnersFailClosed(t *testing.T) { |
| + good := `"names":{"shop":{"account":"abc","app":"shop"}}` |
| + cases := map[string]string{ |
| + "garbage": `{garbage`, |
| + "empty body": ``, |
| + "not an object": `[]`, |
| + "null": `null`, |
| + "version missing": `{` + good + `}`, |
| + "version zero": `{"v":0,` + good + `}`, |
| + "version unknown": `{"v":2,` + good + `}`, |
| + "version negative": `{"v":-1,` + good + `}`, |
| + "version a string": `{"v":"1",` + good + `}`, |
| + "names a list": `{"v":1,"names":[]}`, |
| + "entry a string": `{"v":1,"names":{"shop":"abc/shop"}}`, |
| + "entry no account": `{"v":1,"names":{"shop":{"app":"shop"}}}`, |
| + "entry no app": `{"v":1,"names":{"shop":{"account":"abc"}}}`, |
| + "entry null": `{"v":1,"names":{"shop":null}}`, |
| + "empty name": `{"v":1,"names":{"":{"account":"abc","app":"shop"}}}`, |
| + "unknown field": `{"v":1,"extra":true,` + good + `}`, |
| + "unknown entry key": `{"v":1,"names":{"shop":{"account":"abc","app":"shop","x":1}}}`, |
| + "trailing object": `{"v":1,` + good + `}{"v":1}`, |
| + "trailing garbage": `{"v":1,` + good + `}x`, |
| + } |
| + for name, body := range cases { |
| + t.Run(name, func(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + putOwners(t, s, body, "") |
| + o, etag, err := ReadLegacyOwners(context.Background(), s) |
| + if err == nil { |
| + t.Fatalf("read %q = %+v, want an error", body, o) |
| + } |
| + if o != nil || etag != "" { |
| + t.Fatalf("an error came with owners %+v / etag %q", o, etag) |
| + } |
| + if errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("a bad file reads as absent: %v", err) |
| + } |
| + if !strings.Contains(err.Error(), "legacy route owners") { |
| + t.Fatalf("error does not say what failed: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A store error other than not-found is an error too. |
| +func TestLegacyOwnersReadError(t *testing.T) { |
| + boom := errors.New("bucket unreachable") |
| + o, etag, err := ReadLegacyOwners(context.Background(), failingStore{Store: release.NewDirStore(t.TempDir()), err: boom}) |
| + if !errors.Is(err, boom) || o != nil || etag != "" { |
| + t.Fatalf("read error = %+v %q %v", o, etag, err) |
| + } |
| + var nilOwners *LegacyOwners |
| + if _, ok := nilOwners.Lookup("shop"); ok { |
| + t.Fatal("a nil set resolved a name") |
| + } |
| +} |
| + |
| +type failingStore struct { |
| + release.Store |
| + err error |
| +} |
| + |
| +func (f failingStore) Get(context.Context, string) ([]byte, string, error) { |
| + return nil, "", f.err |
| +} |