Kept on disk it was the take's fallback; once the mesh's interface is up in its place and a peer has handshaken with it, it is an unmaintained way back onto the network, held for ever. It is now removed from where its unit reads it, its kept original verified first and left as it is, and the hold ends. Until proven — no handshake, or wg not answering — it is kept and the report says why. The retirement is recorded apart from holds, so later applies, an undeclare, and a reassignment find it retired rather than missing, and nothing writes it back.
338 lines
14 KiB
Go
338 lines
14 KiB
Go
package apply
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"github.com/novox/mesh-host/internal/firewall"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
)
|
|
|
|
// A declaration is said before it is done.
|
|
//
|
|
// An apply that prints what it did after it did it is a report; what an operator reaching for
|
|
// `reconcile` under pressure needs is a preview — the base filter that closed an adopted
|
|
// control-node for forty-five minutes was listed nowhere until it was on disk (novox/hq issue
|
|
// 104). Converging already previews on the controller; the host's own commands now do too, and
|
|
// `--dry-run` is the preview and nothing else.
|
|
|
|
// Step is one thing an apply would do to this machine.
|
|
type Step struct {
|
|
// Verb is create · update · check · hold · run · remove · forget · restore · disable · enable.
|
|
Verb string `json:"verb"`
|
|
Type string `json:"type,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
Target string `json:"target,omitempty"`
|
|
Why string `json:"why,omitempty"`
|
|
}
|
|
|
|
func (s Step) String() string {
|
|
line := fmt.Sprintf("%-8s %-10s %s", s.Verb, s.Type, s.ID)
|
|
if s.Target != "" && s.Target != s.ID {
|
|
line += " (" + s.Target + ")"
|
|
}
|
|
if s.Why != "" {
|
|
line += " — " + s.Why
|
|
}
|
|
return line
|
|
}
|
|
|
|
// Plan says what applying a declaration would change, in the order ApplyKeeping would do it,
|
|
// before anything on the machine is touched.
|
|
//
|
|
// **Read from the declaration and the node's own record, not from the machine.** What the
|
|
// record cannot settle — whether a file recorded here has since drifted, whether a container
|
|
// runs the spec it was made from — is said as a check, because that is what the apply does: it
|
|
// reads the machine and corrects it. What the record does settle is said as it is: a resource
|
|
// with no record is created; a plain file whose declared content differs from what this host
|
|
// last wrote is updated; a hold is kept; an action is run — an action is a command, and a
|
|
// preview that folded it into "check" would hide the one kind of step that is not read back
|
|
// from state.
|
|
func Plan(d *declaration.Declaration, known store.State, origin string) []Step {
|
|
var steps []Step
|
|
|
|
declared := map[string]bool{}
|
|
for _, r := range d.Resources {
|
|
declared[r.Identity()] = true
|
|
}
|
|
// The found tunnel's configuration is held under an id of its own, declared for as long as the
|
|
// service taking it over is — as ApplyKeeping counts it, or a plan would forget a hold the
|
|
// apply keeps (novox/hq ADR 0105).
|
|
if svc := takesOver(d); svc != nil {
|
|
declared[takeOverID(svc)] = true
|
|
}
|
|
rec := known.Firewall
|
|
ufw := rec != nil && rec.Kind == string(firewall.UFW)
|
|
|
|
if d.Adoption != nil && ufw && rec.DisabledByMesh {
|
|
steps = append(steps, Step{Verb: "enable", Type: "firewall", ID: "ufw",
|
|
Why: "this node is adopted again, so the firewall found on it is put back in force"})
|
|
}
|
|
|
|
// What is held and no longer declared is let go of on paper only (ApplyKeeping does this
|
|
// before the resources); the file or container itself is left as found.
|
|
if origin == store.OriginDeclared {
|
|
for _, h := range known.Held {
|
|
if declared[h.ID] {
|
|
continue
|
|
}
|
|
steps = append(steps, Step{Verb: "forget", Type: h.Kind, ID: h.ID, Target: h.Target,
|
|
Why: "held for " + h.Module + " and no longer declared; left as found"})
|
|
}
|
|
}
|
|
|
|
var protecting, orphans []Step
|
|
made := meshMadeUnits(known)
|
|
for _, orphan := range known.Orphans(declared, origin) {
|
|
step := Step{Verb: "remove", Type: orphan.Type, ID: orphan.ID, Target: orphan.Target,
|
|
Why: "recorded here and no longer declared"}
|
|
switch {
|
|
case orphan.Stateless:
|
|
step.Verb, step.Why = "forget", "no longer declared; its unit's state was never the mesh's and is left as it is"
|
|
case orphan.Type == string(declaration.TypeService):
|
|
// What removal will do, said before it does it (novox/hq ADR 0118), in removeService's
|
|
// words. "restore" only where it may stop or disable something — the record cannot say
|
|
// whether the unit is still as the mesh left it, so "may" is as far as a preview goes —
|
|
// and a unit whose file the mesh wrote is named as the mesh's, since that one is
|
|
// stopped whatever was found.
|
|
f := orphan.Found
|
|
switch {
|
|
case made[orphan.Target]:
|
|
step.Verb, step.Why = "remove", "no longer declared; the mesh wrote its unit file, so it is "+
|
|
"stopped and disabled at boot before that file goes"
|
|
case f == nil:
|
|
step.Verb, step.Why = "forget", "no longer declared; recorded before the host kept what it "+
|
|
"found, so it is left as it is"
|
|
case f.State == "stopped" || f.Boot == "disabled":
|
|
var back []string
|
|
if f.State == "stopped" {
|
|
back = append(back, "stopped")
|
|
}
|
|
if f.Boot == "disabled" {
|
|
back = append(back, "disabled at boot")
|
|
}
|
|
step.Verb, step.Why = "restore", "no longer declared; the host found it "+
|
|
strings.Join(back, " and ")+", and it goes back to that if the mesh changed it"
|
|
default:
|
|
step.Verb, step.Why = "forget", "no longer declared; it was running before the mesh and is "+
|
|
"left as it is — nothing is started or stopped on the way out"
|
|
}
|
|
}
|
|
if d.Adoption == nil && strings.HasPrefix(orphan.ID, declaration.AdoptionPrefix) {
|
|
step.Why = "what protected this node while adopted; removed last, once everything else applied"
|
|
protecting = append(protecting, step)
|
|
continue
|
|
}
|
|
orphans = append(orphans, step)
|
|
}
|
|
|
|
// The guard goes up before anything is removed on an adopted node; on a converged one the
|
|
// removals go first (novox/hq ADR 0103).
|
|
var guard, rest []declaration.Resource
|
|
for _, r := range d.Resources {
|
|
if d.Adoption != nil && strings.HasPrefix(r.Identity(), guardPrefix) {
|
|
guard = append(guard, r)
|
|
continue
|
|
}
|
|
rest = append(rest, r)
|
|
}
|
|
for _, r := range guard {
|
|
steps = append(steps, planned(r, d, known))
|
|
}
|
|
steps = append(steps, orphans...)
|
|
for _, r := range rest {
|
|
step := planned(r, d, known)
|
|
if svc, ok := r.(*declaration.Service); ok && svc.TakesOver != nil && d.Adoption != nil && step.Verb != "hold" {
|
|
// The take comes before the service that replaces the tunnel, as it does in the apply.
|
|
steps = append(steps, plannedTake(svc, known))
|
|
}
|
|
steps = append(steps, step)
|
|
}
|
|
|
|
// Only a declaration from the mesh converges a node; a bundle or a file never retires the
|
|
// firewall found here, and neither says so in a plan.
|
|
if d.Adoption == nil && origin == store.OriginDeclared && ufw && rec.WasActive && !rec.DisabledByMesh {
|
|
steps = append(steps, Step{Verb: "disable", Type: "firewall", ID: "ufw",
|
|
Why: "this node converges: retired once the mesh's own filter is loaded, never before; " +
|
|
"its configuration stays on disk"})
|
|
}
|
|
steps = append(steps, protecting...)
|
|
return steps
|
|
}
|
|
|
|
// planned is what one declared resource would come to.
|
|
//
|
|
// **In the order holdOnAdopted decides it**, because the one cutover ADR 0100 says must be
|
|
// previewed is the one a plan gets backwards if it looks at the record first: a resource this
|
|
// node holds for a module the declaration now says is taken is not held any longer — it is
|
|
// applied, and what was found is replaced. The declaration's word on which modules are untaken
|
|
// comes first; the record of what is held only says what that replacement replaces.
|
|
func planned(r declaration.Resource, d *declaration.Declaration, known store.State) Step {
|
|
step := Step{Type: string(r.Kind()), ID: r.Identity(), Target: r.Target()}
|
|
|
|
if d.Adoption != nil {
|
|
// Something run inside a held container is held with it, while that container's module
|
|
// is untaken; once the module is taken the container is replaced before this runs.
|
|
if in := runsIn(r); in != "" {
|
|
if container, isHeld := heldContainer(known, in); isHeld {
|
|
if _, untaken := d.Adoption.Untaken[container.Module]; untaken {
|
|
step.Verb = "hold"
|
|
step.Why = "runs in " + in + ", which is held as found; not run until " + container.Module + " is taken"
|
|
return step
|
|
}
|
|
}
|
|
}
|
|
// A file written into, or a service whose lifecycle is the machine's, replaces nothing that
|
|
// was found, so it is never held (ADR 0102, ADR 0117).
|
|
into := replacesNothing(r)
|
|
h, held := known.HeldAt(r.Identity())
|
|
module, untaken := d.Adoption.UntakenModuleOf(r.Identity())
|
|
switch {
|
|
case into:
|
|
case untaken && held:
|
|
step.Verb, step.Why = "hold", "found on this machine and kept as it is until "+module+" is taken"
|
|
return step
|
|
case untaken:
|
|
step.Verb = "create"
|
|
step.Why = "unless it is found on this machine — then held as it is until " + module + " is taken"
|
|
return step
|
|
case held:
|
|
// The cutover: the module is taken, and what was held for it is replaced.
|
|
step.Verb = "create"
|
|
if _, recorded := known.Find(r.Identity()); recorded {
|
|
step.Verb = "update"
|
|
}
|
|
step.Why = h.Module + " is taken: replaces what was found and held"
|
|
if h.Kept != "" {
|
|
step.Why += "; the original stays at " + h.Kept
|
|
}
|
|
return step
|
|
}
|
|
}
|
|
|
|
if a, ok := r.(*declaration.Action); ok {
|
|
// Named as what it is. Its verify decides whether it runs, and that is read from the
|
|
// machine, not the record.
|
|
step.Verb = "run"
|
|
step.Target = ""
|
|
step.Why = fmt.Sprintf("an action: `%s`, unless its verify `%s` already passes; if it fails, "+
|
|
"nothing after it is attempted", strings.Join(a.Command, " "), strings.Join(a.Verify, " "))
|
|
if a.In != "" {
|
|
step.Why = "in " + a.In + ", " + step.Why
|
|
}
|
|
return step
|
|
}
|
|
|
|
if svc, ok := r.(*declaration.Service); ok && svc.Stateless() {
|
|
// Nothing is created: the unit and whether it runs are the machine's (novox/hq ADR 0117).
|
|
step.Verb, step.Why = "check", "its lifecycle is the machine's; reloaded or restarted only if "+
|
|
"running when what it reflects changes"
|
|
return step
|
|
}
|
|
was, recorded := known.Find(r.Identity())
|
|
if !recorded {
|
|
step.Verb, step.Why = "create", "no record of it on this node"
|
|
return step
|
|
}
|
|
if want := wouldWrite(r); want != "" && was.Wrote != "" && want != was.Wrote {
|
|
step.Verb, step.Why = "update", "the declaration changed since this host applied it"
|
|
return step
|
|
}
|
|
if c, ok := r.(*declaration.Container); ok {
|
|
if changed := readsChanged(c, d, known, was.Reads); len(changed) > 0 {
|
|
step.Verb = "update"
|
|
step.Why = "recreated: " + strings.Join(changed, ", ") + " changed since it was created"
|
|
return step
|
|
}
|
|
}
|
|
step.Verb, step.Why = "check", "recorded here; corrected if this machine drifted from it"
|
|
return step
|
|
}
|
|
|
|
// plannedTake is what the take of a found tunnel would do to its configuration (novox/hq ADR 0105,
|
|
// ADR 0119): kept as found while the take is not proven, and retired — removed from where its unit
|
|
// reads it, its original staying kept — by the first apply that finds the mesh's interface up in
|
|
// its place with a peer handshaken. Whether that is this apply is read from the machine, which a
|
|
// plan does not do, so it says when rather than whether. One the mesh retired already is said as
|
|
// retired: nothing brings it back.
|
|
func plannedTake(svc *declaration.Service, known store.State) Step {
|
|
t := svc.TakesOver
|
|
step := Step{Verb: "hold", Type: string(declaration.TypeFile), ID: takeOverID(svc), Target: t.Config}
|
|
if r, ok := known.RetiredAt(t.Config); ok {
|
|
step.Verb = "check"
|
|
step.Why = "retired once the take of " + t.Interface + " was proven; its original stays at " + r.Kept +
|
|
" and the mesh never brings it back"
|
|
return step
|
|
}
|
|
step.Why = "the configuration of the tunnel " + t.Interface + ", kept as found while " + svc.Unit +
|
|
" takes it over (" + t.Unit + " stopped and disabled, never flushed); retired — removed from " +
|
|
t.Config + ", its original staying kept — once the take is proven by a peer handshaking on " +
|
|
strings.TrimPrefix(svc.Unit, "wg-quick@")
|
|
if h, ok := known.HeldAt(takeOverID(svc)); ok && h.Kept != "" {
|
|
step.Why += "; the original is at " + h.Kept
|
|
}
|
|
return step
|
|
}
|
|
|
|
// readsChanged is which of the files a container was created reading the apply will hand it
|
|
// changed — the same comparison applyContainer makes (novox/hq 04-ISSUES/103), settled from the
|
|
// declaration and the record alone.
|
|
//
|
|
// A file's digest is what this apply will record for it: a plain file declared here, by its
|
|
// declared content; otherwise what this host last wrote there, under any id. A file neither
|
|
// declares nor records — an env-file a predecessor left — is read by the apply from the machine,
|
|
// which a plan does not do, so it stays a check. A container with no record of what it read was
|
|
// labelled before the host kept that record and is accepted as it is, so it is a check too.
|
|
func readsChanged(c *declaration.Container, d *declaration.Declaration, known store.State,
|
|
wasReading map[string]string) []string {
|
|
if len(wasReading) == 0 {
|
|
return nil
|
|
}
|
|
willWrite := map[string]string{}
|
|
for _, r := range d.Resources {
|
|
if f, ok := r.(*declaration.File); ok {
|
|
if want := wouldWrite(f); want != "" {
|
|
willWrite[f.Path] = want
|
|
}
|
|
}
|
|
}
|
|
// Only what it still reads: a file it was created reading and no longer names is a changed
|
|
// declaration, not a changed file.
|
|
stillReads := map[string]bool{}
|
|
for _, path := range c.EnvFile {
|
|
stillReads[path] = true
|
|
}
|
|
for _, v := range c.Volumes {
|
|
if src := mountSource(v); strings.HasPrefix(src, "/") {
|
|
stillReads[src] = true
|
|
}
|
|
}
|
|
var changed []string
|
|
for _, path := range sortedKeys(wasReading) {
|
|
if !stillReads[path] {
|
|
continue
|
|
}
|
|
now, settled := willWrite[path]
|
|
if !settled {
|
|
if f, recorded := known.At(string(declaration.TypeFile), path); recorded {
|
|
now, settled = f.Wrote, true
|
|
}
|
|
}
|
|
if settled && now != wasReading[path] {
|
|
changed = append(changed, path)
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
// wouldWrite is the digest a plain file would be recorded under, or empty where only the apply
|
|
// can know: a sealed file, one with secrets in it, one written into, one carrying bytes.
|
|
func wouldWrite(r declaration.Resource) string {
|
|
f, ok := r.(*declaration.File)
|
|
if !ok || f.Into != "" || f.Bytes != "" || f.Secret() || len(f.Secrets) > 0 {
|
|
return ""
|
|
}
|
|
return digestOf(f.Content)
|
|
}
|