Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a41e12063 | ||
|
|
c7b534a08c | ||
|
|
ccc86d3594 | ||
|
|
6fe82c61bd | ||
|
|
8e95c1d9ed | ||
|
|
cb55a46400 | ||
|
|
37947ca4ee | ||
|
|
d8e785bb4b | ||
|
|
281837847e | ||
|
|
c7551162d6 | ||
|
|
2cffa33bcc | ||
|
|
2a2be687f3 | ||
|
|
303d4037e8 | ||
|
|
5c8e768ef5 | ||
|
|
aad33dfe5c | ||
|
|
ef26dba275 | ||
|
|
c7a4bf5c11 | ||
|
|
ec983bfc3d |
@@ -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},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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...)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "+
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user