Compare commits
238
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d12bba0109 | ||
|
|
c85c2804a0 | ||
|
|
2ff40109ac | ||
|
|
46c16e18fe | ||
|
|
f00f9c2a2f | ||
|
|
21cd036232 | ||
|
|
f0adb177f0 | ||
|
|
d2d9fbbd7d | ||
|
|
2026ef36f3 | ||
|
|
51e97a8c14 | ||
|
|
dd9ab7927d | ||
|
|
0818e4c29a | ||
|
|
41e640d668 | ||
|
|
a1f9856b39 | ||
|
|
74ed7cbe0a | ||
|
|
36704b5471 | ||
|
|
6e44a27a63 | ||
|
|
dce0eef1ca | ||
|
|
e3c3bb37d0 | ||
|
|
b92fb68116 | ||
|
|
3f7f797168 | ||
|
|
c433e76de2 | ||
|
|
f4efc3cad0 | ||
|
|
a76baa4b47 | ||
|
|
90ce9e9d9f | ||
|
|
df5de3bd9f | ||
|
|
a18d09db75 | ||
|
|
3333a05a74 | ||
|
|
4695dfc0a2 | ||
|
|
7233ddbe35 | ||
|
|
1acfed4add | ||
|
|
6defec8f68 | ||
|
|
2833caf3d6 | ||
|
|
20d5af9b2d | ||
|
|
0ec96a048e | ||
|
|
c957f1ec5e | ||
|
|
85c51ebb59 | ||
|
|
f9805b2113 | ||
|
|
70cb613614 | ||
|
|
bd5cc69804 | ||
|
|
f311fa36dc | ||
|
|
30242197fa | ||
|
|
8f7db549ae | ||
|
|
5226d7bcd0 | ||
|
|
9545121ec9 | ||
|
|
3abb07f1e2 | ||
|
|
9d8aa9d6f0 | ||
|
|
212a3cc113 | ||
|
|
cc0b8554b1 | ||
|
|
0b4c86e3be | ||
|
|
8e79403c21 | ||
|
|
b8c8546118 | ||
|
|
a7270f00cb | ||
|
|
2dbbda51c6 | ||
|
|
2410796869 | ||
|
|
090b9082eb | ||
|
|
8fc1d72cd5 | ||
|
|
9a807de659 | ||
|
|
fc6c5a92e6 | ||
|
|
e74ee82a76 | ||
|
|
b0d03be7a4 | ||
|
|
4ea6a2f04e | ||
|
|
d8ff1540df | ||
|
|
cdc2af7b43 | ||
|
|
7a2ffc0a4c | ||
|
|
b1e7b6d26e | ||
|
|
512b0edb02 | ||
|
|
4ba6d0864e | ||
|
|
73e0916968 | ||
|
|
bf3f45e769 | ||
|
|
fd13834c0d | ||
|
|
4a5dcb0209 | ||
|
|
1ac7741c63 | ||
|
|
f684967c3b | ||
|
|
655f515aa7 | ||
|
|
8f763ee911 | ||
|
|
564948c2f2 | ||
|
|
1c61b72f35 | ||
|
|
2673e7a2c9 | ||
|
|
021d860ca4 | ||
|
|
b28d7bbcbf | ||
|
|
e2e8be72fa | ||
|
|
d4af8bba1b | ||
|
|
02a1c5f753 | ||
|
|
b89b143fe7 | ||
|
|
3a727011ad | ||
|
|
d93cc86f95 | ||
|
|
a390bce5f2 | ||
|
|
c2bae14994 | ||
|
|
690966d847 | ||
|
|
d28ca959f0 | ||
|
|
b232db4e17 | ||
|
|
dfb14151bd | ||
|
|
a018ebd1ca | ||
|
|
7c046a8a1d | ||
|
|
9d48b8baf9 | ||
|
|
ff2553f4db | ||
|
|
ae1293e73e | ||
|
|
a2c2c35e85 | ||
|
|
07164e40f3 | ||
|
|
f39dbdbaf0 | ||
|
|
3a002f6e7b | ||
|
|
ee20388239 | ||
|
|
fedd9dcf35 | ||
|
|
29a7de7699 | ||
|
|
a31659ed03 | ||
|
|
ccbfa9b58a | ||
|
|
e813391dcf | ||
|
|
5684a4579d | ||
|
|
824911dc50 | ||
|
|
5e189c2fca | ||
|
|
809ab4cf29 | ||
|
|
6400e92d19 | ||
|
|
41b3392789 | ||
|
|
e41ab3a505 | ||
|
|
800aabed83 | ||
|
|
9d8398206e | ||
|
|
9815796891 | ||
|
|
ab4ca44f98 | ||
|
|
ef8378a990 | ||
|
|
14e5d91c9a | ||
|
|
f863adb741 | ||
|
|
86cbebd10f | ||
|
|
0e57186bf1 | ||
|
|
e30d838395 | ||
|
|
0863695012 | ||
|
|
c38f21bb39 | ||
|
|
fcca8d9ce8 | ||
|
|
f8ef2de91d | ||
|
|
babd32cfc7 | ||
|
|
039abeff69 | ||
|
|
429ea42357 | ||
|
|
56e2ebec4b | ||
|
|
96bf9415aa | ||
|
|
bdd44154cc | ||
|
|
41f908b803 | ||
|
|
3c1ac6aef2 | ||
|
|
038eff5ca0 | ||
|
|
3a117c2d2b | ||
|
|
03031a46dd | ||
|
|
1fc1e74cc3 | ||
|
|
a338c2e581 | ||
|
|
bd30534ab1 | ||
|
|
2a4b521e1b | ||
|
|
ed43d65bf3 | ||
|
|
971881d58d | ||
|
|
b196cabd8f | ||
|
|
880e7461f9 | ||
|
|
94e49c6b2d | ||
|
|
ad812a5888 | ||
|
|
f823ade868 | ||
|
|
f39e282fed | ||
|
|
2fb7c91eaf | ||
|
|
7a6f9218a0 | ||
|
|
ad39988bc6 | ||
|
|
7b450ab371 | ||
|
|
0c405b70cc | ||
|
|
8d853791b2 | ||
|
|
7e9ae9f07e | ||
|
|
2e884438f9 | ||
|
|
98885e953e | ||
|
|
f274b6effe | ||
|
|
240b903a04 | ||
|
|
280d3b2e1e | ||
|
|
6e1bcdbde4 | ||
|
|
b2808549ee | ||
|
|
bbe74f3fc2 | ||
|
|
3e80b7ae32 | ||
|
|
31804bd8c2 | ||
|
|
d7d93f57c5 | ||
|
|
b9774ea426 | ||
|
|
1545b00a87 | ||
|
|
6953b5bafd | ||
|
|
93efe41dc9 | ||
|
|
1e463def6b | ||
|
|
a654fa768d | ||
|
|
7f98478d6d | ||
|
|
a79972577c | ||
|
|
64defd47c7 | ||
|
|
f62ee0bc75 | ||
|
|
07430240bb | ||
|
|
6f86b484d0 | ||
|
|
24bada7a6f | ||
|
|
762da9e05e | ||
|
|
a192bccf62 | ||
|
|
f0f5873202 | ||
|
|
c9001abdb4 | ||
|
|
a566add815 | ||
|
|
101f0c0d61 | ||
|
|
89a7796afe | ||
|
|
ea6fe09e01 | ||
|
|
7063b183af | ||
|
|
f08681f225 | ||
|
|
238252e152 | ||
|
|
b774a0b4f9 | ||
|
|
89fce1dae3 | ||
|
|
3e11d720b4 | ||
|
|
429672b7ff | ||
|
|
d5c365cb2b | ||
|
|
84540e709a | ||
|
|
6431848342 | ||
|
|
8470dbd8e8 | ||
|
|
64b421b6e1 | ||
|
|
f2eda240ec | ||
|
|
2a5f4c8270 | ||
|
|
5d5dccdd55 | ||
|
|
27fb22fddb | ||
|
|
b602b223e1 | ||
|
|
e8c4824ae2 | ||
|
|
8c2e76f4c1 | ||
|
|
8280a82ef8 | ||
|
|
2d5e76434b | ||
|
|
d9ea387680 | ||
|
|
99ad145bec | ||
|
|
2ff3b50a84 | ||
|
|
a24670d77c | ||
|
|
5fc4052a2b | ||
|
|
a7bf0f6e39 | ||
|
|
1c29168309 | ||
|
|
483e02e7ef | ||
|
|
0650df9414 | ||
|
|
47fb924947 | ||
|
|
09f84b451d | ||
|
|
77ae5320ad | ||
|
|
b7ede83c8c | ||
|
|
195fc63f6a | ||
|
|
482d20737f | ||
|
|
09614d94c8 | ||
|
|
c9b963f8b9 | ||
|
|
5a6e51f975 | ||
|
|
3c96683d98 | ||
|
|
8c4e33c165 | ||
|
|
2a332b0e6e | ||
|
|
b94e0f9a77 | ||
|
|
b840ce0a77 | ||
|
|
286865dfa7 | ||
|
|
ca7c4a5915 | ||
|
|
b30d9c5b5a |
@@ -4,11 +4,16 @@ Tier 0 of the Novox Mesh. The one thing ever installed by hand, and the only thi
|
||||
a machine.
|
||||
|
||||
```
|
||||
scp mesh-host root@machine:/usr/local/bin/
|
||||
mesh-host profile
|
||||
scp nox-mesh-host root@machine:/usr/bin/
|
||||
nox-mesh-host profile
|
||||
```
|
||||
|
||||
That is the whole installation. One statically linked binary, nothing else present, no runtime
|
||||
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
|
||||
to install first ([`novox/hq` ADR 0005](https://git.novox.be/novox/hq)).
|
||||
|
||||
## What it is for
|
||||
@@ -23,7 +28,7 @@ the host never queries the mesh database. It receives declarations and applies t
|
||||
## Joining a mesh
|
||||
|
||||
```
|
||||
mesh-host enrol --token <token> --name <what this machine is called>
|
||||
nox-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
|
||||
@@ -52,20 +57,22 @@ 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.
|
||||
|
||||
```
|
||||
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
|
||||
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`
|
||||
--json machine-readable
|
||||
--state where this node keeps what it knows
|
||||
--dry-run say what applying would change, and change nothing
|
||||
```
|
||||
|
||||
```
|
||||
$ mesh-host profile
|
||||
$ nox-mesh-host profile
|
||||
linux/amd64
|
||||
|
||||
yes container-runtime 29.7.2
|
||||
@@ -115,7 +122,7 @@ A host built for a machine carries its declaration **inside the binary**:
|
||||
make host BUNDLE=path/to/foundation.lock
|
||||
```
|
||||
|
||||
`mesh-host reconcile` then applies it, on a machine the mesh has told nothing yet. That is the
|
||||
`nox-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
|
||||
@@ -151,6 +158,26 @@ 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.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
// An access is judged by what each of its users may do (novox/hq ADR 0325 §7, §10, §12).
|
||||
//
|
||||
// On every look the engine judges each module's access as its module's resource of kind `access`: waiting, with a
|
||||
// wait of kind access, while the mesh may not keep its folder yet or its disk is not there; unhealthy for root's
|
||||
// data on its placed filesystem, and for a kept folder that gives a user less than its right — read by entry and
|
||||
// mask together, on the folder and the sweep's sample under it, which the sweep puts back where it can; healthy
|
||||
// otherwise. The folder 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).
|
||||
|
||||
// judgedAccess is one access as the judge reads it.
|
||||
type judgedAccess struct {
|
||||
module, id, name, path string
|
||||
taken bool
|
||||
users []store.KeptUser
|
||||
// leftover is the mesh's entries nobody wants, not an access (link.KindKeptEntries).
|
||||
leftover bool
|
||||
}
|
||||
|
||||
// accessJudge reads every access on every look. Safe for the apply and the looking loop at once.
|
||||
type accessJudge struct {
|
||||
mu sync.Mutex
|
||||
accesses []judgedAccess
|
||||
// was is each access'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 folder whose look has not returned yet.
|
||||
reading sync.Mutex
|
||||
inFlight map[string]bool
|
||||
}
|
||||
|
||||
// accesses is the one access judge on this machine.
|
||||
var accesses = newAccessJudge()
|
||||
|
||||
func newAccessJudge() *accessJudge {
|
||||
return &accessJudge{was: map[string]link.ResourceHealth{}, inFlight: map[string]bool{}}
|
||||
}
|
||||
|
||||
// set is what the declaration just applied asks: every access of a module, not held as found, with the users of
|
||||
// its folder as every access of that folder gives them. An access no longer declared is forgotten.
|
||||
func (j *accessJudge) set(d *declaration.Declaration, held map[string]bool) {
|
||||
var as []judgedAccess
|
||||
if d != nil {
|
||||
users := map[string]map[uint32]store.KeptUser{}
|
||||
taken := map[string]bool{}
|
||||
for _, r := range d.Resources {
|
||||
if a, ok := r.(*declaration.Access); ok {
|
||||
path := filepath.Clean(a.Path)
|
||||
if users[path] == nil {
|
||||
users[path] = map[uint32]store.KeptUser{}
|
||||
}
|
||||
taken[path] = taken[path] || a.Taken
|
||||
for _, u := range a.Users {
|
||||
had, seen := users[path][uint32(u.UID)]
|
||||
if !seen || (u.Use == declaration.AccessReadWrite && had.Use != declaration.AccessReadWrite) {
|
||||
users[path][uint32(u.UID)] = store.KeptUser{Identity: u.Identity, UID: uint32(u.UID),
|
||||
Use: u.Use, From: u.From}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, r := range d.Resources {
|
||||
a, ok := r.(*declaration.Access)
|
||||
if !ok || held[a.ID] || strings.HasPrefix(a.ID, declaration.AdoptionPrefix) {
|
||||
continue
|
||||
}
|
||||
// An older controller's access is judged by nothing, as before ADR 0325: no state is said of it.
|
||||
if !apply.Keeps(d, a.Path) {
|
||||
continue
|
||||
}
|
||||
at := strings.LastIndex(a.ID, ".")
|
||||
if at <= 0 {
|
||||
continue
|
||||
}
|
||||
path := filepath.Clean(a.Path)
|
||||
ja := judgedAccess{module: a.ID[:at], id: a.ID, name: a.ID[at+1:], path: path, taken: taken[path]}
|
||||
for _, u := range users[path] {
|
||||
ja.users = append(ja.users, u)
|
||||
}
|
||||
sort.Slice(ja.users, func(x, y int) bool { return ja.users[x].UID < ja.users[y].UID })
|
||||
as = append(as, ja)
|
||||
}
|
||||
}
|
||||
sort.Slice(as, func(a, b int) bool { return as[a].id < as[b].id })
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
j.accesses = as
|
||||
declared := map[string]bool{}
|
||||
for _, a := range as {
|
||||
declared[a.id] = true
|
||||
}
|
||||
for id := range j.was {
|
||||
if !declared[id] {
|
||||
delete(j.was, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lookBound is how long a look waits for one accessed folder to answer. A hard network mount whose server is down
|
||||
// blocks a stat for ever, and one access must never stop every health statement this machine makes.
|
||||
var lookBound = 5 * time.Second
|
||||
|
||||
// verdict is one access's judgement in a look.
|
||||
type verdict struct {
|
||||
state, reason string
|
||||
waits []link.AccessWait
|
||||
act *link.AccessActRecord
|
||||
entries []link.KeptEntry
|
||||
}
|
||||
|
||||
// judgeBounded judges one access 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 folder, so a dead mount costs one blocked read.
|
||||
func (j *accessJudge) judgeBounded(a judgedAccess) (verdict, bool) {
|
||||
j.reading.Lock()
|
||||
if j.inFlight[a.path] {
|
||||
j.reading.Unlock()
|
||||
return verdict{}, false
|
||||
}
|
||||
j.inFlight[a.path] = true
|
||||
j.reading.Unlock()
|
||||
done := make(chan verdict, 1)
|
||||
go func() {
|
||||
v := judgeAccess(a)
|
||||
j.reading.Lock()
|
||||
delete(j.inFlight, a.path)
|
||||
j.reading.Unlock()
|
||||
done <- v
|
||||
}()
|
||||
select {
|
||||
case v := <-done:
|
||||
return v, true
|
||||
case <-time.After(lookBound):
|
||||
return verdict{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// judgeAccess is one access's judgement now (ADR 0325 §7, §10, §12), the sweep of its kept folder included.
|
||||
func judgeAccess(a judgedAccess) verdict {
|
||||
v := apply.AccessVerdict(context.Background(), a.path, a.taken, len(a.users) > 0)
|
||||
switch v.Kind {
|
||||
case apply.VerdictWaitKeep:
|
||||
return verdict{state: link.StateWaiting, reason: "it holds data the mesh has not taken on the filesystem " +
|
||||
"it is on now, and waits for the operator's word (novox/hq ADR 0325 §7, §8)",
|
||||
waits: []link.AccessWait{{Kind: link.WaitKindAccess, Access: a.name, Part: a.module,
|
||||
What: apply.KeepWords(a.name), Placed: v.Placed}}}
|
||||
case apply.VerdictWaitDisk:
|
||||
return verdict{state: link.StateWaiting, reason: "the disk that holds it is not there (novox/hq ADR 0325 §9)",
|
||||
waits: []link.AccessWait{{Kind: link.WaitKindAccess, Access: a.name, Part: a.module,
|
||||
What: apply.DiskWords(a.name)}}}
|
||||
case apply.VerdictRoot:
|
||||
return verdict{state: link.StateUnhealthy, reason: apply.RootWords(v.Why)}
|
||||
case apply.VerdictUnknown:
|
||||
// Not a fault and not the operator's: said as not known, and judged again on the next look.
|
||||
return verdict{state: link.StateUnknown, reason: v.Why + "; judged again on the next look"}
|
||||
case apply.VerdictBroken:
|
||||
// Its words carry no path (AccessVerdict says them so): the reason reaches the operator's channel.
|
||||
return verdict{state: link.StateUnhealthy, reason: v.Why}
|
||||
case apply.VerdictMake:
|
||||
return verdict{state: link.StateUnhealthy, reason: "it is not there yet; the next apply makes it where " +
|
||||
"its data lives (novox/hq ADR 0325 §9)"}
|
||||
case apply.VerdictNobody:
|
||||
return verdict{state: link.StateHealthy}
|
||||
}
|
||||
out := verdict{state: link.StateHealthy, entries: entriesOf(a.users)}
|
||||
rec, _ := keptNow(a.path)
|
||||
if act := rec.Act; act != nil {
|
||||
out.act = &link.AccessActRecord{Act: act.Act, Access: a.id, Warrant: act.Warrant, By: act.By, State: act.State,
|
||||
Started: act.Started, Ended: act.Ended, Folders: act.Folders, Files: act.Files, Kept: act.Kept,
|
||||
Links: act.Links, Other: act.Other, Mounts: act.Mounts, Why: act.Why}
|
||||
}
|
||||
if !rec.Kept {
|
||||
out.state, out.reason = link.StateStarting, "the controller says it was taken; the next apply keeps it"
|
||||
return out
|
||||
}
|
||||
look := apply.LookKept(a.path, a.users, apply.SweepLimits)
|
||||
var faults []string
|
||||
if look.Failed != "" {
|
||||
faults = append(faults, "the sweep could not look: "+apply.Pathless(errors.New(look.Failed)))
|
||||
}
|
||||
if len(look.Short) > 0 {
|
||||
faults = append(faults, "the folder itself gives "+strings.Join(look.Short, ", ")+"; put back on the next "+
|
||||
"apply")
|
||||
}
|
||||
if n := look.Sweep.Unrepaired + look.Sweep.LastUnrepaired; n > 0 {
|
||||
faults = append(faults, fmt.Sprintf("%d entries under it give a user less than its right and cannot be "+
|
||||
"put back: an owner's own bits, which the mesh never changes, or an entry the filesystem refused", n))
|
||||
}
|
||||
if len(faults) > 0 {
|
||||
out.state, out.reason = link.StateUnhealthy, strings.Join(faults, "; ")
|
||||
return out
|
||||
}
|
||||
switch {
|
||||
case rec.Act != nil && rec.Act.State == link.ActRunning:
|
||||
out.state, out.reason = link.StateStarting, "the take act is adding its users' entries under it"
|
||||
case !sameUsersList(rec.Users, rec.Want):
|
||||
out.state, out.reason = link.StateStarting, "the entries under it are being changed to its users now"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// entriesOf is a kept folder's listing (ADR 0325 §4): each user, and beside the tool runner's what it stands for.
|
||||
func entriesOf(users []store.KeptUser) []link.KeptEntry {
|
||||
var out []link.KeptEntry
|
||||
for _, u := range users {
|
||||
e := link.KeptEntry{Identity: u.Identity, UID: int(u.UID), Use: u.Use, From: u.From}
|
||||
if u.From == declaration.FromToolRunner {
|
||||
e.Note = "the tool runner's account stands for every module whose tools use this folder, at the widest " +
|
||||
"use any of them declares: a module that declared only read may write through it (novox/hq ADR " +
|
||||
"0325 §4, until issue 569)"
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sameUsersList(a, b []store.KeptUser) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
seen := map[uint32]string{}
|
||||
for _, u := range a {
|
||||
seen[u.UID] = u.Use
|
||||
}
|
||||
for _, u := range b {
|
||||
if use, ok := seen[u.UID]; !ok || use != u.Use {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// keptNow is the record of one kept folder, as the engine's file holds it.
|
||||
func keptNow(path string) (store.KeptFolder, bool) {
|
||||
all, err := apply.KeptFolders.All()
|
||||
if err != nil {
|
||||
return store.KeptFolder{}, false
|
||||
}
|
||||
f, had := all[path]
|
||||
return f, had
|
||||
}
|
||||
|
||||
// look reads every access now. Whether any verdict changed since the last look, for the loop to say it.
|
||||
func (j *accessJudge) look(now time.Time) ([]link.ResourceHealth, bool) {
|
||||
j.mu.Lock()
|
||||
as := append([]judgedAccess(nil), j.accesses...)
|
||||
j.mu.Unlock()
|
||||
|
||||
read := make([]verdict, len(as))
|
||||
for i, a := range as {
|
||||
v, answered := j.judgeBounded(a)
|
||||
if !answered {
|
||||
v = verdict{state: link.StateUnhealthy, reason: "its folder does not answer: judging it did not return " +
|
||||
"within " + lookBound.String()}
|
||||
}
|
||||
read[i] = v
|
||||
}
|
||||
|
||||
// And the entries nobody wants that could not be taken out yet, each under the access that last named its
|
||||
// folder, until its drop walk has run (ADR 0325 §8).
|
||||
for _, l := range apply.Leftovers() {
|
||||
as = append(as, judgedAccess{module: l.Module, id: l.Access + "~entries", name: l.Name, path: l.Path,
|
||||
leftover: true})
|
||||
read = append(read, verdict{state: link.StateUnhealthy, reason: l.Why})
|
||||
}
|
||||
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
changed := false
|
||||
out := make([]link.ResourceHealth, 0, len(as))
|
||||
for i, a := range as {
|
||||
r := read[i]
|
||||
kind := link.KindAccess
|
||||
if a.leftover {
|
||||
kind = link.KindKeptEntries
|
||||
}
|
||||
v := link.ResourceHealth{Module: a.module, Resource: a.id, Kind: kind, Target: a.path,
|
||||
State: r.state, Reason: r.reason, Since: now.UTC(), AccessWaits: r.waits, Act: r.act, Entries: r.entries}
|
||||
if was, had := j.was[a.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[a.id] = v
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, changed
|
||||
}
|
||||
|
||||
// withAccesses adds to a statement every access, as its module's resource of kind access.
|
||||
func withAccesses(h *link.Health, vs []link.ResourceHealth) *link.Health {
|
||||
h.Resources = append(h.Resources, vs...)
|
||||
return h
|
||||
}
|
||||
|
||||
// accessesHealthy says no access 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 runs the walks under kept folders beside the applies, one at a time (ADR 0325 §8): poked after every
|
||||
// apply and every look, it walks whatever folder's entries under it are not what was declared.
|
||||
type keeper struct {
|
||||
running, following atomic.Bool
|
||||
say func(string)
|
||||
watch *apply.Watcher
|
||||
}
|
||||
|
||||
// keeping is the one keeper on this machine.
|
||||
var keeping = &keeper{say: func(string) {}}
|
||||
|
||||
// poke starts a reconcile of the kept folders unless one is running, and has the watch follow what is kept.
|
||||
func (k *keeper) poke(ctx context.Context) {
|
||||
// The watch is set beside, never in the caller's way: watching a new folder's tree reads every folder of it.
|
||||
if k.following.CompareAndSwap(false, true) {
|
||||
go func() {
|
||||
defer k.following.Store(false)
|
||||
k.follow()
|
||||
}()
|
||||
}
|
||||
if !k.running.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer k.running.Store(false)
|
||||
for _, line := range apply.ReconcileKept(ctx) {
|
||||
k.say(line)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// follow has the watch watch every kept folder with its users.
|
||||
func (k *keeper) follow() {
|
||||
if k.watch == nil {
|
||||
return
|
||||
}
|
||||
all, err := apply.KeptFolders.All()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
folders := map[string][]store.KeptUser{}
|
||||
for path, f := range all {
|
||||
if f.Kept && len(f.Want) > 0 {
|
||||
folders[path] = f.Want
|
||||
}
|
||||
}
|
||||
k.watch.Set(folders)
|
||||
}
|
||||
|
||||
// startKeeping opens the watch and has the keeper say what it does. A machine without the watch is said so, and
|
||||
// its sweep alone finds what is made.
|
||||
func startKeeping(ctx context.Context, say func(string)) {
|
||||
keeping.say = say
|
||||
w, err := apply.NewWatcher()
|
||||
if err != nil {
|
||||
say(fmt.Sprintf("the kept folders are not watched (%v): the sweep alone finds what is made in them", err))
|
||||
} else {
|
||||
keeping.watch = w
|
||||
go w.Run(ctx)
|
||||
}
|
||||
keeping.poke(ctx)
|
||||
}
|
||||
|
||||
// accessActFrom is the take act as the controller asks it over the link (ADR 0325 §8): 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 access")
|
||||
}
|
||||
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.TakeFolder(ctx, d, act.Access, filepath.Clean(act.Path), act.Warrant, act.By,
|
||||
apply.ExecRunner)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Row 4: the listing of a kept folder says, beside the tool runner's entry, that it stands for every module whose
|
||||
// tools use the folder at the widest use (novox/hq ADR 0325 §4).
|
||||
func TestTheListingSaysWhatTheToolRunnersEntryStandsFor(t *testing.T) {
|
||||
entries := entriesOf([]store.KeptUser{
|
||||
{Identity: "mesh-radarr", UID: 61001, Use: "read-write", From: declaration.FromContainer},
|
||||
{Identity: "nox", UID: 61003, Use: "read-write", From: declaration.FromToolRunner},
|
||||
})
|
||||
if entries[0].Note != "" || !strings.Contains(entries[1].Note, "widest use") {
|
||||
t.Fatalf("the listing: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 7, the engine's part: a folder that holds data and was never taken is said waiting with one wait of kind
|
||||
// access — the access by its module's own id, the part its module, and words with no path (novox/hq ADR 0325 §7).
|
||||
func TestAnAccessTheMeshMayNotKeepYetIsSaidAsAWaitOfItsOwnKind(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
lib := filepath.Join(dir, "movies")
|
||||
if err := os.MkdirAll(filepath.Join(lib, "a film"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Run as root (the build seat's merge-check), the test's folders stand for an operator's, never root's.
|
||||
if os.Geteuid() == 0 {
|
||||
for _, p := range []string{dir, lib, filepath.Join(lib, "a film")} {
|
||||
if err := os.Lchown(p, 65534, 65534); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
v := judgeAccess(judgedAccess{module: "radarr", id: "radarr.movies", name: "movies", path: lib,
|
||||
users: []store.KeptUser{{Identity: "mesh-radarr", UID: 61001, Use: "read-write", From: "container"}}})
|
||||
if v.state != link.StateWaiting || len(v.waits) != 1 {
|
||||
t.Fatalf("not waiting with one wait: %+v", v)
|
||||
}
|
||||
raw, _ := json.Marshal(v.waits[0])
|
||||
want := `{"kind":"access","access":"movies","part":"radarr","what":"let the mesh keep the shared folder movies",` +
|
||||
`"placed":"` + v.waits[0].Placed + `"}`
|
||||
if string(raw) != want || !strings.HasPrefix(v.waits[0].Placed, "fs-") {
|
||||
t.Fatalf("the wait reads %s", raw)
|
||||
}
|
||||
if strings.Contains(v.reason, dir) {
|
||||
t.Fatalf("the reason names the path: %s", v.reason)
|
||||
}
|
||||
}
|
||||
|
||||
// An older controller's accesses (no users, no identities) are judged by nothing, as before ADR 0325: the statement
|
||||
// carries no resource for them, waiting, unknown or kept-entries.
|
||||
func TestAnOlderControllersAccessesSayNothing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
d, err := declaration.Parse([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"radarr.library","type":"access","path":"` + filepath.Join(dir, "library") + `","mode":"read-write"},
|
||||
{"id":"plex.movies","type":"access","path":"` + filepath.Join(dir, "absent") + `"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(dir, "library"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := newAccessJudge()
|
||||
j.set(d, nil)
|
||||
if vs, _ := j.look(time.Now()); len(vs) != 0 {
|
||||
t.Fatalf("an older controller's accesses are judged: %+v", vs)
|
||||
}
|
||||
}
|
||||
+874
-109
File diff suppressed because it is too large
Load Diff
@@ -189,13 +189,15 @@ func TestAReconcileSpeaksOnlyWhenWhatIsHeldChanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatTheLinkPublishedCountsAsSaid(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) {
|
||||
w := &adoptionWatch{}
|
||||
report := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "a"}}}
|
||||
applier := w.noting(func(context.Context, []byte, []byte) link.Report { return report })
|
||||
applier(context.Background(), nil, nil)
|
||||
heard := w.said
|
||||
heard(report)
|
||||
if w.changed(report) {
|
||||
t.Error("a reconcile repeated what the link had just published")
|
||||
t.Error("a reconcile repeated what the mesh had just heard")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -609,3 +611,63 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
@@ -13,9 +14,15 @@ 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": sequence,
|
||||
"declaration": 1, "resources": []any{}, "owns_nothing": true, "sequence": order.Sequence,
|
||||
"epoch": order.Epoch,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -24,7 +31,7 @@ func keptWith(t *testing.T, sequence int64) store.Declared {
|
||||
}
|
||||
|
||||
func TestAnOlderDeclarationFromTheMeshIsRefused(t *testing.T) {
|
||||
err := refuseOlder(keptWith(t, 7), nil, 5)
|
||||
err := refuseOlder(keptWith(t, 7), nil, link.Order{Sequence: 5})
|
||||
if err == nil {
|
||||
t.Fatal("sequence 5 was accepted over a kept 7")
|
||||
}
|
||||
@@ -34,11 +41,11 @@ func TestAnOlderDeclarationFromTheMeshIsRefused(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestANewerOrEqualDeclarationIsNot(t *testing.T) {
|
||||
if err := refuseOlder(keptWith(t, 7), nil, 8); err != nil {
|
||||
if err := refuseOlder(keptWith(t, 7), nil, link.Order{Sequence: 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, 7); err != nil {
|
||||
if err := refuseOlder(keptWith(t, 7), nil, link.Order{Sequence: 7}); err != nil {
|
||||
t.Fatalf("the same sequence was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -46,13 +53,43 @@ 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, 0); err != nil {
|
||||
if err := refuseOlder(keptWith(t, 7), nil, link.Order{Sequence: 0}); err != nil {
|
||||
t.Fatalf("a declaration claiming no order was refused: %v", err)
|
||||
}
|
||||
if err := refuseOlder(keptWith(t, 0), nil, 3); err != nil {
|
||||
if err := refuseOlder(keptWith(t, 0), nil, link.Order{Sequence: 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, 3); err != nil {
|
||||
if err := refuseOlder(store.Declared{}, store.ErrNothingDeclared, link.Order{Sequence: 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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 identities and keeps shared folders says IdentityContract
|
||||
// (novox/hq ADR 0325), one above ADR 0312's GrantContract.
|
||||
if h.Contract < link.RootContract || link.RootContract != 3 || h.Contract != link.IdentityContract ||
|
||||
link.IdentityContract != link.GrantContract+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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,7 @@
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"id": "broker",
|
||||
|
||||
@@ -3,13 +3,14 @@ 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
|
||||
)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
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=
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
// 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, ¬Judged) {
|
||||
reason += "; " + firstLine(err.Error())
|
||||
}
|
||||
var progress *SearchProgress
|
||||
if notJudged != nil {
|
||||
progress = notJudged.Progress
|
||||
}
|
||||
return Unhealthy, reason, progress
|
||||
case errors.As(err, ¬Judged):
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
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, ¬Judged) {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//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())
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//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 }
|
||||
@@ -0,0 +1,302 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
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)])
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
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, ¬Judged) {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package apply
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
@@ -32,28 +31,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package apply
|
||||
|
||||
// The POSIX ACL a kept folder carries, as the kernel stores it (novox/hq ADR 0325 §5, issue 567).
|
||||
//
|
||||
// **Read and written as the kernel's own extended attribute, never through setfacl.** Linux keeps a file's access
|
||||
// ACL in the attribute system.posix_acl_access and a folder's default ACL in system.posix_acl_default, each a
|
||||
// little-endian header (version 2) followed by entries of a tag, a permission and an id. The engine reads and
|
||||
// writes them on a descriptor it opened itself (fgetxattr, fsetxattr), so a name swapped for a link between the
|
||||
// look and the change changes nothing, and no program is run per file of a library of millions.
|
||||
//
|
||||
// **What the mesh changes is narrow.** It adds or sets one named-user entry per uid it keeps, widens the mask to
|
||||
// what the entries need, and on a file with no ACL yet writes the owning group's entry as exactly the group bits
|
||||
// the mode had: owner, owning group, the owner's bits, others' bits and every other entry stay as found.
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// The attributes and the tags of the kernel's ACL format (linux/posix_acl_xattr.h).
|
||||
const (
|
||||
aclAccessXattr = "system.posix_acl_access"
|
||||
aclDefaultXattr = "system.posix_acl_default"
|
||||
aclVersion = 2
|
||||
|
||||
aclUserObj = 0x01
|
||||
aclUser = 0x02
|
||||
aclGroupObj = 0x04
|
||||
aclGroup = 0x08
|
||||
aclMask = 0x10
|
||||
aclOther = 0x20
|
||||
|
||||
// aclUndefinedID is the id of an entry that names nobody: the owner, the owning group, the mask, others.
|
||||
aclUndefinedID = 0xFFFFFFFF
|
||||
)
|
||||
|
||||
// The permission bits of one entry.
|
||||
const (
|
||||
permRead = 4
|
||||
permWrite = 2
|
||||
permExec = 1
|
||||
)
|
||||
|
||||
// aclEntry is one entry: its tag, its permission bits (rwx as 4, 2, 1) and, for a named user or group, its id.
|
||||
type aclEntry struct {
|
||||
Tag uint16
|
||||
Perm uint16
|
||||
ID uint32
|
||||
}
|
||||
|
||||
// acl is a whole ACL as the kernel holds it.
|
||||
type acl []aclEntry
|
||||
|
||||
// decodeACL reads an attribute's bytes. An attribute that is not version 2, or not whole entries, is refused.
|
||||
func decodeACL(raw []byte) (acl, error) {
|
||||
if len(raw) < 4 || (len(raw)-4)%8 != 0 {
|
||||
return nil, fmt.Errorf("an ACL attribute of %d bytes is not a header and whole entries", len(raw))
|
||||
}
|
||||
if v := binary.LittleEndian.Uint32(raw); v != aclVersion {
|
||||
return nil, fmt.Errorf("an ACL attribute of version %d, and the engine reads version %d", v, aclVersion)
|
||||
}
|
||||
out := make(acl, 0, (len(raw)-4)/8)
|
||||
for at := 4; at < len(raw); at += 8 {
|
||||
out = append(out, aclEntry{
|
||||
Tag: binary.LittleEndian.Uint16(raw[at:]),
|
||||
Perm: binary.LittleEndian.Uint16(raw[at+2:]),
|
||||
ID: binary.LittleEndian.Uint32(raw[at+4:]),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// encode is the attribute's bytes, the entries in the order the kernel requires: by tag, then by id.
|
||||
func (a acl) encode() []byte {
|
||||
sorted := append(acl(nil), a...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
if sorted[i].Tag != sorted[j].Tag {
|
||||
return sorted[i].Tag < sorted[j].Tag
|
||||
}
|
||||
return sorted[i].ID < sorted[j].ID
|
||||
})
|
||||
out := make([]byte, 4, 4+8*len(sorted))
|
||||
binary.LittleEndian.PutUint32(out, aclVersion)
|
||||
for _, e := range sorted {
|
||||
var b [8]byte
|
||||
binary.LittleEndian.PutUint16(b[0:], e.Tag)
|
||||
binary.LittleEndian.PutUint16(b[2:], e.Perm)
|
||||
binary.LittleEndian.PutUint32(b[4:], e.ID)
|
||||
out = append(out, b[:]...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// minimalACL is the ACL a mode alone stands for: the owner's, the owning group's and others' bits.
|
||||
func minimalACL(mode uint32) acl {
|
||||
return acl{
|
||||
{Tag: aclUserObj, Perm: uint16(mode>>6) & 7, ID: aclUndefinedID},
|
||||
{Tag: aclGroupObj, Perm: uint16(mode>>3) & 7, ID: aclUndefinedID},
|
||||
{Tag: aclOther, Perm: uint16(mode) & 7, ID: aclUndefinedID},
|
||||
}
|
||||
}
|
||||
|
||||
// find is the index of the entry with tag and id, or -1.
|
||||
func (a acl) find(tag uint16, id uint32) int {
|
||||
for i, e := range a {
|
||||
if e.Tag == tag && (e.ID == id || (tag != aclUser && tag != aclGroup)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// perm is the bits of the entry with tag and id, and whether there is one.
|
||||
func (a acl) perm(tag uint16, id uint32) (uint16, bool) {
|
||||
if i := a.find(tag, id); i >= 0 {
|
||||
return a[i].Perm, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// check refuses an ACL the kernel would: one owner's, owning group's and others' entry each, and a mask whenever
|
||||
// a named entry is there.
|
||||
func (a acl) check() error {
|
||||
count := map[uint16]int{}
|
||||
named := false
|
||||
for _, e := range a {
|
||||
count[e.Tag]++
|
||||
if e.Tag == aclUser || e.Tag == aclGroup {
|
||||
named = true
|
||||
}
|
||||
}
|
||||
if count[aclUserObj] != 1 || count[aclGroupObj] != 1 || count[aclOther] != 1 || count[aclMask] > 1 {
|
||||
return errors.New("an ACL without exactly one owner's, owning group's and others' entry")
|
||||
}
|
||||
if named && count[aclMask] != 1 {
|
||||
return errors.New("an ACL with named entries and no mask")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// keptACL is a with the mesh's entries made: for each uid of need its entry set to exactly its bits, and the mask
|
||||
// widened by what those entries need — never narrowed, so an app's wider mask stays. A mask already there keeps
|
||||
// what it gave every other entry: one holding a bit the widening adds loses that bit alone; with none yet (a file the mode alone described) the mask starts at the owning
|
||||
// group's bits, which minimalACL gave its entry, so the owning group keeps exactly the right its group bits gave.
|
||||
// Nothing else is changed.
|
||||
func keptACL(a acl, need map[uint32]uint16) acl {
|
||||
out := append(acl(nil), a...)
|
||||
var widen uint16
|
||||
for uid, bits := range need {
|
||||
widen |= bits
|
||||
if i := out.find(aclUser, uid); i >= 0 {
|
||||
out[i].Perm = bits
|
||||
} else {
|
||||
out = append(out, aclEntry{Tag: aclUser, Perm: bits, ID: uid})
|
||||
}
|
||||
}
|
||||
if i := out.find(aclMask, 0); i >= 0 {
|
||||
old := out[i].Perm
|
||||
out[i].Perm |= widen
|
||||
if added := out[i].Perm &^ old; added != 0 {
|
||||
// **A wider mask widens nobody else.** The owning group's entry and every named entry the mesh does
|
||||
// not set here were limited by the old mask: a file made at 0644 under a default ACL carries the
|
||||
// owning group's rwx with a mask of r--. One mask serves them all, so an entry holding a bit the mask
|
||||
// now adds cannot keep its effective right without losing that bit: exactly those bits are taken off
|
||||
// it, and nothing else of it changes. An entry holding none of them is not touched at all.
|
||||
for j := range out {
|
||||
e := &out[j]
|
||||
_, ours := need[e.ID]
|
||||
if (e.Tag == aclGroupObj || e.Tag == aclGroup || (e.Tag == aclUser && !ours)) && e.Perm&added != 0 {
|
||||
e.Perm &^= added
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
if len(need) == 0 {
|
||||
return out
|
||||
}
|
||||
group, _ := out.perm(aclGroupObj, 0)
|
||||
return append(out, aclEntry{Tag: aclMask, Perm: group | widen, ID: aclUndefinedID})
|
||||
}
|
||||
|
||||
// withoutUsers is a with the named-user entry of each uid of drop taken out, and nothing else changed: the mask
|
||||
// stays as it was, so no other entry loses a right.
|
||||
func withoutUsers(a acl, drop map[uint32]bool) acl {
|
||||
out := make(acl, 0, len(a))
|
||||
for _, e := range a {
|
||||
if e.Tag == aclUser && drop[e.ID] {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// effective is the right a uid has through a, given the file's owner and mode: the owner's bits for the owner,
|
||||
// else its named entry limited by the mask, else nothing a named entry gives (the owning group's and others'
|
||||
// rights are not the mesh's to count on). An ACL with no mask limits nothing.
|
||||
func effective(a acl, uid, owner uint32, mode uint32) uint16 {
|
||||
if uid == owner {
|
||||
return uint16(mode>>6) & 7
|
||||
}
|
||||
bits, ok := a.perm(aclUser, uid)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
if mask, has := a.perm(aclMask, 0); has {
|
||||
bits &= mask
|
||||
}
|
||||
return bits
|
||||
}
|
||||
|
||||
// needOn is what a use needs on a folder (read and enter; with read-write, write too) or on a file (read; with
|
||||
// read-write, write): entering means nothing on a file, and an execute bit given to data would be a right nobody
|
||||
// asked for.
|
||||
func needOn(readWrite, folder bool) uint16 {
|
||||
bits := uint16(permRead)
|
||||
if readWrite {
|
||||
bits |= permWrite
|
||||
}
|
||||
if folder {
|
||||
bits |= permExec
|
||||
}
|
||||
return bits
|
||||
}
|
||||
|
||||
// permWords is bits as ls writes them: "rwx", "r-x".
|
||||
func permWords(bits uint16) string {
|
||||
out := []byte("---")
|
||||
if bits&permRead != 0 {
|
||||
out[0] = 'r'
|
||||
}
|
||||
if bits&permWrite != 0 {
|
||||
out[1] = 'w'
|
||||
}
|
||||
if bits&permExec != 0 {
|
||||
out[2] = 'x'
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+1391
-133
File diff suppressed because it is too large
Load Diff
@@ -313,6 +313,7 @@ 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
|
||||
@@ -337,6 +338,7 @@ 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":[
|
||||
@@ -371,6 +373,7 @@ 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
|
||||
@@ -413,6 +416,7 @@ 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
|
||||
@@ -464,6 +468,7 @@ 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":[
|
||||
@@ -486,6 +491,7 @@ 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":[
|
||||
@@ -506,6 +512,7 @@ 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
|
||||
}
|
||||
@@ -557,6 +564,7 @@ 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":[
|
||||
@@ -575,6 +583,7 @@ 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
|
||||
}
|
||||
@@ -602,6 +611,7 @@ 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
|
||||
}
|
||||
@@ -635,6 +645,7 @@ 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
|
||||
}
|
||||
@@ -661,6 +672,7 @@ 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")
|
||||
}
|
||||
@@ -687,6 +699,7 @@ 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
|
||||
@@ -710,6 +723,7 @@ 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
|
||||
@@ -748,6 +762,7 @@ 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
|
||||
@@ -786,6 +801,7 @@ 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
|
||||
@@ -831,6 +847,7 @@ 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
|
||||
@@ -886,6 +903,7 @@ 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
|
||||
@@ -1043,6 +1061,7 @@ 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")
|
||||
}
|
||||
@@ -1071,6 +1090,7 @@ 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")
|
||||
}
|
||||
@@ -1107,6 +1127,7 @@ 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":[
|
||||
@@ -1250,6 +1271,7 @@ 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 {
|
||||
@@ -1398,6 +1420,7 @@ 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")
|
||||
}
|
||||
@@ -1433,6 +1456,7 @@ 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")
|
||||
}
|
||||
@@ -1477,6 +1501,7 @@ 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,
|
||||
@@ -1565,6 +1590,12 @@ 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 {
|
||||
@@ -1621,6 +1652,7 @@ 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
|
||||
|
||||
+120
-13
@@ -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) (Outcome, error) {
|
||||
func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied, away *storeAway) (Outcome, error) {
|
||||
out := begin(r)
|
||||
out.Action = "unchanged"
|
||||
|
||||
body, err := fetch(ctx, r.Source)
|
||||
body, err := fetchMinding(ctx, r.Source, away)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -56,26 +56,28 @@ 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.
|
||||
if previous.Wrote == got {
|
||||
// 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 {
|
||||
if _, err := os.Stat(r.Path); err == nil {
|
||||
owned, err := ownedBy(r.Path, r.Owner)
|
||||
owned, err := ownedBy(ownershipProbe(r.Path, previous.Unpacked), 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(r.Path, 0o755); err != nil {
|
||||
return out, err
|
||||
}
|
||||
written, err := unpack(body, r.Path)
|
||||
unpacked, written, err := replaceWith(body, r.Path, r.Owner, oursFrom(previous, r.Path))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := ownAll(r.Path, r.Owner); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.unpacked = &unpacked
|
||||
out.Action = "updated"
|
||||
if previous.Wrote == "" {
|
||||
out.Action = "created"
|
||||
@@ -84,6 +86,111 @@ 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 {
|
||||
@@ -95,7 +202,7 @@ func fetch(ctx context.Context, source string) ([]byte, error) {
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s answered %s", source, response.Status)
|
||||
return nil, statusError{source: source, code: response.StatusCode, status: response.Status}
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, maxArchive+1))
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
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
|
||||
}
|
||||
@@ -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 := os.MkdirAll(filepath.Dir(real), 0o755); err != nil {
|
||||
if err := makeDirs(filepath.Dir(real), 0o755, r.Owner); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := writeAtomically(real, []byte(next), mode); err != nil {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,12 @@ 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 (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.
|
||||
// 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).
|
||||
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
|
||||
}
|
||||
@@ -62,14 +63,7 @@ func TestTheHostsOwnFormerArchiveIsLeftInPlaceNotFatal(t *testing.T) {
|
||||
t.Fatalf("the rest of the declaration was not applied: %+v", report.Outcomes)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// 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.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ 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
|
||||
@@ -122,8 +123,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[unit] != want {
|
||||
t.Errorf("%s: made %v, want %v", unit, made[unit], want)
|
||||
if made[unitKey("", "", unit)] != want {
|
||||
t.Errorf("%s: made %v, want %v", unit, made[unitKey("", "", unit)], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+62
-9
@@ -109,9 +109,30 @@ func lookBefore(ctx context.Context, sys system.System, d *declaration.Declarati
|
||||
}
|
||||
}
|
||||
case *declaration.Service:
|
||||
if res.Stateless() || known.Recorded(string(declaration.TypeService), res.Unit) {
|
||||
if res.Stateless() || recordedUnit(known, res) {
|
||||
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 —
|
||||
@@ -129,14 +150,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(path) {
|
||||
seen.is["unit:"+res.Unit] = true
|
||||
if path, err := from.ServiceUnitFile(ctx, run, res.Unit); err == nil && installedByHand(known, path) {
|
||||
seen.is[key] = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
boot, _ := sys.ServiceBoot(ctx, run, res.Unit)
|
||||
if state == "running" || boot == "enabled" {
|
||||
seen.is["unit:"+res.Unit] = true
|
||||
seen.is[key] = true
|
||||
}
|
||||
case *declaration.Container:
|
||||
if known.Recorded(string(declaration.TypeContainer), res.Name) {
|
||||
@@ -177,12 +198,26 @@ 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.
|
||||
func installedByHand(path string) bool {
|
||||
// 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 {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
return !strings.HasPrefix(filepath.Clean(path), "/usr/")
|
||||
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
|
||||
}
|
||||
|
||||
func present(path string) bool {
|
||||
@@ -374,7 +409,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:" + res.Unit)
|
||||
isFound = before.has("unit:" + unitKey(res.Scope, res.User, res.Unit))
|
||||
}
|
||||
}
|
||||
if !isFound {
|
||||
@@ -388,7 +423,11 @@ 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" {
|
||||
if which := restartedBy(svc.ReloadOn, changed); len(which) > 0 {
|
||||
// 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 state, err := sys.ServiceState(ctx, run, svc.Unit); err == nil && state == "running" {
|
||||
reloader, can := sys.(serviceReloader)
|
||||
if !can {
|
||||
@@ -711,6 +750,20 @@ 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:
|
||||
|
||||
@@ -107,7 +107,28 @@ 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)
|
||||
@@ -907,6 +928,8 @@ 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
|
||||
@@ -958,6 +981,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//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, uint32(mode.Perm())); 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 = os.FileMode(st.Mode&0o777), int(st.Uid), int(st.Gid)
|
||||
if err := unix.Fchmod(fd, uint32(mode.Perm())); 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
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//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"}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
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 0325 §3, §11, 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) 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 {
|
||||
if err := rootIdentity(id.UID, id.GID); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("the identity %s: %v", id.Name, err))
|
||||
}
|
||||
}
|
||||
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, or refuses it naming whose the name or the numbers are. What it
|
||||
// made is recorded on known; an identity already right is unchanged.
|
||||
func applyIdentity(ctx context.Context, tool accountsTool, id declaration.Identity, 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.GID))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
const never = "; nothing was made, and nothing of the module was attempted (novox/hq ADR 0325 §3)"
|
||||
switch {
|
||||
case hasUser && (user.id != id.UID || user.prim != id.GID):
|
||||
return out, fmt.Errorf("the account %s is here with uid %d and group %d, and the mesh allocated uid %d, "+
|
||||
"gid %d%s", id.Name, user.id, user.prim, id.UID, id.GID, never)
|
||||
case hasGroup && group.id != id.GID:
|
||||
return out, fmt.Errorf("the group %s is here with gid %d, and the mesh allocated gid %d%s", id.Name,
|
||||
group.id, id.GID, 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.GID,
|
||||
byGID.name, id.Name, never)
|
||||
}
|
||||
rec := madeIdentity(known, id.Name)
|
||||
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.GID); err != nil {
|
||||
return out, err
|
||||
}
|
||||
rec.MadeGroup = true
|
||||
made = append(made, fmt.Sprintf("the group (gid %d)", id.GID))
|
||||
}
|
||||
if !hasUser {
|
||||
if err := tool.CreateSystemAccount(ctx, run, id.Name, id.UID, id.GID); err != nil {
|
||||
recordIdentity(known, rec)
|
||||
return out, err
|
||||
}
|
||||
rec.MadeUser = true
|
||||
made = append(made, fmt.Sprintf("the account (uid %d), with no login, no home and no password", id.UID))
|
||||
}
|
||||
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 {
|
||||
out.Detail = "here with its numbers"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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)
|
||||
ran []string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return n + ":x:" + strconv.Itoa(ids[0]) + ":" + strconv.Itoa(ids[1]) + "::/:/usr/bin/nologin\n", nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("getent exited 2")
|
||||
case "groupadd":
|
||||
m.groups[args[len(args)-1]] = [2]int{mustAtoi(args[2]), 0}
|
||||
case "useradd":
|
||||
m.users[args[len(args)-1]] = [2]int{mustAtoi(args[2]), mustAtoi(args[4])}
|
||||
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 mustAtoi(s string) int {
|
||||
n, _ := strconv.Atoi(s)
|
||||
return n
|
||||
}
|
||||
|
||||
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 / --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{
|
||||
withIdentities(t, `{"module":"radarr","name":"mesh-radarr","uid":61001,"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)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ 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)
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ func applyInto(r *declaration.File, previous store.Applied) (Outcome, error) {
|
||||
mode = m
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(r.Path), 0o755); err != nil {
|
||||
if err := makeDirs(filepath.Dir(r.Path), 0o755, r.Owner); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := writeAtomically(r.Path, want, mode); err != nil {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,953 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"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 shared folder is kept usable through access entries (novox/hq ADR 0325, issue 567).
|
||||
//
|
||||
// Several modules use one folder of the operator's — a library one imports into, one renames in, one plays from —
|
||||
// each as its own identity. The engine keeps, for every placed folder, one ACL entry per identity that uses it,
|
||||
// on the folder and as its default for everything made under it, and changes nothing else of the path (§5).
|
||||
//
|
||||
// **When it may keep a folder.** Only one that is the mesh's to keep: taken on the operator's warrant (§8), or
|
||||
// holding no data yet — empty when first seen, or made by the engine (§9). A folder that holds data and was never
|
||||
// taken is stated waiting, with a wait of kind access, until the operator lets the mesh keep it (§7); one whose disk
|
||||
// is not there waits for the disk (§9); root's data on its placed filesystem is never kept, and is unhealthy (§12).
|
||||
//
|
||||
// **What every apply does.** It judges the folder (assessFolder), makes a missing one where its data lives, and
|
||||
// sets the users' entries on the folder itself. What lies under a taken folder is the walks' (ReconcileKept): a
|
||||
// user added has its entries added to everything under it, a user dropped has exactly its own removed, outside
|
||||
// the apply and bounded. The watch and the sweep keep what is made later usable (§6, §10).
|
||||
|
||||
// keptRecords is where the engine keeps what it knows of each kept or seen folder: the file beside the state in
|
||||
// the engine, memory in a test.
|
||||
type keptRecords interface {
|
||||
All() (map[string]store.KeptFolder, error)
|
||||
Update(path string, change func(f *store.KeptFolder, had bool) bool) error
|
||||
}
|
||||
|
||||
// KeptFolders is the engine's record of the folders it keeps. The engine sets the file beside its state.
|
||||
var KeptFolders keptRecords = NewKeptMemory()
|
||||
|
||||
// keptMemory is a keptRecords held in memory.
|
||||
type keptMemory struct {
|
||||
mu sync.Mutex
|
||||
m map[string]store.KeptFolder
|
||||
}
|
||||
|
||||
// NewKeptMemory is an empty record held in memory: a test's, or a one-shot apply's.
|
||||
func NewKeptMemory() keptRecords { return &keptMemory{m: map[string]store.KeptFolder{}} }
|
||||
|
||||
func (k *keptMemory) All() (map[string]store.KeptFolder, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
out := map[string]store.KeptFolder{}
|
||||
for p, f := range k.m {
|
||||
out[p] = f
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k *keptMemory) Update(path string, change func(f *store.KeptFolder, had bool) bool) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
f, had := k.m[path]
|
||||
f.Path = path
|
||||
if change(&f, had) {
|
||||
k.m[path] = f
|
||||
} else {
|
||||
delete(k.m, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// keptRecord is the record of path, and whether there is one. A record that cannot be read is none: the folder
|
||||
// is judged afresh, which waits rather than keeps.
|
||||
func keptRecord(path string) (store.KeptFolder, bool) {
|
||||
all, err := KeptFolders.All()
|
||||
if err != nil {
|
||||
return store.KeptFolder{}, false
|
||||
}
|
||||
f, had := all[path]
|
||||
return f, had
|
||||
}
|
||||
|
||||
// folderWant is what every access naming one folder asks of it: the accesses, each uid at the widest use any of
|
||||
// them gives it (ADR 0325 §4: the tool runner's account stands for every module whose tools use it), and whether
|
||||
// the controller says it was taken.
|
||||
type folderWant struct {
|
||||
path string
|
||||
ids []string
|
||||
users map[uint32]store.KeptUser
|
||||
taken bool
|
||||
}
|
||||
|
||||
// foldersOf is every declared access's folder, by its clean path. A folder the guard refuses is not read (issue
|
||||
// 339).
|
||||
func foldersOf(resources []declaration.Resource, refused map[string]error) map[string]*folderWant {
|
||||
out := map[string]*folderWant{}
|
||||
for _, r := range resources {
|
||||
a, ok := r.(*declaration.Access)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
path := filepath.Clean(a.Path)
|
||||
if refused[path] != nil {
|
||||
continue
|
||||
}
|
||||
f := out[path]
|
||||
if f == nil {
|
||||
f = &folderWant{path: path, users: map[uint32]store.KeptUser{}}
|
||||
out[path] = f
|
||||
}
|
||||
f.ids = append(f.ids, a.ID)
|
||||
f.taken = f.taken || a.Taken
|
||||
for _, u := range a.Users {
|
||||
had, seen := f.users[uint32(u.UID)]
|
||||
if seen && (had.Use == declaration.AccessReadWrite || u.Use != declaration.AccessReadWrite) {
|
||||
continue
|
||||
}
|
||||
f.users[uint32(u.UID)] = store.KeptUser{Identity: u.Identity, UID: uint32(u.UID), Use: u.Use, From: u.From}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// list is the users, by uid.
|
||||
func (f *folderWant) list() []store.KeptUser {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]store.KeptUser, 0, len(f.users))
|
||||
for _, u := range f.users {
|
||||
out = append(out, u)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].UID < out[j].UID })
|
||||
return out
|
||||
}
|
||||
|
||||
// usersOf is a list of users as each uid and whether it writes.
|
||||
func usersOf(us []store.KeptUser) map[uint32]bool {
|
||||
out := map[uint32]bool{}
|
||||
for _, u := range us {
|
||||
out[u.UID] = out[u.UID] || u.Use == declaration.AccessReadWrite
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameUsers is whether two lists give the same uids the same use.
|
||||
func sameUsers(a, b []store.KeptUser) bool {
|
||||
x, y := usersOf(a), usersOf(b)
|
||||
if len(x) != len(y) {
|
||||
return false
|
||||
}
|
||||
for uid, rw := range x {
|
||||
if w, ok := y[uid]; !ok || w != rw {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// accessName is the module and its own id for an access, from the resource id `<module>.<id>`.
|
||||
func accessName(id string) (module, name string) {
|
||||
if m, ok := moduleOf(id); ok {
|
||||
return m, id[len(m)+1:]
|
||||
}
|
||||
return "", id
|
||||
}
|
||||
|
||||
// KeepWords and DiskWords are what an access wait says the operator gives (ADR 0325 §7): never a path.
|
||||
func KeepWords(name string) string { return "let the mesh keep the shared folder " + name }
|
||||
func DiskWords(name string) string { return "mount the disk that holds " + name }
|
||||
|
||||
// The judgements of a folder.
|
||||
const (
|
||||
// folderKeep is a folder the mesh keeps: taken, or holding no data yet.
|
||||
folderKeep = "keep"
|
||||
// folderMake is a folder absent under a parent on its placed filesystem, not root's: made, then kept.
|
||||
folderMake = "make"
|
||||
// folderWaitKeep is a folder that holds data and was never taken: it waits for the operator's word.
|
||||
folderWaitKeep = "wait-keep"
|
||||
// folderWaitDisk is a folder whose disk is not there.
|
||||
folderWaitDisk = "wait-disk"
|
||||
// folderRoot is root's data on its placed filesystem: never kept.
|
||||
folderRoot = "root"
|
||||
// folderUnknown is a folder whose filesystem could not be named: left as it is until it is.
|
||||
folderUnknown = "unknown"
|
||||
)
|
||||
|
||||
// folderVerdict is the judgement of one folder, with what it read.
|
||||
type folderVerdict struct {
|
||||
kind string
|
||||
// how is why a folder kept is the mesh's: store.KeptByWarrant, store.KeptEmpty or store.KeptMade.
|
||||
how string
|
||||
// mount is the mount point that holds what is there.
|
||||
mount string
|
||||
// why is a root verdict's or a broken folder's reason; err a folder that is not one (a file, a link).
|
||||
why string
|
||||
err error
|
||||
// moved is that the folder's recorded mount is mounted and another holds it: it is judged afresh.
|
||||
moved bool
|
||||
// unnamed is a folder whose filesystem cannot be named: never kept, nor taken.
|
||||
unnamed bool
|
||||
}
|
||||
|
||||
// assessFolder judges a folder as an apply, a look and every walk do (ADR 0325 §7, §9, §12): its disk first, then
|
||||
// whose it is, then whether the mesh may keep it. Its words carry no path: they reach the operator's channel.
|
||||
func assessFolder(ctx context.Context, path string, rec store.KeptFolder, taken bool) folderVerdict {
|
||||
others := recordedMounts(path)
|
||||
info, err := lstatWithin(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
m, err := NearestAncestor(path)
|
||||
if err != nil {
|
||||
return folderVerdict{err: err}
|
||||
}
|
||||
if !m.Info.IsDir() {
|
||||
return folderVerdict{err: errors.New("the folder is not there, and its nearest existing parent is not " +
|
||||
"a folder (a file or a link); nothing is made under it")}
|
||||
}
|
||||
pl := placedOn(ctx, path, m.Ancestor, rec.Mount, others)
|
||||
if pl.Away {
|
||||
return folderVerdict{kind: folderWaitDisk, mount: pl.Mount}
|
||||
}
|
||||
uid, gid, ok := statOwner(m.Info)
|
||||
if !ok {
|
||||
return folderVerdict{err: errors.New("the owner of the folder's nearest existing parent cannot be read " +
|
||||
"on this machine")}
|
||||
}
|
||||
if why := rootOwned(uint32(uid), uint32(gid)); why != "" {
|
||||
return folderVerdict{kind: folderRoot, mount: pl.Mount, why: "it is not there, and its nearest " +
|
||||
"existing parent is " + why}
|
||||
}
|
||||
return folderVerdict{kind: folderMake, mount: pl.Mount, how: store.KeptMade, moved: pl.Moved}
|
||||
}
|
||||
if err != nil {
|
||||
return folderVerdict{err: errors.New("the folder cannot be read: " + pathless(err))}
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return folderVerdict{err: errors.New("the folder is not a directory, and an access is a shared directory")}
|
||||
}
|
||||
pl := placedOn(ctx, path, path, rec.Mount, others)
|
||||
if pl.Away {
|
||||
return folderVerdict{kind: folderWaitDisk, mount: pl.Mount}
|
||||
}
|
||||
// Another filesystem at the same mount point (another pool, another disk) is a move too. A record from an earlier
|
||||
// build names its filesystem another way, and is not compared: the next keep names it this way.
|
||||
here := PlacedID(pl.Mount)
|
||||
// **A filesystem that cannot be named now is unknown, never a move** (a name read that did not answer, a
|
||||
// filesystem with no UUID, id or source): the folder 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 folderVerdict{kind: folderUnknown, mount: pl.Mount, unnamed: true,
|
||||
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)
|
||||
facts, err := readFolder(path)
|
||||
if err != nil {
|
||||
return folderVerdict{err: errors.New("the folder cannot be read: " + pathless(err))}
|
||||
}
|
||||
if why := rootOwned(facts.UID, facts.GID); why != "" {
|
||||
return folderVerdict{kind: folderRoot, mount: pl.Mount, why: "it is " + why}
|
||||
}
|
||||
// The controller's word that the folder was taken holds for the filesystem the warrant was carried out on: a
|
||||
// folder moved to another is not taken, and asks again (§8).
|
||||
takenHere := taken && (rec.TakenOn == here || (rec.TakenOn == "" && !moved))
|
||||
switch {
|
||||
case rec.Kept && !moved:
|
||||
return folderVerdict{kind: folderKeep, mount: pl.Mount, how: rec.How}
|
||||
case takenHere:
|
||||
return folderVerdict{kind: folderKeep, mount: pl.Mount, how: store.KeptByWarrant, moved: moved}
|
||||
case facts.Empty:
|
||||
return folderVerdict{kind: folderKeep, mount: pl.Mount, how: store.KeptEmpty, moved: moved}
|
||||
}
|
||||
// What is there now holds data the mesh never took — a disk mounted over a folder kept while it was empty
|
||||
// below it included (a move): it waits for the operator's word.
|
||||
return folderVerdict{kind: folderWaitKeep, mount: pl.Mount, moved: moved}
|
||||
}
|
||||
|
||||
// movedRecord is f judged afresh on the filesystem now at mount: the warrant's filesystem is kept so the
|
||||
// controller's word is not read for the new one, and nothing else of what was kept there holds. What the mesh
|
||||
// added on the old filesystem is left to be taken out once it holds the folder again (Left) only where it was
|
||||
// walked onto data under a warrant: a folder kept because it was empty, or made, carries entries on that empty
|
||||
// folder alone, hidden under the mount point and granting nothing on data, and they are forgotten (said once, by
|
||||
// the apply that saw the move).
|
||||
func movedRecord(f store.KeptFolder, mount string) store.KeptFolder {
|
||||
left := f.Left
|
||||
if f.How == store.KeptByWarrant && len(f.Users) > 0 && f.Mount != "" {
|
||||
left = addLeft(left, f.Mount, f.Placed, f.Fs, f.Users)
|
||||
}
|
||||
placed, fs := placeOf(mount)
|
||||
return store.KeptFolder{Path: f.Path, Mount: mount, Placed: placed, Fs: fs, Want: f.Want, IDs: f.IDs,
|
||||
Act: f.Act, TakenOn: f.TakenOn, Left: left}
|
||||
}
|
||||
|
||||
// placeOf is the name of the filesystem at mount, and what it was made from.
|
||||
func placeOf(mount string) (placed, fs string) {
|
||||
fs = filesystemKey(mount)
|
||||
return placedFrom(mount, fs), fs
|
||||
}
|
||||
|
||||
// addLeft adds users' entries left on mount, once per mount and uid.
|
||||
func addLeft(left []store.KeptLeft, mount, placed, fs string, users []store.KeptUser) []store.KeptLeft {
|
||||
for i := range left {
|
||||
if left[i].Mount == mount && left[i].Placed == placed {
|
||||
have := usersOf(left[i].Users)
|
||||
for _, u := range users {
|
||||
if _, had := have[u.UID]; !had {
|
||||
left[i].Users = append(left[i].Users, u)
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
}
|
||||
return append(left, store.KeptLeft{Mount: mount, Placed: placed, Fs: fs, Since: time.Now().UTC(),
|
||||
Users: append([]store.KeptUser(nil), users...)})
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// recordedMounts is every mount point recorded for another kept folder: where a disk was once seen.
|
||||
func recordedMounts(except string) []string {
|
||||
all, err := KeptFolders.All()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for p, f := range all {
|
||||
if p != except && f.Mount != "" && f.Mount != "/" {
|
||||
out = append(out, f.Mount)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RootWords is the reason root's data is stated unhealthy with (ADR 0325 §12): never a path.
|
||||
func RootWords(why string) string {
|
||||
return why + ", and root's data is never kept: the operator places the data elsewhere or gives it an owner " +
|
||||
"(novox/hq ADR 0325 §12)"
|
||||
}
|
||||
|
||||
// applyAccess is one access, applied (ADR 0325): its folder judged, made when it is missing where its data lives,
|
||||
// and its users' entries kept on it when the mesh may keep it; waiting otherwise, and failing for root's data.
|
||||
// Nothing is changed on the folder but the entries the mesh adds and the mask that shows them.
|
||||
func applyAccess(ctx context.Context, sys system.System, r *declaration.Access, run Runner, previous store.Applied,
|
||||
in inputs) (Outcome, error) {
|
||||
// What ADR 0312's grant put an account in is taken back first: no account is in a group for data (§5).
|
||||
gave, left, said := takeBackGranted(ctx, sys, previous, run, in.groups, in.handed)
|
||||
out, err := applyAccessFolder(ctx, r, in)
|
||||
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
|
||||
}
|
||||
|
||||
// applyAccessFolder is applyAccess's folder.
|
||||
func applyAccessFolder(ctx context.Context, r *declaration.Access, in inputs) (Outcome, error) {
|
||||
out := begin(r)
|
||||
path := filepath.Clean(r.Path)
|
||||
want := in.folders[path]
|
||||
if !keeps(want, in.identities) {
|
||||
return legacyAccess(r)
|
||||
}
|
||||
users := want.list()
|
||||
_, name := accessName(r.ID)
|
||||
rec, _ := keptRecord(path)
|
||||
v := assessFolder(ctx, path, rec, want != nil && want.taken)
|
||||
if v.err != nil {
|
||||
return out, v.err
|
||||
}
|
||||
if v.moved {
|
||||
// What is at the path now is not what was kept (a disk mounted over a folder kept while it was empty
|
||||
// below it): judged afresh, the record of what was kept there no longer holds.
|
||||
_ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool {
|
||||
*f = movedRecord(*f, v.mount)
|
||||
return had
|
||||
})
|
||||
}
|
||||
note := ""
|
||||
if v.moved {
|
||||
note = "; what is at it now is on another filesystem than what the mesh kept there, so it was judged afresh"
|
||||
if rec.How != store.KeptByWarrant {
|
||||
note += "; the entries on the empty folder kept below are forgotten: hidden under the mount, they grant " +
|
||||
"nothing on data"
|
||||
}
|
||||
}
|
||||
if len(r.Grant) > 0 {
|
||||
note += "; its grant is no longer acted on: the mesh puts no account in a group for data (novox/hq ADR 0325 §5)"
|
||||
}
|
||||
switch v.kind {
|
||||
case folderUnknown:
|
||||
out.Action = "waiting"
|
||||
out.Detail = v.why + ": the folder is left exactly as it is, and judged again on the next apply" + note
|
||||
return out, nil
|
||||
case folderWaitDisk:
|
||||
out.Action = "waiting"
|
||||
out.Detail = "the disk that holds it is not there: nothing is made, and nothing mounts it until it is " +
|
||||
"(novox/hq ADR 0325 §9); waits for the operator to " + DiskWords(name) + note
|
||||
return out, nil
|
||||
case folderRoot:
|
||||
if len(users) == 0 {
|
||||
out.Action, out.Detail = "unchanged", "present or not, nothing is kept: no identity uses it"+note
|
||||
return out, nil
|
||||
}
|
||||
return out, errors.New(RootWords(v.why) + "; nothing was made or changed")
|
||||
case folderMake:
|
||||
made, err := CreatePath(path)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Action = "created"
|
||||
out.Detail = fmt.Sprintf("made %d level(s) as its nearest existing parent is: owner, group and mode "+
|
||||
"(novox/hq ADR 0325 §9)", made)
|
||||
if len(users) == 0 {
|
||||
out.Detail += note
|
||||
return out, nil
|
||||
}
|
||||
case folderWaitKeep:
|
||||
if len(users) == 0 {
|
||||
out.Action, out.Detail = "unchanged", "present; nothing is kept: no identity uses it"+note
|
||||
return out, nil
|
||||
}
|
||||
_ = KeptFolders.Update(path, func(f *store.KeptFolder, _ bool) bool {
|
||||
f.Mount, f.Want, f.IDs = v.mount, users, want.ids
|
||||
f.Placed, f.Fs = placeOf(v.mount)
|
||||
return true
|
||||
})
|
||||
out.Action = "waiting"
|
||||
out.Detail = "it holds data the mesh has not taken: nothing is changed on it until the operator's warrant " +
|
||||
"(novox/hq ADR 0325 §7, §8); waits for the operator to " + KeepWords(name) + note
|
||||
return out, nil
|
||||
}
|
||||
if len(users) == 0 {
|
||||
// Nobody uses it any more: what the mesh added goes on the next walk.
|
||||
dropping := false
|
||||
_ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool {
|
||||
f.Want = nil
|
||||
dropping = len(f.Users) > 0
|
||||
return had
|
||||
})
|
||||
if out.Action == "" {
|
||||
out.Action, out.Detail = "unchanged", "present; nothing is kept: no identity uses it"
|
||||
}
|
||||
if dropping {
|
||||
out.Detail += "; the entries the mesh added are taken out by the next walk"
|
||||
}
|
||||
out.Detail += note
|
||||
return out, nil
|
||||
}
|
||||
changed, err := keepTop(path, usersOf(users))
|
||||
if errors.Is(err, ErrNoACL) {
|
||||
_ = KeptFolders.Update(path, func(f *store.KeptFolder, _ bool) bool {
|
||||
f.Mount, f.Want = v.mount, users
|
||||
f.Placed, f.Fs = placeOf(v.mount)
|
||||
return true
|
||||
})
|
||||
out.Action = "waiting"
|
||||
out.Detail = "its filesystem allows no access entries here: the take act lets it, on the operator's word " +
|
||||
"(novox/hq ADR 0325 §8); waits for the operator to " + KeepWords(name) + note
|
||||
return out, nil
|
||||
}
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
var pending string
|
||||
_ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool {
|
||||
first := !f.Kept
|
||||
f.Kept, f.Mount, f.Want, f.IDs = true, v.mount, users, want.ids
|
||||
f.Placed, f.Fs = placeOf(v.mount)
|
||||
if v.how == store.KeptByWarrant && f.TakenOn == "" {
|
||||
// The controller says it was taken, and this engine has no record of where: here, now.
|
||||
f.TakenOn = PlacedID(v.mount)
|
||||
}
|
||||
if first {
|
||||
f.How, f.At = v.how, time.Now().UTC()
|
||||
// A folder that held nothing has nothing under it to walk; one the controller says was taken
|
||||
// without this engine's record is walked whole.
|
||||
if v.how == store.KeptEmpty || v.how == store.KeptMade {
|
||||
f.Users = users
|
||||
}
|
||||
}
|
||||
if !sameUsers(f.Users, f.Want) {
|
||||
pending = "; the entries under it follow on the next walk"
|
||||
}
|
||||
return true
|
||||
})
|
||||
if out.Action == "" {
|
||||
out.Action = "unchanged"
|
||||
if changed {
|
||||
out.Action = "updated"
|
||||
}
|
||||
}
|
||||
out.Detail = joinDetail(out.Detail, "kept for "+usersWords(users)+": its own entries and default, nothing "+
|
||||
"else of it changed (novox/hq ADR 0325 §5)"+pending+note)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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, "; ")
|
||||
}
|
||||
|
||||
// usersWords names users for a person: "mesh-radarr (read-write), the tool runner's nox (read)".
|
||||
func usersWords(users []store.KeptUser) string {
|
||||
words := make([]string, 0, len(users))
|
||||
for _, u := range users {
|
||||
who := u.Identity
|
||||
if u.From == declaration.FromToolRunner {
|
||||
who = "the tool runner's " + who
|
||||
}
|
||||
words = append(words, who+" ("+u.Use+")")
|
||||
}
|
||||
return strings.Join(words, ", ")
|
||||
}
|
||||
|
||||
// forgetUndeclared marks every recorded folder no declared access names any more: nobody wants its entries, and
|
||||
// the next walk takes out exactly what the mesh added, then forgets it.
|
||||
func forgetUndeclared(folders map[string]*folderWant) {
|
||||
all, err := KeptFolders.All()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for path := range all {
|
||||
if folders[path] != nil {
|
||||
continue
|
||||
}
|
||||
_ = KeptFolders.Update(path, func(f *store.KeptFolder, _ bool) bool {
|
||||
f.Want = nil
|
||||
// A folder never kept, or kept with nobody's entries left anywhere, is forgotten now.
|
||||
return (f.Kept && len(f.Users) > 0) || len(f.Left) > 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MissingPath is an accessed folder that is not there, and its nearest existing ancestor.
|
||||
type MissingPath struct {
|
||||
Ancestor string
|
||||
// Levels are the names to make below Ancestor, outermost first.
|
||||
Levels []string
|
||||
// Info is the ancestor as lstat reads it: a link is not a directory.
|
||||
Info os.FileInfo
|
||||
}
|
||||
|
||||
// NearestAncestor reads, for a path that is not there, its nearest existing ancestor by lstat. An error for a
|
||||
// path that is there, one not stated exactly, or an ancestor that cannot be read.
|
||||
func NearestAncestor(path string) (MissingPath, error) {
|
||||
if err := keptPath(path); err != nil {
|
||||
return MissingPath{}, err
|
||||
}
|
||||
if _, err := lstatWithin(path); err == nil || !errors.Is(err, os.ErrNotExist) {
|
||||
return MissingPath{}, errors.New("the folder is there, or cannot be read")
|
||||
}
|
||||
var levels []string
|
||||
for p := path; p != "/"; p = filepath.Dir(p) {
|
||||
levels = append([]string{filepath.Base(p)}, levels...)
|
||||
parent := filepath.Dir(p)
|
||||
info, err := lstatWithin(parent)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return MissingPath{}, errors.New("a parent of the folder cannot be read: " + pathless(err))
|
||||
}
|
||||
return MissingPath{Ancestor: parent, Levels: levels, Info: info}, nil
|
||||
}
|
||||
return MissingPath{}, errors.New("no part of the folder's path is there")
|
||||
}
|
||||
|
||||
// accessPaths is every declared access's folder with its id: what no container mounts while it is not there.
|
||||
//
|
||||
// Only accesses the mesh keeps (Keeps): an older controller's access holds no container, as before ADR 0325.
|
||||
func accessPaths(resources []declaration.Resource, refused map[string]error, identities bool) map[string]string {
|
||||
folders := foldersOf(resources, refused)
|
||||
out := map[string]string{}
|
||||
for _, r := range resources {
|
||||
a, ok := r.(*declaration.Access)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if path := filepath.Clean(a.Path); refused[path] == nil && keeps(folders[path], identities) {
|
||||
out[path] = a.ID
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// keeps is whether ADR 0325's keeping applies to a folder: some access naming it carries users, or the
|
||||
// declaration carries identities — a controller that speaks link.IdentityContract. An older controller's access
|
||||
// (no users, no identities) is applied exactly as before: mounted when present, refused when absent, nothing kept,
|
||||
// nothing made, nothing judged (ADR 0051 §7).
|
||||
func keeps(f *folderWant, identities bool) bool {
|
||||
return identities || (f != nil && len(f.users) > 0)
|
||||
}
|
||||
|
||||
// Keeps is keeps for a whole declaration's access at path, for the engine's looks.
|
||||
func Keeps(d *declaration.Declaration, path string) bool {
|
||||
if d == nil {
|
||||
return false
|
||||
}
|
||||
return keeps(foldersOf(d.Resources, nil)[filepath.Clean(path)], len(d.Identities) > 0)
|
||||
}
|
||||
|
||||
// mountsMissing is the access a container waits for: one whose folder it mounts, or mounts below, is not there
|
||||
// now — its disk away, or not made yet. The runtime would make it as root (04-ISSUES/026).
|
||||
func mountsMissing(ctx context.Context, c *declaration.Container, accesses map[string]string) string {
|
||||
for _, v := range c.Volumes {
|
||||
src := mountSource(v)
|
||||
if !strings.HasPrefix(src, "/") {
|
||||
continue
|
||||
}
|
||||
for path, id := range accesses {
|
||||
if !atOrBelow(filepath.Clean(src), path) {
|
||||
continue
|
||||
}
|
||||
if _, err := statWithin(path); errors.Is(err, os.ErrNotExist) || errors.Is(err, errNoAnswer) {
|
||||
return id
|
||||
}
|
||||
// There, and its disk not: what is at the path is the empty mount point on the filesystem below, and
|
||||
// a container's writes would land there (§9).
|
||||
rec, _ := keptRecord(path)
|
||||
if placedOn(ctx, path, path, rec.Mount, recordedMounts(path)).Away {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WaitingOnAccess is every container of the declaration that waits for an accessed folder not there, read now:
|
||||
// not created by the apply, so not judged alive or dead — the access says why.
|
||||
func WaitingOnAccess(d *declaration.Declaration) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if d == nil {
|
||||
return out
|
||||
}
|
||||
accesses := accessPaths(d.Resources, refusedPlaces(d.Resources), len(d.Identities) > 0)
|
||||
if len(accesses) == 0 {
|
||||
return out
|
||||
}
|
||||
for _, r := range d.Resources {
|
||||
if c, ok := r.(*declaration.Container); ok && mountsMissing(context.Background(), c, accesses) != "" {
|
||||
out[c.ID] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The judgements of an access as a look says them.
|
||||
const (
|
||||
VerdictKeep = "keep"
|
||||
VerdictMake = "make"
|
||||
VerdictWaitKeep = "wait-keep"
|
||||
VerdictWaitDisk = "wait-disk"
|
||||
VerdictRoot = "root"
|
||||
VerdictBroken = "broken"
|
||||
// VerdictUnknown is a folder whose filesystem could not be named this look: neither kept nor faulted.
|
||||
VerdictUnknown = "unknown"
|
||||
// VerdictNobody is a folder no identity uses: nothing is kept, and only a missing disk is said.
|
||||
VerdictNobody = "nobody"
|
||||
)
|
||||
|
||||
// AccessJudgement is a look's judgement of an access's folder, and why, in words; Placed names the filesystem at
|
||||
// it for a wait, with no path (PlacedID).
|
||||
type AccessJudgement struct {
|
||||
Kind, Why, Placed string
|
||||
}
|
||||
|
||||
// AccessVerdict judges an access's folder for a look, read only (ADR 0325 §7, §9, §12): taken is whether the
|
||||
// controller says it was taken, used whether any identity uses it. A folder the mesh could keep that no apply has
|
||||
// kept — its filesystem allowing no ACLs — waits as one not taken does.
|
||||
func AccessVerdict(ctx context.Context, path string, taken, used bool) AccessJudgement {
|
||||
rec, _ := keptRecord(path)
|
||||
v := assessFolder(ctx, path, rec, taken)
|
||||
switch {
|
||||
case v.err != nil:
|
||||
return AccessJudgement{Kind: VerdictBroken, Why: pathless(v.err)}
|
||||
case v.kind == folderWaitDisk:
|
||||
return AccessJudgement{Kind: VerdictWaitDisk}
|
||||
case v.kind == folderUnknown:
|
||||
return AccessJudgement{Kind: VerdictUnknown, Why: v.why}
|
||||
case !used:
|
||||
return AccessJudgement{Kind: VerdictNobody}
|
||||
case v.kind == folderRoot:
|
||||
return AccessJudgement{Kind: VerdictRoot, Why: v.why}
|
||||
case v.kind == folderMake:
|
||||
return AccessJudgement{Kind: VerdictMake}
|
||||
case v.kind == folderWaitKeep, v.kind == folderKeep && !rec.Kept && !taken:
|
||||
return AccessJudgement{Kind: VerdictWaitKeep, Placed: PlacedID(v.mount)}
|
||||
}
|
||||
return AccessJudgement{Kind: VerdictKeep}
|
||||
}
|
||||
|
||||
// Leftover is the mesh's entries that could not be taken out yet: under the accesses that last named the folder,
|
||||
// why, in words with no path.
|
||||
type Leftover struct {
|
||||
Module, Access, Name, Path, Why string
|
||||
}
|
||||
|
||||
// Leftovers is every folder the mesh still has entries on that nobody wants (ADR 0325 §8): a folder no access names
|
||||
// any more whose disk is away, so its drop walk waits; and entries left on a filesystem no longer at a folder. Each
|
||||
// is said until its drop walk has run.
|
||||
func Leftovers() []Leftover {
|
||||
all, err := KeptFolders.All()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []Leftover
|
||||
for path, f := range all {
|
||||
module, name, access := recordName(path, f)
|
||||
dropping := f.Kept && len(f.Want) == 0 && len(f.Users) > 0 && f.Act != nil && f.Act.State == "waiting"
|
||||
switch {
|
||||
case dropping:
|
||||
// Why the walk could not run, as it recorded it: its disk not there, or its filesystem not named.
|
||||
why := strings.TrimSuffix(strings.TrimPrefix(f.Act.Why, "not walked: "), "; walked once it is")
|
||||
if why == "" {
|
||||
why = "the walk could not run"
|
||||
}
|
||||
out = append(out, Leftover{Module: module, Access: access, Name: name, Path: path, Why: "the mesh's entries for " +
|
||||
name + " could not be removed: " + why + "; they are removed once it can (novox/hq ADR 0325 §8)"})
|
||||
case len(f.Left) > 0:
|
||||
out = append(out, Leftover{Module: module, Access: access, Name: name, Path: path, Why: "the mesh's entries for " +
|
||||
name + " on a filesystem no longer at its folder could not be removed: they are removed once that " +
|
||||
"filesystem is back (novox/hq ADR 0325 §8)"})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Access < out[j].Access })
|
||||
return out
|
||||
}
|
||||
|
||||
// recordName is the module, the name and the access a kept folder's record is said under: the accesses that last
|
||||
// named it, or for a record written before it kept them, a name with no path — the module of a container identity
|
||||
// among its users, and a folder named by a digest of its path.
|
||||
func recordName(path string, f store.KeptFolder) (module, name, access string) {
|
||||
if len(f.IDs) > 0 {
|
||||
module, name = accessName(f.IDs[0])
|
||||
return module, name, f.IDs[0]
|
||||
}
|
||||
sum := sha256.Sum256([]byte(path))
|
||||
name = "folder-" + hex.EncodeToString(sum[:4])
|
||||
for _, u := range append(append([]store.KeptUser(nil), f.Users...), f.Want...) {
|
||||
if m, ok := strings.CutPrefix(u.Identity, declaration.IdentityPrefix); ok && u.From == declaration.FromContainer {
|
||||
return m, name, m + "." + name
|
||||
}
|
||||
}
|
||||
return "", name, name
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
//go:build linux
|
||||
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// The walks under a kept folder, beside the apply (novox/hq ADR 0325 §6, §8, §10).
|
||||
//
|
||||
// The take act adds the users' entries to everything under a folder on the operator's warrant; ReconcileKept adds a
|
||||
// joining user's and removes exactly a dropped user's under a folder already taken; the sweep looks at a bounded
|
||||
// part of each kept folder on every look, going on where the last stopped, and puts back what an app narrowed.
|
||||
// One walk at a time per folder: a sweep finds a folder being walked and waits for the next look.
|
||||
|
||||
// folderLocks is one lock per kept folder, held by every walk of it.
|
||||
var folderLocks sync.Map
|
||||
|
||||
func folderLock(path string) *sync.Mutex {
|
||||
l, _ := folderLocks.LoadOrStore(path, &sync.Mutex{})
|
||||
return l.(*sync.Mutex)
|
||||
}
|
||||
|
||||
// actRecord is a walk's counts as the record keeps them.
|
||||
func actRecord(act, warrant, by string, started time.Time, w *aclWalk, err error) *store.KeptAct {
|
||||
c := w.counts
|
||||
rec := &store.KeptAct{Act: act, Warrant: warrant, By: by, 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}
|
||||
switch {
|
||||
case errors.Is(err, ErrWalkBound):
|
||||
rec.State, rec.Why = "stopped", "it met its bound of entries or time; it goes on at the next apply"
|
||||
case err != nil:
|
||||
rec.State, rec.Why = "failed", err.Error()
|
||||
case w.Failed != "":
|
||||
rec.Why = fmt.Sprintf("%d entries could not be given their entries: %s", c.Unrepaired, w.Failed)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
// ReconcileKept walks every kept folder whose entries under it are not what the last apply declared: a user that
|
||||
// joined has its entries added to everything under it, a dropped user — or one whose use changed — has exactly its
|
||||
// own taken out (ADR 0325 §8). Bounded by ActLimits; a walk stopped is gone on with at the next call. A folder
|
||||
// nobody uses any more is forgotten once its entries are out. What it did, in words, for the engine to say.
|
||||
func ReconcileKept(ctx context.Context) []string {
|
||||
all, err := KeptFolders.All()
|
||||
if err != nil {
|
||||
return []string{"the kept folders could not be read: " + err.Error()}
|
||||
}
|
||||
var said []string
|
||||
for path, f := range all {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if len(f.Left) > 0 {
|
||||
said = append(said, dropLeft(path, f)...)
|
||||
if f, _ = keptRecord(path); f.Path == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !f.Kept || sameUsers(f.Users, f.Want) {
|
||||
continue
|
||||
}
|
||||
said = append(said, reconcileOne(ctx, path, f)...)
|
||||
}
|
||||
return said
|
||||
}
|
||||
|
||||
// LeftFor is how long entries left on a filesystem are kept recorded, and said, while it is not back.
|
||||
var LeftFor = 30 * 24 * time.Hour
|
||||
|
||||
// dropLeft takes out the entries the mesh left on a filesystem no longer at the folder, once that filesystem holds
|
||||
// it again (§8): exactly those users' entries, nothing else. Left on the filesystem the folder is kept on now, they
|
||||
// are that folder's own, and its walk takes out whichever are no longer wanted. Until then they stay recorded, and
|
||||
// said (Leftovers).
|
||||
func dropLeft(path string, f store.KeptFolder) []string {
|
||||
lock := folderLock(path)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
holding := ""
|
||||
if _, err := lstatWithin(path); err == nil {
|
||||
holding = mountHolding(path, MountPoints())
|
||||
}
|
||||
holdingID := ""
|
||||
if holding != "" {
|
||||
holdingID = PlacedID(holding)
|
||||
}
|
||||
var said []string
|
||||
var still []store.KeptLeft
|
||||
var mine []store.KeptUser
|
||||
for _, l := range f.Left {
|
||||
// On the folder's own filesystem now: the same name, or a name of an earlier build's form at the same
|
||||
// mount point (which cannot be compared, and is never walked away).
|
||||
same := l.Mount == f.Mount && (l.Placed == "" || l.Placed == f.Placed || l.Fs == "")
|
||||
back := l.Mount == holding && (l.Placed == "" || l.Placed == holdingID)
|
||||
if l.Since.IsZero() {
|
||||
l.Since = time.Now().UTC()
|
||||
}
|
||||
if !same && !back && time.Since(l.Since) > LeftFor {
|
||||
// **A filesystem not back within LeftFor is gone** (a disk replaced, a pool destroyed): what was left
|
||||
// on it is forgotten, said once, rather than said unhealthy for ever.
|
||||
said = append(said, fmt.Sprintf("the kept folder %s: the entries left on a filesystem not back for %s "+
|
||||
"are forgotten: it is taken for gone", path, LeftFor))
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case f.Kept && same:
|
||||
// Left on the filesystem the folder is kept on now: the folder's own entries.
|
||||
mine = append(mine, l.Users...)
|
||||
case same:
|
||||
// On the filesystem the folder is on now, not kept yet: never walked away; they are the folder's
|
||||
// once it is kept there again.
|
||||
still = append(still, l)
|
||||
case !back:
|
||||
still = append(still, l)
|
||||
default:
|
||||
drop := map[uint32]bool{}
|
||||
for _, u := range l.Users {
|
||||
drop[u.UID] = true
|
||||
}
|
||||
w, err := walkKept(path, ActLimits, machineStats, nil, dropVisit(drop))
|
||||
if err != nil || w.Failed != "" {
|
||||
still = append(still, l)
|
||||
said = append(said, fmt.Sprintf("the kept folder %s: the entries left on a filesystem that is back "+
|
||||
"were not all taken out, tried again: %s", path, firstOf(pathlessOr(err), w.Failed)))
|
||||
continue
|
||||
}
|
||||
said = append(said, fmt.Sprintf("the kept folder %s: the entries left on a filesystem that is back "+
|
||||
"were taken out (%d folders and %d files)", path, w.counts.Folders, w.counts.Files))
|
||||
}
|
||||
}
|
||||
_ = KeptFolders.Update(path, func(k *store.KeptFolder, had bool) bool {
|
||||
k.Left = still
|
||||
for _, u := range mine {
|
||||
if _, has := usersOf(k.Users)[u.UID]; !has {
|
||||
k.Users = append(k.Users, u)
|
||||
}
|
||||
}
|
||||
return had && ((k.Kept && (len(k.Users) > 0 || len(k.Want) > 0)) || len(k.Left) > 0)
|
||||
})
|
||||
return said
|
||||
}
|
||||
|
||||
func pathlessOr(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return pathless(err)
|
||||
}
|
||||
|
||||
func firstOf(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func reconcileOne(ctx context.Context, path string, f store.KeptFolder) []string {
|
||||
lock := folderLock(path)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
// **Judged as an apply judges it, before every walk** (§9, §12): a disk not there leaves its mount point —
|
||||
// an empty folder of root's on the filesystem below — at the path, and a walk there would write onto root's
|
||||
// folder, or "drop" entries from a folder that never had them and forget the ones on the data. Nothing is
|
||||
// walked then, and the record stays for the walk that can reach the data.
|
||||
if v := assessFolder(ctx, path, f, false); v.err != nil || v.kind != folderKeep || v.moved {
|
||||
why := "the disk that holds it is not there"
|
||||
switch {
|
||||
case v.err != nil:
|
||||
why = pathless(v.err)
|
||||
case v.kind == folderRoot:
|
||||
why = RootWords(v.why)
|
||||
case v.kind == folderUnknown:
|
||||
why = v.why
|
||||
case v.moved || v.kind == folderWaitKeep:
|
||||
why = "what is at it now is not what the mesh kept there"
|
||||
}
|
||||
_ = KeptFolders.Update(path, func(k *store.KeptFolder, had bool) bool {
|
||||
k.Act = &store.KeptAct{Act: "walk", State: "waiting", Started: time.Now().UTC(),
|
||||
Why: "not walked: " + why + "; walked once it is"}
|
||||
return had
|
||||
})
|
||||
return []string{fmt.Sprintf("the kept folder %s: not walked, %s", path, why)}
|
||||
}
|
||||
have, want := usersOf(f.Users), usersOf(f.Want)
|
||||
drop := map[uint32]bool{}
|
||||
adds := false
|
||||
for uid, rw := range have {
|
||||
if w, ok := want[uid]; !ok || w != rw {
|
||||
drop[uid] = true
|
||||
}
|
||||
}
|
||||
for uid, rw := range want {
|
||||
if h, ok := have[uid]; !ok || h != rw {
|
||||
adds = true
|
||||
}
|
||||
}
|
||||
act, visit := "add", keepVisit(want)
|
||||
switch {
|
||||
case len(drop) > 0 && adds:
|
||||
visit = dropThenKeep(drop, want)
|
||||
case len(drop) > 0:
|
||||
act, visit = "drop", dropVisit(drop)
|
||||
}
|
||||
started := time.Now().UTC()
|
||||
w, err := walkKept(path, ActLimits, machineStats, nil, visit)
|
||||
rec := actRecord(act, "", "", started, w, err)
|
||||
_ = KeptFolders.Update(path, func(k *store.KeptFolder, _ bool) bool {
|
||||
k.Act = rec
|
||||
// Done only when every entry was reached and changed: one that failed is walked again.
|
||||
if err == nil && w.Failed == "" {
|
||||
k.Users = f.Want
|
||||
}
|
||||
return k.Kept && (len(k.Want) > 0 || len(k.Users) > 0)
|
||||
})
|
||||
return []string{fmt.Sprintf("the kept folder %s: %s walk %s (%d folders and %d files changed, %d had "+
|
||||
"them)%s", path, act, rec.State, rec.Folders, rec.Files, rec.Kept, orWhy(rec.Why))}
|
||||
}
|
||||
|
||||
func orWhy(why string) string {
|
||||
if why == "" {
|
||||
return ""
|
||||
}
|
||||
return ": " + why
|
||||
}
|
||||
|
||||
// TakeFolder is the take act (ADR 0325 §8): on the operator's warrant, the users' entries added to the folder of
|
||||
// the access id and everything under it. It judges the ask against the declaration last applied and the folder as
|
||||
// it is, sets the filesystem's ACL type where a ZFS dataset allows none yet, and starts the walk: what it answers
|
||||
// at once, and a channel closed when the walk is through. Refused, nothing is changed.
|
||||
func TakeFolder(ctx context.Context, d *declaration.Declaration, id, path, warrant, by string,
|
||||
run Runner) (<-chan struct{}, string, error) {
|
||||
if d == nil {
|
||||
return nil, "", errors.New("this engine has applied no declaration, and takes no folder it was not told of")
|
||||
}
|
||||
var access *declaration.Access
|
||||
for _, r := range d.Resources {
|
||||
if a, ok := r.(*declaration.Access); ok && a.ID == id {
|
||||
access = a
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case access == nil:
|
||||
return nil, "", fmt.Errorf("the access %s is not declared on this machine", id)
|
||||
case filepath.Clean(access.Path) != path:
|
||||
return nil, "", fmt.Errorf("the access %s is declared at another folder than the one asked", id)
|
||||
}
|
||||
folders := foldersOf(d.Resources, refusedPlaces(d.Resources))
|
||||
want := folders[path]
|
||||
users := want.list()
|
||||
if len(users) == 0 {
|
||||
return nil, "", errors.New("no identity uses the folder, so there is nothing to keep")
|
||||
}
|
||||
rec, _ := keptRecord(path)
|
||||
v := assessFolder(ctx, path, rec, false)
|
||||
switch {
|
||||
case v.err != nil:
|
||||
return nil, "", v.err
|
||||
case v.kind == folderWaitDisk:
|
||||
return nil, "", errors.New("the disk that holds the folder is not there")
|
||||
case v.kind == folderRoot:
|
||||
return nil, "", errors.New(RootWords(v.why))
|
||||
case v.kind == folderMake:
|
||||
return nil, "", errors.New("the folder is not there; the next apply makes it, and asks nobody")
|
||||
case v.unnamed:
|
||||
return nil, "", errors.New(v.why + ", so a warrant could name no filesystem it was given for")
|
||||
}
|
||||
lock := folderLock(path)
|
||||
if !lock.TryLock() {
|
||||
return nil, "", errors.New("a walk of the folder is running; asked again once it is through, the act goes on")
|
||||
}
|
||||
said := ""
|
||||
_, err := keepTop(path, usersOf(users))
|
||||
if errors.Is(err, ErrNoACL) {
|
||||
allowed, aerr := allowACLs(ctx, v.mount, run)
|
||||
if aerr != nil {
|
||||
lock.Unlock()
|
||||
return nil, "", aerr
|
||||
}
|
||||
said = allowed + "; "
|
||||
_, err = keepTop(path, usersOf(users))
|
||||
}
|
||||
if err != nil {
|
||||
lock.Unlock()
|
||||
return nil, "", err
|
||||
}
|
||||
started := time.Now().UTC()
|
||||
_ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool {
|
||||
if v.moved {
|
||||
*f = movedRecord(*f, v.mount)
|
||||
}
|
||||
if !f.Kept {
|
||||
f.Kept, f.How, f.At, f.Users = true, store.KeptByWarrant, started, nil
|
||||
}
|
||||
f.Warrant, f.By, f.Mount, f.Want, f.IDs = warrant, by, v.mount, users, want.ids
|
||||
// The warrant is for the filesystem it was carried out on (§8), named as a wait names it.
|
||||
f.Placed, f.Fs = placeOf(v.mount)
|
||||
f.TakenOn = f.Placed
|
||||
f.Act = &store.KeptAct{Act: "take", Warrant: warrant, By: by, State: "running", Started: started}
|
||||
return true
|
||||
})
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
defer lock.Unlock()
|
||||
w, err := walkKept(path, ActLimits, machineStats, nil, keepVisit(usersOf(users)))
|
||||
rec := actRecord("take", warrant, by, started, w, err)
|
||||
_ = KeptFolders.Update(path, func(f *store.KeptFolder, _ bool) bool {
|
||||
f.Act = rec
|
||||
if err == nil {
|
||||
f.Users = users
|
||||
}
|
||||
return true
|
||||
})
|
||||
}()
|
||||
return done, said + "the take act started under the warrant " + warrant + ": the entries of " +
|
||||
usersWords(users) + " are added to everything under the folder, following no link and entering no other " +
|
||||
"filesystem", nil
|
||||
}
|
||||
|
||||
// allowACLs lets the filesystem at mount carry ACLs, inside the take act only (ADR 0325 §8): a ZFS dataset's
|
||||
// acltype set to posixacl, the dataset read from the machine's mounts. Any other filesystem is refused, naming why.
|
||||
func allowACLs(ctx context.Context, mount string, run Runner) (string, error) {
|
||||
source, fstype := mountSourceOf(mount)
|
||||
if fstype != "zfs" || source == "" {
|
||||
return "", fmt.Errorf("the filesystem that holds the folder (%s) allows no access entries, and only a ZFS "+
|
||||
"dataset's can be let by the act; the operator mounts it with ACLs", orUnknown(fstype))
|
||||
}
|
||||
if _, err := run(ctx, "zfs", "set", "acltype=posixacl", source); err != nil {
|
||||
return "", fmt.Errorf("the ZFS dataset that holds the folder could not be let carry access entries "+
|
||||
"(zfs set acltype=posixacl): %w", err)
|
||||
}
|
||||
return "set acltype=posixacl on the ZFS dataset " + source, nil
|
||||
}
|
||||
|
||||
func orUnknown(s string) string {
|
||||
if s == "" {
|
||||
return "of a type the machine does not say"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// KeptLook is what one look found of a kept folder (ADR 0325 §10).
|
||||
type KeptLook struct {
|
||||
// Short is every user the folder itself gives less than its right, by entry and mask together, in words.
|
||||
Short []string
|
||||
// Busy is a walk of the folder running: the sweep waited.
|
||||
Busy bool
|
||||
// Sweep is the sweep's place after this look.
|
||||
Sweep store.KeptSweep
|
||||
// Repaired is how many entries this look found narrowed and put back; Unrepaired how many it could not.
|
||||
Repaired, Unrepaired int
|
||||
// Failed is why the sweep could not look, in words with no path.
|
||||
Failed string
|
||||
}
|
||||
|
||||
// LookKept looks at a kept folder once: the folder itself judged for each user, then the sweep's bounded part of
|
||||
// what is under it, from where the last look stopped, putting back what an app narrowed. The sweep's place is
|
||||
// recorded; a look that ran out of its bound says so.
|
||||
func LookKept(path string, users []store.KeptUser, limits WalkLimits) KeptLook {
|
||||
var out KeptLook
|
||||
short, err := judgeTop(path, users)
|
||||
if err != nil {
|
||||
out.Failed = err.Error()
|
||||
return out
|
||||
}
|
||||
out.Short = short
|
||||
lock := folderLock(path)
|
||||
if !lock.TryLock() {
|
||||
out.Busy = true
|
||||
rec, _ := keptRecord(path)
|
||||
out.Sweep = rec.Sweep
|
||||
return out
|
||||
}
|
||||
defer lock.Unlock()
|
||||
rec, _ := keptRecord(path)
|
||||
sw := rec.Sweep
|
||||
w, err := walkKept(path, limits, machineStats, sw.Cursor, keepVisit(usersOf(users)))
|
||||
out.Repaired, out.Unrepaired = w.counts.Narrowed-w.counts.Unrepaired, w.counts.Unrepaired
|
||||
sw.Read += w.counts.Read
|
||||
sw.Narrowed += w.counts.Narrowed
|
||||
sw.Unrepaired += w.counts.Unrepaired
|
||||
switch {
|
||||
case errors.Is(err, ErrWalkBound):
|
||||
sw.Cursor, sw.Bound = w.Cursor, true
|
||||
case err != nil:
|
||||
out.Failed = err.Error()
|
||||
default:
|
||||
// Covered whole: the next look starts a new pass, and what this one could not put back is kept until
|
||||
// the next pass is through.
|
||||
sw = store.KeptSweep{Covered: time.Now().UTC(), LastUnrepaired: sw.Unrepaired}
|
||||
}
|
||||
out.Sweep = sw
|
||||
_ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool {
|
||||
f.Sweep = sw
|
||||
return had
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// judgeTop is every user the folder itself gives less than its right, read only.
|
||||
func judgeTop(path string, users []store.KeptUser) ([]string, error) {
|
||||
var short []string
|
||||
_, fd, st, err := openKept(path, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer unix.Close(fd)
|
||||
a, err := getACL(fd, aclAccessXattr, uint32(st.Mode)&0o7777)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, u := range users {
|
||||
need := needOn(u.Use == declaration.AccessReadWrite, true)
|
||||
if got := effective(a, u.UID, st.Uid, uint32(st.Mode)&0o7777); got&need != need {
|
||||
short = append(short, fmt.Sprintf("%s has %s of the %s it needs", u.Identity, permWords(got),
|
||||
permWords(need)))
|
||||
}
|
||||
}
|
||||
return short, nil
|
||||
}
|
||||
|
||||
// JudgeKept reads, without changing anything, how many entries under a kept folder give a user less than its
|
||||
// right, within limits: what the access says while an entry an app narrowed is not put back yet (ADR 0325 §6).
|
||||
func JudgeKept(path string, users []store.KeptUser, limits WalkLimits) (WalkCounts, error) {
|
||||
um := usersOf(users)
|
||||
w, err := walkKept(path, limits, machineStats, nil, func(fd int, st unix.Stat_t, folder bool) (bool, bool, bool,
|
||||
error) {
|
||||
a, err := getACL(fd, aclAccessXattr, uint32(st.Mode)&0o7777)
|
||||
if err != nil {
|
||||
return false, false, false, err
|
||||
}
|
||||
n, u := judgeACL(a, um, st, folder)
|
||||
return false, n, u, nil
|
||||
})
|
||||
return w.counts, err
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ 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")
|
||||
}
|
||||
@@ -81,6 +82,7 @@ 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
|
||||
}
|
||||
@@ -139,6 +141,7 @@ 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")
|
||||
}
|
||||
@@ -174,12 +177,13 @@ func TestACapabilityReachesTheRuntimeAndTheSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A package may be declared absent (novox/hq ADR 0175): removed when it is installed, read back,
|
||||
// A package may be declared absent (novox/hq ADR 0180): 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
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//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 := os.FileMode(st.Mode&0o777), int(st.Uid), int(st.Gid)
|
||||
// Set explicitly even when it existed: a mode given at creation is not a mode maintained.
|
||||
if err := unix.Fchmod(fd, uint32(mode.Perm())); 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})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := os.FileMode(st.Mode & 0o777); got != mode.Perm() {
|
||||
return out, fmt.Errorf("%s is mode %o after setting %o", r.Path, got, mode.Perm())
|
||||
}
|
||||
|
||||
out.Action = "unchanged"
|
||||
switch {
|
||||
case created || !existed:
|
||||
out.Action = "created"
|
||||
case !ownedAlready:
|
||||
out.Action = "updated"
|
||||
case beforeMode != mode.Perm():
|
||||
out.Action = "updated"
|
||||
out.Detail = fmt.Sprintf("mode %o to %o", beforeMode, mode.Perm())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//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)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//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 })
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//go:build linux
|
||||
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// The engine delivers before the controller half (novox/hq ADR 0325): a controller that does not speak
|
||||
// link.IdentityContract sends no identities, no users and no taken, and every access of its declarations is applied
|
||||
// exactly as before this engine — mounted when present, refused when absent (ADR 0051 §7), nothing kept, nothing
|
||||
// made, no container held, nothing judged. These are origin/main's outcomes, word for word.
|
||||
func TestAnOlderControllersAccessesAreAppliedExactlyAsBefore(t *testing.T) {
|
||||
keeping(t, nil, nil)
|
||||
dir := userDir(t)
|
||||
unwritable := filepath.Join(dir, "library") // its group may not write it
|
||||
empty := filepath.Join(dir, "spool")
|
||||
absent := filepath.Join(dir, "media", "movies")
|
||||
for _, d := range []string{unwritable, empty} {
|
||||
if err := os.Mkdir(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// A root-owned empty folder, read only.
|
||||
rootEmpty := ""
|
||||
for _, c := range []string{"/var/empty", "/srv/http", "/srv/ftp"} {
|
||||
if entries, err := os.ReadDir(c); err == nil && len(entries) == 0 {
|
||||
if uid, _, _ := statOwner(mustLstat(t, c)); uid == 0 {
|
||||
rootEmpty = c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
resources := []string{
|
||||
`{"id":"radarr.library","type":"access","path":"` + unwritable + `","mode":"read-write"}`,
|
||||
`{"id":"sab.spool","type":"access","path":"` + empty + `","mode":"read-write"}`,
|
||||
`{"id":"plex.movies","type":"access","path":"` + absent + `"}`,
|
||||
`{"id":"plex.app","type":"container","name":"plex","image":"` + pinned + `","volumes":["` + absent + `:/movies"]}`,
|
||||
}
|
||||
if rootEmpty != "" {
|
||||
resources = append(resources, `{"id":"web.root","type":"access","path":"`+rootEmpty+`","mode":"read-write"}`)
|
||||
}
|
||||
var started bool
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
if len(args) > 0 && args[0] == "run" {
|
||||
started = true
|
||||
}
|
||||
if len(args) > 0 && args[0] == "container" {
|
||||
if started {
|
||||
return "true\t" + containerSpec(declare(t, resources[3]).Resources[0].(*declaration.Container), inputs{}), nil
|
||||
}
|
||||
return "", errors.New("no such container")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
toUser(t)
|
||||
report, _, err := Apply(context.Background(), archHost(t), declare(t, strings.Join(resources, ",")), store.State{},
|
||||
store.OriginDeclared, run, nil, nil)
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), absent+" is not there, and the mesh does not own it — the operator "+
|
||||
"must provide it") {
|
||||
t.Fatalf("an absent access is not refused as before: %v", err)
|
||||
}
|
||||
for id, mode := range map[string]string{"radarr.library": "read-write", "sab.spool": "read-write"} {
|
||||
o := outcomeOf(report, id)
|
||||
if o.Action != "unchanged" || o.Detail != "operator-owned; present, "+mode+", nothing managed" {
|
||||
t.Errorf("%s: %+v, and before it was unchanged, %q", id, o, "operator-owned; present, "+mode+", nothing managed")
|
||||
}
|
||||
}
|
||||
if rootEmpty != "" {
|
||||
if o := outcomeOf(report, "web.root"); o.Action != "unchanged" {
|
||||
t.Errorf("a root-owned folder was judged: %+v", o)
|
||||
}
|
||||
}
|
||||
if !started {
|
||||
t.Error("a container mounting an absent access was held, and before it was created")
|
||||
}
|
||||
if _, err := os.Stat(absent); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Error("an absent access was made")
|
||||
}
|
||||
for _, d := range []string{unwritable, empty} {
|
||||
if n, _ := unix.Getxattr(d, aclAccessXattr, nil); n > 0 {
|
||||
t.Errorf("an ACL was set on %s", d)
|
||||
}
|
||||
if info := mustLstat(t, d); info.Mode().Perm() != 0o755 {
|
||||
t.Errorf("%s changed mode: %o", d, info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
if all, _ := KeptFolders.All(); len(all) != 0 {
|
||||
t.Errorf("folders were recorded as kept: %v", all)
|
||||
}
|
||||
if len(Leftovers()) != 0 || len(WaitingOnAccess(declare(t, strings.Join(resources, ",")))) != 0 {
|
||||
t.Error("something was said or held for an older controller's access")
|
||||
}
|
||||
}
|
||||
|
||||
func mustLstat(t *testing.T, path string) os.FileInfo {
|
||||
t.Helper()
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// Review (low): a folder no access names, whose filesystem could not be named this look, is not said as its disk
|
||||
// being away.
|
||||
func TestALeftoverSaysWhyItsWalkCouldNotRun(t *testing.T) {
|
||||
uuid, fsid, source := "6f1c0a4e-1b2d-4c3e-8f00-aa11bb22cc33", "0000080100000000", "/dev/sdb1"
|
||||
_, lib, access := poolNamed(t, &uuid, &fsid, &source)
|
||||
takeIt(t, lib, access)
|
||||
other := `{"id":"x.dir","type":"directory","path":"` + filepath.Join(filepath.Dir(filepath.Dir(lib)), "x") + `"}`
|
||||
if _, _, err := applyAccesses(t, store.State{}, other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uuid, fsid, source = "", "0000000000000000", "" // nothing names it now
|
||||
toUser(t)
|
||||
ReconcileKept(context.Background())
|
||||
left := Leftovers()
|
||||
if len(left) != 1 || strings.Contains(left[0].Why, "disk") || !strings.Contains(left[0].Why, "could not be named") {
|
||||
t.Fatalf("the leftover does not say why its walk could not run: %+v", left)
|
||||
}
|
||||
}
|
||||
@@ -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 0175): retired for good, by the module that replaced it. Said
|
||||
// Uninstalled (novox/hq ADR 0180): 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
|
||||
|
||||
@@ -42,6 +42,8 @@ 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":
|
||||
@@ -374,6 +376,7 @@ 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}
|
||||
}
|
||||
@@ -471,6 +474,7 @@ 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}
|
||||
}
|
||||
@@ -525,7 +529,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 0175).
|
||||
// (novox/hq ADR 0180).
|
||||
func TestAnUninstalledFrontEndIsRetiredForGood(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
u := &ufwMachine{installed: false, ruleset: "table inet mesh\n"}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Whether a shared folder's disk is there (novox/hq 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 0325 §8).
|
||||
//
|
||||
// **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}
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+53
-1
@@ -84,12 +84,38 @@ func Plan(d *declaration.Declaration, known store.State, origin string) []Step {
|
||||
|
||||
var protecting, orphans []Step
|
||||
made := meshMadeUnits(known)
|
||||
heldUnits := unitsHeld(d.Resources)
|
||||
heldFiles := filesHeld(d.Resources)
|
||||
for _, orphan := range known.Orphans(declared, origin) {
|
||||
step := Step{Verb: "remove", Type: orphan.Type, ID: orphan.ID, Target: orphan.Target,
|
||||
Why: "recorded here and no longer declared"}
|
||||
held := heldUnits[unitKey(orphan.Scope, orphan.User, orphan.Target)]
|
||||
by, handed := heldFiles[fileKey(orphan)]
|
||||
switch {
|
||||
case handed:
|
||||
// In ApplyKeeping's words (novox/hq ADR 0223).
|
||||
step.Verb, step.Why = "forget", "no longer declared; "+by+" declares the file now, so it is handed to it, not removed"
|
||||
case orphan.Type == string(declaration.TypeService) && held != "":
|
||||
// In removeOrphan's words (novox/hq issue 190).
|
||||
step.Verb, step.Why = "forget", "no longer declared; "+held+" still holds the unit, so it is left as it is"
|
||||
case orphan.Stateless:
|
||||
step.Verb, step.Why = "forget", "no longer declared; its unit's state was never the mesh's and is left as it is"
|
||||
case orphan.Type == string(declaration.TypeArchive) && store.IsFormer(orphan.ID):
|
||||
// In removeArchive's words (novox/hq issues 162, 194).
|
||||
step.Verb, step.Why = "forget", "a former target; left in place, since the version before is what a rollback starts"
|
||||
case orphan.Type == string(declaration.TypeArchive) && orphan.Unpacked == nil:
|
||||
step.Verb, step.Why = "forget", "no longer declared; recorded before the host kept what it unpacked, so it is left in place"
|
||||
case orphan.Type == string(declaration.TypeArchive):
|
||||
step.Why = "no longer declared; the files it unpacked go, and the directories the host made for them once empty"
|
||||
case orphan.Type == string(declaration.TypeFile) && orphan.Into == nil && orphan.Kept != "":
|
||||
// In removeWhole's words (novox/hq ADR 0118): written over, so given back, not deleted.
|
||||
step.Verb, step.Why = "restore", "no longer declared; the original the mesh wrote over goes back "+
|
||||
"from "+orphan.Kept+", unless the file was changed since the mesh last wrote it"
|
||||
case orphan.Type == string(declaration.TypeAccess) && orphan.User != "" && len(orphan.Groups) > 0:
|
||||
// In takeBackGranted's words (novox/hq ADR 0325 §5: no account is in a group for data).
|
||||
step.Verb, step.Why = "restore", "no longer declared; the path is left as it is, and "+orphan.User+
|
||||
" leaves "+strings.Join(orphan.Groups, ", ")+", which the mesh put it in, unless another declared "+
|
||||
"resource still asks for it"
|
||||
case orphan.Type == string(declaration.TypeService):
|
||||
// What removal will do, said before it does it (novox/hq ADR 0118), in removeService's
|
||||
// words. "restore" only where it may stop or disable something — the record cannot say
|
||||
@@ -98,12 +124,26 @@ func Plan(d *declaration.Declaration, known store.State, origin string) []Step {
|
||||
// stopped whatever was found.
|
||||
f := orphan.Found
|
||||
switch {
|
||||
case made[orphan.Target]:
|
||||
case made[unitKey(orphan.Scope, orphan.User, orphan.Target)]:
|
||||
step.Verb, step.Why = "remove", "no longer declared; the mesh wrote its unit file, so it is "+
|
||||
"stopped and disabled at boot before that file goes"
|
||||
case f == nil:
|
||||
step.Verb, step.Why = "forget", "no longer declared; recorded before the host kept what it "+
|
||||
"found, so it is left as it is"
|
||||
case orphan.DeclaredUnit != nil && ((f.State == "running" && orphan.DeclaredUnit.State == "stopped") ||
|
||||
(f.Boot == "enabled" && orphan.DeclaredUnit.Boot == "disabled")):
|
||||
// startUnitBack's words (novox/hq ADR 0269): what the mesh's declaration stopped goes back.
|
||||
var back []string
|
||||
if f.State == "running" && orphan.DeclaredUnit.State == "stopped" {
|
||||
back = append(back, "started")
|
||||
}
|
||||
if f.Boot == "enabled" && orphan.DeclaredUnit.Boot == "disabled" {
|
||||
back = append(back, "enabled at boot")
|
||||
}
|
||||
// A preview: whether it is still as the mesh held it is read when the removal runs, and a
|
||||
// masked unit or one changed since is left as it is.
|
||||
step.Verb, step.Why = "restore", "no longer declared; restore if still as the mesh held it: "+
|
||||
strings.Join(back, " and ")+" again, as the host found it, unless it is masked or was changed since"
|
||||
case f.State == "stopped" || f.Boot == "disabled":
|
||||
var back []string
|
||||
if f.State == "stopped" {
|
||||
@@ -235,6 +275,12 @@ func planned(r declaration.Resource, d *declaration.Declaration, known store.Sta
|
||||
step.Verb, step.Why = "create", "no record of it on this node"
|
||||
return step
|
||||
}
|
||||
// A run-once process whose last run failed has its record cleared of what it was run as (novox/hq issue
|
||||
// 467), and the apply runs it again.
|
||||
if was.Wrote == "" && isStep(r) {
|
||||
step.Verb, step.Why = "update", "its last run did not complete, so it runs again"
|
||||
return step
|
||||
}
|
||||
if want := wouldWrite(r); want != "" && was.Wrote != "" && want != was.Wrote {
|
||||
step.Verb, step.Why = "update", "the declaration changed since this host applied it"
|
||||
return step
|
||||
@@ -335,3 +381,9 @@ func wouldWrite(r declaration.Resource) string {
|
||||
}
|
||||
return digestOf(f.Content)
|
||||
}
|
||||
|
||||
// isStep says whether a resource is a run-once process, whose record a failed run clears.
|
||||
func isStep(r declaration.Resource) bool {
|
||||
p, ok := r.(*declaration.Process)
|
||||
return ok && p.RunOnce
|
||||
}
|
||||
|
||||
@@ -103,6 +103,8 @@ type both struct {
|
||||
}
|
||||
|
||||
func (b *both) run(ctx context.Context, name string, args ...string) (string, error) {
|
||||
|
||||
args = oldShape(name, args)
|
||||
switch name {
|
||||
case "nft", "ufw", "iptables", "ip6tables", "firewall-cmd":
|
||||
return b.u.run(ctx, name, args...)
|
||||
|
||||
+141
-22
@@ -8,9 +8,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/witness"
|
||||
)
|
||||
|
||||
// Running the mesh's own code, without the module choosing how.
|
||||
@@ -37,11 +39,11 @@ var daemonRoot = "/var/lib/mesh/daemons"
|
||||
var unitDir = "/etc/systemd/system"
|
||||
|
||||
func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
|
||||
changed map[string]bool, previous store.Applied) (Outcome, error) {
|
||||
changed map[string]bool, previous store.Applied, away *storeAway) (Outcome, error) {
|
||||
out := begin(r)
|
||||
out.Action = "unchanged"
|
||||
|
||||
body, err := fetch(ctx, r.Source)
|
||||
body, err := fetchMinding(ctx, r.Source, away)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -77,11 +79,26 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
|
||||
// Everything about it is as declared. Still asked whether it is RUNNING, because a
|
||||
// declaration that is satisfied by a record rather than by the machine is how a stopped
|
||||
// service reports success.
|
||||
if active, err := run(ctx, "systemctl", "is-active", "--quiet", r.Name+".service"); err == nil {
|
||||
// **The record is carried forward, not re-derived.** An unchanged outcome is recorded
|
||||
// like any other, so one that said nothing about what was written erased the digest; the
|
||||
// next apply then found no record, re-created the daemon, and the one after that found a
|
||||
// record again — the node's runtime restarted every other cycle (novox/hq 04-ISSUES/210).
|
||||
out.wrote = want
|
||||
// **A step that ran is done, and a schedule is its timer** (novox/hq issue 213). Neither's
|
||||
// service is meant to be up between runs, so asking whether it is — and starting it when it
|
||||
// was not — ran a completed step again on every apply, and a scheduled run off its cadence.
|
||||
if r.RunOnce {
|
||||
return out, nil
|
||||
}
|
||||
unit := r.Name + ".service"
|
||||
if r.Schedule != "" {
|
||||
unit = r.Name + ".timer"
|
||||
}
|
||||
if active, err := run(ctx, "systemctl", "is-active", "--quiet", unit); err == nil {
|
||||
_ = active
|
||||
return out, nil
|
||||
}
|
||||
if _, err := run(ctx, "systemctl", "start", r.Name+".service"); err != nil {
|
||||
if _, err := run(ctx, "systemctl", "start", unit); err != nil {
|
||||
return out, fmt.Errorf("%s is installed and would not start: %w", r.Name, err)
|
||||
}
|
||||
out.Action = "updated"
|
||||
@@ -90,32 +107,77 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Replaced rather than merged: the bundle is the whole of what it runs, and files left from a
|
||||
// previous version would be loaded by a runtime that walks a directory.
|
||||
if err := os.RemoveAll(at); err != nil {
|
||||
return out, err
|
||||
// **A core process keeps the build before it until the new one is proved** (novox/hq to-be 45
|
||||
// §8): the engine's witness judges the new build and restores the kept one when it is not healthy
|
||||
// in bound. Placing decides what happens to the directory first — the running build moved aside,
|
||||
// a build on trial discarded, a build rolled back here refused, the running build kept when it is
|
||||
// the one declared — and keeps that, so a restoration later knows which build is where.
|
||||
by := witnessOf(r)
|
||||
placing := witness.Placing{Unpack: true}
|
||||
if by == witness.ByNone {
|
||||
// Nothing judged. Whatever was kept for a process that was judged before goes.
|
||||
if err := witness.Forget(daemonRoot, r.Name); err != nil {
|
||||
return out, err
|
||||
}
|
||||
// Replaced rather than merged: the bundle is the whole of what it runs, and files left from a
|
||||
// previous version would be loaded by a runtime that walks a directory.
|
||||
if err := os.RemoveAll(at); err != nil {
|
||||
return out, err
|
||||
}
|
||||
} else {
|
||||
placing, err = witness.Place(daemonRoot, r.Name, by, got, digestWritten(previous.Wrote), r.NotReversible, time.Now())
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(at, 0o755); err != nil {
|
||||
return out, err
|
||||
written := 0
|
||||
if placing.Unpack {
|
||||
if err := os.MkdirAll(at, 0o755); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if written, err = unpack(body, at); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := ownAll(at, r.User); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
written, err := unpack(body, at)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := ownAll(at, r.User); err != nil {
|
||||
return out, err
|
||||
if placing.Commit != nil {
|
||||
// Kept whatever the start below does: a build that would not start is still the build placed,
|
||||
// and it is the witness's to judge.
|
||||
defer func() {
|
||||
if err := placing.Commit(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "mesh-host: placed %s, and what the witness keeps about it could not be saved: %v\n",
|
||||
r.Name, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// **A step is run to completion, not installed.** What follows it is gated on it finishing,
|
||||
// so the machine is not asked to start something that needed a migration that did not happen.
|
||||
// Nothing is left behind to ask afterwards: the record that it ran is the digest, which is why
|
||||
// the identity above includes the command.
|
||||
//
|
||||
// **Run as the unit a daemon would be, once** (novox/hq design 38 WP4c). Run directly, the
|
||||
// step started in the host's own working directory, without its environment, its environment
|
||||
// files or its user — `node bootstrap/index.js` resolved from wherever the host ran and was told
|
||||
// none of the words it was declared with. A oneshot unit carries all four exactly as a daemon's
|
||||
// does, and starting one waits for it to finish and fails when it fails.
|
||||
if r.RunOnce {
|
||||
if _, err := run(ctx, r.Run[0], r.Run[1:]...); err != nil {
|
||||
return out, fmt.Errorf("the %s step did not complete: %w", r.Name, err)
|
||||
unit := filepath.Join(unitDir, r.Name+".service")
|
||||
if err := os.WriteFile(unit, []byte(unitFor(r)), 0o644); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if _, err := run(ctx, "systemctl", "start", r.Name+".service"); err != nil {
|
||||
out.refused = true // started and did not complete: run again until it does (novox/hq issue 467)
|
||||
return out, fmt.Errorf("the %s step did not complete (journalctl -u %s.service says why): %w", r.Name, r.Name, err)
|
||||
}
|
||||
// Updated whenever it has a record, whose Wrote a failed run cleared (novox/hq issue 467).
|
||||
out.Action = "created"
|
||||
if previous.Wrote != "" {
|
||||
if previous.Wrote != "" || previous.ID != "" {
|
||||
out.Action = "updated"
|
||||
}
|
||||
out.Detail = fmt.Sprintf("%d file(s), step completed", written)
|
||||
@@ -173,9 +235,15 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
|
||||
out.Action = "created"
|
||||
}
|
||||
out.Detail = fmt.Sprintf("%d file(s), running as %s.service", written, r.Name)
|
||||
if !placing.Unpack {
|
||||
out.Detail = fmt.Sprintf("its build already in place, running as %s.service", r.Name)
|
||||
}
|
||||
if because != "" {
|
||||
out.Detail += ", restarted because " + because + " changed"
|
||||
}
|
||||
if placing.Detail != "" {
|
||||
out.Detail += "; " + placing.Detail
|
||||
}
|
||||
out.wrote = want
|
||||
return out, nil
|
||||
}
|
||||
@@ -209,9 +277,9 @@ func unitFor(r *declaration.Process) string {
|
||||
if r.User != "" {
|
||||
fmt.Fprintf(&b, "User=%s\n", r.User)
|
||||
}
|
||||
fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(r.Run, " "))
|
||||
if r.Schedule != "" {
|
||||
// Started by its timer and expected to finish. Restarting it would have it run
|
||||
fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(runFrom(r), " "))
|
||||
if r.Schedule != "" || r.RunOnce {
|
||||
// Started by its timer, or once by the host, and expected to finish. Restarting it would have it run
|
||||
// continuously between fires, which is the opposite of a schedule.
|
||||
b.WriteString("Type=oneshot\n")
|
||||
b.WriteString("\n")
|
||||
@@ -317,6 +385,14 @@ func removeProcess(ctx context.Context, a store.Applied, run Runner) (string, st
|
||||
}
|
||||
found = true
|
||||
// The timer first, so a scheduled run cannot start the service between the two.
|
||||
//
|
||||
// `--now` waits for the stop. The unit the host writes sets no stop timeout and no kill mode, so the
|
||||
// service manager's defaults hold: SIGTERM, then after its default stop timeout (90 s unless the
|
||||
// machine changed DefaultTimeoutStopSec) SIGKILL to the whole group. What a process does on SIGTERM —
|
||||
// nfs-server removing its export file when its configuration is gone (novox/hq ADR 0263) — happens
|
||||
// only if it finishes within that time; a killed process cleans nothing up, and an unassigned module
|
||||
// never starts again to do it later. So a process that cleans up on stop does that first and keeps it
|
||||
// short, rather than after work that can block.
|
||||
if _, err := run(ctx, "systemctl", "disable", "--now", unit); err != nil {
|
||||
return "", "", fmt.Errorf("stopping %s: %w", unit, err)
|
||||
}
|
||||
@@ -330,6 +406,13 @@ func removeProcess(ctx context.Context, a store.Applied, run Runner) (string, st
|
||||
}
|
||||
}
|
||||
bundle := filepath.Join(daemonRoot, name)
|
||||
// And the build kept before it, with what the witness knew (novox/hq to-be 45 §8).
|
||||
if _, err := os.Stat(witness.Dir(daemonRoot, name)); err == nil {
|
||||
found = true
|
||||
if err := witness.Forget(daemonRoot, name); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(bundle); err == nil {
|
||||
found = true
|
||||
if err := os.RemoveAll(bundle); err != nil {
|
||||
@@ -341,3 +424,39 @@ func removeProcess(ctx context.Context, a store.Applied, run Runner) (string, st
|
||||
}
|
||||
return "removed", "stopped; its unit and its bundle removed — the mesh's own code", nil
|
||||
}
|
||||
|
||||
// runFrom is the command as the unit runs it. **A command written `./name` is that file in the
|
||||
// process's own unpacked bundle** (novox/hq ADR 0193): a bundle compiled to a binary runs itself,
|
||||
// and only the host knows where it unpacked it, while the service manager takes an absolute path or
|
||||
// a name it finds on its own search path — never one relative to the working directory.
|
||||
func runFrom(r *declaration.Process) []string {
|
||||
run := append([]string(nil), r.Run...)
|
||||
if len(run) > 0 && strings.HasPrefix(run[0], "./") {
|
||||
run[0] = filepath.Join(daemonRoot, r.Name, strings.TrimPrefix(run[0], "./"))
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
// witnessOf is how a process's new builds are judged: as it declares, or by its name's default — the
|
||||
// mesh's two core processes (novox/hq to-be 45 §8). A step or a scheduled run is never judged.
|
||||
func witnessOf(r *declaration.Process) string {
|
||||
if r.RunOnce || r.Schedule != "" {
|
||||
return witness.ByNone
|
||||
}
|
||||
if r.Witness != "" {
|
||||
return r.Witness
|
||||
}
|
||||
return witness.Default(r.Name)
|
||||
}
|
||||
|
||||
// digestWritten is the bundle digest a process's record says was placed: the first half of what it
|
||||
// wrote, "sha256:<hex> <unit>".
|
||||
func digestWritten(wrote string) string {
|
||||
if fields := strings.Fields(wrote); len(fields) > 0 && strings.HasPrefix(fields[0], "sha256:") {
|
||||
return fields[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DaemonRoot is where unpacked daemons live — and what a witness keeps beside them.
|
||||
func DaemonRoot() string { return daemonRoot }
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// A run-once process is a step run where, how and as whom it was declared (novox/hq design 38
|
||||
// WP4c): its bundle's directory, its environment and environment files, its user. Run directly,
|
||||
// it started in the host's own directory with none of them.
|
||||
func TestARunOnceProcessRunsAsItsOneshotUnit(t *testing.T) {
|
||||
units, bundles := t.TempDir(), t.TempDir()
|
||||
wasUnits, wasBundles := unitDir, daemonRoot
|
||||
unitDir, daemonRoot = units, bundles
|
||||
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
|
||||
|
||||
me, err := user.Current()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, digest := anArchive(t, map[string]string{"bootstrap/index.js": "console.log(1)\n"})
|
||||
var commands []string
|
||||
fail := false
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
args = oldShape(name, args)
|
||||
commands = append(commands, name+" "+strings.Join(args, " "))
|
||||
if fail && name == "systemctl" && len(args) > 0 && args[0] == "start" {
|
||||
return "", errors.New("exit status 1")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
d := declare(t, `{"id":"mosquitto.bootstrap","type":"process","name":"mosquitto-bootstrap","source":"`+serving(t, body)+
|
||||
`","digest":"`+digest+`","run":["node","bootstrap/index.js"],"run-once":true,"user":"`+me.Username+`",`+
|
||||
`"env":{"MESH_ADMIN":"mesh-admin"},"env-file":["/var/lib/mesh/mosquitto/bootstrap.env"]}`)
|
||||
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unit, err := os.ReadFile(filepath.Join(units, "mosquitto-bootstrap.service"))
|
||||
if err != nil {
|
||||
t.Fatalf("no unit was written for the step: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"WorkingDirectory=" + filepath.Join(bundles, "mosquitto-bootstrap"),
|
||||
"EnvironmentFile=/var/lib/mesh/mosquitto/bootstrap.env",
|
||||
`Environment="MESH_ADMIN=mesh-admin"`,
|
||||
"User=" + me.Username,
|
||||
"Type=oneshot",
|
||||
"ExecStart=node bootstrap/index.js",
|
||||
} {
|
||||
if !strings.Contains(string(unit), want) {
|
||||
t.Errorf("the step's unit lacks %q:\n%s", want, unit)
|
||||
}
|
||||
}
|
||||
for _, never := range []string{"Restart=always", "[Install]", "Type=simple"} {
|
||||
if strings.Contains(string(unit), never) {
|
||||
t.Errorf("a step's unit says %q:\n%s", never, unit)
|
||||
}
|
||||
}
|
||||
joined := strings.Join(commands, "; ")
|
||||
if !strings.Contains(joined, "systemctl start mosquitto-bootstrap.service") {
|
||||
t.Errorf("the step was not started as its unit: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "node bootstrap/index.js") || strings.Contains(joined, "enable mosquitto-bootstrap") {
|
||||
t.Errorf("the step was run directly or enabled: %s", joined)
|
||||
}
|
||||
|
||||
// A step that fails fails the apply, and is not recorded as done.
|
||||
fail = true
|
||||
d2 := declare(t, `{"id":"mosquitto.bootstrap","type":"process","name":"mosquitto-bootstrap","source":"`+serving(t, body)+
|
||||
`","digest":"`+digest+`","run":["node","bootstrap/index.js"],"run-once":true,"env":{"MESH_ADMIN":"changed"}}`)
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d2, store.State{}, store.OriginDeclared, run, nil, nil); err == nil {
|
||||
t.Error("a step that failed did not fail the apply")
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,7 @@ func TestAnUndeclaredProcessIsRemovedWithItsUnitAndBundle(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, " "))
|
||||
return "", nil
|
||||
}
|
||||
@@ -256,3 +257,74 @@ func TestAProcessRecordedUnderAPathlikeNameIsRefusedNotRemoved(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/210: the node's runtime was re-created — and restarted — on every reconcile,
|
||||
// because the host did not find what it wrote for a process the cycle before. Applying the same
|
||||
// process declaration twice must do no work the second time.
|
||||
func TestAProcessAppliedAgainIsUnchangedAndNotRestarted(t *testing.T) {
|
||||
units, bundles := t.TempDir(), t.TempDir()
|
||||
wasUnits, wasBundles := unitDir, daemonRoot
|
||||
unitDir, daemonRoot = units, bundles
|
||||
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
|
||||
|
||||
body, digest := anArchive(t, map[string]string{"main.js": "console.log(1)\n"})
|
||||
var commands []string
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
args = oldShape(name, args)
|
||||
commands = append(commands, name+" "+strings.Join(args, " "))
|
||||
return "", nil
|
||||
}
|
||||
d := declare(t, `{"id":"node-tools.runtime","type":"process","name":"node-tools","source":"`+serving(t, body)+
|
||||
`","digest":"`+digest+`","run":["node","main.js"],"env":{"MESH_TOOL_MODULES":"a=/x/index.js"}}`)
|
||||
|
||||
first, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o := outcomeOf(first, "node-tools.runtime"); o.Action != "created" {
|
||||
t.Fatalf("first apply: %+v, want created", o)
|
||||
}
|
||||
rec, ok := state.Find("node-tools.runtime")
|
||||
if !ok || rec.Wrote == "" {
|
||||
t.Fatalf("the host did not record what it wrote for the process: %+v", rec)
|
||||
}
|
||||
commands = nil
|
||||
again, state, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o := outcomeOf(again, "node-tools.runtime"); o.Action != "unchanged" {
|
||||
t.Errorf("second apply: %+v, want unchanged", o)
|
||||
}
|
||||
for _, c := range commands {
|
||||
if strings.Contains(c, "restart") {
|
||||
t.Errorf("the second apply restarted the process: %v", commands)
|
||||
}
|
||||
}
|
||||
// And the record survives an unchanged apply: the third cycle is unchanged too. This is the
|
||||
// cycle the live mesh showed — created, unchanged, created — before the record was carried.
|
||||
if rec, _ := state.Find("node-tools.runtime"); rec.Wrote == "" {
|
||||
t.Fatalf("an unchanged apply dropped the digest from the record: %+v", rec)
|
||||
}
|
||||
commands = nil
|
||||
third, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o := outcomeOf(third, "node-tools.runtime"); o.Action != "unchanged" {
|
||||
t.Errorf("third apply: %+v, want unchanged", o)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0193: a bundle compiled to a binary runs itself — `./name` is that file in the
|
||||
// process's own unpacked bundle, made absolute because the service manager takes nothing relative.
|
||||
func TestAProcessRunsItsOwnBundlesBinary(t *testing.T) {
|
||||
unit := unitFor(&declaration.Process{Name: "node-tools", Run: []string{"./node-tools", "serve"}})
|
||||
if !strings.Contains(unit, "ExecStart="+filepath.Join(daemonRoot, "node-tools", "node-tools")+" serve\n") {
|
||||
t.Errorf("the binary is not run from its bundle:\n%s", unit)
|
||||
}
|
||||
other := unitFor(&declaration.Process{Name: "x", Run: []string{"node", "src/main.js"}})
|
||||
if !strings.Contains(other, "ExecStart=node src/main.js\n") {
|
||||
t.Errorf("a command found on the path was changed:\n%s", other)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user