Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)

This commit is contained in:
2026-10-16 07:37:43 +00:00
parent 1a2c917abf
commit 9652b2d5ce
771 changed files with 52063 additions and 1774 deletions
+59 -3
View File
@@ -9,6 +9,7 @@ package main
import (
"context"
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -43,12 +44,33 @@ func main() {
go guard.Run(context.Background())
var h *Harness
if receives := os.Getenv("MESH_RECEIVES"); receives == "" {
say("MESH_RECEIVES is not set — the provisioner cannot run without it")
} else if issuer, err := Issuer(os.Getenv); err != nil {
if issuer, err := Issuer(os.Getenv); err != nil {
say("%v — the provisioner cannot run without it", err)
} else if realm, err := RealmOf(issuer); err != nil {
say("%v — the provisioner cannot run without it", err)
} else if consumers := handedConsumersFile(os.Getenv, os.Stat); consumers != "" {
// Handed to the provisioner (novox/hq ADR 0373): the provider helper answers the consumers its
// machine's client fills the file with; the grant directory is not read. Retirement is the
// harness's, never run, so its guards and its tools stand (issue 805).
h = &Harness{
Resource: ProvisionName,
Adapter: provisioner{clients: OidcClients{KC: kc, Realm: realm}, guard: guard, announce: announce, log: logStderr},
Log: logStderr,
Announce: announce,
Node: os.Getenv("MESH_NODE"),
}
run, p, bus, err := moved(consumers, h, issuer)
if err != nil {
say("%v", err)
} else {
go func() {
if err := run.Run(context.Background(), p, bus); err != nil {
say("the provider helper stopped: %v", err)
}
}()
}
} else if receives := os.Getenv("MESH_RECEIVES"); receives == "" {
say("MESH_RECEIVES is not set — the provisioner cannot run without it")
} else {
h = &Harness{
Resource: "oidc-client",
@@ -62,9 +84,43 @@ func main() {
}
go h.Run(context.Background())
}
// Started on the grant path while the module is to be handed, it restarts itself when its client first fills
// the consumers' file, so the first fill moves it onto the helper without a hand restart (issue 805): `restart-on`
// names a service or a container, and the only one here is Keycloak's own.
if at := os.Getenv("MESH_CONSUMERS_FILE"); at != "" && handedConsumersFile(os.Getenv, os.Stat) == "" {
go exitWhenFilled(at, os.Stat, 5*time.Second, func() {
say("%s is filled: restarting to answer from it", at)
os.Exit(0)
})
}
// The retirement tools beside keycloak's own (novox/hq ADR 0230).
if err := stdio.Serve("", append(Tools(kc, guard), RetirementTools(h)...)); err != nil {
say("%v", err)
os.Exit(1)
}
}
// handedConsumersFile is the consumers' file when keycloak is handed to the provisioner: the file MESH_CONSUMERS_FILE
// names, which the controller places only where it hands the module. Empty, where it is not set or the file is not
// there, and keycloak then reads the grant directory exactly as it always did (issue 805).
func handedConsumersFile(getenv func(string) string, stat func(string) (os.FileInfo, error)) string {
at := getenv("MESH_CONSUMERS_FILE")
if at == "" {
return ""
}
if _, err := stat(at); err != nil {
return ""
}
return at
}
// exitWhenFilled calls filled once the file is there, asking every so often.
func exitWhenFilled(at string, stat func(string) (os.FileInfo, error), every time.Duration, filled func()) {
for {
if _, err := stat(at); err == nil {
filled()
return
}
time.Sleep(every)
}
}
@@ -0,0 +1,274 @@
package main
// keycloak handed to the provisioner (novox/hq ADR 0373, ADR 0349 clause 12, issue 805): where its consumers file
// is set, keycloak runs on the SDK's provider helper instead of the harness reading the controller's grant files.
//
// The helper learns who binds keycloak from the plans, composes each consumer's entry and reads its credential
// through ConsumersFile, the file this machine's provisioner client fills. It leaves the rest to the two hooks
// here: Make brings the consumer's OIDC client into being exactly as the harness's adapter does, and Gone leaves
// retirement to the harness's guards (retirement.go): the client is disabled and marked to delete, never removed.
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/provider"
)
// ProvisionName is what keycloak provides, as its manifest says.
const ProvisionName = "oidc-client"
// servedValues is what its manifest serves (`serves.oidc-client`): the paths as they are, and the issuer as its own
// config says, which is the one the code reads (Issuer), not a value the helper reads from the manifest.
func servedValues(issuer string) map[string]any {
return map[string]any{
"issuer": issuer,
"authorization-path": "/protocol/openid-connect/auth",
"token-path": "/protocol/openid-connect/token",
"userinfo-path": "/protocol/openid-connect/userinfo",
}
}
// valuesOf is what the consumer's grant file gave the harness as its values: what it contributes (label, endpoint,
// callback), with the names the controller composed for it (name, internal-name) over them (issue 792).
func valuesOf(e provider.Entry) map[string]any {
values := map[string]any{}
for k, v := range e.Contributes {
values[k] = v
}
for k, name := range e.Names {
values[k] = name
}
return values
}
// movedRun is keycloak on the provider helper, with the harness's own memory and retirement behind it: what is held,
// what is counted unasked, what waits for a person (retirement.go). The harness is never Run here; the helper's
// Make and Gone and the loop below drive the same state, so the guards are the harness's, not a copy.
type movedRun struct {
h *Harness
mu sync.Mutex // guards assignments and loaded
assignments map[string][]byte
loaded bool
verified map[string]time.Time
}
func newMovedRun(h *Harness) *movedRun {
h.mu.Lock()
h.init()
h.mu.Unlock()
return &movedRun{h: h, assignments: map[string][]byte{}, verified: map[string]time.Time{}}
}
// Make brings one consumer's client into being under its client id (its plan's `as`) and the vault's credential as
// its secret, as the harness does: at once when it is new or changed, and otherwise only when Keycloak no longer
// holds it, which is asked every VerifyEvery.
func (m *movedRun) Make(ctx context.Context, c provider.Consumer) error {
h, e := m.h, c.Entry
if e.As == "" {
return fmt.Errorf("%s has no client id to make", e.Key())
}
if len(c.Credential) == 0 {
return fmt.Errorf("%s has no credential to make its client with", e.Key())
}
h.mu.Lock()
defer h.mu.Unlock()
as, password := e.As, string(c.Credential)
p := Provision{As: as, Password: password, Values: valuesOf(e), Consumer: e.Consumer}
hash := hashOf(as, password, p.Values, nil)
if was, ok := h.applied[as]; ok && was.hash == hash {
if h.Now().Sub(m.verified[as]) < h.VerifyEvery {
return nil
}
m.verified[as] = h.Now()
hctx, cancel := context.WithTimeout(ctx, h.HoldsTimeout)
held, err := h.Adapter.Holds(hctx, p)
cancel()
if err != nil {
text := scrub(err, password)
h.failed(as, e.Consumer, h.classOf(err, text), text)
return errors.New(text)
}
if held {
h.succeeded(as)
return nil
}
h.say("%s: Keycloak no longer holds it; applying again", as)
}
if err := h.Adapter.Create(ctx, p); err != nil {
text := scrub(err, password)
h.failed(as, e.Consumer, h.classOf(err, text), text)
return errors.New(text)
}
h.succeeded(as)
h.applied[as] = appliedEntry{hash: hash, node: e.Consumer}
m.verified[as] = h.Now()
h.reenabled(as, e.Consumer)
return nil
}
// Gone is the helper asking whether it may forget a consumer that no longer binds. **It never retires**: retiring is
// the harness's rule, the same unasked set over StablePasses passes and StableFor, at most RetireAtOnce at a time
// (retirement.go, ADR 0230), run by retire below. So a consumer missing from one read of the plans loses nothing:
// the helper is told no, and asks again at its next pass, until the client is retired, or asked for again.
func (m *movedRun) Gone(_ context.Context, e provider.Entry) error {
h := m.h
as := e.As
if as == "" {
return nil // refused: nothing was made for it
}
// The plans as they are now, not as the last retirement pass read them: with none read, nothing is let go.
want, read := m.asked(h.Node)
asked := want[as]
h.mu.Lock()
defer h.mu.Unlock()
_, held := h.applied[as]
_, retired := h.r.retired[as]
switch {
case !read:
case asked:
return nil // another holder of the same client id still binds: nothing to retire
case retired, !held && h.r.seeded:
return nil
}
return fmt.Errorf("%s is kept until its retirement is stable (%d passes and %s) and within the bound, or a "+
"person approves it", as, h.StablePasses, h.StableFor)
}
// plan is as much of an assignment as is read to learn who binds keycloak.
type plan struct {
Node string `json:"node"`
Module string `json:"module"`
Provisioning *struct {
Provisions []struct {
Provision string `json:"provision"`
Provider string `json:"provider"`
ProviderModule string `json:"provider-module"`
// As is who the consumer is at its provider's end, its slug first (issue 788).
As string `json:"as"`
} `json:"provisions"`
} `json:"provisioning"`
}
// asked is the client ids the plans bind keycloak for, and whether they could all be read: a plan that is not the
// controller's, or no read yet, is not an answer, so nothing is counted unasked from it (novox/hq issue 241).
func (m *movedRun) asked(node string) (map[string]bool, bool) {
m.mu.Lock()
defer m.mu.Unlock()
if !m.loaded || len(m.assignments) == 0 {
return nil, false
}
want := map[string]bool{}
for key, raw := range m.assignments {
var a plan
if err := json.Unmarshal(raw, &a); err != nil || a.Node+"."+a.Module != key {
return nil, false
}
if a.Provisioning == nil {
continue
}
for _, b := range a.Provisioning.Provisions {
if b.Provider == node && b.ProviderModule == "keycloak" && b.Provision == ProvisionName {
as := b.As
if as == "" {
as = provider.ConsumerIdentity(a.Node, a.Module) // a plan that names none
}
want[as] = true
}
}
}
return want, true
}
// retire is one pass of the harness's retirement over what the plans bind now.
func (m *movedRun) retire(ctx context.Context) {
h := m.h
want, ok := m.asked(h.Node)
h.mu.Lock()
defer h.mu.Unlock()
if !ok {
// Not a result: the passes that must agree before anything is retired start again.
h.r.key, h.r.count = "", 0
return
}
h.seed(ctx, want)
h.retireUnasked(ctx, want)
h.r.lastWant = want
for as := range h.failing {
if !want[as] {
delete(h.failing, as)
}
}
for as := range h.trouble {
if _, held := h.applied[as]; !want[as] && !held {
h.recovered(as, "no longer asked for")
}
}
}
// watch keeps the plans as the bus holds them, from the first read on.
func (m *movedRun) watch(ctx context.Context, bus provider.Bus) error {
err := bus.Watch(ctx, provider.AssignmentsBucket, func(key string, value []byte) {
m.mu.Lock()
defer m.mu.Unlock()
if value == nil {
delete(m.assignments, key)
} else {
m.assignments[key] = append([]byte(nil), value...)
}
})
if err == nil {
m.mu.Lock()
m.loaded = true
m.mu.Unlock()
}
return err
}
// Run answers the consumers through the helper and retires through the harness's rules, until ctx ends.
func (m *movedRun) Run(ctx context.Context, p *provider.Provider, bus provider.Bus) error {
if err := m.watch(ctx, bus); err != nil {
return err
}
go func() {
for {
m.retire(ctx)
select {
case <-ctx.Done():
return
case <-time.After(m.h.Every):
}
}
}()
return p.Run(ctx)
}
// moved is keycloak handed to the provisioner: the helper answering its consumers, and the harness (never run) whose
// retirement and tools it shares. consumersFile is where its machine's client fills its consumers, issuer the one
// keycloak's own config gives (Issuer), served to every consumer.
func moved(consumersFile string, h *Harness, issuer string) (*movedRun, *provider.Provider, provider.Bus, error) {
if consumersFile == "" {
return nil, nil, nil, errors.New("no consumers' file")
}
run := newMovedRun(h)
bus := provider.StateBus("keycloak")
return run, provider.New(provider.Options{
Node: h.Node,
Module: "keycloak",
Offers: map[string]provider.Offer{ProvisionName: {
Credential: provider.PerConsumer,
Serves: servedValues(issuer),
}},
Bus: bus,
Vault: provider.ConsumersFile{Paths: []string{consumersFile}},
Make: run.Make,
Gone: run.Gone,
Every: h.Every,
Logf: func(f string, a ...any) { h.say(f, a...) },
}), bus, nil
}
@@ -0,0 +1,394 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/provider"
)
const testIssuer = "https://id.example.org/realms/Novox"
// bucketBus is the helper's bus over in-memory buckets.
type bucketBus struct {
mu sync.Mutex
buckets map[string]map[string][]byte
}
func (b *bucketBus) Watch(_ context.Context, bucket string, handle func(string, []byte)) error {
b.mu.Lock()
defer b.mu.Unlock()
for k, v := range b.buckets[bucket] {
handle(k, v)
}
return nil
}
func (b *bucketBus) Put(_ context.Context, bucket, key string, value []byte) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.buckets[bucket] == nil {
b.buckets[bucket] = map[string][]byte{}
}
b.buckets[bucket][key] = append([]byte(nil), value...)
return nil
}
func (b *bucketBus) Delete(_ context.Context, bucket, key string) error {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.buckets[bucket], key)
return nil
}
// bind is one consumer's assignment, whose plan names this machine's keycloak as the provider and carries what the
// consumer contributes (issue 776).
func (b *bucketBus) bind(node, module string, contributes map[string]any) {
b.bindNamed(node, module, contributes, nil)
}
// bindNamed is bind with the names the controller composed for the consumer (issue 792).
func (b *bucketBus) bindNamed(node, module string, contributes map[string]any, names map[string]string) {
plan, _ := json.Marshal(map[string]any{"node": node, "module": module, "provisioning": map[string]any{
"provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": "keycloak",
"contributes": contributes, "names": names}}}})
if err := b.Put(context.Background(), provider.AssignmentsBucket, node+"."+module, plan); err != nil {
panic(err)
}
}
// consumersFile writes the file the machine's client fills: one credential per consumer.
func consumersFile(t *testing.T, bound map[[2]string]string) string {
var list []provider.ConsumerCredential
for k, password := range bound {
list = append(list, provider.ConsumerCredential{Consumer: k[0], Module: k[1],
Credential: provider.PairName("novox", ProvisionName, k[0], k[1], ""), Value: password})
}
raw, _ := json.Marshal(list)
at := filepath.Join(t.TempDir(), "consumers.json")
if err := os.WriteFile(at, raw, 0o600); err != nil {
t.Fatal(err)
}
return at
}
// movedWorld is keycloak on the helper over an in-memory bus, with a recorder for a server and a clock to move.
type movedWorld struct {
t *testing.T
a *recorder
bus *bucketBus
h *Harness
run *movedRun
p *provider.Provider
now time.Time
file string
}
func newMovedWorld(t *testing.T, bound map[[2]string]string) *movedWorld {
w := &movedWorld{t: t, a: &recorder{}, bus: &bucketBus{buckets: map[string]map[string][]byte{}},
now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
w.file = consumersFile(t, bound)
w.h = &Harness{Resource: ProvisionName, Adapter: w.a, Node: "novox", Now: func() time.Time { return w.now },
Log: func(string, ...any) {}}
var bus provider.Bus
w.run, w.p, bus, _ = moved(w.file, w.h, testIssuer)
_ = bus
if err := w.run.watch(ctx, w.bus); err != nil {
t.Fatal(err)
}
return w
}
// pass is one helper pass and one retirement pass, five seconds after the last.
func (w *movedWorld) pass() []provider.Outcome {
w.now = w.now.Add(5 * time.Second)
// the plans as the bus holds them now, as the watch would hand them
w.run.assignments = map[string][]byte{}
for k, v := range w.bus.buckets[provider.AssignmentsBucket] {
w.run.assignments[k] = v
}
// the helper reads its buckets from the in-memory bus
w.p = provider.New(provider.Options{Node: "novox", Module: "keycloak", Bus: w.bus,
Vault: provider.ConsumersFile{Paths: []string{w.file}},
Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.PerConsumer,
Serves: servedValues(testIssuer)}},
Make: w.run.Make, Gone: w.run.Gone, Logf: func(string, ...any) {}})
out, err := w.p.Pass(ctx)
if err != nil {
w.t.Fatal(err)
}
w.run.retire(ctx)
return out
}
func bindings(n int) (map[[2]string]string, []string) {
bound := map[[2]string]string{}
var logins []string
for i := 1; i <= n; i++ {
module := fmt.Sprintf("app%d", i)
bound[[2]string{"ace", module}] = "pw-" + module
logins = append(logins, provider.ConsumerIdentity("ace", module))
}
return bound, logins
}
// grafanaContributes and grafanaNames are what grafana's plan entry carries for keycloak: its contribution, and the
// names the controller composed for it (issues 776, 792).
var grafanaContributes = map[string]any{"label": "grafana", "endpoint": "web", "callback": "/login/generic_oauth"}
var grafanaNames = map[string]string{"name": "grafana.example.org", "internal-name": "grafana.home.internal"}
// The consumers' file makes the same call on the adapter as the equivalent grant file does (the harness): the same
// client id, secret, and the values the redirect URIs are made from.
func TestTheConsumersFileMakesWhatTheGrantFileMakes(t *testing.T) {
grafana := provider.ConsumerIdentity("home", "grafana")
values := map[string]any{"name": grafanaNames["name"], "internal-name": grafanaNames["internal-name"]}
for k, v := range grafanaContributes {
values[k] = v
}
// Today: the grant file, through the harness.
g := newWorld(t)
g.give(map[string]any{"as": grafana, "node": "home", "values": values})
g.h.Reconcile(ctx)
// The consumers' file, through the provider helper.
w := newMovedWorld(t, map[[2]string]string{{"home", "grafana"}: "pw-" + grafana})
w.bus.bindNamed("home", "grafana", grafanaContributes, grafanaNames)
for _, o := range w.pass() {
if o.Status != provider.Answered {
t.Fatalf("%+v", o)
}
}
if len(w.a.created) != 1 || len(g.a.created) != 1 {
t.Fatalf("created %d through the helper, %d through the harness, want 1 each", len(w.a.created), len(g.a.created))
}
c, want := w.a.created[0], g.a.created[0]
if c.As != want.As || c.Password != want.Password || c.Consumer != want.Consumer ||
!reflect.DeepEqual(orEmpty(c.Values), orEmpty(want.Values)) {
t.Fatalf("made %+v, the grant file makes %+v", c, want)
}
if _, redirects, err := RedirectsOf(c.Values); err != nil ||
!reflect.DeepEqual(redirects, []string{"https://grafana.example.org/login/generic_oauth", "https://grafana.home.internal/login/generic_oauth"}) {
t.Fatalf("redirects %v, %v", redirects, err)
}
// The entry tells grafana what keycloak serves, the issuer from its own config.
var entry provider.Entry
if err := json.Unmarshal(w.bus.buckets[provider.BucketOf("keycloak")]["home.grafana.oidc-client"], &entry); err != nil {
t.Fatal(err)
}
if entry.Values["issuer"] != testIssuer || entry.Values["token-path"] != "/protocol/openid-connect/token" ||
entry.Values["authorization-path"] != "/protocol/openid-connect/auth" || entry.Values["userinfo-path"] != "/protocol/openid-connect/userinfo" {
t.Fatalf("served %v", entry.Values)
}
// Unchanged, and held: nothing is made again, within a minute or after it. No longer held: made again.
w.a.held = true
w.pass()
w.now = w.now.Add(2 * time.Minute)
w.pass()
if len(w.a.created) != 1 {
t.Fatalf("an unchanged pass created again: %d", len(w.a.created))
}
w.a.held = false
w.now = w.now.Add(2 * time.Minute)
w.pass()
if len(w.a.created) != 2 {
t.Fatalf("not made again: %d", len(w.a.created))
}
}
// Against a Keycloak, both paths leave the same client.
func TestBothPathsLeaveTheSameClientInKeycloak(t *testing.T) {
grafana := provider.ConsumerIdentity("home", "grafana")
values := map[string]any{"name": grafanaNames["name"], "internal-name": grafanaNames["internal-name"]}
for k, v := range grafanaContributes {
values[k] = v
}
made := func(through func(p provisioner)) Rep {
f, o := oidcWorld(t)
through(provisioner{clients: o, announce: func(string, map[string]any) {}, log: func(string, ...any) {}})
return only(t, f, grafana)
}
viaGrant := made(func(p provisioner) {
g := newWorld(t)
g.h.Adapter = p
g.give(map[string]any{"as": grafana, "node": "home", "values": values})
g.h.Reconcile(ctx)
})
viaFile := made(func(p provisioner) {
w := newMovedWorld(t, map[[2]string]string{{"home", "grafana"}: "pw-" + grafana})
w.h.Adapter = p
w.bus.bindNamed("home", "grafana", grafanaContributes, grafanaNames)
w.pass()
})
// Keycloak's own ids differ between two runs; nothing the mesh sets does.
for _, c := range []Rep{viaGrant, viaFile} {
delete(c, "id")
raw, _ := json.Marshal(c)
var again Rep
_ = json.Unmarshal(raw, &again)
for k := range c {
delete(c, k)
}
for k, v := range again {
c[k] = v
}
if mappers, ok := c["protocolMappers"].([]any); ok {
for _, m := range mappers {
delete(m.(map[string]any), "id")
}
}
}
if !reflect.DeepEqual(viaGrant, viaFile) {
t.Fatalf("through the file %v, through the grant %v", viaFile, viaGrant)
}
}
// A consumer that is no longer bound is kept until the harness's retirement is stable, then retired, never deleted.
func TestAConsumerMissingFromOneReadRetiresNothing(t *testing.T) {
bound, logins := bindings(2)
w := newMovedWorld(t, bound)
w.bus.bindNamed("ace", "app1", grafanaContributes, grafanaNames)
w.bus.bindNamed("ace", "app2", grafanaContributes, grafanaNames)
w.pass()
saved := w.bus.buckets[provider.AssignmentsBucket]["ace.app2"]
w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app2")
w.pass()
if len(w.a.removed) != 0 {
t.Fatalf("retired %v on one read", w.a.removed)
}
w.bus.Put(ctx, provider.AssignmentsBucket, "ace.app2", saved)
for i := 0; i < 8; i++ {
w.pass()
}
w.now = w.now.Add(15 * time.Minute)
w.pass()
if len(w.a.removed) != 0 {
t.Fatalf("retired %v", w.a.removed)
}
w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app2")
for i := 0; i < 6; i++ {
w.pass()
}
if len(w.a.removed) != 0 {
t.Fatalf("retired %v before the time was out", w.a.removed)
}
w.now = w.now.Add(11 * time.Minute)
w.pass()
w.pass()
if !reflect.DeepEqual(w.a.removed, []string{logins[1]}) || len(w.a.deleted) != 0 {
t.Fatalf("retired %v, deleted %v", w.a.removed, w.a.deleted)
}
if _, kept := w.bus.buckets[provider.BucketOf("keycloak")]["ace.app2.oidc-client"]; kept {
t.Fatal("the entry stayed after the client was retired")
}
}
// A plan list that was never read is not nobody asking.
func TestNoPlansReadRetiresNothing(t *testing.T) {
bound, _ := bindings(1)
w := newMovedWorld(t, bound)
w.bus.bindNamed("ace", "app1", grafanaContributes, grafanaNames)
w.pass()
w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app1")
for i := 0; i < 8; i++ {
w.pass()
}
w.now = w.now.Add(20 * time.Minute)
w.pass()
if len(w.a.removed) != 0 {
t.Fatalf("retired %v with no plans read", w.a.removed)
}
}
// A consumer whose credential is not in the file yet is waited for: nothing is made for it.
func TestAConsumerWithoutACredentialIsWaitedFor(t *testing.T) {
w := newMovedWorld(t, map[[2]string]string{})
w.bus.bindNamed("home", "grafana", grafanaContributes, grafanaNames)
outcomes := w.pass()
if len(outcomes) != 1 || outcomes[0].Status != provider.Waiting || len(w.a.created) != 0 {
t.Fatalf("%+v, created %v", outcomes, w.a.created)
}
}
// A consumer whose plan names its client id (a slugged module) keeps it, and the retirement counts it as asked for.
func TestAConsumerNamedByItsPlanKeepsItsClientId(t *testing.T) {
agent := provider.ConsumerIdentity("home", "dashboard")
w := newMovedWorld(t, map[[2]string]string{{"home", "grafana-2"}: "pw"})
plan, _ := json.Marshal(map[string]any{"node": "home", "module": "grafana-2", "provisioning": map[string]any{
"provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": "keycloak",
"as": agent, "contributes": grafanaContributes, "names": grafanaNames}}}})
if err := w.bus.Put(ctx, provider.AssignmentsBucket, "home.grafana-2", plan); err != nil {
t.Fatal(err)
}
for _, o := range w.pass() {
if o.Status != provider.Answered {
t.Fatalf("%+v", o)
}
}
if len(w.a.created) != 1 || w.a.created[0].As != agent {
t.Fatalf("made %+v, want one under %s", w.a.created, agent)
}
for i := 0; i < 8; i++ {
w.pass()
}
w.now = w.now.Add(15 * time.Minute)
w.pass()
w.pass()
if len(w.a.removed) != 0 {
t.Fatalf("retired %v: the plan's client id was counted unasked", w.a.removed)
}
}
// Where the file is not there, keycloak takes the grant directory, as before; where it is, the helper.
func TestTheGrantPathIsTakenWhereTheConsumersFileIsAbsent(t *testing.T) {
dir := t.TempDir()
at := filepath.Join(dir, "consumers.json")
env := func(k string) string {
if k == "MESH_CONSUMERS_FILE" {
return at
}
return ""
}
if got := handedConsumersFile(env, os.Stat); got != "" {
t.Fatalf("took %q for a file that is not there", got)
}
if got := handedConsumersFile(func(string) string { return "" }, os.Stat); got != "" {
t.Fatalf("took %q with no environment", got)
}
if err := os.WriteFile(at, []byte("[]"), 0o600); err != nil {
t.Fatal(err)
}
if got := handedConsumersFile(env, os.Stat); got != at {
t.Fatalf("took %q, want %q", got, at)
}
}
// The grant path restarts itself when the consumers file is first filled.
func TestTheGrantPathLeavesWhenTheConsumersFileIsFilled(t *testing.T) {
at := filepath.Join(t.TempDir(), "consumers.json")
done := make(chan struct{})
go exitWhenFilled(at, os.Stat, time.Millisecond, func() { close(done) })
select {
case <-done:
t.Fatal("left before the file was there")
case <-time.After(20 * time.Millisecond):
}
if err := os.WriteFile(at, []byte("[]"), 0o600); err != nil {
t.Fatal(err)
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("did not leave when the file was filled")
}
}
@@ -0,0 +1,64 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The module's directory, where its manifest and its files/ are.
const moduleDir = "../.."
// templateText is the text a file resource places on the machine: its template's, in the module's files/
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
func templateText(t *testing.T, r map[string]any) string {
t.Helper()
name, _ := r["template"].(string)
if name == "" {
t.Fatalf("resource %v names no template", r["id"])
}
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
}
return string(raw)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
if r["type"] == "file" {
templateText(t, r)
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
+2
View File
@@ -0,0 +1,2 @@
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=${secret:admin}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
${consumers:oidc-client}
+3
View File
@@ -0,0 +1,3 @@
KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}
KC_DB_USERNAME=${bound:postgres-database:as}
KC_DB_PASSWORD=${secret:postgres-database}
+1
View File
@@ -0,0 +1 @@
KC_HOSTNAME=https://${bound:route:name}
+1 -1
View File
@@ -2,4 +2,4 @@ module keycloak
go 1.25.0
require git.novox.be/novox/mesh-sdk/go v0.1.7
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19
+2 -2
View File
@@ -1,2 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19 h1:mAMt3KFmrHzDiBv4MLL94opWcG9y73ISMrBUyisapQE=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+54 -8
View File
@@ -1,6 +1,7 @@
{
"module": "keycloak",
"version": "1",
"moved": true,
"upgrade": {
"policy": "record",
"why": "every person's sign-in to every site goes through it, and its new version migrates its database on start: a person takes each build, after a backup (hq ADR 0236)"
@@ -63,7 +64,7 @@
"authorization-path": "/protocol/openid-connect/auth",
"token-path": "/protocol/openid-connect/token",
"userinfo-path": "/protocol/openid-connect/userinfo",
"issuer": "${setting:issuer}"
"issuer": "${config:issuer}"
}
},
"receives": {
@@ -84,6 +85,32 @@
}
}
},
"config": {
"url": {
"type": "string",
"description": "The address the module's provider reaches Keycloak's admin API at, as a URL; empty is this machine's Keycloak on its port.",
"trusted": true,
"default": ""
},
"user": {
"type": "string",
"description": "The Keycloak administrator the module's provider signs in as; empty is admin.",
"trusted": true,
"default": ""
},
"realm": {
"type": "string",
"description": "The realm the module's provider makes its consumers' clients in; empty is master.",
"trusted": true,
"default": ""
},
"issuer": {
"type": "string",
"description": "The issuer Keycloak's tokens name and oidc consumers are told, as the realm's URL, such as https://keycloak.example.org/realms/Example; the operator's, with no default (novox/hq ADR 0112, ADR 0155).",
"trusted": true,
"required": true
}
},
"resources": [
{
"id": "mesh-state",
@@ -102,19 +129,26 @@
"type": "directory",
"mode": "0700"
},
{
"id": "consumers",
"type": "file",
"path": "${dir:state}/consumers.json",
"mode": "0600",
"template": "files/consumers.json"
},
{
"id": "admin-env",
"type": "file",
"path": "${dir:state}/admin.env",
"mode": "0600",
"content": "KEYCLOAK_ADMIN=admin\nKEYCLOAK_ADMIN_PASSWORD=${secret:admin}\n"
"template": "files/admin.env"
},
{
"id": "database-env",
"type": "file",
"path": "${dir:state}/database.env",
"mode": "0600",
"content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nKC_DB_USERNAME=${bound:postgres-database:as}\nKC_DB_PASSWORD=${secret:postgres-database}\n"
"template": "files/database.env"
},
{
"id": "net",
@@ -126,7 +160,7 @@
"type": "file",
"path": "${dir:state}/hostname.env",
"mode": "0644",
"content": "KC_HOSTNAME=https://${bound:route:name}\n"
"template": "files/hostname.env"
},
{
"id": "server",
@@ -161,9 +195,9 @@
"type": "file",
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{}\n",
"template": "files/config.json",
"merge": "json",
"takes-settings": [
"takes-config": [
"url",
"user",
"realm",
@@ -188,9 +222,21 @@
"MESH_KEYCLOAK_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_KEYCLOAK_PASSWORD_FILE": "${dir:state}/admin.secret",
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_KEYCLOAK_CONTAINER": "keycloak"
"MESH_KEYCLOAK_CONTAINER": "keycloak",
"MESH_CONSUMERS_FILE": "${dir:state}/consumers.json"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
},
"state": [
"bound"
],
"reads": [
"mesh-controller.assignments"
]
}