Files
mesh-controller/cmd/mesh-controller/rotate.go
T
jschoubben c3b88b9148 Rename mesh-control -> mesh-controller, substrate -> foundation
One name per thing, per the HQ glossary: the module/container/image/binary/repo
becomes mesh-controller, the seat the-controller, and the store+broker pair the
foundation (embedded base bundles, default template and example lock renamed with
their go:embed directives). No behaviour change — a pure vocabulary rename.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-16 18:40:40 +02:00

118 lines
5.0 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"sort"
)
// rotateCommand replaces a credential and moves both ends together.
//
// **This is the invariant novox/hq ADR 0001 records as unowned, and it was measurably false.** On
// 2026-08-22 `provision_ensure` — documented as never rotating an existing secret — minted a new
// password on every adoption and updated only the provider's row. Consumers on three nodes held
// dead credentials for two days; two rows for one provision were written 216 ms apart, so at most
// one could match the live role. Nothing enumerated who held the old one, and nothing said so.
//
// Three things make that impossible here, and all three are deliberate:
//
// **The holders are a set the mesh can name.** Each pair has its own credential, so rotating one
// consumer's password touches one role and leaves every other consumer alone — and the list of who
// is affected is a query rather than an assumption.
//
// **Both ends are pushed by this command, not by a later one.** A rotation that changed the record
// and left the sending to whoever remembered is the fault above, exactly.
//
// **It is all-or-nothing.** If any affected machine cannot be resolved, nothing is sent and the old
// credential keeps working — which is a mesh that has not rotated, and is far better than one that
// has half-rotated.
func rotateCommand(ctx context.Context, args []string) error {
set := flag.NewFlagSet("rotate", flag.ContinueOnError)
// One consumer rather than all of them. Ordinary: a credential is suspected on one machine,
// and rotating the other nine would be a great deal of disruption for one suspicion.
only := set.String("consumer", "", "only this machine's credential, rather than every holder's")
positionals, err := parseAround(set, args)
if err != nil {
return err
}
if len(positionals) != 1 {
return errors.New("rotate <provision> [--consumer <machine>]")
}
provision := positionals[0]
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
inv := open.inventory
holders, err := inv.HoldersOf(ctx, provision, *only)
if err != nil {
return err
}
if len(holders) == 0 {
// Said, not silent. "Nobody holds this" and "this did not run" must never look the same —
// and a rotation somebody believes happened is worse than one they know did not.
if *only != "" {
return fmt.Errorf(
"%s holds no credential for %q, so there is nothing to rotate. `plan %s` says "+
"what it does hold", *only, provision, *only)
}
return fmt.Errorf(
"nothing in this mesh holds a credential for %q, so there is nothing to rotate",
provision)
}
// Every machine at both ends, named before anything changes. A person about to rotate a
// production credential is entitled to know the blast radius before it is the past tense.
affected := map[string]bool{}
for _, h := range holders {
affected[h.Consumer] = true
affected[h.Provider] = true
}
machines := make([]string, 0, len(affected))
for name := range affected {
machines = append(machines, name)
}
sort.Strings(machines)
fmt.Printf("rotating %s for %d holder(s):\n", provision, len(holders))
for _, h := range holders {
// The module, because a machine may hold several credentials for one provision and
// rotating "anchor's database password" now means rotating three of them.
fmt.Printf(" %s on %s, from %s\n", h.ConsumerModule, h.Consumer, h.Provider)
}
for _, h := range holders {
if err := inv.RotateSecret(ctx, h.Provision, h.Consumer, h.ConsumerModule, h.Provider); err != nil {
// Partly rotated, and said so plainly. What is gone is remade on the next push, so
// the remedy is to run this again rather than to repair anything — but a machine
// whose secret was discarded and not resent is holding a credential the provider is
// about to stop honouring, and that is worth knowing now.
return fmt.Errorf(
"rotating %s for %s on %s from %s: %w\n\nSome credentials were discarded and "+
"not yet sent. Run this again once the cause is fixed",
h.Provision, h.ConsumerModule, h.Consumer, h.Provider, err)
}
}
// **Both ends, in one send.** There is a window either way — a role's password changes on the
// provider and the file changes on the consumer, and they cannot be simultaneous — so the
// honest thing is to make it as short as the broker allows and to never leave it open across
// a command boundary, where it depends on somebody's memory.
fmt.Printf("\nsending to both ends:\n")
if err := sendTo(ctx, open, machines); err != nil {
return fmt.Errorf(
"%w\n\nThe old credentials are gone from the mesh and the new ones have not arrived. "+
"Nothing on those machines has changed yet, so what is running keeps working "+
"until the provider next applies. Fix the cause and run `push --behind`", err)
}
fmt.Printf("\n%d machine(s) told. Until both ends have applied, a consumer whose password "+
"changed cannot authenticate — `status` says who is still behind\n", len(machines))
return nil
}