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
118 lines
5.0 KiB
Go
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
|
|
}
|