diff --git a/internal/apply/apply.go b/internal/apply/apply.go index fa0a83f..fff75e1 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -54,6 +54,8 @@ type Outcome struct { into *store.Into // kept is where this apply kept the original of a file it wrote over (novox/hq ADR 0100). kept string + // stateless is a service whose unit's lifecycle is the machine's (novox/hq ADR 0117). + stateless bool // reads is, for a container, the digest of each file it was created reading, by path — so // the next apply can say which one changed (novox/hq 04-ISSUES/103). reads map[string]string @@ -458,19 +460,21 @@ func ApplyKeeping( Origin: origin, ID: resource.Identity(), Type: string(resource.Kind()), Target: outcome.Target, AppliedAt: time.Now().UTC(), - Wrote: outcome.wrote, - Into: outcome.into, - Kept: kept, - Reads: outcome.reads, - Holds: holds(resource), + Wrote: outcome.wrote, + Into: outcome.into, + Kept: kept, + Reads: outcome.reads, + Stateless: outcome.stateless, + Holds: holds(resource), }) // Its module has been taken, and what was held for it is now the mesh's. A file written - // into replaced nothing that was found, so its outcome says what the write did, not that - // a cutover happened; its hold from when it was declared whole goes all the same — here, - // after the write worked, so a failed one keeps the hold and where its original is. + // into, or a service whose lifecycle is the machine's, replaced nothing that was found, so + // its outcome says what the apply did, not that a cutover happened; a hold from when it was + // declared otherwise goes all the same — here, after the apply worked, so a failed one + // keeps the hold and where its original is. if wasHeld { known.Release(held.ID) - if f, isFile := resource.(*declaration.File); !isFile || f.Into == "" { + if !replacesNothing(resource) { outcome.Detail = takenDetail(held) } } @@ -919,6 +923,9 @@ type unitReloader interface { func applyService(ctx context.Context, sys system.System, r *declaration.Service, run Runner, changed map[string]bool) (Outcome, error) { + if r.Stateless() { + return reflectOnly(ctx, sys, r, run, changed) + } out := begin(r) var changes []string @@ -1029,6 +1036,73 @@ func applyService(ctx context.Context, sys system.System, r *declaration.Service return out, nil } +// reflectOnly is a service whose unit's lifecycle is the machine's (novox/hq ADR 0117): nothing is +// started, stopped, enabled or disabled, and a changed trigger is acted on only where the unit is +// already running. An inactive unit is left so — started, it would be a second network manager on +// a machine that uses another — and it reads the change when whatever starts it does. +func reflectOnly(ctx context.Context, sys system.System, r *declaration.Service, run Runner, + changed map[string]bool) (Outcome, error) { + out := begin(r) + out.stateless = true + restart := reflected(r, changed) + reload := restartedBy(r.ReloadOn, changed) + if len(restart) == 0 && len(reload) == 0 { + out.Action = "unchanged" + out.Detail = "its lifecycle is the machine's; nothing it reflects changed" + return out, nil + } + state, err := sys.ServiceState(ctx, run, r.Unit) + if err != nil { + return out, err + } + if state != "running" { + out.Action = "unchanged" + out.Detail = "not running; the change applies at its next start" + return out, nil + } + if len(restart) > 0 { + // The same as a stated service: the unit's own file may be what changed, and the manager + // reads that again only when told to. + if u, ok := sys.(unitReloader); ok { + if err := u.ReloadUnits(ctx, run); err != nil { + return out, fmt.Errorf("reloading the service manager's units for %s: %w", r.Unit, err) + } + } + if err := sys.SetServiceState(ctx, run, r.Unit, "stopped"); err != nil { + return out, fmt.Errorf("restarting %s: stopping it: %w", r.Unit, err) + } + if err := sys.SetServiceState(ctx, run, r.Unit, "running"); err != nil { + return out, fmt.Errorf("restarting %s: starting it again: %w", r.Unit, err) + } + } else { + reloader, ok := sys.(serviceReloader) + if !ok { + return out, fmt.Errorf("%s must be reloaded for %s and this machine's service manager "+ + "cannot reload a unit", r.Unit, strings.Join(reload, ", ")) + } + if err := reloader.ReloadService(ctx, run, r.Unit); err != nil { + return out, fmt.Errorf("reloading %s: %w", r.Unit, err) + } + } + // Read back: it was running, and a restart or reload that left it otherwise is a failure — + // the machine's network manager down is not a change to report and move past. + after, err := sys.ServiceState(ctx, run, r.Unit) + if err != nil { + return out, err + } + out.Action = "updated" + if len(restart) > 0 { + out.Detail = "restarted for " + strings.Join(restart, ", ") + } else { + out.Detail = "reloaded for " + strings.Join(reload, ", ") + } + if after != "running" { + return out, fmt.Errorf("%s was %s to pick up a change and is %s", r.Unit, + strings.Fields(out.Detail)[0], after) + } + return out, nil +} + // remove undoes one resource the host applied and the declaration no longer names, and reports // what it actually did. // @@ -1086,6 +1160,12 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner) return "removed", "no longer declared", nil case declaration.TypeService: + // A unit whose lifecycle was the machine's is left exactly as it is (novox/hq ADR 0117): + // stopping it here is how unassigning an uplink module would take down the machine's + // network manager, and with it the channel the mesh reaches the machine on. + if a.Stateless { + return "forgotten", "its state was never the mesh's", nil + } // A unit that is no longer declared is stopped, not deleted. The host did not install // it and does not own the unit file — only the state it put the unit into. // diff --git a/internal/apply/hold.go b/internal/apply/hold.go index 1bd5cca..4525ced 100644 --- a/internal/apply/hold.go +++ b/internal/apply/hold.go @@ -108,7 +108,7 @@ func lookBefore(ctx context.Context, sys system.System, d *declaration.Declarati } } case *declaration.Service: - if known.Recorded(string(declaration.TypeService), res.Unit) { + if res.Stateless() || known.Recorded(string(declaration.TypeService), res.Unit) { continue } // **Found is a unit somebody put on this machine, or one the machine uses.** @@ -261,6 +261,19 @@ func heldContainer(known store.State, name string) (store.Held, bool) { return store.Held{}, false } +// replacesNothing is a resource that takes nothing found on the machine from it, so on an adopted +// node it is never held and never previewed as replacing what was found: a file written into +// (novox/hq ADR 0102), and a service whose unit's lifecycle is the machine's (novox/hq ADR 0117). +func replacesNothing(r declaration.Resource) bool { + switch res := r.(type) { + case *declaration.File: + return res.Into != "" + case *declaration.Service: + return res.Stateless() + } + return false +} + // holdOnAdopted decides whether a resource of an adopted node is held rather than applied, and // holds it (novox/hq ADR 0100, ADR 0103). For a module not yet taken, what is present with no // record is kept as it is: a file or a container under its name, a directory, a service's unit, @@ -297,8 +310,9 @@ func holdOnAdopted(ctx context.Context, sys system.System, r declaration.Resourc // A file written into replaces nothing that was found, so it is never held (novox/hq ADR // 0102) — and a hold from when it was declared whole must not keep the mesh's keys out. That // hold is released by the apply once the write has worked, not here: a write that fails keeps - // it, and with it where the original was kept. - if f, ok := r.(*declaration.File); ok && f.Into != "" { + // it, and with it where the original was kept. A service whose lifecycle is the machine's + // replaces nothing either (novox/hq ADR 0117). + if replacesNothing(r) { return false, false, out, nil } diff --git a/internal/apply/plan.go b/internal/apply/plan.go index 07205bd..c79212b 100644 --- a/internal/apply/plan.go +++ b/internal/apply/plan.go @@ -80,6 +80,9 @@ func Plan(d *declaration.Declaration, known store.State, origin string) []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) @@ -139,11 +142,9 @@ func planned(r declaration.Resource, d *declaration.Declaration, known store.Sta } } } - // 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 - } + // 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 { @@ -182,6 +183,12 @@ func planned(r declaration.Resource, d *declaration.Declaration, known store.Sta 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" diff --git a/internal/apply/stateless_test.go b/internal/apply/stateless_test.go new file mode 100644 index 0000000..7ebdaa1 --- /dev/null +++ b/internal/apply/stateless_test.go @@ -0,0 +1,191 @@ +package apply + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/store" +) + +// Defends novox/hq ADR 0117: a service that omits its state leaves the unit's lifecycle to the +// machine. The mesh reflects its triggers on a unit already running and does nothing else to it — +// never starts, stops, enables or disables it, and forgets it when undeclared. + +// unitIn is a service manager whose one unit is active or not, recording what it is asked. +func unitIn(active bool, commands *[]string) Runner { + return func(_ context.Context, name string, args ...string) (string, error) { + line := name + " " + strings.Join(args, " ") + *commands = append(*commands, line) + switch { + case strings.Contains(line, "is-enabled"): + return "enabled", nil + case strings.Contains(line, "show") && strings.Contains(line, "ActiveState"): + if active { + return "LoadState=loaded\nActiveState=active\nSubState=running", nil + } + return "LoadState=loaded\nActiveState=inactive\nSubState=dead", nil + } + return "", nil + } +} + +func statelessDecl(path, content, triggers string) string { + return fmt.Sprintf(`{"declaration":1,"resources":[ + {"id":"uplink.conf","type":"file","path":%q,"into":"block","content":%q}, + {"id":"uplink.manager","type":"service","unit":"NetworkManager.service",%s} + ]}`, path, content, triggers) +} + +// touched is whether any command would change the unit's lifecycle. +func touched(commands []string) []string { + var changing []string + for _, c := range commands { + for _, verb := range []string{" start ", " stop ", " restart ", " enable ", " disable ", " reload "} { + if strings.Contains(c+" ", verb) { + changing = append(changing, c) + } + } + } + return changing +} + +func TestAStatelessServiceRunningIsReloadedForItsTrigger(t *testing.T) { + path := filepath.Join(t.TempDir(), "mesh.conf") + var commands []string + report, _, err := Apply(context.Background(), archHost(t), + parse(t, statelessDecl(path, "[main]\ndns=none\n", `"reload-on":["uplink.conf"]`)), + store.State{}, store.OriginDeclared, unitIn(true, &commands), nil, nil) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(commands, "\n") + if !strings.Contains(joined, "systemctl reload NetworkManager.service") { + t.Errorf("the running manager was not reloaded; commands were %v", commands) + } + for _, c := range touched(commands) { + if !strings.Contains(c, "reload") { + t.Errorf("the manager's lifecycle was touched: %s", c) + } + } + if o := outcomeOf(report, "uplink.manager"); o.Action != "updated" || !strings.Contains(o.Detail, "reloaded for uplink.conf") { + t.Errorf("reported as %q: %s", o.Action, o.Detail) + } +} + +func TestAStatelessServiceNotRunningIsLeftSo(t *testing.T) { + path := filepath.Join(t.TempDir(), "mesh.conf") + for _, triggers := range []string{`"reload-on":["uplink.conf"]`, `"restart-on":["uplink.conf"]`} { + var commands []string + report, _, err := Apply(context.Background(), archHost(t), + parse(t, statelessDecl(path, fmt.Sprintf("# %s\n", triggers), triggers)), + store.State{}, store.OriginDeclared, unitIn(false, &commands), nil, nil) + if err != nil { + t.Fatal(err) + } + if changing := touched(commands); len(changing) > 0 { + t.Errorf("%s: an inactive unit was acted on: %v", triggers, changing) + } + if o := outcomeOf(report, "uplink.manager"); o.Action != "unchanged" || + o.Detail != "not running; the change applies at its next start" { + t.Errorf("%s: reported as %q: %s", triggers, o.Action, o.Detail) + } + } +} + +func TestAStatelessServiceIsRestartedOnlyForItsRestartTrigger(t *testing.T) { + path := filepath.Join(t.TempDir(), "mesh.conf") + var commands []string + d := parse(t, statelessDecl(path, "x\n", `"restart-on":["uplink.conf"]`)) + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, + unitIn(true, &commands), nil, nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(commands, "\n"), "stop NetworkManager.service") { + t.Errorf("not restarted for its restart trigger; commands were %v", commands) + } + // Nothing it reflects changed: nothing is asked of the unit at all. + commands = nil + report, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, + unitIn(true, &commands), nil, nil) + if err != nil { + t.Fatal(err) + } + if changing := touched(commands); len(changing) > 0 { + t.Errorf("with nothing changed the unit was acted on: %v", changing) + } + if got := outcomeOf(report, "uplink.manager").Action; got != "unchanged" { + t.Errorf("with nothing changed it was %q", got) + } +} + +func TestAStatelessServiceUndeclaredIsForgottenNotStopped(t *testing.T) { + path := filepath.Join(t.TempDir(), "mesh.conf") + var commands []string + _, state, err := Apply(context.Background(), archHost(t), + parse(t, statelessDecl(path, "x\n", `"reload-on":["uplink.conf"]`)), + store.State{}, store.OriginDeclared, unitIn(true, &commands), nil, nil) + if err != nil { + t.Fatal(err) + } + if rec, _ := state.Find("uplink.manager"); !rec.Stateless { + t.Fatal("the record does not say the service was stateless") + } + if steps := Plan(somethingElse(t), state, store.OriginDeclared); !hasStep(steps, "forget", "uplink.manager") { + t.Errorf("the preview does not forget it: %v", steps) + } + commands = nil + report, _, err := Apply(context.Background(), archHost(t), somethingElse(t), state, store.OriginDeclared, + unitIn(true, &commands), nil, nil) + if err != nil { + t.Fatal(err) + } + if changing := touched(commands); len(changing) > 0 { + t.Errorf("undeclaring acted on the unit: %v", changing) + } + o := outcomeOf(report, "uplink.manager") + if o.Action != "forgotten" || o.Detail != "its state was never the mesh's" { + t.Errorf("undeclaring was %q: %s", o.Action, o.Detail) + } +} + +func TestAStatelessServiceIsNeverHeldOnAnAdoptedNode(t *testing.T) { + path := filepath.Join(t.TempDir(), "mesh.conf") + resources := fmt.Sprintf(`{"id":"uplink.conf","type":"file","path":%q,"into":"block","content":"x\n"}, + {"id":"uplink.manager","type":"service","unit":"NetworkManager.service","reload-on":["uplink.conf"]}`, path) + d := adopted(t, `{"taken":[],"untaken":{"uplink":["uplink.conf","uplink.manager"]}}`, resources) + for _, s := range Plan(d, store.State{}, store.OriginDeclared) { + if s.ID == "uplink.manager" && (s.Verb == "hold" || s.Verb == "create" || strings.Contains(s.Why, "replaces")) { + t.Errorf("the preview holds or replaces a stateless service: %+v", s) + } + } + var commands []string + report, state, err := ApplyKeeping(context.Background(), archHost(t), d, store.State{}, + store.OriginDeclared, unitIn(true, &commands), nil, nil, KeepIn(t.TempDir())) + if err != nil { + t.Fatal(err) + } + if got := outcomeOf(report, "uplink.manager").Action; got == "held" { + t.Fatal("a stateless service was held, though it replaces nothing that was found") + } + if len(state.Held) != 0 { + t.Errorf("something was held: %+v", state.Held) + } + for _, c := range touched(commands) { + if !strings.Contains(c, "reload") { + t.Errorf("the adopted node's manager lifecycle was touched: %s", c) + } + } +} + +func hasStep(steps []Step, verb, id string) bool { + for _, s := range steps { + if s.Verb == verb && s.ID == id { + return true + } + } + return false +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 0b5d078..5ba9091 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -645,10 +645,20 @@ func (d *Process) validate(where string, _ bool) []string { // (it will come back at boot), or disabled and running (started by hand, gone after a reboot). // Folding them into one field would make the second expressible only by accident. type Service struct { - ID string `json:"id"` - Type Type `json:"type"` - Unit string `json:"unit"` - State string `json:"state"` + ID string `json:"id"` + Type Type `json:"type"` + Unit string `json:"unit"` + // State is "running" or "stopped" — or absent, and then **the unit's lifecycle is the + // machine's; the mesh only reflects its triggers** (novox/hq ADR 0117). The uplink modules + // declare the machine's own network manager this way: the mesh writes into its configuration + // and needs it to read that again, and nothing more. Stated, the host would start the manager + // on a machine that uses another one — two managers fighting over the same links — and, when + // the module was unassigned, stop it: the machine's network, the channel the mesh itself + // arrives on, gone at the moment of a routine change. So a service without a state is never + // started, stopped, enabled or disabled, is reloaded or restarted only when a trigger changed + // and it is already running, and undeclared is simply forgotten. It says nothing unless it + // names a trigger, and it may not say boot or takes-over, which are both lifecycle. + State string `json:"state,omitempty"` // Boot is "enabled" or "disabled" — whether the unit starts at boot. Optional: absent means // the host asserts nothing about it and leaves whatever is there. // @@ -692,6 +702,10 @@ type TakeOver struct { Config string `json:"config"` } +// Stateless reports whether the unit's lifecycle is the machine's, and the mesh only reflects the +// service's triggers (novox/hq ADR 0117). +func (s *Service) Stateless() bool { return s.State == "" } + func (s *Service) Identity() string { return s.ID } func (s *Service) Kind() Type { return TypeService } func (s *Service) Target() string { return s.Unit } @@ -701,9 +715,18 @@ func (s *Service) validate(where string, _ bool) []string { if s.Unit == "" { problems = append(problems, where+": a service needs a unit") } - if s.State != "running" && s.State != "stopped" { + switch { + case s.State == "running" || s.State == "stopped": + case s.State != "": problems = append(problems, fmt.Sprintf( - "%s: state %q; a service is \"running\" or \"stopped\"", where, s.State)) + "%s: state %q; a service is \"running\" or \"stopped\", or omits state to leave the "+ + "unit's lifecycle to the machine", where, s.State)) + case s.Boot != "" || s.TakesOver != nil: + problems = append(problems, where+": a service that omits state leaves the unit's lifecycle "+ + "to the machine, and boot and takes-over are both its lifecycle") + case len(s.RestartOn) == 0 && len(s.ReloadOn) == 0: + problems = append(problems, where+": a service that omits state leaves the unit's lifecycle "+ + "to the machine, and names no restart-on or reload-on — it declares nothing") } if s.Boot != "" && s.Boot != "enabled" && s.Boot != "disabled" { problems = append(problems, fmt.Sprintf( @@ -718,7 +741,7 @@ func (s *Service) validate(where string, _ bool) []string { case t.Unit == s.Unit: problems = append(problems, fmt.Sprintf("%s: takes-over names %s, which is this service's own unit", where, t.Unit)) - case s.State != "running": + case s.State != "running" && s.State != "": problems = append(problems, where+": a service that takes over a tunnel is running — stopping "+ "the found one for a service that will not run would leave the peers with nothing") } diff --git a/internal/declaration/stateless_test.go b/internal/declaration/stateless_test.go new file mode 100644 index 0000000..3338908 --- /dev/null +++ b/internal/declaration/stateless_test.go @@ -0,0 +1,30 @@ +package declaration + +import ( + "strings" + "testing" +) + +// Defends novox/hq ADR 0117: a service may leave its unit's lifecycle to the machine, and then +// says nothing but its triggers. +func TestAServiceWithoutAStateSaysOnlyItsTriggers(t *testing.T) { + for name, c := range map[string]struct{ resource, refusal string }{ + "no trigger": {`{"id":"s","type":"service","unit":"NetworkManager.service"}`, "declares nothing"}, + "with boot": {`{"id":"s","type":"service","unit":"NetworkManager.service","boot":"enabled","reload-on":["f"]}`, "boot and takes-over"}, + "with takes-over": {`{"id":"s","type":"service","unit":"a.service","reload-on":["f"],"takes-over":{"interface":"wg0","unit":"b.service","config":"/etc/x"}}`, "boot and takes-over"}, + "an unknown state": {`{"id":"s","type":"service","unit":"a.service","state":"paused"}`, "omits state to leave the unit's lifecycle to the machine"}, + } { + _, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"f","type":"file","path":"/etc/x","content":"x"},` + c.resource + `]}`)) + if err == nil || !strings.Contains(err.Error(), c.refusal) { + t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err) + } + } + d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"f","type":"file","path":"/etc/x","content":"x"}, + {"id":"s","type":"service","unit":"NetworkManager.service","restart-on":["f"]}]}`)) + if err != nil { + t.Fatal(err) + } + if s := d.Resources[1].(*Service); !s.Stateless() { + t.Error("a service without a state was not read as stateless") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 9465b27..fc1d26b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -77,6 +77,11 @@ type Applied struct { // said once in a log line is not a path the host can find again. Kept string `json:"kept,omitempty"` + // Stateless is, for a service, that its unit's lifecycle was never the mesh's (novox/hq ADR + // 0117) — kept here because removal happens once the declaration that said so is gone, and a + // service removed as if it had a state is stopped: the machine's network manager, for one. + Stateless bool `json:"stateless,omitempty"` + // Into is set for a file written into rather than over (novox/hq ADR 0102): the format, what // each of the mesh's keys held before it set them, which of them were absent, and whether the // file itself was — so undeclaring it gives the machine back exactly what it had.