diff --git a/modules/claude-licence-manager/README.md b/modules/claude-licence-manager/README.md index 6f7a2a6cb..9217ac3c9 100644 --- a/modules/claude-licence-manager/README.md +++ b/modules/claude-licence-manager/README.md @@ -6,8 +6,8 @@ ADR 0206, design 39 and its milestone bus-and-switch). Every grant and access to keeps none. It runs as an account of its own (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), in a runtime of its -own, never in the machine's tool runner: its service part (the carry-over at start, the usage timer, the switch rule, -the conditions) and its tools beside it, so every state, log and secret below is issued to its own account. Its +own, never in the machine's tool runner: its service part (the conditions judged at its start, the usage timer, the switch +rule) and its tools beside it, so every state, log and secret below is issued to its own account. Its account is issued what its manifest names: the asks under `invokes` (the vault, the operator channel, mesh-config and a node's `claude_code_grant`), the events under `emits` and `consumes`. @@ -15,7 +15,7 @@ a node's `claude_code_grant`), the events under `emits` and `consumes`. Nothing is configured. Every node running `claude-code` says what it holds as the event `claude-code.holdings-changed` (the account, fingerprints and expiries, never a token), at its start, on every change, -and again when this module says `holdings.asked` (at every start, after the carry-over, and on `licences_resync`): +and again when this module says `holdings.asked` (at every start, after its conditions are judged, and on `licences_resync`): 1. A report with a refresh token it does not hold is a **candidate**. 2. It asks that node's `claude_code_grant`, giving its public key, and receives the grant sealed to it. @@ -43,14 +43,12 @@ form the runtime issues today (design 39 bus-and-switch C3); each read by `licen | `offered` | state, a lifetime per key | each grant a node offered and its outcome, 90 days | | `usage` | log, 35 days, 256 MiB | every reading, one subject per licence key, with the vendor's answer whole | | `audit` | log, 64 MiB | every act, one subject per kind (`switched`, `carried`, `settings` among them) | -| `carried` | state | `postgres`: when the carry-over ran, and per table the rows read and entries written | +| `carried` | state | `postgres`: the record of the carry-over from the store database, which ran once on 2026-11-12; nothing writes it again | | `conditions` | state | each condition said raised, and since when | -Its database (`postgres-database`, read with the module's own credential) and `grant.key` are kept only as the -carry-over's source: at its first start, before it hears an event, refreshes or reads usage, the manager reads every -row of today's six tables and writes them here and to the vault, then `carried.postgres`. Until then it serves its -reads alone and answers every act with "the carry-over has not ended"; a failed read or write raises -`carry-over-failed`. With `carried.postgres` present it never reads the database again. The database is never written. +The carry-over from the store database is done, and its code has left the module with its requirement of the +database and of the secret `grant-key` (design 39 parts §9, §10); at its start the manager waits for nothing and +clears a `carry-over-failed` it finds kept. ## Switching on usage @@ -63,7 +61,7 @@ with the most room whose weekly window is below `room_below_pct`: one `binding.c Its conditions are said on the operator channel's condition subject, the router deciding where they go: `licence..no-room` (urgent; no licence has room), `licence..near-limit` (at `warn_at_pct`, once per -crossing), `licence..usage-unread`, `carry-over-failed` and `settings..invalid`. +crossing), `licence..usage-unread` and `settings..invalid`. ## The seat's verbs @@ -76,8 +74,7 @@ health, never the token) — through the console as `anthropic-licence-manager.< `licences_switches` (every switch and why), `licences_read_usage` (read now, `dry`), `licences_switch_now` (apply the rule now, even with `switch_auto` off), `licences_settings` (each value, its default and where it was set), `licences_kept` (every entry it keeps, how full against its cap), `licences_resync` (say every binding, holdings.asked -and each condition again, and hand each access token to the vault again), `licences_conditions`, `licences_carried` -(the carry-over's record; `run` while it has not ended). +and each condition again, and hand each access token to the vault again), and `licences_conditions`. ## Settings @@ -98,12 +95,11 @@ sealed box is `claude-code`'s own format, byte for byte — the two modules carr one sealed by the TypeScript agent module the Go one replaced, so the format is the one already on the machines. go test ./... - # the whole module on a real bus, and today's rows on a real postgres: + # the whole module on a real bus: go test -tags integration ./... The whole tests (`whole_integration_test.go`) start the built binary as its runtime does, its kept data in a real -nats-server, and today's database on `MESH_TEST_POSTGRES` or a private postgres started from the machine's `initdb` -on a socket of its own; with neither, the tests that need one skip and say so. The vault, the agent modules, the +nats-server. The vault, the agent modules, the operator channel and the vendor are played beside it; a condition is read through the operator channel's client. ## Files it does not know diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go b/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go deleted file mode 100644 index cf3199641..000000000 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go +++ /dev/null @@ -1,486 +0,0 @@ -package main - -// The carry-over (design 39 bus-and-switch §8, §11 to §13, A3, B14): at its first start the manager reads every row of -// today's database, the module's own and read only, and writes it into its kept data on the bus and its grants into -// the vault, then the key `carried.postgres`. Until that key exists it serves its read verbs alone and does nothing -// else. With the key present it never reads the database again. - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "sync" - "time" -) - -// CarriedKey is the key of the state `carried` the carry-over writes at its end (§8). -const CarriedKey = "postgres" - -// NotCarried is the answer of every acting verb and tool until the carry-over has ended (§11). -const NotCarried = "the carry-over has not ended" - -// usageKeptFor is how far back the carry-over reads usage: the log `usage` keeps no older reading (§6, §12). -const usageKeptFor = 35 * 24 * time.Hour - -// The tables of today's database, in the order the carry-over reads them (§11, §12). -var CarriedTables = []string{"licence", "binding", "lease", "offered", "usage", "audit"} - -// LicenceRow is a row of the table `licence`: the licence and its grant at rest, sealed with `grant.key`. -type LicenceRow struct { - Licence - Sealed string -} - -// OfferedRow is a row of the table `offered`. -type OfferedRow struct { - Fingerprint, Node, Account string - Outcome Outcome - Why string - At time.Time -} - -// UsageTableRow is a row of the table `usage`: the reading and the vendor's answer whole. -type UsageTableRow struct { - Licence string - At int64 // milliseconds - Reading UsageReading - Raw map[string]any -} - -// AuditRow is a row of the table `audit`. -type AuditRow struct { - At time.Time - What string - Detail map[string]any -} - -// CarrySource reads the six tables of today's database, each oldest first; a table that does not exist is no rows. -type CarrySource interface { - Licences(ctx context.Context) ([]LicenceRow, error) - Bindings(ctx context.Context) ([]Binding, error) - Leases(ctx context.Context) ([]LeaseRow, error) - Offered(ctx context.Context) ([]OfferedRow, error) - Usage(ctx context.Context, since time.Time) ([]UsageTableRow, error) - Audit(ctx context.Context) ([]AuditRow, error) -} - -// LeaseRow is one row of today's table `lease`: its key (`licence:` or `account:`), holder and until when. -type LeaseRow struct { - Key string - Holder string - Until time.Time -} - -// CarriedTable is one table in `carried.postgres`: the rows read and the entries written for them. -type CarriedTable struct { - Read int `json:"read"` - Written int `json:"written"` -} - -// CarriedRecord is the value of `carried.postgres` (§8): when, and for each table the rows read and entries written. -type CarriedRecord struct { - At string `json:"at"` - Tables map[string]CarriedTable `json:"tables"` -} - -// Carry is the carry-over's gate: closed until `carried.postgres` exists, and the failure of its last run. -type Carry struct { - State keptState // the state `carried` - // Source opens today's database; called only while the key is absent. - Source func(ctx context.Context) (CarrySource, error) - - mu sync.Mutex - running bool - failure string - done chan struct{} - once sync.Once -} - -func (c *Carry) init() { - c.mu.Lock() - if c.done == nil { - c.done = make(chan struct{}) - } - c.mu.Unlock() -} - -// Ended is whether the carry-over has ended: the key `carried.postgres` exists. -func (c *Carry) Ended() bool { - if c == nil { - return true // a manager built with no carry-over to wait on: the tests' own - } - c.init() - select { - case <-c.done: - return true - default: - } - if e, err := c.State.Get(CarriedKey); err == nil && e != nil && len(e.Value) > 0 { - c.end() - return true - } - return false -} - -func (c *Carry) end() { c.once.Do(func() { close(c.done) }) } - -// Wait is closed once the carry-over has ended. -func (c *Carry) Wait() <-chan struct{} { - c.init() - return c.done -} - -// Acts answers an acting verb or tool: nothing until the carry-over has ended (§11). -func (c *Carry) Acts() error { - if c.Ended() { - return nil - } - return errors.New(NotCarried) -} - -// Failure is what the last run that failed names, empty when none failed. -func (c *Carry) Failure() string { - c.mu.Lock() - defer c.mu.Unlock() - return c.failure -} - -// Run carries over every row when `carried.postgres` is absent, and says nothing else happened when it is present -// (§13). A run that fails names its table and error, and leaves the key absent, so the next is done again whole. -func (c *Carry) Run(ctx context.Context, m *Manager, s *BusStore) (*CarriedRecord, error) { - if c.Ended() { - return nil, nil - } - c.mu.Lock() - if c.running { - c.mu.Unlock() - return nil, errors.New("a carry-over is running") - } - c.running = true - c.mu.Unlock() - rec, err := c.run(ctx, m, s) - c.mu.Lock() - c.running = false - if err != nil { - c.failure = err.Error() - } else { - c.failure = "" - } - c.mu.Unlock() - if err != nil { - m.Log("the carry-over failed: %v", err) - return nil, err - } - c.end() - m.Log("carried over today's database: %v", rec.Tables) - return rec, nil -} - -func (c *Carry) run(ctx context.Context, m *Manager, s *BusStore) (*CarriedRecord, error) { - src, err := c.Source(ctx) - if err != nil { - return nil, fmt.Errorf("the database cannot be opened: %w", err) - } - if closer, ok := src.(interface{ Close() }); ok { - defer closer.Close() - } - co := carryOver{m: m, s: s, src: src, now: m.Now(), tables: map[string]CarriedTable{}} - if err := co.all(ctx); err != nil { - return nil, err - } - rec := CarriedRecord{At: co.now.UTC().Format(time.RFC3339), Tables: co.tables} - detail := map[string]any{"why": "the first start of the manager on the bus", "by": Itself, "tables": rec.Tables} - if err := s.Audit(ctx, "carried", detail); err != nil { - return nil, fmt.Errorf("carried: %w", err) - } - if _, err := c.State.Put(CarriedKey, rec); err != nil { - return nil, fmt.Errorf("carried: the key %s was not written: %w", CarriedKey, err) - } - return &rec, nil -} - -// carryOver is one run of the carry-over. -type carryOver struct { - m *Manager - s *BusStore - src CarrySource - now time.Time - tables map[string]CarriedTable - audit []AuditRow -} - -func (co *carryOver) all(ctx context.Context) error { - // The audit is read first: a licence's adoptedFrom comes from it (B14). - audit, err := co.src.Audit(ctx) - if err != nil { - return fmt.Errorf("audit: %w", err) - } - co.audit = audit - for _, table := range CarriedTables { - var t CarriedTable - var err error - switch table { - case "licence": - t, err = co.licences(ctx) - case "binding": - t, err = co.bindings(ctx) - case "lease": - t, err = co.leases(ctx) - case "offered": - t, err = co.offered(ctx) - case "usage": - t, err = co.usage(ctx) - case "audit": - t, err = co.audits() - } - if err != nil { - return fmt.Errorf("%s: %w", table, err) - } - co.tables[table] = t - } - return nil -} - -// adoptedFrom is the node the newest adoption of a licence names in the audit (B14). -func (co *carryOver) adoptedFrom(name string) string { - from := "" - for _, r := range co.audit { - if r.What != "adopted" || r.Detail["licence"] != name { - continue - } - if n, _ := r.Detail["node"].(string); n != "" { - from = n - } else if f, _ := r.Detail["from"].(string); f != "" { - from = f - } - } - return from -} - -// licences writes a key of `licences` per row, its grant opened with `grant.key` and handed to the vault at -// generation 1, the grant first (§12, §15). -func (co *carryOver) licences(ctx context.Context) (CarriedTable, error) { - rows, err := co.src.Licences(ctx) - if err != nil { - return CarriedTable{}, err - } - t := CarriedTable{Read: len(rows)} - for _, r := range rows { - l := r.Licence - l.Generation, l.Usage = 0, nil - l.AdoptedFrom = co.adoptedFrom(l.Name) - if r.Sealed != "" { - if co.m.Crypt == nil { - return t, fmt.Errorf("the grant of %s cannot be opened: no grant.key", l.Name) - } - plain, err := co.m.Crypt.Open(r.Sealed) - if err != nil { - return t, fmt.Errorf("the grant of %s: %w", l.Name, err) - } - grant, access := plain, plain - if l.Kind == "subscription" { - var g FullGrant - if err := json.Unmarshal([]byte(plain), &g); err != nil { - return t, fmt.Errorf("the grant of %s is not a grant", l.Name) - } - access = accessOf(g) - } - if err := co.m.handOver(ctx, &l, grant, access); err != nil { - return t, err - } - } - if err := co.s.SaveLicence(ctx, l); err != nil { - return t, err - } - t.Written++ - } - return t, nil -} - -// bindings writes a key of `bindings` per row, its generation kept, why "carried over", by the manager, since the -// carry-over's time (§12, B14); a key a run before this one wrote for the same row keeps its since, so it is put again -// with the same value (§13). -func (co *carryOver) bindings(ctx context.Context) (CarriedTable, error) { - rows, err := co.src.Bindings(ctx) - if err != nil { - return CarriedTable{}, err - } - kinds := map[string]string{} - if all, err := co.s.Licences(ctx); err == nil { - for _, l := range all { - kinds[l.Name] = l.Kind - } - } else { - return CarriedTable{}, err - } - t := CarriedTable{Read: len(rows)} - for _, r := range rows { - v := BindingState{Licence: r.Licence, Kind: kinds[r.Licence], Generation: r.Generation, - Since: co.now.UTC().Format(time.RFC3339), Why: "carried over", By: Itself} - var held BindingState - ok, err := co.s.get(co.s.BindingsState, r.Consumer, &held) - if err != nil { - return t, err - } - if ok && held.Licence == v.Licence && held.Generation == v.Generation && held.Why == v.Why { - v.Since = held.Since - } - if _, err := co.s.BindingsState.Put(r.Consumer, v); err != nil { - return t, err - } - // Said after its key is written, as every binding's change is (§21), so a reader's first view taken during the - // carry-over is brought up to date by the event. - from := "" - if ok && held.Licence != v.Licence { - from = held.Licence - } - co.m.changed(Binding{Consumer: r.Consumer, Licence: v.Licence, Kind: v.Kind, Generation: v.Generation, Why: v.Why, By: v.By}, from) - t.Written++ - } - return t, nil -} - -// leases writes a key of `leases` per lease still live at the run, with its remaining lifetime, so the manager never -// refreshes a licence the bundle before it holds a lease on, whatever the delivery's order; an expired one is counted -// read and not written (B44). Its key takes the bus's form: `licence.` or `account.` (§4). -func (co *carryOver) leases(ctx context.Context) (CarriedTable, error) { - rows, err := co.src.Leases(ctx) - if err != nil { - return CarriedTable{}, err - } - t := CarriedTable{Read: len(rows)} - for _, r := range rows { - left := r.Until.Sub(co.now) - if left <= 0 { - continue - } - key := "" - if name, ok := strings.CutPrefix(r.Key, "licence:"); ok { - key = "licence." + KeyFor(name) - } else if account, ok := strings.CutPrefix(r.Key, "account:"); ok { - key = "account." + account - } else { - co.m.Log("the lease %s names neither a licence nor an account; it is not carried", r.Key) - continue - } - v := leaseValue{Holder: r.Holder, Until: r.Until.UTC().Format(time.RFC3339)} - if _, err := co.s.LeasesState.PutTTL(key, v, left); err != nil { - return t, err - } - t.Written++ - } - return t, nil -} - -// offered writes a key of `offered` per row, its lifetime counted from the row's `at`; a row past its lifetime is -// gone already and written nowhere (§5, §12). -func (co *carryOver) offered(ctx context.Context) (CarriedTable, error) { - rows, err := co.src.Offered(ctx) - if err != nil { - return CarriedTable{}, err - } - t := CarriedTable{Read: len(rows)} - for _, r := range rows { - left := offeredFor - co.now.Sub(r.At) - if left <= 0 { - continue - } - v := offeredValue{Node: r.Node, Account: r.Account, Outcome: r.Outcome, Why: r.Why, - At: r.At.UTC().Format(time.RFC3339)} - if err := co.s.putOffered(r.Fingerprint, v, left); err != nil { - return t, err - } - t.Written++ - } - return t, nil -} - -// lastAt is the `at` of a log's last entry for a subject, zero when it has none: rows at or before it are in the log -// already (§13). -func lastAt(l keptLog, key string) (time.Time, error) { - e, err := l.Last(key) - if err != nil || e == nil { - return time.Time{}, err - } - var v struct { - At string `json:"at"` - } - if json.Unmarshal(e.Value, &v) != nil { - return time.Time{}, nil - } - at, _ := time.Parse(time.RFC3339Nano, v.At) - return at, nil -} - -// usage appends one entry of the log `usage` per row of the last 35 days, oldest first, its `at` kept (§12). -func (co *carryOver) usage(ctx context.Context) (CarriedTable, error) { - rows, err := co.src.Usage(ctx, co.now.Add(-usageKeptFor)) - if err != nil { - return CarriedTable{}, err - } - t := CarriedTable{Read: len(rows)} - last := map[string]time.Time{} - for _, r := range rows { - key := KeyFor(r.Licence) - before, seen := last[key] - if !seen { - if before, err = lastAt(co.s.UsageLog, key); err != nil { - return t, err - } - last[key] = before - } - at := time.UnixMilli(r.At).UTC() - if !at.After(before) { - t.Written++ // in the log already, from a run before this one - continue - } - if _, err := co.s.UsageLog.Append(key, UsageEntry{At: at.Format(time.RFC3339Nano), Reading: r.Reading, - Raw: r.Raw}); err != nil { - return t, err - } - t.Written++ - } - return t, nil -} - -// audits appends one entry of the log `audit` per row, oldest first, its `at` kept, under the subject its `what` -// names, `drift` for any other (§7, §12). -func (co *carryOver) audits() (CarriedTable, error) { - t := CarriedTable{Read: len(co.audit)} - last := map[string]time.Time{} - for _, r := range co.audit { - kind := "drift" - for _, k := range AuditKinds { - if k == r.What { - kind = k - } - } - before, seen := last[kind] - if !seen { - var err error - if before, err = lastAt(co.s.AuditLog, kind); err != nil { - return t, err - } - last[kind] = before - } - if !r.At.After(before) { - t.Written++ // in the log already, from a run before this one - continue - } - entry := map[string]any{} - for k, v := range r.Detail { - entry[k] = v - } - entry["at"] = r.At.UTC().Format(time.RFC3339Nano) - if kind != r.What { - entry["what"] = r.What - } - if _, err := co.s.AuditLog.Append(kind, entry); err != nil { - return t, fmt.Errorf("the audit did not take the carried %s entry: %w", r.What, err) - } - t.Written++ - } - return t, nil -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go deleted file mode 100644 index 9c7782424..000000000 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go +++ /dev/null @@ -1,314 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "regexp" - "strings" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgconn" -) - -// memSource is today's database as the carry-over reads it, from rows given; failAt makes one table's read fail. -type memSource struct { - licences []LicenceRow - bindings []Binding - leases []LeaseRow - offered []OfferedRow - usage []UsageTableRow - audit []AuditRow - failAt string -} - -func (s *memSource) fail(table string) error { - if s.failAt == table { - return errors.New("connection reset by peer") - } - return nil -} -func (s *memSource) Licences(context.Context) ([]LicenceRow, error) { - return s.licences, s.fail("licence") -} -func (s *memSource) Bindings(context.Context) ([]Binding, error) { - return s.bindings, s.fail("binding") -} -func (s *memSource) Leases(context.Context) ([]LeaseRow, error) { return s.leases, s.fail("lease") } -func (s *memSource) Offered(context.Context) ([]OfferedRow, error) { - return s.offered, s.fail("offered") -} -func (s *memSource) Audit(context.Context) ([]AuditRow, error) { return s.audit, s.fail("audit") } -func (s *memSource) Usage(_ context.Context, since time.Time) ([]UsageTableRow, error) { - var out []UsageTableRow - for _, u := range s.usage { - if u.At >= since.UnixMilli() { - out = append(out, u) - } - } - return out, s.fail("usage") -} - -// todaysRows is a copy of the shape of today's rows: a subscription and an API key, two bindings, a lease, two offers, -// three readings (one older than 35 days) and four audit rows (one of a kind the log does not name). -func todaysRows(t *testing.T, mm *miniMesh) *memSource { - t.Helper() - grant, _ := json.Marshal(FullGrant{AccessToken: "at-old", RefreshToken: "rt-old", ExpiresAt: 7}) - pct := 42.0 - return &memSource{ - licences: []LicenceRow{ - {Licence: Licence{Name: licence1, Kind: "subscription", AccountUUID: "acct-1", Email: licence1, - RefreshFingerprint: Fingerprint("rt-old"), AccessExpiresAt: 7, AdoptedAt: 1}, Sealed: sealAtRest(t, mm.m.Crypt, string(grant))}, - {Licence: Licence{Name: "api", Kind: "api-key", AdoptedAt: 2}, Sealed: sealAtRest(t, mm.m.Crypt, "sk-ant-api03-x")}, - }, - bindings: []Binding{{Consumer: "laptop", Licence: licence1, Generation: 1013}, {Consumer: "server", Licence: "api", Generation: 1016}}, - leases: []LeaseRow{{Key: "licence:x", Holder: "novox:1", Until: t0.Add(-time.Minute)}}, - offered: []OfferedRow{{Fingerprint: "sha256:aa", Node: "laptop", Account: "acct-1", Outcome: Adopted, Why: "refreshed", At: t0.Add(-24 * time.Hour)}, - {Fingerprint: "sha256:bb", Node: "server", Outcome: Dead, Why: "400", At: t0.Add(-48 * time.Hour)}}, - usage: []UsageTableRow{ - {Licence: licence1, At: t0.Add(-40 * 24 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}}, - {Licence: licence1, At: t0.Add(-2 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}, Raw: map[string]any{"five_hour": 1.0}}, - {Licence: licence1, At: t0.Add(-1 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}}, - }, - audit: []AuditRow{ - {At: t0.Add(-72 * time.Hour), What: "adopted", Detail: map[string]any{"licence": licence1, "node": "laptop"}}, - {At: t0.Add(-71 * time.Hour), What: "bound", Detail: map[string]any{"consumer": "laptop"}}, - {At: t0.Add(-70 * time.Hour), What: "switched", Detail: map[string]any{"consumer": "server"}}, - {At: t0.Add(-69 * time.Hour), What: "grant-moved", Detail: map[string]any{"licence": "api"}}, - }, - } -} - -func carryFor(mm *miniMesh, carried *memState, src CarrySource) *Carry { - return &Carry{State: carried, Source: func(context.Context) (CarrySource, error) { return src, nil }} -} - -func readCarried(t *testing.T, carried *memState) CarriedRecord { - t.Helper() - var rec CarriedRecord - e, _ := carried.Get(CarriedKey) - if e == nil { - t.Fatal("carried.postgres is not written") - } - _ = json.Unmarshal(e.Value, &rec) - return rec -} - -// design 39 bus-and-switch §11/AC1, §12/AC1: every row is carried over and the key written; the counts in -// carried.postgres equal the rows read for every table but lease, and the vault answers each licence's grant. -func TestEveryRowIsCarriedOver(t *testing.T) { - mm := newMesh(t) - carried := newMemState(func() time.Time { return mm.now }) - src := todaysRows(t, mm) - c := carryFor(mm, carried, src) - if _, err := c.Run(context.Background(), mm.m, mm.store); err != nil { - t.Fatal(err) - } - rec := readCarried(t, carried) - want := map[string]int{"licence": 2, "binding": 2, "lease": 1, "offered": 2, "usage": 2, "audit": 4} - for table, n := range want { - got := rec.Tables[table] - if got.Read != n || (table != "lease" && got.Written != got.Read) { - t.Errorf("%s: read %d written %d, want %d read and as many written", table, got.Read, got.Written, n) - } - } - if g, err := mm.grants.Get(context.Background(), KeyFor(licence1), PartGrant); err != nil || !strings.Contains(g, "rt-old") { - t.Fatalf("the vault answers the grant %q, %v", g, err) - } - if a, _ := mm.grants.Get(context.Background(), KeyFor(licence1), PartAccess); strings.Contains(a, "rt-old") || !strings.Contains(a, "at-old") { - t.Fatalf("the access secret is %q", a) - } - if a, _ := mm.grants.Get(context.Background(), KeyFor("api"), PartAccess); a != "sk-ant-api03-x" { - t.Fatalf("the API key's access is %q", a) - } - l, _ := mm.store.Licence(context.Background(), licence1) - if l == nil || l.Generation != 1 || l.AdoptedFrom != "laptop" || l.AccountUUID != "acct-1" || l.AccessExpiresAt != 7 { - t.Fatalf("the licence is %+v", l) - } - if b := mm.bound("laptop"); b.Generation != 1013 || b.Why != "carried over" || b.By != Itself || b.Kind != "subscription" || b.Since == "" { - t.Fatalf("laptop's binding %+v", b) - } - if u := mm.offered.until[OfferedKey("sha256:aa")]; !u.Equal(t0.Add(-24 * time.Hour).Add(offeredFor)) { - t.Fatalf("the offer lives until %v", u) - } - if got := mm.audit.under("drift"); len(got) != 1 || got[0]["licence"] != "api" { - t.Fatalf("drift holds %v", got) - } - if got := mm.audit.under("adopted"); len(got) != 1 || got[0]["at"] != t0.Add(-72*time.Hour).Format(time.RFC3339Nano) { - t.Fatalf("adopted holds %v", got) - } - if got := mm.usage.under(KeyFor(licence1)); len(got) != 2 || got[0]["raw"] == nil { - t.Fatalf("usage holds %v", got) - } - if len(mm.audit.under("carried")) != 1 || !c.Ended() { - t.Fatal("the carry-over did not say it carried, or did not end") - } - // §21: each carried binding is said as binding.changed after its key is written. - said := map[string]bool{} - for _, e := range mm.changedSince(0) { - if e["why"] == "carried over" && e["key"] == KeyFor(fmt.Sprint(e["licence"])) { - said[fmt.Sprint(e["consumer"])] = true - } - } - if !said["laptop"] || !said["server"] || len(mm.changedSince(0)) != 2 { - t.Fatalf("the carry-over said %v", mm.changedSince(0)) - } -} - -// design 39 bus-and-switch §13/AC1: a second start, with carried.postgres present, never opens the database and -// writes nothing. -func TestASecondStartCarriesNothing(t *testing.T) { - mm := newMesh(t) - carried := newMemState(func() time.Time { return mm.now }) - if _, err := carryFor(mm, carried, todaysRows(t, mm)).Run(context.Background(), mm.m, mm.store); err != nil { - t.Fatal(err) - } - writes := carried.writes + mm.licences.writes + mm.bindings.writes + mm.offered.writes - entries := len(mm.audit.entries) + len(mm.usage.entries) - puts := len(mm.grants.puts) - opened := false - again := &Carry{State: carried, Source: func(context.Context) (CarrySource, error) { opened = true; return nil, errors.New("no") }} - if rec, err := again.Run(context.Background(), mm.m, mm.store); rec != nil || err != nil || opened { - t.Fatalf("a second start answered %v %v, opened the database: %v", rec, err, opened) - } - if carried.writes+mm.licences.writes+mm.bindings.writes+mm.offered.writes != writes || - len(mm.audit.entries)+len(mm.usage.entries) != entries || len(mm.grants.puts) != puts { - t.Fatal("a second start wrote") - } -} - -// design 39 bus-and-switch §13/AC2: a carry-over stopped after bindings leaves no key, names its table, and a start -// again carries over whole: every count equals the rows read, and no log entry is appended twice. -func TestAStoppedCarryOverIsDoneAgainWhole(t *testing.T) { - mm := newMesh(t) - carried := newMemState(func() time.Time { return mm.now }) - src := todaysRows(t, mm) - src.failAt = "offered" - c := carryFor(mm, carried, src) - if _, err := c.Run(context.Background(), mm.m, mm.store); err == nil || !strings.Contains(c.Failure(), "offered") { - t.Fatalf("a stopped carry-over answered %v, its failure %q", err, c.Failure()) - } - if e, _ := carried.Get(CarriedKey); e != nil || c.Ended() { - t.Fatal("a stopped carry-over wrote its key") - } - src.failAt = "" - since := mm.bound("laptop").Since - mm.now = mm.now.Add(time.Minute) - if _, err := c.Run(context.Background(), mm.m, mm.store); err != nil { - t.Fatal(err) - } - for table, got := range readCarried(t, carried).Tables { - if table != "lease" && got.Written != got.Read { - t.Errorf("%s: read %d written %d", table, got.Read, got.Written) - } - } - if b := mm.bound("laptop"); b.Since != since { - t.Fatalf("the binding put again changed its since from %s to %s", since, b.Since) - } - if n := len(mm.audit.under("")); n != 5 { - t.Fatalf("the audit holds %d entries, want the four rows and one carried", n) - } - // the run stopped before the logs; a second pass over a log already written appends nothing. - co := carryOver{m: mm.m, s: mm.store, src: src, now: mm.now, tables: map[string]CarriedTable{}, audit: src.audit} - if _, err := co.usage(context.Background()); err != nil { - t.Fatal(err) - } - if got, err := co.audits(); err != nil || got.Written != 4 || len(mm.audit.under("")) != 5 || len(mm.usage.under("")) != 2 { - t.Fatalf("a log written again appended: %+v %v, %d usage entries", got, err, len(mm.usage.under(""))) - } -} - -// design 39 bus-and-switch §11/AC2: with the database unreachable the carry-over fails naming it, every acting verb -// answers "the carry-over has not ended", a read verb is served, and no vendor call is made. -func TestAnUnreachableDatabaseActsNotAndCallsNoVendor(t *testing.T) { - mm := newMesh(t) - carried := newMemState(func() time.Time { return mm.now }) - mm.m.Carry = &Carry{State: carried, Source: func(context.Context) (CarrySource, error) { - return nil, errors.New("dial tcp 192.0.2.1:5432: connect: connection refused") - }} - if _, err := mm.m.Carry.Run(context.Background(), mm.m, mm.store); err == nil || !strings.Contains(mm.m.Carry.Failure(), "connection refused") { - t.Fatalf("the run answered %v, its failure %q", err, mm.m.Carry.Failure()) - } - built = mm.m - t.Cleanup(func() { built = nil }) - served := map[string]stdTool{} - for _, tool := range tools() { - served[strings.TrimPrefix(tool.Name, Seat+".")] = stdTool(tool.Run) - } - for _, name := range []string{"bind", "switch", "release", "refresh", "adopt"} { - if _, err := served[name](map[string]any{"consumer": "laptop", "licence": licence1, "name": "api", "file": "/none"}); err == nil || err.Error() != NotCarried { - t.Errorf("%s answered %v", name, err) - } - } - for _, name := range []string{"licences", "bindings", "usage", "public-key"} { - if _, err := served[name](map[string]any{}); err != nil { - t.Errorf("the read verb %s answered %v", name, err) - } - } - if len(mm.vendor.exchanged) != 0 { - t.Fatalf("the vendor was called: %v", mm.vendor.exchanged) - } -} - -type stdTool func(map[string]any) (any, error) - -// design 39 bus-and-switch §11/AC3: a table that does not exist (a fresh install) is read as no rows; any other -// failure of the database is one. -func TestAMissingTableIsNoRows(t *testing.T) { - if !noTable(&pgconn.PgError{Code: "42P01", Message: `relation "offered" does not exist`}) { - t.Fatal("a missing table is a failure") - } - for _, err := range []error{&pgconn.PgError{Code: "28P01"}, errors.New("connection refused"), nil} { - if noTable(err) { - t.Fatalf("%v is read as a missing table", err) - } - } -} - -// design 39 bus-and-switch §1/AC3: no code of the manager writes to its database. -func TestNoWriteToTheDatabase(t *testing.T) { - write := regexp.MustCompile(`(?i)\b(insert\s+into|update\s+\w+\s+set|delete\s+from|create\s+(table|sequence|index)|drop\s+(table|sequence|index)|alter\s+table|truncate\s|setval\s*\(|nextval\s*\()`) - files, _ := os.ReadDir(".") - for _, f := range files { - if !strings.HasSuffix(f.Name(), ".go") || strings.HasSuffix(f.Name(), "_test.go") { - continue - } - raw, err := os.ReadFile(f.Name()) - if err != nil { - t.Fatal(err) - } - if w := write.Find(raw); w != nil { - t.Errorf("%s writes to the database: %q", f.Name(), w) - } - } -} - -// design 39 bus-and-switch §12, B44: a lease still live at the run is carried with its remaining lifetime, and a -// refresh waits for it to end; an expired one is counted read and not written. -func TestALiveLeaseIsCarriedAndBlocksARefreshUntilItEnds(t *testing.T) { - mm := newMesh(t) - carried := newMemState(func() time.Time { return mm.now }) - src := todaysRows(t, mm) - src.leases = []LeaseRow{{Key: "licence:" + licence1, Holder: "the old bundle", Until: mm.now.Add(90 * time.Second)}, - {Key: "account:acct-1", Holder: "the old bundle", Until: mm.now.Add(-time.Second)}} - if _, err := carryFor(mm, carried, src).Run(context.Background(), mm.m, mm.store); err != nil { - t.Fatal(err) - } - if got := readCarried(t, carried).Tables["lease"]; got.Read != 2 || got.Written != 1 { - t.Fatalf("lease: read %d written %d, want 2 read and 1 written", got.Read, got.Written) - } - if e, _ := mm.leases.Get("account.acct-1"); e != nil { - t.Fatalf("an expired lease was carried: %s", e.Value) - } - r, err := mm.m.Rotate(context.Background(), licence1, true) - if err != nil || r["refreshed"] != false || r["reason"] != "another run holds its lease" { - t.Fatalf("a refresh under the carried lease answered %v, %v", r, err) - } - mm.now = mm.now.Add(91 * time.Second) - if r, err := mm.m.Rotate(context.Background(), licence1, true); err != nil || r["reason"] == "another run holds its lease" { - t.Fatalf("once the carried lease ended the refresh answered %v, %v", r, err) - } -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go index 4e53daaa0..c8d3d3796 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go @@ -22,9 +22,6 @@ import ( // Publisher is the name the manager says its conditions under, and the id of their subject (B17). const Publisher = "claude-licence-manager" -// CarryOverFailed is the condition of a carry-over that failed (§11). -const CarryOverFailed = "carry-over-failed" - // HeadlineLength is the operator channel's limit on a headline; a longer one is cut, ending in "…" (B39). const HeadlineLength = 60 @@ -91,15 +88,6 @@ func machines(n int) string { // Judge is every condition the manager holds now, judged anew from its own state and readings, by key. func (m *Manager) Judge(ctx context.Context) (map[string]oc.Condition, error) { held := map[string]oc.Condition{} - if m.Carry != nil && !m.Carry.Ended() { - if why := m.Carry.Failure(); why != "" { - held[CarryOverFailed] = condition(CarryOverFailed, occ.SeverityUrgent, - "The licence manager could not carry over its licences", - "the carry-over of today's database failed: "+why, - "the licence manager could not carry over its licences; no consumer is moved and no token is refreshed until it does", - "It failed: "+why+".", "The licence manager carried over its licences") - } - } s := m.settings() for _, o := range s.OutOfBound { bound := fmt.Sprintf("at least %g", o.Min) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go index 1fab35649..2ef22da9b 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go @@ -65,16 +65,6 @@ func TestAConditionIsKeptThenSaidOnce(t *testing.T) { _ = os.WriteFile(file, []byte(`{"switch_at_pct": 98}`), 0o600) mm.m.Settings = SettingsFrom(file) }}, - {"a carry-over that failed", CarryOverFailed, occ.SeverityUrgent, - func(mm *miniMesh) { - mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now }), - Source: func(context.Context) (CarrySource, error) { return nil, errors.New("connection refused") }} - _, _ = mm.m.Carry.Run(ctx, mm.m, mm.store) - }, - func(mm *miniMesh) { - mm.m.Carry.Source = func(context.Context) (CarrySource, error) { return &memSource{}, nil } - _, _ = mm.m.Carry.Run(ctx, mm.m, mm.store) - }}, } { mm := newMesh(t) kept, said := saying(mm) @@ -107,7 +97,7 @@ func TestAConditionIsKeptThenSaidOnce(t *testing.T) { } } -// design 39 bus-and-switch §9/AC2: at its start the manager says cleared each key of `conditions` it no longer holds, +// design 39 bus-and-switch §9/AC2, design 39 parts §11: at its start the manager says cleared each key of `conditions` it no longer holds, // and says raised again each it still holds, unchanged or not. func TestAStartClearsWhatItNoLongerHolds(t *testing.T) { ctx := context.Background() @@ -118,18 +108,25 @@ func TestAStartClearsWhatItNoLongerHolds(t *testing.T) { mm.m.Settings.WarnAt = 100 _ = mm.m.SayConditions(ctx, false) _, _ = kept.Put(gone.Key, KeptCondition{Body: gone, Since: "2026-11-11T20:00:00Z"}) + // design 39 parts §11: a carry-over-failed key kept from before the carry-over left is cleared like any other. + carry := condition("carry-over-failed", occ.SeverityUrgent, "The licence manager could not carry over its licences", + "s", "", "", "The licence manager carried over its licences") + _, _ = kept.Put(carry.Key, KeptCondition{Body: carry, Since: "2026-11-12T09:00:00Z"}) *said = nil if err := mm.m.SayConditions(ctx, true); err != nil { t.Fatal(err) } - if strings.Join(of(*said, gone.Key), ",") != "cleared" || strings.Join(of(*said, "settings.warn_at_pct.invalid"), ",") != "raised" { + if strings.Join(of(*said, gone.Key), ",") != "cleared" || strings.Join(of(*said, carry.Key), ",") != "cleared" || + strings.Join(of(*said, "settings.warn_at_pct.invalid"), ",") != "raised" { t.Fatalf("a start said %+v", *said) } - if (*said)[0].Resolved != gone.Resolved { - t.Fatalf("the clearing carries %+v", (*said)[0]) + if (*said)[0].Resolved != carry.Resolved || (*said)[1].Resolved != gone.Resolved { + t.Fatalf("the clearings carry %+v", *said) } - if e, _ := kept.Get(gone.Key); e != nil { - t.Fatal("a key no longer held is kept") + for _, key := range []string{gone.Key, carry.Key} { + if e, _ := kept.Get(key); e != nil { + t.Fatalf("%s, no longer held, is kept", key) + } } } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go b/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go deleted file mode 100644 index 2857e5f97..000000000 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go +++ /dev/null @@ -1,55 +0,0 @@ -package main - -// The grants at rest (novox/hq ADR 0183): encrypted with a key the vault made for this module, so the -// store holds ciphertext and only this module, reading its own secret, can open a row. AES-256-GCM, the -// key derived from the vault's secret by SHA-256 so a secret of any length serves. - -import ( - "crypto/sha256" - "encoding/base64" - "errors" - "os" - "strings" -) - -// Crypt opens what today's database keeps at rest. -type Crypt struct{ key []byte } - -// NewCrypt is the store's cipher from the vault's secret. -func NewCrypt(secret string) (*Crypt, error) { - secret = strings.TrimSpace(secret) - if secret == "" { - return nil, errors.New("the key the grants are encrypted with is empty: the vault has not delivered it yet") - } - sum := sha256.Sum256([]byte(secret)) - return &Crypt{key: sum[:]}, nil -} - -// CryptFromFile reads the vault's secret from the file the mesh delivered it to. -func CryptFromFile(path string) (*Crypt, error) { - raw, err := os.ReadFile(path) - if err != nil { - return nil, err - } - return NewCrypt(string(raw)) -} - -// Open decrypts a grant today's database keeps at rest, `v1..`: the carry-over's alone, since -// nothing seals a grant at rest any more (design 39 bus-and-switch §20, B15). -func (c *Crypt) Open(sealed string) (string, error) { - parts := strings.Split(sealed, ".") - if len(parts) != 3 || parts[0] != "v1" { - return "", errors.New("not a grant this module sealed") - } - iv, err1 := base64.StdEncoding.DecodeString(parts[1]) - ct, err2 := base64.StdEncoding.DecodeString(parts[2]) - if err := errors.Join(err1, err2); err != nil { - return "", err - } - gcm, _ := newGCM(c.key) - plain, err := gcm.Open(nil, iv, ct, nil) - if err != nil { - return "", errors.New("the grant does not open with this module's key") - } - return string(plain), nil -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/holdings.go b/modules/claude-licence-manager/cmd/claude-licence-manager/holdings.go index 1925b9613..27793e5b6 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/holdings.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/holdings.go @@ -2,7 +2,7 @@ package main // What every node holds, heard as events (design 39 bus-and-switch §23, §46, B12): the agent module says // `holdings-changed` with its holdings report whole, at its start, on every change and when asked; the manager says -// `holdings.asked` at every start, after its carry-over, and on its resync, and takes each report it hears as that +// `holdings.asked` at every start, after its conditions are judged, and on its resync, and takes each report it hears as that // node's latest. import ( diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index 1d34a8da9..48d5d0bd2 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -1,7 +1,7 @@ // claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by a runtime of its own under // its own account (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), never by the machine's tool runner, // and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). Its service part runs long (the -// carry-over at start, the usage timer, the switch rule, the conditions): it watches what every node reports holding +// conditions judged at its start, the usage timer, the switch rule): it watches what every node reports holding // and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage. Its tools part, the // seat's verbs and the module's tools, is served beside it and acts only through that one running manager. // @@ -45,20 +45,16 @@ var ( ) // manager builds the rules' dependencies once, from the module's words (ADR 0192): file paths, never -// values. A failure is said and tried again on the next call, so a database that arrives late is not fatal. +// values. A failure is said and tried again on the next call, so a key pair that cannot be made yet is not fatal. func manager() (*Manager, error) { buildMu.Lock() defer buildMu.Unlock() if built != nil { return built, nil } - dir, keyFile := os.Getenv("MESH_LICENCE_STATE"), os.Getenv("MESH_LICENCE_KEY_FILE") - if dir == "" || keyFile == "" { - return nil, errors.New("MESH_LICENCE_STATE and MESH_LICENCE_KEY_FILE are not set: the mesh renders them for this module") - } - crypt, err := CryptFromFile(keyFile) - if err != nil { - return nil, err + dir := os.Getenv("MESH_LICENCE_STATE") + if dir == "" { + return nil, errors.New("MESH_LICENCE_STATE is not set: the mesh renders it for this module") } keys, err := keypair(dir, say) if err != nil { @@ -72,14 +68,10 @@ func manager() (*Manager, error) { Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"), LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), UsageLog: stdio.Log("usage"), AuditLog: stdio.Log("audit"), Now: time.Now}, - Vendor: LiveVendor(), - Crypt: crypt, - // Today's database is the carry-over's source alone, read once and never written (§11, A3). - Carry: &Carry{State: stdio.State("carried"), Source: func(ctx context.Context) (CarrySource, error) { - return PgSourceFromEnv(ctx) - }}, - Grants: VaultGrants{Keys: keys}, - Keys: keys, + Vendor: LiveVendor(), + Carried: stdio.State("carried"), + Grants: VaultGrants{Keys: keys}, + Keys: keys, AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) { var a GrantAnswer raw, err := stdio.Ask("claude-code.claude_code_grant@"+node, map[string]any{"public_key": publicKey}) @@ -192,30 +184,23 @@ func writeKeyPair(dir, file string, replace bool) error { // heard is what every node last said it holds (ADR 0206, design 39 bus-and-switch §23), by node. var heard = &Heard{} -// carryOverFirst runs the carry-over at the start, before the manager hears an event, refreshes or reads usage (§11), and -// answers once it has ended: at once when `carried.postgres` exists, else when a run writes it, this one or one asked -// by hand later. -func carryOverFirst() { +// judgedAtStart judges every condition anew at the start, before the manager asks what the nodes hold and starts its +// timers: raised again each it holds, cleared each key of `conditions` it no longer holds, carry-over-failed among +// them when present (design 39 parts §11, bus-and-switch §9). It waits for nothing to be carried over. +func judgedAtStart() { <-stdio.Served() m, err := manager() if err != nil { - say("the carry-over cannot start: %v", err) + say("the conditions cannot be judged at the start: %v", err) return } - if !m.Carry.Ended() { - bus, _ := m.Store.(*BusStore) - _, _ = m.Carry.Run(context.Background(), m, bus) // a failure is said, and the run is asked again by hand (§44) - } - // At its start the manager judges every condition anew: raised again each it holds, cleared each it no longer - // holds, carry-over-failed among them (§9, §11). if err := m.SayConditions(context.Background(), true); err != nil { say("saying the conditions at the start: %v", err) } - <-m.Carry.Wait() } func daemon() { - carryOverFirst() + judgedAtStart() var passing sync.Mutex pass := func() { passing.Lock() // one pass at a time: each account is leased, and a pass is cheap @@ -236,7 +221,7 @@ func daemon() { // What every node holds (ADR 0206, design 39 bus-and-switch §23): each `holdings-changed` heard is that node's // latest report and starts a pass; once heard, every node is asked to say it again (B12: at every start, after the - // carry-over). Asked again until it answers — the channel to the runtime opens as the bundle starts. + // conditions are judged). Asked again until it answers — the channel to the runtime opens as the bundle starts. go func() { waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second} for attempt := 0; ; attempt++ { @@ -310,8 +295,7 @@ func text(a map[string]any, k string) (string, error) { } // verb is one of the seat's verbs: listed as `.`, so the runtime serves it on the seat's subject. -// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625). A verb -// that acts answers nothing but "the carry-over has not ended" until it has (§11). +// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625). func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool { return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: func(a map[string]any) (any, error) { @@ -319,19 +303,11 @@ func verb(name, description string, input map[string]any, run func(ctx context.C if err != nil { return nil, err } - if !reads[name] { - if err := m.Carry.Acts(); err != nil { - return nil, err - } - } ctx := WithCause(context.Background(), Cause{Why: "the " + name + " verb", By: stdio.CallerOf(a)}) return run(ctx, m, a) }} } -// reads are the seat's verbs that only read, served while the carry-over has not ended (§11). -var reads = map[string]bool{"licences": true, "bindings": true, "usage": true, "public-key": true, "current": true} - // sinceOf is the argument since, RFC 3339; absent is the zero time. func sinceOf(a map[string]any) (time.Time, error) { v, _ := a["since"].(string) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 81828085f..49ac2f3f4 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -137,10 +137,8 @@ type GrantAnswer struct { type Manager struct { Store Store Vendor Vendor - // Crypt opens the grants today's database keeps at rest, for the carry-over alone (design 39 bus-and-switch §12, A3). - Crypt *Crypt - // Carry is the carry-over's gate (§11): no act until it has ended. Nil is a manager with nothing to carry over. - Carry *Carry + // Carried is the state `carried`, the carry-over's record, which nothing writes again (design 39 parts §10). + Carried keptState // Grants is the vault, which holds every licence's grant and access token (§15, §16). Grants Grants Keys KeyPair diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index c94dbc3f1..bba2a463b 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -127,10 +127,9 @@ func newMesh(t *testing.T) *miniMesh { mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, LeasesState: mm.leases, OfferedState: mm.offered, UsageLog: mm.usage, AuditLog: mm.audit, Now: now} mm.vendor = &stubVendor{live: map[string]bool{}, now: now} - crypt, _ := NewCrypt("a key the vault made") mm.keys, _ = GenerateKeyPair() mm.m = &Manager{ - Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Grants: mm.grants, Keys: mm.keys, + Store: mm.store, Vendor: mm.vendor, Grants: mm.grants, Keys: mm.keys, AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) { if mm.askDown { return GrantAnswer{}, fmt.Errorf("503 no responders") @@ -278,7 +277,7 @@ func TestTwoRefreshRunsStartedTogetherRotateAGrantOnce(t *testing.T) { ctx := context.Background() _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) mm.now = t0.Add(5 * time.Hour) - second := &Manager{Store: mm.m.Store, Vendor: mm.m.Vendor, Crypt: mm.m.Crypt, Grants: mm.m.Grants, Keys: mm.m.Keys, + second := &Manager{Store: mm.m.Store, Vendor: mm.m.Vendor, Grants: mm.m.Grants, Keys: mm.m.Keys, AskGrant: mm.m.AskGrant, Emit: mm.m.Emit, Now: mm.m.Now, Log: mm.m.Log, Holder: "another run", Settings: mm.m.Settings} var wg sync.WaitGroup diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go index 3a63d0a65..4e05cfeb1 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" "testing" ) @@ -190,3 +191,80 @@ func TestTheManagerRunsAsItsOwnAccount(t *testing.T) { } } } + +// design 39 parts §9, §10: the carry-over has left the module whole. Its manifest requires neither the store database +// nor the secret grant-key, contributes and binds nothing and has no secrets section; no word of its code names the +// database's address or grant.key; its go.mod requires no postgres driver; the tool licences_carried is gone, and the +// state carried stays as the carry-over's record. +func TestTheCarryOverHasLeft(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, gone := range []string{"contributes", "binds", "secrets"} { + if _, ok := m[gone]; ok { + t.Errorf("the manifest keeps %q", gone) + } + } + var requires []json.RawMessage + _ = json.Unmarshal(m["requires"], &requires) + carried := false + for _, r := range requires { + var bare string + if json.Unmarshal(r, &bare) == nil { + t.Errorf("the manifest requires %q", bare) + } + var e struct{ Name string } + if json.Unmarshal(r, &e) == nil && e.Name == "carried" { + carried = true + } + } + if !carried { + t.Error("the state carried, the carry-over's record, is gone") + } + var tools []string + _ = json.Unmarshal(m["tools"], &tools) + if slices.Contains(tools, "licences_carried") { + t.Error("the manifest keeps the tool licences_carried") + } + var resources []struct{ ID string } + _ = json.Unmarshal(m["resources"], &resources) + for _, r := range resources { + if r.ID == "database-url" { + t.Error("the manifest keeps the resource database-url") + } + } + words := []string{"DATABASE_URL" + "_FILE", "MESH_LICENCE_KEY" + "_FILE"} + err = filepath.WalkDir(moduleDir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + switch filepath.Ext(path) { + case ".go", ".json", ".mod", ".sum": + default: + if filepath.Base(filepath.Dir(path)) != "files" { + return nil // a built binary is no code of the module + } + } + src, err := os.ReadFile(path) + if err != nil { + return err + } + for _, w := range words { + if strings.Contains(string(src), w) { + t.Errorf("%s names %s", path, w) + } + } + if filepath.Base(path) == "go.mod" && strings.Contains(string(src), "github.com/jackc/pgx") { + t.Errorf("%s requires the postgres driver", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go b/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go deleted file mode 100644 index 892986886..000000000 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go +++ /dev/null @@ -1,161 +0,0 @@ -package main - -// Today's database, read once by the carry-over and never written (design 39 bus-and-switch §1, §11, A3): the -// module's own, opened with the credential the mesh still provisions for it alone. Every query is a select; a table -// that does not exist (a fresh install) is read as no rows. - -import ( - "context" - "encoding/json" - "errors" - "os" - "strings" - "time" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgxpool" -) - -// PgSource reads today's database. -type PgSource struct{ pool *pgxpool.Pool } - -// PgSourceFromEnv opens the database the mesh provisioned, its URL in the file DATABASE_URL_FILE names. -func PgSourceFromEnv(ctx context.Context) (*PgSource, error) { - file := os.Getenv("DATABASE_URL_FILE") - if file == "" { - return nil, errors.New("DATABASE_URL_FILE is not set: the manager's database is a requirement the mesh resolves") - } - raw, err := os.ReadFile(file) - if err != nil { - return nil, err - } - return OpenPgSource(ctx, strings.TrimSpace(string(raw))) -} - -// OpenPgSource opens a database at a URL, and reaches it once, so an unreachable one fails here. -func OpenPgSource(ctx context.Context, url string) (*PgSource, error) { - pool, err := pgxpool.New(ctx, url) - if err != nil { - return nil, err - } - if err := pool.Ping(ctx); err != nil { - pool.Close() - return nil, err - } - return &PgSource{pool: pool}, nil -} - -// Close lets the database go. -func (s *PgSource) Close() { s.pool.Close() } - -// noTable is whether err is postgres saying a table does not exist (SQLSTATE 42P01). -func noTable(err error) bool { - var pg *pgconn.PgError - return errors.As(err, &pg) && pg.Code == "42P01" -} - -// each runs a select and scans each row; a table that does not exist is no rows. -func (s *PgSource) each(ctx context.Context, q string, scan func(pgx.Rows) error, args ...any) error { - rows, err := s.pool.Query(ctx, q, args...) - if err != nil { - if noTable(err) { - return nil - } - return err - } - defer rows.Close() - for rows.Next() { - if err := scan(rows); err != nil { - return err - } - } - if err := rows.Err(); err != nil && !noTable(err) { - return err - } - return nil -} - -func (s *PgSource) Licences(ctx context.Context) ([]LicenceRow, error) { - out := []LicenceRow{} - err := s.each(ctx, `select name, kind, coalesce(account_uuid,''), coalesce(email,''), coalesce(organization_uuid,''), - coalesce(sealed,''), coalesce(refresh_fingerprint,''), coalesce(access_expires_at,0), - coalesce(refresh_expires_at,0), failures, coalesce(notified_at,0), adopted_at, coalesce(rotated_at,0) - from licence order by adopted_at, name`, func(r pgx.Rows) error { - var l LicenceRow - err := r.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint, - &l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt) - out = append(out, l) - return err - }) - return out, err -} - -func (s *PgSource) Bindings(ctx context.Context) ([]Binding, error) { - out := []Binding{} - err := s.each(ctx, `select consumer, licence, generation from binding order by generation, consumer`, - func(r pgx.Rows) error { - var b Binding - err := r.Scan(&b.Consumer, &b.Licence, &b.Generation) - out = append(out, b) - return err - }) - return out, err -} - -func (s *PgSource) Leases(ctx context.Context) ([]LeaseRow, error) { - out := []LeaseRow{} - err := s.each(ctx, `select key, holder, until from lease order by key`, func(r pgx.Rows) error { - var l LeaseRow - err := r.Scan(&l.Key, &l.Holder, &l.Until) - out = append(out, l) - return err - }) - return out, err -} - -func (s *PgSource) Offered(ctx context.Context) ([]OfferedRow, error) { - out := []OfferedRow{} - err := s.each(ctx, `select fingerprint, node, coalesce(account_uuid,''), outcome, why, at from offered - order by at, fingerprint`, func(r pgx.Rows) error { - var o OfferedRow - var outcome string - err := r.Scan(&o.Fingerprint, &o.Node, &o.Account, &outcome, &o.Why, &o.At) - o.Outcome = Outcome(outcome) - out = append(out, o) - return err - }) - return out, err -} - -func (s *PgSource) Usage(ctx context.Context, since time.Time) ([]UsageTableRow, error) { - out := []UsageTableRow{} - err := s.each(ctx, `select licence, at, reading, raw from usage where at >= $1 order by at, licence`, - func(r pgx.Rows) error { - var u UsageTableRow - var reading, raw []byte - if err := r.Scan(&u.Licence, &u.At, &reading, &raw); err != nil { - return err - } - _ = json.Unmarshal(reading, &u.Reading) - _ = json.Unmarshal(raw, &u.Raw) - out = append(out, u) - return nil - }, since.UnixMilli()) - return out, err -} - -func (s *PgSource) Audit(ctx context.Context) ([]AuditRow, error) { - out := []AuditRow{} - err := s.each(ctx, `select at, what, detail from audit order by at`, func(r pgx.Rows) error { - var a AuditRow - var detail []byte - if err := r.Scan(&a.At, &a.What, &detail); err != nil { - return err - } - _ = json.Unmarshal(detail, &a.Detail) - out = append(out, a) - return nil - }) - return out, err -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go index 35f99deab..b26ff3628 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go @@ -1,11 +1,6 @@ package main -import ( - "crypto/rand" - "encoding/base64" - "strings" - "testing" -) +import "testing" func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) { prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1} @@ -22,35 +17,3 @@ func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) { t.Fatal("an answer with no access token was a grant") } } - -func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) { - a, _ := NewCrypt("one key") - b, _ := NewCrypt("another key") - sealed := sealAtRest(t, a, `{"refreshToken":"rt"}`) - // The plaintext's own words, not two letters a random ciphertext holds one time in about seventy. - if strings.Contains(sealed, "refreshToken") { - t.Fatal("stored in the clear") - } - if got, err := a.Open(sealed); err != nil || got != `{"refreshToken":"rt"}` { - t.Fatalf("%q %v", got, err) - } - if _, err := b.Open(sealed); err == nil { - t.Fatal("opened with another key") - } - if _, err := NewCrypt(" "); err == nil { - t.Fatal("an empty key was accepted") - } -} - -// sealAtRest seals a grant as today's database keeps it, `v1..`: the rows the carry-over reads. -func sealAtRest(t *testing.T, c *Crypt, plaintext string) string { - t.Helper() - gcm, err := newGCM(c.key) - if err != nil { - t.Fatal(err) - } - iv := make([]byte, 12) - _, _ = rand.Read(iv) - b64 := base64.StdEncoding.EncodeToString - return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil)) -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go b/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go index f7bbd4dea..adb198bab 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go @@ -1,8 +1,7 @@ package main // The module's own tools (design 39 bus-and-switch §37 to §44, B40): beside the seat's verbs, they read everything the -// manager keeps and start by hand everything it does by itself. A tool that acts answers "the carry-over has not ended" -// until it has (§11); licences_carried's run is the carry-over itself. +// manager keeps and start by hand everything it does by itself. import ( "context" @@ -17,19 +16,14 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" ) -// tool is one of the module's own tools; acts says whether it acts, so the carry-over's gate holds it (§11). -func tool(name, description string, acts bool, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool { +// tool is one of the module's own tools. +func tool(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool { return stdio.Tool{Name: name, Description: description, Input: input, Run: func(a map[string]any) (any, error) { m, err := manager() if err != nil { return nil, err } - if acts { - if err := m.Carry.Acts(); err != nil { - return nil, err - } - } ctx := WithCause(context.Background(), Cause{Why: "the " + name + " tool", By: stdio.CallerOf(a)}) return run(ctx, m, a) }} @@ -48,7 +42,7 @@ func moduleTools() []stdio.Tool { dry := boolean("answer what the rule would do, and do nothing") return []stdio.Tool{ tool("licences_switches", "Every switch of a consumer and why, newest first, from the audit: when, from and to which licence, the consumers, by whom and why, and for a switch by usage the reading that moved them.", - false, map[string]any{"licence": licence, "consumer": str("a consumer; absent for all"), + map[string]any{"licence": licence, "consumer": str("a consumer; absent for all"), "since": str("a time, RFC 3339: no switch before it"), "limit": number("how many (default 20)")}, func(ctx context.Context, m *Manager, a map[string]any) (any, error) { l, _ := a["licence"].(string) @@ -60,14 +54,14 @@ func moduleTools() []stdio.Tool { return m.Switches(strings.TrimSpace(l), strings.TrimSpace(c), since, limitOf(a, 20, 2000)) }), tool("licences_read_usage", "Read usage now, for one subscription or every one, as the manager does on its cadence; then apply the switch rule unless dry. Answers each reading and what the rule did or would do.", - true, map[string]any{"licence": licence, "dry": dry}, + map[string]any{"licence": licence, "dry": dry}, func(ctx context.Context, m *Manager, a map[string]any) (any, error) { l, _ := a["licence"].(string) d, _ := a["dry"].(bool) return m.ReadUsageOf(ctx, strings.TrimSpace(l), d) }), tool("licences_switch_now", "Apply the switch rule now from the latest readings, for one licence or all, even with switch_auto off since a person asked; with dry, answer the moves and make none.", - true, map[string]any{"licence": licence, "dry": dry}, + map[string]any{"licence": licence, "dry": dry}, func(ctx context.Context, m *Manager, a map[string]any) (any, error) { l, _ := a["licence"].(string) d, _ := a["dry"].(bool) @@ -78,11 +72,11 @@ func moduleTools() []stdio.Tool { return acts, err }), tool("licences_settings", "The settings in force: each one's value, its default, where it was set (mesh, node or the default, as mesh-config answers it) and why it is invalid when it is.", - false, nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { + nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.SettingsInForce(ctx) }), tool("licences_kept", "Every entry the manager keeps on the bus: a state's keys, or a log's subjects with their last entry; with key, that key's value or that subject's last entry; and how full each is against its cap.", - false, map[string]any{"entry": str("one of licences, bindings, leases, offered, usage, audit, carried, conditions; absent for all"), + map[string]any{"entry": str("one of licences, bindings, leases, offered, usage, audit, carried, conditions; absent for all"), "key": str("a key of a state, or a subject of a log")}, func(ctx context.Context, m *Manager, a map[string]any) (any, error) { e, _ := a["entry"].(string) @@ -90,19 +84,13 @@ func moduleTools() []stdio.Tool { return m.Kept(strings.TrimSpace(e), strings.TrimSpace(k)) }), tool("licences_resync", "Recover: say binding.changed again for every binding, ask every node what it holds, say again each condition held, and hand each licence's access token to the vault again. Answers what it said.", - true, nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { + nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Resync(ctx) }), tool("licences_conditions", "The conditions the manager said and holds: each key of its state conditions with its body and since.", - false, nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) { + nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) { return m.KeptConditions() }), - tool("licences_carried", "The carry-over's record (when, and per table the rows read and entries written), or, while it has not ended, the failure it names; with run and no record, carry over again. Refused once carried.", - false, map[string]any{"run": boolean("carry over again: only while it has not ended")}, - func(ctx context.Context, m *Manager, a map[string]any) (any, error) { - run, _ := a["run"].(bool) - return m.Carried(ctx, run) - }), } } @@ -203,8 +191,8 @@ func (m *Manager) kept() (map[string]keptState, map[string]keptLog) { states["leases"], states["offered"] = bus.LeasesState, bus.OfferedState logs["usage"], logs["audit"] = bus.UsageLog, bus.AuditLog } - if m.Carry != nil { - states["carried"] = m.Carry.State + if m.Carried != nil { + states["carried"] = m.Carried } if m.Conditions != nil { states["conditions"] = m.Conditions @@ -385,37 +373,3 @@ func (m *Manager) KeptConditions() ([]KeptCondition, error) { } return out, nil } - -// Carried is the carry-over's record, or the failure its last run names while it has not ended; with run and no -// record it carries over again, and is refused once carried (§44). -func (m *Manager) Carried(ctx context.Context, run bool) (map[string]any, error) { - if m.Carry == nil { - return nil, errors.New("this manager has nothing to carry over") - } - if m.Carry.Ended() { - if run { - return nil, errors.New("the carry-over has ended: carried.postgres is written, and it is never done again") - } - e, err := m.Carry.State.Get(CarriedKey) - if err != nil || e == nil { - return nil, err - } - var rec CarriedRecord - if err := json.Unmarshal(e.Value, &rec); err != nil { - return nil, err - } - return map[string]any{"carried": rec}, nil - } - if !run { - return map[string]any{"carried": nil, "failure": m.Carry.Failure()}, nil - } - bus, _ := m.Store.(*BusStore) - rec, err := m.Carry.Run(ctx, m, bus) - if serr := m.SayConditions(ctx, false); serr != nil { - m.Log("saying the conditions after the carry-over: %v", serr) - } - if err != nil { - return map[string]any{"carried": nil, "failure": m.Carry.Failure()}, err - } - return map[string]any{"carried": rec}, nil -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go index 01af59786..6232280bd 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go @@ -10,6 +10,9 @@ import ( "time" ) +// stdTool is one served verb or tool's run. +type stdTool func(map[string]any) (any, error) + // served is every verb and tool the binary serves, by name, run on mm's manager. func served(t *testing.T, mm *miniMesh) map[string]stdTool { t.Helper() @@ -149,7 +152,7 @@ func TestReadUsageNowAppliesTheRuleUnlessDry(t *testing.T) { } // design 39 bus-and-switch §39/AC1, §32: switching now runs the rule with switch_auto off, and dry moves nothing; an -// acting tool waits for the carry-over (§11). +// acting tool waits for nothing to be carried over (design 39 parts §11). func TestSwitchNowRunsWithSwitchAutoOff(t *testing.T) { mm := fourOnA(t) mm.m.Settings.SwitchAuto = false @@ -160,10 +163,6 @@ func TestSwitchNowRunsWithSwitchAutoOff(t *testing.T) { if got := answer(t, run, map[string]any{"licence": "a"}).([]any); len(got) != 1 || mm.bound("ace").Licence != "b" { t.Fatalf("switched %v, ace on %s", got, mm.bound("ace").Licence) } - mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now })} - if _, err := run(nil); err == nil || err.Error() != NotCarried { - t.Fatalf("before the carry-over answered %v", err) - } } // design 39 bus-and-switch §40/AC1, B18: a value set at node level is answered with its level, the rest with the @@ -214,7 +213,7 @@ func TestKeptReadsEveryEntryOfTheManifest(t *testing.T) { mm := newMesh(t) ctx := context.Background() _, _ = saying(mm) - mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now })} + mm.m.Carried = newMemState(func() time.Time { return mm.now }) _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-secret-1", true, t0)}) _ = mm.m.ReadUsage(ctx) run := served(t, mm)["licences_kept"] @@ -299,36 +298,3 @@ func TestConditionsAnswersEachKey(t *testing.T) { t.Fatalf("keys %v", keys) } } - -// design 39 bus-and-switch §44/AC1: licences_carried answers the failure while not carried, carries over with run, then -// answers the record and refuses run. -func TestCarriedRunIsRefusedOnceCarried(t *testing.T) { - mm := newMesh(t) - _, said := saying(mm) - down := true - mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now }), - Source: func(context.Context) (CarrySource, error) { - if down { - return nil, errors.New("connection refused") - } - return &memSource{}, nil - }} - run := served(t, mm)["licences_carried"] - if _, err := run(map[string]any{"run": true}); err == nil || len(of(*said, CarryOverFailed)) != 1 { - t.Fatalf("a failed run answered %v, said %v", err, *said) - } - if got := answer(t, run, nil).(map[string]any); !strings.Contains(fmtAny(got["failure"]), "connection refused") { - t.Fatalf("not carried %v", got) - } - down = false - if got := answer(t, run, map[string]any{"run": true}).(map[string]any); got["carried"] == nil || - strings.Join(of(*said, CarryOverFailed), ",") != "raised,cleared" { - t.Fatalf("carried %v, said %v", got, *said) - } - if got := answer(t, run, nil).(map[string]any); got["carried"].(map[string]any)["tables"] == nil { - t.Fatalf("the record %v", got) - } - if _, err := run(map[string]any{"run": true}); err == nil { - t.Fatal("run is answered once carried") - } -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go index a9606cd64..7f35fdce2 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go @@ -2,11 +2,10 @@ package main -// The manager whole against a real bus (design 39 bus-and-switch §9, §11 to §13, §23, §26, §28, §29, §46, B41): the +// The manager whole against a real bus (design 39 bus-and-switch §9, §23, §26, §28, §29, §46, B41): the // built binary, played over its real stdin and stdout as the node's tool runtime plays it (tools/call with the caller in // _meta; mesh/state.* from a real nats-server's key-value buckets, mesh/log.* from the logs' real streams, mesh/publish -// and mesh/seat.publish onto their subjects), and today's database on a real postgres (MESH_TEST_POSTGRES, or a private -// server started here from the machine's initdb on a socket of its own, listening on no port). What the manager asks of +// and mesh/seat.publish onto their subjects). What the manager asks of // other parts is played beside it: the vault's licence-put and get, a node's claude_code_grant and its holdings-changed, // the operator channel's tell, and the vendor's token and usage endpoints. A condition is taken off the operator // channel's subject and decoded by its client, the contract the router reads (B41); the router itself is not run. @@ -22,7 +21,6 @@ import ( "io" "net/http" "net/http/httptest" - "net/url" "os" "os/exec" "path/filepath" @@ -32,7 +30,6 @@ import ( "testing" "time" - "github.com/jackc/pgx/v5" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" "golang.org/x/crypto/nacl/box" @@ -42,165 +39,45 @@ import ( "git.novox.be/novox/mesh-sdk/go/testbus" ) -func TestMain(m *testing.M) { - code := m.Run() - stopPostgres() - os.Exit(code) -} - -// ---- today's database on a real postgres --------------------------------------------------------------------- - -var ( - pgOnce sync.Once - pgBase string - pgDir string - pgWhy string - pgCount int - pgMu sync.Mutex -) - -func startPostgres() { - if u := os.Getenv("MESH_TEST_POSTGRES"); u != "" { - pgBase = u - return - } - initdb, err := exec.LookPath("initdb") - if err != nil { - pgWhy = "MESH_TEST_POSTGRES is unset and the machine has no initdb" - return - } - dir, err := os.MkdirTemp("", "licpg") - if err != nil { - pgWhy = err.Error() - return - } - pgDir = dir - if out, err := exec.Command(initdb, "-D", dir+"/data", "-U", "postgres", "-A", "trust", "-E", "UTF8", "--no-locale").CombinedOutput(); err != nil { - pgWhy = fmt.Sprintf("initdb: %v: %s", err, out) - return - } - // A socket in its own directory and no port: nothing on this machine reaches it but these tests. - if out, err := exec.Command(filepath.Join(filepath.Dir(initdb), "pg_ctl"), "-D", dir+"/data", "-l", dir+"/log", "-w", - "-o", "-k "+dir+" -c listen_addresses='' -F", "start").CombinedOutput(); err != nil { - pgWhy = fmt.Sprintf("pg_ctl start: %v: %s", err, out) - return - } - pgBase = "postgres://postgres@/postgres?host=" + dir -} - -func stopPostgres() { - if pgDir == "" { - return - } - if initdb, err := exec.LookPath("initdb"); err == nil { - _ = exec.Command(filepath.Join(filepath.Dir(initdb), "pg_ctl"), "-D", pgDir+"/data", "-m", "immediate", "stop").Run() - } - _ = os.RemoveAll(pgDir) -} - -// freshDatabase is an empty database of its own for one test, or a skip saying why there is none. -func freshDatabase(t *testing.T) (string, *pgx.Conn) { - t.Helper() - pgOnce.Do(startPostgres) - if pgBase == "" { - t.Skipf("NOT TESTED ON POSTGRES: %s", pgWhy) - } - pgMu.Lock() - pgCount++ - name := fmt.Sprintf("licmgr_%d_%d", os.Getpid(), pgCount) - pgMu.Unlock() - ctx := context.Background() - admin, err := pgx.Connect(ctx, pgBase) - if err != nil { - t.Fatal(err) - } - defer admin.Close(ctx) - if _, err := admin.Exec(ctx, "create database "+name); err != nil { - t.Fatal(err) - } - u, err := url.Parse(pgBase) - if err != nil { - t.Fatal(err) - } - u.Path = "/" + name - conn, err := pgx.Connect(ctx, u.String()) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = conn.Close(ctx) }) - return u.String(), conn -} - -// todaysSchema is the schema trunk's manager made (its PgStore's Schema): what the carry-over reads. -var todaysSchema = []string{ - `create table licence (name text primary key, kind text not null check (kind in ('subscription', 'api-key')), - account_uuid text unique, email text, organization_uuid text, sealed text, refresh_fingerprint text, - access_expires_at bigint, refresh_expires_at bigint, failures integer not null default 0, notified_at bigint, - adopted_at bigint not null, rotated_at bigint)`, - `create sequence binding_generation`, - `create table binding (consumer text primary key, licence text not null references licence(name), generation bigint not null)`, - `create table lease (key text primary key, holder text not null, until timestamptz not null)`, - `create table offered (fingerprint text primary key, node text not null, account_uuid text, outcome text not null, - why text not null, at timestamptz not null default now())`, - `create table usage (licence text not null, at bigint not null, reading jsonb not null, raw jsonb not null)`, - `create table audit (at timestamptz not null default now(), what text not null, detail jsonb not null)`, -} - const ( licenceA = "first@example.org" licenceB = "second@example.org" - grantKey = "a key the vault made" ) -// consumers are the four nodes bound to licenceA in today's rows. +// consumers are the four nodes bound to licenceA. var consumers = []string{"anchor", "home-server", "laptop", "workstation"} -// fillTodaysRows writes a copy of the shape of today's rows: two subscriptions, four consumers bound to the first, a -// lease, two offers (one older than the offered state's 90 days), three readings (one older than 35 days) and four -// audit rows (one of a kind the log does not name). -func fillTodaysRows(t *testing.T, conn *pgx.Conn) { - t.Helper() - ctx := context.Background() - for _, s := range todaysSchema { - if _, err := conn.Exec(ctx, s); err != nil { - t.Fatal(err) - } - } - crypt, _ := NewCrypt(grantKey) +// seed keeps what a running manager kept before this start: two subscriptions, their grants and access tokens in the +// vault, and four consumers bound to the first. +func (m *mesh) seed() { + m.t.Helper() now := time.Now() far := now.Add(30 * 24 * time.Hour).UnixMilli() + licences, err := m.js.KeyValue(m.ctx, Publisher+"_licences") + if err != nil { + m.t.Fatal(err) + } + bindings, err := m.js.KeyValue(m.ctx, Publisher+"_bindings") + if err != nil { + m.t.Fatal(err) + } for i, l := range []struct{ name, account, refresh, access string }{ {licenceA, "acct-a", "rt-a", "at-a"}, {licenceB, "acct-b", "rt-b", "at-b"}} { grant, _ := json.Marshal(FullGrant{AccessToken: l.access, RefreshToken: l.refresh, ExpiresAt: far}) - if _, err := conn.Exec(ctx, `insert into licence (name, kind, account_uuid, email, sealed, refresh_fingerprint, - access_expires_at, refresh_expires_at, adopted_at, rotated_at) values ($1,'subscription',$2,$1,$3,$4,$5,$5,$6,$7)`, - l.name, l.account, sealAtRest(t, crypt, string(grant)), Fingerprint(l.refresh), far, int64(i+1), now.UnixMilli()); err != nil { - t.Fatal(err) + m.secrets[SecretName(KeyFor(l.name), PartGrant)] = string(grant) + m.secrets[SecretName(KeyFor(l.name), PartAccess)] = l.access + v, _ := json.Marshal(Licence{Name: l.name, Kind: "subscription", AccountUUID: l.account, Email: l.name, + RefreshFingerprint: Fingerprint(l.refresh), AccessExpiresAt: far, RefreshExpiresAt: far, + AdoptedAt: int64(i + 1), RotatedAt: now.UnixMilli(), Generation: 1}) + if _, err := licences.Put(m.ctx, KeyFor(l.name), v); err != nil { + m.t.Fatal(err) } } for i, c := range consumers { - if _, err := conn.Exec(ctx, `insert into binding values ($1, $2, $3)`, c, licenceA, 1013+i); err != nil { - t.Fatal(err) - } - } - reading := `{"sessionPct": 42, "weeklyPct": 10}` - for _, q := range []struct { - sql string - args []any - }{ - {`insert into lease values ('licence:x', 'novox:1', now())`, nil}, - {`insert into offered values ('sha256:aa', 'laptop', 'acct-a', 'adopted', 'refreshed', $1)`, []any{now.Add(-24 * time.Hour)}}, - {`insert into offered values ('sha256:bb', 'anchor', null, 'dead', '400', $1)`, []any{now.Add(-100 * 24 * time.Hour)}}, - {`insert into usage values ($1, $2, $3, '{}')`, []any{licenceA, now.Add(-40 * 24 * time.Hour).UnixMilli(), reading}}, - {`insert into usage values ($1, $2, $3, '{"five_hour": {"utilization": 42}}')`, []any{licenceA, now.Add(-2 * time.Hour).UnixMilli(), reading}}, - {`insert into usage values ($1, $2, $3, '{}')`, []any{licenceB, now.Add(-time.Hour).UnixMilli(), reading}}, - {`insert into audit values ($1, 'adopted', '{"licence": "first@example.org", "node": "laptop"}')`, []any{now.Add(-72 * time.Hour)}}, - {`insert into audit values ($1, 'bound', '{"consumer": "laptop"}')`, []any{now.Add(-71 * time.Hour)}}, - {`insert into audit values ($1, 'switched', '{"consumer": "anchor"}')`, []any{now.Add(-70 * time.Hour)}}, - {`insert into audit values ($1, 'grant-moved', '{"licence": "second@example.org"}')`, []any{now.Add(-69 * time.Hour)}}, - } { - if _, err := conn.Exec(ctx, q.sql, q.args...); err != nil { - t.Fatal(err) + v, _ := json.Marshal(BindingState{Licence: licenceA, Kind: "subscription", Generation: int64(1013 + i), + Since: now.UTC().Format(time.RFC3339), Why: "bound", By: Itself}) + if _, err := bindings.Put(m.ctx, c, v); err != nil { + m.t.Fatal(err) } } } @@ -278,7 +155,7 @@ type said struct { body json.RawMessage } -// mesh is the bus, the state directory, the database and the parts beside the manager, kept across its starts. +// mesh is the bus, the state directory and the parts beside the manager, kept across its starts. type mesh struct { t *testing.T ctx context.Context @@ -286,7 +163,6 @@ type mesh struct { js jetstream.JetStream bin string dir string - dbURL string vendor *vendor own issued // what the manager's own runtime is issued from its manifest (C1) @@ -301,7 +177,7 @@ type mesh struct { conditions []oc.Condition } -func newWholeMesh(t *testing.T, dbURL string) *mesh { +func newWholeMesh(t *testing.T) *mesh { t.Helper() // The manager runs as its own account, never in the machine's tool runner, whose account is issued none of its // logs or states (C1): this player plays that runtime of its own, issuing what the manifest names and nothing else. @@ -340,12 +216,10 @@ func newWholeMesh(t *testing.T, dbURL string) *mesh { } } _ = os.MkdirAll(filepath.Join(dir, "state"), 0o700) - for f, v := range map[string]string{"grant.key": grantKey, "database.url": dbURL, "settings.json": "{}"} { - if err := os.WriteFile(filepath.Join(dir, f), []byte(v), 0o600); err != nil { - t.Fatal(err) - } + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte("{}"), 0o600); err != nil { + t.Fatal(err) } - m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, dir: dir, dbURL: dbURL, vendor: newVendor(t), own: own, + m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, dir: dir, vendor: newVendor(t), own: own, news: make(chan struct{}, 1), secrets: map[string]string{}, calls: map[string][32]byte{}, callArgs: map[string]map[string]any{}, logins: map[string]FullGrant{}} sub, err := nc.Subscribe("mesh.seat.operator-channel.accept.condition.>", func(msg *nats.Msg) { @@ -446,7 +320,6 @@ func (m *mesh) start() *started { m.t.Helper() cmd := exec.CommandContext(m.ctx, m.bin) cmd.Env = append(os.Environ(), "MESH_LICENCE_STATE="+filepath.Join(m.dir, "state"), - "MESH_LICENCE_KEY_FILE="+filepath.Join(m.dir, "grant.key"), "DATABASE_URL_FILE="+filepath.Join(m.dir, "database.url"), "MESH_LICENCE_SETTINGS="+filepath.Join(m.dir, "settings.json"), "MESH_NODE=novox", "MESH_MODULE="+Publisher, "MESH_ANTHROPIC_TOKEN_ENDPOINT="+m.vendor.server.URL+"/token", "MESH_ANTHROPIC_USAGE_ENDPOINT="+m.vendor.server.URL+"/usage") in, _ := cmd.StdinPipe() @@ -890,19 +763,6 @@ func (p *started) must(name string, args map[string]any) json.RawMessage { return raw } -// carried is the manager's carry-over record, as licences_carried answers it. -func (p *started) carried() CarriedRecord { - p.t.Helper() - var a struct { - Carried *CarriedRecord `json:"carried"` - Failure string `json:"failure"` - } - if err := json.Unmarshal(p.must("licences_carried", nil), &a); err != nil || a.Carried == nil { - p.t.Fatalf("licences_carried answers no record (%v): %s", err, a.Failure) - } - return *a.Carried -} - // bindings are the consumers' bindings, as the seat's verb answers them. func (p *started) bindings() map[string]Binding { p.t.Helper() @@ -917,152 +777,17 @@ func (p *started) bindings() map[string]Binding { return out } -// written is every revision of the states and every entry of the logs the carry-over writes: what a second start -// must leave as it is (§13). -func (m *mesh) written() map[string]uint64 { - m.t.Helper() - out := map[string]uint64{} - for _, s := range []string{"licences", "bindings", "offered", "carried"} { - info, err := m.js.Stream(m.ctx, "KV_"+Publisher+"_"+s) - if err != nil { - m.t.Fatal(err) - } - i, _ := info.Info(m.ctx) - out[s] = i.State.LastSeq - } - for l := range m.own.Logs { - info, err := m.js.Stream(m.ctx, "LOG_"+Publisher+"_"+l) - if err != nil { - m.t.Fatal(err) - } - i, _ := info.Info(m.ctx) - out[l] = i.State.LastSeq - } - return out -} - // ---- the tests ------------------------------------------------------------------------------------------------- -// design 39 bus-and-switch §11/AC1, §12/AC1, §13/AC1, §13/AC2: a start stopped at a table after bindings says -// carry-over-failed naming it and acts on nothing; started again it carries every row whole, the counts in -// carried.postgres equal to the rows read, the vault answering each licence's grant, and clears the condition; a -// further start writes nothing. -func TestTodaysRowsAreCarriedOverOnceAndWhole(t *testing.T) { - dbURL, conn := freshDatabase(t) - fillTodaysRows(t, conn) - ctx := context.Background() - // The table after bindings cannot be read: the start stops there. - if _, err := conn.Exec(ctx, `alter table offered rename column why to reason`); err != nil { - t.Fatal(err) - } - m := newWholeMesh(t, dbURL) - first := m.start() - m.until("carry-over-failed is said", func() bool { return len(m.conditionsOf(CarryOverFailed)) > 0 }) - m.mu.Lock() - raised := m.conditionsOf(CarryOverFailed)[0] - m.mu.Unlock() - if raised.State != "raised" || raised.Severity != "urgent" || !strings.Contains(raised.Explanation+raised.Summary, "offered") { - t.Errorf("carry-over-failed said %+v", raised) - } - if _, err := first.call(Seat+".bind", map[string]any{"consumer": "laptop", "licence": licenceB}); err == nil || - !strings.Contains(err.Error(), "the carry-over has not ended") { - t.Errorf("bind before the carry-over ended answered %v", err) - } - if n := m.vendor.called(); n != 0 { - t.Errorf("the vendor was called %d times before the carry-over ended", n) - } - first.stop() - - if _, err := conn.Exec(ctx, `alter table offered rename column reason to why`); err != nil { - t.Fatal(err) - } - m.mu.Lock() - beforeSecond := len(m.of("binding.changed")) - m.mu.Unlock() - second := m.start() - m.until("holdings.asked after the carry-over", func() bool { return m.count(HoldingsAsked) > 0 }) - rec := second.carried() - // §21: every carried binding is said by the start that carries it whole (the stopped one said those it wrote), so a reader's first view taken during the carry-over is brought up to date. - m.mu.Lock() - carriedSaid := 0 - for _, c := range m.of("binding.changed")[beforeSecond:] { - if c["why"] == "carried over" && c["licence"] == licenceA { - carriedSaid++ - } - } - m.mu.Unlock() - if carriedSaid != 4 { - t.Errorf("the carry-over said %d binding.changed, not one per binding", carriedSaid) - } - want := map[string][2]int{"licence": {2, 2}, "binding": {4, 4}, "lease": {1, 0}, "offered": {2, 1}, "usage": {2, 2}, "audit": {4, 4}} - for table, n := range want { - if got := rec.Tables[table]; got.Read != n[0] || got.Written != n[1] { - t.Errorf("%s: read %d written %d, want read %d written %d", table, got.Read, got.Written, n[0], n[1]) - } - } - m.mu.Lock() - for _, l := range []struct{ name, refresh string }{{licenceA, "rt-a"}, {licenceB, "rt-b"}} { - if g := m.secrets[SecretName(KeyFor(l.name), PartGrant)]; !strings.Contains(g, l.refresh) { - t.Errorf("the vault holds %q as the grant of %s", g, l.name) - } - } - m.mu.Unlock() - if b := second.bindings(); len(b) != 4 || b["anchor"].Licence != licenceA || b["anchor"].Generation != 1013 || b["anchor"].Why != "carried over" { - t.Errorf("bindings answers %+v", b) - } - m.until("carry-over-failed is cleared", func() bool { - c := m.conditionsOf(CarryOverFailed) - return len(c) > 1 && c[len(c)-1].State == "cleared" - }) - second.stop() - - before := m.written() - asked := m.count(HoldingsAsked) - third := m.start() - m.until("holdings.asked at the third start", func() bool { return m.count(HoldingsAsked) > asked }) - if again := third.carried(); again.At != rec.At { - t.Errorf("the third start carried over again at %s (first at %s)", again.At, rec.At) - } - if after := m.written(); fmt.Sprint(after) != fmt.Sprint(before) { - t.Errorf("a further start wrote %v, before it %v", after, before) - } -} - -// design 39 bus-and-switch §11/AC2: with the database unreachable the condition is raised, no act is taken and the -// vendor is never called. -func TestAnUnreachableDatabaseRaisesAndCallsNoVendor(t *testing.T) { - m := newWholeMesh(t, "postgres://postgres@127.0.0.1:1/claude_licences?connect_timeout=2") - p := m.start() - m.until("carry-over-failed is said", func() bool { return len(m.conditionsOf(CarryOverFailed)) > 0 }) - m.mu.Lock() - c := m.conditionsOf(CarryOverFailed)[0] - m.mu.Unlock() - if c.State != "raised" || c.Severity != "urgent" || c.Subject.ID != Publisher { - t.Errorf("carry-over-failed said %+v", c) - } - if _, err := p.call("licences_read_usage", nil); err == nil || !strings.Contains(err.Error(), "the carry-over has not ended") { - t.Errorf("licences_read_usage before the carry-over answered %v", err) - } - if n := m.vendor.called(); n != 0 { - t.Errorf("the vendor was called %d times", n) - } - m.mu.Lock() - defer m.mu.Unlock() - if n := m.count(HoldingsAsked); n != 0 { - t.Errorf("holdings.asked was said %d times before the carry-over", n) - } -} - // design 39 bus-and-switch §26/AC2, §28, §29/AC2, §9/AC3, B41: a reading of 99 % moves the four consumers bound to a // licence to the one with the most room in one act, saying one binding.changed each, one licence.switched and one tell; // with every licence at its threshold no-room is raised on the operator channel's subject, and a reading with room // clears it. func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) { - dbURL, conn := freshDatabase(t) - fillTodaysRows(t, conn) - m := newWholeMesh(t, dbURL) + m := newWholeMesh(t) + m.seed() p := m.start() - m.until("holdings.asked after the carry-over", func() bool { return m.count(HoldingsAsked) > 0 }) + m.until("holdings.asked at the start", func() bool { return m.count(HoldingsAsked) > 0 }) m.mu.Lock() carried := m.count("binding.changed") m.mu.Unlock() @@ -1128,13 +853,12 @@ func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) { } } -// design 39 bus-and-switch §23/AC1, §46/AC1, §11/AC3: on a fresh install (no table) the carry-over carries nothing and -// the manager says holdings.asked; each node's agent module answers with one holdings-changed; a report carrying an +// design 39 bus-and-switch §23/AC1, §46/AC1, design 39 parts §11: on a fresh install the manager waits for no carry-over +// and says holdings.asked; each node's agent module answers with one holdings-changed; a report carrying an // unknown refresh token's fingerprint makes a candidate, which is adopted, listed by licences with its organisation, // and chosen as a target at its first reading with room. func TestANewLoginIsAdoptedListedAndATarget(t *testing.T) { - dbURL, _ := freshDatabase(t) - m := newWholeMesh(t, dbURL) + m := newWholeMesh(t) changed := time.Now().UTC().Format(time.RFC3339Nano) report := func(node, account, email, org, refresh string) Holdings { h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: email, OrganizationUUID: org}, @@ -1155,9 +879,6 @@ func TestANewLoginIsAdoptedListedAndATarget(t *testing.T) { if asked != 2 { t.Errorf("the nodes were asked for their logins %d times, not once each", asked) } - if rec := p.carried(); rec.Tables["licence"].Read != 0 || rec.Tables["audit"].Read != 0 { - t.Errorf("a fresh install carried %+v", rec.Tables) - } // The full licence is read first: nothing has room yet, so its consumer stays. m.vendor.set("at-old", 99) diff --git a/modules/claude-licence-manager/files/database.url b/modules/claude-licence-manager/files/database.url deleted file mode 100644 index dc59da9a2..000000000 --- a/modules/claude-licence-manager/files/database.url +++ /dev/null @@ -1 +0,0 @@ -postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/modules/claude-licence-manager/go.mod b/modules/claude-licence-manager/go.mod index dc60fbeab..fc42a8641 100644 --- a/modules/claude-licence-manager/go.mod +++ b/modules/claude-licence-manager/go.mod @@ -6,7 +6,6 @@ require ( git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 - github.com/jackc/pgx/v5 v5.11.0 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 ) @@ -14,17 +13,12 @@ require ( require ( github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.20.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nats-server/v2 v2.11.17 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect - golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.16.0 // indirect ) diff --git a/modules/claude-licence-manager/go.sum b/modules/claude-licence-manager/go.sum index 96a5b2dcb..4bc701c15 100644 --- a/modules/claude-licence-manager/go.sum +++ b/modules/claude-licence-manager/go.sum @@ -5,14 +5,6 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= -github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= @@ -36,12 +28,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/modules/claude-licence-manager/module.json b/modules/claude-licence-manager/module.json index 5d6fe9d52..162379dc2 100644 --- a/modules/claude-licence-manager/module.json +++ b/modules/claude-licence-manager/module.json @@ -4,8 +4,6 @@ "slug": "licmgr", "runs-as": "claude-licence-manager", "requires": [ - "postgres-database", - "secret", { "name": "licences", "storage": "key-value", @@ -68,24 +66,6 @@ "max-mib": 64 } ], - "contributes": { - "postgres-database": { - "name": "claude_licences", - "readable": [ - "audit", - "binding" - ] - } - }, - "binds": { - "postgres-database": "${dir:state}/database.json" - }, - "secrets": { - "postgres-database": "${dir:state}/database.secret", - "secret": { - "grant-key": "${dir:state}/grant.key" - } - }, "own-secrets": { "broker": "${dir:state}/broker" }, @@ -247,14 +227,6 @@ "place": ".", "unknown": "program" }, - { - "id": "database-url", - "type": "file", - "path": "${dir:state}/database.url", - "mode": "0600", - "owner": "claude-licence-manager", - "template": "files/database.url" - }, { "id": "settings", "type": "file", @@ -285,8 +257,7 @@ "licences_settings", "licences_kept", "licences_resync", - "licences_conditions", - "licences_carried" + "licences_conditions" ], "build": { "artifacts": [ @@ -301,9 +272,7 @@ "claude-licence-manager" ], "env": { - "DATABASE_URL_FILE": "${dir:state}/database.url", "MESH_LICENCE_STATE": "${dir:state}", - "MESH_LICENCE_KEY_FILE": "${dir:state}/grant.key", "MESH_LICENCE_SETTINGS": "${dir:state}/settings.json" } },