diff --git a/checks/noplacement/assignments-readers b/checks/noplacement/assignments-readers index 38daeb29..64985d46 100644 --- a/checks/noplacement/assignments-readers +++ b/checks/noplacement/assignments-readers @@ -2,7 +2,3 @@ # why. The check of this directory (noplacement) refuses any other module that reads them, and an entry here whose module no longer # does: the list only shrinks, and it is empty before the controller's lease is written to a holder that is no # controller (novox/hq ADR 0549 §3, §6; issue 774). -gitea the provisioner's plan of a module (go/provider) rides only in an assignment's value; its home after the lease is issue 774's provisioner-plan subtask -keycloak the provisioner's plan of a module (go/provider), as gitea -postgres the provisioner's plan of a module (go/provider), as gitea -route-adapter the provisioner's plan of a module (go/provider), as gitea diff --git a/modules/gitea/cmd/forge-account-provider/builder_test.go b/modules/gitea/cmd/forge-account-provider/builder_test.go index f325acd7..8897013f 100644 --- a/modules/gitea/cmd/forge-account-provider/builder_test.go +++ b/modules/gitea/cmd/forge-account-provider/builder_test.go @@ -192,17 +192,14 @@ func (r *teamRig) pass() []provider.Outcome { return out } -// bind makes module on node bind gitea's forge-account in the assignments mirror. +// bind makes module on node bind gitea's forge-account in the seat mesh-provisioner's plans. func (r *teamRig) bind(node, module string) { - r.bus.set(provider.AssignmentsBucket, node+"."+module, []byte(fmt.Sprintf( - `{"node":%q,"module":%q,"provisioning":{"provisions":[{"provision":"forge-account","provider":"novox","provider-module":"gitea","as":"mesh_%s"}]}}`, - node, module, strings.ReplaceAll(node+"_"+module, "-", "_")))) + r.bus.bindPlan(node, module, "mesh_"+strings.ReplaceAll(node+"_"+module, "-", "_")) } +// unbind is the operator's yes to retiring module's forge-account on node (ADR 0570 clause 5). func (r *teamRig) unbind(node, module string) { - r.bus.mu.Lock() - delete(r.bus.buckets[provider.AssignmentsBucket], node+"."+module) - r.bus.mu.Unlock() + r.bus.retire(node, module) } // key reports a key in module's state keys, of the kind and for the account given. diff --git a/modules/gitea/cmd/forge-account-provider/bus_test.go b/modules/gitea/cmd/forge-account-provider/bus_test.go index 996a1816..7cacfe5b 100644 --- a/modules/gitea/cmd/forge-account-provider/bus_test.go +++ b/modules/gitea/cmd/forge-account-provider/bus_test.go @@ -30,6 +30,7 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-sdk/go/provider" + "git.novox.be/novox/mesh-sdk/go/provisions" ) const bundleVar = "MESH_FORGE_ACCOUNT_TEST_BUNDLE" @@ -80,7 +81,7 @@ func startBus(t *testing.T) (*runtime, context.CancelFunc) { rt := &runtime{t: t, js: js, kvs: map[string]jetstream.KeyValue{}, ctx: ctx, wait: map[string]chan json.RawMessage{}} // The controller's job: a bucket per state the module declares (bound) or reads (the others). for state, bucket := range map[string]string{"bound": "gitea_bound", "ssh-client.keys": "ssh-client_keys", - "mesh-controller.assignments": "mesh-controller_assignments"} { + "mesh-provisioner.plans": "mesh-provisioner_plans", "mesh-provisioner.provisions": "mesh-provisioner_provisions"} { kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: bucket, History: 1}) if err != nil { t.Fatal(err) @@ -296,9 +297,11 @@ func TestTheProviderWholeAnswersAMachineThatReportedItsKey(t *testing.T) { } rt, cancel := startBus(t) t.Cleanup(cancel) - rt.put("mesh-controller.assignments", "g14.ssh-client", map[string]any{"node": "g14", "module": "ssh-client", - "provisioning": map[string]any{"provisions": []map[string]any{{"provision": "forge-account", "provider": "novox", + rt.put("mesh-provisioner.plans", "g14.ssh-client", map[string]any{"v": 1, "node": "g14", "module": "ssh-client", + "plan": map[string]any{"provisions": []map[string]any{{"provision": "forge-account", "provider": "novox", "provider-module": "gitea", "as": "mesh_g14_ssh_client"}}}}) + rt.put("mesh-provisioner.provisions", "g14.ssh-client.forge-account", + json.RawMessage(provisionKey("g14", "ssh-client", provisions.Asked, nil))) rt.put("ssh-client.keys", "g14.nox", map[string]string{"node": "g14", "account": "nox", "kind": "machine", "public": pubA + " nox@g14 (mesh)", "fingerprint": "SHA256:x", "at": "2026-10-16T10:00:00Z"}) rt.start([]string{"MESH_NODE=novox", "MESH_MODULE=gitea", "MESH_SERVED_MODULE=gitea", diff --git a/modules/gitea/cmd/forge-account-provider/main_test.go b/modules/gitea/cmd/forge-account-provider/main_test.go index 024b5f6d..12583c16 100644 --- a/modules/gitea/cmd/forge-account-provider/main_test.go +++ b/modules/gitea/cmd/forge-account-provider/main_test.go @@ -25,7 +25,7 @@ func TestConfigFromEnvNamesWhatIsMissing(t *testing.T) { } // Issue 955: the runtime grants a module only the states its manifest `reads`, and the provider watches the machines' -// keys and, through the SDK's provider, the controller's assignments (where it learns its consumers). Run as the +// keys and, through the SDK's provider, the seat mesh-provisioner's plans and provisions (where it learns its consumers, ADR 0570). Run as the // bus test does, with every bucket made, a missing grant passes unseen; the manifest is read here instead. func TestTheManifestReadsEveryStateTheProviderWatches(t *testing.T) { raw, err := os.ReadFile("../../module.json") @@ -38,7 +38,7 @@ func TestTheManifestReadsEveryStateTheProviderWatches(t *testing.T) { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } - for _, state := range []string{"mesh-controller.assignments", "ssh-client.keys"} { + for _, state := range []string{"mesh-provisioner.plans", "mesh-provisioner.provisions", "ssh-client.keys"} { if !slices.Contains(m.Reads, state) { t.Errorf("gitea's reads %v lack %q, so the runtime refuses its watch", m.Reads, state) } diff --git a/modules/gitea/cmd/forge-account-provider/service_test.go b/modules/gitea/cmd/forge-account-provider/service_test.go index 7f47709a..34b6bfc7 100644 --- a/modules/gitea/cmd/forge-account-provider/service_test.go +++ b/modules/gitea/cmd/forge-account-provider/service_test.go @@ -17,6 +17,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/provider" + "git.novox.be/novox/mesh-sdk/go/provisions" "gitea/internal/forgehttp" ) @@ -78,6 +79,17 @@ func (b *fakeBus) entry(t *testing.T, key string) provider.Entry { } return e } + +// serves is whether gitea's entry under key serves its consumer: there, provided and with values (ADR 0570 clause 3: +// an entry the provider could not make is kept, failed, serving nothing). +func (b *fakeBus) serves(key string) bool { + b.mu.Lock() + raw, ok := b.buckets[provider.BucketOf("gitea")][key] + b.mu.Unlock() + var e provider.Entry + return ok && json.Unmarshal(raw, &e) == nil && e.State == provisions.Provided && len(e.Values) > 0 +} + func (b *fakeBus) has(key string) bool { b.mu.Lock() defer b.mu.Unlock() @@ -85,11 +97,41 @@ func (b *fakeBus) has(key string) bool { return ok } -// bindClient makes ssh-client on node bind gitea's forge-account in the assignments mirror. +// bindClient makes ssh-client on node bind gitea's forge-account in the seat mesh-provisioner's plans. func (b *fakeBus) bindClient(node string) { - b.set(provider.AssignmentsBucket, node+".ssh-client", []byte(fmt.Sprintf( - `{"node":%q,"module":"ssh-client","provisioning":{"provisions":[{"provision":"forge-account","provider":"novox","provider-module":"gitea","as":"mesh_%s_ssh_client"}]}}`, - node, node))) + b.bindPlan(node, "ssh-client", "mesh_"+node+"_ssh_client") +} + +// bindPlan makes module on node bind gitea's forge-account as the seat mesh-provisioner keeps it: its plan, and the +// provision key asked of gitea on novox (novox/hq ADR 0570 clauses 1 to 3). +func (b *fakeBus) bindPlan(node, module, as string) { + b.set(provisions.PlansBucket, node+"."+module, []byte(fmt.Sprintf( + `{"v":1,"node":%q,"module":%q,"plan":{"provisions":[{"provision":"forge-account","provider":"novox","provider-module":"gitea","as":%q}]}}`, + node, module, as))) + b.set(provisions.ProvisionsBucket, node+"."+module+".forge-account", provisionKey(node, module, provisions.Asked, nil)) +} + +// retire is the operator's yes to the retiring of module's forge-account on node: its plan gone, its key retiring +// with the warrant (ADR 0570 clause 5), the only way the provider lets an entry go. +func (b *fakeBus) retire(node, module string) { + b.mu.Lock() + delete(b.buckets[provisions.PlansBucket], node+"."+module) + b.mu.Unlock() + b.set(provisions.ProvisionsBucket, node+"."+module+".forge-account", provisionKey(node, module, provisions.Retiring, + &provisions.Warrant{Ask: "ask-1", Option: "yes", By: "operator", At: time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)})) +} + +func provisionKey(node, module, state string, w *provisions.Warrant) []byte { + pr := provisions.Provision{Provision: "forge-account", State: state, Warrant: w, + Consumer: provisions.End{Node: node, Module: module}, Provider: provisions.End{Node: "novox", Module: "gitea"}} + if state == provisions.Retiring { + pr.Why = "no want needs it" + } + raw, err := provisions.Encode(pr) + if err != nil { + panic(err) + } + return raw } // fakeForge is gitea's API as far as the provider uses it. @@ -343,9 +385,7 @@ func TestAConsumerThatNoLongerBindsLosesItsMeshKeysAndKeepsItsAccount(t *testing r.forge.mu.Lock() r.forge.held["nox-agent"] = append(r.forge.held["nox-agent"], map[string]any{"id": int64(99), "title": "my own", "key": pubB}) r.forge.mu.Unlock() - r.bus.mu.Lock() - delete(r.bus.buckets[provider.AssignmentsBucket], "g14.ssh-client") - r.bus.mu.Unlock() + r.bus.retire("g14", "ssh-client") r.pass() if r.bus.has("g14.ssh-client.forge-account") { t.Fatal("the entry stayed") @@ -460,7 +500,7 @@ func TestADeletedKeyEntryRemovesTheMeshKeyAndTheBoundEntryButNotTheOperatorsKey( if got := r.forge.titles("nox-agent"); len(got) != 1 || got[0] != "my own" { t.Fatalf("keys = %v", got) } - if r.bus.has("g14.ssh-client.forge-account") { + if r.bus.serves("g14.ssh-client.forge-account") { t.Fatal("the entry still serves a machine whose key is gone") } } @@ -493,7 +533,7 @@ func TestAPassBeforeTheKeysAreLoadedRemovesAndDeletesNothing(t *testing.T) { r.svc.loaded = true r.svc.mu.Unlock() r.pass() - if got := strings.Join(r.forge.titles("nox-agent"), ","); got != "my own" || r.bus.has("g14.ssh-client.forge-account") { + if got := strings.Join(r.forge.titles("nox-agent"), ","); got != "my own" || r.bus.serves("g14.ssh-client.forge-account") { t.Fatalf("keys = %s", got) } } @@ -515,7 +555,7 @@ func TestKeepRunningStartsTheProviderAgainAfterARefusedWatch(t *testing.T) { r.svc.KeepRunning(ctx, func(ctx context.Context) error { calls++ if calls <= 2 { - return errors.New(`watching mesh-controller_assignments: gitea reads no state called "mesh-controller.assignments"`) + return errors.New(`watching mesh-provisioner_plans: gitea reads no state called "mesh-provisioner.plans"`) } close(serving) <-ctx.Done() @@ -535,7 +575,7 @@ func TestKeepRunningStartsTheProviderAgainAfterARefusedWatch(t *testing.T) { if len(pauses) != 2 || pauses[0] != 5*time.Second || pauses[1] != 10*time.Second { t.Fatalf("the backoff is %v", pauses) } - if len(said) == 0 || !strings.Contains(said[0], "mesh-controller.assignments") { + if len(said) == 0 || !strings.Contains(said[0], "mesh-provisioner.plans") { t.Fatalf("a refusal is said loudly: %v", said) } loud := 0 diff --git a/modules/gitea/cmd/forge-account-provider/watch_test.go b/modules/gitea/cmd/forge-account-provider/watch_test.go index 2fb60b97..60618da4 100644 --- a/modules/gitea/cmd/forge-account-provider/watch_test.go +++ b/modules/gitea/cmd/forge-account-provider/watch_test.go @@ -142,7 +142,7 @@ func TestABuildModulesStateThatNeverAnswersHoldsBackOnlyItsOwnEntries(t *testing } // 1482/AC4 (the provider's half): the manifest reads the states of both build modules beside ssh-client's and the -// controller's assignments; the provider watches exactly the modules the code names as builders. +// seat mesh-provisioner's plans; the provider watches exactly the modules the code names as builders. func TestTheManifestReadsTheBuildModulesKeys(t *testing.T) { raw, err := os.ReadFile("../../module.json") if err != nil { @@ -154,7 +154,7 @@ func TestTheManifestReadsTheBuildModulesKeys(t *testing.T) { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } - for _, state := range []string{"mesh-controller.assignments", "ssh-client.keys", "mesh-build-go.keys", "mesh-build-node.keys", "mesh-build-image.keys"} { + for _, state := range []string{"mesh-provisioner.plans", "mesh-provisioner.provisions", "ssh-client.keys", "mesh-build-go.keys", "mesh-build-node.keys", "mesh-build-image.keys"} { if !slices.Contains(m.Reads, state) { t.Errorf("gitea's reads %v lack %q", m.Reads, state) } diff --git a/modules/gitea/go.mod b/modules/gitea/go.mod index 6639c1c3..4042334e 100644 --- a/modules/gitea/go.mod +++ b/modules/gitea/go.mod @@ -3,7 +3,7 @@ module gitea go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029223134-08fc70783a7c github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/modules/gitea/go.sum b/modules/gitea/go.sum index 2a3e9f34..38594c1f 100644 --- a/modules/gitea/go.sum +++ b/modules/gitea/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029223134-08fc70783a7c h1:inVTVNIZI4/v7/aVhL+EkKzHCDlN73hJrWKz3jI59mY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029223134-08fc70783a7c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/gitea/module.json b/modules/gitea/module.json index b864e4d3..69b88adb 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -566,7 +566,8 @@ "announced" ], "reads": [ - "mesh-controller.assignments", + "mesh-provisioner.plans", + "mesh-provisioner.provisions", "ssh-client.keys", "mesh-build-go.keys", "mesh-build-node.keys", diff --git a/modules/keycloak/cmd/keycloak-provider/grant_moved_test.go b/modules/keycloak/cmd/keycloak-provider/grant_moved_test.go index 20843dcd..80f02f9a 100644 --- a/modules/keycloak/cmd/keycloak-provider/grant_moved_test.go +++ b/modules/keycloak/cmd/keycloak-provider/grant_moved_test.go @@ -15,6 +15,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/provider" "git.novox.be/novox/mesh-sdk/go/provider/harness" + "git.novox.be/novox/mesh-sdk/go/provisions" ) // grantGives writes a grant file (MESH_RECEIVES) naming consumers the plans do not move (issue 937): each one's @@ -38,11 +39,33 @@ func grantGives(t *testing.T, given ...map[string]any) string { return at } -// noOpPlan puts one assignment the bus can read that binds nothing of keycloak's, so the moved path has plans it +// noOpPlan puts one plan the bus can read that binds nothing of keycloak's, so the moved path has plans it // could read (m.asked answers "read", issue 241) even when nothing is moved. func noOpPlan(bus *bucketBus, node, module string) { - raw, _ := json.Marshal(map[string]any{"node": node, "module": module}) - _ = bus.Put(ctx, provider.AssignmentsBucket, node+"."+module, raw) + seatPlan(bus, node, module, map[string]any{}) +} + +// seatPlan puts a module's plan as the seat mesh-provisioner keeps it, and for each of its provisions served by +// keycloak on novox the key the seat composes, asked (novox/hq ADR 0570 clauses 1 to 3). +func seatPlan(bus *bucketBus, node, module string, plan map[string]any) { + raw, _ := json.Marshal(map[string]any{"v": 1, "node": node, "module": module, "plan": plan}) + _ = bus.Put(ctx, provisions.PlansBucket, node+"."+module, raw) + list, _ := plan["provisions"].([]any) + for _, e := range list { + entry := e.(map[string]any) + name, _ := entry["provision"].(string) + local, _ := entry["local"].(string) + key, err := provisions.Key(node, module, name, local) + if err != nil { + panic(err) + } + asked, err := provisions.Encode(provisions.Provision{Provision: name, Local: local, State: provisions.Asked, + Consumer: provisions.End{Node: node, Module: module}, Provider: provisions.End{Node: "novox", Module: "keycloak"}}) + if err != nil { + panic(err) + } + _ = bus.Put(ctx, provisions.ProvisionsBucket, key, asked) + } } // waitUntil polls cond until it is true or the timeout passes. diff --git a/modules/keycloak/cmd/keycloak-provider/live_retire_test.go b/modules/keycloak/cmd/keycloak-provider/live_retire_test.go index 4cbc24a0..0ab5cc34 100644 --- a/modules/keycloak/cmd/keycloak-provider/live_retire_test.go +++ b/modules/keycloak/cmd/keycloak-provider/live_retire_test.go @@ -62,6 +62,14 @@ func TestLiveRetirement(t *testing.T) { if code := authorize(); code == 200 { t.Fatal("a retired client is still served") } + // Its secret is removed (ADR 0570 clause 5): the mesh's no longer authenticates. + if found, err := o.KC.FindClient(ctx, o.Realm, p.As); err != nil || found == nil { + t.Fatal(found, err) + } else if id, _ := found["id"].(string); true { + if secret, err := o.KC.ClientSecretByID(ctx, o.Realm, id); err != nil || secret == p.Password { + t.Fatal("a retired client keeps the mesh's secret", err) + } + } // Retired, so a person may delete it. if err := o.Delete(ctx, p.As); err != nil { t.Fatal(err) diff --git a/modules/keycloak/cmd/keycloak-provider/moved_test.go b/modules/keycloak/cmd/keycloak-provider/moved_test.go index f69aa363..dd5371dd 100644 --- a/modules/keycloak/cmd/keycloak-provider/moved_test.go +++ b/modules/keycloak/cmd/keycloak-provider/moved_test.go @@ -155,10 +155,9 @@ func TestBothPathsLeaveTheSameClientInKeycloak(t *testing.T) { t.Fatal(err) } bus := &bucketBus{buckets: map[string]map[string][]byte{}} - plan, _ := json.Marshal(map[string]any{"node": "home", "module": "grafana", "provisioning": map[string]any{ + seatPlan(bus, "home", "grafana", map[string]any{ "provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", - "provider-module": "keycloak", "contributes": grafanaContributes, "names": grafanaNames}}}}) - _ = bus.Put(ctx, provider.AssignmentsBucket, "home.grafana", plan) + "provider-module": "keycloak", "contributes": grafanaContributes, "names": grafanaNames}}}) pr := provider.New(provider.Options{Node: "novox", Module: "keycloak", Bus: bus, Vault: provider.ConsumersFile{Paths: []string{file}}, Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.PerConsumer, diff --git a/modules/keycloak/cmd/keycloak-provider/oidc.go b/modules/keycloak/cmd/keycloak-provider/oidc.go index c7815462..ca702b0a 100644 --- a/modules/keycloak/cmd/keycloak-provider/oidc.go +++ b/modules/keycloak/cmd/keycloak-provider/oidc.go @@ -24,15 +24,18 @@ package main import ( "context" + "crypto/rand" + "encoding/hex" "encoding/json" "errors" "fmt" - "git.novox.be/novox/mesh-sdk/go/provider/harness" "net/url" "regexp" "sort" "strings" "time" + + "git.novox.be/novox/mesh-sdk/go/provider/harness" ) // Mark is the attribute marking a client as the mesh's own work. @@ -307,8 +310,10 @@ func (o OidcClients) Holds(ctx context.Context, p harness.Provision) (bool, erro return secret == p.Password, nil } -// Retire disables a consumer's client — only one the mesh made — and marks it with when and why. -// Answers what happened: "retired", "absent" or "not ours". +// Retire, on the operator's yes (novox/hq ADR 0570 clause 5), removes the client's secret — replaced by one nobody +// is given, so the mesh's credential no longer authenticates — and disables and marks the client with when and why. +// The client and its settings are kept; asked for again, Ensure sets the mesh's secret and enables it. Only a client +// the mesh made. Answers what happened: "retired", "absent" or "not ours". func (o OidcClients) Retire(ctx context.Context, as, why string, at time.Time) (string, error) { found, err := o.KC.FindClient(ctx, o.Realm, as) if err != nil { @@ -332,6 +337,11 @@ func (o OidcClients) Retire(ctx context.Context, as, why string, at time.Time) ( attrs[MarkRetiredWhy] = why merged["attributes"] = attrs merged["enabled"] = false + unknown := make([]byte, 32) + if _, err := rand.Read(unknown); err != nil { + return "", err + } + merged["secret"] = hex.EncodeToString(unknown) // never kept, never said id, _ := found["id"].(string) return "retired", o.KC.UpdateClient(ctx, o.Realm, id, merged) } diff --git a/modules/keycloak/cmd/keycloak-provider/oidc_test.go b/modules/keycloak/cmd/keycloak-provider/oidc_test.go index 45c95ae3..4d8f52d0 100644 --- a/modules/keycloak/cmd/keycloak-provider/oidc_test.go +++ b/modules/keycloak/cmd/keycloak-provider/oidc_test.go @@ -186,7 +186,7 @@ func TestAClientTheMeshDidNotMakeIsRefusedAndLeftAlone(t *testing.T) { } } -// Retired is disabled and marked, everything else kept; asked for again it is enabled as it was; only +// Retired is its secret removed, disabled and marked, everything else kept (ADR 0570 clause 5); asked for again it is enabled as it was; only // a deletion removes it, and never while enabled (novox/hq ADR 0230). func TestARetiredClientIsDisabledKeptAndEnabledAgainAsItWas(t *testing.T) { f, o := oidcWorld(t) @@ -204,7 +204,7 @@ func TestARetiredClientIsDisabledKeptAndEnabledAgainAsItWas(t *testing.T) { t.Fatal(done, err) } c := f.clients[id] - if c["enabled"] != false || c["secret"] != "s" || attributes(c)[MarkRetired] != "2026-10-06T12:00:00Z" || + if c["enabled"] != false || c["secret"] == "s" || c["secret"] == "" || attributes(c)[MarkRetired] != "2026-10-06T12:00:00Z" || attributes(c)[MarkRetiredWhy] != "the mesh stopped asking for it" || c["description2"] == nil { t.Fatalf("%v", c) } @@ -219,7 +219,7 @@ func TestARetiredClientIsDisabledKeptAndEnabledAgainAsItWas(t *testing.T) { t.Fatal(err) } c = f.clients[id] - if c["enabled"] != true || attributes(c)[MarkRetired] != nil || attributes(c)[MarkRetiredWhy] != nil || c["description2"] == nil { + if c["enabled"] != true || c["secret"] != "s" || attributes(c)[MarkRetired] != nil || attributes(c)[MarkRetiredWhy] != nil || c["description2"] == nil { t.Fatalf("%v", c) } mustHold(t, o, p, true) diff --git a/modules/keycloak/cmd/keycloak-provider/provisioner.go b/modules/keycloak/cmd/keycloak-provider/provisioner.go index e0428a4e..6bf4aef6 100644 --- a/modules/keycloak/cmd/keycloak-provider/provisioner.go +++ b/modules/keycloak/cmd/keycloak-provider/provisioner.go @@ -62,7 +62,8 @@ func (a provisioner) Create(ctx context.Context, p harness.Provision) error { return nil } -// Retire disables the consumer's client and marks it, never deletes it (novox/hq ADR 0230). +// Retire removes the consumer's client secret and disables and marks the client, never deletes it (novox/hq ADR 0570 +// clause 5, ADR 0230). func (a provisioner) Retire(ctx context.Context, as string, _ map[string]any, why string, at time.Time) error { if err := a.refused(); err != nil { return err @@ -75,7 +76,7 @@ func (a provisioner) Retire(ctx context.Context, as string, _ map[string]any, wh case "not ours": a.log("[provisioner:oidc-client] %s: a client of that id exists that the mesh did not make — left alone", as) case "retired": - a.log("[provisioner:oidc-client] retired client %s in realm %s: disabled, kept, marked to delete", as, a.clients.Realm) + a.log("[provisioner:oidc-client] retired client %s in realm %s: its secret removed, disabled, kept, marked to delete", as, a.clients.Realm) a.announce("client.retired", map[string]any{"realm": a.clients.Realm, "clientId": as}) } return nil diff --git a/modules/keycloak/go.mod b/modules/keycloak/go.mod index 28481201..f395dfe8 100644 --- a/modules/keycloak/go.mod +++ b/modules/keycloak/go.mod @@ -2,4 +2,4 @@ module keycloak go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029223134-08fc70783a7c diff --git a/modules/keycloak/go.sum b/modules/keycloak/go.sum index 6162079d..fbfd7431 100644 --- a/modules/keycloak/go.sum +++ b/modules/keycloak/go.sum @@ -1,2 +1,2 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 h1:Cdl5N56QPfmFBoLHrDfvvyc5Q+PUnTjf3fgYD7imcco= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029223134-08fc70783a7c h1:inVTVNIZI4/v7/aVhL+EkKzHCDlN73hJrWKz3jI59mY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029223134-08fc70783a7c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/modules/keycloak/module.json b/modules/keycloak/module.json index 7bf874bd..84c08f10 100644 --- a/modules/keycloak/module.json +++ b/modules/keycloak/module.json @@ -241,6 +241,7 @@ "bound" ], "reads": [ - "mesh-controller.assignments" + "mesh-provisioner.plans", + "mesh-provisioner.provisions" ] } diff --git a/modules/postgres/cmd/postgres-provider/harness_test.go b/modules/postgres/cmd/postgres-provider/harness_test.go index 26b0246c..7c04bdba 100644 --- a/modules/postgres/cmd/postgres-provider/harness_test.go +++ b/modules/postgres/cmd/postgres-provider/harness_test.go @@ -120,8 +120,8 @@ func TestProvisionerCreatesTheDatabaseThenItsExtensions(t *testing.T) { if err := a.Retire(ctx, "mesh_ace_letta", nil, "test", time.Now()); err != nil { t.Fatal(err) } - noDrop(t, f.statements()) - if !has(f.statements(), `NOLOGIN`) { + // On the operator's yes the login goes and the database stays (ADR 0570 clause 5). + if !has(f.statements(), `^DROP ROLE "mesh_ace_letta"$`) || has(f.statements(), `DROP DATABASE`) { t.Fatal(f.statements()) } } diff --git a/modules/postgres/cmd/postgres-provider/live_test.go b/modules/postgres/cmd/postgres-provider/live_test.go index 38f91ef5..5e847973 100644 --- a/modules/postgres/cmd/postgres-provider/live_test.go +++ b/modules/postgres/cmd/postgres-provider/live_test.go @@ -86,6 +86,14 @@ func TestLive(t *testing.T) { if err != nil || len(r.Rows) != 1 || cell(r.Rows[0], 0) != "1" { t.Fatal("retiring lost the data:", r, err) } + // The login is removed, its database kept by its keeper (ADR 0570 clause 5). + if has, _ := c.exists(ctx, "SELECT 1 FROM pg_roles WHERE rolname = "+Literal(p.As)); has { + t.Fatal("the retired login is still there") + } + if has, _ := c.exists(ctx, "SELECT 1 FROM pg_database d JOIN pg_roles r ON r.oid = d.datdba WHERE d.datname = "+ + Literal(p.As)+" AND r.rolname = "+Literal(p.As+"~retired")); !has { + t.Fatal("the database is not its keeper's") + } inv, err := a.Inventory(ctx) if err != nil { t.Fatal(err) diff --git a/modules/postgres/cmd/postgres-provider/provisioner.go b/modules/postgres/cmd/postgres-provider/provisioner.go index a0d4ed06..b9bbb02d 100644 --- a/modules/postgres/cmd/postgres-provider/provisioner.go +++ b/modules/postgres/cmd/postgres-provider/provisioner.go @@ -35,10 +35,14 @@ func (a provisioner) Create(ctx context.Context, p harness.Provision) error { if err := a.pg.CreateDatabaseAndRole(ctx, database, p.As, p.Password); err != nil { return err } + // A login retired and asked for again takes back what its keeper kept (retire_pg.go). + if err := a.pg.Reclaim(ctx, database, p.As); err != nil { + return err + } if err := a.pg.EnsureExtensions(ctx, database, extensions); err != nil { return err } - // The active mark: a retired consumer asked for again loses its mark to delete here, its LOGIN + // The active mark: a login locked before ADR 0570 and asked for again loses its mark to delete here, its LOGIN // already set again by the role statement above (novox/hq ADR 0230). if err := a.pg.MarkActive(ctx, p.As, p.Consumer); err != nil { return err @@ -47,11 +51,10 @@ func (a provisioner) Create(ctx context.Context, p harness.Provision) error { return nil } -// Retire locks the login, never drops (novox/hq issue 241, ADR 0230): the database is kept under its -// own name, the role marked retired with when and why (retire_pg.go). On 2026-10-04 a misread -// contributions file withdrew every consumer at once, and dropping made that a loss of seven databases. -// Deleting is `cleanup delete`, a person's act; taking a database out of service by hand is -// postgres_retire_database, which renames rather than drops. +// Retire removes the login on the operator's yes (novox/hq ADR 0570 clause 5, ADR 0114 §2): what it owns is handed +// to its keeper, its sessions end and the login is dropped; the database and its data are kept (retire_pg.go). +// Deleting is `cleanup delete`, a person's act; taking a database out of service by hand is postgres_retire_database, +// which renames rather than drops. func (a provisioner) Retire(ctx context.Context, as string, _ map[string]any, why string, at time.Time) error { if err := a.pg.RetireRole(ctx, as, why, at); err != nil { return err diff --git a/modules/postgres/cmd/postgres-provider/retire_pg.go b/modules/postgres/cmd/postgres-provider/retire_pg.go index e07a5640..24e911c8 100644 --- a/modules/postgres/cmd/postgres-provider/retire_pg.go +++ b/modules/postgres/cmd/postgres-provider/retire_pg.go @@ -1,20 +1,22 @@ package main -// What retired means in postgres (novox/hq ADR 0230). +// What retired means in postgres (novox/hq ADR 0570 clause 5, ADR 0114 §2 and §9; ADR 0230 for the delete). // -// **Retired is the login locked, the database kept, and the role marked.** `ALTER ROLE … NOLOGIN` — -// the consumer's login can no longer connect, as itself, to anything — and its open sessions are -// ended. The database, its owner, its grants and every byte in it stay exactly as they were: CONNECT is -// not revoked and the database still allows connections, because the backup contribution dumps every -// database and a retired one is still worth backing up, and because re-enabling must give it back as it -// was. The mark is the role's comment, a JSON object the mesh owns: +// **Retired is the login removed, the database kept under a keeper role, and the keeper marked.** Retiring happens +// only on the operator's yes to a retiring provision (the SDK's moved path, Gone). What the login owns, its database +// first, is handed to its keeper, a role of its own that cannot log in (`~retired`, KeeperOf), its open +// sessions are ended, and the login is dropped: nothing can connect as it any more, and no credential of it is left. +// The database, its grants to others and every byte in it stay: the backup contribution dumps every database and a +// retired one is still worth backing up. The mark is the keeper's comment, a JSON object the mesh owns: // -// {"mesh":"consumer","node":"ace"} active -// {"mesh":"consumer","node":"ace","retired":"2026-10-06T…Z","why":"…"} retired +// {"mesh":"consumer","node":"ace"} an active login +// {"mesh":"consumer","node":"ace","retired":"2026-10-06T…Z","why":"…","login":"…"} a keeper // -// Asked for again, the ordinary create sets LOGIN and the password again and writes the active mark. -// Deleted — only through `cleanup delete` — the database is dropped, then the role, unless it still owns -// another database (a set-aside copy), which is said and kept. +// Asked for again, the ordinary create makes the login again and its keeper hands everything back (Reclaim) and +// goes. Deleted — only through `cleanup delete` — the database is dropped, then the keeper. +// +// **A login retired before this record** was only locked (`NOLOGIN`, its own role marked retired); it is listed and +// deleted as before, and create sets its LOGIN again. // // **What the mesh made is recognised by its mark, or by its shape before the mark existed**: a role // valid until 'infinity' (only the mesh's role statement says that) that owns a database of its own @@ -41,6 +43,17 @@ type roleMark struct { Node string `json:"node,omitempty"` Retired string `json:"retired,omitempty"` Why string `json:"why,omitempty"` + // Login is, on a keeper, the login it keeps for. + Login string `json:"login,omitempty"` +} + +// KeeperOf is the role a retired login's database and objects are handed to: the login's name with `~retired`. +func KeeperOf(login string) (string, error) { + k := login + "~retired" + if len(k) > 63 { + return "", fmt.Errorf("%s is too long a login for a keeper role (%s is over postgres's 63 bytes)", login, k) + } + return k, nil } func readMark(comment string) (roleMark, bool) { @@ -64,8 +77,9 @@ func (c *Client) MarkActive(ctx context.Context, role, node string) error { return err } -// RetireRole locks the login, ends its sessions and marks it retired with when and why. Its database -// is not touched. A role that does not exist has nothing to retire. +// RetireRole removes a login on the operator's yes (ADR 0570 clause 5): what it owns is handed to its keeper, marked +// with when and why, its sessions end and the login is dropped. The database and its data are kept. A login that does +// not exist has nothing to retire. func (c *Client) RetireRole(ctx context.Context, role, why string, at time.Time) error { r, err := c.Query(ctx, "", "SELECT coalesce(shobj_description(oid, 'pg_authid'), '') FROM pg_roles WHERE rolname = "+ Literal(role)) @@ -76,10 +90,66 @@ func (c *Client) RetireRole(ctx context.Context, role, why string, at time.Time) return nil } prev, _ := readMark(cell(r.Rows[0], 0)) + keeper, err := KeeperOf(role) + if err != nil { + return err + } + has, err := c.exists(ctx, "SELECT 1 FROM pg_roles WHERE rolname = "+Literal(keeper)) + if err != nil { + return err + } + if !has { + if _, err := c.Query(ctx, "", fmt.Sprintf("CREATE ROLE %s NOLOGIN", Ident(keeper))); err != nil { + return err + } + } + if _, err := c.Query(ctx, "", MarkStatement(keeper, roleMark{Node: prev.Node, Retired: at.UTC().Format(time.RFC3339), + Why: why, Login: role})); err != nil { + return err + } + // No new session, and none left open. if err := c.LockRole(ctx, role); err != nil { return err } - _, err = c.Query(ctx, "", MarkStatement(role, roleMark{Node: prev.Node, Retired: at.UTC().Format(time.RFC3339), Why: why})) + // In its own database, where its objects are; the database itself is a shared object, handed with them. + in := "" + if own, err := c.exists(ctx, "SELECT 1 FROM pg_database WHERE datname = "+Literal(role)); err != nil { + return err + } else if own { + in = role + } + for _, sql := range []string{ + fmt.Sprintf("REASSIGN OWNED BY %s TO %s", Ident(role), Ident(keeper)), + fmt.Sprintf("DROP OWNED BY %s", Ident(role)), + } { + if _, err := c.Query(ctx, in, sql); err != nil { + return err + } + } + _, err = c.Query(ctx, "", fmt.Sprintf("DROP ROLE %s", Ident(role))) + return err +} + +// Reclaim gives a login made again what its keeper kept (ADR 0570 clause 5: a provision needed again): the keeper's +// database and objects are handed back, and the keeper goes. A login with no keeper has nothing to reclaim. +func (c *Client) Reclaim(ctx context.Context, database, role string) error { + keeper, err := KeeperOf(role) + if err != nil { + return err + } + has, err := c.exists(ctx, "SELECT 1 FROM pg_roles WHERE rolname = "+Literal(keeper)) + if err != nil || !has { + return err + } + for _, sql := range []string{ + fmt.Sprintf("REASSIGN OWNED BY %s TO %s", Ident(keeper), Ident(role)), + fmt.Sprintf("DROP OWNED BY %s", Ident(keeper)), + } { + if _, err := c.Query(ctx, database, sql); err != nil { + return err + } + } + _, err = c.Query(ctx, "", fmt.Sprintf("DROP ROLE %s", Ident(keeper))) return err } @@ -89,7 +159,8 @@ var setAside = regexp.MustCompile(`_deleted_(\d{8})$`) // of its own database. const InventoryStatement = `SELECT r.rolname, r.rolcanlogin, coalesce(shobj_description(r.oid, 'pg_authid'), '') AS mark, coalesce(r.rolvaliduntil = 'infinity', false) AS mesh_shaped, - (SELECT pg_database_size(d.datname) FROM pg_database d WHERE d.datname = r.rolname AND d.datdba = r.oid) AS size + (SELECT pg_database_size(d.datname) FROM pg_database d WHERE d.datdba = r.oid + AND (d.datname = r.rolname OR d.datname || '~retired' = r.rolname)) AS size FROM pg_roles r WHERE r.rolname !~ '^pg_' AND NOT r.rolsuper AND r.rolname <> ` + "'" + Reader + "'" + ` ORDER BY r.rolname` @@ -118,6 +189,9 @@ func (c *Client) Inventory(ctx context.Context) (harness.Inventory, error) { continue } at, _ := time.Parse(time.RFC3339, m.Retired) + if m.Login != "" { + name = m.Login // a keeper: listed under the login it keeps for + } inv.Retired = append(inv.Retired, harness.Retired{Consumer: name, Node: m.Node, RetiredAt: at, Why: m.Why, SizeBytes: sizeOf(size), Kind: harness.KindConsumer}) } @@ -160,7 +234,19 @@ func (c *Client) DeleteRetired(ctx context.Context, r harness.Retired) (int64, e return freed, err } if len(row.Rows) == 0 { - return freed, nil + // Retired by removing the login: its keeper goes with the database it kept. + keeper, err := KeeperOf(r.Consumer) + if err != nil { + return freed, err + } + if has, err := c.exists(ctx, "SELECT 1 FROM pg_roles WHERE rolname = "+Literal(keeper)); err != nil || !has { + return freed, err + } + if _, err := c.Query(ctx, "", fmt.Sprintf("DROP OWNED BY %s", Ident(keeper))); err != nil { + return freed, err + } + _, err = c.Query(ctx, "", fmt.Sprintf("DROP ROLE %s", Ident(keeper))) + return freed, err } owns, err := c.Query(ctx, "", "SELECT datname FROM pg_database WHERE datdba = (SELECT oid FROM pg_roles WHERE rolname = "+ Literal(r.Consumer)+")") diff --git a/modules/postgres/cmd/postgres-provider/retire_pg_test.go b/modules/postgres/cmd/postgres-provider/retire_pg_test.go index ee9a476a..75feec90 100644 --- a/modules/postgres/cmd/postgres-provider/retire_pg_test.go +++ b/modules/postgres/cmd/postgres-provider/retire_pg_test.go @@ -10,31 +10,74 @@ import ( "time" ) -func TestRetiringLocksMarksAndDropsNothing(t *testing.T) { +// ADR 0570 clause 5, on the operator's yes: the login's database and objects go to its keeper, marked; its sessions +// end; the login is dropped; the database is not. +func TestRetiringHandsTheDatabaseToAKeeperAndRemovesTheLogin(t *testing.T) { f, c := newFake(admin) f.answer(`shobj_description\(oid`, []string{"c"}, []string{`{"mesh":"consumer","node":"ace"}`}) - f.answer(`SELECT 1 FROM pg_roles`, []string{"?column?"}, []string{"1"}) + f.answer(`pg_roles WHERE rolname = 'mesh_ace_letta'$`, []string{"?column?"}, []string{"1"}) + f.answer(`pg_database WHERE datname = 'mesh_ace_letta'$`, []string{"?column?"}, []string{"1"}) at := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) - if err := c.RetireRole(ctx, "mesh_ace_letta", "the mesh stopped asking for it", at); err != nil { + if err := c.RetireRole(ctx, "mesh_ace_letta", "the operator approved its retirement", at); err != nil { t.Fatal(err) } - sql := f.statements() - if !has(sql, `ALTER ROLE "mesh_ace_letta" NOLOGIN`) || !has(sql, `pg_terminate_backend.*usename = 'mesh_ace_letta'`) || - !has(sql, `COMMENT ON ROLE "mesh_ace_letta" IS '\{"mesh":"consumer","node":"ace","retired":"2026-10-06T12:00:00Z","why":"the mesh stopped asking for it"\}'`) { - t.Fatal(strings.Join(sql, "\n")) + var order []string + for _, call := range f.calls { + switch { + case strings.HasPrefix(call.SQL, "SELECT"): + case strings.HasPrefix(call.SQL, "REASSIGN"), strings.HasPrefix(call.SQL, "DROP OWNED"): + order = append(order, call.Database+": "+call.SQL) + default: + order = append(order, call.SQL) + } } - noDrop(t, sql) - if has(sql, `REVOKE|ALLOW_CONNECTIONS|RENAME`) { - t.Fatal("retiring touched the database:", strings.Join(sql, "\n")) + if !has(f.statements(), `pg_terminate_backend.*usename = 'mesh_ace_letta'`) { + t.Fatal(strings.Join(f.statements(), "\n")) + } + want := []string{ + `CREATE ROLE "mesh_ace_letta~retired" NOLOGIN`, + `COMMENT ON ROLE "mesh_ace_letta~retired" IS '{"mesh":"consumer","node":"ace","retired":"2026-10-06T12:00:00Z","why":"the operator approved its retirement","login":"mesh_ace_letta"}'`, + `ALTER ROLE "mesh_ace_letta" NOLOGIN`, + `mesh_ace_letta: REASSIGN OWNED BY "mesh_ace_letta" TO "mesh_ace_letta~retired"`, + `mesh_ace_letta: DROP OWNED BY "mesh_ace_letta"`, + `DROP ROLE "mesh_ace_letta"`, + } + if strings.Join(order, "\n") != strings.Join(want, "\n") { + t.Fatalf("sent:\n%s\nwant:\n%s", strings.Join(order, "\n"), strings.Join(want, "\n")) + } + if has(f.statements(), `DROP DATABASE|REVOKE|ALLOW_CONNECTIONS|RENAME`) { + t.Fatal("retiring touched the database:", strings.Join(f.statements(), "\n")) } // No such role: nothing to retire, nothing done. f, c = newFake(admin) - if err := c.RetireRole(ctx, "gone", "x", at); err != nil || has(f.statements(), `ALTER|COMMENT`) { + if err := c.RetireRole(ctx, "gone", "x", at); err != nil || has(f.statements(), `ALTER|COMMENT|CREATE|DROP`) { t.Fatal(f.statements(), err) } } +// A login made again takes back what its keeper kept, and the keeper goes; with no keeper, nothing is sent. +func TestALoginMadeAgainReclaimsWhatItsKeeperKept(t *testing.T) { + f, c := newFake(admin) + if err := c.Reclaim(ctx, "mesh_ace_letta", "mesh_ace_letta"); err != nil || has(f.statements(), `REASSIGN|DROP`) { + t.Fatal(f.statements(), err) + } + f, c = newFake(admin) + f.answer(`pg_roles WHERE rolname = 'mesh_ace_letta~retired'$`, []string{"?column?"}, []string{"1"}) + if err := c.Reclaim(ctx, "mesh_ace_letta", "mesh_ace_letta"); err != nil { + t.Fatal(err) + } + var got []string + for _, call := range f.calls[1:] { + got = append(got, call.Database+": "+call.SQL) + } + want := `mesh_ace_letta: REASSIGN OWNED BY "mesh_ace_letta~retired" TO "mesh_ace_letta"` + "\n" + + `mesh_ace_letta: DROP OWNED BY "mesh_ace_letta~retired"` + "\n" + `postgres: DROP ROLE "mesh_ace_letta~retired"` + if strings.Join(got, "\n") != want { + t.Fatal(strings.Join(got, "\n")) + } +} + func TestTheInventoryIsWhatTheMeshMadeByMarkOrShape(t *testing.T) { f, c := newFake(admin) f.answer(`FROM pg_roles r`, []string{"rolname", "rolcanlogin", "mark", "mesh_shaped", "size"}, @@ -44,6 +87,7 @@ func TestTheInventoryIsWhatTheMeshMadeByMarkOrShape(t *testing.T) { []string{"locked_before", "f", "", "t", "400"}, []string{"hal_registry", "t", "", "f", "500"}, []string{"jochens", "t", "", "t", ""}, + []string{"mesh_ace_umami~retired", "f", `{"mesh":"consumer","node":"ace","retired":"2026-10-29T12:00:00Z","why":"approved","login":"mesh_ace_umami"}`, "f", "600"}, ) f.answer(`_deleted_`, []string{"datname", "size"}, []string{"mesh_novox_gitea_deleted_20261005", "7771827"}) inv, err := c.Inventory(ctx) @@ -53,7 +97,7 @@ func TestTheInventoryIsWhatTheMeshMadeByMarkOrShape(t *testing.T) { if strings.Join(inv.Active, ",") != "active_marked,active_shaped" { t.Fatalf("active: %v", inv.Active) } - if len(inv.Retired) != 3 { + if len(inv.Retired) != 4 { t.Fatalf("%+v", inv.Retired) } r := inv.Retired[0] @@ -64,7 +108,10 @@ func TestTheInventoryIsWhatTheMeshMadeByMarkOrShape(t *testing.T) { if r := inv.Retired[1]; r.Consumer != "locked_before" || !r.RetiredAt.IsZero() || r.Kind != harness.KindConsumer { t.Fatalf("found locked without a mark: %+v", r) } - if r := inv.Retired[2]; r.Kind != KindSetAside || r.RetiredAt.Format("20060102") != "20261005" || r.SizeBytes != 7771827 { + if r := inv.Retired[2]; r.Consumer != "mesh_ace_umami" || r.SizeBytes != 600 || r.Why != "approved" { + t.Fatalf("a keeper is listed under the login it keeps for: %+v", r) + } + if r := inv.Retired[3]; r.Kind != KindSetAside || r.RetiredAt.Format("20060102") != "20261005" || r.SizeBytes != 7771827 { t.Fatalf("set aside: %+v", r) } } @@ -97,6 +144,18 @@ func TestDeletingDropsTheDatabaseThenTheRole(t *testing.T) { if db < 0 || role < db { t.Fatal(sql) } + // A login removed on retiring: the database, then its keeper. + f, c = newFake(admin) + f.answer(`SELECT pg_database_size`, []string{"size"}, []string{"9000"}) + f.answer(`pg_roles WHERE rolname = 'mesh_ace_umami~retired'$`, []string{"?column?"}, []string{"1"}) + if _, err := c.DeleteRetired(ctx, harness.Retired{Consumer: "mesh_ace_umami", Kind: harness.KindConsumer}); err != nil { + t.Fatal(err) + } + sql = strings.Join(f.statements(), "\n") + db, keeper := strings.Index(sql, `DROP DATABASE "mesh_ace_umami"`), strings.Index(sql, `DROP ROLE "mesh_ace_umami~retired"`) + if db < 0 || keeper < db { + t.Fatal(sql) + } // A role still owning a set-aside database is kept, and said. f, c = newFake(admin) f.answer(`SELECT rolcanlogin`, []string{"rolcanlogin"}, []string{"f"}) diff --git a/modules/postgres/go.mod b/modules/postgres/go.mod index b1598d4e..0170aebb 100644 --- a/modules/postgres/go.mod +++ b/modules/postgres/go.mod @@ -3,7 +3,7 @@ module postgres go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029223134-08fc70783a7c github.com/jackc/pgx/v5 v5.11.0 ) diff --git a/modules/postgres/go.sum b/modules/postgres/go.sum index 923b4ba3..09e1479d 100644 --- a/modules/postgres/go.sum +++ b/modules/postgres/go.sum @@ -1,5 +1,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 h1:Cdl5N56QPfmFBoLHrDfvvyc5Q+PUnTjf3fgYD7imcco= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029223134-08fc70783a7c h1:inVTVNIZI4/v7/aVhL+EkKzHCDlN73hJrWKz3jI59mY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029223134-08fc70783a7c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/modules/postgres/module.json b/modules/postgres/module.json index cbc629bd..8f9f0579 100644 --- a/modules/postgres/module.json +++ b/modules/postgres/module.json @@ -196,6 +196,7 @@ "bound" ], "reads": [ - "mesh-controller.assignments" + "mesh-provisioner.plans", + "mesh-provisioner.provisions" ] } diff --git a/modules/route-adapter/cmd/route-provider/provider_test.go b/modules/route-adapter/cmd/route-provider/provider_test.go index abb464e1..44ac3e40 100644 --- a/modules/route-adapter/cmd/route-provider/provider_test.go +++ b/modules/route-adapter/cmd/route-provider/provider_test.go @@ -7,6 +7,7 @@ import ( "testing" "git.novox.be/novox/mesh-sdk/go/provider" + "git.novox.be/novox/mesh-sdk/go/provisions" ) // bucketBus is the helper's bus over in-memory buckets. @@ -45,12 +46,22 @@ func (b *bucketBus) Delete(_ context.Context, bucket, key string) error { // the entry's values are the controller's composition, copied unchanged, and no vault is asked. func TestAConsumerOfARouteIsToldItsNamesFromItsPlanEntry(t *testing.T) { bus := &bucketBus{buckets: map[string]map[string][]byte{}} - plan, _ := json.Marshal(map[string]any{"node": "g14", "module": "gitea", "provisioning": map[string]any{ + plan, _ := json.Marshal(map[string]any{"v": 1, "node": "g14", "module": "gitea", "plan": map[string]any{ "provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": ModuleName, "at": "10.7.0.1", "from": "novox", "as": "mesh_g14_gitea", "names": map[string]any{"name": "git.example.org", "internal-name": "git.novox.internal", "name-web": "git.example.org"}}}}}) - if err := bus.Put(context.Background(), provider.AssignmentsBucket, "g14.gitea", plan); err != nil { + if err := bus.Put(context.Background(), provisions.PlansBucket, "g14.gitea", plan); err != nil { + t.Fatal(err) + } + // The key the seat composed for the entry, asked of route-adapter (novox/hq ADR 0570 clauses 1, 3). + key, _ := provisions.Key("g14", "gitea", ProvisionName, "") + asked, err := provisions.Encode(provisions.Provision{Provision: ProvisionName, State: provisions.Asked, + Consumer: provisions.End{Node: "g14", Module: "gitea"}, Provider: provisions.End{Node: "novox", Module: ModuleName}}) + if err != nil { + t.Fatal(err) + } + if err := bus.Put(context.Background(), provisions.ProvisionsBucket, key, asked); err != nil { t.Fatal(err) } p := newProvider("novox", bus, t.Logf) diff --git a/modules/route-adapter/go.mod b/modules/route-adapter/go.mod index a4ec98b8..5c33ad5a 100644 --- a/modules/route-adapter/go.mod +++ b/modules/route-adapter/go.mod @@ -2,4 +2,4 @@ module route-adapter go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029223134-08fc70783a7c diff --git a/modules/route-adapter/go.sum b/modules/route-adapter/go.sum index 6162079d..fbfd7431 100644 --- a/modules/route-adapter/go.sum +++ b/modules/route-adapter/go.sum @@ -1,2 +1,2 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 h1:Cdl5N56QPfmFBoLHrDfvvyc5Q+PUnTjf3fgYD7imcco= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029223134-08fc70783a7c h1:inVTVNIZI4/v7/aVhL+EkKzHCDlN73hJrWKz3jI59mY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029223134-08fc70783a7c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/modules/route-adapter/module.json b/modules/route-adapter/module.json index e9f5151b..2827462c 100644 --- a/modules/route-adapter/module.json +++ b/modules/route-adapter/module.json @@ -130,6 +130,7 @@ "bound" ], "reads": [ - "mesh-controller.assignments" + "mesh-provisioner.plans", + "mesh-provisioner.provisions" ] }