diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 6aa2b14..f7e5d62 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -50,6 +50,8 @@ type Outcome struct { // wrote is a digest of what this apply put there, kept so the next one can tell a machine // that drifted from one the mesh changed its mind about. Not reported: it is bookkeeping. wrote string + // into is what a file written into held before the mesh's keys (novox/hq ADR 0102). + into *store.Into } // Report is what an apply did, in the order it did it. @@ -298,6 +300,7 @@ func ApplyKeeping( ID: resource.Identity(), Type: string(resource.Kind()), Target: outcome.Target, AppliedAt: time.Now().UTC(), Wrote: outcome.wrote, + Into: outcome.into, Holds: holds(resource), }) // Its module has been taken, and what was held for it is now the mesh's. @@ -489,6 +492,9 @@ func applyAccess(r *declaration.Access) (Outcome, error) { } func applyFile(r *declaration.File, previous store.Applied, unseal Unseal) (Outcome, error) { + if r.Into != "" { + return applyInto(r, previous) + } out := begin(r) // What actually goes on disk. For a sealed file the mesh never had this, and neither did @@ -688,6 +694,11 @@ func reflected(r *declaration.Service, changed map[string]bool) []string { return restartedBy(r.RestartOn, changed) } +// serviceReloader is a service manager that can tell a running unit to read its configuration again. +type serviceReloader interface { + ReloadService(ctx context.Context, run system.Runner, unit string) error +} + // unitReloader is a service manager that caches unit files and must be told to read them again. type unitReloader interface { ReloadUnits(ctx context.Context, run system.Runner) error @@ -774,6 +785,25 @@ func applyService(ctx context.Context, sys system.System, r *declaration.Service "%s was restarted to pick up a change and is %s", r.Unit, after) } changes = append(changes, "restarted for "+strings.Join(reflected(r, changed), ", ")) + } else if r.State == "running" && len(restartedBy(r.ReloadOn, changed)) > 0 { + // Told to read its configuration again, not stopped: for a service whose restart would + // stop what it runs — every container, for the container runtime (novox/hq ADR 0102). + 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(restartedBy(r.ReloadOn, changed), ", ")) + } + if err := reloader.ReloadService(ctx, run, r.Unit); err != nil { + return out, fmt.Errorf("reloading %s: %w", r.Unit, err) + } + after, err := sys.ServiceState(ctx, run, r.Unit) + if err != nil { + return out, err + } + if after != "running" { + return out, fmt.Errorf("%s was reloaded to pick up a change and is %s", r.Unit, after) + } + changes = append(changes, "reloaded for "+strings.Join(restartedBy(r.ReloadOn, changed), ", ")) } if len(changes) == 0 { @@ -828,6 +858,9 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner) return "removed", "no longer declared, and empty", nil case declaration.TypeFile: + if a.Into != nil { + return removeInto(a) + } if err := os.RemoveAll(a.Target); err != nil { return "", "", err } diff --git a/internal/apply/hold.go b/internal/apply/hold.go index 89a4d44..d8ddfb4 100644 --- a/internal/apply/hold.go +++ b/internal/apply/hold.go @@ -46,9 +46,13 @@ func KeepIn(dir string) Keep { } // holdable is whether a resource is one a predecessor can already have on the machine: a file at -// a path, or a container under a name. +// a path, or a container under a name. A file written into is not: it replaces nothing that was +// found, only adds the mesh's keys beside it (novox/hq ADR 0102). func holdable(r declaration.Resource) bool { - return r.Kind() == declaration.TypeFile || r.Kind() == declaration.TypeContainer + if f, ok := r.(*declaration.File); ok { + return f.Into == "" + } + return r.Kind() == declaration.TypeContainer } // found is whether a declared file or container is present on the machine with no record of this diff --git a/internal/apply/into.go b/internal/apply/into.go new file mode 100644 index 0000000..d2e4c91 --- /dev/null +++ b/internal/apply/into.go @@ -0,0 +1,235 @@ +package apply + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "sort" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/store" +) + +// A file written into, never over (novox/hq ADR 0102). +// +// **The file is the machine's; the mesh owns keys in it.** The container runtime's configuration +// is the case that needed it: the mesh states one fact there — its registry is trusted over the +// private network — and writing the file whole replaced everything the machine had set, down to +// where the runtime keeps its data. So the host reads what is there, sets only the declared keys, +// keeps every other key as it found it, and records what each of its keys held before. Undeclared, +// each key goes back, and a file the mesh created goes only if nothing but its keys is left. + +// applyInto writes a file's declared keys into the object already at its path. +func applyInto(r *declaration.File, previous store.Applied) (Outcome, error) { + out := begin(r) + if r.Into != declaration.IntoJSON { + return out, fmt.Errorf("%s: into %q is not a format this host writes into", r.Path, r.Into) + } + var declared map[string]json.RawMessage + if err := json.Unmarshal([]byte(r.Content), &declared); err != nil { + return out, fmt.Errorf("%s: the keys to write are not a JSON object: %w", r.Path, err) + } + + existing, err := os.ReadFile(r.Path) + existed := err == nil + if err != nil && !errors.Is(err, os.ErrNotExist) { + return out, err + } + object := map[string]json.RawMessage{} + if existed && len(bytes.TrimSpace(existing)) > 0 { + if err := json.Unmarshal(existing, &object); err != nil || object == nil { + // Refused, never replaced: a file the host cannot read as an object is a file it + // cannot write into without losing whatever it is. + return out, fmt.Errorf("%s is not a JSON object, so the mesh cannot write its keys into it "+ + "without replacing what is there; it was left as it is", r.Path) + } + } + + rec := store.Into{Format: declaration.IntoJSON, Before: map[string]json.RawMessage{}} + if previous.Into != nil { + rec.Created = previous.Into.Created + for k, v := range previous.Into.Before { + rec.Before[k] = v + } + rec.Absent = slices.Clone(previous.Into.Absent) + } else { + rec.Created = !existed + } + tracked := func(k string) bool { _, ok := rec.Before[k]; return ok || slices.Contains(rec.Absent, k) } + + // Drift: the machine no longer holds what this host last set in its keys. + drifted := previous.Wrote != "" && existed && digestOf(keysOf(object, keysTracked(rec))) != previous.Wrote + + // Keys the mesh set before and no longer declares go back to what they held. + for _, k := range keysTracked(rec) { + if _, still := declared[k]; still { + continue + } + giveBack(object, &rec, k) + } + // Declared keys: remember what each held the first time, then set it. + for _, k := range keysIn(declared) { + if !tracked(k) { + if v, had := object[k]; had { + rec.Before[k] = v + } else { + rec.Absent = append(rec.Absent, k) + } + } + object[k] = declared[k] + } + + want, err := render(object) + if err != nil { + return out, err + } + same := existed && canonical(existing) == canonical(want) + if !same { + mode := os.FileMode(0o644) + if info, err := os.Stat(r.Path); err == nil { + mode = info.Mode().Perm() // the machine's file keeps the machine's mode + } else if r.Mode != "" { + if m, err := modeOf(r.Mode, mode); err == nil { + mode = m + } + } + if err := os.MkdirAll(filepath.Dir(r.Path), 0o755); err != nil { + return out, err + } + if err := writeAtomically(r.Path, want, mode); err != nil { + return out, err + } + } + // Read back: every declared key holds what was declared. + written, err := os.ReadFile(r.Path) + if err != nil { + return out, fmt.Errorf("wrote into %s and cannot read it back: %w", r.Path, err) + } + var check map[string]json.RawMessage + if err := json.Unmarshal(written, &check); err != nil { + return out, fmt.Errorf("%s is not a JSON object after writing into it: %w", r.Path, err) + } + for k, v := range declared { + if canonical(check[k]) != canonical(v) { + return out, fmt.Errorf("%s does not hold the declared %q after writing into it", r.Path, k) + } + } + + if len(rec.Before) == 0 { + rec.Before = nil + } + out.into = &rec + out.wrote = digestOf(keysOf(check, keysIn(declared))) + switch { + case !existed: + out.Action = "created" + out.Detail = "written into; the file was not there" + case same: + out.Action = "unchanged" + case drifted: + out.Action = "corrected" + out.Detail = "the mesh's keys had been changed on the machine; the rest of the file was kept" + default: + out.Action = "updated" + out.Detail = "the mesh's keys written in; every other key kept as it was" + } + return out, nil +} + +// removeInto gives back what a file written into held before the mesh's keys. +func removeInto(a store.Applied) (string, string, error) { + existing, err := os.ReadFile(a.Target) + if errors.Is(err, os.ErrNotExist) { + return "forgotten", "no longer there", nil + } + if err != nil { + return "", "", err + } + object := map[string]json.RawMessage{} + if len(bytes.TrimSpace(existing)) > 0 { + if err := json.Unmarshal(existing, &object); err != nil || object == nil { + return "kept", "no longer a JSON object, so the mesh's keys were left in it; " + + "remove them by hand", nil + } + } + rec := *a.Into + for _, k := range keysTracked(rec) { + giveBack(object, &rec, k) + } + if a.Into.Created && len(object) == 0 { + if err := os.Remove(a.Target); err != nil { + return "", "", err + } + return "removed", "no longer declared; the mesh had created it and nothing else was in it", nil + } + want, err := render(object) + if err != nil { + return "", "", err + } + info, err := os.Stat(a.Target) + if err != nil { + return "", "", err + } + if err := writeAtomically(a.Target, want, info.Mode().Perm()); err != nil { + return "", "", err + } + return "restored", "no longer declared; the mesh's keys were given back what they held", nil +} + +func giveBack(object map[string]json.RawMessage, rec *store.Into, k string) { + if v, had := rec.Before[k]; had { + object[k] = v + delete(rec.Before, k) + return + } + delete(object, k) + rec.Absent = slices.DeleteFunc(rec.Absent, func(a string) bool { return a == k }) +} + +func keysTracked(rec store.Into) []string { + var keys []string + for k := range rec.Before { + keys = append(keys, k) + } + keys = append(keys, rec.Absent...) + sort.Strings(keys) + return slices.Compact(keys) +} + +func keysOf(object map[string]json.RawMessage, keys []string) string { + var b bytes.Buffer + for _, k := range keys { + b.WriteString(k + "=" + canonical(object[k]) + "\n") + } + return b.String() +} + +func keysIn(m map[string]json.RawMessage) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// canonical is a JSON value compacted, so formatting is not mistaken for a change. +func canonical(raw []byte) string { + var b bytes.Buffer + if err := json.Compact(&b, raw); err != nil { + return string(raw) + } + return b.String() +} + +func render(object map[string]json.RawMessage) ([]byte, error) { + b, err := json.MarshalIndent(object, "", " ") + if err != nil { + return nil, err + } + return append(b, '\n'), nil +} diff --git a/internal/apply/into_test.go b/internal/apply/into_test.go new file mode 100644 index 0000000..421e013 --- /dev/null +++ b/internal/apply/into_test.go @@ -0,0 +1,213 @@ +package apply + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/store" +) + +// Defends novox/hq ADR 0102: a file the mesh shares with software it did not install is written +// into, never over, and a service that re-reads its configuration is reloaded, not restarted. + +func intoDecl(t *testing.T, path, keys string) string { + t.Helper() + return fmt.Sprintf(`{"declaration":1,"resources":[ + {"id":"networking.registry-trust","type":"file","path":%q,"into":"json","content":%q} + ]}`, path, keys) +} + +func readObject(t *testing.T, path string) map[string]any { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var o map[string]any + if err := json.Unmarshal(raw, &o); err != nil { + t.Fatalf("%s is not a JSON object: %v\n%s", path, err, raw) + } + return o +} + +// The machine's own runtime settings, the way a predecessor leaves them. +const machinesOwn = `{"data-root":"/srv/docker","log-opts":{"max-size":"10m"},"insecure-registries":["192.0.2.7:5000"]}` + +func TestWritingIntoKeepsEveryKeyTheMachineHad(t *testing.T) { + path := filepath.Join(t.TempDir(), "daemon.json") + if err := os.WriteFile(path, []byte(machinesOwn), 0o600); err != nil { + t.Fatal(err) + } + d := parse(t, intoDecl(t, path, `{"insecure-registries":["10.42.0.1:5000"]}`)) + report, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + o := readObject(t, path) + if o["data-root"] != "/srv/docker" { + t.Errorf("the machine's data directory was not kept: %v", o) + } + if fmt.Sprint(o["log-opts"]) != "map[max-size:10m]" { + t.Errorf("the machine's logging settings were not kept: %v", o) + } + if fmt.Sprint(o["insecure-registries"]) != "[10.42.0.1:5000]" { + t.Errorf("the mesh's key was not written: %v", o) + } + if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 { + t.Errorf("the machine's file mode was changed to %o", info.Mode().Perm()) + } + if got := report.Outcomes[0].Action; got != "updated" { + t.Errorf("writing into was reported as %q", got) + } + + // Again, with nothing changed: nothing to do. + report, state, err = Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if got := report.Outcomes[0].Action; got != "unchanged" { + t.Errorf("a second apply was %q", got) + } + + // Undeclared: the key goes back to what the machine had, and the file stays. + empty := somethingElse(t) + report, _, err = Apply(context.Background(), archHost(t), empty, state, store.OriginDeclared, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + o = readObject(t, path) + if fmt.Sprint(o["insecure-registries"]) != "[192.0.2.7:5000]" || o["data-root"] != "/srv/docker" { + t.Errorf("undeclaring did not give the machine back what it had: %v", o) + } + if got := report.Outcomes[0].Action; got != "restored" { + t.Errorf("undeclaring was reported as %q", got) + } +} + +func TestAFileWrittenIntoThatWasNotThereIsRemovedWhenOnlyTheMeshsKeysAreLeft(t *testing.T) { + path := filepath.Join(t.TempDir(), "daemon.json") + d := parse(t, intoDecl(t, path, `{"insecure-registries":["10.42.0.1:5000"]}`)) + report, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if got := report.Outcomes[0].Action; got != "created" { + t.Errorf("writing into a file that was not there was %q", got) + } + // Somebody else adds a key of their own: the file is no longer only the mesh's. + o := readObject(t, path) + o["debug"] = true + raw, _ := json.Marshal(o) + _ = os.WriteFile(path, raw, 0o644) + + empty := somethingElse(t) + if _, _, err := Apply(context.Background(), archHost(t), empty, state, store.OriginDeclared, nil, nil, nil); err != nil { + t.Fatal(err) + } + o = readObject(t, path) + if _, still := o["insecure-registries"]; still || o["debug"] != true { + t.Errorf("undeclaring should remove the mesh's key and keep the other: %v", o) + } + + // Without the other key, the file the mesh created goes. + path2 := filepath.Join(t.TempDir(), "daemon.json") + d2 := parse(t, intoDecl(t, path2, `{"insecure-registries":["10.42.0.1:5000"]}`)) + _, state2, err := Apply(context.Background(), archHost(t), d2, store.State{}, store.OriginDeclared, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if _, _, err := Apply(context.Background(), archHost(t), empty, state2, store.OriginDeclared, nil, nil, nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path2); !os.IsNotExist(err) { + t.Errorf("a file the mesh created, holding only its keys, was left behind") + } +} + +func TestAKeyNoLongerDeclaredGoesBackAndANewOneIsRemembered(t *testing.T) { + path := filepath.Join(t.TempDir(), "daemon.json") + _ = os.WriteFile(path, []byte(machinesOwn), 0o644) + first := parse(t, intoDecl(t, path, `{"insecure-registries":["10.42.0.1:5000"]}`)) + _, state, err := Apply(context.Background(), archHost(t), first, store.State{}, store.OriginDeclared, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + second := parse(t, intoDecl(t, path, `{"registry-mirrors":["http://10.42.0.1:5000"]}`)) + if _, _, err := Apply(context.Background(), archHost(t), second, state, store.OriginDeclared, nil, nil, nil); err != nil { + t.Fatal(err) + } + o := readObject(t, path) + if fmt.Sprint(o["insecure-registries"]) != "[192.0.2.7:5000]" { + t.Errorf("a key the mesh stopped declaring was not given back: %v", o) + } + if fmt.Sprint(o["registry-mirrors"]) != "[http://10.42.0.1:5000]" { + t.Errorf("the newly declared key was not written: %v", o) + } +} + +func TestAFileThatIsNotAnObjectIsRefusedAndLeftAlone(t *testing.T) { + path := filepath.Join(t.TempDir(), "daemon.json") + _ = os.WriteFile(path, []byte("# not json at all\n"), 0o644) + d := parse(t, intoDecl(t, path, `{"insecure-registries":["10.42.0.1:5000"]}`)) + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, nil, nil, nil); err == nil { + t.Fatal("writing into a file that is not a JSON object was not refused") + } + raw, _ := os.ReadFile(path) + if string(raw) != "# not json at all\n" { + t.Errorf("a file the mesh could not write into was changed: %q", raw) + } +} + +func TestAFileWrittenIntoIsNeverHeldOnAnAdoptedNode(t *testing.T) { + path := filepath.Join(t.TempDir(), "daemon.json") + _ = os.WriteFile(path, []byte(machinesOwn), 0o644) + d := adopted(t, `{"taken":[],"untaken":{"networking":["networking.registry-trust"]}}`, + fmt.Sprintf(`{"id":"networking.registry-trust","type":"file","path":%q,"into":"json","content":%q}`, + path, `{"insecure-registries":["10.42.0.1:5000"]}`)) + m := &machine{} + report, state := applyAdopted(t, d, store.State{}, m, t.TempDir()) + if got := outcomeOf(report, "networking.registry-trust").Action; got == "held" { + t.Fatal("a file written into was held, though it replaces nothing that was found") + } + if len(state.Held) != 0 { + t.Errorf("something was held: %+v", state.Held) + } + o := readObject(t, path) + if o["data-root"] != "/srv/docker" || fmt.Sprint(o["insecure-registries"]) != "[10.42.0.1:5000]" { + t.Errorf("the adopted node's file was not written into: %v", o) + } +} + +func TestAServiceIsReloadedNotRestartedForWhatItReloadsOn(t *testing.T) { + path := filepath.Join(t.TempDir(), "daemon.json") + d := parse(t, fmt.Sprintf(`{"declaration":1,"resources":[ + {"id":"trust","type":"file","path":%q,"into":"json","content":%q}, + {"id":"runtime","type":"service","unit":"docker.service","state":"running","reload-on":["trust"]} + ]}`, path, `{"insecure-registries":["10.42.0.1:5000"]}`)) + var commands []string + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, + recordingServices(&commands), nil, nil); err != nil { + t.Fatal(err) + } + joined := strings.Join(commands, "\n") + if !strings.Contains(joined, "systemctl reload docker.service") { + t.Errorf("the runtime was not reloaded; commands were %v", commands) + } + if strings.Contains(joined, "stop docker.service") || strings.Contains(joined, "restart docker.service") { + t.Errorf("the runtime was stopped, which stops every container on the machine; commands were %v", commands) + } +} + +// somethingElse is a declaration that no longer holds the file: only an unrelated directory. +func somethingElse(t *testing.T) *declaration.Declaration { + t.Helper() + return parse(t, fmt.Sprintf(`{"declaration":1,"resources":[ + {"id":"other","type":"directory","path":%q} + ]}`, filepath.Join(t.TempDir(), "other"))) +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 991654f..a458c8a 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -154,6 +154,13 @@ type File struct { // (ADR 0030), and it does not overwrite that either. CreateOnce bool `json:"create-once,omitempty"` + // Into says the file is shared with software the mesh did not install, and the content is + // the mesh's part of it: written into what is there, never over it (novox/hq ADR 0102). Only + // "json" is spoken — the content is a JSON object whose keys the host sets in the file's + // object, keeping every other key as it found it and recording what each of its keys held + // before, so undeclaring the file gives those back. + Into string `json:"into,omitempty"` + // Sealed is content encrypted to this node's sealing key, for a file the mesh must deliver // without being able to read. // @@ -224,6 +231,24 @@ func (f *File) validate(where string, _ bool) []string { if f.Path == "" { problems = append(problems, where+": a file needs a path") } + switch f.Into { + case "": + case IntoJSON: + var object map[string]json.RawMessage + if err := json.Unmarshal([]byte(f.Content), &object); err != nil || object == nil { + problems = append(problems, where+ + ": a file written into JSON carries a JSON object of the keys it sets") + } + if f.Sealed != "" || f.Bytes != "" || len(f.Secrets) > 0 || f.CreateOnce { + problems = append(problems, where+ + ": a file written into says only its keys, in content — not sealed, bytes, "+ + "secrets or create-once") + } + default: + problems = append(problems, fmt.Sprintf( + "%s: into %q; a file is written into \"json\", or omits it to be written whole", + where, f.Into)) + } var said []string for name, value := range map[string]string{ "content": f.Content, "sealed": f.Sealed, "bytes": f.Bytes, @@ -586,6 +611,12 @@ type Service struct { // would be an action, and the link may not carry one (novox/hq ADR 0005) — so this is not a // way around that rule, it is the shape the rule leaves. RestartOn []string `json:"restart-on,omitempty"` + + // ReloadOn names resources whose change means this service must be reloaded — for a service + // that re-reads its configuration when told to, where a restart would stop what it runs: the + // container runtime, whose restart stops every container on the machine (novox/hq ADR 0102). + // A change that is also in RestartOn restarts it, which covers a reload. + ReloadOn []string `json:"reload-on,omitempty"` } func (s *Service) Identity() string { return s.ID } @@ -609,6 +640,9 @@ func (s *Service) validate(where string, _ bool) []string { return problems } +// IntoJSON is the one structured format a file is written into. +const IntoJSON = "json" + // Opening is a port reachable on an adopted node, from where, and on which path. // // **From** is everywhere or mesh — the private network, by its interface. **Path** is incoming, diff --git a/internal/declaration/into_test.go b/internal/declaration/into_test.go new file mode 100644 index 0000000..531e015 --- /dev/null +++ b/internal/declaration/into_test.go @@ -0,0 +1,35 @@ +package declaration + +import ( + "strings" + "testing" +) + +// Defends novox/hq ADR 0102: a file written into carries only a JSON object of its keys, in a +// format the host speaks, and a service may name what it is reloaded on. + +func TestAFileWrittenIntoIsRefusedUnlessItIsAnObjectOfKeys(t *testing.T) { + for name, c := range map[string]struct{ resource, refusal string }{ + "another format": {`{"id":"f","type":"file","path":"/etc/x","into":"toml","content":"a = 1"}`, `into "toml"`}, + "not an object": {`{"id":"f","type":"file","path":"/etc/x","into":"json","content":"[1,2]"}`, "JSON object"}, + "with create-once": {`{"id":"f","type":"file","path":"/etc/x","into":"json","content":"{}","create-once":true}`, "create-once"}, + } { + _, err := Parse([]byte(`{"declaration":1,"resources":[` + 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","into":"json","content":"{\"k\":1}"}, + {"id":"s","type":"service","unit":"docker.service","state":"running","reload-on":["f"]} + ]}`)) + if err != nil { + t.Fatal(err) + } + if f := d.Resources[0].(*File); f.Into != IntoJSON { + t.Errorf("into was read as %q", f.Into) + } + if s := d.Resources[1].(*Service); len(s.ReloadOn) != 1 || s.ReloadOn[0] != "f" { + t.Errorf("reload-on was read as %v", s.ReloadOn) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 3e8a82a..78430fa 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -69,6 +69,19 @@ type Applied struct { // person who edits a managed file watches their change vanish every few minutes with nothing // anywhere saying why. Wrote string `json:"wrote,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. + Into *Into `json:"into,omitempty"` +} + +// Into is what a file written into held before the mesh's keys. +type Into struct { + Format string `json:"format"` + Before map[string]json.RawMessage `json:"before,omitempty"` + Absent []string `json:"absent,omitempty"` + Created bool `json:"created,omitempty"` } // State is the whole of what a node knows about what it has done. diff --git a/internal/system/arch.go b/internal/system/arch.go index abee02b..20a0cf7 100644 --- a/internal/system/arch.go +++ b/internal/system/arch.go @@ -254,3 +254,9 @@ func (arch) ReloadUnits(ctx context.Context, run Runner) error { _, err := run(ctx, "systemctl", "daemon-reload") return err } + +// ReloadService tells a running unit to read its configuration again, without stopping it. +func (arch) ReloadService(ctx context.Context, run Runner, unit string) error { + _, err := run(ctx, "systemctl", "reload", unit) + return err +}