package inventory import ( "context" "encoding/json" "fmt" "github.com/novox/mesh-controller/internal/catalogue" ) // The seats the mesh has, as data (novox/hq ADR 0122). // // The set the control plane reads is a table here, not a slice compiled into it. It is seeded from // the binary's defaults the first time the mesh comes up (SeedSeats), and thereafter it is the live // copy: a rename or an added seat is a write here, and the control plane loads it at startup rather // than being rebuilt for it. // Seats is every seat the mesh defines, read from the store. func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) { rows, err := i.store.Pool().Query(ctx, `select name, scope, delivers, decided, accepts, emits, serves from seat order by name`) if err != nil { return nil, err } defer rows.Close() var seats []catalogue.Seat for rows.Next() { var s catalogue.Seat var accepts, emits, serves []byte if err := rows.Scan(&s.Name, &s.Scope, &s.Delivers, &s.Decision, &accepts, &emits, &serves); err != nil { return nil, err } // The protocol, from the row (novox/hq ADR 0132). A row that predates the columns has empty // lists, and UseSeats keeps the compiled protocol for it until the next seeding fills them. if err := json.Unmarshal(accepts, &s.Accepts); err != nil { return nil, fmt.Errorf("seat %s: accepts: %w", s.Name, err) } if err := json.Unmarshal(emits, &s.Emits); err != nil { return nil, fmt.Errorf("seat %s: emits: %w", s.Name, err) } if err := json.Unmarshal(serves, &s.Serves); err != nil { return nil, fmt.Errorf("seat %s: serves: %w", s.Name, err) } seats = append(seats, s) } return seats, rows.Err() } // SeedSeats writes the mesh's default set into the table where it is not already present. // // **Idempotent, and never overwriting.** Run every time the control plane migrates, it fills an // empty table on first boot and adds a seat a new release ships — but it leaves a row already there // exactly as it is, so an operator's rename in the table is not undone by the next deploy putting // the old name back. What a release removes from the defaults is not deleted here either; retiring a // seat is its own decision, not a silent consequence of it dropping out of the binary. // // **The protocol is seeded additively** (novox/hq ADR 0132, design 33 §7). A row that has none takes // the compiled protocol whole — that is the compiled fallback becoming data, once. A row that has one // gains any verb the defaults name and it lacks, and loses nothing: a seat's tools are an interface, // additive within a version, and a verb an operator added to the row is theirs to keep. func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (int, error) { var added int for _, s := range defaults { accepts, emits, serves, err := protocolJSON(s) if err != nil { return added, err } tag, err := i.store.Pool().Exec(ctx, `insert into seat (name, scope, delivers, decided, accepts, emits, serves) values ($1, $2, $3, $4, $5, $6, $7) on conflict (name) do nothing`, s.Name, s.Scope, s.Delivers, s.Decision, accepts, emits, serves) if err != nil { return added, err } if n := int(tag.RowsAffected()); n > 0 { added += n continue } if err := i.widenProtocol(ctx, s); err != nil { return added, err } } return added, nil } // widenProtocol adds to a seat's row whatever the defaults name and the row lacks, by verb name. func (i *Inventory) widenProtocol(ctx context.Context, s catalogue.Seat) error { var accepts, emits, serves []byte if err := i.store.Pool().QueryRow(ctx, `select accepts, emits, serves from seat where name = $1`, s.Name).Scan(&accepts, &emits, &serves); err != nil { return err } var row catalogue.Seat if err := json.Unmarshal(accepts, &row.Accepts); err != nil { return err } if err := json.Unmarshal(emits, &row.Emits); err != nil { return err } if err := json.Unmarshal(serves, &row.Serves); err != nil { return err } changed := false row.Accepts, changed = union(row.Accepts, s.Accepts, changed) row.Emits, changed = union(row.Emits, s.Emits, changed) have := map[string]bool{} for _, v := range row.Serves { have[v.Name] = true } for _, v := range s.Serves { if !have[v.Name] { row.Serves = append(row.Serves, v) changed = true } } if !changed { return nil } a, e, sv, err := protocolJSON(row) if err != nil { return err } _, err = i.store.Pool().Exec(ctx, `update seat set accepts = $2, emits = $3, serves = $4 where name = $1`, s.Name, a, e, sv) return err } func union(have, want []string, changed bool) ([]string, bool) { seen := map[string]bool{} for _, h := range have { seen[h] = true } for _, w := range want { if !seen[w] { have = append(have, w) seen[w] = true changed = true } } return have, changed } func protocolJSON(s catalogue.Seat) (accepts, emits, serves []byte, err error) { if accepts, err = json.Marshal(orEmpty(s.Accepts)); err != nil { return } if emits, err = json.Marshal(orEmpty(s.Emits)); err != nil { return } verbs := s.Serves if verbs == nil { verbs = []catalogue.Verb{} } serves, err = json.Marshal(verbs) return } func orEmpty(s []string) []string { if s == nil { return []string{} } return s } // Aliases is every former seat name and the seat it now resolves to (novox/hq ADR 0122). func (i *Inventory) Aliases(ctx context.Context) (map[string]string, error) { rows, err := i.store.Pool().Query(ctx, `select alias, seat from seat_alias`) if err != nil { return nil, err } defer rows.Close() aliases := map[string]string{} for rows.Next() { var alias, seat string if err := rows.Scan(&alias, &seat); err != nil { return nil, err } aliases[alias] = seat } return aliases, rows.Err() } // RenameSeat gives a seat a new name and keeps the old one as an alias (novox/hq ADR 0122). // // **This is the whole of a rename.** The seat's canonical name becomes `to`; `from` is remembered as // an alias so every reference to it — a manifest's claim, a held record, the build machine's // embedded set — goes on resolving to the same seat, unchanged. Nothing is rebuilt and nothing // freezes. Any alias that pointed to `from` is repointed to `to`, so a chain of renames does not // leave an older name resolving to a name that no longer exists. func (i *Inventory) RenameSeat(ctx context.Context, from, to string) error { if from == to { return fmt.Errorf("a seat is renamed to a different name; %q is already its name", to) } tag, err := i.store.Pool().Exec(ctx, `update seat set name = $1 where name = $2`, to, from) if err != nil { return err } if tag.RowsAffected() == 0 { return fmt.Errorf("no seat named %q to rename", from) } // The old name resolves to the new one; and any name that resolved to the old one now resolves // to the new one, so no alias is left pointing at a name that is gone. if _, err := i.store.Pool().Exec(ctx, `insert into seat_alias (alias, seat) values ($1, $2) on conflict (alias) do update set seat = excluded.seat`, from, to); err != nil { return err } if _, err := i.store.Pool().Exec(ctx, `update seat_alias set seat = $1 where seat = $2`, to, from); err != nil { return err } return nil } // HoldSeat records that one assignment holds a seat, replacing whoever held it — as one write, so // the seat is never without a holder in between (novox/hq ADR 0131, design 28 task 5.3). The // assignment must exist; the store refuses otherwise, and that refusal is the right one: a seat // cannot be handed to something that is not running anywhere. func (i *Inventory) HoldSeat(ctx context.Context, seat, scope, nodeName, module string) error { node, err := i.NodeByName(ctx, nodeName) if err != nil { return err } _, err = i.store.Pool().Exec(ctx, `insert into seat_holding (seat, scope, node, module) values ($1, $2, $3, $4) on conflict (seat) do update set scope = excluded.scope, node = excluded.node, module = excluded.module, since = now()`, seat, scope, node.ID, module) if err != nil { return fmt.Errorf("recording %s on %s as the holder of %s: %w", module, nodeName, seat, err) } return nil } // Holdings is every seat whose holder is on record, as the resolver reads it. A seat with no row here // is held by derivation, exactly as before the table existed. func (i *Inventory) Holdings(ctx context.Context) ([]catalogue.Held, error) { rows, err := i.store.Pool().Query(ctx, `select h.seat, h.scope, n.name, h.module, coalesce(n.site, '') from seat_holding h join node n on n.id = h.node order by h.seat`) if err != nil { return nil, err } defer rows.Close() var out []catalogue.Held for rows.Next() { var h catalogue.Held if err := rows.Scan(&h.Claim, &h.Scope, &h.Node, &h.Module, &h.Site); err != nil { return nil, err } out = append(out, h) } return out, rows.Err() }