diff --git a/internal/apply/apply.go b/internal/apply/apply.go index ecb4d26..6435005 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -270,6 +270,8 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru return applyUser(ctx, sys, res, run) case *declaration.Archive: return applyArchive(ctx, res, previous) + case *declaration.Daemon: + return applyDaemon(ctx, res, run, changed, previous) case *declaration.Action: return applyAction(ctx, res, run) case *declaration.Network: diff --git a/internal/apply/daemon.go b/internal/apply/daemon.go new file mode 100644 index 0000000..1d7d0dc --- /dev/null +++ b/internal/apply/daemon.go @@ -0,0 +1,172 @@ +package apply + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/store" +) + +// Running the mesh's own code, without the module choosing how. +// +// **A daemon is an intent and this is one answer to it.** A module says what to run and the +// machine's own supervisor is how — which means the module is not writing a unit file, and not +// choosing between a container and a service before it can declare anything +// (novox/hq 03-DESIGN/01-to-be/18-building-a-module.md). +// +// What this does, in order: fetch the bundle, refuse it unless it hashes to what was declared, +// unpack it where the mesh keeps such things, write the unit, and put it in the state asked for. +// The unit is the mesh's — an operator editing it loses the edit at the next declaration, which is +// the same rule every managed file on a machine follows (ADR 0011). + +// daemonRoot is where unpacked daemons live. +// +// Under the mesh's own directory rather than somewhere a distribution owns: these are files the +// mesh puts there and replaces, and putting them where a package manager also writes is how two +// owners end up disagreeing about one path. +const daemonRoot = "/var/lib/mesh/daemons" + +// unitDir is where the mesh writes the units it owns. +const unitDir = "/etc/systemd/system" + +func applyDaemon(ctx context.Context, r *declaration.Daemon, run Runner, + changed map[string]bool, previous store.Applied) (Outcome, error) { + out := begin(r) + out.Action = "unchanged" + + body, err := fetch(ctx, r.Source) + if err != nil { + return out, err + } + sum := sha256.Sum256(body) + got := "sha256:" + hex.EncodeToString(sum[:]) + if got != r.Digest { + // Refused before anything is written or started. What is at that address is not what was + // declared, and running it would be running something nobody reviewed. + return out, fmt.Errorf( + "%s was declared as %s and what arrived is %s; nothing was unpacked or started", + r.Source, r.Digest, got) + } + + // **The identity of a daemon is its bytes AND how it is run.** Two daemons from one bundle + // differing only in their command are different daemons, and a record that tracked the digest + // alone would call the second one unchanged. + want := got + " " + unitFor(r) + at := filepath.Join(daemonRoot, r.Name) + + // **Something it reads changed, so it must be restarted even though it is unchanged.** A + // running process does not re-read its configuration: replace the file, find the daemon + // already up, do nothing, and the machine keeps behaving the way it did before while every + // check passes. The same rule a service follows, for the same reason. + var because string + for _, id := range r.RestartOn { + if changed[id] { + because = id + break + } + } + + if previous.Wrote == want && because == "" { + // Everything about it is as declared. Still asked whether it is RUNNING, because a + // declaration that is satisfied by a record rather than by the machine is how a stopped + // service reports success. + if active, err := run(ctx, "systemctl", "is-active", "--quiet", r.Name+".service"); err == nil { + _ = active + return out, nil + } + if _, err := run(ctx, "systemctl", "start", r.Name+".service"); err != nil { + return out, fmt.Errorf("%s is installed and would not start: %w", r.Name, err) + } + out.Action = "updated" + out.Detail = "restarted a daemon that had stopped" + out.wrote = want + return out, nil + } + + // Replaced rather than merged: the bundle is the whole of what it runs, and files left from a + // previous version would be loaded by a runtime that walks a directory. + if err := os.RemoveAll(at); err != nil { + return out, err + } + if err := os.MkdirAll(at, 0o755); err != nil { + return out, err + } + written, err := unpack(body, at) + if err != nil { + return out, err + } + if err := ownAll(at, r.User); err != nil { + return out, err + } + + unit := filepath.Join(unitDir, r.Name+".service") + if err := os.WriteFile(unit, []byte(unitFor(r)), 0o644); err != nil { + return out, err + } + if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil { + return out, err + } + // Enabled and restarted, in that order: enabled so it survives a reboot, restarted rather than + // started because this path is also how a new version arrives and the old one is still running. + if _, err := run(ctx, "systemctl", "enable", r.Name+".service"); err != nil { + return out, err + } + if _, err := run(ctx, "systemctl", "restart", r.Name+".service"); err != nil { + return out, fmt.Errorf("%s was installed and would not start: %w", r.Name, err) + } + + out.Action = "updated" + if previous.Wrote == "" { + out.Action = "created" + } + out.Detail = fmt.Sprintf("%d file(s), running as %s.service", written, r.Name) + if because != "" { + out.Detail += ", restarted because " + because + " changed" + } + out.wrote = want + return out, nil +} + +// unitFor is the unit the mesh writes for a daemon. +// +// **Generated whole and never edited in place**, the same rule as every other managed file: an +// edit survives until the next declaration and then vanishes, which is worse than not being +// allowed at all, so the file says so. +// +// Deterministic — environment sorted — because this string is half the daemon's identity, and a +// map iterated in Go's order would make every apply look like a change. +func unitFor(r *declaration.Daemon) string { + var b strings.Builder + b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n") + b.WriteString("# declaration changes, and an edit would survive until then and vanish.\n") + b.WriteString("[Unit]\n") + fmt.Fprintf(&b, "Description=%s, a mesh daemon\n", r.Name) + b.WriteString("After=network-online.target\n") + b.WriteString("Wants=network-online.target\n\n") + + b.WriteString("[Service]\n") + b.WriteString("Type=simple\n") + fmt.Fprintf(&b, "WorkingDirectory=%s\n", filepath.Join(daemonRoot, r.Name)) + for _, file := range r.EnvFile { + fmt.Fprintf(&b, "EnvironmentFile=%s\n", file) + } + for _, key := range sortedKeys(r.Env) { + fmt.Fprintf(&b, "Environment=%s=%s\n", key, r.Env[key]) + } + if r.User != "" { + fmt.Fprintf(&b, "User=%s\n", r.User) + } + fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(r.Run, " ")) + // Restarted when it exits, because a daemon that stops is not a daemon. Delayed, so a process + // that fails at once does not spin the machine. + b.WriteString("Restart=always\nRestartSec=5\n\n") + + b.WriteString("[Install]\nWantedBy=multi-user.target\n") + return b.String() +} diff --git a/internal/apply/daemon_test.go b/internal/apply/daemon_test.go new file mode 100644 index 0000000..2103818 --- /dev/null +++ b/internal/apply/daemon_test.go @@ -0,0 +1,82 @@ +package apply + +import ( + "strings" + "testing" + + "github.com/novox/mesh-host/internal/declaration" +) + +func aDaemon() *declaration.Daemon { + return &declaration.Daemon{ + ID: "server", Type: declaration.TypeDaemon, Name: "greeter", + Source: "https://store.invalid/greeter/daemon", + Digest: "sha256:" + strings.Repeat("a", 64), + Run: []string{"node", "index.js"}, + Env: map[string]string{"MESH_NODE": "anchor", "A_FIRST": "1"}, + } +} + +// The unit the mesh writes says what it runs, where, and that it comes back. +func TestTheUnitRunsWhatTheDaemonSaid(t *testing.T) { + unit := unitFor(aDaemon()) + for _, want := range []string{ + "ExecStart=node index.js", + "WorkingDirectory=/var/lib/mesh/daemons/greeter", + "Restart=always", + "WantedBy=multi-user.target", + } { + if !strings.Contains(unit, want) { + t.Fatalf("the unit does not say %q:\n%s", want, unit) + } + } +} + +// **Generated whole and saying so.** Every managed file on a machine carries this, because an edit +// that survives until the next declaration and then vanishes is worse than one that is refused. +func TestTheUnitSaysItIsTheMeshs(t *testing.T) { + unit := unitFor(aDaemon()) + if !strings.HasPrefix(unit, "#") || !strings.Contains(unit, "Do not edit") { + t.Fatalf("the unit does not say it is generated:\n%s", unit) + } +} + +// **Deterministic, because the unit is half the daemon's identity.** Environment held in a map +// would be written in Go's iteration order, so every apply would see a different unit and call an +// unchanged daemon changed — restarting it on every declaration for ever. +func TestTheUnitIsTheSameEveryTime(t *testing.T) { + first := unitFor(aDaemon()) + for i := 0; i < 20; i++ { + if again := unitFor(aDaemon()); again != first { + t.Fatalf("two renderings of one daemon differ:\n%s\n---\n%s", first, again) + } + } + // And sorted, so the order is a decision rather than luck. + if strings.Index(first, "A_FIRST") > strings.Index(first, "MESH_NODE") { + t.Fatalf("environment is not in a stable order:\n%s", first) + } +} + +// **Two daemons from one bundle differing only in their command are different daemons.** Tracking +// the digest alone would call the second one unchanged and leave the first one running. +func TestADaemonsIdentityIncludesHowItIsRun(t *testing.T) { + one := aDaemon() + two := aDaemon() + two.Run = []string{"node", "other.js"} + if unitFor(one) == unitFor(two) { + t.Fatal("two daemons with different commands render one unit, so a change would be missed") + } +} + +// A daemon that runs as somebody says so, and one that does not says nothing — rather than naming +// root explicitly, which would be a claim the mesh does not need to make. +func TestADaemonRunsAsWhoItSaid(t *testing.T) { + as := aDaemon() + as.User = "greeter" + if !strings.Contains(unitFor(as), "User=greeter") { + t.Fatalf("the unit does not run as the user it named:\n%s", unitFor(as)) + } + if strings.Contains(unitFor(aDaemon()), "User=") { + t.Fatalf("a daemon that named no user had one written for it:\n%s", unitFor(aDaemon())) + } +} diff --git a/internal/declaration/daemon_test.go b/internal/declaration/daemon_test.go new file mode 100644 index 0000000..d741753 --- /dev/null +++ b/internal/declaration/daemon_test.go @@ -0,0 +1,72 @@ +package declaration + +import ( + "strings" + "testing" +) + +func aDaemon() *Daemon { + return &Daemon{ + ID: "server", Type: TypeDaemon, Name: "greeter", + Source: "https://store.invalid/greeter/daemon", + Digest: "sha256:" + strings.Repeat("a", 64), + Run: []string{"node", "index.js"}, + } +} + +// A daemon is part of the vocabulary, or a declaration carrying one is refused whole. +func TestADaemonIsSomethingTheHostSpeaks(t *testing.T) { + var found bool + for _, kind := range Vocabulary() { + if kind == TypeDaemon { + found = true + } + } + if !found { + t.Fatal("a daemon cannot be declared, so a module that declares one is refused") + } + if newOf(TypeDaemon) == nil { + t.Fatal("the decoder has no daemon, so one would be refused as an unknown kind") + } +} + +// **Pinned by digest, like everything else that crosses a network.** A bundle fetched by a +// reference somebody can repoint is not pinned, and it is the one thing on a machine that would +// then be running code nobody reviewed. +func TestADaemonsBundleMustBePinned(t *testing.T) { + for _, bad := range []string{"", "latest", "sha256:short", strings.Repeat("a", 64)} { + d := aDaemon() + d.Digest = bad + if problems := d.validate("a daemon", false); len(problems) == 0 { + t.Fatalf("a daemon pinned by %q was accepted", bad) + } + } +} + +// What to run is named, never inferred. Guessing an entrypoint from which files are present makes +// a daemon change what it runs when somebody adds a file. +func TestADaemonMustSayWhatToRun(t *testing.T) { + d := aDaemon() + d.Run = nil + if problems := d.validate("a daemon", false); len(problems) == 0 { + t.Fatal("a daemon with no command was accepted") + } +} + +// Its name becomes a unit name and a path, so a separator in it would write somewhere nobody meant. +func TestADaemonsNameCannotEscapeItsUnit(t *testing.T) { + for _, bad := range []string{"", "../escape", "two words", "a/b"} { + d := aDaemon() + d.Name = bad + if problems := d.validate("a daemon", false); len(problems) == 0 { + t.Fatalf("a daemon called %q was accepted", bad) + } + } +} + +// And a well-formed one is accepted, or the tests above prove only that everything is refused. +func TestAWellFormedDaemonIsAccepted(t *testing.T) { + if problems := aDaemon().validate("a daemon", false); len(problems) != 0 { + t.Fatalf("a well-formed daemon was refused: %v", problems) + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 84a6824..26c94a3 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -59,6 +59,20 @@ const ( // (04-ISSUES/026) — and leaves everything about it alone. Several modules declaring one // access is ordinary, because none of them owns it. TypeAccess Type = "access" + + // TypeDaemon is a long-running process the mesh keeps running, named by what it runs rather + // than by how it is hosted. + // + // **The intent, not the mechanism.** Until this, an author decided the hosting before they + // could declare anything: code of their own meant a `container` built from an image, a script + // meant a `service` and a unit somebody else had to install. Same intent — run this and keep + // it running — expressed two unrelated ways, and the choice baked into which kind was picked. + // + // A daemon names an artifact and what to run. The mesh unpacks the artifact where it keeps + // such things, writes the unit, and puts it in the state asked for. One module may declare + // several, in different languages, because a module is one piece of software and not one + // process (novox/hq ADR 0040). + TypeDaemon Type = "daemon" ) // Resource is one thing that should be true of the machine. @@ -393,6 +407,81 @@ func (a *Archive) validate(where string, _ bool) []string { return problems } +// Daemon is a long-running process the mesh installs, keeps running, and owns the unit for. +// +// The difference from Service is who owns the unit: a Service puts an EXISTING unit into a state +// and deliberately does not install one, which is right for software that ships its own. A Daemon +// is the mesh's own code — a bundle it built — so there is no unit until the mesh writes it, and +// nothing else will. +// +// The difference from Container is the hosting, and a module should not have to choose: what a +// daemon says is what to run, and the machine's own process supervisor is how. A module whose code +// genuinely needs a container's isolation declares a container and says so. +type Daemon struct { + ID string `json:"id"` + Type Type `json:"type"` + // Name is what the unit is called, and what an operator will see in the process table. + Name string `json:"name"` + // Source is where to fetch the bundle from, and Digest is what it must hash to. The same + // discipline as an archive, for the same reason: this crosses a network the mesh does not + // control. + Source string `json:"source"` + Digest string `json:"digest"` + // Run is the command, relative to the unpacked bundle. The first element is the program. + // + // **Named by the module, never inferred.** Guessing an entrypoint from which files exist makes + // a daemon change what it runs when somebody adds a file. + Run []string `json:"run"` + // Env and EnvFile are what it runs with. A file rather than inline values is how a credential + // reaches a daemon without passing through the declaration. + Env map[string]string `json:"env,omitempty"` + EnvFile []string `json:"env-file,omitempty"` + // User is who it runs as. Absent means root, which is what the mesh's own modules need for + // the things they do to a machine. + User string `json:"user,omitempty"` + // RestartOn names resources whose change means this must be restarted — the same rule a + // service follows, and for the same reason: a running process does not re-read its + // configuration, so replacing a file and finding the process already up leaves the machine + // behaving the way it did before while every check passes. + RestartOn []string `json:"restart-on,omitempty"` +} + +func (d *Daemon) Identity() string { return d.ID } +func (d *Daemon) Kind() Type { return TypeDaemon } +func (d *Daemon) Target() string { return d.Name } + +func (d *Daemon) validate(where string, _ bool) []string { + var problems []string + if d.Name == "" { + problems = append(problems, where+": a daemon needs a name, which is what its unit is called") + } + if strings.ContainsAny(d.Name, "/ \t") { + // It becomes a unit name and a file on disk. A name with a separator in it would write + // somewhere nobody meant. + problems = append(problems, where+": a daemon's name becomes a unit name, so it cannot "+ + "contain a path separator or a space") + } + if d.Source == "" { + problems = append(problems, where+": a daemon needs somewhere to fetch its bundle from") + } + if !strings.HasPrefix(d.Digest, "sha256:") || len(d.Digest) != len("sha256:")+64 { + // The same rule an archive follows, and for the same reason: this crosses a network the + // mesh does not control, and a reference that can be made to point elsewhere is not one. + problems = append(problems, where+ + ": a daemon's bundle is pinned by digest, as sha256:<64 hex characters>") + } + if len(d.Run) == 0 { + problems = append(problems, where+": a daemon needs to say what to run") + } + for _, part := range d.Run { + if part == "" { + problems = append(problems, where+": a daemon's command has an empty element") + break + } + } + return problems +} + // Service is a unit the host puts into a state. It does not install the unit. // // Two states, and they are orthogonal rather than one scale. A unit can be enabled and stopped @@ -654,6 +743,8 @@ func newOf(t Type) Resource { return &Archive{} case TypeAccess: return &Access{} + case TypeDaemon: + return &Daemon{} } return nil } @@ -661,8 +752,8 @@ func newOf(t Type) Resource { // Vocabulary is every kind this host speaks. func Vocabulary() []Type { return []Type{ - TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile, TypeNetwork, - TypePackage, TypeService, TypeUser, + TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDaemon, TypeDirectory, TypeFile, + TypeNetwork, TypePackage, TypeService, TypeUser, } } diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index 11244bc..1f3c025 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -266,7 +266,7 @@ func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) { } } -func TestTheVocabularyIsTheEightShapesTheMeshNeeds(t *testing.T) { +func TestTheVocabularyIsTheElevenShapesTheMeshNeeds(t *testing.T) { // Six of them the bootstrap uses (novox/hq 07-the-substrate.md), and removing one is a // failing test rather than a discovery during a first-node install. // @@ -282,7 +282,7 @@ func TestTheVocabularyIsTheEightShapesTheMeshNeeds(t *testing.T) { } for _, want := range []Type{ TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction, - TypeUser, TypeArchive, TypeNetwork, TypeAccess, + TypeUser, TypeArchive, TypeNetwork, TypeAccess, TypeDaemon, } { if !speaks[want] { t.Errorf("the host no longer speaks %q", want) @@ -298,8 +298,19 @@ func TestTheVocabularyIsTheEightShapesTheMeshNeeds(t *testing.T) { // `access` is the tenth, and novox/hq ADR 0051 is its decision: shared, pre-existing data is // the operator's, and a module is granted use of it without owning it — a shape the host must // tell apart from a directory precisely because it must NOT create, chown or remove it. - if len(speaks) != 10 { - t.Errorf("the vocabulary is %d shapes rather than 10; every addition widens what a compromised "+ + // + // `daemon` is the eleventh, and it exists because the mechanism was leaking into every module. + // Running code of one's own meant a `container` and therefore an image; running a script meant + // a `service` and a unit somebody else had to install. One intent — run this and keep it + // running — expressed two unrelated ways, with the hosting chosen before anything could be + // declared. A daemon says what to run; the machine's own supervisor is how, and the mesh owns + // the unit because it is the mesh's own code (novox/hq 03-DESIGN/01-to-be/18-building-a-module.md). + // + // It is a full-host shape rather than a portable one: it needs a process supervisor to install + // into. It does NOT need a container runtime, which is the point — only software that + // genuinely needs isolation asks for a container. + if len(speaks) != 11 { + t.Errorf("the vocabulary is %d shapes rather than 11; every addition widens what a compromised "+ "control plane can express, so a change here is a decision: %s", len(speaks), vocabulary()) } diff --git a/internal/system/system.go b/internal/system/system.go index 0c2cb96..03c77d3 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -173,6 +173,11 @@ func everyShape() []declaration.Type { // bind mount, and a bind mount needs the container runtime a full host has. So it sits // here with the container it guards, not at the portable floor (novox/hq ADR 0051). declaration.TypeAccess, + // A daemon needs a process supervisor to install a unit into, which is what separates a + // full host from the floor. It does NOT need a container runtime, which is the point of + // it: the mesh's own code runs as a process on the machine, and only software that + // genuinely needs isolation asks for a container. + declaration.TypeDaemon, } }