Files
mesh-host/internal/bootstrap/bus_adoption.go
T
mesh-tools 52600ec7bd
mesh/merge-gate head 52600ec7bd61 merged into trunk cdcba9ca7375 — pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes wi…
mesh/repo-check head 52600ec7bd61 merged into trunk cdcba9ca7375 — pass: its merge-check.sh passed
mesh/delivery delivered
Take the node-engine's parser from mesh-sdk: remaining imports (issue 1392)
2026-10-20 11:17:14 +00:00

180 lines
7.8 KiB
Go

package bootstrap
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/declaration"
)
// Adopting the bus is the mesh's planned step for replacing it (novox/hq ADR 0333 §3, ADR 0236).
//
// The adoption replaces the bus's image once: the module's built image takes the place of the upstream
// one the bundle raised, on the same streams, users and certificate. The controller refuses a push that
// would replace the bus — "a planned step: `bus upgrade --why …` snapshots its streams first and checks
// them after" — so genesis stopped at step 18 (issue 590). The installer takes that step: it snapshots the
// streams itself, because at genesis no backup holder exists to be asked, and hands the snapshot to
// `bus upgrade`, which sends the machine, opens `bus-maintenance`, and ends the step done only once the
// bus answers healthy again — every stream, every durable consumer, a round trip (H-bus). The installer
// waits for that end.
// adoptionWhy is what the step is recorded as, for the record of hand acts and the bus's step.
const adoptionWhy = "genesis adopts its bus as the nats module (ADR 0333)"
// Where the installer keeps the streams' snapshot before the adoption, and the credential the snapshot
// program reads while it runs. Beside genesis's own credentials; variables so a test writes elsewhere.
var (
busSnapshotFile = "/var/lib/mesh-genesis/bus-before-adoption.tar"
busSnapshotCredential = "/var/lib/mesh-genesis/bus-snapshot.credential"
busCertificateFile = "/var/lib/mesh-broker-tls/tls.crt"
busControllerSecret = BusControllerFile
// busStepWithin bounds the wait for the controller to say the step ended: its own bound (15 min,
// mesh-controller busStepBound), and its doctor's next look after that.
busStepWithin = 17 * time.Minute
)
// adoptTheBus sends the nats module to this machine through the bus's planned step. A bus already on the
// module's image is the module's: it is sent like any module, and nothing is replaced.
func adoptTheBus(ctx context.Context, o Options, control controlPlane, bus *declaration.Container,
say func(string)) error {
if running := runningImage(ctx, control, bus.Name); running != "" && running != bus.Image {
say(" already adopted " + bus.Name + " — it runs the module's image")
_, err := pushNode(ctx, o, control, say)
return err
}
image, err := theModulesBusImage(ctx, o, control)
if err != nil {
return err
}
if err := snapshotTheBus(ctx, o, control, image, say); err != nil {
return err
}
say(" restarting " + bus.Name + " — once, onto the " + BusModule + " module's image, as the bus's " +
"planned step; its streams, users and certificate stay where they are (novox/hq ADR 0333 §3, ADR 0236)")
if _, err := control.within(2*o.Wait).tell(ctx, "bus", "upgrade", "--why", adoptionWhy,
"--reversible", "--snapshot-taken", busSnapshotFile); err != nil {
return fmt.Errorf("the bus's planned step was refused, so the bus is not replaced: %w", err)
}
return nil
}
// meshBuiltImage is an image the mesh built, as it records it: `artifact-store://<module>/<artifact>@sha256:…`.
var meshBuiltImage = regexp.MustCompile(`artifact-store://([a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*@sha256:[0-9a-f]{64})`)
// theModulesBusImage is the nats module's server image, as the build just made it, pulled from this
// mesh's registry: the image that carries the snapshot program.
func theModulesBusImage(ctx context.Context, o Options, control controlPlane) (string, error) {
said, err := control.tell(ctx, "builds", BusModule, "-n", "1")
if err != nil {
return "", err
}
for _, m := range meshBuiltImage.FindAllStringSubmatch(said, -1) {
if strings.HasPrefix(m[1], BusModule+"/") {
return strings.TrimSuffix(o.Registry, "/") + "/" + m[1], nil
}
}
return "", fmt.Errorf("the mesh says no image it built for the %s module, so its snapshot program cannot "+
"be run before the bus is replaced:\n%s", BusModule, indent(strings.TrimSpace(said)))
}
// snapshotTheBus writes every stream of the running bus to busSnapshotFile with the module's own snapshot
// program (mesh-catalog modules/nats, ADR 0235), as the controller's account, pinned to the bus's
// certificate. The credential is written for the program's one run and removed after it.
func snapshotTheBus(ctx context.Context, o Options, control controlPlane, image string, say func(string)) error {
password, err := readCredentialFile(busControllerSecret)
if err != nil {
return fmt.Errorf("the controller's bus credential is not at %s, so the streams cannot be snapshotted "+
"before the bus is replaced: %w", busControllerSecret, err)
}
fingerprint, err := certificateFingerprint(busCertificateFile)
if err != nil {
return err
}
port := o.Ports.orDefaults().Bus
server := "tls://127.0.0.1:" + strconv.Itoa(port)
credential, err := json.Marshal(map[string]string{"url": server, "fingerprint": fingerprint,
"user": busControllerUser, "password": password})
if err != nil {
return err
}
if err := os.WriteFile(busSnapshotCredential, credential, 0o600); err != nil {
return fmt.Errorf("the snapshot's credential could not be written: %w", err)
}
defer os.Remove(busSnapshotCredential)
script := "docker run --rm -i --network host --entrypoint /usr/local/bin/mesh-nats-snapshot " + image +
" snapshot --server " + server + " --credential - < " + busSnapshotCredential + " > " + busSnapshotFile +
".partial && mv " + busSnapshotFile + ".partial " + busSnapshotFile
running, cancel := context.WithTimeout(ctx, busStepWithin)
defer cancel()
if out, err := control.run(running, "sh", "-c", script); err != nil {
_, _ = control.run(ctx, "rm", "-f", busSnapshotFile+".partial")
return fmt.Errorf("the bus's streams could not be snapshotted, so the bus is not replaced: %w\n%s",
err, indent(strings.TrimSpace(out)))
}
say(" snapshotted every stream of the bus, at " + busSnapshotFile + " (its module's snapshot program)")
return nil
}
// certificateFingerprint is the pin of the bus's certificate: sha256 of its DER, as every machine pins it.
func certificateFingerprint(path string) (string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("the bus's certificate is not at %s, so it cannot be pinned for the snapshot: %w", path, err)
}
block, _ := pem.Decode(raw)
if block == nil {
return "", fmt.Errorf("%s holds no PEM certificate", path)
}
sum := sha256.Sum256(block.Bytes)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
// waitForTheBusStep waits until the controller says the bus's planned step ended: done once the bus
// answered healthy — every stream, every durable consumer, a round trip — or failed, with its snapshot as
// the way back. The step's own bound is the controller's; this waits a little past it.
func waitForTheBusStep(ctx context.Context, o Options, control controlPlane, say func(string)) error {
deadline := time.Now().Add(busStepWithin)
var last string
for {
said, err := control.tell(ctx, "bus")
if err == nil {
for _, line := range strings.Split(said, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "last step ") {
last = strings.TrimSpace(line)
}
}
switch {
case strings.Contains(last, ": done at "):
say(" bus step done " + last)
return nil
case strings.Contains(last, ": failed at "):
return fmt.Errorf("the bus's planned step ended failed: %s\n%s", last, indent(strings.TrimSpace(said)))
}
}
if !time.Now().Before(deadline) {
break
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(answerEvery):
}
}
if last == "" {
return errors.New("the controller says no bus step after the adoption was sent; `mesh-controller bus` says how it stands")
}
return fmt.Errorf("the bus's planned step did not end within %s: %s", busStepWithin, last)
}