A service may omit its state, so unassigning an uplink module never stops the machine's network manager (hq ADR 0117)
This commit is contained in:
+89
-9
@@ -54,6 +54,8 @@ type Outcome struct {
|
|||||||
into *store.Into
|
into *store.Into
|
||||||
// kept is where this apply kept the original of a file it wrote over (novox/hq ADR 0100).
|
// kept is where this apply kept the original of a file it wrote over (novox/hq ADR 0100).
|
||||||
kept string
|
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
|
// 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).
|
// the next apply can say which one changed (novox/hq 04-ISSUES/103).
|
||||||
reads map[string]string
|
reads map[string]string
|
||||||
@@ -458,19 +460,21 @@ func ApplyKeeping(
|
|||||||
Origin: origin,
|
Origin: origin,
|
||||||
ID: resource.Identity(), Type: string(resource.Kind()),
|
ID: resource.Identity(), Type: string(resource.Kind()),
|
||||||
Target: outcome.Target, AppliedAt: time.Now().UTC(),
|
Target: outcome.Target, AppliedAt: time.Now().UTC(),
|
||||||
Wrote: outcome.wrote,
|
Wrote: outcome.wrote,
|
||||||
Into: outcome.into,
|
Into: outcome.into,
|
||||||
Kept: kept,
|
Kept: kept,
|
||||||
Reads: outcome.reads,
|
Reads: outcome.reads,
|
||||||
Holds: holds(resource),
|
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
|
// 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
|
// into, or a service whose lifecycle is the machine's, replaced nothing that was found, so
|
||||||
// a cutover happened; its hold from when it was declared whole goes all the same — here,
|
// its outcome says what the apply did, not that a cutover happened; a hold from when it was
|
||||||
// after the write worked, so a failed one keeps the hold and where its original is.
|
// 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 {
|
if wasHeld {
|
||||||
known.Release(held.ID)
|
known.Release(held.ID)
|
||||||
if f, isFile := resource.(*declaration.File); !isFile || f.Into == "" {
|
if !replacesNothing(resource) {
|
||||||
outcome.Detail = takenDetail(held)
|
outcome.Detail = takenDetail(held)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -919,6 +923,9 @@ type unitReloader interface {
|
|||||||
|
|
||||||
func applyService(ctx context.Context, sys system.System, r *declaration.Service, run Runner,
|
func applyService(ctx context.Context, sys system.System, r *declaration.Service, run Runner,
|
||||||
changed map[string]bool) (Outcome, error) {
|
changed map[string]bool) (Outcome, error) {
|
||||||
|
if r.Stateless() {
|
||||||
|
return reflectOnly(ctx, sys, r, run, changed)
|
||||||
|
}
|
||||||
out := begin(r)
|
out := begin(r)
|
||||||
var changes []string
|
var changes []string
|
||||||
|
|
||||||
@@ -1029,6 +1036,73 @@ func applyService(ctx context.Context, sys system.System, r *declaration.Service
|
|||||||
return out, nil
|
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
|
// remove undoes one resource the host applied and the declaration no longer names, and reports
|
||||||
// what it actually did.
|
// 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
|
return "removed", "no longer declared", nil
|
||||||
|
|
||||||
case declaration.TypeService:
|
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
|
// 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.
|
// it and does not own the unit file — only the state it put the unit into.
|
||||||
//
|
//
|
||||||
|
|||||||
+17
-3
@@ -108,7 +108,7 @@ func lookBefore(ctx context.Context, sys system.System, d *declaration.Declarati
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case *declaration.Service:
|
case *declaration.Service:
|
||||||
if known.Recorded(string(declaration.TypeService), res.Unit) {
|
if res.Stateless() || known.Recorded(string(declaration.TypeService), res.Unit) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// **Found is a unit somebody put on this machine, or one the machine uses.**
|
// **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
|
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
|
// 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
|
// 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,
|
// 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
|
// 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
|
// 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
|
// 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.
|
// it, and with it where the original was kept. A service whose lifecycle is the machine's
|
||||||
if f, ok := r.(*declaration.File); ok && f.Into != "" {
|
// replaces nothing either (novox/hq ADR 0117).
|
||||||
|
if replacesNothing(r) {
|
||||||
return false, false, out, nil
|
return false, false, out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-5
@@ -80,6 +80,9 @@ func Plan(d *declaration.Declaration, known store.State, origin string) []Step {
|
|||||||
for _, orphan := range known.Orphans(declared, origin) {
|
for _, orphan := range known.Orphans(declared, origin) {
|
||||||
step := Step{Verb: "remove", Type: orphan.Type, ID: orphan.ID, Target: orphan.Target,
|
step := Step{Verb: "remove", Type: orphan.Type, ID: orphan.ID, Target: orphan.Target,
|
||||||
Why: "recorded here and no longer declared"}
|
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) {
|
if d.Adoption == nil && strings.HasPrefix(orphan.ID, declaration.AdoptionPrefix) {
|
||||||
step.Why = "what protected this node while adopted; removed last, once everything else applied"
|
step.Why = "what protected this node while adopted; removed last, once everything else applied"
|
||||||
protecting = append(protecting, step)
|
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).
|
// A file written into, or a service whose lifecycle is the machine's, replaces nothing that
|
||||||
into := false
|
// was found, so it is never held (ADR 0102, ADR 0117).
|
||||||
if f, ok := r.(*declaration.File); ok && f.Into != "" {
|
into := replacesNothing(r)
|
||||||
into = true
|
|
||||||
}
|
|
||||||
h, held := known.HeldAt(r.Identity())
|
h, held := known.HeldAt(r.Identity())
|
||||||
module, untaken := d.Adoption.UntakenModuleOf(r.Identity())
|
module, untaken := d.Adoption.UntakenModuleOf(r.Identity())
|
||||||
switch {
|
switch {
|
||||||
@@ -182,6 +183,12 @@ func planned(r declaration.Resource, d *declaration.Declaration, known store.Sta
|
|||||||
return step
|
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())
|
was, recorded := known.Find(r.Identity())
|
||||||
if !recorded {
|
if !recorded {
|
||||||
step.Verb, step.Why = "create", "no record of it on this node"
|
step.Verb, step.Why = "create", "no record of it on this node"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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).
|
// (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.
|
// Folding them into one field would make the second expressible only by accident.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type Type `json:"type"`
|
Type Type `json:"type"`
|
||||||
Unit string `json:"unit"`
|
Unit string `json:"unit"`
|
||||||
State string `json:"state"`
|
// 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
|
// 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.
|
// the host asserts nothing about it and leaves whatever is there.
|
||||||
//
|
//
|
||||||
@@ -692,6 +702,10 @@ type TakeOver struct {
|
|||||||
Config string `json:"config"`
|
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) Identity() string { return s.ID }
|
||||||
func (s *Service) Kind() Type { return TypeService }
|
func (s *Service) Kind() Type { return TypeService }
|
||||||
func (s *Service) Target() string { return s.Unit }
|
func (s *Service) Target() string { return s.Unit }
|
||||||
@@ -701,9 +715,18 @@ func (s *Service) validate(where string, _ bool) []string {
|
|||||||
if s.Unit == "" {
|
if s.Unit == "" {
|
||||||
problems = append(problems, where+": a service needs a 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(
|
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" {
|
if s.Boot != "" && s.Boot != "enabled" && s.Boot != "disabled" {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
@@ -718,7 +741,7 @@ func (s *Service) validate(where string, _ bool) []string {
|
|||||||
case t.Unit == s.Unit:
|
case t.Unit == s.Unit:
|
||||||
problems = append(problems, fmt.Sprintf("%s: takes-over names %s, which is this service's own unit",
|
problems = append(problems, fmt.Sprintf("%s: takes-over names %s, which is this service's own unit",
|
||||||
where, t.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 "+
|
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")
|
"the found one for a service that will not run would leave the peers with nothing")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,11 @@ type Applied struct {
|
|||||||
// said once in a log line is not a path the host can find again.
|
// said once in a log line is not a path the host can find again.
|
||||||
Kept string `json:"kept,omitempty"`
|
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
|
// 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
|
// 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.
|
// file itself was — so undeclaring it gives the machine back exactly what it had.
|
||||||
|
|||||||
Reference in New Issue
Block a user