The mechanism was leaking into every module. Code of one's own meant a container and therefore an image; 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 names a bundle and a command. The host fetches it, refuses it unless it hashes to what was declared, unpacks it where the mesh keeps such things, writes the unit and puts it in the state asked for. The unit is the mesh's, generated whole and saying so, because an edit that survives until the next declaration and then vanishes is worse than one that is refused. Its identity is the bytes AND how it is run: two daemons from one bundle differing only in their command are different daemons, and tracking the digest alone would call the second unchanged and leave the first running. The unit is rendered deterministically for the same reason — environment from a map would be written in Go's iteration order, so every apply would see a different unit and restart an unchanged daemon for ever. restart-on is honoured as a service's is: a running process does not re-read its configuration, so replacing a file and finding the daemon already up leaves the machine behaving as before while every check passes. A full-host shape, not a portable one: it needs a process supervisor to install into. It does NOT need a container runtime, which is the point. Two guards caught this properly and both were updated deliberately rather than silenced: the vocabulary count, which exists because every addition widens what a compromised control plane can express, and the shape test that catches a kind the language has and a host cannot apply — added after `network` did exactly that. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
173 lines
6.4 KiB
Go
173 lines
6.4 KiB
Go
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()
|
|
}
|