Author SHA1 Message Date
mesh-admin 7a41e12063 Merge pull request 'The tool runner joins the own groups of the modules whose tools it runs, and own directories may keep their group (hq ADR 0334, issue 567)' (#97) from feat/334-the-runner-joins-own-groups into main 2026-10-12 22:22:14 +00:00
jschoubben c7b534a08c Keep a module directory's setgid bit, warn about a refused runner, and restart the runner when it leaves (hq ADR 0334 review)
mesh/merge-gate head c7b534a08c98 merged into trunk 6fe82c61bd32 — pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes wi…
mesh/repo-check head c7b534a08c98 merged into trunk 6fe82c61bd32 — pass: its merge-check.sh passed
mesh/delivery delivered
A module's own directories need setgid so what is made beneath keeps its own group,
and a mode of 2750 refused the whole declaration while every apply dropped the bit:
a directory's mode now carries setgid (never setuid or sticky), set, kept and said.
A tool runner's account that is missing or root's failed the whole apply and dropped
the library's outcome: it is now a warning, said as runner:<module> or
runner:library:<name>. And a runner that leaves a group is taken out before the
resources apply, so its unit is restarted in that apply to lose it.
2026-10-13 00:11:50 +02:00
jschoubben ccc86d3594 Put the tool runner in the own group of each module whose tools it runs (hq ADR 0334 §2)
A joining module's own directories belong to its own group, and its tools run in the
tool runner, which could not read them. An identity may now name the tool runner's
account: the engine puts it in mesh-<module> as it puts it in a library's group,
says the change as identity:<module> for the runner's unit to restart once, takes
it back when no identity names it, and refuses an account that is missing or root's.
The engine states contract 7.
2026-10-13 00:06:29 +02:00
mesh-admin 6fe82c61bd Merge pull request 'Raise genesis on the bus the mesh runs on, from one bundle (issue 146, ADR 0331)' (#96) from fix/146-genesis-on-the-bus into main 2026-10-12 21:11:52 +00:00
mesh-admin 8e95c1d9ed Merge pull request 'A machine says what it is made of, and says it once a day unasked (issue 160)' (#92) from fix/160-a-machine-says-what-it-is into main 2026-10-12 21:07:05 +00:00
jochen cb55a46400 Place the bus's users again on a re-run until the bus is adopted (hq issue 146, review 102)
mesh/merge-gate head cb55a4640050 merged into trunk 8e95c1d9ed2d — pass: the change touches no module of the mesh's graph
mesh/repo-check head cb55a4640050 merged into trunk 8e95c1d9ed2d — pass: its merge-check.sh passed
mesh/delivery delivered
A re-run's apply writes the bundle's one-entry user list over the list placed at
enrolment, and an enrolled machine skipped placing it: enrolled was taken for adopted,
against ADR 0331 §5. Until the controller knows the nats module, the installer places
the composed list again. Also drop the previous broker's AMQP and management ports,
which nothing raises any more, and say why the bus always carries a ports setting.
2026-10-12 23:06:33 +02:00
jochen 37947ca4ee Raise genesis on the bus the mesh runs on, from one bundle (hq issue 146, ADR 0331)
mesh/merge-gate head 37947ca4eed1 merged into trunk d8e785bb4b4b — pass: the change touches no module of the mesh's graph
mesh/repo-check head 37947ca4eed1 merged into trunk d8e785bb4b4b — pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The installer could not raise a first node: the old bundle's control plane had no
MESH_BUS_NATS and crash-looped, and the NATS bundle was refused because rootsecrets
only knew the previous broker's guest:guest@ template.

One bundle now, the NATS one. rootsecrets makes the controller's bus account and puts
it in both placeholders (the address and the bcrypt hash in the carried user list),
kept with its hash so a re-run writes the same bundle. Preflight refuses a template
naming no bus connection it knows, naming the placeholder, instead of waiting out the
verify step. The broker step adopts the bus as the nats module and refuses, by name,
a catalogue without it or a server that is not the foundation's container.
2026-10-12 22:49:49 +02:00
mesh-admin d8e785bb4b Merge pull request 'Keep libraries under a group of their own, and give each joining module a user in it (hq ADR 0328, issue 567)' (#95) from feat/328-libraries-and-module-users into main 2026-10-12 20:28:16 +00:00
jschoubben 281837847e Put busybox's account removal back, test the sample, and read a held lock as kept only for a running walk (hq ADR 0328 review)
mesh/merge-gate head 281837847e03 merged into trunk 303d4037e84e — pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes wi…
mesh/repo-check head 281837847e03 merged into trunk 303d4037e84e — pass: its merge-check.sh passed
mesh/delivery delivered
An earlier change dropped alpine's RemoveAccount and RemoveGroup, so alpine made no
module identity at all. The sample beneath a root in shape had no test of its own.
And an apply that found the library's lock held read the library as kept even when
what held it was a take act about to be refused for a missing disk; it now does so
only while a take walk is running, and otherwise waits and judges as always.
2026-10-12 22:21:42 +02:00
jschoubben c7551162d6 Give no account a home, keep old data's sub-paths for the warrant, and never hand out setgid (hq ADR 0328 review)
usermod --gid regroups every file under an account's home, and accounts were made
with / as home: they now have /nonexistent, and an older account's home is moved
there (without its files) before its group changes. A sub-path asked for later that
holds data not in the group waits for the operator as the root would. The take act
gives the group to hardlinked files an account owns (the *arr apps' downloads) and
leaves root's; a file's setuid or setgid bit the regroup takes is never put back, so
no setgid file hands out the library's group; each is counted. An apply no longer
waits for a running take walk.
2026-10-12 22:18:16 +02:00
jschoubben 2cffa33bcc Keep a root without asking only when its data beneath is the library's already (hq ADR 0328 §7)
A root in the group with setgid was kept without a warrant even when the data beneath
it was not in the group, which §7 says the operator is asked about. A root is now
kept without asking only when it is empty, or it and a bounded sample beneath it are
all in the group with group write (and setgid on folders). On busybox, an account the
mesh made is given its added groups with addgroup and delgroup; a change of its
primary group is refused, saying why.
2026-10-12 22:14:10 +02:00
jschoubben 2a2be687f3 Keep libraries under groups of their own, and give modules users in them (hq ADR 0328, issue 567)
ADR 0328 supersedes ADR 0325: shared data is a library with one home, kept usable by
its group rather than by ACL entries. The engine now makes each library's group and
each module's user in its libraries' groups; keeps a library's root and requested
sub-paths in its group with group write and setgid on its home, making missing
sub-paths as the root is; waits for the operator's word before touching a root that
holds data, and on the take act walks it by descriptor (no link, same filesystem,
bounded and resumed, files with other names left); holds the containers of a library
whose disk or mount is not there; and puts the tool runner's account in the group.
The ACL keeping, mask repair and watch are gone; an access is again as before ADR 0325.
2026-10-12 22:11:09 +02:00
mesh-admin 303d4037e8 Merge pull request 'Give the controller the contract it can fill: tool runners by name, the take act's access id, wait reasons and ACL support (hq ADR 0325)' (#94) from feat/325-the-contract-the-controller-needs into main 2026-10-12 17:57:41 +00:00
jschoubben 5c8e768ef5 Read an account by its name, never as a number getent takes for a uid (hq ADR 0325)
mesh/merge-gate head 5c8e768ef520 merged into trunk ef26dba2753a — pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes wi…
mesh/repo-check head 5c8e768ef520 merged into trunk ef26dba2753a — pass: its merge-check.sh passed
mesh/delivery delivered
getent passwd reads an all-digit key as a uid, so a tool runner's account named
"1000" resolved to whoever has uid 1000 and the entry went to them. Only an answer
naming the account asked for is taken.
2026-10-12 19:51:09 +02:00
jschoubben aad33dfe5c Give the controller the contract it can fill (hq ADR 0325, issue 567)
The controller cannot know the operator's account's uid, names an access by its
declared id (which holds a slash), and should not parse words. A tool runner's
account may now come by name alone and is read here (refused when absent or root's,
its uid said in the access's listing); the take act matches the declared id exactly;
an access wait says its reason (keep or disk) and, for a filesystem that refuses
ACLs, whether the take act can enable them.
2026-10-12 19:51:09 +02:00
mesh-admin ef26dba275 Merge pull request 'Give every module an identity and keep shared folders through ACL entries (hq ADR 0325, issue 567)' (#93) from feat/325-identities-and-kept-folders into main 2026-10-12 17:50:59 +00:00
jochen c7a4bf5c11 Judge the daily report by wall time, and keep one filesystem check at a time (issue 160)
mesh/merge-gate head c7a4bf5c11d6 merged into trunk d8e785bb4b4b — pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes wi…
mesh/repo-check head c7a4bf5c11d6 merged into trunk d8e785bb4b4b — pass: its merge-check.sh passed
mesh/delivery delivered
Go's monotonic clock stops while a laptop is suspended, so a day judged by it could pass a day and a half
of wall time before the machine said what it is. And a hung statfs left a goroutine and thread blocked per
apply; now one check runs at a time and reports after it say the sizes unknown until it ends.
2026-10-12 17:42:18 +02:00
jochen ec983bfc3d Have a machine say what it is made of, and say it once a day unasked (issue 160)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The mesh knew a machine's architecture and kernel family and nothing it is made of, and heard even
that only when it sent the machine something. The profile now carries the machine's memory, each local
filesystem's size and free space, processor model, cores and threads, distribution and version, kernel
release, virtual or physical, boot time and timezone; a fact it cannot read is named unknown with why,
never zeroed. The reconcile says the profile unasked once the mesh last heard it 23 hours ago, so on a
machine nobody pushed to nothing the mesh holds of it is older than a day.
2026-10-12 17:21:36 +02:00
66 changed files with 5212 additions and 5337 deletions
+2 -4
View File
@@ -130,7 +130,7 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh
The foundation's ports are this machine's, each checked free before anything is
raised and kept as the node's setting for the module that binds it:
--store-port 5432 --bus-port 5671 --amqp-port 5672 --management-port 15672
--store-port 5432 --bus-port 5671
--registry-port 5000 (follows --registry, and must agree with it)
--packages-port 3000 --hub-port 51820/udp
--overlay-range the private network's range (default 10.42.0.0/16); refused
@@ -325,9 +325,7 @@ func newFlagSet(opts *bootstrap.Options, jsonOut *bool) *flag.FlagSet {
into *int
}{
{"store-port", "the store", &opts.Ports.Store},
{"bus-port", "the bus (amqps)", &opts.Ports.Bus},
{"amqp-port", "the broker's AMQP", &opts.Ports.AMQP},
{"management-port", "the broker's management, on loopback", &opts.Ports.Management},
{"bus-port", "the bus", &opts.Ports.Bus},
{"registry-port", "the registry", &opts.Ports.Registry},
{"packages-port", "the package registry", &opts.Ports.Packages},
{"hub-port", "the private network's hub (udp)", &opts.Ports.Hub},
-431
View File
@@ -1,431 +0,0 @@
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
}
-78
View File
@@ -1,78 +0,0 @@
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)
}
}
+289
View File
@@ -0,0 +1,289 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// A library is judged on every look (novox/hq ADR 0328 §8, §9).
//
// Each library this machine is reached by is said as a resource `library:<name>` of kind library, with no module:
// healthy while it is kept (or, off its home, while its mount is there), waiting with an access wait of reason keep
// or disk, unknown while its filesystem could not be named, unhealthy otherwise. The root is the target, never in a
// reason nor in a wait's words: the controller says them on the operator's channel, which withholds a path (ADR
// 0234 §6). An access is judged by nothing, as before ADR 0325.
// libraryJudge reads every library on every look. Safe for the apply and the looking loop at once.
type libraryJudge struct {
mu sync.Mutex
libraries []declaration.Library
declared *declaration.Declaration
// was is each library's last verdict, with since when and how many looks in a row found it unhealthy.
was map[string]link.ResourceHealth
// reading guards inFlight: every library whose look has not returned yet.
reading sync.Mutex
inFlight map[string]bool
}
// accesses is the one library judge on this machine (its name from when it judged accesses).
var accesses = newLibraryJudge()
func newLibraryJudge() *libraryJudge {
return &libraryJudge{was: map[string]link.ResourceHealth{}, inFlight: map[string]bool{}}
}
// set is what the declaration just applied declares: every library. One no longer declared is forgotten.
func (j *libraryJudge) set(d *declaration.Declaration, _ map[string]bool) {
var ls []declaration.Library
if d != nil {
ls = append(ls, d.Libraries...)
}
sort.Slice(ls, func(a, b int) bool { return ls[a].Library < ls[b].Library })
j.mu.Lock()
defer j.mu.Unlock()
j.libraries, j.declared = ls, d
declared := map[string]bool{}
for _, l := range ls {
declared[apply.LibraryAccess(l.Library)] = true
}
for id := range j.was {
if !declared[id] {
delete(j.was, id)
}
}
}
// declaration is the declaration last set.
func (j *libraryJudge) declaration() *declaration.Declaration {
j.mu.Lock()
defer j.mu.Unlock()
return j.declared
}
// lookBound is how long a look waits for one library to answer. A hard network mount whose server is down blocks
// a stat for ever, and one library must never stop every health statement this machine makes.
var lookBound = 5 * time.Second
// verdict is one library's judgement in a look.
type verdict struct {
state, reason string
waits []link.AccessWait
act *link.AccessActRecord
}
// judgeBounded judges one library within lookBound, outside any lock. A look that has not answered is said so;
// while it still hangs, later looks start no other on the same library.
func (j *libraryJudge) judgeBounded(l declaration.Library) (verdict, bool) {
j.reading.Lock()
if j.inFlight[l.Library] {
j.reading.Unlock()
return verdict{}, false
}
j.inFlight[l.Library] = true
j.reading.Unlock()
done := make(chan verdict, 1)
go func() {
v := judgeLibrary(l)
j.reading.Lock()
delete(j.inFlight, l.Library)
j.reading.Unlock()
done <- v
}()
select {
case v := <-done:
return v, true
case <-time.After(lookBound):
return verdict{}, false
}
}
// judgeLibrary is one library's judgement now (ADR 0328 §7, §8, §9).
func judgeLibrary(l declaration.Library) verdict {
v := apply.JudgeLibrary(context.Background(), l)
access := apply.LibraryAccess(l.Library)
var out verdict
if a := v.Act; a != nil {
out.act = &link.AccessActRecord{Act: a.Act, Access: access, Warrant: a.Warrant, By: a.By, State: a.State,
Started: a.Started, Ended: a.Ended, Folders: a.Folders, Files: a.Files, Kept: a.Kept, Links: a.Links,
Other: a.Other, Mounts: a.Mounts, Shared: a.Shared, Linked: a.Linked, Stripped: a.Stripped, Why: a.Why}
}
switch v.Kind {
case apply.VerdictWaitKeep:
out.state, out.reason = link.StateWaiting, "its root holds data the mesh has not taken on the filesystem "+
"it is on now, and waits for the operator's word (novox/hq ADR 0328 §7, §8)"
out.waits = []link.AccessWait{{Kind: link.WaitKindAccess, Access: access, Part: l.Library,
What: apply.KeepWords(l.Library), Placed: v.Placed, Reason: link.WaitKeep}}
case apply.VerdictWaitDisk:
out.state, out.reason = link.StateWaiting, v.Why+" (novox/hq ADR 0328 §9)"
out.waits = []link.AccessWait{{Kind: link.WaitKindAccess, Access: access, Part: l.Library,
What: apply.DiskWords(l.Library), Reason: link.WaitDisk}}
case apply.VerdictUnknown:
// Not a fault and not the operator's: said as not known, and judged again on the next look.
out.state, out.reason = link.StateUnknown, v.Why+"; judged again on the next look"
case apply.VerdictBroken:
out.state, out.reason = link.StateUnhealthy, v.Why
default:
out.state = link.StateHealthy
switch {
case v.Act != nil && v.Act.State == link.ActRunning:
out.state, out.reason = link.StateStarting, "the take act is setting its group under its root"
case v.Act != nil && (v.Act.State == link.ActStopped || v.Act.State == link.ActFailed):
out.state, out.reason = link.StateStarting, "the take act goes on from where it stopped: "+v.Act.Why
}
}
return out
}
// look reads every library now. Whether any verdict changed since the last look, for the loop to say it.
func (j *libraryJudge) look(now time.Time) ([]link.ResourceHealth, bool) {
j.mu.Lock()
ls := append([]declaration.Library(nil), j.libraries...)
j.mu.Unlock()
read := make([]verdict, len(ls))
for i, l := range ls {
v, answered := j.judgeBounded(l)
if !answered {
v = verdict{state: link.StateUnknown, reason: "its root does not answer: judging it did not return " +
"within " + lookBound.String()}
}
read[i] = v
}
// And every tool runner's account the last apply could not put in a group, as a resource of its own (ADR 0334
// §2): unhealthy, never failing the apply.
warnings := apply.RunnerWarnings()
j.mu.Lock()
defer j.mu.Unlock()
changed := false
out := make([]link.ResourceHealth, 0, len(ls)+len(warnings))
for _, w := range warnings {
v := link.ResourceHealth{Module: w.Module, Resource: w.Resource, Kind: link.KindRunner, State: link.StateUnhealthy,
Reason: "the tool runner's account joins no group: " + w.Why, Since: now.UTC(), Streak: 1}
if was, had := j.was[w.Resource]; had && was.Reason == v.Reason {
v.Since, v.Streak = was.Since, was.Streak+1
} else {
changed = true
}
j.was[w.Resource] = v
out = append(out, v)
}
for i, l := range ls {
r := read[i]
id := apply.LibraryAccess(l.Library)
v := link.ResourceHealth{Resource: id, Kind: link.KindLibrary, Target: l.Root, State: r.state,
Reason: r.reason, Since: now.UTC(), AccessWaits: r.waits, Act: r.act}
if was, had := j.was[id]; had && was.State == r.state && was.Reason == r.reason {
v.Since = was.Since
v.Streak = was.Streak
} else {
changed = true
}
if r.state == link.StateUnhealthy {
v.Streak++
}
j.was[id] = v
out = append(out, v)
}
return out, changed
}
// withAccesses adds to a statement every library, as a resource of kind library.
func withAccesses(h *link.Health, vs []link.ResourceHealth) *link.Health {
h.Resources = append(h.Resources, vs...)
return h
}
// accessesHealthy says no library in vs is anything but healthy.
func accessesHealthy(vs []link.ResourceHealth) bool {
for _, v := range vs {
if v.State != link.StateHealthy {
return false
}
}
return true
}
// keeper goes on with take acts stopped at their bound, beside the applies, one at a time (ADR 0328 §7).
type keeper struct {
running atomic.Bool
say func(string)
}
// keeping is the one keeper on this machine.
var keeping = &keeper{say: func(string) {}}
// poke goes on with every stopped take act of the declaration last applied, unless that is running already.
func (k *keeper) poke(ctx context.Context) {
d := accesses.declaration()
if d == nil || len(d.Libraries) == 0 || !k.running.CompareAndSwap(false, true) {
return
}
go func() {
defer k.running.Store(false)
for _, line := range apply.ReconcileLibraries(ctx, d) {
k.say(line)
}
}()
}
// startKeeping has the keeper say what it does.
func startKeeping(ctx context.Context, say func(string)) {
keeping.say = say
keeping.poke(ctx)
}
// accessActFrom is the take act as the controller asks it over the link (ADR 0328 §7): judged against the
// declaration this node keeps, and run beside the applies.
func accessActFrom(statePath string, timeout time.Duration) link.AccessActFunc {
return func(act link.AccessAct) (string, error) {
kept, err := store.ReadDeclared(store.DeclaredPath(statePath))
if errors.Is(err, store.ErrNothingDeclared) {
return "", errors.New("this node keeps no declaration, so it is declared no library")
}
if err != nil {
return "", err
}
d, err := declaration.Parse(kept.Declaration)
if err != nil {
return "", fmt.Errorf("the declaration this node keeps does not read: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
_, said, err := apply.TakeLibrary(ctx, d, act.Access, filepath.Clean(act.Path), act.Warrant, act.By)
if err != nil {
fmt.Fprintf(os.Stderr, "mesh-host: the take act on %s was refused: %v\n", act.Access, err)
return "", err
}
return said, nil
}
}
// withWaiting is held with every id of waiting beside it: what liveness does not judge.
func withWaiting(held, waiting map[string]bool) map[string]bool {
if len(waiting) == 0 {
return held
}
out := map[string]bool{}
for id := range held {
out[id] = true
}
for id := range waiting {
out[id] = true
}
return out
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/link"
)
// A library whose root holds data never taken is said waiting with one access wait: the access `library:<name>`, the
// part its name, words with no path, the filesystem's name, and reason keep (novox/hq ADR 0328 §8); one whose root
// is absent waits with reason disk (§9). Neither carries a module.
func TestALibrarySaysItsWaitWithoutWordsToParse(t *testing.T) {
was := apply.Libraries
apply.Libraries = apply.NewLibraryMemory()
defer func() { apply.Libraries = was }()
dir := t.TempDir()
root := filepath.Join(dir, "media")
if err := os.MkdirAll(filepath.Join(root, "a film"), 0o755); err != nil {
t.Fatal(err)
}
d, err := declaration.Parse([]byte(`{"declaration":1,"groups":[{"name":"mesh-media","gid":62000},` +
`{"name":"mesh-books","gid":62001}],"libraries":[` +
`{"library":"media","root":"` + root + `","group":"mesh-media","gid":62000,"home":true},` +
`{"library":"books","root":"` + filepath.Join(dir, "books") + `","group":"mesh-books","gid":62001,"home":true}],` +
`"owns_nothing":true,"resources":[]}`))
if err != nil {
t.Fatal(err)
}
j := newLibraryJudge()
j.set(d, nil)
vs, _ := j.look(time.Now())
if len(vs) != 2 {
t.Fatalf("the libraries are not said: %+v", vs)
}
books, media := vs[0], vs[1]
if media.Kind != link.KindLibrary || media.Resource != "library:media" || media.Module != "" ||
media.State != link.StateWaiting || len(media.AccessWaits) != 1 {
t.Fatalf("a library holding data is not said waiting: %+v", media)
}
raw, _ := json.Marshal(media.AccessWaits[0])
want := `{"kind":"access","access":"library:media","part":"media","what":"let the mesh keep the library media",` +
`"placed":"` + media.AccessWaits[0].Placed + `","reason":"keep"}`
if string(raw) != want || !strings.HasPrefix(media.AccessWaits[0].Placed, "fs-") {
t.Fatalf("the wait reads %s", raw)
}
if strings.Contains(media.Reason, dir) || len(books.AccessWaits) != 1 || books.AccessWaits[0].Reason != link.WaitDisk {
t.Fatalf("a missing root does not wait for its disk, or a reason names a path: %+v %+v", books, media)
}
}
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/link"
)
// Defends novox/hq issue 160: a machine nobody pushed to still says what it is made of once a day, so the
// newest of it the mesh holds is no older than a day. Before, the reconcile said it only with something
// else (a hold, a firewall, an outward link changed), and a converged machine's picture was as old as the
// last delivery to it.
func TestAMachineNobodyPushedToSaysWhatItIsOnceADay(t *testing.T) {
at := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)
w := &adoptionWatch{now: func() time.Time { return at }}
quiet := link.Report{Outward: []string{"wlan0"},
Profile: map[string]any{"machine": map[string]any{"observed_at": at}}}
if !w.news(quiet) {
t.Fatal("the first reconcile after the engine started did not say what the machine is")
}
w.said(quiet)
for _, after := range []time.Duration{5 * time.Minute, 12 * time.Hour, 22*time.Hour + 55*time.Minute} {
at = time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC).Add(after)
if w.news(quiet) {
t.Errorf("%s after the mesh heard it, an unchanged reconcile was said again", after)
}
}
// The reconcile that falls past 23 hours says it, so it reaches the mesh inside the day.
at = time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC).Add(23*time.Hour + 5*time.Minute)
if !w.news(quiet) {
t.Fatal("a day on, the reconcile did not say what the machine is")
}
w.said(quiet)
at = at.Add(5 * time.Minute)
if w.news(quiet) {
t.Error("once said, it was said again at the next reconcile")
}
}
// A delivery's account carries the profile too: heard, it starts the day again.
func TestADeliveryHeardStartsTheDayAgain(t *testing.T) {
at := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)
w := &adoptionWatch{now: func() time.Time { return at }}
report := link.Report{Outward: []string{"eth0"}, Profile: map[string]any{"capabilities": []any{}}}
w.said(report)
at = at.Add(20 * time.Hour)
w.said(report) // a delivery's apply
at = at.Add(20 * time.Hour)
if w.news(report) {
t.Error("said again unasked 20 hours after a delivery told the mesh")
}
}
// A refusal carries nothing about the machine, so it is never said for being a day old.
func TestARefusalIsNotSaidForTheMachinesSake(t *testing.T) {
at := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)
w := &adoptionWatch{now: func() time.Time { return at }}
if w.machineDue(link.Report{Refused: "no"}) {
t.Error("a refusal was due for what the machine is made of")
}
}
// A laptop that slept still says what it is within a day of wall time (review of mesh-host#92). Go's monotonic
// clock stops while a machine is suspended, and Sub between two times that both carry a monotonic reading uses
// it, so a watch that kept one would count a day asleep as almost nothing. Simulated with a clock that first
// reads now (a monotonic reading), and after the sleep reads the wall time a day on: the watch must keep no
// monotonic reading, and judge the day as the wall clock says it.
func TestALaptopThatSleptStillSaysWhatItIsWithinADay(t *testing.T) {
before := time.Now() // carries a monotonic reading
asleep := before.Add(3 * time.Second)
if asleep.Sub(before) != 3*time.Second {
t.Fatal("the monotonic reading is not what this test simulates with")
}
calls := 0
w := &adoptionWatch{now: func() time.Time {
calls++
if calls == 1 {
return before
}
// The wall clock a day on, as a machine's clock reads after waking.
return asleep.Round(0).Add(24 * time.Hour)
}}
report := link.Report{Outward: []string{"wlan0"}, Profile: map[string]any{"capabilities": []any{}}}
w.said(report)
if strings.Contains(w.machineSaid.String(), "m=") {
t.Fatalf("the watch kept a monotonic reading (%s): a sleep would not count toward the day", w.machineSaid)
}
if !w.news(report) {
t.Error("a day of wall time on, a laptop that slept did not say what it is")
}
}
// And the watch's own clock never carries a monotonic reading.
func TestTheWatchClockIsWallTime(t *testing.T) {
w := &adoptionWatch{}
if s := w.clock().String(); strings.Contains(s, "m=") {
t.Errorf("the watch's clock carries a monotonic reading: %s", s)
}
w = &adoptionWatch{now: time.Now}
if s := w.clock().String(); strings.Contains(s, "m=") {
t.Errorf("a given clock's time kept its monotonic reading: %s", s)
}
}
+50 -17
View File
@@ -241,8 +241,8 @@ func run(ctx context.Context, command string, opts options) error {
// found (novox/hq issue 339).
handedOverFrom(opts.state)
foundState.Store(opts.state)
// And what it knows of the shared folders it keeps is beside its state (novox/hq ADR 0325 §5).
apply.KeptFolders = store.NewKeptFile(opts.state)
// And what it knows of the libraries it keeps is beside its state (novox/hq ADR 0328 §6).
apply.Libraries = store.NewLibraryFile(opts.state)
// And the guard asks the running container runtimes where they keep their data (novox/hq issue 339).
apply.AskRuntimes = apply.ExecRunner
switch command {
@@ -1123,7 +1123,7 @@ func runLink(ctx context.Context, opts options) error {
// this process's, a `reconcile` run by hand, the installer's — reads it (novox/hq issue 224).
sched.RecordWindowsIn(apply.WindowsIn(filepath.Dir(opts.state)))
go sched.Run(ctx)
// The watch on every kept folder and the walks under them, beside the applies (novox/hq ADR 0325 §6, §8).
// The take act's walks that stopped at their bound go on beside the applies (novox/hq ADR 0328 §7).
startKeeping(ctx, func(line string) { say(line) })
// The one liveness judge on this machine (novox/hq ADR 0240), made before anything applies: every
@@ -1236,7 +1236,7 @@ func runLink(ctx context.Context, opts options) error {
// A read-only judgement of the directories this node is declared, asked by the controller (novox/hq
// issue 358): which the walk that follows no link another account could make would refuse.
JudgeDirectories: judgeDirectoriesFrom(opts.state),
// The take act on an access's folder, on the operator's warrant (novox/hq ADR 0325 §8).
// The take act on a library, on the operator's warrant (novox/hq ADR 0328 §7).
AccessAct: accessActFrom(opts.state, opts.timeout),
Say: say,
Timeout: opts.timeout,
@@ -1358,6 +1358,32 @@ type adoptionWatch struct {
last string
// faulty is whether the last report the mesh heard said an apply refused or failed (novox/hq issue 233).
faulty bool
// machineSaid is when the mesh last heard what this machine is made of (novox/hq issue 160), and now the
// clock it is judged by (time.Now when nil).
machineSaid time.Time
now func() time.Time
}
// machineEvery is how old what a machine said of itself may grow before a reconcile says it again unasked
// (novox/hq issue 160): a machine nobody pushed to still says it once a day, so no fact the mesh holds of it
// is older than a day. Under a day by more than one reconcile interval, so the reconcile that says it falls
// inside the day rather than just past it.
const machineEvery = 23 * time.Hour
// clock is the wall time, its monotonic reading stripped (Round(0)). **Wall, because a laptop sleeps**: Go's
// monotonic clock does not advance while the machine is suspended, so a day judged by it on a laptop that slept
// for half of it is a day and a half of wall time, and what the mesh holds of the machine is older than a day.
func (w *adoptionWatch) clock() time.Time {
if w.now != nil {
return w.now().Round(0)
}
return time.Now().Round(0)
}
// machineDue is whether a report carries what the machine is made of and the mesh last heard it a day ago
// or more. A refusal carries none, so it never is.
func (w *adoptionWatch) machineDue(r link.Report) bool {
return r.Refused == "" && len(r.Profile) > 0 && w.clock().Sub(w.machineSaid) >= machineEvery
}
// fingerprint is what a report says about adoption: each hold and whether it changed, the
@@ -1422,7 +1448,8 @@ func applyState(r link.Report) string {
// faulty is whether a report says an apply refused or failed.
func faulty(r link.Report) bool { return r.Refused != "" || len(r.Failed) > 0 }
// news is whether a reconcile's report is worth saying unasked: it says something the last one the mesh heard
// news is whether a reconcile's report is worth saying unasked: what the machine is made of last reached the
// mesh a day ago or more (novox/hq issue 160); or it says something the last one the mesh heard
// did not, and it is about the machine (worthSaying) — or about how the apply went, refused or failed now, or
// clean after the last one the mesh heard was not (novox/hq issue 233). A refusal says nothing about the
// machine, but that the machine refuses is what the mesh raises a condition from, and a clean apply after it is
@@ -1430,6 +1457,10 @@ func faulty(r link.Report) bool { return r.Refused != "" || len(r.Failed) > 0 }
func (w *adoptionWatch) news(r link.Report) bool {
w.mu.Lock()
defer w.mu.Unlock()
// What the machine is made of, once a day whatever else changed (novox/hq issue 160).
if w.machineDue(r) {
return true
}
if adoptionFingerprint(r) == w.last {
return false
}
@@ -1450,6 +1481,9 @@ func (w *adoptionWatch) said(r link.Report) {
defer w.mu.Unlock()
w.last = adoptionFingerprint(r)
w.faulty = faulty(r)
if len(r.Profile) > 0 {
w.machineSaid = w.clock()
}
}
// changed is differs and said together, for a report published as it is made.
@@ -1602,8 +1636,7 @@ const (
// judgeWhatRuns looks at every long-running resource every liveness.LookEvery and says the statement on
// the bus when a state changed, again every minute while one is not healthy, and every five minutes
// anyway. A statement the bus did not take is said at the next look. It reads; it never acts (ADR 0240
// rule 6) — but for the sweep of a kept folder, which ADR 0325 §10 has every look make: it puts back an entry or a
// mask an app narrowed, and nothing else.
// rule 6) — but for going on with a take act stopped at its bound (ADR 0328 §7), which runs beside it.
func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, say link.Announce) {
ticker := time.NewTicker(liveness.LookEvery)
defer ticker.Stop()
@@ -1642,8 +1675,8 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa
}
}
}
// Every access, judged on every look, its kept folder swept (novox/hq ADR 0325 §10); and a walk stopped
// at its bound goes on.
// Every library, judged on every look (novox/hq ADR 0328 §8, §9); and a take act stopped at its bound
// goes on, beside.
accessSt, accessesChanged := accesses.look(time.Now())
keeping.poke(ctx)
owed = owed || accessesChanged
@@ -1700,10 +1733,10 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa
// healthAsReported is a statement as the report and the event carry it.
func healthAsReported(st liveness.Statement, ns *network.Statement) *link.Health {
// IdentityContract: this engine reads a resource's declared `health` and judges it (ADR 0240 Phase B), judges a
// user's declared `root` (novox/hq ADR 0266), and reads a declaration's identities, a container's identity and
// an access's users (novox/hq ADR 0325), which is what tells the controller it may be sent any of those fields.
h := &link.Health{Contract: link.IdentityContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}}
// LibraryContract: this engine reads a resource's declared `health` and judges it (ADR 0240 Phase B), judges a
// user's declared `root` (novox/hq ADR 0266), and reads a declaration's identities, groups and libraries
// (novox/hq ADR 0328), which is what tells the controller it may be sent any of those fields.
h := &link.Health{Contract: link.OwnGroupContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}}
for _, r := range st.Resources {
h.Resources = append(h.Resources, link.ResourceHealth{Module: r.Module, Resource: r.ID, Kind: r.Kind,
Target: r.Target, State: r.State, Reason: r.Reason, Since: r.Since.UTC(), Streak: r.Streak,
@@ -2084,9 +2117,9 @@ func applyAndKeepHeld(ctx context.Context, opts options, raw []byte, signed *sto
// What runs here for a module, and how each is (novox/hq ADR 0240): judged from what was just
// applied, so every resource this apply started says `starting` and the gate waits for it.
if j := judging; j != nil {
// A container that waits for an accessed folder not there was not created: the access says why, and
// liveness judges it neither alive nor dead (novox/hq ADR 0325 §9).
j.Set(liveness.LongRunning(declared, withWaiting(held, apply.WaitingOnAccess(declared))))
// A container that waits for a library whose disk or mount is not there was not created: the library says
// why, and liveness judges it neither alive nor dead (novox/hq ADR 0328 §9).
j.Set(liveness.LongRunning(declared, withWaiting(held, apply.WaitingOnLibrary(declared))))
st, _ := j.Look(ctx)
netJudge.declare(declaredResolvConf(declared))
var netSt *network.Statement
@@ -2109,7 +2142,7 @@ func applyAndKeepHeld(ctx context.Context, opts options, raw []byte, signed *sto
accountSt = &as
}
accesses.set(declared, held)
// What the apply changed in who uses a kept folder is walked under it now, beside (ADR 0325 §8).
// A take act stopped at its bound goes on, beside (ADR 0328 §7).
keeping.poke(ctx)
accessSt, _ := accesses.look(time.Now())
report.Health = withAccesses(withFoundDirectories(withAccounts(withUnits(healthAsReported(st, netSt), unitSt),
+4 -4
View File
@@ -87,10 +87,10 @@ func TestTheStatementSaysAnAccountJudgedForRoot(t *testing.T) {
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 {
// At RootContract or later: an engine that also reads an identity's runner says OwnGroupContract (novox/hq ADR
// 0334), one above ADR 0328's LibraryContract.
if h.Contract < link.RootContract || link.RootContract != 3 || h.Contract != link.OwnGroupContract ||
link.OwnGroupContract != link.LibraryContract+1 {
t.Fatalf("contract %d", h.Contract)
}
got := map[string]string{}
+19 -33
View File
@@ -2,51 +2,37 @@
## `foundation-first-node.lock`
What a machine must be before a mesh exists — the bootstrap in
[novox/hq `07-the-foundation.md`](https://git.novox.be/novox/hq), whole:
The one genesis bundle (novox/hq ADR 0331): what a machine must be before a mesh exists — the
bootstrap in [novox/hq `07-the-foundation.md`](https://git.novox.be/novox/hq), whole, on the bus the
mesh runs on:
```
0 a container runtime
0 a container runtime, and a filter before anything listens
1 the store runs
2 a database per context `inventory` and `identity`
2 a database per context `inventory`, `identity` and `licences`
3 those contexts' schemas mesh-controller migrate
4 the broker runs with a certificate it generated itself
5 the control plane runs mesh-controller serve
4 the bus's certificate mesh-controller broker certificate
5 the bus runs NATS, with the controller's account as its first user
6 the control plane runs mesh-controller serve, given the bus (MESH_BUS_NATS)
```
**A machine that applies this is a mesh** — one node, with nothing joined to it yet, which is
exactly what the first node is (novox/hq ADR 0004). From here it hands out tokens and everything
else joins the ordinary way.
This file said it stopped at step 4 for longer than that was true, which is its own small lesson:
a comment about what something does not do is a comment nobody updates.
It is a **template**: `mesh-bootstrap` reads it, makes the store's superuser and the controller's
account on the bus, and writes them where the template says `bootstrap` (internal/bootstrap,
rootsecrets.go). A template whose bus connection it does not recognise is refused at preflight,
naming the placeholder it looked for. The bundle that raised the previous broker was deleted with
ADR 0331; there is one bundle, and every lab bed names it.
Build a host carrying it:
Build an installer carrying a builder, and point it at this:
```
make host SYSTEM=arch BUNDLE=examples/foundation-first-node.lock
make bootstrap IMAGE=<builder image>
mesh-bootstrap --bundle examples/foundation-first-node.lock …
```
**The registry address and digests have to be replaced before this is useful.** They are written
as `192.0.2.250:5000/…@sha256:…` because a digest belongs to whatever registry serves it — here,
one a lab scenario raises, which reports its digests when it comes up. That is not a placeholder
to be tidied away: a bundle is built *for a target*, and which registry that target pulls from is
part of the target.
### What was verified, and how
On a lab machine confirmed sealed — `curl https://example.com` times out, the lab registry answers
200 — the whole bundle applied from bare: eight resources, `inventory` created and `mesh` nowhere,
the `node` table present with its indexes, the migration recorded, and LavinMQ answering
`lavinmqctl status` with AMQP listening on 5672.
Three consecutive reconciles after that: **already matches — 8 resource(s) checked**, each time.
Then the machine was **rebooted**, and everything came back: docker from `boot: enabled`, both
containers because the host creates every container `--restart unless-stopped`
(`internal/apply/apply.go`), the schema intact in its named volume, and reconcile still finding
nothing to do.
The reboot is worth doing rather than assuming. Nothing in the declaration asks for a container to
return, so that it does is a property of the host, and the only way to know it holds is to take
the machine away and give it back.
**The registry address is the lab's.** The images are written as `192.0.2.250:5000/…@sha256:…`; the
lab rewrites the third-party ones to upstream references by the same digest, and the installer
replaces the control plane's with the image it built.
+31 -1
View File
@@ -7,6 +7,7 @@ package examples
import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
@@ -48,7 +49,7 @@ func TestTheBundleCarriesTheFoundationAndTheControlPlaneAndNothingElse(t *testin
}
sort.Strings(images)
want := []string{"lavinmq", "mesh-controller", "postgres"}
want := []string{"mesh-controller", "nats", "postgres"}
if strings.Join(images, ",") != strings.Join(want, ",") {
t.Fatalf("the bundle carries %v; expected exactly %v.\n\n"+
"Adding one is a change to what every first node becomes, and to ADR 0006's "+
@@ -67,3 +68,32 @@ func TestTheControlPlaneIsCarriedToo(t *testing.T) {
t.Fatal("nothing in the bundle starts the control plane, so the machine would raise a " +
"foundation and stop")
}
// One genesis bundle, on the bus the mesh runs on (novox/hq ADR 0331 §2). Two bundles let every bed
// and README name the one that could not raise a mesh, for weeks (issue 146).
func TestThereIsOneGenesisBundle(t *testing.T) {
bundles, err := filepath.Glob("*.lock")
if err != nil {
t.Fatal(err)
}
if len(bundles) != 1 || bundles[0] != "foundation-first-node.lock" {
t.Fatalf("examples/ holds %v; it holds one genesis bundle, foundation-first-node.lock", bundles)
}
}
// The control plane the bundle raises is given the bus, with the account the installer fills and
// the certificate it pins (ADR 0331 §4); without it, it crash-loops for ever (issue 146).
func TestTheControlPlaneIsGivenTheBus(t *testing.T) {
for _, r := range bundle(t).Resources {
c, ok := r.(*declaration.Container)
if !ok || !strings.Contains(c.Image, "mesh-controller") {
continue
}
if !strings.HasPrefix(c.Env["MESH_BUS_NATS"], "nats://controller:bootstrap@") || c.Env["MESH_BROKER_CERTIFICATE"] == "" {
t.Fatalf("the control plane is given the bus %q and the certificate %q",
c.Env["MESH_BUS_NATS"], c.Env["MESH_BROKER_CERTIFICATE"])
}
return
}
t.Fatal("nothing in the bundle starts the control plane")
}
-200
View File
@@ -1,200 +0,0 @@
// foundation-first-node-nats.lock — what a machine must be before a mesh exists, on the bus being
// built (novox/hq ADR 0106, design 25).
//
// The same twelve steps as foundation-first-node.lock, with one difference that matters: **the mesh
// writes its own user list, and at genesis there is no mesh yet to write it.** So this carries the
// first one — the controller's own account, at a well-known bootstrap password, exactly as the store
// is reached at `postgres:bootstrap` and the old bus at `guest:guest`. It is rotated with those, and
// from the controller's first composition onward the file is the controller's to write.
//
// The accounts file is its own file beside the server's configuration, because the server's own
// settings belong to whoever raises it and the users belong to the mesh (design 25 §4). Both live in
// one directory, of necessity: an include path is resolved relative to the including file's own
// directory, so a server given an absolute one looks for it underneath that directory and refuses to
// start.
//
// No `verify` on the TLS block, deliberately — that setting makes the server demand a *client*
// certificate, and nothing in the mesh presents one: a host pins this server's exact certificate and
// authenticates with a password (ADR 0004, design 25 §4).
{
"declaration": 1,
"resources": [
{
"id": "container-runtime",
"type": "package",
"package": "docker"
},
{
"id": "container-runtime-running",
"type": "service",
"unit": "docker.service",
"state": "running",
"boot": "enabled"
},
// **A filter before anything listens** (novox/hq issue 054, ADR 0088). The store and the
// broker are adopted as modules later and so bind to every interface from the moment they
// start; the packet filter that governs who may reach them is a module too, installed a
// dozen steps later. Between the two, a control-node facing the network had its store and
// its bus open to anyone who could reach the machine. So the foundation carries a filter of
// its own — the same table the filter module will replace wholesale once it can derive one:
// drop by default, keep loopback, replies, ssh and the mesh's own ports (the bus a node
// enrols over, the registry a node pulls from), and let the container runtime's own
// networks through the forward chain so containers keep working. A published container port
// is forwarded, never input (issue 047), which is why the forward chain is where the store's
// and broker's ports are refused from outside — and a container on this machine dialling a
// port this machine publishes reaches it through the runtime's proxy, which IS input, which
// is why the bus and the registry are opened in both chains, exactly as the derived ruleset
// does.
{
"id": "base-filter-package",
"type": "package",
"package": "nftables"
},
{
"id": "base-filter",
"type": "file",
"path": "/etc/nftables.conf",
"mode": "0644",
"content": "#!/usr/sbin/nft -f\n# the foundation's own filter, until the mesh derives one (novox/hq issue 054)\ntable inet mesh {}\ndelete table inet mesh\n\ntable inet mesh {\n\tchain input {\n\t\ttype filter hook input priority filter; policy drop;\n\t\tct state established,related accept\n\t\tct state invalid drop\n\t\tiif lo accept\n\t\ticmp type echo-request accept\n\t\ticmpv6 type { echo-request, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept\n\t\t# ssh, from anywhere — never closed\n\t\ttcp dport 22 accept\n\t\t# the mesh's own, from anywhere: the bus a node enrols over and a container on this machine reaches through the proxy, the registry a node pulls from\n\t\ttcp dport 5671 accept\n\t\ttcp dport 5000 accept\n\t}\n\tchain output {\n\t\ttype filter hook output priority filter; policy accept;\n\t}\n\tchain forward {\n\t\ttype filter hook forward priority filter; policy drop;\n\t\tct state established,related accept\n\t\tct state invalid drop\n\t\t# the container runtime's bridge networks, and the networks its compose files are given\n\t\tip saddr 172.16.0.0/12 accept\n\t\tip saddr 192.168.128.0/17 accept\n\t\t# the mesh's own: the bus a node enrols over, the registry a node pulls from\n\t\tct original proto-dst 5671 accept\n\t\tct original proto-dst 5000 accept\n\t}\n}\n"
},
{
"id": "base-filter-loaded",
"type": "service",
"unit": "nftables.service",
"state": "running",
"boot": "enabled",
"restart-on": ["base-filter"]
},
{
"id": "store",
"type": "container",
"name": "mesh-store",
"image": "192.0.2.250:5000/postgres@sha256:7abf537131b66ed5af448d90653abf1679b0c7e9a1f07efdd4c3108a401b259a",
"env": {
"POSTGRES_PASSWORD": "bootstrap",
"PGDATA": "/var/lib/postgresql/data/pgdata"
},
"ports": ["5432:5432"],
"volumes": ["mesh-store-data:/var/lib/postgresql/data"]
},
// Over TCP, not the socket. While the store initialises it runs a temporary server on the
// socket ONLY, then stops it and starts the real one — so a socket check passes, the action
// exits happy, and the verify a moment later lands in the gap and fails. The action and its
// verify must ask the same question, or the action can succeed into a state verify rejects.
{
"id": "store-ready",
"type": "action",
"in": "mesh-store",
"command": ["sh", "-c", "for i in $(seq 1 180); do pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && exit 0; sleep 1; done; echo 'the store did not answer within 180s; its own last words follow'; pg_isready -h 127.0.0.1 -U postgres; tail -n 20 /var/lib/postgresql/data/log/*.log 2>/dev/null; exit 1"],
"verify": ["pg_isready", "-h", "127.0.0.1", "-U", "postgres"]
},
{
"id": "inventory-database",
"type": "action",
"in": "mesh-store",
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE inventory'"],
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw inventory"]
},
{
"id": "identity-database",
"type": "action",
"in": "mesh-store",
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE identity'"],
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw identity"]
},
// Each context owns its own database (novox/hq ADR 0008). A third one is a third database,
// created the same way and named the same way — which is the whole of adding a context to the
// bootstrap, and is why the count is not something the foundation has an opinion about.
{
"id": "licences-database",
"type": "action",
"in": "mesh-store",
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE licences'"],
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw licences"]
},
{
"id": "context-schemas",
"type": "action",
"command": ["docker", "run", "--rm", "--network", "container:mesh-store",
"-e", "MESH_STORE_INVENTORY=postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable",
"-e", "MESH_STORE_IDENTITY=postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable",
"-e", "MESH_STORE_LICENCES=postgres://postgres:bootstrap@127.0.0.1:5432/licences?sslmode=disable",
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"migrate"],
"verify": ["sh", "-c", "docker exec mesh-store psql -U postgres -d inventory -tAc \"select to_regclass('public.node')\" | grep -qx node && docker exec mesh-store psql -U postgres -d identity -tAc \"select to_regclass('public.signing_key')\" | grep -qx signing_key && docker exec mesh-store psql -U postgres -d licences -tAc \"select to_regclass('public.licence')\" | grep -qx licence"]
},
{
"id": "bus-certificate",
"type": "action",
// **The mesh makes its own** (novox/hq 04-ISSUES/146). This ran `openssl` inside the
// broker's image while the broker was one that carried it; the bus that replaced it has a
// shell and no openssl, and no other image the bundle names has one either. So the program
// that needs the certificate writes it — already on this machine, since the schema step ran
// it, and asking nothing of the image it writes into. Self-signed on purpose: a host pins
// this server's exact certificate (novox/hq ADR 0004), and at this moment there is no mesh
// to ask an authority of.
// `--user 0:0` because the volume is root's and this image runs as nobody, which is right
// for the long-running control plane and wrong for a one-shot writing into a fresh volume.
"command": ["docker", "run", "--rm", "--user", "0:0", "-v", "mesh-broker-tls:/tls",
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"broker", "certificate", "--into", "/tls"],
"verify": ["docker", "run", "--rm", "--user", "0:0", "-v", "mesh-broker-tls:/tls",
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"broker", "certificate", "--check", "--into", "/tls"]
},
{
"id": "bus-conf-dir",
"type": "directory",
"path": "/var/lib/mesh-bus-conf",
"mode": "0700"
},
{
"id": "bus-conf",
"type": "file",
"path": "/var/lib/mesh-bus-conf/nats.conf",
"mode": "0644",
"content": "port: 4222\nhttp: 127.0.0.1:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\njetstream {\n store_dir: \"/data\"\n}\n\ninclude accounts.conf\n"
},
{
"id": "bus-accounts",
"type": "file",
"path": "/var/lib/mesh-bus-conf/accounts.conf",
"mode": "0600",
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.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",
"type": "container",
"name": "mesh-broker",
"image": "192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927",
"ports": ["5671:4222", "127.0.0.1:8222:8222"],
"volumes": ["mesh-broker-data:/data", "mesh-broker-tls:/tls:ro", "/var/lib/mesh-bus-conf:/etc/nats:ro"],
"args": ["-c", "/etc/nats/nats.conf", "-js"]
},
{
"id": "broker-ready",
"type": "action",
"command": ["sh", "-c", "for i in $(seq 1 60); do docker run --rm --network host --entrypoint sh 192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 -c 'nc -z 127.0.0.1 5671' >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1"],
"verify": ["sh", "-c", "docker run --rm --network host --entrypoint sh 192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 -c 'nc -z 127.0.0.1 5671'"]
},
{
"id": "control-plane",
"type": "container",
"name": "mesh-controller",
"image": "192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"network": "host",
"args": ["serve"],
"volumes": ["mesh-broker-tls:/broker-tls:ro"],
"env": {
"MESH_STORE_INVENTORY": "postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable",
"MESH_STORE_IDENTITY": "postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable",
"MESH_STORE_LICENCES": "postgres://postgres:bootstrap@127.0.0.1:5432/licences?sslmode=disable",
"MESH_BUS_NATS": "nats://controller:bootstrap@127.0.0.1:5671",
"MESH_BROKER_ADDRESS": "192.0.2.10:5671",
"MESH_BROKER_CERTIFICATE": "/broker-tls/tls.crt"
}
}
]
}
+67 -37
View File
@@ -1,36 +1,39 @@
// foundation-first-node.lock — what a machine must be before a mesh exists.
// foundation-first-node.lock — what a machine must be before a mesh exists: the one genesis bundle
// (novox/hq ADR 0331 §2), on the bus the mesh runs on (ADR 0106, ADR 0131, design 25).
//
// The whole bootstrap (novox/hq 03-DESIGN/01-to-be/07-the-foundation.md): a container runtime, a
// store, a database per context, those contexts' schemas, the broker, and the control plane
// running on top of them.
// filter, a store, a database per context, those contexts' schemas, the bus with its certificate
// and its first user list, and the control plane running on top of them.
//
// It stopped before the control plane once, and this comment said so for longer than it was true.
// **The mesh writes its own user list, and at genesis there is no mesh yet to write it.** So this
// carries the first one — the controller's own account — and the control plane's address names the
// same account. Both say `bootstrap`, and both are placeholders: the installer makes the account's
// password, puts it in the address and its hash in the list (mesh-host internal/bootstrap
// rootsecrets.go), and refuses a bundle whose bus connection it does not recognise. Until the first
// node has enrolled the installer places the list the controller composes; from the bus's adoption
// as the `nats` module on, the module receives it like any module (ADR 0331 §1, §5).
//
// The broker generates its OWN certificate, in its own image, into a volume it then mounts read
// only. Self-signed, because at this moment there is no mesh to issue one and no public name to
// obtain one for -- and it does not matter, because what a joining node checks is the fingerprint
// pinned in its token, not a chain or a name (novox/hq ADR 0004). The subject is decoration.
// The accounts file is its own file beside the server's configuration, because the server's own
// settings belong to whoever raises it and the users belong to the mesh (design 25 §4). Both live in
// one directory, of necessity: an include path is resolved relative to the including file's own
// directory, so a server given an absolute one looks for it underneath that directory and refuses to
// start.
//
// No `verify` on the TLS block, deliberately — that setting makes the server demand a *client*
// certificate, and nothing in the mesh presents one: a host pins this server's exact certificate and
// authenticates with a password (ADR 0004, design 25 §4). The control plane pins it too, through
// MESH_BROKER_CERTIFICATE, and dials the bus on the machine's own loopback.
//
// PINNED BY DIGEST, and the digest is not decoration: a tag can be made to point at a different
// image, and this file is applied on a machine with no mesh to ask about anything. These digests
// belong to the registry the lab raises, which is what a real node pulls from anyway — what is
// required is a reference that is exact and cannot move (novox/hq ADR 0006).
//
// The store waits up to three minutes rather than one. A machine that has just pulled the
// image and is running initdb for the first time can take longer than sixty seconds, and it
// failed that way three times in the lab -- a flaky bootstrap that a second run always fixed,
// which is the worst kind because it teaches people to run things twice.
//
// It failed a fourth time on 2026-08-30, on a loaded machine, and the report was `docker exited
// 1:` with nothing after the colon. Raising the timeout again would treat the symptom; what makes
// a retry the only available response is a timeout that reports nothing. So the wait now says
// what it saw before giving up.
// image, and this file is applied on a machine with no mesh to ask about anything (ADR 0006). The
// registry prefix is the lab's; the digests are upstream's.
//
// The store's data is a NAMED VOLUME, not a directory on the machine. A directory the host
// creates is owned by root, and the database runs as somebody else inside the container — so it
// could not write, and the container crash-looped. A named volume lets the image set up its own
// ownership, and outlives the container, which is what you want for the thing holding the mesh's
// state.
{
"declaration": 1,
"resources": [
@@ -139,30 +142,58 @@
"verify": ["sh", "-c", "docker exec mesh-store psql -U postgres -d inventory -tAc \"select to_regclass('public.node')\" | grep -qx node && docker exec mesh-store psql -U postgres -d identity -tAc \"select to_regclass('public.signing_key')\" | grep -qx signing_key && docker exec mesh-store psql -U postgres -d licences -tAc \"select to_regclass('public.licence')\" | grep -qx licence"]
},
{
"id": "broker-certificate",
"id": "bus-certificate",
"type": "action",
"command": ["docker", "run", "--rm", "--entrypoint", "sh", "-v", "mesh-broker-tls:/tls",
"192.0.2.250:5000/cloudamqp/lavinmq@sha256:b117c254e6e269a29db479e6b410ca4e46e035b4981e49d24b159673ef09d336",
"-c", "test -f /tls/tls.crt || (openssl req -x509 -newkey rsa:2048 -nodes -keyout /tls/tls.key -out /tls/tls.crt -days 3650 -subj '/CN=mesh-broker' >/dev/null 2>&1 && chmod 644 /tls/tls.crt && chmod 600 /tls/tls.key)"],
"verify": ["docker", "run", "--rm", "--entrypoint", "sh", "-v", "mesh-broker-tls:/tls",
"192.0.2.250:5000/cloudamqp/lavinmq@sha256:b117c254e6e269a29db479e6b410ca4e46e035b4981e49d24b159673ef09d336",
"-c", "test -s /tls/tls.crt && openssl x509 -in /tls/tls.crt -noout"]
// **The mesh makes its own** (novox/hq 04-ISSUES/146). This ran `openssl` inside the
// broker's image while the broker was one that carried it; the bus that replaced it has a
// shell and no openssl, and no other image the bundle names has one either. So the program
// that needs the certificate writes it — already on this machine, since the schema step ran
// it, and asking nothing of the image it writes into. Self-signed on purpose: a host pins
// this server's exact certificate (novox/hq ADR 0004), and at this moment there is no mesh
// to ask an authority of.
// `--user 0:0` because the volume is root's and this image runs as nobody, which is right
// for the long-running control plane and wrong for a one-shot writing into a fresh volume.
"command": ["docker", "run", "--rm", "--user", "0:0", "-v", "mesh-broker-tls:/tls",
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"broker", "certificate", "--into", "/tls"],
"verify": ["docker", "run", "--rm", "--user", "0:0", "-v", "mesh-broker-tls:/tls",
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"broker", "certificate", "--check", "--into", "/tls"]
},
{
"id": "bus-conf-dir",
"type": "directory",
"path": "/var/lib/mesh-bus-conf",
"mode": "0700"
},
{
"id": "bus-conf",
"type": "file",
"path": "/var/lib/mesh-bus-conf/nats.conf",
"mode": "0644",
"content": "port: 4222\nhttp: 127.0.0.1:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\njetstream {\n store_dir: \"/data\"\n}\n\ninclude accounts.conf\n"
},
{
"id": "bus-accounts",
"type": "file",
"path": "/var/lib/mesh-bus-conf/accounts.conf",
"mode": "0600",
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.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",
"type": "container",
"name": "mesh-broker",
"image": "192.0.2.250:5000/cloudamqp/lavinmq@sha256:b117c254e6e269a29db479e6b410ca4e46e035b4981e49d24b159673ef09d336",
"ports": ["5671:5671", "5672:5672", "127.0.0.1:15672:15672"],
"volumes": ["mesh-broker-data:/var/lib/lavinmq", "mesh-broker-tls:/tls:ro"],
"args": ["--amqps-port=5671", "--cert=/tls/tls.crt", "--key=/tls/tls.key"]
"image": "192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927",
"ports": ["5671:4222", "127.0.0.1:8222:8222"],
"volumes": ["mesh-broker-data:/data", "mesh-broker-tls:/tls:ro", "/var/lib/mesh-bus-conf:/etc/nats:ro"],
"args": ["-c", "/etc/nats/nats.conf", "-js"]
},
{
"id": "broker-ready",
"type": "action",
"in": "mesh-broker",
"command": ["sh", "-c", "for i in $(seq 1 60); do lavinmqctl status >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1"],
"verify": ["lavinmqctl", "status"]
"command": ["sh", "-c", "for i in $(seq 1 60); do docker run --rm --network host --entrypoint sh 192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 -c 'nc -z 127.0.0.1 5671' >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1"],
"verify": ["sh", "-c", "docker run --rm --network host --entrypoint sh 192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 -c 'nc -z 127.0.0.1 5671'"]
},
{
"id": "control-plane",
@@ -176,8 +207,7 @@
"MESH_STORE_INVENTORY": "postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable",
"MESH_STORE_IDENTITY": "postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable",
"MESH_STORE_LICENCES": "postgres://postgres:bootstrap@127.0.0.1:5432/licences?sslmode=disable",
"MESH_BROKER_AMQP": "amqp://guest:guest@127.0.0.1:5672/",
"MESH_BROKER_MANAGEMENT": "http://guest:guest@127.0.0.1:15672",
"MESH_BUS_NATS": "nats://controller:bootstrap@127.0.0.1:5671",
"MESH_BROKER_ADDRESS": "192.0.2.10:5671",
"MESH_BROKER_CERTIFICATE": "/broker-tls/tls.crt"
}
+29
View File
@@ -285,3 +285,32 @@ func (e Exec) heldBy(pid string) (map[string]bool, error) {
}
return held, nil
}
// IDs is `getent passwd`: an account's uid and primary gid, and whether the user database names it at all. A
// database that does not answer is an error, never "no such account".
func (e Exec) IDs(ctx context.Context, account string) (uid, gid int, found bool, err error) {
out, err := e.Run(ctx, "getent", "passwd", account)
if err != nil {
if strings.Contains(err.Error(), "exited 2") || strings.Contains(err.Error(), "exit status 2") {
return 0, 0, false, nil
}
return 0, 0, false, fmt.Errorf("the user database did not answer about %q: %w", account, err)
}
fields := strings.Split(strings.TrimSpace(out), ":")
if len(fields) < 4 {
return 0, 0, false, fmt.Errorf("the user database gave %q for %q, which is not a passwd entry",
strings.TrimSpace(out), account)
}
// getent reads an all-digit key as a uid: "1000" answers with whoever has uid 1000. Only the account named is
// the one asked for.
if fields[0] != account {
return 0, 0, false, fmt.Errorf("the user database answered %q with the account %q: an account is named, "+
"never numbered", account, fields[0])
}
uid, uerr := strconv.Atoi(fields[2])
gid, gerr := strconv.Atoi(fields[3])
if uerr != nil || gerr != nil {
return 0, 0, false, fmt.Errorf("the user database gave no numbers for %q", account)
}
return uid, gid, true, nil
}
+32
View File
@@ -0,0 +1,32 @@
package accounts
import (
"context"
"errors"
"strings"
"testing"
)
// An account is read by its name (novox/hq ADR 0325 §4): getent reads an all-digit key as a uid, so "1000" would
// answer with whoever has uid 1000 — refused, never taken for the account named.
func TestAnAccountIsReadByItsNameNeverItsNumber(t *testing.T) {
db := map[string]string{
"nox": "nox:x:1003:1003::/home/nox:/bin/sh",
"1000": "jochen:x:1000:1000::/home/jochen:/bin/zsh", // what getent answers for a uid
}
e := Exec{Run: func(_ context.Context, name string, args ...string) (string, error) {
if line, ok := db[args[len(args)-1]]; ok {
return line + "\n", nil
}
return "", errors.New("getent exited 2")
}}
if uid, gid, found, err := e.IDs(context.Background(), "nox"); err != nil || !found || uid != 1003 || gid != 1003 {
t.Fatalf("nox: %d %d %v %v", uid, gid, found, err)
}
if _, _, found, err := e.IDs(context.Background(), "1000"); found || err == nil || !strings.Contains(err.Error(), "jochen") {
t.Fatalf("an all-digit name was read as a uid: found=%v err=%v", found, err)
}
if _, _, found, err := e.IDs(context.Background(), "ghost"); found || err != nil {
t.Fatalf("an absent account: found=%v err=%v", found, err)
}
}
-242
View File
@@ -1,242 +0,0 @@
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)
}
+176 -42
View File
@@ -41,6 +41,9 @@ type Outcome struct {
ID string `json:"id"`
Type string `json:"type"`
Target string `json:"target"`
// changes is what a tool runner leaving a group changes (`library:<name>`, `identity:<module>`): what the runner's
// unit names under restart-on. Never said.
changes string
// Action is created · updated · unchanged · corrected · removed — and held-still, a container
// a maintenance window is holding stopped, left for the next apply after it (novox/hq issue 224).
//
@@ -262,7 +265,7 @@ func ApplyMindingWindows(
}
report = Report{}
// **No module identity is root** (novox/hq ADR 0325 §11): an identity or a container told a root number refuses
// **No module identity is root** (novox/hq ADR 0328 §15): an identity or a container told a root number refuses
// the declaration whole, before anything of it is made or started.
if err := refuseRootIdentities(d); err != nil {
return report, known, &Error{Resource: "the declaration's identities", Err: err, Done: report}
@@ -349,12 +352,8 @@ func ApplyMindingWindows(
case declaration.TypeUser:
action, detail, err = removeUser(ctx, sys, orphan, run, wanted, handed)
case declaration.TypeAccess:
// The folder is left as it is: what the mesh added to it is taken out by the walk once no access names
// it (novox/hq ADR 0325 §8), never by removing the access's record.
// The path is left as it is (novox/hq ADR 0051); what ADR 0312's grant put an account in is taken back.
action, detail = "forgotten", "an operator-owned path is never the host's to remove"
if _, kept := keptRecord(filepath.Clean(orphan.Target)); kept {
detail += "; the entries the mesh added go once no access names it"
}
gave, left, said := takeBackGranted(ctx, sys, orphan, run, wanted, handed)
if said != "" {
detail = joinDetail(detail, said)
@@ -539,11 +538,6 @@ func ApplyMindingWindows(
// which declared directories were already here: one this apply makes is the mesh's, one found may not be.
refreshRuntimeRoots(ctx)
in.refused = refusedPlaces(d.Resources)
// What every declared access asks of its folder, several accesses of one folder as one (novox/hq ADR 0325 §5),
// and every accessed folder, which no container mounts while it is not there (§9).
in.folders = foldersOf(d.Resources, in.refused)
in.identities = len(d.Identities) > 0
in.accesses = accessPaths(d.Resources, in.refused, in.identities)
in.agentHomes = rootNeverHomes(d)
in.dirsBefore = map[string]bool{}
for _, resource := range d.Resources {
@@ -585,17 +579,48 @@ func ApplyMindingWindows(
sort.Strings(out)
return out
}
// **Every module's identity is made before anything of it is applied** (novox/hq ADR 0325 §3): a directory
// it owns is given to it, and a container is told to run as it. One that cannot be made — its name or numbers
// another's here — fails, naming whose, and its module is not attempted; the rest of the machine is.
// **Every library's group, then every module's identity, is made before anything of it is applied** (novox/hq
// ADR 0328 §3, §4): a directory a module owns is given to it, and its user is in its libraries' groups. One that
// cannot be made — its name or numbers another's here — fails, naming whose, and what needs it is not
// attempted; the rest of the machine is.
var identityFailures []*Error
if len(d.Identities) > 0 {
// warnings is every tool runner's account that joins no group this apply: said, never failing it.
var warnings []RunnerWarning
brokenGroups := map[int]bool{}
if len(d.Groups) > 0 || len(d.Identities) > 0 {
tool, toolErr := toolFor(sys)
for _, id := range d.Identities {
for _, g := range d.Groups {
var outcome Outcome
err := toolErr
if err == nil {
outcome, err = applyIdentity(ctx, tool, id, &known, run)
outcome, err = applyGroup(ctx, tool, g, &known, run)
}
if err != nil {
identityFailures = append(identityFailures, &Error{Resource: "group " + g.Name, Err: err})
log(fmt.Sprintf(" failed group %s: %v", g.Name, err))
brokenGroups[g.GID] = true
continue
}
report.Outcomes = append(report.Outcomes, outcome)
if outcome.Action != "unchanged" {
log(fmt.Sprintf(" %s %s: %s", outcome.Action, outcome.ID, outcome.Detail))
}
}
names := map[int]string{}
for _, g := range d.Groups {
names[g.GID] = g.Name
}
for _, id := range d.Identities {
var outcome Outcome
err := toolErr
for _, gid := range append([]int{id.GID}, id.Groups...) {
if brokenGroups[gid] && err == nil {
err = fmt.Errorf("its library group %s could not be made; nothing of the module was attempted",
names[gid])
}
}
if err == nil {
outcome, err = applyIdentity(ctx, tool, id, names, &known, run)
}
if err != nil {
// Counted with the others once the resources are through: here, before the guard, it would read
@@ -607,12 +632,82 @@ func ApplyMindingWindows(
"was attempted"
continue
}
// The tool runner joins the module's own group, so its tools read what the module keeps (ADR 0334
// §2); a change of `identity:<module>` restarts it once, through its unit's restart-on.
if id.Runner != "" {
moved, said, err := joinGroup(ctx, sys, id.Runner, id.Name, store.Membership{Module: id.Module}, &known, run)
if err != nil {
// A warning, never a failure of the apply or of the module: the module runs; its tools may not
// read its directories until the account is right (said as runner:<module>).
warnings = append(warnings, RunnerWarning{Resource: "runner:" + id.Module, Module: id.Module,
Why: err.Error()})
outcome.Detail = joinDetail(outcome.Detail, "its tool runner joins no group: "+err.Error())
log(fmt.Sprintf(" warning identity %s's runner: %v", id.Name, err))
} else if moved {
changed[IdentityChange(id.Module)] = true
outcome.Detail = joinDetail(outcome.Detail, said)
if outcome.Action == "unchanged" {
outcome.Action = "updated"
}
}
}
report.Outcomes = append(report.Outcomes, outcome)
if outcome.Action != "unchanged" {
log(fmt.Sprintf(" %s %s: %s", outcome.Action, outcome.ID, outcome.Detail))
}
}
}
// **Every library this machine is reached by, before anything mounts it** (ADR 0328 §6, §9, §11): kept on its
// home, its tool runner's account in its group, and the containers that mount one whose disk or mount is not
// there held. A tool runner that joined a group is a change of `library:<name>`, which the tool runner's unit
// names under restart-on to be restarted once.
verdicts := map[string]libraryVerdict{}
for _, l := range d.Libraries {
outcome, v, err := applyLibrary(ctx, l)
verdicts[l.Library] = v
if err == nil {
var moved bool
var said string
var refused error
moved, said, refused = joinRunner(ctx, sys, l, &known, run)
if refused != nil {
// A warning, never a failure: the library is kept as it is, and said as runner:library:<name>.
warnings = append(warnings, RunnerWarning{Resource: "runner:" + LibraryAccess(l.Library),
Why: refused.Error()})
outcome.Detail = joinDetail(outcome.Detail, "its tool runner joins no group: "+refused.Error())
log(fmt.Sprintf(" warning %s's runner: %v", LibraryAccess(l.Library), refused))
}
if moved {
changed[LibraryAccess(l.Library)] = true
outcome.Detail = joinDetail(outcome.Detail, said)
if outcome.Action == "unchanged" {
outcome.Action = "updated"
}
}
}
if err != nil {
identityFailures = append(identityFailures, &Error{Resource: LibraryAccess(l.Library), Err: err})
log(fmt.Sprintf(" failed %s (%s): %v", LibraryAccess(l.Library), l.Root, err))
continue
}
report.Outcomes = append(report.Outcomes, outcome)
if outcome.Action != "unchanged" {
log(fmt.Sprintf(" %s %s (%s): %s", outcome.Action, outcome.ID, outcome.Target, outcome.Detail))
}
}
in.held = heldRoots(verdicts, d.Libraries)
// A tool runner no longer put in a group leaves it before anything is applied, so its unit, which names the
// change under restart-on, is restarted in this same apply to lose it (ADR 0328 §11, ADR 0334 §2, ADR 0252 §3).
if origin == store.OriginDeclared {
for _, o := range leaveRunners(ctx, sys, d.Libraries, d.Identities, &known, run, wanted) {
if o.Action == "restored" {
changed[o.changes] = true
}
report.Outcomes = append(report.Outcomes, o)
log(fmt.Sprintf(" %s %s: %s", o.Action, o.ID, o.Detail))
}
}
setRunnerWarnings(warnings)
for i, resource := range ordered {
if !orphansRemoved && i == guardFirst {
// **Only a guard that is up may let the filter go.** Removing the derived filter's
@@ -999,14 +1094,17 @@ func ApplyMindingWindows(
// Only the mesh says what is assigned: a carried bundle's silence undeclares no identity and no folder.
if origin == store.OriginDeclared {
// A folder no access names any more has what the mesh added taken out by the next walk (ADR 0325 §8).
forgetUndeclared(in.folders)
// And an identity no longer declared goes, once what ran as it went above (ADR 0325 §3).
// And an identity, then a library group, no longer declared goes, once what ran as it went above (ADR 0328
// §3, §4).
if tool, err := toolFor(sys); err == nil {
for _, o := range removeIdentities(ctx, tool, d.Identities, &known, run) {
report.Outcomes = append(report.Outcomes, o)
log(fmt.Sprintf(" %s %s: %s", o.Action, o.ID, o.Detail))
}
for _, o := range removeGroups(ctx, tool, d.Groups, &known, run) {
report.Outcomes = append(report.Outcomes, o)
log(fmt.Sprintf(" %s %s: %s", o.Action, o.ID, o.Detail))
}
}
}
@@ -1137,12 +1235,13 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru
if err := refuseMounts(res, in.refused); err != nil {
return begin(r), err
}
// Not created over an accessed folder not there, which the runtime would make as root (novox/hq ADR 0325 §9).
if id := mountsMissing(ctx, res, in.accesses); id != "" {
// Not created over a library whose disk or mount is not there: the runtime would make the root as root,
// and the container's writes would land on the filesystem below (novox/hq ADR 0328 §9).
if name := mountsHeld(res, in.held); name != "" {
out := begin(r)
out.Action = "waiting"
out.Detail = "not created: it mounts the access " + id + ", whose folder or its disk is not there; " +
"created once it is (novox/hq ADR 0325 §9)"
out.Detail = "not created: it mounts the library " + name + ", whose disk or mount is not there; " +
"created once it is (novox/hq ADR 0328 §9)"
return out, nil
}
}
@@ -1193,6 +1292,44 @@ func begin(r declaration.Resource) Outcome {
return Outcome{ID: r.Identity(), Type: string(r.Kind()), Target: r.Target()}
}
// dirModeOf is a directory's mode as declared: its permission bits, and the setgid bit when it is 2xxx (novox/hq ADR
// 0334 §1), as os.ModeSetgid.
func dirModeOf(spec string, fallback os.FileMode) (os.FileMode, error) {
mode, err := modeOf(spec, fallback)
if err != nil {
return 0, err
}
out := mode & os.ModePerm
if mode&0o2000 != 0 {
out |= os.ModeSetgid
}
return out, nil
}
// dirBits is what of a directory's mode the mesh keeps: its permission bits and its setgid bit.
func dirBits(m os.FileMode) os.FileMode { return m & (os.ModePerm | os.ModeSetgid) }
// unixDirMode is dirBits as a chmod takes it.
func unixDirMode(m os.FileMode) uint32 {
out := uint32(m.Perm())
if m&os.ModeSetgid != 0 {
out |= 0o2000
}
return out
}
// statDirMode is a directory's mode as the kernel says it, in dirBits' terms.
func statDirMode(raw uint32) os.FileMode {
out := os.FileMode(raw & 0o777)
if raw&0o2000 != 0 {
out |= os.ModeSetgid
}
return out
}
// modeText is a directory's mode as a person writes it: 0750, 2750.
func modeText(m os.FileMode) string { return fmt.Sprintf("%04o", unixDirMode(m)) }
func modeOf(spec string, fallback os.FileMode) (os.FileMode, error) {
if spec == "" {
return fallback, nil
@@ -1217,7 +1354,7 @@ func modeOf(spec string, fallback os.FileMode) (os.FileMode, error) {
// already has the declared owner and mode, so a person who sets them by hand at the machine hands it over.
func applyDirectory(r *declaration.Directory, previous store.Applied, wasBefore bool) (Outcome, error) {
out := begin(r)
mode, err := modeOf(r.Mode, 0o755)
mode, err := dirModeOf(r.Mode, 0o755)
if err != nil {
return out, err
}
@@ -1261,20 +1398,20 @@ func applyDirectory(r *declaration.Directory, previous store.Applied, wasBefore
if err != nil {
return out, err
}
if !owned || before.Mode().Perm() != mode.Perm() {
if !owned || dirBits(before.Mode()) != dirBits(mode) {
out.Action, out.asFound = "unchanged", true
owner := r.Owner
if owner == "" {
owner = "root"
}
out.asFoundDeclared = fmt.Sprintf("%s, mode %o", owner, mode.Perm())
out.asFoundDeclared = fmt.Sprintf("%s, mode %s", owner, modeText(mode))
out.asFoundAt = time.Now().UTC()
if previous.AsFound && filepath.Clean(previous.Target) == filepath.Clean(r.Path) && !previous.FoundAt.IsZero() {
out.asFoundAt = previous.FoundAt
}
out.Detail = fmt.Sprintf("found here before the mesh and used as found: its owner %s and mode %o are "+
out.Detail = fmt.Sprintf("found here before the mesh and used as found: its owner %s and mode %s are "+
"left, though %s were declared (novox/hq issue 339); `nox node hand-over <node> %s` on the "+
"control-node hands it to the mesh", ownerText(before), before.Mode().Perm(), out.asFoundDeclared, r.Path)
"control-node hands it to the mesh", ownerText(before), modeText(dirBits(before.Mode())), out.asFoundDeclared, r.Path)
return out, nil
}
}
@@ -1305,8 +1442,8 @@ func applyDirectory(r *declaration.Directory, previous store.Applied, wasBefore
if !after.IsDir() {
return out, fmt.Errorf("%s is not a directory after applying", r.Path)
}
if after.Mode().Perm() != mode.Perm() {
return out, fmt.Errorf("%s is mode %o after setting %o", r.Path, after.Mode().Perm(), mode.Perm())
if dirBits(after.Mode()) != dirBits(mode) {
return out, fmt.Errorf("%s is mode %s after setting %s", r.Path, modeText(dirBits(after.Mode())), modeText(mode))
}
ownedAlready, err := ownedBy(r.Path, r.Owner)
@@ -1324,9 +1461,9 @@ func applyDirectory(r *declaration.Directory, previous store.Applied, wasBefore
out.Action = "created"
} else if !ownedAlready {
out.Action = "updated"
} else if before.Mode().Perm() != mode.Perm() {
} else if dirBits(before.Mode()) != dirBits(mode) {
out.Action = "updated"
out.Detail = fmt.Sprintf("mode %o to %o", before.Mode().Perm(), mode.Perm())
out.Detail = fmt.Sprintf("mode %s to %s", modeText(dirBits(before.Mode())), modeText(mode))
}
return out, nil
}
@@ -1362,10 +1499,10 @@ func applyHandedOver(r *declaration.Directory, out Outcome, mode os.FileMode) (O
return out, err
}
out.Action = "unchanged"
if before.Perm() != mode.Perm() || (chown && (beforeUID != uid || beforeGID != gid)) {
if dirBits(before) != dirBits(mode) || (chown && (beforeUID != uid || beforeGID != gid)) {
out.Action = "updated"
out.Detail = fmt.Sprintf("handed to the mesh: owner %d:%d, mode %o, given what was declared", beforeUID, beforeGID,
before.Perm())
out.Detail = fmt.Sprintf("handed to the mesh: owner %d:%d, mode %s, given what was declared", beforeUID, beforeGID,
modeText(dirBits(before)))
}
return out, nil
}
@@ -2194,12 +2331,9 @@ type inputs struct {
// dirsBefore is every declared directory that was on the machine when this apply began (novox/hq issue
// 339): one the apply itself made — a file's parent — is the mesh's, one that was there may not be.
dirsBefore map[string]bool
// folders is what every declared access asks of its folder, by path (novox/hq ADR 0325 §5); accesses every
// accessed folder with its access's id, which no container mounts while it is not there (§9).
folders map[string]*folderWant
// identities is that the declaration carries identities: its controller speaks ADR 0325 for every access.
identities bool
accesses map[string]string
// held is the root of every library whose disk or mount is not there, with its name: no container that mounts
// it is created (novox/hq ADR 0328 §9).
held map[string]string
// handed is every group a record that went this apply handed to a resource still asking for it.
handed Handed
}
+3 -3
View File
@@ -137,7 +137,7 @@ func chmodUnderAs(home, path string, mode os.FileMode, want int) error {
return err
}
defer unix.Close(fd)
if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil {
if err := unix.Fchmod(fd, unixDirMode(mode)); err != nil {
return &os.PathError{Op: "chmod", Path: path, Err: err}
}
return nil
@@ -256,8 +256,8 @@ func giveHandedOver(path string, mode os.FileMode, uid, gid int, chown bool) (be
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 {
before, beforeUID, beforeGID = statDirMode(st.Mode), int(st.Uid), int(st.Gid)
if err := unix.Fchmod(fd, unixDirMode(mode)); err != nil {
return 0, 0, 0, &os.PathError{Op: "chmod", Path: path, Err: err}
}
if chown && (beforeUID != uid || beforeGID != gid) {
+151 -22
View File
@@ -14,7 +14,7 @@ import (
"github.com/novox/mesh-host/internal/system"
)
// Every module is given an identity of its own (novox/hq ADR 0325 §3, §11, issue 567).
// Every module is given an identity of its own (novox/hq ADR 0328 §3, §4, §15, issue 567).
//
// The controller allocates each module a user and a group number once, from a range of its own, the same on every
// machine, and declares them as `mesh-<module>`. The engine makes the group and the account with exactly those
@@ -32,7 +32,8 @@ import (
// 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
CreateSystemAccount(ctx context.Context, run system.Runner, name string, uid, gid int, groups []string) error
ModifyAccount(ctx context.Context, run system.Runner, name string, gid int, groups []string) error
CreateSystemGroup(ctx context.Context, run system.Runner, name string, gid int) error
RemoveAccount(ctx context.Context, run system.Runner, name string) error
RemoveGroup(ctx context.Context, run system.Runner, name string) error
@@ -75,9 +76,22 @@ func lookUp(ctx context.Context, run Runner, database, key string) (entry, bool,
func refuseRootIdentities(d *declaration.Declaration) error {
var problems []string
for _, id := range d.Identities {
if err := rootIdentity(id.UID, id.GID); err != nil {
// Its user's number, its own group's (the same number), its primary group and every added group.
if err := rootIdentity(id.UID, id.UID); err != nil {
problems = append(problems, fmt.Sprintf("the identity %s: %v", id.Name, err))
}
for _, gid := range append([]int{id.GID}, id.Groups...) {
if why := rootGroup(gid); why != "" {
problems = append(problems, fmt.Sprintf("the identity %s is given gid %s, and a module's identity "+
"is never in a root group (novox/hq ADR 0328 §15)", id.Name, why))
}
}
}
for _, g := range d.Groups {
if why := rootGroup(g.GID); why != "" {
problems = append(problems, fmt.Sprintf("the library group %s is gid %s, and a library's group is never a "+
"root group (novox/hq ADR 0328 §15)", g.Name, why))
}
}
for _, r := range d.Resources {
if c, ok := r.(*declaration.Container); ok && c.RunAs != nil {
@@ -94,10 +108,13 @@ func refuseRootIdentities(d *declaration.Declaration) error {
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) {
// applyIdentity makes one identity as declared (ADR 0328 §4): its own group `mesh-<module>` with the number of its
// uid, and its user with that uid, its primary group gid (its own, or its first library's) and its other libraries'
// groups as added groups — or refuses it naming whose the name or the numbers are. A user the engine made is given
// a changed primary or added groups; one that was there before is never changed, and is refused if it is not as
// declared. What it made is recorded on known; an identity already right is unchanged.
func applyIdentity(ctx context.Context, tool accountsTool, id declaration.Identity, names map[int]string,
known *store.State, run Runner) (Outcome, error) {
out := Outcome{ID: "identity " + id.Name, Type: "identity", Target: id.Name, Action: "unchanged"}
user, hasUser, err := lookUp(ctx, run, "passwd", id.Name)
if err != nil {
@@ -111,42 +128,74 @@ func applyIdentity(ctx context.Context, tool accountsTool, id declaration.Identi
if err != nil {
return out, err
}
byGID, gidUsed, err := lookUp(ctx, run, "group", strconv.Itoa(id.GID))
byGID, gidUsed, err := lookUp(ctx, run, "group", strconv.Itoa(id.UID))
if err != nil {
return out, err
}
const never = "; nothing was made, and nothing of the module was attempted (novox/hq ADR 0325 §3)"
rec := madeIdentity(known, id.Name)
const never = "; nothing was made, and nothing of the module was attempted (novox/hq ADR 0328 §4)"
switch {
case hasUser && (user.id != id.UID || 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 hasUser && user.id != id.UID:
return out, fmt.Errorf("the account %s is here with uid %d, and the mesh allocated uid %d%s", id.Name,
user.id, id.UID, never)
case hasUser && user.prim != id.GID && !rec.MadeUser:
return out, fmt.Errorf("the account %s is here with group %d, was not made by the mesh, and the mesh "+
"gives it group %d%s", id.Name, user.prim, id.GID, never)
case hasGroup && group.id != id.UID:
return out, fmt.Errorf("the group %s is here with gid %d, and a module's own group has its uid, %d%s",
id.Name, group.id, id.UID, never)
case uidUsed && byUID.name != id.Name:
return out, fmt.Errorf("uid %d is the account %s's here, and the mesh allocated it to %s%s", id.UID,
byUID.name, id.Name, never)
case gidUsed && byGID.name != id.Name:
return out, fmt.Errorf("gid %d is the group %s's here, and the mesh allocated it to %s%s", id.GID,
return out, fmt.Errorf("gid %d is the group %s's here, and the mesh allocated it to %s%s", id.UID,
byGID.name, id.Name, never)
}
rec := madeIdentity(known, id.Name)
added := make([]string, 0, len(id.Groups))
for _, gid := range id.Groups {
added = append(added, names[gid])
}
sort.Strings(added)
rec.Module, rec.Name, rec.UID, rec.GID = id.Module, id.Name, id.UID, id.GID
var made []string
if !hasGroup {
if err := tool.CreateSystemGroup(ctx, run, id.Name, id.GID); err != nil {
if err := tool.CreateSystemGroup(ctx, run, id.Name, id.UID); err != nil {
return out, err
}
rec.MadeGroup = true
made = append(made, fmt.Sprintf("the group (gid %d)", id.GID))
made = append(made, fmt.Sprintf("its own group (gid %d)", id.UID))
}
if !hasUser {
if err := tool.CreateSystemAccount(ctx, run, id.Name, id.UID, id.GID); err != nil {
if err := tool.CreateSystemAccount(ctx, run, id.Name, id.UID, id.GID, added); err != nil {
recordIdentity(known, rec)
return out, err
}
rec.MadeUser = true
made = append(made, fmt.Sprintf("the account (uid %d), with no login, no home and no password", id.UID))
made = append(made, fmt.Sprintf("the account (uid %d, group %d%s), with no login, no home and no password",
id.UID, id.GID, alsoIn(added)))
} else if rec.MadeUser {
// The mesh's own account: its primary and added groups follow what is declared.
in, err := system.GroupsOf(ctx, system.Runner(run), id.Name)
if err != nil {
return out, err
}
have := map[string]bool{}
for _, g := range in {
if g != id.Name && g != names[user.prim] {
have[g] = true
}
}
same := user.prim == id.GID && len(have) == len(added)
for _, g := range added {
same = same && have[g]
}
if !same {
if err := tool.ModifyAccount(ctx, run, id.Name, id.GID, added); err != nil {
return out, err
}
out.Action = "updated"
out.Detail = fmt.Sprintf("gave the account group %d%s", id.GID, alsoIn(added))
}
}
if rec.At.IsZero() {
rec.At = time.Now().UTC()
@@ -154,12 +203,92 @@ func applyIdentity(ctx context.Context, tool accountsTool, id declaration.Identi
recordIdentity(known, rec)
if len(made) > 0 {
out.Action, out.Detail = "created", "made "+strings.Join(made, " and ")
} else {
} else if out.Detail == "" {
out.Detail = "here with its numbers"
}
return out, nil
}
func alsoIn(groups []string) string {
if len(groups) == 0 {
return ""
}
return ", also in " + strings.Join(groups, ", ")
}
// applyGroup makes one library's group with the number the controller allocated (ADR 0328 §3), or refuses it naming
// whose the name or the number is, or that the number is a root group's (§15). What it made is recorded on known.
func applyGroup(ctx context.Context, tool accountsTool, g declaration.Group, known *store.State,
run Runner) (Outcome, error) {
out := Outcome{ID: "group " + g.Name, Type: "group", Target: g.Name, Action: "unchanged"}
if why := rootGroup(g.GID); why != "" {
return out, fmt.Errorf("its gid is %s, and a library's group is never a root group (novox/hq ADR 0328 §15); "+
"nothing was made", why)
}
byName, hasName, err := lookUp(ctx, run, "group", g.Name)
if err != nil {
return out, err
}
byGID, gidUsed, err := lookUp(ctx, run, "group", strconv.Itoa(g.GID))
if err != nil {
return out, err
}
switch {
case hasName && byName.id != g.GID:
return out, fmt.Errorf("the group %s is here with gid %d, and the mesh allocated gid %d; nothing was made",
g.Name, byName.id, g.GID)
case gidUsed && byGID.name != g.Name:
return out, fmt.Errorf("gid %d is the group %s's here, and the mesh allocated it to %s; nothing was made",
g.GID, byGID.name, g.Name)
case hasName:
out.Detail = "here with its number"
return out, nil
}
if err := tool.CreateSystemGroup(ctx, run, g.Name, g.GID); err != nil {
return out, err
}
known.Groups = append(known.Groups, store.MadeGroup{Name: g.Name, GID: g.GID, At: time.Now().UTC()})
out.Action, out.Detail = "created", fmt.Sprintf("made the library's group (gid %d)", g.GID)
return out, nil
}
// removeGroups removes every library group the engine made that is no longer declared, while it still has the
// number the engine gave it. Never fatal: one that could not be removed (still a user's primary group) stays
// recorded, said, for the next apply.
func removeGroups(ctx context.Context, tool accountsTool, declared []declaration.Group, known *store.State,
run Runner) []Outcome {
wanted := map[string]bool{}
for _, g := range declared {
wanted[g.Name] = true
}
var out []Outcome
var keep []store.MadeGroup
for _, m := range known.Groups {
if wanted[m.Name] {
keep = append(keep, m)
continue
}
o := Outcome{ID: "group " + m.Name, Type: "group", Target: m.Name, Action: "removed",
Detail: "no longer declared: removed the group"}
g, has, err := lookUp(ctx, run, "group", m.Name)
switch {
case err != nil || (has && g.id != m.GID):
o.Action, o.Detail = "forgotten", "no longer declared: the group was left, it no longer has the gid the "+
"mesh gave it, or the database did not answer"
case has:
if err := tool.RemoveGroup(ctx, run, m.Name); err != nil {
o.Action, o.Detail = "waiting", "no longer declared: the group could not be removed: "+err.Error()
keep = append(keep, m)
}
default:
o.Action, o.Detail = "forgotten", "no longer declared, and no longer here"
}
out = append(out, o)
}
known.Groups = keep
return out
}
// madeIdentity is what the engine recorded of name, or an empty record.
func madeIdentity(known *store.State, name string) store.MadeIdentity {
for _, m := range known.Identities {
+103 -5
View File
@@ -11,6 +11,7 @@ import (
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// The engine's half of ADR 0325 §3 and §11 (issue 567): every module given an account and a group of its own with
@@ -19,7 +20,20 @@ import (
// 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
// in is each account's added groups, by name; homes each account's home, "/" when not set.
in map[string][]string
homes map[string]string
ran []string
}
// groupNamed is the name of gid in m, or the number.
func (m *accountsMachine) groupNamed(gid int) string {
for n, ids := range m.groups {
if ids[0] == gid {
return n
}
}
return strconv.Itoa(gid)
}
func (m *accountsMachine) run(_ context.Context, name string, args ...string) (string, error) {
@@ -36,14 +50,55 @@ func (m *accountsMachine) run(_ context.Context, name string, args ...string) (s
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
home := m.homes[n]
if home == "" {
home = "/"
}
return n + ":x:" + strconv.Itoa(ids[0]) + ":" + strconv.Itoa(ids[1]) + "::" + home + ":/usr/bin/nologin\n", nil
}
}
return "", errors.New("getent exited 2")
case "id":
name := args[len(args)-1]
u, ok := m.users[name]
if !ok {
return "", errors.New("id exited 1")
}
return strings.Join(append([]string{m.groupNamed(u[1])}, m.in[name]...), " ") + "\n", nil
case "groupadd":
m.groups[args[len(args)-1]] = [2]int{mustAtoi(args[2]), 0}
case "useradd":
m.users[args[len(args)-1]] = [2]int{mustAtoi(args[2]), mustAtoi(args[4])}
name := args[len(args)-1]
m.users[name] = [2]int{mustAtoi(args[2]), mustAtoi(args[4])}
if args[5] == "--groups" {
m.setIn(name, strings.Split(args[6], ","))
}
if m.homes == nil {
m.homes = map[string]string{}
}
m.homes[name] = "/nonexistent"
case "usermod":
name := args[len(args)-1]
if args[0] == "--append" {
m.setIn(name, append(m.in[name], args[2]))
} else if args[0] == "--home" {
if m.homes == nil {
m.homes = map[string]string{}
}
m.homes[name] = args[1]
} else {
u := m.users[name]
m.users[name] = [2]int{u[0], mustAtoi(args[1])}
m.setIn(name, strings.Split(args[3], ","))
}
case "gpasswd":
var kept []string
for _, g := range m.in[args[1]] {
if g != args[2] {
kept = append(kept, g)
}
}
m.setIn(args[1], kept)
case "userdel":
delete(m.users, args[0])
case "groupdel":
@@ -55,11 +110,39 @@ func (m *accountsMachine) run(_ context.Context, name string, args ...string) (s
return "", nil
}
func (m *accountsMachine) setIn(name string, groups []string) {
if m.in == nil {
m.in = map[string][]string{}
}
var out []string
for _, g := range groups {
if g != "" {
out = append(out, g)
}
}
m.in[name] = out
}
func mustAtoi(s string) int {
n, _ := strconv.Atoi(s)
return n
}
// withGroups is a declaration with library groups, identities and resources.
func withGroups(t *testing.T, groups, identities, resources string) *declaration.Declaration {
t.Helper()
nothing := ""
if resources == "" {
nothing = `"owns_nothing":true,`
}
d, err := declaration.Parse([]byte(`{"declaration":1,"groups":[` + groups + `],"identities":[` + identities +
`],` + nothing + `"resources":[` + resources + `]}`))
if err != nil {
t.Fatal(err)
}
return d
}
func withIdentities(t *testing.T, identities, resources string) *declaration.Declaration {
t.Helper()
d, err := declaration.Parse([]byte(`{"declaration":1,"identities":[` + identities + `],"resources":[` +
@@ -83,7 +166,7 @@ func TestAModulesIdentityIsMadeWithItsNumbersAndRemovedWhenUndeclared(t *testing
}
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",
"useradd --system --uid 61001 --gid 61001 --no-create-home --home-dir /nonexistent --shell /usr/bin/nologin mesh-radarr",
}
if strings.Join(m.ran, "\n") != strings.Join(want, "\n") {
t.Fatalf("made with:\n%s\nwant:\n%s", strings.Join(m.ran, "\n"), strings.Join(want, "\n"))
@@ -163,7 +246,9 @@ func TestNoModuleIdentityIsRoot(t *testing.T) {
defer func() { RootGroupIDs = was }()
dir := t.TempDir()
for _, d := range []*declaration.Declaration{
withIdentities(t, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":10}`,
withGroups(t, `{"name":"mesh-media","gid":10}`, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":10}`,
`{"id":"radarr.config","type":"directory","path":"`+dir+`/config"}`),
withGroups(t, `{"name":"mesh-media","gid":10}`, ``,
`{"id":"radarr.config","type":"directory","path":"`+dir+`/config"}`),
withIdentities(t, ``, `{"id":"radarr.config","type":"directory","path":"`+dir+`/config"},`+
`{"id":"radarr.app","type":"container","name":"radarr","image":"`+pinned+`",`+
@@ -285,3 +370,16 @@ func TestAGrantsGroupThatCouldNotBeLeftIsTriedAgain(t *testing.T) {
t.Fatalf("the group was not taken back on the next apply: %+v (%s)", rec, groups)
}
}
// Every system the engine runs on that makes accounts makes module identities: arch and alpine alike.
func TestArchAndAlpineMakeModuleIdentities(t *testing.T) {
for _, name := range []string{"arch", "alpine"} {
sys, err := system.For(name)
if err != nil {
t.Fatal(err)
}
if _, err := toolFor(sys); err != nil {
t.Errorf("%s makes no module identity: %v", name, err)
}
}
}
+75 -784
View File
@@ -7,9 +7,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
@@ -19,306 +17,101 @@ import (
"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).
// An access is an operator's path a module mounts and the mesh does not own (novox/hq ADR 0051): mounted when
// present, refused when absent, nothing changed on it. ADR 0325 kept a shared folder through ACL entries; ADR 0328
// supersedes it with libraries (library.go), and an access is again exactly what it was before: an access's `users`
// and `taken` are accepted and not acted on.
// 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
// applyAccess is one access, applied as ADR 0051 has it; what ADR 0312's grant put an account in is taken back
// first, so no account is in a group for data but a library's (ADR 0328 §11).
func applyAccess(ctx context.Context, sys system.System, r *declaration.Access, run Runner, previous store.Applied,
in inputs) (Outcome, error) {
gave, left, said := takeBackGranted(ctx, sys, previous, run, in.groups, in.handed)
out, err := legacyAccess(r)
if len(left) > 0 {
out.user, out.groups = previous.User, left
}
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)
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 nil
return out, err
}
// 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()
// 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 store.KeptFolder{}, false
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)
}
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 {
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
}
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}
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 out
return gave, left, "the group grant ADR 0312 made is taken back: " + account + " " + strings.Join(said, "; ")
}
// 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)
// 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) {
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}
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 folderVerdict{err: errors.New("the folder cannot be read: " + pathless(err))}
return out, err
}
if !info.IsDir() {
return folderVerdict{err: errors.New("the folder is not a directory, and an access is a shared directory")}
return out, fmt.Errorf(
"%s is not a directory, and an access is a shared directory the operator provides",
r.Path)
}
pl := placedOn(ctx, path, path, rec.Mount, others)
if pl.Away {
return folderVerdict{kind: folderWaitDisk, mount: pl.Mount}
mode := declaration.AccessRead
if r.Mode != "" {
mode = r.Mode
}
// 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...)})
out.Action = "unchanged"
out.Detail = "operator-owned; present, " + mode + ", nothing managed"
return out, nil
}
// PlacedID names the filesystem at mount for a wait, with no path in it: the controller asks again when the
@@ -449,505 +242,3 @@ func pathless(err error) string {
}
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
}
-446
View File
@@ -1,446 +0,0 @@
//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
+563
View File
@@ -0,0 +1,563 @@
package apply
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// A library is kept usable by its group (novox/hq ADR 0328, issue 567).
//
// A library is a named tree of shared data with one home (§1). On its home the engine keeps its root and every
// sub-path its modules ask for — those folders themselves, never what is in them — in the library's group with
// group read, write and enter and the setgid bit, so what is made beneath keeps the group (§6); a missing sub-path
// is made with the root's owner and the library's group (§9). The owner, the owner's bits and others' bits are
// never changed. What the root already holds joins only on the operator's word (§7): until then the library waits
// with reason keep and nothing under it is walked. A root that is absent, or whose disk is not there, makes
// nothing, holds every container that mounts the library, and waits with reason disk (§9). Off its home a library is
// a mount the mounts module makes: nothing is kept there, and a mount that is not there holds the containers too.
//
// **When a root is the mesh's to keep without asking.** When it holds nothing yet, or is already in the library's
// group with group write and setgid (kept so before, by hand or by the mesh). Every other root waits for the take
// act, which walks it whole (TakeLibrary). Kept, it stays kept on the filesystem it was kept on — named by UUID,
// filesystem id or source (PlacedID) — and another filesystem at its root is judged afresh.
// libraryRecords is where the engine keeps what it knows of each library it is home to: the file beside the state
// in the engine, memory in a test.
type libraryRecords interface {
All() (map[string]store.LibraryRecord, error)
Update(name string, change func(r *store.LibraryRecord, had bool) bool) error
}
// Libraries is the engine's record of the libraries it is home to. The engine sets the file beside its state.
var Libraries libraryRecords = NewLibraryMemory()
type libraryMemory struct {
mu sync.Mutex
m map[string]store.LibraryRecord
}
// NewLibraryMemory is an empty record held in memory: a test's, or a one-shot apply's.
func NewLibraryMemory() libraryRecords { return &libraryMemory{m: map[string]store.LibraryRecord{}} }
func (k *libraryMemory) All() (map[string]store.LibraryRecord, error) {
k.mu.Lock()
defer k.mu.Unlock()
out := map[string]store.LibraryRecord{}
for n, r := range k.m {
out[n] = r
}
return out, nil
}
func (k *libraryMemory) Update(name string, change func(r *store.LibraryRecord, had bool) bool) error {
k.mu.Lock()
defer k.mu.Unlock()
r, had := k.m[name]
r.Library = name
if change(&r, had) {
k.m[name] = r
} else {
delete(k.m, name)
}
return nil
}
// libraryRecord is the record of one library, and whether there is one. A record that cannot be read is none: the
// library is judged afresh, which waits rather than keeps.
func libraryRecord(name string) (store.LibraryRecord, bool) {
all, err := Libraries.All()
if err != nil {
return store.LibraryRecord{}, false
}
r, had := all[name]
return r, had
}
// libraryLocks is one lock per library, held by every change to its root and every walk under it.
var libraryLocks sync.Map
func libraryLock(name string) *sync.Mutex {
l, _ := libraryLocks.LoadOrStore(name, &sync.Mutex{})
return l.(*sync.Mutex)
}
// libraryWalks is every library a take walk is running under now, with the filesystem it walks: set once the walk
// has been judged and started, cleared when it ends.
var libraryWalks sync.Map
// LibraryAccess is the access a library's wait and its take act name.
func LibraryAccess(name string) string { return declaration.LibraryAccessPrefix + name }
// KeepWords and DiskWords are what a library's wait says the operator gives (ADR 0328 §8, §9): never a path.
func KeepWords(name string) string { return "let the mesh keep the library " + name }
func DiskWords(name string) string { return "mount the disk that holds the library " + name }
// The judgements of a library.
const (
// libraryKeep is a library the mesh keeps: taken, empty when first seen, or found in shape.
libraryKeep = "keep"
// libraryWaitKeep is a root that holds data never taken: it waits for the operator's word.
libraryWaitKeep = "wait-keep"
// libraryWaitDisk is a root that is absent, or whose disk (or, off its home, whose mount) is not there.
libraryWaitDisk = "wait-disk"
// libraryUnknown is a root whose filesystem could not be named now: left as it is until it is.
libraryUnknown = "unknown"
// libraryMounted is a library off its home whose mount is there: nothing to keep.
libraryMounted = "mounted"
)
// libraryVerdict is the judgement of one library, with what it read.
type libraryVerdict struct {
kind string
// how is why a kept library is the mesh's: store.KeptByWarrant, store.KeptEmpty or store.KeptInShape.
how string
// mount is the mount point that holds the root; placed and fs name its filesystem.
mount, placed, fs string
// moved is that another filesystem than the one the library was kept on is at its root.
moved bool
why string
err error
}
// assessLibrary judges a library as an apply, a look and the take act all do (ADR 0328 §7, §9, §15): its group
// first, then its disk, then whether the mesh may keep it. Its words carry no path: they reach the operator's
// channel. Reads only.
func assessLibrary(ctx context.Context, l declaration.Library, rec store.LibraryRecord) libraryVerdict {
if why := rootGroup(l.GID); why != "" {
return libraryVerdict{err: fmt.Errorf("its group is %s, and a library's group is never a root group "+
"(novox/hq ADR 0328 §15); nothing was made or changed", why)}
}
// Nowhere the machine's own trees are, as for an access (novox/hq issue 339): a library at /etc would be
// the machine's configuration given a group.
if err := refusePath(l.Root, placingAccess, "", ""); err != nil {
return libraryVerdict{err: errors.New(pathless(err) + "; nothing was made or changed")}
}
if !l.Home {
// Off its home the mounts module mounts it: there when its root is a mount point now.
for _, p := range MountPoints() {
if p == l.Root {
return libraryVerdict{kind: libraryMounted, mount: p}
}
}
return libraryVerdict{kind: libraryWaitDisk, why: "its mount is not there"}
}
info, err := lstatWithin(l.Root)
switch {
case errors.Is(err, os.ErrNotExist):
return libraryVerdict{kind: libraryWaitDisk, why: "its root is not there"}
case errors.Is(err, errNoAnswer):
return libraryVerdict{kind: libraryUnknown, why: "its root does not answer: a read of it did not return " +
"within its bound"}
case err != nil:
return libraryVerdict{err: errors.New("its root cannot be read: " + pathless(err))}
case !info.IsDir():
return libraryVerdict{err: errors.New("its root is not a folder (a file, or a link), and a library's root " +
"is a folder the engine reaches through no link")}
}
pl := placedOn(ctx, l.Root, l.Root, rec.Mount, recordedLibraryMounts(l.Library))
if pl.Away {
return libraryVerdict{kind: libraryWaitDisk, mount: pl.Mount, why: "the disk that holds its root is not there"}
}
fs := filesystemKey(pl.Mount)
here := placedFrom(pl.Mount, fs)
// **A filesystem that cannot be named now is unknown, never a move**: the library holds what it is — nothing
// walked, nothing newly kept, nothing reset — and is judged again when it is named. One never kept is so never
// kept: it fails closed.
if here == "" {
return libraryVerdict{kind: libraryUnknown, mount: pl.Mount,
why: "its filesystem could not be named this time (no UUID, filesystem id or source answered)"}
}
moved := pl.Moved || (rec.Fs != "" && rec.Mount == pl.Mount && rec.Placed != here)
v := libraryVerdict{mount: pl.Mount, placed: here, fs: fs, moved: moved}
// The controller's word that the library was taken holds for the filesystem the warrant was carried out on.
takenHere := l.Taken && (rec.KeptOn == here || (rec.KeptOn == "" && !moved))
switch {
case rec.Kept && rec.KeptOn == here && !moved && rec.How == store.KeptByWarrant, takenHere:
// Taken whole on the operator's word: every sub-path is the library's.
v.kind, v.how = libraryKeep, store.KeptByWarrant
return v
case rec.Kept && rec.KeptOn == here && !moved:
// Kept without a warrant (empty, or in shape): a sub-path asked for since that holds data not in the group
// waits for the operator's word as the root would (§7).
v.kind, v.how = libraryKeep, rec.How
if !pathsInShape(l, rec.Paths) {
v.kind, v.how = libraryWaitKeep, ""
}
return v
}
facts, err := readFolder(l.Root)
if err != nil {
return libraryVerdict{err: errors.New("its root cannot be read: " + pathless(err))}
}
switch {
case facts.Empty:
// Nothing there, so no sub-path either: every one is made.
v.kind, v.how = libraryKeep, store.KeptEmpty
case int(facts.GID) == l.GID && facts.Mode&0o2070 == 0o2070 && sampleInShape(l.Root, l.GID) &&
pathsInShape(l, nil):
// The root and a bounded sample beneath it already in the group with group write and setgid: data that
// is the library's already, kept without asking. One entry of the sample out of shape asks (§7).
v.kind, v.how = libraryKeep, store.KeptInShape
default:
// What is there holds data the mesh never took — a disk mounted over a root kept while it was empty below
// it included (a move): it waits for the operator's word, and nothing under it is walked.
v.kind = libraryWaitKeep
}
return v
}
// recordedLibraryMounts is every mount point recorded for another library: where a disk was once seen.
func recordedLibraryMounts(except string) []string {
all, err := Libraries.All()
if err != nil {
return nil
}
var out []string
for name, r := range all {
if name != except && r.Mount != "" && r.Mount != "/" {
out = append(out, r.Mount)
}
}
return out
}
// applyLibrary is one library, applied (ADR 0328 §6, §7, §9): kept on its home when the mesh may keep it, waiting
// otherwise, and failing for a root group. Nothing is changed on the root but the group, group rwx and setgid of
// the root and its requested sub-paths, and the sub-paths made.
func applyLibrary(ctx context.Context, l declaration.Library) (Outcome, libraryVerdict, error) {
out := Outcome{ID: LibraryAccess(l.Library), Type: "library", Target: l.Root}
lock := libraryLock(l.Library)
if !lock.TryLock() {
// **A take walk is running (up to its bound, half an hour): the apply does not wait for it.** The library is
// the walk's until it is through; it keeps it, and its containers are not held for it. Only a walk that
// has started, on the filesystem it judged: anything else holding the lock (a take act about to be
// refused, say) is short, and the apply waits for it and judges the library as always.
if on, walking := libraryWalks.Load(l.Library); walking {
out.Action = "unchanged"
out.Detail = "the take act is setting its group under its root now; this apply leaves the library to it"
return out, libraryVerdict{kind: libraryKeep, how: store.KeptByWarrant, placed: on.(string)}, nil
}
lock.Lock()
}
defer lock.Unlock()
rec, _ := libraryRecord(l.Library)
v := assessLibrary(ctx, l, rec)
if v.err != nil {
return out, v, v.err
}
switch v.kind {
case libraryMounted:
out.Action, out.Detail = "unchanged", "not its home: mounted, and nothing is kept here"
return out, v, nil
case libraryWaitDisk:
out.Action = "waiting"
out.Detail = v.why + ": nothing is made, and no container that mounts it is created until it is there " +
"(novox/hq ADR 0328 §9); waits for the operator to " + DiskWords(l.Library)
return out, v, nil
case libraryUnknown:
out.Action = "waiting"
out.Detail = v.why + ": the library is left exactly as it is, and judged again on the next apply"
return out, v, nil
case libraryWaitKeep:
_ = Libraries.Update(l.Library, func(r *store.LibraryRecord, had bool) bool {
if !had || v.moved {
r.Root, r.Mount, r.Placed, r.Fs = l.Root, v.mount, v.placed, v.fs
}
if v.moved {
// Kept on another filesystem: what was kept there no longer holds here.
r.Kept, r.How, r.Cursor = false, "", nil
}
return true
})
out.Action = "waiting"
out.Detail = "its root holds data the mesh has not taken: nothing is changed under it until the operator's " +
"warrant (novox/hq ADR 0328 §7); waits for the operator to " + KeepWords(l.Library)
return out, v, nil
}
changed, made, err := keepFolders(l.Root, l.Paths, l.GID)
if err != nil {
return out, v, err
}
_ = Libraries.Update(l.Library, func(r *store.LibraryRecord, _ bool) bool {
if !r.Kept || r.KeptOn != v.placed {
r.How, r.At = v.how, time.Now().UTC()
}
r.Root, r.Mount, r.Placed, r.Fs, r.GID = l.Root, v.mount, v.placed, v.fs, l.GID
r.Kept, r.KeptOn = true, v.placed
r.Paths = unionPaths(r.Paths, l.Paths)
return true
})
out.Action = "unchanged"
if changed+made > 0 {
out.Action = "updated"
}
out.Detail = fmt.Sprintf("kept in %s: its root and %d sub-path(s) with group rwx and setgid, %d made; owners "+
"and everyone else's rights unchanged (novox/hq ADR 0328 §6)", l.Group, len(l.Paths), made)
return out, v, nil
}
// heldRoots is every library root whose containers are held: its disk, or its mount, is not there (§9).
func heldRoots(verdicts map[string]libraryVerdict, libs []declaration.Library) map[string]string {
out := map[string]string{}
for _, l := range libs {
if v, ok := verdicts[l.Library]; ok && v.kind == libraryWaitDisk {
out[l.Root] = l.Library
}
}
return out
}
// mountsHeld is the library a container waits for: one whose root it mounts, or mounts below, is held.
func mountsHeld(c *declaration.Container, held map[string]string) string {
for _, v := range c.Volumes {
src := mountSource(v)
if !strings.HasPrefix(src, "/") {
continue
}
for root, name := range held {
if atOrBelow(filepath.Clean(src), root) {
return name
}
}
}
return ""
}
// WaitingOnLibrary is every container of the declaration that waits for a library whose disk or mount is not
// there, read now: not created by the apply, so not judged alive or dead — the library says why.
func WaitingOnLibrary(d *declaration.Declaration) map[string]bool {
out := map[string]bool{}
if d == nil || len(d.Libraries) == 0 {
return out
}
verdicts := map[string]libraryVerdict{}
for _, l := range d.Libraries {
rec, _ := libraryRecord(l.Library)
verdicts[l.Library] = assessLibrary(context.Background(), l, rec)
}
held := heldRoots(verdicts, d.Libraries)
for _, r := range d.Resources {
if c, ok := r.(*declaration.Container); ok && mountsHeld(c, held) != "" {
out[c.ID] = true
}
}
return out
}
// The judgements of a library as a look says them.
const (
VerdictKeep = "keep"
VerdictMounted = "mounted"
VerdictWaitKeep = "wait-keep"
VerdictWaitDisk = "wait-disk"
VerdictUnknown = "unknown"
VerdictBroken = "broken"
)
// LibraryJudgement is a look's judgement of a library, and why, in words with no path; Placed names its root's
// filesystem for a keep wait, with no path (PlacedID).
type LibraryJudgement struct {
Kind, Why, Placed string
// Act is the last walk under its root.
Act *store.KeptAct
}
// JudgeLibrary judges a library for a look, read only.
func JudgeLibrary(ctx context.Context, l declaration.Library) LibraryJudgement {
rec, _ := libraryRecord(l.Library)
v := assessLibrary(ctx, l, rec)
j := LibraryJudgement{Act: rec.Act}
switch {
case v.err != nil:
j.Kind, j.Why = VerdictBroken, pathless(v.err)
case v.kind == libraryWaitKeep:
j.Kind, j.Placed = VerdictWaitKeep, v.placed
case v.kind == libraryWaitDisk:
j.Kind, j.Why = VerdictWaitDisk, v.why
case v.kind == libraryUnknown:
j.Kind, j.Why = VerdictUnknown, v.why
case v.kind == libraryMounted:
j.Kind = VerdictMounted
default:
j.Kind = VerdictKeep
}
return j
}
// joinRunner puts a library's tool runner's account in its group (ADR 0328 §11).
func joinRunner(ctx context.Context, sys system.System, l declaration.Library, known *store.State,
run Runner) (bool, string, error) {
if l.Runner == "" {
return false, "", nil
}
return joinGroup(ctx, sys, l.Runner, l.Group, store.Membership{Library: l.Library}, known, run)
}
// IdentityChange is what a tool runner joining a module's own group changes (ADR 0334 §2): `identity:<module>`,
// which the tool runner's unit names under restart-on, as it names `library:<name>`.
func IdentityChange(module string) string { return "identity:" + module }
// joinGroup puts a tool runner's account in a group (ADR 0328 §11, ADR 0334 §2), as ADR 0252 puts an account in a
// group: recorded only when the mesh put it there, so one it was in before is never taken back. The account must be
// here and never root's: uid 0, or a root group as its primary (§15). Whether it moved, and what was said.
func joinGroup(ctx context.Context, sys system.System, account, group string, of store.Membership, known *store.State,
run Runner) (bool, string, error) {
who, found, err := lookUp(ctx, run, "passwd", account)
switch {
case err != nil:
return false, "", fmt.Errorf("the tool runner's account %s could not be read: %w", account, err)
case !found:
return false, "", fmt.Errorf("the tool runner's account %s is not on this machine, so it joins no group", account)
}
if err := rootIdentity(who.id, who.prim); err != nil {
return false, "", fmt.Errorf("the tool runner's account %s joins no group: %w", account, err)
}
in, err := system.GroupsOf(ctx, system.Runner(run), account)
if err != nil {
return false, "", fmt.Errorf("the tool runner's account %s could not be read: %w", account, err)
}
if setOf(in)[group] {
return false, "", nil
}
if err := sys.AddUserToGroup(ctx, system.Runner(run), account, group); err != nil {
return false, "", err
}
for _, m := range known.Members {
if m.Account == account && m.Group == group {
return true, fmt.Sprintf("put the tool runner's %s back in %s", account, group), nil
}
}
of.Account, of.Group = account, group
known.Members = append(known.Members, of)
return true, fmt.Sprintf("put the tool runner's %s in %s; it holds it once restarted", account, group), nil
}
// leaveRunners takes every tool runner's account the mesh put in a library's group, or a module's own group, out of
// it once no declared library or identity puts it there and no declared user resource asks for it (ADR 0252 §3). Never fatal: what could not be
// left stays recorded, said, for the next apply.
func leaveRunners(ctx context.Context, sys system.System, libs []declaration.Library, ids []declaration.Identity,
known *store.State, run Runner, wanted Wanted) []Outcome {
declared := map[string]bool{}
for _, l := range libs {
if l.Runner != "" {
declared[l.Runner+" "+l.Group] = true
}
}
for _, id := range ids {
if id.Runner != "" {
declared[id.Runner+" "+id.Name] = true
}
}
var out []Outcome
var keep []store.Membership
for _, m := range known.Members {
if declared[m.Account+" "+m.Group] {
keep = append(keep, m)
continue
}
o := Outcome{ID: LibraryAccess(m.Library) + " runner", Type: "library", Target: m.Account,
changes: LibraryAccess(m.Library)}
if m.Module != "" {
o.ID, o.Type = "identity "+declaration.IdentityPrefix+m.Module+" runner", "identity"
o.changes = IdentityChange(m.Module)
}
if others := wanted.othersAsk(m.Account, m.Group, ""); len(others) > 0 {
o.Action, o.Detail = "forgotten", fmt.Sprintf("left %s in %s, which %s asks for", m.Account, m.Group,
strings.Join(others, ", "))
out = append(out, o)
continue
}
gone, why := leaveGroup(ctx, sys, m.Account, m.Group, run)
o.Action, o.Detail = "restored", m.Account+" "+why
if !gone {
o.Action = "waiting"
keep = append(keep, m)
}
out = append(out, o)
}
known.Members = keep
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// pathsInShape is whether every requested sub-path not kept before is absent (it is made), empty, or already in the
// group with group write and setgid, it and a bounded sample beneath it: what may be kept without the operator's
// word (§7). One that cannot be read is not.
func pathsInShape(l declaration.Library, kept []string) bool {
done := map[string]bool{}
for _, p := range kept {
done[p] = true
}
for _, p := range l.Paths {
if done[p] {
continue
}
path := filepath.Join(l.Root, p)
if _, err := lstatWithin(path); errors.Is(err, os.ErrNotExist) {
continue
}
facts, err := readFolder(path)
if err != nil {
return false
}
if facts.Empty {
continue
}
if int(facts.GID) != l.GID || facts.Mode&0o2070 != 0o2070 || !sampleInShape(path, l.GID) {
return false
}
}
return true
}
// unionPaths is a with every path of b not in it.
func unionPaths(a, b []string) []string {
have := map[string]bool{}
for _, p := range a {
have[p] = true
}
for _, p := range b {
if !have[p] {
a = append(a, p)
have[p] = true
}
}
sort.Strings(a)
return a
}
// RunnerWarning is a tool runner's account that joins no group (missing, or root's): said as a resource of its own,
// `runner:<module>` or `runner:library:<name>`, unhealthy with words with no path — never a failure of the apply.
type RunnerWarning struct {
Resource, Module, Why string
}
var runnerWarnings struct {
sync.Mutex
list []RunnerWarning
}
// setRunnerWarnings is the warnings the last apply found, in place of the ones before.
func setRunnerWarnings(ws []RunnerWarning) {
runnerWarnings.Lock()
defer runnerWarnings.Unlock()
runnerWarnings.list = append([]RunnerWarning(nil), ws...)
}
// RunnerWarnings is every tool runner's account the last apply could not put in a group.
func RunnerWarnings() []RunnerWarning {
runnerWarnings.Lock()
defer runnerWarnings.Unlock()
return append([]RunnerWarning(nil), runnerWarnings.list...)
}
+161
View File
@@ -0,0 +1,161 @@
//go:build linux
package apply
import (
"context"
"errors"
"fmt"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// The take act on a library, and the walk that goes on with one stopped at its bound (novox/hq ADR 0328 §7).
// actRecord is a walk's counts as the record keeps them.
func actRecord(warrant, by, on string, started time.Time, w *treeWalk, err error) *store.KeptAct {
c := w.counts
rec := &store.KeptAct{Act: "take", Warrant: warrant, By: by, On: on, State: "done", Started: started,
Ended: time.Now().UTC(), Folders: c.Folders, Files: c.Files, Kept: c.Kept, Links: c.Links, Other: c.Other,
Mounts: c.Mounts, Shared: c.Shared, Linked: c.Linked, Stripped: c.Stripped}
if c.Shared+c.Linked+c.Stripped > 0 {
rec.Why = fmt.Sprintf("%d file(s) with another name given the group, %d root's left as they are, %d whose "+
"setuid or setgid bit the group change took", c.Linked, c.Shared, c.Stripped)
}
switch {
case errors.Is(err, ErrWalkBound):
rec.State, rec.Why = "stopped", "it met its bound of entries or time; it goes on from where it stopped"
case err != nil:
rec.State, rec.Why = "failed", err.Error()
case w.Failed != "":
rec.State = "failed"
rec.Why = fmt.Sprintf("%d entries could not be given the group: %s; walked again on the next look", c.Failed,
w.Failed)
}
return rec
}
// TakeLibrary is the take act (ADR 0328 §7): on the operator's warrant, the library's group, group read and write,
// and on folders group enter and setgid, set on its root and everything under it — no link followed, no other
// filesystem entered, the owner and the owner's and others' bits unchanged, a file with another name elsewhere left
// as it is. It judges the ask against the declaration last applied and the root as it is, and starts the walk: what
// it answers at once, and a channel closed when the walk is through or stopped at its bound. Refused, nothing is
// changed.
func TakeLibrary(ctx context.Context, d *declaration.Declaration, access, path, warrant, by string) (<-chan struct{},
string, error) {
if d == nil {
return nil, "", errors.New("this engine has applied no declaration, and takes no library it was not told of")
}
var lib *declaration.Library
for i := range d.Libraries {
if LibraryAccess(d.Libraries[i].Library) == access {
lib = &d.Libraries[i]
}
}
switch {
case lib == nil:
return nil, "", fmt.Errorf("the access %s names no library declared on this machine", access)
case !lib.Home:
return nil, "", fmt.Errorf("this machine is not the home of the library %s, and a library is taken on its "+
"home", lib.Library)
case lib.Root != path:
return nil, "", fmt.Errorf("the library %s is declared at another root than the one asked", lib.Library)
}
lock := libraryLock(lib.Library)
if !lock.TryLock() {
return nil, "", errors.New("a walk of the library is running; asked again once it is through, the act goes on")
}
rec, _ := libraryRecord(lib.Library)
v := assessLibrary(ctx, *lib, rec)
var refused error
switch {
case v.err != nil:
refused = v.err
case v.kind == libraryWaitDisk:
refused = errors.New(v.why)
case v.kind == libraryUnknown:
refused = errors.New(v.why + ", so a warrant could name no filesystem it was given for")
}
if refused != nil {
lock.Unlock()
return nil, "", refused
}
if _, _, err := keepFolders(lib.Root, lib.Paths, lib.GID); err != nil {
lock.Unlock()
return nil, "", err
}
started := time.Now().UTC()
_ = Libraries.Update(lib.Library, func(r *store.LibraryRecord, _ bool) bool {
r.Root, r.Mount, r.Placed, r.Fs, r.GID = lib.Root, v.mount, v.placed, v.fs, lib.GID
r.Kept, r.KeptOn, r.How, r.At = true, v.placed, store.KeptByWarrant, started
r.Warrant, r.By, r.Cursor = warrant, by, nil
r.Act = &store.KeptAct{Act: "take", Warrant: warrant, By: by, On: v.placed, State: "running", Started: started}
return true
})
done := make(chan struct{})
go func() {
defer close(done)
defer lock.Unlock()
walkLibrary(lib.Library, lib.Root, lib.GID, warrant, by, v.placed, nil)
}()
return done, fmt.Sprintf("the take act started under the warrant %s: %s's group, group read and write, and "+
"setgid on its folders are set on everything under its root, following no link and entering no other "+
"filesystem", warrant, lib.Library), nil
}
// walkLibrary walks a library's root from resume with its group, and records what it did: a walk stopped at its
// bound keeps its cursor for the next. The caller holds the library's lock.
func walkLibrary(name, root string, gid int, warrant, by, on string, resume []string) {
libraryWalks.Store(name, on)
defer libraryWalks.Delete(name)
started := time.Now().UTC()
w, err := walkRoot(root, ActLimits, machineStats, resume, groupVisit(gid))
rec := actRecord(warrant, by, on, started, w, err)
_ = Libraries.Update(name, func(r *store.LibraryRecord, _ bool) bool {
r.Act = rec
r.Cursor = nil
if errors.Is(err, ErrWalkBound) {
r.Cursor = w.Cursor
}
return true
})
}
// ReconcileLibraries goes on with every take act that stopped at its bound or left entries it could not change,
// from where it stopped — only while the library is still kept on the filesystem it was taken on (judged as an apply
// judges it). What it did, in words, for the engine to say.
func ReconcileLibraries(ctx context.Context, d *declaration.Declaration) []string {
if d == nil {
return nil
}
var said []string
for _, l := range d.Libraries {
if ctx.Err() != nil {
break
}
if !l.Home {
continue
}
rec, _ := libraryRecord(l.Library)
if rec.Act == nil || (rec.Act.State != "stopped" && rec.Act.State != "failed") || rec.How != store.KeptByWarrant {
continue
}
lock := libraryLock(l.Library)
if !lock.TryLock() {
continue
}
v := assessLibrary(ctx, l, rec)
if v.err != nil || v.kind != libraryKeep || v.placed != rec.KeptOn || v.moved {
lock.Unlock()
continue
}
walkLibrary(l.Library, l.Root, l.GID, rec.Warrant, rec.By, rec.KeptOn, rec.Cursor)
lock.Unlock()
after, _ := libraryRecord(l.Library)
said = append(said, fmt.Sprintf("the library %s: the take act went on, %s (%d folders and %d files changed)",
l.Library, after.Act.State, after.Act.Folders, after.Act.Files))
}
return said
}
File diff suppressed because it is too large Load Diff
+12 -7
View File
@@ -77,9 +77,10 @@ func applyMeshDirectory(r *declaration.Directory, out Outcome, mode os.FileMode,
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 {
beforeMode, beforeUID, beforeGID := statDirMode(st.Mode), int(st.Uid), int(st.Gid)
// Set explicitly even when it existed: a mode given at creation is not a mode maintained — its setgid bit
// with it (novox/hq ADR 0334 §1).
if err := unix.Fchmod(fd, unixDirMode(mode)); err != nil {
return out, &os.PathError{Op: "chmod", Path: r.Path, Err: err}
}
ownedAlready := !chown || (beforeUID == uid && beforeGID == gid)
@@ -87,6 +88,10 @@ func applyMeshDirectory(r *declaration.Directory, out Outcome, mode os.FileMode,
if err := unix.Fchown(fd, uid, gid); err != nil {
return out, fmt.Errorf("cannot give %s to %q: %w", r.Path, r.Owner, &os.PathError{Op: "chown", Path: r.Path, Err: err})
}
// A chown may take the setgid bit: the mode is set again after it.
if err := unix.Fchmod(fd, unixDirMode(mode)); err != nil {
return out, &os.PathError{Op: "chmod", Path: r.Path, Err: err}
}
}
// Read back, from the descriptor that was changed.
@@ -96,8 +101,8 @@ func applyMeshDirectory(r *declaration.Directory, out Outcome, mode os.FileMode,
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())
if got := statDirMode(st.Mode); got != dirBits(mode) {
return out, fmt.Errorf("%s is mode %s after setting %s", r.Path, modeText(got), modeText(mode))
}
out.Action = "unchanged"
@@ -106,9 +111,9 @@ func applyMeshDirectory(r *declaration.Directory, out Outcome, mode os.FileMode,
out.Action = "created"
case !ownedAlready:
out.Action = "updated"
case beforeMode != mode.Perm():
case beforeMode != dirBits(mode):
out.Action = "updated"
out.Detail = fmt.Sprintf("mode %o to %o", beforeMode, mode.Perm())
out.Detail = fmt.Sprintf("mode %s to %s", modeText(beforeMode), modeText(mode))
}
return out, nil
}
-136
View File
@@ -1,136 +0,0 @@
//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)
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ import (
"time"
)
// Whether a shared folder's disk is there (novox/hq ADR 0325 §9).
// Whether a library's disk is there (novox/hq ADR 0328 §9; first ADR 0325 §9).
//
// **The rule the engine keeps.** A folder is placed on the filesystem it is first seen on: the mount point that
// holds it — the longest mount point in /proc/self/mountinfo that is the folder or above it — is recorded beside the
@@ -20,7 +20,7 @@ import (
// 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).
// word as any folder holding data does (ADR 0328 §7).
//
// **On first sight, with no record,** the engine knows a disk is missing only from where disks are meant to be:
// every mount target in /etc/fstab, every `Where=` of a systemd .mount unit, every mount point a ZFS dataset names
+1 -1
View File
@@ -112,7 +112,7 @@ func Plan(d *declaration.Declaration, known store.State, origin string) []Step {
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).
// In takeBackGranted's words (novox/hq ADR 0328 §11: no account is in a group for data but a library's).
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"
+5 -5
View File
@@ -15,7 +15,7 @@ import (
"github.com/novox/mesh-host/internal/accounts"
)
// Root's numbers, which no module identity is and whose data is never kept (novox/hq ADR 0325 §11, §12).
// Root's numbers, which no module identity is and whose data is never kept (novox/hq ADR 0328 §15).
//
// A module identity whose uid or gid is 0, or whose gid is a group whose members act as root (accounts.RootGroups,
// the account judge's own closed list), would be a way to root through a container or a shared folder: refused,
@@ -105,7 +105,7 @@ func rootGroup(gid int) string {
return ""
}
// rootOwned is why a folder owned by uid and gid is root's data (ADR 0325 §12), or "": owned by root, or by a
// rootOwned is why a folder owned by uid and gid is root's data (ADR 0325 §12, kept for the legacy reader), or "": owned by root, or by a
// root group.
func rootOwned(uid, gid uint32) string {
if uid == 0 {
@@ -117,13 +117,13 @@ func rootOwned(uid, gid uint32) string {
return ""
}
// rootIdentity is why a module identity's numbers are refused (ADR 0325 §11), or nil.
// rootIdentity is why a module identity's numbers are refused (ADR 0328 §15), or nil.
func rootIdentity(uid, gid int) error {
if uid <= 0 {
return fmt.Errorf("its uid is %d, and a module's identity is never root (novox/hq ADR 0325 §11)", uid)
return fmt.Errorf("its uid is %d, and a module's identity is never root (novox/hq ADR 0328 §15)", uid)
}
if why := rootGroup(gid); why != "" {
return fmt.Errorf("its gid is %s, and a module's identity is never in a root group (novox/hq ADR 0325 §11)", why)
return fmt.Errorf("its gid is %s, and a module's identity is never in a root group (novox/hq ADR 0328 §15)", why)
}
return nil
}
+22 -33
View File
@@ -6,60 +6,49 @@ import (
"time"
)
// A bounded walk under a kept folder (novox/hq ADR 0325 §8, §10, issue 567).
// A bounded walk under a library's root (novox/hq ADR 0328 §7, issue 567).
//
// **Walked by descriptor.** The folder is opened as a mesh directory is (meshWalk: no link another account could
// make, the folder itself no link), and everything under it is reached from its parent's descriptor with
// O_NOFOLLOW: a link met is passed by, a folder whose device is not the kept folder's is not entered, and an entry
// opened is checked to be the one that was looked at. An entry's ACL is read and written through its own
// descriptor, so a name swapped for a link between the look and the change changes nothing.
// **Walked by descriptor.** The root is opened as a mesh directory is (meshWalk: no link another account could
// make, the root itself no link), and everything under it is reached from its parent's descriptor with O_NOFOLLOW:
// a link met is passed by, a folder whose device is not the root's is not entered, and an entry opened is checked
// to be the one that was looked at. An entry's group and mode are set through its own descriptor, so a name
// swapped for a link between the look and the change changes nothing.
//
// **Bounded, and resumed.** A media library is millions of files, and a walk that never ends is a machine that
// cannot say what it did. Each walk reads at most so many entries for at most so long; one that meets its bound
// says how far it got (a cursor, the names of each level below the folder, kept in the engine's state and never
// said) and the next goes on from there, in the order of the names, so successive looks cover a folder whole.
// **Bounded, and resumed.** A library is millions of files, and a walk that never ends is a machine that cannot
// say what it did. Each walk reads at most so many entries for at most so long; one that meets its bound says how
// far it got (a cursor, the names of each level below the root, kept in the engine's state and never said) and the
// next goes on from there, in the order of the names, so successive walks cover the library whole.
// WalkLimits bound one walk under a folder: how many entries it reads, and for how long.
// WalkLimits bound one walk under a root: how many entries it reads, and for how long.
type WalkLimits struct {
Entries int
Time time.Duration
}
// ActLimits bound the take act and the walk that adds or drops a user's entries. One stopped goes on at the next
// apply, from the start: what already has its entries is passed quickly.
// ActLimits bound one walk of the take act; one stopped at its bound goes on from its cursor at the next look.
var ActLimits = WalkLimits{Entries: 5_000_000, Time: 30 * time.Minute}
// SweepLimits bound the sweep of one kept folder on one look (ADR 0325 §10): the look must come back within its
// own bound whatever the folder holds.
var SweepLimits = WalkLimits{Entries: 2000, Time: 2 * time.Second}
// WalkCounts is what one walk did or found. Counts only: never a path below the folder.
// WalkCounts is what one walk did or found. Counts only: never a path below the root.
type WalkCounts struct {
// Folders and Files are the entries changed; Kept the ones that already had what they need.
Folders, Files, Kept int
// Links are links passed by, Other entries neither a file nor a folder, Mounts folders on another
// filesystem not entered.
Links, Other, Mounts int
// Read is every entry looked at.
Read int
// Narrowed is the entries that gave a user less than its right (judged by entry and mask together) and
// were put back; Unrepaired the ones that could not be — the owner's bits, which the mesh never changes.
Narrowed, Unrepaired int
// Links are links passed by, Other entries neither a file nor a folder, Mounts folders on another filesystem
// not entered; Linked files with another name an account owns, given the group; Shared files with another
// name root owns, left as they are; Stripped files whose setuid or setgid bit the group change took.
Links, Other, Mounts, Linked, Shared, Stripped int
// Read is every entry looked at; Failed the entries that could not be changed (an immutable file, say).
Read, Failed int
}
// ErrWalkBound is a walk that met its bound before it was through: what it did stays, and the cursor says where
// the next one goes on.
var ErrWalkBound = errors.New("the walk met its bound")
// ErrNoACL is a filesystem that allows no ACLs here (ENOTSUP): a ZFS dataset whose acltype is off, say. The access
// waits for the take act, which sets it where it can (ADR 0325 §8).
var ErrNoACL = errors.New("the filesystem allows no access entries")
// keptPath refuses a folder that is not stated exactly: absolute and clean, so `..` and a doubled separator
// never name it.
// keptPath refuses a root that is not stated exactly: absolute and clean, so `..` and a doubled separator never
// name it.
func keptPath(path string) error {
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
return errors.New("the folder is not stated as the engine states one (absolute, no `..`, no doubled or " +
return errors.New("the root is not stated as the engine states one (absolute, no `..`, no doubled or " +
"trailing separator); nothing was changed")
}
return nil
+255 -292
View File
@@ -2,7 +2,7 @@
package apply
// The descriptor half of walk.go and acl.go (novox/hq ADR 0325 §5, §8, §9, §10).
// The descriptor half of walk.go and library.go (novox/hq ADR 0328 §6, §7, §9).
import (
"errors"
@@ -10,6 +10,7 @@ import (
"io"
"os"
"sort"
"strings"
"time"
"golang.org/x/sys/unix"
@@ -29,29 +30,45 @@ var machineStats = walkStats{
fd: unix.Fstat,
}
// visitFunc is what a walk does to one folder or file, open on fd, whose stat is st: whether it changed it, and
// whether it found the entry narrowed and could not put it back. An error stops the walk.
type visitFunc func(fd int, st unix.Stat_t, folder bool) (changed, narrowed, unrepaired bool, err error)
// What a visit did to one entry, as flags: nothing (it had what it needs), or changed, and beside it whether it was a
// file with another name (linked, changed; shared, left), and whether its setuid or setgid bit went.
const (
visitKept = 0
visitChanged = 1 << iota
visitLinked // a file with another name, owned by an account: given the group
visitShared // a file with another name, owned by root: left as it is
visitStripped // a file whose setuid or setgid bit the group change took, and which stays taken
)
// aclWalk is one walk under a kept folder, from the descriptor of the folder itself.
type aclWalk struct {
// visitFunc is what a walk does to one folder or file, open on fd, whose stat is st: what it did, or the error
// counted on that entry alone (errStopWalk stops the walk).
type visitFunc func(fd int, st unix.Stat_t, folder bool) (int, error)
// errStopWalk wraps an error that stops the whole walk, never one entry's alone.
type errStopWalk struct{ err error }
func (e errStopWalk) Error() string { return e.err.Error() }
func (e errStopWalk) Unwrap() error { return e.err }
// treeWalk is one walk under a library's root, from the descriptor of the root itself.
type treeWalk struct {
dev uint64
deadline time.Time
limit int
stats walkStats
visit visitFunc
counts WalkCounts
// at is the names of the levels below the folder down to the entry being walked; Cursor, once the walk met
// its bound, where the next goes on.
// at is the names of the levels below the root down to the entry being walked; Cursor, once the walk met its
// bound, where the next goes on.
at []string
Cursor []string
// Failed is the first failure on one entry, in words with no path.
Failed string
}
// openKept opens a kept folder as a mesh directory is opened (no link another account could make, the folder
// itself no link) and starts a walk from it on the folder's own device.
func openKept(path string, limits WalkLimits, stats walkStats, visit visitFunc) (*aclWalk, int, unix.Stat_t, error) {
// openRoot opens a library's root as a mesh directory is opened (no link another account could make, the root
// itself no link) and starts a walk from it on the root's own device.
func openRoot(path string, limits WalkLimits, stats walkStats, visit visitFunc) (*treeWalk, int, unix.Stat_t, error) {
var st unix.Stat_t
if err := keptPath(path); err != nil {
return nil, -1, st, err
@@ -59,36 +76,36 @@ func openKept(path string, limits WalkLimits, stats walkStats, visit visitFunc)
mw := &meshWalk{want: path}
fd, err := mw.open()
if errors.Is(err, errNotThereYet) {
return nil, -1, st, errors.New("the folder is not there; nothing was changed")
return nil, -1, st, errors.New("the root is not there; nothing was changed")
}
if err != nil {
return nil, -1, st, errors.New("the folder is not walked: " + pathless(err))
return nil, -1, st, errors.New("the root is not walked: " + pathless(err))
}
if mw.At != path {
unix.Close(fd)
return nil, -1, st, errors.New("the folder is reached through a link, and the engine walks no folder " +
"through one; nothing was changed")
return nil, -1, st, errors.New("the root is reached through a link, and the engine walks no root through " +
"one; nothing was changed")
}
if err := stats.fd(fd, &st); err != nil {
unix.Close(fd)
return nil, -1, st, fmt.Errorf("the folder cannot be read: %v; nothing was changed", err)
return nil, -1, st, fmt.Errorf("the root cannot be read: %v; nothing was changed", err)
}
return &aclWalk{dev: uint64(st.Dev), deadline: time.Now().Add(limits.Time), limit: limits.Entries,
return &treeWalk{dev: uint64(st.Dev), deadline: time.Now().Add(limits.Time), limit: limits.Entries,
stats: stats, visit: visit}, fd, st, nil
}
// walkKept walks path whole, or from resume, with visit: the walk, with what it did and the cursor where it
// stopped when it met its bound (with ErrWalkBound), and the walk's own error otherwise.
func walkKept(path string, limits WalkLimits, stats walkStats, resume []string, visit visitFunc) (*aclWalk, error) {
w, fd, st, err := openKept(path, limits, stats, visit)
// walkRoot walks path whole, or from resume, with visit: the walk, with what it did and the cursor where it stopped
// when it met its bound (with ErrWalkBound), and the walk's own error otherwise.
func walkRoot(path string, limits WalkLimits, stats walkStats, resume []string, visit visitFunc) (*treeWalk, error) {
w, fd, st, err := openRoot(path, limits, stats, visit)
if err != nil {
return &aclWalk{}, err
return &treeWalk{}, err
}
return w, w.dir(fd, st, resume)
}
// read counts one entry, and says the bound when it is met, keeping where the walk stood as the cursor.
func (w *aclWalk) read() error {
func (w *treeWalk) read() error {
if w.counts.Read >= w.limit || time.Now().After(w.deadline) {
w.Cursor = append([]string(nil), w.at...)
return ErrWalkBound
@@ -97,27 +114,9 @@ func (w *aclWalk) read() error {
return nil
}
// count adds one visit's result.
func (w *aclWalk) count(folder, changed, narrowed, unrepaired bool) {
switch {
case changed && folder:
w.counts.Folders++
case changed:
w.counts.Files++
default:
w.counts.Kept++
}
if narrowed {
w.counts.Narrowed++
}
if unrepaired {
w.counts.Unrepaired++
}
}
// dir is one folder, open on fd, whose stat is st: the folder itself (unless the walk resumes below it, which an
// earlier walk visited), then every entry in it in the order of their names. It closes fd.
func (w *aclWalk) dir(fd int, st unix.Stat_t, resume []string) error {
func (w *treeWalk) dir(fd int, st unix.Stat_t, resume []string) error {
f := os.NewFile(uintptr(fd), "")
defer f.Close()
if len(resume) == 0 {
@@ -154,9 +153,9 @@ func (w *aclWalk) dir(fd int, st unix.Stat_t, resume []string) error {
return nil
}
// entry is one name in the folder open on dirfd: a link passed by, a folder on this device walked, a file
// visited through its own descriptor, anything else left.
func (w *aclWalk) entry(dirfd int, name string, resume []string) error {
// entry is one name in the folder open on dirfd: a link passed by, a folder on this device walked, a file visited
// through its own descriptor, anything else left.
func (w *treeWalk) entry(dirfd int, name string, resume []string) error {
var st unix.Stat_t
if err := w.stats.at(dirfd, name, &st); err != nil {
if errors.Is(err, unix.ENOENT) {
@@ -212,192 +211,88 @@ func (w *aclWalk) entry(dirfd int, name string, resume []string) error {
return w.visited(fd, opened, false)
}
// visited visits one entry and counts what it did. A filesystem that allows no ACLs stops the walk; any other
// failure on one entry (an immutable file, say) is counted unrepaired, its words kept once, and the walk goes on.
func (w *aclWalk) visited(fd int, st unix.Stat_t, folder bool) error {
changed, narrowed, unrepaired, err := w.visit(fd, st, folder)
if errors.Is(err, ErrNoACL) {
return err
// visited visits one entry and counts what it did. An error that stops the walk stops it; any other failure on
// one entry (an immutable file, say) is counted failed, its words kept once, and the walk goes on.
func (w *treeWalk) visited(fd int, st unix.Stat_t, folder bool) error {
did, err := w.visit(fd, st, folder)
var stop errStopWalk
if errors.As(err, &stop) {
return stop.err
}
if err != nil {
unrepaired = true
w.counts.Failed++
if w.Failed == "" {
w.Failed = err.Error()
}
return nil
}
w.count(folder, changed, narrowed, unrepaired)
return nil
}
// getACL reads one of an entry's ACLs through its descriptor: the access ACL, or a folder's default. An access
// ACL that is not there is the one its mode stands for; a default that is not there is nil.
func getACL(fd int, name string, mode uint32) (acl, error) {
buf := make([]byte, 4+8*32)
for {
n, err := unix.Fgetxattr(fd, name, buf)
switch {
case errors.Is(err, unix.ENODATA):
if name == aclAccessXattr {
return minimalACL(mode), nil
}
return nil, nil
case errors.Is(err, unix.ERANGE):
buf = make([]byte, 2*len(buf))
continue
case errors.Is(err, unix.ENOTSUP), errors.Is(err, unix.EOPNOTSUPP):
return nil, ErrNoACL
case err != nil:
return nil, fmt.Errorf("an entry's ACL could not be read: %v", err)
}
return decodeACL(buf[:n])
}
}
// setACL writes one of an entry's ACLs through its descriptor, refusing one the kernel would.
func setACL(fd int, name string, a acl) error {
if err := a.check(); err != nil {
return err
}
err := unix.Fsetxattr(fd, name, a.encode(), 0)
switch {
case errors.Is(err, unix.ENOTSUP), errors.Is(err, unix.EOPNOTSUPP):
return ErrNoACL
case err != nil:
return fmt.Errorf("an entry's ACL could not be written: %v", err)
case did&visitShared != 0:
w.counts.Shared++
case did&visitChanged != 0 && folder:
w.counts.Folders++
case did&visitChanged != 0:
w.counts.Files++
default:
w.counts.Kept++
}
if did&visitLinked != 0 {
w.counts.Linked++
}
if did&visitStripped != 0 {
w.counts.Stripped++
}
return nil
}
// sameACL is whether a and b hold the same entries, whatever their order.
func sameACL(a, b acl) bool {
return string(a.encode()) == string(b.encode())
}
// needs is, for a folder or a file, each kept uid's bits — except its owner's, which its owner's bits give and the
// mesh never changes.
func needs(users map[uint32]bool, owner uint32, folder bool) map[uint32]uint16 {
out := map[uint32]uint16{}
for uid, rw := range users {
if uid != owner {
out[uid] = needOn(rw, folder)
}
}
return out
}
// keepVisit makes users' entries on each entry (ADR 0325 §5): the access ACL with each uid's entry and a mask
// that covers it, and on a folder the same as its default, so what is made under it inherits them. An entry that
// had them is not written. A user that owns the entry is judged by its owner's bits, never given an entry: an
// owner whose bits fall short is unrepaired, said, and never changed.
func keepVisit(users map[uint32]bool) visitFunc {
return func(fd int, st unix.Stat_t, folder bool) (bool, bool, bool, error) {
mode := uint32(st.Mode) & 0o7777
need := needs(users, st.Uid, folder)
access, err := getACL(fd, aclAccessXattr, mode)
if err != nil {
return false, false, false, err
}
narrowed, unrepaired := judgeACL(access, users, st, folder)
changed := false
if kept := keptACL(access, need); !sameACL(kept, access) {
if err := setACL(fd, aclAccessXattr, kept); err != nil {
return false, narrowed, true, err
// groupVisit gives each entry the library's group gid, group read and write, and on a folder group enter and the
// setgid bit, so what is made beneath keeps the group (ADR 0328 §6, §7). The owner, the owner's bits and others'
// bits are never changed — but for what the group change itself takes:
//
// - **A file's setuid and setgid bits stay taken.** The kernel drops them when a file's group changes; a setgid
// file in the library's group would hand anyone who runs it the group, and a setuid one is no library data. They
// are never put back, setgid is taken off a file the group change left it on, and each such file is counted.
// - **A file with another name** (the *arr apps hardlink downloads into the library) is given the group when an
// account owns it: the kernel's protected_hardlinks already keeps an account from linking a file it does not
// own. One root owns is left as it is: its other name may be anywhere, and the group would reach it. Both are
// counted.
func groupVisit(gid int) visitFunc {
return func(fd int, st unix.Stat_t, folder bool) (int, error) {
did := visitKept
if !folder && st.Nlink > 1 {
if st.Uid == 0 {
return visitShared, nil
}
changed = true
did |= visitLinked
}
mode := uint32(st.Mode) & 0o7777
want := mode | 0o060
if folder {
def, err := getACL(fd, aclDefaultXattr, mode)
if err != nil {
return changed, narrowed, unrepaired, err
}
base := def
if base == nil {
// No default yet: one whose owner's, owning group's and others' entries are the folder's own, as
// setfacl -d derives it.
base = minimalACL(mode)
if g, ok := access.perm(aclGroupObj, 0); ok {
base[1].Perm = g
}
}
// The default is for what anyone makes under the folder, never its owner alone: every user's entry.
all := map[uint32]uint16{}
for uid, rw := range users {
all[uid] = needOn(rw, true)
}
if kept := keptACL(base, all); def == nil || !sameACL(kept, def) {
if err := setACL(fd, aclDefaultXattr, kept); err != nil {
return changed, narrowed, true, err
}
changed = true
want = mode | 0o070 | unix.S_ISGID
}
regroup := int(st.Gid) != gid
if !folder && regroup {
want &^= unix.S_ISUID | unix.S_ISGID
}
if !regroup && want == mode {
return did &^ visitLinked, nil
}
if regroup {
if err := unix.Fchown(fd, -1, gid); err != nil {
return 0, fmt.Errorf("an entry's group could not be set: %v", err)
}
}
return changed, narrowed, unrepaired, nil
if err := unix.Fchmod(fd, want); err != nil {
return 0, fmt.Errorf("an entry's mode could not be set: %v", err)
}
if !folder && mode&(unix.S_ISUID|unix.S_ISGID) != 0 && want&(unix.S_ISUID|unix.S_ISGID) == 0 {
did |= visitStripped
}
return did | visitChanged, nil
}
}
// judgeACL is whether an entry with access ACL a gives a user less than its right — by entry and mask together
// (ADR 0325 §6) — and whether that is one the mesh cannot put back: the owner's own bits.
func judgeACL(a acl, users map[uint32]bool, st unix.Stat_t, folder bool) (narrowed, unrepaired bool) {
mode := uint32(st.Mode) & 0o7777
for uid, rw := range users {
need := needOn(rw, folder)
if effective(a, uid, st.Uid, mode)&need == need {
continue
}
narrowed = true
if uid == st.Uid {
unrepaired = true
}
}
return narrowed, unrepaired
}
// dropVisit takes exactly the entries of each uid of drop out of each entry's access ACL and a folder's default
// (ADR 0325 §8): no other entry, and not the mask, so nobody else loses a right.
func dropVisit(drop map[uint32]bool) visitFunc {
return func(fd int, st unix.Stat_t, folder bool) (bool, bool, bool, error) {
mode := uint32(st.Mode) & 0o7777
changed := false
for _, name := range []string{aclAccessXattr, aclDefaultXattr} {
if name == aclDefaultXattr && !folder {
continue
}
a, err := getACL(fd, name, mode)
if errors.Is(err, ErrNoACL) {
continue
}
if err != nil {
// Counted unrepaired and said by the walk, which then walks it again: never read as "nothing to drop".
return changed, false, true, err
}
if a == nil {
continue
}
if left := withoutUsers(a, drop); len(left) != len(a) {
if err := setACL(fd, name, left); err != nil {
return changed, false, true, err
}
changed = true
}
}
return changed, false, false, nil
}
}
// dropThenKeep is drop then keep, on one walk: a user whose use changed, or one gone beside one come.
func dropThenKeep(drop map[uint32]bool, users map[uint32]bool) visitFunc {
d, k := dropVisit(drop), keepVisit(users)
return func(fd int, st unix.Stat_t, folder bool) (bool, bool, bool, error) {
c1, _, _, err := d(fd, st, folder)
if err != nil {
return c1, false, false, err
}
c2, n, u, err := k(fd, st, folder)
return c1 || c2, n, u, err
}
}
// folderEmpty is whether the folder open on fd holds nothing yet: the data the mesh may keep without asking.
// folderEmpty is whether the folder open on fd holds nothing yet: a root the mesh may keep without asking.
func folderEmpty(fd int) (bool, error) {
dup, err := unix.Dup(fd)
if err != nil {
@@ -415,20 +310,11 @@ func folderEmpty(fd int) (bool, error) {
return false, fmt.Errorf("the folder could not be read: %v", err)
}
// keepTop keeps users' entries on the kept folder itself (ADR 0325 §5): what every apply does, beside the walks.
func keepTop(path string, users map[uint32]bool) (changed bool, err error) {
w, fd, st, err := openKept(path, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, keepVisit(users))
if err != nil {
return false, err
}
defer unix.Close(fd)
changed, _, _, err = w.visit(fd, st, true)
return changed, err
}
// FolderFacts is what the engine reads of a folder before keeping it: its owner, group and whether it holds data.
// FolderFacts is what the engine reads of a library's root before keeping it: its owner, group, mode and whether
// it holds data.
type FolderFacts struct {
UID, GID uint32
Mode uint32
Empty bool
}
@@ -455,87 +341,105 @@ func readFolder(path string) (FolderFacts, error) {
if err != nil {
return FolderFacts{}, err
}
return FolderFacts{UID: st.Uid, GID: st.Gid, Empty: empty}, nil
return FolderFacts{UID: st.Uid, GID: st.Gid, Mode: uint32(st.Mode) & 0o7777, Empty: empty}, nil
}
// CreatePath makes a missing accessed folder (ADR 0325 §9): each missing level made from the nearest existing
// ancestor down, each with that ancestor's owner, group and mode. No link is followed (the ancestor is reached as
// a mesh directory is, and each level is opened with O_NOFOLLOW), no level is made on another filesystem, no file
// is made, and nothing that was there is changed: a level that appears meanwhile stops it. The caller has judged
// that the ancestor is on the placed filesystem and not root's. How many levels it made.
func CreatePath(path string) (int, error) {
return createPath(path, machineStats)
}
func createPath(path string, stats walkStats) (int, error) {
m, err := NearestAncestor(path)
// keepFolders keeps a library's root and each of its requested sub-paths — the folders themselves, never what is
// in them — in the group gid with group read, write and enter and the setgid bit (ADR 0328 §6). A sub-path that is
// missing is made, each level with the root's owner, the group, and the root's owner's and others' bits beside
// group rwx and setgid (§9). No link is followed (the root is reached as a mesh directory is, and every level below
// it by O_NOFOLLOW), nothing is made or changed on another filesystem, and the owner and the owner's and others'
// bits of a folder that was there are never changed. How many folders it changed and made.
func keepFolders(root string, paths []string, gid int) (changed, made int, err error) {
_, fd, rst, err := openRoot(root, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, nil)
if err != nil {
return 0, fmt.Errorf("the folder cannot be made: %v; nothing was changed", err)
return 0, 0, err
}
if !m.Info.IsDir() {
return 0, errors.New("its nearest existing parent is not a folder (a file or a link), and a folder is " +
"made only below a folder; nothing was changed")
}
mw := &meshWalk{want: m.Ancestor}
fd, err := mw.open()
if err != nil {
return 0, fmt.Errorf("the nearest existing parent is not walked: %v; nothing was changed", err)
}
if mw.At != m.Ancestor {
unix.Close(fd)
return 0, errors.New("the nearest existing parent is reached through a link, and nothing is made " +
"through one; nothing was changed")
}
var anc unix.Stat_t
if err := stats.fd(fd, &anc); err != nil {
unix.Close(fd)
return 0, fmt.Errorf("the nearest existing parent cannot be read: %v; nothing was changed", err)
}
if why := rootOwned(anc.Uid, anc.Gid); why != "" {
unix.Close(fd)
return 0, fmt.Errorf("its nearest existing parent is %s, and nothing is made under root's data "+
"(novox/hq ADR 0325 §12); nothing was changed", why)
}
mode := uint32(anc.Mode) & 0o7777
made := 0
for _, name := range m.Levels {
// Made for its maker alone (the engine, as root), so it is nobody else's until it is the ancestor's.
if err := unix.Mkdirat(fd, name, 0o700); err != nil {
unix.Close(fd)
if errors.Is(err, unix.EEXIST) {
return made, errors.New("a level of the folder appeared while it was made, and nothing that is " +
"there is changed; it stopped there")
}
return made, fmt.Errorf("a level could not be made: %v", err)
defer unix.Close(fd)
keep := func(fd int, st unix.Stat_t) error {
mode := uint32(st.Mode) & 0o7777
want := mode | 0o070 | unix.S_ISGID
if int(st.Gid) == gid && want == mode {
return nil
}
next, err := unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
unix.Close(fd)
if int(st.Gid) != gid {
if err := unix.Fchown(fd, -1, gid); err != nil {
return fmt.Errorf("a folder's group could not be set: %v", err)
}
}
if err := unix.Fchmod(fd, want); err != nil {
return fmt.Errorf("a folder's mode could not be set: %v", err)
}
changed++
return nil
}
if err := keep(fd, rst); err != nil {
return changed, made, err
}
madeMode := uint32(rst.Mode)&0o0707 | 0o070 | unix.S_ISGID
for _, p := range paths {
n, err := keepPath(fd, rst, p, gid, madeMode, keep)
made += n
if err != nil {
return made, fmt.Errorf("a level made could not be opened: %v", err)
return changed, made, err
}
fd = next
made++
}
return changed, made, nil
}
// keepPath walks one sub-path down from the root open on rootFd, making each missing level, and keeps the last.
func keepPath(rootFd int, rst unix.Stat_t, p string, gid int, madeMode uint32,
keep func(int, unix.Stat_t) error) (int, error) {
made := 0
cur, err := unix.Dup(rootFd)
if err != nil {
return made, err
}
defer func() { unix.Close(cur) }()
const dirFlags = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC
levels := strings.Split(p, "/")
for i, name := range levels {
next, err := unix.Openat(cur, name, dirFlags, 0)
if errors.Is(err, unix.ENOENT) {
// Made for its maker alone (the engine, as root), so it is nobody else's until it is the root's.
if mkErr := unix.Mkdirat(cur, name, 0o700); mkErr != nil && !errors.Is(mkErr, unix.EEXIST) {
return made, fmt.Errorf("a level of the path %s could not be made: %v", p, mkErr)
}
if next, err = unix.Openat(cur, name, dirFlags, 0); err == nil {
if err = unix.Fchown(next, int(rst.Uid), gid); err == nil {
err = unix.Fchmod(next, madeMode)
}
if err != nil {
unix.Close(next)
return made, fmt.Errorf("a level of the path %s made could not be given the root's owner "+
"and the library's group: %v", p, err)
}
made++
}
}
if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) {
return made, fmt.Errorf("the path %s is not a folder below the root (a link, or a file), and the "+
"engine keeps no path through one", p)
}
if err != nil {
return made, fmt.Errorf("the path %s could not be opened: %v", p, err)
}
unix.Close(cur)
cur = next
var st unix.Stat_t
if err := stats.fd(fd, &st); err != nil {
unix.Close(fd)
return made, fmt.Errorf("a level made could not be read: %v", err)
if err := unix.Fstat(cur, &st); err != nil {
return made, err
}
if uint64(st.Dev) != uint64(anc.Dev) {
unix.Close(fd)
return made, errors.New("a level made is on another filesystem than its parent, and it stopped there")
if st.Dev != rst.Dev {
return made, fmt.Errorf("the path %s lies on another filesystem than the root, and the engine keeps "+
"nothing there", p)
}
if st.Uid != anc.Uid || st.Gid != anc.Gid {
if err := unix.Fchown(fd, int(anc.Uid), int(anc.Gid)); err != nil {
unix.Close(fd)
return made, fmt.Errorf("a level made could not be given its parent's owner: %v", err)
if i == len(levels)-1 {
if err := keep(cur, st); err != nil {
return made, err
}
}
if err := unix.Fchmod(fd, mode); err != nil {
unix.Close(fd)
return made, fmt.Errorf("a level made could not be given its parent's mode: %v", err)
}
}
unix.Close(fd)
return made, nil
}
@@ -570,3 +474,62 @@ var MountUUID = func(point string) string {
}
return ""
}
// SampleLimits bound the sample that judges whether a root's data is already the library's: so many entries a
// folder, so many levels down, for so long. A sample that runs out of time is not in shape: it asks.
var SampleLimits = struct {
PerFolder, Levels int
Time time.Duration
}{PerFolder: 20, Levels: 3, Time: 2 * time.Second}
// sampleInShape is whether the first entries of each folder under root, a few levels down, are all in the group gid
// with group read and write, and folders with group enter and setgid (ADR 0328 §7). Reads only, by descriptor: no
// link followed, no other filesystem entered. A file with more than one name is not judged (the take act leaves it).
func sampleInShape(root string, gid int) bool {
_, fd, st, err := openRoot(root, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, nil)
if err != nil {
return false
}
deadline := time.Now().Add(SampleLimits.Time)
return sampleFolder(fd, uint64(st.Dev), gid, SampleLimits.Levels, deadline)
}
// sampleFolder judges the folder open on fd's first entries, and the folders among them levels further. It closes fd.
func sampleFolder(fd int, dev uint64, gid, levels int, deadline time.Time) bool {
f := os.NewFile(uintptr(fd), "")
defer f.Close()
names, err := f.Readdirnames(SampleLimits.PerFolder)
if err != nil && !errors.Is(err, io.EOF) {
return false
}
for _, name := range names {
if time.Now().After(deadline) {
return false
}
var st unix.Stat_t
if err := unix.Fstatat(fd, name, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
return false
}
if uint64(st.Dev) != dev {
continue
}
mode := uint32(st.Mode)
switch mode & unix.S_IFMT {
case unix.S_IFREG:
if st.Nlink == 1 && (int(st.Gid) != gid || mode&0o060 != 0o060) {
return false
}
case unix.S_IFDIR:
if int(st.Gid) != gid || mode&0o2070 != 0o2070 {
return false
}
if levels > 1 {
next, err := unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil || !sampleFolder(next, dev, gid, levels-1, deadline) {
return false
}
}
}
}
return true
}
+13 -35
View File
@@ -7,55 +7,33 @@ import (
"errors"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// The node-engine runs on Linux: a folder is kept only by descriptor and through the kernel's ACL attributes,
// which this system has not. Everything refuses, saying so, and changes nothing.
// The node-engine runs on Linux: a library is kept only by descriptor, which this system cannot. Everything refuses,
// saying so, and changes nothing.
var errNoKeeping = errors.New("a shared folder is kept only by descriptor, which this system cannot; nothing was changed")
var errNoKeeping = errors.New("a library is kept only by descriptor, which this system cannot; nothing was changed")
// FolderFacts is what the engine reads of a folder before keeping it.
// FolderFacts is what the engine reads of a library's root before keeping it.
type FolderFacts struct {
UID, GID uint32
Mode uint32
Empty bool
}
func readFolder(string) (FolderFacts, error) { return FolderFacts{}, errNoKeeping }
func keepTop(string, map[uint32]bool) (bool, error) { return false, errNoKeeping }
func CreatePath(string) (int, error) { return 0, errNoKeeping }
func ReconcileKept(context.Context) []string { return nil }
func JudgeKept(string, []store.KeptUser, WalkLimits) (WalkCounts, error) {
return WalkCounts{}, errNoKeeping
}
func readFolder(string) (FolderFacts, error) { return FolderFacts{}, errNoKeeping }
func keepFolders(string, []string, int) (int, int, error) { return 0, 0, errNoKeeping }
func ReconcileLibraries(context.Context, *declaration.Declaration) []string { return nil }
// KeptLook is what one look found of a kept folder.
type KeptLook struct {
Short []string
Busy bool
Sweep store.KeptSweep
Repaired, Unrepaired int
Failed string
}
func LookKept(string, []store.KeptUser, WalkLimits) KeptLook {
return KeptLook{Failed: errNoKeeping.Error()}
}
func TakeFolder(context.Context, *declaration.Declaration, string, string, string, string, Runner) (<-chan struct{},
func TakeLibrary(context.Context, *declaration.Declaration, string, string, string, string) (<-chan struct{},
string, error) {
return nil, "", errNoKeeping
}
// Watcher watches nothing where a folder is not kept.
type Watcher struct{}
func NewWatcher() (*Watcher, error) { return nil, errNoKeeping }
func (*Watcher) Set(map[string][]store.KeptUser) {}
func (*Watcher) Run(context.Context) {}
// MountFsid reads no filesystem id where a folder is not kept.
// MountFsid reads no filesystem id where a library is not kept.
var MountFsid = func(string) string { return "" }
// MountUUID reads no UUID where a folder is not kept.
// MountUUID reads no UUID where a library is not kept.
var MountUUID = func(string) string { return "" }
func sampleInShape(string, int) bool { return false }
-350
View File
@@ -1,350 +0,0 @@
//go:build linux
package apply
import (
"context"
"errors"
"strconv"
"sync"
"time"
"unsafe"
"golang.org/x/sys/unix"
"github.com/novox/mesh-host/internal/store"
)
// The watch on each kept folder (novox/hq ADR 0325 §6).
//
// What an identity makes in a kept folder inherits the default entries, and its mask follows the mode its maker
// asked for: a file made at 0644 gives a read-write user only read until its mask is widened. The engine watches
// every folder of each kept tree (inotify: made, moved in, attributes changed) and, on notice, gives the entry its
// users' entries and a mask that covers them — the same keepVisit the walks use, through the entry's own
// descriptor, reached from the kept folder down by O_NOFOLLOW, never through a link and never on another
// filesystem. Notice can be lost (the kernel's queue overflowed) and watches run out (the machine's limit, ENOSPC):
// then the record says so and the sweep finds what was made where no watch is (§10).
// watchMask is what a watch hears: an entry made or moved in, and an entry's mode or ACL changed.
// It is added through /proc/self/fd, whose link is the folder's own descriptor: following it is what reaches the
// folder that was opened, so the watch takes no IN_DONT_FOLLOW.
const watchMask = unix.IN_CREATE | unix.IN_MOVED_TO | unix.IN_ATTRIB | unix.IN_ONLYDIR
// MaxWatchedPerFolder bounds the folders watched under one kept folder: beyond it the sweep alone keeps the rest.
var MaxWatchedPerFolder = 100_000
// Watcher watches every kept folder's tree.
type Watcher struct {
mu sync.Mutex
fd int
dirs map[int]watchedDir
roots map[string]*watchedRoot
}
// watchedDir is one watched folder: its kept folder and the names below it.
type watchedDir struct {
root string
rel []string
}
// watchedRoot is one kept folder watched: its users, its device, and its watches.
type watchedRoot struct {
users map[uint32]bool
dev uint64
wds []int
out bool
}
// NewWatcher opens the machine's notice of changes.
func NewWatcher() (*Watcher, error) {
// Not blocking: Run polls it, so the end of its context ends it whatever the kernel has to say.
fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
if err != nil {
return nil, err
}
return &Watcher{fd: fd, dirs: map[int]watchedDir{}, roots: map[string]*watchedRoot{}}, nil
}
// Set is every kept folder to watch, with its users: one newly kept is watched whole (within
// MaxWatchedPerFolder), one no longer kept is let go, and one whose users changed is repaired with the new ones.
func (w *Watcher) Set(folders map[string][]store.KeptUser) {
w.mu.Lock()
defer w.mu.Unlock()
for path, root := range w.roots {
if _, still := folders[path]; !still {
for _, wd := range root.wds {
_, _ = unix.InotifyRmWatch(w.fd, uint32(wd))
delete(w.dirs, wd)
}
delete(w.roots, path)
}
}
for path, users := range folders {
if root, had := w.roots[path]; had {
root.users = usersOf(users)
continue
}
root := &watchedRoot{users: usersOf(users)}
w.roots[path] = root
w.watchTree(path, root)
w.say(path, root)
}
}
// say records how many folders of a kept folder are watched, and whether the watches ran out.
func (w *Watcher) say(path string, root *watchedRoot) {
n, out := len(root.wds), root.out
_ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool {
f.Watches, f.OutOfWatches = n, out
return had
})
}
// watchTree watches a kept folder and every folder under it on its device, by descriptor: each watch is added
// through /proc/self/fd of the folder's own descriptor, so no name is followed to another place.
func (w *Watcher) watchTree(path string, root *watchedRoot) {
_, fd, st, err := openKept(path, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, nil)
if err != nil {
return
}
root.dev = uint64(st.Dev)
w.watchBelow(path, root, fd, nil)
}
// watchBelow watches the folder open on fd, rel below root, and every folder under it. It closes fd.
func (w *Watcher) watchBelow(path string, root *watchedRoot, fd int, rel []string) {
defer unix.Close(fd)
if root.out || len(root.wds) >= MaxWatchedPerFolder {
root.out = true
return
}
wd, err := unix.InotifyAddWatch(w.fd, "/proc/self/fd/"+strconv.Itoa(fd), watchMask)
if errors.Is(err, unix.ENOSPC) {
root.out = true
return
}
if err != nil {
return
}
root.wds = append(root.wds, wd)
w.dirs[wd] = watchedDir{root: path, rel: append([]string(nil), rel...)}
dup, err := unix.Dup(fd)
if err != nil {
return
}
names, err := readNames(dup)
if err != nil {
return
}
for _, name := range names {
var st unix.Stat_t
if unix.Fstatat(fd, name, &st, unix.AT_SYMLINK_NOFOLLOW) != nil || st.Mode&unix.S_IFMT != unix.S_IFDIR ||
uint64(st.Dev) != root.dev {
continue
}
next, err := unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
continue
}
w.watchBelow(path, root, next, append(rel, name))
}
}
// watchPoll is how long Run waits for notice before it looks whether its context ended.
const watchPoll = 250 * time.Millisecond
// Run reads notices until ctx ends, repairing each entry made or changed in a watched folder, and then closes the
// watch: polled, so a quiet machine's watch ends as soon as a busy one's.
func (w *Watcher) Run(ctx context.Context) {
defer unix.Close(w.fd)
buf := make([]byte, 64*1024)
for ctx.Err() == nil {
ready, err := unix.Poll([]unix.PollFd{{Fd: int32(w.fd), Events: unix.POLLIN}}, int(watchPoll/time.Millisecond))
if err != nil && !errors.Is(err, unix.EINTR) {
return
}
if ready <= 0 {
continue
}
n, err := unix.Read(w.fd, buf)
if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EINTR) {
continue
}
if err != nil || n <= 0 {
return
}
for at := 0; at+unix.SizeofInotifyEvent <= n; {
ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[at]))
nameAt := at + unix.SizeofInotifyEvent
name := cString(buf[nameAt : nameAt+int(ev.Len)])
at = nameAt + int(ev.Len)
if ev.Mask&unix.IN_Q_OVERFLOW != 0 {
w.lost()
continue
}
if ev.Mask&unix.IN_IGNORED != 0 {
// The watch is gone — its folder removed, or let go by Set: what pointed at it goes too.
w.forget(int(ev.Wd))
continue
}
w.heard(int(ev.Wd), name, ev.Mask)
}
}
}
// lost is notice lost (the kernel's queue overflowed): every kept folder is watched afresh, and what was made
// meanwhile is the sweep's.
func (w *Watcher) lost() {
w.mu.Lock()
defer w.mu.Unlock()
for path, root := range w.roots {
for _, wd := range root.wds {
_, _ = unix.InotifyRmWatch(w.fd, uint32(wd))
delete(w.dirs, wd)
}
root.wds, root.out = nil, false
w.watchTree(path, root)
w.say(path, root)
}
}
// forget lets go of a watch the kernel removed.
func (w *Watcher) forget(wd int) {
w.mu.Lock()
defer w.mu.Unlock()
dir, ok := w.dirs[wd]
if !ok {
return
}
delete(w.dirs, wd)
if root := w.roots[dir.root]; root != nil {
for i, had := range root.wds {
if had == wd {
root.wds = append(root.wds[:i], root.wds[i+1:]...)
break
}
}
w.say(dir.root, root)
}
}
// watched is how many folders are watched now.
func (w *Watcher) watched() int {
w.mu.Lock()
defer w.mu.Unlock()
return len(w.dirs)
}
// heard repairs one entry a watch heard of: name in the watched folder wd, or the folder itself.
func (w *Watcher) heard(wd int, name string, mask uint32) {
w.mu.Lock()
dir, ok := w.dirs[wd]
root := w.roots[dir.root]
var users map[uint32]bool
var dev uint64
if root != nil {
// Read under the lock: Set gives a root new users while notices are heard.
users, dev = root.users, root.dev
}
w.mu.Unlock()
if !ok || root == nil {
return
}
fd, err := openBelow(dir.root, dir.rel, dev)
if err != nil {
return
}
defer unix.Close(fd)
visit := keepVisit(users)
if name == "" {
var st unix.Stat_t
if unix.Fstat(fd, &st) == nil {
_, _, _, _ = visit(fd, st, true)
}
return
}
var st unix.Stat_t
if unix.Fstatat(fd, name, &st, unix.AT_SYMLINK_NOFOLLOW) != nil || uint64(st.Dev) != dev {
return
}
isDir := st.Mode&unix.S_IFMT == unix.S_IFDIR
if !isDir && st.Mode&unix.S_IFMT != unix.S_IFREG {
return
}
flags := unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_CLOEXEC | unix.O_NOCTTY | unix.O_NONBLOCK
if isDir {
flags |= unix.O_DIRECTORY
}
efd, err := unix.Openat(fd, name, flags, 0)
if err != nil {
return
}
var opened unix.Stat_t
if unix.Fstat(efd, &opened) != nil || opened.Ino != st.Ino || opened.Dev != st.Dev {
unix.Close(efd)
return
}
_, _, _, _ = visit(efd, opened, isDir)
if isDir && mask&(unix.IN_CREATE|unix.IN_MOVED_TO) != 0 {
// A folder made or moved in is watched, and what was moved in with it is the sweep's.
w.mu.Lock()
defer w.mu.Unlock()
if w.roots[dir.root] != root {
// Let go by Set meanwhile: nothing more is watched for it.
unix.Close(efd)
return
}
w.watchBelow(dir.root, root, efd, append(append([]string(nil), dir.rel...), name))
w.say(dir.root, root)
return
}
unix.Close(efd)
}
// openBelow opens the folder rel below the kept folder root, each level by O_NOFOLLOW on root's device.
func openBelow(root string, rel []string, dev uint64) (int, error) {
_, fd, _, err := openKept(root, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, nil)
if err != nil {
return -1, err
}
for _, name := range rel {
next, err := unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
unix.Close(fd)
if err != nil {
return -1, err
}
var st unix.Stat_t
if err := unix.Fstat(next, &st); err != nil || uint64(st.Dev) != dev {
unix.Close(next)
return -1, errors.New("a level is on another filesystem")
}
fd = next
}
return fd, nil
}
// readNames reads every name in the folder open on fd, and closes it.
func readNames(fd int) ([]string, error) {
var names []string
buf := make([]byte, 32*1024)
defer unix.Close(fd)
for {
n, err := unix.ReadDirent(fd, buf)
if err != nil {
return names, err
}
if n <= 0 {
return names, nil
}
_, _, names = unix.ParseDirent(buf[:n], -1, names)
}
}
func cString(b []byte) string {
for i, c := range b {
if c == 0 {
return string(b[:i])
}
}
return string(b)
}
+8 -8
View File
@@ -106,11 +106,11 @@ type AdoptedRewrite struct {
}
// RewriteAdopted makes the produced bundle one for an adopted machine: the foundation's own filter
// taken out, and the mesh's guard put in its place, guarding on this node the store's port, the
// broker's management port and the broker's plaintext port. The last is published on every
// interface and the foundation's filter admits it from the private network only, so a found
// firewall that filters only incoming traffic would leave it reachable from anywhere (novox/hq ADR
// 0103). Every one is a port of a module genesis takes. The nftables package stays: the guard is loaded with it, and
// taken out, and the mesh's guard put in its place, guarding on this node the store's port. It is
// published on every interface and the foundation's filter admits it from the private network only,
// so a found firewall that filters only incoming traffic would leave it reachable from anywhere
// (novox/hq ADR 0103). The bus is reached from anywhere on purpose — a node enrols over it — and its
// monitoring port is published on loopback only, so neither is guarded. The nftables package stays: the guard is loaded with it, and
// installing a package loads no table. Openings are not the bundle's — the first push declares
// them, once there is a controller to derive them.
func RewriteAdopted(r *Rewritten, p FoundationPorts) (AdoptedRewrite, error) {
@@ -128,11 +128,11 @@ func RewriteAdopted(r *Rewritten, p FoundationPorts) (AdoptedRewrite, error) {
out.Removed = append(out.Removed, id)
}
out.Guarded = []int{p.Store, p.Management, p.AMQP}
out.Guarded = []int{p.Store}
var text bytes.Buffer
text.WriteString(",\n // The mesh's guard (novox/hq ADR 0100): this machine is adopted, so its own firewall\n" +
" // stays in force and the foundation's filter is not loaded. The guard only refuses: the\n" +
" // store's port and the broker's management and plaintext ports, except from the machine and\n" +
" // store's port, except from the machine and\n" +
" // the private network.")
for _, res := range guardResources(out.Guarded) {
var one bytes.Buffer
@@ -185,7 +185,7 @@ func (r Rewritten) declares(id string) bool {
// container runtime's configuration whole — and neither is anything the operator installs later.
var genesisTakes = map[string]bool{
RegistryModule: true, ControlPlaneModule: true, BuilderModule: true,
"postgres": true, "lavinmq": true, "mesh-vault": true, "mesh-catalog": true,
"postgres": true, BusModule: true, "mesh-vault": true, "mesh-catalog": true,
}
// takeIfAdopted takes one of genesis's own modules on an adopted node, once it is assigned and
+4 -4
View File
@@ -70,7 +70,7 @@ func TestTheGuardRefusesOnlyWhatIsAddressedToThisMachine(t *testing.T) {
func TestAnAdoptedBundleLoadsNoDroppingTableAndExactlyTheGuard(t *testing.T) {
r := producedBundle(t)
p := FoundationPorts{Store: 5433, Management: 15673, AMQP: 5773}
p := FoundationPorts{Store: 5433}
if _, err := RewritePorts(&r, p, ""); err != nil {
t.Fatal(err)
}
@@ -101,9 +101,9 @@ func TestAnAdoptedBundleLoadsNoDroppingTableAndExactlyTheGuard(t *testing.T) {
if len(guards) != 1 {
t.Fatalf("%d guard table(s)", len(guards))
}
// The store's, the broker's plaintext and its management port: each one the filter admits
// from the private network only (novox/hq ADR 0103).
if !strings.Contains(guards[0].Content, "tcp dport { 5433, 5773, 15673 } drop") {
// The store's: the one foundation port the filter admits from the private network only
// (novox/hq ADR 0103). The bus is reached from anywhere, its monitoring only on loopback.
if !strings.Contains(guards[0].Content, "tcp dport 5433 drop") && !strings.Contains(guards[0].Content, "tcp dport { 5433 } drop") {
t.Errorf("the guard does not refuse this node's ports: %s", guards[0].Content)
}
if strings.Count(guards[0].Content, "accept") != 1 || !strings.Contains(guards[0].Content, "policy accept") {
+5 -5
View File
@@ -503,7 +503,7 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
for _, c := range []struct {
what, path string
made bool
}{{"store superuser", StoreSuperuserFile, creds.StoreMade}, {"broker admin", BrokerAdminFile, creds.BrokerMade}} {
}{{"store superuser", StoreSuperuserFile, creds.StoreMade}, {"bus controller", BusControllerFile, creds.BusMade}} {
switch {
case c.made && o.DryRun:
say(fmt.Sprintf(" %-17s would be made and kept at %s (0600)", c.what, c.path))
@@ -513,9 +513,9 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
say(fmt.Sprintf(" %-17s already at %s — kept", c.what, c.path))
}
}
say(fmt.Sprintf(" credentials the template's bootstrap and guest are gone: %d store and %d broker "+
"connection(s) rewritten, the store reads its password from a file, the broker's admin is changed once it answers",
root.StoreURLs, root.BrokerURLs))
say(fmt.Sprintf(" credentials the template's bootstrap is gone: %d store and %d bus "+
"connection(s) rewritten, the store reads its password from a file, the bus is raised with the controller's account",
root.StoreURLs, root.BusURLs))
if rewritten.Renamed {
say(fmt.Sprintf(" control plane %s, renamed from %s",
rewritten.TempName, rewritten.WasCalled))
@@ -733,7 +733,7 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
}
// ---- broker ---------------------------------------------------------------------------
say("broker — the foundation's broker, adopted as the lavinmq module: one bus, not two")
say("broker — the foundation's bus, adopted as the nats module: one bus, not two")
if err := InstallBroker(ctx, o, permanentControl, rewritten.Declaration, say); err != nil {
return result, failed(StepBroker, err)
}
+18 -16
View File
@@ -30,14 +30,13 @@ const theControlPlaneModule = `{
"inventory": "/var/lib/mesh/mesh-controller/inventory",
"identity": "/var/lib/mesh/mesh-controller/identity",
"licences": "/var/lib/mesh/mesh-controller/licences",
"broker": "/var/lib/mesh/mesh-controller/broker",
"broker-management": "/var/lib/mesh/mesh-controller/broker-management"
"bus": "/var/lib/mesh/mesh-controller/bus"
},
"resources": [
{"id": "mesh-state", "type": "directory", "path": "/var/lib/mesh/mesh-controller", "mode": "0700"},
{"id": "broker-env", "type": "file", "path": "/var/lib/mesh/mesh-controller/broker.env",
"mode": "0600",
"content": "MESH_BROKER_AMQP=${secret:broker}\nMESH_BROKER_MANAGEMENT=${secret:broker-management}\nMESH_BROKER_ADDRESS=${machine:at}:5671\n"},
"content": "MESH_BUS_NATS=${secret:bus}\nMESH_BROKER_ADDRESS=${machine:at}:5671\n"},
{"id": "server", "type": "container", "name": "mesh-controller",
"image": "` + builtImage + `",
"network": "host", "args": ["serve"],
@@ -133,11 +132,10 @@ func TestTheStoreConnectionsComeFromTheBundleThatMadeThem(t *testing.T) {
// them at /run/secrets. Matching on the path alone would find nothing and refuse a correct
// manifest, which is exactly the ordinary case in the catalogue.
for variable, secret := range map[string]string{
"MESH_STORE_INVENTORY": "inventory",
"MESH_STORE_IDENTITY": "identity",
"MESH_STORE_LICENCES": "licences",
"MESH_BROKER_AMQP": "broker",
"MESH_BROKER_MANAGEMENT": "broker-management",
"MESH_STORE_INVENTORY": "inventory",
"MESH_STORE_IDENTITY": "identity",
"MESH_STORE_LICENCES": "licences",
"MESH_BUS_NATS": "bus",
} {
if wanted[variable] != secret {
t.Errorf("%s would be accepted as %q, want %q", variable, wanted[variable], secret)
@@ -161,9 +159,10 @@ func TestTheStoreConnectionsComeFromTheBundleThatMadeThem(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// Three stores and both halves of the broker: everything the foundation made and nothing else.
if len(delivered) != 5 {
t.Fatalf("%d values were delivered, and the foundation names five: %v",
// Three stores and the controller's account on the bus: everything the foundation made and
// nothing else.
if len(delivered) != 4 {
t.Fatalf("%d values were delivered, and the foundation names four: %v",
len(delivered), delivered)
}
for _, secret := range delivered {
@@ -177,12 +176,15 @@ func TestTheStoreConnectionsComeFromTheBundleThatMadeThem(t *testing.T) {
// secret in a mesh is one the mesh made; `secret accept` is only for what predates the mesh.
func TestASecretTheFoundationNeverMadeIsLeftToTheMesh(t *testing.T) {
extra := strings.Replace(theControlPlaneModule,
`"broker": "/var/lib/mesh/mesh-controller/broker",`,
`"broker": "/var/lib/mesh/mesh-controller/broker",
"something-new": "/var/lib/mesh/mesh-controller/something-new",`, 1)
`"bus": "/var/lib/mesh/mesh-controller/bus"`,
`"bus": "/var/lib/mesh/mesh-controller/bus",
"something-new": "/var/lib/mesh/mesh-controller/something-new"`, 1)
extra = strings.Replace(extra,
`"content": "MESH_BROKER_AMQP=${secret:broker}\n`,
`"content": "MESH_SOMETHING_NEW=${secret:something-new}\nMESH_BROKER_AMQP=${secret:broker}\n`, 1)
`"content": "MESH_BUS_NATS=${secret:bus}\n`,
`"content": "MESH_SOMETHING_NEW=${secret:something-new}\nMESH_BUS_NATS=${secret:bus}\n`, 1)
if extra == theControlPlaneModule {
t.Fatal("the fixture no longer says what this test adds to")
}
rewritten, err := Rewrite(theRealBundle(t), held)
if err != nil {
+30 -1
View File
@@ -94,6 +94,13 @@ func Enrol(ctx context.Context, o Options, sys system.System, control controlPla
switch mine, err := identity.Load(where); {
case err == nil && mine.Node == o.Node:
say(" already enrolled " + o.Node + " — " + where)
// **Enrolled is not adopted** (novox/hq ADR 0331 §5). This run's apply wrote the bundle's
// user list again — the controller's entry and nobody else's — over the list placed when
// the machine enrolled. Until the bus is the nats module, that list is still the
// installer's to place, so it is placed again, before anything dials the bus as a node.
if err := placeUntilAdopted(ctx, control, say); err != nil {
return out, err
}
case err == nil:
return out, fmt.Errorf(
"this machine is already node %q and was asked to become %q.\n"+
@@ -159,7 +166,8 @@ func Enrol(ctx context.Context, o Options, sys system.System, control controlPla
// busAccounts and busContainer are where the installer's bundle raises the bus: the file its
// configuration includes, and the container that reads it. The installer raised them, so it is the
// one that knows them (examples/foundation-first-node-nats.lock).
// one that knows them (examples/foundation-first-node.lock). A bridge until the bus is adopted as the
// nats module, which receives the list in its declaration from then on (novox/hq ADR 0331 §5).
const (
busAccounts = "/var/lib/mesh-bus-conf/accounts.conf"
busContainer = "mesh-broker"
@@ -191,6 +199,27 @@ func placeTheBusUsers(ctx context.Context, control controlPlane, say func(string
return nil
}
// placeUntilAdopted places the bus's users unless the nats module has them now (novox/hq ADR 0331
// §5): from the bus's adoption on, the module receives the list in its declaration, and the
// installer's bridge is not run again.
//
// **Placed again rather than left alone.** The other way out — writing the bundle's list only
// where no list exists yet — needs a file the applier creates and then never reconciles, which no
// resource of a declaration is; and it would keep whatever list was there, while the controller's
// composition is the one that is right. So a re-run converges the file on what the controller says.
// The module is asked of the controller by name (`module list`), which is where adoption registers it.
func placeUntilAdopted(ctx context.Context, control controlPlane, say func(string)) error {
known, err := control.tell(ctx, "module", "list")
if err != nil {
return err
}
if mentions(known, BusModule) {
say(" bus users the " + BusModule + " module's — not placed by the installer")
return nil
}
return placeTheBusUsers(ctx, control, say)
}
// runTheHost makes sure something on this machine is listening to the mesh, and proves it.
//
// **The installer does not install the service, and says so.** A unit file is a packaging decision
+60
View File
@@ -59,6 +59,9 @@ func TestAMachineThatHasAlreadyEnrolledIsNotEnrolledAgain(t *testing.T) {
switch {
case strings.Contains(joined, "node list"):
return "anchor here 01J0\n", nil
// The bus is the nats module already, so its users are the module's.
case strings.Contains(joined, "module list"):
return "nats 1\npostgres 1\n", nil
// An agent is running here too. Both halves are needed: enrolling makes the mesh hear from
// a machine once, so the first answer alone also describes a machine with no agent at all.
case name == "pgrep":
@@ -82,6 +85,11 @@ func TestAMachineThatHasAlreadyEnrolledIsNotEnrolledAgain(t *testing.T) {
t.Errorf("a token was issued for a machine that already holds an identity: %v",
runtime.commands)
}
// Nor are the bus's users placed: the bus is adopted, so they are the nats module's, and the
// installer's bridge is not run again (novox/hq ADR 0331 §5).
if runtime.ran("broker accounts") || runtime.ran("accounts.conf") {
t.Errorf("the bus's users were placed on a re-run: %v", runtime.commands)
}
if out.Agent != "already running" {
t.Errorf("the host agent is reported as %q", out.Agent)
}
@@ -95,6 +103,8 @@ func TestAMeshThatHasHeardFromAMachineWithNoAgentStartsOne(t *testing.T) {
runtime := &asked{answer: func(name string, args []string) (string, error) {
joined := strings.Join(args, " ")
switch {
case strings.Contains(joined, "module list"):
return "nats 1\n", nil // the bus is adopted: no users to place
case strings.Contains(joined, "node list"):
// Exactly what enrolling leaves behind, with nothing running.
return "anchor here 01J0\n", nil
@@ -159,6 +169,8 @@ func TestAHostThatIsRunningAndUnheardOfIsNotAnInstall(t *testing.T) {
runtime := &asked{answer: func(_ string, args []string) (string, error) {
joined := strings.Join(args, " ")
switch {
case strings.Contains(joined, "module list"):
return "nats 1\n", nil // the bus is adopted: no users to place
case strings.Contains(joined, "node list"):
// Enrolled, and never spoken.
return "anchor never spoken 01J0\n", nil
@@ -190,6 +202,8 @@ func TestAMachineWithNoHostServiceIsRefusedRatherThanGivenOne(t *testing.T) {
runtime := &asked{answer: func(_ string, args []string) (string, error) {
joined := strings.Join(args, " ")
switch {
case strings.Contains(joined, "module list"):
return "nats 1\n", nil // the bus is adopted: no users to place
case strings.Contains(joined, "node list"):
return "anchor never spoken 01J0\n", nil
case args[0] == "show":
@@ -314,3 +328,49 @@ func TestAFirstNodeIsLetOntoTheBusItRaised(t *testing.T) {
"(placed at %v, enrolled at %d):\n%s", placed, enrol, strings.Join(runtime.commands, "\n"))
}
}
// A re-run on a machine that enrolled and whose bus is not yet the nats module places the
// controller's list again (novox/hq ADR 0331 §5, review of mesh-host #96). The apply just wrote the
// bundle's list — the controller alone — over the one placed at enrolment; left so, the next time the
// bus re-reads it every node and the enrolment account are refused.
func TestARerunOnAnEnrolledMachineWhoseBusIsNotAdoptedPlacesTheUsersAgain(t *testing.T) {
runtime := &asked{answer: func(name string, args []string) (string, error) {
joined := strings.Join(args, " ")
switch {
case strings.Contains(joined, "node list"):
return "anchor here 01J0\n", nil
case strings.Contains(joined, "module list"):
return "mesh-controller 1\npostgres 1\n", nil
case strings.Contains(joined, "broker accounts"):
return "accounts {\n MESH { users = [ { user: \"node.anchor\" } ] }\n}\n", nil
case name == "pgrep":
return "4242\n", nil
case name == "sh":
return "", nil
}
return "", fmt.Errorf("unexpected: %s %v", name, args)
}}
out, err := Enrol(context.Background(), Options{
Node: "anchor", State: alreadyEnrolled(t, "anchor"), Timeout: time.Second,
Host: "/usr/local/bin/mesh-host", HostInBackground: true,
}, arch(t), controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second},
func(string) {})
if err != nil {
t.Fatal(err)
}
if out.Joined || runtime.ran("token issue") {
t.Errorf("a re-run enrolled again: %v", runtime.commands)
}
placed := false
for _, c := range runtime.commands {
if strings.HasPrefix(c, "sh -c") && strings.Contains(c, "node.anchor") &&
strings.Contains(c, "/var/lib/mesh-bus-conf/accounts.conf") && strings.Contains(c, "kill -s HUP mesh-broker") {
placed = true
}
}
if !placed {
t.Fatalf("the controller's list was not placed again on a re-run before adoption:\n%s",
strings.Join(runtime.commands, "\n"))
}
}
+64 -18
View File
@@ -14,8 +14,8 @@ import (
// Genesis raises a store and a broker before any module system exists, because the control plane
// cannot ask provisioning for the store it keeps its own records in or the broker it is reached over
// (novox/hq ADR 0006). That leaves two servers behind: the foundation's, and a second one the
// `postgres`/`lavinmq` modules used to raise for other modules to use. This turns the foundation's
// own servers into those modules, so a mesh runs ONE postgres and ONE lavinmq — the control plane's
// `postgres` and bus modules used to raise for other modules to use. This turns the foundation's
// own servers into those modules, so a mesh runs ONE postgres and ONE bus — the control plane's
// contexts and the database of each module that asks for one, in the same server (WBS 3.1/3.2).
//
// **Adopted in place, not replaced.** The control plane is stateless and is swapped for a fresh
@@ -155,37 +155,83 @@ func deliverCredential(ctx context.Context, o Options, control controlPlane, mod
return nil
}
// InstallBroker makes the foundation's broker the `lavinmq` module, adopted in place — the same
// shape as InstallStore, for the same reasons. The administrator's password is the one genesis
// gave the image's default account (rootsecrets.go), carried in so the module's provisioner can
// reach the management API as it.
// BusModule is the module the foundation's bus is adopted as (novox/hq ADR 0331 §1). The previous
// broker's module, lavinmq, left the catalogue with the move to one bus (ADR 0131).
const BusModule = "nats"
// InstallBroker makes the foundation's bus the `nats` module, adopted in place — the same shape as
// InstallStore, for the same reasons: the bus holds the mesh's streams, so it is kept, and from the
// module's first push the bus's user list reaches it in the module's declaration like any module's
// (ADR 0331 §1, §5). The installer's own placing of that list (enrol.go placeTheBusUsers) was the
// bridge to here and is not run again.
//
// **What is compared is the container's name, not its image.** The store's module pins the upstream
// postgres the foundation runs; the bus's module builds its server image (it carries the mesh's
// snapshot tool), so no pinned reference can be equal to the foundation's. A module whose server is
// not the foundation's container by name would raise a second bus beside the first, and is refused
// by name before anything is registered.
func InstallBroker(ctx context.Context, o Options, control controlPlane,
foundation *declaration.Declaration, say func(string)) error {
const module = "lavinmq"
const module = BusModule
manifest, err := readManifest(o.Catalogue, module)
if err != nil {
return err
}
broker, err := brokerIn(foundation)
bus, err := brokerIn(foundation)
if err != nil {
return err
}
if err := serverMatchesFoundation(manifest, broker, module); err != nil {
if err := busServerIsTheFoundations(manifest, bus, module); err != nil {
return err
}
say(" adopting " + broker.Name + " — the broker the foundation raised, unchanged")
if err := installProvider(ctx, o, control, module, manifest, "(the provisioner; the server is adopted, not built)",
func() error {
return deliverCredential(ctx, o, control, module, "admin", BrokerAdminFile, "the broker's administrator", say)
},
say); err != nil {
say(" adopting " + bus.Name + " — the bus the foundation raised, its streams kept")
if err := installProvider(ctx, o, control, module, manifest, "(the server, adopting the foundation's bus)",
nil, say); err != nil {
return err
}
say(" adopted " + broker.Name + " — the foundation's broker is now the " + module + " module")
say(" adopted " + bus.Name + " — the foundation's bus is now the " + module +
" module, and its user list is the module's from here on")
return nil
}
// busServerIsTheFoundations checks that the bus module's server container is the one the
// foundation raised, by name, so the applier takes it over rather than raising a second bus.
func busServerIsTheFoundations(manifest []byte, bus *declaration.Container, module string) error {
var m struct {
Resources []struct {
Type string `json:"type"`
Name string `json:"name"`
} `json:"resources"`
}
if err := json.Unmarshal(manifest, &m); err != nil {
return fmt.Errorf("the %s module's manifest is not readable: %w", module, err)
}
var names []string
for _, r := range m.Resources {
if r.Type != "container" {
continue
}
if r.Name == bus.Name {
return nil
}
names = append(names, r.Name)
}
return fmt.Errorf(
"the %s module's server is %s, and the foundation's bus is the container %q.\n"+
"The module adopts the foundation's bus in place, so its server has to carry the name the "+
"foundation raised; under another name it would raise a second bus beside the first, and "+
"the mesh would run two (novox/hq ADR 0331 §1). Nothing has been registered",
module, describeNames(names), bus.Name)
}
func describeNames(names []string) string {
if len(names) == 0 {
return "no container at all"
}
return "the container " + strings.Join(names, ", ")
}
// InstallVault installs the vault as a foundation module (novox/hq ADR 0085, amended). Nothing to
// adopt and nothing to carry in: it is its own runtime, built from the catalogue like any provider,
// and from its first push it keeps the export of every operator-sealed secret on its own disk.
@@ -207,9 +253,9 @@ func storeIn(d *declaration.Declaration) (*declaration.Container, error) {
return foundationContainer(d, StoreID, "store")
}
// brokerIn finds the broker container in the bundle this installer produced.
// brokerIn finds the bus's container in the bundle this installer produced.
func brokerIn(d *declaration.Declaration) (*declaration.Container, error) {
return foundationContainer(d, BrokerID, "broker")
return foundationContainer(d, BrokerID, "bus")
}
func foundationContainer(d *declaration.Declaration, id, what string) (*declaration.Container, error) {
+93
View File
@@ -0,0 +1,93 @@
package bootstrap
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// aCatalogue is a catalogue checkout holding these modules' manifests.
func aCatalogue(t *testing.T, manifests map[string]string) string {
t.Helper()
dir := t.TempDir()
for module, manifest := range manifests {
at := filepath.Join(dir, catalogueDir, module)
if err := os.MkdirAll(at, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(at, "module.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
}
return dir
}
// aBusModule is the nats module's manifest as far as adoption reads it: a server container by name.
func aBusModule(server string) string {
return `{"module": "nats", "version": "1",
"claims": [{"name": "mesh-broker", "scope": "mesh"}],
"bus-users": "/var/lib/nats-module/conf/accounts.conf",
"resources": [{"id": "server", "type": "container", "name": "` + server + `", "ports": ["4222:4222"], "artifact": "server"}],
"build": {"artifacts": [{"name": "server", "kind": "image", "from": "Dockerfile"}]}}`
}
func busStep(t *testing.T, catalogue string) (*asked, error) {
t.Helper()
rewritten, err := Rewrite(theRealBundle(t), held)
if err != nil {
t.Fatal(err)
}
runtime := &asked{answer: aMeshThatAgrees(nil)}
control := controlPlane{container: "mesh-controller", run: runtime.run, timeout: 0}
err = InstallBroker(context.Background(), installing(t, catalogue), control, rewritten.Declaration, func(string) {})
return runtime, err
}
// The broker step adopts the foundation's bus as the nats module (novox/hq ADR 0331 §1): the module
// the mesh goes on managing, registered from the catalogue — never lavinmq, which left it.
func TestTheBrokerStepAdoptsTheBusAsTheNatsModule(t *testing.T) {
catalogue := aCatalogue(t, map[string]string{BusModule: aBusModule("mesh-broker")})
runtime, _ := busStep(t, catalogue)
if !runtime.ran("module add /nats-module.json") {
t.Fatalf("the nats module was not registered: %v", runtime.commands)
}
if runtime.ran("lavinmq") {
t.Errorf("the broker step still speaks of lavinmq: %v", runtime.commands)
}
}
// A catalogue that holds no nats module fails the step by name, before anything is registered —
// which is what today's step did for lavinmq, silently adopting a module that no longer exists.
func TestTheBrokerStepFailsForAModuleTheCatalogueDoesNotHold(t *testing.T) {
catalogue := aCatalogue(t, map[string]string{"lavinmq": `{"module": "lavinmq", "version": "1"}`})
runtime, err := busStep(t, catalogue)
if err == nil {
t.Fatal("a catalogue without the nats module was adopted from")
}
if !strings.Contains(err.Error(), "nats module") {
t.Errorf("the refusal does not name the module it looked for: %v", err)
}
if runtime.ran("module add") {
t.Errorf("something was registered: %v", runtime.commands)
}
}
// A bus module whose server is not the foundation's container by name would raise a second bus
// beside the first; it is refused naming both, before anything is registered.
func TestABusModuleWhoseServerIsNotTheFoundationsIsRefused(t *testing.T) {
catalogue := aCatalogue(t, map[string]string{BusModule: aBusModule("mesh-broker-nats")})
runtime, err := busStep(t, catalogue)
if err == nil {
t.Fatal("a bus module with another server was adopted")
}
for _, named := range []string{"mesh-broker-nats", `"mesh-broker"`} {
if !strings.Contains(err.Error(), named) {
t.Errorf("the refusal does not name %s: %v", named, err)
}
}
if runtime.ran("module add") {
t.Errorf("something was registered: %v", runtime.commands)
}
}
+2 -2
View File
@@ -144,8 +144,8 @@ func RaisePackageRegistry(ctx context.Context, o Options, d Deps, control contro
// recordTheForgesPort makes the port given for the package registry a setting of the module that
// serves it, on this node (novox/hq 04-ISSUES/085, ADR 0100).
//
// **The forge is the one foundation port that was not a setting.** The store's, the bus's, the
// broker's management port and the registry's each become a `ports` setting of the module that
// **The forge is the one foundation port that was not a setting.** The store's, the bus's and the
// registry's each become a `ports` setting of the module that
// binds them, set where that module is registered and assigned; the forge is raised by hand here,
// long before its module can be built, so there was no registration to hang it on and the number
// lived in rewritten manifest text instead. So the manifest is registered here — not assigned, and
+32 -29
View File
@@ -20,24 +20,21 @@ import (
// FoundationPorts are the machine's ports the foundation binds (novox/hq ADR 0100).
//
// **The node's, not the catalogue's.** A machine in use may already hold one — a predecessor's
// registry on 5000, its broker's management port — and a port fixed in the bundle and the manifests
// registry on 5000, its bus on 5671 — and a port fixed in the bundle and the manifests
// surfaces as a container that fails to bind, and one changed at genesis would be changed back when
// the foundation is adopted as modules. So each is an input here, checked free, rewritten into the
// bundle, and handed to the controller as that node's setting for the module that binds it.
type FoundationPorts struct {
Store int `json:"store"`
Bus int `json:"bus"`
AMQP int `json:"amqp"`
Management int `json:"management"`
Registry int `json:"registry"`
Packages int `json:"packages"`
Hub int `json:"hub"`
Store int `json:"store"`
Bus int `json:"bus"`
Registry int `json:"registry"`
Packages int `json:"packages"`
Hub int `json:"hub"`
}
// DefaultPorts are the catalogue's numbers.
func DefaultPorts() FoundationPorts {
return FoundationPorts{Store: 5432, Bus: 5671, AMQP: 5672, Management: 15672, Registry: 5000,
Packages: 3000, Hub: 51820}
return FoundationPorts{Store: 5432, Bus: 5671, Registry: 5000, Packages: 3000, Hub: 51820}
}
// DefaultOverlayRange is the controller's default private-network range.
@@ -47,7 +44,7 @@ const DefaultOverlayRange = "10.42.0.0/16"
func (p FoundationPorts) orDefaults() FoundationPorts {
d := DefaultPorts()
for _, f := range []struct{ got, def *int }{
{&p.Store, &d.Store}, {&p.Bus, &d.Bus}, {&p.AMQP, &d.AMQP}, {&p.Management, &d.Management},
{&p.Store, &d.Store}, {&p.Bus, &d.Bus},
{&p.Registry, &d.Registry}, {&p.Packages, &d.Packages}, {&p.Hub, &d.Hub},
} {
if *f.got == 0 {
@@ -60,8 +57,7 @@ func (p FoundationPorts) orDefaults() FoundationPorts {
// named is each port with what it is and its protocol, in a fixed order.
func (p FoundationPorts) named() []namedPort {
return []namedPort{
{"the store", "tcp", p.Store}, {"the bus", "tcp", p.Bus}, {"the broker's AMQP", "tcp", p.AMQP},
{"the broker's management", "tcp", p.Management}, {"the registry", "tcp", p.Registry},
{"the store", "tcp", p.Store}, {"the bus", "tcp", p.Bus}, {"the registry", "tcp", p.Registry},
{"the package registry", "tcp", p.Packages}, {"the private network's hub", "udp", p.Hub},
}
}
@@ -88,8 +84,11 @@ func (p FoundationPorts) Check() error {
}
// moduleSettings is what each foundation module is told about its ports on this node: the port it
// declares, to the machine's port it is given. Only what differs from the catalogue — a converged
// genesis on the defaults sets nothing, and so changes nothing it did before.
// declares, to the machine's port it is given. Only what differs from the catalogue's manifest is
// set. For the store, the registry and the forge the manifest declares the foundation's default, so
// a genesis on the defaults sets nothing for them. The bus is the exception: the nats module
// declares 4222 and the foundation publishes the bus on 5671, the port every enrolment token
// names, so the bus always carries a setting.
func (p FoundationPorts) moduleSettings() map[string]map[string]int {
d := DefaultPorts()
out := map[string]map[string]int{}
@@ -103,14 +102,18 @@ func (p FoundationPorts) moduleSettings() map[string]map[string]int {
out[module][strconv.Itoa(declared)] = given
}
add("postgres", d.Store, p.Store)
add("lavinmq", d.Bus, p.Bus)
add("lavinmq", d.AMQP, p.AMQP)
add("lavinmq", d.Management, p.Management)
// The bus's module declares the server's own port, and the foundation published the bus on the
// node's: adopted in place, it keeps the port every token already names (ADR 0331 §1).
add(BusModule, busModulePort, p.Bus)
add(RegistryModule, d.Registry, p.Registry)
add(ForgeModule, d.Packages, p.Packages)
return out
}
// busModulePort is the machine's port the nats module declares for the bus (mesh-catalog
// modules/nats, "4222:4222").
const busModulePort = 4222
// PortsSetting is the controller's settings key for a module's given ports.
const PortsSetting = "ports"
@@ -200,10 +203,10 @@ func RewritePorts(r *Rewritten, p FoundationPorts, overlayRange string) (PortsRe
if p.Store != d.Store {
replace(`"ports": ["5432:5432"]`, fmt.Sprintf(`"ports": ["%d:5432"]`, p.Store), "the store's published port")
}
if p.Bus != d.Bus || p.AMQP != d.AMQP || p.Management != d.Management {
replace(`"ports": ["5671:5671", "5672:5672", "127.0.0.1:15672:15672"]`,
fmt.Sprintf(`"ports": ["%d:5671", "%d:5672", "127.0.0.1:%d:15672"]`, p.Bus, p.AMQP, p.Management),
"the broker's published ports")
if p.Bus != d.Bus {
replace(`"ports": ["5671:4222", "127.0.0.1:8222:8222"]`,
fmt.Sprintf(`"ports": ["%d:4222", "127.0.0.1:8222:8222"]`, p.Bus),
"the bus's published ports")
}
if err != nil {
return out, err
@@ -221,9 +224,9 @@ func RewritePorts(r *Rewritten, p FoundationPorts, overlayRange string) (PortsRe
value := control.Env[key]
now := value
now = strings.ReplaceAll(now, "@127.0.0.1:5432/", fmt.Sprintf("@127.0.0.1:%d/", p.Store))
now = strings.ReplaceAll(now, "@127.0.0.1:5672/", fmt.Sprintf("@127.0.0.1:%d/", p.AMQP))
if strings.HasSuffix(now, "@127.0.0.1:15672") {
now = strings.TrimSuffix(now, "15672") + strconv.Itoa(p.Management)
// The bus, as the control plane dials it (MESH_BUS_NATS) on the machine's loopback.
if strings.HasSuffix(now, "@127.0.0.1:5671") {
now = strings.TrimSuffix(now, "5671") + strconv.Itoa(p.Bus)
}
if key == brokerAddressVar {
if host, port, splitErr := net.SplitHostPort(value); splitErr == nil && port == "5671" {
@@ -316,8 +319,8 @@ func PortsFree(ctx context.Context, run Runner, p FoundationPorts, ours func(rea
}
if len(problems) > 0 {
return fmt.Errorf("the foundation's ports must be free before anything is raised:\n - %s\n"+
"Give it another with the matching flag (--store-port, --bus-port, --amqp-port, "+
"--management-port, --registry-port, --packages-port, --hub-port); nothing was changed",
"Give it another with the matching flag (--store-port, --bus-port, "+
"--registry-port, --packages-port, --hub-port); nothing was changed",
strings.Join(problems, "\n - "))
}
return nil
@@ -504,8 +507,8 @@ func CheckTheMachine(ctx context.Context, o Options, run Runner, bundle *declara
if err := PortsFree(ctx, run, p, ours); err != nil {
return err
}
say(fmt.Sprintf(" ports free store %d, bus %d, amqp %d, management %d, registry %d, packages %d, hub %d/udp",
p.Store, p.Bus, p.AMQP, p.Management, p.Registry, p.Packages, p.Hub))
say(fmt.Sprintf(" ports free store %d, bus %d, registry %d, packages %d, hub %d/udp",
p.Store, p.Bus, p.Registry, p.Packages, p.Hub))
if o.Tunnel != "" {
// One tunnel and one range: the mesh's range IS the found tunnel's, so the rule that the
// two must not overlap applies only where a found tunnel is left running beside the mesh's
+10 -11
View File
@@ -31,7 +31,7 @@ func producedBundle(t *testing.T) Rewritten {
if err != nil {
t.Fatal(err)
}
if _, err := RewriteRoot(&r, RootCredentials{Store: "s", Broker: "b"}); err != nil {
if _, err := RewriteRoot(&r, testCredentials(t, "s", "b")); err != nil {
t.Fatal(err)
}
return r
@@ -60,7 +60,7 @@ func TestTheDefaultPortsLeaveTheBundleAsItWas(t *testing.T) {
func TestGivenPortsMoveOnlyTheMachinesSide(t *testing.T) {
r := producedBundle(t)
p := FoundationPorts{Store: 5433, Bus: 5771, AMQP: 5772, Management: 15673, Registry: 5100}
p := FoundationPorts{Store: 5433, Bus: 5771, Registry: 5100}
got, err := RewritePorts(&r, p, "10.77.0.0/16")
if err != nil {
t.Fatal(err)
@@ -72,9 +72,9 @@ func TestGivenPortsMoveOnlyTheMachinesSide(t *testing.T) {
if len(storeC.Ports) != 1 || storeC.Ports[0] != "5433:5432" {
t.Errorf("the store publishes %v", storeC.Ports)
}
broker := containerNamed(r.Declaration, "mesh-broker")
if strings.Join(broker.Ports, " ") != "5771:5671 5772:5672 127.0.0.1:15673:15672" {
t.Errorf("the broker publishes %v", broker.Ports)
bus := containerNamed(r.Declaration, "mesh-broker")
if strings.Join(bus.Ports, " ") != "5771:4222 127.0.0.1:8222:8222" {
t.Errorf("the bus publishes %v", bus.Ports)
}
control, err := controlPlaneIn(r.Declaration)
if err != nil {
@@ -85,9 +85,8 @@ func TestGivenPortsMoveOnlyTheMachinesSide(t *testing.T) {
t.Errorf("%s still dials %s", key, value)
}
}
if !strings.Contains(control.Env["MESH_BROKER_AMQP"], "@127.0.0.1:5772/") ||
!strings.HasSuffix(control.Env["MESH_BROKER_MANAGEMENT"], "@127.0.0.1:15673") {
t.Errorf("the broker is dialled at %s and %s", control.Env["MESH_BROKER_AMQP"], control.Env["MESH_BROKER_MANAGEMENT"])
if !strings.HasSuffix(control.Env[BusVar], "@127.0.0.1:5771") {
t.Errorf("the control plane dials the bus at %s", control.Env[BusVar])
}
if control.Env["MESH_BROKER_ADDRESS"] != "192.0.2.10:5771" || r.BrokerAddress != "192.0.2.10:5771" {
t.Errorf("nodes are told to dial %s (%s)", control.Env["MESH_BROKER_ADDRESS"], r.BrokerAddress)
@@ -169,20 +168,20 @@ func noneOurs(reachable.Reach) bool { return false }
func TestABusyPortIsRefusedNamingItsHolder(t *testing.T) {
m := machineRunner{
ss: "tcp LISTEN 0 4096 0.0.0.0:5000 0.0.0.0:* users:((\"docker-proxy\",pid=1,fd=7))\n" +
"tcp LISTEN 0 4096 127.0.0.1:15672 0.0.0.0:* users:((\"beam.smp\",pid=2,fd=7))\n",
"tcp LISTEN 0 4096 0.0.0.0:5671 0.0.0.0:* users:((\"beam.smp\",pid=2,fd=7))\n",
ps: "predecessor-registry\t0.0.0.0:5000->5000/tcp\n",
}
err := PortsFree(context.Background(), m.run, DefaultPorts(), noneOurs)
if err == nil {
t.Fatal("held ports were not refused")
}
for _, want := range []string{"predecessor-registry", "beam.smp", "tcp/5000", "tcp/15672", "--registry-port"} {
for _, want := range []string{"predecessor-registry", "beam.smp", "tcp/5000", "tcp/5671", "--registry-port"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not say %q: %v", want, err)
}
}
p := DefaultPorts()
p.Registry, p.Management = 5100, 15673
p.Registry, p.Bus = 5100, 5771
if err := PortsFree(context.Background(), m.run, p, noneOurs); err != nil {
t.Errorf("other ports given and still refused: %v", err)
}
+23 -6
View File
@@ -12,6 +12,28 @@ import (
"github.com/novox/mesh-host/internal/profile"
)
// CheckTemplate is what preflight asks of the bundle template before anything is changed: that it
// is a declaration, that it raises a control plane, and that it names the bus the way this
// installer fills it (novox/hq ADR 0331 §3, §4).
//
// Parsed here as well as at the rewrite, because a template that is not a declaration should cost
// a second rather than an image load and a written file. And a control plane given no bus never
// answers: the verify step would wait its whole timeout to say so, and a template like that is a
// fault in the bundle, said at once.
func CheckTemplate(template []byte) (*declaration.Declaration, error) {
parsed, err := declaration.ParseFileTrusted(template)
if err != nil {
return nil, fmt.Errorf("the bundle template is not a declaration: %w", err)
}
if _, err := controlPlaneIn(parsed); err != nil {
return nil, err
}
if err := BusPlaceholders(template, parsed); err != nil {
return nil, err
}
return parsed, nil
}
// DefaultRegistry is where an image reference that names no host comes from.
const DefaultRegistry = "registry-1.docker.io:443"
@@ -76,13 +98,8 @@ func Preflight(ctx context.Context, o Options, d Deps, say func(string)) ([]byte
"it. Point --bundle at one; mesh-host's examples/foundation-first-node.lock is "+
"the shape", err)
}
// Parsed here as well as at the rewrite, because a template that is not a declaration should
// cost a second rather than an image load and a written file.
parsed, err := declaration.ParseFileTrusted(template)
parsed, err := CheckTemplate(template)
if err != nil {
return nil, fmt.Errorf("the bundle template is not a declaration: %w", err)
}
if _, err := controlPlaneIn(parsed); err != nil {
return nil, err
}
say(fmt.Sprintf(" bundle template %s (%d resources)", o.Template, len(parsed.Resources)))
+18
View File
@@ -149,3 +149,21 @@ func TestWhereAnImageWouldBeFetchedFrom(t *testing.T) {
}
}
}
// A template whose temporary control plane is given no bus is refused at preflight, before anything
// is changed, rather than raised and waited on at the verify step until its timeout (novox/hq issue
// 146, ADR 0331 §4): that control plane says it has no MESH_BUS_NATS every few seconds, for ever.
func TestATemplateWhoseControlPlaneHasNoBusIsRefusedBeforeAnythingIsRaised(t *testing.T) {
template := string(theRealBundle(t))
if _, err := CheckTemplate([]byte(template)); err != nil {
t.Fatalf("the shipped bundle is refused at preflight: %v", err)
}
const bus = `"MESH_BUS_NATS": "nats://controller:bootstrap@127.0.0.1:5671",`
if !strings.Contains(template, bus) {
t.Fatalf("the shipped bundle does not give its control plane %s", bus)
}
_, err := CheckTemplate([]byte(strings.Replace(template, bus, "", 1)))
if err == nil || !strings.Contains(err.Error(), BusVar) {
t.Fatalf("a control plane with no bus was let through preflight: %v", err)
}
}
+1 -1
View File
@@ -99,7 +99,7 @@ type Rewritten struct {
// migration pointing at an image no registry serves, and the apply would fail in the middle —
// after the store is up, before the broker. They are one image and they move together.
//
// **Third-party images are not touched.** postgres and lavinmq keep the `name@sha256:` references
// **Third-party images are not touched.** postgres and nats keep the `name@sha256:` references
// the template carries and are pulled from wherever those name (novox/hq ADR 0006). This is
// checked afterwards rather than merely intended: the produced bundle is re-parsed and every other
// container's image is compared against what it was.
+1 -1
View File
@@ -183,7 +183,7 @@ func TestTheProducedBundleKeepsTheTemplatesComments(t *testing.T) {
if err != nil {
t.Fatal(err)
}
const remembered = "NAMED VOLUME"
const remembered = "A filter before anything listens"
if !strings.Contains(string(out.Bundle), remembered) {
t.Errorf("the produced bundle lost the template's comments; %q is gone", remembered)
}
+185 -102
View File
@@ -4,49 +4,58 @@ import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/crypto/bcrypt"
"github.com/novox/mesh-host/internal/declaration"
)
// The mesh's root credentials, made at genesis rather than copied from the template.
//
// **The template carries `bootstrap` and `guest`, and a mesh raised from it kept them** (novox/hq
// issue 071). The store's superuser and the broker's administrator are the two credentials every
// other one rests on, and they were the two that were not secret: constants in a file anybody can
// read, carried into the mesh by `secret accept` and marked as something the mesh must never
// replace — which is correct for a credential that already created the databases, and made the
// well-known value permanent.
// **The template carries `bootstrap`, and a mesh raised from it kept it** (novox/hq issue 071). The
// store's superuser and the controller's account on the bus are the two credentials every other one
// rests on, and they were the two that were not secret: constants in a file anybody can read,
// carried into the mesh by `secret accept` and marked as something the mesh must never replace —
// which is correct for a credential that already created the databases, and made the well-known
// value permanent.
//
// So the installer makes them. Two random values, **made once and kept on this machine** at the
// paths the postgres and lavinmq modules declare as their own secrets — so that when phase three
// adopts the store and the broker, `secret accept` carries in exactly the value the servers were
// raised with, and the host's later write of the sealed secret lands the same bytes in the same
// file. A second run finds the files and changes nothing, which is what lets the installer say
// "already done" about a store it must not restart.
// So the installer makes them. Two random values, **made once and kept on this machine** — the
// store's at the path the postgres module declares as its own secret, so that when phase three
// adopts the store `secret accept` carries in exactly the value the server was raised with; the
// bus's beside the other things only genesis holds. A second run finds the files and changes
// nothing, which is what lets the installer say "already done" about a store it must not restart.
//
// **The store reads its password from a file, not its environment.** `POSTGRES_PASSWORD` in a
// container's environment is in `docker inspect` for ever; the module that adopts the store
// declares the same file mount, so the two specs are one and the applier reconciles rather than
// recreates (phase3.go). The broker has no such file: its image's default administrator is changed
// in place by an action once the broker answers, and the produced bundle carries that action.
// recreates (phase3.go).
//
// **The bus has no administrator to change; it has a user list** (novox/hq ADR 0331 §3). The
// bundle carries the first list — the controller's own account at `bootstrap` — and the
// controller's address names the same account. Both are the bundle's placeholders: the address's
// `nats://controller:bootstrap@` and the controller's entry in the carried list. The installer
// puts the made password in the first and its bcrypt hash in the second, so the bus is raised with
// the account the controller dials with. The controller records a hash of what it actually uses
// (mesh-controller internal/broker, CredentialIn), so its own compositions carry the same account
// onward, and the permanent controller is given it by `secret accept` (control.go deliverStores).
const (
// StoreSuperuserFile is where the store's superuser password lives on the machine — the
// postgres module's own-secret path, so genesis and adoption write the same file.
StoreSuperuserFile = "/var/lib/postgres/superuser.secret"
// BrokerAdminFile is the same for the broker's administrator — the lavinmq module's.
BrokerAdminFile = "/var/lib/lavinmq-module/admin.secret"
// BrokerAdminUser is the broker's administrator. The image's default account, kept by name
// and given a password that is not the image's default; a renamed account would have to be
// created before anything can authenticate, and the thing that creates accounts is the thing
// that has to authenticate first.
BrokerAdminUser = "guest"
// BusControllerFile is where the controller's password on the bus lives on the machine. Not a
// module's path: no module reads it — the permanent controller is handed the address the
// temporary one was given — and genesis keeps it only so a second run puts the same account
// in the same two places.
BusControllerFile = "/var/lib/mesh-genesis/bus-controller.secret"
// BusControllerHashFile is the bcrypt hash of it, kept with it. bcrypt salts every hash, so a
// hash made afresh on every run would rewrite the bus's user list on every run; kept, the
// produced bundle is the same bundle twice.
BusControllerHashFile = BusControllerFile + ".bcrypt"
storeSuperuserMount = "/run/secrets/superuser"
@@ -55,15 +64,81 @@ const (
templateStorePassword = `"POSTGRES_PASSWORD": "bootstrap"`
templateStoreVolumes = `"volumes": ["mesh-store-data:/var/lib/postgresql/data"]`
templateStoreURL = "postgres:bootstrap@"
templateBrokerURL = "guest:guest@"
templateBrokerReady = "\"verify\": [\"lavinmqctl\", \"status\"]\n },"
brokerAdminMarker = "/var/lib/lavinmq/.mesh-admin"
// BusURLPlaceholder is the controller's account in its bus address, as the template names it.
BusURLPlaceholder = "nats://" + busControllerUser + ":bootstrap@"
// BusUserPlaceholder is the controller's entry in the user list the template carries, up to
// its password's hash — written as it stands inside the bundle's JSON string, quotes escaped.
BusUserPlaceholder = `{ user: \"` + busControllerUser + `\", password: \"`
busControllerUser = "controller"
)
// RootCredentials are the two values, and whether this run made them.
// BusVar is where the control plane finds the mesh's bus, with its own account (mesh-controller
// internal/broker NATSVar), and CertificateVar the certificate it pins the bus to (ADR 0004).
const (
BusVar = "MESH_BUS_NATS"
CertificateVar = "MESH_BROKER_CERTIFICATE"
)
// BusPlaceholders refuses a template whose bus connection this installer does not recognise,
// naming the placeholder it looked for (novox/hq ADR 0331 §3, §4).
//
// Asked at preflight, before anything is changed, and again where the bundle is rewritten. A
// template with no `MESH_BUS_NATS` raises a control plane that cannot reach the mesh's bus and
// says so every few seconds for ever (issue 146): the verify step would wait out its whole timeout
// for an answer that cannot come. That is a fault in the bundle, and it is found here instead.
func BusPlaceholders(template []byte, d *declaration.Declaration) error {
control, err := controlPlaneIn(d)
if err != nil {
return err
}
address := strings.TrimSpace(control.Env[BusVar])
if address == "" {
return fmt.Errorf("the template's control plane (%q) is given no %s, so it could not reach the mesh's "+
"bus and would never answer. A genesis template names the bus as %s<address>, which this "+
"installer fills with the account it makes; mesh-host's examples/foundation-first-node.lock "+
"is the shape", control.Name, BusVar, BusURLPlaceholder)
}
if !strings.HasPrefix(address, BusURLPlaceholder) {
return fmt.Errorf("the template's control plane reaches the bus at %s, and this installer looked for "+
"%s<address> there: the account it makes goes where that placeholder is, and it will not "+
"guess where else a bus connection could be", redactURL(address), BusURLPlaceholder)
}
if strings.TrimSpace(control.Env[CertificateVar]) == "" {
return fmt.Errorf("the template's control plane is given no %s, so it could not pin the bus's "+
"certificate (novox/hq ADR 0004) and would refuse to dial it", CertificateVar)
}
if n := bytes.Count(template, []byte(BusUserPlaceholder)); n != 1 {
return fmt.Errorf("the template's user list for the bus names the controller's account %d times, and "+
"this installer looked for it once, as %s<hash>: the bus has to be raised with the account the "+
"controller dials with, and that is where it goes", n, BusUserPlaceholder)
}
return nil
}
// redactURL is a bus address with any password in it replaced, for an error that quotes it.
func redactURL(address string) string {
scheme, rest, has := strings.Cut(address, "://")
if !has {
rest, scheme = address, ""
}
at := strings.LastIndex(rest, "@")
user, _, hasPassword := strings.Cut(rest[:max(at, 0)], ":")
if at < 0 || !hasPassword {
return address
}
if scheme != "" {
scheme += "://"
}
return scheme + user + ":…" + rest[at:]
}
// RootCredentials are the two values, and whether this run made them. BusHash is the bcrypt hash of
// Bus, as the bus's user list carries it.
type RootCredentials struct {
Store, Broker string
StoreMade, BrokerMade bool
Store, Bus string
BusHash string
StoreMade, BusMade bool
}
// RootSecrets reads the credentials this machine already holds, or makes them.
@@ -71,17 +146,44 @@ type RootCredentials struct {
// A dry run makes them in memory and writes nothing — so the bundle it reports is the shape of the
// real one, and a machine that was only asked is not left holding half a genesis.
func RootSecrets(dryRun bool) (RootCredentials, error) {
return rootSecretsAt(StoreSuperuserFile, BusControllerFile, BusControllerHashFile, dryRun)
}
func rootSecretsAt(storeFile, busFile, hashFile string, dryRun bool) (RootCredentials, error) {
var out RootCredentials
var err error
if out.Store, out.StoreMade, err = keptOrMade(StoreSuperuserFile, dryRun); err != nil {
if out.Store, out.StoreMade, err = keptOrMade(storeFile, dryRun); err != nil {
return out, err
}
if out.Broker, out.BrokerMade, err = keptOrMade(BrokerAdminFile, dryRun); err != nil {
if out.Bus, out.BusMade, err = keptOrMade(busFile, dryRun); err != nil {
return out, err
}
if out.BusHash, err = keptHash(hashFile, out.Bus, dryRun); err != nil {
return out, err
}
return out, nil
}
// keptHash is the bcrypt hash of password kept at path, made again only when the kept one is
// missing or no longer the hash of this password.
func keptHash(path, password string, dryRun bool) (string, error) {
if kept, err := readCredentialFile(path); err == nil {
if bcrypt.CompareHashAndPassword([]byte(kept), []byte(password)) == nil {
return kept, nil
}
} else if !os.IsNotExist(err) {
return "", err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
if dryRun {
return string(hash), nil
}
return string(hash), writeCredentialFile(path, string(hash))
}
func keptOrMade(path string, dryRun bool) (value string, made bool, err error) {
value, err = readCredentialFile(path)
if err == nil {
@@ -97,8 +199,13 @@ func keptOrMade(path string, dryRun bool) (value string, made bool, err error) {
if dryRun {
return value, true, nil
}
return value, true, writeCredentialFile(path, value)
}
// writeCredentialFile writes a credential as genesis keeps it.
func writeCredentialFile(path, value string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return "", false, err
return err
}
// Written whole and renamed into place, at 0600, owned by whoever runs the installer — root,
// which is also who the host runs as when it later writes the sealed copy here.
@@ -112,12 +219,9 @@ func keptOrMade(path string, dryRun bool) (value string, made bool, err error) {
// tolerates either ending, so a file an earlier genesis wrote still reads.
tmp := path + ".genesis"
if err := os.WriteFile(tmp, []byte(value), 0o600); err != nil {
return "", false, err
return err
}
if err := os.Rename(tmp, path); err != nil {
return "", false, err
}
return value, true, nil
return os.Rename(tmp, path)
}
// readCredentialFile is a credential as genesis keeps it: the value alone, its line ending gone.
@@ -134,13 +238,6 @@ func readCredentialFile(path string) (string, error) {
return value, nil
}
// credentialFingerprint names a credential without being one — what the broker-admin action
// leaves on the broker's volume, so its verify holds for this value and not for any value.
func credentialFingerprint(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:8])
}
// freshSecret is the same shape the controller mints: 30 random bytes as unpadded base64url, 40
// characters, URL-safe — it lands inside connection strings.
func freshSecret() (string, error) {
@@ -153,55 +250,48 @@ func freshSecret() (string, error) {
// Masking makes a reporter that never says the credentials this run made.
//
// The applier reports each action with its command line, and two of them now carry a real
// password — the context schemas' connection strings and the broker's change_password. Those
// lines go to a terminal and to whatever keeps the transcript, which for the lab is a file. The
// exact values are known here, so they are replaced wherever they appear, in every line said.
// The applier reports each action with its command line, and the context schemas' connection
// strings carry a real password. Those lines go to a terminal and to whatever keeps the
// transcript, which for the lab is a file. The exact values are known here, so they are replaced
// wherever they appear, in every line said.
func Masking(say func(string), c RootCredentials) func(string) {
replacer := strings.NewReplacer(c.Store, "…", c.Broker, "…")
if c.Store == "" || c.Broker == "" {
if c.Store == "" || c.Bus == "" {
return say
}
replacer := strings.NewReplacer(c.Store, "…", c.Bus, "…")
return func(line string) { say(replacer.Replace(line)) }
}
// RefuseExistingServers stops a run that would put a made credential in front of a server raised
// RefuseExistingServers stops a run that would put a made credential in front of a store raised
// by an earlier installer with the template's.
//
// The store's password is set by initdb, once, on an empty volume; the broker's by the action
// above, once. A machine that already holds `mesh-store-data` or `mesh-broker-data` and has no
// credential file was raised with `bootstrap` and `guest`, and minting new values here would make a
// bundle that dials with passwords the servers do not have — failing three steps later, in the
// schemas, with nothing pointing back here. Refused by name instead, with the way forward.
// The store's password is set by initdb, once, on an empty volume. A machine that already holds
// `mesh-store-data` and has no credential file was raised with `bootstrap`, and minting a new
// value here would make a bundle that dials with a password the server does not have — failing
// three steps later, in the schemas, with nothing pointing back here. Refused by name instead,
// with the way forward.
//
// **The bus is not asked.** Its accounts live in the user list beside its configuration, which
// the bundle writes and the bus re-reads, not in its data volume — so a bus raised earlier takes
// the made account as readily as a new one.
func RefuseExistingServers(ctx context.Context, run Runner, c RootCredentials) error {
for _, check := range []struct {
made bool
volume string
what string
file string
}{
{c.StoreMade, "mesh-store-data", "store", StoreSuperuserFile},
{c.BrokerMade, "mesh-broker-data", "broker", BrokerAdminFile},
} {
if !check.made {
continue
}
if _, err := run(ctx, "docker", "volume", "inspect", check.volume); err != nil {
continue // no such volume: a fresh machine, which is the case this installer makes
}
return fmt.Errorf(
"this machine already holds the %s's data (volume %s) and no credential at %s, so it was raised "+
"by an earlier installer with the template's password. A new one made here would not open it. "+
"Put the password the %s has into %s (0600, the value alone) and run again; then change it "+
"on the server and accept the new value — this installer does not rotate a running %s",
check.what, check.volume, check.file, check.what, check.file, check.what)
if !c.StoreMade {
return nil
}
return nil
if _, err := run(ctx, "docker", "volume", "inspect", "mesh-store-data"); err != nil {
return nil // no such volume: a fresh machine, which is the case this installer makes
}
return fmt.Errorf(
"this machine already holds the store's data (volume mesh-store-data) and no credential at %s, so it was raised "+
"by an earlier installer with the template's password. A new one made here would not open it. "+
"Put the password the store has into %s (0600, the value alone) and run again; then change it "+
"on the server and accept the new value — this installer does not rotate a running store",
StoreSuperuserFile, StoreSuperuserFile)
}
// RootRewrite says what RewriteRoot did to the bundle.
type RootRewrite struct {
StoreURLs, BrokerURLs int
StoreURLs, BusURLs int
}
// RewriteRoot puts the made credentials into the produced bundle, in place of the template's.
@@ -225,37 +315,30 @@ func RewriteRoot(r *Rewritten, c RootCredentials) (RootRewrite, error) {
"the store's volumes"); err != nil {
return out, err
}
// Everything that dials the store or the broker with the template's credentials.
// Everything that dials the store with the template's credentials.
out.StoreURLs = bytes.Count(bundle, []byte(templateStoreURL))
if out.StoreURLs == 0 {
return out, fmt.Errorf("the template names no %q connection, so this installer cannot tell what it would be leaving well-known", templateStoreURL)
}
bundle = bytes.ReplaceAll(bundle, []byte(templateStoreURL), []byte("postgres:"+c.Store+"@"))
out.BrokerURLs = bytes.Count(bundle, []byte(templateBrokerURL))
if out.BrokerURLs == 0 {
return out, fmt.Errorf("the template names no %q connection, so this installer cannot tell what it would be leaving well-known", templateBrokerURL)
}
bundle = bytes.ReplaceAll(bundle, []byte(templateBrokerURL), []byte(BrokerAdminUser+":"+c.Broker+"@"))
// The broker's administrator, changed once the broker answers and before anything dials it.
// Verified by a marker on the broker's own data volume holding this password's fingerprint —
// the image carries nothing that can try a password from inside, and a marker that merely
// existed would let a regenerated password go unapplied for ever. What proves the password
// works is the control plane answering over it, a few resources later. The marker ends in a
// newline because the verify reads it with the shell's `read`, which fails at end of file
// without one — an action that ran and a verify that said no, once, in the lab.
fp := credentialFingerprint(c.Broker)
action := templateBrokerReady + "\n" +
" {\n" +
" \"id\": \"broker-admin\",\n" +
" \"type\": \"action\",\n" +
" \"in\": \"mesh-broker\",\n" +
" \"command\": [\"sh\", \"-c\", \"lavinmqctl change_password " + BrokerAdminUser + " '" + c.Broker + "' && echo " + fp + " > " + brokerAdminMarker + "\"],\n" +
" \"verify\": [\"sh\", \"-c\", \"read m < " + brokerAdminMarker + " && [ \\\"$m\\\" = " + fp + " ]\"]\n" +
" },"
if bundle, err = replaceOnce(bundle, templateBrokerReady, action, "the broker's readiness check"); err != nil {
// The bus: the controller's account, in its address and in the user list the bus is raised
// with, the same account in both (novox/hq ADR 0331 §3).
if err := BusPlaceholders(bundle, r.Declaration); err != nil {
return out, err
}
out.BusURLs = bytes.Count(bundle, []byte(BusURLPlaceholder))
bundle = bytes.ReplaceAll(bundle, []byte(BusURLPlaceholder),
[]byte("nats://"+busControllerUser+":"+c.Bus+"@"))
at := bytes.Index(bundle, []byte(BusUserPlaceholder)) + len(BusUserPlaceholder)
end := bytes.Index(bundle[at:], []byte(`\"`))
if end < 0 {
return out, fmt.Errorf("the controller's entry in the template's user list for the bus has no end to its password")
}
if !strings.HasPrefix(c.BusHash, "$2") {
return out, fmt.Errorf("the controller's bus password was given no bcrypt hash, so the bus could not be raised with it; this is the installer's fault")
}
bundle = append(append(append([]byte{}, bundle[:at]...), c.BusHash...), bundle[at+end:]...)
parsed, err := declaration.ParseFileTrusted(bundle)
if err != nil {
+128 -91
View File
@@ -5,13 +5,24 @@ import (
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"github.com/novox/mesh-host/internal/declaration"
)
// The produced bundle carries no well-known credential: the store reads its password from the
// file genesis made, every connection string names the made values, and the broker's default
// administrator is changed by an action before anything dials it (novox/hq issue 071).
func TestTheProducedBundleCarriesNoWellKnownCredential(t *testing.T) {
// testCredentials are made values with a real bcrypt hash of the bus password, as RootSecrets makes.
func testCredentials(t *testing.T, store, bus string) RootCredentials {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(bus), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
return RootCredentials{Store: store, Bus: bus, BusHash: string(hash)}
}
// theExample is the one genesis bundle, rewritten for an image as the installer does.
func theExample(t *testing.T) Rewritten {
t.Helper()
template, err := os.ReadFile("../../examples/foundation-first-node.lock")
if err != nil {
t.Skip("no example bundle beside this checkout")
@@ -20,81 +31,142 @@ func TestTheProducedBundleCarriesNoWellKnownCredential(t *testing.T) {
if err != nil {
t.Fatal(err)
}
creds := RootCredentials{Store: "STORE-PW-40-characters-of-random-base64u", Broker: "BROKER-PW-40-characters-of-random-base64"}
return r
}
// The produced bundle carries no well-known credential: the store reads its password from the
// file genesis made, and the controller's account on the bus is the made one in both places the
// bundle names it — the address the control plane dials and the user list the bus is raised with
// (novox/hq issue 071, ADR 0331 §3).
func TestTheProducedBundleCarriesNoWellKnownCredential(t *testing.T) {
r := theExample(t)
creds := testCredentials(t, "STORE-PW-40-characters-of-random-base64u", "BUS-PW-40-characters-of-random-base64uuu")
got, err := RewriteRoot(&r, creds)
if err != nil {
t.Fatal(err)
}
text := string(r.Bundle)
for _, gone := range []string{`"bootstrap"`, "postgres:bootstrap@", "guest:guest@"} {
for _, gone := range []string{`"bootstrap"`, "postgres:bootstrap@", "controller:bootstrap@"} {
if strings.Contains(text, gone) {
t.Errorf("the produced bundle still says %s", gone)
}
}
if got.StoreURLs < 3 || got.BrokerURLs < 2 {
t.Errorf("rewrote %d store and %d broker connections; the template has three and two", got.StoreURLs, got.BrokerURLs)
if got.StoreURLs < 3 || got.BusURLs != 1 {
t.Errorf("rewrote %d store and %d bus connections; the template has three and one", got.StoreURLs, got.BusURLs)
}
var store, action bool
var store, accounts, control bool
for _, res := range r.Declaration.Resources {
switch x := res.(type) {
case *declaration.Container:
if x.Name != "mesh-store" {
switch x.Name {
case "mesh-store":
store = true
if _, has := x.Env["POSTGRES_PASSWORD"]; has {
t.Error("the store still takes its password from its environment")
}
if x.Env["POSTGRES_PASSWORD_FILE"] != storeSuperuserMount {
t.Errorf("the store reads its password from %q", x.Env["POSTGRES_PASSWORD_FILE"])
}
if !strings.Contains(strings.Join(x.Volumes, " "), StoreSuperuserFile+":"+storeSuperuserMount) {
t.Errorf("the store does not mount %s: %v", StoreSuperuserFile, x.Volumes)
}
case r.TempName:
control = true
if want := "nats://controller:" + creds.Bus + "@127.0.0.1:5671"; x.Env[BusVar] != want {
t.Errorf("the temporary control plane dials the bus at %q, not with the made account", x.Env[BusVar])
}
}
case *declaration.File:
if x.ID != "bus-accounts" {
continue
}
store = true
if _, has := x.Env["POSTGRES_PASSWORD"]; has {
t.Error("the store still takes its password from its environment")
accounts = true
hash := hashOf(t, x.Content, "controller")
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(creds.Bus)) != nil {
t.Errorf("the bus is raised with a controller account whose hash %q is not the made password's", hash)
}
if x.Env["POSTGRES_PASSWORD_FILE"] != storeSuperuserMount {
t.Errorf("the store reads its password from %q", x.Env["POSTGRES_PASSWORD_FILE"])
}
if !strings.Contains(strings.Join(x.Volumes, " "), StoreSuperuserFile+":"+storeSuperuserMount) {
t.Errorf("the store does not mount %s: %v", StoreSuperuserFile, x.Volumes)
}
case *declaration.Action:
if x.ID != "broker-admin" {
continue
}
action = true
if x.In != "mesh-broker" || !strings.Contains(strings.Join(x.Command, " "), "change_password "+BrokerAdminUser+" '"+creds.Broker+"'") {
t.Errorf("the broker-admin action is %v in %q", x.Command, x.In)
if bcrypt.CompareHashAndPassword([]byte(hash), []byte("bootstrap")) == nil {
t.Error("the bus is still raised with the controller at bootstrap")
}
}
}
if !store || !action {
t.Fatalf("store=%v action=%v", store, action)
}
// The order matters: the broker's password changes after it answers and before the control
// plane, which dials it with the new one, is raised.
var readyAt, adminAt, controlAt int
for i, res := range r.Declaration.Resources {
switch res.Identity() {
case "broker-ready":
readyAt = i
case "broker-admin":
adminAt = i
case "control-plane":
controlAt = i
}
}
if !(readyAt < adminAt && adminAt < controlAt) {
t.Errorf("order ready=%d admin=%d control=%d", readyAt, adminAt, controlAt)
if !store || !accounts || !control {
t.Fatalf("store=%v accounts=%v control=%v", store, accounts, control)
}
}
// A template that no longer says what this expects is refused, not half-rewritten.
func TestATemplateWithoutTheKnownCredentialsIsRefused(t *testing.T) {
// hashOf is the password hash a NATS user list gives one user.
func hashOf(t *testing.T, list, user string) string {
t.Helper()
marker := `{ user: "` + user + `", password: "`
at := strings.Index(list, marker)
if at < 0 {
t.Fatalf("the user list names no %s:\n%s", user, list)
}
rest := list[at+len(marker):]
return rest[:strings.Index(rest, `"`)]
}
// The bus's placeholders are what the installer looks for, and a template naming no bus connection
// it knows is refused at preflight, naming the placeholder (novox/hq ADR 0331 §3, §4).
func TestABundleNamingNoBusConnectionItKnowsIsRefusedByItsPlaceholder(t *testing.T) {
template, err := os.ReadFile("../../examples/foundation-first-node.lock")
if err != nil {
t.Skip("no example bundle beside this checkout")
}
changed := strings.Replace(string(template), `"POSTGRES_PASSWORD": "bootstrap"`, `"POSTGRES_PASSWORD": "other"`, 1)
r, err := Rewrite([]byte(changed), "sha256:"+strings.Repeat("ab", 32))
parse := func(b string) *declaration.Declaration {
d, err := declaration.ParseFileTrusted([]byte(b))
if err != nil {
t.Fatal(err)
}
return d
}
if err := BusPlaceholders(template, parse(string(template))); err != nil {
t.Fatalf("the shipped bundle is refused: %v", err)
}
for _, c := range []struct{ what, from, to, names string }{
{"no bus address", `"MESH_BUS_NATS": "nats://controller:bootstrap@127.0.0.1:5671",`, "", BusVar},
{"the previous broker's", `"MESH_BUS_NATS": "nats://controller:bootstrap@127.0.0.1:5671"`,
`"MESH_BUS_NATS": "amqp://guest:guest@127.0.0.1:5671"`, BusURLPlaceholder},
{"no pinned certificate", `"MESH_BROKER_CERTIFICATE": "/broker-tls/tls.crt"`, `"MESH_OTHER": "x"`, CertificateVar},
{"no controller in the user list", `{ user: \"controller\"`, `{ user: \"someone\"`, BusUserPlaceholder},
} {
if !strings.Contains(string(template), c.from) {
t.Fatalf("%s: the bundle does not say %s", c.what, c.from)
}
changed := strings.Replace(string(template), c.from, c.to, 1)
err := BusPlaceholders([]byte(changed), parse(changed))
if err == nil {
t.Errorf("%s: accepted", c.what)
continue
}
if !strings.Contains(err.Error(), c.names) {
t.Errorf("%s: refused without naming %s: %v", c.what, c.names, err)
}
if strings.Contains(err.Error(), "guest:guest") {
t.Errorf("%s: the refusal quotes a password: %v", c.what, err)
}
}
}
// The account is made once, with its hash, and a second run puts the same bytes in the bundle — a
// hash salted afresh every run would rewrite the bus's user list every run.
func TestTheBusAccountIsKeptWithItsHash(t *testing.T) {
dir := t.TempDir()
first, err := rootSecretsAt(dir+"/store", dir+"/bus", dir+"/bus.bcrypt", false)
if err != nil {
t.Fatal(err)
}
if _, err := RewriteRoot(&r, RootCredentials{Store: "x", Broker: "y"}); err == nil {
t.Fatal("a template with an unknown store password was rewritten")
if !first.BusMade || len(first.Bus) != 40 || bcrypt.CompareHashAndPassword([]byte(first.BusHash), []byte(first.Bus)) != nil {
t.Fatalf("first run: made=%v hash ok=%v", first.BusMade, bcrypt.CompareHashAndPassword([]byte(first.BusHash), []byte(first.Bus)) == nil)
}
second, err := rootSecretsAt(dir+"/store", dir+"/bus", dir+"/bus.bcrypt", false)
if err != nil {
t.Fatal(err)
}
if second.BusMade || second.Bus != first.Bus || second.BusHash != first.BusHash {
t.Fatalf("a second run changed the bus account: made=%v same=%v same hash=%v",
second.BusMade, second.Bus == first.Bus, second.BusHash == first.BusHash)
}
}
@@ -125,60 +197,25 @@ func TestRootSecretsAreKeptAcrossRuns(t *testing.T) {
// Nothing the installer says after making the credentials contains them.
func TestTheTranscriptNeverSaysTheCredentials(t *testing.T) {
var said []string
say := Masking(func(l string) { said = append(said, l) }, RootCredentials{Store: "STORE-PW", Broker: "BROKER-PW"})
say := Masking(func(l string) { said = append(said, l) }, RootCredentials{Store: "STORE-PW", Bus: "BUS-PW"})
say("created context-schemas (docker run -e MESH_STORE_INVENTORY=postgres://postgres:STORE-PW@127.0.0.1:5432/inventory)")
say("failed broker-admin (sh -c lavinmqctl change_password guest 'BROKER-PW' && echo x)")
say("failed control-plane (MESH_BUS_NATS=nats://controller:BUS-PW@127.0.0.1:5671)")
for _, l := range said {
if strings.Contains(l, "STORE-PW") || strings.Contains(l, "BROKER-PW") {
if strings.Contains(l, "STORE-PW") || strings.Contains(l, "BUS-PW") {
t.Errorf("said a credential: %s", l)
}
}
if !strings.Contains(said[0], "postgres:…@") || !strings.Contains(said[1], "guest '…'") {
if !strings.Contains(said[0], "postgres:…@") || !strings.Contains(said[1], "controller:…@") {
t.Errorf("the lines were not the same lines with the values masked: %v", said)
}
}
// The broker-admin marker is written with a line ending, because the verify reads it with `read`.
func TestTheBrokerAdminMarkerHasALineEnding(t *testing.T) {
template, err := os.ReadFile("../../examples/foundation-first-node.lock")
if err != nil {
t.Skip("no example bundle beside this checkout")
}
r, err := Rewrite(template, "sha256:"+strings.Repeat("ab", 32))
if err != nil {
t.Fatal(err)
}
if _, err := RewriteRoot(&r, RootCredentials{Store: "s", Broker: "b"}); err != nil {
t.Fatal(err)
}
for _, res := range r.Declaration.Resources {
a, ok := res.(*declaration.Action)
if !ok || a.ID != "broker-admin" {
continue
}
cmd := strings.Join(a.Command, " ")
if !strings.Contains(cmd, "&& echo ") || strings.Contains(cmd, "printf %s") {
t.Errorf("the marker is written without a line ending: %s", cmd)
}
if !strings.Contains(strings.Join(a.Verify, " "), "read m <") {
t.Errorf("the verify does not read the marker: %v", a.Verify)
}
}
}
// The foundation filters before anything listens: nftables is in place before the store, and its
// rules refuse the store's and broker's client ports from outside while keeping ssh, the bus a node
// enrols over and the registry a node pulls from (novox/hq issue 054).
func TestTheFoundationFiltersBeforeAnythingListens(t *testing.T) {
template, err := os.ReadFile("../../examples/foundation-first-node.lock")
if err != nil {
t.Skip("no example bundle beside this checkout")
}
r, err := Rewrite(template, "sha256:"+strings.Repeat("ab", 32))
if err != nil {
t.Fatal(err)
}
if _, err := RewriteRoot(&r, RootCredentials{Store: "s", Broker: "b"}); err != nil {
r := theExample(t)
if _, err := RewriteRoot(&r, testCredentials(t, "s", "b")); err != nil {
t.Fatal(err)
}
var filterAt, loadedAt, storeAt, brokerAt = -1, -1, -1, -1
+169 -28
View File
@@ -12,6 +12,7 @@ import (
"fmt"
"io"
"net"
"path/filepath"
"reflect"
"regexp"
"slices"
@@ -132,7 +133,13 @@ func (d *Directory) validate(where string, _ bool) []string {
if d.Path == "" {
problems = append(problems, where+": a directory needs a path")
}
return append(problems, checkMode(where, d.Mode)...)
// A directory's mode may carry the setgid bit (2xxx), so what is made beneath it keeps its group (novox/hq ADR
// 0334 §1); never setuid or sticky, which mean nothing the mesh asks of a directory it owns.
mode := d.Mode
if len(mode) == 4 && mode[0] == '2' {
mode = "0" + mode[1:]
}
return append(problems, checkMode(where, mode)...)
}
// File is a file with literal content. The host renders nothing.
@@ -456,10 +463,9 @@ const (
// **The distinction 04-ISSUES/036 and 026 turn on.** A `directory` resource is the mesh's own —
// it creates it, sets its owner and mode, and removes it when empty ([ADR 0030](novox/hq)). A
// media library, a download spool is the operator's: it existed before the mesh, several modules
// read and write it at once, and the mesh must not chown, chmod or remove it. The host confirms it
// is there and mounts it, and keeps it usable by the identities that use it through ACL entries it
// adds and nothing else (novox/hq ADR 0325 §5); a missing one is made only below a parent on the
// filesystem the data was placed on (§9).
// read and write it at once, and the mesh must not create, chown, chmod or remove it. The host
// confirms it is there and mounts it; nothing else. Library data is a library's (novox/hq ADR 0328),
// not an access.
type Access struct {
ID string `json:"id"`
Type Type `json:"type"`
@@ -467,15 +473,11 @@ type Access struct {
// Mode is how this module reaches the path: read or read-write. Absent narrows to read.
Mode string `json:"mode,omitempty"`
// Users are the identities that use the folder, resolved by the controller (novox/hq ADR 0325 §3, §4): each
// module identity whose container mounts it, and the tool runner's account standing for every module whose
// tools use it. The engine keeps one ACL entry per user on the folder and as its default (§5); several
// accesses naming one folder are kept as one, each uid at the widest use any of them gives it. Absent is a
// controller older than ADR 0325, or an access nobody but others' bits reach (§2): nothing is kept.
// Users and Taken are what a controller speaking ADR 0325 sends (the identities that use the folder, and
// that the operator's warrant was carried out): accepted, so its declaration is not refused whole, and not
// acted on since ADR 0328 superseded ADR 0325 — no ACL entry is kept.
Users []AccessUser `json:"users,omitempty"`
// Taken is the controller saying the operator's warrant for this folder was carried out (§8): the folder is
// kept although it holds data. The engine also keeps a folder it took itself, on the act, or found empty.
Taken bool `json:"taken,omitempty"`
Taken bool `json:"taken,omitempty"`
// Grant is what a controller older than ADR 0325 still sends (ADR 0312's group grant, superseded): accepted
// so its declaration is not refused whole, and never acted on — no account is put in a group for data (§5).
@@ -489,10 +491,10 @@ const (
)
// AccessUser is one identity that uses an accessed folder: its name and uid as the controller allocated them, its
// use, and where it runs.
// use, and where it runs. A tool runner's account may come with no uid (0 or absent): the engine reads it by name.
type AccessUser struct {
Identity string `json:"identity"`
UID int `json:"uid"`
UID int `json:"uid,omitempty"`
Use string `json:"use"`
From string `json:"from"`
}
@@ -529,9 +531,11 @@ func (a *Access) validate(where string, _ bool) []string {
case u.Identity == "" || !accountName.MatchString(u.Identity):
problems = append(problems, fmt.Sprintf("%s: a user of an access is an account's name, and %q is not one",
where, u.Identity))
case u.UID <= 0:
case u.UID < 0 || (u.UID == 0 && u.From != FromToolRunner):
// Never root (novox/hq ADR 0325 §11): an entry for uid 0 would be a right root has anyway, and a
// number below it is no account.
// number below it is no account. A tool runner's account alone may come without its number: the
// controller cannot know the operator's account's, and the engine reads it by name
// (apply.ResolveRunners), refusing one that is root's.
problems = append(problems, fmt.Sprintf("%s: the user %s has uid %d, and a module's identity is never "+
"root or below it (novox/hq ADR 0325 §11)", where, u.Identity, u.UID))
}
@@ -1540,19 +1544,132 @@ type Declaration struct {
// not refused for it.
PutBack bool
// Identities is the identity of every module assigned to this machine (novox/hq ADR 0325 §3): an account
// Identities is the identity of every module assigned to this machine (novox/hq ADR 0328 §4): an account
// and a group `mesh-<module>` with the numbers the controller allocated once for the module, the same on
// every machine. The engine makes each before any resource is applied, and removes what it made of one no
// longer declared. Absent is a controller older than ADR 0325.
Identities []Identity
// Groups is the group of every library this machine is reached by (novox/hq ADR 0328 §3): `mesh-<library>`
// with the number the controller allocated once, mesh-wide. The engine makes each, and removes what it made of
// one no longer declared. Absent is a controller older than ADR 0328.
Groups []Group
// Libraries is every library this machine is reached by (ADR 0328 §1, §6, §9): its root here, its group, whether
// this is its home, whether the operator's warrant to take it was carried out, the sub-paths its modules ask for,
// and the tool runner's account to put in its group (§11). Absent is a controller older than ADR 0328.
Libraries []Library
}
// Identity is one module's identity, as the controller allocated it.
// Identity is one module's identity, as the controller allocated it (ADR 0328 §4): its user `mesh-<module>` with
// uid, its own group of the same name and number, its primary group gid (its own, or its first library's group),
// and the groups of its other libraries as added groups.
type Identity struct {
Module string `json:"module"`
Name string `json:"name"`
UID int `json:"uid"`
GID int `json:"gid"`
Groups []int `json:"groups,omitempty"`
// Runner is the tool runner's account, put in the module's own group (novox/hq ADR 0334 §2) when its tools run
// on this machine. Absent for a module that declares no tools here, and from a controller older than ADR 0334.
Runner string `json:"runner,omitempty"`
}
// Group is one library's group, as the controller allocated it (ADR 0328 §3).
type Group struct {
Name string `json:"name"`
GID int `json:"gid"`
}
// GroupPrefix starts every library group's name, as IdentityPrefix starts every module's.
const GroupPrefix = "mesh-"
// LibraryAccessPrefix starts the access a library's wait and its take act name: `library:<name>`.
const LibraryAccessPrefix = "library:"
// Library is one library as this machine sees it (ADR 0328).
type Library struct {
Library string `json:"library"`
// Root is the library's root on this machine: on its home the data itself, elsewhere the mount point the mounts
// module mounts it at.
Root string `json:"root"`
Group string `json:"group"`
GID int `json:"gid"`
// Home is that the library's data lives on this machine: only there does the engine keep it.
Home bool `json:"home"`
// Taken is the controller saying the operator's warrant to take it was carried out (§7).
Taken bool `json:"taken,omitempty"`
// Paths are the sub-paths its modules ask for, relative to the root.
Paths []string `json:"paths,omitempty"`
// Runner is the tool runner's account, put in the library's group (§11). Absent when no module's tools use it.
Runner string `json:"runner,omitempty"`
}
// checkLibraries judges the groups and libraries on arrival: every library's group declared, named for it, never
// root's number; paths that stay below the root; a runner that is an account's name; and no two roots that nest
// (§13).
func checkLibraries(groups []Group, libs []Library) []string {
var problems []string
byName := map[string]int{}
numbers := map[int]string{}
for _, g := range groups {
where := fmt.Sprintf("group %q", g.Name)
if !strings.HasPrefix(g.Name, GroupPrefix) || !accountName.MatchString(g.Name) {
problems = append(problems, fmt.Sprintf("%s: a library's group is named %s<library>", where, GroupPrefix))
}
if g.GID <= 0 {
problems = append(problems, fmt.Sprintf("%s: gid %d, and a library's group is never root, nor a number "+
"below it (novox/hq ADR 0328 §15); nothing of this declaration was made", where, g.GID))
}
if _, twice := byName[g.Name]; twice {
problems = append(problems, where+": declared twice")
}
if other, used := numbers[g.GID]; used {
problems = append(problems, fmt.Sprintf("%s: gid %d is %s's too", where, g.GID, other))
}
byName[g.Name], numbers[g.GID] = g.GID, g.Name
}
seen := map[string]bool{}
for _, l := range libs {
where := fmt.Sprintf("library %q", l.Library)
switch {
case l.Library == "" || strings.ContainsAny(l.Library, "/ \t\n"):
problems = append(problems, where+": a library is named by one word")
case seen[l.Library]:
problems = append(problems, where+": declared twice")
}
seen[l.Library] = true
if !filepath.IsAbs(l.Root) || filepath.Clean(l.Root) != l.Root || l.Root == "/" {
problems = append(problems, fmt.Sprintf("%s: its root %q is not an absolute, clean path below /", where,
l.Root))
}
if gid, ok := byName[l.Group]; !ok || gid != l.GID {
problems = append(problems, fmt.Sprintf("%s: its group %s (gid %d) is not one this declaration makes",
where, l.Group, l.GID))
}
if l.Group != GroupPrefix+l.Library {
problems = append(problems, fmt.Sprintf("%s: its group is %s%s, and %s is not", where, GroupPrefix,
l.Library, l.Group))
}
for _, p := range l.Paths {
if p == "" || filepath.IsAbs(p) || filepath.Clean(p) != p || p == ".." || strings.HasPrefix(p, "../") {
problems = append(problems, fmt.Sprintf("%s: the path %q is not a clean path below its root", where, p))
}
}
if l.Runner != "" && !accountName.MatchString(l.Runner) {
problems = append(problems, fmt.Sprintf("%s: its runner %q is not an account's name", where, l.Runner))
}
}
for i, a := range libs {
for _, b := range libs[i+1:] {
if a.Root != "" && b.Root != "" && (a.Root == b.Root || strings.HasPrefix(a.Root, b.Root+"/") ||
strings.HasPrefix(b.Root, a.Root+"/")) {
problems = append(problems, fmt.Sprintf("library %q: its root and library %q's nest, and libraries "+
"never overlap (novox/hq ADR 0328 §13)", a.Library, b.Library))
}
}
}
return problems
}
// IdentityPrefix starts every module identity's name.
@@ -1560,9 +1677,13 @@ const IdentityPrefix = "mesh-"
// checkIdentities judges the identities on arrival: each named for its module, never root, and no name or number
// twice — two modules with one account would write as each other.
func checkIdentities(ids []Identity) []string {
func checkIdentities(ids []Identity, groups []Group) []string {
var problems []string
names, uids, gids := map[string]bool{}, map[int]string{}, map[int]string{}
names, uids := map[string]bool{}, map[int]string{}
libraryGroups := map[int]bool{}
for _, g := range groups {
libraryGroups[g.GID] = true
}
for _, id := range ids {
where := fmt.Sprintf("identity %q", id.Name)
if id.Module == "" || id.Name != IdentityPrefix+id.Module || !accountName.MatchString(id.Name) {
@@ -1571,7 +1692,7 @@ func checkIdentities(ids []Identity) []string {
}
if id.UID <= 0 || id.GID <= 0 {
problems = append(problems, fmt.Sprintf("%s: uid %d, gid %d, and a module's identity is never root, "+
"nor a number below it (novox/hq ADR 0325 §11); nothing of this declaration was made", where,
"nor a number below it (novox/hq ADR 0328 §15); nothing of this declaration was made", where,
id.UID, id.GID))
}
if names[id.Name] {
@@ -1580,10 +1701,25 @@ func checkIdentities(ids []Identity) []string {
if other, used := uids[id.UID]; used {
problems = append(problems, fmt.Sprintf("%s: uid %d is %s's too", where, id.UID, other))
}
if other, used := gids[id.GID]; used {
problems = append(problems, fmt.Sprintf("%s: gid %d is %s's too", where, id.GID, other))
// Its primary group is its own (gid == uid) or a library's this declaration makes (ADR 0328 §4); so is
// every added group. A module's own number is never a library group's.
if id.GID != id.UID && !libraryGroups[id.GID] {
problems = append(problems, fmt.Sprintf("%s: its primary group gid %d is neither its own (%d) nor a "+
"library's this declaration makes", where, id.GID, id.UID))
}
names[id.Name], uids[id.UID], gids[id.GID] = true, id.Name, id.Name
if libraryGroups[id.UID] {
problems = append(problems, fmt.Sprintf("%s: uid %d is a library group's number too", where, id.UID))
}
for _, g := range id.Groups {
if !libraryGroups[g] {
problems = append(problems, fmt.Sprintf("%s: its added group gid %d is not a library's this "+
"declaration makes", where, g))
}
}
if id.Runner != "" && !accountName.MatchString(id.Runner) {
problems = append(problems, fmt.Sprintf("%s: its runner %q is not an account's name", where, id.Runner))
}
names[id.Name], uids[id.UID] = true, id.Name
}
return problems
}
@@ -1755,9 +1891,13 @@ type envelope struct {
// sends them only to a host whose reports say `reads_generation`.
Generation int64 `json:"generation,omitempty"`
PutBack bool `json:"put_back,omitempty"`
// Identities is optional on the wire too (novox/hq ADR 0325 §3). **An older host refuses this key**; a
// Identities is optional on the wire too (novox/hq ADR 0325 §3, ADR 0328 §4). **An older host refuses this key**; a
// controller sends it only to a host whose health says link.IdentityContract.
Identities []Identity `json:"identities,omitempty"`
// Groups and Libraries are optional too (novox/hq ADR 0328). **An older host refuses these keys**; a controller
// sends them only to a host whose health says link.LibraryContract.
Groups []Group `json:"groups,omitempty"`
Libraries []Library `json:"libraries,omitempty"`
}
func parse(raw []byte, allowActions bool) (*Declaration, error) {
@@ -1776,8 +1916,9 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence,
Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack,
Identities: env.Identities}
problems := checkIdentities(env.Identities)
Identities: env.Identities, Groups: env.Groups, Libraries: env.Libraries}
problems := checkIdentities(env.Identities, env.Groups)
problems = append(problems, checkLibraries(env.Groups, env.Libraries)...)
if env.Generation < 0 {
// Below zero would read as "none claimed" and pass every refusal of an older generation.
problems = append(problems, fmt.Sprintf("an assignment generation below zero (%d) is not one the "+
+24 -5
View File
@@ -47,11 +47,14 @@ func TestTheTakeActRunsOnlyOnAWarrant(t *testing.T) {
Nonce: nonce}
}
for why, body := range map[string][]byte{
"no warrant": signedAct(t, key, ask(aNonce+"01"), AccessActAsk{Act: ActTake, Access: "radarr.movies"}),
"another act": signedAct(t, key, ask(aNonce+"02"), AccessActAsk{Act: "share", Access: "radarr.movies", Warrant: "w"}),
"no access": signedAct(t, key, ask(aNonce+"03"), AccessActAsk{Act: ActTake, Warrant: "w"}),
"a hand-over's": signedAsk(t, key, ask(aNonce+"04")),
"a path as access": signedAct(t, key, ask(aNonce+"05"), AccessActAsk{Act: ActTake, Access: "/srv", Warrant: "w"}),
"no warrant": signedAct(t, key, ask(aNonce+"01"), AccessActAsk{Act: ActTake, Access: "radarr.movies"}),
"another act": signedAct(t, key, ask(aNonce+"02"), AccessActAsk{Act: "share", Access: "radarr.movies", Warrant: "w"}),
"no access": signedAct(t, key, ask(aNonce+"03"), AccessActAsk{Act: ActTake, Warrant: "w"}),
"a hand-over's": signedAsk(t, key, ask(aNonce+"04")),
"a slash in the warrant": signedAct(t, key, ask(aNonce+"05"), AccessActAsk{Act: ActTake, Access: "radarr.movies",
Warrant: "w/1"}),
"two lines as access": signedAct(t, key, ask(aNonce+"07"), AccessActAsk{Act: ActTake, Access: "radarr.\nmovies",
Warrant: "w"}),
} {
if a := AnswerAccessAct(check, handle, body); a.Refused == "" || !strings.HasSuffix(a.Refused, "nothing was changed") {
t.Errorf("%s: not refused, or refused without saying nothing was changed: %+v", why, a)
@@ -66,3 +69,19 @@ func TestTheTakeActRunsOnlyOnAWarrant(t *testing.T) {
t.Fatalf("a warranted ask did not reach the act whole: %+v %+v", a, given)
}
}
// The controller names an access by its declared resource id exactly, which may hold a `/`: it reaches the act,
// which matches it against what was declared.
func TestATakeActNamesItsAccessByItsDeclaredId(t *testing.T) {
m, key := aMember(t)
var given []AccessAct
handle := func(a AccessAct) (string, error) { given = append(given, a); return "started", nil }
check := HandOverCheck{Signer: m.Signer, Node: m.Node, Nonces: &Nonces{}}
ask := HandOverAsk{Node: m.Node, Path: "/services/media/movies", By: "the operator",
Expires: time.Now().Add(HandOverGood), Nonce: aNonce + "08"}
id := "radarr.access-services/media/movies"
a := AnswerAccessAct(check, handle, signedAct(t, key, ask, AccessActAsk{Act: ActTake, Access: id, Warrant: "w-1"}))
if a.Refused != "" || len(given) != 1 || given[0].Access != id {
t.Fatalf("an access id with a slash did not reach the act as declared: %+v %+v", a, given)
}
}
+21 -22
View File
@@ -328,16 +328,15 @@ type DirectoryJudgements interface {
HearDirectoryJudgements(JudgeDirectoriesFunc)
}
// The take act on an accessed folder, asked over the bus (novox/hq ADR 0325 §8, issue 567).
// The take act on a library, asked over the bus (novox/hq ADR 0328 §7, issue 567).
//
// A folder that already holds data is kept only on the operator's word: the access waits (§7), the controller asks
// the operator once for that folder on that machine (ADR 0259), and on the approved warrant asks this node's engine
// on AskAccessActSubject, signed as a hand-over is and held to the same checks (believed), under a signing context
// of its own, naming the act, the access, its folder as the engine states it, and the warrant. The engine judges
// every value against what it was last declared and what is on disk, answers at once that the act started or why
// it is refused, adds the users' entries to everything under the folder (following no link, entering no other
// filesystem), and states the act on the access's verdict (Act). Adding or dropping a user of a folder already
// taken asks nobody: the engine does it on the apply that declares it.
// A library whose root already holds data is kept only on the operator's word: it waits (§8), the controller asks
// the operator once for that library (ADR 0259), and on the approved warrant asks the library's home engine on
// AskAccessActSubject, signed as a hand-over is and held to the same checks (believed), under a signing context of
// its own, naming the act, the access `library:<name>`, its root as the engine states it, and the warrant. The engine
// judges every value against what it was last declared and what is on disk, answers at once that the act started or
// why it is refused, sets the library's group, group write and setgid on everything under the root (following no
// link, entering no other filesystem), and states the act on the library's verdict (Act).
// AskAccessActSubject is where the controller asks this node's engine to run an act on one access's folder.
func AskAccessActSubject(node string) string { return "mesh.node." + node + ".ask.access-act" }
@@ -345,17 +344,13 @@ func AskAccessActSubject(node string) string { return "mesh.node." + node + ".as
// AccessActContext is prefixed to an act ask's bytes before signing: never another ask's, nor a declaration's.
const AccessActContext = "novox-mesh access-act v2\n"
// The walks under an accessed folder. Only ActTake is asked; ActAdd and ActDrop are what an apply starts.
const (
// ActTake adds the users' entries to the folder and everything under it, on a warrant (ADR 0325 §8).
ActTake = "take"
// ActAdd adds the entries of a user that joined a taken folder; ActDrop removes exactly a dropped user's.
ActAdd = "add"
ActDrop = "drop"
)
// ActTake is the one act asked: the library's group, group write and setgid set on everything under its root, on
// a warrant (ADR 0328 §7).
const ActTake = "take"
// AccessActAsk is an act's own fields, beside a hand-over's (node, path, by, expires, nonce): which act, the
// access by its resource id, and the warrant the operator approved.
// access by its declared resource id exactly as the declaration names it (`/` allowed), and the warrant the
// operator approved.
type AccessActAsk struct {
Act string `json:"act"`
Access string `json:"access"`
@@ -400,10 +395,14 @@ func AnswerAccessAct(check HandOverCheck, handle AccessActFunc, body []byte) Han
case act.Act != ActTake:
return refusedAct(fmt.Sprintf("the ask names the act %q, and the one act asked is %q", act.Act, ActTake))
}
for _, v := range []string{act.Access, act.Warrant} {
if len(v) > MaxActField || strings.ContainsAny(v, "\r\n\x00/") {
return refusedAct("the ask's access or warrant is not one short name")
}
// The access is the declared resource's id exactly as the declaration names it — which may hold a `/`
// (radarr.access-services/media/movies) — and the engine matches it against what it was declared, refusing
// anything else; the warrant is one short name.
if len(act.Access) > MaxActField || strings.ContainsAny(act.Access, "\r\n\x00") {
return refusedAct("the ask's access is not one short line")
}
if len(act.Warrant) > MaxActField || strings.ContainsAny(act.Warrant, "\r\n\x00/") {
return refusedAct("the ask's warrant is not one short name")
}
said, err := handle(AccessAct{Act: act.Act, Access: act.Access, Path: ask.Path, Warrant: act.Warrant, By: ask.By})
if err != nil {
+2 -2
View File
@@ -66,7 +66,7 @@ type natsLink struct {
searching *nats.Subscription
// judging hears the controller asking for a read-only judgement of this node's directories (issue 358).
judging *nats.Subscription
// acting hears the controller asking for the take act on an access's folder (novox/hq ADR 0325 §8).
// acting hears the controller asking for the take act on a library (novox/hq ADR 0328 §7).
acting *nats.Subscription
node string
// signer is the mesh's key, which a hand-over ask is verified with as a declaration is (issue 356).
@@ -262,7 +262,7 @@ func (l *natsLink) HearDirectoryJudgements(handle JudgeDirectoriesFunc) {
}
// HearAccessActs listens for the controller asking this node to run the take act on an access's folder (novox/hq ADR
// 0325 §8), on this machine's own subject, verified as a hand-over is (AnswerAccessAct). A bus whose user list is older
// 0328 §7), on this machine's own subject, verified as a hand-over is (AnswerAccessAct). A bus whose user list is older
// than the grant refuses the subscription, said by the client; the controller then hears no answer.
func (l *natsLink) HearAccessActs(handle AccessActFunc) {
if handle == nil {
+48 -30
View File
@@ -266,6 +266,20 @@ const GrantContract = 4
// tells the controller this host may be sent those fields; an older host refuses the whole declaration for them.
const IdentityContract = 5
// LibraryContract is the statement of a host that reads a declaration's `groups` and `libraries` and an identity's
// `groups` (novox/hq ADR 0328, which supersedes ADR 0325): it makes each library's group and each module's user in
// its libraries' groups, keeps each library it is home to under its group, holds the containers of a library whose
// disk is not there, states a library it may not keep yet as waiting, and hears the take act for `library:<name>`.
// It keeps no ACL entries: an access's `users` and `taken` are accepted and not acted on. Like the fields before
// it, its presence is what tells the controller this host may be sent those fields.
const LibraryContract = 6
// OwnGroupContract is the statement of a host that also reads an identity's `runner` (novox/hq ADR 0334 §2): it puts
// the tool runner's account in the module's own group, takes it back when no identity names it, and says the change
// as `identity:<module>`, which the tool runner's unit names under restart-on to be restarted once. Like the fields
// before it, its presence is what tells the controller this host may be sent `runner` on an identity.
const OwnGroupContract = 7
// Health is one statement of every long-running resource's state on this machine (to-be 48 §4). Said in
// every report, as an event on each change, and again every minute while anything is not healthy — so a
// lost statement is not a lost fault.
@@ -304,15 +318,19 @@ const KindAccount = "account"
// starting ReasonUsedAsFound, until the operator hands it to the mesh (`nox node hand-over <node> <path>` on the control-node, novox/hq issue 356).
const KindDirectory = "directory"
// KindAccess is an access whose folder the engine keeps for its users (novox/hq ADR 0325): healthy while every user
// has its effective right on the folder and the sweep's sample under it (§10), waiting while the mesh may not keep
// it yet or its disk is not there (§7), unhealthy otherwise — root's data on its placed filesystem among it (§12).
// KindAccess is an access (novox/hq ADR 0051): no engine since ADR 0328 states one; kept for the controller's reading
// of an older engine's statements.
const KindAccess = "access"
// KindKeptEntries is the mesh's entries on a shared folder that nobody wants and that could not be taken out yet
// (novox/hq ADR 0325 §8): a folder no access names whose disk is away, or entries left on a filesystem no longer at
// a folder. Unhealthy, said under the access that last named it, until the drop walk has run.
const KindKeptEntries = "kept-entries"
// KindLibrary is a library this machine is reached by (novox/hq ADR 0328), said under resource `library:<name>`
// with no module: healthy while it is kept (or, not home, while its mount is there), waiting with an access wait of
// reason keep or disk, unknown while its filesystem could not be named, unhealthy otherwise.
const KindLibrary = "library"
// KindRunner is a tool runner's account the engine could not put in a group (novox/hq ADR 0334 §2, ADR 0328 §11):
// missing, or root's. Said unhealthy as `runner:<module>` (with its module) or `runner:library:<name>`, words with no
// path, never failing an apply; gone once the account is right.
const KindRunner = "runner"
// ReasonUsedAsFound starts the reason of a directory used as found.
const ReasonUsedAsFound = "used as found:"
@@ -426,24 +444,21 @@ type ResourceHealth struct {
// Waits is what a resource stated waiting waits for (novox/hq ADR 0283): each one own secret or one
// setting of the module by name, the part, and what the operator gives, in words. Absent otherwise.
Waits []health.Wait `json:"waits,omitempty"`
// AccessWaits is what an access stated waiting waits for (novox/hq ADR 0325 §7). Beside Waits rather than in
// AccessWaits is what a library stated waiting waits for (novox/hq ADR 0328 §8, §9). Beside Waits rather than in
// it because mesh-sdk's health.Wait names a secret or a setting and nothing else; it moves there once that type
// has a kind. Absent otherwise.
AccessWaits []AccessWait `json:"access-waits,omitempty"`
// Act is the last walk under an access's folder (ADR 0325 §8): a take on a warrant, or an add or a drop of a
// user's entries. Absent on every other verdict, and on a folder never walked.
// Act is the last walk under a library's root (ADR 0328 §7): the take on a warrant. Absent on every other
// verdict, and on a library never walked.
Act *AccessActRecord `json:"act,omitempty"`
// Entries is the listing of a kept folder's users, for an access's verdict (ADR 0325 §4): each identity, its
// use, and a note beside the tool runner's entry. Absent on a folder not kept.
Entries []KeptEntry `json:"entries,omitempty"`
}
// WaitKindAccess is the kind of a wait on an access (novox/hq ADR 0325 §7).
// WaitKindAccess is the kind of a wait on a library (novox/hq ADR 0328 §8, §9; first ADR 0325 §7).
const WaitKindAccess = "access"
// AccessWait is one access waiting for the operator: the access by its module's own id for it, the part (its
// module), and what the operator gives, in words with no path (ADR 0234 §6): "let the mesh keep the shared folder
// movies", or "mount the disk that holds books".
// AccessWait is one library waiting for the operator (ADR 0328 §8, §9): the access `library:<name>`, the part (the
// library), and what the operator gives, in words with no path (ADR 0234 §6): "let the mesh keep the library media",
// or "mount the disk that holds the library media".
type AccessWait struct {
// Kind is WaitKindAccess.
Kind string `json:"kind"`
@@ -453,18 +468,16 @@ type AccessWait struct {
// Placed names, with no path, the filesystem the folder is on now, on a wait for the operator's word: a warrant
// given for another filesystem (the folder moved) does not answer it, and the controller asks again.
Placed string `json:"placed,omitempty"`
// Reason is what the wait is for, for the controller to read without parsing words: WaitKeep (the operator's
// word to keep it) or WaitDisk (its disk mounted).
Reason string `json:"reason"`
}
// KeptEntry is one user of a kept folder as its listing says it.
type KeptEntry struct {
Identity string `json:"identity"`
UID int `json:"uid"`
Use string `json:"use"`
From string `json:"from"`
// Note is said beside the tool runner's entry (ADR 0325 §4): it stands for every module whose tools use the
// folder, at the widest use any of them declares.
Note string `json:"note,omitempty"`
}
// The reasons of an access wait.
const (
WaitKeep = "keep"
WaitDisk = "disk"
)
// The states of a walk under an accessed folder.
const (
@@ -476,10 +489,10 @@ const (
ActFailed = "failed"
)
// AccessActRecord is one walk under an access's folder, as the engine records it (novox/hq ADR 0325 §8). Counts
// only: never a path.
// AccessActRecord is one walk under a library's root, as the engine records it (novox/hq ADR 0328 §7). Counts only:
// never a path.
type AccessActRecord struct {
// Act is ActTake, ActAdd or ActDrop.
// Act is ActTake.
Act string `json:"act"`
Access string `json:"access"`
Warrant string `json:"warrant,omitempty"`
@@ -496,6 +509,11 @@ type AccessActRecord struct {
Links int `json:"links,omitempty"`
Other int `json:"other,omitempty"`
Mounts int `json:"mounts,omitempty"`
// Shared is the files with another name root owns, left as they are; Linked those an account owns, given the
// group; Stripped the files whose setuid or setgid bit the group change took.
Shared int `json:"shared,omitempty"`
Linked int `json:"linked,omitempty"`
Stripped int `json:"stripped,omitempty"`
// Why is what stopped or failed it, in words with no path.
Why string `json:"why,omitempty"`
}
+1 -1
View File
@@ -63,7 +63,7 @@ type Queue struct {
// link (novox/hq issue 358). Nil answers every such ask with a refusal.
JudgeDirectories JudgeDirectoriesFunc
// AccessAct judges and runs the take act on an access's folder, asked by the controller on an approved
// warrant (novox/hq ADR 0325 §8). Nil answers every such ask with a refusal.
// warrant (novox/hq ADR 0328 §7). Nil answers every such ask with a refusal.
AccessAct AccessActFunc
Say Announce
Timeout time.Duration
+1 -1
View File
@@ -252,7 +252,7 @@ func serve(ctx context.Context, link Link, m Membership, queue *Queue, say Annou
if j, ok := link.(DirectoryJudgements); ok {
j.HearDirectoryJudgements(queue.JudgeDirectories)
}
// And asking for the take act on an access's folder, on a warrant (novox/hq ADR 0325 §8).
// And asking for the take act on a library, on a warrant (novox/hq ADR 0328 §7).
if s, ok := link.(AccessActs); ok {
s.HearAccessActs(queue.AccessAct)
}
+445
View File
@@ -0,0 +1,445 @@
package profile
import (
"context"
"fmt"
"os"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
// Machine is what this machine is made of, as it says itself (novox/hq issue 160): what somebody
// deciding where a module should go wants to know, beside what it can do.
//
// **A fact it could not determine is said, never zeroed.** Every fact below is either present or
// named in Unknown with why; a machine reporting 0 bytes of memory would be read as a machine with
// none. So a field is omitted only when Unknown says why.
type Machine struct {
// ObservedAt is when this was read: the age every reader shows beside it.
ObservedAt time.Time `json:"observed_at"`
// KernelRelease is the running kernel's release, as /proc/sys/kernel/osrelease says it. (The
// profile's own `kernel` is the kernel's family, `linux`.)
KernelRelease string `json:"kernel_release,omitempty"`
// Distribution and its Version, from /etc/os-release: VERSION_ID, or BUILD_ID for a rolling
// distribution that has no version (Arch Linux says `rolling`).
Distribution string `json:"distribution,omitempty"`
Version string `json:"version,omitempty"`
// Processor is its model name; Cores the physical cores and Threads the logical processors.
Processor string `json:"processor,omitempty"`
Cores int `json:"cores,omitempty"`
Threads int `json:"threads,omitempty"`
MemoryBytes int64 `json:"memory_bytes,omitempty"`
// Virtual is `physical`, or the kind of virtual machine or container it is (`kvm`, `lxc`), as
// systemd-detect-virt names it.
Virtual string `json:"virtual,omitempty"`
// BootedAt is when it booted: its uptime is the time since, so the fact does not change between
// two reports of the same boot.
BootedAt *time.Time `json:"booted_at,omitempty"`
Timezone string `json:"timezone,omitempty"`
// Filesystems is each local filesystem mounted, by where, with its size and what is free.
Filesystems []Filesystem `json:"filesystems,omitempty"`
// Unknown names each fact that could not be determined, and why.
Unknown map[string]string `json:"unknown,omitempty"`
}
// Filesystem is one mounted local filesystem.
type Filesystem struct {
Mount string `json:"mount"`
Device string `json:"device"`
Type string `json:"type"`
// SizeBytes and FreeBytes are what statfs says: free is what an unprivileged writer may still
// use (f_bavail), the number a person deciding where data goes wants.
SizeBytes int64 `json:"size_bytes"`
FreeBytes int64 `json:"free_bytes"`
}
// The facts, by the names Unknown says them under.
const (
FactKernelRelease = "kernel release"
FactDistribution = "distribution"
FactVersion = "version"
FactProcessor = "processor"
FactCores = "cores"
FactThreads = "threads"
FactMemory = "memory"
FactVirtual = "virtual or physical"
FactUptime = "uptime"
FactTimezone = "timezone"
FactFilesystems = "filesystems"
FactPublicAddress = "public address"
)
// MachineFacts is every fact Unknown may name, in the order a reader shows them.
var MachineFacts = []string{FactKernelRelease, FactDistribution, FactVersion, FactProcessor, FactCores,
FactThreads, FactMemory, FactVirtual, FactUptime, FactTimezone, FactFilesystems, FactPublicAddress}
// publicAddressUnknown is why the public uplink address is not said. A machine behind a NAT cannot see
// its public address from itself, and asking a service outside the mesh is a dependency the mesh does not
// take on unasked; how a machine learns it is not decided yet, so it is said as unknown rather than
// guessed from a private address.
const publicAddressUnknown = "a machine behind a NAT cannot see its public address from itself, and how it " +
"learns it is not decided yet"
// Sources is where the facts are read from. Replaced in tests only for the parsing layer; the real
// sources are exercised against this machine as well (novox/hq ADR 0017).
type Sources struct {
Read func(path string) ([]byte, error)
Readlink func(path string) (string, error)
Run Runner
// Statfs is (size, free) in bytes of the filesystem mounted at a path.
Statfs func(path string) (size, free int64, err error)
Now func() time.Time
}
// RealSources reads this machine.
func RealSources() Sources {
return Sources{Read: os.ReadFile, Readlink: os.Readlink, Run: ExecRunner, Statfs: statfs, Now: time.Now}
}
func statfs(path string) (int64, int64, error) {
var s syscall.Statfs_t
if err := syscall.Statfs(path, &s); err != nil {
return 0, 0, err
}
return int64(s.Blocks) * int64(s.Bsize), int64(s.Bavail) * int64(s.Bsize), nil
}
// DescribeMachine reads what this machine is made of. It never fails: a fact that cannot be read is
// named in Unknown with why, and the rest are still said.
func DescribeMachine(ctx context.Context, src Sources) Machine {
if src.Now == nil {
src.Now = time.Now
}
m := Machine{ObservedAt: src.Now().UTC(), Unknown: map[string]string{}}
unknown := func(fact string, why string) { m.Unknown[fact] = why }
if b, err := src.Read("/proc/sys/kernel/osrelease"); err != nil {
unknown(FactKernelRelease, err.Error())
} else if r := strings.TrimSpace(string(b)); r == "" {
unknown(FactKernelRelease, "/proc/sys/kernel/osrelease is empty")
} else {
m.KernelRelease = r
}
if b, err := src.Read("/etc/os-release"); err != nil {
unknown(FactDistribution, err.Error())
unknown(FactVersion, err.Error())
} else {
name, version, whyName, whyVersion := distributionOf(string(b))
m.Distribution, m.Version = name, version
if whyName != "" {
unknown(FactDistribution, whyName)
}
if whyVersion != "" {
unknown(FactVersion, whyVersion)
}
}
if b, err := src.Read("/proc/cpuinfo"); err != nil {
unknown(FactProcessor, err.Error())
unknown(FactCores, err.Error())
unknown(FactThreads, err.Error())
} else {
c := processorOf(string(b))
m.Processor, m.Cores, m.Threads = c.model, c.cores, c.threads
for fact, why := range c.unknown {
unknown(fact, why)
}
}
if b, err := src.Read("/proc/meminfo"); err != nil {
unknown(FactMemory, err.Error())
} else if kb, ok := memTotalKB(string(b)); !ok {
unknown(FactMemory, "MemTotal not found in /proc/meminfo")
} else {
m.MemoryBytes = kb * 1024
}
m.Virtual, _ = virtualOf(ctx, src.Run, unknown)
if b, err := src.Read("/proc/stat"); err != nil {
unknown(FactUptime, err.Error())
} else if booted, ok := bootedAt(string(b)); !ok {
unknown(FactUptime, "btime not found in /proc/stat")
} else {
m.BootedAt = &booted
}
if tz, why := timezoneOf(src); why != "" {
unknown(FactTimezone, why)
} else {
m.Timezone = tz
}
if b, err := src.Read("/proc/self/mounts"); err != nil {
unknown(FactFilesystems, err.Error())
} else {
fs, why := filesystemsOf(ctx, string(b), src.Statfs)
m.Filesystems = fs
if why != "" {
unknown(FactFilesystems, why)
}
}
unknown(FactPublicAddress, publicAddressUnknown)
return m
}
// distributionOf is the distribution's name and version from an os-release file, with why either is
// not said. NAME first, then ID; VERSION_ID first, then BUILD_ID.
func distributionOf(osRelease string) (name, version, whyName, whyVersion string) {
fields := map[string]string{}
for _, line := range strings.Split(osRelease, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, "=")
if !found {
continue
}
fields[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`)
}
name = firstOf(fields["NAME"], fields["ID"])
version = firstOf(fields["VERSION_ID"], fields["BUILD_ID"])
if name == "" {
whyName = "/etc/os-release names neither NAME nor ID"
}
if version == "" {
whyVersion = "/etc/os-release names neither VERSION_ID nor BUILD_ID"
}
return name, version, whyName, whyVersion
}
func firstOf(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
type processor struct {
model string
cores, threads int
unknown map[string]string
}
// processorOf reads /proc/cpuinfo: the model from `model name` (or `Model`, `Hardware`, `cpu model`,
// which other architectures use), threads from its `processor` entries, and cores from the distinct
// (physical id, core id) pairs — which an architecture that does not say them leaves unknown rather than
// guessed equal to the threads.
func processorOf(cpuinfo string) processor {
p := processor{unknown: map[string]string{}}
pairs := map[string]bool{}
physical := "0"
var models = map[string]string{}
for _, line := range strings.Split(cpuinfo, "\n") {
key, value, found := strings.Cut(line, ":")
if !found {
continue
}
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
switch key {
case "processor":
p.threads++
case "physical id":
physical = value
case "core id":
pairs[physical+"/"+value] = true
case "model name", "Model", "Hardware", "cpu model":
if _, had := models[key]; !had {
models[key] = value
}
}
}
p.model = firstOf(models["model name"], models["cpu model"], models["Model"], models["Hardware"])
p.cores = len(pairs)
if p.model == "" {
p.unknown[FactProcessor] = "/proc/cpuinfo names no model"
}
if p.threads == 0 {
p.unknown[FactThreads] = "/proc/cpuinfo lists no processor"
}
if p.cores == 0 {
p.unknown[FactCores] = "/proc/cpuinfo says no core id"
}
return p
}
func memTotalKB(meminfo string) (int64, bool) {
for _, line := range strings.Split(meminfo, "\n") {
if !strings.HasPrefix(line, "MemTotal:") {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
return 0, false
}
kb, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || kb <= 0 {
return 0, false
}
return kb, true
}
return 0, false
}
// virtualOf asks systemd-detect-virt. It says `none` on a physical machine, and exits 1 doing so.
func virtualOf(ctx context.Context, run Runner, unknown func(string, string)) (string, bool) {
if run == nil {
unknown(FactVirtual, "nothing here runs commands")
return "", false
}
out, err := run(ctx, "systemd-detect-virt")
switch said := strings.TrimSpace(out); {
case said == "none":
return "physical", true
case said != "" && err == nil:
return said, true
case err != nil:
unknown(FactVirtual, "systemd-detect-virt: "+err.Error())
default:
unknown(FactVirtual, "systemd-detect-virt said nothing")
}
return "", false
}
func bootedAt(stat string) (time.Time, bool) {
for _, line := range strings.Split(stat, "\n") {
parts := strings.Fields(line)
if len(parts) == 2 && parts[0] == "btime" {
secs, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || secs <= 0 {
return time.Time{}, false
}
return time.Unix(secs, 0).UTC(), true
}
}
return time.Time{}, false
}
// timezoneOf is the zone /etc/localtime links into the zoneinfo tree, or /etc/timezone's.
func timezoneOf(src Sources) (string, string) {
var whys []string
if src.Readlink != nil {
target, err := src.Readlink("/etc/localtime")
if err == nil {
if _, zone, found := strings.Cut(target, "zoneinfo/"); found && zone != "" {
return zone, ""
}
whys = append(whys, "/etc/localtime links to "+target+", outside a zoneinfo tree")
} else {
whys = append(whys, "/etc/localtime: "+err.Error())
}
}
if b, err := src.Read("/etc/timezone"); err == nil {
if zone := strings.TrimSpace(string(b)); zone != "" {
return zone, ""
}
whys = append(whys, "/etc/timezone is empty")
} else {
whys = append(whys, err.Error())
}
return "", strings.Join(whys, "; ")
}
// localTypes are the filesystem types said: those on a local device. A network filesystem is left out on
// purpose — statfs on a server that stopped answering blocks, and this runs on every apply.
var localTypes = map[string]bool{"ext2": true, "ext3": true, "ext4": true, "xfs": true, "btrfs": true,
"zfs": true, "f2fs": true, "vfat": true, "exfat": true, "ntfs": true, "ntfs3": true, "fuseblk": true,
"jfs": true, "reiserfs": true, "bcachefs": true}
// sizing is held while a check of the filesystems' sizes runs.
var sizing sync.Mutex
// filesystemsOf lists each local filesystem once — a device mounted at several places (a btrfs
// subvolume, a bind mount) under its shortest mount — with its size and what is free. Bounded by the
// context, so a filesystem whose statfs hangs cannot hang the apply that reports it.
func filesystemsOf(ctx context.Context, mounts string, stat func(string) (int64, int64, error)) ([]Filesystem, string) {
if stat == nil {
return nil, "nothing here can ask a filesystem its size"
}
byDevice := map[string]Filesystem{}
for _, line := range strings.Split(mounts, "\n") {
f := strings.Fields(line)
if len(f) < 3 || !localTypes[f[2]] {
continue
}
mount := unescapeMount(f[1])
if had, ok := byDevice[f[0]]; ok && len(had.Mount) <= len(mount) {
continue
}
byDevice[f[0]] = Filesystem{Mount: mount, Device: f[0], Type: f[2]}
}
if len(byDevice) == 0 {
return nil, "/proc/self/mounts lists no local filesystem"
}
// One check at a time, across every report: a filesystem whose statfs hangs keeps the one check that
// asked it, and every report after it says the sizes unknown until it finishes, rather than leaving
// another goroutine and thread blocked behind it on every apply.
if !sizing.TryLock() {
return nil, "the last check of the filesystems' sizes has not finished yet"
}
type answer struct {
fs []Filesystem
why []string
}
done := make(chan answer, 1)
go func() {
defer sizing.Unlock()
var a answer
for _, fs := range byDevice {
size, free, err := stat(fs.Mount)
if err != nil {
a.why = append(a.why, fmt.Sprintf("%s: %v", fs.Mount, err))
continue
}
fs.SizeBytes, fs.FreeBytes = size, free
a.fs = append(a.fs, fs)
}
done <- a
}()
select {
case a := <-done:
sort.Slice(a.fs, func(i, j int) bool { return a.fs[i].Mount < a.fs[j].Mount })
sort.Strings(a.why)
return a.fs, strings.Join(a.why, "; ")
case <-ctx.Done():
return nil, "asking the filesystems their size did not finish: " + ctx.Err().Error()
}
}
// unescapeMount undoes the octal escapes /proc/self/mounts writes a space or tab in a path as.
func unescapeMount(path string) string {
if !strings.Contains(path, `\`) {
return path
}
var b strings.Builder
for i := 0; i < len(path); i++ {
if path[i] == '\\' && i+3 < len(path) {
if n, err := strconv.ParseUint(path[i+1:i+4], 8, 8); err == nil {
b.WriteByte(byte(n))
i += 3
continue
}
}
b.WriteByte(path[i])
}
return b.String()
}
+185
View File
@@ -0,0 +1,185 @@
package profile
import (
"context"
"encoding/json"
"errors"
"os"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
)
// The parsing layer, with the machine's files given (novox/hq ADR 0017 allows a fake here and only here);
// the real machine is asked below.
var files = map[string]string{
"/proc/sys/kernel/osrelease": "7.2.8-arch1-2\n",
"/etc/os-release": "NAME=\"Arch Linux\"\nPRETTY_NAME=\"Arch Linux\"\nID=arch\nBUILD_ID=rolling\n",
"/proc/cpuinfo": "processor\t: 0\nmodel name\t: AMD Ryzen 9 5900HS\nphysical id\t: 0\ncore id\t\t: 0\n\n" +
"processor\t: 1\nmodel name\t: AMD Ryzen 9 5900HS\nphysical id\t: 0\ncore id\t\t: 0\n\n" +
"processor\t: 2\nmodel name\t: AMD Ryzen 9 5900HS\nphysical id\t: 0\ncore id\t\t: 1\n",
"/proc/meminfo": "MemTotal: 32000000 kB\nMemFree: 1 kB\n",
"/proc/stat": "cpu 1 2 3\nbtime 1791800000\n",
"/proc/self/mounts": "/dev/nvme0n1p2 / btrfs rw 0 0\n/dev/nvme0n1p2 /home btrfs rw 0 0\n" +
"proc /proc proc rw 0 0\ntank/data /srv/my\\040data zfs rw 0 0\n" +
"server:/x /mnt/x nfs4 rw 0 0\n/dev/loop3 /snap/core/1 squashfs ro 0 0\n",
}
func given(read map[string]string) Sources {
return Sources{
Read: func(p string) ([]byte, error) {
if s, ok := read[p]; ok {
return []byte(s), nil
}
return nil, &os.PathError{Op: "open", Path: p, Err: os.ErrNotExist}
},
Readlink: func(string) (string, error) { return "/usr/share/zoneinfo/Europe/Brussels", nil },
Run: func(context.Context, string, ...string) (string, error) { return "none\n", errors.New("exited 1") },
Statfs: func(p string) (int64, int64, error) { return 1000, 400, nil },
Now: func() time.Time { return time.Date(2026, 10, 12, 15, 0, 0, 0, time.UTC) },
}
}
func TestMachine_saysEveryFactItCanRead(t *testing.T) {
m := DescribeMachine(context.Background(), given(files))
if m.KernelRelease != "7.2.8-arch1-2" || m.Distribution != "Arch Linux" || m.Version != "rolling" {
t.Errorf("kernel %q, distribution %q %q", m.KernelRelease, m.Distribution, m.Version)
}
if m.Processor != "AMD Ryzen 9 5900HS" || m.Cores != 2 || m.Threads != 3 {
t.Errorf("processor %q, %d cores, %d threads", m.Processor, m.Cores, m.Threads)
}
if m.MemoryBytes != 32000000*1024 {
t.Errorf("memory %d", m.MemoryBytes)
}
if m.Virtual != "physical" {
t.Errorf("systemd-detect-virt said none and exited 1; read as %q", m.Virtual)
}
if m.BootedAt == nil || m.BootedAt.Unix() != 1791800000 {
t.Errorf("booted at %v", m.BootedAt)
}
if m.Timezone != "Europe/Brussels" {
t.Errorf("timezone %q", m.Timezone)
}
// One btrfs device under its shortest mount, the zfs dataset with its escaped space, and no network or
// read-only image filesystem.
var mounts []string
for _, f := range m.Filesystems {
mounts = append(mounts, f.Mount)
if f.SizeBytes != 1000 || f.FreeBytes != 400 {
t.Errorf("%s: size %d free %d", f.Mount, f.SizeBytes, f.FreeBytes)
}
}
if got := strings.Join(mounts, ","); got != "/,/srv/my data" {
t.Errorf("filesystems %q", got)
}
// Everything read is not unknown; the public address is, with why.
for fact := range m.Unknown {
if fact != FactPublicAddress {
t.Errorf("%s said unknown though it was readable: %s", fact, m.Unknown[fact])
}
}
if m.Unknown[FactPublicAddress] == "" {
t.Error("the public address is not said, and not said unknown either")
}
}
// The acceptance of issue 160: a machine that cannot determine a fact says so rather than reporting a zero.
func TestMachine_aFactItCannotReadIsSaidUnknownNeverZero(t *testing.T) {
src := given(map[string]string{})
src.Readlink = func(string) (string, error) { return "", os.ErrNotExist }
src.Run = func(context.Context, string, ...string) (string, error) { return "", errors.New("not found") }
m := DescribeMachine(context.Background(), src)
raw, _ := json.Marshal(m)
var said map[string]any
_ = json.Unmarshal(raw, &said)
for key, v := range said {
if key == "observed_at" || key == "unknown" {
continue
}
t.Errorf("%s is said as %v from a machine where nothing could be read", key, v)
}
for _, fact := range MachineFacts {
if strings.TrimSpace(m.Unknown[fact]) == "" {
t.Errorf("%s is neither said nor said unknown with why", fact)
}
}
}
func TestMachine_aHungFilesystemCannotHangTheReport(t *testing.T) {
src := given(files)
block := make(chan struct{})
var asked atomic.Int32
src.Statfs = func(string) (int64, int64, error) { asked.Add(1); <-block; return 0, 0, nil }
defer func() {
close(block)
// The check that hung ends once its filesystem answers, and lets the next one run.
for !sizing.TryLock() {
time.Sleep(time.Millisecond)
}
sizing.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
m := DescribeMachine(ctx, src)
if len(m.Filesystems) != 0 || !strings.Contains(m.Unknown[FactFilesystems], "did not finish") {
t.Errorf("filesystems %v, unknown %q", m.Filesystems, m.Unknown[FactFilesystems])
}
stuck := asked.Load()
// Every report while it hangs says so at once, and asks no filesystem again: no goroutine is left
// blocked behind the hung one per apply.
for i := 0; i < 3; i++ {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
m = DescribeMachine(ctx, src)
cancel()
if !strings.Contains(m.Unknown[FactFilesystems], "has not finished yet") {
t.Errorf("a report while the check hangs said %q", m.Unknown[FactFilesystems])
}
}
if asked.Load() != stuck {
t.Errorf("filesystems asked %d times while one check hung, was %d", asked.Load(), stuck)
}
}
func TestMachine_coresUnsaidAreUnknownNotGuessed(t *testing.T) {
p := processorOf("processor\t: 0\nModel\t: Raspberry Pi 4\nprocessor\t: 1\n")
if p.model != "Raspberry Pi 4" || p.threads != 2 || p.cores != 0 || p.unknown[FactCores] == "" {
t.Errorf("%+v", p)
}
}
// Against the real machine (ADR 0017): what it says agrees with what this process sees.
func TestAgainstThisMachine_machineSaysWhatItIs(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("the facts are read from Linux's own files")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
m := DescribeMachine(ctx, RealSources())
if m.MemoryBytes <= 0 && m.Unknown[FactMemory] == "" {
t.Error("memory neither said nor unknown")
}
if m.Threads != 0 && m.Threads < runtime.NumCPU() {
t.Errorf("%d threads said, and this process alone may use %d", m.Threads, runtime.NumCPU())
}
if m.BootedAt != nil && m.BootedAt.After(time.Now()) {
t.Errorf("booted in the future: %v", m.BootedAt)
}
for _, f := range m.Filesystems {
if f.SizeBytes <= 0 || f.FreeBytes > f.SizeBytes {
t.Errorf("%s: size %d free %d", f.Mount, f.SizeBytes, f.FreeBytes)
}
}
for _, fact := range MachineFacts {
if why, unknown := m.Unknown[fact]; unknown && strings.TrimSpace(why) == "" {
t.Errorf("%s unknown with no why", fact)
}
}
raw, _ := json.MarshalIndent(m, "", " ")
t.Logf("%s", raw)
}
+7
View File
@@ -68,6 +68,9 @@ type Profile struct {
Architecture string `json:"architecture"`
Kernel string `json:"kernel"`
Capabilities []Verdict `json:"capabilities"`
// Machine is what it is made of (novox/hq issue 160), said with every profile: at enrolment, after
// every apply, and by the reconcile once a day when nothing else made it speak.
Machine *Machine `json:"machine,omitempty"`
}
// Has reports whether a named capability is present. Unknown names are absent, not an error:
@@ -114,5 +117,9 @@ func Detect(ctx context.Context, detectors []Detector, timeout time.Duration) Pr
sort.Slice(p.Capabilities, func(i, j int) bool {
return p.Capabilities[i].Name < p.Capabilities[j].Name
})
describeCtx, cancel := context.WithTimeout(ctx, timeout)
m := DescribeMachine(describeCtx, RealSources())
cancel()
p.Machine = &m
return p
}
-212
View File
@@ -1,212 +0,0 @@
package store
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sync"
"time"
)
// KeptName is the file, beside the node's state, of every shared folder the engine keeps (novox/hq ADR 0325 §5):
// whose entries it added, on which filesystem it was placed, whether it was taken and under which warrant, and how
// far its sweep got. Its own file for the reason the hand-overs have theirs: the take act, the watch and the sweep
// run beside an apply, whose save of the state would lose a record written into it meanwhile.
const KeptName = "kept-folders.json"
// KeptPath is that file beside the state at statePath.
func KeptPath(statePath string) string {
return filepath.Join(filepath.Dir(statePath), KeptName)
}
// KeptUser is one identity the mesh keeps an entry for on a folder: its name, its number, its use and where it
// runs (a container, or the tool runner standing for every module whose tools use the folder, ADR 0325 §4).
type KeptUser struct {
Identity string `json:"identity"`
UID uint32 `json:"uid"`
Use string `json:"use"`
From string `json:"from"`
}
// How a folder came to be kept.
const (
// KeptByWarrant is a folder that held data, taken on the operator's word (ADR 0325 §8).
KeptByWarrant = "warrant"
// KeptEmpty is a folder that held nothing yet when it was first kept (§5).
KeptEmpty = "empty"
// KeptMade is a folder the engine made under a parent on its placed filesystem (§9).
KeptMade = "made"
)
// KeptFolder is one shared folder the engine keeps, or has seen and waits to keep. Counts and mount points only
// beside its own path: never a path under it.
type KeptFolder struct {
Path string `json:"path"`
// Mount is the mount point of the filesystem the folder was placed on when the engine first saw it there:
// later, a nearest existing parent on another one is the disk not there (ADR 0325 §9).
Mount string `json:"mount,omitempty"`
// Kept is that the mesh keeps it; How why (KeptByWarrant, KeptEmpty, KeptMade); Warrant and By the warrant and
// who asked, for a folder taken on one; At since when.
Kept bool `json:"kept,omitempty"`
How string `json:"how,omitempty"`
Warrant string `json:"warrant,omitempty"`
By string `json:"by,omitempty"`
At time.Time `json:"at,omitzero"`
// Users are the identities whose entries are on everything under the folder; Want what the last apply
// declared. Where they differ a walk adds or removes exactly the difference.
Users []KeptUser `json:"users,omitempty"`
Want []KeptUser `json:"want,omitempty"`
// IDs are the accesses that last named the folder: what a leftover is said under once none does.
IDs []string `json:"ids,omitempty"`
// Placed names the filesystem the folder was kept on (the engine's PlacedID: its mount point, source and type),
// so another filesystem mounted at the same point is a move, as another mount point is.
Placed string `json:"placed,omitempty"`
// Fs is what Placed was made from beside the mount point: "uuid:<the filesystem's UUID>", "fsid:<statfs's id>"
// or "source:<type>:<mountinfo's source>". Kept on this machine only, never said. Absent on a record from an
// earlier build, whose Placed is then not compared.
Fs string `json:"fs,omitempty"`
// TakenOn names, as Placed does, the filesystem the operator's warrant was carried out on (ADR 0325 §8): the
// controller's word that the folder was taken holds for that filesystem alone, and a folder moved to another
// asks again. One that names no filesystem this way (a mount point, from an unmerged build) is not taken.
TakenOn string `json:"taken_on,omitempty"`
// Left is the entries the mesh added on a filesystem no longer at the folder (a disk mounted over it, or
// one gone): taken out by a drop walk once that filesystem holds the folder again, said until then.
Left []KeptLeft `json:"left,omitempty"`
// Act is the last walk under the folder: a take, an add or a drop.
Act *KeptAct `json:"act,omitempty"`
// Sweep is how far the sweep got (ADR 0325 §10).
Sweep KeptSweep `json:"sweep"`
// Watches is how many folders under it are watched; OutOfWatches that the machine's watch limit was met, and
// the sweep alone finds what is made where no watch is (§6).
Watches int `json:"watches,omitempty"`
OutOfWatches bool `json:"out_of_watches,omitempty"`
}
// KeptLeft is the mesh's entries left on one filesystem: its mount point and whose entries they are.
type KeptLeft struct {
Mount string `json:"mount"`
// Placed names that filesystem as KeptFolder.Placed does: entries left on the filesystem the folder is on
// now are the folder's own, never walked away.
Placed string `json:"placed,omitempty"`
Fs string `json:"fs,omitempty"`
Users []KeptUser `json:"users"`
// Since is when they were left: a filesystem not back at its mount point within LeftFor is gone, and what was
// left on it is forgotten (said once).
Since time.Time `json:"since,omitzero"`
}
// KeptAct is one walk under a kept folder, as the engine records it. Counts only: never a path.
type KeptAct struct {
// Act is take, add or drop.
Act string `json:"act"`
Warrant string `json:"warrant,omitempty"`
By string `json:"by,omitempty"`
State string `json:"state"`
Started time.Time `json:"started"`
Ended time.Time `json:"ended,omitzero"`
// Folders and Files are the entries changed; Kept the ones that already had what they need; Links the links
// passed by unfollowed; Other the entries neither a file nor a folder; Mounts the folders on another
// filesystem not entered.
Folders int `json:"folders,omitempty"`
Files int `json:"files,omitempty"`
Kept int `json:"kept,omitempty"`
Links int `json:"links,omitempty"`
Other int `json:"other,omitempty"`
Mounts int `json:"mounts,omitempty"`
// Why is what stopped or failed it, in words with no path.
Why string `json:"why,omitempty"`
}
// KeptSweep is the sweep's place in a kept folder: where the last look stopped, below the folder, by the names
// of each level (never said, only resumed from); when it last covered the folder whole; and what this pass found.
type KeptSweep struct {
Cursor []string `json:"cursor,omitempty"`
Covered time.Time `json:"covered,omitzero"`
// Read is how many entries this pass read so far; Narrowed how many gave a user less than its right and were
// put back; Unrepaired how many could not be (an owner's bits, which the mesh never changes, or a write that
// failed).
Read int `json:"read,omitempty"`
Narrowed int `json:"narrowed,omitempty"`
Unrepaired int `json:"unrepaired,omitempty"`
// LastUnrepaired is what the last whole pass could not put back: said until the next pass is through.
LastUnrepaired int `json:"last_unrepaired,omitempty"`
// Bound is that the last look ran out of its bound before the folder was covered.
Bound bool `json:"bound,omitempty"`
}
// KeptFile is the kept folders' file, one writer at a time in this process.
type KeptFile struct {
path string
mu sync.Mutex
}
// NewKeptFile is the kept folders' file beside the state at statePath.
func NewKeptFile(statePath string) *KeptFile { return &KeptFile{path: KeptPath(statePath)} }
// All is every kept or seen folder, by path. None is an empty map; a file that cannot be read is an error.
func (k *KeptFile) All() (map[string]KeptFolder, error) {
k.mu.Lock()
defer k.mu.Unlock()
return k.load()
}
// Update changes the record of one folder under the file's lock: change is given the record (empty when there is
// none) and whether there was one, and says whether to keep it; a record not kept is removed.
func (k *KeptFile) Update(path string, change func(f *KeptFolder, had bool) bool) error {
k.mu.Lock()
defer k.mu.Unlock()
all, err := k.load()
if err != nil {
return err
}
f, had := all[path]
f.Path = path
if change(&f, had) {
all[path] = f
} else {
delete(all, path)
}
return k.save(all)
}
func (k *KeptFile) load() (map[string]KeptFolder, error) {
raw, err := os.ReadFile(k.path)
if errors.Is(err, fs.ErrNotExist) {
return map[string]KeptFolder{}, nil
}
if err != nil {
return nil, fmt.Errorf("reading the kept folders: %w", err)
}
out := map[string]KeptFolder{}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("the kept folders (%s) are unreadable: %w", k.path, err)
}
return out, nil
}
func (k *KeptFile) save(all map[string]KeptFolder) error {
raw, err := json.MarshalIndent(all, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(k.path), ".kept-folders-*.json")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(append(raw, '\n')); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), k.path)
}
+166
View File
@@ -0,0 +1,166 @@
package store
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sync"
"time"
)
// LibrariesName is the file, beside the node's state, of every library this machine is home to (novox/hq ADR 0328
// §6, §7, §9): which filesystem its root was kept on, whether and how it is kept, and the last walk under it. Its own
// file for the reason the hand-overs have theirs: the take act runs beside an apply, whose save of the state would
// lose a record written into it meanwhile.
const LibrariesName = "libraries.json"
// LibrariesPath is that file beside the state at statePath.
func LibrariesPath(statePath string) string {
return filepath.Join(filepath.Dir(statePath), LibrariesName)
}
// How a library came to be kept.
const (
// KeptByWarrant is a root that held data, taken on the operator's word (ADR 0328 §7).
KeptByWarrant = "warrant"
// KeptEmpty is a root that held nothing yet when it was first kept.
KeptEmpty = "empty"
// KeptInShape is a root found already in the library's group with group write and setgid.
KeptInShape = "in-shape"
)
// LibraryRecord is one library as the engine keeps it on its home. Counts, names and mount points only beside its
// root: never a path under it.
type LibraryRecord struct {
Library string `json:"library"`
Root string `json:"root"`
// GID is the library's group as it was last kept: what a walk stopped at its bound goes on with.
GID int `json:"gid,omitempty"`
// Mount is the mount point that held the root when the engine first saw it; Placed and Fs name that filesystem
// (the engine's PlacedID, and what it was made from: UUID, filesystem id or source).
Mount string `json:"mount,omitempty"`
Placed string `json:"placed,omitempty"`
Fs string `json:"fs,omitempty"`
// Kept is that the mesh keeps it, on the filesystem KeptOn names (a Placed): another filesystem at the root is
// judged afresh. How is why; Warrant and By the warrant and who asked, for one taken on a warrant.
Kept bool `json:"kept,omitempty"`
KeptOn string `json:"kept_on,omitempty"`
How string `json:"how,omitempty"`
Warrant string `json:"warrant,omitempty"`
By string `json:"by,omitempty"`
At time.Time `json:"at,omitzero"`
// Act is the last walk under the root; Cursor where a walk that met its bound stopped (the names of each level
// below the root, never said), for the next to go on from.
Act *KeptAct `json:"act,omitempty"`
// Paths are the sub-paths kept so far on the filesystem KeptOn names: one asked for later that holds data not
// in the group waits for the operator's word as a root would, unless the library was taken whole.
Paths []string `json:"paths,omitempty"`
Cursor []string `json:"cursor,omitempty"`
}
// KeptAct is one walk under a library's root, as the engine records it. Counts only: never a path.
type KeptAct struct {
Act string `json:"act"`
Warrant string `json:"warrant,omitempty"`
By string `json:"by,omitempty"`
State string `json:"state"`
Started time.Time `json:"started"`
Ended time.Time `json:"ended,omitzero"`
// On names the filesystem it walked (a Placed).
On string `json:"on,omitempty"`
// Folders and Files are the entries changed; Kept the ones that already had what they need; Links the links
// passed by unfollowed; Other the entries neither a file nor a folder; Mounts the folders on another filesystem
// not entered; Shared the files with another name root owns, left as they are.
Folders int `json:"folders,omitempty"`
Files int `json:"files,omitempty"`
Kept int `json:"kept,omitempty"`
Links int `json:"links,omitempty"`
Other int `json:"other,omitempty"`
Mounts int `json:"mounts,omitempty"`
Shared int `json:"shared,omitempty"`
// Linked is the files with another name an account owns, given the group; Stripped the files whose setuid or
// setgid bit the group change took, and which stays taken.
Linked int `json:"linked,omitempty"`
Stripped int `json:"stripped,omitempty"`
// Why is what stopped or failed it, in words with no path.
Why string `json:"why,omitempty"`
}
// LibraryFile is the libraries' file, one writer at a time in this process.
type LibraryFile struct {
path string
mu sync.Mutex
}
// NewLibraryFile is the libraries' file beside the state at statePath.
func NewLibraryFile(statePath string) *LibraryFile {
return &LibraryFile{path: LibrariesPath(statePath)}
}
// All is every library's record, by name. None is an empty map; a file that cannot be read is an error.
func (k *LibraryFile) All() (map[string]LibraryRecord, error) {
k.mu.Lock()
defer k.mu.Unlock()
return k.load()
}
// Update changes one library's record under the file's lock: change is given the record (empty when there is none)
// and whether there was one, and says whether to keep it; a record not kept is removed.
func (k *LibraryFile) Update(name string, change func(r *LibraryRecord, had bool) bool) error {
k.mu.Lock()
defer k.mu.Unlock()
all, err := k.load()
if err != nil {
return err
}
r, had := all[name]
r.Library = name
if change(&r, had) {
all[name] = r
} else {
delete(all, name)
}
return k.save(all)
}
func (k *LibraryFile) load() (map[string]LibraryRecord, error) {
raw, err := os.ReadFile(k.path)
if errors.Is(err, fs.ErrNotExist) {
return map[string]LibraryRecord{}, nil
}
if err != nil {
return nil, fmt.Errorf("reading the libraries: %w", err)
}
out := map[string]LibraryRecord{}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("the libraries (%s) are unreadable: %w", k.path, err)
}
return out, nil
}
func (k *LibraryFile) save(all map[string]LibraryRecord) error {
raw, err := json.MarshalIndent(all, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(k.path), ".libraries-*.json")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(append(raw, '\n')); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), k.path)
}
+25 -1
View File
@@ -242,10 +242,34 @@ type State struct {
// assigned again finds the tunnel's configuration retired rather than missing.
Retired []Retired `json:"retired,omitempty"`
// Identities is every module identity the engine made here (novox/hq ADR 0325 §3): its account and group,
// Identities is every module identity the engine made here (novox/hq ADR 0328 §4): its account and group,
// by name and number, and which of the two it made. An identity no longer declared is removed, and only
// what the engine made: an account or group that was there before is never the mesh's to remove.
Identities []MadeIdentity `json:"identities,omitempty"`
// Groups is every library group the engine made here (novox/hq ADR 0328 §3), removed when no longer declared;
// a group that was there before is never the mesh's to remove.
Groups []MadeGroup `json:"groups,omitempty"`
// Members is every library group the engine put a tool runner's account in that it was not in before (ADR 0328
// §11, as ADR 0252 records an account's groups): taken back when no longer declared.
Members []Membership `json:"members,omitempty"`
}
// MadeGroup is one library group the engine made.
type MadeGroup struct {
Name string `json:"name"`
GID int `json:"gid"`
At time.Time `json:"at"`
}
// Membership is one account the engine put in one library's group.
type Membership struct {
Account string `json:"account"`
Group string `json:"group"`
// Library is the library whose group it is; Module, for a module's own group (ADR 0334 §2), the module.
Library string `json:"library,omitempty"`
Module string `json:"module,omitempty"`
}
// MadeIdentity is one module identity as the engine made it.
+93 -7
View File
@@ -7,7 +7,7 @@ import (
"strings"
)
// A module's identity as a system account (novox/hq ADR 0325 §3): an account and a group with exactly the numbers
// A module's identity as a system account (novox/hq ADR 0328 §3, §4): an account and a group with exactly the numbers
// the controller allocated, no login shell, no home and no password. Optional beside System: a system that has
// none (android) makes no module identity, and the engine says so.
@@ -19,16 +19,52 @@ func (arch) CreateSystemGroup(ctx context.Context, run Runner, name string, gid
return nil
}
// CreateSystemAccount makes a system account with uid and the group gid as its own: no home made, `/` as the
// NoHome is the home field of every module account: a path that is not there, so nothing that follows an account's
// home — usermod regrouping every file under it, for one — ever walks a real tree. Never `/`: `usermod --gid` on
// an account whose home is `/` regroups every file it owns on the machine.
const NoHome = "/nonexistent"
// CreateSystemAccount makes a system account with uid and the group gid as its own: no home made, NoHome as the
// home field, nologin as its shell, and the password useradd leaves locked.
func (arch) CreateSystemAccount(ctx context.Context, run Runner, name string, uid, gid int) error {
if _, err := run(ctx, "useradd", "--system", "--uid", strconv.Itoa(uid), "--gid", strconv.Itoa(gid),
"--no-create-home", "--home-dir", "/", "--shell", "/usr/bin/nologin", name); err != nil {
func (arch) CreateSystemAccount(ctx context.Context, run Runner, name string, uid, gid int, groups []string) error {
args := []string{"--system", "--uid", strconv.Itoa(uid), "--gid", strconv.Itoa(gid)}
if len(groups) > 0 {
args = append(args, "--groups", strings.Join(groups, ","))
}
args = append(args, "--no-create-home", "--home-dir", NoHome, "--shell", "/usr/bin/nologin", name)
if _, err := run(ctx, "useradd", args...); err != nil {
return fmt.Errorf("cannot create the account %q: %w", name, err)
}
return nil
}
// ModifyAccount gives an account the mesh made its primary group gid and exactly groups as its added groups.
//
// **Its home is moved to NoHome first, never its files.** `usermod --gid` regroups every file under the account's
// home that the account owns; an account made with `/` as its home (an earlier engine's) would have the whole
// machine walked as root. So an account whose home is not NoHome is given NoHome first, without -m: no file moves.
func (arch) ModifyAccount(ctx context.Context, run Runner, name string, gid int, groups []string) error {
passwd, err := run(ctx, "getent", "passwd", name)
if err != nil {
return fmt.Errorf("cannot read the account %q: %w", name, err)
}
fields := strings.Split(strings.TrimSpace(passwd), ":")
if len(fields) < 6 {
return fmt.Errorf("the user database gave %q for %q, which is not a passwd entry", strings.TrimSpace(passwd),
name)
}
if fields[5] != NoHome {
if _, err := run(ctx, "usermod", "--home", NoHome, name); err != nil {
return fmt.Errorf("cannot give the account %q no home before its groups: %w", name, err)
}
}
if _, err := run(ctx, "usermod", "--gid", strconv.Itoa(gid), "--groups", strings.Join(groups, ","),
name); err != nil {
return fmt.Errorf("cannot give the account %q its groups: %w", name, err)
}
return nil
}
// RemoveAccount removes an account, leaving every file it owns where it is.
func (arch) RemoveAccount(ctx context.Context, run Runner, name string) error {
if _, err := run(ctx, "userdel", name); err != nil {
@@ -54,7 +90,7 @@ func (alpine) CreateSystemGroup(ctx context.Context, run Runner, name string, gi
}
// CreateSystemAccount makes a system account with busybox's adduser: -S system, -D no password, -H no home made.
func (alpine) CreateSystemAccount(ctx context.Context, run Runner, name string, uid, gid int) error {
func (alpine) CreateSystemAccount(ctx context.Context, run Runner, name string, uid, gid int, groups []string) error {
group, err := run(ctx, "getent", "group", strconv.Itoa(gid))
if err != nil {
return fmt.Errorf("cannot create the account %q: its group gid %d is not here: %w", name, gid, err)
@@ -63,10 +99,60 @@ func (alpine) CreateSystemAccount(ctx context.Context, run Runner, name string,
if n, _, ok := strings.Cut(group, ":"); ok {
groupName = n
}
if _, err := run(ctx, "adduser", "-S", "-D", "-H", "-h", "/", "-s", "/sbin/nologin", "-u", strconv.Itoa(uid),
if _, err := run(ctx, "adduser", "-S", "-D", "-H", "-h", NoHome, "-s", "/sbin/nologin", "-u", strconv.Itoa(uid),
"-G", groupName, name); err != nil {
return fmt.Errorf("cannot create the account %q: %w", name, err)
}
for _, g := range groups {
if _, err := run(ctx, "addgroup", name, g); err != nil {
return fmt.Errorf("cannot put the account %q in %q: %w", name, g, err)
}
}
return nil
}
// ModifyAccount gives an account the mesh made exactly groups as its added groups, with busybox's addgroup and
// delgroup. Its primary group busybox cannot change (it has no usermod, and the mesh edits no passwd by hand):
// an account whose primary group must change is refused, saying so, and left as it is.
func (alpine) ModifyAccount(ctx context.Context, run Runner, name string, gid int, groups []string) error {
passwd, err := run(ctx, "getent", "passwd", name)
if err != nil {
return fmt.Errorf("cannot read the account %q: %w", name, err)
}
fields := strings.Split(strings.TrimSpace(passwd), ":")
if len(fields) < 4 || fields[3] != strconv.Itoa(gid) {
return fmt.Errorf("this system cannot change the account %q's primary group to %d: busybox has no usermod, "+
"and the mesh edits no passwd by hand: %w", name, gid, ErrUnsupported)
}
primary := ""
if g, err := run(ctx, "getent", "group", fields[3]); err == nil {
primary, _, _ = strings.Cut(strings.TrimSpace(g), ":")
}
out, err := run(ctx, "id", "-nG", name)
if err != nil {
return fmt.Errorf("cannot read the account %q's groups: %w", name, err)
}
have, want := map[string]bool{}, map[string]bool{}
for _, g := range strings.Fields(out) {
if g != primary && g != name {
have[g] = true
}
}
for _, g := range groups {
want[g] = true
if !have[g] {
if _, err := run(ctx, "addgroup", name, g); err != nil {
return fmt.Errorf("cannot put the account %q in %q: %w", name, g, err)
}
}
}
for g := range have {
if !want[g] {
if _, err := run(ctx, "delgroup", name, g); err != nil {
return fmt.Errorf("cannot take the account %q out of %q: %w", name, g, err)
}
}
}
return nil
}
+91
View File
@@ -0,0 +1,91 @@
package system
import (
"context"
"errors"
"strings"
"testing"
)
// busybox has no usermod (novox/hq ADR 0328 §4): an account the mesh made is given its added groups with addgroup
// and delgroup, and a change of its primary group is refused, saying why.
func TestBusyboxGivesAnAccountItsAddedGroupsAndRefusesAnotherPrimary(t *testing.T) {
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
ran = append(ran, name+" "+strings.Join(args, " "))
switch {
case name == "getent" && args[0] == "passwd":
return "mesh-radarr:x:61001:62000::/:/sbin/nologin\n", nil
case name == "getent" && args[0] == "group":
return "mesh-media:x:62000:\n", nil
case name == "id":
return "mesh-media mesh-old\n", nil
}
return "", nil
}
if err := (alpine{}).ModifyAccount(context.Background(), run, "mesh-radarr", 62000, []string{"mesh-books"}); err != nil {
t.Fatal(err)
}
got := strings.Join(ran, "\n")
if !strings.Contains(got, "addgroup mesh-radarr mesh-books") || !strings.Contains(got, "delgroup mesh-radarr mesh-old") ||
strings.Contains(got, "delgroup mesh-radarr mesh-media") {
t.Fatalf("not given exactly its added groups:\n%s", got)
}
err := (alpine{}).ModifyAccount(context.Background(), run, "mesh-radarr", 62005, nil)
if !errors.Is(err, ErrUnsupported) || !strings.Contains(err.Error(), "primary group") {
t.Fatalf("a change of primary group was not refused saying why: %v", err)
}
}
// An account is made with no home, and a change of its groups moves an old home of `/` away first: `usermod --gid`
// regroups every file under the home the account owns, and under `/` that is the whole machine.
func TestAnAccountsGroupsChangeOnlyWithNoHome(t *testing.T) {
var ran []string
home := "/"
run := func(_ context.Context, name string, args ...string) (string, error) {
line := name + " " + strings.Join(args, " ")
ran = append(ran, line)
if name == "getent" {
return "mesh-radarr:x:61001:61001::" + home + ":/usr/bin/nologin\n", nil
}
if name == "usermod" && args[0] == "--home" {
home = args[1]
}
if name == "usermod" && args[0] == "--gid" && home != NoHome {
t.Fatalf("usermod --gid ran with the home %s: every file there would be regrouped", home)
}
return "", nil
}
if err := (arch{}).CreateSystemAccount(context.Background(), run, "mesh-radarr", 61001, 61001, nil); err != nil {
t.Fatal(err)
}
if !strings.Contains(ran[0], "--no-create-home --home-dir "+NoHome+" ") {
t.Fatalf("an account is made with a home: %s", ran[0])
}
if err := (arch{}).ModifyAccount(context.Background(), run, "mesh-radarr", 62000, nil); err != nil {
t.Fatal(err)
}
got := strings.Join(ran, "\n")
if !strings.Contains(got, "usermod --home "+NoHome+" mesh-radarr") || strings.Contains(got, " -m") ||
strings.Contains(got, "--move-home") {
t.Fatalf("the home was not moved away first, without moving files:\n%s", got)
}
}
// busybox removes what the mesh made of an identity with deluser and delgroup.
func TestBusyboxRemovesAnAccountAndAGroup(t *testing.T) {
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
ran = append(ran, name+" "+strings.Join(args, " "))
return "", nil
}
if err := (alpine{}).RemoveAccount(context.Background(), run, "mesh-radarr"); err != nil {
t.Fatal(err)
}
if err := (alpine{}).RemoveGroup(context.Background(), run, "mesh-media"); err != nil {
t.Fatal(err)
}
if strings.Join(ran, "\n") != "deluser mesh-radarr\ndelgroup mesh-media" {
t.Fatalf("removed with: %v", ran)
}
}