Refuse a declaration for the other mode, or older than the mesh's last, and say what an apply would change first
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.
This commit is contained in:
@@ -1611,6 +1611,13 @@ func digestOf(content string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// DigestOf names a declaration by its bytes, exactly as the mesh names what it sends: sha256 of
|
||||
// the raw bytes, hex. The two sides never digest different things.
|
||||
func DigestOf(raw []byte) string {
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// holds is the machine's own ports a resource occupies.
|
||||
//
|
||||
// **What the declaration binds, not what is open.** A machine's open ports are a moving target —
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// A node is adopted or converged, and a declaration says which it is for (novox/hq ADR 0100).
|
||||
//
|
||||
// **The two are not interchangeable, and the host knows which it is.** A converged declaration
|
||||
// carries the mesh's drop-by-default filter and retires the firewall the node was found with; on
|
||||
// an adopted node that closes the machine to everything the predecessor still serves — which is
|
||||
// what happened when an operator ran `reconcile` on an adopted control-node and it applied the
|
||||
// converged genesis bundle (novox/hq issue 104). The host reported "adopted" in every report and
|
||||
// compared nothing. Now it compares, at the point of application, whichever command delivered
|
||||
// the declaration.
|
||||
//
|
||||
// Only the mesh changes a node's mode, and it does so by sending a declaration: the flip arrives
|
||||
// over the link as the first converged declaration after adopted ones (`converge` on the
|
||||
// controller), and the way back as the first adopted one (`adopt`). So a declaration the link
|
||||
// delivers, signed and verified, is the mode's authority and is not checked against the record —
|
||||
// it becomes the record. Everything else — the carried bundle, a file, the kept declaration a
|
||||
// disconnected node re-applies — is held to the mode already recorded.
|
||||
|
||||
// ModeOf says which mode a declaration is for.
|
||||
func ModeOf(d *declaration.Declaration) string {
|
||||
if d.Adoption != nil {
|
||||
return store.ModeAdopted
|
||||
}
|
||||
return store.ModeConverged
|
||||
}
|
||||
|
||||
// CheckMode refuses a declaration whose mode is not the one this node has recorded. A node with
|
||||
// no recorded mode — a machine nothing has said a mode to yet — takes either.
|
||||
func CheckMode(known store.State, d *declaration.Declaration) error {
|
||||
if known.Mode == "" || known.Mode == ModeOf(d) {
|
||||
return nil
|
||||
}
|
||||
act := "`converge`"
|
||||
if ModeOf(d) == store.ModeAdopted {
|
||||
act = "`adopt`"
|
||||
}
|
||||
return fmt.Errorf("this node is %s; the declaration says %s — %s declaration is not applied "+
|
||||
"to %s node; %s on the controller is the act that changes it, and it sends the "+
|
||||
"declaration that does", known.Mode, ModeOf(d), article(ModeOf(d)), article(known.Mode), act)
|
||||
}
|
||||
|
||||
func article(mode string) string {
|
||||
if mode == store.ModeAdopted {
|
||||
return "an adopted"
|
||||
}
|
||||
return "a converged"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Defends novox/hq issue 104: a declaration is refused for the other mode than the node is in,
|
||||
// naming both and the act that changes it; a node no mode has been said to takes either.
|
||||
func TestADeclarationForTheOtherModeIsRefused(t *testing.T) {
|
||||
converged := parse(t, `{"declaration":1,"resources":[{"id":"a","type":"file","path":"/tmp/a","content":"x\n"}]}`)
|
||||
adopted := parse(t, `{"declaration":1,"adoption":{"taken":[]},"resources":[{"id":"a","type":"file","path":"/tmp/a","content":"x\n"}]}`)
|
||||
|
||||
err := CheckMode(store.State{Mode: store.ModeAdopted}, converged)
|
||||
if err == nil || !strings.Contains(err.Error(), "this node is adopted; the declaration says converged") ||
|
||||
!strings.Contains(err.Error(), "`converge`") {
|
||||
t.Errorf("a converged declaration on an adopted node: %v", err)
|
||||
}
|
||||
err = CheckMode(store.State{Mode: store.ModeConverged}, adopted)
|
||||
if err == nil || !strings.Contains(err.Error(), "this node is converged; the declaration says adopted") ||
|
||||
!strings.Contains(err.Error(), "`adopt`") {
|
||||
t.Errorf("an adopted declaration on a converged node: %v", err)
|
||||
}
|
||||
if err := CheckMode(store.State{Mode: store.ModeAdopted}, adopted); err != nil {
|
||||
t.Errorf("the node's own mode was refused: %v", err)
|
||||
}
|
||||
if err := CheckMode(store.State{}, converged); err != nil {
|
||||
t.Errorf("a node in no mode yet refused a declaration: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Defends novox/hq issue 104: what an apply would change is said before anything is, from the
|
||||
// declaration and the node's record — and an action is named as the action it is.
|
||||
|
||||
func trusted(t *testing.T, raw string) *declaration.Declaration {
|
||||
t.Helper()
|
||||
d, err := declaration.ParseFileTrusted([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("fixture is not a valid declaration: %v", err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func verbs(steps []Step) string {
|
||||
var out []string
|
||||
for _, s := range steps {
|
||||
out = append(out, s.Verb+" "+s.ID)
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
func TestAPlanNamesAnActionAsAnAction(t *testing.T) {
|
||||
d := trusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"init","type":"action","command":["createdb","mesh"],"verify":["psql","-c","select 1"]}]}`)
|
||||
steps := Plan(d, store.State{}, store.OriginCarried)
|
||||
if len(steps) != 1 || steps[0].Verb != "run" {
|
||||
t.Fatalf("an action was planned as %s", verbs(steps))
|
||||
}
|
||||
if !strings.Contains(steps[0].Why, "createdb mesh") || !strings.Contains(steps[0].Why, "nothing after it") {
|
||||
t.Errorf("the plan does not say what the action runs and what failing it means: %q", steps[0].Why)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPlanSaysWhatIsRecordedAndWhatIsNot(t *testing.T) {
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"new","type":"file","path":"/tmp/new","content":"a\n"},
|
||||
{"id":"same","type":"file","path":"/tmp/same","content":"b\n"},
|
||||
{"id":"moved","type":"file","path":"/tmp/moved","content":"c\n"},
|
||||
{"id":"sealed","type":"file","path":"/tmp/sealed","sealed":"AAAA","mode":"0600"}]}`)
|
||||
known := store.State{}
|
||||
known.Record(store.Applied{ID: "same", Type: "file", Target: "/tmp/same", Wrote: digestOf("b\n")})
|
||||
known.Record(store.Applied{ID: "moved", Type: "file", Target: "/tmp/moved", Wrote: digestOf("old\n")})
|
||||
known.Record(store.Applied{ID: "sealed", Type: "file", Target: "/tmp/sealed", Wrote: digestOf("secret")})
|
||||
known.Record(store.Applied{ID: "gone", Type: "file", Target: "/tmp/gone"})
|
||||
|
||||
got := verbs(Plan(d, known, store.OriginCarried))
|
||||
want := "remove gone, create new, check same, update moved, check sealed"
|
||||
if got != want {
|
||||
t.Errorf("planned %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPlanSaysTheFirewallAConvergingNodeRetires(t *testing.T) {
|
||||
d := parse(t, `{"declaration":1,"resources":[{"id":"a","type":"file","path":"/tmp/a","content":"x\n"}]}`)
|
||||
known := store.State{Firewall: &store.FoundFirewall{Kind: "ufw", WasActive: true, FoundAt: time.Now()}}
|
||||
known.Record(store.Applied{ID: "adoption.guard.table", Type: "file", Target: "/etc/guard", Origin: store.OriginDeclared})
|
||||
|
||||
got := verbs(Plan(d, known, store.OriginDeclared))
|
||||
// The firewall goes last but for what protected the node, which goes after it.
|
||||
if got != "create a, disable ufw, remove adoption.guard.table" {
|
||||
t.Errorf("a converging node planned %q", got)
|
||||
}
|
||||
// A file or the bundle never retires the firewall found here, and says nothing about it.
|
||||
if got := verbs(Plan(d, known, store.OriginCarried)); strings.Contains(got, "ufw") {
|
||||
t.Errorf("a carried declaration planned to touch the firewall: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPlanHoldsWhatAnAdoptedNodeFound(t *testing.T) {
|
||||
d := parse(t, `{"declaration":1,"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.page","hello-web.server"]}},
|
||||
"resources":[
|
||||
{"id":"hello-web.page","type":"file","path":"/srv/index.html","content":"x\n"},
|
||||
{"id":"hello-web.server","type":"container","name":"hello-web","image":"example/web@sha256:0000000000000000000000000000000000000000000000000000000000000000"}]}`)
|
||||
known := store.State{}
|
||||
known.RecordHeld(store.Held{ID: "hello-web.page", Module: "hello-web", Kind: "file", Target: "/srv/index.html"})
|
||||
|
||||
steps := Plan(d, known, store.OriginDeclared)
|
||||
if len(steps) != 2 || steps[0].Verb != "hold" || steps[1].Verb != "create" {
|
||||
t.Fatalf("an adopted node planned %s", verbs(steps))
|
||||
}
|
||||
if !strings.Contains(steps[1].Why, "held as it is until hello-web is taken") {
|
||||
t.Errorf("the plan does not say an untaken module's resource is held if found: %q", steps[1].Why)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
@@ -33,8 +34,15 @@ type Runner = apply.Runner
|
||||
// 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, not as carried — and its digest is what `mesh-host reconcile` later holds the carried
|
||||
// bundle against: what genesis applied had its ports, root credentials and adoption rewritten,
|
||||
// so the carried bytes are never it, and applying them 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,
|
||||
run Runner, say func(string)) (apply.Report, error) {
|
||||
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.
|
||||
@@ -55,6 +63,16 @@ func ApplyBundle(ctx context.Context, o Options, sys system.System, d *declarati
|
||||
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; the controller records the same and says it in every declaration from then on
|
||||
// (novox/hq ADR 0100).
|
||||
updated.Genesis = &store.Genesis{Digest: apply.DigestOf(raw), At: time.Now().UTC(), Rewritten: true}
|
||||
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.
|
||||
|
||||
@@ -3,12 +3,15 @@ package bootstrap
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -113,7 +116,7 @@ func TestTheBundleKeepsTheOriginalOfWhatItWritesOver(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o := Options{State: filepath.Join(dir, "state.json")}
|
||||
report, err := ApplyBundle(context.Background(), o, sys, d, nil, quietly)
|
||||
report, err := ApplyBundle(context.Background(), o, sys, d, nil, nil, quietly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -127,3 +130,40 @@ func TestTheBundleKeepsTheOriginalOfWhatItWritesOver(t *testing.T) {
|
||||
t.Errorf("the kept original is %q (%v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Defends novox/hq issue 104: genesis consumes the bundle, recording the digest of what it applied
|
||||
// and the mode the operator raised the machine in, so the host's own `reconcile` never applies the
|
||||
// carried bytes over it.
|
||||
func TestGenesisConsumesTheBundleAndRecordsTheMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
raw := []byte(`{"declaration":1,"resources":[
|
||||
{"id":"a","type":"file","path":"` + filepath.Join(dir, "a.conf") + `","content":"x\n"}]}`)
|
||||
d, err := declaration.ParseFileTrusted(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sys, err := system.For("arch")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, adopted := range []bool{false, true} {
|
||||
o := Options{State: filepath.Join(dir, fmt.Sprintf("state-%v.json", adopted)), Adopted: adopted}
|
||||
if _, err := ApplyBundle(context.Background(), o, sys, d, raw, nil, quietly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
known, err := store.Load(o.State)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if known.Genesis == nil || known.Genesis.Digest != apply.DigestOf(raw) || !known.Genesis.Rewritten {
|
||||
t.Errorf("adopted=%v: genesis did not record the bundle it consumed: %+v", adopted, known.Genesis)
|
||||
}
|
||||
want := store.ModeConverged
|
||||
if adopted {
|
||||
want = store.ModeAdopted
|
||||
}
|
||||
if known.Mode != want {
|
||||
t.Errorf("adopted=%v: genesis recorded the mode as %q, want %q", adopted, known.Mode, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,7 +566,7 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
|
||||
|
||||
// ---- 4. apply -----------------------------------------------------------------------
|
||||
say("apply — raising the foundation")
|
||||
report, err := ApplyBundle(ctx, o, sys, rewritten.Declaration, d.Run, say)
|
||||
report, err := ApplyBundle(ctx, o, sys, rewritten.Declaration, rewritten.Bundle, d.Run, say)
|
||||
result.Applied, result.Changed = len(report.Outcomes), report.Changed()
|
||||
if err != nil {
|
||||
return result, failed(StepApply, err)
|
||||
|
||||
@@ -89,7 +89,7 @@ func RetireTheTemporaryControlPlane(ctx context.Context, o Options, sys system.S
|
||||
// same state file. What makes this a removal rather than a no-op is that the state file
|
||||
// records the container as something this installer applied, and the declaration no longer
|
||||
// asks for it.
|
||||
report, err := ApplyBundle(ctx, o, sys, without, run, say)
|
||||
report, err := ApplyBundle(ctx, o, sys, without, bundle, run, say)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf(
|
||||
"%w\n\nThe permanent control plane is running and the temporary one is still here. "+
|
||||
|
||||
@@ -80,6 +80,25 @@ func SaveDeclared(path string, d Declared) error {
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
// ReadDeclared reads what was kept without proving it is the mesh's.
|
||||
//
|
||||
// For naming and comparing only — which declaration this node was last told, and what mode it
|
||||
// said — never for applying. A declaration to apply goes through LoadDeclared, which verifies.
|
||||
func ReadDeclared(path string) (Declared, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Declared{}, ErrNothingDeclared
|
||||
}
|
||||
if err != nil {
|
||||
return Declared{}, fmt.Errorf("this node was told something and cannot read it back: %w", err)
|
||||
}
|
||||
var d Declared
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
return Declared{}, fmt.Errorf("what this node was told is unreadable at %s: %w", path, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// LoadDeclared reads it back and proves it is still the mesh's.
|
||||
//
|
||||
// Verified against the signing key this node holds, which came from its token. A declaration on
|
||||
|
||||
@@ -104,6 +104,34 @@ type State struct {
|
||||
// Firewall is the firewall found on this machine when it was first adopted, and whether the
|
||||
// mesh has since retired it (novox/hq ADR 0100). Nil on a node that was never adopted.
|
||||
Firewall *FoundFirewall `json:"firewall,omitempty"`
|
||||
|
||||
// Mode is the node's mode as this host last recorded it: adopted or converged (novox/hq ADR
|
||||
// 0100). Empty on a machine nothing has said a mode to yet. Recorded from every declaration
|
||||
// the mesh sends and at genesis from what the operator said, so a declaration that says the
|
||||
// other mode can be refused before it is applied (novox/hq issue 104).
|
||||
Mode string `json:"mode,omitempty"`
|
||||
|
||||
// Genesis is the bundle this host consumed raising the foundation, if it has. Once recorded,
|
||||
// the bundle carried in the binary is not applied again: what genesis applied was rewritten
|
||||
// for this machine, and the mesh has said more since (novox/hq issue 104).
|
||||
Genesis *Genesis `json:"genesis,omitempty"`
|
||||
}
|
||||
|
||||
// Modes a node can be in (novox/hq ADR 0100).
|
||||
const (
|
||||
ModeAdopted = "adopted"
|
||||
ModeConverged = "converged"
|
||||
)
|
||||
|
||||
// Genesis is the bundle a host consumed raising this machine's foundation.
|
||||
type Genesis struct {
|
||||
// Digest is sha256 of the exact bytes applied.
|
||||
Digest string `json:"digest"`
|
||||
At time.Time `json:"at"`
|
||||
// Rewritten is true when the bytes applied were the carried bundle rewritten for this
|
||||
// machine — its foundation ports, root credentials, and adoption — so the bundle the binary
|
||||
// carries is not what was applied.
|
||||
Rewritten bool `json:"rewritten,omitempty"`
|
||||
}
|
||||
|
||||
// FoundFirewall is what the host found filtering this machine, and what it did about it.
|
||||
|
||||
Reference in New Issue
Block a user