Author SHA1 Message Date
jochen a95c2b6ea9 A unit may be user-scoped: applied through the account's own manager (hq ADR 0177)
A workstation's per-user daemons — a window manager's reload watcher, an
audio mask, a memory guard — are units in the operator account's own service
manager, and until now had no form the mesh could send (to-be 29). The
`service` shape gains `scope` ("system", the default, or "user") and `user`
(the account, named ${machine:account} by a module); a user-scoped unit
without an account, or a system unit naming one, is refused at parse.

The host reaches the account's manager as `systemctl --user --machine=<account>@`
from its own process: no environment to forge, no user to switch to. Done on
the runner rather than per system, since every system's reading of a unit
already goes through systemctl. Apply, reflect-only and removal all go through
the same manager, and the applied record carries scope and user so removal
gives the unit back to the manager it came from. It answers only while that
manager runs — a login, or lingering enabled for the account; declaring
lingering is a follow-up.

Tests: a user-scoped unit is started and enabled in the account's manager and
recorded with its scope; a system unit never sees --user; the validation of
scope and user.
2026-10-02 16:48:14 +02:00
269 changed files with 1342 additions and 41600 deletions
+13 -40
View File
@@ -4,16 +4,11 @@ Tier 0 of the Novox Mesh. The one thing ever installed by hand, and the only thi
a machine.
```
scp nox-mesh-host root@machine:/usr/bin/
nox-mesh-host profile
scp mesh-host root@machine:/usr/local/bin/
mesh-host profile
```
That is the whole installation. **It is a daemon**: the launcher runs it, and the operator's command is
`nox` (`mesh-cli`), on any node, from their own account (`novox/hq` ADR 0272). The subcommands below are
for installing a machine, joining it, and rescuing one no mesh can reach, run as root at the machine; a
line the mesh addresses to the operator names a `nox` line, never this binary. Once a delivered engine
proves itself, it renews the one placed by hand here to its own build, so the command on the machine is
the engine that runs it (`novox/hq` issue 356). One statically linked binary, nothing else present, no runtime
That is the whole installation. One statically linked binary, nothing else present, no runtime
to install first ([`novox/hq` ADR 0005](https://git.novox.be/novox/hq)).
## What it is for
@@ -28,7 +23,7 @@ the host never queries the mesh database. It receives declarations and applies t
## Joining a mesh
```
nox-mesh-host enrol --token <token> --name <what this machine is called>
mesh-host enrol --token <token> --name <what this machine is called>
```
**The node generates its own identity** — an Ed25519 keypair whose private half never leaves the
@@ -57,22 +52,20 @@ and then stops, having saved nothing — so it can be run again unchanged.
connects to nothing and listens on nothing — what it applies comes from a file.
```
nox-mesh-host profile what this machine can be asked to do
nox-mesh-host inventory what this machine is, and what it holds
nox-mesh-host apply FILE make this machine match a declaration from a file
nox-mesh-host reconcile make this machine match what the mesh last told it — or, before
any mesh has, the bundle this host carries
nox-mesh-host bundle show what this host carries
nox-mesh-host owned what this host has applied and still owns
nox-mesh-host hand-over DIR hand a directory used as found to the mesh, on a machine no mesh
can reach; the operator's line is `nox node hand-over <node> DIR`
mesh-host profile what this machine can be asked to do
mesh-host inventory what this machine is, and what it holds
mesh-host apply FILE make this machine match a declaration from a file
mesh-host reconcile make this machine match what the mesh last told it — or, before
any mesh has, the bundle this host carries
mesh-host bundle show what this host carries
mesh-host owned what this host has applied and still owns
--json machine-readable
--state where this node keeps what it knows
--dry-run say what applying would change, and change nothing
```
```
$ nox-mesh-host profile
$ mesh-host profile
linux/amd64
yes container-runtime 29.7.2
@@ -122,7 +115,7 @@ A host built for a machine carries its declaration **inside the binary**:
make host BUNDLE=path/to/foundation.lock
```
`nox-mesh-host reconcile` then applies it, on a machine the mesh has told nothing yet. That is the
`mesh-host reconcile` then applies it, on a machine the mesh has told nothing yet. That is the
first node's path — no mesh present, nothing fetched, nothing else copied onto the machine. Once
the mesh has spoken, `reconcile` holds the machine to what it last said and never to the bundle,
which genesis consumed; a bundle or a file is refused when it says the other mode than the node
@@ -158,26 +151,6 @@ carries. The claim is not. The foundation is four container services, and:
So `foundation.lock` here is a real bundle with a placeholder's content. Saying that plainly
beats shipping a host that claims a foundation it has never raised.
## mesh-cli's socket (novox/hq ADR 0272)
The engine serves `/run/nox-mesh-host/cli.sock` for `mesh-cli`, the operator's command (`internal/meshcli`). It
reads the asking account from the kernel (`SO_PEERCRED`) and asks the controller on this node's own subject; the
controller decides what the line may do, and the engine runs nothing it receives. One line per connection, each
connection's deadline set the moment it is accepted, two lines at once per account and eight for the machine,
"busy" beyond that.
**Only a login session can be the terminal.** The engine reads the asking process's cgroup and sends its login
session's scope (`session-<id>.scope` under the account's own `user-<uid>.slice`, which logind's own record in
`/run/systemd/sessions/<id>` must say is this account's, of class `user`), or nothing, read the moment the
connection is accepted; the controller makes
nothing else the terminal. A service running as the operator — the tool runner, a unit of the account's user
manager — is at most an ordinary call.
**A process outside the machine's own namespaces is refused**, whatever its user id: the peer's mount and user
namespace must be the engine's. So `mesh-cli` run inside a flatpak, a toolbox or distrobox container, or any other
container — even one running as the operator's own id — is refused with the reason; run it from the machine's own
shell.
## A capability is detected, never assumed
The reason this is the first thing built rather than a detail of it.
+4 -2
View File
@@ -130,7 +130,7 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh
The foundation's ports are this machine's, each checked free before anything is
raised and kept as the node's setting for the module that binds it:
--store-port 5432 --bus-port 5671
--store-port 5432 --bus-port 5671 --amqp-port 5672 --management-port 15672
--registry-port 5000 (follows --registry, and must agree with it)
--packages-port 3000 --hub-port 51820/udp
--overlay-range the private network's range (default 10.42.0.0/16); refused
@@ -325,7 +325,9 @@ func newFlagSet(opts *bootstrap.Options, jsonOut *bool) *flag.FlagSet {
into *int
}{
{"store-port", "the store", &opts.Ports.Store},
{"bus-port", "the bus", &opts.Ports.Bus},
{"bus-port", "the bus (amqps)", &opts.Ports.Bus},
{"amqp-port", "the broker's AMQP", &opts.Ports.AMQP},
{"management-port", "the broker's management, on loopback", &opts.Ports.Management},
{"registry-port", "the registry", &opts.Ports.Registry},
{"packages-port", "the package registry", &opts.Ports.Packages},
{"hub-port", "the private network's hub (udp)", &opts.Ports.Hub},
-77
View File
@@ -1,77 +0,0 @@
package main
import (
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// **A declaration from an older lease epoch is refused before anything is applied** (novox/hq to-be
// 45 §6, ADR 0227 rule 2). The controller that sent it lost its lease and goes on sending; this node
// applied the holder's. Refused on the host's own apply path — the one the queue's worker runs — before
// the machine is read or touched, with a report naming what was refused and what is held, and the
// kept declaration left as it was.
func TestADeclarationFromAnOlderEpochIsRefusedBeforeAnythingIsApplied(t *testing.T) {
dir := t.TempDir()
opts := options{state: filepath.Join(dir, "state.json")}
_, private, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
target := filepath.Join(dir, "a.conf")
declared := func(epoch, sequence int64) store.Declared {
body, err := json.Marshal(map[string]any{"declaration": 1, "epoch": epoch, "sequence": sequence,
"resources": []any{map[string]any{"id": "a", "type": "file", "path": target, "content": "x\n"}}})
if err != nil {
t.Fatal(err)
}
return store.Declared{Declaration: body, Signature: ed25519.Sign(private, body)}
}
held := declared(57, 3)
if err := store.SaveDeclared(store.DeclaredPath(opts.state), held); err != nil {
t.Fatal(err)
}
stale := declared(41, 12)
report := applyAndKeep(context.Background(), opts, stale.Declaration, &stale, nil, nil)
if report.OlderThan == nil || *report.OlderThan != (link.Order{Epoch: 57, Sequence: 3}) ||
report.Order != (link.Order{Epoch: 41, Sequence: 12}) || report.Declared != digestOf(stale.Declaration) ||
report.Refused == "" {
t.Fatalf("the refusal does not name what was refused and what is held: %+v", report)
}
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
t.Fatal("the refused declaration touched the machine")
}
kept, err := store.ReadDeclared(store.DeclaredPath(opts.state))
if err != nil || string(kept.Declaration) != string(held.Declaration) {
t.Fatal("the refused declaration replaced what this node kept")
}
}
// The queue's counts are kept beside the state and read back by the next host; unreadable is an
// error, never zero.
func TestTheNumbersSurviveTheHost(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
if sequence, refused, err := numbersBeside(state).Read(); err != nil || sequence != 0 || refused != 0 {
t.Fatalf("a node that never reported read %d, %d, %v", sequence, refused, err)
}
if err := numbersBeside(state).Save(41, 2); err != nil {
t.Fatal(err)
}
if sequence, refused, err := numbersBeside(state).Read(); err != nil || sequence != 41 || refused != 2 {
t.Fatalf("read back %d, %d, %v", sequence, refused, err)
}
if err := os.WriteFile(store.NumbersPath(state), []byte(`{"report_sequence":`), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := numbersBeside(state).Read(); err == nil {
t.Fatal("unreadable numbers were read as zero")
}
}
-213
View File
@@ -1,213 +0,0 @@
package main
import (
"errors"
"fmt"
"os"
"os/user"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// A directory used as found is never quiet (novox/hq issue 339).
//
// The engine leaves the owner and mode of a directory it did not make: changing them would be root handing a
// directory it never made to whichever account a declaration named. That is safe and, said nowhere, a module
// whose data directory it cannot write would simply fail. So each such directory is said three ways: a WARNING
// line in every apply's log; a resource of its module of kind `directory`, stated unhealthy on every look,
// which the controller raises as that module's condition naming the directory and its owner; and a way out,
// which is the operator's line at the controller's terminal — `nox node hand-over <node> <path>` on the
// control-node (novox/hq ADR 0272, issue 356) — after which the next apply gives it the declared owner and
// mode like any directory of the mesh's. The controller carries that line to this engine over the bus
// (link.AskHandOverSubject); the engine's own `hand-over` subcommand is the same act for a machine with no
// mesh to reach.
// foundState is where the node's state is, for the looks to read the directories used as found from; empty
// until the engine runs.
var foundState atomic.Value
// foundNode is this node's name in the mesh, for the way out to name it; empty until the engine is enrolled.
var foundNode atomic.Value
// handedOverFrom has the apply read the directories handed over from beside the state at statePath.
func handedOverFrom(statePath string) {
apply.HandedOver = func() map[string]bool {
all, err := store.LoadHandedOver(statePath)
if err != nil {
fmt.Fprintf(os.Stderr, "mesh-host: %v; no directory counts as handed over until it reads\n", err)
return nil
}
out := map[string]bool{}
for _, h := range all {
out[apply.HandOverKey(h.ID, h.Path)] = true
}
return out
}
}
// foundDirectories is every directory the node's state records as used as found, sorted by id.
func foundDirectories(known store.State) []store.Applied {
var out []store.Applied
for _, r := range known.Resources {
if r.Type == "directory" && r.AsFound {
out = append(out, r)
}
}
sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID })
return out
}
// handOverLine is the operator's line that hands a directory to the mesh, with this node's name when it is
// known (novox/hq ADR 0272): typed on the control-node, from the operator's own account. The directory's
// path is the resource's target beside the reason, never in it: the controller says a reason on the
// operator's channel, which withholds a path (ADR 0234 §6), and keeps the target as evidence.
func handOverLine() string {
node, _ := foundNode.Load().(string)
if node == "" {
node = "<node>"
}
return fmt.Sprintf("`nox node hand-over %s <directory>` on the control-node", node)
}
// withFoundDirectories adds to a statement every directory used as found, as its module's resource of kind
// directory, unhealthy: the reason names its owner and mode as they are now, what was declared, and the line
// that hands it over. The path is the target, which the controller keeps as evidence.
func withFoundDirectories(h *link.Health, found []store.Applied) *link.Health {
for _, r := range found {
at := strings.LastIndex(r.ID, ".")
if at <= 0 {
continue
}
owner, mode := "unknown", "unknown"
if info, err := os.Lstat(r.Target); err == nil {
mode = fmt.Sprintf("%o", info.Mode().Perm())
if uid, gid, ok := apply.OwnerOf(info); ok {
owner = fmt.Sprintf("%d:%d", uid, gid)
}
}
declared := r.Declared
if declared == "" {
declared = "another owner or mode"
}
h.Resources = append(h.Resources, link.ResourceHealth{Module: r.ID[:at], Resource: r.ID,
Kind: link.KindDirectory, Target: r.Target, State: link.StateUnhealthy,
Reason: fmt.Sprintf("%s owned by %s, mode %s, as found; %s was declared and not given it — %s, with its "+
"path, hands it to the mesh", link.ReasonUsedAsFound, owner, mode, declared, handOverLine()),
Since: foundSince(r).UTC()})
}
return h
}
// foundNow is the directories used as found, read from the node's state; none when it cannot be read, which
// the apply says on its own.
func foundNow() []store.Applied {
path, _ := foundState.Load().(string)
if path == "" {
return nil
}
known, err := store.Load(path)
if err != nil {
return nil
}
return foundDirectories(known)
}
// handOverFound hands one directory used as found to the mesh, on the operator's word carried by the
// controller (link.HandOverFunc) or typed at this machine. It changes nothing on disk itself; the next apply
// gives the directory its declared owner and mode, and says so.
//
// **Every value is judged, and a refusal records nothing.** The path is the directory's absolute path exactly
// as the engine states it — clean, so `..` and a doubled separator never name a directory; one this machine's
// state records as used as found; on disk a directory and not a link, reached through no link. The engine
// never follows a path it did not state: a hand-over is root giving a directory to an account at the next
// apply, and a path that resolves elsewhere would give that elsewhere.
func handOverFound(statePath, path, by string) (string, error) {
if !filepath.IsAbs(path) {
return "", fmt.Errorf("hand-over takes the directory's absolute path, and %q is not one; nothing was handed over", path)
}
if filepath.Clean(path) != path {
return "", fmt.Errorf("%q is not the directory's path as the engine states it (no `..`, no doubled or trailing "+
"separator); nothing was handed over", path)
}
known, err := store.Load(statePath)
if err != nil {
return "", err
}
var found *store.Applied
for _, r := range foundDirectories(known) {
if filepath.Clean(r.Target) == path {
r := r
found = &r
break
}
}
if found == nil {
return "", errors.New(path + " is not a directory this machine uses as found (the module's condition names " +
"the ones that are); nothing was handed over")
}
info, err := os.Lstat(path)
switch {
case err != nil:
return "", fmt.Errorf("%s cannot be read on this machine: %v; nothing was handed over", path, err)
case info.Mode()&os.ModeSymlink != 0:
return "", fmt.Errorf("%s is a link, and the mesh hands over no link (novox/hq ADR 0012); nothing was handed over", path)
case !info.IsDir():
return "", fmt.Errorf("%s is not a directory on this machine; nothing was handed over", path)
}
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
return "", fmt.Errorf("%s cannot be resolved on this machine: %v; nothing was handed over", path, err)
}
if resolved != path {
return "", fmt.Errorf("%s is reached through a link (it is %s), and the mesh hands over nothing through one "+
"(novox/hq issue 339); nothing was handed over", path, resolved)
}
if err := store.AddHandedOver(statePath, store.HandOver{ID: found.ID, Path: path, At: time.Now().UTC(), By: by}); err != nil {
return "", err
}
return fmt.Sprintf("%s (%s) is handed to the mesh by %s: the next apply gives it %s, and it is the mesh's from "+
"then on — removed when empty and no longer declared", path, found.ID, by, orUnsaid(found.Declared)), nil
}
// handOverFrom is the engine's hand-over for the controller's ask: the state beside statePath, the path as asked.
func handOverFrom(statePath string) link.HandOverFunc {
return func(path, by string) (string, error) { return handOverFound(statePath, path, by) }
}
// handOverCommand is `nox-mesh-host hand-over <dir>` at this machine, as root: the same act, for a machine
// with no mesh to reach. The operator's line is `nox node hand-over <node> <dir>` on the control-node.
func handOverCommand(opts options) error {
by := "a person at the machine"
if u, err := user.Current(); err == nil {
by = u.Username + " at the machine"
}
said, err := handOverFound(opts.state, filepath.Clean(opts.file), by)
if err != nil {
return err
}
fmt.Fprintln(opts.out, said)
return nil
}
// foundSince is when a directory was first found as it is: kept across applies, so the controller can tell one
// a send found from one found before it. A record from before that was kept says when it was last applied.
func foundSince(r store.Applied) time.Time {
if !r.FoundAt.IsZero() {
return r.FoundAt
}
return r.AppliedAt
}
func orUnsaid(s string) string {
if s == "" {
return "the declared owner and mode"
}
return s
}
-177
View File
@@ -1,177 +0,0 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// Every directory used as found is its module's resource of kind directory, unhealthy, naming its owner, on
// every statement (novox/hq issue 339): the controller raises that as the module's condition. The way out it
// names is the operator's line, with this node's name (novox/hq ADR 0272, issue 356).
func TestAFoundDirectoryIsStatedUnhealthy(t *testing.T) {
dir := t.TempDir()
known := store.State{Resources: []store.Applied{
{ID: "notes.data", Type: "directory", Target: dir, AsFound: true, Declared: "root, mode 755", AppliedAt: time.Now()},
{ID: "notes.cache", Type: "directory", Target: dir},
}}
foundNode.Store("laptop")
t.Cleanup(func() { foundNode.Store("") })
h := withFoundDirectories(&link.Health{}, foundDirectories(known))
if len(h.Resources) != 1 {
t.Fatalf("stated: %+v", h.Resources)
}
r := h.Resources[0]
if r.Module != "notes" || r.Kind != link.KindDirectory || r.State != link.StateUnhealthy || r.Target != dir ||
!strings.HasPrefix(r.Reason, link.ReasonUsedAsFound) || !strings.Contains(r.Reason, "owned by ") ||
!strings.Contains(r.Reason, "root, mode 755") {
t.Fatalf("stated as: %+v", r)
}
// The path is the target, kept as evidence, and never in the reason: the operator's channel withholds a path
// (novox/hq ADR 0234 §6), and the controller says the reason there.
if !strings.Contains(r.Reason, "`nox node hand-over laptop <directory>` on the control-node") ||
strings.Contains(r.Reason, "mesh-host") || strings.Contains(r.Reason, dir) {
t.Fatalf("the way out is not the operator's line, or carries the path: %q", r.Reason)
}
foundNode.Store("")
if r := withFoundDirectories(&link.Health{}, foundDirectories(known)).Resources[0]; !strings.Contains(r.Reason,
"`nox node hand-over <node> <directory>`") {
t.Fatalf("before enrolment the line names a placeholder: %q", r.Reason)
}
}
// realDir is a temporary directory with every link in its path resolved, so a judgement of links is about the
// links the test makes.
func realDir(t *testing.T) string {
t.Helper()
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
return dir
}
// A hand-over records a directory used as found, and refuses one that is not — through the engine's own
// subcommand and through the controller's ask alike, which are one judgement.
func TestHandOverRecordsOnlyAFoundDirectory(t *testing.T) {
dir := realDir(t)
statePath := filepath.Join(dir, "state.json")
found := filepath.Join(dir, "found")
if err := os.Mkdir(found, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "mine"), 0o755); err != nil {
t.Fatal(err)
}
if err := store.Save(statePath, store.State{Resources: []store.Applied{
{ID: "notes.data", Type: "directory", Target: found, AsFound: true, Declared: "root, mode 755"},
{ID: "notes.mine", Type: "directory", Target: filepath.Join(dir, "mine")},
}}); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if err := handOverCommand(options{state: statePath, file: filepath.Join(dir, "mine"), out: &out}); err == nil {
t.Fatal("a directory the mesh made was handed over")
}
if err := handOverCommand(options{state: statePath, file: found + "/", out: &out}); err != nil {
t.Fatal(err)
}
all, err := store.LoadHandedOver(statePath)
if err != nil || len(all) != 1 || all[0].Path != found || !strings.HasSuffix(all[0].By, " at the machine") {
t.Fatalf("recorded: %+v %v", all, err)
}
handedOverFrom(statePath)
if !apply.HandedOver()[apply.HandOverKey("notes.data", found)] {
t.Fatal("the apply does not see the hand-over")
}
if !strings.Contains(out.String(), "root, mode 755") {
t.Fatalf("said: %s", out.String())
}
// The controller's ask is the same judgement, recording who asked in the controller's words.
_ = os.Remove(store.HandedOverPath(statePath))
said, err := handOverFrom(statePath)(found, "jo through mesh-cli on anchor")
if err != nil || !strings.Contains(said, "by jo through mesh-cli on anchor") || !strings.Contains(said, "root, mode 755") {
t.Fatalf("the ask answered %q, %v", said, err)
}
all, _ = store.LoadHandedOver(statePath)
if len(all) != 1 || all[0].By != "jo through mesh-cli on anchor" || all[0].ID != "notes.data" {
t.Fatalf("recorded: %+v", all)
}
}
// Every value of a hand-over is judged before anything is recorded, and each refusal records nothing: a relative
// path, a path that is not the engine's statement of the directory (`..`, a doubled or trailing separator), one
// not used as found, a link at the path, a link in the path, a file, a directory that is gone.
func TestAHandOverIsRefusedForEveryPathThatIsNotTheFoundDirectory(t *testing.T) {
dir := realDir(t)
statePath := filepath.Join(dir, "state.json")
found := filepath.Join(dir, "found")
if err := os.Mkdir(found, 0o700); err != nil {
t.Fatal(err)
}
// A link at the path of a second found directory, and a found directory below a linked directory.
linked := filepath.Join(dir, "linked")
if err := os.Symlink(found, linked); err != nil {
t.Fatal(err)
}
through := filepath.Join(dir, "via", "data")
if err := os.Symlink(dir, filepath.Join(dir, "via")); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "data"), 0o700); err != nil {
t.Fatal(err)
}
file := filepath.Join(dir, "file")
if err := os.WriteFile(file, nil, 0o600); err != nil {
t.Fatal(err)
}
gone := filepath.Join(dir, "gone")
if err := store.Save(statePath, store.State{Resources: []store.Applied{
{ID: "notes.data", Type: "directory", Target: found, AsFound: true},
{ID: "notes.linked", Type: "directory", Target: linked, AsFound: true},
{ID: "notes.through", Type: "directory", Target: through, AsFound: true},
{ID: "notes.file", Type: "directory", Target: file, AsFound: true},
{ID: "notes.gone", Type: "directory", Target: gone, AsFound: true},
}}); err != nil {
t.Fatal(err)
}
// Each refused by its own check, which the reason names.
refused := map[string][2]string{
"a relative path": {"found", "not one"},
"a path with ..": {dir + "/x/../found", "as the engine states it"},
"a doubled separator": {dir + "//found", "as the engine states it"},
"a trailing separator": {found + "/", "as the engine states it"},
"a directory not used as found": {filepath.Join(dir, "other"), "not a directory this machine uses as found"},
"a link at the path": {linked, "is a link"},
"a link in the path": {through, "reached through a link"},
"a file": {file, "is not a directory on this machine"},
"a directory that is gone": {gone, "cannot be read on this machine"},
}
for name, c := range refused {
said, err := handOverFound(statePath, c[0], "jo")
if err == nil || said != "" {
t.Fatalf("%s (%s) was handed over: %q", name, c[0], said)
}
if !strings.Contains(err.Error(), "nothing was handed over") || !strings.Contains(err.Error(), c[1]) {
t.Fatalf("%s's refusal does not say %q and that nothing was handed over: %v", name, c[1], err)
}
}
if all, err := store.LoadHandedOver(statePath); err != nil || len(all) != 0 {
t.Fatalf("a refusal recorded something: %+v %v", all, err)
}
// The found directory itself, stated exactly, is taken.
if _, err := handOverFound(statePath, found, "jo"); err != nil {
t.Fatal(err)
}
if all, _ := store.LoadHandedOver(statePath); len(all) != 1 || all[0].Path != found {
t.Fatalf("recorded: %+v", all)
}
}
-40
View File
@@ -1,40 +0,0 @@
package main
import (
"context"
"testing"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// **The generation applied is kept, and a put-back never lowers it**: after a put-back of generation 5
// over 7, a send from generation 6 is still older than what this machine applied.
func TestThePutBackDoesNotLowerTheGenerationApplied(t *testing.T) {
built, err := system.For("arch")
if err != nil || built.Confirm(context.Background(), apply.ExecRunner) != nil {
t.Skip("this machine is not one these tests can apply on")
}
was := builtFor
builtFor = "arch"
t.Cleanup(func() { builtFor = was })
f := machineThatApplied(t, 7)
newer := f.declared(t, map[string]any{"sequence": 9, "generation": 8}, "docker.conf", f.stays)
if r := applyAndKeep(context.Background(), f.opts, newer.Declaration, &newer, nil, nil); r.Refused != "" {
t.Fatalf("a newer generation was refused: %s", r.Refused)
}
back := f.declared(t, map[string]any{"sequence": 10, "generation": 5, "put_back": true}, "docker.conf", f.stays)
if r := applyAndKeep(context.Background(), f.opts, back.Declaration, &back, nil, nil); r.Refused != "" {
t.Fatalf("a put-back was refused: %s", r.Refused)
}
state, err := store.Load(f.opts.state)
if err != nil || state.Generation != 8 {
t.Fatalf("the generation applied is %d after a put-back of 5 over 8 (%v)", state.Generation, err)
}
stale := f.declared(t, map[string]any{"sequence": 11, "generation": 7}, "docker.conf", f.stays)
if r := applyAndKeep(context.Background(), f.opts, stale.Declaration, &stale, nil, nil); r.OlderGeneration == nil {
t.Fatalf("a send from generation 7 after 8 was applied because a put-back came between: %+v", r)
}
}
-170
View File
@@ -1,170 +0,0 @@
package main
import (
"context"
"crypto/ed25519"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// The assignment generation a declaration was composed from (novox/hq issue 234).
//
// On 2026-10-04 a declaration newer in sequence than every other, and composed from a view of the
// assignments older than the one this machine had applied, named four fewer modules — and the host,
// ordering by sequence alone, undeclared all four. The sequence orders arrival; it cannot tell a later
// send carrying earlier content. The generation can: the node-engine refuses a declaration composed from
// an older generation than the one it applied, before anything is read from the machine.
//
// These read what the report says through its JSON, as the controller does, so they run against a
// node-engine that has never heard of a generation and fail there for that reason.
// generationFixture is a machine that applied generation 7: a file the mesh declared, kept in its state,
// and the declaration it kept — signed — that named it.
type generationFixture struct {
opts options
private ed25519.PrivateKey
kept store.Declared
// stays is the file the older generation's declaration does not name.
stays string
}
func machineThatApplied(t *testing.T, generation int64) generationFixture {
t.Helper()
dir := t.TempDir()
opts := options{state: filepath.Join(dir, "state.json")}
_, private, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
stays := filepath.Join(dir, "docker.conf")
if err := os.WriteFile(stays, []byte("kept\n"), 0o644); err != nil {
t.Fatal(err)
}
// The state as a node-engine that applied generation 7 keeps it: the file it owns, and the highest
// generation it applied. Written as JSON, so the fixture is the same bytes for any build under test.
state := map[string]any{"resources": []any{map[string]any{"id": "docker.conf", "type": "file",
"origin": store.OriginDeclared, "target": stays}}, "generation": generation}
raw, err := json.Marshal(state)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(opts.state, raw, 0o600); err != nil {
t.Fatal(err)
}
f := generationFixture{opts: opts, private: private, stays: stays}
f.kept = f.declared(t, map[string]any{"sequence": 3, "generation": generation}, "docker.conf", stays)
if err := store.SaveDeclared(store.DeclaredPath(opts.state), f.kept); err != nil {
t.Fatal(err)
}
return f
}
// declared is a signed declaration with the envelope's keys given and one file.
func (f generationFixture) declared(t *testing.T, envelope map[string]any, id, path string) store.Declared {
t.Helper()
body := map[string]any{"declaration": 1,
"resources": []any{map[string]any{"id": id, "type": "file", "path": path, "content": "x\n"}}}
for k, v := range envelope {
body[k] = v
}
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
return store.Declared{Declaration: raw, Signature: ed25519.Sign(f.private, raw)}
}
// reportJSON is a report as the controller reads it.
func reportJSON(t *testing.T, r link.Report) map[string]any {
t.Helper()
raw, err := json.Marshal(r)
if err != nil {
t.Fatal(err)
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatal(err)
}
return out
}
// **A newer sequence from an older generation is refused, and nothing is undeclared** (issue 234).
func TestANewerSequenceFromAnOlderGenerationIsRefusedAndNothingIsUndeclared(t *testing.T) {
f := machineThatApplied(t, 7)
other := filepath.Join(filepath.Dir(f.opts.state), "sudo.conf")
stale := f.declared(t, map[string]any{"sequence": 9, "generation": 6}, "sudo.conf", other)
report := applyAndKeep(context.Background(), f.opts, stale.Declaration, &stale, nil, nil)
said := reportJSON(t, report)
older, _ := said["older_generation"].(map[string]any)
if older == nil || older["generation"] != float64(6) || older["applied"] != float64(7) {
t.Fatalf("the refusal does not name the generation refused and the one applied: %v", said)
}
if report.Refused == "" || !strings.Contains(report.Refused, "generation 6") ||
!strings.Contains(report.Refused, "generation 7") {
t.Errorf("the refusal is not said in words naming both generations: %q", report.Refused)
}
if report.Declared != digestOf(stale.Declaration) || report.Sequence != 9 {
t.Errorf("the refusal does not name the declaration refused, so the mesh cannot name who sent it: %+v", report)
}
if _, err := os.Stat(f.stays); err != nil {
t.Fatalf("a refused declaration undeclared what the machine applied: %v", err)
}
if _, err := os.Stat(other); err == nil {
t.Error("the refused declaration touched the machine")
}
state, err := store.Load(f.opts.state)
if err != nil {
t.Fatal(err)
}
if _, owned := state.Find("docker.conf"); !owned {
t.Error("the refused declaration took a resource out of what this machine owns")
}
kept, err := store.ReadDeclared(store.DeclaredPath(f.opts.state))
if err != nil || string(kept.Declaration) != string(f.kept.Declaration) {
t.Error("the refused declaration replaced what this node kept, so the next reconcile would apply it")
}
}
// refusedForItsGeneration says a report refused its declaration for the generation it came from.
func refusedForItsGeneration(t *testing.T, r link.Report) bool {
t.Helper()
return reportJSON(t, r)["older_generation"] != nil || strings.Contains(r.Refused, "generation")
}
// refusedAsUnread says a report refused its declaration as one this node-engine cannot read.
func refusedAsUnread(r link.Report) bool { return strings.Contains(r.Refused, "not a declaration") }
// **A gate's put-back is applied, whatever generation it carries**: it carries the generation of what it
// puts back, and says it is a put-back (issue 234).
func TestAPutBackFromAnOlderGenerationIsNotRefused(t *testing.T) {
f := machineThatApplied(t, 7)
back := f.declared(t, map[string]any{"sequence": 9, "generation": 5, "put_back": true}, "docker.conf", f.stays)
report := applyAndKeep(context.Background(), f.opts, back.Declaration, &back, nil, nil)
if refusedForItsGeneration(t, report) || refusedAsUnread(report) {
t.Fatalf("a put-back was refused: %s", report.Refused)
}
}
// **A declaration that carries no generation is applied as it always was**: an older controller sends
// none, and a node-engine that refused it would strand every machine the moment the controller is older.
func TestADeclarationWithoutAGenerationIsNotRefusedForOne(t *testing.T) {
f := machineThatApplied(t, 7)
older := f.declared(t, map[string]any{"sequence": 9}, "docker.conf", f.stays)
report := applyAndKeep(context.Background(), f.opts, older.Declaration, &older, nil, nil)
if refusedForItsGeneration(t, report) || refusedAsUnread(report) {
t.Fatalf("a declaration without a generation was refused: %s", report.Refused)
}
// And the same generation again is no older.
same := f.declared(t, map[string]any{"sequence": 10, "generation": 7}, "docker.conf", f.stays)
report = applyAndKeep(context.Background(), f.opts, same.Declaration, &same, nil, nil)
if refusedForItsGeneration(t, report) || refusedAsUnread(report) {
t.Fatalf("a declaration of the generation applied was refused: %s", report.Refused)
}
}
-153
View File
@@ -1,153 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/identity"
)
// Joining through the tunnel (novox/hq ADR 0169).
//
// **The bus is never open to the internet, so a joining machine reaches it over the tunnel.** It
// makes its tunnel key first and prints the public half; the token is issued for that key, and the
// hub is told the key before the token is shown; the token carries the one peer this machine needs.
// So the tunnel can come up before the mesh has said anything else — the circle ADR 0004 broke by
// carrying the bus's address in the token is broken here by carrying the hub's.
// tunnelConfigPath and tunnelUnit are where the mesh's own declaration puts the private network, so
// the first tunnel is the same interface and unit the mesh takes over, not a second one beside it.
var (
tunnelConfigPath = "/etc/wireguard/mesh0.conf"
tunnelUnit = "wg-quick@mesh0"
// lookPath finds WireGuard's tools; a variable so a test needs none installed.
lookPath = exec.LookPath
)
// keyCommand makes this machine's tunnel key, or reads the one it already made, and prints the
// public half: what the token is issued for. Making it twice would be a token issued for a key the
// machine no longer has, so an existing key is kept.
func keyCommand(opts options) error {
path := identity.OverlayKeyPath(opts.state)
if key, err := identity.LoadOverlayKey(path); err == nil {
fmt.Println(key.Public)
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if _, err := os.Stat(identity.Path(opts.state)); err == nil {
return fmt.Errorf("this machine has joined already (%s), and its tunnel key is its own; "+
"there is no key to make", identity.Path(opts.state))
}
key, err := identity.GenerateOverlayKey()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
if err := os.WriteFile(path, []byte(key.Private+"\n"), 0o600); err != nil {
return fmt.Errorf("cannot write this machine's tunnel key: %w", err)
}
fmt.Println(key.Public)
fmt.Fprintln(os.Stderr, "\nthis machine's tunnel key, made here; the private half stays in "+path+".\n"+
"Issue the token for it — `token issue --new <name> --overlay-key <the line above>` — and enrol with that token.")
return nil
}
// tunnelKeyFor is the key a token through the tunnel was issued for, read from where `key` left it.
// Refused when there is none, or it is another: the hub knows only the key the token names.
func tunnelKeyFor(t *identity.TokenTunnel, state string) (identity.OverlayKey, error) {
path := identity.OverlayKeyPath(state)
key, err := identity.LoadOverlayKey(path)
if errors.Is(err, os.ErrNotExist) {
return identity.OverlayKey{}, fmt.Errorf("this token was issued for a tunnel key, and this " +
"machine has none: run `nox-mesh-host key` here first and issue the token for the key it prints")
}
if err != nil {
return identity.OverlayKey{}, err
}
if key.Public != t.Key {
return identity.OverlayKey{}, fmt.Errorf("this token was issued for the tunnel key %s, and this "+
"machine's is %s — it is another machine's token, or the key was made again; issue a new "+
"token for %s", t.Key, key.Public, key.Public)
}
return key, nil
}
// tunnelConfig is the first tunnel: this machine's address, and the hub as its one peer, reaching the
// whole private network through it. The private key is set from its file, as the mesh's own
// declaration does it, so the file holds no secret.
func tunnelConfig(t *identity.TokenTunnel, keyPath string) string {
return fmt.Sprintf(`# Written by nox-mesh-host enrol: the one peer a joining machine needs (novox/hq ADR 0169).
# The mesh's own declaration replaces this once the machine has joined.
[Interface]
Address = %s
PostUp = wg set %%i private-key %s
[Peer]
PublicKey = %s
Endpoint = %s
AllowedIPs = %s
PersistentKeepalive = 25
`, t.Address, keyPath, t.HubKey, t.HubEndpoint, t.Range)
}
// bringTheTunnelUp writes the first tunnel and starts it, so the bus the token names can be reached.
func bringTheTunnelUp(ctx context.Context, t *identity.TokenTunnel, keyPath string, run apply.Runner) error {
if _, err := lookPath("wg-quick"); err != nil {
return errors.New("joining through the tunnel needs WireGuard's tools on this machine " +
"(wireguard-tools), and wg-quick is not here")
}
if err := os.MkdirAll(filepath.Dir(tunnelConfigPath), 0o700); err != nil {
return err
}
if err := os.WriteFile(tunnelConfigPath, []byte(tunnelConfig(t, keyPath)), 0o600); err != nil {
return fmt.Errorf("cannot write the first tunnel: %w", err)
}
if out, err := run(ctx, "systemctl", "restart", tunnelUnit); err != nil {
return fmt.Errorf("the first tunnel would not start (%s): %v %s", tunnelUnit, err, strings.TrimSpace(out))
}
fmt.Printf("the tunnel to the hub is up: %s, through %s\n", t.Address, t.HubEndpoint)
return waitForTheHub(ctx, run, handshakeWithin)
}
// handshakeWithin is how long the hub has to answer the first tunnel. Issuing the token sent the hub
// this machine as a peer; the hub applies that on its own time, and a bus dialled before it has is a
// timeout that names the bus rather than the tunnel.
var handshakeWithin = 90 * time.Second
// waitForTheHub waits until the tunnel has shaken hands with the hub, so the bus is dialled over a
// tunnel that answers — and says so in the tunnel's own words when it does not.
func waitForTheHub(ctx context.Context, run apply.Runner, within time.Duration) error {
deadline := time.Now().Add(within)
for {
out, err := run(ctx, "wg", "show", "mesh0", "latest-handshakes")
if err == nil {
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
fields := strings.Fields(line)
if len(fields) == 2 && fields[1] != "0" {
fmt.Println("the hub answered the tunnel")
return nil
}
}
}
if time.Now().After(deadline) {
return fmt.Errorf("the hub has not answered the tunnel in %s: the token may be another machine's, "+
"the hub may not have been sent this machine as a peer, or its tunnel's port is not reachable "+
"from here", within)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
}
-139
View File
@@ -1,139 +0,0 @@
package main
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/identity"
)
// `key` makes the tunnel key once and prints its public half; asked again it prints the same one,
// because a token may already have been issued for it (novox/hq ADR 0169).
func TestKeyMakesTheTunnelKeyOnceAndKeepsIt(t *testing.T) {
dir := t.TempDir()
opts := options{state: filepath.Join(dir, "state.json")}
first := captureStdout(t, func() {
if err := keyCommand(opts); err != nil {
t.Fatal(err)
}
})
second := captureStdout(t, func() {
if err := keyCommand(opts); err != nil {
t.Fatal(err)
}
})
if strings.TrimSpace(first) == "" || strings.TrimSpace(first) != strings.TrimSpace(second) {
t.Fatalf("the key changed between two asks: %q then %q", first, second)
}
info, err := os.Stat(identity.OverlayKeyPath(opts.state))
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("the private half is readable beyond root: %v", info.Mode().Perm())
}
}
// A token through the tunnel takes the key it was issued for, and says so when this machine has none
// or another.
func TestATokenThroughTheTunnelTakesItsOwnKey(t *testing.T) {
dir := t.TempDir()
state := filepath.Join(dir, "state.json")
tt := &identity.TokenTunnel{Key: "x", Address: "10.42.0.9/32", Range: "10.42.0.0/16", HubKey: "h", HubEndpoint: "198.51.100.1:51820"}
if _, err := tunnelKeyFor(tt, state); err == nil || !strings.Contains(err.Error(), "nox-mesh-host key") {
t.Fatalf("a machine with no key was not told to make one: %v", err)
}
captureStdout(t, func() { _ = keyCommand(options{state: state}) })
if _, err := tunnelKeyFor(tt, state); err == nil || !strings.Contains(err.Error(), "issued for the tunnel key x") {
t.Fatalf("another machine's token was taken: %v", err)
}
mine, _ := identity.LoadOverlayKey(identity.OverlayKeyPath(state))
tt.Key = mine.Public
if got, err := tunnelKeyFor(tt, state); err != nil || got.Public != mine.Public {
t.Fatalf("this machine's own token was refused: %v", err)
}
}
// The first tunnel is the mesh's interface and unit, with the hub as its one peer and no secret in
// the file — the same shape the mesh's declaration replaces it with.
func TestTheFirstTunnelIsTheMeshsInterfaceWithTheHubAsItsPeer(t *testing.T) {
dir := t.TempDir()
tunnelConfigPath = filepath.Join(dir, "wireguard", "mesh0.conf")
lookPath = func(string) (string, error) { return "/usr/bin/wg-quick", nil }
t.Cleanup(func() { tunnelConfigPath = "/etc/wireguard/mesh0.conf" })
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
if name == "wg" {
return "HUBKEY\t1759400000\n", nil
}
ran = append(ran, name+" "+strings.Join(args, " "))
return "", nil
}
tt := &identity.TokenTunnel{Key: "k", Address: "10.42.0.9/32", Range: "10.42.0.0/16", HubKey: "HUBKEY", HubEndpoint: "198.51.100.1:51820"}
captureStdout(t, func() {
if err := bringTheTunnelUp(context.Background(), tt, "/var/lib/mesh-host/overlay.key", run); err != nil {
t.Fatal(err)
}
})
raw, err := os.ReadFile(tunnelConfigPath)
if err != nil {
t.Fatal(err)
}
conf := string(raw)
for _, want := range []string{"Address = 10.42.0.9/32", "PostUp = wg set %i private-key /var/lib/mesh-host/overlay.key",
"PublicKey = HUBKEY", "Endpoint = 198.51.100.1:51820", "AllowedIPs = 10.42.0.0/16", "PersistentKeepalive = 25"} {
if !strings.Contains(conf, want) {
t.Errorf("the first tunnel lacks %q:\n%s", want, conf)
}
}
if strings.Contains(conf, "PrivateKey") {
t.Error("the first tunnel's file holds the private key")
}
if len(ran) != 1 || ran[0] != "systemctl restart wg-quick@mesh0" {
t.Errorf("the tunnel was started as %v", ran)
}
}
// captureStdout is what fn printed to standard output.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
was := os.Stdout
os.Stdout = w
fn()
os.Stdout = was
w.Close()
out, _ := io.ReadAll(r)
return string(out)
}
// The bus is dialled only once the hub has answered the tunnel, and a hub that never does is said
// as the tunnel's fault rather than the bus's.
func TestTheBusWaitsForTheHubToAnswer(t *testing.T) {
asked := 0
answersOnThird := func(_ context.Context, name string, args ...string) (string, error) {
asked++
if asked < 3 {
return "HUBKEY\t0\n", nil
}
return "HUBKEY\t1759400000\n", nil
}
captureStdout(t, func() {
if err := waitForTheHub(context.Background(), answersOnThird, time.Minute); err != nil {
t.Fatal(err)
}
})
never := func(context.Context, string, ...string) (string, error) { return "HUBKEY\t0\n", nil }
err := waitForTheHub(context.Background(), never, 0)
if err == nil || !strings.Contains(err.Error(), "has not answered the tunnel") {
t.Fatalf("a hub that never answered was not said: %v", err)
}
}
-44
View File
@@ -1,44 +0,0 @@
package main
import (
"errors"
"fmt"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// judgeDirectories judges every directory of the declaration this node keeps, read-only (novox/hq issue 358): which
// the walk that follows no link another account could make would refuse. Nothing is made, chmodded or chowned.
func judgeDirectories(statePath string) (string, error) {
kept, err := store.ReadDeclared(store.DeclaredPath(statePath))
if errors.Is(err, store.ErrNothingDeclared) {
return "", errors.New("this node keeps no declaration, so it is declared no directory")
}
if err != nil {
return "", err
}
d, err := declaration.Parse(kept.Declaration)
if err != nil {
return "", fmt.Errorf("the declaration this node keeps does not read: %w", err)
}
return apply.JudgeDirectories(d), nil
}
// judgeDirectoriesFrom is judgeDirectories as the controller asks it over the link.
func judgeDirectoriesFrom(statePath string) link.JudgeDirectoriesFunc {
return func(string) (string, error) { return judgeDirectories(statePath) }
}
// judgeDirectoriesCommand is `nox-mesh-host judge-directories` at this machine, as root: the same judgement, for a
// machine the controller cannot ask.
func judgeDirectoriesCommand(opts options) error {
said, err := judgeDirectories(opts.state)
if err != nil {
return err
}
fmt.Fprintln(opts.out, said)
return nil
}
-289
View File
@@ -1,289 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// A library is judged on every look (novox/hq ADR 0328 §8, §9).
//
// Each library this machine is reached by is said as a resource `library:<name>` of kind library, with no module:
// healthy while it is kept (or, off its home, while its mount is there), waiting with an access wait of reason keep
// or disk, unknown while its filesystem could not be named, unhealthy otherwise. The root is the target, never in a
// reason nor in a wait's words: the controller says them on the operator's channel, which withholds a path (ADR
// 0234 §6). An access is judged by nothing, as before ADR 0325.
// libraryJudge reads every library on every look. Safe for the apply and the looking loop at once.
type libraryJudge struct {
mu sync.Mutex
libraries []declaration.Library
declared *declaration.Declaration
// was is each library's last verdict, with since when and how many looks in a row found it unhealthy.
was map[string]link.ResourceHealth
// reading guards inFlight: every library whose look has not returned yet.
reading sync.Mutex
inFlight map[string]bool
}
// accesses is the one library judge on this machine (its name from when it judged accesses).
var accesses = newLibraryJudge()
func newLibraryJudge() *libraryJudge {
return &libraryJudge{was: map[string]link.ResourceHealth{}, inFlight: map[string]bool{}}
}
// set is what the declaration just applied declares: every library. One no longer declared is forgotten.
func (j *libraryJudge) set(d *declaration.Declaration, _ map[string]bool) {
var ls []declaration.Library
if d != nil {
ls = append(ls, d.Libraries...)
}
sort.Slice(ls, func(a, b int) bool { return ls[a].Library < ls[b].Library })
j.mu.Lock()
defer j.mu.Unlock()
j.libraries, j.declared = ls, d
declared := map[string]bool{}
for _, l := range ls {
declared[apply.LibraryAccess(l.Library)] = true
}
for id := range j.was {
if !declared[id] {
delete(j.was, id)
}
}
}
// declaration is the declaration last set.
func (j *libraryJudge) declaration() *declaration.Declaration {
j.mu.Lock()
defer j.mu.Unlock()
return j.declared
}
// lookBound is how long a look waits for one library to answer. A hard network mount whose server is down blocks
// a stat for ever, and one library must never stop every health statement this machine makes.
var lookBound = 5 * time.Second
// verdict is one library's judgement in a look.
type verdict struct {
state, reason string
waits []link.AccessWait
act *link.AccessActRecord
}
// judgeBounded judges one library within lookBound, outside any lock. A look that has not answered is said so;
// while it still hangs, later looks start no other on the same library.
func (j *libraryJudge) judgeBounded(l declaration.Library) (verdict, bool) {
j.reading.Lock()
if j.inFlight[l.Library] {
j.reading.Unlock()
return verdict{}, false
}
j.inFlight[l.Library] = true
j.reading.Unlock()
done := make(chan verdict, 1)
go func() {
v := judgeLibrary(l)
j.reading.Lock()
delete(j.inFlight, l.Library)
j.reading.Unlock()
done <- v
}()
select {
case v := <-done:
return v, true
case <-time.After(lookBound):
return verdict{}, false
}
}
// judgeLibrary is one library's judgement now (ADR 0328 §7, §8, §9).
func judgeLibrary(l declaration.Library) verdict {
v := apply.JudgeLibrary(context.Background(), l)
access := apply.LibraryAccess(l.Library)
var out verdict
if a := v.Act; a != nil {
out.act = &link.AccessActRecord{Act: a.Act, Access: access, Warrant: a.Warrant, By: a.By, State: a.State,
Started: a.Started, Ended: a.Ended, Folders: a.Folders, Files: a.Files, Kept: a.Kept, Links: a.Links,
Other: a.Other, Mounts: a.Mounts, Shared: a.Shared, Linked: a.Linked, Stripped: a.Stripped, Why: a.Why}
}
switch v.Kind {
case apply.VerdictWaitKeep:
out.state, out.reason = link.StateWaiting, "its root holds data the mesh has not taken on the filesystem "+
"it is on now, and waits for the operator's word (novox/hq ADR 0328 §7, §8)"
out.waits = []link.AccessWait{{Kind: link.WaitKindAccess, Access: access, Part: l.Library,
What: apply.KeepWords(l.Library), Placed: v.Placed, Reason: link.WaitKeep}}
case apply.VerdictWaitDisk:
out.state, out.reason = link.StateWaiting, v.Why+" (novox/hq ADR 0328 §9)"
out.waits = []link.AccessWait{{Kind: link.WaitKindAccess, Access: access, Part: l.Library,
What: apply.DiskWords(l.Library), Reason: link.WaitDisk}}
case apply.VerdictUnknown:
// Not a fault and not the operator's: said as not known, and judged again on the next look.
out.state, out.reason = link.StateUnknown, v.Why+"; judged again on the next look"
case apply.VerdictBroken:
out.state, out.reason = link.StateUnhealthy, v.Why
default:
out.state = link.StateHealthy
switch {
case v.Act != nil && v.Act.State == link.ActRunning:
out.state, out.reason = link.StateStarting, "the take act is setting its group under its root"
case v.Act != nil && (v.Act.State == link.ActStopped || v.Act.State == link.ActFailed):
out.state, out.reason = link.StateStarting, "the take act goes on from where it stopped: "+v.Act.Why
}
}
return out
}
// look reads every library now. Whether any verdict changed since the last look, for the loop to say it.
func (j *libraryJudge) look(now time.Time) ([]link.ResourceHealth, bool) {
j.mu.Lock()
ls := append([]declaration.Library(nil), j.libraries...)
j.mu.Unlock()
read := make([]verdict, len(ls))
for i, l := range ls {
v, answered := j.judgeBounded(l)
if !answered {
v = verdict{state: link.StateUnknown, reason: "its root does not answer: judging it did not return " +
"within " + lookBound.String()}
}
read[i] = v
}
// And every tool runner's account the last apply could not put in a group, as a resource of its own (ADR 0334
// §2): unhealthy, never failing the apply.
warnings := apply.RunnerWarnings()
j.mu.Lock()
defer j.mu.Unlock()
changed := false
out := make([]link.ResourceHealth, 0, len(ls)+len(warnings))
for _, w := range warnings {
v := link.ResourceHealth{Module: w.Module, Resource: w.Resource, Kind: link.KindRunner, State: link.StateUnhealthy,
Reason: "the tool runner's account joins no group: " + w.Why, Since: now.UTC(), Streak: 1}
if was, had := j.was[w.Resource]; had && was.Reason == v.Reason {
v.Since, v.Streak = was.Since, was.Streak+1
} else {
changed = true
}
j.was[w.Resource] = v
out = append(out, v)
}
for i, l := range ls {
r := read[i]
id := apply.LibraryAccess(l.Library)
v := link.ResourceHealth{Resource: id, Kind: link.KindLibrary, Target: l.Root, State: r.state,
Reason: r.reason, Since: now.UTC(), AccessWaits: r.waits, Act: r.act}
if was, had := j.was[id]; had && was.State == r.state && was.Reason == r.reason {
v.Since = was.Since
v.Streak = was.Streak
} else {
changed = true
}
if r.state == link.StateUnhealthy {
v.Streak++
}
j.was[id] = v
out = append(out, v)
}
return out, changed
}
// withAccesses adds to a statement every library, as a resource of kind library.
func withAccesses(h *link.Health, vs []link.ResourceHealth) *link.Health {
h.Resources = append(h.Resources, vs...)
return h
}
// accessesHealthy says no library in vs is anything but healthy.
func accessesHealthy(vs []link.ResourceHealth) bool {
for _, v := range vs {
if v.State != link.StateHealthy {
return false
}
}
return true
}
// keeper goes on with take acts stopped at their bound, beside the applies, one at a time (ADR 0328 §7).
type keeper struct {
running atomic.Bool
say func(string)
}
// keeping is the one keeper on this machine.
var keeping = &keeper{say: func(string) {}}
// poke goes on with every stopped take act of the declaration last applied, unless that is running already.
func (k *keeper) poke(ctx context.Context) {
d := accesses.declaration()
if d == nil || len(d.Libraries) == 0 || !k.running.CompareAndSwap(false, true) {
return
}
go func() {
defer k.running.Store(false)
for _, line := range apply.ReconcileLibraries(ctx, d) {
k.say(line)
}
}()
}
// startKeeping has the keeper say what it does.
func startKeeping(ctx context.Context, say func(string)) {
keeping.say = say
keeping.poke(ctx)
}
// accessActFrom is the take act as the controller asks it over the link (ADR 0328 §7): judged against the
// declaration this node keeps, and run beside the applies.
func accessActFrom(statePath string, timeout time.Duration) link.AccessActFunc {
return func(act link.AccessAct) (string, error) {
kept, err := store.ReadDeclared(store.DeclaredPath(statePath))
if errors.Is(err, store.ErrNothingDeclared) {
return "", errors.New("this node keeps no declaration, so it is declared no library")
}
if err != nil {
return "", err
}
d, err := declaration.Parse(kept.Declaration)
if err != nil {
return "", fmt.Errorf("the declaration this node keeps does not read: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
_, said, err := apply.TakeLibrary(ctx, d, act.Access, filepath.Clean(act.Path), act.Warrant, act.By)
if err != nil {
fmt.Fprintf(os.Stderr, "mesh-host: the take act on %s was refused: %v\n", act.Access, err)
return "", err
}
return said, nil
}
}
// withWaiting is held with every id of waiting beside it: what liveness does not judge.
func withWaiting(held, waiting map[string]bool) map[string]bool {
if len(waiting) == 0 {
return held
}
out := map[string]bool{}
for id := range held {
out[id] = true
}
for id := range waiting {
out[id] = true
}
return out
}
-56
View File
@@ -1,56 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/link"
)
// A library whose root holds data never taken is said waiting with one access wait: the access `library:<name>`, the
// part its name, words with no path, the filesystem's name, and reason keep (novox/hq ADR 0328 §8); one whose root
// is absent waits with reason disk (§9). Neither carries a module.
func TestALibrarySaysItsWaitWithoutWordsToParse(t *testing.T) {
was := apply.Libraries
apply.Libraries = apply.NewLibraryMemory()
defer func() { apply.Libraries = was }()
dir := t.TempDir()
root := filepath.Join(dir, "media")
if err := os.MkdirAll(filepath.Join(root, "a film"), 0o755); err != nil {
t.Fatal(err)
}
d, err := declaration.Parse([]byte(`{"declaration":1,"groups":[{"name":"mesh-media","gid":62000},` +
`{"name":"mesh-books","gid":62001}],"libraries":[` +
`{"library":"media","root":"` + root + `","group":"mesh-media","gid":62000,"home":true},` +
`{"library":"books","root":"` + filepath.Join(dir, "books") + `","group":"mesh-books","gid":62001,"home":true}],` +
`"owns_nothing":true,"resources":[]}`))
if err != nil {
t.Fatal(err)
}
j := newLibraryJudge()
j.set(d, nil)
vs, _ := j.look(time.Now())
if len(vs) != 2 {
t.Fatalf("the libraries are not said: %+v", vs)
}
books, media := vs[0], vs[1]
if media.Kind != link.KindLibrary || media.Resource != "library:media" || media.Module != "" ||
media.State != link.StateWaiting || len(media.AccessWaits) != 1 {
t.Fatalf("a library holding data is not said waiting: %+v", media)
}
raw, _ := json.Marshal(media.AccessWaits[0])
want := `{"kind":"access","access":"library:media","part":"media","what":"let the mesh keep the library media",` +
`"placed":"` + media.AccessWaits[0].Placed + `","reason":"keep"}`
if string(raw) != want || !strings.HasPrefix(media.AccessWaits[0].Placed, "fs-") {
t.Fatalf("the wait reads %s", raw)
}
if strings.Contains(media.Reason, dir) || len(books.AccessWaits) != 1 || books.AccessWaits[0].Reason != link.WaitDisk {
t.Fatalf("a missing root does not wait for its disk, or a reason names a path: %+v %+v", books, media)
}
}
-107
View File
@@ -1,107 +0,0 @@
package main
import (
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/link"
)
// Defends novox/hq issue 160: a machine nobody pushed to still says what it is made of once a day, so the
// newest of it the mesh holds is no older than a day. Before, the reconcile said it only with something
// else (a hold, a firewall, an outward link changed), and a converged machine's picture was as old as the
// last delivery to it.
func TestAMachineNobodyPushedToSaysWhatItIsOnceADay(t *testing.T) {
at := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)
w := &adoptionWatch{now: func() time.Time { return at }}
quiet := link.Report{Outward: []string{"wlan0"},
Profile: map[string]any{"machine": map[string]any{"observed_at": at}}}
if !w.news(quiet) {
t.Fatal("the first reconcile after the engine started did not say what the machine is")
}
w.said(quiet)
for _, after := range []time.Duration{5 * time.Minute, 12 * time.Hour, 22*time.Hour + 55*time.Minute} {
at = time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC).Add(after)
if w.news(quiet) {
t.Errorf("%s after the mesh heard it, an unchanged reconcile was said again", after)
}
}
// The reconcile that falls past 23 hours says it, so it reaches the mesh inside the day.
at = time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC).Add(23*time.Hour + 5*time.Minute)
if !w.news(quiet) {
t.Fatal("a day on, the reconcile did not say what the machine is")
}
w.said(quiet)
at = at.Add(5 * time.Minute)
if w.news(quiet) {
t.Error("once said, it was said again at the next reconcile")
}
}
// A delivery's account carries the profile too: heard, it starts the day again.
func TestADeliveryHeardStartsTheDayAgain(t *testing.T) {
at := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)
w := &adoptionWatch{now: func() time.Time { return at }}
report := link.Report{Outward: []string{"eth0"}, Profile: map[string]any{"capabilities": []any{}}}
w.said(report)
at = at.Add(20 * time.Hour)
w.said(report) // a delivery's apply
at = at.Add(20 * time.Hour)
if w.news(report) {
t.Error("said again unasked 20 hours after a delivery told the mesh")
}
}
// A refusal carries nothing about the machine, so it is never said for being a day old.
func TestARefusalIsNotSaidForTheMachinesSake(t *testing.T) {
at := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)
w := &adoptionWatch{now: func() time.Time { return at }}
if w.machineDue(link.Report{Refused: "no"}) {
t.Error("a refusal was due for what the machine is made of")
}
}
// A laptop that slept still says what it is within a day of wall time (review of mesh-host#92). Go's monotonic
// clock stops while a machine is suspended, and Sub between two times that both carry a monotonic reading uses
// it, so a watch that kept one would count a day asleep as almost nothing. Simulated with a clock that first
// reads now (a monotonic reading), and after the sleep reads the wall time a day on: the watch must keep no
// monotonic reading, and judge the day as the wall clock says it.
func TestALaptopThatSleptStillSaysWhatItIsWithinADay(t *testing.T) {
before := time.Now() // carries a monotonic reading
asleep := before.Add(3 * time.Second)
if asleep.Sub(before) != 3*time.Second {
t.Fatal("the monotonic reading is not what this test simulates with")
}
calls := 0
w := &adoptionWatch{now: func() time.Time {
calls++
if calls == 1 {
return before
}
// The wall clock a day on, as a machine's clock reads after waking.
return asleep.Round(0).Add(24 * time.Hour)
}}
report := link.Report{Outward: []string{"wlan0"}, Profile: map[string]any{"capabilities": []any{}}}
w.said(report)
if strings.Contains(w.machineSaid.String(), "m=") {
t.Fatalf("the watch kept a monotonic reading (%s): a sleep would not count toward the day", w.machineSaid)
}
if !w.news(report) {
t.Error("a day of wall time on, a laptop that slept did not say what it is")
}
}
// And the watch's own clock never carries a monotonic reading.
func TestTheWatchClockIsWallTime(t *testing.T) {
w := &adoptionWatch{}
if s := w.clock().String(); strings.Contains(s, "m=") {
t.Errorf("the watch's clock carries a monotonic reading: %s", s)
}
w = &adoptionWatch{now: time.Now}
if s := w.clock().String(); strings.Contains(s, "m=") {
t.Errorf("a given clock's time kept its monotonic reading: %s", s)
}
}
+108 -906
View File
File diff suppressed because it is too large Load Diff
+4 -66
View File
@@ -189,15 +189,13 @@ func TestAReconcileSpeaksOnlyWhenWhatIsHeldChanged(t *testing.T) {
}
}
// What the mesh heard — a delivery's account as much as a reconcile's — counts as said: the queue
// tells the watch every report the broker took (link.Queue.Heard).
func TestWhatTheMeshHeardCountsAsSaid(t *testing.T) {
func TestWhatTheLinkPublishedCountsAsSaid(t *testing.T) {
w := &adoptionWatch{}
report := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "a"}}}
heard := w.said
heard(report)
applier := w.noting(func(context.Context, []byte, []byte) link.Report { return report })
applier(context.Background(), nil, nil)
if w.changed(report) {
t.Error("a reconcile repeated what the mesh had just heard")
t.Error("a reconcile repeated what the link had just published")
}
}
@@ -611,63 +609,3 @@ func TestTheApplysLogIsNeverNil(t *testing.T) {
t.Fatalf("the apply's detail did not reach the caller's announce: %v", said)
}
}
// Defends novox/hq issues 257 and 261: a reconcile due while the link applies a newer declaration
// applies that newer one once its turn comes — never the one kept when its timer fired. Read before
// waiting, it gave back a module assigned a second earlier, and reported a declaration the mesh no
// longer recorded as sent.
func TestAReconcileAppliesWhatWasKeptWhenItsTurnComes(t *testing.T) {
built, err := system.For("arch")
if err != nil || built.Confirm(context.Background(), apply.ExecRunner) != nil {
t.Skip("this machine is not one these tests can apply on")
}
was := builtFor
builtFor = "arch"
t.Cleanup(func() { builtFor = was })
dir := t.TempDir()
opts := options{state: filepath.Join(dir, "state.json")}
public, private, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
target := filepath.Join(dir, "a.conf")
kept := func(content string) store.Declared {
body := []byte(`{"declaration":1,"resources":[{"id":"a","type":"file","path":"` + target +
`","content":"` + content + `\n"}]}`)
return store.Declared{Declaration: body, Signature: ed25519.Sign(private, body)}
}
if err := store.SaveDeclared(store.DeclaredPath(opts.state), kept("older")); err != nil {
t.Fatal(err)
}
// The link is applying: the reconcile's timer fires and it waits its turn.
applying.Lock()
done := make(chan error, 1)
go func() {
_, err := reapplyKept(context.Background(), opts, public, nil, nil)
done <- err
}()
time.Sleep(50 * time.Millisecond)
// The link's apply ends by keeping the newer declaration, and lets go.
if err := store.SaveDeclared(store.DeclaredPath(opts.state), kept("newer")); err != nil {
applying.Unlock()
t.Fatal(err)
}
applying.Unlock()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(10 * time.Second):
t.Fatal("the reconcile never ran once the node was free")
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(got) != "newer\n" {
t.Errorf("the reconcile applied %q, what was kept before the newer declaration", got)
}
}
-25
View File
@@ -1,25 +0,0 @@
package main
import (
"context"
"git.novox.be/novox/mesh-sdk/go/cli"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/meshcli"
)
// serveMeshCLI answers mesh-cli on this machine for as long as this engine serves (novox/hq ADR 0272): the account
// from the kernel, the line asked of the controller over the link open at the time. A socket that cannot be served
// is said once, and this engine goes on applying; mesh-cli then says the engine does not answer.
func serveMeshCLI(ctx context.Context, queue *link.Queue, say func(string)) {
if err := meshcli.Serve(ctx, cli.Socket, forwardOver(queue), say); err != nil {
say("mesh-cli is not answered on this machine: " + err.Error())
}
}
// forwardOver asks the controller over the queue's link, and follows the line to what came of it.
func forwardOver(queue *link.Queue) meshcli.Forward {
return func(ctx context.Context, asked meshcli.Asked) cli.Answer {
return meshcli.AskTheController(ctx, queue.AskController, asked)
}
}
-54
View File
@@ -1,54 +0,0 @@
package main
import (
"testing"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/liveness"
"github.com/novox/mesh-host/internal/network"
)
// The machine's networking in the engine's statement (novox/hq ADR 0241): the file judged is the one the
// uplink holder declares whole, the mesh name asked is the bus's, and the statement carries the parts.
func TestTheResolverFileJudgedIsTheOneAModuleDeclaresWhole(t *testing.T) {
d := &declaration.Declaration{Resources: []declaration.Resource{
&declaration.File{ID: "hosts.file", Type: "file", Path: "/etc/hosts", Content: "x"},
&declaration.File{ID: "networkmanager.resolv", Type: "file", Path: network.ResolvConf, Content: "nameserver 10.10.0.1\n"},
}}
content, owner, ok := declaredResolvConf(d)
if !ok || owner != "networkmanager" || content != "nameserver 10.10.0.1\n" {
t.Fatalf("got %q %q %v", content, owner, ok)
}
d.Resources[1].(*declaration.File).CreateOnce = true
if _, _, ok := declaredResolvConf(d); ok {
t.Fatal("a seed nobody holds the machine to was judged as the declared file")
}
if _, _, ok := declaredResolvConf(nil); ok {
t.Fatal("no declaration declared a file")
}
}
func TestTheMeshNameAskedIsTheBuses(t *testing.T) {
for broker, want := range map[string]string{"anchor.internal:4222": "anchor.internal", "192.0.2.10:5671": "",
"anchor.internal": "anchor.internal", "[2001:db8::1]:4222": ""} {
if got := hostOf(broker); got != want {
t.Errorf("%s: got %q, want %q", broker, got, want)
}
}
}
func TestTheStatementCarriesTheNetwork(t *testing.T) {
at := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
h := healthAsReported(liveness.Statement{At: at}, &network.Statement{State: network.Unhealthy, Since: at,
Parts: []network.Part{{Part: network.PartResolvConf, State: network.Unhealthy, Writer: "FortiClient",
Owner: "networkmanager", Reason: "the resolver file was rewritten by another program", Since: at}}})
if h.Network == nil || h.Network.State != network.Unhealthy || len(h.Network.Parts) != 1 ||
h.Network.Parts[0].Writer != "FortiClient" || h.Network.Parts[0].Owner != "networkmanager" {
t.Fatalf("the statement says %+v", h.Network)
}
if h := healthAsReported(liveness.Statement{At: at}, nil); h.Network != nil {
t.Fatal("an engine with no network judge said a network")
}
}
+7 -44
View File
@@ -5,7 +5,6 @@ import (
"strings"
"testing"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
@@ -14,15 +13,9 @@ import (
// "older" could not.
func keptWith(t *testing.T, sequence int64) store.Declared {
t.Helper()
return keptAt(t, link.Order{Sequence: sequence})
}
func keptAt(t *testing.T, order link.Order) store.Declared {
t.Helper()
body, err := json.Marshal(map[string]any{
"declaration": 1, "resources": []any{}, "owns_nothing": true, "sequence": order.Sequence,
"epoch": order.Epoch,
"declaration": 1, "resources": []any{}, "owns_nothing": true, "sequence": sequence,
})
if err != nil {
t.Fatal(err)
@@ -31,7 +24,7 @@ func keptAt(t *testing.T, order link.Order) store.Declared {
}
func TestAnOlderDeclarationFromTheMeshIsRefused(t *testing.T) {
err := refuseOlder(keptWith(t, 7), nil, link.Order{Sequence: 5})
err := refuseOlder(keptWith(t, 7), nil, 5)
if err == nil {
t.Fatal("sequence 5 was accepted over a kept 7")
}
@@ -41,11 +34,11 @@ func TestAnOlderDeclarationFromTheMeshIsRefused(t *testing.T) {
}
func TestANewerOrEqualDeclarationIsNot(t *testing.T) {
if err := refuseOlder(keptWith(t, 7), nil, link.Order{Sequence: 8}); err != nil {
if err := refuseOlder(keptWith(t, 7), nil, 8); err != nil {
t.Fatalf("sequence 8 was refused over a kept 7: %v", err)
}
// Equal is the same declaration again, which reconciling is for.
if err := refuseOlder(keptWith(t, 7), nil, link.Order{Sequence: 7}); err != nil {
if err := refuseOlder(keptWith(t, 7), nil, 7); err != nil {
t.Fatalf("the same sequence was refused: %v", err)
}
}
@@ -53,43 +46,13 @@ func TestANewerOrEqualDeclarationIsNot(t *testing.T) {
func TestNoOrderClaimedMeansNoOrderCompared(t *testing.T) {
// An older controller sends none; a host that received before it understood them kept none.
// Refusing on a guess would strand a node the moment the controller is older than the host.
if err := refuseOlder(keptWith(t, 7), nil, link.Order{Sequence: 0}); err != nil {
if err := refuseOlder(keptWith(t, 7), nil, 0); err != nil {
t.Fatalf("a declaration claiming no order was refused: %v", err)
}
if err := refuseOlder(keptWith(t, 0), nil, link.Order{Sequence: 3}); err != nil {
if err := refuseOlder(keptWith(t, 0), nil, 3); err != nil {
t.Fatalf("a declaration was refused against a kept one that claimed no order: %v", err)
}
if err := refuseOlder(store.Declared{}, store.ErrNothingDeclared, link.Order{Sequence: 3}); err != nil {
if err := refuseOlder(store.Declared{}, store.ErrNothingDeclared, 3); err != nil {
t.Fatalf("a first declaration was refused: %v", err)
}
}
// **The lease epoch decides first** (novox/hq to-be 45 §6): a declaration from a controller that lost
// its lease is refused whatever its sequence, and a new lease holder's is taken whatever its sequence.
func TestAnOlderEpochIsRefusedAndANewerTaken(t *testing.T) {
kept := keptAt(t, link.Order{Epoch: 57, Sequence: 3})
err := refuseOlder(kept, nil, link.Order{Epoch: 41, Sequence: 12})
if err == nil || !strings.Contains(err.Error(), "epoch 41") || !strings.Contains(err.Error(), "epoch 57") {
t.Fatalf("an older epoch was not refused by name: %v", err)
}
if err := refuseOlder(kept, nil, link.Order{Epoch: 58, Sequence: 1}); err != nil {
t.Fatalf("a new lease holder's first declaration was refused for its sequence: %v", err)
}
if err := refuseOlder(kept, nil, link.Order{Epoch: 57, Sequence: 2}); err == nil {
t.Fatal("a lower sequence under the same epoch was accepted")
}
// A controller with no lease — older, or rolled back to a build from before it — is today's
// behaviour: no epoch compared.
if err := refuseOlder(kept, nil, link.Order{Sequence: 4}); err != nil {
t.Fatalf("a declaration with no epoch was refused for one: %v", err)
}
if held, older := olderThanKept(kept, nil, link.Order{Epoch: 41, Sequence: 12}); !older ||
held != (link.Order{Epoch: 57, Sequence: 3}) {
t.Fatalf("olderThanKept said %v, holding %+v", older, held)
}
// What is kept cannot be read: no order to compare, so nothing is refused on a guess.
if _, older := olderThanKept(store.Declared{Declaration: []byte("garbled")}, nil,
link.Order{Epoch: 41, Sequence: 1}); older {
t.Fatal("refused against a kept declaration nobody can read")
}
}
-154
View File
@@ -1,154 +0,0 @@
package main
import (
"context"
"errors"
"os/exec"
"sort"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// packageManagers are the tools a system's probe asks, for a system whose probe is its package manager.
var packageManagers = map[string]string{"arch": "pacman", "alpine": "apk"}
// unanswered is a probe of the package manager that failed before an apply: which tool, and its words.
type unanswered struct {
tool string
words string
}
// failedKey is how a report names the probe among what failed: one key, the same on every report, so the
// mesh counts a package manager that keeps not answering as one failure that goes on.
func (u *unanswered) failedKey() string { return "the package manager (" + u.tool + ")" }
// said is the line the apply says before it begins.
func (u *unanswered) said() string {
return u.tool + " does not answer here, so every package in this declaration fails and the rest of it is " +
"applied (novox/hq issue 233): " + u.words
}
// partial is the failure of an apply that applied everything else, when no package was declared to fail.
func (u *unanswered) partial() error {
return errors.New("applied only in part: " + u.words + "\n\nevery resource that does not need " + u.tool +
" was attempted; none here needed it, and " + u.tool + " must answer before this machine applies whole " +
"(novox/hq issue 233)")
}
// probePackageManager proves the machine is the system the host was built for, before anything is applied.
//
// **A package manager that does not answer fails only what needs it** (novox/hq issue 233). The probe was a
// refusal of the whole declaration — right for a host installed on a machine it was not built for, and a
// wedge when what the probe reads is the mesh's own: a declaration that left the package manager's module
// out removed its configuration file, and from then on every declaration was refused, the one that would
// write the file back and the node-engine's own upgrade included. A person on the machine was the only way
// back. Now the rest applies — files, units, accounts, the node-engine itself — and each package fails with
// the probe's words (the system's PackageInstalled asks the same probe first), so a later declaration can
// undo what an earlier one did. Before the rest is applied, a declaration composed from an older view of the
// assignments than this machine applied has already been refused (issue 234's generation).
//
// **A package manager that is not there at all still refuses whole.** That is the host installed on a
// machine it was not built for — the arch host on Alpine — which is what the probe exists to catch, and no
// declaration the mesh sends removes a package manager's program. Only one that is there and errors (a
// configuration file gone, a database locked) is the mesh's own fault and applies in part.
//
// A system with no package manager (android) still refuses whole: what its probe asks is the system itself,
// which no resource the mesh declares can remove, and nothing else stands between it and the wrong machine.
func probePackageManager(ctx context.Context, sys system.System) (*unanswered, error) {
err := sys.Confirm(ctx, apply.ExecRunner)
if err == nil {
return nil, nil
}
tool, ok := packageManagers[sys.Name()]
if !ok || errors.Is(err, exec.ErrNotFound) {
return nil, err
}
return &unanswered{tool: tool, words: err.Error()}, nil
}
// applyPass is one apply of the declaration over what the node's state says.
type applyPass func(known store.State) (apply.Report, store.State, error)
// applyMindingProbe applies, and **once more when the package manager did not answer and this apply wrote
// something** (novox/hq issue 233). What it wrote may be what the package manager needed — the declaration
// that names its module again writes its configuration file back — and then the packages go in now rather
// than at the next reconcile. Asked again only then: an apply that wrote nothing changed nothing the package
// manager reads. The second pass is the same declaration over the state the first left, so what the first
// applied reads unchanged and only what needs the package manager moves.
//
// It returns the last pass's account, the probe as it stands after it (nil once the package manager
// answers), and the first pass's outcomes when a second ran, for whoever counts what an apply moved.
func applyMindingProbe(ctx context.Context, sys system.System, probe *unanswered, known store.State,
pass applyPass, say func(string)) (apply.Report, store.State, error, *unanswered, []apply.Outcome) {
report, updated, err := pass(known)
if probe == nil || !wrote(report, err) {
return report, updated, err, probe, nil
}
again, refused := probePackageManager(ctx, sys)
if refused != nil || again != nil {
return report, updated, err, probe, nil
}
say(probe.tool + " answers now that this apply wrote what it reads; applying again, so its packages are " +
"installed in this apply (novox/hq issue 233)")
first := append([]apply.Outcome(nil), report.Outcomes...)
var partial *apply.Error
if errors.As(err, &partial) {
first = append(first, partial.Done.Outcomes...)
}
report, updated, err = pass(updated)
return report, updated, err, nil, first
}
// wrote is whether an apply moved anything on the machine, failed or not.
func wrote(report apply.Report, err error) bool {
if report.Changed() {
return true
}
var partial *apply.Error
return errors.As(err, &partial) && partial.Done.Changed()
}
// unapplied is which declared resources an apply did not apply, failed or not attempted, sorted; nil for a
// clean one.
func unapplied(d *declaration.Declaration, report apply.Report, err error) []string {
if err == nil {
return nil
}
done := map[string]bool{}
for _, o := range report.Outcomes {
done[o.ID] = o.Action != "skipped"
}
var partial *apply.Error
if errors.As(err, &partial) {
for _, o := range partial.Done.Outcomes {
done[o.ID] = done[o.ID] || o.Action != "skipped"
}
}
var out []string
for _, r := range d.Resources {
if !done[r.Identity()] {
out = append(out, r.Identity())
}
}
sort.Strings(out)
if len(out) == 0 {
// Failed, and every resource has an account: what failed is no resource (the firewall found, a removal).
out = []string{"(apply)"}
}
return out
}
// withUnanswered adds the probe to what a report says failed.
func withUnanswered(failed map[string]string, u *unanswered) map[string]string {
if u == nil {
return failed
}
if failed == nil {
failed = map[string]string{}
}
failed[u.failedKey()] = u.said()
return failed
}
-209
View File
@@ -1,209 +0,0 @@
package main
import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/system"
)
// Defends novox/hq issue 233: a package manager that does not answer is said even when nothing declared
// needs it — the apply is partial, never "applied" — and the report names it under one key, so the mesh
// counts it as one failure that goes on.
func TestAPackageManagerThatDoesNotAnswerIsSaidWithNoPackageDeclared(t *testing.T) {
unansweringPacman(t)
dir := t.TempDir()
opts := options{state: filepath.Join(dir, "state.json"), out: &strings.Builder{}}
conf := filepath.Join(dir, "pacman.conf")
raw := []byte(`{"declaration":1,"resources":[{"id":"pacman.conf","type":"file","path":"` + conf +
`","content":"[options]\n"}]}`)
d, err := declaration.ParseFileTrusted(raw)
if err != nil {
t.Fatal(err)
}
err = runApply(context.Background(), opts, d, raw, fromFile)
if err == nil || !strings.Contains(err.Error(), "applied only in part") || !strings.Contains(err.Error(), "could not be read") {
t.Fatalf("an apply whose package manager did not answer was not said to be partial: %v", err)
}
if _, statErr := os.Stat(conf); statErr != nil {
t.Fatalf("the file was not written: %v", statErr)
}
report := applyAndKeep(context.Background(), opts, raw, nil, nil, nil)
if report.Refused != "" {
t.Fatalf("refused whole: %s", report.Refused)
}
why, ok := report.Failed["the package manager (pacman)"]
if !ok || !strings.Contains(why, "could not be read") {
t.Errorf("the report does not name the package manager with the probe's words: %v", report.Failed)
}
}
// A system whose probe asks the system itself, not a tool a resource needs, still refuses whole: nothing
// the mesh declares can remove it, and nothing else stands between that host and the wrong machine.
func TestASystemWithNoPackageManagerStillRefusesWhole(t *testing.T) {
t.Setenv("PATH", t.TempDir())
sys, err := system.For("android")
if err != nil {
t.Fatal(err)
}
probe, err := probePackageManager(context.Background(), sys)
if err == nil || probe != nil {
t.Fatalf("the android host's failed probe did not refuse: %v, %+v", err, probe)
}
}
// pacmanAnsweringOnce is a pacman that does not answer until conf exists, as the workstation's did until its
// configuration file was written back, and installs into a directory of its own once it does.
func pacmanAnsweringOnce(t *testing.T, conf string) {
t.Helper()
bin, installed := t.TempDir(), t.TempDir()
script := "#!/bin/sh\n" +
"[ -f '" + conf + "' ] || { echo \"error: config file " + conf + " could not be read: No such file or directory\" >&2; exit 1; }\n" +
"for a; do last=$a; done\n" +
"case \"$1\" in\n" +
"-Q) [ \"$2\" = pacman ] && exit 0; [ -f '" + installed + "'/\"$2\" ] && exit 0; exit 1;;\n" +
"-S) touch '" + installed + "'/\"$last\"; exit 0;;\n" +
"esac\nexit 1\n"
if err := os.WriteFile(filepath.Join(bin, "pacman"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
was := builtFor
builtFor = "arch"
t.Cleanup(func() { builtFor = was })
}
// Defends the review of novox/hq issue 233: **the apply that writes the package manager's configuration back
// installs its packages too**. The probe is asked again once the partial apply wrote something, and when it
// answers the declaration is applied again over what the first pass left — the packages in this apply, not five
// minutes later, and the report clean.
func TestThePackagesGoInWhenTheApplyThatRestoresThePackageManagerWroteIt(t *testing.T) {
dir := t.TempDir()
conf := filepath.Join(dir, "pacman.conf")
pacmanAnsweringOnce(t, conf)
opts := options{state: filepath.Join(dir, "state.json")}
// The package first: within one pass it is asked about before the file is written, which is the order that
// leaves it to a second pass.
raw := []byte(`{"declaration":1,"resources":[` +
`{"id":"pacman.base","type":"package","package":"base"},` +
`{"id":"pacman.conf","type":"file","path":"` + conf + `","content":"[options]\n"}]}`)
report := applyAndKeep(context.Background(), opts, raw, nil, nil, nil)
if report.Refused != "" || len(report.Failed) != 0 {
t.Fatalf("the apply that made the package manager answer did not install its packages: refused %q, failed %v",
report.Refused, report.Failed)
}
if !slices.Contains(report.Applied, "pacman.base") || !slices.Contains(report.Applied, "pacman.conf") {
t.Errorf("the report does not account for both: %v", report.Applied)
}
}
// Defends decision (1) of the review of novox/hq issue 233: **a package manager that is not there at all still
// refuses whole**. That is the host installed on the wrong machine, which the probe exists to catch.
func TestAPackageManagerThatIsNotThereRefusesWhole(t *testing.T) {
t.Setenv("PATH", t.TempDir())
was := builtFor
builtFor = "arch"
t.Cleanup(func() { builtFor = was })
dir := t.TempDir()
conf := filepath.Join(dir, "pacman.conf")
raw := []byte(`{"declaration":1,"resources":[{"id":"pacman.conf","type":"file","path":"` + conf +
`","content":"[options]\n"}]}`)
report := applyAndKeep(context.Background(), options{state: filepath.Join(dir, "state.json")}, raw, nil, nil, nil)
if !strings.Contains(report.Refused, "pacman does not answer") {
t.Fatalf("a machine with no pacman at all was not refused whole: %+v", report)
}
if _, err := os.Stat(conf); err == nil {
t.Error("the refused declaration touched the machine")
}
}
// Defends the review of novox/hq issue 233: **a re-apply whose result changed is said unasked**. A machine
// reconciles every five minutes and speaks only when its account is news; how the apply went was not part of
// it, so a package manager that stopped answering between deliveries was never said, and one that answered
// again never cleared. Now a failure is said once, the same failure again is not, and a clean re-apply after it
// is — which is what clears the mesh's condition.
func TestAReapplyWhoseResultChangedIsSaidUnasked(t *testing.T) {
dir := t.TempDir()
conf := filepath.Join(dir, "elsewhere", "pacman.conf") // not declared: the apply cannot make it answer
pacmanAnsweringOnce(t, conf)
opts := options{state: filepath.Join(dir, "state.json")}
raw := []byte(`{"declaration":1,"resources":[` +
`{"id":"motd","type":"file","path":"` + filepath.Join(dir, "motd") + `","content":"hello\n"},` +
`{"id":"pacman.base","type":"package","package":"base"}]}`)
w := &adoptionWatch{}
// Clean once, as it was before: base installed while pacman answered.
if err := os.MkdirAll(filepath.Dir(conf), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(conf, []byte("[options]\n"), 0o644); err != nil {
t.Fatal(err)
}
clean := applyAndKeep(context.Background(), opts, raw, nil, nil, nil)
if faulty(clean) {
t.Fatalf("the first apply was not clean: %+v", clean)
}
w.said(clean)
if w.news(applyAndKeep(context.Background(), opts, raw, nil, nil, nil)) {
t.Error("a clean re-apply after a clean one was said, so every machine speaks every five minutes")
}
// The configuration goes: the re-apply fails, and that is news once.
if err := os.Remove(conf); err != nil {
t.Fatal(err)
}
failed := applyAndKeep(context.Background(), opts, raw, nil, nil, nil)
if !faulty(failed) {
t.Fatalf("the re-apply did not fail: %+v", failed)
}
if !w.news(failed) {
t.Fatal("a re-apply that failed after a clean one was not said, so the mesh never hears the machine fails")
}
w.said(failed)
if w.news(applyAndKeep(context.Background(), opts, raw, nil, nil, nil)) {
t.Error("the same failure was said again on the next re-apply")
}
// It comes back: the clean re-apply is news, and is what clears the mesh's condition.
if err := os.WriteFile(conf, []byte("[options]\n"), 0o644); err != nil {
t.Fatal(err)
}
again := applyAndKeep(context.Background(), opts, raw, nil, nil, nil)
if faulty(again) {
t.Fatalf("the re-apply did not come back clean: %+v", again)
}
if !w.news(again) {
t.Fatal("a clean re-apply after a failed one was not said, so the mesh's condition never clears")
}
}
// A refusal is news too, once, and a reconcile with nothing to say after a clean one stays silent.
func TestARefusalIsSaidOnceAndNothingIsNotSaid(t *testing.T) {
w := &adoptionWatch{}
w.said(link.Report{})
if w.news(link.Report{}) {
t.Error("nothing was said again")
}
refused := link.Report{Refused: "pacman does not answer here"}
if !w.news(refused) {
t.Fatal("a refusal was not said")
}
w.said(refused)
if w.news(link.Report{Refused: "pacman does not answer here, at another time"}) {
t.Error("the same refusal in other words was said again")
}
if !w.news(link.Report{}) {
t.Error("a clean apply after a refusal was not said")
}
}
-109
View File
@@ -1,109 +0,0 @@
package main
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// unansweringPacman puts a pacman on PATH that answers as one whose configuration file is gone, as the
// workstation's did on 2026-10-04 (novox/hq issue 233), and makes this an arch host.
func unansweringPacman(t *testing.T) {
t.Helper()
bin := t.TempDir()
script := "#!/bin/sh\necho 'error: config file /etc/pacman.conf could not be read: No such file or directory' >&2\nexit 1\n"
if err := os.WriteFile(filepath.Join(bin, "pacman"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
was := builtFor
builtFor = "arch"
t.Cleanup(func() { builtFor = was })
}
// TestReplay233 replays novox/hq issue 233. The package manager's module wrote pacman's configuration
// file; a declaration that left the module out removed it; from then on pacman did not answer, and the
// node-engine refused every declaration whole — the one that named the module again and would have
// written the file back included. A person restored the file by hand an hour and a half later.
//
// Held to: a declaration that restores the file is applied while pacman does not answer. Only what needs
// pacman fails, with the probe's words; the file is written and recorded. Written only with what the
// node-engine had before the fix, so it lays over the commit before it, where it fails.
func TestReplay233(t *testing.T) {
unansweringPacman(t)
dir := t.TempDir()
opts := options{state: filepath.Join(dir, "state.json")}
conf := filepath.Join(dir, "pacman.conf")
raw := []byte(`{"declaration":1,"resources":[` +
`{"id":"pacman.conf","type":"file","path":"` + conf + `","content":"[options]\n"},` +
`{"id":"pacman.base","type":"package","package":"base"}]}`)
report := applyAndKeep(context.Background(), opts, raw, nil, nil, nil)
if report.Refused != "" {
t.Fatalf("the declaration that restores the package manager's configuration was refused whole: %s", report.Refused)
}
if got, err := os.ReadFile(conf); err != nil || string(got) != "[options]\n" {
t.Fatalf("the file that restores the package manager was not written: %q, %v", got, err)
}
known, err := store.Load(opts.state)
if err != nil {
t.Fatal(err)
}
if _, ok := known.Find("pacman.conf"); !ok {
t.Error("the file written was not recorded, so the next apply would not know it is the mesh's")
}
words := strings.Join(failedWords(report.Failed), "\n")
if !strings.Contains(words, "pacman does not answer") || !strings.Contains(words, "could not be read") {
t.Errorf("the report does not say the package manager did not answer, in the probe's words: %q", words)
}
if !strings.Contains(words, "pacman.base") {
t.Errorf("the report does not name the package that was not applied: %q", words)
}
}
func failedWords(failed map[string]string) []string {
var out []string
for id, why := range failed {
out = append(out, id+": "+why)
}
return out
}
// Defends novox/hq issue 233 at the machine: `mesh-host apply` applies what does not need the package
// manager, and says the apply was partial with the probe's words — a non-zero exit, never "applied".
func TestApplyByHandAppliesWhatDoesNotNeedAPackageManagerThatDoesNotAnswer(t *testing.T) {
unansweringPacman(t)
dir := t.TempDir()
out := &bytes.Buffer{}
opts := options{state: filepath.Join(dir, "state.json"), out: out}
conf := filepath.Join(dir, "pacman.conf")
raw := []byte(`{"declaration":1,"resources":[` +
`{"id":"pacman.conf","type":"file","path":"` + conf + `","content":"[options]\n"},` +
`{"id":"pacman.base","type":"package","package":"base"}]}`)
d, err := declaration.ParseFileTrusted(raw)
if err != nil {
t.Fatal(err)
}
err = runApply(context.Background(), opts, d, raw, fromFile)
if _, statErr := os.Stat(conf); statErr != nil {
t.Fatalf("the file was not written: %v (the apply said %v)", statErr, err)
}
if err == nil {
t.Fatal("an apply whose package manager did not answer said it applied")
}
if !strings.Contains(err.Error(), "pacman does not answer") || !strings.Contains(err.Error(), "pacman.base") {
t.Errorf("the failure does not name the package or the probe's words: %v", err)
}
if !strings.Contains(out.String(), "pacman does not answer") {
t.Errorf("what was said before applying does not say the package manager does not answer:\n%s", out.String())
}
}
-20
View File
@@ -1,20 +0,0 @@
package main
import (
"strings"
"testing"
"github.com/novox/mesh-host/internal/liveness"
)
// The restart count the engine says is its own since it began watching the resource, never since the state
// began (novox/hq issue 458).
func TestAnUnhealthyResourceSaysWhatItsRestartCountCounts(t *testing.T) {
r := liveness.State{Resource: liveness.Resource{ID: "messenger.own-runtime", Kind: "process",
Target: "messenger-runtime.service"}, State: liveness.Unhealthy, Reason: liveness.ReasonRestarting, Restarts: 3890}
said := unhealthyWords(r)
if !strings.Contains(said, "3890 automatic restart(s) since this engine began watching it") ||
strings.Contains(said, "counted") {
t.Fatalf("the restart count is not said as what it counts: %s", said)
}
}
-125
View File
@@ -1,125 +0,0 @@
package main
import (
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/accounts"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/liveness"
"github.com/novox/mesh-host/internal/units"
)
// The machine's failed units in the engine's statement (novox/hq issue 315): a module's is among the
// resources, unhealthy, as that module's unit, naming it; the machine's own beside them; and an engine
// not reading them says nothing of them.
func TestTheStatementCarriesTheFailedUnits(t *testing.T) {
at := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
us := &units.Statement{At: at, State: units.Degraded, Failed: []units.Failed{
{Unit: "openrazer-daemon.service", Scope: units.ScopeUser, Load: "loaded", Result: "exit-code",
User: "operator", Module: "openrazer", Resource: "openrazer.daemon", Via: units.ViaPackage, Since: at, Streak: 2},
{Unit: "storage-media.mount", Scope: units.ScopeSystem, Load: "loaded", Result: "timeout", Since: at, Streak: 2},
}}
h := withUnits(healthAsReported(liveness.Statement{At: at}, nil), us)
if len(h.Resources) != 1 {
t.Fatalf("one module's unit among the resources: %+v", h.Resources)
}
r := h.Resources[0]
if r.Module != "openrazer" || r.Resource != "openrazer.daemon" || r.Kind != link.KindUnit ||
r.Target != "openrazer-daemon.service" || r.State != link.StateUnhealthy ||
!strings.Contains(r.Reason, "account's own service manager") || !strings.Contains(r.Reason, "exit-code") {
t.Fatalf("the module's failed unit: %+v", r)
}
// Whose manager it fails in, so the controller can tell it from a fault (novox/hq ADR 0254).
if r.Account != "operator" {
t.Fatalf("the unit in the account's own manager does not name the account: %q", r.Account)
}
if h.Units == nil || h.Units.State != units.Degraded || len(h.Units.Failed) != 1 ||
h.Units.Failed[0].Unit != "storage-media.mount" || h.Units.Failed[0].Result != "timeout" {
t.Fatalf("the machine's own: %+v", h.Units)
}
if h := withUnits(healthAsReported(liveness.Statement{At: at}, nil), nil); h.Units != nil {
t.Fatal("an engine not reading units said them")
}
if h := withUnits(healthAsReported(liveness.Statement{At: at}, nil), &units.Statement{}); h.Units != nil {
t.Fatal("a judge not yet given a declaration said units")
}
}
// Every resource that runs in an account's own service manager names the account, and so does the account
// a module put in a group (novox/hq ADR 0254): the controller reads a unit that cannot run before a new login
// by the two together. What the machine's own manager runs names none.
func TestTheStatementNamesTheAccountsManager(t *testing.T) {
at := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
st := liveness.Statement{At: at, Resources: []liveness.State{
{Resource: liveness.Resource{Module: "openrazer", ID: "openrazer.daemon", Kind: "service",
Target: "openrazer-daemon.service", Scope: declaration.ScopeUser, User: "operator"}, State: liveness.Unhealthy,
Reason: "down", Since: at},
{Resource: liveness.Resource{Module: "docker", ID: "docker.engine", Kind: "service", Target: "docker.service",
Scope: declaration.ScopeSystem}, State: liveness.Healthy, Since: at},
}}
as := &accounts.Statement{At: at, Accounts: []accounts.Verdict{{Account: accounts.Account{Module: "openrazer",
ID: "openrazer.operator", Name: "operator", Groups: []string{"openrazer"}}, State: accounts.Unhealthy,
Reason: accounts.ReasonRelogin + ": operator is in the group openrazer", Since: at}}}
h := withAccounts(healthAsReported(st, nil), as)
got := map[string]string{}
for _, r := range h.Resources {
got[r.Resource] = r.Account
}
want := map[string]string{"openrazer.daemon": "operator", "docker.engine": "", "openrazer.operator": "operator"}
for id, account := range want {
if a, said := got[id]; !said || a != account {
t.Errorf("%s names the account %q (said %v); want %q", id, a, said, account)
}
}
}
// An account declared never to become root (novox/hq ADR 0266) is said with root never, under the contract
// that tells the controller this engine judges it; an account judged for its groups alone is not.
func TestTheStatementSaysAnAccountJudgedForRoot(t *testing.T) {
at := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
as := &accounts.Statement{At: at, Accounts: []accounts.Verdict{
{Account: accounts.Account{Module: "claude-code", ID: "claude-code.agent", Name: "agent", Root: true},
State: accounts.Unhealthy, Reason: accounts.ReasonRoot + ": in the group docker, which grants root", Since: at},
{Account: accounts.Account{Module: "openrazer", ID: "openrazer.account", Name: "operator",
Groups: []string{"openrazer"}}, State: accounts.Healthy, Since: at},
}}
h := withAccounts(healthAsReported(liveness.Statement{At: at}, nil), as)
// At RootContract or later: an engine that also reads an identity's runner says OwnGroupContract (novox/hq ADR
// 0334), one above ADR 0328's LibraryContract.
if h.Contract < link.RootContract || link.RootContract != 3 || h.Contract != link.OwnGroupContract ||
link.OwnGroupContract != link.LibraryContract+1 {
t.Fatalf("contract %d", h.Contract)
}
got := map[string]string{}
for _, r := range h.Resources {
got[r.Resource] = r.Root
}
if got["claude-code.agent"] != "never" || got["openrazer.account"] != "" {
t.Fatalf("root said: %v", got)
}
}
// A verdict waiting for the setuid search says how far it got, in the reason the root-free verb repeats and as
// fields (novox/hq issue 361); a mount point, never a path the walk visited or found.
func TestTheStatementSaysHowFarTheSetuidSearchGot(t *testing.T) {
at := time.Date(2026, 10, 10, 1, 40, 0, 0, time.UTC)
sp := &accounts.SearchProgress{Started: at, Filesystems: 3, Done: 1, Walking: "/home", Visited: 42, Found: 2,
Kept: at, Finished: []accounts.SearchedFilesystem{{Mount: "/", Took: 90 * time.Second}}}
as := &accounts.Statement{At: at, Accounts: []accounts.Verdict{{Account: accounts.Account{Module: "claude-code",
ID: "claude-code.agent", Name: "agent", Root: true}, State: accounts.Unknown,
Reason: accounts.ReasonPending + ": the search for setuid programs, started 2026-10-10 01:27, has not finished yet",
Since: at, Search: sp}}}
h := withAccounts(healthAsReported(liveness.Statement{At: at}, nil), as)
r := h.Resources[0]
if !strings.HasPrefix(r.Reason, accounts.ReasonPending) || !strings.Contains(r.Reason, "1 of 3 filesystems walked, walking /home, 42 entries visited") {
t.Fatalf("the reason: %s", r.Reason)
}
s := r.Search
if s == nil || s.Filesystems != 3 || s.Done != 1 || s.Walking != "/home" || s.Visited != 42 || s.Found != 2 ||
s.Kept == nil || len(s.Finished) != 1 || s.Finished[0].Mount != "/" || *s.Finished[0].TookSeconds != 90 {
t.Fatalf("the fields: %+v", s)
}
}
-64
View File
@@ -1,64 +0,0 @@
package main
import (
"os"
"path/filepath"
"reflect"
"testing"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// The last apply's report, kept beside the state until the mesh takes it (novox/hq issue 264).
// What a host that died between applying and reporting kept is read back by the next one exactly —
// the digest and the outcome, never a new account of the machine.
func TestTheKeptReportSurvivesTheHost(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
made := link.Report{Declared: "sha256:d1", Applied: []string{"a"}, Failed: map[string]string{"b": "no room"},
Host: "v2"}
if err := unsaidBeside(state).Keep(made); err != nil {
t.Fatal(err)
}
kept, ok, err := unsaidBeside(state).Pending()
if err != nil || !ok {
t.Fatalf("nothing kept: %v", err)
}
if !reflect.DeepEqual(kept, made) {
t.Fatalf("kept %+v, made %+v", kept, made)
}
// Taken about another declaration, it stays; taken about this one, it goes.
if err := unsaidBeside(state).Said("sha256:other"); err != nil {
t.Fatal(err)
}
if _, ok, _ := unsaidBeside(state).Pending(); !ok {
t.Fatal("a report about another declaration cleared this one")
}
if err := unsaidBeside(state).Said("sha256:d1"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(store.UnsaidPath(state)); !os.IsNotExist(err) {
t.Fatalf("the report was taken and is still kept: %v", err)
}
}
// A node that never applied anything has nothing to say again; and an apply refused before its
// declaration was read clears an older report, which would otherwise be re-said after it.
func TestNothingKeptWhenNothingWasApplied(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
if _, ok, err := unsaidBeside(state).Pending(); ok || err != nil {
t.Fatalf("a node that applied nothing has a report to say: %v %v", ok, err)
}
if err := unsaidBeside(state).Keep(link.Report{Declared: "sha256:d1"}); err != nil {
t.Fatal(err)
}
if err := unsaidBeside(state).Keep(link.Report{Refused: "forged"}); err != nil {
t.Fatal(err)
}
if _, ok, _ := unsaidBeside(state).Pending(); ok {
t.Fatal("an older report is kept after a later declaration was refused")
}
}
-166
View File
@@ -1,166 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/upgrade"
"github.com/novox/mesh-host/internal/witness"
)
// The node-engine's part in core upgrades that roll back (novox/hq to-be 45 §8, ADR 0227 rule 8):
// proving its own build to the launcher that witnesses it, witnessing the controller and the node
// tools it places, and saying every verdict on its reports.
// standingRollbacks is every verdict that still stands on this machine: the launcher's about this
// host, and the engine's about the processes it witnesses. Said on every report.
func standingRollbacks(statePath string) []link.Rollback {
var out []link.Rollback
verdicts, err := upgrade.ReadRolledBack(upgrade.RolledBackPath(statePath))
if err != nil {
fmt.Fprintf(os.Stderr, "mesh-host: %v\n", err)
}
for _, v := range verdicts {
out = append(out, link.Rollback{Component: link.ComponentEngine, From: v.From, To: v.To,
Outcome: v.Outcome, Why: v.Why, At: v.At})
}
return append(out, witness.Standing(apply.DaemonRoot())...)
}
// rolledBackHosts are the host versions the launcher will not start again, so this host never stands
// aside for one.
func rolledBackHosts(statePath string) []string {
verdicts, _ := upgrade.ReadRolledBack(upgrade.RolledBackPath(statePath))
return upgrade.RolledBackVersions(verdicts)
}
// proving is this host waiting for the mesh to take a report it made under its own build — the
// evidence its launcher's trial waits for — and acting on it once.
type proving struct {
statePath string
version string
say func(string)
once sync.Once
}
// unproved says whether this host's version is not yet known-good here: a host on trial.
func unproved(statePath, version string) bool {
known, err := upgrade.ReadKnownGood(upgrade.KnownGoodPath(statePath))
return err != nil || known != version
}
// heard is told every account of the machine the mesh has taken. The first one about a declaration,
// made by this build, proves it.
func (p *proving) heard(r link.Report) {
if r.Declared == "" || r.Refused != "" || r.Host != p.version {
return
}
p.once.Do(func() {
retired, err := upgrade.Proved(p.statePath, upgrade.VersionsDir(""), p.version)
if err != nil {
p.say(fmt.Sprintf("this host reported under its own build %s, and proving it was not complete: %v", p.version, err))
}
if len(retired) > 0 {
p.say(fmt.Sprintf("host %s is proved; retired the host version(s) %s", p.version, strings.Join(retired, ", ")))
}
renewFallback(p.version, p.say)
renewLauncher(p.say)
})
}
// renewFallback makes the engine placed by hand — the launcher's fallback, and the `nox-mesh-host` on this
// machine's PATH — a copy of this proved build when it is not one (novox/hq issue 356). On the same
// evidence known-good is written on, and said when it happens or cannot: a stale command that answers
// for a build that is not running would otherwise stay until somebody typed it.
func renewFallback(version string, say func(string)) {
self, err := os.Executable()
if err != nil {
say("the engine placed by hand could not be renewed: this build does not know its own path: " + err.Error())
return
}
fallback := upgrade.FallbackPath()
renewed, err := upgrade.RenewFallback(self, fallback)
switch {
case err != nil:
say(fmt.Sprintf("the engine placed by hand at %s is not this build, and could not be renewed: %v", fallback, err))
case renewed:
say(fmt.Sprintf("renewed the engine placed by hand at %s to this build, %s: the command on this machine is the "+
"engine that runs it", fallback, version))
}
}
// The launcher, as the service manager runs it.
const (
launcherPath = upgrade.DefaultLibexec + "/launch"
hostUnit = "nox-mesh-host.service"
)
// renewLauncher asks the service manager to restart this host's service when the launcher running
// it was replaced on disk since it started (novox/hq to-be 45 §8): a delivered launcher otherwise
// takes effect only at the next boot, and the witness it carries with it. A launcher from
// this one on runs its successor itself, at the host's next clean exit; this is for the launcher
// before it. Said, and only when it is certain: the parent is the launcher, and the file it reads
// is gone from under it.
func renewLauncher(say func(string)) {
if _, err := os.Stat("/run/systemd/system"); err != nil {
return
}
if !launcherReplaced(os.Getppid(), "/proc", launcherPath) {
return
}
say("the launcher running this host was replaced on disk; asking the service manager to run the new one")
if _, err := apply.ExecRunner(context.Background(), "systemctl", "restart", "--no-block", hostUnit); err != nil {
say("could not ask for the new launcher, so it runs from the next boot: " + err.Error())
}
}
// launcherReplaced is whether process pid is a shell reading the launcher at path, from a file that
// is no longer there — the one a delivery renamed a new launcher over.
func launcherReplaced(pid int, proc, path string) bool {
if pid <= 1 {
return false
}
base := filepath.Join(proc, strconv.Itoa(pid))
cmdline, err := os.ReadFile(filepath.Join(base, "cmdline"))
if err != nil || !strings.Contains(string(cmdline), path) {
return false
}
fds, err := os.ReadDir(filepath.Join(base, "fd"))
if err != nil {
return false
}
for _, fd := range fds {
target, err := os.Readlink(filepath.Join(base, "fd", fd.Name()))
if err == nil && target == path+" (deleted)" {
return true
}
}
return false
}
// watchWhatThisHostPlaced judges the core builds this host placed, until ctx ends (to-be 45 §8).
func watchWhatThisHostPlaced(ctx context.Context, node string, queue *link.Queue, say func(string)) {
host, _ := os.Hostname()
w := &witness.Watcher{
Root: apply.DaemonRoot(), Node: node, Host: host,
Asker: func() link.Asker { return queue.Asker() },
Run: witness.Runner(apply.ExecRunner),
Hold: func(f func()) {
applying.Lock()
defer applying.Unlock()
f()
},
// Said now, not at the next report: the verdict is on every report from here, and the mesh
// is asked for one at once.
Concluded: func(link.Rollback) { queue.ReportAsked() },
Say: say,
}
w.Watch(ctx)
}
-120
View File
@@ -1,120 +0,0 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/upgrade"
)
// The first account the mesh takes from this build, about a declaration, proves it to the launcher;
// nothing else does — a refusal, a report about no declaration, another build's report (to-be 45 §8).
func TestOnlyAnAccountUnderThisBuildProvesIt(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
t.Setenv("MESH_HOST_LIBEXEC", t.TempDir())
// Never the machine's own engine placed by hand: proving renews it (issue 356).
fallback := filepath.Join(t.TempDir(), "nox-mesh-host")
if err := os.WriteFile(fallback, []byte("a stale engine placed by hand"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv(upgrade.FallbackVar, fallback)
var said []string
p := &proving{statePath: state, version: "2.0", say: func(s string) { said = append(said, s) }}
for _, r := range []link.Report{
{Host: "2.0", Refused: "no"},
{Host: "2.0"},
{Host: "1.0", Declared: "abc"},
} {
p.heard(r)
if !unproved(state, "2.0") {
t.Fatalf("%+v proved this build", r)
}
if got, _ := os.ReadFile(fallback); string(got) != "a stale engine placed by hand" {
t.Fatalf("%+v renewed the engine placed by hand", r)
}
}
p.heard(link.Report{Host: "2.0", Declared: "abc"})
if unproved(state, "2.0") {
t.Fatalf("an account of a declaration under this build did not prove it (%v)", said)
}
if got, _ := upgrade.ReadKnownGood(upgrade.KnownGoodPath(state)); got != "2.0" {
t.Fatalf("known-good is %q", got)
}
// **Proved, the engine placed by hand becomes this build** (issue 356): the command on the machine's PATH is
// the engine that runs it, and that is said.
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
want, _ := os.ReadFile(self)
if got, _ := os.ReadFile(fallback); len(want) == 0 || string(got) != string(want) {
t.Fatalf("the engine placed by hand was not renewed to this build (%d bytes, want %d)", len(got), len(want))
}
renewed := false
for _, s := range said {
renewed = renewed || strings.Contains(s, "renewed the engine placed by hand at "+fallback)
}
if !renewed {
t.Fatalf("the renewal was not said: %q", said)
}
}
// Every verdict that stands is said, and a reconcile that has one says it unasked.
func TestAStandingRollbackIsSaidOnEveryReport(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
line := "2.0\t1.0\t1759744800\trolled-back\tit did not report within 600s of starting\n"
if err := os.WriteFile(upgrade.RolledBackPath(state), []byte(line), 0o644); err != nil {
t.Fatal(err)
}
got := standingRollbacks(state)
if len(got) == 0 || got[0].Component != link.ComponentEngine || got[0].From != "2.0" || got[0].To != "1.0" ||
got[0].Outcome != link.RolledBack {
t.Fatalf("what the report says is %+v", got)
}
if !worthSaying(link.Report{Rollbacks: got}) {
t.Fatal("a reconcile with a rollback standing does not say it")
}
if adoptionFingerprint(link.Report{Rollbacks: got}) == adoptionFingerprint(link.Report{}) {
t.Fatal("a rollback reached or ended is not news to the reconcile")
}
}
// The launcher running this host is known to have been replaced only when it certainly was: a shell
// reading the launcher, from a file renamed over. Asked of a real process, not a model of one.
func TestAReplacedLauncherIsSeen(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "launch")
if err := os.WriteFile(path, []byte("#!/bin/sh\nsleep 5\necho done\n"), 0o755); err != nil {
t.Fatal(err)
}
shell := exec.Command("sh", path)
if err := shell.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = shell.Process.Kill(); _ = shell.Wait() })
time.Sleep(200 * time.Millisecond)
if launcherReplaced(shell.Process.Pid, "/proc", path) {
t.Fatal("a launcher still on disk reads as replaced")
}
// Delivered as the mesh writes a file: a new one renamed over it.
if err := os.WriteFile(path+".new", []byte("#!/bin/sh\necho new\n"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Rename(path+".new", path); err != nil {
t.Fatal(err)
}
if !launcherReplaced(shell.Process.Pid, "/proc", path) {
fds, _ := os.ReadDir(filepath.Join("/proc", strconv.Itoa(shell.Process.Pid), "fd"))
t.Skipf("this shell does not keep its script open (%d fds), so a replaced launcher cannot be seen here", len(fds))
}
if launcherReplaced(shell.Process.Pid, "/proc", filepath.Join(dir, "other")) {
t.Fatal("a process reading another script reads as this launcher")
}
}
+33 -26
View File
@@ -2,44 +2,51 @@
## `foundation-first-node.lock`
The one genesis bundle (novox/hq ADR 0331): what a machine must be before a mesh exists — the
bootstrap in [novox/hq `07-the-foundation.md`](https://git.novox.be/novox/hq), whole, on the bus the
mesh runs on:
What a machine must be before a mesh exists — the bootstrap in
[novox/hq `07-the-foundation.md`](https://git.novox.be/novox/hq), whole:
```
0 a container runtime, and a filter before anything listens
0 a container runtime
1 the store runs
2 a database per context `inventory`, `identity` and `licences`
2 a database per context `inventory` and `identity`
3 those contexts' schemas mesh-controller migrate
4 the bus's certificate mesh-controller broker certificate
5 the bus runs NATS as mesh-broker-nats, where the nats module keeps it
(ADR 0333), with the controller's account as its first user
6 the control plane runs mesh-controller serve, given the bus (MESH_BUS_NATS)
4 the broker runs with a certificate it generated itself
5 the control plane runs mesh-controller serve
```
**A machine that applies this is a mesh** — one node, with nothing joined to it yet, which is
exactly what the first node is (novox/hq ADR 0004). From here it hands out tokens and everything
else joins the ordinary way.
It is a **template**: `mesh-bootstrap` reads it, makes the store's superuser and the controller's
account on the bus, and writes them where the template says `bootstrap` (internal/bootstrap,
rootsecrets.go). A template whose bus connection it does not recognise is refused at preflight,
naming the placeholder it looked for. The bundle that raised the previous broker was deleted with
ADR 0331; there is one bundle, and every lab bed names it.
This file said it stopped at step 4 for longer than that was true, which is its own small lesson:
a comment about what something does not do is a comment nobody updates.
**The bus is raised where the `nats` module keeps it** (novox/hq ADR 0333): the container
`mesh-broker-nats`, its configuration and user list in `/var/lib/nats-module/conf`, its streams in
`/var/lib/mesh-broker-nats`, its certificate in `/var/lib/mesh-broker-tls`, published on the node's bus
port. At the installer's broker step the module adopts it: its own image replaces the upstream one, once,
on the same streams, users and certificate. `internal/bootstrap` holds the bundle to the module's manifest.
Build an installer carrying a builder, and point it at this:
Build a host carrying it:
```
make bootstrap IMAGE=<builder image>
mesh-bootstrap --bundle examples/foundation-first-node.lock …
make host SYSTEM=arch BUNDLE=examples/foundation-first-node.lock
```
**The registry address is the lab's.** The images are written as `192.0.2.250:5000/…@sha256:…`; the
lab rewrites the third-party ones to upstream references by the same digest, and the installer
replaces the control plane's with the image it built.
**The registry address and digests have to be replaced before this is useful.** They are written
as `192.0.2.250:5000/…@sha256:…` because a digest belongs to whatever registry serves it — here,
one a lab scenario raises, which reports its digests when it comes up. That is not a placeholder
to be tidied away: a bundle is built *for a target*, and which registry that target pulls from is
part of the target.
### What was verified, and how
On a lab machine confirmed sealed — `curl https://example.com` times out, the lab registry answers
200 — the whole bundle applied from bare: eight resources, `inventory` created and `mesh` nowhere,
the `node` table present with its indexes, the migration recorded, and LavinMQ answering
`lavinmqctl status` with AMQP listening on 5672.
Three consecutive reconciles after that: **already matches — 8 resource(s) checked**, each time.
Then the machine was **rebooted**, and everything came back: docker from `boot: enabled`, both
containers because the host creates every container `--restart unless-stopped`
(`internal/apply/apply.go`), the schema intact in its named volume, and reconcile still finding
nothing to do.
The reboot is worth doing rather than assuming. Nothing in the declaration asks for a container to
return, so that it does is a property of the host, and the only way to know it holds is to take
the machine away and give it back.
+1 -31
View File
@@ -7,7 +7,6 @@ package examples
import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
@@ -49,7 +48,7 @@ func TestTheBundleCarriesTheFoundationAndTheControlPlaneAndNothingElse(t *testin
}
sort.Strings(images)
want := []string{"mesh-controller", "nats", "postgres"}
want := []string{"lavinmq", "mesh-controller", "postgres"}
if strings.Join(images, ",") != strings.Join(want, ",") {
t.Fatalf("the bundle carries %v; expected exactly %v.\n\n"+
"Adding one is a change to what every first node becomes, and to ADR 0006's "+
@@ -68,32 +67,3 @@ func TestTheControlPlaneIsCarriedToo(t *testing.T) {
t.Fatal("nothing in the bundle starts the control plane, so the machine would raise a " +
"foundation and stop")
}
// One genesis bundle, on the bus the mesh runs on (novox/hq ADR 0331 §2). Two bundles let every bed
// and README name the one that could not raise a mesh, for weeks (issue 146).
func TestThereIsOneGenesisBundle(t *testing.T) {
bundles, err := filepath.Glob("*.lock")
if err != nil {
t.Fatal(err)
}
if len(bundles) != 1 || bundles[0] != "foundation-first-node.lock" {
t.Fatalf("examples/ holds %v; it holds one genesis bundle, foundation-first-node.lock", bundles)
}
}
// The control plane the bundle raises is given the bus, with the account the installer fills and
// the certificate it pins (ADR 0331 §4); without it, it crash-loops for ever (issue 146).
func TestTheControlPlaneIsGivenTheBus(t *testing.T) {
for _, r := range bundle(t).Resources {
c, ok := r.(*declaration.Container)
if !ok || !strings.Contains(c.Image, "mesh-controller") {
continue
}
if !strings.HasPrefix(c.Env["MESH_BUS_NATS"], "nats://controller:bootstrap@") || c.Env["MESH_BROKER_CERTIFICATE"] == "" {
t.Fatalf("the control plane is given the bus %q and the certificate %q",
c.Env["MESH_BUS_NATS"], c.Env["MESH_BROKER_CERTIFICATE"])
}
return
}
t.Fatal("nothing in the bundle starts the control plane")
}
+200
View File
@@ -0,0 +1,200 @@
// foundation-first-node-nats.lock — what a machine must be before a mesh exists, on the bus being
// built (novox/hq ADR 0106, design 25).
//
// The same twelve steps as foundation-first-node.lock, with one difference that matters: **the mesh
// writes its own user list, and at genesis there is no mesh yet to write it.** So this carries the
// first one — the controller's own account, at a well-known bootstrap password, exactly as the store
// is reached at `postgres:bootstrap` and the old bus at `guest:guest`. It is rotated with those, and
// from the controller's first composition onward the file is the controller's to write.
//
// The accounts file is its own file beside the server's configuration, because the server's own
// settings belong to whoever raises it and the users belong to the mesh (design 25 §4). Both live in
// one directory, of necessity: an include path is resolved relative to the including file's own
// directory, so a server given an absolute one looks for it underneath that directory and refuses to
// start.
//
// No `verify` on the TLS block, deliberately — that setting makes the server demand a *client*
// certificate, and nothing in the mesh presents one: a host pins this server's exact certificate and
// authenticates with a password (ADR 0004, design 25 §4).
{
"declaration": 1,
"resources": [
{
"id": "container-runtime",
"type": "package",
"package": "docker"
},
{
"id": "container-runtime-running",
"type": "service",
"unit": "docker.service",
"state": "running",
"boot": "enabled"
},
// **A filter before anything listens** (novox/hq issue 054, ADR 0088). The store and the
// broker are adopted as modules later and so bind to every interface from the moment they
// start; the packet filter that governs who may reach them is a module too, installed a
// dozen steps later. Between the two, a control-node facing the network had its store and
// its bus open to anyone who could reach the machine. So the foundation carries a filter of
// its own — the same table the filter module will replace wholesale once it can derive one:
// drop by default, keep loopback, replies, ssh and the mesh's own ports (the bus a node
// enrols over, the registry a node pulls from), and let the container runtime's own
// networks through the forward chain so containers keep working. A published container port
// is forwarded, never input (issue 047), which is why the forward chain is where the store's
// and broker's ports are refused from outside — and a container on this machine dialling a
// port this machine publishes reaches it through the runtime's proxy, which IS input, which
// is why the bus and the registry are opened in both chains, exactly as the derived ruleset
// does.
{
"id": "base-filter-package",
"type": "package",
"package": "nftables"
},
{
"id": "base-filter",
"type": "file",
"path": "/etc/nftables.conf",
"mode": "0644",
"content": "#!/usr/sbin/nft -f\n# the foundation's own filter, until the mesh derives one (novox/hq issue 054)\ntable inet mesh {}\ndelete table inet mesh\n\ntable inet mesh {\n\tchain input {\n\t\ttype filter hook input priority filter; policy drop;\n\t\tct state established,related accept\n\t\tct state invalid drop\n\t\tiif lo accept\n\t\ticmp type echo-request accept\n\t\ticmpv6 type { echo-request, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept\n\t\t# ssh, from anywhere — never closed\n\t\ttcp dport 22 accept\n\t\t# the mesh's own, from anywhere: the bus a node enrols over and a container on this machine reaches through the proxy, the registry a node pulls from\n\t\ttcp dport 5671 accept\n\t\ttcp dport 5000 accept\n\t}\n\tchain output {\n\t\ttype filter hook output priority filter; policy accept;\n\t}\n\tchain forward {\n\t\ttype filter hook forward priority filter; policy drop;\n\t\tct state established,related accept\n\t\tct state invalid drop\n\t\t# the container runtime's bridge networks, and the networks its compose files are given\n\t\tip saddr 172.16.0.0/12 accept\n\t\tip saddr 192.168.128.0/17 accept\n\t\t# the mesh's own: the bus a node enrols over, the registry a node pulls from\n\t\tct original proto-dst 5671 accept\n\t\tct original proto-dst 5000 accept\n\t}\n}\n"
},
{
"id": "base-filter-loaded",
"type": "service",
"unit": "nftables.service",
"state": "running",
"boot": "enabled",
"restart-on": ["base-filter"]
},
{
"id": "store",
"type": "container",
"name": "mesh-store",
"image": "192.0.2.250:5000/postgres@sha256:7abf537131b66ed5af448d90653abf1679b0c7e9a1f07efdd4c3108a401b259a",
"env": {
"POSTGRES_PASSWORD": "bootstrap",
"PGDATA": "/var/lib/postgresql/data/pgdata"
},
"ports": ["5432:5432"],
"volumes": ["mesh-store-data:/var/lib/postgresql/data"]
},
// Over TCP, not the socket. While the store initialises it runs a temporary server on the
// socket ONLY, then stops it and starts the real one — so a socket check passes, the action
// exits happy, and the verify a moment later lands in the gap and fails. The action and its
// verify must ask the same question, or the action can succeed into a state verify rejects.
{
"id": "store-ready",
"type": "action",
"in": "mesh-store",
"command": ["sh", "-c", "for i in $(seq 1 180); do pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && exit 0; sleep 1; done; echo 'the store did not answer within 180s; its own last words follow'; pg_isready -h 127.0.0.1 -U postgres; tail -n 20 /var/lib/postgresql/data/log/*.log 2>/dev/null; exit 1"],
"verify": ["pg_isready", "-h", "127.0.0.1", "-U", "postgres"]
},
{
"id": "inventory-database",
"type": "action",
"in": "mesh-store",
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE inventory'"],
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw inventory"]
},
{
"id": "identity-database",
"type": "action",
"in": "mesh-store",
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE identity'"],
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw identity"]
},
// Each context owns its own database (novox/hq ADR 0008). A third one is a third database,
// created the same way and named the same way — which is the whole of adding a context to the
// bootstrap, and is why the count is not something the foundation has an opinion about.
{
"id": "licences-database",
"type": "action",
"in": "mesh-store",
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE licences'"],
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw licences"]
},
{
"id": "context-schemas",
"type": "action",
"command": ["docker", "run", "--rm", "--network", "container:mesh-store",
"-e", "MESH_STORE_INVENTORY=postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable",
"-e", "MESH_STORE_IDENTITY=postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable",
"-e", "MESH_STORE_LICENCES=postgres://postgres:bootstrap@127.0.0.1:5432/licences?sslmode=disable",
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"migrate"],
"verify": ["sh", "-c", "docker exec mesh-store psql -U postgres -d inventory -tAc \"select to_regclass('public.node')\" | grep -qx node && docker exec mesh-store psql -U postgres -d identity -tAc \"select to_regclass('public.signing_key')\" | grep -qx signing_key && docker exec mesh-store psql -U postgres -d licences -tAc \"select to_regclass('public.licence')\" | grep -qx licence"]
},
{
"id": "bus-certificate",
"type": "action",
// **The mesh makes its own** (novox/hq 04-ISSUES/146). This ran `openssl` inside the
// broker's image while the broker was one that carried it; the bus that replaced it has a
// shell and no openssl, and no other image the bundle names has one either. So the program
// that needs the certificate writes it — already on this machine, since the schema step ran
// it, and asking nothing of the image it writes into. Self-signed on purpose: a host pins
// this server's exact certificate (novox/hq ADR 0004), and at this moment there is no mesh
// to ask an authority of.
// `--user 0:0` because the volume is root's and this image runs as nobody, which is right
// for the long-running control plane and wrong for a one-shot writing into a fresh volume.
"command": ["docker", "run", "--rm", "--user", "0:0", "-v", "mesh-broker-tls:/tls",
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"broker", "certificate", "--into", "/tls"],
"verify": ["docker", "run", "--rm", "--user", "0:0", "-v", "mesh-broker-tls:/tls",
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"broker", "certificate", "--check", "--into", "/tls"]
},
{
"id": "bus-conf-dir",
"type": "directory",
"path": "/var/lib/mesh-bus-conf",
"mode": "0700"
},
{
"id": "bus-conf",
"type": "file",
"path": "/var/lib/mesh-bus-conf/nats.conf",
"mode": "0644",
"content": "port: 4222\nhttp: 127.0.0.1:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\njetstream {\n store_dir: \"/data\"\n}\n\ninclude accounts.conf\n"
},
{
"id": "bus-accounts",
"type": "file",
"path": "/var/lib/mesh-bus-conf/accounts.conf",
"mode": "0600",
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.API.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.control.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\"] }\n subscribe: { allow: [\"$JS.API.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.mesh-controller.tool.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n"
},
{
"id": "broker",
"type": "container",
"name": "mesh-broker",
"image": "192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927",
"ports": ["5671:4222", "127.0.0.1:8222:8222"],
"volumes": ["mesh-broker-data:/data", "mesh-broker-tls:/tls:ro", "/var/lib/mesh-bus-conf:/etc/nats:ro"],
"args": ["-c", "/etc/nats/nats.conf", "-js"]
},
{
"id": "broker-ready",
"type": "action",
"command": ["sh", "-c", "for i in $(seq 1 60); do docker run --rm --network host --entrypoint sh 192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 -c 'nc -z 127.0.0.1 5671' >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1"],
"verify": ["sh", "-c", "docker run --rm --network host --entrypoint sh 192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 -c 'nc -z 127.0.0.1 5671'"]
},
{
"id": "control-plane",
"type": "container",
"name": "mesh-controller",
"image": "192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"network": "host",
"args": ["serve"],
"volumes": ["mesh-broker-tls:/broker-tls:ro"],
"env": {
"MESH_STORE_INVENTORY": "postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable",
"MESH_STORE_IDENTITY": "postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable",
"MESH_STORE_LICENCES": "postgres://postgres:bootstrap@127.0.0.1:5432/licences?sslmode=disable",
"MESH_BUS_NATS": "nats://controller:bootstrap@127.0.0.1:5671",
"MESH_BROKER_ADDRESS": "192.0.2.10:5671",
"MESH_BROKER_CERTIFICATE": "/broker-tls/tls.crt"
}
}
]
}
+39 -104
View File
@@ -1,39 +1,36 @@
// foundation-first-node.lock — what a machine must be before a mesh exists: the one genesis bundle
// (novox/hq ADR 0331 §2), on the bus the mesh runs on (ADR 0106, ADR 0131, design 25).
// foundation-first-node.lock — what a machine must be before a mesh exists.
//
// The whole bootstrap (novox/hq 03-DESIGN/01-to-be/07-the-foundation.md): a container runtime, a
// filter, a store, a database per context, those contexts' schemas, the bus with its certificate
// and its first user list, and the control plane running on top of them.
// store, a database per context, those contexts' schemas, the broker, and the control plane
// running on top of them.
//
// **The mesh writes its own user list, and at genesis there is no mesh yet to write it.** So this
// carries the first one — the controller's own account — and the control plane's address names the
// same account. Both say `bootstrap`, and both are placeholders: the installer makes the account's
// password, puts it in the address and its hash in the list (mesh-host internal/bootstrap
// rootsecrets.go), and refuses a bundle whose bus connection it does not recognise. Until the first
// node has enrolled the installer places the list the controller composes; from the bus's adoption
// as the `nats` module on, the module receives it like any module (ADR 0331 §1, §5).
// It stopped before the control plane once, and this comment said so for longer than it was true.
//
// The accounts file is its own file beside the server's configuration, because the server's own
// settings belong to whoever raises it and the users belong to the mesh (design 25 §4). Both live in
// one directory, of necessity: an include path is resolved relative to the including file's own
// directory, so a server given an absolute one looks for it underneath that directory and refuses to
// start.
//
// No `verify` on the TLS block, deliberately — that setting makes the server demand a *client*
// certificate, and nothing in the mesh presents one: a host pins this server's exact certificate and
// authenticates with a password (ADR 0004, design 25 §4). The control plane pins it too, through
// MESH_BROKER_CERTIFICATE, and dials the bus on the machine's own loopback.
// The broker generates its OWN certificate, in its own image, into a volume it then mounts read
// only. Self-signed, because at this moment there is no mesh to issue one and no public name to
// obtain one for -- and it does not matter, because what a joining node checks is the fingerprint
// pinned in its token, not a chain or a name (novox/hq ADR 0004). The subject is decoration.
//
// PINNED BY DIGEST, and the digest is not decoration: a tag can be made to point at a different
// image, and this file is applied on a machine with no mesh to ask about anything (ADR 0006). The
// registry prefix is the lab's; the digests are upstream's.
// image, and this file is applied on a machine with no mesh to ask about anything. These digests
// belong to the registry the lab raises, which is what a real node pulls from anyway — what is
// required is a reference that is exact and cannot move (novox/hq ADR 0006).
//
// The store waits up to three minutes rather than one. A machine that has just pulled the
// image and is running initdb for the first time can take longer than sixty seconds, and it
// failed that way three times in the lab -- a flaky bootstrap that a second run always fixed,
// which is the worst kind because it teaches people to run things twice.
//
// It failed a fourth time on 2026-08-30, on a loaded machine, and the report was `docker exited
// 1:` with nothing after the colon. Raising the timeout again would treat the symptom; what makes
// a retry the only available response is a timeout that reports nothing. So the wait now says
// what it saw before giving up.
//
// The store's data is a NAMED VOLUME, not a directory on the machine. A directory the host
// creates is owned by root, and the database runs as somebody else inside the container — so it
// could not write, and the container crash-looped. A named volume lets the image set up its own
// ownership, and outlives the container, which is what you want for the thing holding the mesh's
// state.
{
"declaration": 1,
"resources": [
@@ -141,94 +138,31 @@
"migrate"],
"verify": ["sh", "-c", "docker exec mesh-store psql -U postgres -d inventory -tAc \"select to_regclass('public.node')\" | grep -qx node && docker exec mesh-store psql -U postgres -d identity -tAc \"select to_regclass('public.signing_key')\" | grep -qx signing_key && docker exec mesh-store psql -U postgres -d licences -tAc \"select to_regclass('public.licence')\" | grep -qx licence"]
},
// **The bus is raised where the `nats` module keeps it** (novox/hq ADR 0333 §1): the container
// `mesh-broker-nats`, its configuration and user list in /var/lib/nats-module/conf, its streams in
// /var/lib/mesh-broker-nats and its certificate in /var/lib/mesh-broker-tls — the names and paths the
// catalogue's module declares. So adopting it as that module at the broker step keeps it: the
// module's built image takes the place of the upstream one below, once, on the same streams, users
// and certificate, and nothing else moves. mesh-host's internal/bootstrap holds the two to each other
// against the module's own manifest (busLayout), at the broker step and in its tests.
//
// Directories on the machine, not volumes the runtime names: the module mounts these paths, and the
// control plane, once it runs as the mesh's process, reads the certificate at its path on the machine.
// The certificate's directory is readable by everyone because the certificate is public and the control
// plane runs as nobody; its key is written 0600 by the program that makes it.
{
"id": "bus-tls-dir",
"type": "directory",
"path": "/var/lib/mesh-broker-tls",
"mode": "0755"
},
{
"id": "bus-data-dir",
"type": "directory",
"path": "/var/lib/mesh-broker-nats",
"mode": "0700"
},
{
"id": "bus-certificate",
"id": "broker-certificate",
"type": "action",
// **The mesh makes its own** (novox/hq 04-ISSUES/146). This ran `openssl` inside the
// broker's image while the broker was one that carried it; the bus that replaced it has a
// shell and no openssl, and no other image the bundle names has one either. So the program
// that needs the certificate writes it — already on this machine, since the schema step ran
// it, and asking nothing of the image it writes into. Self-signed on purpose: a host pins
// this server's exact certificate (novox/hq ADR 0004), and at this moment there is no mesh
// to ask an authority of.
// `--user 0:0` because the directory is root's and this image runs as nobody, which is right
// for the long-running control plane and wrong for a one-shot writing into a fresh directory.
"command": ["docker", "run", "--rm", "--user", "0:0", "-v", "/var/lib/mesh-broker-tls:/tls",
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"broker", "certificate", "--into", "/tls"],
"verify": ["docker", "run", "--rm", "--user", "0:0", "-v", "/var/lib/mesh-broker-tls:/tls",
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"broker", "certificate", "--check", "--into", "/tls"]
"command": ["docker", "run", "--rm", "--entrypoint", "sh", "-v", "mesh-broker-tls:/tls",
"192.0.2.250:5000/cloudamqp/lavinmq@sha256:b117c254e6e269a29db479e6b410ca4e46e035b4981e49d24b159673ef09d336",
"-c", "test -f /tls/tls.crt || (openssl req -x509 -newkey rsa:2048 -nodes -keyout /tls/tls.key -out /tls/tls.crt -days 3650 -subj '/CN=mesh-broker' >/dev/null 2>&1 && chmod 644 /tls/tls.crt && chmod 600 /tls/tls.key)"],
"verify": ["docker", "run", "--rm", "--entrypoint", "sh", "-v", "mesh-broker-tls:/tls",
"192.0.2.250:5000/cloudamqp/lavinmq@sha256:b117c254e6e269a29db479e6b410ca4e46e035b4981e49d24b159673ef09d336",
"-c", "test -s /tls/tls.crt && openssl x509 -in /tls/tls.crt -noout"]
},
// **Seeds, written only where nothing is** (create-once). The bus's configuration and user list are
// the bundle's until the bus is adopted, and the module's from then on (ADR 0333 §4): an installer
// run again after the adoption applies this bundle again, and an ordinary file would put the
// bundle's one user back over every user the mesh composed, and leave the node unable to reach its
// bus. Until the adoption the installer places the composed list over the seed (enrol.go).
{
"id": "bus-conf-dir",
"type": "directory",
"path": "/var/lib/nats-module/conf",
"mode": "0700"
},
{
"id": "bus-conf",
"type": "file",
"path": "/var/lib/nats-module/conf/nats.conf",
"mode": "0644",
"create-once": true,
"content": "port: 4222\nhttp: 0.0.0.0:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\njetstream {\n store_dir: \"/data\"\n}\n\ninclude accounts.conf\n"
},
{
"id": "bus-accounts",
"type": "file",
"path": "/var/lib/nats-module/conf/accounts.conf",
"mode": "0600",
"create-once": true,
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.ACK.DEAD_LETTER_NOTICES.controller.>\", \"$JS.API.>\", \"$KV.mesh-controller_asked.>\", \"$KV.mesh-controller_calls.>\", \"$KV.mesh-controller_hand-acts.>\", \"$KV.mesh-controller_conditions.>\", \"$KV.mesh-controller_condition-history.>\", \"$KV.mesh-controller_lease.>\", \"$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>\", \"$KV.SEAT_NODE_BUILD_AGENT_cancelled.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.node-build-agent.accept.>\", \"mesh.seat.mesh-build-machine.tool.>\", \"mesh.seat.node-build-agent.tool.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\", \"mesh.seat.mesh-controller.event.rolled-back\", \"mesh.seat.mesh-controller.event.checked\", \"mesh.seat.mesh-controller.event.plan-moved\", \"mesh.seat.mesh-controller.event.condition-raised\", \"mesh.seat.mesh-controller.event.condition-changed\", \"mesh.seat.mesh-controller.event.condition-cleared\", \"mesh.seat.mesh-controller.event.doctor-heartbeat\", \"mesh.seat.mesh-controller.event.secret-replaced\", \"mesh.seat.mesh-controller.event.healer-acted\", \"$SRV.INFO\", \"mesh.seat.node-intrusion-prevention.tool.banned.*\", \"mesh.seat.node-backup.tool.backed-up.*\", \"mesh.seat.node-backup.tool.now.*\", \"mesh.seat.mesh-delivery.tool.stalled\", \"mesh.seat.mesh-delivery.tool.close\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.seat.mesh-delivery.tool.release\", \"mesh.seat.mesh-delivery.tool.stop\", \"mesh.seat.mesh-delivery.tool.times\", \"mesh.seat.mesh-delivery.tool.groups\", \"mesh.seat.node-service-manager.tool.restart.*\", \"mesh.seat.mesh-controller.tool.plans\", \"mesh.events.dead.>\", \"mesh.again.>\"] }\n subscribe: { allow: [\"$JS.API.>\", \"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>\", \"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.*.event.provisioner.failing\", \"mesh.mod.*.event.provisioner.recovered\", \"mesh.mod.*.event.provisioner.retirement\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.gitea.event.pull.updated\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.operator-channel.event.decided.mesh-controller\", \"mesh.seat.mesh-controller.tool.>\", \"$SRV.PING\", \"$SRV.INFO\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.>\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.>\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n"
},
// The upstream server, until the broker step adopts it: the `nats` module's own image, built by the
// mesh, takes its place there once, on these same directories (ADR 0333 §3). The published port is the
// node's bus port, 5671 unless given, which every enrolment token names; the installer gives the
// module the setting that maps its declared 4222 to it (ADR 0333 §2).
{
"id": "broker",
"type": "container",
"name": "mesh-broker-nats",
"image": "192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927",
"ports": ["5671:4222", "127.0.0.1:8222:8222"],
"volumes": ["/var/lib/mesh-broker-nats:/data", "/var/lib/nats-module/conf:/etc/nats:ro", "/var/lib/mesh-broker-tls:/tls:ro"],
"args": ["-c", "/etc/nats/nats.conf", "-js"]
"name": "mesh-broker",
"image": "192.0.2.250:5000/cloudamqp/lavinmq@sha256:b117c254e6e269a29db479e6b410ca4e46e035b4981e49d24b159673ef09d336",
"ports": ["5671:5671", "5672:5672", "127.0.0.1:15672:15672"],
"volumes": ["mesh-broker-data:/var/lib/lavinmq", "mesh-broker-tls:/tls:ro"],
"args": ["--amqps-port=5671", "--cert=/tls/tls.crt", "--key=/tls/tls.key"]
},
{
"id": "broker-ready",
"type": "action",
"command": ["sh", "-c", "for i in $(seq 1 60); do docker run --rm --network host --entrypoint sh 192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 -c 'nc -z 127.0.0.1 5671' >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1"],
"verify": ["sh", "-c", "docker run --rm --network host --entrypoint sh 192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 -c 'nc -z 127.0.0.1 5671'"]
"in": "mesh-broker",
"command": ["sh", "-c", "for i in $(seq 1 60); do lavinmqctl status >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1"],
"verify": ["lavinmqctl", "status"]
},
{
"id": "control-plane",
@@ -237,12 +171,13 @@
"image": "192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"network": "host",
"args": ["serve"],
"volumes": ["/var/lib/mesh-broker-tls:/broker-tls:ro"],
"volumes": ["mesh-broker-tls:/broker-tls:ro"],
"env": {
"MESH_STORE_INVENTORY": "postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable",
"MESH_STORE_IDENTITY": "postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable",
"MESH_STORE_LICENCES": "postgres://postgres:bootstrap@127.0.0.1:5432/licences?sslmode=disable",
"MESH_BUS_NATS": "nats://controller:bootstrap@127.0.0.1:5671",
"MESH_BROKER_AMQP": "amqp://guest:guest@127.0.0.1:5672/",
"MESH_BROKER_MANAGEMENT": "http://guest:guest@127.0.0.1:15672",
"MESH_BROKER_ADDRESS": "192.0.2.10:5671",
"MESH_BROKER_CERTIFICATE": "/broker-tls/tls.crt"
}
+1 -2
View File
@@ -3,14 +3,13 @@ module github.com/novox/mesh-host
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c
github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0
golang.org/x/sys v0.48.0
)
require (
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/sys v0.48.0 // indirect
)
-6
View File
@@ -1,9 +1,3 @@
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI=
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010190644-622b6b4f556d h1:BR8uwv4BqyLaBwITuLylrd9DRUnu0D5DjJw0RuQSXbk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010190644-622b6b4f556d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
-320
View File
@@ -1,320 +0,0 @@
// Package accounts is the node-engine reading whether an account has, where it runs, the groups a module
// put it in (novox/hq ADR 0252, issue 247).
//
// **A group takes effect at the next login.** The apply puts the account in a group in the machine's
// database at once. Every process already running keeps the groups it started with: the account's own
// service manager, and every unit it starts. The lighting daemon's module puts the operator's account in
// `openrazer`, and the daemon, started by that manager, still refuses to start, for as long as the
// session that began before lasts. Nothing said why.
//
// On every look the engine reads, for each account a module declares groups for:
//
// 1. whether the user database lists the account in each group. One it does not is the apply's to put
// right, and is said as `not in the group`;
// 2. whether the account's own service manager runs, and if it does, the groups that process holds,
// read from the process itself. A group the database lists and the running manager lacks is said as
// **relogin needed**, with what to do.
//
// No running manager is healthy: nobody is logged in, and the next login takes the groups.
//
// Each verdict is the declaring module's, as a resource of kind `account`, beside what liveness says: the
// controller raises it as the module's condition on two statements in a row, and clears it on the first
// healthy one.
//
// **And an account declared never to become root without a person** (`root: never`, novox/hq ADR 0266) —
// the account agent sessions run as on a machine where they must not reach root by themselves — is judged
// on every look for whether it can anyway: by its uid, by a group that grants root to its members, by any
// sudo rule at all, or by reading a secret the mesh placed for another account (the tool runner's bus
// credential among them, which carries every co-hosted module's grants). Judged first, whether or not
// anybody is logged in: a way to root does not wait for a session. Any way found is unhealthy, its reason
// starting ReasonRoot and naming every way; a question the machine did not answer is unknown, never
// healthy. This is the fact the controller reads to tell a machine where no agent can become root from one
// where an agent can.
//
// **It reads; it never acts** (ADR 0240 rule 6): the user and group databases, the machine's own service
// manager's `show` of the account's manager unit, and that process's status file. It never starts the
// account's manager, which asking that manager itself would.
package accounts
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/novox/mesh-host/internal/declaration"
)
// The states, in the words liveness says them (the controller reads them alike).
const (
Healthy = "healthy"
Unhealthy = "unhealthy"
Unknown = "unknown"
)
// ReasonRelogin starts the reason of an account whose running session lacks a group it is in.
const ReasonRelogin = "relogin needed"
// ReasonRoot starts the reason of an account declared never to become root without a person that can
// (novox/hq ADR 0266); every way found follows it.
const ReasonRoot = "can become root without a person"
// RootGroups are the groups whose members become root by membership alone, without a sudo rule naming
// them: the administrators' groups a distribution's own rules or polkit treat as root, the container
// runtime's socket (a container with the host's root mounted), the raw disks, and the virtualisation
// daemons that start a guest with the host's devices — and the kernel's memory (kmem) and the password hashes
// (shadow), either of which is root a step away (novox/hq ADR 0312 §4 review). A closed list, so what the judge
// calls "grants root" is written down and reviewable rather than guessed per machine.
var RootGroups = []string{"root", "wheel", "sudo", "admin", "docker", "disk", "lxd", "incus-admin", "libvirt",
"kmem", "shadow"}
// Account is one user resource of a module that declares groups.
type Account struct {
Module string
// ID is the user resource's id; Name the account.
ID string
Name string
Groups []string
// Root is true for an account declared never to become root without a person (novox/hq ADR 0266).
Root bool
// Secrets are the paths of every secret the declaration places for another account, judged for
// whether this one can read them; only for a Root account.
Secrets []string
}
// Of is every account a declaration has a module put in a group. held is every resource an adopted
// machine holds as found, by id: its groups are not the mesh's yet. A resource the mesh declares in its
// own right (no module) is not judged here.
func Of(d *declaration.Declaration, held map[string]bool) []Account {
if d == nil {
return nil
}
var out []Account
for _, r := range d.Resources {
u, ok := r.(*declaration.User)
root := ok && u.Root == declaration.RootNever
if !ok || (len(u.Groups) == 0 && !root) || held[u.ID] || u.Name == "" {
continue
}
at := strings.LastIndex(u.ID, ".")
if at <= 0 || strings.HasPrefix(u.ID, declaration.AdoptionPrefix) {
continue
}
a := Account{Module: u.ID[:at], ID: u.ID, Name: u.Name, Groups: append([]string(nil), u.Groups...), Root: root}
if root {
a.Secrets = secretsOf(d, u.Name)
}
out = append(out, a)
}
sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID })
return out
}
// secretsOf is the path of every secret file the declaration places for anybody but account: sealed whole,
// or with sealed values in its content. One the account owns is its own to read.
func secretsOf(d *declaration.Declaration, account string) []string {
var out []string
for _, r := range d.Resources {
f, ok := r.(*declaration.File)
if !ok || f.Path == "" || (f.Sealed == "" && len(f.Secrets) == 0) || f.Owner == account {
continue
}
out = append(out, f.Path)
}
sort.Strings(out)
return out
}
// Session is what an account's own service manager holds.
type Session struct {
// Running is whether the manager runs.
Running bool
// Has is, of the groups asked about, those the running manager holds.
Has map[string]bool
}
// Reader is what a look asks the machine. An error is "could not be read": said unknown, never healthy.
type Reader interface {
// InDatabase is every group the user database lists an account in.
InDatabase(ctx context.Context, account string) ([]string, error)
// Session is the account's own service manager: whether it runs, and which of groups it holds.
Session(ctx context.Context, account string, groups []string) (Session, error)
// Escalation is every way the account can become root without a person, in words — its uid, a group
// of RootGroups, a sudo rule, a secret of secrets it can read — and none when there is none. Reads
// only (novox/hq ADR 0266).
Escalation(ctx context.Context, account string, secrets []string) ([]string, error)
}
// Verdict is one account's state as a statement says it.
type Verdict struct {
Account
State string
Reason string
Since time.Time
Streak int
// Search is how far the setuid search got, while the verdict waits for it (novox/hq issue 361); nil
// otherwise. Never part of Reason: it moves every minute, and a verdict that changed every minute would
// be said in the journal every minute.
Search *SearchProgress
}
// Statement is one look at every account.
type Statement struct {
At time.Time
Accounts []Verdict
}
// Healthy says no account in it is anything but healthy.
func (s Statement) Healthy() bool {
for _, v := range s.Accounts {
if v.State != Healthy {
return false
}
}
return true
}
// Judge reads every account's groups on every look. Safe for the apply and the looking loop at once.
type Judge struct {
reader Reader
Now func() time.Time
mu sync.Mutex
accounts []Account
kept map[string]*Verdict
last Statement
}
// New is a judge reading through r.
func New(r Reader) *Judge {
return &Judge{reader: r, Now: time.Now, kept: map[string]*Verdict{}}
}
// Set is what the declaration just applied asks. An account no longer asked for is forgotten.
func (j *Judge) Set(as []Account) {
j.mu.Lock()
defer j.mu.Unlock()
j.accounts = append([]Account(nil), as...)
declared := map[string]bool{}
for _, a := range as {
declared[a.ID] = true
}
for id := range j.kept {
if !declared[id] {
delete(j.kept, id)
}
}
}
// Last is the statement of the last look.
func (j *Judge) Last() Statement {
j.mu.Lock()
defer j.mu.Unlock()
return j.last
}
// Look reads every account once, and answers the statement and whether any verdict changed since the
// last look.
func (j *Judge) Look(ctx context.Context) (Statement, bool) {
j.mu.Lock()
defer j.mu.Unlock()
now := j.Now()
st := Statement{At: now}
changed := len(j.last.Accounts) != len(j.accounts)
for _, a := range j.accounts {
state, reason, search := j.judge(ctx, a)
k := j.kept[a.ID]
if k == nil || k.State != state || k.Reason != reason {
changed = true
k = &Verdict{Account: a, State: state, Reason: reason, Since: now}
j.kept[a.ID] = k
}
k.Account, k.Search = a, search
if state == Unhealthy {
k.Streak++
} else {
k.Streak = 0
}
st.Accounts = append(st.Accounts, *k)
}
j.last = st
return st, changed
}
// judge is one account's verdict on one look, and how far the setuid search got while it waits for one.
func (j *Judge) judge(ctx context.Context, a Account) (string, string, *SearchProgress) {
if a.Root {
ways, err := j.reader.Escalation(ctx, a.Name, a.Secrets)
var notJudged *SetuidNotJudged
switch {
case len(ways) > 0:
// A way found is a way found, whatever else could not be judged; said beside it.
reason := ReasonRoot + ": " + strings.Join(ways, "; ")
if errors.As(err, &notJudged) {
reason += "; " + firstLine(err.Error())
}
var progress *SearchProgress
if notJudged != nil {
progress = notJudged.Progress
}
return Unhealthy, reason, progress
case errors.As(err, &notJudged):
// The setuid search has no result yet: not judged, said as such — the same words on every look
// until it has one, never a search that stalls the look (novox/hq ADR 0266).
return Unknown, firstLine(err.Error()), notJudged.Progress
case err != nil:
return Unknown, "whether the account can become root could not be read: " + firstLine(err.Error()), nil
}
if len(a.Groups) == 0 {
// Declared for root alone: nothing of a session to read.
return Healthy, "", nil
}
}
in, err := j.reader.InDatabase(ctx, a.Name)
if err != nil {
return Unknown, "the user database could not be read: " + firstLine(err.Error()), nil
}
listed := map[string]bool{}
for _, g := range in {
listed[g] = true
}
var missing, inDB []string
for _, g := range a.Groups {
if listed[g] {
inDB = append(inDB, g)
} else {
missing = append(missing, g)
}
}
if len(missing) > 0 {
return Unhealthy, fmt.Sprintf("not in the group %s: the apply has not put %s there; its outcome says why",
strings.Join(missing, ", "), a.Name), nil
}
s, err := j.reader.Session(ctx, a.Name, inDB)
if err != nil {
return Unknown, "the account's own service manager could not be read: " + firstLine(err.Error()), nil
}
if !s.Running {
// Nobody is logged in, and the account does not linger: the next login takes every group.
return Healthy, "", nil
}
var lacking []string
for _, g := range inDB {
if !s.Has[g] {
lacking = append(lacking, g)
}
}
if len(lacking) > 0 {
return Unhealthy, fmt.Sprintf("%s: %s is in the group %s, and its running session began before it was; "+
"log out of every session and in again, or reboot", ReasonRelogin, a.Name, strings.Join(lacking, ", ")), nil
}
return Healthy, "", nil
}
func firstLine(s string) string {
line, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
return line
}
-165
View File
@@ -1,165 +0,0 @@
package accounts
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/declaration"
)
// The account judge (novox/hq ADR 0252, "how it is checked"): an account in a group its running session
// lacks is unhealthy with "relogin needed"; the same account with no session, or a session that has the
// group, is healthy; one the database does not list in the group is unhealthy saying so; and everything
// asked of the machine is a read.
// machine is a fake: the database's groups, the manager's process and its status file.
type machine struct {
t *testing.T
proc string
inDB string
pid string
held string // the Groups line of the manager's status
asked []string
failsID bool
}
func (m *machine) run(_ context.Context, name string, args ...string) (string, error) {
m.asked = append(m.asked, name+" "+strings.Join(args, " "))
switch {
case name == "id":
if m.failsID {
return "", errors.New("id exited 1")
}
return m.inDB + "\n", nil
case name == "getent" && args[0] == "passwd":
return "operator:x:1500:1500::/home/operator:/bin/zsh\n", nil
case name == "getent" && args[0] == "group":
switch args[1] {
case "openrazer":
return "openrazer:x:964:operator\n", nil
case "wheel":
return "wheel:x:998:operator\n", nil
}
return "", errors.New("getent exited 2: ")
case name == "systemctl":
return m.pid + "\n", nil
}
return "", errors.New("not a command the judge may run")
}
func (m *machine) status() {
m.t.Helper()
if m.pid == "" || m.pid == "0" {
return
}
dir := filepath.Join(m.proc, m.pid)
if err := os.MkdirAll(dir, 0o755); err != nil {
m.t.Fatal(err)
}
body := "Name:\tsystemd\nUid:\t1500\t1500\t1500\t1500\nGid:\t1500\t1500\t1500\t1500\nGroups:\t" + m.held + "\n"
if err := os.WriteFile(filepath.Join(dir, "status"), []byte(body), 0o644); err != nil {
m.t.Fatal(err)
}
}
var razer = Account{Module: "openrazer", ID: "openrazer.account", Name: "operator", Groups: []string{"openrazer"}}
func look(t *testing.T, m *machine) Verdict {
t.Helper()
m.status()
j := New(Exec{Run: m.run, Proc: m.proc})
j.Now = func() time.Time { return time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC) }
j.Set([]Account{razer})
st, _ := j.Look(t.Context())
if len(st.Accounts) != 1 {
t.Fatalf("the statement: %+v", st)
}
return st.Accounts[0]
}
func TestAGroupTheRunningSessionLacksSaysReloginNeeded(t *testing.T) {
m := &machine{t: t, proc: t.TempDir(), inDB: "operator wheel openrazer", pid: "4242", held: "998"}
v := look(t, m)
if v.State != Unhealthy || !strings.HasPrefix(v.Reason, ReasonRelogin) || !strings.Contains(v.Reason, "openrazer") ||
!strings.Contains(v.Reason, "log out") {
t.Fatalf("a session without the group: %+v", v)
}
if v.Module != "openrazer" || v.ID != "openrazer.account" {
t.Errorf("the verdict is not the module's: %+v", v)
}
for _, a := range m.asked {
read := strings.HasPrefix(a, "id -nG ") || strings.HasPrefix(a, "getent ") ||
strings.HasPrefix(a, "systemctl show --property=MainPID --value user@")
if !read {
t.Errorf("the judge asked something that is not a read: %q", a)
}
}
}
func TestASessionThatHasTheGroupIsHealthy(t *testing.T) {
m := &machine{t: t, proc: t.TempDir(), inDB: "operator wheel openrazer", pid: "4242", held: "998 964"}
if v := look(t, m); v.State != Healthy || v.Reason != "" {
t.Fatalf("a session with the group: %+v", v)
}
}
func TestNoSessionIsHealthyTheNextLoginTakesTheGroup(t *testing.T) {
m := &machine{t: t, proc: t.TempDir(), inDB: "operator openrazer", pid: "0"}
if v := look(t, m); v.State != Healthy {
t.Fatalf("no session: %+v", v)
}
}
func TestAnAccountTheDatabaseDoesNotListIsUnhealthySayingSo(t *testing.T) {
m := &machine{t: t, proc: t.TempDir(), inDB: "operator wheel", pid: "4242", held: "998"}
v := look(t, m)
if v.State != Unhealthy || !strings.HasPrefix(v.Reason, "not in the group openrazer") {
t.Fatalf("not in the group: %+v", v)
}
}
func TestADatabaseThatDoesNotAnswerIsUnknownNeverHealthy(t *testing.T) {
m := &machine{t: t, proc: t.TempDir(), failsID: true}
if v := look(t, m); v.State != Unknown {
t.Fatalf("an unread database: %+v", v)
}
}
func TestOnlyAModulesAccountWithGroupsIsJudged(t *testing.T) {
d, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[
{"id":"zsh.login","type":"user","name":"operator","shell":"/bin/zsh"},
{"id":"openrazer.account","type":"user","name":"operator","groups":["openrazer"]},
{"id":"docker.account","type":"user","name":"operator","groups":["docker"]},
{"id":"meshown","type":"user","name":"mesh","groups":["x"]}
]}`))
if err != nil {
t.Fatal(err)
}
got := Of(d, map[string]bool{"docker.account": true})
if len(got) != 1 || got[0].ID != "openrazer.account" || got[0].Module != "openrazer" {
t.Fatalf("judged: %+v", got)
}
}
func TestAVerdictThatHoldsCountsItsStreakAndAChangeIsSaid(t *testing.T) {
m := &machine{t: t, proc: t.TempDir(), inDB: "operator openrazer", pid: "4242", held: "998"}
m.status()
j := New(Exec{Run: m.run, Proc: m.proc})
j.Set([]Account{razer})
_, changed := j.Look(t.Context())
st, again := j.Look(t.Context())
if !changed || again || st.Accounts[0].Streak != 2 {
t.Fatalf("first look changed %v, second %v, streak %d", changed, again, st.Accounts[0].Streak)
}
m.held = "998 964"
m.status()
st, changed = j.Look(t.Context())
if !changed || st.Accounts[0].State != Healthy || st.Accounts[0].Streak != 0 {
t.Fatalf("after the new login: changed %v, %+v", changed, st.Accounts[0])
}
}
-316
View File
@@ -1,316 +0,0 @@
package accounts
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
"time"
)
// Runner runs a command and answers what it printed — the apply's own (apply.ExecRunner).
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// Exec reads the machine through its command lines and the process table. **Reads only**: `id`, `getent`,
// the machine's own manager's `systemctl show`, `sudo -l -U` (which lists, as root, what sudo would let an
// account run, and runs nothing), a status file under /proc and a secret's owner and mode (a test holds
// both).
type Exec struct {
Run Runner
// Proc is where the process table is; empty is /proc. A test points it at a directory of its own.
Proc string
// Stat is a file's owner and mode; nil is the machine's own (os.Stat). A test gives files of its own.
Stat func(path string) (FileMode, error)
// Lstat is a path's own owner and mode, never through a link, with its type bits in Perm (fs.ModeSymlink,
// fs.ModeDir); nil is the machine's own (os.Lstat). Whether an account can reach a setuid program is read
// with it at every judgement (novox/hq issue 361).
Lstat func(path string) (FileMode, error)
// ReadFile, ReadDir and ACL read doas's and polkit's rules and a file's POSIX ACL; nil is the machine's.
ReadFile func(path string) ([]byte, error)
ReadDir func(path string) ([]fs.DirEntry, error)
ACL func(path string) ([]ACLEntry, error)
// Cache keeps the search for setuid programs between looks; nil searches on every look.
Cache *SetuidCache
// SearchRun runs the setuid search's commands (the package manager it asks); nil is Run. A
// serving engine gives Idle, so they yield to everything else on the machine, as the walk does.
SearchRun Runner
// Walk carries a walk for setuid programs on; nil is the engine's own (walker). A test gives its own.
Walk func(ctx context.Context, p *SetuidProgress, checkpoint func()) error
// Now is the clock the cache is kept by; nil is the machine's.
Now func() time.Time
}
// FileMode is what decides whether an account reads a file: its owner, its group and its permission bits.
type FileMode struct {
UID, GID int
Perm fs.FileMode
}
// Escalation is every way account can become root without a person (novox/hq ADR 0266) that the judge
// looks for — Judged lists them, NotJudged what it does not: its uid, a group of RootGroups the user database
// lists it in, any sudo rule naming it or a group of it, any of secrets it can read by owner, group or other
// bits, and the ways of ways.go: doas, polkit, a runtime's socket, an ACL, a setuid program no package owns.
// sudo absent is no sudo rule; doas and polkit (pkexec's grants) are judged from their own rules. A secret not there yet is skipped: there is nothing to read.
// The parent directories are not walked, so a file the bits allow and a directory hides is still said:
// the judge errs toward saying a way that is not, never toward missing one that is.
func (e Exec) Escalation(ctx context.Context, account string, secrets []string) ([]string, error) {
var ways []string
uidOut, err := e.Run(ctx, "id", "-u", account)
if err != nil {
return nil, fmt.Errorf("the user database did not answer about %q: %w", account, err)
}
uid, err := strconv.Atoi(strings.TrimSpace(uidOut))
if err != nil {
return nil, fmt.Errorf("the user database gave %q as %q's number", strings.TrimSpace(uidOut), account)
}
if uid == 0 {
ways = append(ways, "its uid is 0")
}
names, err := e.InDatabase(ctx, account)
if err != nil {
return nil, err
}
for _, g := range names {
if slices.Contains(RootGroups, g) {
ways = append(ways, "in the group "+g+", which grants root")
}
}
listed, err := e.Run(ctx, "sudo", "-l", "-U", account)
rules, err := SudoRules(listed, err)
if err != nil {
return nil, err
}
if len(rules) > 0 {
ways = append(ways, "sudo grants it: "+strings.Join(rules, ", "))
}
gidsOut, err := e.Run(ctx, "id", "-G", account)
if err != nil {
return nil, fmt.Errorf("the user database did not answer about %q's groups: %w", account, err)
}
gids := map[int]bool{}
for _, f := range strings.Fields(gidsOut) {
if n, err := strconv.Atoi(f); err == nil {
gids[n] = true
}
}
if len(secrets) > 0 {
stat := e.Stat
if stat == nil {
stat = statOf
}
for _, path := range secrets {
m, err := stat(path)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("the secret %s could not be read for its owner and mode: %w", path, err)
}
if Readable(m, uid, gids) {
ways = append(ways, "it can read the secret "+path)
}
}
}
more, err := e.moreWays(ctx, account, uid, names, gids, secrets)
var notJudged *SetuidNotJudged
if err != nil && !errors.As(err, &notJudged) {
return nil, err
}
// Every way found, and — when the setuid search has no result yet — that it is not judged.
return append(ways, more...), err
}
// Readable is whether an account of uid, in the groups gids, reads a file of m by its permission bits, as
// the kernel decides it: the owner's bits for the owner (root reads everything), the group's for a member,
// the others' for anybody else.
func Readable(m FileMode, uid int, gids map[int]bool) bool {
switch {
case uid == 0:
return true
case m.UID == uid:
return m.Perm&0o400 != 0
case gids[m.GID]:
return m.Perm&0o040 != 0
default:
return m.Perm&0o004 != 0
}
}
// SudoRules is the rules `sudo -l -U <account>` lists, from what it printed and how it ended: none when
// the account "is not allowed to run sudo" or sudo is not on the machine; every indented line after "may
// run the following commands" otherwise. Any rule counts — the decision is no sudo for the account at
// all, so a rule that asks for a password the account was never given is still a rule somebody can give
// it one for. Output that says neither is an error: unread is never none.
func SudoRules(out string, err error) ([]string, error) {
if err != nil && (errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist)) {
return nil, nil
}
text := out
if err != nil {
text += "\n" + err.Error()
}
if strings.Contains(text, "is not allowed to run sudo") {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("sudo did not list the account's rules: %w", err)
}
var rules []string
listing := false
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "may run the following commands") {
listing = true
continue
}
if listing && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && strings.TrimSpace(line) != "" {
rules = append(rules, strings.TrimSpace(line))
}
}
if !listing {
return nil, fmt.Errorf("sudo listed neither rules nor a refusal: %q", firstLine(out))
}
return rules, nil
}
func statOf(path string) (FileMode, error) {
info, err := os.Stat(path)
if err != nil {
return FileMode{}, err
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return FileMode{}, fmt.Errorf("%s has no owner this platform reports", path)
}
return FileMode{UID: int(st.Uid), GID: int(st.Gid), Perm: info.Mode().Perm()}, nil
}
// lstatOf is a path's own owner and mode, its type bits kept.
func lstatOf(path string) (FileMode, error) {
info, err := os.Lstat(path)
if err != nil {
return FileMode{}, err
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return FileMode{}, fmt.Errorf("%s has no owner this platform reports", path)
}
return FileMode{UID: int(st.Uid), GID: int(st.Gid), Perm: info.Mode()}, nil
}
// InDatabase is `id -nG`: every group the user database lists the account in.
func (e Exec) InDatabase(ctx context.Context, account string) ([]string, error) {
out, err := e.Run(ctx, "id", "-nG", account)
if err != nil {
return nil, err
}
return strings.Fields(out), nil
}
// Session reads the account's own manager, user@<uid>.service, from the machine's manager — never from
// the account's, which asking would start — and the groups its process holds, from its status file.
func (e Exec) Session(ctx context.Context, account string, groups []string) (Session, error) {
passwd, err := e.Run(ctx, "getent", "passwd", account)
if err != nil {
return Session{}, fmt.Errorf("the user database did not answer about %q: %w", account, err)
}
fields := strings.Split(strings.TrimSpace(passwd), ":")
if len(fields) < 7 || fields[2] == "" {
return Session{}, fmt.Errorf("the user database gave no number for %q", account)
}
shown, err := e.Run(ctx, "systemctl", "show", "--property=MainPID", "--value", "user@"+fields[2]+".service")
if err != nil {
return Session{}, fmt.Errorf("the machine's service manager did not say whether %q's own runs: %w", account, err)
}
pid := strings.TrimSpace(shown)
if pid == "" || pid == "0" {
return Session{}, nil
}
held, err := e.heldBy(pid)
if err != nil {
return Session{}, err
}
s := Session{Running: true, Has: map[string]bool{}}
for _, g := range groups {
entry, err := e.Run(ctx, "getent", "group", g)
if err != nil {
return Session{}, fmt.Errorf("the group database did not answer about %q: %w", g, err)
}
parts := strings.Split(strings.TrimSpace(entry), ":")
if len(parts) < 3 {
return Session{}, fmt.Errorf("the group database gave %q for %q, which is not a group entry", entry, g)
}
s.Has[g] = held[parts[2]]
}
return s, nil
}
// heldBy is every group id a process holds, from the Groups line of its status file.
func (e Exec) heldBy(pid string) (map[string]bool, error) {
proc := e.Proc
if proc == "" {
proc = "/proc"
}
raw, err := os.ReadFile(filepath.Join(proc, pid, "status"))
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("the account's manager, process %s, ended while it was read", pid)
}
if err != nil {
return nil, err
}
// Its supplementary groups, and its own group, which the supplementary list need not repeat.
held := map[string]bool{}
named := false
for _, line := range strings.Split(string(raw), "\n") {
if rest, ok := strings.CutPrefix(line, "Groups:"); ok {
named = true
for _, gid := range strings.Fields(rest) {
held[gid] = true
}
}
if rest, ok := strings.CutPrefix(line, "Gid:"); ok {
if f := strings.Fields(rest); len(f) > 0 {
held[f[0]] = true
}
}
}
if !named {
return nil, fmt.Errorf("process %s's status names no groups", pid)
}
return held, nil
}
// IDs is `getent passwd`: an account's uid and primary gid, and whether the user database names it at all. A
// database that does not answer is an error, never "no such account".
func (e Exec) IDs(ctx context.Context, account string) (uid, gid int, found bool, err error) {
out, err := e.Run(ctx, "getent", "passwd", account)
if err != nil {
if strings.Contains(err.Error(), "exited 2") || strings.Contains(err.Error(), "exit status 2") {
return 0, 0, false, nil
}
return 0, 0, false, fmt.Errorf("the user database did not answer about %q: %w", account, err)
}
fields := strings.Split(strings.TrimSpace(out), ":")
if len(fields) < 4 {
return 0, 0, false, fmt.Errorf("the user database gave %q for %q, which is not a passwd entry",
strings.TrimSpace(out), account)
}
// getent reads an all-digit key as a uid: "1000" answers with whoever has uid 1000. Only the account named is
// the one asked for.
if fields[0] != account {
return 0, 0, false, fmt.Errorf("the user database answered %q with the account %q: an account is named, "+
"never numbered", account, fields[0])
}
uid, uerr := strconv.Atoi(fields[2])
gid, gerr := strconv.Atoi(fields[3])
if uerr != nil || gerr != nil {
return 0, 0, false, fmt.Errorf("the user database gave no numbers for %q", account)
}
return uid, gid, true, nil
}
-78
View File
@@ -1,78 +0,0 @@
//go:build linux
package accounts
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"syscall"
"golang.org/x/sys/unix"
)
// ioprioIdle is the idle I/O class (IOPRIO_CLASS_IDLE, 3, shifted by IOPRIO_CLASS_SHIFT, 13): the process gets
// the disk only when nothing else asks for it, where the disk's scheduler honours classes (BFQ does).
const ioprioIdle = 3 << 13
// Idle runs a command as CLocale does, at the lowest priority the machine has (novox/hq issue 361): CPU nice
// 19 and the idle I/O class, set on the process as soon as it has started — so the search for setuid programs,
// which walks every file and runs as long as that takes, yields to everything else. It dies with the engine
// (the parent-death signal), so a restarted engine never leaves a walk running beside its own. A priority it
// could not lower is a failure: the command is killed rather than walk the disks at full priority.
func Idle(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C", "LANGUAGE=C")
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
// The parent-death signal follows the thread that started the child, not the process: held on this
// thread until the child has ended, so the runtime retiring a thread never kills a walk midway.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("%s: %w", name, err)
}
pid := cmd.Process.Pid
if err := lower(pid); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return "", fmt.Errorf("%s: its priority could not be lowered, so it was stopped: %w", name, err)
}
err := cmd.Wait()
out := stdout.String()
if err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) {
return out, fmt.Errorf("%s exited %d: %s", name, exit.ExitCode(), strings.TrimSpace(stderr.String()))
}
return out, fmt.Errorf("%s: %w", name, err)
}
return out, nil
}
// lower sets a process's CPU nice to 19 and its I/O class to idle.
func lower(pid int) error {
if err := unix.Setpriority(unix.PRIO_PROCESS, pid, 19); err != nil {
return fmt.Errorf("nice: %w", err)
}
if _, _, errno := unix.Syscall(unix.SYS_IOPRIO_SET, 1 /* IOPRIO_WHO_PROCESS */, uintptr(pid), ioprioIdle); errno != 0 {
return fmt.Errorf("I/O class: %w", errno)
}
return nil
}
// LowerThisThread puts the calling goroutine on a thread of its own at nice 19 and the idle I/O class, for the
// walk for setuid programs, which runs in the engine (novox/hq issue 361): on Linux both are a thread's, so the
// engine's other work keeps its priority. The goroutine is locked to the thread and never unlocked, so the
// runtime ends the thread with it rather than hand a lowered thread to other work; a command it starts inherits
// the priority too.
func LowerThisThread() error {
runtime.LockOSThread()
return lower(unix.Gettid())
}
-13
View File
@@ -1,13 +0,0 @@
//go:build !linux
package accounts
import "context"
// Idle is CLocale where the machine offers no idle class this engine sets (novox/hq issue 361).
func Idle(ctx context.Context, name string, args ...string) (string, error) {
return CLocale(ctx, name, args...)
}
// LowerThisThread is nothing where the machine offers no per-thread priority this engine sets.
func LowerThisThread() error { return nil }
-32
View File
@@ -1,32 +0,0 @@
package accounts
import (
"context"
"errors"
"strings"
"testing"
)
// An account is read by its name (novox/hq ADR 0325 §4): getent reads an all-digit key as a uid, so "1000" would
// answer with whoever has uid 1000 — refused, never taken for the account named.
func TestAnAccountIsReadByItsNameNeverItsNumber(t *testing.T) {
db := map[string]string{
"nox": "nox:x:1003:1003::/home/nox:/bin/sh",
"1000": "jochen:x:1000:1000::/home/jochen:/bin/zsh", // what getent answers for a uid
}
e := Exec{Run: func(_ context.Context, name string, args ...string) (string, error) {
if line, ok := db[args[len(args)-1]]; ok {
return line + "\n", nil
}
return "", errors.New("getent exited 2")
}}
if uid, gid, found, err := e.IDs(context.Background(), "nox"); err != nil || !found || uid != 1003 || gid != 1003 {
t.Fatalf("nox: %d %d %v %v", uid, gid, found, err)
}
if _, _, found, err := e.IDs(context.Background(), "1000"); found || err == nil || !strings.Contains(err.Error(), "jochen") {
t.Fatalf("an all-digit name was read as a uid: found=%v err=%v", found, err)
}
if _, _, found, err := e.IDs(context.Background(), "ghost"); found || err != nil {
t.Fatalf("an absent account: found=%v err=%v", found, err)
}
}
-302
View File
@@ -1,302 +0,0 @@
package accounts
import (
"context"
"errors"
"fmt"
"io/fs"
"os/exec"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
)
// An account declared never to become root without a person (novox/hq ADR 0266, "how it is checked"): each
// way to root is found and said, none is healthy whether or not anybody is logged in, an unanswered question
// is unknown, and the judge only reads.
// agentMachine is a fake machine for the escalation question: the account's uid, its groups by name and
// number, what sudo lists, and the secrets' owners and modes.
type agentMachine struct {
uid string
groups string
gids string
sudo string
sudoErr error
files map[string]FileMode
failsID bool
asked []string
// texts are files' contents (doas's, polkit's); dirs a directory's files by name; acls a file's ACL;
// setuid what the walk finds, one per line, and packaged the paths a package owns.
texts map[string]string
dirs map[string][]string
acls map[string][]ACLEntry
setuid string
findErr error
packaged map[string]bool
// proc is /proc's files, aSafeProc unless a test changes them; findArgs sees the filesystems walked.
proc map[string]string
findArgs func([]string)
// pacmanCalls counts the package manager's calls: one for every path found.
pacmanCalls int
// nodes are paths' own owners and modes (Lstat); any other is root's, a directory searchable by all, or a
// file the walk found, setuid and executable by all.
nodes map[string]FileMode
}
func (m *agentMachine) lstat(path string) (FileMode, error) {
if n, ok := m.nodes[path]; ok {
return n, nil
}
if strings.Contains(m.setuid, path+"\n") {
return FileMode{Perm: fs.ModeSetuid | 0o755}, nil
}
return FileMode{Perm: fs.ModeDir | 0o755}, nil
}
func (m *agentMachine) readFile(path string) ([]byte, error) {
if t, ok := m.texts[path]; ok {
return []byte(t), nil
}
if t, ok := m.proc[path]; ok {
return []byte(t), nil
}
return nil, fs.ErrNotExist
}
// aSafeProc is what a machine with nothing to say has in /proc: links protected, nothing on the runtimes'
// ports, one root filesystem.
func aSafeProc() map[string]string {
return map[string]string{
"/proc/sys/fs/protected_hardlinks": "1\n",
"/proc/sys/fs/protected_symlinks": "1\n",
"/proc/net/tcp": " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n" +
" 0: 0100007F:1092 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1\n",
"/proc/mounts": "/dev/sda2 / ext4 rw,relatime 0 0\nproc /proc proc rw,nosuid 0 0\ntmpfs /tmp tmpfs rw,nosuid 0 0\n",
}
}
func (m *agentMachine) readDir(path string) ([]fs.DirEntry, error) {
names, ok := m.dirs[path]
if !ok {
return nil, fs.ErrNotExist
}
var out []fs.DirEntry
for _, n := range names {
out = append(out, fakeEntry(n))
}
return out, nil
}
type fakeEntry string
func (f fakeEntry) Name() string { return string(f) }
func (f fakeEntry) IsDir() bool { return false }
func (f fakeEntry) Type() fs.FileMode { return 0 }
func (f fakeEntry) Info() (fs.FileInfo, error) { return nil, fs.ErrNotExist }
func (m *agentMachine) acl(path string) ([]ACLEntry, error) {
if _, ok := m.files[path]; !ok {
return nil, fs.ErrNotExist
}
return m.acls[path], nil
}
// walk is the walk for setuid programs on this machine: m.setuid found, then m.findErr.
func (m *agentMachine) walk(_ context.Context, p *SetuidProgress, _ func()) error {
m.asked = append(m.asked, "walk "+strings.Join(p.Mounts, " "))
if m.findArgs != nil {
m.findArgs(p.Mounts)
}
for _, l := range strings.Split(m.setuid, "\n") {
if l = strings.TrimSpace(l); l != "" {
p.Found = append(p.Found, l)
}
}
return m.findErr
}
func (m *agentMachine) run(_ context.Context, name string, args ...string) (string, error) {
line := name + " " + strings.Join(args, " ")
m.asked = append(m.asked, line)
switch {
case m.failsID && name == "id":
return "", errors.New("id exited 1: no such user")
case line == "id -u agent":
return m.uid + "\n", nil
case line == "id -nG agent":
return m.groups + "\n", nil
case line == "id -G agent":
return m.gids + "\n", nil
case line == "sudo -l -U agent":
return m.sudo, m.sudoErr
case name == "pacman" && len(args) >= 2 && args[0] == "-Qo":
m.pacmanCalls++
var out, errs []string
for _, p := range args[1:] {
if m.packaged[p] {
out = append(out, p+" is owned by somepackage 1.0-1")
} else {
errs = append(errs, "error: No package owns "+p)
}
}
said := strings.Join(out, "\n")
if len(errs) > 0 {
return said, errors.New("pacman exited 1: " + strings.Join(errs, "\n"))
}
return said, nil
}
return "", errors.New("not a command the judge may run: " + line)
}
func (m *agentMachine) stat(path string) (FileMode, error) {
if f, ok := m.files[path]; ok {
return f, nil
}
return FileMode{}, fs.ErrNotExist
}
const notAllowed = "User agent is not allowed to run sudo on box.\n"
var agent = Account{Module: "claude-code", ID: "claude-code.agent", Name: "agent", Root: true,
Secrets: []string{"/var/lib/mesh/node-tools/broker"}}
func clean() *agentMachine {
return &agentMachine{uid: "1600", groups: "agent", gids: "1600", sudo: notAllowed,
files: map[string]FileMode{"/var/lib/mesh/node-tools/broker": {UID: 1500, GID: 1500, Perm: 0o600}},
proc: aSafeProc()}
}
func lookAgent(t *testing.T, m *agentMachine) Verdict {
t.Helper()
j := New(Exec{Run: m.run, Walk: m.walk, Stat: m.stat, ReadFile: m.readFile, ReadDir: m.readDir, ACL: m.acl, Lstat: m.lstat})
j.Set([]Account{agent})
st, _ := j.Look(t.Context())
if len(st.Accounts) != 1 {
t.Fatalf("the statement: %+v", st)
}
for _, a := range m.asked {
if !strings.HasPrefix(a, "id ") && a != "sudo -l -U agent" && !strings.HasPrefix(a, "walk /") &&
!strings.HasPrefix(a, "pacman -Qo ") {
t.Errorf("the judge asked something that is not a read: %q", a)
}
}
return st.Accounts[0]
}
func TestAnAgentAccountWithNoWayToRootIsHealthyWithNobodyLoggedIn(t *testing.T) {
if v := lookAgent(t, clean()); v.State != Healthy || v.Reason != "" || !v.Root {
t.Fatalf("no way to root: %+v", v)
}
}
func TestEachWayToRootIsSaid(t *testing.T) {
for _, c := range []struct {
name string
set func(*agentMachine)
said string
}{
{"uid 0", func(m *agentMachine) { m.uid = "0" }, "its uid is 0"},
{"docker", func(m *agentMachine) { m.groups = "agent docker" }, "in the group docker, which grants root"},
{"wheel", func(m *agentMachine) { m.groups = "agent wheel" }, "in the group wheel, which grants root"},
{"sudo", func(m *agentMachine) {
m.sudo = "Matching Defaults entries for agent on box:\n env_reset\n\nUser agent may run the following commands on box:\n (ALL) NOPASSWD: ALL\n"
}, "sudo grants it: (ALL) NOPASSWD: ALL"},
{"secret by others", func(m *agentMachine) {
m.files["/var/lib/mesh/node-tools/broker"] = FileMode{UID: 1500, GID: 1500, Perm: 0o644}
}, "it can read the secret /var/lib/mesh/node-tools/broker"},
{"secret by group", func(m *agentMachine) {
m.gids = "1600 1500"
m.files["/var/lib/mesh/node-tools/broker"] = FileMode{UID: 1500, GID: 1500, Perm: 0o640}
}, "it can read the secret /var/lib/mesh/node-tools/broker"},
} {
t.Run(c.name, func(t *testing.T) {
m := clean()
c.set(m)
v := lookAgent(t, m)
if v.State != Unhealthy || !strings.HasPrefix(v.Reason, ReasonRoot+": ") || !strings.Contains(v.Reason, c.said) {
t.Fatalf("want %q said: %+v", c.said, v)
}
})
}
}
func TestEveryWayIsSaidAtOnce(t *testing.T) {
m := clean()
m.groups = "agent docker"
m.sudo = "User agent may run the following commands on box:\n (ALL) ALL\n"
v := lookAgent(t, m)
if !strings.Contains(v.Reason, "docker") || !strings.Contains(v.Reason, "(ALL) ALL") {
t.Fatalf("both ways: %+v", v)
}
}
func TestAnUnansweredQuestionIsUnknownNeverHealthy(t *testing.T) {
m := clean()
m.failsID = true
if v := lookAgent(t, m); v.State != Unknown {
t.Fatalf("an unread database: %+v", v)
}
m = clean()
m.sudo = "something sudo never says\n"
if v := lookAgent(t, m); v.State != Unknown {
t.Fatalf("an unread sudo: %+v", v)
}
}
func TestASecretNotThereYetIsNoWay(t *testing.T) {
m := clean()
delete(m.files, "/var/lib/mesh/node-tools/broker")
if v := lookAgent(t, m); v.State != Healthy {
t.Fatalf("no secret placed: %+v", v)
}
}
func TestSudoRules(t *testing.T) {
none := []struct {
out string
err error
}{
{notAllowed, nil},
{notAllowed, errors.New("sudo exited 1: ")},
{"", fmt.Errorf("sudo: %w", exec.ErrNotFound)},
}
for _, c := range none {
if rules, err := SudoRules(c.out, c.err); err != nil || len(rules) != 0 {
t.Errorf("%q, %v: rules %v, err %v", c.out, c.err, rules, err)
}
}
rules, err := SudoRules("Matching Defaults entries for agent on box:\n env_reset\n\nUser agent may run the following commands on box:\n (ALL) /usr/bin/systemctl\n (root) NOPASSWD: /usr/bin/true\n", nil)
if err != nil || len(rules) != 2 || rules[0] != "(ALL) /usr/bin/systemctl" {
t.Fatalf("two rules: %v, %v", rules, err)
}
if _, err := SudoRules("", errors.New("sudo exited 1: sudo: unable to resolve host")); err == nil {
t.Error("a failing sudo that said neither was read as no rules")
}
}
func TestReadable(t *testing.T) {
m := FileMode{UID: 1500, GID: 1500, Perm: 0o600}
if Readable(m, 1600, map[int]bool{1600: true}) || !Readable(m, 1500, nil) || !Readable(m, 0, nil) {
t.Fatal("owner bits")
}
}
func TestOfJudgesARootNeverAccountWithNoGroupsAndItsSecrets(t *testing.T) {
d, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[
{"id":"claude-code.agent","type":"user","name":"agent","root":"never"},
{"id":"zsh.login","type":"user","name":"operator","shell":"/bin/zsh"},
{"id":"node-tools.need-broker","type":"file","path":"/var/lib/mesh/broker","sealed":"x","owner":"operator"},
{"id":"claude-code.own","type":"file","path":"/home/agent/own","sealed":"x","owner":"agent"},
{"id":"claude-code.plain","type":"file","path":"/etc/plain","content":"x"}
]}`))
if err != nil {
t.Fatal(err)
}
got := Of(d, nil)
if len(got) != 1 || got[0].ID != "claude-code.agent" || !got[0].Root ||
len(got[0].Secrets) != 1 || got[0].Secrets[0] != "/var/lib/mesh/broker" {
t.Fatalf("judged: %+v", got)
}
}
-635
View File
@@ -1,635 +0,0 @@
package accounts
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"syscall"
"time"
"github.com/novox/mesh-host/rootsearch"
)
// SetuidCache keeps the search for setuid programs, which walks every local filesystem, and runs it in the
// background (novox/hq ADR 0266, issue 361): a look never waits for it, and the search itself is never cut
// short.
//
// - **A verdict comes only from a complete search, while it is fresh**: one that walked everything and had the
// package manager answer for every path found, started less than FreshFor ago. Before the first completes,
// or once the last is older, the answer is "not judged yet" (ReasonPending) while a search runs, and "not
// judged" (ReasonIncomplete) after one failed. Never "none found" from a search that did not finish.
// - **It runs to completion**, once at a time, with no bound, on a thread of its own at idle priority (Lower):
// a fresh one when the last complete one is Every old, when SearchAgain asks (an apply changed what root
// controls), or when a filesystem appeared that the last one did not walk. One that fails is tried again
// after a back-off that doubles from FailedBackoff up to MaxBackoff.
// - **Resumable**: the walk's progress is kept in ProgressPath every CheckpointEvery, and a search started
// again — after an engine restart, an update, a reboot or a failure — carries on from it while it is
// younger than FreshFor. A search SearchAgain asked for never resumes an older walk: it starts from nothing.
// - **Kept** in Path, beside the engine's other state, with when it started and finished: an engine restart
// serves the last complete search rather than judging nothing until a new walk ends.
//
// Why a complete search stays sound for a day and more, and a resumed walk is sound, is said in the rootsearch
// package and setuid_walk.go: only root makes a setuid-root file, never the agent account.
type SetuidCache struct {
// Every and FreshFor are rootsearch's when zero.
Every, FreshFor time.Duration
// Path is the file the last complete search is kept in, and ProgressPath the walk under way; empty keeps
// either in memory only. The engine's own state directory, which only root writes: a file there that is
// not a regular file the engine's account owns and alone may write is not read (keptFile).
Path, ProgressPath string
// Lower is called on the search's own goroutine before it walks (LowerThisThread on a serving engine); a
// priority it cannot lower fails the search rather than walk the disks at full priority. Nil lowers nothing.
Lower func() error
// Say is where a search's end and a kept file refused are said; nil says nothing.
Say func(string)
mu sync.Mutex
wg sync.WaitGroup
loaded bool
last *setuidResult // the last complete search
unfinished *SetuidProgress // a walk that did not finish, to resume
progress *SearchProgress // how far the running search got, as of its last checkpoint
running bool
gen int // counts SearchNow: a search started under an older one is thrown away
cancel context.CancelFunc // ends the running search's walk
startedAt time.Time // when the search running, or the last one, was started (a resumed one: its start)
attemptAt time.Time // when the running or last search was started or resumed
endedAt time.Time // when the last search ended, on the same clock
again bool // asked to search again since the running or last one started
failedAt time.Time
failed error
backoff time.Duration
starts int // how many searches were started or resumed, for a test
}
// setuidResult is one complete search, as kept.
type setuidResult struct {
// Started and Finished are on the caller's clock: Finished is Started plus how long the search took.
Started time.Time `json:"started"`
Finished time.Time `json:"finished"`
// Mounts are the filesystems it walked; Found the setuid-root programs no package owns.
Mounts Paths `json:"mounts"`
Found Paths `json:"found"`
}
const (
// FailedBackoff is the first wait after a search failed, doubling to MaxBackoff.
FailedBackoff = time.Hour
MaxBackoff = 6 * time.Hour
// AgainAfter is the least time between the starts of two searches that SearchAgain or a new filesystem
// asked for: a run of applies does not walk the disks back to back.
AgainAfter = time.Hour
// clockSkew is how far in the future a kept search may say it started and still judge: a clock set back
// further makes it no verdict, never a fresh one.
clockSkew = 5 * time.Minute
// SetuidSearchFile and SetuidProgressFile are the files' names beside the engine's state: the last
// complete search, and the walk under way.
SetuidSearchFile = "setuid-search.json"
SetuidProgressFile = "setuid-search-progress.json"
)
// SetuidNotJudged is the search's answer when no complete search judges: none has finished yet, the last one is
// older than FreshFor, or every one since failed. The judge says the way is not judged, never that there is none.
type SetuidNotJudged struct {
// Pending is true while a search runs; false after one failed.
Pending bool
Since time.Time
Err error
// Last is when the last complete search started, when there is one too old to judge.
Last time.Time
// Progress is how far the running search got, as of its last checkpoint: nil when none runs.
Progress *SearchProgress
}
// SearchProgress is how far a running search got, as of its last checkpoint, for anybody to read (novox/hq
// issue 361): counts, times and mount points only. **Never a path the walk visited or found**: the cursor and
// the files found are under accounts' homes as often as not, and stay in the engine's state and journal.
type SearchProgress struct {
// Started is when the search began, on the cache's clock.
Started time.Time
// Filesystems is how many it walks; Done how many it has walked to their end; Walking the mount point of
// the one it walks now, empty when every one is walked.
Filesystems, Done int
Walking string
// Visited is how many entries it has gone past; Found how many setuid- or setgid-root files it found so
// far, before the package manager is asked about them.
Visited int64
Found int
// AtLeast says Visited is a lower bound: the walk was begun by an engine that did not count.
AtLeast bool
// Kept is when its progress was last kept, zero before the first time.
Kept time.Time
// Finished is every filesystem walked to its end, in order, and how long its walk took.
Finished []SearchedFilesystem
}
// SearchedFilesystem is one filesystem a search walked to its end. Took is negative when not known: walked by
// an engine before it counted.
type SearchedFilesystem struct {
Mount string
Took time.Duration
}
// progressOf is p as anybody may read it. Counts and mount points, no I/O.
func progressOf(p *SetuidProgress, kept time.Time) *SearchProgress {
sp := &SearchProgress{Started: p.Started, Filesystems: len(p.Mounts), Done: len(p.Done), Visited: p.Visited,
Found: len(p.Found), Kept: kept, AtLeast: !p.Counted}
for i, m := range p.Done {
took := time.Duration(-1)
if i < len(p.Took) {
took = p.Took[i]
}
sp.Finished = append(sp.Finished, SearchedFilesystem{Mount: m, Took: took})
}
for _, m := range p.Mounts {
if !slices.Contains(p.Done, m) {
sp.Walking = m
break
}
}
return sp
}
// Words are the progress in one line, as the verdict says it beside "not judged yet".
func (sp *SearchProgress) Words() string {
if sp == nil {
return ""
}
s := fmt.Sprintf("progress: %d of %d filesystems walked", sp.Done, sp.Filesystems)
if sp.Walking != "" {
s += ", walking " + sp.Walking
}
atLeast := ""
if sp.AtLeast {
atLeast = "at least "
}
s += fmt.Sprintf(", %s%d entries visited, %d setuid- or setgid-root files found so far", atLeast, sp.Visited, sp.Found)
if sp.Kept.IsZero() {
s += ", not kept yet"
} else {
s += ", kept " + sp.Kept.Local().Format("2006-01-02 15:04:05")
}
var took []string
for _, f := range sp.Finished {
if f.Took < 0 {
took = append(took, f.Mount+" in a time not known")
} else {
took = append(took, f.Mount+" in "+f.Took.Round(time.Second).String())
}
}
if len(took) > 0 {
s += "; walked " + strings.Join(took, ", ")
}
return s
}
func (e *SetuidNotJudged) Error() string {
stale := ""
if !e.Last.IsZero() {
stale = fmt.Sprintf("; the last complete one, started %s, is too old to judge", e.Last.Local().Format("2006-01-02 15:04"))
}
if e.Pending {
return fmt.Sprintf("%s: the search for setuid programs, started %s, has not finished yet%s", ReasonPending,
e.Since.Local().Format("2006-01-02 15:04"), stale)
}
return fmt.Sprintf("%s: the search for setuid programs did not complete (last tried %s: %v); it is tried "+
"again later%s", ReasonIncomplete, e.Since.Local().Format("2006-01-02 15:04"), e.Err, stale)
}
// The reasons of a root verdict the setuid search could not answer.
const (
ReasonPending = rootsearch.ReasonPending
ReasonIncomplete = rootsearch.ReasonIncomplete
)
func (c *SetuidCache) every() time.Duration {
if c.Every > 0 {
return c.Every
}
return rootsearch.Every
}
func (c *SetuidCache) freshFor() time.Duration {
if c.FreshFor > 0 {
return c.FreshFor
}
return rootsearch.FreshFor
}
// SearchAgain asks for a new search at the next look: an apply changed what root controls (an account, a group,
// a sudo rule, a package, an action). The last complete search keeps judging until it is too old: only root acts
// through an apply, and the agent account cannot make a setuid-root file. Nil c is nothing to ask.
func (c *SetuidCache) SearchAgain() {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.again = true
// The walk under way, or kept, started before root acted: never carried on, even by an engine that
// restarts before the next search starts (the review of 2026-10-10). It keeps no progress from now on.
c.unfinished = nil
c.forgetProgress()
}
// SearchNow throws away the last complete search, kept or in memory, and any walk under way or kept, and has
// the next look start a full search at once, past AgainAfter and any back-off (novox/hq issue 361): the
// operator changed by hand what the last one found, which no apply says. Until the new one completes the
// verdict is "not judged yet". Asked at the controller's terminal alone (`nox node setuid-search <node>`): a
// search can never make a machine free wrongly, but asked again and again it would keep a machine unjudged
// and its disks busy. by is who asked, for the journal. Nil c has nothing to throw away.
func (c *SetuidCache) SearchNow(by string) string {
if c == nil {
return "this engine keeps no search for setuid programs"
}
c.mu.Lock()
defer c.mu.Unlock()
c.loaded = true // nothing kept is read after this: it is thrown away
what := "no complete search was kept"
if c.last != nil {
what = "the complete search started " + c.last.Started.Local().Format("2006-01-02 15:04") + " is thrown away"
}
if c.running {
what += ", and the search running since " + c.startedAt.Local().Format("2006-01-02 15:04") + " is ended"
if c.cancel != nil {
c.cancel()
}
}
c.gen++
c.last, c.unfinished, c.progress = nil, nil, nil
c.failed, c.failedAt, c.backoff, c.again = nil, time.Time{}, 0, false
c.forgetProgress()
if c.Path != "" {
if err := os.Remove(c.Path); err != nil && !errors.Is(err, fs.ErrNotExist) {
c.say("the search for setuid programs could not forget the complete search it kept: " + err.Error())
}
}
said := "a new search for setuid programs starts at the next look, asked by " + by + ": " + what +
"; until it completes, whether an account can become root through one is not judged"
c.say(said)
return said
}
// SetuidSearch is one search: it carries p on to its end — walking what p has not walked, calling checkpoint
// as it goes so its progress can be kept — and answers the setuid-root programs no package owns.
type SetuidSearch func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error)
// Look answers the setuid-root programs no package owns as the last complete search found them, and when it
// started — while it is fresh — and starts a search in the background when one is due; it never waits for one.
// mounts are the filesystems a new search walks. Nil c searches in place, for a caller that wants to.
//
// **One clock: the caller's.** A search's time is the `now` it was started at, never the wall clock read when it
// finished: due-ness compares a later `now` with it, and mixing the two clocks made a search never due again
// whenever the caller's clock ran behind the wall clock (found while checking issue 356). Measured from the
// start, a result reads at most one search older than it is, which errs towards not judging.
func (c *SetuidCache) Look(now time.Time, mounts []string, search SetuidSearch) ([]string, time.Time, error) {
if c == nil {
p := &SetuidProgress{Started: now, Mounts: slices.Clone(mounts), Counted: true}
found, err := search(context.Background(), p, func() {})
return found, now, err
}
c.mu.Lock()
defer c.mu.Unlock()
if !c.loaded {
c.loaded = true
c.last = c.loadResult()
c.unfinished = c.loadProgress()
}
var age time.Duration
if c.last != nil {
age = now.Sub(c.last.Started)
}
// Fresh only while it walked every filesystem a search would walk now: one mounted since is not judged
// by a search that never saw it.
fresh := c.last != nil && age > -clockSkew && age < c.freshFor() && covers(c.last.Mounts, mounts)
// Asked for (an apply, a new filesystem): at most once an AgainAfter, counted from when the last search
// ended, so a walk of hours is not followed by another at once, apply after apply.
gap := now.Sub(c.attemptAt)
if !c.endedAt.IsZero() {
gap = now.Sub(c.endedAt)
}
due := !c.running && (c.failed == nil || now.Sub(c.failedAt) >= c.backoff) &&
(!fresh || age >= c.every() || (c.again && (c.attemptAt.IsZero() || gap >= AgainAfter)))
if due {
c.start(now, mounts, search)
}
switch {
case fresh:
return slices.Clone(c.last.Found), c.last.Started, nil
case c.running:
return nil, time.Time{}, &SetuidNotJudged{Pending: true, Since: c.startedAt, Last: c.lastStarted(),
Progress: c.progress}
case c.failed != nil:
return nil, time.Time{}, &SetuidNotJudged{Since: c.failedAt, Err: c.failed, Last: c.lastStarted()}
default:
// Not fresh, not running, never failed: cannot be, as not fresh is due. Never a verdict.
return nil, time.Time{}, &SetuidNotJudged{Pending: true, Since: now, Last: c.lastStarted()}
}
}
func (c *SetuidCache) lastStarted() time.Time {
if c.last == nil {
return time.Time{}
}
return c.last.Started
}
// resumable is the walk that did not finish, when a search may carry it on: younger than FreshFor, and no
// SearchAgain since — an apply that moved root's things wants a walk that starts after it. Anything else is
// thrown away. c.mu held.
func (c *SetuidCache) resumable(now time.Time) *SetuidProgress {
p := c.unfinished
c.unfinished = nil
if p == nil {
return nil
}
age := now.Sub(p.Started)
if c.again || age <= -clockSkew || age >= c.freshFor() {
c.forgetProgress()
return nil
}
return p
}
// start runs one search in the background, with no bound: a walk that did not finish carried on, or a new one.
// c.mu held.
func (c *SetuidCache) start(now time.Time, mounts []string, search SetuidSearch) {
p := c.resumable(now)
resumed := p != nil
if p == nil {
p = &SetuidProgress{ID: fmt.Sprintf("%x", now.UnixNano()), Started: now, Mounts: slices.Clone(mounts),
Cursor: map[string]string{}, Counted: true}
}
c.running, c.startedAt, c.attemptAt, c.again = true, p.Started, now, false
c.starts++
// What a look says until the first checkpoint: where the kept walk was, or nothing walked.
kept := p.Kept
c.progress = progressOf(p, kept)
if resumed {
c.say(fmt.Sprintf("the search for setuid programs started %s carries on where it was (%d of %d "+
"filesystems walked)", p.Started.Local().Format("2006-01-02 15:04"), len(p.Done), len(p.Mounts)))
}
began := time.Now()
gen := c.gen
ctx, cancel := context.WithCancel(context.Background())
c.cancel = cancel
c.wg.Add(1)
go func() {
defer c.wg.Done()
var found []string
var err error
if c.Lower != nil {
// The goroutine stays on its lowered thread until it ends, and the thread ends with it.
if lerr := c.Lower(); lerr != nil {
err = fmt.Errorf("its priority could not be lowered, so it did not walk: %w", lerr)
}
}
if err == nil {
unkept := false
found, err = search(ctx, p, func() {
c.mu.Lock()
superseded := c.again || c.gen != gen
c.mu.Unlock()
if !superseded { // root acted since this walk started: its progress is not to be carried on
at := time.Now()
p.Kept = at
kerr := c.keepProgress(p)
// Superseded while it wrote (SearchAgain, SearchNow), after they forgot the kept walk: forgotten
// again here, so no engine restarted before this walk ends carries it on (the review of
// 2026-10-10). Written outside the lock, so a look never waits on the disk.
c.mu.Lock()
if c.again || c.gen != gen {
c.forgetProgress()
}
c.mu.Unlock()
if kerr != nil {
p.Kept = kept // not kept: the file still says the time before
if !unkept {
unkept = true
c.say("the search for setuid programs could not keep how far it got, and starts over " +
"if the engine stops: " + kerr.Error())
}
} else {
kept = at
}
}
// What a look says from now: computed from p here, on the walk's own goroutine, never read
// by a look while the walk changes it.
sp := progressOf(p, kept)
c.mu.Lock()
if c.gen == gen {
c.progress = sp
}
c.mu.Unlock()
})
}
took := time.Since(began)
cancel()
c.mu.Lock()
defer c.mu.Unlock()
c.running, c.progress, c.cancel = false, nil, nil
c.endedAt = now.Add(took)
if c.gen != gen {
// Thrown away by SearchNow while it ran: neither a result nor a failure, and nothing to carry on. The
// next look starts the search asked for.
c.endedAt = time.Time{}
// A checkpoint that read its generation just before SearchNow may have kept this walk again.
c.forgetProgress()
c.say(fmt.Sprintf("the search for setuid programs started %s was ended, as asked (%s this time)",
p.Started.Local().Format("2006-01-02 15:04"), took.Round(time.Second)))
return
}
if err != nil {
// The back-off counts from when it failed.
c.failed, c.failedAt = err, c.endedAt
c.backoff = min(max(2*c.backoff, FailedBackoff), MaxBackoff)
if !c.again { // superseded by an ask to search again: never carried on
c.unfinished = p
}
c.say(fmt.Sprintf("the search for setuid programs started %s did not complete (%s this time): %v",
p.Started.Local().Format("2006-01-02 15:04"), took.Round(time.Second), err))
return
}
r := &setuidResult{Started: p.Started, Finished: now.Add(took), Mounts: p.Mounts, Found: found}
c.last, c.failed, c.backoff = r, nil, 0
if err := c.keepResult(r); err != nil {
c.say("the search for setuid programs could not be kept, and judges from memory until the engine " +
"restarts: " + err.Error())
}
c.forgetProgress()
// The paths in the engine's journal alone: the verdict says one in a home by count.
named := ""
if len(found) > 0 {
named = ": " + strings.Join(found, ", ")
}
c.say(fmt.Sprintf("the search for setuid programs started %s completed (%s this time): %d no package "+
"owns%s", p.Started.Local().Format("2006-01-02 15:04"), took.Round(time.Second), len(found), named))
}()
}
func (c *SetuidCache) say(s string) {
if c.Say != nil {
c.Say(s)
}
}
// covers says every filesystem a search would walk now was walked.
func covers(walked, now []string) bool {
for _, m := range now {
if !slices.Contains(walked, m) {
return false
}
}
return true
}
// Wait is for a test: until the search running, if any, has finished.
func (c *SetuidCache) Wait() { c.wg.Wait() }
func (c *SetuidCache) keepResult(r *setuidResult) error { return keepFile(c.Path, r) }
func (c *SetuidCache) keepProgress(p *SetuidProgress) error { return keepFile(c.ProgressPath, p) }
// forgetProgress removes the kept walk: finished, or thrown away.
func (c *SetuidCache) forgetProgress() {
if c.ProgressPath == "" {
return
}
if err := os.Remove(c.ProgressPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
c.say("the search for setuid programs could not forget the walk it kept: " + err.Error())
}
}
// keepFile writes v to path: a new file beside it, readable by its owner alone, renamed over it — a link at
// path is replaced, never followed. Empty path keeps nothing.
func keepFile(path string, v any) error {
if path == "" {
return nil
}
raw, err := json.Marshal(v)
if err != nil {
return err
}
f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err := f.Write(raw); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(f.Name(), path)
}
// loadResult reads the kept complete search, or nil: none kept, or one that cannot be believed, said why.
func (c *SetuidCache) loadResult() *setuidResult {
var r setuidResult
if !c.loadFile(c.Path, "complete search", &r) {
return nil
}
if r.Started.IsZero() || r.Finished.Before(r.Started) || len(r.Mounts) == 0 {
c.say("the kept search for setuid programs says no complete search, and a new one runs")
return nil
}
return &r
}
// loadProgress reads the kept walk, or nil.
func (c *SetuidCache) loadProgress() *SetuidProgress {
var p SetuidProgress
if !c.loadFile(c.ProgressPath, "walk under way", &p) {
return nil
}
if p.Started.IsZero() || len(p.Mounts) == 0 {
c.say("the kept walk for setuid programs says no walk, and it starts over")
c.forgetProgress()
return nil
}
if p.Cursor == nil {
p.Cursor = map[string]string{}
}
return &p
}
func (c *SetuidCache) loadFile(path, what string, v any) bool {
if path == "" {
return false
}
raw, err := keptFile(path)
if errors.Is(err, fs.ErrNotExist) {
return false
}
if err == nil {
if err = json.Unmarshal(raw, v); err != nil {
err = fmt.Errorf("%s is not JSON: %w", path, err)
}
}
if err != nil {
c.say(fmt.Sprintf("the kept %s for setuid programs is not read, and a new one runs: %v", what, err))
return false
}
return true
}
// keptFile reads a kept file, believing it only as a regular file, not followed through a link, owned by the
// engine's own account and writable by it alone, in a directory that is the same: anything else could have been
// written by somebody other than root, and a search it says is no search.
func keptFile(path string) ([]byte, error) {
if err := ownedAlone(filepath.Dir(path), true); err != nil {
return nil, err
}
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
if err != nil {
return nil, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, err
}
if err := ownedAloneInfo(path, info, false); err != nil {
return nil, err
}
return io.ReadAll(io.LimitReader(f, 64<<20))
}
func ownedAlone(path string, dir bool) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
return ownedAloneInfo(path, info, dir)
}
func ownedAloneInfo(path string, info fs.FileInfo, dir bool) error {
if dir && !info.IsDir() || !dir && !info.Mode().IsRegular() {
return fmt.Errorf("%s is not a plain %s", path, map[bool]string{true: "directory", false: "file"}[dir])
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Errorf("%s's owner could not be read", path)
}
if int(st.Uid) != os.Geteuid() {
return fmt.Errorf("%s is owned by uid %d, not the engine's %d", path, st.Uid, os.Geteuid())
}
if info.Mode().Perm()&0o022 != 0 {
return fmt.Errorf("%s may be written by others than its owner (mode %o)", path, info.Mode().Perm())
}
return nil
}
-447
View File
@@ -1,447 +0,0 @@
package accounts
import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/rootsearch"
)
// The search for setuid programs runs to completion and judges from its last complete result while it is fresh
// (novox/hq issue 361): never a bound that ends it, never a verdict from a search that did not finish or is too
// old, a new search after root acted, and the result kept beside the engine's state, believed only as root's.
var at = time.Date(2026, 10, 10, 1, 0, 0, 0, time.UTC)
var root = []string{"/"}
// gate is a search that waits until released; each search started hands its context on started. found and
// err are read when it is released.
type gate struct {
release chan struct{}
started chan context.Context
found []string
err error
}
func newGate() *gate {
return &gate{release: make(chan struct{}, 16), started: make(chan context.Context, 16)}
}
func (g *gate) search(ctx context.Context, p *SetuidProgress, _ func()) ([]string, error) {
g.started <- ctx
<-g.release
return g.found, g.err
}
func (g *gate) finish(c *SetuidCache) { g.release <- struct{}{}; c.Wait() }
// starts is how many searches c started.
func starts(c *SetuidCache) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.starts
}
func notJudged(t *testing.T, err error, pending bool) *SetuidNotJudged {
t.Helper()
var nj *SetuidNotJudged
if !errors.As(err, &nj) || nj.Pending != pending {
t.Fatalf("want not judged (pending %v), got %v", pending, err)
}
want := ReasonIncomplete
if pending {
want = ReasonPending
}
if !strings.HasPrefix(err.Error(), want) {
t.Fatalf("the words start %q: %v", want, err)
}
return nj
}
func TestTheSearchRunsToCompletionWithNoBound(t *testing.T) {
c := &SetuidCache{}
g := newGate()
if _, _, err := c.Look(at, root, g.search); err == nil {
t.Fatal("a look before the first search finished judged")
}
ctx := <-g.started
if _, ok := ctx.Deadline(); ok {
t.Fatal("the search was given a deadline: it must run until it has walked everything")
}
// Hours later, still running: still pending, never killed, never a second walk beside it.
_, _, err := c.Look(at.Add(5*time.Hour), root, g.search)
notJudged(t, err, true)
if starts(c) != 1 || ctx.Err() != nil {
t.Fatalf("a running search was started again or stopped: %d searches, %v", starts(c), ctx.Err())
}
g.found = []string{"/opt/x"}
g.finish(c)
found, when, err := c.Look(at.Add(5*time.Hour), root, g.search)
if err != nil || len(found) != 1 || !when.Equal(at) {
t.Fatalf("the complete search judges, from its start: %v %v %v", found, when, err)
}
}
func TestAFreshCompleteSearchJudgesUntilItIsDue(t *testing.T) {
c := &SetuidCache{}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
if _, _, err := c.Look(at.Add(rootsearch.Every-time.Minute), root, g.search); err != nil {
t.Fatalf("a complete search within a day: %v", err)
}
if starts(c) != 1 {
t.Fatalf("searched again before it was due: %d", starts(c))
}
// Due: a new search starts, and the last complete one judges meanwhile.
if _, _, err := c.Look(at.Add(rootsearch.Every), root, g.search); err != nil {
t.Fatalf("while the daily search runs, the last complete one still judges: %v", err)
}
if starts(c) != 2 {
t.Fatalf("the daily search did not start: %d", starts(c))
}
g.finish(c)
}
func TestAStaleSearchIsNotJudgedNeverFree(t *testing.T) {
c := &SetuidCache{}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
c.Look(at.Add(rootsearch.Every), root, g.search) // the daily one, which does not finish
_, _, err := c.Look(at.Add(rootsearch.FreshFor), root, g.search)
nj := notJudged(t, err, true)
if !nj.Last.Equal(at) || !strings.Contains(err.Error(), "too old to judge") {
t.Fatalf("a stale search says when the last complete one started: %v", err)
}
g.finish(c)
if _, _, err := c.Look(at.Add(rootsearch.FreshFor), root, g.search); err != nil {
t.Fatalf("once the new search completed it judges again: %v", err)
}
}
func TestAnIncompleteSearchIsNeverFree(t *testing.T) {
c := &SetuidCache{}
g := newGate()
g.err = errors.New("the search for setuid programs did not complete its walk")
c.Look(at, root, g.search)
g.finish(c)
_, _, err := c.Look(at.Add(time.Minute), root, g.search)
notJudged(t, err, false)
if starts(c) != 1 {
t.Fatalf("tried again within its back-off: %d", starts(c))
}
c.Look(at.Add(FailedBackoff+time.Second), root, g.search)
if starts(c) != 2 {
t.Fatalf("not tried again after its back-off: %d", starts(c))
}
g.finish(c)
// A stale complete search and a failed new one: still not judged, never the stale one.
c = &SetuidCache{}
g = newGate()
c.Look(at, root, g.search)
g.finish(c)
g.err = errors.New("pacman could not say who owns the setuid programs found")
c.Look(at.Add(rootsearch.Every), root, g.search)
g.finish(c)
if _, _, err := c.Look(at.Add(rootsearch.Every+time.Hour), root, g.search); err != nil {
t.Fatalf("a failed search leaves the fresh complete one judging: %v", err)
}
_, _, err = c.Look(at.Add(rootsearch.FreshFor), root, g.search)
if err == nil {
t.Fatal("a stale search judged after the newer one failed")
}
g.finish(c)
}
func TestAnApplyThatMovedRootSearchesAgain(t *testing.T) {
c := &SetuidCache{}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
c.SearchAgain()
if _, _, err := c.Look(at.Add(time.Minute), root, g.search); err != nil || starts(c) != 1 {
t.Fatalf("asked again within AgainAfter of the last search's end: %v, %d searches", err, starts(c))
}
if _, _, err := c.Look(at.Add(AgainAfter+time.Second), root, g.search); err != nil {
t.Fatalf("the last complete search judges while the new one runs: %v", err)
}
if starts(c) != 2 {
t.Fatalf("an apply that moved root did not search again: %d", starts(c))
}
// Asked again while one runs: one more after it, never two at once.
c.SearchAgain()
c.Look(at.Add(AgainAfter+time.Minute), root, g.search)
if starts(c) != 2 {
t.Fatalf("a second search started beside the running one: %d", starts(c))
}
g.finish(c) // ends, on the cache's clock, at about AgainAfter+1s
c.Look(at.Add(2*AgainAfter), root, g.search)
if starts(c) != 2 {
t.Fatal("a search started again less than AgainAfter after the last one ended")
}
c.Look(at.Add(2*AgainAfter+2*time.Second), root, g.search)
if starts(c) != 3 {
t.Fatalf("the ask made while a search ran was dropped: %d", starts(c))
}
g.finish(c)
var nilCache *SetuidCache
nilCache.SearchAgain()
}
// A filesystem the last complete search did not walk is not judged by it: not judged yet, and a search starts.
func TestAFilesystemTheSearchDidNotWalkIsNotJudgedByIt(t *testing.T) {
c := &SetuidCache{}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
_, _, err := c.Look(at.Add(time.Hour), []string{"/", "/srv"}, g.search)
notJudged(t, err, true)
if starts(c) != 2 {
t.Fatalf("a new filesystem was not searched: %d", starts(c))
}
g.finish(c)
if _, _, err := c.Look(at.Add(time.Hour), []string{"/", "/srv"}, g.search); err != nil {
t.Fatalf("once walked it judges: %v", err)
}
}
// An ask to search again forgets the walk under way, so an engine restarted before the next search does not
// carry on a walk that started before root acted.
func TestAnAskToSearchAgainIsNotLostToARestart(t *testing.T) {
dir := t.TempDir()
paths := func() *SetuidCache {
return &SetuidCache{Path: filepath.Join(dir, SetuidSearchFile), ProgressPath: filepath.Join(dir, SetuidProgressFile)}
}
seen := make(chan *SetuidProgress, 1)
first := paths()
first.Look(at, root, interrupted(seen))
<-seen
first.SearchAgain() // an apply moved root's things; then the engine restarts
restarted := paths()
var with SetuidProgress
restarted.Look(at.Add(time.Hour), root, func(ctx context.Context, p *SetuidProgress, _ func()) ([]string, error) {
with = *p
return nil, nil
})
restarted.Wait()
if !with.Started.Equal(at.Add(time.Hour)) || len(with.Found) != 0 {
t.Fatalf("the walk from before the apply was carried on: %+v", with)
}
}
// A cursor whose name is not UTF-8 is kept as its bytes: JSON strings would change it, and the resumed walk
// would skip what it stood before.
func TestACursorOfAnyBytesIsKeptExactly(t *testing.T) {
p := &SetuidProgress{Started: at, Mounts: Paths{"/"}, Cursor: Cursors{"/": "/home/agent/\x80/x"},
Found: Paths{"/opt/\xff"}}
var back SetuidProgress
fromJSON(t, mustJSON(t, p), &back)
if back.Cursor["/"] != "/home/agent/\x80/x" || back.Found[0] != "/opt/\xff" {
t.Fatalf("kept as %q, %q", back.Cursor["/"], back.Found[0])
}
}
func TestTheCompleteSearchIsKeptAndReadAfterARestart(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, SetuidSearchFile)
c := &SetuidCache{Path: path}
g := newGate()
g.found = []string{"/opt/x"}
c.Look(at, root, g.search)
g.finish(c)
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("kept readable by its owner alone: %v %v", info, err)
}
restarted := &SetuidCache{Path: path}
found, when, err := restarted.Look(at.Add(time.Hour), root, newGate().search)
if err != nil || len(found) != 1 || !when.Equal(at) {
t.Fatalf("a restarted engine judges from the kept search: %v %v %v", found, when, err)
}
restarted.Wait()
// And not once it is too old.
again := &SetuidCache{Path: path}
g2 := newGate()
if _, _, err := again.Look(at.Add(rootsearch.FreshFor), root, g2.search); err == nil {
t.Fatal("a kept search too old to judge judged")
}
g2.finish(again)
}
func TestAKeptSearchNotRootsAloneIsNotRead(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, SetuidSearchFile)
c := &SetuidCache{Path: path}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
for name, spoil := range map[string]func() error{
"writable by others": func() error { return os.Chmod(path, 0o666) },
"a link": func() error {
other := filepath.Join(dir, "elsewhere.json")
if err := os.Rename(path, other); err != nil {
return err
}
return os.Symlink(other, path)
},
"its directory writable by others": func() error { return os.Chmod(dir, 0o777) },
} {
c = &SetuidCache{Path: path}
g = newGate()
c.Look(at, root, g.search)
g.finish(c) // a fresh, good one kept again
os.Chmod(dir, 0o700)
if err := spoil(); err != nil {
t.Fatal(err)
}
var said []string
r := &SetuidCache{Path: path, Say: func(s string) { said = append(said, s) }}
gr := newGate()
if _, _, err := r.Look(at.Add(time.Minute), root, gr.search); err == nil {
t.Fatalf("%s: a kept search that is not root's alone judged", name)
}
if len(said) == 0 {
t.Fatalf("%s: refusing the kept search said nothing", name)
}
gr.finish(r)
os.Chmod(dir, 0o700)
os.Remove(path)
}
}
func TestAKeptSearchFromTheFutureIsNotFresh(t *testing.T) {
c := &SetuidCache{}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
if _, _, err := c.Look(at.Add(-time.Hour), root, g.search); err == nil {
t.Fatal("a clock set back an hour read a search as fresh")
}
g.finish(c)
}
func TestTheIdleRunnerLowersItsCommand(t *testing.T) {
if _, err := exec.LookPath("nice"); err != nil {
t.Skip("no nice here")
}
out, err := Idle(t.Context(), "sh", "-c", "sleep 0.3; nice")
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(out) != "19" {
t.Fatalf("the search's commands run at nice %q, want 19", strings.TrimSpace(out))
}
if _, err := Idle(t.Context(), "sh", "-c", "echo gone >&2; exit 1"); err == nil ||
!strings.HasPrefix(err.Error(), "sh exited 1: gone") {
t.Fatalf("its failure is worded as CLocale's: %v", err)
}
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return raw
}
func fromJSON(t *testing.T, raw []byte, v any) {
t.Helper()
if err := json.Unmarshal(raw, v); err != nil {
t.Fatal(err)
}
}
// interrupted is a search that walks to a cursor, keeps it, and never returns: the engine stopped there.
func interrupted(seen chan<- *SetuidProgress) SetuidSearch {
return func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error) {
p.Found = append(p.Found, "/opt/a")
p.Cursor["/"] = "/opt/a"
checkpoint()
seen <- p
select {} // the engine stops here
}
}
func TestASearchInterruptedByARestartCarriesOnAndIsDatedByItsStart(t *testing.T) {
dir := t.TempDir()
paths := func() *SetuidCache {
return &SetuidCache{Path: filepath.Join(dir, SetuidSearchFile), ProgressPath: filepath.Join(dir, SetuidProgressFile)}
}
seen := make(chan *SetuidProgress, 1)
first := paths()
first.Look(at, root, interrupted(seen))
<-seen
// The engine restarts two hours later.
restarted := paths()
var resumedWith SetuidProgress
done := make(chan struct{})
_, _, err := restarted.Look(at.Add(2*time.Hour), root, func(ctx context.Context, p *SetuidProgress, _ func()) ([]string, error) {
resumedWith = *p
close(done)
return p.Found, nil
})
notJudged(t, err, true)
<-done
restarted.Wait()
if !resumedWith.Started.Equal(at) || resumedWith.Cursor["/"] != "/opt/a" || len(resumedWith.Found) != 1 {
t.Fatalf("the restarted search did not carry on the walk: %+v", resumedWith)
}
found, when, err := restarted.Look(at.Add(2*time.Hour), root, newGate().search)
if err != nil || !when.Equal(at) || len(found) != 1 {
t.Fatalf("the complete search is dated by its start, not its resumption: %v %v %v", found, when, err)
}
if _, err := os.Stat(filepath.Join(dir, SetuidProgressFile)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("the finished walk's progress is still kept: %v", err)
}
}
func TestAWalkTooOldOrAskedAgainStartsOver(t *testing.T) {
for name, c := range map[string]struct {
after time.Duration
again bool
}{"older than it may judge": {rootsearch.FreshFor, false}, "an apply asked for a new one": {time.Hour, true}} {
dir := t.TempDir()
paths := func() *SetuidCache {
return &SetuidCache{Path: filepath.Join(dir, SetuidSearchFile), ProgressPath: filepath.Join(dir, SetuidProgressFile)}
}
seen := make(chan *SetuidProgress, 1)
paths().Look(at, root, interrupted(seen))
<-seen
restarted := paths()
if c.again {
restarted.SearchAgain()
}
var with SetuidProgress
restarted.Look(at.Add(c.after), root, func(ctx context.Context, p *SetuidProgress, _ func()) ([]string, error) {
with = *p
return nil, nil
})
restarted.Wait()
if !with.Started.Equal(at.Add(c.after)) || len(with.Cursor) != 0 || len(with.Found) != 0 {
t.Fatalf("%s: the old walk was carried on: %+v", name, with)
}
}
}
func TestASearchWhosePriorityCannotBeLoweredDoesNotWalk(t *testing.T) {
c := &SetuidCache{Lower: func() error { return errors.New("not permitted") }}
walked := false
c.Look(at, root, func(context.Context, *SetuidProgress, func()) ([]string, error) { walked = true; return nil, nil })
c.Wait()
_, _, err := c.Look(at.Add(time.Minute), root, newGate().search)
notJudged(t, err, false)
if walked {
t.Fatal("it walked at full priority")
}
}
-175
View File
@@ -1,175 +0,0 @@
package accounts
// The walk for setuid programs yields to a busy disk (novox/hq issue 433). It asks before each directory and each
// file it reads, so no burst between two looks is longer than PaceLook, however large a directory. The walk is put in the idle I/O class
// (idle_linux.go), but only a disk scheduler that schedules by class honours that (BFQ does). The control-node's
// disks run mq-deadline, which does not, and there the walk read at full priority: on 2026-10-11 it kept two
// spinning disks saturated for sixteen minutes, the bus whose store shares those disks stalled for half a minute
// twice, and the controller lost its lease each time. So the walk also paces itself by the machine's I/O pressure
// (PSI), which reads the same whatever the scheduler.
//
// It looks every PaceLook at the share of the time since its last look in which some task waited on I/O — from
// the kernel's cumulative stall counter, not its ten-second average, which moves only every two seconds and would
// let the walk read blind that long. Above PaceAbove it pauses, PaceStep at a time, until the share falls below.
//
// **The walk keeps at least a tenth of the time, whatever the pressure** (the review of #79): a run of pauses is
// at most PaceRatio times as long as the walk read since the one before. The control-node's own load sits near
// PaceAbove with no walk running, so a walk that paused for as long as the pressure stayed high would take days,
// outlive the age a search judges for, and leave the machine unjudged — the fault issue 361 repaired. With the
// floor a walk takes at most 1 + PaceRatio times as long as it would unpaced, and its bursts are PaceLook long.
import (
"bufio"
"bytes"
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
)
const (
// PaceAbove is the share of the time since the last look, in percent, in which some task waited on I/O, above
// which the walk pauses.
PaceAbove = 10.0
// PaceLook is how long the walk reads between two looks at the pressure: the longest it reads blind.
PaceLook = 250 * time.Millisecond
// PaceStep is one pause, after which the pressure is looked at again.
PaceStep = 500 * time.Millisecond
// PaceRatio bounds a run of pauses to this many times the time the walk read since the run before: the walk
// keeps at least 1/(1+PaceRatio) of the time.
PaceRatio = 9
// PaceAtMost is the longest run of pauses, whatever the walk read before it.
PaceAtMost = 30 * time.Second
// ioPressureFile is where the kernel says the machine's I/O pressure.
ioPressureFile = "/proc/pressure/io"
)
// pacer is what the walk asks before it reads a directory. A nil pacer never pauses.
type pacer struct {
// path is the pressure file read, kept so a test can see the engine's own walker reads the machine's.
path string
// stalled is the cumulative time some task waited on I/O (PSI's "some total").
stalled func() (time.Duration, error)
sleep func(context.Context, time.Duration) error
now func() time.Time
say func(string)
lastLook time.Time // when the pressure was last looked at
readSince time.Time // when the walk last began to read, after a run of pauses or at its start
prevAt time.Time // the time of the last sample of the counter
prevTotal time.Duration // the counter at that sample
saidBlind bool
}
// newPacer paces by the machine's own I/O pressure, and says through say what it cannot do.
func newPacer(say func(string)) *pacer {
return &pacer{
path: ioPressureFile,
stalled: func() (time.Duration, error) { return readIOStalled(ioPressureFile) },
sleep: sleepCtx,
now: time.Now,
say: say,
}
}
// yield pauses while the machine's I/O pressure is above PaceAbove: looking at most every PaceLook, pausing
// PaceStep at a time, a run of pauses at most PaceRatio times what the walk read since the last run and never more
// than PaceAtMost. It ends early only with ctx, whose error ends the walk as incomplete. Pressure that cannot be
// read pauses nothing, and is said once.
func (pc *pacer) yield(ctx context.Context) error {
if pc == nil || pc.stalled == nil {
return nil
}
now := pc.now()
if pc.readSince.IsZero() {
pc.readSince = now
}
if !pc.lastLook.IsZero() && now.Sub(pc.lastLook) < PaceLook {
return nil
}
pc.lastLook = now
share, known, err := pc.sample(now)
if err != nil || !known || share <= PaceAbove {
return nil
}
budget := min(PaceAtMost, PaceRatio*now.Sub(pc.readSince))
for paused := time.Duration(0); paused < budget; {
step := min(PaceStep, budget-paused)
if err := pc.sleep(ctx, step); err != nil {
return err
}
paused += step
share, known, err = pc.sample(pc.now())
if err != nil || (known && share <= PaceAbove) {
break
}
}
resumed := pc.now()
pc.readSince, pc.lastLook = resumed, resumed
return nil
}
// sample is the share of the time since the last sample, in percent, in which some task waited on I/O; known is
// false for the first sample, which only sets where the next one counts from.
func (pc *pacer) sample(now time.Time) (float64, bool, error) {
total, err := pc.stalled()
if err != nil {
if !pc.saidBlind && pc.say != nil {
pc.say(fmt.Sprintf("the walk for setuid programs cannot read the machine's I/O pressure (%v), so it does "+
"not pace itself: where the disk's scheduler ignores the idle class, it may load the disk at full "+
"priority", err))
}
pc.saidBlind = true
return 0, false, err
}
prevAt, prevTotal := pc.prevAt, pc.prevTotal
pc.prevAt, pc.prevTotal = now, total
elapsed := now.Sub(prevAt)
if prevAt.IsZero() || elapsed <= 0 || total < prevTotal {
return 0, false, nil
}
return 100 * float64(total-prevTotal) / float64(elapsed), true, nil
}
// readIOStalled is the "some total" of a PSI file: the time, cumulative since boot, in which some task waited on I/O.
func readIOStalled(path string) (time.Duration, error) {
b, err := os.ReadFile(path)
if err != nil {
return 0, err
}
return parseIOStalled(b)
}
func parseIOStalled(b []byte) (time.Duration, error) {
sc := bufio.NewScanner(bytes.NewReader(b))
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) == 0 || fields[0] != "some" {
continue
}
for _, f := range fields[1:] {
if v, ok := strings.CutPrefix(f, "total="); ok {
us, err := strconv.ParseUint(v, 10, 64)
if err != nil {
return 0, fmt.Errorf("the I/O pressure's total is not a count of microseconds: %q", v)
}
return time.Duration(us) * time.Microsecond, nil
}
}
}
return 0, fmt.Errorf("the I/O pressure names no \"some\" line with a total")
}
// sleepCtx waits d, or until ctx ends.
func sleepCtx(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
-302
View File
@@ -1,302 +0,0 @@
package accounts
import (
"context"
"errors"
"slices"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/rootsearch"
)
// The walk for setuid programs yields to a busy disk by the machine's I/O pressure (novox/hq issue 433), because
// the idle I/O class it is put in is ignored by a disk scheduler that does not schedule by class, as the
// control-node's mq-deadline does not — and it keeps a tenth of the time whatever the pressure, so it still ends.
// fakePace is a pacer whose stall counter, sleeps and clock a test controls.
type fakePace struct {
// shares is the stall share, in percent, of each interval between two reads of the counter, in turn; the last
// is said again once they run out. The first read only sets the counter's start.
shares []float64
i int
total time.Duration
lastRead time.Time
reads int
clock time.Time
// tick moves the clock on at every look at it, as a walk reading directories takes time; zero does not.
tick time.Duration
slept []time.Duration
said []string
fail error
}
func newFake(shares ...float64) *fakePace {
return &fakePace{shares: shares, clock: time.Unix(1_000_000, 0)}
}
func (f *fakePace) pacer() *pacer {
return &pacer{
stalled: func() (time.Duration, error) {
if f.fail != nil {
return 0, f.fail
}
f.reads++
if !f.lastRead.IsZero() {
s := f.shares[min(f.i, len(f.shares)-1)]
f.i++
f.total += time.Duration(float64(f.clock.Sub(f.lastRead)) * s / 100)
}
f.lastRead = f.clock
return f.total, nil
},
sleep: func(ctx context.Context, d time.Duration) error {
if err := ctx.Err(); err != nil {
return err
}
f.slept = append(f.slept, d)
f.clock = f.clock.Add(d)
return nil
},
now: func() time.Time {
f.clock = f.clock.Add(f.tick)
return f.clock
},
say: func(s string) { f.said = append(f.said, s) },
}
}
func (f *fakePace) paused() time.Duration {
var total time.Duration
for _, d := range f.slept {
total += d
}
return total
}
func TestTheWalkPausesWhileTheDiskIsUnderPressureAndGoesOnWhenItEases(t *testing.T) {
f := newFake(60, 40, 3)
pc := f.pacer()
if err := pc.yield(t.Context()); err != nil { // the first look sets where the counter counts from
t.Fatal(err)
}
f.clock = f.clock.Add(300 * time.Millisecond) // the walk reads
if err := pc.yield(t.Context()); err != nil {
t.Fatal(err)
}
// 60 % since the first look: pause; 40 % during the first pause: pause again; 3 %: go on.
if want := []time.Duration{PaceStep, PaceStep}; !slices.Equal(f.slept, want) {
t.Fatalf("slept %v, want %v", f.slept, want)
}
}
func TestTheFirstLookOnlySetsWhereTheNextCountsFrom(t *testing.T) {
f := newFake(99)
if err := f.pacer().yield(t.Context()); err != nil {
t.Fatal(err)
}
if len(f.slept) != 0 {
t.Fatalf("slept %v on the first look, which has nothing to compare", f.slept)
}
}
func TestTheWalkDoesNotPauseOnAQuietDisk(t *testing.T) {
f := newFake(0.4)
pc := f.pacer()
for range 10 {
if err := pc.yield(t.Context()); err != nil {
t.Fatal(err)
}
f.clock = f.clock.Add(PaceLook)
}
if len(f.slept) != 0 {
t.Fatalf("slept %v on a quiet disk", f.slept)
}
}
// The control-node's own load sits near the bound with no walk running: the walk must still end (issue 361).
func TestTheWalkKeepsATenthOfTheTimeWhateverThePressure(t *testing.T) {
f := newFake(100)
pc := f.pacer()
var read time.Duration
for range 400 {
if err := pc.yield(t.Context()); err != nil {
t.Fatal(err)
}
f.clock = f.clock.Add(PaceLook)
read += PaceLook
}
if f.paused() == 0 {
t.Fatal("never paused under constant pressure")
}
if f.paused() > PaceRatio*read {
t.Fatalf("paused %v while it read %v: more than %d times as long", f.paused(), read, PaceRatio)
}
}
// What the floor promises against the ages a search is judged by: the walk that took 16m31s unpaced on the
// control-node on 2026-10-11, slowed to its worst, still completes well inside the time the controller stays quiet
// while it waits for a search. A guard on the constants only: that the code keeps to the ratio is proven by
// TestTheWalkKeepsATenthOfTheTimeWhateverThePressure.
func TestTheSlowestPacedWalkStillJudgesInTime(t *testing.T) {
const unpaced = 16*time.Minute + 31*time.Second
worst := (1 + PaceRatio) * unpaced
if worst >= rootsearch.Quiet {
t.Fatalf("a walk paced to its worst takes %v, not less than the %v the controller waits quietly", worst,
rootsearch.Quiet)
}
}
func TestARunOfPausesIsAtMostPaceAtMost(t *testing.T) {
f := newFake(100)
pc := f.pacer()
if err := pc.yield(t.Context()); err != nil {
t.Fatal(err)
}
f.clock = f.clock.Add(10 * time.Minute) // read a long time without a look: the ratio would allow 90 minutes
if err := pc.yield(t.Context()); err != nil {
t.Fatal(err)
}
if f.paused() != PaceAtMost {
t.Fatalf("paused %v in a row, want %v", f.paused(), PaceAtMost)
}
}
func TestTheWalkLooksAtThePressureAtMostEveryPaceLook(t *testing.T) {
f := newFake(1)
pc := f.pacer()
for range 5 {
if err := pc.yield(t.Context()); err != nil {
t.Fatal(err)
}
f.clock = f.clock.Add(PaceLook / 10)
}
if f.reads != 1 {
t.Fatalf("read the pressure %d times within %v, want once", f.reads, PaceLook)
}
f.clock = f.clock.Add(PaceLook)
if err := pc.yield(t.Context()); err != nil {
t.Fatal(err)
}
if f.reads != 2 {
t.Fatalf("read the pressure %d times after %v, want twice", f.reads, PaceLook)
}
}
// Pressure that cannot be read pauses nothing, and is said once, with what it means, rather than at every look.
func TestAPressureThatCannotBeReadIsSaidOnce(t *testing.T) {
f := newFake(0)
f.fail = errors.New("no such file")
pc := f.pacer()
for range 3 {
if err := pc.yield(t.Context()); err != nil {
t.Fatal(err)
}
f.clock = f.clock.Add(PaceLook)
}
if len(f.slept) != 0 || len(f.said) != 1 {
t.Fatalf("slept %v, said %q; want no pause and one line", f.slept, f.said)
}
if !strings.Contains(f.said[0], "full priority") {
t.Errorf("the line does not say what it means for the disk: %q", f.said[0])
}
}
// The engine's own walk is paced by the machine's pressure, and says through the search's own channel.
func TestTheEnginesWalkIsPacedByTheMachinesPressure(t *testing.T) {
var said []string
w := engineWalker(nil, func(s string) { said = append(said, s) })
if w.pace == nil || w.pace.path != ioPressureFile || w.pace.stalled == nil {
t.Fatalf("the engine's walker is not paced by %s: %+v", ioPressureFile, w.pace)
}
w.pace.say("x")
if len(said) != 1 {
t.Fatal("the engine's walker does not say through the search's channel")
}
}
// many is a tree of enough directories that a walk reaches a look at the pressure partway.
func many(t *testing.T) string {
names := []string{"bin/", "bin/su!", "bin/ls", "sbin/", "sbin/mount!", "usr/lib/helper%"}
for _, d := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
names = append(names, "usr/share/"+d+"/")
}
return tree(t, names...)
}
// A walk ended while it pauses is an incomplete walk, never one that found nothing.
func TestAWalkEndedWhileItPausesIsIncomplete(t *testing.T) {
top := many(t)
f := newFake(80)
f.tick = 100 * time.Millisecond
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
pc := f.pacer()
// The engine stops while the walk waits for the disk: the context ends inside the pause, not before the walk.
pc.sleep = func(ctx context.Context, d time.Duration) error {
f.slept = append(f.slept, d)
cancel()
return ctx.Err()
}
w := mine()
w.pace = pc
err := w.walk(ctx, &SetuidProgress{Mounts: []string{top}}, func() {})
if len(f.slept) == 0 {
t.Fatal("the walk never paused, so this proves nothing about a walk ended while it pauses")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("a walk ended while it paused answered %v", err)
}
}
// Paced, the walk finds what it finds unpaced: pausing changes when, never what.
func TestAPacedWalkFindsWhatAnUnpacedWalkFinds(t *testing.T) {
top := many(t)
plain := walkAll(t, mine(), top)
f := newFake(80, 2)
f.tick = 100 * time.Millisecond
w := mine()
w.pace = f.pacer()
paced := walkAll(t, w, top)
if !slices.Equal(plain, paced) {
t.Fatalf("paced walk found %v, unpaced %v", paced, plain)
}
if len(f.slept) == 0 {
t.Fatal("the walk read its directories without asking the pacer: it never paused under pressure")
}
}
func TestTheIOPressureIsReadAsTheKernelWritesIt(t *testing.T) {
got, err := parseIOStalled([]byte("some avg10=26.24 avg60=20.07 avg300=27.47 total=123456789\n" +
"full avg10=21.90 avg60=15.77 avg300=22.47 total=98765432\n"))
if err != nil || got != 123456789*time.Microsecond {
t.Fatalf("read %v, %v; want 123456789 µs", got, err)
}
for _, bad := range []string{"", "full avg10=1.00 avg60=0 avg300=0 total=1\n", "some avg10=1 total=x\n"} {
if _, err := parseIOStalled([]byte(bad)); err == nil {
t.Errorf("%q was read as a pressure", bad)
}
}
}
// One directory of many files is paced as it is read, not only between directories: a file's own details may be
// read from the disk too.
func TestADirectoryOfManyFilesIsPacedAsItIsRead(t *testing.T) {
names := []string{"bin/"}
for i := range 20 {
names = append(names, "bin/f"+strings.Repeat("x", i+1))
}
top := tree(t, names...)
f := newFake(80, 2)
f.tick = 100 * time.Millisecond
w := mine()
w.pace = f.pacer()
walkAll(t, w, top)
// Two directories alone would be looked at once, 200 ms apart, within one look: no pause at all.
if len(f.slept) == 0 {
t.Fatal("a directory of twenty files was read without a pause under pressure")
}
}
-218
View File
@@ -1,218 +0,0 @@
package accounts
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// How far a running search got is readable through the verdict (novox/hq issue 361): counts, times and mount
// points, counted once across a resume, and never a path the walk visited or found.
// A walk stopped midway and resumed counts every entry once, as the walk that was never stopped does, and says
// how long each filesystem took.
func TestTheProgressCountsEachEntryOnceAcrossAResume(t *testing.T) {
top := tree(t, "a/1!", "a/2", "a-b/3!", "b/c/4!", "b/c/5", "b/d!", "c/6!", "d/7!")
other := tree(t, "x/1", "y!")
whole := &SetuidProgress{Mounts: []string{top, other}}
if err := mine().walk(t.Context(), whole, func() {}); err != nil {
t.Fatal(err)
}
// top and the 14 entries below it, and other and its 3.
if whole.Visited != 15+4 || len(whole.Took) != 2 || whole.Took[0] < 0 || whole.Walking != 0 {
t.Fatalf("the whole walk: visited %d, took %v, walking %v", whole.Visited, whole.Took, whole.Walking)
}
w := mine()
w.every = 0
ctx, stop := context.WithCancel(t.Context())
p := &SetuidProgress{Mounts: []string{top, other}, Counted: true}
var kept []byte
err := w.walk(ctx, p, func() {
kept = mustJSON(t, p)
if strings.HasSuffix(p.Cursor[top], "/b/c/4") {
stop()
}
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("the walk was stopped: %v", err)
}
var resumed SetuidProgress
fromJSON(t, kept, &resumed)
if resumed.Visited == 0 || resumed.Visited >= whole.Visited || resumed.Walking < 0 {
t.Fatalf("the kept walk counted %d of %d", resumed.Visited, whole.Visited)
}
if err := mine().walk(t.Context(), &resumed, func() {}); err != nil {
t.Fatal(err)
}
if resumed.Visited != whole.Visited || len(resumed.Took) != 2 || resumed.Took[0] < 0 {
t.Fatalf("the resumed walk counted %d entries (the whole one %d), took %v", resumed.Visited,
whole.Visited, resumed.Took)
}
}
// A walk kept before the times were counted has them not known, never shifted onto the next filesystem, and
// its entries visited are a lower bound; the filesystem it was part-way through took a time not known.
func TestAWalkKeptWithoutTimesSaysThemNotKnown(t *testing.T) {
top, other := tree(t, "a"), tree(t, "b")
p := &SetuidProgress{Mounts: []string{top, other}, Done: Paths{top}}
if err := mine().walk(t.Context(), p, func() {}); err != nil {
t.Fatal(err)
}
if len(p.Took) != 2 || p.Took[0] >= 0 || p.Took[1] < 0 {
t.Fatalf("took %v", p.Took)
}
if w := progressOf(p, time.Time{}).Words(); !strings.Contains(w, top+" in a time not known") ||
!strings.Contains(w, "at least ") {
t.Fatalf("the words: %s", w)
}
}
// A look while the search runs says how far it got at its last checkpoint, with no path it visited or found.
func TestALookSaysHowFarTheRunningSearchGotAndNoPath(t *testing.T) {
dir := t.TempDir()
c := &SetuidCache{ProgressPath: filepath.Join(dir, SetuidProgressFile)}
secret := "/home/someone/private/rootshell"
checkpointed, release := make(chan struct{}), make(chan struct{})
search := func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error) {
p.Mounts = Paths{"/", "/home", "/boot"}
p.Done, p.Took = Paths{"/"}, []time.Duration{90 * time.Minute}
p.Cursor["/home"] = "/home/someone/private"
p.Found = Paths{"/usr/bin/odd", secret}
p.Visited = 1234567
checkpoint()
close(checkpointed)
<-release
return nil, nil
}
_, _, err := c.Look(at, root, search)
nj := notJudged(t, err, true)
if nj.Progress == nil || nj.Progress.Visited != 0 || nj.Progress.Walking != "/" {
t.Fatalf("before the first checkpoint: %+v", nj.Progress)
}
<-checkpointed
_, _, err = c.Look(at.Add(time.Minute), root, search)
nj = notJudged(t, err, true)
sp := nj.Progress
if sp == nil || sp.Filesystems != 3 || sp.Done != 1 || sp.Walking != "/home" || sp.Visited != 1234567 ||
sp.Found != 2 || sp.Kept.IsZero() || len(sp.Finished) != 1 || sp.Finished[0].Took != 90*time.Minute {
t.Fatalf("the progress: %+v", sp)
}
words := sp.Words()
for _, want := range []string{"1 of 3 filesystems walked", "walking /home", "1234567 entries visited",
"2 setuid- or setgid-root files found so far", "kept " + sp.Kept.Local().Format("2006-01-02 15:04:05"),
"walked / in 1h30m0s"} {
if !strings.Contains(words, want) {
t.Errorf("the words lack %q: %s", want, words)
}
}
for _, leak := range []string{"someone", "private", "odd", "rootshell"} {
if strings.Contains(words, leak) || strings.Contains(err.Error(), leak) {
t.Errorf("a path leaks (%q): %s / %v", leak, words, err)
}
}
close(release)
c.Wait()
if _, _, err := c.Look(at.Add(2*time.Minute), root, search); err != nil {
t.Fatalf("the complete search judges: %v", err)
}
}
// The progress moves every minute; the verdict does not change for it, so the journal says it once.
type waiting struct{ progress *SearchProgress }
func (w *waiting) InDatabase(context.Context, string) ([]string, error) { return nil, nil }
func (w *waiting) Session(context.Context, string, []string) (Session, error) {
return Session{}, nil
}
func (w *waiting) Escalation(context.Context, string, []string) ([]string, error) {
return nil, &SetuidNotJudged{Pending: true, Since: at, Progress: w.progress}
}
func TestTheProgressMovesWithoutChangingTheVerdict(t *testing.T) {
r := &waiting{progress: &SearchProgress{Filesystems: 2, Visited: 10}}
j := New(r)
j.Set([]Account{{Module: "claude-code", ID: "claude-code.agent", Name: "agent", Root: true}})
st, changed := j.Look(t.Context())
if !changed || st.Accounts[0].State != Unknown || st.Accounts[0].Search.Visited != 10 {
t.Fatalf("first look: changed %v, %+v", changed, st.Accounts[0])
}
r.progress = &SearchProgress{Filesystems: 2, Visited: 20}
st, changed = j.Look(t.Context())
if changed || st.Accounts[0].Search.Visited != 20 || strings.Contains(st.Accounts[0].Reason, "progress") {
t.Fatalf("second look: changed %v, %+v", changed, st.Accounts[0])
}
}
// Asked at the terminal, a search starts afresh at the next look: the last complete one is thrown away, kept or
// in memory, and a running one is ended and neither judges nor is carried on.
func TestSearchNowThrowsAwayTheLastSearchAndStartsAFullOne(t *testing.T) {
dir := t.TempDir()
c := &SetuidCache{Path: filepath.Join(dir, SetuidSearchFile), ProgressPath: filepath.Join(dir, SetuidProgressFile)}
g := newGate()
g.found = []string{"/home/someone/rootshell"}
c.Look(at, root, g.search)
<-g.started
g.finish(c)
if found, _, err := c.Look(at.Add(time.Hour), root, g.search); err != nil || len(found) != 1 {
t.Fatalf("the complete search judges: %v %v", found, err)
}
said := c.SearchNow("jo at the controller's terminal")
if !strings.Contains(said, "thrown away") || strings.Contains(said, "someone") {
t.Fatalf("said: %s", said)
}
if _, err := os.Stat(c.Path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("the kept search is still there: %v", err)
}
// The next look starts a full search at once, though the last ended only an hour ago.
ended := make(chan error, 1)
running := func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error) {
if len(p.Done) != 0 || len(p.Cursor) != 0 {
t.Errorf("not a full search: %+v", p)
}
checkpoint()
<-ctx.Done()
ended <- ctx.Err()
return nil, ctx.Err()
}
_, _, err := c.Look(at.Add(time.Hour+time.Minute), root, running)
notJudged(t, err, true)
if starts(c) != 2 {
t.Fatalf("searches started: %d", starts(c))
}
// Asked again while it runs: it is ended, judges nothing, fails nothing, and is not carried on.
c.SearchNow("jo")
if err := <-ended; !errors.Is(err, context.Canceled) {
t.Fatalf("the running search was not ended: %v", err)
}
c.Wait()
if _, err := os.Stat(c.ProgressPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("the ended walk is still kept: %v", err)
}
g2 := newGate()
_, _, err = c.Look(at.Add(time.Hour+2*time.Minute), root, g2.search)
notJudged(t, err, true)
<-g2.started
g2.finish(c)
if starts(c) != 3 {
t.Fatalf("searches started: %d", starts(c))
}
if _, _, err := c.Look(at.Add(time.Hour+3*time.Minute), root, g2.search); err != nil {
t.Fatalf("the new search judges: %v", err)
}
}
func TestAFilesystemPartWalkedByAnEngineThatDidNotCountTookATimeNotKnown(t *testing.T) {
top := tree(t, "a", "b")
p := &SetuidProgress{Mounts: []string{top}, Cursor: Cursors{top: filepath.Join(top, "a")}}
if err := mine().walk(t.Context(), p, func() {}); err != nil {
t.Fatal(err)
}
if len(p.Took) != 1 || p.Took[0] >= 0 {
t.Fatalf("took %v", p.Took)
}
}
-115
View File
@@ -1,115 +0,0 @@
package accounts
import (
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
)
// A setuid program no package owns counts against an account only where it can execute it, read at every
// judgement from the modes on the way (novox/hq issue 361): the operator's copies in a home of mode 0700 are no
// way for the agent account, and become one the moment the home is opened, without a new search.
const homeFind = "/home/op/usr/bin/mount"
func homeMachine(home FileMode) *agentMachine {
m := clean()
m.setuid = homeFind + "\n"
m.nodes = map[string]FileMode{"/home/op": home}
return m
}
func TestReachabilityDecidesWhetherAFindCounts(t *testing.T) {
op := func(perm fs.FileMode) FileMode { return FileMode{UID: 1500, GID: 1500, Perm: fs.ModeDir | perm} }
for _, c := range []struct {
name string
set func(*agentMachine)
counted bool
}{
{"a home of 0700", func(m *agentMachine) {}, false},
{"a home of 0755", func(m *agentMachine) { m.nodes["/home/op"] = op(0o755) }, true},
{"a home its group may search, the agent in it", func(m *agentMachine) {
m.nodes["/home/op"], m.gids = op(0o710), "1600 1500"
}, true},
{"a home its group may search, the agent not in it", func(m *agentMachine) { m.nodes["/home/op"] = op(0o710) }, false},
{"an ACL on the home", func(m *agentMachine) {
m.files["/home/op"] = FileMode{UID: 1500, GID: 1500, Perm: 0o700}
m.acls = map[string][]ACLEntry{"/home/op": {{}}}
}, true},
{"a link on the way", func(m *agentMachine) {
m.nodes["/home/op"] = FileMode{UID: 1500, GID: 1500, Perm: fs.ModeSymlink | 0o777}
}, true},
{"a file the agent may not execute", func(m *agentMachine) {
m.nodes["/home/op"] = op(0o755)
m.nodes[homeFind] = FileMode{Perm: fs.ModeSetuid | 0o4750}
}, false},
} {
t.Run(c.name, func(t *testing.T) {
m := homeMachine(FileMode{UID: 1500, GID: 1500, Perm: fs.ModeDir | 0o700})
c.set(m)
v := lookAgent(t, m)
if c.counted != (v.State == Unhealthy) {
t.Fatalf("counted %v: %+v", c.counted, v)
}
if strings.Contains(v.Reason, "/home/op") {
t.Fatalf("a path in a home was said: %s", v.Reason)
}
if c.counted && !strings.Contains(v.Reason, "a setuid-root program no package owns in an account's home, which agent can execute") {
t.Fatalf("the reason: %s", v.Reason)
}
})
}
}
// The search keeps every find; whether one counts is read again at every look.
func TestAHomeOpenedAfterTheSearchCountsWithoutANewSearch(t *testing.T) {
m := homeMachine(FileMode{UID: 1500, GID: 1500, Perm: fs.ModeDir | 0o700})
c := &SetuidCache{}
j := New(Exec{Run: m.run, Walk: m.walk, Stat: m.stat, ReadFile: m.readFile, ReadDir: m.readDir, ACL: m.acl,
Lstat: m.lstat, Cache: c})
j.Set([]Account{agent})
j.Look(t.Context())
c.Wait()
if st, _ := j.Look(t.Context()); st.Accounts[0].State != Healthy {
t.Fatalf("a find in a closed home: %+v", st.Accounts[0])
}
m.nodes["/home/op"] = FileMode{UID: 1500, GID: 1500, Perm: fs.ModeDir | 0o755}
st, _ := j.Look(t.Context())
if st.Accounts[0].State != Unhealthy || starts(c) != 1 {
t.Fatalf("the home opened: %+v, %d searches", st.Accounts[0], starts(c))
}
m.nodes["/home/op"] = FileMode{UID: 1500, GID: 1500, Perm: fs.ModeDir | 0o700}
if st, _ := j.Look(t.Context()); st.Accounts[0].State != Healthy || starts(c) != 1 {
t.Fatalf("the home closed again: %+v", st.Accounts[0])
}
}
// On the machine's own modes, as their owner (the directories above a test's are its own): a directory without
// its owner's search bit hides what is in it.
func TestExecutableReadsTheMachinesModes(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("root searches every directory")
}
top := tree(t, "home/bin/x!")
home := filepath.Join(top, "home")
me := os.Getuid()
noACL := func(string) ([]ACLEntry, error) { return nil, nil }
if err := os.Chmod(home, 0o600); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(home, 0o700) })
if Executable(filepath.Join(home, "bin/x"), me, map[int]bool{}, lstatOf, noACL) {
t.Fatal("a file in a directory its account may not search was executable")
}
if err := os.Chmod(home, 0o700); err != nil {
t.Fatal(err)
}
if !Executable(filepath.Join(home, "bin/x"), me, map[int]bool{}, lstatOf, noACL) {
t.Fatal("a file every directory opens was not executable")
}
if Executable(filepath.Join(home, "bin/gone"), me, map[int]bool{}, lstatOf, noACL) {
t.Fatal("a file gone was executable")
}
}
-313
View File
@@ -1,313 +0,0 @@
package accounts
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"syscall"
"time"
)
// The walk for setuid programs, resumable (novox/hq issue 361).
//
// The walk is the engine's own, in Go rather than find, so it can say how far it got: filepath.WalkDir visits
// every filesystem's tree depth first, each directory's entries in lexical order, so the walk's progress is one
// cursor per filesystem — the last path it visited — and everything before the cursor in that order is done.
// The progress is kept every CheckpointEvery beside the engine's state (SetuidCache), so an engine restarted,
// updated or rebooted midway carries on from its cursor rather than starting over: on the control-node the
// whole walk takes far longer than an engine runs between updates.
//
// **Why a resumed walk is sound.** Only root can make a setuid- or setgid-root file: the agent account can
// neither set the bit on root's file nor give its own file to root. A file that appears behind the cursor while
// the walk runs, or while it waits to be resumed, was made by root — and root's acts through the mesh start a
// new search (apply.MovesRoot), whose walk starts from nothing. A walk is dated by its start, and one resumed
// more than FreshFor after its start is thrown away and begun again.
// SetuidProgress is one walk, as far as it got.
type SetuidProgress struct {
// ID names the walk; Started is when it began, on the cache's clock: the search is dated by it.
ID string `json:"id"`
Started time.Time `json:"started"`
// Mounts are the filesystems it walks, root first; Done those walked to their end.
Mounts Paths `json:"mounts"`
Done Paths `json:"done"`
// Cursor is, for a filesystem being walked, the last path visited.
Cursor Cursors `json:"cursor"`
// Found is every setuid- or setgid-root regular file found so far, before the package manager is asked.
Found Paths `json:"found"`
// Visited is how many entries the walk has gone past: each once, across a resume, as it is kept with the
// cursor it matches.
Visited int64 `json:"visited"`
// Took is how long the walk spent in each of Done, in its order (negative: not known, a walk kept by an
// engine before it counted); Walking how long it has spent in the filesystem it walks now. Time the
// engine did not run is in neither.
Took []time.Duration `json:"took"`
Walking time.Duration `json:"walking"`
// Kept is when this progress was kept, as the cache wrote it; zero before the first time.
Kept time.Time `json:"kept"`
// Counted is a walk counted from its start: false for one kept by an engine before it counted, whose
// Visited is then a lower bound and whose filesystem under way took a time not known (the review of
// 2026-10-10).
Counted bool `json:"counted"`
}
// Paths are paths kept as their bytes. **Never as JSON strings**: encoding/json replaces bytes that are not
// UTF-8 with U+FFFD, and a cursor so changed sorts after the names it stood before — a resumed walk would skip
// them unread (the review of 2026-10-10). A name of any bytes is any account's to make.
type Paths []string
func (ps Paths) MarshalJSON() ([]byte, error) {
raw := make([][]byte, len(ps))
for i, p := range ps {
raw[i] = []byte(p)
}
return json.Marshal(raw)
}
func (ps *Paths) UnmarshalJSON(b []byte) error {
var raw [][]byte
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
*ps = make(Paths, len(raw))
for i, r := range raw {
(*ps)[i] = string(r)
}
return nil
}
// Cursors are each filesystem's cursor, kept as bytes as Paths are.
type Cursors map[string]string
type keptCursor struct {
Mount []byte `json:"mount"`
Path []byte `json:"path"`
}
func (cs Cursors) MarshalJSON() ([]byte, error) {
raw := []keptCursor{}
for m, p := range cs {
raw = append(raw, keptCursor{Mount: []byte(m), Path: []byte(p)})
}
slices.SortFunc(raw, func(a, b keptCursor) int { return strings.Compare(string(a.Mount), string(b.Mount)) })
return json.Marshal(raw)
}
func (cs *Cursors) UnmarshalJSON(b []byte) error {
var raw []keptCursor
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
*cs = Cursors{}
for _, r := range raw {
(*cs)[string(r.Mount)] = string(r.Path)
}
return nil
}
// CheckpointEvery is how often a walk's progress is kept.
const CheckpointEvery = time.Minute
// prunedPaths are never walked: the kernel's own trees, and the container runtimes' layers.
var prunedPaths = []string{"/proc", "/sys", "/var/lib/docker", "/var/lib/containers"}
// walker walks for setuid programs. rootUID and rootGID are the owner and group a match has: root's (0), but
// for a test. mountPoints are every filesystem mounted on the machine (MountPoints): a directory that is one is
// another filesystem, left without being read — a FUSE mount root cannot read, or a network mount whose
// server is away, would fail or hang the walk (the review of 2026-10-10).
type walker struct {
rootUID, rootGID uint32
every time.Duration
mountPoints map[string]bool
// pace is asked before each directory is read (setuid_pace.go); nil never pauses.
pace *pacer
}
// walk carries p on to its end, calling checkpoint every w.every and after each filesystem. It ends early only
// with an error — a directory it could not read, or ctx ended — which is an incomplete walk, never "none".
// A file or directory that vanished while it walked is not an error: what still exists was walked.
func (w walker) walk(ctx context.Context, p *SetuidProgress, checkpoint func()) error {
if p.Cursor == nil {
p.Cursor = Cursors{}
}
// One Took for each of Done: a walk kept before they were counted has them not known.
for len(p.Took) < len(p.Done) {
p.Took = append(p.Took, -1)
}
p.Took = p.Took[:len(p.Done)]
if !p.Counted && len(p.Cursor) > 0 {
p.Walking = -1 // walked partly by an engine that did not count its time
}
last := time.Now()
for _, m := range p.Mounts {
if slices.Contains(p.Done, m) {
continue
}
if err := w.walkMount(ctx, p, m, checkpoint, &last); err != nil {
return err
}
p.Done = append(p.Done, m)
p.Took = append(p.Took, p.Walking)
p.Walking = 0
delete(p.Cursor, m)
checkpoint()
last = time.Now()
}
// Looked at again: a file found that is gone, or no longer setuid-root, is no answer the package manager
// can give, and would fail every search that resumes this walk (the review of 2026-10-10).
var still Paths
for _, f := range p.Found {
info, err := os.Lstat(f)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return fmt.Errorf("the walk for setuid programs could not read %s again: %w", f, err)
}
if w.setuidRoot(info) {
still = append(still, f)
}
}
slices.Sort(still)
p.Found = slices.Compact(still)
return nil
}
func (w walker) walkMount(ctx context.Context, p *SetuidProgress, mount string, checkpoint func(), last *time.Time) error {
top, err := os.Lstat(mount)
if err != nil {
return fmt.Errorf("the filesystem at %s could not be read: %w", mount, err)
}
dev, ok := deviceOf(top)
if !ok || !top.IsDir() {
return fmt.Errorf("the filesystem at %s is not a directory whose device could be read", mount)
}
var cursor []string
if c := p.Cursor[mount]; c != "" {
cursor = components(c)
}
// How long it walks this filesystem, carried on from what a resumed walk had spent in it.
began, before := time.Now(), p.Walking
spent := func() time.Duration {
if before < 0 {
return -1
}
return before + time.Since(began)
}
defer func() { p.Walking = spent() }()
return filepath.WalkDir(mount, func(path string, d fs.DirEntry, err error) error {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil // vanished while it walked
}
return fmt.Errorf("the walk for setuid programs could not read %s: %w", path, err)
}
at := components(path)
if cursor != nil && walkOrder(at, cursor) < 0 && !isPrefix(at, cursor) {
// Before the cursor and not on its way: visited by the walk this resumes.
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
switch {
case d.IsDir():
if path != mount && (slices.Contains(prunedPaths, path) || w.mountPoints[path]) {
return filepath.SkipDir
}
info, err := os.Lstat(path)
if errors.Is(err, fs.ErrNotExist) {
return filepath.SkipDir
}
if err != nil {
return fmt.Errorf("the walk for setuid programs could not read %s: %w", path, err)
}
if d, ok := deviceOf(info); !ok || d != dev {
return filepath.SkipDir // another filesystem, walked as its own or not at all (find's -xdev)
}
// Before its entries are read: a busy disk is waited for here (novox/hq issue 433).
if err := w.pace.yield(ctx); err != nil {
return err
}
case d.Type().IsRegular():
// A file's own details may be read from the disk too: a directory of many files is paced as it is read.
if err := w.pace.yield(ctx); err != nil {
return err
}
info, err := d.Info() // the entry's own, never through a link
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("the walk for setuid programs could not read %s: %w", path, err)
}
if w.setuidRoot(info) {
p.Found = append(p.Found, path)
}
}
// The cursor only moves on: the directories on the way back to it are visited again, and are before it.
// Counted here alone, so an entry visited again on the way back to the cursor is counted once.
if cursor == nil || walkOrder(at, cursor) > 0 {
p.Cursor[mount], cursor = path, at
p.Visited++
}
if time.Since(*last) >= w.every {
p.Walking = spent()
checkpoint()
*last = time.Now()
}
return nil
})
}
// setuidRoot is a regular file setuid and owned by root, or setgid to root's group.
func (w walker) setuidRoot(info fs.FileInfo) bool {
st, ok := info.Sys().(*syscall.Stat_t)
if !ok || !info.Mode().IsRegular() {
return false
}
return uint32(st.Uid) == w.rootUID && info.Mode()&fs.ModeSetuid != 0 ||
uint32(st.Gid) == w.rootGID && info.Mode()&fs.ModeSetgid != 0
}
func deviceOf(info fs.FileInfo) (uint64, bool) {
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return uint64(st.Dev), true
}
// components are a path's names, in order; "/" has none.
func components(path string) []string {
path = strings.Trim(filepath.Clean(path), "/")
if path == "" {
return nil
}
return strings.Split(path, "/")
}
// walkOrder compares two paths in the order WalkDir visits them: name by name, each in lexical order, a
// directory before what is in it.
func walkOrder(a, b []string) int {
for i := 0; i < len(a) && i < len(b); i++ {
if c := strings.Compare(a[i], b[i]); c != 0 {
return c
}
}
return len(a) - len(b)
}
// isPrefix says a is b or a directory b is in.
func isPrefix(a, b []string) bool {
return len(a) <= len(b) && slices.Equal(a, b[:len(a)])
}
-213
View File
@@ -1,213 +0,0 @@
package accounts
import (
"context"
"errors"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
)
// The engine's own walk for setuid programs (novox/hq issue 361): what it finds, what it never follows, and that
// a walk interrupted carries on from its cursor.
// tree makes files under a directory of its own: a name ending in "/" a directory, "!" setuid, "%" setgid.
func tree(t *testing.T, names ...string) string {
t.Helper()
top := t.TempDir()
for _, n := range names {
mode := os.FileMode(0o755)
switch {
case strings.HasSuffix(n, "/"):
if err := os.MkdirAll(filepath.Join(top, n), 0o755); err != nil {
t.Fatal(err)
}
continue
case strings.HasSuffix(n, "!"):
n, mode = strings.TrimSuffix(n, "!"), mode|os.ModeSetuid
case strings.HasSuffix(n, "%"):
n, mode = strings.TrimSuffix(n, "%"), mode|os.ModeSetgid
}
p := filepath.Join(top, n)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(p, mode); err != nil {
t.Fatal(err)
}
}
return top
}
// mine is a walker for which this test's own account is root.
func mine() walker {
return walker{rootUID: uint32(os.Getuid()), rootGID: uint32(os.Getgid()), every: time.Hour}
}
func walkAll(t *testing.T, w walker, top string) []string {
t.Helper()
p := &SetuidProgress{Mounts: []string{top}}
if err := w.walk(t.Context(), p, func() {}); err != nil {
t.Fatal(err)
}
return rel(top, p.Found)
}
func rel(top string, paths []string) []string {
var out []string
for _, p := range paths {
out = append(out, strings.TrimPrefix(p, top+"/"))
}
return out
}
func TestTheWalkFindsSetuidAndSetgidRootFilesAndFollowsNoLink(t *testing.T) {
top := tree(t, "bin/plain", "bin/rootshell!", "sbin/group%", "deep/a/b/c/x!")
if err := os.Symlink(filepath.Join(top, "bin/rootshell"), filepath.Join(top, "link")); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(top, "bin"), filepath.Join(top, "linkdir")); err != nil {
t.Fatal(err)
}
found := walkAll(t, mine(), top)
if strings.Join(found, ",") != "bin/rootshell,deep/a/b/c/x,sbin/group" {
t.Fatalf("found %v", found)
}
if got := walkAll(t, walker{rootUID: 4242424, rootGID: 4242424, every: time.Hour}, top); len(got) != 0 {
t.Fatalf("files nobody's root owns were found: %v", got)
}
}
func TestAWalkThatCannotReadADirectoryIsIncomplete(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root reads every directory")
}
top := tree(t, "open/x!", "closed/y!")
if err := os.Chmod(filepath.Join(top, "closed"), 0); err != nil {
t.Fatal(err)
}
defer os.Chmod(filepath.Join(top, "closed"), 0o755)
p := &SetuidProgress{Mounts: []string{top}}
if err := mine().walk(t.Context(), p, func() {}); err == nil {
t.Fatal("a walk that could not read a directory was complete")
}
}
func TestWalkOrderIsNameByName(t *testing.T) {
// "/a/b" is walked before "/a-c" although '-' sorts before '/': the walk goes name by name.
if walkOrder(components("/a/b"), components("/a-c")) >= 0 {
t.Fatal("/a/b is walked before /a-c")
}
if walkOrder(components("/a"), components("/a/b")) >= 0 || !isPrefix(components("/a"), components("/a/b")) {
t.Fatal("a directory is walked before what is in it")
}
if len(components("/")) != 0 {
t.Fatal("/ has no names")
}
}
// A walk stopped midway carries on from its cursor: what was before the cursor is not walked again, what is after
// it is, and every file of the first walk is found.
func TestAnInterruptedWalkCarriesOnFromItsCursor(t *testing.T) {
top := tree(t, "a/1!", "a/2", "a-b/3!", "b/c/4!", "b/c/5", "b/d!", "c/6!", "d/7!")
w := mine()
w.every = 0 // a checkpoint at every path
ctx, stop := context.WithCancel(t.Context())
p := &SetuidProgress{Mounts: []string{top}}
var kept []byte
visits := 0
err := w.walk(ctx, p, func() {
visits++
kept = mustJSON(t, p)
if strings.HasSuffix(p.Cursor[top], "/b/c/4") {
stop() // the engine stops here
}
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("the walk was stopped: %v", err)
}
var resumed SetuidProgress
fromJSON(t, kept, &resumed)
if !strings.HasSuffix(resumed.Cursor[top], "/b/c/4") {
t.Fatalf("the kept cursor is %q", resumed.Cursor[top])
}
// Meanwhile, a file appears before the cursor and one after it.
for _, n := range []string{"a/late!", "e/late!"} {
tree2(t, top, n)
}
w.every = time.Hour
if err := w.walk(t.Context(), &resumed, func() {}); err != nil {
t.Fatal(err)
}
got := rel(top, resumed.Found)
want := []string{"a-b/3", "a/1", "b/c/4", "b/d", "c/6", "d/7", "e/late"}
if !slices.Equal(got, want) {
t.Fatalf("the resumed walk found %v, want %v: the first walk's finds kept, nothing before the cursor "+
"walked again, everything after it walked", got, want)
}
}
func tree2(t *testing.T, top, n string) {
t.Helper()
p := filepath.Join(top, strings.TrimSuffix(n, "!"))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(p, 0o755|os.ModeSetuid); err != nil {
t.Fatal(err)
}
}
// A search's commands and walk run on a lowered thread while the rest of the engine keeps its priority.
func TestTheSearchRunsOnALoweredThreadOfItsOwn(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)
var err error
go func() {
defer wg.Done()
err = LowerThisThread()
}()
wg.Wait()
if err != nil {
t.Fatalf("the thread could not be lowered: %v", err)
}
}
// Another filesystem's mount point is left without being read, and a file found that is gone, or no longer
// setuid-root, when the walk ends is not asked about.
func TestTheWalkLeavesMountPointsAndDropsWhatWentMeanwhile(t *testing.T) {
top := tree(t, "fuse/secret!", "bin/x!", "bin/y!")
w := mine()
w.mountPoints = map[string]bool{filepath.Join(top, "fuse"): true}
w.every = 0
p := &SetuidProgress{Mounts: []string{top}}
removed := false
if err := w.walk(t.Context(), p, func() {
if !removed && strings.HasSuffix(p.Cursor[top], "/bin/y") {
removed = true
os.Remove(filepath.Join(top, "bin/x"))
}
}); err != nil {
t.Fatal(err)
}
if got := rel(top, p.Found); strings.Join(got, ",") != "bin/y" {
t.Fatalf("found %v", got)
}
}
func TestAMountPointIsReadAsTheKernelEscapesIt(t *testing.T) {
mp := MountPoints("/dev/a /mnt/a\\040b ext4 rw 0 0\n/dev/b /mnt/back\\134slash ext4 rw 0 0\n")
if !mp["/mnt/a b"] || !mp[`/mnt/back\slash`] {
t.Fatalf("read %v", mp)
}
}
-810
View File
@@ -1,810 +0,0 @@
package accounts
// The ways to root the judge looks for beyond uid, groups and sudo (novox/hq ADR 0266, the review of
// 2026-10-08), and the ones it does not. Judged and NotJudged are the one written list: the record quotes it,
// and a way added here is a line added there.
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"slices"
"sort"
"strings"
"time"
"golang.org/x/sys/unix"
)
// Judged is every way to root the judge looks for, in the words its reasons use.
var Judged = []string{
"its uid is 0",
"membership of a group that grants root by membership: " + strings.Join(RootGroups, ", "),
"any sudo rule for it or one of its groups, as `sudo -l -U` lists it (with or without a password)",
"any doas rule permitting it or one of its groups (/etc/doas.conf, /etc/opendoas.conf)",
"any polkit rule naming it or one of its groups (/etc/polkit-1/rules.d, /usr/share/polkit-1/rules.d, " +
"/etc/polkit-1/localauthority), which is how pkexec and systemd's own actions are granted",
"a polkit rule that grants every account: a rule of a .rules file that answers polkit.Result.YES and names " +
"no user and no group, or a .pkla section whose Identity is unix-user:* or unix-group:* with a Result of " +
"yes — each rule and section judged on its own, comments taken out",
"write access to a container runtime's socket (docker, podman, containerd), by owner, group, other or " +
"POSIX ACL",
"a container runtime's API listening on TCP port 2375 or 2376 (/proc/net/tcp, /proc/net/tcp6), which any " +
"local account reaches",
"read access to a secret the mesh placed for another account, by owner, group, other or POSIX ACL",
"a program that no installed package owns and is setuid with owner root, or setgid with group root, " +
"whoever owns it, on every local filesystem mounted without nosuid (container and image layers, " +
"network and pseudo filesystems excepted) — searched in the background at most hourly, its last result " +
"served with when it was found; until a search has finished, the verdict says not judged",
"hard links or symbolic links to other accounts' files left unprotected by the kernel " +
"(fs.protected_hardlinks or fs.protected_symlinks is 0)",
}
// NotJudged is what the judge does not look for: each is a way to root it would miss.
var NotJudged = []string{
"a root-run unit, timer, cron entry or script the account can write",
"a directory on root's PATH, or in /etc/profile.d, the account can write",
"root's or the operator's ssh keys or authorized_keys readable or writable by it",
"a file root writes or reads, in a directory the account owns, other than below its home (the node-engine " +
"refuses links there)",
"file capabilities (setcap) on a program",
"a setuid program a package installed that has a flaw of its own",
"a terminal the account shares with a root process (TIOCSTI, sudo without use_pty)",
"a polkit rule that grants every account by logic the text does not show (a JavaScript condition " +
"other than a named user or group), or every account with an active local session",
"a container runtime's API on a TCP port other than 2375 and 2376, or on a unix socket not named here",
"a setuid program on a filesystem not judged: a container or image layer (overlay, squashfs), a network " +
"or FUSE filesystem",
}
// RuntimeSockets are the container runtimes' sockets: write access to one runs a container as root.
var RuntimeSockets = []string{"/run/docker.sock", "/var/run/docker.sock", "/run/podman/podman.sock",
"/run/containerd/containerd.sock"}
// DoasConfigs and PolkitDirs are where those grants live.
var (
DoasConfigs = []string{"/etc/doas.conf", "/etc/opendoas.conf"}
PolkitDirs = []string{"/etc/polkit-1/rules.d", "/usr/share/polkit-1/rules.d", "/etc/polkit-1/localauthority"}
)
// ACLEntry is one named entry of a POSIX ACL: a user's or a group's, and whether it grants read and write
// once the ACL's mask is applied.
type ACLEntry struct {
User bool
ID int
Read, Write bool
}
// The tags and bits of the kernel's ACL xattr.
const (
aclUser = 0x02
aclGroup = 0x08
aclMask = 0x10
)
// ParseACL reads a system.posix_acl_access value: a version, then entries of tag, permission and id; the
// named users' and groups' entries are answered with the mask applied.
func ParseACL(raw []byte) ([]ACLEntry, error) {
if len(raw) < 4 || (len(raw)-4)%8 != 0 {
return nil, fmt.Errorf("an ACL of %d bytes is not one", len(raw))
}
type entry struct {
tag, perm uint16
id uint32
}
var es []entry
mask := uint16(7)
for at := 4; at < len(raw); at += 8 {
e := entry{binary.LittleEndian.Uint16(raw[at:]), binary.LittleEndian.Uint16(raw[at+2:]),
binary.LittleEndian.Uint32(raw[at+4:])}
if e.tag == aclMask {
mask = e.perm
}
es = append(es, e)
}
var out []ACLEntry
for _, e := range es {
if e.tag != aclUser && e.tag != aclGroup {
continue
}
p := e.perm & mask
out = append(out, ACLEntry{User: e.tag == aclUser, ID: int(e.id), Read: p&4 != 0, Write: p&2 != 0})
}
return out, nil
}
// aclOf is a file's ACL from the machine: none when it has none.
func aclOf(path string) ([]ACLEntry, error) {
buf := make([]byte, 1024)
n, err := unix.Getxattr(path, "system.posix_acl_access", buf)
if errors.Is(err, unix.ENODATA) || errors.Is(err, unix.EOPNOTSUPP) {
return nil, nil
}
if err != nil {
return nil, &os.PathError{Op: "getxattr", Path: path, Err: err}
}
return ParseACL(buf[:n])
}
// grantsByACL says whether an ACL entry gives the account read (or write).
func grantsByACL(acl []ACLEntry, uid int, gids map[int]bool, write bool) bool {
for _, e := range acl {
if (e.User && e.ID == uid) || (!e.User && gids[e.ID]) {
if (write && e.Write) || (!write && e.Read) {
return true
}
}
}
return false
}
// Writable is Readable's twin for the write bits.
func Writable(m FileMode, uid int, gids map[int]bool) bool {
switch {
case uid == 0:
return true
case m.UID == uid:
return m.Perm&0o200 != 0
case gids[m.GID]:
return m.Perm&0o020 != 0
default:
return m.Perm&0o002 != 0
}
}
// DoasRules is every line of a doas configuration that permits the account or one of its groups.
func DoasRules(conf string, account string, groups []string) []string {
var out []string
for _, line := range strings.Split(conf, "\n") {
if i := strings.IndexByte(line, '#'); i >= 0 {
line = line[:i]
}
f := strings.Fields(line)
if len(f) < 2 || f[0] != "permit" {
continue
}
i := 1
for i < len(f) && (f[i] == "nopass" || f[i] == "persist" || f[i] == "keepenv" || f[i] == "nolog" ||
f[i] == "setenv" || strings.HasPrefix(f[i], "{")) {
if strings.HasPrefix(f[i], "{") {
for i < len(f) && !strings.HasSuffix(f[i], "}") {
i++
}
}
i++
}
if i >= len(f) {
continue
}
who := f[i]
if who == account || (strings.HasPrefix(who, ":") && contains(groups, who[1:])) {
out = append(out, strings.TrimSpace(line))
}
}
return out
}
// PolkitNames says whether a polkit rule or authority file names the account or one of its groups.
func PolkitNames(text, account string, groups []string) bool {
needles := []string{"unix-user:" + account, `"` + account + `"`, `'` + account + `'`}
for _, g := range groups {
needles = append(needles, "unix-group:"+g, `isInGroup("`+g+`")`, `isInGroup('`+g+`')`)
}
for _, n := range needles {
if strings.Contains(text, n) {
return true
}
}
return false
}
// PolkitGrantsEveryone says whether a polkit rule or authority file grants every account, naming none: a .rules
// file one of whose rules answers polkit.Result.YES with no condition on a user or a group, or a .pkla one of
// whose sections has an Identity of every user or every group with a Result of yes.
//
// **Each rule is judged on its own, comments taken out first** (the third review of 2026-10-08): a rule naming a
// user elsewhere in the file, or a user named only in a comment, must not hide another rule's unconditional
// yes. A rule's text is read, not run: a condition the text does not show is listed under NotJudged.
func PolkitGrantsEveryone(path, text string) bool {
if strings.HasSuffix(path, ".pkla") {
var kept []string
for _, line := range strings.Split(text, "\n") {
if t := strings.TrimSpace(line); strings.HasPrefix(t, "#") || strings.HasPrefix(t, ";") {
continue
}
kept = append(kept, line)
}
for _, section := range strings.Split(strings.Join(kept, "\n"), "[") {
var every, yes bool
for _, line := range strings.Split(section, "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
v = strings.TrimSpace(v)
switch strings.TrimSpace(k) {
case "Identity":
for _, id := range strings.Split(v, ";") {
if id = strings.TrimSpace(id); id == "unix-user:*" || id == "unix-group:*" {
every = true
}
}
case "ResultAny", "ResultActive", "ResultInactive":
if v == "yes" {
yes = true
}
}
}
if every && yes {
return true
}
}
return false
}
for _, rule := range polkitRules(stripJSComments(text)) {
if !strings.Contains(rule, "polkit.Result.YES") {
continue
}
// A condition on a user or a group names whom it grants; one on an active local session grants only an
// account with a seat, which an agent started through sudo has not (listed under NotJudged).
conditioned := false
for _, condition := range []string{".user", "isInGroup", "unix-user:", "unix-group:", ".active", ".local"} {
if strings.Contains(rule, condition) {
conditioned = true
break
}
}
if !conditioned {
return true
}
}
return false
}
// polkitRules is each rule of a .rules file: the text from one polkit.addRule to the next. Text before the first
// is no rule.
func polkitRules(text string) []string {
parts := strings.Split(text, "addRule(")
if len(parts) < 2 {
return nil
}
return parts[1:]
}
// stripJSComments takes /* … */ and // … comments out of a rule file, leaving what is in strings alone.
func stripJSComments(text string) string {
var b strings.Builder
var quote byte
for i := 0; i < len(text); i++ {
c := text[i]
switch {
case quote != 0:
b.WriteByte(c)
if c == '\\' && i+1 < len(text) {
i++
b.WriteByte(text[i])
} else if c == quote {
quote = 0
}
case c == '"' || c == '\'' || c == '`':
quote = c
b.WriteByte(c)
case c == '/' && i+1 < len(text) && text[i+1] == '*':
end := strings.Index(text[i+2:], "*/")
if end < 0 {
return b.String()
}
i += end + 3
case c == '/' && i+1 < len(text) && text[i+1] == '/':
end := strings.IndexByte(text[i:], '\n')
if end < 0 {
return b.String()
}
i += end - 1
default:
b.WriteByte(c)
}
}
return b.String()
}
// RuntimeAPIPorts are the ports a container runtime's API listens on by convention: 2375 plain, 2376 TLS.
var RuntimeAPIPorts = []int{2375, 2376}
// ListeningPorts is every local TCP port in the listening state, from /proc/net/tcp's or tcp6's text.
func ListeningPorts(text string) []int {
var out []int
for i, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if i == 0 || len(f) < 4 || f[3] != "0A" {
continue
}
_, port, ok := strings.Cut(f[1], ":")
if !ok {
continue
}
var n int
if _, err := fmt.Sscanf(port, "%X", &n); err == nil {
out = append(out, n)
}
}
return out
}
// pseudoFS are filesystem types no setuid program is installed on, or that are a container's or an image's
// own layers: not searched.
var pseudoFS = map[string]bool{"proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true,
"cgroup": true, "cgroup2": true, "securityfs": true, "pstore": true, "bpf": true, "debugfs": true,
"tracefs": true, "mqueue": true, "hugetlbfs": true, "configfs": true, "fusectl": true, "autofs": true,
"binfmt_misc": true, "efivarfs": true, "overlay": true, "squashfs": true, "nsfs": true, "ramfs": true,
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "9p": true, "virtiofs": true}
// SuidMounts is every local filesystem a setuid program could run from: the mount points of /proc/mounts whose
// type is not pseudo, network or a container's layer, and that are not mounted nosuid. Root first.
func SuidMounts(text string) []string {
seen := map[string]bool{}
var out []string
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 4 || pseudoFS[f[2]] || strings.HasPrefix(f[2], "fuse") {
continue
}
at := unescapeMount(f[1])
if strings.HasPrefix(at, "/var/lib/docker") || strings.HasPrefix(at, "/var/lib/containers") ||
strings.HasPrefix(at, "/proc") || strings.HasPrefix(at, "/sys") {
continue
}
if contains(strings.Split(f[3], ","), "nosuid") || seen[at] {
continue
}
seen[at] = true
out = append(out, at)
}
sort.Slice(out, func(i, j int) bool { return out[i] == "/" || (out[j] != "/" && out[i] < out[j]) })
return out
}
// MountPoints is every mount point /proc/mounts lists, of any type: the walk leaves each it meets inside
// another filesystem without reading it.
func MountPoints(text string) map[string]bool {
out := map[string]bool{}
for _, line := range strings.Split(text, "\n") {
if f := strings.Fields(line); len(f) >= 2 {
out[unescapeMount(f[1])] = true
}
}
return out
}
// unescapeMount is a mount point as /proc/mounts escapes it — a space, a tab, a newline and a backslash as
// three octal digits after a backslash — made a path again.
func unescapeMount(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+3 < len(s) && isOctal(s[i+1:i+4]) {
b.WriteByte((s[i+1]-'0')<<6 | (s[i+2]-'0')<<3 | (s[i+3] - '0'))
i += 3
continue
}
b.WriteByte(s[i])
}
return b.String()
}
func isOctal(s string) bool {
if len(s) != 3 {
return false
}
for i := 0; i < 3; i++ {
if s[i] < '0' || s[i] > '7' {
return false
}
}
return true
}
func contains(xs []string, s string) bool {
for _, x := range xs {
if x == s {
return true
}
}
return false
}
// setuidSearch is every setuid- or setgid-root regular file on the filesystems of p that no installed package
// owns: walked by the engine (setuid_walk.go, resumable from p), then asked of the package manager through the
// search's runner (Exec.SearchRun). **No bound of its own** (novox/hq issue 361): it runs until it has walked
// everything. A walk that could not complete — a directory it could not read — is an unanswered question, never
// "none"; a file that vanished while it walked is not.
func (e Exec) setuidSearch(ctx context.Context, p *SetuidProgress, checkpoint func(), mountPoints map[string]bool) ([]string, error) {
walk := e.Walk
if walk == nil {
var say func(string)
if e.Cache != nil {
say = e.Cache.Say
}
walk = engineWalker(mountPoints, say).walk
}
if err := walk(ctx, p, checkpoint); err != nil {
return nil, fmt.Errorf("the walk for setuid programs did not complete: %w", err)
}
if e.SearchRun != nil {
e.Run = e.SearchRun
}
found := slices.Clone(p.Found)
slices.Sort(found)
return e.unpackaged(ctx, slices.Compact(found))
}
// engineWalker is the walk the engine runs: checkpointed every CheckpointEvery, leaving other filesystems unread,
// and paced by the machine's I/O pressure (setuid_pace.go, novox/hq issue 433), saying through say what it cannot do.
func engineWalker(mountPoints map[string]bool, say func(string)) walker {
return walker{every: CheckpointEvery, mountPoints: mountPoints, pace: newPacer(say)}
}
// errorLines are the lines a command said on its error stream, as the runner words a failure: "<name> exited N:
// <its words>", the prefix taken off the first line. Empty lines are dropped.
func errorLines(err error, name string) []string {
words := err.Error()
if strings.HasPrefix(words, name+" exited") {
if i := strings.Index(words, ": "); i >= 0 {
words = words[i+2:]
}
}
var out []string
for _, l := range strings.Split(words, "\n") {
if l = strings.TrimSpace(l); l != "" {
out = append(out, l)
}
}
return out
}
// unpackaged is every path no installed package owns: pacman asked about all of them in one call, else apk
// about each. Never a guess: a path neither said owned nor said unowned is an unanswered question.
func (e Exec) unpackaged(ctx context.Context, paths []string) ([]string, error) {
if len(paths) == 0 {
return nil, nil
}
out, err := e.Run(ctx, "pacman", append([]string{"-Qo"}, paths...)...)
if err == nil || !errors.Is(err, exec.ErrNotFound) && !errors.Is(err, fs.ErrNotExist) {
owned := map[string]bool{}
for _, l := range strings.Split(out, "\n") {
if p, _, ok := strings.Cut(strings.TrimSpace(l), " is owned by "); ok {
owned[p] = true
}
}
unowned := map[string]bool{}
if err != nil {
// pacman exits 1 when a path is owned by nothing, and says which on its error stream.
for _, l := range errorLines(err, "pacman") {
if _, p, ok := strings.Cut(l, "No package owns "); ok {
unowned[strings.TrimSpace(p)] = true
} else {
return nil, fmt.Errorf("pacman could not say who owns the setuid programs found: %s", l)
}
}
}
var out []string
for _, p := range paths {
switch {
case owned[p]:
case unowned[p]:
out = append(out, p)
default:
return nil, fmt.Errorf("pacman said nothing of who owns %s", p)
}
}
return out, nil
}
var none []string
for _, p := range paths {
said, err := e.Run(ctx, "apk", "info", "-W", p)
if err != nil {
return nil, fmt.Errorf("no package manager could say who owns %s: %w", p, err)
}
if !strings.Contains(said, " is owned by ") {
none = append(none, p)
}
}
return none, nil
}
// moreWays is every way beyond uid, groups and sudo; an unanswered question is an error, never none.
func (e Exec) moreWays(ctx context.Context, account string, uid int, groups []string, gids map[int]bool,
secrets []string) ([]string, error) {
read := e.ReadFile
if read == nil {
read = os.ReadFile
}
readDir := e.ReadDir
if readDir == nil {
readDir = os.ReadDir
}
acl := e.ACL
if acl == nil {
acl = aclOf
}
stat := e.Stat
if stat == nil {
stat = statOf
}
var ways []string
// doas.
for _, conf := range DoasConfigs {
raw, err := read(conf)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("doas's rules in %s could not be read: %w", conf, err)
}
for _, r := range DoasRules(string(raw), account, groups) {
ways = append(ways, "doas permits it: "+r)
}
}
// polkit.
for _, dir := range PolkitDirs {
var named, everyone []string
err := walkFiles(readDir, dir, func(path string) error {
raw, err := read(path)
if err != nil {
return err
}
if PolkitNames(string(raw), account, groups) {
named = append(named, path)
} else if PolkitGrantsEveryone(path, string(raw)) {
everyone = append(everyone, path)
}
return nil
})
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("polkit's rules in %s could not be read: %w", dir, err)
}
for _, p := range named {
ways = append(ways, "a polkit rule names it or a group of it: "+p)
}
for _, p := range everyone {
ways = append(ways, "a polkit rule grants every account: "+p)
}
}
// The container runtimes' sockets.
seen := map[string]bool{}
for _, s := range RuntimeSockets {
// Two names of one socket are one socket. A test that gives its own files names them as they are.
real, err := filepath.EvalSymlinks(s)
if e.Stat != nil {
real, err = s, nil
}
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("the socket %s could not be read: %w", s, err)
}
if seen[real] {
continue
}
seen[real] = true
m, err := stat(real)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("the socket %s could not be read for its owner and mode: %w", s, err)
}
a, err := acl(real)
if err != nil {
return nil, err
}
if Writable(m, uid, gids) || grantsByACL(a, uid, gids, true) {
ways = append(ways, "it can write the container runtime's socket "+s+", which runs a container as root")
}
}
// The secrets' ACLs: the bits were judged already.
for _, path := range secrets {
a, err := acl(path)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("the secret %s's ACL could not be read: %w", path, err)
}
if grantsByACL(a, uid, gids, false) {
ways = append(ways, "an ACL lets it read the secret "+path)
}
}
// setuid programs no package owns.
now := time.Now
if e.Now != nil {
now = e.Now
}
mountsText, err := read("/proc/mounts")
if err != nil {
return nil, fmt.Errorf("the mounted filesystems could not be read: %w", err)
}
mounts := SuidMounts(string(mountsText))
if len(mounts) == 0 {
return nil, errors.New("no filesystem a setuid program could run from was found in /proc/mounts")
}
// In the background, the last complete result served while it is fresh (SetuidCache): never a stall. No
// fresh complete result is said beside the other ways, which are judged all the same.
mountPoints := MountPoints(string(mountsText))
unowned, searchedAt, searchErr := e.Cache.Look(now(), mounts,
func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error) {
return e.setuidSearch(ctx, p, checkpoint, mountPoints)
})
var notJudged *SetuidNotJudged
if searchErr != nil && !errors.As(searchErr, &notJudged) {
return nil, searchErr
}
// Counted only where the account can execute it, read now from the paths' modes, never kept with the
// search: an account may open its own home to others later without root, and no search would see it.
lstat := e.Lstat
if lstat == nil {
lstat = lstatOf
}
started := " (search started " + searchedAt.Local().Format("2006-01-02 15:04") + ")"
inHomes := 0
for _, p := range unowned {
if !Executable(p, uid, gids, lstat, acl) {
continue
}
if inAHome(p) {
inHomes++ // its path stays in the engine's journal: a home's names are its owner's
continue
}
ways = append(ways, "a setuid-root program no package owns, which "+account+" can execute: "+p+started)
}
switch {
case inHomes == 1:
ways = append(ways, "a setuid-root program no package owns in an account's home, which "+account+
" can execute"+started)
case inHomes > 1:
ways = append(ways, fmt.Sprintf("%d setuid-root programs no package owns in accounts' homes, which %s can "+
"execute%s", inHomes, account, started))
}
// The kernel's protection of links: off, any account may link another's file where root then acts on it.
for _, sysctl := range []string{"protected_hardlinks", "protected_symlinks"} {
raw, err := read("/proc/sys/fs/" + sysctl)
if err != nil {
return nil, fmt.Errorf("fs.%s could not be read: %w", sysctl, err)
}
if strings.TrimSpace(string(raw)) == "0" {
what := map[string]string{"protected_hardlinks": "hard links", "protected_symlinks": "symbolic links"}[sysctl]
ways = append(ways, fmt.Sprintf("%s to other accounts' files are not protected (fs.%s=0)", what, sysctl))
}
}
// A container runtime's API on TCP, which any account on the machine reaches.
for _, table := range []string{"/proc/net/tcp", "/proc/net/tcp6"} {
raw, err := read(table)
if errors.Is(err, fs.ErrNotExist) && table == "/proc/net/tcp6" {
continue // no IPv6 on this machine
}
if err != nil {
return nil, fmt.Errorf("the listening ports in %s could not be read: %w", table, err)
}
for _, port := range ListeningPorts(string(raw)) {
for _, api := range RuntimeAPIPorts {
if port == api {
ways = append(ways, fmt.Sprintf("a container runtime's API listens on TCP port %d, which any "+
"local account reaches", port))
}
}
}
}
if notJudged != nil {
// The other ways are judged; this one is said as not judged, never as none.
return ways, notJudged
}
return ways, nil
}
// Executable says whether an account of uid, in the groups gids, can execute path as the kernel decides it:
// every directory from the root down searchable for it, and the file executable for it, each by the owner's
// bits for its owner, the group's for a member, the others' for anybody else (novox/hq issue 361). It errs
// towards a way: a link on the way, an ACL on any of them, or a mode that could not be read is executable.
// A path gone is not: there is nothing to execute.
func Executable(path string, uid int, gids map[int]bool, lstat func(string) (FileMode, error),
acl func(string) ([]ACLEntry, error)) bool {
if uid == 0 {
return true
}
at := "/"
steps := []string{at}
for _, name := range components(path) {
at = filepath.Join(at, name)
steps = append(steps, at)
}
for i, p := range steps {
m, err := lstat(p)
if errors.Is(err, fs.ErrNotExist) {
return false
}
if err != nil || m.Perm&fs.ModeSymlink != 0 {
return true
}
if a, err := acl(p); (err != nil && !errors.Is(err, fs.ErrNotExist)) || len(a) > 0 {
return true
}
bit := fs.FileMode(0o001)
switch {
case m.UID == uid:
bit = 0o100
case gids[m.GID]:
bit = 0o010
}
if m.Perm&bit == 0 {
return false
}
if i < len(steps)-1 && !m.Perm.IsDir() {
return false // a file on the way: the path names nothing
}
}
return true
}
// homes are where accounts' homes are: a path under one is said by count alone, its name its owner's.
var homes = []string{"/home/", "/root/", "/var/home/"}
func inAHome(path string) bool {
for _, h := range homes {
if strings.HasPrefix(path, h) {
return true
}
}
return false
}
// walkFiles calls fn for every regular file below dir, through readDir.
func walkFiles(readDir func(string) ([]fs.DirEntry, error), dir string, fn func(string) error) error {
entries, err := readDir(dir)
if err != nil {
return err
}
for _, en := range entries {
p := filepath.Join(dir, en.Name())
if en.IsDir() {
if err := walkFiles(readDir, p, fn); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
continue
}
if err := fn(p); err != nil {
return err
}
}
return nil
}
// CLocale runs a command in the C locale, so what the judge parses — sudo's listing above all — is one
// language whatever the machine's is; stdin closed, output captured.
func CLocale(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C", "LANGUAGE=C")
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) {
return string(out), fmt.Errorf("%s exited %d: %s", name, exit.ExitCode(), strings.TrimSpace(stderr.String()))
}
return string(out), fmt.Errorf("%s: %w", name, err)
}
return string(out), nil
}
-305
View File
@@ -1,305 +0,0 @@
package accounts
import (
"context"
"encoding/binary"
"errors"
"fmt"
"os/exec"
"strings"
"testing"
"time"
)
// The ways beyond uid, groups and sudo (novox/hq ADR 0266, the review of 2026-10-08): doas, polkit, a
// runtime's socket, an ACL on a secret, a setuid program no package owns — each said; each unread question
// unknown; and the judge's commands in the C locale.
func TestEachFurtherWayToRootIsSaid(t *testing.T) {
const broker = "/var/lib/mesh/node-tools/broker"
for _, c := range []struct {
name string
set func(*agentMachine)
said string
}{
{"doas by name", func(m *agentMachine) {
m.texts = map[string]string{"/etc/doas.conf": "permit persist operator\npermit nopass agent as root\n"}
}, "doas permits it: permit nopass agent as root"},
{"doas by group", func(m *agentMachine) {
m.groups = "agent builders"
m.texts = map[string]string{"/etc/opendoas.conf": "permit :builders # the builders\n"}
}, "doas permits it: permit :builders"},
{"polkit by name", func(m *agentMachine) {
m.dirs = map[string][]string{"/etc/polkit-1/rules.d": {"10-agent.rules"}}
m.texts = map[string]string{"/etc/polkit-1/rules.d/10-agent.rules": `polkit.addRule(function(a, s) { if (s.user == "agent") return polkit.Result.YES; });`}
}, "a polkit rule names it or a group of it: /etc/polkit-1/rules.d/10-agent.rules"},
{"polkit by group", func(m *agentMachine) {
m.groups = "agent network"
m.dirs = map[string][]string{"/usr/share/polkit-1/rules.d": {"50-nm.rules"}}
m.texts = map[string]string{"/usr/share/polkit-1/rules.d/50-nm.rules": `if (subject.isInGroup("network")) return polkit.Result.YES;`}
}, "a polkit rule names it or a group of it: /usr/share/polkit-1/rules.d/50-nm.rules"},
{"docker socket by group bits", func(m *agentMachine) {
m.gids = "1600 970"
m.files["/run/docker.sock"] = FileMode{UID: 0, GID: 970, Perm: 0o660}
}, "it can write the container runtime's socket /run/docker.sock"},
{"docker socket by ACL", func(m *agentMachine) {
m.files["/run/docker.sock"] = FileMode{UID: 0, GID: 970, Perm: 0o660}
m.acls = map[string][]ACLEntry{"/run/docker.sock": {{User: true, ID: 1600, Read: true, Write: true}}}
}, "it can write the container runtime's socket /run/docker.sock"},
{"secret by ACL", func(m *agentMachine) {
m.acls = map[string][]ACLEntry{broker: {{User: false, ID: 1600, Read: true}}}
}, "an ACL lets it read the secret " + broker},
{"polkit for every account", func(m *agentMachine) {
m.dirs = map[string][]string{"/etc/polkit-1/rules.d": {"00-all.rules"}}
m.texts = map[string]string{"/etc/polkit-1/rules.d/00-all.rules": `polkit.addRule(function(action, subject) { return polkit.Result.YES; });`}
}, "a polkit rule grants every account: /etc/polkit-1/rules.d/00-all.rules"},
{"pkla for every account", func(m *agentMachine) {
m.dirs = map[string][]string{"/etc/polkit-1/localauthority": {"all.pkla"}}
m.texts = map[string]string{"/etc/polkit-1/localauthority/all.pkla": "[all]\nIdentity=unix-user:*\nAction=*\nResultAny=yes\n"}
}, "a polkit rule grants every account: /etc/polkit-1/localauthority/all.pkla"},
{"docker on TCP", func(m *agentMachine) {
m.proc["/proc/net/tcp6"] = " sl local_address rem_address st\n 0: 00000000000000000000000000000000:0947 00000000000000000000000000000000:0000 0A 0\n"
}, "a container runtime's API listens on TCP port 2375"},
{"hard links unprotected", func(m *agentMachine) {
m.proc["/proc/sys/fs/protected_hardlinks"] = "0\n"
}, "hard links to other accounts' files are not protected (fs.protected_hardlinks=0)"},
{"setuid on a second filesystem", func(m *agentMachine) {
m.proc["/proc/mounts"] += "/dev/sdb1 /srv xfs rw 0 0\n/dev/sdc1 /data ext4 rw,nosuid 0 0\n"
m.setuid = "/srv/bin/rootshell\n"
m.findArgs = func(args []string) {
if len(args) != 2 || args[0] != "/" || args[1] != "/srv" {
panic(fmt.Sprintf("searched %v", args))
}
}
}, "a setuid-root program no package owns, which agent can execute: /srv/bin/rootshell"},
{"setuid no package owns", func(m *agentMachine) {
m.setuid = "/usr/bin/sudo\n/usr/local/bin/rootshell\n"
m.packaged = map[string]bool{"/usr/bin/sudo": true}
}, "a setuid-root program no package owns, which agent can execute: /usr/local/bin/rootshell"},
} {
t.Run(c.name, func(t *testing.T) {
m := clean()
c.set(m)
v := lookAgent(t, m)
if v.State != Unhealthy || !strings.HasPrefix(v.Reason, ReasonRoot+": ") || !strings.Contains(v.Reason, c.said) {
t.Fatalf("want %q said: %+v", c.said, v)
}
})
}
}
func TestWhatGrantsSomebodyElseIsNoWay(t *testing.T) {
m := clean()
m.texts = map[string]string{"/etc/doas.conf": "permit operator\npermit :wheel\n# permit agent\n",
"/usr/share/polkit-1/rules.d/50-default.rules": `polkit.addAdminRule(function(a, s) { return ["unix-group:wheel"]; });`}
m.dirs = map[string][]string{"/usr/share/polkit-1/rules.d": {"50-default.rules"}}
m.files["/run/docker.sock"] = FileMode{UID: 0, GID: 970, Perm: 0o660}
m.setuid = "/usr/bin/sudo\n/usr/bin/passwd\n"
m.packaged = map[string]bool{"/usr/bin/sudo": true, "/usr/bin/passwd": true}
if v := lookAgent(t, m); v.State != Healthy {
t.Fatalf("the operator's and wheel's grants, a socket of another group, packaged setuid programs: %+v", v)
}
}
func TestDoasWithoutSudoIsJudgedFromItsRules(t *testing.T) {
m := clean()
m.sudo, m.sudoErr = "", fmt.Errorf("sudo: %w", exec.ErrNotFound)
m.texts = map[string]string{"/etc/doas.conf": "permit nopass agent\n"}
v := lookAgent(t, m)
if v.State != Unhealthy || !strings.Contains(v.Reason, "doas permits it") {
t.Fatalf("no sudo is no sudo rule, and doas is read for itself: %+v", v)
}
}
func TestAFurtherQuestionUnansweredIsUnknown(t *testing.T) {
m := clean()
m.findErr = errors.New("find: interrupted")
if v := lookAgent(t, m); v.State != Unknown {
t.Fatalf("a search that did not finish: %+v", v)
}
m = clean()
m.setuid = "/usr/local/bin/x\n"
j := New(Exec{Run: func(ctx context.Context, name string, args ...string) (string, error) {
if name == "pacman" || name == "apk" {
return "", fmt.Errorf("%s: %w", name, exec.ErrNotFound)
}
return m.run(ctx, name, args...)
}, Walk: m.walk, Stat: m.stat, ReadFile: m.readFile, ReadDir: m.readDir, ACL: m.acl, Lstat: m.lstat})
j.Set([]Account{agent})
if st, _ := j.Look(t.Context()); st.Accounts[0].State != Unknown {
t.Fatalf("no package manager to ask: %+v", st.Accounts[0])
}
}
func TestANotJudgedSearchStillSaysAWayFound(t *testing.T) {
j := New(fakeReader{ways: []string{"in the group docker, which grants root"}, err: &SetuidNotJudged{Pending: true, Since: time.Now()}})
j.Set([]Account{{Module: "m", ID: "m.a", Name: "agent", Root: true}})
st, _ := j.Look(context.Background())
if v := st.Accounts[0]; v.State != Unhealthy || !strings.Contains(v.Reason, "docker") || !strings.Contains(v.Reason, ReasonPending) {
t.Fatalf("a way found is unhealthy, with the search's state beside it: %+v", v)
}
j = New(fakeReader{err: &SetuidNotJudged{Since: time.Now(), Err: errors.New("timeout")}})
j.Set([]Account{{Module: "m", ID: "m.a", Name: "agent", Root: true}})
st, _ = j.Look(context.Background())
if v := st.Accounts[0]; v.State != Unknown || !strings.HasPrefix(v.Reason, ReasonIncomplete) {
t.Fatalf("nothing found and the search incomplete: unknown, said so: %+v", v)
}
}
type fakeReader struct {
ways []string
err error
}
func (f fakeReader) InDatabase(context.Context, string) ([]string, error) { return nil, nil }
func (f fakeReader) Session(context.Context, string, []string) (Session, error) {
return Session{}, nil
}
func (f fakeReader) Escalation(context.Context, string, []string) ([]string, error) {
return f.ways, f.err
}
func TestParseACL(t *testing.T) {
entry := func(tag, perm uint16, id uint32) []byte {
b := make([]byte, 8)
binary.LittleEndian.PutUint16(b, tag)
binary.LittleEndian.PutUint16(b[2:], perm)
binary.LittleEndian.PutUint32(b[4:], id)
return b
}
raw := []byte{2, 0, 0, 0}
raw = append(raw, entry(0x01, 6, 0xffffffff)...)
raw = append(raw, entry(0x02, 6, 1600)...) // user agent rw
raw = append(raw, entry(0x04, 4, 0xffffffff)...)
raw = append(raw, entry(0x08, 6, 970)...) // group 970 rw
raw = append(raw, entry(0x10, 4, 0xffffffff)...) // mask r--
raw = append(raw, entry(0x20, 0, 0xffffffff)...)
acl, err := ParseACL(raw)
if err != nil || len(acl) != 2 {
t.Fatalf("%v %v", acl, err)
}
if !acl[0].User || acl[0].ID != 1600 || !acl[0].Read || acl[0].Write {
t.Fatalf("the mask takes write away: %+v", acl[0])
}
if grantsByACL(acl, 1600, nil, true) || !grantsByACL(acl, 1601, map[int]bool{970: true}, false) {
t.Fatal("grants")
}
if _, err := ParseACL([]byte{2, 0, 0}); err == nil {
t.Fatal("a short ACL")
}
}
func TestTheJudgesCommandsRunInTheCLocale(t *testing.T) {
t.Setenv("LC_ALL", "de_DE.UTF-8")
t.Setenv("LANG", "de_DE.UTF-8")
out, err := CLocale(t.Context(), "sh", "-c", `echo "$LC_ALL $LANG"`)
if err != nil || strings.TrimSpace(out) != "C C" {
t.Fatalf("%q %v", out, err)
}
if _, err := CLocale(t.Context(), "sh", "-c", "echo nope >&2; exit 3"); err == nil ||
!strings.Contains(err.Error(), "exited 3: nope") {
t.Fatalf("an exit is said with its words: %v", err)
}
}
func TestTheJudgedListIsWrittenDown(t *testing.T) {
if len(Judged) < 8 || len(NotJudged) == 0 {
t.Fatal("what is judged and what is not are both written down")
}
}
func TestDistributionRulesForASeatOrForSomebodyAreNotEveryone(t *testing.T) {
for _, rule := range []string{
`polkit.addRule(function(a, s) { if (s.local && s.active) return polkit.Result.YES; });`,
`polkit.addRule(function(a, s) { if (s.isInGroup("wheel")) return polkit.Result.YES; });`,
`polkit.addAdminRule(function(a, s) { return ["unix-group:wheel"]; });`,
} {
if PolkitGrantsEveryone("/x.rules", rule) {
t.Errorf("not every account: %s", rule)
}
}
if PolkitGrantsEveryone("/x.pkla", "[a]\nIdentity=unix-group:wheel\nResultAny=yes\n") {
t.Error("a pkla for wheel is not every account")
}
}
func TestAMachineMissingWhatTheJudgeReadsIsUnknown(t *testing.T) {
for _, file := range []string{"/proc/sys/fs/protected_hardlinks", "/proc/net/tcp", "/proc/mounts"} {
m := clean()
delete(m.proc, file)
if v := lookAgent(t, m); v.State != Unknown {
t.Errorf("%s unread: %+v", file, v)
}
}
}
func TestSuidMountsLeaveOutNosuidPseudoAndContainerLayers(t *testing.T) {
got := SuidMounts("/dev/sdb1 /srv xfs rw 0 0\n/dev/sda2 / ext4 rw 0 0\noverlay /var/lib/docker/overlay2/x/merged overlay rw 0 0\n" +
"/dev/sdc1 /data ext4 rw,nosuid 0 0\nproc /proc proc rw 0 0\nhost:/x /mnt/nfs nfs4 rw 0 0\n/dev/sdd1 /my\\040disk ext4 rw 0 0\n")
if strings.Join(got, "|") != "/|/my disk|/srv" {
t.Fatalf("got %q", got)
}
}
// Each polkit rule is judged on its own, comments taken out (the third review of 2026-10-08).
func TestAnUnconditionalPolkitYesIsNotMaskedByAnotherRuleOrAComment(t *testing.T) {
cases := []struct {
name, text string
every bool
}{
{"another rule names a user", `polkit.addRule(function(a, s) { if (s.user == "operator") return polkit.Result.YES; });
polkit.addRule(function(a, s) { return polkit.Result.YES; });`, true},
{"a user named only in a comment", `// subject.user == "x"
polkit.addRule(function(a, s) { /* isInGroup("wheel") */ return polkit.Result.YES; });`, true},
{"the yes only in a comment", `polkit.addRule(function(a, s) { // return polkit.Result.YES;
return polkit.Result.NO; });`, false},
{"a condition in the same rule", `polkit.addRule(function(a, s) { if (s.isInGroup("wheel")) { return polkit.Result.YES; } });`, false},
{"a comment marker inside a string", `polkit.addRule(function(a, s) { var u = "http://x"; return polkit.Result.YES; });`, true},
}
for _, c := range cases {
if got := PolkitGrantsEveryone("/etc/polkit-1/rules.d/x.rules", c.text); got != c.every {
t.Errorf("%s: grants every account %v, want %v", c.name, got, c.every)
}
}
if PolkitGrantsEveryone("/x.pkla", "# [a]\n# Identity=unix-user:*\n# ResultAny=yes\n") {
t.Error("a .pkla section only in comments grants nobody")
}
if !PolkitGrantsEveryone("/x.pkla", "[a]\nIdentity=unix-group:wheel\nResultAny=yes\n[b]\nIdentity=unix-user:*\nResultActive=yes\n") {
t.Error("each .pkla section on its own")
}
}
// The package manager is asked about every path found in one call, and answers each: owned, or owned by nothing.
func TestThePackageManagerIsAskedOnceForEveryPathFound(t *testing.T) {
m := clean()
m.setuid = "/usr/bin/sudo\n/usr/bin/passwd\n/usr/local/bin/x\n/opt/y\n"
m.packaged = map[string]bool{"/usr/bin/sudo": true, "/usr/bin/passwd": true}
search := func(e Exec) ([]string, error) {
return e.setuidSearch(t.Context(), &SetuidProgress{Mounts: []string{"/"}}, func() {}, nil)
}
found, err := search(Exec{Run: m.run, Walk: m.walk})
if err != nil {
t.Fatal(err)
}
if m.pacmanCalls != 1 {
t.Errorf("pacman was asked %d times for four paths", m.pacmanCalls)
}
if strings.Join(found, ",") != "/opt/y,/usr/local/bin/x" {
t.Errorf("unowned: %v", found)
}
// An error pacman says that is not "owned by nothing" is no answer.
odd := func(ctx context.Context, name string, args ...string) (string, error) {
if name == "pacman" {
return "", errors.New("pacman exited 1: error: could not open database")
}
return m.run(ctx, name, args...)
}
if _, err := search(Exec{Run: odd, Walk: m.walk}); err == nil {
t.Error("a package manager that could not answer was read as an answer")
}
// A walk that did not complete is no answer either.
m.findErr = errors.New("could not read /mnt/data/private: permission denied")
if _, err := search(Exec{Run: m.run, Walk: m.walk}); err == nil {
t.Error("a walk that did not complete was read as an answer")
}
}
+23
View File
@@ -3,6 +3,7 @@ package apply
import (
"context"
"os"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
@@ -31,6 +32,28 @@ func TestAnAccessPresentIsConfirmedAndNothingIsChanged(t *testing.T) {
}
}
// Absent is refused, not created. A bind mount whose source does not exist is made by the
// container runtime as root, with whatever mode it picks — the silent wrong-ownership
// 04-ISSUES/026 records. So the host says plainly that the operator must provide the path, rather
// than conjuring a directory it does not own.
func TestAnAccessThatIsAbsentIsRefusedClearlyAndNotCreated(t *testing.T) {
missing := t.TempDir() + "/media/library" // named, never created
d := declare(t, `{"id":"lib","type":"access","path":"`+missing+`"}`)
_, _, err := Apply(context.Background(), archHost(t), d, store.State{},
store.OriginDeclared, noServices, nil, nil)
if err == nil {
t.Fatal("an absent operator-owned path was accepted, and would be created as root by the runtime")
}
if !strings.Contains(err.Error(), "the operator must provide") ||
!strings.Contains(err.Error(), "does not own") {
t.Fatalf("the refusal does not say whose the path is: %v", err)
}
if _, statErr := os.Stat(missing); statErr == nil {
t.Fatal("the host created the path it does not own")
}
}
// Undeclaring an access never removes the path. Unassigning the module that reached the media
// library must not delete the library — that is the data loss ADR 0030 exists to prevent, on a
// directory the mesh never made. The record is dropped; the operator's data is left exactly as it
-76
View File
@@ -1,76 +0,0 @@
package apply
// What is never placed below an agent's home (novox/hq ADR 0266 §7).
//
// An account that never becomes root owns every directory in its home, so it can swap a link into the path
// between the engine's check and the engine's write. The engine writes a whole file there through a
// descriptor that followed no link (home_links.go), but it unpacks an archive, and writes into an existing
// file, by path. So below the home of an account the declaration says never becomes root, neither is placed.
// The catalogue's own check (checks/agenthome) refuses either under `${machine:agent-home}`, so no module
// asks; this is the engine refusing it again where it applies. The machine's own trees, other accounts'
// homes and a directory's owner below a home are placement_guard.go's (novox/hq issue 339).
import (
"fmt"
"path/filepath"
"github.com/novox/mesh-host/internal/declaration"
)
// AgentHomeRefusedError is an archive or a written-into file the engine will not place below an agent's home.
type AgentHomeRefusedError struct {
Path, What, Home string
}
func (e *AgentHomeRefusedError) Error() string {
return fmt.Sprintf("%s is not placed: %s below %s, the home of an account that never becomes root, which "+
"owns every parent there and could swap a link in between the check and the write (novox/hq ADR 0266); "+
"nothing was touched", e.Path, e.What, e.Home)
}
// refuseUnderAgentHome says why a resource is not placed below one of agentHomes; nil when it may be.
func refuseUnderAgentHome(r declaration.Resource, agentHomes []string) error {
what := ""
switch res := r.(type) {
case *declaration.Archive:
what = "an archive unpacked"
case *declaration.File:
if res.Into != "" {
what = "a file written into (" + res.Into + ")"
}
}
if what == "" {
return nil
}
path := filepath.Clean(r.Target())
for _, p := range []string{path, resolved(path)} {
for _, home := range agentHomes {
if atOrBelow(p, home) {
return &AgentHomeRefusedError{Path: path, What: what, Home: home}
}
}
}
return nil
}
// rootNeverHomes is the home of every account the declaration says never becomes root, from the user
// database; an account not made yet has no home to protect.
func rootNeverHomes(d *declaration.Declaration) []string {
if d == nil {
return nil
}
homes := accountsOfHomes()
var out []string
for _, r := range d.Resources {
u, ok := r.(*declaration.User)
if !ok || u.Root != declaration.RootNever {
continue
}
for home, a := range homes {
if a.Name == u.Name {
out = append(out, home)
}
}
}
return out
}
-36
View File
@@ -1,36 +0,0 @@
package apply
// Defends novox/hq ADR 0266 §7: no archive is unpacked and no file is written into below the home of an account
// the declaration says never becomes root; a whole file there still passes.
import (
"testing"
"github.com/novox/mesh-host/internal/declaration"
)
func TestNothingIsUnpackedOrWrittenIntoBelowAnAgentsHome(t *testing.T) {
was := accountsOfHomes
accountsOfHomes = func() map[string]homeAccount {
return map[string]homeAccount{"/home/agent": {"agent", 1001}, "/home/operator": {"operator", 1000}}
}
t.Cleanup(func() { accountsOfHomes = was })
agent := []string{"/home/agent"}
if err := refuseUnderAgentHome(&declaration.Archive{ID: "a.x", Type: declaration.TypeArchive, Path: "/home/agent/.local/x", Owner: "agent"}, agent); err == nil {
t.Error("an archive below an agent's home is refused")
}
if err := refuseUnderAgentHome(&declaration.File{ID: "a.f", Type: declaration.TypeFile, Path: "/home/agent/.claude.json", Owner: "agent", Into: "json", Content: "{}"}, agent); err == nil {
t.Error("a file written into below an agent's home is refused")
}
if err := refuseUnderAgentHome(&declaration.File{ID: "a.f", Type: declaration.TypeFile, Path: "/home/agent/x", Owner: "agent", Content: "x"}, agent); err != nil {
t.Errorf("a whole file there passes: %v", err)
}
if err := refuseUnderAgentHome(&declaration.Archive{ID: "a.x", Type: declaration.TypeArchive, Path: "/home/operator/x", Owner: "operator"}, agent); err != nil {
t.Errorf("an archive in a home whose account may become root is placement_guard.go's to judge, not this: %v", err)
}
d := parse(t, `{"declaration":1,"resources":[{"id":"c.agent","type":"user","name":"agent","root":"never"},`+
`{"id":"c.op","type":"user","name":"operator"}]}`)
if got := rootNeverHomes(d); len(got) != 1 || got[0] != "/home/agent" {
t.Errorf("the agent's home is known by the user database: %v", got)
}
}
+135 -1527
View File
File diff suppressed because it is too large Load Diff
-32
View File
@@ -313,7 +313,6 @@ func TestAServiceIsReadBackNotAssumed(t *testing.T) {
// running. A unit that starts and immediately dies satisfies the command.
started := false
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if args[0] == "show" {
if started {
return "LoadState=loaded\nActiveState=failed\n", nil // started, then died
@@ -338,7 +337,6 @@ func TestAServiceIsReadBackNotAssumed(t *testing.T) {
func TestAnUnknownServiceStateIsRefusedNotGuessed(t *testing.T) {
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
return "LoadState=loaded\nActiveState=reticent\n", nil
}
d := parse(t, `{"declaration":1,"resources":[
@@ -373,7 +371,6 @@ func TestADroppedServiceIsGivenBackTheStateItWasFoundIn(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
var commands []string
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
commands = append(commands, strings.Join(args, " "))
if args[0] == "show" {
return "LoadState=loaded\nActiveState=active\n", nil
@@ -416,7 +413,6 @@ func TestAServiceRecordsTheStateItWasFoundInOnceAndCarriesIt(t *testing.T) {
active := "inactive"
enabled := "disabled"
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch args[0] {
case "show":
return "LoadState=loaded\nActiveState=" + active + "\n", nil
@@ -468,7 +464,6 @@ func TestAUnitThatDoesNotExistIsNotStopped(t *testing.T) {
// Absence read as satisfaction — 04-ISSUES/007 wearing a different hat, and the mirror of
// the degraded-init bug the capability detector had.
absent := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
return "LoadState=not-found\nActiveState=inactive\n", nil
}
d := parse(t, `{"declaration":1,"resources":[
@@ -491,7 +486,6 @@ func TestAMaskedUnitIsRefused(t *testing.T) {
// Masked means someone deliberately made it unstartable. Applying over that would undo a
// decision the host did not make and cannot see the reason for.
masked := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
return "LoadState=masked\nActiveState=inactive\n", nil
}
d := parse(t, `{"declaration":1,"resources":[
@@ -512,7 +506,6 @@ func TestForgettingAUnitThatIsGoneDoesNotStrandTheNode(t *testing.T) {
// already true.
var stopped bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if args[0] == "show" {
return "LoadState=not-found\nActiveState=inactive\n", nil
}
@@ -564,7 +557,6 @@ func TestABrokenPackageDatabaseIsNotReadAsNotInstalled(t *testing.T) {
// answer would silently reinstall on a machine whose package manager is broken, or report
// "installed nothing" as success. The apply must fail instead.
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
return "", errors.New("pacman: error: could not lock database")
}
d := parseTrusted(t, `{"declaration":1,"resources":[
@@ -583,7 +575,6 @@ func TestABrokenPackageDatabaseIsNotReadAsNotInstalled(t *testing.T) {
func TestAnInstalledPackageIsNotReinstalled(t *testing.T) {
var installed bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if args[0] == "-S" {
installed = true
}
@@ -611,7 +602,6 @@ func TestAPackageIsNeverUninstalled(t *testing.T) {
// machine may have had it before the mesh ever saw it. Undeclaring is not "remove it".
var uninstalled bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if len(args) > 0 && (args[0] == "-R" || args[0] == "-Rs") {
uninstalled = true
}
@@ -645,7 +635,6 @@ func TestAnActionThatIsAlreadyTrueDoesNotRun(t *testing.T) {
// database is, so "is it already there" is a question only the declaration can ask.
var ran bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name == "create-db" {
ran = true
}
@@ -672,7 +661,6 @@ func TestAnActionThatSucceedsAndDoesNothingFails(t *testing.T) {
// this repository's most expensive failure shape. Here the command "succeeds" every time
// and verify never passes.
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name == "has-db" {
return "", errors.New("no such database")
}
@@ -699,7 +687,6 @@ func TestAnActionRunsInsideTheContainerItNames(t *testing.T) {
// there is any mesh to ask.
var sawExec bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name == "docker" && args[0] == "exec" && args[1] == "store" {
sawExec = true
return "", nil
@@ -723,7 +710,6 @@ func TestAContainerThatExitsImmediatelyFailsTheApply(t *testing.T) {
// still running. A container whose entrypoint dies satisfies the command exactly as one
// that came up does — which is the read-back rule, in the place it matters most.
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch {
case args[0] == "info":
return "27.0\n", nil
@@ -762,7 +748,6 @@ func TestAContainerWhoseDeclarationChangedIsReplaced(t *testing.T) {
var removed, created bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch args[0] {
case "info":
return "27.0\n", nil
@@ -801,7 +786,6 @@ func TestAContainerThatMatchesIsLeftAlone(t *testing.T) {
var touched bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch args[0] {
case "info":
return "27.0\n", nil
@@ -847,7 +831,6 @@ func TestAContainerIsRecreatedWhenARestartOnResourceChanged(t *testing.T) {
var removed, created bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch args[0] {
case "info":
return "27.0\n", nil
@@ -903,7 +886,6 @@ func TestRestartOnFiresOnlyForResourcesThatChanged(t *testing.T) {
func systemctlStub(t *testing.T, load, active, enabled string, verbs *[]string) Runner {
t.Helper()
return func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch args[0] {
case "show":
return "LoadState=" + load + "\nActiveState=" + active + "\n", nil
@@ -1061,7 +1043,6 @@ func TestTheRuntimeProbeIsPerRuntime(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
var probedWith string
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != tc.present {
return "", errors.New("not installed")
}
@@ -1090,7 +1071,6 @@ func TestAContainerUsesTheRuntimeTheMachineHas(t *testing.T) {
// machine already has (novox/hq research 012), so hardcoding one contradicts it.
var calledWith []string
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name == "docker" {
return "", errors.New("not installed")
}
@@ -1127,7 +1107,6 @@ func TestNoRuntimeIsSaidPlainly(t *testing.T) {
// Naming what was tried, because "docker: command not found" on a machine that deliberately
// runs podman sends the reader looking for the wrong thing.
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
return "", errors.New("not installed")
}
d := parseTrusted(t, `{"declaration":1,"resources":[
@@ -1271,7 +1250,6 @@ func TestAServiceIsNotRestartedByAChangeItDoesNotName(t *testing.T) {
// before and after one.
func recordingServices(commands *[]string) Runner {
return func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
line := name + " " + strings.Join(args, " ")
*commands = append(*commands, line)
switch {
@@ -1420,7 +1398,6 @@ func TestAFailedActionStopsWhatFollows(t *testing.T) {
func TestAContainerIsGivenTheMeshsNames(t *testing.T) {
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != "docker" {
return "", errors.New("not installed")
}
@@ -1456,7 +1433,6 @@ func TestAContainerIsGivenTheMeshsNames(t *testing.T) {
func TestAContainerGivenNoNamesIsRunAsBefore(t *testing.T) {
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != "docker" {
return "", errors.New("not installed")
}
@@ -1501,7 +1477,6 @@ func TestResourcesAreAppliedInTheOrderTheyWereDeclared(t *testing.T) {
var order []string
done := map[string]bool{}
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
order = append(order, name+" "+strings.Join(args, " "))
// `verify` is the idempotency check, so it has to fail before the step and pass after —
// a stub that always succeeds means every action is already done and nothing ever runs,
@@ -1590,12 +1565,6 @@ func TestADirectoryHoldingAnythingTheMeshDidNotPutThereIsKept(t *testing.T) {
for _, o := range report.Outcomes {
if o.Action == "kept" && strings.Contains(o.Detail, "did not put there") {
said = true
// And it says how data goes, which is never by hand on this line's word (novox/hq ADR
// 0233): a person's `cleanup delete`, after a last restore point.
if strings.Contains(o.Detail, "by hand") || !strings.Contains(o.Detail, "never deletes data") ||
!strings.Contains(o.Detail, "cleanup delete") {
t.Errorf("the kept directory is said as %q", o.Detail)
}
}
}
if !said {
@@ -1652,7 +1621,6 @@ func TestAContainerStaleFromAnEarlierApplyIsReplaced(t *testing.T) {
var removed, created bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch args[0] {
case "info":
return "27.0\n", nil
+13 -120
View File
@@ -35,11 +35,11 @@ import (
// enough for a desktop theme and small enough to notice.
const maxArchive = 512 << 20
func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied, away *storeAway) (Outcome, error) {
func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied) (Outcome, error) {
out := begin(r)
out.Action = "unchanged"
body, err := fetchMinding(ctx, r.Source, away)
body, err := fetch(ctx, r.Source)
if err != nil {
return out, err
}
@@ -56,28 +56,26 @@ func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Ap
out.wrote = got
// Already what it should be. The digest is the whole identity of an archive, so a matching
// record means the unpacked tree came from these exact bytes — at this path: a record of the
// same bytes somewhere else says nothing about what is here.
if previous.Wrote == got && previous.Target == r.Path {
// record means the unpacked tree came from these exact bytes.
if previous.Wrote == got {
if _, err := os.Stat(r.Path); err == nil {
owned, err := ownedBy(ownershipProbe(r.Path, previous.Unpacked), r.Owner)
owned, err := ownedBy(r.Path, r.Owner)
if err == nil && owned {
// What it unpacked is carried, or — on a record from before the host kept it — read
// from the archive now, so the record can say it from here on (novox/hq issue 162).
out.unpacked, err = stillUnpacked(body, r.Path, previous.Unpacked)
if err != nil {
return out, err
}
return out, nil
}
}
}
unpacked, written, err := replaceWith(body, r.Path, r.Owner, oursFrom(previous, r.Path))
if err := os.MkdirAll(r.Path, 0o755); err != nil {
return out, err
}
written, err := unpack(body, r.Path)
if err != nil {
return out, err
}
out.unpacked = &unpacked
if err := ownAll(r.Path, r.Owner); err != nil {
return out, err
}
out.Action = "updated"
if previous.Wrote == "" {
out.Action = "created"
@@ -86,111 +84,6 @@ func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Ap
return out, nil
}
// replaceWith makes the directory exactly the archive (novox/hq issue 220), and says what it put
// there (novox/hq issue 162).
//
// **The tree on disk is the archive and nothing else — of what the mesh put there.** The digest is
// the whole identity of what is unpacked here, so a file the previous archive had and this one does
// not must go. Unpacked over the old tree, it stayed: a bundle rebuilt as one file per entrypoint
// kept the package directory of the version before, which code could still import, and a fix that
// removed a file worked on a fresh machine only. So the archive is unpacked into a fresh directory
// beside the old one, owned, and swapped in by rename. A running process keeps the files it has open,
// and the old tree is removed only once the new one is in place. A failed unpack leaves the old tree
// untouched.
//
// **What the mesh did not put there is never swapped away** (novox/hq issue 162, ADR 0030). The
// swap is for a directory that is the host's own: one it made, holding nothing but what the mesh
// put there. A directory that was there before the archive, or that something else has written
// into since, is the machine's: the archive's files are moved into it one by one, what the previous
// archive placed and this one does not is taken out, and everything else is left as it is. A file
// the archive would write over that the mesh did not put there refuses the archive before anything
// is moved — unless it already holds exactly the archive's bytes.
func replaceWith(body []byte, path, owner string, o ours) (store.Unpacked, int, error) {
parent := filepath.Dir(path)
madeParents, err := makeDirsSaying(parent, 0o755, owner)
if err != nil {
return store.Unpacked{}, 0, err
}
parents := joinParents(madeParents, o.parents)
fresh := path + ".unpacking"
replaced := path + ".replaced"
// What an interrupted earlier attempt — or removal — left beside the directory.
for _, leftover := range []string{fresh, replaced, path + ".removing"} {
if err := os.RemoveAll(leftover); err != nil {
return store.Unpacked{}, 0, err
}
}
if err := os.Mkdir(fresh, 0o755); err != nil {
return store.Unpacked{}, 0, err
}
written, err := unpack(body, fresh)
if err == nil {
err = ownAll(fresh, owner)
}
var files, dirs []string
if err == nil {
files, dirs, err = treeOf(fresh)
}
if err != nil {
os.RemoveAll(fresh)
return store.Unpacked{}, written, err
}
info, err := os.Lstat(path)
existed := err == nil
if err != nil && !os.IsNotExist(err) {
os.RemoveAll(fresh)
return store.Unpacked{}, written, err
}
if existed && !info.IsDir() {
// Swapped, it would be deleted: a file at the path is nothing an archive put there.
os.RemoveAll(fresh)
return store.Unpacked{}, written, fmt.Errorf(
"%s is there and is not a directory, and the mesh did not put it there; nothing was unpacked", path)
}
foreign := 0
if existed && !o.all {
if foreign, err = foreignIn(path, o.paths); err != nil {
os.RemoveAll(fresh)
return store.Unpacked{}, written, err
}
}
if existed && (foreign > 0 || !(o.made || o.all)) {
u, err := mergeInto(fresh, path, owner, files, dirs, o)
os.RemoveAll(fresh)
if err != nil {
return store.Unpacked{}, written, err
}
u.Parents = parents
return u, written, nil
}
hadOne := true
if err := os.Rename(path, replaced); err != nil {
if !os.IsNotExist(err) {
os.RemoveAll(fresh)
return store.Unpacked{}, written, err
}
hadOne = false
}
if err := os.Rename(fresh, path); err != nil {
if hadOne {
// Put the old tree back rather than leave nothing at the path.
os.Rename(replaced, path)
}
os.RemoveAll(fresh)
return store.Unpacked{}, written, err
}
// The directory is the host's own: it made it, now or before, and nothing else is in it.
u := store.Unpacked{Files: files, Dirs: dirs, Made: true, Parents: parents}
if hadOne {
if err := os.RemoveAll(replaced); err != nil {
return u, written, fmt.Errorf("%s is in place, and the tree it replaced could not be removed: %w", path, err)
}
}
return u, written, nil
}
func fetch(ctx context.Context, source string) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
if err != nil {
@@ -202,7 +95,7 @@ func fetch(ctx context.Context, source string) ([]byte, error) {
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, statusError{source: source, code: response.StatusCode, status: response.Status}
return nil, fmt.Errorf("%s answered %s", source, response.Status)
}
body, err := io.ReadAll(io.LimitReader(response.Body, maxArchive+1))
if err != nil {
-261
View File
@@ -1,261 +0,0 @@
package apply
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq issue 162: an archive can be undeclared. What it unpacked is gone, anything that
// was in its directory beforehand is still there, and the apply that removed it applied everything
// else in the same declaration.
func exists(path string) bool {
_, err := os.Lstat(path)
return err == nil
}
func archiveDecl(t *testing.T, id, path string, files map[string]string, more string) (string, string) {
t.Helper()
body, digest := anArchive(t, files)
return `{"id":"` + id + `","type":"archive","source":"` + serving(t, body) + `","digest":"` + digest +
`","path":"` + path + `"}` + more, digest
}
func TestUnassigningAnArchiveRemovesExactlyWhatItUnpacked(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "bundles", "notes", "tools")
archive, _ := archiveDecl(t, "notes.tools", target,
map[string]string{"index.js": "x", "lib/one.js": "1", "lib/two.js": "2"},
`,{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"a"}`)
_, state, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if rec, _ := state.Find("notes.tools"); rec.Unpacked == nil || !rec.Unpacked.Made ||
len(rec.Unpacked.Files) != 3 || len(rec.Unpacked.Parents) != 2 {
t.Fatalf("what the archive unpacked was not recorded: %+v", rec.Unpacked)
}
// Unassigned, and the same push declares something new: both happen.
d := declare(t, `{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"a"},
{"id":"other.conf","type":"file","path":"`+dir+`/other.conf","content":"b"}`)
report, state, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatalf("undeclaring an archive stopped the apply: %v", err)
}
if o := outcomeOf(report, "notes.tools"); o.Action != "removed" {
t.Fatalf("the archive: %+v", o)
}
if o := outcomeOf(report, "other.conf"); o.Action != "created" {
t.Fatalf("the rest of the declaration: %+v", o)
}
if _, still := state.Find("notes.tools"); still {
t.Fatal("the archive is still on record")
}
// The directory it was unpacked into and the parents the host made to reach it, all gone.
if exists(filepath.Join(dir, "bundles")) {
t.Fatal("what the host made for the archive is still there")
}
if !exists(filepath.Join(dir, "notes.conf")) {
t.Fatal("a file the archive did not place was removed")
}
if entries, _ := os.ReadDir(dir); len(entries) != 2 {
t.Fatalf("%d entries left in the directory", len(entries))
}
}
func TestADirectoryFoundBeforeTheArchiveKeepsWhatWasInIt(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "powerlevel10k")
if err := os.MkdirAll(filepath.Join(target, "lib"), 0o755); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(target, "mine.zsh"), []byte("somebody's"), 0o644)
os.WriteFile(filepath.Join(target, "lib", "mine.zsh"), []byte("somebody's"), 0o644)
archive, _ := archiveDecl(t, "shell.theme", target,
map[string]string{"p10k.zsh": "theme", "lib/theme.zsh": "lib", "gitstatus/gs": "gs"}, "")
_, state, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
// Applying over a directory that was there keeps what was in it.
for _, kept := range []string{"mine.zsh", "lib/mine.zsh"} {
if got, _ := os.ReadFile(filepath.Join(target, kept)); string(got) != "somebody's" {
t.Fatalf("%s after the apply: %q", kept, got)
}
}
if got, _ := os.ReadFile(filepath.Join(target, "lib", "theme.zsh")); string(got) != "lib" {
t.Fatalf("lib/theme.zsh is %q", got)
}
rec, _ := state.Find("shell.theme")
if rec.Unpacked == nil || rec.Unpacked.Made || strings.Join(rec.Unpacked.Dirs, ",") != "gitstatus" {
t.Fatalf("recorded as %+v", rec.Unpacked)
}
// A new version without one of its files: that file goes, nothing else does.
archive, _ = archiveDecl(t, "shell.theme", target, map[string]string{"p10k.zsh": "theme 2"}, "")
if _, state, err = Apply(context.Background(), archHost(t), declare(t, archive), state,
store.OriginDeclared, noServices, nil, nil); err != nil {
t.Fatal(err)
}
if exists(filepath.Join(target, "lib", "theme.zsh")) || exists(filepath.Join(target, "gitstatus")) {
t.Fatal("the previous version's files are still there")
}
if !exists(filepath.Join(target, "lib", "mine.zsh")) {
t.Fatal("a file the mesh did not put there went with the previous version")
}
report, _, err := Apply(context.Background(), archHost(t), declare(t, `{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`), state,
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(report, "shell.theme"); o.Action != "removed" || !strings.Contains(o.Detail, "there before") {
t.Fatalf("the archive: %+v", o)
}
if exists(filepath.Join(target, "p10k.zsh")) {
t.Fatal("the archive's file is still there")
}
for _, kept := range []string{"mine.zsh", "lib/mine.zsh"} {
if got, _ := os.ReadFile(filepath.Join(target, kept)); string(got) != "somebody's" {
t.Fatalf("%s after the removal: %q", kept, got)
}
}
}
// A file the archive would write over that the mesh did not put there refuses the archive, and the
// directory is left exactly as it was.
func TestAnArchiveDoesNotWriteOverAFileItFound(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "theme")
os.MkdirAll(target, 0o755)
os.WriteFile(filepath.Join(target, "p10k.zsh"), []byte("somebody's"), 0o644)
archive, _ := archiveDecl(t, "shell.theme", target, map[string]string{"p10k.zsh": "theme", "x": "x"}, "")
if _, _, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
store.OriginDeclared, noServices, nil, nil); err == nil || !strings.Contains(err.Error(), "did not put there") {
t.Fatalf("written over: %v", err)
}
if got, _ := os.ReadFile(filepath.Join(target, "p10k.zsh")); string(got) != "somebody's" {
t.Fatalf("p10k.zsh is %q", got)
}
if exists(filepath.Join(target, "x")) {
t.Fatal("part of a refused archive was moved in")
}
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Fatalf("%d entries beside the directory", len(entries))
}
}
func TestADirectoryTheHostMadeGoesOnlyWhenEmpty(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "theme")
archive, _ := archiveDecl(t, "shell.theme", target, map[string]string{"p10k.zsh": "t", "lib/a.zsh": "a"}, "")
_, state, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
// Somebody writes into the directory the host made.
os.WriteFile(filepath.Join(target, "lib", "local.zsh"), []byte("mine"), 0o644)
report, _, err := Apply(context.Background(), archHost(t), declare(t, `{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`), state,
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
o := outcomeOf(report, "shell.theme")
if o.Action != "removed" || !strings.Contains(o.Detail, "did not put there") {
t.Fatalf("the archive: %+v", o)
}
if exists(filepath.Join(target, "p10k.zsh")) || exists(filepath.Join(target, "lib", "a.zsh")) {
t.Fatal("the archive's files are still there")
}
if got, _ := os.ReadFile(filepath.Join(target, "lib", "local.zsh")); string(got) != "mine" {
t.Fatalf("a file the archive did not place: %q", got)
}
}
// An archive recorded before the host kept what it unpacked cannot be told from anything else in
// its directory: it is left in place, said and forgotten, and the apply goes on.
func TestAnArchiveRecordedBeforeItsFilesWereKeptIsLeftAndForgotten(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "tools")
os.MkdirAll(target, 0o755)
os.WriteFile(filepath.Join(target, "index.js"), []byte("x"), 0o644)
known := store.State{Resources: []store.Applied{
{ID: "notes.tools", Type: "archive", Target: target, Wrote: "sha256:old", Origin: store.OriginDeclared},
}}
d := declare(t, `{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"a"}`)
report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatalf("an archive from before stopped the apply: %v", err)
}
if o := outcomeOf(report, "notes.tools"); o.Action != "forgotten" || !strings.Contains(o.Detail, "left in place") {
t.Fatalf("the archive: %+v", o)
}
if o := outcomeOf(report, "notes.conf"); o.Action != "created" {
t.Fatalf("the rest of the declaration: %+v", o)
}
if _, still := state.Find("notes.tools"); still {
t.Fatal("still on record")
}
if !exists(filepath.Join(target, "index.js")) {
t.Fatal("removed without a record of what it unpacked")
}
}
// One recorded before, still declared and unchanged, is read from its own bytes on the next apply,
// so it can be undeclared from then on: the whole directory when it holds exactly the archive, only
// the archive's files when it holds anything else.
func TestAnArchiveRecordedBeforeLearnsWhatItUnpacked(t *testing.T) {
for _, extra := range []bool{false, true} {
dir := t.TempDir()
target := filepath.Join(dir, "tools")
files := map[string]string{"index.js": "x", "lib/a.js": "a"}
archive, digest := archiveDecl(t, "notes.tools", target, files, "")
for name, body := range files {
os.MkdirAll(filepath.Dir(filepath.Join(target, name)), 0o755)
os.WriteFile(filepath.Join(target, name), []byte(body), 0o644)
}
if extra {
os.WriteFile(filepath.Join(target, "lib", "local.js"), []byte("mine"), 0o644)
}
known := store.State{Resources: []store.Applied{
{ID: "notes.tools", Type: "archive", Target: target, Wrote: digest, Origin: store.OriginDeclared},
}}
report, state, err := Apply(context.Background(), archHost(t), declare(t, archive), known,
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(report, "notes.tools"); o.Action != "unchanged" {
t.Fatalf("an unchanged archive was %s", o.Action)
}
rec, _ := state.Find("notes.tools")
if rec.Unpacked == nil || len(rec.Unpacked.Files) != 2 || rec.Unpacked.Made == extra {
t.Fatalf("extra=%v: learned %+v", extra, rec.Unpacked)
}
if _, _, err := Apply(context.Background(), archHost(t), declare(t, `{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`), state,
store.OriginDeclared, noServices, nil, nil); err != nil {
t.Fatal(err)
}
if exists(filepath.Join(target, "index.js")) || exists(filepath.Join(target, "lib", "a.js")) {
t.Fatalf("extra=%v: the archive's files are still there", extra)
}
if exists(target) != extra {
t.Fatalf("extra=%v: the directory is there: %v", extra, exists(target))
}
if extra && !exists(filepath.Join(target, "lib", "local.js")) {
t.Fatal("a file the archive did not place was removed")
}
}
}
-68
View File
@@ -1,68 +0,0 @@
package apply
import (
"context"
"os"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// novox/hq issue 220: the tree on disk is exactly the archive. A file the previous archive had and
// this one does not is gone, and nothing is left beside the directory.
func TestAnArchiveReplacesTheTreeItWasUnpackedOver(t *testing.T) {
dir := t.TempDir()
first, firstDigest := anArchive(t, map[string]string{"index.js": "old", "node_modules/dep/index.js": "dep"})
d := declare(t, `{"id":"bundle-tools","type":"archive","source":"`+serving(t, first)+
`","digest":"`+firstDigest+`","path":"`+dir+`/tools"}`)
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
second, secondDigest := anArchive(t, map[string]string{"index.js": "one file"})
d = declare(t, `{"id":"bundle-tools","type":"archive","source":"`+serving(t, second)+
`","digest":"`+secondDigest+`","path":"`+dir+`/tools"}`)
if _, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, noServices, nil, nil); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(dir + "/tools/index.js"); string(got) != "one file" {
t.Fatalf("index.js is %q", got)
}
if _, err := os.Stat(dir + "/tools/node_modules"); err == nil {
t.Fatal("the previous archive's directory is still there")
}
entries, _ := os.ReadDir(dir)
if len(entries) != 1 {
names := []string{}
for _, e := range entries {
names = append(names, e.Name())
}
t.Fatalf("beside the tree: %v", names)
}
}
// A tree is replaced only by one that unpacked whole: an archive refused halfway leaves the old
// tree as it was.
func TestARefusedArchiveLeavesTheTreeItWouldHaveReplaced(t *testing.T) {
dir := t.TempDir()
first, firstDigest := anArchive(t, map[string]string{"index.js": "old"})
d := declare(t, `{"id":"bundle-tools","type":"archive","source":"`+serving(t, first)+
`","digest":"`+firstDigest+`","path":"`+dir+`/tools"}`)
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
bad, badDigest := anArchive(t, map[string]string{"index.js": "new", "../../escaped": "no"})
d = declare(t, `{"id":"bundle-tools","type":"archive","source":"`+serving(t, bad)+
`","digest":"`+badDigest+`","path":"`+dir+`/tools"}`)
if _, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, noServices, nil, nil); err == nil {
t.Fatal("an escaping archive was accepted")
}
if got, _ := os.ReadFile(dir + "/tools/index.js"); string(got) != "old" {
t.Fatalf("index.js is %q after a refused archive", got)
}
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Fatalf("%d entries beside the tree after a refused archive", len(entries))
}
}
-506
View File
@@ -1,506 +0,0 @@
package apply
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/novox/mesh-host/internal/store"
)
// What an archive put on the machine, and taking exactly that away (novox/hq issue 162).
//
// An archive unpacks many files into a directory the mesh did not necessarily make, so undeclaring
// one has a real question in it: remove what the archive put there, or remove the directory? The
// second deletes whatever else lives there — for the host's own versions directory, every other
// delivered version. So the host records what each archive unpacked and whether it made the
// directory, and removal takes away exactly that: the files the archive placed, then the
// directories the host made for them once they are empty. Never a file the archive did not place,
// never a directory that was there before, never one that still holds anything else. It is the
// rule every other kind follows: the mesh gives back what it found (ADR 0118), and data outlives
// the mesh that declared it (ADR 0030).
// ours is what of the tree at an archive's path the record says is the mesh's.
type ours struct {
// all is a record from before the host kept what an archive unpacked: since the swap of issue
// 220 the tree at the path was the archive and nothing else, so the whole of it is taken for
// the mesh's, as the swap that follows has always taken it.
all bool
// paths are the files and directories the previous archive put there, relative to the path.
paths map[string]bool
files []string
dirs []string
made bool
// parents are the directories above the path the host made to reach it, deepest first.
parents []string
}
// oursFrom reads the record of the archive before this apply, for this path only: a record of the
// same archive at a path it has moved from says nothing about what is at the new one.
func oursFrom(previous store.Applied, path string) ours {
if previous.Wrote == "" || previous.Target != path {
return ours{}
}
u := previous.Unpacked
if u == nil {
return ours{all: true, made: true}
}
o := ours{paths: map[string]bool{}, files: u.Files, dirs: u.Dirs, made: u.Made, parents: u.Parents}
for _, rel := range append(append([]string{}, u.Files...), u.Dirs...) {
o.paths[rel] = true
}
return o
}
// ownershipProbe is what says whether an archive is still its owner's. The directory, when the host
// made it; one of the archive's own files when the directory was there before — that one is held as
// found (ADR 0182), so its owner is never the archive's to judge.
func ownershipProbe(path string, u *store.Unpacked) string {
if u != nil && !u.Made && len(u.Files) > 0 {
return filepath.Join(path, u.Files[0])
}
return path
}
// stillUnpacked is what an unchanged archive has on the machine. The record's, when it has one; on
// a record from before the host kept it, read from the archive's own bytes now — and the directory
// is taken for the host's only when it holds exactly the archive and nothing else, which is what the
// swap of issue 220 leaves. Otherwise the directory is kept for somebody's, and only the archive's
// files are recorded as its.
func stillUnpacked(body []byte, path string, recorded *store.Unpacked) (*store.Unpacked, error) {
if recorded != nil {
kept := *recorded
return &kept, nil
}
files, dirs, err := listArchive(body)
if err != nil {
return nil, err
}
u := &store.Unpacked{Files: pathsOf(files)}
if exactly, err := holdsExactly(path, files, dirs); err == nil && exactly {
u.Dirs = pathsOf(dirs)
u.Made = true
}
return u, nil
}
// listArchive reads what an archive holds without unpacking it: each file's digest by its path, and
// every directory, named or implied, relative to where it unpacks. Refused on the same terms as
// unpack, so a listing never names a path an unpack would not write.
func listArchive(body []byte) (map[string]string, map[string]bool, error) {
zipped, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return nil, nil, fmt.Errorf("this is not a gzipped tar: %w", err)
}
defer zipped.Close()
files, dirs := map[string]string{}, map[string]bool{}
reader := tar.NewReader(zipped)
for {
header, err := reader.Next()
if err == io.EOF {
return files, dirs, nil
}
if err != nil {
return nil, nil, err
}
rel := filepath.Clean(header.Name)
if rel == "." {
continue
}
if !insideRel(rel) {
return nil, nil, fmt.Errorf("%s names a path outside the archive", header.Name)
}
for d := filepath.Dir(rel); d != "."; d = filepath.Dir(d) {
dirs[filepath.ToSlash(d)] = true
}
switch header.Typeflag {
case tar.TypeDir:
dirs[filepath.ToSlash(rel)] = true
case tar.TypeReg:
sum := sha256.New()
if _, err := io.Copy(sum, io.LimitReader(reader, maxArchive)); err != nil {
return nil, nil, err
}
files[filepath.ToSlash(rel)] = hex.EncodeToString(sum.Sum(nil))
default:
return nil, nil, fmt.Errorf("%s is a %c, and this host unpacks only files and directories",
header.Name, header.Typeflag)
}
}
}
// holdsExactly is whether a directory holds the archive's files with the archive's bytes, its
// directories, and nothing else.
func holdsExactly(root string, files map[string]string, dirs map[string]bool) (bool, error) {
seen := 0
exact := true
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
rel = filepath.ToSlash(rel)
switch {
case d.IsDir():
if !dirs[rel] {
exact = false
return filepath.SkipAll
}
case d.Type().IsRegular():
want, ok := files[rel]
if !ok || digestOfFile(path) != want {
exact = false
return filepath.SkipAll
}
seen++
default:
exact = false
return filepath.SkipAll
}
return nil
})
if err != nil {
return false, err
}
return exact && seen == len(files), nil
}
func digestOfFile(path string) string {
file, err := os.Open(path)
if err != nil {
return ""
}
defer file.Close()
sum := sha256.New()
if _, err := io.Copy(sum, file); err != nil {
return ""
}
return hex.EncodeToString(sum.Sum(nil))
}
// treeOf is every file and directory under root, relative to it, slash-separated and sorted.
func treeOf(root string) (files, dirs []string, err error) {
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil || rel == "." {
return err
}
if d.IsDir() {
dirs = append(dirs, filepath.ToSlash(rel))
} else {
files = append(files, filepath.ToSlash(rel))
}
return nil
})
sort.Strings(files)
sort.Strings(dirs)
if files == nil {
files = []string{}
}
return files, dirs, err
}
// foreignIn counts what under root the mesh did not put there. A directory that is not the mesh's
// counts once, with everything in it.
func foreignIn(root string, mine map[string]bool) (int, error) {
count := 0
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil || rel == "." {
return err
}
if !mine[filepath.ToSlash(rel)] {
count++
if d.IsDir() {
return filepath.SkipDir
}
}
return nil
})
return count, err
}
// mergeInto moves a freshly unpacked archive into a directory that is not only the mesh's, one entry
// at a time, and takes out what the previous archive placed that this one does not. Everything the
// mesh did not put there stays as it is. Collisions are looked for before anything is moved, so a
// refused archive leaves the directory exactly as it was.
func mergeInto(fresh, path, owner string, files, dirs []string, o ours) (store.Unpacked, error) {
var collisions []string
for _, rel := range dirs {
info, err := os.Lstat(filepath.Join(path, filepath.FromSlash(rel)))
if err == nil && !info.IsDir() && !o.paths[rel] {
collisions = append(collisions, rel)
}
}
for _, rel := range files {
dest := filepath.Join(path, filepath.FromSlash(rel))
info, err := os.Lstat(dest)
switch {
case err != nil:
case o.paths[rel] && !info.IsDir():
case info.IsDir():
if !o.paths[rel] {
collisions = append(collisions, rel)
} else if n, err := foreignIn(dest, o.paths); err != nil || n > 0 {
collisions = append(collisions, rel)
}
case !info.Mode().IsRegular() ||
digestOfFile(dest) != digestOfFile(filepath.Join(fresh, filepath.FromSlash(rel))):
// Already holding exactly the archive's bytes is not a collision: it is what an
// interrupted earlier apply of this same archive left, or the same file either way.
collisions = append(collisions, rel)
}
}
if len(collisions) > 0 {
shown := collisions
if len(shown) > 5 {
shown = shown[:5]
}
return store.Unpacked{}, fmt.Errorf("%s already holds %d path(s) the archive would write over "+
"and the mesh did not put there (%s); nothing was unpacked, and what is there is left as it "+
"is (novox/hq issue 162)", path, len(collisions), strings.Join(shown, ", "))
}
var made []string
for _, rel := range dirs {
dest := filepath.Join(path, filepath.FromSlash(rel))
info, err := os.Lstat(dest)
if err == nil && info.IsDir() {
if o.paths[rel] {
made = append(made, rel)
}
continue
}
if err == nil {
// The previous archive's file where this one has a directory.
if err := os.Remove(dest); err != nil {
return store.Unpacked{}, err
}
}
mode := os.FileMode(0o755)
if from, err := os.Stat(filepath.Join(fresh, filepath.FromSlash(rel))); err == nil {
mode = from.Mode().Perm()
}
if err := os.Mkdir(dest, mode); err != nil {
return store.Unpacked{}, err
}
if err := own(dest, owner); err != nil {
return store.Unpacked{}, err
}
made = append(made, rel)
}
for _, rel := range files {
dest := filepath.Join(path, filepath.FromSlash(rel))
if info, err := os.Lstat(dest); err == nil && info.IsDir() {
// The previous archive's directory where this one has a file, holding nothing else.
if err := os.RemoveAll(dest); err != nil {
return store.Unpacked{}, err
}
}
if err := os.Rename(filepath.Join(fresh, filepath.FromSlash(rel)), dest); err != nil {
return store.Unpacked{}, err
}
}
// What the previous archive placed and this one does not.
now := map[string]bool{}
for _, rel := range append(append([]string{}, files...), dirs...) {
now[rel] = true
}
for _, rel := range o.files {
if now[rel] {
continue
}
if err := os.Remove(filepath.Join(path, filepath.FromSlash(rel))); err != nil && !os.IsNotExist(err) {
return store.Unpacked{}, err
}
}
for _, rel := range deepestFirst(o.dirs) {
if now[rel] {
continue
}
dest := filepath.Join(path, filepath.FromSlash(rel))
if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
// Still holding something the mesh did not put there: kept, and still the host's to
// take away once it is empty.
made = append(made, rel)
}
}
sort.Strings(made)
return store.Unpacked{Files: files, Dirs: made, Made: o.made}, nil
}
// removeArchive is what undeclaring an archive does (novox/hq issue 162): exactly the files it
// unpacked, then the directories the host made for them once they are empty.
//
// **Never fatal.** An archive that could not be removed stopped the whole apply, on every apply
// after, until it was declared again — so every module with tools was un-unassignable, and a race
// between two pushes froze a machine against every other change. Whatever cannot be taken away is
// said, left in place, and forgotten, as a former target is (issue 194).
//
// **Removed whole when it is the host's own, and in one step.** A directory the host made that
// holds nothing but the archive is renamed aside and then removed: a reader — the runtime serving a
// module's tools from its bundle — sees the whole tree or none of it, never half, and a file it has
// open stays readable until it closes it. The runtime is told the module went by its own membership,
// not by the files disappearing.
func removeArchive(a store.Applied) (string, string, error) {
if store.IsFormer(a.ID) {
// The version before is what a rollback starts (ADR 0141) and what a reader may still have
// open; the launcher retires the host's own versions, not the apply (issue 194).
return "forgotten", "a former target left in place: only an archive the declaration dropped is " +
"taken away (novox/hq issues 162, 194)", nil
}
u := a.Unpacked
if u == nil {
return "forgotten", "left in place: recorded before the host kept what an archive unpacked, so " +
"its files cannot be told from anything else there (novox/hq issue 162)", nil
}
root := filepath.Clean(a.Target)
mine := map[string]bool{}
for _, rel := range append(append([]string{}, u.Files...), u.Dirs...) {
if !insideRel(filepath.FromSlash(rel)) {
return "forgotten", fmt.Sprintf("left in place: its record names %q, which is not inside %s",
rel, root), nil
}
mine[rel] = true
}
info, err := os.Lstat(root)
if os.IsNotExist(err) {
removeParents(root, u.Parents)
return "forgotten", "no longer there", nil
}
if err != nil {
return "forgotten", fmt.Sprintf("left in place: %v", err), nil
}
if !info.IsDir() {
return "forgotten", "left in place: no longer a directory, so not what the archive was unpacked into", nil
}
foreign, err := foreignIn(root, mine)
if err != nil {
return "forgotten", fmt.Sprintf("left in place: cannot read what is in it: %v", err), nil
}
if u.Made && foreign == 0 {
aside := root + ".removing"
if err := os.RemoveAll(aside); err == nil {
if err := os.Rename(root, aside); err == nil {
if err := os.RemoveAll(aside); err != nil {
return "forgotten", fmt.Sprintf("taken out of place, and what it unpacked could not be "+
"removed from %s: %v — remove it by hand", aside, err), nil
}
removeParents(root, u.Parents)
return "removed", fmt.Sprintf("no longer declared; the %d file(s) it unpacked, and the "+
"directory the host made for them", len(u.Files)), nil
}
}
// A rename that could not be made is taken file by file instead.
}
removed := 0
var failed []string
for _, rel := range u.Files {
err := os.Remove(filepath.Join(root, filepath.FromSlash(rel)))
switch {
case err == nil:
removed++
case os.IsNotExist(err):
default:
failed = append(failed, err.Error())
}
}
for _, rel := range deepestFirst(u.Dirs) {
// Only once empty: what is still inside is somebody's.
_ = os.Remove(filepath.Join(root, filepath.FromSlash(rel)))
}
detail := fmt.Sprintf("no longer declared; %d file(s) it unpacked removed", removed)
switch {
case u.Made && os.Remove(root) == nil:
removeParents(root, u.Parents)
detail += ", and the directory the host made for them"
case u.Made:
left, _ := os.ReadDir(root)
detail += fmt.Sprintf("; the directory is kept: %d item(s) inside that the mesh did not put there",
len(left))
default:
detail += "; the directory is kept: it was there before the archive"
}
if len(failed) > 0 {
// Said and not fatal: fatal, the record would stay and fail the same way on every apply
// after — the very wedge this removal exists to end.
return "forgotten", detail + "; could not remove, and left in place: " + strings.Join(failed, "; "), nil
}
return "removed", detail, nil
}
// removeParents takes away the directories above an archive the host made to reach it, deepest
// first, each only once it is empty and only if it is above the archive's directory.
func removeParents(root string, parents []string) {
for _, p := range parents {
clean := filepath.Clean(p)
if !filepath.IsAbs(clean) || !strings.HasPrefix(root, clean+string(os.PathSeparator)) {
continue
}
_ = os.Remove(clean)
}
}
// joinParents is the parents made now and those recorded before, deepest first, once each.
func joinParents(now, before []string) []string {
seen := map[string]bool{}
var out []string
for _, p := range append(append([]string{}, now...), before...) {
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
sort.Slice(out, func(i, j int) bool { return len(out[i]) > len(out[j]) })
return out
}
// insideRel is whether a relative path stays inside the directory it is relative to.
func insideRel(rel string) bool {
clean := filepath.Clean(rel)
return clean != "." && !filepath.IsAbs(clean) && clean != ".." &&
!strings.HasPrefix(clean, ".."+string(os.PathSeparator))
}
func deepestFirst(rels []string) []string {
out := append([]string{}, rels...)
sort.Slice(out, func(i, j int) bool {
return strings.Count(out[i], "/") > strings.Count(out[j], "/") ||
(strings.Count(out[i], "/") == strings.Count(out[j], "/") && out[i] > out[j])
})
return out
}
func pathsOf[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
+1 -1
View File
@@ -183,7 +183,7 @@ func applyBlock(r *declaration.File, previous store.Applied) (Outcome, error) {
} else if mode, err = modeOf(r.Mode, mode); err != nil {
return out, err
}
if err := makeDirs(filepath.Dir(real), 0o755, r.Owner); err != nil {
if err := os.MkdirAll(filepath.Dir(real), 0o755); err != nil {
return out, err
}
if err := writeAtomically(real, []byte(next), mode); err != nil {
-180
View File
@@ -1,180 +0,0 @@
package apply
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// novox/hq ADR 0223: the machine's resolver file moves from the module that wrote it to the uplink's
// holder, and the two records meet in one apply — the old undeclared, the new declared at the same
// path. Every orphan is removed before anything is applied, so without a handover the file was
// deleted (or given back the original the mesh once wrote over) and every resource applied before the
// new one ran on a machine without it. Handed over, the file is never absent, the record going is
// forgotten only once the new one is recorded, and what was kept of the machine's original goes with
// the file to its new owner.
const (
theOriginal = "nameserver 192.0.2.53\n"
firstOwners = "# the mesh, as the first module wrote it\nnameserver 10.42.0.1\n"
newOwners = "# the mesh, as the uplink writes it\nnameserver 10.42.0.1\nnameserver 10.42.0.3\n"
)
// heldBy is a declaration whose file at path is the module's, with a service declared ahead of it
// so the apply does something on the machine before the file's turn comes.
func heldBy(t *testing.T, path, module, content string) string {
t.Helper()
return fmt.Sprintf(`{"declaration":1,"resources":[
{"id":"%[1]s.service","type":"service","unit":"%[1]s.service","state":"running"},
{"id":"%[1]s.fact-resolvers","type":"file","path":%[2]q,"mode":"0644","content":%[3]q}
]}`, module, path, content)
}
// watching is a service manager that answers every unit running, and fails the test whenever the
// file is not there while it is asked something — the moment between an orphan's removal and the
// new owner's turn.
func watching(t *testing.T, path string) Runner {
t.Helper()
var commands []string
services := recordingServices(&commands)
return func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if _, err := os.Stat(path); err != nil {
t.Errorf("the file was not there while %s %s ran: %v", name, strings.Join(args, " "), err)
}
return services(ctx, name, args...)
}
}
func TestAWholeFileIsHandedToItsNewOwnerNotRemoved(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "resolv.conf")
if err := os.WriteFile(path, []byte(theOriginal), 0o644); err != nil {
t.Fatal(err)
}
keep := KeepIn(filepath.Join(dir, "kept"))
apply := func(raw string, known store.State) (Report, store.State) {
t.Helper()
report, state, err := ApplyKeeping(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, watching(t, path), nil, nil, keep)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
return report, state
}
// The first owner writes over the machine's file, keeping the original.
_, state := apply(heldBy(t, path, "resolv-conf", firstOwners), store.State{})
first, _ := state.Find("resolv-conf.fact-resolvers")
if first.Kept == "" {
t.Fatal("the machine's original was not kept before the first write")
}
// The uplink takes it over in one apply.
report, state := apply(heldBy(t, path, "networkmanager", newOwners), state)
if got, _ := os.ReadFile(path); string(got) != newOwners {
t.Fatalf("after the handover the file holds %q", got)
}
for _, o := range report.Outcomes {
if o.ID == "resolv-conf.fact-resolvers" && o.Action != "forgotten" {
t.Errorf("the record going was %q, not forgotten: %s", o.Action, o.Detail)
}
}
if _, still := state.Find("resolv-conf.fact-resolvers"); still {
t.Error("the record going is still recorded after its file was handed over")
}
now, _ := state.Find("networkmanager.fact-resolvers")
if now.Kept != first.Kept {
t.Errorf("the new owner keeps the original at %q; the first kept it at %q", now.Kept, first.Kept)
}
if kept, _ := os.ReadFile(now.Kept); string(kept) != theOriginal {
t.Errorf("what the new owner would give back is %q, not the machine's original", kept)
}
if aside, _ := filepath.Glob(path + ".removed-*"); len(aside) > 0 {
t.Errorf("the file was moved aside on the way: %v", aside)
}
// Steady from here, and undeclared the machine's original comes back — not the first owner's.
report, state = apply(heldBy(t, path, "networkmanager", newOwners), state)
for _, o := range report.Outcomes {
if o.ID == "networkmanager.fact-resolvers" && o.Action != "unchanged" {
t.Errorf("a second apply was %q: %s", o.Action, o.Detail)
}
}
if _, _, err := ApplyKeeping(context.Background(), archHost(t), somethingElse(t), state,
store.OriginDeclared, recordingServices(new([]string)), nil, nil, keep); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(path); string(got) != theOriginal {
t.Errorf("undeclaring the new owner left %q; the machine's original goes back", got)
}
}
// A file the mesh made where there was none is handed over the same way, and is still the mesh's —
// undeclared, it goes.
func TestAFileTheMeshMadeIsHandedOverAndStillGoesWithItsLastOwner(t *testing.T) {
path := filepath.Join(t.TempDir(), "resolv.conf")
run := watching(t, path)
_, state, err := Apply(context.Background(), archHost(t), parse(t, heldBy(t, path, "resolv-conf", firstOwners)),
store.State{}, store.OriginDeclared, recordingServices(new([]string)), nil, nil)
if err != nil {
t.Fatal(err)
}
_, state, err = Apply(context.Background(), archHost(t), parse(t, heldBy(t, path, "dhcpcd", newOwners)),
state, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(path); string(got) != newOwners {
t.Fatalf("after the handover the file holds %q", got)
}
if _, _, err := Apply(context.Background(), archHost(t), somethingElse(t), state, store.OriginDeclared,
recordingServices(new([]string)), nil, nil); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("the mesh's own file stayed after its last owner was undeclared: %v", err)
}
}
// A file edited on the machine since the first owner wrote it is still corrected and said so by the
// new owner: the handover judges drift against what this host last wrote there.
func TestAHandedOverFileChangedOnTheMachineIsSaidCorrected(t *testing.T) {
path := filepath.Join(t.TempDir(), "resolv.conf")
_, state, err := Apply(context.Background(), archHost(t), parse(t, heldBy(t, path, "resolv-conf", firstOwners)),
store.State{}, store.OriginDeclared, recordingServices(new([]string)), nil, nil)
if err != nil {
t.Fatal(err)
}
_ = os.WriteFile(path, []byte("nameserver 1.1.1.1\n"), 0o644)
report, _, err := Apply(context.Background(), archHost(t), parse(t, heldBy(t, path, "systemd-networkd", newOwners)),
state, store.OriginDeclared, recordingServices(new([]string)), nil, nil)
if err != nil {
t.Fatal(err)
}
for _, o := range report.Outcomes {
if o.ID == "systemd-networkd.fact-resolvers" && o.Action != "corrected" {
t.Errorf("a handed-over file changed on the machine was %q: %s", o.Action, o.Detail)
}
}
}
// The preview says the same before it happens.
func TestThePlanHandsAFileOverRatherThanRemovingIt(t *testing.T) {
path := filepath.Join(t.TempDir(), "resolv.conf")
_, state, err := Apply(context.Background(), archHost(t), parse(t, heldBy(t, path, "resolv-conf", firstOwners)),
store.State{}, store.OriginDeclared, recordingServices(new([]string)), nil, nil)
if err != nil {
t.Fatal(err)
}
for _, step := range Plan(parse(t, heldBy(t, path, "networkmanager", newOwners)), state, store.OriginDeclared) {
if step.ID == "resolv-conf.fact-resolvers" && (step.Verb != "forget" || !strings.Contains(step.Why, "networkmanager.fact-resolvers")) {
t.Errorf("the plan would %s the file being handed over: %s", step.Verb, step.Why)
}
}
}
+13 -7
View File
@@ -10,12 +10,11 @@ import (
// The host's own former archive stops nothing (novox/hq issue 194). A new host's first apply finds
// the version before it as a former target of the archive that delivered it; an archive has no
// removal then (issue 162), and the refusal stopped every machine applying anything. A former
// target of an archive is left in place, said, and forgotten: the version before is what a rollback
// starts (ADR 0141).
// removal (issue 162), and the refusal stopped every machine applying anything. A former target of
// such a kind is left in place, said, and forgotten. An archive the declaration dropped still fails,
// as 162 has it.
func TestTheHostsOwnFormerArchiveIsLeftInPlaceNotFatal(t *testing.T) {
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name == "docker" && args[0] == "info" {
return "29.0.0\n", nil
}
@@ -63,7 +62,14 @@ func TestTheHostsOwnFormerArchiveIsLeftInPlaceNotFatal(t *testing.T) {
t.Fatalf("the rest of the declaration was not applied: %+v", report.Outcomes)
}
// An archive the declaration dropped is taken away since issue 162; one recorded before the
// host kept what it unpacked is left in place and forgotten, never fatal — that case is
// archive_removal_test.go's.
// An archive the declaration dropped is a different matter: nothing can undo it, and saying
// it was would report an effect the host declined to have (issue 162).
dropped := store.State{Resources: []store.Applied{
{ID: "tool.next", Type: "archive", Target: "/usr/lib/tool/versions/old", Origin: store.OriginDeclared},
}}
only := parse(t, `{"declaration":1,"resources":[{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"x"}]}`)
if _, _, err := Apply(context.Background(), archHost(t), only, dropped, store.OriginDeclared, run, nil, nil); err == nil ||
!strings.Contains(err.Error(), "no way to remove") {
t.Fatalf("a dropped archive was passed over: %v", err)
}
}
-257
View File
@@ -1,257 +0,0 @@
package apply
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/store"
)
// A directory used as found is never quiet, and a person at the machine can hand it over (novox/hq issue 339).
func TestAFoundDirectoryIsSaidAndCanBeHandedOver(t *testing.T) {
dir := t.TempDir()
found := filepath.Join(dir, "found")
if err := os.Mkdir(found, 0o700); err != nil {
t.Fatal(err)
}
decl := `{"id":"notes.data","type":"directory","path":"` + found + `","mode":"0755"}`
var said []string
log := func(line string) { said = append(said, line) }
_, state, err := Apply(context.Background(), archHost(t), declare(t, decl), store.State{}, store.OriginDeclared,
noServices, log, nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(said, "\n"), "WARNING notes.data") {
t.Fatalf("the apply did not warn of a directory used as found: %q", said)
}
if a, _ := state.Find("notes.data"); !a.AsFound || !strings.Contains(a.Declared, "755") {
t.Fatalf("the record does not say what was declared: %+v", a)
}
// Said again at the next apply: a warning once is a warning missed.
said = nil
if _, state, err = Apply(context.Background(), archHost(t), declare(t, decl), state, store.OriginDeclared,
noServices, log, nil); err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(said, "\n"), "WARNING notes.data") {
t.Fatalf("the next apply did not warn again: %q", said)
}
// Handed over: the next apply gives it the declared mode and it is the mesh's.
before := HandedOver
HandedOver = func() map[string]bool { return map[string]bool{handOverKey("notes.data", found): true} }
t.Cleanup(func() { HandedOver = before })
if _, state, err = Apply(context.Background(), archHost(t), declare(t, decl), state, store.OriginDeclared,
noServices, nil, nil); err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(found); info.Mode().Perm() != 0o755 {
t.Fatalf("a directory handed over was not given its declared mode: %o", info.Mode().Perm())
}
if a, _ := state.Find("notes.data"); a.AsFound {
t.Fatal("a directory handed over is still recorded as found")
}
}
// The review's additions to the guard: the spool, /opt, the container runtimes' data wherever the runtime keeps
// it, and an account's .ssh as an access or a mount, or as a directory anywhere but its own home.
func TestTheRuntimesDataAndAnySSHAreRefused(t *testing.T) {
cfg := filepath.Join(t.TempDir(), "daemon.json")
if err := os.WriteFile(cfg, []byte(`{"data-root":"/storage/docker"}`), 0o644); err != nil {
t.Fatal(err)
}
before := runtimeConfigs
runtimeConfigs = runtimeFiles{daemonJSON: cfg}
t.Cleanup(func() { runtimeConfigs = before })
for _, kind := range []placing{placingDirectory, placingAccess, placingMount} {
for _, path := range []string{"/var/spool/cron", "/opt/app", "/var/lib/docker/volumes/x",
"/var/lib/containers/storage", "/storage/docker", "/storage/docker/overlay2"} {
if err := refusePath(path, kind, "notes", ""); err == nil {
t.Errorf("%v at %s was taken", kind, path)
}
}
}
for _, path := range []string{"/srv/backup/.ssh", "/srv/backup/.ssh/keys"} {
for _, kind := range []placing{placingDirectory, placingAccess, placingMount} {
if err := refusePath(path, kind, "notes", ""); err == nil {
t.Errorf("%v at %s was taken", kind, path)
}
}
}
if err := refusePath("/storage/dockerish", placingAccess, "notes", ""); err != nil {
t.Errorf("a path beside the data root: %v", err)
}
}
// When a directory was found is kept from the first apply that found it (novox/hq issue 339): the controller
// tells a directory a send found from one found before it by that time, so it must not move with every apply.
func TestWhenADirectoryWasFoundIsKept(t *testing.T) {
dir := t.TempDir()
found := filepath.Join(dir, "found")
if err := os.Mkdir(found, 0o700); err != nil {
t.Fatal(err)
}
decl := `{"id":"notes.data","type":"directory","path":"` + found + `","mode":"0755"}`
_, state, err := Apply(context.Background(), archHost(t), declare(t, decl), store.State{}, store.OriginDeclared,
noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
first, _ := state.Find("notes.data")
if first.FoundAt.IsZero() {
t.Fatal("no time the directory was found")
}
time.Sleep(10 * time.Millisecond)
_, state, err = Apply(context.Background(), archHost(t), declare(t, decl), state, store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if again, _ := state.Find("notes.data"); !again.FoundAt.Equal(first.FoundAt) {
t.Fatalf("found at %v, then %v: the time moved with the apply", first.FoundAt, again.FoundAt)
}
}
// A hand-over is for the resource that was used as found there: another declaration that later points another
// resource at the same path is not handed anything (novox/hq issue 339).
func TestAHandOverIsForTheResourceThatAskedForIt(t *testing.T) {
dir := t.TempDir()
found := filepath.Join(dir, "found")
if err := os.Mkdir(found, 0o700); err != nil {
t.Fatal(err)
}
before := HandedOver
HandedOver = func() map[string]bool { return map[string]bool{handOverKey("notes.data", found): true} }
t.Cleanup(func() { HandedOver = before })
other := `{"id":"other.data","type":"directory","path":"` + found + `","mode":"0755"}`
if _, _, err := Apply(context.Background(), archHost(t), declare(t, other), store.State{}, store.OriginDeclared,
noServices, nil, nil); err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(found); info.Mode().Perm() != 0o700 {
t.Fatalf("a hand-over for notes.data gave other.data the directory: mode %o", info.Mode().Perm())
}
}
// **A handed-over directory is changed through no link, at the moment of use** (review of issue 356). The
// hand-over refused a link when it was recorded; between then and the apply its parent is swapped for a link to
// a victim tree. The apply refuses, and the victim's directory keeps its mode — and so does a directory whose own
// name became a link, and one whose grandparent did.
func TestAHandedOverDirectoryIsChangedThroughNoLink(t *testing.T) {
for _, c := range []struct {
swap string
applyOnce bool
}{{"parent", false}, {"grandparent", false}, {"itself", false}, {"parent", true}, {"grandparent", true}} {
swap, applyOnce := c.swap, c.applyOnce
name := swap
if applyOnce {
name += " after an apply gave it its owner"
}
t.Run(name, func(t *testing.T) {
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
found := filepath.Join(dir, "top", "parent", "data")
if err := os.MkdirAll(found, 0o700); err != nil {
t.Fatal(err)
}
decl := `{"id":"notes.data","type":"directory","path":"` + found + `","mode":"0755"}`
_, state, err := Apply(context.Background(), archHost(t), declare(t, decl), store.State{}, store.OriginDeclared,
noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if a, _ := state.Find("notes.data"); !a.AsFound {
t.Fatalf("not used as found: %+v", a)
}
before := HandedOver
HandedOver = func() map[string]bool { return map[string]bool{handOverKey("notes.data", found): true} }
t.Cleanup(func() { HandedOver = before })
if applyOnce {
// The hand-over takes: the directory is given its mode, and the record is no longer as found.
if _, state, err = Apply(context.Background(), archHost(t), declare(t, decl), state, store.OriginDeclared,
noServices, nil, nil); err != nil {
t.Fatal(err)
}
if a, _ := state.Find("notes.data"); a.AsFound {
t.Fatalf("still as found after the hand-over took: %+v", a)
}
if info, _ := os.Stat(found); info.Mode().Perm() != 0o755 {
t.Fatalf("the hand-over did not take: %o", info.Mode().Perm())
}
}
// The victim: a tree of the same shape somewhere else, its data directory mode 0700.
victim := filepath.Join(dir, "victim", "top", "parent", "data")
if err := os.MkdirAll(victim, 0o700); err != nil {
t.Fatal(err)
}
var swapped, target string
switch swap {
case "parent":
swapped, target = filepath.Join(dir, "top", "parent"), filepath.Join(dir, "victim", "top", "parent")
case "grandparent":
swapped, target = filepath.Join(dir, "top"), filepath.Join(dir, "victim", "top")
case "itself":
swapped, target = found, victim
}
if err := os.Rename(swapped, swapped+".real"); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, swapped); err != nil {
t.Fatal(err)
}
_, _, err = Apply(context.Background(), archHost(t), declare(t, decl), state, store.OriginDeclared,
noServices, nil, nil)
if err == nil {
t.Fatal("a handed-over directory reached through a link was applied")
}
// Refused by the path's re-check before any descriptor is opened (a link at the name itself is refused
// earlier still, by the directory applier's own look at it).
want := "reached through a link"
if swap == "itself" {
want = "symbolic link"
}
if !strings.Contains(err.Error(), want) {
t.Fatalf("refused as %v; want %q", err, want)
}
if info, _ := os.Stat(victim); info.Mode().Perm() != 0o700 {
t.Fatalf("the victim's directory was given the declared mode through the link: %o", info.Mode().Perm())
}
})
}
}
// The check at the moment of use, without the path's re-check: a component swapped for a link is refused by the
// descriptor walk itself, whatever was resolved a moment before.
func TestGiveHandedOverFollowsNoLink(t *testing.T) {
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
victim := filepath.Join(dir, "victim", "data")
if err := os.MkdirAll(victim, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(dir, "victim"), filepath.Join(dir, "parent")); err != nil {
t.Fatal(err)
}
if _, _, _, err := giveHandedOver(filepath.Join(dir, "parent", "data"), 0o755, 0, 0, false); err == nil {
t.Fatal("a directory reached through a link was changed")
}
if info, _ := os.Stat(victim); info.Mode().Perm() != 0o700 {
t.Fatalf("the victim was changed: %o", info.Mode().Perm())
}
mode, _, _, err := giveHandedOver(victim, 0o750, 0, 0, false)
if err != nil || mode.Perm() != 0o700 {
t.Fatalf("the directory itself: before %o, %v", mode, err)
}
if info, _ := os.Stat(victim); info.Mode().Perm() != 0o750 {
t.Fatalf("the directory itself was not given its mode: %o", info.Mode().Perm())
}
}
+2 -3
View File
@@ -59,7 +59,6 @@ func TestAUnitWhoseFileTheMeshWroteIsStoppedWhateverItsRecordSays(t *testing.T)
m := unitsMachine(map[string]*fakeUnit{"mesh-guard.service": {active: "active", enabled: "enabled"}})
fileThereAtStop := false
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name == "systemctl" && args[0] == "stop" {
_, err := os.Stat(unitFile)
fileThereAtStop = err == nil
@@ -123,8 +122,8 @@ func TestOnlyAUnitsOwnFileTheMeshCreatedMakesItTheMeshs(t *testing.T) {
made := meshMadeUnits(known)
for unit, want := range map[string]bool{"made.service": true, "runtime.service": true, "kept.service": false,
"into.service": false, "mesh.conf": false, "docker.service.d": false, "elsewhere.service": false, "dir.service": false} {
if made[unitKey("", "", unit)] != want {
t.Errorf("%s: made %v, want %v", unit, made[unitKey("", "", unit)], want)
if made[unit] != want {
t.Errorf("%s: made %v, want %v", unit, made[unit], want)
}
}
}
-171
View File
@@ -1,171 +0,0 @@
package apply
import (
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// A unit the mesh found running and enabled, and held stopped and disabled by its own declaration, is
// given back running and enabled when that declaration goes (the zfs module holds the distribution's
// scrub timer stopped while its own timer scrubs the pool; unassigning it must not leave the pool with no
// schedule at all). The report says so.
func TestAUnitTheMeshStoppedAndDisabledIsGivenBackRunningAndEnabled(t *testing.T) {
m := unitsMachine(map[string]*fakeUnit{
"zfs-scrub-weekly@storage.timer": {active: "active", enabled: "enabled"},
})
_, state, err := applyOn(t, `{"declaration":1,"resources":[
{"id":"zfs.distribution-weekly","type":"service","unit":"zfs-scrub-weekly@storage.timer","state":"stopped","boot":"disabled"}]}`,
store.State{}, m)
if err != nil {
t.Fatal(err)
}
if u := m.units["zfs-scrub-weekly@storage.timer"]; u.active != "inactive" || u.enabled != "disabled" {
t.Fatalf("the fixture did not stop and disable it: %s and %s", u.active, u.enabled)
}
report, _, err := applyOn(t, nothingButA(t), copyOf(t, state), m)
if err != nil {
t.Fatal(err)
}
if u := m.units["zfs-scrub-weekly@storage.timer"]; u.active != "active" || u.enabled != "enabled" {
t.Errorf("given back as %s and %s; it was found running and enabled", u.active, u.enabled)
}
said := ""
for _, r := range report.Outcomes {
if r.ID == "zfs.distribution-weekly" {
said = r.Action + ": " + r.Detail
}
}
if !strings.HasPrefix(said, "restored: ") || !strings.Contains(said, "started") || !strings.Contains(said, "enabled at boot") {
t.Errorf("the report says %q", said)
}
}
// Only what the mesh's own declaration did is undone: a unit the mesh held stopped that was found stopped
// stays stopped, and one found running that the mesh never stopped is left running as before.
func TestOnlyWhatTheMeshsDeclarationStoppedIsStartedAgain(t *testing.T) {
m := unitsMachine(map[string]*fakeUnit{
"found-stopped.timer": {active: "inactive", enabled: "disabled"},
"left-running.timer": {active: "active", enabled: "enabled"},
})
_, state, err := applyOn(t, `{"declaration":1,"resources":[
{"id":"a","type":"service","unit":"found-stopped.timer","state":"stopped","boot":"disabled"},
{"id":"b","type":"service","unit":"left-running.timer","state":"running","boot":"enabled"}]}`,
store.State{}, m)
if err != nil {
t.Fatal(err)
}
if _, _, err := applyOn(t, nothingButA(t), state, m); err != nil {
t.Fatal(err)
}
if u := m.units["found-stopped.timer"]; u.active != "inactive" || u.enabled != "disabled" {
t.Errorf("found stopped and disabled, given back as %s and %s", u.active, u.enabled)
}
if u := m.units["left-running.timer"]; u.active != "active" || u.enabled != "enabled" {
t.Errorf("found running, given back as %s and %s", u.active, u.enabled)
}
}
// A record from before the host kept what it declared says nothing about who stopped the unit: it is
// left as it is, never started.
func TestARecordWithoutWhatWasDeclaredStartsNothing(t *testing.T) {
m := unitsMachine(map[string]*fakeUnit{"old.timer": {active: "inactive", enabled: "disabled"}})
state := store.State{}
state.Record(store.Applied{ID: "old", Type: "service", Target: "old.timer", Origin: store.OriginDeclared,
Found: &store.FoundUnit{Unit: "old.timer", State: "running", Boot: "enabled"}})
if _, _, err := applyOn(t, nothingButA(t), state, m); err != nil {
t.Fatal(err)
}
if u := m.units["old.timer"]; u.active != "inactive" || u.enabled != "disabled" {
t.Errorf("an old record started %s and %s", u.active, u.enabled)
}
}
// The plan says it before the apply does it.
func TestThePlanSaysAStoppedUnitIsStartedAgain(t *testing.T) {
m := unitsMachine(map[string]*fakeUnit{"zfs-scrub-weekly@storage.timer": {active: "active", enabled: "enabled"}})
_, state, err := applyOn(t, `{"declaration":1,"resources":[
{"id":"w","type":"service","unit":"zfs-scrub-weekly@storage.timer","state":"stopped","boot":"disabled"}]}`,
store.State{}, m)
if err != nil {
t.Fatal(err)
}
said := ""
for _, s := range Plan(parse(t, nothingButA(t)), state, store.OriginDeclared) {
if s.ID == "w" {
said = s.Verb + ": " + s.Why
}
}
if !strings.HasPrefix(said, "restore: ") || !strings.Contains(said, "restore if still as the mesh held it") ||
strings.Contains(said, "so it is started") {
t.Errorf("the plan says %q", said)
}
}
// Third review: on removal a unit is started again only when it is exactly as the mesh held it, stopped and
// disabled. A masked unit, or one somebody started or enabled since, is left alone, and the report says why.
func TestAMaskedOrChangedUnitIsLeftAloneOnRemoval(t *testing.T) {
for _, c := range []struct {
name, active, enabled, says string
}{
{"masked", "inactive", "masked", "masked"},
{"enabled since", "inactive", "enabled", "not the stopped and disabled"},
{"started since", "active", "disabled", "not the stopped and disabled"},
} {
m := unitsMachine(map[string]*fakeUnit{"w.timer": {active: "active", enabled: "enabled"}})
_, state, err := applyOn(t, `{"declaration":1,"resources":[
{"id":"w","type":"service","unit":"w.timer","state":"stopped","boot":"disabled"}]}`, store.State{}, m)
if err != nil {
t.Fatal(err)
}
m.units["w.timer"].active, m.units["w.timer"].enabled = c.active, c.enabled
report, _, err := applyOn(t, nothingButA(t), state, m)
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
if u := m.units["w.timer"]; u.active != c.active || u.enabled != c.enabled {
t.Errorf("%s: changed to %s and %s", c.name, u.active, u.enabled)
}
said := ""
for _, o := range report.Outcomes {
if o.ID == "w" {
said = o.Action + ": " + o.Detail
}
}
if !strings.HasPrefix(said, "forgotten: ") || !strings.Contains(said, c.says) {
t.Errorf("%s: the report says %q", c.name, said)
}
}
}
// A restore that fails — the start refused, or the unit not running after it — waits: the record stays,
// the report says so, and no apply is made fatal by it.
func TestAFailedRestoreWaitsAndIsNeverFatal(t *testing.T) {
m := unitsMachine(map[string]*fakeUnit{"w.timer": {active: "active", enabled: "enabled"}})
_, state, err := applyOn(t, `{"declaration":1,"resources":[
{"id":"w","type":"service","unit":"w.timer","state":"stopped","boot":"disabled"}]}`, store.State{}, m)
if err != nil {
t.Fatal(err)
}
m.units["w.timer"].wontStart = true
for i := 0; i < 2; i++ {
report, next, err := applyOn(t, nothingButA(t), state, m)
if err != nil {
t.Fatalf("apply %d failed: %v", i+1, err)
}
said := ""
for _, o := range report.Outcomes {
if o.ID == "w" {
said = o.Action + ": " + o.Detail
}
}
if !strings.Contains(said, "wait") || !strings.Contains(said, "not running") {
t.Errorf("apply %d: the report says %q", i+1, said)
}
if _, kept := next.Find("w"); !kept {
t.Errorf("apply %d: the record went, so nothing would try again", i+1)
}
state = next
}
}
-230
View File
@@ -1,230 +0,0 @@
package apply
import (
"context"
"fmt"
"strings"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// An account's groups, from any module (novox/hq ADR 0252, issue 247).
//
// **A module that needs the account in a group declares the account with that group, and nothing else
// of it.** The shell's module sets the account's shell; the lighting daemon's module puts the same
// account in `openrazer`; the container runtime's in `docker`. A shell is one value and stays one
// module's (the controller refuses two); a group is added, so several modules' groups never contradict.
//
// **The mesh takes back only what it gave.** A group the account was in before is the machine's or the
// operator's, and stays when every resource that named it goes. A group the mesh put the account in is
// recorded on the resource that put it there, and given back when that resource stops asking for it —
// unless another declared resource still asks for the same group, which keeps it.
//
// **A group takes effect at the next login.** The machine's group database changes at once; every
// process already running, the account's own service manager and everything it started included, keeps
// the groups it started with. So the outcome says a new login is needed, and the node-engine's account
// judge (internal/accounts) says so on every look until the account's running manager has the group.
// Wanted is every group a declaration asks an account to be in, and which resources ask: account →
// group → resource ids.
type Wanted map[string]map[string][]string
// groupsWanted reads every user resource's groups from a declaration.
func groupsWanted(resources []declaration.Resource) Wanted {
w := Wanted{}
for _, r := range resources {
u, ok := r.(*declaration.User)
if !ok || u.Name == "" {
continue
}
for _, g := range u.Groups {
if w[u.Name] == nil {
w[u.Name] = map[string][]string{}
}
w[u.Name][g] = append(w[u.Name][g], u.ID)
}
}
return w
}
// othersAsk is every resource other than one that asks for an account to be in a group.
func (w Wanted) othersAsk(account, group, except string) []string {
var others []string
for _, id := range w[account][group] {
if id != except {
others = append(others, id)
}
}
return others
}
// applyGroups makes the account be in every group the resource declares, and takes it out of every group
// this resource put it in and no longer asks for. What the mesh put the account in is recorded on out.
func applyGroups(ctx context.Context, sys system.System, r *declaration.User, run Runner,
previous store.Applied, wanted Wanted, handed Handed, out *Outcome) error {
// What this resource put the account in before, for this account only: a declaration that renamed its
// user says nothing about the new one's groups.
var added []string
if previous.Target == r.Name {
added = append(added, previous.Groups...)
}
if len(r.Groups) == 0 && len(added) == 0 {
return nil
}
in, err := system.GroupsOf(ctx, system.Runner(run), r.Name)
if err != nil {
return err
}
already := setOf(in)
declared := setOf(r.Groups)
var put []string
for _, want := range r.Groups {
if already[want] {
// A record that went handed it here: the mesh's, and this resource's to take back (novox/hq ADR 0312 §5).
if handed.has(r.Name, want) && !contains(added, want) {
added = append(added, want)
}
continue
}
exists, err := system.GroupExists(ctx, system.Runner(run), want)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("%q was not put in the group %q: this machine has no such group yet. The package "+
"that makes it is not installed, or is declared after the account", r.Name, want)
}
if err := sys.AddUserToGroup(ctx, system.Runner(run), r.Name, want); err != nil {
return err
}
already[want] = true
put = append(put, want)
if !contains(added, want) {
added = append(added, want)
}
}
// What this resource put the account in and no longer asks for.
var kept, said []string
for _, g := range added {
switch {
case declared[g]:
kept = append(kept, g)
case !already[g]:
// Taken out since, by a person: nothing to give back.
case len(wanted.othersAsk(r.Name, g, r.ID)) > 0:
said = append(said, fmt.Sprintf("left in %s, which %s still asks for", g,
strings.Join(wanted.othersAsk(r.Name, g, r.ID), ", ")))
default:
gone, why := leaveGroup(ctx, sys, r.Name, g, run)
if !gone {
kept = append(kept, g)
}
said = append(said, why)
}
}
out.groups = kept
if len(put) > 0 {
said = append([]string{fmt.Sprintf("put in %s; a session that began before has %s only after a new "+
"login", strings.Join(put, ", "), them(len(put)))}, said...)
}
if len(put) > 0 || len(said) > 0 {
if out.Action == "unchanged" {
out.Action = "updated"
}
out.Detail = joinDetail(out.Detail, strings.Join(said, "; "))
}
return nil
}
// giveGroupsBack is removeUser's groups: every group the mesh put the account in, taken back unless
// another declared resource still asks for it. Never fatal, for the shell's reason: a removal that failed
// would stay recorded and fail the same way on every apply after. Empty when there was nothing to say.
//
// A group left because another declared resource still asks for it is handed to that resource (handed), which
// records it as the mesh's, so it is taken back once nobody asks (novox/hq ADR 0312 §5).
func giveGroupsBack(ctx context.Context, sys system.System, a store.Applied, run Runner, wanted Wanted,
handed Handed) (bool, string) {
if len(a.Groups) == 0 {
return false, ""
}
in, err := system.GroupsOf(ctx, system.Runner(run), a.Target)
if err != nil {
return false, fmt.Sprintf("the groups the mesh put it in (%s) were not given back: %v",
strings.Join(a.Groups, ", "), err)
}
already := setOf(in)
gave := false
var said []string
for _, g := range a.Groups {
if !already[g] {
continue
}
if others := wanted.othersAsk(a.Target, g, a.ID); len(others) > 0 {
said = append(said, fmt.Sprintf("left in %s, which %s still asks for", g, strings.Join(others, ", ")))
handed.hand(a.Target, g)
continue
}
gone, why := leaveGroup(ctx, sys, a.Target, g, run)
gave = gave || gone
said = append(said, why)
}
return gave, strings.Join(said, "; ")
}
// leaveGroup takes an account out of one group the mesh put it in, read back from the machine, and says
// what came of it.
func leaveGroup(ctx context.Context, sys system.System, account, group string, run Runner) (bool, string) {
l, ok := sys.(system.GroupLeaver)
if !ok {
return false, fmt.Sprintf("left in %s: this machine's system cannot take an account out of one group", group)
}
if err := l.RemoveUserFromGroup(ctx, system.Runner(run), account, group); err != nil {
return false, fmt.Sprintf("left in %s: %v", group, err)
}
in, err := system.GroupsOf(ctx, system.Runner(run), account)
if err != nil {
return false, fmt.Sprintf("taken out of %s, and the group database could not be read back: %v", group, err)
}
if setOf(in)[group] {
return false, fmt.Sprintf("taken out of %s, and the group database still lists it there", group)
}
return true, fmt.Sprintf("taken out of %s, which the mesh had put it in", group)
}
func setOf(items []string) map[string]bool {
s := map[string]bool{}
for _, i := range items {
s[i] = true
}
return s
}
func contains(items []string, want string) bool {
for _, i := range items {
if i == want {
return true
}
}
return false
}
func them(n int) string {
if n == 1 {
return "it"
}
return "them"
}
func joinDetail(a, b string) string {
switch {
case a == "":
return b
case b == "":
return a
}
return a + "; " + b
}
-233
View File
@@ -1,233 +0,0 @@
package apply
import (
"context"
"errors"
"sort"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq ADR 0252 and issue 247: a module puts the operator's account in a group by declaring
// the account with that group alone; the account's other groups are never touched; a new login is said;
// and the mesh gives back only a group it put the account in, and only when nobody declared still asks.
// groupDB is a fake user and group database: the account's groups, the groups the machine has, and every
// command it was asked.
type groupDB struct {
account string
in map[string]bool
exists map[string]bool
asked []string
}
func newGroupDB(in []string, exists ...string) *groupDB {
g := &groupDB{account: "operator", in: map[string]bool{}, exists: map[string]bool{}}
for _, x := range in {
g.in[x], g.exists[x] = true, true
}
for _, x := range exists {
g.exists[x] = true
}
return g
}
func (g *groupDB) run(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
g.asked = append(g.asked, name+" "+strings.Join(args, " "))
switch {
case name == "getent" && args[0] == "passwd":
if args[1] == g.account {
return g.account + ":x:1500:1500::/home/" + g.account + ":/bin/bash\n", nil
}
return "", errors.New("getent exited 2: ")
case name == "getent" && args[0] == "group":
if g.exists[args[1]] {
return args[1] + ":x:900:\n", nil
}
return "", errors.New("getent exited 2: ")
case name == "id":
var out []string
for x := range g.in {
out = append(out, x)
}
sort.Strings(out)
return strings.Join(out, " ") + "\n", nil
case name == "usermod" && args[0] == "--append":
if !g.exists[args[2]] {
return "", errors.New("usermod exited 6: group '" + args[2] + "' does not exist")
}
g.in[args[2]] = true
case name == "gpasswd" && args[0] == "--delete":
delete(g.in, args[2])
}
return "", nil
}
func (g *groupDB) groups() string {
var out []string
for x := range g.in {
out = append(out, x)
}
sort.Strings(out)
return strings.Join(out, " ")
}
func applyGroupsOf(t *testing.T, g *groupDB, known store.State, resources ...string) (Report, store.State, error) {
t.Helper()
if len(resources) == 0 {
resources = []string{`{"id":"other.dir","type":"directory","path":"` + t.TempDir() + `/other"}`}
}
return Apply(context.Background(), archHost(t), parse(t, `{"declaration":1,"resources":[`+
strings.Join(resources, ",")+`]}`), known, store.OriginDeclared, g.run, nil, nil)
}
const (
razer = `{"id":"openrazer.account","type":"user","name":"operator","groups":["openrazer"]}`
docker = `{"id":"docker.account","type":"user","name":"operator","groups":["docker"]}`
shell = `{"id":"zsh.login","type":"user","name":"operator","groups":[]}`
)
func TestAModulePutsTheAccountInAGroupAndSaysANewLoginIsNeeded(t *testing.T) {
g := newGroupDB([]string{"wheel", "plugdev"}, "openrazer")
report, state, err := applyGroupsOf(t, g, store.State{}, shell, razer)
if err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "openrazer plugdev wheel" {
t.Fatalf("the account's groups are %q", got)
}
o := outcomeOf(report, "openrazer.account")
if o.Action != "updated" || !strings.Contains(o.Detail, "put in openrazer") || !strings.Contains(o.Detail, "new login") {
t.Errorf("the outcome did not say the group and the new login: %+v", o)
}
a, _ := state.Find("openrazer.account")
if strings.Join(a.Groups, " ") != "openrazer" {
t.Errorf("the record holds %v, want the one group the mesh added", a.Groups)
}
for _, asked := range g.asked {
if strings.HasPrefix(asked, "usermod") && !strings.HasPrefix(asked, "usermod --append --groups openrazer ") {
t.Errorf("usermod was asked something other than appending the one group: %q", asked)
}
}
// Applied again: nothing to do, and the record still says the mesh added it.
report, state, err = applyGroupsOf(t, g, state, shell, razer)
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(report, "openrazer.account"); o.Action != "unchanged" {
t.Errorf("a second apply changed something: %+v", o)
}
if a, _ := state.Find("openrazer.account"); strings.Join(a.Groups, " ") != "openrazer" {
t.Errorf("a second apply lost what the mesh added: %v", a.Groups)
}
}
func TestAGroupTheAccountWasAlreadyInIsNeverTakenBack(t *testing.T) {
g := newGroupDB([]string{"wheel", "openrazer"})
_, state, err := applyGroupsOf(t, g, store.State{}, razer)
if err != nil {
t.Fatal(err)
}
if a, _ := state.Find("openrazer.account"); len(a.Groups) != 0 {
t.Fatalf("a group found was recorded as the mesh's: %v", a.Groups)
}
if _, _, err := applyGroupsOf(t, g, state); err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "openrazer wheel" {
t.Errorf("undeclaring took a found group: %q", got)
}
}
func TestTheGroupTheMeshAddedIsGivenBackWhenItsModuleGoes(t *testing.T) {
g := newGroupDB([]string{"wheel"}, "openrazer")
_, state, err := applyGroupsOf(t, g, store.State{}, razer)
if err != nil {
t.Fatal(err)
}
report, state, err := applyGroupsOf(t, g, state)
if err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "wheel" {
t.Errorf("the account's groups after its module went: %q, want wheel alone", got)
}
o := outcomeOf(report, "openrazer.account")
if o.Action != "restored" || !strings.Contains(o.Detail, "taken out of openrazer") {
t.Errorf("the removal did not say the group was given back: %+v", o)
}
if _, still := state.Find("openrazer.account"); still {
t.Error("the record stayed")
}
}
func TestAGroupAnotherResourceStillAsksForStays(t *testing.T) {
both := `{"id":"openrazer.account","type":"user","name":"operator","groups":["openrazer","video"]}`
video := `{"id":"media.account","type":"user","name":"operator","groups":["video"]}`
g := newGroupDB(nil, "openrazer", "video")
_, state, err := applyGroupsOf(t, g, store.State{}, both, video)
if err != nil {
t.Fatal(err)
}
report, _, err := applyGroupsOf(t, g, state, video)
if err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "video" {
t.Errorf("the account's groups: %q, want video kept for media and openrazer given back", got)
}
if o := outcomeOf(report, "openrazer.account"); !strings.Contains(o.Detail, "media.account still asks for") {
t.Errorf("the removal did not say why video stayed: %+v", o)
}
}
func TestAGroupNoLongerDeclaredIsGivenBackWhileTheAccountStaysDeclared(t *testing.T) {
g := newGroupDB(nil, "openrazer", "input")
two := `{"id":"openrazer.account","type":"user","name":"operator","groups":["openrazer","input"]}`
_, state, err := applyGroupsOf(t, g, store.State{}, two)
if err != nil {
t.Fatal(err)
}
_, state, err = applyGroupsOf(t, g, state, razer)
if err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "openrazer" {
t.Errorf("the account's groups: %q, want input given back", got)
}
if a, _ := state.Find("openrazer.account"); strings.Join(a.Groups, " ") != "openrazer" {
t.Errorf("the record holds %v", a.Groups)
}
}
func TestAGroupThatDoesNotExistYetFailsTheResourceSayingSo(t *testing.T) {
g := newGroupDB([]string{"wheel"})
_, _, err := applyGroupsOf(t, g, store.State{}, razer)
if err == nil || !strings.Contains(err.Error(), "no such group yet") {
t.Fatalf("a missing group was not said: %v", err)
}
for _, asked := range g.asked {
if strings.HasPrefix(asked, "usermod") {
t.Errorf("usermod was run for a group the machine does not have: %q", asked)
}
}
}
func TestAGroupAPersonTookTheAccountOutOfSinceIsPutBackWhileDeclared(t *testing.T) {
g := newGroupDB(nil, "openrazer")
_, state, err := applyGroupsOf(t, g, store.State{}, razer)
if err != nil {
t.Fatal(err)
}
delete(g.in, "openrazer")
if _, _, err := applyGroupsOf(t, g, state, razer); err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "openrazer" {
t.Errorf("a declared group was not put back: %q", got)
}
}
-321
View File
@@ -1,321 +0,0 @@
package apply
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// novox/hq issue 213: the controller moves from a container to a process on the one machine that
// runs it. Every orphan is removed before anything is applied, so without a handover the container
// went first and nothing answered the mesh's verbs while the process was fetched, unpacked and
// started — and for ever, if it did not start. A process that `replaces` the container is applied
// first; the container goes only once the process is running a moment later.
// aMachine fakes the service manager and the container runtime: it records every command, answers
// `systemctl show` with whether the process is running, and has a container until it is removed.
type aMachine struct {
commands []string
running bool // what `systemctl show` says of the process once it was started
started bool
container bool
timer bool // whether a timer, once started, is up
crashing bool // up at the first look, waiting to restart at the next
looks int
}
func (m *aMachine) run(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
line := name + " " + strings.Join(args, " ")
m.commands = append(m.commands, line)
switch {
case name == "systemctl" && len(args) > 0 && (args[0] == "restart" || args[0] == "start"):
m.started = true
case name == "systemctl" && len(args) > 0 && args[0] == "is-active" && strings.HasSuffix(args[len(args)-1], ".timer"):
if m.timer {
return "active", nil
}
return "inactive", errors.New("inactive")
case name == "systemctl" && len(args) > 0 && args[0] == "is-active":
if m.started && m.running {
return "active", nil
}
return "inactive", errors.New("inactive")
case name == "systemctl" && len(args) > 0 && args[0] == "show":
m.looks++
if m.crashing && m.started {
// Up at the first look; waiting to be started again, a new process, at the second.
if m.looks == 1 {
return "ActiveState=active\nSubState=running\nMainPID=42\nNRestarts=0\n", nil
}
return "ActiveState=activating\nSubState=auto-restart\nMainPID=0\nNRestarts=1\n", nil
}
if m.started && m.running {
return "ActiveState=active\nSubState=running\nMainPID=42\nNRestarts=0\n", nil
}
return "ActiveState=inactive\nSubState=dead\nMainPID=0\nNRestarts=0\n", nil
case name == "docker" && len(args) > 1 && args[0] == "rm":
m.container = false
case name == "docker" && len(args) > 1 && args[0] == "container" && args[1] == "inspect":
if !m.container {
return "", errors.New("no such container")
}
return "true\t", nil
}
return "", nil
}
func (m *aMachine) index(prefix string) int {
for i, c := range m.commands {
if strings.HasPrefix(c, prefix) {
return i
}
}
return -1
}
// theController is a machine whose controller ran as a container, recorded, and a declaration that
// runs it as a process from a bundle served here instead.
func theController(t *testing.T, digest, source, extra string) (store.State, string) {
t.Helper()
known := store.State{Resources: []store.Applied{{
Origin: store.OriginDeclared, ID: "mesh-controller.server", Type: "container", Target: "mesh-controller",
}}}
return known, `{"declaration":1,"resources":[
{"id":"mesh-controller.controller","type":"process","name":"mesh-controller","source":"` + source +
`","digest":"` + digest + `","run":["./mesh-controller","serve"]` + extra + `}]}`
}
func onAMachine(t *testing.T) {
t.Helper()
serviceSettle, handoverSettle = 0, 0
wasUnits, wasBundles := unitDir, daemonRoot
unitDir, daemonRoot = t.TempDir(), t.TempDir()
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
}
func TestAContainerAProcessReplacesGoesOnlyOnceTheProcessRuns(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
known, raw := theController(t, digest, serving(t, body), `,"replaces":["mesh-controller.server"]`)
m := &aMachine{running: true, container: true}
report, after, err := Apply(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, m.run, nil, nil)
if err != nil {
t.Fatalf("the handover failed: %v", err)
}
started, removed := m.index("systemctl restart mesh-controller.service"), m.index("docker rm -f mesh-controller")
if started < 0 || removed < 0 {
t.Fatalf("the process was not started or the container not removed: %v", m.commands)
}
if removed < started {
t.Fatalf("the container was removed before its replacement was started — a window with "+
"nothing answering: %v", m.commands)
}
if looked := m.index("systemctl show mesh-controller.service"); looked < 0 || looked > removed {
t.Errorf("the container was removed without looking whether the process runs: %v", m.commands)
}
if o := outcomeOf(report, "mesh-controller.server"); o.Action != "removed" {
t.Errorf("the container's outcome is %+v, want removed", o)
}
if _, still := after.Find("mesh-controller.server"); still {
t.Error("the host still records the container it removed")
}
if _, has := after.Find("mesh-controller.controller"); !has {
t.Error("the process was not recorded")
}
}
// The case the handover exists for: the replacement does not stay up. The container keeps
// answering, stays recorded so a later apply hands it over, and the apply says why it failed.
func TestAContainerIsKeptWhenItsReplacementDoesNotRun(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
known, raw := theController(t, digest, serving(t, body), `,"replaces":["mesh-controller.server"]`)
m := &aMachine{running: false, container: true}
report, after, err := Apply(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, m.run, nil, nil)
if err == nil {
t.Fatal("a replacement that is not running was reported as a clean apply")
}
if !strings.Contains(err.Error(), "mesh-controller.server") {
t.Errorf("the failure does not name what was kept: %v", err)
}
if m.index("docker rm") >= 0 {
t.Fatalf("the container was removed though its replacement is not running: %v", m.commands)
}
if o := outcomeOf(report, "mesh-controller.server"); o.Action != "kept" {
t.Errorf("the container's outcome is %+v, want kept", o)
}
if _, still := after.Find("mesh-controller.server"); !still {
t.Fatal("the container was forgotten, so no later apply would ever remove it")
}
// The next apply finds the process up and finishes the handover.
m.running, m.commands = true, nil
report, after, err = Apply(context.Background(), archHost(t), parse(t, raw), after,
store.OriginDeclared, m.run, nil, nil)
if err != nil {
t.Fatalf("the second apply failed: %v", err)
}
if o := outcomeOf(report, "mesh-controller.server"); o.Action != "removed" {
t.Errorf("the second apply did not hand over: %+v (%v)", o, m.commands)
}
if _, still := after.Find("mesh-controller.server"); still {
t.Error("the container is still recorded after the handover")
}
}
// A replacement that never applied — its bundle is not what was declared — touches nothing, and
// what it replaces keeps running.
func TestAContainerIsKeptWhenItsReplacementFailsToApply(t *testing.T) {
onAMachine(t)
body, _ := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
known, raw := theController(t, "sha256:"+strings.Repeat("b", 64), serving(t, body),
`,"replaces":["mesh-controller.server"]`)
m := &aMachine{running: true, container: true}
report, after, err := Apply(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, m.run, nil, nil)
if err == nil {
t.Fatal("a replacement whose bundle did not match was reported applied")
}
if m.index("docker rm") >= 0 {
t.Fatalf("the container was removed though nothing replaced it: %v", m.commands)
}
if o := outcomeOf(report, "mesh-controller.server"); o.Action != "kept" {
t.Errorf("the container's outcome is %+v, want kept", o)
}
if _, still := after.Find("mesh-controller.server"); !still {
t.Fatal("the container was forgotten")
}
}
// Without `replaces` nothing changes: an orphan goes before anything is applied, as it always has.
// Kept as a test because it is the window the field exists to close.
func TestWithoutReplacesAnOrphanStillGoesFirst(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
known, raw := theController(t, digest, serving(t, body), ``)
m := &aMachine{running: true, container: true}
if _, _, err := Apply(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, m.run, nil, nil); err != nil {
t.Fatal(err)
}
if removed, started := m.index("docker rm -f mesh-controller"), m.index("systemctl restart mesh-controller.service"); removed < 0 || removed > started {
t.Fatalf("an orphan nothing replaces was not removed first: %v", m.commands)
}
}
// novox/hq issue 213, beyond what the oneshot unit (process_step_test.go) already holds: a step
// written `./name` runs its own bundle's binary — the controller's preparation is its own binary —
// and a step is started, never enabled.
func TestAStepRunsItsOwnBundlesBinaryAndIsNotEnabled(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
m := &aMachine{running: true}
d := declare(t, `{"id":"mesh-controller.controller-prepare","type":"process","name":"mesh-controller-prepare",
"source":"`+serving(t, body)+`","digest":"`+digest+`","run":["./mesh-controller","prepare"],"run-once":true}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil); err != nil {
t.Fatalf("the step failed: %v", err)
}
for _, c := range m.commands {
if strings.HasPrefix(c, "./") || strings.HasPrefix(c, "systemctl enable") {
t.Errorf("the step was run directly or enabled: %v", m.commands)
}
}
unit, err := os.ReadFile(filepath.Join(unitDir, "mesh-controller-prepare.service"))
if err != nil {
t.Fatal(err)
}
want := "ExecStart=" + filepath.Join(daemonRoot, "mesh-controller-prepare", "mesh-controller") + " prepare"
if !strings.Contains(string(unit), want) {
t.Errorf("the step does not run its own bundle's binary (%q):\n%s", want, unit)
}
}
func TestAStepThatFailsGatesItsModule(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
src := serving(t, body)
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name == "systemctl" && len(args) > 1 && args[0] == "start" {
return "", errors.New("Job for mesh-controller-prepare.service failed")
}
if name == "systemctl" && len(args) > 0 && args[0] == "restart" {
t.Errorf("the module's process was started after its step failed")
}
return "", nil
}
d := declare(t, `{"id":"mesh-controller.controller-prepare","type":"process","name":"mesh-controller-prepare",
"source":"`+src+`","digest":"`+digest+`","run":["./mesh-controller","prepare"],"run-once":true},
{"id":"mesh-controller.controller","type":"process","name":"mesh-controller",
"source":"`+src+`","digest":"`+digest+`","run":["./mesh-controller","serve"]}`)
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil)
if err == nil {
t.Fatal("a failed step was reported as a clean apply")
}
if o := outcomeOf(report, "mesh-controller.controller"); o.Action != "skipped" {
t.Errorf("the process after a failed step was %+v, want skipped", o)
}
}
// A completed step is done: applied again unchanged, it is not run again — its service is never up
// between runs, and reading that as "a daemon that stopped" re-ran the controller's preparation on
// every apply. Nor is a scheduled run started off its cadence; its timer is what is kept up.
func TestACompletedStepIsNotRunAgainAndAScheduleIsItsTimer(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"job": "#!/bin/sh\n"})
src := serving(t, body)
for _, mode := range []string{`"run-once":true`, `"schedule":"0 3 * * *"`} {
// The service of either is never up between runs; a scheduled one's timer is.
m := &aMachine{running: false, timer: true}
d := declare(t, `{"id":"m.job","type":"process","name":"m-job","source":"`+src+`","digest":"`+digest+
`","run":["./job"],`+mode+`}`)
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil)
if err != nil {
t.Fatalf("%s: first apply: %v", mode, err)
}
m.commands = nil
report, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, m.run, nil, nil)
if err != nil {
t.Fatalf("%s: second apply: %v", mode, err)
}
if m.index("systemctl start m-job.service") >= 0 || m.index("systemctl restart m-job.service") >= 0 {
t.Errorf("%s: an unchanged apply ran the job again: %v", mode, m.commands)
}
if o := outcomeOf(report, "m.job"); o.Action != "unchanged" {
t.Errorf("%s: an unchanged apply reported %+v", mode, o)
}
}
}
// A replacement crash-looping between its restarts is not up, though the service manager calls it
// "activating" — the state the host's ordinary second look accepts as running. Removing the
// container on that reading would leave nothing answering.
func TestAContainerIsKeptWhenItsReplacementIsCrashLooping(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
known, raw := theController(t, digest, serving(t, body), `,"replaces":["mesh-controller.server"]`)
m := &aMachine{crashing: true, container: true}
_, after, err := Apply(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, m.run, nil, nil)
if err == nil || !strings.Contains(err.Error(), "auto-restart") {
t.Fatalf("a crash-looping replacement was accepted: %v", err)
}
if m.index("docker rm") >= 0 {
t.Fatalf("the container was removed for a replacement that keeps dying: %v", m.commands)
}
if _, still := after.Find("mesh-controller.server"); !still {
t.Fatal("the container was forgotten")
}
}
-84
View File
@@ -1,84 +0,0 @@
package apply
import (
"context"
"slices"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// The node-engine owns every verdict, and nothing else sets a container's check (novox/hq ADR 0240 rule 3,
// "how it is checked"): a declared command becomes the container's check with the declared timing, an
// adopted image check keeps the image's command under the declared timing, and a check the engine makes
// itself — http, tcp — sets nothing on the container and recreates nothing.
func TestADeclaredCommandBecomesTheContainersCheckAndNothingElseSetsOne(t *testing.T) {
pinned := "postgres@sha256:" + strings.Repeat("a", 64)
ranWith := func(health string) []string {
t.Helper()
var ran []string
run := func(_ context.Context, cmd string, args ...string) (string, error) {
if cmd == "docker" && len(args) > 0 && args[0] == "run" {
ran = args
return "deadbeef\n", nil
}
return "", nil
}
field := ""
if health != "" {
field = `,"health":` + health
}
d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"db","type":"container","name":"db","image":"`+pinned+`","ports":["31001:5432"]`+field+`}
]}`)
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
if ran == nil {
t.Fatal("the container was not run")
}
return ran
}
healthFlags := func(args []string) []string {
var out []string
for i, a := range args {
if strings.HasPrefix(a, "--health") || a == "--no-healthcheck" {
out = append(out, a)
if i+1 < len(args) {
out = append(out, args[i+1])
}
}
}
return out
}
exec := healthFlags(ranWith(`{"kind":"exec","command":"pg_isready -q","interval":"30s","timeout":"5s","looks":3,"grace":"1m0s"}`))
want := []string{"--health-cmd", "pg_isready -q", "--health-interval", "30s", "--health-timeout", "5s",
"--health-retries", "3", "--health-start-period", "1m0s"}
if !slices.Equal(exec, want) {
t.Errorf("a declared command was run as %v, want %v", exec, want)
}
adopted := healthFlags(ranWith(`{"kind":"runtime","interval":"20s","timeout":"3s","looks":2,"grace":"30s"}`))
if slices.Contains(adopted, "--health-cmd") || !slices.Contains(adopted, "20s") || !slices.Contains(adopted, "2") {
t.Errorf("an adopted image check was run as %v: the image's command under the declared timing", adopted)
}
for _, h := range []string{"", `{"kind":"http","endpoint":"web","port":31001,"path":"/","interval":"30s","timeout":"5s","looks":3,"grace":"1m0s"}`,
`{"kind":"tcp","port":31001,"interval":"30s","timeout":"5s","looks":3,"grace":"1m0s"}`} {
if got := healthFlags(ranWith(h)); len(got) > 0 {
t.Errorf("a container whose check the engine makes itself (%q) was given the runtime's: %v", h, got)
}
}
// Declaring an http check recreates nothing; declaring a command does.
plain := &declaration.Container{ID: "db", Name: "db", Image: pinned}
withHTTP := *plain
withHTTP.Health = &declaration.Health{Kind: "http", Port: 31001, Path: "/", Interval: "30s", Timeout: "5s", Looks: 3, Grace: "1m0s"}
withExec := *plain
withExec.Health = &declaration.Health{Kind: "exec", Command: "true", Interval: "30s", Timeout: "5s", Looks: 3, Grace: "1m0s"}
if containerSpec(plain, inputs{}) != containerSpec(&withHTTP, inputs{}) {
t.Error("declaring an http check would recreate the container")
}
if containerSpec(plain, inputs{}) == containerSpec(&withExec, inputs{}) {
t.Error("declaring a command would not reach a running container: it is fixed when the container is made")
}
}
+9 -62
View File
@@ -109,30 +109,9 @@ func lookBefore(ctx context.Context, sys system.System, d *declaration.Declarati
}
}
case *declaration.Service:
if res.Stateless() || recordedUnit(known, res) {
if res.Stateless() || known.Recorded(string(declaration.TypeService), res.Unit) {
continue
}
key := "unit:" + unitKey(res.Scope, res.User, res.Unit)
run := run
if res.UserScoped() {
// In the account's own manager (novox/hq ADR 0177), and only asked while it runs. With
// it not running, what can be known is whether somebody put the unit's file where that
// manager loads an administrator's units from — and the mesh's own file there is not
// somebody's. An account the machine does not have has no unit to find.
away, err := managerAway(ctx, sys, res.Scope, res.User, run)
if err != nil {
continue
}
if away != "" {
for _, path := range userUnitFiles(res.User, res.Unit) {
if present(path) && !meshWroteWhole(known, path) {
seen.is[key] = true
}
}
continue
}
run = managerFor(res.Scope, res.User, run)
}
// **Found is a unit somebody put on this machine, or one the machine uses.**
//
// Where it comes from first: a unit the service manager loads from outside /usr —
@@ -150,14 +129,14 @@ func lookBefore(ctx context.Context, sys system.System, d *declaration.Declarati
continue
}
if from, ok := sys.(unitFiles); ok {
if path, err := from.ServiceUnitFile(ctx, run, res.Unit); err == nil && installedByHand(known, path) {
seen.is[key] = true
if path, err := from.ServiceUnitFile(ctx, run, res.Unit); err == nil && installedByHand(path) {
seen.is["unit:"+res.Unit] = true
continue
}
}
boot, _ := sys.ServiceBoot(ctx, run, res.Unit)
if state == "running" || boot == "enabled" {
seen.is[key] = true
seen.is["unit:"+res.Unit] = true
}
case *declaration.Container:
if known.Recorded(string(declaration.TypeContainer), res.Name) {
@@ -198,26 +177,12 @@ type unitFiles interface {
}
// installedByHand is whether a unit file is one somebody put on this machine rather than one a
// package ships: anywhere but /usr, where distributions keep what they install — and not one this
// host wrote whole. That covers an account's units (novox/hq ADR 0177): one under the account's
// ~/.config/systemd/user or /etc/systemd/user is somebody's, unless the mesh wrote it there.
func installedByHand(known store.State, path string) bool {
// package ships: anywhere but /usr, where distributions keep what they install.
func installedByHand(path string) bool {
if path == "" {
return false
}
return !strings.HasPrefix(filepath.Clean(path), "/usr/") && !meshWroteWhole(known, path)
}
// recordedUnit is whether this host has a record of a service in the same manager as res, under the
// same name (novox/hq ADR 0177): an account's unit and the machine's of one name are two units.
func recordedUnit(known store.State, res *declaration.Service) bool {
want := unitKey(res.Scope, res.User, res.Unit)
for _, r := range known.Resources {
if r.Type == string(declaration.TypeService) && unitKey(r.Scope, r.User, r.Target) == want {
return true
}
}
return false
return !strings.HasPrefix(filepath.Clean(path), "/usr/")
}
func present(path string) bool {
@@ -409,7 +374,7 @@ func holdOnAdopted(ctx context.Context, sys system.System, r declaration.Resourc
case *declaration.User:
isFound = before.has("user:" + res.Name)
case *declaration.Service:
isFound = before.has("unit:" + unitKey(res.Scope, res.User, res.Unit))
isFound = before.has("unit:" + res.Unit)
}
}
if !isFound {
@@ -423,11 +388,7 @@ func holdOnAdopted(ctx context.Context, sys system.System, r declaration.Resourc
// A held service is not started, stopped, enabled or restarted — but a reload stops nothing,
// so one the module names still happens (novox/hq ADR 0102, ADR 0103).
if svc, ok := r.(*declaration.Service); ok && svc.State == "running" {
// In the unit's own manager, and nothing to reload in one that is not running (novox/hq ADR
// 0177): the state read below then fails, and the reload is not attempted.
away, awayErr := managerAway(ctx, sys, svc.Scope, svc.User, run)
run := managerFor(svc.Scope, svc.User, run)
if which := restartedBy(svc.ReloadOn, changed); len(which) > 0 && awayErr == nil && away == "" {
if which := restartedBy(svc.ReloadOn, changed); len(which) > 0 {
if state, err := sys.ServiceState(ctx, run, svc.Unit); err == nil && state == "running" {
reloader, can := sys.(serviceReloader)
if !can {
@@ -750,20 +711,6 @@ func hold(ctx context.Context, sys system.System, r declaration.Resource, module
}
detail = "the user was found on the machine; its shell and groups are kept until " + module + " is taken"
case *declaration.Service:
if res.UserScoped() {
// Read in the account's own manager, and not at all while it is not running (novox/hq
// ADR 0177): held as found, with nothing to compare until it runs.
away, err := managerAway(ctx, sys, res.Scope, res.User, run)
if err != nil {
return out, h, err
}
if away != "" {
detail = "its unit was found in " + res.User + "'s own manager, which is not running; " +
"kept as found until " + module + " is taken"
break
}
run = managerFor(res.Scope, res.User, run)
}
state, err := sys.ServiceState(ctx, run, res.Unit)
switch {
case err != nil && !already:
-24
View File
@@ -107,28 +107,7 @@ type fakeContainer struct {
networks, mounts, ports []string
}
// oldShape is a systemctl call as these fakes were written to read it: the verb, the unit, then the
// options. The host passes `--` before every unit (third review of mesh-catalog #147), which
// internal/system's own test holds; the fakes read the unit where they always did.
func oldShape(name string, args []string) []string {
if name != "systemctl" || len(args) < 2 || args[len(args)-2] != "--" {
return args
}
v := 0
for v < len(args) && strings.HasPrefix(args[v], "-") {
v++
}
if v >= len(args)-2 {
return args
}
out := append([]string{}, args[:v+1]...)
out = append(out, args[len(args)-1])
return append(out, args[v+1:len(args)-2]...)
}
func (m *machine) run(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
m.asked = append(m.asked, name+" "+strings.Join(args, " "))
if name == "systemctl" {
return m.systemctl(args)
@@ -928,8 +907,6 @@ func TestMountingTheMachinesOwnPlumbingIsNotFoundData(t *testing.T) {
type unreachable struct{ asked []string }
func (u *unreachable) run(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
u.asked = append(u.asked, name+" "+strings.Join(args, " "))
if name == "docker" && args[0] == "info" {
return "27.0\n", nil
@@ -981,7 +958,6 @@ func TestAHeldContainerStaysHeldWhenTheRuntimeCannotAnswer(t *testing.T) {
func TestAVolumeTheRuntimeCannotBeAskedAboutStopsTheContainer(t *testing.T) {
dir := t.TempDir()
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch {
case name == "docker" && args[0] == "info":
return "27.0\n", nil
-161
View File
@@ -1,161 +0,0 @@
package apply
// A link under a person's home is never followed as root (novox/hq ADR 0266, the review of 2026-10-08).
//
// The node-engine runs as root and places files and directories under homes: the operator's shell
// configuration, the agent's `~/.claude`. Everything below a home is the account's to change. An account that
// replaces `~/.claude` with a symbolic link to /etc, between two applies, would have root change the owner and
// mode of /etc, or write a file into it, on the next one — and on a machine whose agents run as an account of
// their own, that account is exactly the one that must not become root.
//
// So for a path strictly below a home:
//
// - **a symbolic link in any component below the home, the path's own included, refuses the resource**, said
// in words, and nothing is done to it — before anything is read, written, chowned or chmodded;
// - **the owner and mode are set through a descriptor opened without following a link**, each component
// opened from the one above it (homes_linux.go), so a link put in place after the check is not followed
// either; a link that is itself the thing to own is owned as a link, never its target;
// - **a file is written through its directory's descriptor**, made with O_EXCL|O_NOFOLLOW and renamed within
// that directory, so neither the file nor a parent can be swapped for a link between check and write.
//
// The home itself is the machine's, made by root, and is followed as it is. A path outside every home is
// handled exactly as before: a module's system paths may be links the machine set up (a resolver's file is).
//
// Which homes: every account the user database lists with a uid of 1000 or more, other than the overflow
// account, and every home under /home. A service account's home under /var/lib is a module's own directory,
// and is left as it was.
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// homes is every home of a person's or an agent's account on this machine, as placement_guard.go reads them
// from the user database. A variable so a test can name the homes it made.
var homes = func() []string {
var out []string
for home := range accountsOfHomes() {
out = append(out, home)
}
// In one order: the user database is read into a map, and a map's order is not one.
sort.Strings(out)
return out
}
// homeAbove is the home a path lies strictly below, the deepest when homes nest; "" when none.
func homeAbove(path string) string {
path = filepath.Clean(path)
hs := homes()
sort.Slice(hs, func(i, j int) bool { return len(hs[i]) > len(hs[j]) })
for _, h := range hs {
if strings.HasPrefix(path, h+string(os.PathSeparator)) {
return h
}
}
return ""
}
// LinkUnderHomeError is a resource refused because a component below a home is a symbolic link.
type LinkUnderHomeError struct {
Path, Link, Home string
}
func (e *LinkUnderHomeError) Error() string {
return fmt.Sprintf("%s is a symbolic link inside the home %s, and the node-engine runs as root: it never "+
"follows a link an account can put there, so nothing was done to %s (novox/hq ADR 0266). Make %s a "+
"directory or file again, or ask why something replaced it", e.Link, e.Home, e.Path, e.Link)
}
// refuseLinksUnderHome refuses a path below a home when any component below the home, its own included, is a
// symbolic link. Components that do not exist yet end the walk: what is missing is made without following.
func refuseLinksUnderHome(path string) error {
home := homeAbove(path)
if home == "" {
return nil
}
rel, err := filepath.Rel(home, filepath.Clean(path))
if err != nil {
return err
}
at := home
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
at = filepath.Join(at, part)
info, err := os.Lstat(at)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return &LinkUnderHomeError{Path: path, Link: at, Home: home}
}
}
return nil
}
// HardLinkUnderHomeError is a regular file below a home with more than one link: the account that owns the
// home may have linked a file it does not own there, and root acting on it by name would act on that file
// (novox/hq ADR 0266). Refused, whatever the resource.
type HardLinkUnderHomeError struct {
Path string
Links uint64
}
func (e *HardLinkUnderHomeError) Error() string {
return fmt.Sprintf("%s has %d links: below a home a file with more than one is not owned, chmodded or read "+
"as root, since one of its names may be a file the home's account does not own", e.Path, e.Links)
}
// chmodDirPath is chmodPath for a directory resource: below a home, what is there must be a directory.
func chmodDirPath(path string, mode os.FileMode) error {
home := homeAbove(path)
if home == "" {
return os.Chmod(path, mode)
}
return chmodUnderAs(home, path, mode, kindDir)
}
// statPath is os.Stat, except below a home, where it never follows a link.
func statPath(path string) (os.FileInfo, error) {
if homeAbove(path) != "" {
return os.Lstat(path)
}
return os.Stat(path)
}
// chmodPath sets a mode; below a home through a descriptor that followed no link.
func chmodPath(path string, mode os.FileMode) error {
home := homeAbove(path)
if home == "" {
return os.Chmod(path, mode)
}
return chmodUnder(home, path, mode)
}
// chownPath sets an owner; below a home through a descriptor that followed no link, and a link itself is
// owned as a link.
func chownPath(path string, uid, gid int) error {
home := homeAbove(path)
if home == "" {
return os.Chown(path, uid, gid)
}
if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
return os.Lchown(path, uid, gid)
}
return chownUnder(home, path, uid, gid)
}
// readPath reads a file; below a home without following a link.
func readPath(path string) ([]byte, error) {
home := homeAbove(path)
if home == "" {
return os.ReadFile(path)
}
return readUnder(home, path)
}
-255
View File
@@ -1,255 +0,0 @@
//go:build linux
package apply
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq ADR 0266 (the review of 2026-10-08): below a home, the node-engine — root — follows no
// symbolic link an account can put there: not to chown or chmod, not to write, not to make a directory.
// aLinkedHome is a home the test names as one, with `.claude` replaced by a link to a directory outside it,
// as an account would to have root change /etc.
func aLinkedHome(t *testing.T) (home, outside string) {
t.Helper()
root := t.TempDir()
home = filepath.Join(root, "home", "agent")
outside = filepath.Join(root, "etc")
for _, d := range []string{home, outside} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(outside, "shadow"), []byte("root's"), 0o600); err != nil {
t.Fatal(err)
}
was := homes
homes = func() []string { return []string{home} }
t.Cleanup(func() { homes = was })
return home, outside
}
func link(t *testing.T, target, at string) {
t.Helper()
if err := os.Symlink(target, at); err != nil {
t.Fatal(err)
}
}
func applyOneResource(t *testing.T, resource string) error {
t.Helper()
d := declare(t, resource)
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, noServices, nil, nil)
return err
}
func mustBeLinkRefusal(t *testing.T, err error) {
t.Helper()
var refused *LinkUnderHomeError
if !errors.As(err, &refused) {
t.Fatalf("refused as a link under a home, got %v", err)
}
if !strings.Contains(err.Error(), "never follows a link") {
t.Fatalf("said in words: %v", err)
}
}
func modeOfPath(t *testing.T, p string) os.FileMode {
t.Helper()
info, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
return info.Mode().Perm()
}
func TestADirectoryUnderAHomeThatIsALinkIsRefusedAndItsTargetUntouched(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
err := applyOneResource(t, `{"id":"claude-code.agent-home","type":"directory","path":"`+
filepath.Join(home, ".claude")+`","mode":"0700"}`)
mustBeLinkRefusal(t, err)
if m := modeOfPath(t, outside); m != 0o755 {
t.Fatalf("the link's target was chmodded to %o", m)
}
}
func TestAFileThroughALinkedParentUnderAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
err := applyOneResource(t, `{"id":"m.f","type":"file","path":"`+filepath.Join(home, ".claude", "shadow")+
`","content":"the mesh's\n","mode":"0644"}`)
mustBeLinkRefusal(t, err)
if got, _ := os.ReadFile(filepath.Join(outside, "shadow")); string(got) != "root's" {
t.Fatalf("written through the link: %q", got)
}
if m := modeOfPath(t, filepath.Join(outside, "shadow")); m != 0o600 {
t.Fatalf("chmodded through the link: %o", m)
}
}
func TestAFileThatIsItselfALinkUnderAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, filepath.Join(outside, "shadow"), filepath.Join(home, ".zshrc"))
err := applyOneResource(t, `{"id":"m.f","type":"file","path":"`+filepath.Join(home, ".zshrc")+
`","content":"x\n"}`)
mustBeLinkRefusal(t, err)
if got, _ := os.ReadFile(filepath.Join(outside, "shadow")); string(got) != "root's" {
t.Fatalf("written through the link: %q", got)
}
}
func TestADirectoryAndAFileUnderAHomeAreMadeAsBefore(t *testing.T) {
home, _ := aLinkedHome(t)
dir := filepath.Join(home, ".claude")
if err := applyOneResource(t, `{"id":"m.d","type":"directory","path":"`+dir+`","mode":"0700"}`); err != nil {
t.Fatal(err)
}
if m := modeOfPath(t, dir); m != 0o700 {
t.Fatalf("mode %o", m)
}
file := filepath.Join(home, ".config", "mesh", "env.sh")
if err := applyOneResource(t, `{"id":"m.f","type":"file","path":"`+file+`","content":"A=1\n","mode":"0640"}`); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(file); string(got) != "A=1\n" || modeOfPath(t, file) != 0o640 {
t.Fatalf("written %q mode %o", got, modeOfPath(t, file))
}
}
// The descriptor half: a link put in place after any check is still not followed.
func TestTheDescriptorCallsFollowNoLinkBelowAHome(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
mustBeLinkRefusal(t, chmodPath(filepath.Join(home, ".claude"), 0o777))
mustBeLinkRefusal(t, chmodPath(filepath.Join(home, ".claude", "shadow"), 0o777))
if m := modeOfPath(t, filepath.Join(outside, "shadow")); m != 0o600 {
t.Fatalf("chmodded through the link: %o", m)
}
mustBeLinkRefusal(t, writeAtomically(filepath.Join(home, ".claude", "new"), []byte("x"), 0o644))
if _, err := os.Stat(filepath.Join(outside, "new")); !os.IsNotExist(err) {
t.Fatal("written through the link")
}
if _, err := makeDirsSaying(filepath.Join(home, ".claude", "a", "b"), 0o755, ""); err == nil {
t.Fatal("made directories through the link")
}
if _, err := os.Stat(filepath.Join(outside, "a")); !os.IsNotExist(err) {
t.Fatal("made a directory through the link")
}
if _, err := readPath(filepath.Join(home, ".claude", "shadow")); err == nil {
t.Fatal("read through the link")
}
me := os.Getuid()
// A link itself is owned as a link, never its target.
if err := chownPath(filepath.Join(home, ".claude"), me, os.Getgid()); err != nil {
t.Fatalf("a link is owned as a link: %v", err)
}
}
func TestRemovalThroughALinkUnderAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
_, _, err := remove(context.Background(), archHost(t), store.Applied{ID: "m.f", Type: "file",
Target: filepath.Join(home, ".claude", "shadow")}, nil, nil)
mustBeLinkRefusal(t, err)
if _, err := os.Stat(filepath.Join(outside, "shadow")); err != nil {
t.Fatal("removed through the link")
}
}
func TestAPathOutsideEveryHomeIsHandledAsBefore(t *testing.T) {
_, outside := aLinkedHome(t)
elsewhere := filepath.Join(filepath.Dir(outside), "srv")
if err := os.MkdirAll(elsewhere, 0o755); err != nil {
t.Fatal(err)
}
link(t, outside, filepath.Join(elsewhere, "linked"))
if homeAbove(filepath.Join(elsewhere, "linked", "x")) != "" {
t.Fatal("not below a home")
}
if err := refuseLinksUnderHome(filepath.Join(elsewhere, "linked", "x")); err != nil {
t.Fatal("a system path may be a link the machine set up; only a home's are refused")
}
}
func TestHomesAreAPersonsOrAnAgentsNotAServicesOrRoots(t *testing.T) {
dir := t.TempDir()
passwd := filepath.Join(dir, "passwd")
if err := os.WriteFile(passwd, []byte("root:x:0:0::/root:/bin/bash\n"+
"postgres:x:968:968::/var/lib/postgres:/usr/bin/nologin\n"+
"nobody:x:65534:65534::/:/usr/bin/nologin\n"+
"operator:x:1000:1000::/home/operator:/bin/zsh\n"+
"agent:x:1001:1001::/home/agent:/bin/bash\n"+
"svc:x:990:990::/home/svc:/usr/bin/nologin\n"), 0o644); err != nil {
t.Fatal(err)
}
was := passwdFile
passwdFile = passwd
t.Cleanup(func() { passwdFile = was })
got := strings.Join(homes(), ",")
if got != "/home/agent,/home/operator,/home/svc" {
t.Fatalf("homes %s", got)
}
if homeAbove("/home/agent/.claude") != "/home/agent" || homeAbove("/home/agent") != "" ||
homeAbove("/var/lib/postgres/data") != "" || homeAbove("/root/.ssh") != "" {
t.Fatal("strictly below a person's or an agent's home, and nothing else")
}
}
// A hard link below a home to a file the home's account does not own is never owned, chmodded or read by
// name: fstat on the opened descriptor counts its links first (the re-review of 2026-10-08).
func TestAHardLinkedFileBelowAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
shadow := filepath.Join(outside, "shadow")
linked := filepath.Join(home, ".claude")
if err := os.Link(shadow, linked); err != nil {
t.Skipf("no hard link here: %v", err)
}
var hard *HardLinkUnderHomeError
if err := chmodPath(linked, 0o777); !errors.As(err, &hard) {
t.Fatalf("chmod of a hard-linked file below a home is refused, got %v", err)
}
if err := chownPath(linked, os.Getuid(), os.Getgid()); !errors.As(err, &hard) {
t.Fatalf("chown of it is refused, got %v", err)
}
if _, err := readPath(linked); !errors.As(err, &hard) {
t.Fatalf("reading it is refused, got %v", err)
}
if m := modeOfPath(t, shadow); m != 0o600 {
t.Fatalf("the other file's mode changed: %o", m)
}
// As the directory resource sees it: a file where ~/.claude should be is left alone.
if err := chmodDirPath(linked, 0o700); err == nil || !strings.Contains(err.Error(), "where a directory was expected") {
t.Fatalf("a file where a directory was expected is refused, got %v", err)
}
}
func TestADirectoryWhereAFileWasExpectedAndAFifoAreRefused(t *testing.T) {
home, _ := aLinkedHome(t)
dir := filepath.Join(home, "d")
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatal(err)
}
if _, err := readPath(dir); err == nil || !strings.Contains(err.Error(), "where a file was expected") {
t.Fatalf("a directory where a file was expected is refused, got %v", err)
}
fifo := filepath.Join(home, "f")
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
t.Skipf("no fifo here: %v", err)
}
if err := chmodPath(fifo, 0o644); err == nil || !strings.Contains(err.Error(), "neither a file nor a directory") {
t.Fatalf("a fifo is refused, got %v", err)
}
if err := chmodDirPath(dir, 0o700); err != nil {
t.Fatalf("a directory as a directory passes: %v", err)
}
}
-126
View File
@@ -1,126 +0,0 @@
package apply
import (
"context"
"os"
osuser "os/user"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq ADR 0182 and to-be 41: a parent the host makes inside an owner's home is the
// owner's, one that was there is held as found, and one outside the home is made as before.
// aHome gives the account running the test a home in a directory the test owns, and writes down
// every directory the host gives to whom. The account's own name, so what the host chowns resolves
// without being root; the record, so what was given is told apart from what was merely made.
func aHome(t *testing.T) (home, owner string, given map[string]string) {
t.Helper()
me, err := osuser.Current()
if err != nil {
t.Skip("no current user to own anything")
}
home = t.TempDir()
given = map[string]string{}
wasHome, wasOwn := homeOf, ownMade
homeOf = func(name string) (string, error) {
if name == me.Username {
return home, nil
}
return wasHome(name)
}
ownMade = func(path, owner string) error {
given[path] = owner
return wasOwn(path, owner)
}
t.Cleanup(func() { homeOf, ownMade = wasHome, wasOwn })
return home, me.Username, given
}
func givenPaths(given map[string]string) []string {
var paths []string
for p := range given {
paths = append(paths, p)
}
sort.Strings(paths)
return paths
}
func TestAFileUnderAHomeGivesTheParentsItMadeToItsOwner(t *testing.T) {
home, owner, given := aHome(t)
target := filepath.Join(home, ".config", "mesh", "environment.sh")
d := parse(t, `{"declaration":1,"resources":[{"id":"shell.env","type":"file","path":"`+target+
`","content":"export A=1\n","owner":"`+owner+`"}]}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared,
noServices, nil, nil); err != nil {
t.Fatal(err)
}
want := []string{filepath.Join(home, ".config"), filepath.Join(home, ".config", "mesh")}
if got := givenPaths(given); strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("given to the owner: %v, want %v", got, want)
}
for _, p := range want {
if given[p] != owner {
t.Errorf("%s given to %q", p, given[p])
}
}
}
func TestAnArchiveUnderAHomeGivesTheParentsItMadeToItsOwner(t *testing.T) {
home, owner, given := aHome(t)
body, digest := anArchive(t, map[string]string{"p10k.zsh": "theme"})
target := filepath.Join(home, ".local", "share", "powerlevel10k")
d := declare(t, `{"id":"shell.theme","type":"archive","source":"`+serving(t, body)+
`","digest":"`+digest+`","path":"`+target+`","owner":"`+owner+`"}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared,
noServices, nil, nil); err != nil {
t.Fatal(err)
}
for _, p := range []string{filepath.Join(home, ".local"), filepath.Join(home, ".local", "share")} {
if given[p] != owner {
t.Errorf("%s, made by the host, was not given to the owner: %v", p, givenPaths(given))
}
}
}
func TestAParentThatWasThereIsHeldAsFound(t *testing.T) {
home, owner, given := aHome(t)
config := filepath.Join(home, ".config")
if err := os.Mkdir(config, 0o700); err != nil {
t.Fatal(err)
}
target := filepath.Join(config, "mesh", "environment.sh")
d := parse(t, `{"declaration":1,"resources":[{"id":"shell.env","type":"file","path":"`+target+
`","content":"export A=1\n","owner":"`+owner+`"}]}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared,
noServices, nil, nil); err != nil {
t.Fatal(err)
}
if _, touched := given[config]; touched {
t.Error("a parent that was already there was given to the owner")
}
if info, _ := os.Stat(config); info.Mode().Perm() != 0o700 {
t.Errorf("a parent that was already there changed mode: %o", info.Mode().Perm())
}
if given[filepath.Join(config, "mesh")] != owner {
t.Errorf("the parent the host made was not given to the owner: %v", givenPaths(given))
}
}
func TestAParentOutsideTheHomeIsMadeAsBefore(t *testing.T) {
_, owner, given := aHome(t)
target := filepath.Join(t.TempDir(), "var", "lib", "module", "settings.conf")
d := parse(t, `{"declaration":1,"resources":[{"id":"module.conf","type":"file","path":"`+target+
`","content":"a=1\n","owner":"`+owner+`"}]}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared,
noServices, nil, nil); err != nil {
t.Fatal(err)
}
if len(given) != 0 {
t.Errorf("parents outside the owner's home were given to it: %v", givenPaths(given))
}
}
-269
View File
@@ -1,269 +0,0 @@
//go:build linux
package apply
// The descriptor half of home_links.go: below a home, every component is opened from the one above it with
// O_NOFOLLOW, so a link is refused by the kernel at the moment of use, not only at the check before it.
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"golang.org/x/sys/unix"
)
// openDirUnder opens the directory rel names below home, following no link below the home.
func openDirUnder(home, dir string) (int, error) {
rel, err := filepath.Rel(home, filepath.Clean(dir))
if err != nil || strings.HasPrefix(rel, "..") {
return -1, fmt.Errorf("%s is not below %s", dir, home)
}
fd, err := unix.Open(home, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return -1, &os.PathError{Op: "open", Path: home, Err: err}
}
if rel == "." {
return fd, nil
}
at := home
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
at = filepath.Join(at, part)
next, err := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
unix.Close(fd)
if err != nil {
return -1, linkOr(at, home, dir, "open", err)
}
fd = next
}
return fd, nil
}
// linkOr says a refused link in the mesh's words, and any other failure as the system's.
func linkOr(at, home, path, op string, err error) error {
if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) {
if info, lerr := os.Lstat(at); lerr == nil && info.Mode()&os.ModeSymlink != 0 {
return &LinkUnderHomeError{Path: path, Link: at, Home: home}
}
}
return &os.PathError{Op: op, Path: at, Err: err}
}
// What a caller expects to find at a path below a home: either, a directory, or a regular file.
const (
kindAny = iota
kindDir
kindFile
)
// openUnder opens the file or directory at path below home, following no link, for its metadata.
func openUnder(home, path string) (int, error) { return openUnderAs(home, path, kindAny) }
// openUnderAs opens path below home as what the caller expects, and refuses what root must not act on
// (novox/hq ADR 0266): a directory is opened with O_DIRECTORY|O_NOFOLLOW; whatever was opened is judged by
// fstat on the descriptor itself, before any fchown, fchmod or read — so a name swapped after a check is
// judged as what it now is. Refused: anything but a directory or a regular file (a device, a fifo, a
// socket), a kind other than the one expected, and a regular file with more than one link. The account that
// owns the home can hard-link a file it does not own (root's, where fs.protected_hardlinks is off) into its
// home; owned or chmodded by name, root would hand that file over.
func openUnderAs(home, path string, want int) (int, error) {
dir, err := openDirUnder(home, filepath.Dir(path))
if err != nil {
return -1, err
}
defer unix.Close(dir)
base := filepath.Base(path)
flags := unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_NONBLOCK | unix.O_CLOEXEC
fd := -1
if want != kindFile {
fd, err = unix.Openat(dir, base, flags|unix.O_DIRECTORY, 0)
if errors.Is(err, unix.ENOTDIR) && want == kindAny {
fd, err = unix.Openat(dir, base, flags, 0)
}
} else {
fd, err = unix.Openat(dir, base, flags, 0)
}
if err != nil {
err = linkOr(path, home, path, "open", err)
var link *LinkUnderHomeError
if want == kindDir && !errors.As(err, &link) && errors.Is(err, unix.ENOTDIR) {
return -1, fmt.Errorf("%s is not a directory where a directory was expected: below a home it is left alone", path)
}
return -1, err
}
if err := judgeOpened(fd, path, want); err != nil {
unix.Close(fd)
return -1, err
}
return fd, nil
}
// judgeOpened is openUnderAs's verdict on what the descriptor holds.
func judgeOpened(fd int, path string, want int) error {
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil {
return &os.PathError{Op: "fstat", Path: path, Err: err}
}
switch st.Mode & unix.S_IFMT {
case unix.S_IFDIR:
if want == kindFile {
return fmt.Errorf("%s is a directory where a file was expected: below a home it is left alone", path)
}
case unix.S_IFREG:
if want == kindDir {
return fmt.Errorf("%s is a file where a directory was expected: below a home it is left alone", path)
}
if st.Nlink > 1 {
return &HardLinkUnderHomeError{Path: path, Links: uint64(st.Nlink)}
}
default:
return fmt.Errorf("%s is neither a file nor a directory: below a home it is left alone", path)
}
return nil
}
func chmodUnder(home, path string, mode os.FileMode) error {
return chmodUnderAs(home, path, mode, kindAny)
}
func chmodUnderAs(home, path string, mode os.FileMode, want int) error {
fd, err := openUnderAs(home, path, want)
if err != nil {
return err
}
defer unix.Close(fd)
if err := unix.Fchmod(fd, unixDirMode(mode)); err != nil {
return &os.PathError{Op: "chmod", Path: path, Err: err}
}
return nil
}
func chownUnder(home, path string, uid, gid int) error {
fd, err := openUnder(home, path)
if err != nil {
return err
}
defer unix.Close(fd)
if err := unix.Fchown(fd, uid, gid); err != nil {
return &os.PathError{Op: "chown", Path: path, Err: err}
}
return nil
}
func readUnder(home, path string) ([]byte, error) {
fd, err := openUnderAs(home, path, kindFile)
if err != nil {
return nil, err
}
f := os.NewFile(uintptr(fd), path)
defer f.Close()
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err == nil && st.Mode&unix.S_IFMT != unix.S_IFREG {
return nil, fmt.Errorf("%s is not a regular file", path)
}
return io.ReadAll(f)
}
// mkdirAllUnder makes the directories missing below home down to dir, each made in its parent's descriptor
// and opened without following a link; answers those it made, deepest first, as makeDirsSaying does.
func mkdirAllUnder(home, dir string, mode os.FileMode) ([]string, error) {
rel, err := filepath.Rel(home, filepath.Clean(dir))
if err != nil || strings.HasPrefix(rel, "..") {
return nil, fmt.Errorf("%s is not below %s", dir, home)
}
fd, err := unix.Open(home, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return nil, &os.PathError{Op: "open", Path: home, Err: err}
}
defer func() { unix.Close(fd) }()
var made []string
if rel == "." {
return nil, nil
}
at := home
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
at = filepath.Join(at, part)
if err := unix.Mkdirat(fd, part, uint32(mode.Perm())); err == nil {
made = append([]string{at}, made...)
} else if !errors.Is(err, unix.EEXIST) {
return made, &os.PathError{Op: "mkdir", Path: at, Err: err}
}
next, err := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return made, linkOr(at, home, dir, "open", err)
}
unix.Close(fd)
fd = next
}
return made, nil
}
// writeUnder writes a file below home atomically, through its directory's descriptor: made O_EXCL|O_NOFOLLOW
// under a name of its own, given its mode, and renamed over the file within that directory.
func writeUnder(home, path string, content []byte, mode os.FileMode) error {
dir, err := openDirUnder(home, filepath.Dir(path))
if err != nil {
return err
}
defer unix.Close(dir)
name := ".mesh-host-" + strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.Itoa(os.Getpid())
fd, err := unix.Openat(dir, name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600)
if err != nil {
return &os.PathError{Op: "create", Path: filepath.Join(filepath.Dir(path), name), Err: err}
}
f := os.NewFile(uintptr(fd), name)
_, werr := f.Write(content)
if werr == nil {
werr = f.Sync()
}
if werr == nil {
if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil {
werr = &os.PathError{Op: "chmod", Path: path, Err: err}
}
}
if cerr := f.Close(); werr == nil {
werr = cerr
}
if werr == nil {
if err := unix.Renameat(dir, name, dir, filepath.Base(path)); err != nil {
werr = &os.PathError{Op: "rename", Path: path, Err: err}
}
}
if werr != nil {
_ = unix.Unlinkat(dir, name, 0)
}
return werr
}
// giveHandedOver gives a directory the operator handed to the mesh its declared mode and owner (novox/hq issue
// 356, issue 339) through one descriptor opened from / with no link followed on any component (openUnderAs with /
// as the home), judged a directory by fstat on that descriptor, and changed with fchmod and fchown on it. The
// hand-over refused a link when it was recorded; this refuses one at the moment of use, so a component swapped for
// a link between the record and the apply cannot carry root's chmod and chown to wherever it points. Returns the
// mode and owner the directory had before.
func giveHandedOver(path string, mode os.FileMode, uid, gid int, chown bool) (before os.FileMode, beforeUID, beforeGID int, err error) {
fd, err := openUnderAs("/", path, kindDir)
if err != nil {
return 0, 0, 0, err
}
defer unix.Close(fd)
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil {
return 0, 0, 0, &os.PathError{Op: "fstat", Path: path, Err: err}
}
before, beforeUID, beforeGID = statDirMode(st.Mode), int(st.Uid), int(st.Gid)
if err := unix.Fchmod(fd, unixDirMode(mode)); err != nil {
return 0, 0, 0, &os.PathError{Op: "chmod", Path: path, Err: err}
}
if chown && (beforeUID != uid || beforeGID != gid) {
if err := unix.Fchown(fd, uid, gid); err != nil {
return 0, 0, 0, &os.PathError{Op: "chown", Path: path, Err: err}
}
}
return before, beforeUID, beforeGID, nil
}
-84
View File
@@ -1,84 +0,0 @@
//go:build !linux
package apply
// Where there is no openat with O_NOFOLLOW to rely on, the check before use is all: a link below a home is
// refused, and the call is made by path. The node-engine runs on Linux; this keeps the package building.
import (
"os"
"path/filepath"
"github.com/novox/mesh-host/internal/declaration"
)
func chmodUnder(home, path string, mode os.FileMode) error {
if err := refuseLinksUnderHome(path); err != nil {
return err
}
return os.Chmod(path, mode)
}
func chmodUnderAs(home, path string, mode os.FileMode, _ int) error {
return chmodUnder(home, path, mode)
}
const kindDir = 1
func chownUnder(home, path string, uid, gid int) error {
if err := refuseLinksUnderHome(path); err != nil {
return err
}
return os.Lchown(path, uid, gid)
}
func readUnder(home, path string) ([]byte, error) {
if err := refuseLinksUnderHome(path); err != nil {
return nil, err
}
return os.ReadFile(path)
}
func mkdirAllUnder(home, dir string, mode os.FileMode) ([]string, error) {
if err := refuseLinksUnderHome(dir); err != nil {
return nil, err
}
var made []string
for d := filepath.Clean(dir); d != home; d = filepath.Dir(d) {
if _, err := os.Lstat(d); err == nil {
break
}
made = append(made, d)
}
return made, os.MkdirAll(dir, mode)
}
func writeUnder(home, path string, content []byte, mode os.FileMode) error {
if err := refuseLinksUnderHome(path); err != nil {
return err
}
return writeAtomicallyByPath(path, content, mode)
}
// giveHandedOver is refused where there is no openat with O_NOFOLLOW: a handed-over directory is never changed by
// path (novox/hq issue 356). The node-engine runs on Linux.
func giveHandedOver(path string, _ os.FileMode, _, _ int, _ bool) (os.FileMode, int, int, error) {
return 0, 0, 0, &PlacementRefusedError{Path: path, Why: "a handed-over directory is changed only through a " +
"descriptor that followed no link, which this system cannot open"}
}
// judgeMeshDir refuses where there is no openat with O_NOFOLLOW (novox/hq issue 358). The node-engine runs on
// Linux.
func judgeMeshDir(path string) error {
return &PlacementRefusedError{Path: path, Why: "a mesh directory is judged only by a walk that follows no link " +
"another account could make, which this system cannot do"}
}
// meshDirsByDescriptor is false where there is no openat with O_NOFOLLOW: a mesh directory is changed by path, as
// before novox/hq issue 358. The node-engine runs on Linux.
const meshDirsByDescriptor = false
func applyMeshDirectory(r *declaration.Directory, out Outcome, _ os.FileMode, _ bool) (Outcome, error) {
return out, &PlacementRefusedError{Path: r.Path, Why: "a mesh directory is changed only through a descriptor " +
"that followed no link another account could make, which this system cannot open"}
}
-382
View File
@@ -1,382 +0,0 @@
package apply
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// Every module is given an identity of its own (novox/hq ADR 0328 §3, §4, §15, issue 567).
//
// The controller allocates each module a user and a group number once, from a range of its own, the same on every
// machine, and declares them as `mesh-<module>`. The engine makes the group and the account with exactly those
// numbers — a system account: no login shell, no home, no password — before anything of the declaration is
// applied, so a directory the module owns can be given to it and a container told to run as it.
//
// **Never root** (§11): a number that is 0, or a gid a root group has here (by its number and by its name), is
// refused for the whole declaration before anything is made or started, naming why — for an account and for an
// image alike. **Never another's**: a name that exists with other numbers, or numbers another name holds, is
// refused for that identity alone, naming whose, and its module is not attempted; the rest of the machine is.
//
// **What it made, and only that, goes** when the identity is no longer declared: the record says whether the
// engine made the account, the group or both, and an account or a group that was there before is never removed.
// accountsTool is how the engine reads and changes the machine's accounts: the system's own tools through the
// runner, which is what a test stands in for.
type accountsTool interface {
CreateSystemAccount(ctx context.Context, run system.Runner, name string, uid, gid int, groups []string) error
ModifyAccount(ctx context.Context, run system.Runner, name string, gid int, groups []string) error
CreateSystemGroup(ctx context.Context, run system.Runner, name string, gid int) error
RemoveAccount(ctx context.Context, run system.Runner, name string) error
RemoveGroup(ctx context.Context, run system.Runner, name string) error
}
// entry is one line the user or group database gives for a key: its name and its numbers.
type entry struct {
name string
id, prim int
}
// lookUp asks the database (passwd or group) for key, a name or a number. Absent is (entry{}, false, nil); a
// database that does not answer is an error, never read as absent (system.LookUpUser's rule).
func lookUp(ctx context.Context, run Runner, database, key string) (entry, bool, error) {
out, err := run(ctx, "getent", database, key)
if err != nil {
if code, ok := system.ExitCode(err); ok && code == 2 {
return entry{}, false, nil
}
return entry{}, false, fmt.Errorf("the %s database did not answer about %s: %w", database, key, err)
}
fields := strings.Split(strings.TrimSpace(strings.SplitN(out, "\n", 2)[0]), ":")
if len(fields) < 3 {
return entry{}, false, fmt.Errorf("the %s database gave %q for %s", database, strings.TrimSpace(out), key)
}
e := entry{name: fields[0], prim: -1}
if e.id, err = strconv.Atoi(fields[2]); err != nil {
return entry{}, false, fmt.Errorf("the %s database gave %q for %s", database, strings.TrimSpace(out), key)
}
if database == "passwd" && len(fields) >= 4 {
if e.prim, err = strconv.Atoi(fields[3]); err != nil {
e.prim = -1
}
}
return e, true, nil
}
// refuseRootIdentities is every identity and every container's run-as that is root's (§11), judged before
// anything of the declaration is applied: one is enough to refuse it whole, so nothing is made or started.
func refuseRootIdentities(d *declaration.Declaration) error {
var problems []string
for _, id := range d.Identities {
// Its user's number, its own group's (the same number), its primary group and every added group.
if err := rootIdentity(id.UID, id.UID); err != nil {
problems = append(problems, fmt.Sprintf("the identity %s: %v", id.Name, err))
}
for _, gid := range append([]int{id.GID}, id.Groups...) {
if why := rootGroup(gid); why != "" {
problems = append(problems, fmt.Sprintf("the identity %s is given gid %s, and a module's identity "+
"is never in a root group (novox/hq ADR 0328 §15)", id.Name, why))
}
}
}
for _, g := range d.Groups {
if why := rootGroup(g.GID); why != "" {
problems = append(problems, fmt.Sprintf("the library group %s is gid %s, and a library's group is never a "+
"root group (novox/hq ADR 0328 §15)", g.Name, why))
}
}
for _, r := range d.Resources {
if c, ok := r.(*declaration.Container); ok && c.RunAs != nil {
if err := rootIdentity(c.RunAs.UID, c.RunAs.GID); err != nil {
problems = append(problems, fmt.Sprintf("the container %s is told to run as uid %d, gid %d: %v",
c.ID, c.RunAs.UID, c.RunAs.GID, err))
}
}
}
if len(problems) == 0 {
return nil
}
sort.Strings(problems)
return fmt.Errorf("%s; nothing of this declaration was made or started", strings.Join(problems, "; "))
}
// applyIdentity makes one identity as declared (ADR 0328 §4): its own group `mesh-<module>` with the number of its
// uid, and its user with that uid, its primary group gid (its own, or its first library's) and its other libraries'
// groups as added groups — or refuses it naming whose the name or the numbers are. A user the engine made is given
// a changed primary or added groups; one that was there before is never changed, and is refused if it is not as
// declared. What it made is recorded on known; an identity already right is unchanged.
func applyIdentity(ctx context.Context, tool accountsTool, id declaration.Identity, names map[int]string,
known *store.State, run Runner) (Outcome, error) {
out := Outcome{ID: "identity " + id.Name, Type: "identity", Target: id.Name, Action: "unchanged"}
user, hasUser, err := lookUp(ctx, run, "passwd", id.Name)
if err != nil {
return out, err
}
group, hasGroup, err := lookUp(ctx, run, "group", id.Name)
if err != nil {
return out, err
}
byUID, uidUsed, err := lookUp(ctx, run, "passwd", strconv.Itoa(id.UID))
if err != nil {
return out, err
}
byGID, gidUsed, err := lookUp(ctx, run, "group", strconv.Itoa(id.UID))
if err != nil {
return out, err
}
rec := madeIdentity(known, id.Name)
const never = "; nothing was made, and nothing of the module was attempted (novox/hq ADR 0328 §4)"
switch {
case hasUser && user.id != id.UID:
return out, fmt.Errorf("the account %s is here with uid %d, and the mesh allocated uid %d%s", id.Name,
user.id, id.UID, never)
case hasUser && user.prim != id.GID && !rec.MadeUser:
return out, fmt.Errorf("the account %s is here with group %d, was not made by the mesh, and the mesh "+
"gives it group %d%s", id.Name, user.prim, id.GID, never)
case hasGroup && group.id != id.UID:
return out, fmt.Errorf("the group %s is here with gid %d, and a module's own group has its uid, %d%s",
id.Name, group.id, id.UID, never)
case uidUsed && byUID.name != id.Name:
return out, fmt.Errorf("uid %d is the account %s's here, and the mesh allocated it to %s%s", id.UID,
byUID.name, id.Name, never)
case gidUsed && byGID.name != id.Name:
return out, fmt.Errorf("gid %d is the group %s's here, and the mesh allocated it to %s%s", id.UID,
byGID.name, id.Name, never)
}
added := make([]string, 0, len(id.Groups))
for _, gid := range id.Groups {
added = append(added, names[gid])
}
sort.Strings(added)
rec.Module, rec.Name, rec.UID, rec.GID = id.Module, id.Name, id.UID, id.GID
var made []string
if !hasGroup {
if err := tool.CreateSystemGroup(ctx, run, id.Name, id.UID); err != nil {
return out, err
}
rec.MadeGroup = true
made = append(made, fmt.Sprintf("its own group (gid %d)", id.UID))
}
if !hasUser {
if err := tool.CreateSystemAccount(ctx, run, id.Name, id.UID, id.GID, added); err != nil {
recordIdentity(known, rec)
return out, err
}
rec.MadeUser = true
made = append(made, fmt.Sprintf("the account (uid %d, group %d%s), with no login, no home and no password",
id.UID, id.GID, alsoIn(added)))
} else if rec.MadeUser {
// The mesh's own account: its primary and added groups follow what is declared.
in, err := system.GroupsOf(ctx, system.Runner(run), id.Name)
if err != nil {
return out, err
}
have := map[string]bool{}
for _, g := range in {
if g != id.Name && g != names[user.prim] {
have[g] = true
}
}
same := user.prim == id.GID && len(have) == len(added)
for _, g := range added {
same = same && have[g]
}
if !same {
if err := tool.ModifyAccount(ctx, run, id.Name, id.GID, added); err != nil {
return out, err
}
out.Action = "updated"
out.Detail = fmt.Sprintf("gave the account group %d%s", id.GID, alsoIn(added))
}
}
if rec.At.IsZero() {
rec.At = time.Now().UTC()
}
recordIdentity(known, rec)
if len(made) > 0 {
out.Action, out.Detail = "created", "made "+strings.Join(made, " and ")
} else if out.Detail == "" {
out.Detail = "here with its numbers"
}
return out, nil
}
func alsoIn(groups []string) string {
if len(groups) == 0 {
return ""
}
return ", also in " + strings.Join(groups, ", ")
}
// applyGroup makes one library's group with the number the controller allocated (ADR 0328 §3), or refuses it naming
// whose the name or the number is, or that the number is a root group's (§15). What it made is recorded on known.
func applyGroup(ctx context.Context, tool accountsTool, g declaration.Group, known *store.State,
run Runner) (Outcome, error) {
out := Outcome{ID: "group " + g.Name, Type: "group", Target: g.Name, Action: "unchanged"}
if why := rootGroup(g.GID); why != "" {
return out, fmt.Errorf("its gid is %s, and a library's group is never a root group (novox/hq ADR 0328 §15); "+
"nothing was made", why)
}
byName, hasName, err := lookUp(ctx, run, "group", g.Name)
if err != nil {
return out, err
}
byGID, gidUsed, err := lookUp(ctx, run, "group", strconv.Itoa(g.GID))
if err != nil {
return out, err
}
switch {
case hasName && byName.id != g.GID:
return out, fmt.Errorf("the group %s is here with gid %d, and the mesh allocated gid %d; nothing was made",
g.Name, byName.id, g.GID)
case gidUsed && byGID.name != g.Name:
return out, fmt.Errorf("gid %d is the group %s's here, and the mesh allocated it to %s; nothing was made",
g.GID, byGID.name, g.Name)
case hasName:
out.Detail = "here with its number"
return out, nil
}
if err := tool.CreateSystemGroup(ctx, run, g.Name, g.GID); err != nil {
return out, err
}
known.Groups = append(known.Groups, store.MadeGroup{Name: g.Name, GID: g.GID, At: time.Now().UTC()})
out.Action, out.Detail = "created", fmt.Sprintf("made the library's group (gid %d)", g.GID)
return out, nil
}
// removeGroups removes every library group the engine made that is no longer declared, while it still has the
// number the engine gave it. Never fatal: one that could not be removed (still a user's primary group) stays
// recorded, said, for the next apply.
func removeGroups(ctx context.Context, tool accountsTool, declared []declaration.Group, known *store.State,
run Runner) []Outcome {
wanted := map[string]bool{}
for _, g := range declared {
wanted[g.Name] = true
}
var out []Outcome
var keep []store.MadeGroup
for _, m := range known.Groups {
if wanted[m.Name] {
keep = append(keep, m)
continue
}
o := Outcome{ID: "group " + m.Name, Type: "group", Target: m.Name, Action: "removed",
Detail: "no longer declared: removed the group"}
g, has, err := lookUp(ctx, run, "group", m.Name)
switch {
case err != nil || (has && g.id != m.GID):
o.Action, o.Detail = "forgotten", "no longer declared: the group was left, it no longer has the gid the "+
"mesh gave it, or the database did not answer"
case has:
if err := tool.RemoveGroup(ctx, run, m.Name); err != nil {
o.Action, o.Detail = "waiting", "no longer declared: the group could not be removed: "+err.Error()
keep = append(keep, m)
}
default:
o.Action, o.Detail = "forgotten", "no longer declared, and no longer here"
}
out = append(out, o)
}
known.Groups = keep
return out
}
// madeIdentity is what the engine recorded of name, or an empty record.
func madeIdentity(known *store.State, name string) store.MadeIdentity {
for _, m := range known.Identities {
if m.Name == name {
return m
}
}
return store.MadeIdentity{}
}
// recordIdentity records what was made of one identity, in place of any record before.
func recordIdentity(known *store.State, rec store.MadeIdentity) {
for i, m := range known.Identities {
if m.Name == rec.Name {
known.Identities[i] = rec
return
}
}
if rec.MadeUser || rec.MadeGroup {
known.Identities = append(known.Identities, rec)
}
}
// removeIdentities removes what the engine made of every identity no longer declared — the account, then the
// group — and only while it still has the numbers the engine gave it. Never fatal: what could not be removed
// stays recorded, said, for the next apply.
func removeIdentities(ctx context.Context, tool accountsTool, declared []declaration.Identity, known *store.State,
run Runner) []Outcome {
wanted := map[string]bool{}
for _, id := range declared {
wanted[id.Name] = true
}
var out []Outcome
var keep []store.MadeIdentity
for _, m := range known.Identities {
if wanted[m.Name] {
keep = append(keep, m)
continue
}
o := Outcome{ID: "identity " + m.Name, Type: "identity", Target: m.Name, Action: "removed"}
var said []string
if m.MadeUser {
if u, has, err := lookUp(ctx, run, "passwd", m.Name); err != nil || (has && u.id != m.UID) {
said = append(said, "the account was left: it no longer has the uid the mesh gave it, or the "+
"database did not answer")
m.MadeUser = false
} else if has {
if err := tool.RemoveAccount(ctx, run, m.Name); err != nil {
said = append(said, "the account could not be removed: "+err.Error())
} else {
m.MadeUser = false
said = append(said, "removed the account")
}
} else {
m.MadeUser = false
}
}
if m.MadeGroup && !m.MadeUser {
if g, has, err := lookUp(ctx, run, "group", m.Name); err != nil || (has && g.id != m.GID) {
said = append(said, "the group was left: it no longer has the gid the mesh gave it, or the "+
"database did not answer")
m.MadeGroup = false
} else if has {
if err := tool.RemoveGroup(ctx, run, m.Name); err != nil {
said = append(said, "the group could not be removed: "+err.Error())
} else {
m.MadeGroup = false
said = append(said, "removed the group")
}
} else {
m.MadeGroup = false
}
}
if m.MadeUser || m.MadeGroup {
o.Action = "waiting"
keep = append(keep, m)
}
o.Detail = "no longer declared: " + strings.Join(said, "; ")
out = append(out, o)
}
known.Identities = keep
return out
}
// toolFor is the accounts tool of a system, or why it has none.
func toolFor(sys system.System) (accountsTool, error) {
if t, ok := sys.(accountsTool); ok {
return t, nil
}
return nil, errors.New("this system makes no module identity: it has no way to make a system account")
}
-385
View File
@@ -1,385 +0,0 @@
package apply
import (
"context"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// The engine's half of ADR 0325 §3 and §11 (issue 567): every module given an account and a group of its own with
// the numbers the controller allocated, a container told to run as it, and never a root number.
// accountsMachine is a user and group database the runner answers from, and every command that changed it.
type accountsMachine struct {
users, groups map[string][2]int // name → id, primary gid (a group's second is unused)
// in is each account's added groups, by name; homes each account's home, "/" when not set.
in map[string][]string
homes map[string]string
ran []string
}
// groupNamed is the name of gid in m, or the number.
func (m *accountsMachine) groupNamed(gid int) string {
for n, ids := range m.groups {
if ids[0] == gid {
return n
}
}
return strconv.Itoa(gid)
}
func (m *accountsMachine) run(_ context.Context, name string, args ...string) (string, error) {
line := name + " " + strings.Join(args, " ")
switch name {
case "getent":
db, key := args[0], args[1]
table := m.users
if db == "group" {
table = m.groups
}
for n, ids := range table {
if n == key || strconv.Itoa(ids[0]) == key {
if db == "group" {
return n + ":x:" + strconv.Itoa(ids[0]) + ":\n", nil
}
home := m.homes[n]
if home == "" {
home = "/"
}
return n + ":x:" + strconv.Itoa(ids[0]) + ":" + strconv.Itoa(ids[1]) + "::" + home + ":/usr/bin/nologin\n", nil
}
}
return "", errors.New("getent exited 2")
case "id":
name := args[len(args)-1]
u, ok := m.users[name]
if !ok {
return "", errors.New("id exited 1")
}
return strings.Join(append([]string{m.groupNamed(u[1])}, m.in[name]...), " ") + "\n", nil
case "groupadd":
m.groups[args[len(args)-1]] = [2]int{mustAtoi(args[2]), 0}
case "useradd":
name := args[len(args)-1]
m.users[name] = [2]int{mustAtoi(args[2]), mustAtoi(args[4])}
if args[5] == "--groups" {
m.setIn(name, strings.Split(args[6], ","))
}
if m.homes == nil {
m.homes = map[string]string{}
}
m.homes[name] = "/nonexistent"
case "usermod":
name := args[len(args)-1]
if args[0] == "--append" {
m.setIn(name, append(m.in[name], args[2]))
} else if args[0] == "--home" {
if m.homes == nil {
m.homes = map[string]string{}
}
m.homes[name] = args[1]
} else {
u := m.users[name]
m.users[name] = [2]int{u[0], mustAtoi(args[1])}
m.setIn(name, strings.Split(args[3], ","))
}
case "gpasswd":
var kept []string
for _, g := range m.in[args[1]] {
if g != args[2] {
kept = append(kept, g)
}
}
m.setIn(args[1], kept)
case "userdel":
delete(m.users, args[0])
case "groupdel":
delete(m.groups, args[0])
default:
return "", nil
}
m.ran = append(m.ran, line)
return "", nil
}
func (m *accountsMachine) setIn(name string, groups []string) {
if m.in == nil {
m.in = map[string][]string{}
}
var out []string
for _, g := range groups {
if g != "" {
out = append(out, g)
}
}
m.in[name] = out
}
func mustAtoi(s string) int {
n, _ := strconv.Atoi(s)
return n
}
// withGroups is a declaration with library groups, identities and resources.
func withGroups(t *testing.T, groups, identities, resources string) *declaration.Declaration {
t.Helper()
nothing := ""
if resources == "" {
nothing = `"owns_nothing":true,`
}
d, err := declaration.Parse([]byte(`{"declaration":1,"groups":[` + groups + `],"identities":[` + identities +
`],` + nothing + `"resources":[` + resources + `]}`))
if err != nil {
t.Fatal(err)
}
return d
}
func withIdentities(t *testing.T, identities, resources string) *declaration.Declaration {
t.Helper()
d, err := declaration.Parse([]byte(`{"declaration":1,"identities":[` + identities + `],"resources":[` +
resources + `]}`))
if err != nil {
t.Fatal(err)
}
return d
}
// Row 3: the account made as a system account with exactly its numbers, recorded, and removed — only what was
// made — when no longer declared.
func TestAModulesIdentityIsMadeWithItsNumbersAndRemovedWhenUndeclared(t *testing.T) {
m := &accountsMachine{users: map[string][2]int{}, groups: map[string][2]int{}}
dir := t.TempDir()
d := withIdentities(t, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}`,
`{"id":"radarr.config","type":"directory","path":"`+dir+`/config"}`)
report, known, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil)
if err != nil {
t.Fatal(err)
}
want := []string{
"groupadd --system --gid 61001 mesh-radarr",
"useradd --system --uid 61001 --gid 61001 --no-create-home --home-dir /nonexistent --shell /usr/bin/nologin mesh-radarr",
}
if strings.Join(m.ran, "\n") != strings.Join(want, "\n") {
t.Fatalf("made with:\n%s\nwant:\n%s", strings.Join(m.ran, "\n"), strings.Join(want, "\n"))
}
if o := outcomeOf(report, "identity mesh-radarr"); o.Action != "created" {
t.Errorf("the identity's outcome: %+v", o)
}
if len(known.Identities) != 1 || !known.Identities[0].MadeUser || !known.Identities[0].MadeGroup {
t.Fatalf("what was made is not recorded: %+v", known.Identities)
}
// Applied again: nothing is made twice.
m.ran = nil
if _, known, err = Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, m.run, nil, nil); err != nil {
t.Fatal(err)
}
if len(m.ran) != 0 {
t.Fatalf("an identity already here was made again: %v", m.ran)
}
// Undeclared, with a group the machine had before for another identity: only what was made goes.
m.groups["mesh-sonarr"] = [2]int{61002, 0}
other := withIdentities(t, `{"module":"sonarr","name":"mesh-sonarr","uid":61002,"gid":61002}`,
`{"id":"x.dir","type":"directory","path":"`+dir+`/x"}`)
if _, known, err = Apply(context.Background(), archHost(t), other, known, store.OriginDeclared, m.run, nil, nil); err != nil {
t.Fatal(err)
}
if _, has := m.users["mesh-radarr"]; has {
t.Error("an identity no longer declared kept its account")
}
if _, has := m.groups["mesh-radarr"]; has {
t.Error("an identity no longer declared kept its group")
}
if rec := madeIdentity(&known, "mesh-sonarr"); rec.MadeGroup {
t.Error("a group that was there before is recorded as the mesh's")
}
}
// Row 3: a name that exists with other numbers, or numbers another name holds, is refused naming whose, and the
// module is not attempted; the rest of the machine is.
func TestAnIdentityWhoseNameOrNumbersAreAnothersIsRefused(t *testing.T) {
dir := t.TempDir()
for _, tc := range []struct {
users, groups map[string][2]int
says string
}{
{users: map[string][2]int{"mesh-radarr": {1500, 1500}}, groups: map[string][2]int{}, says: "uid 1500"},
{users: map[string][2]int{"someone": {61001, 100}}, groups: map[string][2]int{}, says: "someone"},
{users: map[string][2]int{}, groups: map[string][2]int{"media": {61001, 0}}, says: "media"},
} {
m := &accountsMachine{users: tc.users, groups: tc.groups}
d := withIdentities(t, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}`,
`{"id":"radarr.config","type":"directory","path":"`+dir+`/radarr"},`+
`{"id":"plex.config","type":"directory","path":"`+dir+`/plex"}`)
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil)
if err == nil || !strings.Contains(err.Error(), tc.says) {
t.Errorf("not refused naming %s: %v", tc.says, err)
}
if len(m.ran) != 0 {
t.Errorf("something was made for an identity that is another's: %v", m.ran)
}
if o := outcomeOf(report, "radarr.config"); o.Action != "skipped" {
t.Errorf("the module of a refused identity was attempted: %+v", o)
}
if _, err := os.Stat(filepath.Join(dir, "plex")); err != nil {
t.Errorf("the rest of the machine was not applied: %v", err)
}
os.RemoveAll(filepath.Join(dir, "plex"))
}
}
// Row 11: a declaration naming a root group for an account or an image is refused with its reason, and nothing is
// made or started. A number 0 is refused on arrival, by the parser.
func TestNoModuleIdentityIsRoot(t *testing.T) {
was := RootGroupIDs
RootGroupIDs = func() map[int]string { return map[int]string{10: "wheel"} }
defer func() { RootGroupIDs = was }()
dir := t.TempDir()
for _, d := range []*declaration.Declaration{
withGroups(t, `{"name":"mesh-media","gid":10}`, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":10}`,
`{"id":"radarr.config","type":"directory","path":"`+dir+`/config"}`),
withGroups(t, `{"name":"mesh-media","gid":10}`, ``,
`{"id":"radarr.config","type":"directory","path":"`+dir+`/config"}`),
withIdentities(t, ``, `{"id":"radarr.config","type":"directory","path":"`+dir+`/config"},`+
`{"id":"radarr.app","type":"container","name":"radarr","image":"`+pinned+`",`+
`"identity":{"mode":"run","uid":61001,"gid":10}}`),
} {
m := &accountsMachine{users: map[string][2]int{}, groups: map[string][2]int{}}
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil)
if err == nil || !strings.Contains(err.Error(), "wheel") || !strings.Contains(err.Error(), "never") {
t.Errorf("a root group was not refused naming why: %v", err)
}
if len(m.ran) != 0 {
t.Errorf("something was made or started: %v", m.ran)
}
if _, err := os.Stat(filepath.Join(dir, "config")); err == nil {
t.Error("a resource of a declaration naming a root group was applied")
}
}
for _, raw := range []string{
`{"declaration":1,"identities":[{"module":"m","name":"mesh-m","uid":0,"gid":61001}],"resources":[]}`,
`{"declaration":1,"resources":[{"id":"m.app","type":"container","name":"m","image":"` + pinned + `",` +
`"identity":{"mode":"env","user_env":"PUID","group_env":"PGID","uid":61001,"gid":0}}]}`,
} {
if _, err := declaration.Parse([]byte(raw)); err == nil || !strings.Contains(err.Error(), "never root") {
t.Errorf("a number 0 was not refused on arrival: %v", err)
}
}
}
// Row 3: a container declaring identity is told it — by the variables its image reads, or the runtime's user
// option — and a changed identity is a container recreated.
func TestAContainerIsToldItsModulesIdentity(t *testing.T) {
env := &declaration.Container{ID: "radarr.app", Name: "radarr", Image: pinned, RunAs: &declaration.ContainerIdentity{
Mode: declaration.IdentityByEnv, UserEnv: "PUID", GroupEnv: "PGID", UID: 61001, GID: 61001}}
run := &declaration.Container{ID: "plex.app", Name: "plex", Image: pinned, RunAs: &declaration.ContainerIdentity{
Mode: declaration.IdentityByRun, UID: 61002, GID: 61002}}
if got := strings.Join(foregroundRunArgs(env, "x"), " "); !strings.Contains(got, "--env PUID=61001 --env PGID=61001") {
t.Errorf("an image told by environment was not: %s", got)
}
if got := strings.Join(foregroundRunArgs(run, "x"), " "); !strings.Contains(got, "--user 61002:61002") {
t.Errorf("an image told by the runtime's user option was not: %s", got)
}
plain := *env
plain.RunAs = nil
moved := *env
moved.RunAs = &declaration.ContainerIdentity{Mode: declaration.IdentityByEnv, UserEnv: "PUID", GroupEnv: "PGID",
UID: 61009, GID: 61009}
if containerSpec(env, inputs{}) == containerSpec(&plain, inputs{}) ||
containerSpec(env, inputs{}) == containerSpec(&moved, inputs{}) {
t.Error("an identity told, or renumbered, does not recreate the container")
}
}
// ADR 0325 §5 supersedes ADR 0312's group grant: what a grant put an account in is taken back on the first apply of
// the access, and nothing is granted again.
func TestWhatAGroupGrantPutAnAccountInIsTakenBack(t *testing.T) {
dir := t.TempDir()
groups := "nox media"
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
ran = append(ran, name+" "+strings.Join(args, " "))
switch name {
case "id":
return groups + "\n", nil
case "gpasswd":
groups = "nox"
}
return "", nil
}
known := store.State{Resources: []store.Applied{{ID: "importer.books", Type: "access", Target: dir,
Origin: store.OriginDeclared, User: "nox", Groups: []string{"media"}}}}
d := declare(t, `{"id":"importer.books","type":"access","path":"`+dir+`","mode":"read-write","grant":{"account":"nox"}}`)
report, known, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if groups != "nox" || !strings.Contains(strings.Join(ran, "\n"), "gpasswd --delete nox media") {
t.Fatalf("the grant's group was not taken back: %v", ran)
}
if o := outcomeOf(report, "importer.books"); o.Action != "updated" || !strings.Contains(o.Detail, "taken back") {
t.Errorf("the take-back is not said: %+v", o)
}
if rec, _ := known.Find("importer.books"); len(rec.Groups) != 0 {
t.Errorf("the record still holds the grant's groups: %+v", rec)
}
}
// Review L7: a group the grant put an account in that could not be left stays recorded, and is tried again.
func TestAGrantsGroupThatCouldNotBeLeftIsTriedAgain(t *testing.T) {
dir := t.TempDir()
refuse := true
groups := "nox media"
run := func(_ context.Context, name string, args ...string) (string, error) {
switch name {
case "id":
return groups + "\n", nil
case "gpasswd":
if refuse {
return "", errors.New("gpasswd exited 1: locked")
}
groups = "nox"
}
return "", nil
}
known := store.State{Resources: []store.Applied{{ID: "importer.books", Type: "access", Target: dir,
Origin: store.OriginDeclared, User: "nox", Groups: []string{"media"}}}}
d := declare(t, `{"id":"importer.books","type":"access","path":"`+dir+`","mode":"read-write"}`)
_, known, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if rec, _ := known.Find("importer.books"); rec.User != "nox" || len(rec.Groups) != 1 {
t.Fatalf("a group that could not be left was forgotten: %+v", rec)
}
refuse = false
if _, known, err = Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil); err != nil {
t.Fatal(err)
}
if rec, _ := known.Find("importer.books"); len(rec.Groups) != 0 || groups != "nox" {
t.Fatalf("the group was not taken back on the next apply: %+v (%s)", rec, groups)
}
}
// Every system the engine runs on that makes accounts makes module identities: arch and alpine alike.
func TestArchAndAlpineMakeModuleIdentities(t *testing.T) {
for _, name := range []string{"arch", "alpine"} {
sys, err := system.For(name)
if err != nil {
t.Fatal(err)
}
if _, err := toolFor(sys); err != nil {
t.Errorf("%s makes no module identity: %v", name, err)
}
}
}
-1
View File
@@ -19,7 +19,6 @@ import (
// not catch a regression back to it — this one refuses to, on purpose.
func dockerLikeByKind(containers map[string]bool) func(context.Context, string, ...string) (string, error) {
return func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != "docker" {
return "", errors.New("unexpected program: " + name)
}
+1 -1
View File
@@ -132,7 +132,7 @@ func applyInto(r *declaration.File, previous store.Applied) (Outcome, error) {
mode = m
}
}
if err := makeDirs(filepath.Dir(r.Path), 0o755, r.Owner); err != nil {
if err := os.MkdirAll(filepath.Dir(r.Path), 0o755); err != nil {
return out, err
}
if err := writeAtomically(r.Path, want, mode); err != nil {
-129
View File
@@ -1,129 +0,0 @@
package apply
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// novox/hq issue 190, ADR 0222: the private network stops writing the mesh's registry into the
// container runtime's file, and the runtime's own module writes the same member. Both are moved in
// one apply, and the machine never loses the member: the network's record goes first (its member
// leaves the list), the runtime's module is applied after (the member is added back, now recorded
// as the runtime's), and the daemon is reloaded once, never stopped, disabled or restarted — even
// though the record going says the unit was found stopped and disabled.
const theRegistry = "anchor.internal:5100"
func runtimeDecl(t *testing.T, path string, network bool) *declaration.Declaration {
t.Helper()
var resources []string
if network {
resources = append(resources,
fmt.Sprintf(`{"id":"networking.registry-trust","type":"file","path":%q,"into":"json","content":%q}`,
path, `{"insecure-registries":["`+theRegistry+`"]}`),
`{"id":"networking.registry-trust-reload","type":"service","unit":"docker.service","state":"running",
"reload-on":["networking.registry-trust"]}`)
}
resources = append(resources,
fmt.Sprintf(`{"id":"docker.daemon","type":"file","path":%q,"into":"json","content":%q}`,
path, `{"live-restore":true,"insecure-registries":["`+theRegistry+`"]}`),
`{"id":"docker.runtime","type":"service","unit":"docker.service","state":"running","boot":"enabled",
"reload-on":["docker.daemon"]}`)
return parse(t, `{"declaration":1,"resources":[`+strings.Join(resources, ",")+`]}`)
}
func TestTheRegistryMovesToTheRuntimesModuleWithoutLeavingTheList(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.json")
if err := os.WriteFile(path, []byte(`{"insecure-registries":["192.0.2.7:5000"]}`), 0o644); err != nil {
t.Fatal(err)
}
var commands []string
run := recordingServices(&commands)
// Both declare it: the network's record added the member, so the runtime's finds it there.
_, state, err := Apply(context.Background(), archHost(t), runtimeDecl(t, path, true), store.State{},
store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
// Say the network's record found the runtime stopped and disabled: giving that back would stop
// every container on the machine.
for i := range state.Resources {
if state.Resources[i].ID == "networking.registry-trust-reload" {
state.Resources[i].Found = &store.FoundUnit{Unit: "docker.service", State: "stopped", Boot: "disabled"}
}
}
commands = nil
report, state, err := Apply(context.Background(), archHost(t), runtimeDecl(t, path, false), state,
store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if got := fmt.Sprint(readObject(t, path)["insecure-registries"]); got != "[192.0.2.7:5000 "+theRegistry+"]" {
t.Fatalf("the list after the move: %s", got)
}
rec, _ := state.Find("docker.daemon")
if added := rec.Into.Added["insecure-registries"]; len(added) != 1 || canonical(added[0]) != `"`+theRegistry+`"` {
t.Errorf("the member is not recorded as the runtime's module's: %s", added)
}
reloads := 0
for _, c := range commands {
if strings.Contains(c, "docker.service") && (strings.Contains(c, " stop ") || strings.Contains(c, " restart ") ||
strings.Contains(c, " disable ")) {
t.Errorf("the runtime was given back as the network's record found it: %q", c)
}
if strings.Contains(c, "reload docker.service") {
reloads++
}
}
if reloads != 1 {
t.Errorf("the runtime was reloaded %d times; commands were %v", reloads, commands)
}
for _, o := range report.Outcomes {
if o.ID == "networking.registry-trust-reload" && o.Action != "forgotten" {
t.Errorf("the network's record of the unit was %q: %s", o.Action, o.Detail)
}
}
// Steady from here on, and undeclaring the runtime's module takes only what it added.
report, state, err = Apply(context.Background(), archHost(t), runtimeDecl(t, path, false), state,
store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
for _, o := range report.Outcomes {
if o.ID == "docker.daemon" && o.Action != "unchanged" {
t.Errorf("a second apply was %q", o.Action)
}
}
if _, _, err := Apply(context.Background(), archHost(t), somethingElse(t), state, store.OriginDeclared, run, nil, nil); err != nil {
t.Fatal(err)
}
if got := fmt.Sprint(readObject(t, path)["insecure-registries"]); got != "[192.0.2.7:5000]" {
t.Errorf("undeclaring the runtime's module left %s", got)
}
}
// And the preview says the same before it happens.
func TestThePlanForgetsARecordOfAUnitStillHeld(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.json")
var commands []string
_, state, err := Apply(context.Background(), archHost(t), runtimeDecl(t, path, true), store.State{},
store.OriginDeclared, recordingServices(&commands), nil, nil)
if err != nil {
t.Fatal(err)
}
for _, step := range Plan(runtimeDecl(t, path, false), state, store.OriginDeclared) {
if step.ID == "networking.registry-trust-reload" && step.Verb != "forget" {
t.Errorf("the plan would %s the network's record of a unit docker.runtime holds: %s", step.Verb, step.Why)
}
}
}
-72
View File
@@ -1,72 +0,0 @@
//go:build linux
package apply
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The read-only judgement of a node's directories (novox/hq issue 358): one line each — placed, would be refused
// naming the link and who could replace it, or not there yet (placed: an apply makes it) — and nothing made or
// changed.
func TestJudgeDirectoriesSaysWhatTheWalkWouldRefuseAndChangesNothing(t *testing.T) {
base := t.TempDir()
plain := filepath.Join(base, "plain")
if err := os.Mkdir(plain, 0o755); err != nil {
t.Fatal(err)
}
elsewhere := filepath.Join(base, "elsewhere", "state")
if err := os.MkdirAll(elsewhere, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(base, "elsewhere"), filepath.Join(base, "linked")); err != nil {
t.Fatal(err)
}
missing := filepath.Join(base, "missing", "deeper")
d := declare(t, `{"id":"a.plain","type":"directory","path":"`+plain+`","mode":"0700"},`+
`{"id":"a.linked","type":"directory","path":"`+filepath.Join(base, "linked", "state")+`","mode":"0700"},`+
`{"id":"a.missing","type":"directory","path":"`+missing+`","mode":"0700"}`)
said := JudgeDirectories(d)
lines := strings.Split(said, "\n")
if len(lines) != 4 || !strings.HasPrefix(lines[0], "3 directories declared: 2 placed, 1 would be refused") {
t.Fatalf("the judgement: %q", said)
}
if lines[1] != plain+": placed" || lines[3] != missing+": placed" {
t.Errorf("a directory the walk reaches is not said placed: %q", said)
}
if !strings.Contains(lines[2], "would be refused") || !strings.Contains(lines[2], filepath.Join(base, "linked")) ||
!strings.Contains(lines[2], "every account") {
t.Errorf("the linked directory is not said refused naming the link and who: %q", lines[2])
}
if _, err := os.Stat(filepath.Join(base, "missing")); !os.IsNotExist(err) {
t.Error("the judgement made a directory")
}
for _, p := range []string{plain, elsewhere} {
if info, _ := os.Stat(p); info.Mode().Perm() != 0o755 {
t.Errorf("the judgement changed %s to %o", p, info.Mode().Perm())
}
}
}
// A link only root could have made is followed by the judgement as by the apply.
func TestJudgeDirectoriesFollowsARootOnlyLink(t *testing.T) {
wasDirs, wasLinks := couldReplace, linkReplacer
couldReplace = func(string, uint32, uint32, uint32) string { return "" }
linkReplacer = func(string, uint32) string { return "" }
t.Cleanup(func() { couldReplace, linkReplacer = wasDirs, wasLinks })
base := t.TempDir()
if err := os.MkdirAll(filepath.Join(base, "real", "state"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("real", filepath.Join(base, "run")); err != nil {
t.Fatal(err)
}
path := filepath.Join(base, "run", "state")
if said := JudgeDirectories(declare(t, `{"id":"a.s","type":"directory","path":"`+path+`"}`)); !strings.Contains(said, path+": placed") {
t.Fatalf("a directory behind a root-only link is not said placed: %q", said)
}
}
-244
View File
@@ -1,244 +0,0 @@
package apply
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// An access is an operator's path a module mounts and the mesh does not own (novox/hq ADR 0051): mounted when
// present, refused when absent, nothing changed on it. ADR 0325 kept a shared folder through ACL entries; ADR 0328
// supersedes it with libraries (library.go), and an access is again exactly what it was before: an access's `users`
// and `taken` are accepted and not acted on.
// applyAccess is one access, applied as ADR 0051 has it; what ADR 0312's grant put an account in is taken back
// first, so no account is in a group for data but a library's (ADR 0328 §11).
func applyAccess(ctx context.Context, sys system.System, r *declaration.Access, run Runner, previous store.Applied,
in inputs) (Outcome, error) {
gave, left, said := takeBackGranted(ctx, sys, previous, run, in.groups, in.handed)
out, err := legacyAccess(r)
if len(left) > 0 {
out.user, out.groups = previous.User, left
}
if said != "" {
out.Detail = joinDetail(out.Detail, said)
if gave && out.Action == "unchanged" {
// Updated, so what names the access under restart-on — the tool runner — holds the groups it has now.
out.Action = "updated"
}
}
return out, err
}
// takeBackGranted takes an account out of every group ADR 0312's grant put it in for an access, as removeUser takes
// back a user's (ADR 0252 §3): unless another declared resource still asks for it, which is then handed the group.
// Whether any was taken back, the groups still to take back — one whose leaving failed stays recorded, so the next
// apply tries again — and what was said; nothing for an access that granted nothing.
func takeBackGranted(ctx context.Context, sys system.System, previous store.Applied, run Runner, wanted Wanted,
handed Handed) (bool, []string, string) {
if previous.User == "" || len(previous.Groups) == 0 {
return false, nil, ""
}
account := previous.User
in, err := system.GroupsOf(ctx, system.Runner(run), account)
if err != nil {
return false, previous.Groups, fmt.Sprintf("the groups ADR 0312's grant put %s in (%s) were not taken "+
"back, and are tried again: %v", account, strings.Join(previous.Groups, ", "), err)
}
already := setOf(in)
gave := false
var left, said []string
for _, g := range previous.Groups {
switch {
case !already[g]:
continue
case len(wanted.othersAsk(account, g, previous.ID)) > 0:
said = append(said, fmt.Sprintf("left in %s, which %s still asks for", g,
strings.Join(wanted.othersAsk(account, g, previous.ID), ", ")))
handed.hand(account, g)
continue
}
gone, why := leaveGroup(ctx, sys, account, g, run)
if !gone {
left = append(left, g)
why += "; tried again on the next apply"
}
gave = gave || gone
said = append(said, why)
}
return gave, left, "the group grant ADR 0312 made is taken back: " + account + " " + strings.Join(said, "; ")
}
// legacyAccess is an access from a controller older than ADR 0325, applied exactly as before it (ADR 0051 §7).
//
// **Absent is refused, not created.** A bind mount whose source does not exist is made for you by the container
// runtime, as root, with whatever mode it picks — which is exactly the silent wrong-ownership 04-ISSUES/026
// records. So the host checks first and says plainly that the operator must provide the path, rather than
// conjuring a directory it does not own and cannot give the right owner. Nothing is written, so this never reports
// a change: the machine did not move, the host merely confirmed a fact about it.
func legacyAccess(r *declaration.Access) (Outcome, error) {
out := begin(r)
info, err := os.Stat(r.Path)
if errors.Is(err, os.ErrNotExist) {
return out, fmt.Errorf(
"%s is not there, and the mesh does not own it — the operator must provide it. It is "+
"shared, pre-existing data (novox/hq ADR 0051): the host mounts it and creates "+
"nothing, so a missing one is said here rather than made as root by the container "+
"runtime", r.Path)
}
if err != nil {
return out, err
}
if !info.IsDir() {
return out, fmt.Errorf(
"%s is not a directory, and an access is a shared directory the operator provides",
r.Path)
}
mode := declaration.AccessRead
if r.Mode != "" {
mode = r.Mode
}
out.Action = "unchanged"
out.Detail = "operator-owned; present, " + mode + ", nothing managed"
return out, nil
}
// PlacedID names the filesystem at mount for a wait, with no path in it: the controller asks again when the
// folder's filesystem is not the one a warrant was given for. Empty when the filesystem cannot be named, and a folder
// on it is then never kept (it fails closed, and waits).
func PlacedID(mount string) string { return placedFrom(mount, filesystemKey(mount)) }
// placedFrom is PlacedID from a filesystem key already read.
func placedFrom(mount, key string) string {
if mount == "" || key == "" {
return ""
}
sum := sha256.Sum256([]byte(mount + "\x00" + key))
return "fs-" + hex.EncodeToString(sum[:6])
}
// filesystemKey is what names the filesystem at mount across reboots, in this order:
//
// - a filesystem on a block device: its UUID, found by matching the mount's device against the devices in
// /dev/disk/by-uuid (never blkid). xfs's statfs id follows the device number, so the UUID comes first;
// - else the kernel's filesystem id (statfs f_fsid) when it is not zero: ZFS's from its dataset, btrfs's from its
// UUID;
// - else mountinfo's source with the filesystem's type: an NFS export's server:/export, a FUSE filesystem's own
// source string (both report f_fsid 0).
//
// Never the mount point and type alone, and never a device's name, which a reboot may renumber. Empty when none of
// these answers within statBound: then nothing is kept there. Read once per mount at a time.
func filesystemKey(mount string) string {
if mount == "" {
return ""
}
v, ok := boundedOnce("fs "+mount, func() any { return readFilesystemKey(mount) })
if !ok {
return ""
}
return v.(string)
}
func readFilesystemKey(mount string) string {
if u := MountUUID(mount); u != "" {
return "uuid:" + u
}
if id := MountFsid(mount); strings.Trim(id, "0") != "" {
return "fsid:" + id
}
if source, fstype := mountSourceOf(mount); source != "" && source != "none" {
return "source:" + fstype + ":" + source
}
return ""
}
// statBound is how long a read of a folder may take before it is said not to answer: a hard network mount whose
// server is down blocks a stat for ever, and one access must never hold the apply.
var statBound = 5 * time.Second
// errNoAnswer is a folder whose read did not return within statBound.
var errNoAnswer = errors.New("it does not answer: a read of it did not return within its bound (a mount that is down)")
// lstatWithin is os.Lstat within statBound, and statWithin os.Stat. A read that hangs is left to return on its own.
func lstatWithin(path string) (os.FileInfo, error) { return within("lstat", os.Lstat, path) }
func statWithin(path string) (os.FileInfo, error) { return within("stat", os.Stat, path) }
// flight is one read in flight, shared by everyone who asks while it is: a read that hangs on a dead mount holds
// one goroutine per path, never one per apply or look.
type flight struct {
done chan struct{}
v any
}
var inFlight = struct {
sync.Mutex
reads map[string]*flight
}{reads: map[string]*flight{}}
// boundedOnce is read's answer within statBound, sharing a read of key already in flight; false when it did not
// answer in time.
func boundedOnce(key string, read func() any) (any, bool) {
inFlight.Lock()
f, running := inFlight.reads[key]
if !running {
f = &flight{done: make(chan struct{})}
inFlight.reads[key] = f
go func() {
f.v = read()
inFlight.Lock()
delete(inFlight.reads, key)
inFlight.Unlock()
close(f.done)
}()
}
inFlight.Unlock()
select {
case <-f.done:
return f.v, true
case <-time.After(statBound):
return nil, false
}
}
func within(op string, stat func(string) (os.FileInfo, error), path string) (os.FileInfo, error) {
type read struct {
info os.FileInfo
err error
}
v, ok := boundedOnce(op+" "+path, func() any {
info, err := stat(path)
return read{info, err}
})
if !ok {
return nil, errNoAnswer
}
r := v.(read)
return r.info, r.err
}
// absPath is an absolute path in words: what pathless takes out.
var absPath = regexp.MustCompile(`(^|[\s"'(=])/[^\s"',;:)]*`)
// Pathless is an error in words with no path (ADR 0234 §6): a path error's operation and cause, and any other
// path said as "a path".
func Pathless(err error) string { return pathless(err) }
func pathless(err error) string {
var pe *os.PathError
if errors.As(err, &pe) {
return pe.Op + ": " + pe.Err.Error()
}
return absPath.ReplaceAllString(err.Error(), "${1}a path")
}
+1 -5
View File
@@ -16,7 +16,6 @@ func TestAContainerJoinsTheNetworksItKeeps(t *testing.T) {
var ran []string
connectFails := false
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != "docker" {
return "", errors.New("not installed")
}
@@ -82,7 +81,6 @@ func TestALeftOutModuleIsNeitherRemovedNorForgotten(t *testing.T) {
var removed []string
gone := map[string]bool{}
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != "docker" {
return "", nil
}
@@ -141,7 +139,6 @@ func TestALeftOutModuleIsNeitherRemovedNorForgotten(t *testing.T) {
func TestACapabilityReachesTheRuntimeAndTheSpec(t *testing.T) {
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != "docker" {
return "", errors.New("not installed")
}
@@ -177,13 +174,12 @@ func TestACapabilityReachesTheRuntimeAndTheSpec(t *testing.T) {
}
}
// A package may be declared absent (novox/hq ADR 0180): removed when it is installed, read back,
// A package may be declared absent (novox/hq ADR 0175): removed when it is installed, read back,
// left alone when it is not.
func TestAPackageDeclaredAbsentIsRemovedWhenPresentAndLeftWhenNot(t *testing.T) {
installed := true
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
ran = append(ran, name+" "+strings.Join(args, " "))
if name != "pacman" {
return "", nil
-563
View File
@@ -1,563 +0,0 @@
package apply
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// A library is kept usable by its group (novox/hq ADR 0328, issue 567).
//
// A library is a named tree of shared data with one home (§1). On its home the engine keeps its root and every
// sub-path its modules ask for — those folders themselves, never what is in them — in the library's group with
// group read, write and enter and the setgid bit, so what is made beneath keeps the group (§6); a missing sub-path
// is made with the root's owner and the library's group (§9). The owner, the owner's bits and others' bits are
// never changed. What the root already holds joins only on the operator's word (§7): until then the library waits
// with reason keep and nothing under it is walked. A root that is absent, or whose disk is not there, makes
// nothing, holds every container that mounts the library, and waits with reason disk (§9). Off its home a library is
// a mount the mounts module makes: nothing is kept there, and a mount that is not there holds the containers too.
//
// **When a root is the mesh's to keep without asking.** When it holds nothing yet, or is already in the library's
// group with group write and setgid (kept so before, by hand or by the mesh). Every other root waits for the take
// act, which walks it whole (TakeLibrary). Kept, it stays kept on the filesystem it was kept on — named by UUID,
// filesystem id or source (PlacedID) — and another filesystem at its root is judged afresh.
// libraryRecords is where the engine keeps what it knows of each library it is home to: the file beside the state
// in the engine, memory in a test.
type libraryRecords interface {
All() (map[string]store.LibraryRecord, error)
Update(name string, change func(r *store.LibraryRecord, had bool) bool) error
}
// Libraries is the engine's record of the libraries it is home to. The engine sets the file beside its state.
var Libraries libraryRecords = NewLibraryMemory()
type libraryMemory struct {
mu sync.Mutex
m map[string]store.LibraryRecord
}
// NewLibraryMemory is an empty record held in memory: a test's, or a one-shot apply's.
func NewLibraryMemory() libraryRecords { return &libraryMemory{m: map[string]store.LibraryRecord{}} }
func (k *libraryMemory) All() (map[string]store.LibraryRecord, error) {
k.mu.Lock()
defer k.mu.Unlock()
out := map[string]store.LibraryRecord{}
for n, r := range k.m {
out[n] = r
}
return out, nil
}
func (k *libraryMemory) Update(name string, change func(r *store.LibraryRecord, had bool) bool) error {
k.mu.Lock()
defer k.mu.Unlock()
r, had := k.m[name]
r.Library = name
if change(&r, had) {
k.m[name] = r
} else {
delete(k.m, name)
}
return nil
}
// libraryRecord is the record of one library, and whether there is one. A record that cannot be read is none: the
// library is judged afresh, which waits rather than keeps.
func libraryRecord(name string) (store.LibraryRecord, bool) {
all, err := Libraries.All()
if err != nil {
return store.LibraryRecord{}, false
}
r, had := all[name]
return r, had
}
// libraryLocks is one lock per library, held by every change to its root and every walk under it.
var libraryLocks sync.Map
func libraryLock(name string) *sync.Mutex {
l, _ := libraryLocks.LoadOrStore(name, &sync.Mutex{})
return l.(*sync.Mutex)
}
// libraryWalks is every library a take walk is running under now, with the filesystem it walks: set once the walk
// has been judged and started, cleared when it ends.
var libraryWalks sync.Map
// LibraryAccess is the access a library's wait and its take act name.
func LibraryAccess(name string) string { return declaration.LibraryAccessPrefix + name }
// KeepWords and DiskWords are what a library's wait says the operator gives (ADR 0328 §8, §9): never a path.
func KeepWords(name string) string { return "let the mesh keep the library " + name }
func DiskWords(name string) string { return "mount the disk that holds the library " + name }
// The judgements of a library.
const (
// libraryKeep is a library the mesh keeps: taken, empty when first seen, or found in shape.
libraryKeep = "keep"
// libraryWaitKeep is a root that holds data never taken: it waits for the operator's word.
libraryWaitKeep = "wait-keep"
// libraryWaitDisk is a root that is absent, or whose disk (or, off its home, whose mount) is not there.
libraryWaitDisk = "wait-disk"
// libraryUnknown is a root whose filesystem could not be named now: left as it is until it is.
libraryUnknown = "unknown"
// libraryMounted is a library off its home whose mount is there: nothing to keep.
libraryMounted = "mounted"
)
// libraryVerdict is the judgement of one library, with what it read.
type libraryVerdict struct {
kind string
// how is why a kept library is the mesh's: store.KeptByWarrant, store.KeptEmpty or store.KeptInShape.
how string
// mount is the mount point that holds the root; placed and fs name its filesystem.
mount, placed, fs string
// moved is that another filesystem than the one the library was kept on is at its root.
moved bool
why string
err error
}
// assessLibrary judges a library as an apply, a look and the take act all do (ADR 0328 §7, §9, §15): its group
// first, then its disk, then whether the mesh may keep it. Its words carry no path: they reach the operator's
// channel. Reads only.
func assessLibrary(ctx context.Context, l declaration.Library, rec store.LibraryRecord) libraryVerdict {
if why := rootGroup(l.GID); why != "" {
return libraryVerdict{err: fmt.Errorf("its group is %s, and a library's group is never a root group "+
"(novox/hq ADR 0328 §15); nothing was made or changed", why)}
}
// Nowhere the machine's own trees are, as for an access (novox/hq issue 339): a library at /etc would be
// the machine's configuration given a group.
if err := refusePath(l.Root, placingAccess, "", ""); err != nil {
return libraryVerdict{err: errors.New(pathless(err) + "; nothing was made or changed")}
}
if !l.Home {
// Off its home the mounts module mounts it: there when its root is a mount point now.
for _, p := range MountPoints() {
if p == l.Root {
return libraryVerdict{kind: libraryMounted, mount: p}
}
}
return libraryVerdict{kind: libraryWaitDisk, why: "its mount is not there"}
}
info, err := lstatWithin(l.Root)
switch {
case errors.Is(err, os.ErrNotExist):
return libraryVerdict{kind: libraryWaitDisk, why: "its root is not there"}
case errors.Is(err, errNoAnswer):
return libraryVerdict{kind: libraryUnknown, why: "its root does not answer: a read of it did not return " +
"within its bound"}
case err != nil:
return libraryVerdict{err: errors.New("its root cannot be read: " + pathless(err))}
case !info.IsDir():
return libraryVerdict{err: errors.New("its root is not a folder (a file, or a link), and a library's root " +
"is a folder the engine reaches through no link")}
}
pl := placedOn(ctx, l.Root, l.Root, rec.Mount, recordedLibraryMounts(l.Library))
if pl.Away {
return libraryVerdict{kind: libraryWaitDisk, mount: pl.Mount, why: "the disk that holds its root is not there"}
}
fs := filesystemKey(pl.Mount)
here := placedFrom(pl.Mount, fs)
// **A filesystem that cannot be named now is unknown, never a move**: the library holds what it is — nothing
// walked, nothing newly kept, nothing reset — and is judged again when it is named. One never kept is so never
// kept: it fails closed.
if here == "" {
return libraryVerdict{kind: libraryUnknown, mount: pl.Mount,
why: "its filesystem could not be named this time (no UUID, filesystem id or source answered)"}
}
moved := pl.Moved || (rec.Fs != "" && rec.Mount == pl.Mount && rec.Placed != here)
v := libraryVerdict{mount: pl.Mount, placed: here, fs: fs, moved: moved}
// The controller's word that the library was taken holds for the filesystem the warrant was carried out on.
takenHere := l.Taken && (rec.KeptOn == here || (rec.KeptOn == "" && !moved))
switch {
case rec.Kept && rec.KeptOn == here && !moved && rec.How == store.KeptByWarrant, takenHere:
// Taken whole on the operator's word: every sub-path is the library's.
v.kind, v.how = libraryKeep, store.KeptByWarrant
return v
case rec.Kept && rec.KeptOn == here && !moved:
// Kept without a warrant (empty, or in shape): a sub-path asked for since that holds data not in the group
// waits for the operator's word as the root would (§7).
v.kind, v.how = libraryKeep, rec.How
if !pathsInShape(l, rec.Paths) {
v.kind, v.how = libraryWaitKeep, ""
}
return v
}
facts, err := readFolder(l.Root)
if err != nil {
return libraryVerdict{err: errors.New("its root cannot be read: " + pathless(err))}
}
switch {
case facts.Empty:
// Nothing there, so no sub-path either: every one is made.
v.kind, v.how = libraryKeep, store.KeptEmpty
case int(facts.GID) == l.GID && facts.Mode&0o2070 == 0o2070 && sampleInShape(l.Root, l.GID) &&
pathsInShape(l, nil):
// The root and a bounded sample beneath it already in the group with group write and setgid: data that
// is the library's already, kept without asking. One entry of the sample out of shape asks (§7).
v.kind, v.how = libraryKeep, store.KeptInShape
default:
// What is there holds data the mesh never took — a disk mounted over a root kept while it was empty below
// it included (a move): it waits for the operator's word, and nothing under it is walked.
v.kind = libraryWaitKeep
}
return v
}
// recordedLibraryMounts is every mount point recorded for another library: where a disk was once seen.
func recordedLibraryMounts(except string) []string {
all, err := Libraries.All()
if err != nil {
return nil
}
var out []string
for name, r := range all {
if name != except && r.Mount != "" && r.Mount != "/" {
out = append(out, r.Mount)
}
}
return out
}
// applyLibrary is one library, applied (ADR 0328 §6, §7, §9): kept on its home when the mesh may keep it, waiting
// otherwise, and failing for a root group. Nothing is changed on the root but the group, group rwx and setgid of
// the root and its requested sub-paths, and the sub-paths made.
func applyLibrary(ctx context.Context, l declaration.Library) (Outcome, libraryVerdict, error) {
out := Outcome{ID: LibraryAccess(l.Library), Type: "library", Target: l.Root}
lock := libraryLock(l.Library)
if !lock.TryLock() {
// **A take walk is running (up to its bound, half an hour): the apply does not wait for it.** The library is
// the walk's until it is through; it keeps it, and its containers are not held for it. Only a walk that
// has started, on the filesystem it judged: anything else holding the lock (a take act about to be
// refused, say) is short, and the apply waits for it and judges the library as always.
if on, walking := libraryWalks.Load(l.Library); walking {
out.Action = "unchanged"
out.Detail = "the take act is setting its group under its root now; this apply leaves the library to it"
return out, libraryVerdict{kind: libraryKeep, how: store.KeptByWarrant, placed: on.(string)}, nil
}
lock.Lock()
}
defer lock.Unlock()
rec, _ := libraryRecord(l.Library)
v := assessLibrary(ctx, l, rec)
if v.err != nil {
return out, v, v.err
}
switch v.kind {
case libraryMounted:
out.Action, out.Detail = "unchanged", "not its home: mounted, and nothing is kept here"
return out, v, nil
case libraryWaitDisk:
out.Action = "waiting"
out.Detail = v.why + ": nothing is made, and no container that mounts it is created until it is there " +
"(novox/hq ADR 0328 §9); waits for the operator to " + DiskWords(l.Library)
return out, v, nil
case libraryUnknown:
out.Action = "waiting"
out.Detail = v.why + ": the library is left exactly as it is, and judged again on the next apply"
return out, v, nil
case libraryWaitKeep:
_ = Libraries.Update(l.Library, func(r *store.LibraryRecord, had bool) bool {
if !had || v.moved {
r.Root, r.Mount, r.Placed, r.Fs = l.Root, v.mount, v.placed, v.fs
}
if v.moved {
// Kept on another filesystem: what was kept there no longer holds here.
r.Kept, r.How, r.Cursor = false, "", nil
}
return true
})
out.Action = "waiting"
out.Detail = "its root holds data the mesh has not taken: nothing is changed under it until the operator's " +
"warrant (novox/hq ADR 0328 §7); waits for the operator to " + KeepWords(l.Library)
return out, v, nil
}
changed, made, err := keepFolders(l.Root, l.Paths, l.GID)
if err != nil {
return out, v, err
}
_ = Libraries.Update(l.Library, func(r *store.LibraryRecord, _ bool) bool {
if !r.Kept || r.KeptOn != v.placed {
r.How, r.At = v.how, time.Now().UTC()
}
r.Root, r.Mount, r.Placed, r.Fs, r.GID = l.Root, v.mount, v.placed, v.fs, l.GID
r.Kept, r.KeptOn = true, v.placed
r.Paths = unionPaths(r.Paths, l.Paths)
return true
})
out.Action = "unchanged"
if changed+made > 0 {
out.Action = "updated"
}
out.Detail = fmt.Sprintf("kept in %s: its root and %d sub-path(s) with group rwx and setgid, %d made; owners "+
"and everyone else's rights unchanged (novox/hq ADR 0328 §6)", l.Group, len(l.Paths), made)
return out, v, nil
}
// heldRoots is every library root whose containers are held: its disk, or its mount, is not there (§9).
func heldRoots(verdicts map[string]libraryVerdict, libs []declaration.Library) map[string]string {
out := map[string]string{}
for _, l := range libs {
if v, ok := verdicts[l.Library]; ok && v.kind == libraryWaitDisk {
out[l.Root] = l.Library
}
}
return out
}
// mountsHeld is the library a container waits for: one whose root it mounts, or mounts below, is held.
func mountsHeld(c *declaration.Container, held map[string]string) string {
for _, v := range c.Volumes {
src := mountSource(v)
if !strings.HasPrefix(src, "/") {
continue
}
for root, name := range held {
if atOrBelow(filepath.Clean(src), root) {
return name
}
}
}
return ""
}
// WaitingOnLibrary is every container of the declaration that waits for a library whose disk or mount is not
// there, read now: not created by the apply, so not judged alive or dead — the library says why.
func WaitingOnLibrary(d *declaration.Declaration) map[string]bool {
out := map[string]bool{}
if d == nil || len(d.Libraries) == 0 {
return out
}
verdicts := map[string]libraryVerdict{}
for _, l := range d.Libraries {
rec, _ := libraryRecord(l.Library)
verdicts[l.Library] = assessLibrary(context.Background(), l, rec)
}
held := heldRoots(verdicts, d.Libraries)
for _, r := range d.Resources {
if c, ok := r.(*declaration.Container); ok && mountsHeld(c, held) != "" {
out[c.ID] = true
}
}
return out
}
// The judgements of a library as a look says them.
const (
VerdictKeep = "keep"
VerdictMounted = "mounted"
VerdictWaitKeep = "wait-keep"
VerdictWaitDisk = "wait-disk"
VerdictUnknown = "unknown"
VerdictBroken = "broken"
)
// LibraryJudgement is a look's judgement of a library, and why, in words with no path; Placed names its root's
// filesystem for a keep wait, with no path (PlacedID).
type LibraryJudgement struct {
Kind, Why, Placed string
// Act is the last walk under its root.
Act *store.KeptAct
}
// JudgeLibrary judges a library for a look, read only.
func JudgeLibrary(ctx context.Context, l declaration.Library) LibraryJudgement {
rec, _ := libraryRecord(l.Library)
v := assessLibrary(ctx, l, rec)
j := LibraryJudgement{Act: rec.Act}
switch {
case v.err != nil:
j.Kind, j.Why = VerdictBroken, pathless(v.err)
case v.kind == libraryWaitKeep:
j.Kind, j.Placed = VerdictWaitKeep, v.placed
case v.kind == libraryWaitDisk:
j.Kind, j.Why = VerdictWaitDisk, v.why
case v.kind == libraryUnknown:
j.Kind, j.Why = VerdictUnknown, v.why
case v.kind == libraryMounted:
j.Kind = VerdictMounted
default:
j.Kind = VerdictKeep
}
return j
}
// joinRunner puts a library's tool runner's account in its group (ADR 0328 §11).
func joinRunner(ctx context.Context, sys system.System, l declaration.Library, known *store.State,
run Runner) (bool, string, error) {
if l.Runner == "" {
return false, "", nil
}
return joinGroup(ctx, sys, l.Runner, l.Group, store.Membership{Library: l.Library}, known, run)
}
// IdentityChange is what a tool runner joining a module's own group changes (ADR 0334 §2): `identity:<module>`,
// which the tool runner's unit names under restart-on, as it names `library:<name>`.
func IdentityChange(module string) string { return "identity:" + module }
// joinGroup puts a tool runner's account in a group (ADR 0328 §11, ADR 0334 §2), as ADR 0252 puts an account in a
// group: recorded only when the mesh put it there, so one it was in before is never taken back. The account must be
// here and never root's: uid 0, or a root group as its primary (§15). Whether it moved, and what was said.
func joinGroup(ctx context.Context, sys system.System, account, group string, of store.Membership, known *store.State,
run Runner) (bool, string, error) {
who, found, err := lookUp(ctx, run, "passwd", account)
switch {
case err != nil:
return false, "", fmt.Errorf("the tool runner's account %s could not be read: %w", account, err)
case !found:
return false, "", fmt.Errorf("the tool runner's account %s is not on this machine, so it joins no group", account)
}
if err := rootIdentity(who.id, who.prim); err != nil {
return false, "", fmt.Errorf("the tool runner's account %s joins no group: %w", account, err)
}
in, err := system.GroupsOf(ctx, system.Runner(run), account)
if err != nil {
return false, "", fmt.Errorf("the tool runner's account %s could not be read: %w", account, err)
}
if setOf(in)[group] {
return false, "", nil
}
if err := sys.AddUserToGroup(ctx, system.Runner(run), account, group); err != nil {
return false, "", err
}
for _, m := range known.Members {
if m.Account == account && m.Group == group {
return true, fmt.Sprintf("put the tool runner's %s back in %s", account, group), nil
}
}
of.Account, of.Group = account, group
known.Members = append(known.Members, of)
return true, fmt.Sprintf("put the tool runner's %s in %s; it holds it once restarted", account, group), nil
}
// leaveRunners takes every tool runner's account the mesh put in a library's group, or a module's own group, out of
// it once no declared library or identity puts it there and no declared user resource asks for it (ADR 0252 §3). Never fatal: what could not be
// left stays recorded, said, for the next apply.
func leaveRunners(ctx context.Context, sys system.System, libs []declaration.Library, ids []declaration.Identity,
known *store.State, run Runner, wanted Wanted) []Outcome {
declared := map[string]bool{}
for _, l := range libs {
if l.Runner != "" {
declared[l.Runner+" "+l.Group] = true
}
}
for _, id := range ids {
if id.Runner != "" {
declared[id.Runner+" "+id.Name] = true
}
}
var out []Outcome
var keep []store.Membership
for _, m := range known.Members {
if declared[m.Account+" "+m.Group] {
keep = append(keep, m)
continue
}
o := Outcome{ID: LibraryAccess(m.Library) + " runner", Type: "library", Target: m.Account,
changes: LibraryAccess(m.Library)}
if m.Module != "" {
o.ID, o.Type = "identity "+declaration.IdentityPrefix+m.Module+" runner", "identity"
o.changes = IdentityChange(m.Module)
}
if others := wanted.othersAsk(m.Account, m.Group, ""); len(others) > 0 {
o.Action, o.Detail = "forgotten", fmt.Sprintf("left %s in %s, which %s asks for", m.Account, m.Group,
strings.Join(others, ", "))
out = append(out, o)
continue
}
gone, why := leaveGroup(ctx, sys, m.Account, m.Group, run)
o.Action, o.Detail = "restored", m.Account+" "+why
if !gone {
o.Action = "waiting"
keep = append(keep, m)
}
out = append(out, o)
}
known.Members = keep
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// pathsInShape is whether every requested sub-path not kept before is absent (it is made), empty, or already in the
// group with group write and setgid, it and a bounded sample beneath it: what may be kept without the operator's
// word (§7). One that cannot be read is not.
func pathsInShape(l declaration.Library, kept []string) bool {
done := map[string]bool{}
for _, p := range kept {
done[p] = true
}
for _, p := range l.Paths {
if done[p] {
continue
}
path := filepath.Join(l.Root, p)
if _, err := lstatWithin(path); errors.Is(err, os.ErrNotExist) {
continue
}
facts, err := readFolder(path)
if err != nil {
return false
}
if facts.Empty {
continue
}
if int(facts.GID) != l.GID || facts.Mode&0o2070 != 0o2070 || !sampleInShape(path, l.GID) {
return false
}
}
return true
}
// unionPaths is a with every path of b not in it.
func unionPaths(a, b []string) []string {
have := map[string]bool{}
for _, p := range a {
have[p] = true
}
for _, p := range b {
if !have[p] {
a = append(a, p)
have[p] = true
}
}
sort.Strings(a)
return a
}
// RunnerWarning is a tool runner's account that joins no group (missing, or root's): said as a resource of its own,
// `runner:<module>` or `runner:library:<name>`, unhealthy with words with no path — never a failure of the apply.
type RunnerWarning struct {
Resource, Module, Why string
}
var runnerWarnings struct {
sync.Mutex
list []RunnerWarning
}
// setRunnerWarnings is the warnings the last apply found, in place of the ones before.
func setRunnerWarnings(ws []RunnerWarning) {
runnerWarnings.Lock()
defer runnerWarnings.Unlock()
runnerWarnings.list = append([]RunnerWarning(nil), ws...)
}
// RunnerWarnings is every tool runner's account the last apply could not put in a group.
func RunnerWarnings() []RunnerWarning {
runnerWarnings.Lock()
defer runnerWarnings.Unlock()
return append([]RunnerWarning(nil), runnerWarnings.list...)
}
-161
View File
@@ -1,161 +0,0 @@
//go:build linux
package apply
import (
"context"
"errors"
"fmt"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// The take act on a library, and the walk that goes on with one stopped at its bound (novox/hq ADR 0328 §7).
// actRecord is a walk's counts as the record keeps them.
func actRecord(warrant, by, on string, started time.Time, w *treeWalk, err error) *store.KeptAct {
c := w.counts
rec := &store.KeptAct{Act: "take", Warrant: warrant, By: by, On: on, State: "done", Started: started,
Ended: time.Now().UTC(), Folders: c.Folders, Files: c.Files, Kept: c.Kept, Links: c.Links, Other: c.Other,
Mounts: c.Mounts, Shared: c.Shared, Linked: c.Linked, Stripped: c.Stripped}
if c.Shared+c.Linked+c.Stripped > 0 {
rec.Why = fmt.Sprintf("%d file(s) with another name given the group, %d root's left as they are, %d whose "+
"setuid or setgid bit the group change took", c.Linked, c.Shared, c.Stripped)
}
switch {
case errors.Is(err, ErrWalkBound):
rec.State, rec.Why = "stopped", "it met its bound of entries or time; it goes on from where it stopped"
case err != nil:
rec.State, rec.Why = "failed", err.Error()
case w.Failed != "":
rec.State = "failed"
rec.Why = fmt.Sprintf("%d entries could not be given the group: %s; walked again on the next look", c.Failed,
w.Failed)
}
return rec
}
// TakeLibrary is the take act (ADR 0328 §7): on the operator's warrant, the library's group, group read and write,
// and on folders group enter and setgid, set on its root and everything under it — no link followed, no other
// filesystem entered, the owner and the owner's and others' bits unchanged, a file with another name elsewhere left
// as it is. It judges the ask against the declaration last applied and the root as it is, and starts the walk: what
// it answers at once, and a channel closed when the walk is through or stopped at its bound. Refused, nothing is
// changed.
func TakeLibrary(ctx context.Context, d *declaration.Declaration, access, path, warrant, by string) (<-chan struct{},
string, error) {
if d == nil {
return nil, "", errors.New("this engine has applied no declaration, and takes no library it was not told of")
}
var lib *declaration.Library
for i := range d.Libraries {
if LibraryAccess(d.Libraries[i].Library) == access {
lib = &d.Libraries[i]
}
}
switch {
case lib == nil:
return nil, "", fmt.Errorf("the access %s names no library declared on this machine", access)
case !lib.Home:
return nil, "", fmt.Errorf("this machine is not the home of the library %s, and a library is taken on its "+
"home", lib.Library)
case lib.Root != path:
return nil, "", fmt.Errorf("the library %s is declared at another root than the one asked", lib.Library)
}
lock := libraryLock(lib.Library)
if !lock.TryLock() {
return nil, "", errors.New("a walk of the library is running; asked again once it is through, the act goes on")
}
rec, _ := libraryRecord(lib.Library)
v := assessLibrary(ctx, *lib, rec)
var refused error
switch {
case v.err != nil:
refused = v.err
case v.kind == libraryWaitDisk:
refused = errors.New(v.why)
case v.kind == libraryUnknown:
refused = errors.New(v.why + ", so a warrant could name no filesystem it was given for")
}
if refused != nil {
lock.Unlock()
return nil, "", refused
}
if _, _, err := keepFolders(lib.Root, lib.Paths, lib.GID); err != nil {
lock.Unlock()
return nil, "", err
}
started := time.Now().UTC()
_ = Libraries.Update(lib.Library, func(r *store.LibraryRecord, _ bool) bool {
r.Root, r.Mount, r.Placed, r.Fs, r.GID = lib.Root, v.mount, v.placed, v.fs, lib.GID
r.Kept, r.KeptOn, r.How, r.At = true, v.placed, store.KeptByWarrant, started
r.Warrant, r.By, r.Cursor = warrant, by, nil
r.Act = &store.KeptAct{Act: "take", Warrant: warrant, By: by, On: v.placed, State: "running", Started: started}
return true
})
done := make(chan struct{})
go func() {
defer close(done)
defer lock.Unlock()
walkLibrary(lib.Library, lib.Root, lib.GID, warrant, by, v.placed, nil)
}()
return done, fmt.Sprintf("the take act started under the warrant %s: %s's group, group read and write, and "+
"setgid on its folders are set on everything under its root, following no link and entering no other "+
"filesystem", warrant, lib.Library), nil
}
// walkLibrary walks a library's root from resume with its group, and records what it did: a walk stopped at its
// bound keeps its cursor for the next. The caller holds the library's lock.
func walkLibrary(name, root string, gid int, warrant, by, on string, resume []string) {
libraryWalks.Store(name, on)
defer libraryWalks.Delete(name)
started := time.Now().UTC()
w, err := walkRoot(root, ActLimits, machineStats, resume, groupVisit(gid))
rec := actRecord(warrant, by, on, started, w, err)
_ = Libraries.Update(name, func(r *store.LibraryRecord, _ bool) bool {
r.Act = rec
r.Cursor = nil
if errors.Is(err, ErrWalkBound) {
r.Cursor = w.Cursor
}
return true
})
}
// ReconcileLibraries goes on with every take act that stopped at its bound or left entries it could not change,
// from where it stopped — only while the library is still kept on the filesystem it was taken on (judged as an apply
// judges it). What it did, in words, for the engine to say.
func ReconcileLibraries(ctx context.Context, d *declaration.Declaration) []string {
if d == nil {
return nil
}
var said []string
for _, l := range d.Libraries {
if ctx.Err() != nil {
break
}
if !l.Home {
continue
}
rec, _ := libraryRecord(l.Library)
if rec.Act == nil || (rec.Act.State != "stopped" && rec.Act.State != "failed") || rec.How != store.KeptByWarrant {
continue
}
lock := libraryLock(l.Library)
if !lock.TryLock() {
continue
}
v := assessLibrary(ctx, l, rec)
if v.err != nil || v.kind != libraryKeep || v.placed != rec.KeptOn || v.moved {
lock.Unlock()
continue
}
walkLibrary(l.Library, l.Root, l.GID, rec.Warrant, rec.By, rec.KeptOn, rec.Cursor)
lock.Unlock()
after, _ := libraryRecord(l.Library)
said = append(said, fmt.Sprintf("the library %s: the take act went on, %s (%d folders and %d files changed)",
l.Library, after.Act.State, after.Act.Folders, after.Act.Files))
}
return said
}
File diff suppressed because it is too large Load Diff
-43
View File
@@ -1,43 +0,0 @@
package apply
import (
"context"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// A container declared to log to the journal is run with the journal as its log driver, and the
// place it logs is part of its spec, so moving it recreates the container (novox/hq ADR 0179).
func TestAContainerLoggingToTheJournalIsRunThatWayAndRecreatedWhenMoved(t *testing.T) {
pinned := "postgres@sha256:" + strings.Repeat("a", 64)
var ran []string
run := func(_ context.Context, cmd string, args ...string) (string, error) {
if cmd == "docker" && len(args) > 0 && args[0] == "run" {
ran = args
return "deadbeef\n", nil
}
return "", nil
}
d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"front","type":"container","name":"front","image":"`+pinned+`","logging":"journald"}
]}`)
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
sent := false
for i, a := range ran {
if a == "--log-driver" && i+1 < len(ran) && ran[i+1] == "journald" {
sent = true
}
}
if !sent {
t.Fatalf("the container's output was not sent to the journal: %v", ran)
}
with := d.Resources[0].(*declaration.Container)
without := *with
without.Logging = ""
if containerSpec(with, inputs{}) == containerSpec(&without, inputs{}) {
t.Fatal("where a container logs is not part of its spec, so moving it would not recreate it")
}
}
-248
View File
@@ -1,248 +0,0 @@
package apply
// What a maintenance window is holding still, written where every apply on this machine can read it
// (novox/hq issue 224, ADR 0189).
//
// A scheduled step declared `while-stopped` stops its module's containers, runs, and starts them
// again. Until this, nothing told the **apply** that a window was open, and the apply's rule for a
// container it finds stopped is the right rule everywhere else: stopped is broken, so remove it and
// create it running. In the middle of a window that rule reopens the window — for the store's
// collector, a registry recreated mid-collection accepts an upload the sweep then deletes, and the
// build that made the image reported success.
//
// **The apply learns which containers a window holds; the window does not take the apply lock.**
// The issue weighed both. Holding the lock for a window makes the race impossible and makes a push
// that arrives mid-window wait for minutes, which is what issue 185's outages looked like from
// outside. Telling the apply blocks nothing: a held container is reported as held and left exactly
// as it is — even when its declaration changed — and the first apply after the window converges it
// by the ordinary rule. The cost, stated in the issue, is a second source for "is this container
// meant to be running"; it is kept honest by being short-lived and by expiring on its own.
//
// **A file under the node's state directory, not a variable in the scheduler.** The scheduler lives
// in the daemon, but the daemon is not the only thing that applies here: `mesh-host reconcile` and
// `mesh-host apply` run as their own processes beside it, and the installer applies the carried
// bundle — which is where the store's registry comes from — from a third. A map shared in memory
// would protect only the daemon's own applies, which is to say it would leave open exactly the case
// a person running `reconcile` by hand at 03:31 creates. The state directory is already where every
// one of them meets (the apply lock, the node's state, the kept originals), it is root's alone, and
// each window is one small file written atomically, so no reader ever sees half of one.
//
// **A window that is never closed must not hold for ever** — the same risk the field itself carries,
// one level up. Three things close it:
//
// - the scheduler removes the file in a defer that runs after the containers are started again,
// whatever the step did;
// - a window whose process is gone is closed: the file names the process that opened it, and a
// host that crashed or was replaced mid-window is no longer holding anything, so the apply is
// free to bring back what it left stopped — which is then the apply doing the job the dead
// process could not;
// - and every window carries an end, windowAtMost after it opened, past which it is read as
// closed whoever is still alive. A step that genuinely runs longer than that is pathological, and
// the apply then does what it did before this record existed, which is the lesser harm next to a
// service the mesh can never bring back.
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"github.com/novox/mesh-host/internal/store"
)
// WindowsName is the directory beside the node's state that holds the open windows, one file each.
const WindowsName = "windows"
// windowAtMost is the longest a window is believed. The store's collector is minutes over a store of
// fifty-odd repositories; six hours is far past any collection this mesh will do and short enough
// that a window nothing closed is over by the next working day (novox/hq issue 224).
const windowAtMost = 6 * time.Hour
// Window is one scheduled step holding containers still: which step, which containers by runtime
// name, since when, and the latest moment it is believed.
type Window struct {
Step string `json:"step"`
Holds []string `json:"holds"`
Opened time.Time `json:"opened"`
Until time.Time `json:"until"`
// PID is the process that opened it. Not reported: it is how a window whose host died is read
// as closed.
PID int `json:"pid"`
}
// Windows is where this machine's open windows are recorded. A nil *Windows records nothing and
// holds nothing — a test, or a caller that has no state directory.
type Windows struct {
dir string
// alive says whether a process is still running. Replaced in tests; the real one asks the kernel.
alive func(pid int) bool
}
// WindowsIn records windows under stateDir/windows — the directory the node's state lives in, which
// every process that applies on this machine already shares.
func WindowsIn(stateDir string) *Windows {
return &Windows{dir: filepath.Join(stateDir, WindowsName), alive: processAlive}
}
// processAlive is whether pid names a running process. Signal 0 delivers nothing and only asks; a
// process this one may not signal is still a process (EPERM), which can only happen across users.
func processAlive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
}
// fileFor is where one step's window is written. A step id is the declaration's, which may carry a
// dot or a slash; the file name keeps what is safe and replaces the rest.
func (w *Windows) fileFor(step string) string {
safe := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
return r
}
return '_'
}, step)
return filepath.Join(w.dir, "window-"+safe+".json")
}
// open records that step holds these containers still from now, before the first of them is
// stopped — so no apply can find one stopped and not know why.
func (w *Windows) open(step string, holds []string, now time.Time) error {
if w == nil {
return nil
}
if err := os.MkdirAll(w.dir, 0o700); err != nil {
return err
}
raw, err := json.Marshal(Window{Step: step, Holds: holds, Opened: now.UTC(),
Until: now.UTC().Add(windowAtMost), PID: os.Getpid()})
if err != nil {
return err
}
return writeAtomically(w.fileFor(step), raw, 0o600)
}
// How long a step about to open a window waits for an apply in flight on this machine to end, and how
// often it looks (novox/hq issue 291). Past the bound it opens the window anyway, said: the applies
// then wait for the window instead (store_away.go), which is the slower order, never a failed one.
var (
applyWaitAtMost = 15 * time.Minute
applyPoll = time.Second
)
// openAfterApplies opens step's window only once no apply is in flight on this machine (novox/hq issue
// 291): an apply that started before the window would otherwise reach the store's archives with its
// server already held still. The apply lock is held only while the record is written — every apply
// that starts afterwards reads the window and waits for the store — so a push arriving mid-window
// still never queues behind the window itself (issue 224's reason for not taking the lock).
func (w *Windows) openAfterApplies(ctx context.Context, step string, holds []string, log func(string)) error {
if w == nil {
return nil
}
stateDir := filepath.Dir(w.dir)
deadline := time.Now().Add(applyWaitAtMost)
waited := false
for {
release, took, err := store.TryLockIn(stateDir)
if err != nil {
return err
}
if took {
defer release()
if waited {
log(fmt.Sprintf("scheduled step %s: the apply in flight ended; its window opens now", step))
}
return w.open(step, holds, time.Now())
}
if !waited {
waited = true
log(fmt.Sprintf("scheduled step %s: an apply is in flight on this machine — the window opens once "+
"it ends, so nothing it fetches finds %s held still (novox/hq issue 291)", step, strings.Join(holds, ", ")))
}
if time.Now().After(deadline) {
log(fmt.Sprintf("scheduled step %s: the apply in flight did not end within %s; the window opens "+
"beside it, and it waits for the window", step, applyWaitAtMost))
return w.open(step, holds, time.Now())
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(applyPoll):
}
}
}
// close removes the record of step's window. Called after its containers are started again; a file
// already gone is the state wanted.
func (w *Windows) close(step string) error {
if w == nil {
return nil
}
if err := os.Remove(w.fileFor(step)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
// Open is the windows open at now, by step. One that has ended, or whose process is gone, is not
// open — and its file is removed on the way, since nothing else will (novox/hq issue 224). A file
// that cannot be read is not a window either: the alternative is a machine whose containers are
// never converged because of a byte nobody can see.
func (w *Windows) Open(now time.Time) []Window {
if w == nil {
return nil
}
entries, err := os.ReadDir(w.dir)
if err != nil {
return nil
}
var open []Window
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), "window-") || !strings.HasSuffix(e.Name(), ".json") {
continue
}
path := filepath.Join(w.dir, e.Name())
raw, err := os.ReadFile(path)
if err != nil {
continue
}
var win Window
if err := json.Unmarshal(raw, &win); err != nil {
_ = os.Remove(path)
continue
}
if !now.Before(win.Until) || !w.alive(win.PID) {
_ = os.Remove(path)
continue
}
open = append(open, win)
}
sort.Slice(open, func(i, j int) bool { return open[i].Step < open[j].Step })
return open
}
// holding is the open window that holds the container named name, if one does.
func (w *Windows) holding(name string, now time.Time) (Window, bool) {
for _, win := range w.Open(now) {
for _, held := range win.Holds {
if held == name {
return win, true
}
}
}
return Window{}, false
}
// String is the window as a person reads it in a log line.
func (win Window) String() string {
return fmt.Sprintf("%s holds %s still since %s", win.Step, strings.Join(win.Holds, ", "),
win.Opened.Format(time.RFC3339))
}
-542
View File
@@ -1,542 +0,0 @@
package apply
import (
"context"
"errors"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// A scheduled step may hold its module's own containers still while it runs (novox/hq ADR 0189,
// issue 108).
//
// What it exists for: the artifact store's collector walks the storage and requires every writer
// stopped. A run-once step runs beside containers and a scheduled one is the same container again,
// so the mesh had no way to say it — which is why the store it inherited has never collected
// anything. The risk the field brings is one shape only: a window that opens and never closes.
// Every test here is about that shape.
// windowRun records the order of stop / run / start, which is the whole of what is being asserted.
type windowRun struct {
mu sync.Mutex
order []string
failAt string // the arg[0] that should fail ("run" makes the step fail)
wontGo string // a container name that refuses to start again
}
func (w *windowRun) run(_ context.Context, _ string, args ...string) (string, error) {
w.mu.Lock()
defer w.mu.Unlock()
switch args[0] {
case "info":
return "27.0\n", nil
case "stop", "start":
w.order = append(w.order, args[0]+" "+args[1])
if args[0] == "start" && args[1] == w.wontGo {
return "", errors.New("the runtime refused")
}
case "run":
w.order = append(w.order, "run")
if w.failAt == "run" {
return "", errors.New("the step exited non-zero")
}
}
return "", nil
}
func (w *windowRun) seen() []string {
w.mu.Lock()
defer w.mu.Unlock()
return append([]string{}, w.order...)
}
// aStoreWithACollector is a module in the shape distribution has: a server that must not be
// writing, and a nightly step that walks its storage with the server held still.
func aStoreWithACollector(t *testing.T) *declaration.Declaration {
t.Helper()
return parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"mesh-registry","image":"`+pinned+`"},
{"id":"collect","type":"container","name":"mesh-registry-collect","image":"`+pinned+`",
"schedule":"30 3 * * *","while-stopped":["store"]}
]}`)
}
func fireOnce(t *testing.T, d *declaration.Declaration, w *windowRun) {
t.Helper()
clock := &fixedClock{now: time.Date(2026, 10, 2, 3, 29, 0, 0, time.UTC)}
s := NewScheduler(clock, w.run, func(string) {})
s.Sync(d, nil)
s.Advance(context.Background(), time.Date(2026, 10, 2, 3, 30, 5, 0, time.UTC))
s.Wait()
}
func TestAScheduledStepHoldsItsModulesContainerStillAndStartsItAgain(t *testing.T) {
w := &windowRun{}
fireOnce(t, aStoreWithACollector(t), w)
got := w.seen()
want := []string{"stop mesh-registry", "run", "start mesh-registry"}
var kept []string
for _, line := range got {
if strings.HasPrefix(line, "stop mesh-registry-collect") {
// Clearing the step's own exited container by name; not part of the window.
continue
}
kept = append(kept, line)
}
if len(kept) != len(want) {
t.Fatalf("the window was not stop, run, start: %v", got)
}
for i := range want {
if kept[i] != want[i] {
t.Fatalf("the window was %v, want %v", kept, want)
}
}
}
// The one that matters: a step that fails must leave the service running.
func TestAFailedStepStillClosesTheWindow(t *testing.T) {
w := &windowRun{failAt: "run"}
fireOnce(t, aStoreWithACollector(t), w)
var started bool
for _, line := range w.seen() {
if line == "start mesh-registry" {
started = true
}
}
if !started {
t.Fatalf("the step failed and the container it held still was never started again: %v", w.seen())
}
}
// A container that will not come back is said loudly: it is down, and nothing else notices until
// the next apply compares it.
func TestAContainerThatWillNotStartAgainIsSaidLoudly(t *testing.T) {
w := &windowRun{wontGo: "mesh-registry"}
var said []string
clock := &fixedClock{now: time.Date(2026, 10, 2, 3, 29, 0, 0, time.UTC)}
s := NewScheduler(clock, w.run, func(line string) { said = append(said, line) })
s.Sync(aStoreWithACollector(t), nil)
s.Advance(context.Background(), time.Date(2026, 10, 2, 3, 30, 5, 0, time.UTC))
s.Wait()
var loud bool
for _, line := range said {
if strings.Contains(line, "WILL NOT START AGAIN") && strings.Contains(line, "mesh-registry") {
loud = true
}
}
if !loud {
t.Fatalf("a service left stopped by a maintenance window was not said loudly: %v", said)
}
}
// Several containers come back in the reverse of the order they were stopped: a module names the
// dependant first, and starting it before what it depends on is not bringing it back.
func TestTheWindowClosesInTheReverseOfTheOrderItOpened(t *testing.T) {
d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"web","type":"container","name":"web","image":"`+pinned+`"},
{"id":"db","type":"container","name":"db","image":"`+pinned+`"},
{"id":"collect","type":"container","name":"collect","image":"`+pinned+`",
"schedule":"30 3 * * *","while-stopped":["web","db"]}
]}`)
w := &windowRun{}
fireOnce(t, d, w)
var stops, starts []string
for _, line := range w.seen() {
switch {
case line == "stop web" || line == "stop db":
stops = append(stops, line)
case strings.HasPrefix(line, "start "):
starts = append(starts, line)
}
}
if len(stops) != 2 || stops[0] != "stop web" || stops[1] != "stop db" {
t.Fatalf("stopped in %v, want the order the step named them", stops)
}
if len(starts) != 2 || starts[0] != "start db" || starts[1] != "start web" {
t.Fatalf("started in %v, want the reverse", starts)
}
}
// And the refusals, each for what it says rather than that it says something.
func TestAMaintenanceWindowIsRefusedWhereItCannotMean(t *testing.T) {
for _, c := range []struct{ name, body, says string }{
{
"a window with no schedule",
`{"id":"collect","type":"container","name":"c","image":"` + pinned + `","while-stopped":["store"]}`,
"needs a schedule",
},
{
"a window naming itself",
`{"id":"collect","type":"container","name":"c","image":"` + pinned + `","schedule":"30 3 * * *","while-stopped":["collect"]}`,
"this step itself",
},
{
"a window naming something that is not a container here",
`{"id":"collect","type":"container","name":"c","image":"` + pinned + `","schedule":"30 3 * * *","while-stopped":["elsewhere"]}`,
"no container by that id",
},
} {
_, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[` + c.body + `]}`))
if err == nil {
t.Errorf("%s was accepted", c.name)
continue
}
if !strings.Contains(err.Error(), c.says) {
t.Errorf("%s: the refusal does not say %q: %v", c.name, c.says, err)
}
}
}
// A changed window is a changed declaration, and the install says so.
//
// The cadence already works this way: "a changed schedule is a changed spec — the marker moves and
// the install is reported updated and re-established" (containerSpec). Which containers are held
// still for the run is the same kind of statement, and a declaration that changed it while the
// machine reported no change would be a machine quietly running the old window.
func TestAChangedWindowMovesTheSpec(t *testing.T) {
one := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"mesh-registry","image":"`+pinned+`"},
{"id":"other","type":"container","name":"other","image":"`+pinned+`"},
{"id":"collect","type":"container","name":"collect","image":"`+pinned+`",
"schedule":"30 3 * * *","while-stopped":["store"]}
]}`)
two := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"mesh-registry","image":"`+pinned+`"},
{"id":"other","type":"container","name":"other","image":"`+pinned+`"},
{"id":"collect","type":"container","name":"collect","image":"`+pinned+`",
"schedule":"30 3 * * *","while-stopped":["store","other"]}
]}`)
stepOf := func(d *declaration.Declaration) *declaration.Container {
for _, r := range d.Resources {
if c, ok := r.(*declaration.Container); ok && c.ID == "collect" {
return c
}
}
t.Fatal("no step in the fixture")
return nil
}
if containerSpec(stepOf(one), inputs{}) == containerSpec(stepOf(two), inputs{}) {
t.Fatal("the window changed and the spec did not; the machine would report no change " +
"and keep holding the containers it held yesterday")
}
// And a container with no window is untouched by the field existing at all.
plain := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"mesh-registry","image":"`+pinned+`"}
]}`)
spec := containerSpec(plain.Resources[0].(*declaration.Container), inputs{})
if strings.Contains(spec, "while-stopped") || strings.Contains(spec, "held") {
t.Errorf("an ordinary container's spec mentions a field it does not set:\n%s", spec)
}
}
// ---- An apply arriving during a window (novox/hq issue 224) ----
//
// A window holds a container stopped on purpose; the apply's rule for a stopped container is to
// replace it. Until the apply could see the window, a push landing at 03:30 recreated the registry
// in the middle of its collection — the collector, already past its mark phase, then swept a blob
// that an upload had just written. These drive the scheduler and the apply against one pretend
// runtime, so what is asserted is what one does to the other.
// aRegistryMachine is a runtime holding one long-running container, mesh-registry, and running the
// step in the foreground until the test lets it finish.
type aRegistryMachine struct {
mu sync.Mutex
exists bool
running bool
spec string
calls []string
wontGo bool // the registry refuses to start again after the window
failRun bool // the step exits non-zero
stepIn chan struct{} // the step has started: the window is open
stepGo chan struct{} // let the step finish
}
func newRegistryMachine(spec string) *aRegistryMachine {
return &aRegistryMachine{exists: true, running: true, spec: spec,
stepIn: make(chan struct{}, 4), stepGo: make(chan struct{})}
}
func (m *aRegistryMachine) run(_ context.Context, _ string, args ...string) (string, error) {
m.mu.Lock()
switch args[0] {
case "info", "image":
m.mu.Unlock()
return "27.0\n", nil
case "stop":
m.calls = append(m.calls, "stop "+args[1])
if args[1] == "mesh-registry" {
m.running = false
}
case "start":
m.calls = append(m.calls, "start "+args[1])
if args[1] == "mesh-registry" {
if m.wontGo {
m.mu.Unlock()
return "", errors.New("the runtime refused")
}
m.running = true
}
case "container": // inspect
name := args[len(args)-1]
if name != "mesh-registry" || !m.exists {
m.mu.Unlock()
return "", errors.New("no such container")
}
out := "false\t" + m.spec
if m.running {
out = "true\t" + m.spec
}
m.mu.Unlock()
return out, nil
case "rm":
name := args[len(args)-1]
m.calls = append(m.calls, "rm "+name)
if name == "mesh-registry" {
m.exists, m.running = false, false
}
case "run":
if !slices.Contains(args, "--detach") {
// The step, in the foreground, for as long as the test says.
m.calls = append(m.calls, "step")
fail := m.failRun
m.mu.Unlock()
m.stepIn <- struct{}{}
<-m.stepGo
if fail {
return "", errors.New("the step exited non-zero")
}
return "", nil
}
m.calls = append(m.calls, "create "+args[slices.Index(args, "--name")+1])
for i, a := range args {
if a == "--label" && strings.HasPrefix(args[i+1], specLabel+"=") {
m.spec = strings.TrimPrefix(args[i+1], specLabel+"=")
}
}
m.exists, m.running = true, true
}
m.mu.Unlock()
return "", nil
}
// touched is every call since mark that recreated or removed the registry.
func (m *aRegistryMachine) touched(mark int) []string {
m.mu.Lock()
defer m.mu.Unlock()
var out []string
for _, c := range m.calls[mark:] {
if c == "rm mesh-registry" || c == "create mesh-registry" {
out = append(out, c)
}
}
return out
}
func (m *aRegistryMachine) mark() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.calls)
}
// theStoreMoved is the store's module with the server's declaration changed — a push that landed
// at 03:30 with a new registry image, say.
func theStoreMoved(t *testing.T) *declaration.Declaration {
t.Helper()
return parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"mesh-registry","image":"`+pinned+`","env":{"NEW":"1"}},
{"id":"collect","type":"container","name":"mesh-registry-collect","image":"`+pinned+`",
"schedule":"30 3 * * *","while-stopped":["store"]}
]}`)
}
// openAWindow fires the collector and returns once it is running, the registry held still.
func openAWindow(t *testing.T, d *declaration.Declaration, m *aRegistryMachine, windows *Windows) *Scheduler {
t.Helper()
clock := &fixedClock{now: time.Date(2026, 10, 2, 3, 29, 0, 0, time.UTC)}
s := NewScheduler(clock, m.run, func(string) {})
s.RecordWindowsIn(windows)
s.Sync(d, nil)
s.Advance(context.Background(), time.Date(2026, 10, 2, 3, 30, 5, 0, time.UTC))
select {
case <-m.stepIn:
case <-time.After(5 * time.Second):
t.Fatal("the step never started")
}
return s
}
func TestAnApplyDuringAWindowLeavesWhatItHoldsAlone(t *testing.T) {
d := aStoreWithACollector(t)
want := containerSpec(d.Resources[0].(*declaration.Container), inputs{})
m := newRegistryMachine(want)
windows := WindowsIn(t.TempDir())
s := openAWindow(t, d, m, windows)
mark := m.mark()
// The push lands mid-window, and with it a registry whose declaration moved: the very shape
// that, before this, removed the server and created it running under the collector.
report, state, err := ApplyMindingWindows(context.Background(), archHost(t), theStoreMoved(t),
store.State{}, store.OriginDeclared, m.run, nil, nil, nil, windows)
if err != nil {
t.Fatalf("the apply failed: %v", err)
}
if got := m.touched(mark); len(got) > 0 {
t.Fatalf("an apply during the window touched the container it holds still: %v", got)
}
o := outcomeOf(report, "store")
if o.Action != heldStill {
t.Fatalf("a held container was reported %q, want %q: %+v", o.Action, heldStill, o)
}
for _, says := range []string{"collect", "maintenance window", "first apply after", "declaration moved"} {
if !strings.Contains(o.Detail, says) {
t.Errorf("the outcome does not say %q: %s", says, o.Detail)
}
}
// Held still is not a change: nothing about the machine moved (the step's own install, first
// time here, is).
if (Report{Outcomes: []Outcome{o}}).Changed() {
t.Errorf("a container left alone reads as a change: %+v", o)
}
// The report says a window is open, and which.
if len(report.Windows) != 1 || report.Windows[0].Step != "collect" ||
len(report.Windows[0].Holds) != 1 || report.Windows[0].Holds[0] != "mesh-registry" {
t.Errorf("the report does not say the collector's window is open: %+v", report.Windows)
}
// The window closes: the registry is running again and the record is gone.
close(m.stepGo)
s.Wait()
if open := windows.Open(time.Now()); len(open) != 0 {
t.Fatalf("the window closed and is still recorded open: %+v", open)
}
// And the next apply converges it by the ordinary rule — the changed declaration recreates it.
mark = m.mark()
report, _, err = ApplyMindingWindows(context.Background(), archHost(t), theStoreMoved(t),
state, store.OriginDeclared, m.run, nil, nil, nil, windows)
if err != nil {
t.Fatalf("the apply after the window failed: %v", err)
}
if got := m.touched(mark); len(got) != 2 || got[0] != "rm mesh-registry" || got[1] != "create mesh-registry" {
t.Fatalf("the apply after the window did not converge the container: %v", got)
}
if o := outcomeOf(report, "store"); o.Action != "updated" {
t.Errorf("the container was reported %q after the window, want updated: %+v", o.Action, o)
}
if len(report.Windows) != 0 {
t.Errorf("a closed window is still reported open: %+v", report.Windows)
}
}
// A step that fails releases its hold like one that succeeds: the record is erased in the same
// defer that starts the server again, and an apply afterwards does what it always did — here, to a
// registry that would not come back, the one thing that brings it back.
func TestAFailedStepReleasesItsHold(t *testing.T) {
d := aStoreWithACollector(t)
m := newRegistryMachine(containerSpec(d.Resources[0].(*declaration.Container), inputs{}))
m.failRun, m.wontGo = true, true
windows := WindowsIn(t.TempDir())
s := openAWindow(t, d, m, windows)
if open := windows.Open(time.Now()); len(open) != 1 {
t.Fatalf("the window is not recorded while the step runs: %+v", open)
}
close(m.stepGo)
s.Wait()
if open := windows.Open(time.Now()); len(open) != 0 {
t.Fatalf("a failed step left its window recorded open: %+v", open)
}
mark := m.mark()
report, _, err := ApplyMindingWindows(context.Background(), archHost(t), d, store.State{},
store.OriginDeclared, m.run, nil, nil, nil, windows)
if err != nil {
t.Fatalf("the apply after the failed step failed: %v", err)
}
if got := m.touched(mark); len(got) != 2 {
t.Fatalf("the registry left stopped by a failed window was not brought back: %v", got)
}
if o := outcomeOf(report, "store"); o.Action == heldStill {
t.Errorf("the hold outlived the step: %+v", o)
}
}
// A window whose host died mid-step — crashed, or stood aside for a successor — holds nothing:
// the process that would start the server again is gone, so the apply must be free to. And a window
// nothing closed ends on its own.
func TestAWindowNothingClosesDoesNotHoldForEver(t *testing.T) {
dir := t.TempDir()
alive := true
windows := &Windows{dir: dir, alive: func(int) bool { return alive }}
now := time.Now()
if err := windows.open("collect", []string{"mesh-registry"}, now); err != nil {
t.Fatal(err)
}
if _, held := windows.holding("mesh-registry", now); !held {
t.Fatal("an open window with a live host does not hold its container")
}
if _, held := windows.holding("mesh-registry", now.Add(windowAtMost)); held {
t.Error("a window past its end still holds")
}
if err := windows.open("collect", []string{"mesh-registry"}, now); err != nil {
t.Fatal(err)
}
alive = false
if _, held := windows.holding("mesh-registry", now); held {
t.Error("a window whose host is gone still holds")
}
if entries, _ := os.ReadDir(dir); len(entries) != 0 {
t.Errorf("a window read as closed was left on disk: %v", entries)
}
// The real liveness check: this process is alive, and a pid nothing can have is not.
if !processAlive(os.Getpid()) || processAlive(0) {
t.Error("processAlive does not tell a live process from none")
}
}
// A window that cannot be recorded is not opened: an apply would not know, and would recreate the
// server under the step. One night's collection is the cheaper loss.
func TestAWindowThatCannotBeRecordedIsNotOpened(t *testing.T) {
notADir := filepath.Join(t.TempDir(), "state")
if err := os.WriteFile(notADir, nil, 0o600); err != nil {
t.Fatal(err)
}
d := aStoreWithACollector(t)
m := newRegistryMachine(containerSpec(d.Resources[0].(*declaration.Container), inputs{}))
var said []string
var mu sync.Mutex
clock := &fixedClock{now: time.Date(2026, 10, 2, 3, 29, 0, 0, time.UTC)}
s := NewScheduler(clock, m.run, func(line string) { mu.Lock(); said = append(said, line); mu.Unlock() })
s.RecordWindowsIn(WindowsIn(notADir))
s.Sync(d, nil)
s.Advance(context.Background(), time.Date(2026, 10, 2, 3, 30, 5, 0, time.UTC))
s.Wait()
for _, c := range m.calls {
if c == "stop mesh-registry" || c == "step" {
t.Fatalf("a window that could not be recorded was opened anyway: %v", m.calls)
}
}
mu.Lock()
defer mu.Unlock()
if len(said) == 0 || !strings.Contains(strings.Join(said, "\n"), "not run") {
t.Errorf("the step not running was not said: %v", said)
}
}
-125
View File
@@ -1,125 +0,0 @@
package apply
// A mesh directory outside a home is given its mode and owner only through a descriptor (novox/hq issue 358).
//
// Below a home every component is opened without following a link (ADR 0266, home_links.go), and a directory the
// operator handed to the mesh is changed through one descriptor opened from / with no link followed (issue 356).
// Every other directory the mesh owns — a module's state under /var/lib, a drop-in directory under /etc — was
// given its mode and owner by path, with chmod and chown, which follow a link anywhere in the path: whoever could
// turn a parent into a link between two applies directed root's chmod and chown at the link's target.
//
// The danger is a link another account can make or replace, not a link as such: /var/run is a link to /run on
// every node, and a disk is sometimes reached through one. So (the decision recorded on issue 358, 2026-10-11):
//
// - **the directory is opened component by component from /**, each with O_DIRECTORY|O_NOFOLLOW, and its mode
// and owner are changed with fchmod and fchown on that descriptor; a directory the walk makes is made in its
// parent's descriptor. A link put in place after any check is refused by the kernel at the moment of use;
// - **a link in a parent is followed only when root alone could have put it there**: the link is root's, and
// every directory from / down to the one holding it is root's and writable by no other account (no write for
// others, no write for a group other than root's). Its target is then walked the same way, from /;
// - **any other link refuses that directory**, naming the link and the account that could replace it; nothing
// is changed there, and the rest of the declaration applies;
// - **the directory itself is never a link** (issue 339, applyDirectory).
//
// A directory below a home keeps the walk of ADR 0266, which follows no link below the home at all.
import (
"fmt"
osuser "os/user"
"path/filepath"
"strconv"
"strings"
"github.com/novox/mesh-host/internal/declaration"
)
// LinkInPathError is a mesh directory refused because a link in its path is one another account could replace.
type LinkInPathError struct {
// Path is the directory declared, Link the link in its path, Who the account that could replace it.
Path, Link, Who string
}
func (e *LinkInPathError) Error() string { return e.Path + " " + e.why() }
// why is the refusal without the path it refuses, as a PlacementRefusedError says it.
func (e *LinkInPathError) why() string {
return fmt.Sprintf("is reached through the link %s, which %s could replace: the node-engine runs as root and "+
"follows a link to a directory it gives a mode and owner only when root alone could have made it (novox/hq "+
"issue 358). Make %s a directory, or make it and every directory above it root's and writable by no other "+
"account", e.Link, e.Who, e.Link)
}
// couldReplace says who besides root could replace an entry of a directory with this owner, group and mode: ""
// when nobody can. A variable so a test, which runs below a directory every account may write (/tmp), can name
// which of its own directories count as root's alone.
var couldReplace = func(path string, uid, gid, mode uint32) string {
if uid != 0 {
return "the account " + accountNamed(uid) + " (it owns " + path + ")"
}
if mode&0o002 != 0 {
return "every account (" + path + " is writable by all)"
}
if mode&0o020 != 0 && gid != 0 {
return "the group " + groupNamed(gid) + " (it may write " + path + ")"
}
return ""
}
// linkReplacer says who besides root owns a link: "" when root does. A variable for the same reason.
var linkReplacer = func(link string, uid uint32) string {
if uid != 0 {
return "the account " + accountNamed(uid) + " (it owns the link)"
}
return ""
}
func accountNamed(uid uint32) string {
if u, err := osuser.LookupId(strconv.FormatUint(uint64(uid), 10)); err == nil {
return u.Username
}
return "with uid " + strconv.FormatUint(uint64(uid), 10)
}
func groupNamed(gid uint32) string {
if g, err := osuser.LookupGroupId(strconv.FormatUint(uint64(gid), 10)); err == nil {
return g.Name
}
return "with gid " + strconv.FormatUint(uint64(gid), 10)
}
// JudgeDirectories is the walk's judgement over every directory a declaration names, read-only (novox/hq issue
// 358): nothing is made, chmodded or chowned. One line per directory, in the declaration's order, after one line
// that counts them: placed, would be refused and why, refused already by the placement guard (issue 339), or
// below a home, where ADR 0266's walk judges it as before.
func JudgeDirectories(d *declaration.Declaration) string {
var lines []string
var placed, refused, guarded, homed int
for _, r := range d.Resources {
dir, ok := r.(*declaration.Directory)
if !ok {
continue
}
path := filepath.Clean(dir.Path)
if err := refusePath(path, placingDirectory, moduleOfID(dir.ID), dir.Owner); err != nil {
guarded++
lines = append(lines, path+": refused already by the placement guard: "+err.Error())
continue
}
if home := homeAbove(path); home != "" {
homed++
lines = append(lines, path+": below the home "+home+", walked without following any link as before (ADR 0266)")
continue
}
if err := judgeMeshDir(path); err != nil {
refused++
lines = append(lines, path+": would be refused: "+err.Error())
continue
}
placed++
lines = append(lines, path+": placed")
}
head := fmt.Sprintf("%d directories declared: %d placed, %d would be refused, %d refused already by the "+
"placement guard, %d below a home (novox/hq issue 358; judged read-only, nothing changed)",
placed+refused+guarded+homed, placed, refused, guarded, homed)
return strings.Join(append([]string{head}, lines...), "\n")
}
-119
View File
@@ -1,119 +0,0 @@
//go:build linux
package apply
// The apply half of mesh_dirs.go (novox/hq issue 358): a mesh directory outside every home made and given its mode
// and owner through the walk's descriptor.
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
"github.com/novox/mesh-host/internal/declaration"
)
// meshDirsByDescriptor says this system can open a directory component by component without following a link.
const meshDirsByDescriptor = true
// openMeshDir opens the directory at path, from /, one component at a time with O_DIRECTORY|O_NOFOLLOW; a missing
// component is made in its parent's descriptor with mode, and made is called with each one made and its descriptor.
// A link in a parent is followed only when couldReplace and linkReplacer find that root alone could have made it,
// and its target is walked again from /; any other link is a LinkInPathError, and the last component being one is
// refused as not placed. Answers the directory's descriptor, which the caller closes, with the walk: what it made
// and the path it reached the directory at.
func openMeshDir(path string, mode os.FileMode, made func(dir string, fd int) error) (int, *meshWalk, error) {
w := &meshWalk{want: filepath.Clean(path), create: true, mode: mode, made: made}
fd, err := w.open()
return fd, w, err
}
// applyMeshDirectory gives a mesh directory outside every home its declared mode and owner through a descriptor
// (novox/hq issue 358): opened by openMeshDir, made there when missing, judged a directory by fstat on it, changed
// with fchmod and fchown on it, and read back from it. The parents it makes inside the owner's home are the owner's,
// as makeDirs gives them (novox/hq ADR 0182).
func applyMeshDirectory(r *declaration.Directory, out Outcome, mode os.FileMode, existed bool) (Outcome, error) {
uid, gid, chown := 0, 0, r.Owner != ""
var err error
if chown {
if uid, gid, err = idsOf(r.Owner); err != nil {
return out, fmt.Errorf("%s should belong to %q: %w", r.Path, r.Owner, err)
}
}
clean := filepath.Clean(r.Path)
home := ""
if chown {
if h, herr := homeOf(r.Owner); herr == nil && h != "" {
home = filepath.Clean(h)
}
}
giveMade := func(dir string, fd int) error {
if dir == clean || home == "" || (dir != home && !strings.HasPrefix(dir, home+string(os.PathSeparator))) {
return nil
}
if err := unix.Fchown(fd, uid, gid); err != nil {
return fmt.Errorf("cannot give %s to %q: %w", dir, r.Owner, &os.PathError{Op: "chown", Path: dir, Err: err})
}
return nil
}
fd, walk, err := openMeshDir(clean, mode, giveMade)
if err != nil {
var link *LinkInPathError
if errors.As(err, &link) {
return out, &PlacementRefusedError{Path: r.Path, Why: "it " + link.why()}
}
return out, err
}
defer unix.Close(fd)
// Made by this walk when its last component is: at the path the walk reached it at, which root-only links in its
// path may have moved from the path declared.
created := len(walk.Made) > 0 && walk.Made[len(walk.Made)-1] == walk.At
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil {
return out, &os.PathError{Op: "fstat", Path: r.Path, Err: err}
}
beforeMode, beforeUID, beforeGID := statDirMode(st.Mode), int(st.Uid), int(st.Gid)
// Set explicitly even when it existed: a mode given at creation is not a mode maintained — its setgid bit
// with it (novox/hq ADR 0334 §1).
if err := unix.Fchmod(fd, unixDirMode(mode)); err != nil {
return out, &os.PathError{Op: "chmod", Path: r.Path, Err: err}
}
ownedAlready := !chown || (beforeUID == uid && beforeGID == gid)
if !ownedAlready {
if err := unix.Fchown(fd, uid, gid); err != nil {
return out, fmt.Errorf("cannot give %s to %q: %w", r.Path, r.Owner, &os.PathError{Op: "chown", Path: r.Path, Err: err})
}
// A chown may take the setgid bit: the mode is set again after it.
if err := unix.Fchmod(fd, unixDirMode(mode)); err != nil {
return out, &os.PathError{Op: "chmod", Path: r.Path, Err: err}
}
}
// Read back, from the descriptor that was changed.
if err := unix.Fstat(fd, &st); err != nil {
return out, fmt.Errorf("made %s and cannot stat it: %w", r.Path, err)
}
if st.Mode&unix.S_IFMT != unix.S_IFDIR {
return out, fmt.Errorf("%s is not a directory after applying", r.Path)
}
if got := statDirMode(st.Mode); got != dirBits(mode) {
return out, fmt.Errorf("%s is mode %s after setting %s", r.Path, modeText(got), modeText(mode))
}
out.Action = "unchanged"
switch {
case created || !existed:
out.Action = "created"
case !ownedAlready:
out.Action = "updated"
case beforeMode != dirBits(mode):
out.Action = "updated"
out.Detail = fmt.Sprintf("mode %s to %s", modeText(beforeMode), modeText(mode))
}
return out, nil
}
-95
View File
@@ -1,95 +0,0 @@
//go:build linux
package apply
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Review 47 of novox/hq issue 358: a directory the walk refuses gates its module, and nothing below it is written,
// whether the link was there when the apply began or appeared while it ran.
// aLinkedModule is a module's directory at <base>/parent/state, its parent a link to <base>/elsewhere, and a file of
// the module below the directory; and another module's file elsewhere.
func aLinkedModule(t *testing.T) (target, other, decl string) {
t.Helper()
base := t.TempDir()
target = filepath.Join(base, "elsewhere", "state")
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(base, "elsewhere"), filepath.Join(base, "parent")); err != nil {
t.Fatal(err)
}
dir := filepath.Join(base, "parent", "state")
other = filepath.Join(base, "other.conf")
decl = `{"id":"mod.state","type":"directory","path":"` + dir + `","mode":"0700"},` +
`{"id":"mod.conf","type":"file","path":"` + dir + `/settings.conf","content":"a=1\n"},` +
`{"id":"other.conf","type":"file","path":"` + other + `","content":"kept apart\n"}`
return target, other, decl
}
func holdsTheGate(t *testing.T, target, other string, report Report, err error) {
t.Helper()
var refusal *PlacementRefusedError
if !errors.As(err, &refusal) {
t.Fatalf("the directory behind the link was not refused: %v", err)
}
if _, statErr := os.Stat(filepath.Join(target, "settings.conf")); statErr == nil {
t.Error("a file was written into the link's target, below the directory the engine refused")
}
if info, _ := os.Stat(target); info.Mode().Perm() != 0o755 {
t.Errorf("the link's target was given mode %o (was 755)", info.Mode().Perm())
}
if o := outcomeOf(report, "mod.conf"); o.Action != "skipped" {
t.Errorf("the file below the refused directory is not reported skipped: %+v", o)
}
if strings.Contains(err.Error(), "everything was attempted") || !strings.Contains(err.Error(), "the rest of mod was not attempted") {
t.Errorf("the report does not say the module was gated: %v", err)
}
if got, readErr := os.ReadFile(other); readErr != nil || string(got) != "kept apart\n" {
t.Errorf("another module's file was not applied: %q, %v", got, readErr)
}
}
// The link there when the apply begins: refused by the guard before anything is applied.
func TestADirectoryBehindALinkGatesItsModuleThroughApply(t *testing.T) {
target, other, decl := aLinkedModule(t)
report, _, err := Apply(context.Background(), archHost(t), declare(t, decl), store.State{}, store.OriginDeclared,
noServices, nil, nil)
holdsTheGate(t, target, other, report, err)
}
// The link found only at the moment of use: refused by the walk during the apply, and still a refusal that gates its
// module and what is below it. Simulated by trusting / for the guard's walk (two walks of /: one to the link, one
// from its target) and not for the apply's.
func TestADirectoryRefusedDuringTheApplyGatesItsModule(t *testing.T) {
target, other, decl := aLinkedModule(t)
wasDirs, wasLinks := couldReplace, linkReplacer
walksOfRoot := 0
couldReplace = func(path string, _, _, _ uint32) string {
if path == "/" {
walksOfRoot++
if walksOfRoot > 2 {
return "every account (a test's /)"
}
}
return ""
}
linkReplacer = func(string, uint32) string { return "" }
t.Cleanup(func() { couldReplace, linkReplacer = wasDirs, wasLinks })
report, _, err := Apply(context.Background(), archHost(t), declare(t, decl), store.State{}, store.OriginDeclared,
noServices, nil, nil)
if walksOfRoot < 3 {
t.Fatalf("the apply did not walk again at the moment of use (%d walks)", walksOfRoot)
}
holdsTheGate(t, target, other, report, err)
}
-186
View File
@@ -1,186 +0,0 @@
//go:build linux
package apply
// The descriptor half of mesh_dirs.go (novox/hq issue 358).
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
)
// maxLinksFollowed bounds the root-only links one walk follows, as the kernel bounds a lookup's (MAXSYMLINKS).
const maxLinksFollowed = 40
// errNotThereYet is a read-only walk meeting a component that does not exist: an apply would make it there.
var errNotThereYet = errors.New("not there yet")
// meshWalk is one walk of a mesh directory's path from /.
type meshWalk struct {
// want is the directory as declared.
want string
// create makes a missing component, with mode, in its parent's descriptor; without it the walk stops there
// with errNotThereYet and makes nothing.
create bool
mode os.FileMode
// made is called with each directory the walk made and its descriptor.
made func(dir string, fd int) error
// Made is every directory the walk made, outermost first, and At the path the directory was reached at:
// want, or where the root-only links in its path led.
Made []string
At string
}
// judgeMeshDir is openMeshDir's judgement and nothing else (novox/hq issue 358): the same walk, making nothing
// and changing nothing; nil when an apply would give the directory its mode and owner, also when a part of its
// path is not there yet (an apply makes it, as root, in a directory it reached).
func judgeMeshDir(path string) error {
w := &meshWalk{want: filepath.Clean(path)}
fd, err := w.open()
if errors.Is(err, errNotThereYet) {
return nil
}
if err != nil {
return err
}
unix.Close(fd)
return nil
}
func (w *meshWalk) open() (int, error) {
if !filepath.IsAbs(w.want) {
return -1, fmt.Errorf("%s is not an absolute path", w.want)
}
p := w.want
for followed := 0; ; followed++ {
if followed > maxLinksFollowed {
return -1, &os.PathError{Op: "open", Path: w.want, Err: unix.ELOOP}
}
fd, next, err := w.fromRoot(p)
if err != nil {
return -1, err
}
if next == "" {
w.At = p
return fd, nil
}
p = next
}
}
// fromRoot is one walk down p: the directory's descriptor, or the path to walk next when a root-only link was
// met (with -1), or the refusal.
func (w *meshWalk) fromRoot(p string) (int, string, error) {
fd, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return -1, "", &os.PathError{Op: "open", Path: "/", Err: err}
}
// Who could replace an entry of the directories walked so far: once one could, every link below it is
// theirs to make, whoever owns the link.
who, err := replacerOf(fd, "/")
if err != nil {
unix.Close(fd)
return -1, "", err
}
at := "/"
var parts []string
if p != "/" {
parts = strings.Split(strings.TrimPrefix(p, "/"), "/")
}
const dirFlags = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC
for i, part := range parts {
here := filepath.Join(at, part)
last := i == len(parts)-1
next, err := unix.Openat(fd, part, dirFlags, 0)
if errors.Is(err, unix.ENOENT) {
if !w.create {
unix.Close(fd)
return -1, "", errNotThereYet
}
mkErr := unix.Mkdirat(fd, part, uint32(w.mode.Perm()))
if mkErr != nil && !errors.Is(mkErr, unix.EEXIST) {
unix.Close(fd)
return -1, "", &os.PathError{Op: "mkdir", Path: here, Err: mkErr}
}
next, err = unix.Openat(fd, part, dirFlags, 0)
if mkErr == nil && err == nil {
w.Made = append(w.Made, here)
if w.made != nil {
if gErr := w.made(here, next); gErr != nil {
unix.Close(next)
unix.Close(fd)
return -1, "", gErr
}
}
}
}
if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) {
var st unix.Stat_t
if lerr := unix.Fstatat(fd, part, &st, unix.AT_SYMLINK_NOFOLLOW); lerr == nil && st.Mode&unix.S_IFMT == unix.S_IFLNK {
to, rerr := w.followable(fd, part, here, st, who, last)
unix.Close(fd)
if rerr != nil {
return -1, "", rerr
}
if !filepath.IsAbs(to) {
to = filepath.Join(at, to)
}
return -1, filepath.Join("/", to, filepath.Join(parts[i+1:]...)), nil
}
}
unix.Close(fd)
if err != nil {
switch {
case errors.Is(err, unix.ENOTDIR) && last:
return -1, "", fmt.Errorf("%s exists and is not a directory", here)
case errors.Is(err, unix.ENOTDIR):
return -1, "", fmt.Errorf("%s is not a directory, and %s lies below it", here, w.want)
}
return -1, "", &os.PathError{Op: "open", Path: here, Err: err}
}
fd = next
at = here
if who == "" {
if who, err = replacerOf(fd, here); err != nil {
unix.Close(fd)
return -1, "", err
}
}
}
return fd, "", nil
}
// replacerOf is couldReplace for the directory open on fd.
func replacerOf(fd int, path string) (string, error) {
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil {
return "", &os.PathError{Op: "fstat", Path: path, Err: err}
}
return couldReplace(path, st.Uid, st.Gid, st.Mode&0o7777), nil
}
// followable judges the link at here, held by the directory open on fd: its target when root alone could have
// made it, else the refusal. who is who could replace an entry of a directory above it ("" when nobody).
func (w *meshWalk) followable(fd int, name, here string, st unix.Stat_t, who string, last bool) (string, error) {
if last {
return "", &PlacementRefusedError{Path: w.want, Why: "it is a symbolic link, and the node-engine follows no " +
"link to a directory it owns"}
}
if who == "" {
who = linkReplacer(here, st.Uid)
}
if who != "" {
return "", &LinkInPathError{Path: w.want, Link: here, Who: who}
}
buf := make([]byte, unix.PathMax)
n, err := unix.Readlinkat(fd, name, buf)
if err != nil {
return "", &os.PathError{Op: "readlink", Path: here, Err: err}
}
return string(buf[:n]), nil
}
-91
View File
@@ -1,91 +0,0 @@
//go:build linux
package apply
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq issue 358: a mesh directory outside every home is given its mode and owner only through a
// descriptor opened from /, so a parent swapped for a link between two applies cannot carry root's chmod and
// chown to the link's target — unless root alone could have made that link (/var/run is one on every node).
// A link only root could have made — itself root's, in directories no other account may write — is followed, as
// /var/run to /run is on every node; relative or absolute.
func TestALinkOnlyRootCouldMakeIsFollowed(t *testing.T) {
for name, to := range map[string]func(base string) string{
"absolute": func(base string) string { return filepath.Join(base, "elsewhere") },
"relative": func(string) string { return "elsewhere" },
} {
t.Run(name, func(t *testing.T) {
rootAlone(t, "")
base, r, previous := aMeshDirectory(t)
target := swapParentForLink(t, base, to(base))
out, err := applyDirectory(r, previous, true)
if err != nil {
t.Fatalf("a directory behind a root-only link was refused: %v", err)
}
if got := permOf(t, target); got != 0o700 {
t.Errorf("the directory behind a root-only link is mode %o, want 700", got)
}
if out.Action != "updated" {
t.Errorf("the apply said %q, want updated", out.Action)
}
})
}
}
// A link another account owns refuses the directory even in directories root alone may write, naming the account.
func TestALinkAnotherAccountOwnsIsRefused(t *testing.T) {
rootAlone(t, "the account mallory (it owns the link)")
base, r, previous := aMeshDirectory(t)
target := swapParentForLink(t, base, filepath.Join(base, "elsewhere"))
_, err := applyDirectory(r, previous, true)
if err == nil || !strings.Contains(err.Error(), "mallory") {
t.Fatalf("a link another account owns was not refused naming it: %v", err)
}
if got := permOf(t, target); got != 0o755 {
t.Errorf("the link's target was given mode %o (was 755)", got)
}
}
// A directory that is missing is made through the walk, below a link root alone could make.
func TestAMissingDirectoryIsMadeBehindARootOnlyLink(t *testing.T) {
rootAlone(t, "")
base := t.TempDir()
if err := os.Mkdir(filepath.Join(base, "real"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("real", filepath.Join(base, "link")); err != nil {
t.Fatal(err)
}
r := &declaration.Directory{ID: "mod.state", Path: filepath.Join(base, "link", "a", "b"), Mode: "0750"}
out, err := applyDirectory(r, store.Applied{}, false)
if err != nil {
t.Fatal(err)
}
if out.Action != "created" {
t.Errorf("the apply said %q, want created", out.Action)
}
if got := permOf(t, filepath.Join(base, "real", "a", "b")); got != 0o750 {
t.Errorf("the made directory is mode %o, want 750", got)
}
}
// rootAlone has every directory count as writable by root alone, as /, /var and /run are on a node, and a link's
// owner be linkOwner ("" for root): a test runs below /tmp, which every account may write.
func rootAlone(t *testing.T, linkOwner string) {
t.Helper()
wasDirs, wasLinks := couldReplace, linkReplacer
couldReplace = func(string, uint32, uint32, uint32) string { return "" }
linkReplacer = func(string, uint32) string { return linkOwner }
t.Cleanup(func() { couldReplace, linkReplacer = wasDirs, wasLinks })
}
+1 -1
View File
@@ -77,7 +77,7 @@ func retireFirewall(ctx context.Context, d *declaration.Declaration, origin stri
return "", nil
}
if !firewall.Installed(ctx, run) {
// Uninstalled (novox/hq ADR 0180): retired for good, by the module that replaced it. Said
// Uninstalled (novox/hq ADR 0175): retired for good, by the module that replaced it. Said
// once, and nothing is asked of a command that is not there.
if rec.RetiredBy != firewall.RetiredRemoved {
rec.RetiredBy = firewall.RetiredRemoved
+1 -5
View File
@@ -42,8 +42,6 @@ func (u *ufwMachine) iptables(args []string) (string, error) {
}
func (u *ufwMachine) run(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
u.asked = append(u.asked, name+" "+strings.Join(args, " "))
switch name {
case "nft":
@@ -376,7 +374,6 @@ func TestReturningToAdoptedLoadsTheGuardBeforeRemovingTheFilter(t *testing.T) {
}
guardUpAtStop := false
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != "systemctl" {
return "", &exec.Error{Name: name, Err: exec.ErrNotFound}
}
@@ -474,7 +471,6 @@ func TestAGuardThatFailsLeavesTheDerivedFilterInForce(t *testing.T) {
`","content":"table inet mesh_guard {}\n"}`
stopped := false
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != "systemctl" {
return "", &exec.Error{Name: name, Err: exec.ErrNotFound}
}
@@ -529,7 +525,7 @@ func TestUfwIsNotRetiredUntilTheMeshsOwnFilterIsLoaded(t *testing.T) {
}
// A front end that is no longer installed is recorded as removed, said once, and asked nothing of
// (novox/hq ADR 0180).
// (novox/hq ADR 0175).
func TestAnUninstalledFrontEndIsRetiredForGood(t *testing.T) {
dir := t.TempDir()
u := &ufwMachine{installed: false, ruleset: "table inet mesh\n"}
-261
View File
@@ -1,261 +0,0 @@
package apply
import (
"bufio"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
// Whether a library's disk is there (novox/hq ADR 0328 §9; first ADR 0325 §9).
//
// **The rule the engine keeps.** A folder is placed on the filesystem it is first seen on: the mount point that
// holds it — the longest mount point in /proc/self/mountinfo that is the folder or above it — is recorded beside the
// kept folder. On every later apply the folder (or, when it is absent, its nearest existing parent) is judged
// against that record: held by another mount point than the one recorded while the recorded one is not mounted is
// the disk not there. A recorded mount point that is mounted while another holds the folder is a **move**: what is
// at the path now is not what was kept (a disk mounted over a folder first seen empty below it, a mount made
// deeper), so the folder is judged afresh — kept again only if it holds nothing, else it waits for the operator's
// word as any folder holding data does (ADR 0328 §7).
//
// **On first sight, with no record,** the engine knows a disk is missing only from where disks are meant to be:
// every mount target in /etc/fstab, every `Where=` of a systemd .mount unit, every mount point a ZFS dataset names
// (`zfs list`, when the machine has zfs), every mount point this engine has seen mounted since it started, and every
// mount point recorded for another kept folder. A target that is the folder or above it, lies below the mount point
// that holds what is there now, and is not mounted, is the disk not there. Otherwise the filesystem that holds what
// is there is the placed one. A mount nothing names in those places (a pool not imported, whose datasets `zfs list`
// does not show; a hand `mount` never seen) is not known to be missing on first sight: then a folder first seen
// empty below it is kept as empty, and the move above takes it back the moment the disk is there.
// MountPoints is every mount point this machine has mounted now, from /proc/self/mountinfo. A variable so a test
// can stand in for mounts it cannot make.
var MountPoints = func() []string {
return readMountPoints()
}
// mountHistory is every mount point this engine has seen mounted since it started (§9's "mountinfo history").
var mountHistory = struct {
sync.Mutex
seen map[string]bool
}{seen: map[string]bool{}}
// sawMounted adds points to the history, and is the history: every mount point seen, never "/".
func sawMounted(points []string) []string {
mountHistory.Lock()
defer mountHistory.Unlock()
for _, p := range points {
if p != "/" {
mountHistory.seen[p] = true
}
}
out := make([]string, 0, len(mountHistory.seen))
for p := range mountHistory.seen {
out = append(out, p)
}
return out
}
func readMountPoints() []string {
raw, err := os.ReadFile("/proc/self/mountinfo")
if err != nil {
return []string{"/"}
}
return mountinfoPoints(string(raw))
}
// MountTargets is every place a disk is meant to be mounted: /etc/fstab's targets and ZFS datasets' mount points,
// read at most once a minute — every look judges every access by them. A variable so a test can stand in.
var MountTargets = func(ctx context.Context) []string {
targets.Lock()
defer targets.Unlock()
if targets.read.IsZero() || time.Since(targets.read) > time.Minute {
targets.list, targets.read = readMountTargets(ctx), time.Now()
}
return append([]string(nil), targets.list...)
}
var targets struct {
sync.Mutex
list []string
read time.Time
}
func readMountTargets(ctx context.Context) []string {
var out []string
if raw, err := os.ReadFile("/etc/fstab"); err == nil {
out = append(out, fstabTargets(string(raw))...)
}
out = append(out, mountUnitTargets(systemdUnitDirs)...)
if _, err := exec.LookPath("zfs"); err == nil {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if raw, err := exec.CommandContext(ctx, "zfs", "list", "-H", "-o", "mountpoint").Output(); err == nil {
for _, line := range strings.Split(string(raw), "\n") {
if p := strings.TrimSpace(line); strings.HasPrefix(p, "/") {
out = append(out, filepath.Clean(p))
}
}
}
}
return out
}
// systemdUnitDirs are where systemd keeps the units a machine mounts by.
var systemdUnitDirs = []string{"/etc/systemd/system", "/run/systemd/system", "/run/systemd/generator",
"/usr/lib/systemd/system", "/lib/systemd/system"}
// mountUnitTargets reads the `Where=` of every .mount unit in dirs.
func mountUnitTargets(dirs []string) []string {
var out []string
for _, dir := range dirs {
names, err := filepath.Glob(filepath.Join(dir, "*.mount"))
if err != nil {
continue
}
for _, name := range names {
raw, err := os.ReadFile(name)
if err != nil {
continue
}
for _, line := range strings.Split(string(raw), "\n") {
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "Where="); ok && strings.HasPrefix(v, "/") {
out = append(out, filepath.Clean(strings.TrimSpace(v)))
}
}
}
}
return out
}
// mountinfoPoints reads the mount point of each line of mountinfo, its fifth field, with the kernel's octal escapes
// undone.
func mountinfoPoints(raw string) []string {
var out []string
for _, line := range strings.Split(raw, "\n") {
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
out = append(out, filepath.Clean(unescapeMount(fields[4])))
}
return out
}
// mountSourceOf is the source and filesystem type of the mount at point, from mountinfo: for a ZFS dataset, its
// name. Empty when the machine says nothing of it.
func mountSourceOf(point string) (source, fstype string) { return MountSource(point) }
// MountSource is mountSourceOf as the machine answers it. A variable so a test can stand in for another filesystem
// mounted at the same point.
var MountSource = func(point string) (source, fstype string) {
raw, err := os.ReadFile("/proc/self/mountinfo")
if err != nil {
return "", ""
}
for _, line := range strings.Split(string(raw), "\n") {
before, after, ok := strings.Cut(line, " - ")
fields, rest := strings.Fields(before), strings.Fields(after)
if !ok || len(fields) < 5 || len(rest) < 2 {
continue
}
if filepath.Clean(unescapeMount(fields[4])) == point {
source, fstype = unescapeMount(rest[1]), rest[0]
}
}
return source, fstype
}
// fstabTargets reads the second field of each fstab line that mounts somewhere: not swap, not a comment.
func fstabTargets(raw string) []string {
var out []string
sc := bufio.NewScanner(strings.NewReader(raw))
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 2 || strings.HasPrefix(fields[0], "#") || !strings.HasPrefix(fields[1], "/") {
continue
}
out = append(out, filepath.Clean(unescapeMount(fields[1])))
}
return out
}
// unescapeMount undoes the kernel's and fstab's octal escapes: \040 is a space.
func unescapeMount(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+4 <= len(s) {
if v, ok := octal3(s[i+1 : i+4]); ok {
b.WriteByte(v)
i += 3
continue
}
}
b.WriteByte(s[i])
}
return b.String()
}
func octal3(s string) (byte, bool) {
if len(s) != 3 {
return 0, false
}
v := 0
for _, c := range s {
if c < '0' || c > '7' {
return 0, false
}
v = v*8 + int(c-'0')
}
return byte(v), v < 256
}
// mountHolding is the longest of points that is path or above it: the mount point that holds it.
func mountHolding(path string, points []string) string {
best := "/"
for _, p := range points {
if atOrBelow(path, p) && len(p) > len(best) {
best = p
}
}
return best
}
// placement is the judgement of where a folder's data lives.
type placement struct {
// Mount is the mount point that holds what is there: the folder, or its nearest existing parent.
Mount string
// Away is that the disk the folder was placed on is not there.
Away bool
// Moved is that the recorded mount point is mounted and another holds the folder: what is there is not what
// was kept.
Moved bool
}
// placedOn judges path, of which existing is what is there (the folder itself, or its nearest existing parent),
// against what was recorded and where disks are meant to be.
func placedOn(ctx context.Context, path, existing, recorded string, others []string) placement {
points := MountPoints()
mounted := map[string]bool{}
for _, p := range points {
mounted[p] = true
}
have := mountHolding(existing, points)
seen := sawMounted(points)
if recorded != "" {
return placement{Mount: have, Away: have != recorded && !mounted[recorded],
Moved: have != recorded && mounted[recorded]}
}
for _, t := range append(append(MountTargets(ctx), others...), seen...) {
if atOrBelow(path, t) && t != have && atOrBelow(t, have) && !mounted[t] {
return placement{Mount: have, Away: true}
}
}
return placement{Mount: have}
}
-626
View File
@@ -1,626 +0,0 @@
package apply
// Where the node-engine places nothing and mounts nothing, whoever asks (novox/hq issue 339).
//
// A directory names its path, and the controller resolves part of that path from what was set for the
// module: its `places` setting moves a directory anywhere, with an owner it names, and its `accesses` setting
// says which of the machine's paths are mounted into its container. The engine runs as root, so a path it
// accepts blindly is a path anyone who could change those settings hands to any account: a directory at /etc
// owned by a caller's account gives it /etc, and an access at / mounts the machine's root into a container.
// The controller refuses both where a setting is made; the engine refuses them again where it applies,
// because a guard in one place is a guard one change away from gone. Whatever the declaration says:
//
// 1. **No directory, access or mount source is one of the machine's own roots, or holds one**: /, /etc,
// /usr, /var, /var/lib, /home, /run and the rest of protectedRoots. Modules place directories BELOW /etc
// or /var/lib, never the root itself; owning one is owning everything in it.
// 2. **Nothing is placed in /proc, /sys, /dev, /boot, /root, /var/spool, /opt or the container runtimes' data
// (/var/lib/docker, /var/lib/containers, /var/lib/containerd, and where the runtimes' configuration moves
// them: runtimeDataRoots), nor in the node-engine's
// own trees** (its state, its identity, its installed builds) but by its own module; nothing is mounted from
// those but /proc, /sys and /dev. A mount of a kernel file, a device or the clock is the plumbing
// systemPath names.
// 2a. **An account's .ssh is never an access or a mount**, and is a directory only below its account's home,
// as that account's (rule 4).
// 3. **A directory below /etc, /usr or /run is root's.** The machine's configuration and programs are read
// as root's word; a directory there owned by another account is that account writing root's word. An
// access is never there at all: the operator's data is not the machine's configuration.
// 4. **Below a person's or an agent's home, a directory is that account's.** Root's or another account's
// directory there is one the account does not control in a tree whose every parent it does. A home
// itself may be a module's directory (a backup repository kept as an account's home is one), and as
// every directory the mesh did not make, it is used as found: never chowned or chmodded (applyDirectory).
// 5. **A mount source that is a refused directory or a refused access is refused with it**: the container
// would otherwise bind the very path the engine would not place, and the runtime creates a missing one
// as root.
// 6. **A file is judged as a directory is** (novox/hq issue 495): never one of the machine's own roots, nor
// in a tree nothing is placed in (rule 2), and never below a directory or an access refused here. A file
// or an archive below a refused path is refused with it, whichever module declares it and in whatever
// order. Rules 3 and 4 are a directory's owner and do not reach a file: a file below /etc is the mesh's
// ordinary configuration, and one below a home is judged by home_links.go and agent_home_guard.go.
// A refused directory also gates the rest of its module, as a step that did not complete does
// (apply.go's `gated`): what a module declares after its directory was written for that directory.
//
// Each is a failed resource with its reason in the node's report; nothing is touched. Paths are judged as
// declared and again with every link in them resolved, so a link at /srv/x pointing at /etc places nothing.
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/novox/mesh-host/internal/declaration"
)
// protectedRoots are paths no directory, access or mount source may be, nor hold.
var protectedRoots = []string{"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64",
"/media", "/mnt", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp", "/usr", "/usr/bin",
"/usr/lib", "/usr/lib64", "/usr/local", "/usr/local/bin", "/usr/local/lib", "/usr/local/sbin", "/usr/sbin",
"/usr/share", "/var", "/var/cache", "/var/lib", "/var/lib/mesh", "/var/log", "/var/run", "/var/tmp",
"/var/spool"}
// forbiddenBelow are trees nothing is placed in or mounted from: the kernel's, the boot loader's and root's
// home. engineTrees are the node-engine's own, which only its own module places in.
var (
forbiddenBelow = []string{"/proc", "/sys", "/dev", "/boot", "/root", "/var/spool", "/opt", "/var/lib/docker",
"/var/lib/containers", "/var/lib/containerd"}
// forbiddenBelowMount are the trees no container mounts from: a mount of the kernel's files and devices is
// the plumbing a container may need (systemPath); root's home and the boot loader's are no plumbing.
forbiddenBelowMount = []string{"/boot", "/root", "/var/spool", "/opt", "/var/lib/docker", "/var/lib/containers",
"/var/lib/containerd"}
engineTrees = []string{"/var/lib/mesh-host", "/usr/lib/nox-mesh-host"}
// rootsOnly are trees a directory below is root's, and an access is never in.
rootsOnly = []string{"/etc", "/usr", "/run", "/var/run"}
)
// runtimeFiles are where the container runtimes say where they keep their data: dockerd's daemon.json, its
// unit and the unit's drop-ins (an ExecStart with --data-root, or the older -g/--graph, continued over lines,
// quoted, through Environment= or EnvironmentFile=, or a --config-file naming another daemon.json), and podman's
// storage.conf (graphroot, a basic or a literal string). The running runtimes are asked first. containerd keeps its own under /var/lib/containerd, which forbiddenBelow names; a
// containerd configured elsewhere, and podman's rootless stores under each account's home, are not read: the
// first is no runtime this mesh runs, and the second is below a home, which rule 4 already keeps for its
// account. A variable so a test names its own.
type runtimeFiles struct {
daemonJSON string
units []string
dropInDirs []string
storageConf string
}
var runtimeConfigs = runtimeFiles{
daemonJSON: "/etc/docker/daemon.json",
units: []string{"/etc/systemd/system/docker.service", "/usr/lib/systemd/system/docker.service", "/lib/systemd/system/docker.service"},
dropInDirs: []string{"/etc/systemd/system/docker.service.d", "/run/systemd/system/docker.service.d", "/usr/lib/systemd/system/docker.service.d"},
storageConf: "/etc/containers/storage.conf",
}
var (
dataRootFlag = regexp.MustCompile(`(?:--data-root|--graph|-g)(?:=|\s+)(\S+)`)
configFlag = regexp.MustCompile(`--config-file(?:=|\s+)(\S+)`)
graphRoot = regexp.MustCompile(`(?m)^\s*graphroot\s*=\s*(?:"([^"]+)"|'([^']+)')`)
envVar = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)`)
)
// runtimeRoots is where the container runtimes keep their data, as the last apply read it (readRuntimeRoots);
// nil until an apply has read it, when the guard reads the files itself.
var (
runtimeRootsMu sync.Mutex
runtimeRoots []string
)
// AskRuntimes is how the engine asks the running container runtimes where they keep their data: set by the engine
// to run the commands, nil in a test, which then reads the files alone. Its own, never the apply's runner, whose
// commands a test reads back as what the apply did.
var AskRuntimes Runner
// refreshRuntimeRoots reads where the runtimes keep their data once, at the start of an apply.
func refreshRuntimeRoots(ctx context.Context) {
roots := readRuntimeRoots(ctx, AskRuntimes)
runtimeRootsMu.Lock()
runtimeRoots = roots
runtimeRootsMu.Unlock()
}
// runtimeDataRoots is every place the container runtimes keep their data, beyond the default trees forbiddenBelow
// names: every container's filesystem is there.
func runtimeDataRoots() []string {
runtimeRootsMu.Lock()
roots := runtimeRoots
runtimeRootsMu.Unlock()
if roots != nil {
return roots
}
return readRuntimeRoots(context.Background(), nil)
}
// readRuntimeRoots asks the running runtimes where they keep their data, when run is given (`docker info`,
// `podman info`), and reads their configuration besides: an answer from a runtime that is running is what it
// really does, and the files say what it will do when it starts again. Both count. A runtime that is not running
// or not installed answers nothing, which is no error.
func readRuntimeRoots(ctx context.Context, run Runner) []string {
seen := map[string]bool{}
var out []string
add := func(p string) {
p = strings.Trim(strings.TrimSpace(p), `"'`)
if !filepath.IsAbs(p) {
return
}
p = filepath.Clean(p)
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
if run != nil {
// Each runtime has its own ten seconds: one that hangs costs the other nothing.
for _, q := range [][]string{{"docker", "info", "--format", "{{.DockerRootDir}}"},
{"podman", "info", "--format", "{{.Store.GraphRoot}}"}} {
ask, cancel := context.WithTimeout(ctx, 10*time.Second)
if root, err := run(ask, q[0], q[1:]...); err == nil {
add(root)
}
cancel()
}
}
daemonJSON := func(path string) {
raw, err := os.ReadFile(path)
if err != nil {
return
}
var c struct {
DataRoot string `json:"data-root"`
Graph string `json:"graph"`
}
if json.Unmarshal(raw, &c) == nil {
add(c.DataRoot)
add(c.Graph)
}
}
daemonJSON(runtimeConfigs.daemonJSON)
units := append([]string(nil), runtimeConfigs.units...)
for _, dir := range runtimeConfigs.dropInDirs {
matches, _ := filepath.Glob(filepath.Join(dir, "*.conf"))
units = append(units, matches...)
}
// The unit and its drop-ins are one unit to systemd: a variable set in one is seen by an ExecStart in another.
env := map[string]string{}
var execs []string
for _, u := range units {
raw, err := os.ReadFile(u)
if err != nil {
continue
}
e, x := unitLines(string(raw))
for k, v := range e {
env[k] = v
}
execs = append(execs, x...)
}
for _, line := range execs {
line = envVar.ReplaceAllStringFunc(line, func(ref string) string {
m := envVar.FindStringSubmatch(ref)
if v, ok := env[m[1]+m[2]]; ok {
return v
}
return ref
})
for _, m := range dataRootFlag.FindAllStringSubmatch(line, -1) {
add(m[1])
}
for _, m := range configFlag.FindAllStringSubmatch(line, -1) {
daemonJSON(strings.Trim(m[1], `"'`))
}
}
if raw, err := os.ReadFile(runtimeConfigs.storageConf); err == nil {
for _, m := range graphRoot.FindAllStringSubmatch(string(raw), -1) {
add(m[1] + m[2])
}
}
return out
}
// unitLines reads a unit file as systemd does for what matters here: a line ending in a backslash continues on the
// next, Environment= sets variables (quoted or not, several to a line), EnvironmentFile= (a leading - says it may
// be missing) reads KEY=value lines, and every ExecStart line is returned whole.
func unitLines(text string) (map[string]string, []string) {
var joined []string
var cur strings.Builder
for _, line := range strings.Split(text, "\n") {
trimmed := strings.TrimRight(line, " \t")
if strings.HasSuffix(trimmed, "\\") {
cur.WriteString(strings.TrimSuffix(trimmed, "\\") + " ")
continue
}
cur.WriteString(line)
joined = append(joined, cur.String())
cur.Reset()
}
if cur.Len() > 0 {
joined = append(joined, cur.String())
}
env := map[string]string{}
setPairs := func(s string) {
for _, f := range splitQuoted(s) {
if k, v, ok := strings.Cut(f, "="); ok {
env[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), `"'`)
}
}
}
var execs []string
for _, line := range joined {
l := strings.TrimSpace(line)
switch {
case strings.HasPrefix(l, "Environment="):
setPairs(strings.TrimPrefix(l, "Environment="))
case strings.HasPrefix(l, "EnvironmentFile="):
path := strings.TrimPrefix(strings.TrimSpace(strings.TrimPrefix(l, "EnvironmentFile=")), "-")
if raw, err := os.ReadFile(path); err == nil {
for _, kv := range strings.Split(string(raw), "\n") {
kv = strings.TrimSpace(kv)
if kv == "" || strings.HasPrefix(kv, "#") {
continue
}
setPairs(kv)
}
}
case strings.HasPrefix(l, "ExecStart"):
execs = append(execs, l)
}
}
return env, execs
}
// splitQuoted splits on blanks outside double or single quotes, keeping the quotes' contents whole.
func splitQuoted(s string) []string {
var out []string
var cur strings.Builder
var quote rune
for _, r := range s {
switch {
case quote != 0 && r == quote:
quote = 0
case quote == 0 && (r == '"' || r == '\''):
quote = r
case quote == 0 && (r == ' ' || r == '\t'):
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
default:
cur.WriteRune(r)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
// engineModule is the module whose resources may place in the engine's own trees.
const engineModule = "mesh-host"
// passwdFile is the user database homes are read from. A variable so a test names its own.
var passwdFile = "/etc/passwd"
// PlacementRefusedError is a resource the engine will not place, or mount, where it says.
type PlacementRefusedError struct {
Path, Why string
}
func (e *PlacementRefusedError) Error() string {
return fmt.Sprintf("%s is not placed: %s (novox/hq issue 339); nothing was touched", e.Path, e.Why)
}
// homeAccount is a person's or an agent's account and its home.
type homeAccount struct {
Name string
UID int
}
// accountsOfHomes is each person's or agent's home and the account it belongs to, from the user database: an
// account with a uid of 1000 or more, or a home under /home. A variable so a test names its own.
var accountsOfHomes = func() map[string]homeAccount {
f, err := os.Open(passwdFile)
if err != nil {
return nil
}
defer f.Close()
out := map[string]homeAccount{}
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Split(sc.Text(), ":")
if len(fields) < 6 {
continue
}
uid, err := strconv.Atoi(fields[2])
if err != nil {
continue
}
home := filepath.Clean(fields[5])
if home == "/" || home == "." || home == "" || home == "/nonexistent" {
continue
}
if (uid >= 1000 && uid != 65534) || strings.HasPrefix(home, "/home/") {
out[home] = homeAccount{Name: fields[0], UID: uid}
}
}
return out
}
// below says whether path is strictly below dir.
func below(path, dir string) bool {
if dir == "/" {
return path != "/"
}
return strings.HasPrefix(path, dir+"/")
}
// atOrBelow says whether path is dir or below it.
func atOrBelow(path, dir string) bool { return path == dir || below(path, dir) }
// resolved is a path with every link in it followed, as far as the path exists, and the rest as declared.
func resolved(path string) string {
rest := ""
for p := path; ; p = filepath.Dir(p) {
if real, err := filepath.EvalSymlinks(p); err == nil {
return filepath.Clean(filepath.Join(real, rest))
}
if filepath.Dir(p) == p {
return path
}
rest = filepath.Join(filepath.Base(p), rest)
}
}
// ownedByAccount says whether a declared owner is that account: by name, or by its uid ("1001", "1001:1001").
func ownedByAccount(owner string, a homeAccount) bool {
if owner == a.Name {
return true
}
user, _, _ := strings.Cut(owner, ":")
if uid, err := strconv.Atoi(user); err == nil {
return uid == a.UID
}
return false
}
// rootOwner says whether a declared owner is root: none, "root", or uid 0.
func rootOwner(owner string) bool {
if owner == "" || owner == "root" {
return true
}
user, _, _ := strings.Cut(owner, ":")
return user == "0"
}
// what a guarded path is, for the words of a refusal.
type placing int
const (
placingDirectory placing = iota
placingAccess
placingMount
placingFile
)
// refusePath says why a path is not placed or mounted; nil when it may be. module is the resource's module,
// owner a directory's declared owner.
func refusePath(path string, kind placing, module, owner string) error {
clean := filepath.Clean(path)
if !filepath.IsAbs(clean) {
return nil // the declaration refuses a relative path already; a named volume is not a path
}
for _, p := range []string{clean, resolved(clean)} {
if err := refuseOne(p, kind, module, owner); err != nil {
if p != clean {
err.Why = fmt.Sprintf("through a link, it is %s, and %s", p, err.Why)
err.Path = clean
}
return err
}
}
return nil
}
func refuseOne(path string, kind placing, module, owner string) *PlacementRefusedError {
for _, root := range protectedRoots {
if path == root || below(root, path) {
return &PlacementRefusedError{Path: path, Why: root + " is one of the machine's own directories, " +
"and owning or mounting it would be owning or mounting everything in it"}
}
}
trees := append([]string(nil), forbiddenBelow...)
if kind == placingMount {
// A mount is the machine's plumbing as often as a module's data — the clock, a kernel file, /dev/null
// (systemPath) — and what a setting can mount at all is a directory or an access, refused above it.
trees = append([]string(nil), forbiddenBelowMount...)
}
// The container runtimes' data, wherever the machine keeps it: every container's filesystem is there.
trees = append(trees, runtimeDataRoots()...)
for _, tree := range trees {
if atOrBelow(path, tree) {
return &PlacementRefusedError{Path: path, Why: "nothing is placed in or mounted from " + tree}
}
}
if module != engineModule {
for _, tree := range engineTrees {
if atOrBelow(path, tree) {
return &PlacementRefusedError{Path: path, Why: tree + " is the node-engine's own, placed in by " +
"its own module alone"}
}
}
}
for _, tree := range rootsOnly {
if !below(path, tree) {
continue
}
switch {
case kind == placingAccess:
return &PlacementRefusedError{Path: path, Why: "an access is the operator's data, and " + tree +
" is the machine's own"}
case kind == placingDirectory && !rootOwner(owner):
return &PlacementRefusedError{Path: path, Why: fmt.Sprintf("a directory below %s is root's, and this "+
"one is declared %s's", tree, owner)}
}
}
ssh := false
for _, part := range strings.Split(path, "/") {
ssh = ssh || part == ".ssh"
}
if ssh && (kind == placingAccess || kind == placingMount) {
return &PlacementRefusedError{Path: path, Why: "it is an account's .ssh, which holds its keys and who may " +
"log in as it, and is never an access or a mount"}
}
if kind != placingDirectory {
return nil
}
homes := accountsOfHomes()
var deepest string
for home := range homes {
if below(path, home) && len(home) > len(deepest) {
deepest = home
}
}
if ssh && deepest == "" {
return &PlacementRefusedError{Path: path, Why: "a .ssh directory is placed only below its account's home, " +
"as that account's"}
}
if deepest != "" {
if a := homes[deepest]; !ownedByAccount(owner, a) {
if owner == "" {
owner = "root"
}
return &PlacementRefusedError{Path: path, Why: fmt.Sprintf("it is below %s's home and declared %s's; "+
"below a home only that account's directories are placed", a.Name, owner)}
}
}
return nil
}
// moduleOfID is the module a resource id names, or "".
func moduleOfID(id string) string {
module, _ := moduleOf(id)
return module
}
// refusedPlaces judges every directory, access, file and archive of a declaration before anything is applied,
// and answers each refusal by path: what is refused is refused again as a container's mount source.
//
// Directories and accesses first, so a file or an archive below one of them is refused with it whatever order
// they are declared in (novox/hq issue 495): the engine refused Docker's data root as a directory and then wrote
// a file into it, because nothing judged the file's path.
func refusedPlaces(resources []declaration.Resource) map[string]error {
out := map[string]error{}
for _, r := range resources {
var err error
switch res := r.(type) {
case *declaration.Directory:
err = refusePath(res.Path, placingDirectory, moduleOfID(res.ID), res.Owner)
// And outside every home, the walk's judgement before anything is applied (novox/hq issue 358): a
// link another account could make in its path refuses it, and with it what is below it, whatever
// order they are declared in. The apply walks again at the moment of use. A directory handed over
// keeps its own, stricter walk (issue 356), which refuses any link at all.
if err == nil && meshDirsByDescriptor && homeAbove(res.Path) == "" &&
!HandedOver()[handOverKey(res.ID, res.Path)] {
if judged := judgeMeshDir(res.Path); judged != nil {
err = judged
var link *LinkInPathError
if errors.As(judged, &link) {
err = &PlacementRefusedError{Path: res.Path, Why: "it " + link.why()}
}
}
}
case *declaration.Access:
err = refusePath(res.Path, placingAccess, moduleOfID(res.ID), "")
default:
continue
}
if err != nil {
out[filepath.Clean(r.Target())] = err
}
}
trees := make(map[string]error, len(out))
for path, err := range out {
trees[path] = err
}
for _, r := range resources {
var err error
switch res := r.(type) {
case *declaration.File:
if err = belowRefused(res.Path, trees); err == nil {
err = refusePath(res.Path, placingFile, moduleOfID(res.ID), "")
}
case *declaration.Archive:
// Only below a refused path: where an archive may unpack is otherwise the archive's own judgement
// (archive.go), and the engine's own module unpacks its versions into its own tree.
err = belowRefused(res.Path, trees)
default:
continue
}
if err != nil {
out[filepath.Clean(r.Target())] = err
}
}
return out
}
// belowRefused says why a path below a refused directory or access is refused with it; nil when it is below
// none. Judged as declared and with its links resolved, as refusePath judges.
func belowRefused(path string, refused map[string]error) error {
clean := filepath.Clean(path)
if !filepath.IsAbs(clean) {
return nil
}
for _, p := range []string{clean, resolved(clean)} {
// The deepest refused tree it is below, so the same declaration always gives the same reason.
var tree string
for t := range refused {
if below(p, t) && len(t) > len(tree) {
tree = t
}
}
if tree == "" {
continue
}
why := refused[tree]
reason := why.Error()
var refusal *PlacementRefusedError
if errors.As(why, &refusal) {
reason = refusal.Why
}
return &PlacementRefusedError{Path: clean, Why: "it is below " + tree + ", which is refused: " + reason}
}
return nil
}
// refuseMounts says why a container's mounts are not made; nil when they may be.
func refuseMounts(c *declaration.Container, refused map[string]error) error {
for _, v := range c.Volumes {
src := mountSource(v)
if !strings.HasPrefix(src, "/") {
continue // a named volume, which the runtime keeps in its own tree
}
src = filepath.Clean(src)
for path, why := range refused {
if atOrBelow(src, path) {
var refusal *PlacementRefusedError
if errors.As(why, &refusal) {
return &PlacementRefusedError{Path: src, Why: "it is mounted from " + path +
", which is refused: " + refusal.Why}
}
return why
}
}
if err := refusePath(src, placingMount, moduleOfID(c.ID), ""); err != nil {
return err
}
}
return nil
}
-196
View File
@@ -1,196 +0,0 @@
package apply
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Where the node-engine places nothing and mounts nothing, whoever asks (novox/hq issue 339). A module's
// `places` setting moved a directory anywhere, with an owner it named, and the engine — root — made it so: a
// directory at /etc owned by a caller's account handed it /etc. Each test here is a declaration the controller
// might send; the engine refuses it whatever the controller decided.
func applyOnce(t *testing.T, resources string, run Runner, state store.State) (Report, store.State, error) {
t.Helper()
if run == nil {
run = noServices
}
return Apply(context.Background(), archHost(t), declare(t, resources), state, store.OriginDeclared, run, nil, nil)
}
func refusedFor339(t *testing.T, what string, err error) {
t.Helper()
if err == nil || !strings.Contains(err.Error(), "issue 339") || !strings.Contains(err.Error(), "nothing was touched") {
t.Fatalf("%s was not refused as the machine's own: %v", what, err)
}
}
func TestNoDirectoryIsPlacedAtTheMachinesOwn(t *testing.T) {
for _, path := range []string{"/", "/etc", "/etc/", "/usr", "/var", "/var/lib", "/home", "/run", "/boot", "/root",
"/var/lib/mesh-host", "/var/lib/mesh-host/identity", "/proc/1/task", "/sys/kernel", "/dev/shm", "/root/.ssh",
"/srv/../etc"} {
_, _, err := applyOnce(t, `{"id":"notes.data","type":"directory","path":"`+path+`","mode":"0755"}`, nil, store.State{})
refusedFor339(t, path, err)
}
// Below /etc, /usr or /run, a directory is root's: the machine's configuration is read as root's word.
_, _, err := applyOnce(t, `{"id":"notes.data","type":"directory","path":"/etc/sudoers.d","owner":"1001:1001"}`,
nil, store.State{})
refusedFor339(t, "/etc/sudoers.d owned by another account", err)
_, _, err = applyOnce(t, `{"id":"notes.data","type":"directory","path":"/etc/systemd/system/getty@.service.d",`+
`"owner":"1001:1001"}`, nil, store.State{})
refusedFor339(t, "a drop-in directory owned by another account", err)
}
func TestNoAccessIsAtTheMachinesOwn(t *testing.T) {
for _, path := range []string{"/", "/etc", "/etc/ssl", "/usr", "/var/lib", "/home", "/root", "/proc",
"/var/lib/mesh-host", "/run/docker"} {
_, _, err := applyOnce(t, `{"id":"notes.media","type":"access","path":"`+path+`"}`, nil, store.State{})
refusedFor339(t, "an access at "+path, err)
}
}
// A container is never handed the machine's own as a mount, nor a directory or access the engine refused.
func TestNoContainerMountsTheMachinesOwn(t *testing.T) {
ran := false
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch {
case args[0] == "info":
return "27.0\n", nil
case args[0] == "container":
return "", os.ErrNotExist
case args[0] == "run":
ran = true
return "deadbeef\n", nil
}
return "", nil
}
for _, mount := range []string{"/:/host", "/etc:/host-etc", "/var/lib:/data", "/home:/homes:ro",
"/var/lib/mesh-host:/state", "/proc:/p", "/dev:/d", "/root/.ssh:/keys", "/boot/efi:/efi"} {
ran = false
_, _, err := Apply(context.Background(), archHost(t), parseTrusted(t, `{"declaration":1,"resources":[
{"id":"notes.server","type":"container","name":"notes","image":"`+pinned+`","volumes":["`+mount+`"]}
]}`), store.State{}, store.OriginCarried, run, nil, nil)
refusedFor339(t, "a container mounting "+mount, err)
if ran {
t.Fatalf("a container mounting %s was run", mount)
}
}
}
// The path is not followed: a link where a directory is declared, or a link on the way to it, is judged where
// it leads.
func TestALinkIsNotFollowedToTheMachinesOwn(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target")
if err := os.Mkdir(target, 0o700); err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "link")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
_, _, err := applyOnce(t, `{"id":"notes.data","type":"directory","path":"`+link+`","mode":"0755"}`, nil, store.State{})
refusedFor339(t, "a directory that is a link", err)
if info, _ := os.Stat(target); info.Mode().Perm() != 0o700 {
t.Fatalf("the link's target was changed to %o", info.Mode().Perm())
}
// A link on the way: <dir>/via → /usr, so <dir>/via/share is /usr/share.
via := filepath.Join(dir, "via")
if err := os.Symlink("/usr", via); err != nil {
t.Fatal(err)
}
_, _, err = applyOnce(t, `{"id":"notes.data","type":"directory","path":"`+via+`/share"}`, nil, store.State{})
refusedFor339(t, "a directory reached through a link to /usr", err)
_, _, err = applyOnce(t, `{"id":"notes.media","type":"access","path":"`+via+`"}`, nil, store.State{})
refusedFor339(t, "an access reached through a link to /usr", err)
}
// A directory found here, that the mesh did not make, keeps its owner and mode; one the mesh made keeps the
// declared mode, as before.
func TestAFoundDirectoryIsUsedAsFound(t *testing.T) {
dir := t.TempDir()
found := filepath.Join(dir, "found")
if err := os.Mkdir(found, 0o700); err != nil {
t.Fatal(err)
}
report, state, err := applyOnce(t, `{"id":"notes.data","type":"directory","path":"`+found+`","mode":"0755"}`,
nil, store.State{})
if err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(found); info.Mode().Perm() != 0o700 {
t.Fatalf("a directory the mesh did not make was chmodded to %o", info.Mode().Perm())
}
if len(report.Outcomes) != 1 || !strings.Contains(report.Outcomes[0].Detail, "used as found") {
t.Fatalf("the report does not say it was used as found: %+v", report.Outcomes)
}
if a, _ := state.Find("notes.data"); !a.AsFound {
t.Fatal("the record does not say the directory was found")
}
// Still found at the next apply, and never removed: undeclared, it is forgotten and left.
if _, state, err = applyOnce(t, `{"id":"notes.data","type":"directory","path":"`+found+`","mode":"0755"}`,
nil, state); err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(found); info.Mode().Perm() != 0o700 {
t.Fatalf("a found directory was chmodded at the next apply to %o", info.Mode().Perm())
}
if _, _, err = applyOnce(t, `{"id":"notes.other","type":"directory","path":"`+filepath.Join(dir, "other")+`"}`,
nil, state); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(found); err != nil {
t.Fatalf("a found directory was removed: %v", err)
}
// The mesh's own: made by it, its mode is maintained.
made := filepath.Join(dir, "made")
_, state, err = applyOnce(t, `{"id":"notes.made","type":"directory","path":"`+made+`","mode":"0755"}`, nil, store.State{})
if err != nil {
t.Fatal(err)
}
if err := os.Chmod(made, 0o700); err != nil {
t.Fatal(err)
}
if _, state, err = applyOnce(t, `{"id":"notes.made","type":"directory","path":"`+made+`","mode":"0755"}`,
nil, state); err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(made); info.Mode().Perm() != 0o755 {
t.Fatalf("the mesh's own directory was not given its mode back: %o", info.Mode().Perm())
}
// Moved — a placement changed — onto a directory that was already there: that one is found, not the mesh's.
if _, _, err = applyOnce(t, `{"id":"notes.made","type":"directory","path":"`+found+`","mode":"0755"}`,
nil, state); err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(found); info.Mode().Perm() != 0o700 {
t.Fatalf("a placement moved onto a found directory chmodded it to %o", info.Mode().Perm())
}
}
// The module's own directories keep being placed: below /var/lib, below /etc as root's, a container's mount of
// the machine's docker socket.
func TestAModulesOwnPlacesAreStillPlaced(t *testing.T) {
for _, path := range []string{"/var/lib/notes/data", "/etc/systemd/logind.conf.d", "/var/run/fail2ban",
"/srv/notes", "/mnt/plex/config", "/storage/media", "/var/lib/mesh/registry"} {
if err := refusePath(path, placingDirectory, "notes", ""); err != nil {
t.Errorf("%s: %v", path, err)
}
}
for _, path := range []string{"/var/lib/minio-store", "/var/run/docker.sock", "/services/ollama", "/storage/media",
"/etc/localtime", "/proc/cpuinfo", "/dev/null", "/dev/dri"} {
if err := refusePath(path, placingMount, "notes", ""); err != nil {
t.Errorf("a mount of %s: %v", path, err)
}
}
if err := refusePath("/var/lib/notes/data", placingDirectory, "notes", "1001:2000"); err != nil {
t.Errorf("an owner below /var/lib: %v", err)
}
}
-68
View File
@@ -1,68 +0,0 @@
package apply
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Below a person's or an agent's home, a directory is that account's (novox/hq issue 339): root's, or another
// account's, there is one the account does not control in a tree whose every parent it does — and a container
// mounting one the engine refused is refused with it.
func TestBelowAHomeOnlyThatAccountsDirectoryIsPlaced(t *testing.T) {
dir := t.TempDir()
home := filepath.Join(dir, "home", "alice")
if err := os.MkdirAll(home, 0o755); err != nil {
t.Fatal(err)
}
// A uid of its own, never the test's: the build seat runs tests as root, and root's home is no person's.
const alice = 4242
passwd := filepath.Join(dir, "passwd")
if err := os.WriteFile(passwd, fmt.Appendf(nil, "root:x:0:0::/root:/bin/sh\nalice:x:%d:%d::%s:/bin/sh\n",
alice, alice, home), 0o644); err != nil {
t.Fatal(err)
}
before := passwdFile
passwdFile = passwd
t.Cleanup(func() { passwdFile = before })
_, _, err := applyOnce(t, `{"id":"notes.data","type":"directory","path":"`+home+`/.config/autostart"}`, nil, store.State{})
refusedFor339(t, "root's directory below alice's home", err)
_, _, err = applyOnce(t, `{"id":"notes.data","type":"directory","path":"`+home+`/.config/autostart",`+
`"owner":"`+strconv.Itoa(alice+1)+`"}`, nil, store.State{})
refusedFor339(t, "another account's directory below alice's home", err)
if _, err := os.Stat(filepath.Join(home, ".config")); err == nil {
t.Fatal("a refused directory's parent was made")
}
// Alice's own is placed (judged, not applied: a chown to her needs root).
if err := refusePath(home+"/.config/notes", placingDirectory, "notes", strconv.Itoa(alice)+":"+strconv.Itoa(alice)); err != nil {
t.Fatalf("alice's own directory: %v", err)
}
// A container mounting a refused directory is refused with it, and not run.
ran := false
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch args[0] {
case "info":
return "27.0\n", nil
case "container":
return "", os.ErrNotExist
case "run":
ran = true
}
return "", nil
}
_, _, err = Apply(context.Background(), archHost(t), parseTrusted(t, `{"declaration":1,"resources":[
{"id":"notes.data","type":"directory","path":"`+home+`/.ssh"},
{"id":"notes.server","type":"container","name":"notes","image":"`+pinned+`","volumes":["`+home+`/.ssh:/keys"]}
]}`), store.State{}, store.OriginCarried, run, nil, nil)
refusedFor339(t, "a container mounting a refused directory", err)
if ran {
t.Fatal("a container mounting a refused directory was run")
}
}

Some files were not shown because too many files have changed in this diff Show More