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 · 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 } 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 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"} 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 { steps = append(steps, planned(r, d, known)) } // 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 replaces nothing that was found, so it is never held (ADR 0102). into := false if f, ok := r.(*declaration.File); ok && f.Into != "" { into = true } 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 } 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 } // 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) }