An operator ran `mesh-host reconcile` on an adopted control-node with twelve modules assigned. It applied the bundle the host carries — the genesis declaration, foundation only, converged: recreated the store, failed on the broker's held port, wrote the converged base filter and started its service, and stopped at the first failing action. The filter closed the machine for forty-five minutes. The host reported the node adopted in every report, the declaration said converged, and nothing compared the two; nothing was printed before acting (hq issue 104). The host now records the node's mode — from every declaration the mesh sends, and at genesis from what the operator said — and refuses, at the point of application, a declaration that says the other mode, naming both and the act that changes it. Only a declaration the link delivers, signed, changes the mode: that is how `converge` and `adopt` arrive, so the flip still works and nothing else can do it. Genesis marks the bundle consumed, with the digest of what it applied, so `reconcile` holds a node the mesh has spoken to against what the mesh last said and never the bundle, and refuses the carried bytes when they are not what genesis applied. A file is refused when it is not what the mesh last said: a declaration carries no sequence and no issued-at, so the host cannot tell older from newer, and says so. Both commands print what they would change — a hold, a removal, an action named as one — before touching anything, and --dry-run is that list and nothing more.
159 lines
7.1 KiB
Go
159 lines
7.1 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-host/internal/apply"
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
"github.com/novox/mesh-host/internal/system"
|
|
)
|
|
|
|
// Runner is the same runner every applier in this repository takes.
|
|
type Runner = apply.Runner
|
|
|
|
// ApplyBundle raises the foundation, through the host's own apply.
|
|
//
|
|
// **This calls `internal/apply` rather than running the `mesh-host` binary**, and that is worth
|
|
// stating because shelling out would have been easier. The installer and the host must apply a
|
|
// declaration identically — same removal pass, same read-backs, same refusal model, same record of
|
|
// what this machine now owns — and two code paths that must behave the same are two code paths
|
|
// that will not. The `mesh-host` binary is also not guaranteed to be on a machine this program is
|
|
// raising, which would make the installer depend on the thing it installs.
|
|
//
|
|
// It applies under `store.OriginCarried`, which is the same origin `mesh-host reconcile` uses and
|
|
// is not a detail: what the foundation raised must be invisible to the removal pass of a
|
|
// declaration that later arrives from the control plane, or the first thing the mesh tells this
|
|
// node would tear down the mesh (novox/hq 04-ISSUES/010).
|
|
//
|
|
// What it does not do is the host's own lifecycle bookkeeping — recording a known-good version,
|
|
// clearing the launcher's start counter. Those are facts about a running `mesh-host`, and this is
|
|
// not one.
|
|
//
|
|
// What it does record is that the bundle was consumed, and in which mode the operator raised
|
|
// the machine. `raw` is the exact bytes of what is applied — the bundle as rewritten for this
|
|
// machine, not as carried — and its digest is what `mesh-host reconcile` later holds the carried
|
|
// bundle against: what genesis applied had its ports, root credentials and adoption rewritten,
|
|
// so the carried bytes are never it, and applying them on a raised node recreated the store and
|
|
// loaded the converged filter on an adopted one (novox/hq issue 104).
|
|
func ApplyBundle(ctx context.Context, o Options, sys system.System, d *declaration.Declaration,
|
|
raw []byte, run Runner, say func(string)) (apply.Report, error) {
|
|
|
|
// Refuse a shape this host cannot apply before anything is applied, exactly as `mesh-host`
|
|
// does: finding out half way through is the half-configured machine tier 0 exists to prevent.
|
|
if err := system.Check(sys, d); err != nil {
|
|
return apply.Report{}, err
|
|
}
|
|
|
|
known, err := store.Load(o.State)
|
|
if err != nil {
|
|
return apply.Report{}, err
|
|
}
|
|
|
|
// The bundle writes over whatever the machine has at the paths the foundation needs — a
|
|
// distribution's own /etc/nftables.conf among them — so it keeps the original of each file it
|
|
// has no record of, beside the node's state, exactly as a declaration from the mesh does
|
|
// (novox/hq ADR 0100).
|
|
report, updated, applyErr := apply.ApplyKeeping(ctx, sys, d, known, store.OriginCarried, run,
|
|
func(line string) { say(" " + strings.TrimPrefix(line, " ")) }, refuseSealed,
|
|
apply.KeepIn(filepath.Dir(o.State)))
|
|
|
|
// The bundle is consumed, whichever way the apply went: what is on the machine came from these
|
|
// bytes, and the carried ones must not be applied over it. The mode is the operator's word at
|
|
// genesis; the controller records the same and says it in every declaration from then on
|
|
// (novox/hq ADR 0100).
|
|
updated.Genesis = &store.Genesis{Digest: apply.DigestOf(raw), At: time.Now().UTC(), Rewritten: true}
|
|
updated.Mode = store.ModeConverged
|
|
if o.Adopted {
|
|
updated.Mode = store.ModeAdopted
|
|
}
|
|
|
|
// Saved whichever way it went, for the reason `mesh-host` gives: what was applied before a
|
|
// failure is on the machine either way, and a host that did not record it would believe it
|
|
// owns less than it does and leave that behind for ever.
|
|
if saveErr := store.Save(o.State, updated); saveErr != nil {
|
|
if applyErr != nil {
|
|
return report, fmt.Errorf("%w\n\nand this node's state could not be saved: %v",
|
|
applyErr, saveErr)
|
|
}
|
|
return report, saveErr
|
|
}
|
|
if applyErr != nil {
|
|
return report, fmt.Errorf("%w\n\nThe machine is in whatever state that left it. Fix what "+
|
|
"is named above and run this again — every step is idempotent, and the ones that "+
|
|
"already succeeded will say so", applyErr)
|
|
}
|
|
return report, nil
|
|
}
|
|
|
|
// refuseSealed is what happens when a bundle contains a file the mesh sealed to this node.
|
|
//
|
|
// It cannot happen and it is refused with a sentence rather than a nil dereference. A sealing key
|
|
// is generated at enrolment (`internal/identity`), and enrolment is something that happens on a
|
|
// mesh — which is the thing this program is raising. A foundation bundle carrying a sealed file
|
|
// would be a bundle written for a node that has already joined.
|
|
func refuseSealed(string) ([]byte, error) {
|
|
return nil, errors.New(
|
|
"this bundle contains a file sealed to a node's key, and a machine that has not enrolled " +
|
|
"has no such key. A foundation is applied before any mesh exists, so it can carry no " +
|
|
"secret the mesh sealed")
|
|
}
|
|
|
|
// WorkOutSystem decides which half of the host applies things on this machine, and proves it.
|
|
//
|
|
// `mesh-host` pins this at link time because it is built for one operating system and refuses to
|
|
// touch a machine without knowing which (novox/hq ADR 0005). An installer run by hand has no
|
|
// link-time to pin it at, so it asks — but it does not guess: every system already knows how to
|
|
// prove it is the one it claims to be, by asking its package database about a package that is
|
|
// certainly there. Exactly one may answer.
|
|
//
|
|
// A machine where none answers is refused with what each of them said, because "unsupported
|
|
// system" is a sentence nobody can act on and "pacman does not answer here" is.
|
|
func WorkOutSystem(ctx context.Context, run Runner, named string) (system.System, error) {
|
|
if strings.TrimSpace(named) != "" {
|
|
chosen, err := system.For(named)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := chosen.Confirm(ctx, run); err != nil {
|
|
return nil, fmt.Errorf("--system %s was given, and this machine says otherwise: %w",
|
|
named, err)
|
|
}
|
|
return chosen, nil
|
|
}
|
|
|
|
var answered []system.System
|
|
var refusals []string
|
|
for _, candidate := range system.All() {
|
|
if err := candidate.Confirm(ctx, run); err != nil {
|
|
refusals = append(refusals, fmt.Sprintf(" %s: %v", candidate.Name(), err))
|
|
continue
|
|
}
|
|
answered = append(answered, candidate)
|
|
}
|
|
|
|
switch len(answered) {
|
|
case 1:
|
|
return answered[0], nil
|
|
case 0:
|
|
return nil, fmt.Errorf(
|
|
"this machine is none of the systems this installer knows how to change, so nothing "+
|
|
"was attempted:\n%s\nName one with --system if it is really one of them and its "+
|
|
"package database is merely unwell", strings.Join(refusals, "\n"))
|
|
default:
|
|
var names []string
|
|
for _, s := range answered {
|
|
names = append(names, s.Name())
|
|
}
|
|
return nil, fmt.Errorf(
|
|
"this machine answers as %s at once, and the installer must not choose between them: "+
|
|
"package names and unit names differ, and picking wrong misconfigures the machine "+
|
|
"quietly. Say which with --system", strings.Join(names, " and "))
|
|
}
|
|
}
|