Files
mesh-host/internal/apply/plan.go
T

272 lines
10 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 · 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 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"
}
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, 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
}
// 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)
}