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 — and its digest is what `mesh-host reconcile` later holds the carried bundle against, // by digest: a host built from the carried template does not match it, since genesis rewrote the // ports, root credentials and adoption; a host built from the lock genesis wrote out does. // Applying the unrewritten template 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 } // One apply at a time on this machine: a host already running here applies too. unlock, err := store.Lock(o.State, func() { say(" waiting another apply holds this node's state") }) if err != nil { return apply.Report{}, err } defer unlock() 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, and only at genesis: the controller records the same and says it in every // declaration from then on (novox/hq ADR 0100), so a node the mesh has spoken to — this // installer re-run on it, or finishing a pivot — keeps the mode it has, which may since have // been flipped. updated.Genesis = &store.Genesis{Digest: apply.DigestOf(raw), At: time.Now().UTC(), Rewritten: true} if updated.Mode == "" { 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 ")) } }