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.
171 lines
6.4 KiB
Go
171 lines
6.4 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 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.
|
|
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 {
|
|
if h, held := known.HeldAt(r.Identity()); held {
|
|
step.Verb, step.Why = "hold", "found on this machine and kept as it is until "+h.Module+" is taken"
|
|
return step
|
|
}
|
|
if module, untaken := d.Adoption.UntakenModuleOf(r.Identity()); untaken {
|
|
step.Verb = "create"
|
|
step.Why = "unless it is found on this machine — then held as it is until " + module + " is taken"
|
|
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
|
|
}
|
|
step.Verb, step.Why = "check", "recorded here; corrected if this machine drifted from it"
|
|
return step
|
|
}
|
|
|
|
// 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)
|
|
}
|