WIP: judge an access by who writes it, and act on its path only on a warrant (issue 567)
Paused: ADR 0325 replaces runs-as and the group grant. Kept for reuse: the bounded descriptor walks, the warrant-backed access act (share, create), the access wait and the missing-path waiting state.
This commit is contained in:
@@ -520,6 +520,8 @@ func ApplyMindingWindows(
|
||||
// created with, and what each account is asked to be in beside its user resources' groups — read before any
|
||||
// orphan goes, so one access going leaves an account in a group another still grants.
|
||||
in.grants = grantsOf(d.Resources, in.refused)
|
||||
// Every accessed path missing below a folder, which no container mounts until it is made (novox/hq ADR 0324).
|
||||
in.missing = missingAccesses(d.Resources, in.refused)
|
||||
in.containerGroups = containerGroups(d.Resources, in.grants)
|
||||
wanted.withGrants(d.Resources, in.grants)
|
||||
in.agentHomes = rootNeverHomes(d)
|
||||
@@ -809,6 +811,14 @@ func ApplyMindingWindows(
|
||||
if outcome.found != nil {
|
||||
known.KeepFound(resource.Identity(), origin, *outcome.found)
|
||||
}
|
||||
// A step that waits for a missing accessed path has not completed: it gates its module as one that
|
||||
// failed would (novox/hq ADR 0136, ADR 0324).
|
||||
if c, ok := resource.(*declaration.Container); ok && c.RunOnce {
|
||||
if module, ours := moduleOf(resource.Identity()); ours {
|
||||
gated[module] = true
|
||||
gatedWhy[module] = "a step this module declares waits for an accessed path to be made"
|
||||
}
|
||||
}
|
||||
report.Outcomes = append(report.Outcomes, outcome)
|
||||
log(fmt.Sprintf(" waiting %s (%s): %s", outcome.ID, outcome.Target, outcome.Detail))
|
||||
continue
|
||||
@@ -1072,6 +1082,14 @@ 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 a missing accessed path, which the runtime would make as root (novox/hq ADR 0324).
|
||||
if id := mountsMissing(res, in.missing); id != "" {
|
||||
out := begin(r)
|
||||
out.Action = "waiting"
|
||||
out.Detail = "not created: it mounts the access " + id + ", whose path is not there yet; created once " +
|
||||
"it is made (novox/hq ADR 0324)"
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
// Nothing below a home is touched through a link an account put there (novox/hq ADR 0266).
|
||||
// And nothing is unpacked or written into below an agent's home (agent_home_guard.go).
|
||||
@@ -1317,6 +1335,11 @@ func applyAccess(ctx context.Context, sys system.System, r *declaration.Access,
|
||||
out := begin(r)
|
||||
info, err := os.Stat(r.Path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// Below a folder that is there, it waits for the operator, and the rest applies (novox/hq ADR 0324).
|
||||
if _, missing := missingAccess(r.Path); missing {
|
||||
out.Action, out.Detail = "waiting", MissingWords(r.Path)
|
||||
return out, nil
|
||||
}
|
||||
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 "+
|
||||
@@ -2166,6 +2189,9 @@ type inputs struct {
|
||||
containerGroups map[string][]int
|
||||
// handed is every group a record that went this apply handed to a resource still asking for it.
|
||||
handed Handed
|
||||
// missing is every accessed path not there below a folder that is, by path, with its access's id (novox/hq
|
||||
// ADR 0324): a container mounting it waits.
|
||||
missing map[string]string
|
||||
}
|
||||
|
||||
// fileDigest is what a file the container reads holds, by digest.
|
||||
|
||||
@@ -62,6 +62,10 @@ type PathGroup struct {
|
||||
Name string
|
||||
// Writable is whether the group's write bit is set on the path itself.
|
||||
Writable bool
|
||||
// UID is the path's owner, and OwnerWritable whether the owner's write bit is set: a writer whose uid owns
|
||||
// the path writes it as its owner (novox/hq ADR 0322 §2).
|
||||
UID int
|
||||
OwnerWritable bool
|
||||
}
|
||||
|
||||
// Words names the group for a person: its name and number, or its number alone.
|
||||
@@ -79,11 +83,12 @@ var GroupOfPath = func(path string) (PathGroup, error) {
|
||||
if err != nil {
|
||||
return PathGroup{}, err
|
||||
}
|
||||
_, gid, ok := statOwner(info)
|
||||
uid, gid, ok := statOwner(info)
|
||||
if !ok {
|
||||
return PathGroup{}, fmt.Errorf("the group that owns %s cannot be read on this machine", path)
|
||||
}
|
||||
g := PathGroup{GID: gid, Writable: info.Mode().Perm()&0o020 != 0}
|
||||
g := PathGroup{GID: gid, Writable: info.Mode().Perm()&0o020 != 0, UID: uid,
|
||||
OwnerWritable: info.Mode().Perm()&0o200 != 0}
|
||||
if found, err := user.LookupGroupId(strconv.Itoa(gid)); err == nil {
|
||||
g.Name = found.Name
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
)
|
||||
|
||||
// A missing accessed path waits for the operator (novox/hq ADR 0324, issue 567).
|
||||
//
|
||||
// ADR 0051 refused an access whose path is not there, and the refusal failed the whole apply: one module's
|
||||
// folder not made yet held every other module on the machine. The runtime must still not make it — a bind
|
||||
// whose source is missing is made by it as root, with whatever mode it picks (04-ISSUES/026) — so the engine
|
||||
// mounts nothing for the access: every container that mounts the path or below it is not created, and is said
|
||||
// waiting with the access, while the rest of the declaration applies. The access is stated waiting on every look
|
||||
// with a wait of kind access, and the operator's warrant for the create act makes the path, each level as its
|
||||
// nearest existing ancestor is. Only when that ancestor is a directory: one that is a file or a link is refused
|
||||
// as before, and one owned by a group that is never granted is stated unhealthy, with no wait.
|
||||
|
||||
// MissingPath is an accessed path 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 := shareablePath(path); err != nil {
|
||||
return MissingPath{}, err
|
||||
}
|
||||
if _, err := os.Lstat(path); err == nil || !errors.Is(err, os.ErrNotExist) {
|
||||
return MissingPath{}, fmt.Errorf("%s is there, or cannot be read: %v", path, err)
|
||||
}
|
||||
var levels []string
|
||||
for p := path; p != "/"; p = filepath.Dir(p) {
|
||||
levels = append([]string{filepath.Base(p)}, levels...)
|
||||
parent := filepath.Dir(p)
|
||||
info, err := os.Lstat(parent)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return MissingPath{}, err
|
||||
}
|
||||
return MissingPath{Ancestor: parent, Levels: levels, Info: info}, nil
|
||||
}
|
||||
return MissingPath{}, errors.New("no part of the path is there")
|
||||
}
|
||||
|
||||
// missingAccess is whether r's path is missing below an ancestor that is a directory: the access waits.
|
||||
func missingAccess(path string) (MissingPath, bool) {
|
||||
m, err := NearestAncestor(filepath.Clean(path))
|
||||
if err != nil || !m.Info.IsDir() {
|
||||
return MissingPath{}, false
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
// missingAccesses is every declared access whose path is missing below a directory, by path, with its id: what
|
||||
// no container mounts until it is made. A path the guard refuses is not read (issue 339).
|
||||
func missingAccesses(resources []declaration.Resource, refused map[string]error) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, r := range resources {
|
||||
a, ok := r.(*declaration.Access)
|
||||
if !ok || refused[filepath.Clean(a.Path)] != nil {
|
||||
continue
|
||||
}
|
||||
if _, missing := missingAccess(a.Path); missing {
|
||||
out[filepath.Clean(a.Path)] = a.ID
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mountsMissing is the access a container waits for: one whose path it mounts, or mounts below, is missing.
|
||||
func mountsMissing(c *declaration.Container, missing map[string]string) string {
|
||||
for _, v := range c.Volumes {
|
||||
src := mountSource(v)
|
||||
if !strings.HasPrefix(src, "/") {
|
||||
continue
|
||||
}
|
||||
for path, id := range missing {
|
||||
if atOrBelow(filepath.Clean(src), path) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WaitingOnAccess is every container of the declaration that waits for a missing accessed path, read now: not
|
||||
// created by the apply, so not judged alive or dead and not fired on a schedule — the access says why.
|
||||
func WaitingOnAccess(d *declaration.Declaration) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if d == nil {
|
||||
return out
|
||||
}
|
||||
missing := missingAccesses(d.Resources, refusedPlaces(d.Resources))
|
||||
if len(missing) == 0 {
|
||||
return out
|
||||
}
|
||||
for _, r := range d.Resources {
|
||||
if c, ok := r.(*declaration.Container); ok && mountsMissing(c, missing) != "" {
|
||||
out[c.ID] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MissingWords is the outcome of an access whose path is missing below a directory.
|
||||
func MissingWords(path string) string {
|
||||
return fmt.Sprintf("%s is not there yet, below a folder that is: nothing mounts it and the containers that do are "+
|
||||
"not created until it is made — on the operator's warrant, by the create act (novox/hq ADR 0324)", path)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The share act on a read-write access's path (novox/hq ADR 0322 §6, issue 567).
|
||||
//
|
||||
// **The one change the mesh makes on an accessed path, and only on a warrant.** ADR 0312 §2 changes nothing on
|
||||
// an accessed path. When the identities that write one path are more than one account, they share it only
|
||||
// through its group: the group may write it, and every folder carries setgid, so a folder one of them makes keeps
|
||||
// the group and the next can write in it too (§3). The operator lets that happen by approving a warrant for it
|
||||
// (ADR 0259); the controller then asks this engine, signed, to run the act. The act adds group write to the path
|
||||
// and every file and folder under it, and setgid to every folder: it changes no owner, no group and no other bit,
|
||||
// follows no link, enters no other filesystem, and runs only for the path's own group, never a root group's.
|
||||
//
|
||||
// **Walked by descriptor.** The path is opened as a mesh directory is (meshWalk: no link another account could
|
||||
// make, the path 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 path's is not entered, and an entry
|
||||
// opened is checked to be the one that was looked at. A file is changed through its own descriptor, so a name
|
||||
// swapped for a link between the look and the change changes nothing.
|
||||
|
||||
// ShareLimits bound one walk under an access's path: how many entries it reads, and for how long.
|
||||
type ShareLimits struct {
|
||||
Entries int
|
||||
Time time.Duration
|
||||
}
|
||||
|
||||
// ShareActLimits bound the act: a media library is millions of files, and an act that never ends is a machine
|
||||
// that cannot say what it did. One stopped is asked again and goes on: what has the bits already is kept.
|
||||
var ShareActLimits = ShareLimits{Entries: 5_000_000, Time: 30 * time.Minute}
|
||||
|
||||
// ShareCounts is what one walk did or found. Counts only: never a path below the access.
|
||||
type ShareCounts struct {
|
||||
// Folders and Files are the entries changed; Kept the ones that had the bits already.
|
||||
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
|
||||
}
|
||||
|
||||
// ErrShareBound is a walk that met its bound before it was through: what it did stays, what it did not reach is
|
||||
// not known.
|
||||
var ErrShareBound = errors.New("the walk met its bound")
|
||||
|
||||
// SharedVerdict is the read-only judgement of a shared path (ADR 0322 §3).
|
||||
type SharedVerdict struct {
|
||||
// Shared is that the path may be written by its group and it and every folder under it carry setgid.
|
||||
Shared bool
|
||||
// Why is what keeps it from being shared, in words with no path.
|
||||
Why string
|
||||
Counts ShareCounts
|
||||
}
|
||||
|
||||
// ShareRefusal is why the act is never run for gid on a path whose group is pathGID, or nil.
|
||||
func ShareRefusal(pathGID, gid int) error {
|
||||
if gid != pathGID {
|
||||
return fmt.Errorf("the share act runs for the path's own group, gid %d, and was asked for gid %d; nothing "+
|
||||
"was changed", pathGID, gid)
|
||||
}
|
||||
g := PathGroup{GID: pathGID}
|
||||
if found, err := user.LookupGroupId(strconv.Itoa(pathGID)); err == nil {
|
||||
g.Name = found.Name
|
||||
}
|
||||
if err := GrantRefusal(g); err != nil {
|
||||
return fmt.Errorf("the share act never runs for this group: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shareablePath refuses a path that is not stated exactly: absolute and clean, so `..` and a doubled separator
|
||||
// never name it.
|
||||
func shareablePath(path string) error {
|
||||
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
|
||||
return fmt.Errorf("%q is not an access's path as the engine states it (absolute, no `..`, no doubled or "+
|
||||
"trailing separator); nothing was changed", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
//go:build linux
|
||||
|
||||
package apply
|
||||
|
||||
// The descriptor half of share.go (novox/hq ADR 0322 §3, §6).
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// shareStats is how a walk reads an entry and an open descriptor: the machine's own calls, or a test's stand-in
|
||||
// for a device it cannot mount.
|
||||
type shareStats struct {
|
||||
at func(dirfd int, name string, st *unix.Stat_t) error
|
||||
fd func(fd int, st *unix.Stat_t) error
|
||||
}
|
||||
|
||||
var machineStats = shareStats{
|
||||
at: func(dirfd int, name string, st *unix.Stat_t) error {
|
||||
return unix.Fstatat(dirfd, name, st, unix.AT_SYMLINK_NOFOLLOW)
|
||||
},
|
||||
fd: unix.Fstat,
|
||||
}
|
||||
|
||||
// SharePath runs the share act on path for gid (ADR 0322 §6): group write on it and on every file and folder
|
||||
// under it, setgid on every folder, nothing else. The path's group must be gid and never a root group's; a
|
||||
// walk stopped at its bound returns what it did with ErrShareBound.
|
||||
func SharePath(path string, gid int, limits ShareLimits) (ShareCounts, error) {
|
||||
return sharePath(path, gid, limits, machineStats)
|
||||
}
|
||||
|
||||
func sharePath(path string, gid int, limits ShareLimits, stats shareStats) (ShareCounts, error) {
|
||||
w, fd, err := openShared(path, limits, stats)
|
||||
if err != nil {
|
||||
return ShareCounts{}, err
|
||||
}
|
||||
w.change = true
|
||||
var st unix.Stat_t
|
||||
if err := stats.fd(fd, &st); err != nil {
|
||||
unix.Close(fd)
|
||||
return ShareCounts{}, fmt.Errorf("the path cannot be read: %v; nothing was changed", err)
|
||||
}
|
||||
if err := ShareRefusal(int(st.Gid), gid); err != nil {
|
||||
unix.Close(fd)
|
||||
return ShareCounts{}, err
|
||||
}
|
||||
err = w.dir(fd, st)
|
||||
return w.counts, err
|
||||
}
|
||||
|
||||
// JudgeShared reads whether path is shared (ADR 0322 §3): its group may write it, and it and every folder under
|
||||
// it carry setgid. Reads only, and stops at the first folder without setgid; one met its bound returns
|
||||
// ErrShareBound, and the verdict is then not known.
|
||||
func JudgeShared(path string, limits ShareLimits) (SharedVerdict, error) {
|
||||
return judgeShared(path, limits, machineStats)
|
||||
}
|
||||
|
||||
func judgeShared(path string, limits ShareLimits, stats shareStats) (SharedVerdict, error) {
|
||||
w, fd, err := openShared(path, limits, stats)
|
||||
if err != nil {
|
||||
return SharedVerdict{}, err
|
||||
}
|
||||
var st unix.Stat_t
|
||||
if err := stats.fd(fd, &st); err != nil {
|
||||
unix.Close(fd)
|
||||
return SharedVerdict{}, err
|
||||
}
|
||||
if st.Mode&0o020 == 0 {
|
||||
unix.Close(fd)
|
||||
return SharedVerdict{Why: "its group cannot write it"}, nil
|
||||
}
|
||||
err = w.dir(fd, st)
|
||||
v := SharedVerdict{Counts: w.counts}
|
||||
switch {
|
||||
case errors.Is(err, errNotShared):
|
||||
v.Why = "a folder in it does not keep its group on new folders (no setgid)"
|
||||
return v, nil
|
||||
case err != nil:
|
||||
return v, err
|
||||
}
|
||||
v.Shared = true
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// errNotShared stops a judgement at the first folder without setgid.
|
||||
var errNotShared = errors.New("a folder without setgid")
|
||||
|
||||
// shareWalk is one walk under an access's path, from the descriptor of the path itself.
|
||||
type shareWalk struct {
|
||||
// change is the act; without it the walk reads, and stops at the first folder without setgid.
|
||||
change bool
|
||||
dev uint64
|
||||
deadline time.Time
|
||||
limit int
|
||||
stats shareStats
|
||||
counts ShareCounts
|
||||
}
|
||||
|
||||
// openShared opens path as a mesh directory is opened (no link another account could make, the path itself no
|
||||
// link) and starts a walk from it on the path's own device.
|
||||
func openShared(path string, limits ShareLimits, stats shareStats) (*shareWalk, int, error) {
|
||||
if err := shareablePath(path); err != nil {
|
||||
return nil, -1, err
|
||||
}
|
||||
mw := &meshWalk{want: path}
|
||||
fd, err := mw.open()
|
||||
if errors.Is(err, errNotThereYet) {
|
||||
return nil, -1, errors.New("the path is not there; nothing was changed")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, -1, fmt.Errorf("the path is not walked: %v", err)
|
||||
}
|
||||
if mw.At != path {
|
||||
unix.Close(fd)
|
||||
return nil, -1, errors.New("the path is reached through a link, and the share act walks no path through " +
|
||||
"one; nothing was changed")
|
||||
}
|
||||
var st unix.Stat_t
|
||||
if err := stats.fd(fd, &st); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, -1, fmt.Errorf("the path cannot be read: %v; nothing was changed", err)
|
||||
}
|
||||
return &shareWalk{dev: uint64(st.Dev), deadline: time.Now().Add(limits.Time), limit: limits.Entries,
|
||||
stats: stats}, fd, nil
|
||||
}
|
||||
|
||||
// read counts one entry, and says the bound when it is met.
|
||||
func (w *shareWalk) read() error {
|
||||
w.counts.Read++
|
||||
if w.counts.Read > w.limit || time.Now().After(w.deadline) {
|
||||
return ErrShareBound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dir is one folder, open on fd, whose stat is st: its own bits, then every entry in it. It closes fd.
|
||||
func (w *shareWalk) dir(fd int, st unix.Stat_t) error {
|
||||
f := os.NewFile(uintptr(fd), "")
|
||||
defer f.Close()
|
||||
if err := w.read(); err != nil {
|
||||
return err
|
||||
}
|
||||
mode := uint32(st.Mode) & 0o7777
|
||||
want := mode | 0o020 | unix.S_ISGID
|
||||
switch {
|
||||
case !w.change && mode&unix.S_ISGID == 0:
|
||||
return errNotShared
|
||||
case !w.change || want == mode:
|
||||
w.counts.Kept++
|
||||
default:
|
||||
if err := unix.Fchmod(fd, want); err != nil {
|
||||
return fmt.Errorf("a folder's mode could not be set: %v", err)
|
||||
}
|
||||
w.counts.Folders++
|
||||
}
|
||||
for {
|
||||
names, err := f.Readdirnames(256)
|
||||
for _, name := range names {
|
||||
if err := w.entry(fd, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if errors.Is(err, io.EOF) || (err == nil && len(names) == 0) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("a folder could not be read: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// entry is one name in the folder open on dirfd: a link passed by, a folder on this device walked, a file
|
||||
// changed through its own descriptor, anything else left.
|
||||
func (w *shareWalk) entry(dirfd int, name string) error {
|
||||
var st unix.Stat_t
|
||||
if err := w.stats.at(dirfd, name, &st); err != nil {
|
||||
if errors.Is(err, unix.ENOENT) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("an entry could not be read: %v", err)
|
||||
}
|
||||
switch st.Mode & unix.S_IFMT {
|
||||
case unix.S_IFLNK:
|
||||
w.counts.Links++
|
||||
return nil
|
||||
case unix.S_IFDIR, unix.S_IFREG:
|
||||
default:
|
||||
w.counts.Other++
|
||||
return nil
|
||||
}
|
||||
if uint64(st.Dev) != w.dev {
|
||||
w.counts.Mounts++
|
||||
return nil
|
||||
}
|
||||
isDir := st.Mode&unix.S_IFMT == unix.S_IFDIR
|
||||
if !isDir && !w.change {
|
||||
// A judgement reads folders alone: setgid is a folder's.
|
||||
return nil
|
||||
}
|
||||
flags := unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_CLOEXEC | unix.O_NOCTTY | unix.O_NONBLOCK
|
||||
if isDir {
|
||||
flags |= unix.O_DIRECTORY
|
||||
}
|
||||
fd, err := unix.Openat(dirfd, name, flags, 0)
|
||||
switch {
|
||||
case errors.Is(err, unix.ENOENT):
|
||||
return nil
|
||||
case errors.Is(err, unix.ELOOP), errors.Is(err, unix.ENOTDIR):
|
||||
// Swapped for a link, or for something else, after it was looked at: passed by.
|
||||
w.counts.Links++
|
||||
return nil
|
||||
case err != nil:
|
||||
return fmt.Errorf("an entry could not be opened: %v", err)
|
||||
}
|
||||
var opened unix.Stat_t
|
||||
if err := w.stats.fd(fd, &opened); err != nil {
|
||||
unix.Close(fd)
|
||||
return fmt.Errorf("an entry could not be read: %v", err)
|
||||
}
|
||||
if opened.Dev != st.Dev || opened.Ino != st.Ino || opened.Mode&unix.S_IFMT != st.Mode&unix.S_IFMT {
|
||||
unix.Close(fd)
|
||||
return errors.New("an entry changed while it was walked, and the act stopped there")
|
||||
}
|
||||
if isDir {
|
||||
return w.dir(fd, opened)
|
||||
}
|
||||
defer unix.Close(fd)
|
||||
if err := w.read(); err != nil {
|
||||
return err
|
||||
}
|
||||
mode := uint32(opened.Mode) & 0o7777
|
||||
if mode&0o020 != 0 {
|
||||
w.counts.Kept++
|
||||
return nil
|
||||
}
|
||||
if err := unix.Fchmod(fd, mode|0o020); err != nil {
|
||||
return fmt.Errorf("a file's mode could not be set: %v", err)
|
||||
}
|
||||
w.counts.Files++
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreatePath runs the create act on a missing accessed path (novox/hq ADR 0324): each missing level made from the
|
||||
// nearest existing ancestor down, each with that ancestor's owner, group and mode, setgid included. 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 while
|
||||
// the act runs stops it. How many levels it made.
|
||||
func CreatePath(path string) (int, error) {
|
||||
return createPath(path, machineStats)
|
||||
}
|
||||
|
||||
func createPath(path string, stats shareStats) (int, error) {
|
||||
m, err := NearestAncestor(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("the path cannot be made: %v; nothing was changed", err)
|
||||
}
|
||||
if !m.Info.IsDir() {
|
||||
return 0, errors.New("its nearest existing ancestor is not a folder (a file or a link), and the create act " +
|
||||
"makes a path 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 ancestor is not walked: %v; nothing was changed", err)
|
||||
}
|
||||
if mw.At != m.Ancestor {
|
||||
unix.Close(fd)
|
||||
return 0, errors.New("the nearest existing ancestor is reached through a link, and the create act makes " +
|
||||
"nothing 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 ancestor cannot be read: %v; nothing was changed", err)
|
||||
}
|
||||
if err := ShareRefusal(int(anc.Gid), int(anc.Gid)); err != nil {
|
||||
unix.Close(fd)
|
||||
return 0, fmt.Errorf("the create act makes nothing as this ancestor's group: %w", err)
|
||||
}
|
||||
mode := uint32(anc.Mode) & 0o7777
|
||||
made := 0
|
||||
for _, name := range m.Levels {
|
||||
// Made with no bits, so it is nobody's until it is the ancestor's.
|
||||
if err := unix.Mkdirat(fd, name, 0); err != nil {
|
||||
unix.Close(fd)
|
||||
if errors.Is(err, unix.EEXIST) {
|
||||
return made, errors.New("a level of the path appeared while the act ran, and the act changes " +
|
||||
"nothing that is there; it stopped there")
|
||||
}
|
||||
return made, fmt.Errorf("a level could not be made: %v", err)
|
||||
}
|
||||
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 made, fmt.Errorf("a level made could not be opened: %v", err)
|
||||
}
|
||||
fd = next
|
||||
made++
|
||||
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 uint64(st.Dev) != uint64(anc.Dev) {
|
||||
unix.Close(fd)
|
||||
return made, errors.New("a level made is on another filesystem than its ancestor, and the act stopped there")
|
||||
}
|
||||
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 ancestor's owner: %v", err)
|
||||
}
|
||||
if err := unix.Fchmod(fd, mode); err != nil {
|
||||
unix.Close(fd)
|
||||
return made, fmt.Errorf("a level made could not be given its ancestor's mode: %v", err)
|
||||
}
|
||||
}
|
||||
unix.Close(fd)
|
||||
return made, nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build !linux
|
||||
|
||||
package apply
|
||||
|
||||
import "errors"
|
||||
|
||||
// SharePath refuses where there is no openat with O_NOFOLLOW (novox/hq ADR 0322 §6). The node-engine runs on Linux.
|
||||
func SharePath(path string, gid int, limits ShareLimits) (ShareCounts, error) {
|
||||
return ShareCounts{}, errors.New("the share act walks only by descriptor, which this system cannot; nothing was changed")
|
||||
}
|
||||
|
||||
// JudgeShared cannot judge where there is no openat with O_NOFOLLOW. The node-engine runs on Linux.
|
||||
func JudgeShared(path string, limits ShareLimits) (SharedVerdict, error) {
|
||||
return SharedVerdict{}, errors.New("a shared path is judged only by descriptor, which this system cannot")
|
||||
}
|
||||
|
||||
// CreatePath refuses where there is no openat with O_NOFOLLOW (novox/hq ADR 0324). The node-engine runs on Linux.
|
||||
func CreatePath(path string) (int, error) {
|
||||
return 0, errors.New("the create act walks only by descriptor, which this system cannot; nothing was changed")
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"os/user"
|
||||
"strconv"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
)
|
||||
|
||||
// An access is judged by who writes it (novox/hq ADR 0322 §2, issue 567).
|
||||
//
|
||||
// ADR 0312 judged a read-write access by its path's group alone: unhealthy whenever the group may not write.
|
||||
// But image software that takes its identity from its settings (PUID, PGID) often owns the path it writes, and
|
||||
// writes it as its owner whatever the group's bits. So the writers are named — each container of the grant with
|
||||
// the identity it declares it runs as, and the grant's account with its own uid and primary gid — and the access
|
||||
// is writable when every one of them may write the path: as its owner when its uid owns it and the owner may
|
||||
// write, or through the group when the group may write (its own gid, or the group the grant gives it). A
|
||||
// container that declares no runs-as is judged by the granted group alone, as before.
|
||||
|
||||
// Writer is one identity that writes a read-write access's path.
|
||||
type Writer struct {
|
||||
// Who names it in words: "the container sonarr.app", "the account sonarr".
|
||||
Who string
|
||||
// Known is that UID and GID are known: a container's runs-as, an account the machine names.
|
||||
Known bool
|
||||
UID, GID int
|
||||
}
|
||||
|
||||
// AccountIDs is an account's uid and primary gid as this machine numbers them. A variable so a test can stand in
|
||||
// for accounts it cannot make.
|
||||
var AccountIDs = func(name string) (int, int, bool) {
|
||||
u, err := user.Lookup(name)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
uid, uerr := strconv.Atoi(u.Uid)
|
||||
gid, gerr := strconv.Atoi(u.Gid)
|
||||
return uid, gid, uerr == nil && gerr == nil
|
||||
}
|
||||
|
||||
// WritersOf is every writer of the read-write access a, read from the declaration it came in: each container the
|
||||
// grant names, with its runs-as when it declares one, and the grant's account.
|
||||
func WritersOf(resources []declaration.Resource, a *declaration.Access) []Writer {
|
||||
if a.Grant == nil || a.Mode != declaration.AccessReadWrite {
|
||||
return nil
|
||||
}
|
||||
runsAs := map[string]string{}
|
||||
for _, r := range resources {
|
||||
if c, ok := r.(*declaration.Container); ok {
|
||||
runsAs[c.ID] = c.RunsAs
|
||||
}
|
||||
}
|
||||
var out []Writer
|
||||
for _, id := range a.Grant.Containers {
|
||||
w := Writer{Who: "the container " + id}
|
||||
if uid, gid, err := declaration.ParseRunsAs(runsAs[id]); runsAs[id] != "" && err == nil {
|
||||
w.Known, w.UID, w.GID = true, uid, gid
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
if a.Grant.Account != "" {
|
||||
w := Writer{Who: "the account " + a.Grant.Account}
|
||||
w.UID, w.GID, w.Known = AccountIDs(a.Grant.Account)
|
||||
out = append(out, w)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CannotWrite is every writer that may not write a path whose owner and group g describes: neither its owner
|
||||
// with the owner's write bit, nor of a group that may write it. A group that may write is given to every writer by
|
||||
// the grant (ADR 0312 §1), so then nobody is left out; the caller has refused a group that is never granted.
|
||||
func CannotWrite(g PathGroup, writers []Writer) []Writer {
|
||||
if g.Writable {
|
||||
return nil
|
||||
}
|
||||
var out []Writer
|
||||
for _, w := range writers {
|
||||
if w.Known && w.UID == g.UID && g.OwnerWritable {
|
||||
continue
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1207,6 +1207,25 @@ type Container struct {
|
||||
// Health is how this resource is ready (novox/hq ADR 0240 rule 2, Phase B): one kind and its
|
||||
// timing, judged by the node-engine beside liveness. Absent: judged alive or not, and nothing more.
|
||||
Health *Health `json:"health,omitempty"`
|
||||
|
||||
// RunsAs is the identity the container's code writes as, "<uid>:<gid>", resolved by the controller
|
||||
// from the module's settings like any value (novox/hq ADR 0322 §1): image software that takes its
|
||||
// identity from its environment (PUID and PGID) runs as neither the runtime's user nor anything the
|
||||
// engine could read off the image. Only judged by: a read-write access is writable for it when its
|
||||
// uid owns the path or its gid is the path's group (§2). Never passed to the runtime and not part of
|
||||
// the spec, so declaring it recreates nothing. Absent: judged by the granted group alone (ADR 0312 §1).
|
||||
RunsAs string `json:"runs-as,omitempty"`
|
||||
}
|
||||
|
||||
// ParseRunsAs reads a container's runs-as, "<uid>:<gid>", as two non-negative numbers.
|
||||
func ParseRunsAs(s string) (uid, gid int, err error) {
|
||||
u, g, ok := strings.Cut(s, ":")
|
||||
uid, uerr := strconv.Atoi(u)
|
||||
gid, gerr := strconv.Atoi(g)
|
||||
if !ok || uerr != nil || gerr != nil || uid < 0 || gid < 0 || strings.ContainsAny(s, "+ ") {
|
||||
return 0, 0, fmt.Errorf("runs-as is %q, and it is two non-negative numbers, \"<uid>:<gid>\"", s)
|
||||
}
|
||||
return uid, gid, nil
|
||||
}
|
||||
|
||||
func (c *Container) Identity() string { return c.ID }
|
||||
@@ -1289,6 +1308,13 @@ func (c *Container) validate(where string, _ bool) []string {
|
||||
problems = append(problems, where+": networks is for a container that keeps running; a step "+
|
||||
"runs and exits, and joins nothing afterwards")
|
||||
}
|
||||
// Judged as it arrived, after the controller resolved it (novox/hq ADR 0322 §1): a setting that was never
|
||||
// given, or is not a number, is refused here, naming the container, not read as some identity.
|
||||
if c.RunsAs != "" {
|
||||
if _, _, err := ParseRunsAs(c.RunsAs); err != nil {
|
||||
problems = append(problems, where+": container "+c.Name+": "+err.Error())
|
||||
}
|
||||
}
|
||||
return append(problems, checkImage(where, c.Image)...)
|
||||
}
|
||||
|
||||
|
||||
+132
-14
@@ -136,20 +136,27 @@ func AnswerHandOver(check HandOverCheck, handle HandOverFunc, body []byte) HandO
|
||||
// one short line naming who asked, and a path when withPath and none otherwise. The nonce is taken last, after
|
||||
// every other check passed. It answers the ask with who asked trimmed, or why it is refused.
|
||||
func believed(check HandOverCheck, context string, withPath bool, body []byte) (HandOverAsk, string) {
|
||||
ask, _, refused := believedBytes(check, context, withPath, body)
|
||||
return ask, refused
|
||||
}
|
||||
|
||||
// believedBytes is believed with the ask's bytes as signed, for an ask that carries more than a hand-over's
|
||||
// fields: read out of them only once they are believed.
|
||||
func believedBytes(check HandOverCheck, context string, withPath bool, body []byte) (HandOverAsk, []byte, string) {
|
||||
if len(body) > MaxHandOverAsk {
|
||||
return HandOverAsk{}, fmt.Sprintf("the ask is %d bytes, more than an ask", len(body))
|
||||
return HandOverAsk{}, nil, fmt.Sprintf("the ask is %d bytes, more than an ask", len(body))
|
||||
}
|
||||
var signed SignedHandOver
|
||||
if err := json.Unmarshal(body, &signed); err != nil {
|
||||
return HandOverAsk{}, "the ask is not a signed ask: " + err.Error()
|
||||
return HandOverAsk{}, nil, "the ask is not a signed ask: " + err.Error()
|
||||
}
|
||||
if len(check.Signer) != ed25519.PublicKeySize ||
|
||||
!ed25519.Verify(check.Signer, append([]byte(context), signed.Ask...), signed.Signature) {
|
||||
return HandOverAsk{}, "the ask is not signed by the mesh"
|
||||
return HandOverAsk{}, nil, "the ask is not signed by the mesh"
|
||||
}
|
||||
var ask HandOverAsk
|
||||
if err := json.Unmarshal(signed.Ask, &ask); err != nil {
|
||||
return HandOverAsk{}, "the signed ask is not an ask: " + err.Error()
|
||||
return HandOverAsk{}, nil, "the signed ask is not an ask: " + err.Error()
|
||||
}
|
||||
now := time.Now()
|
||||
if check.Now != nil {
|
||||
@@ -163,25 +170,25 @@ func believed(check HandOverCheck, context string, withPath bool, body []byte) (
|
||||
nonce, nonceErr := hex.DecodeString(ask.Nonce)
|
||||
switch {
|
||||
case check.Node == "" || ask.Node != check.Node:
|
||||
return HandOverAsk{}, fmt.Sprintf("the ask is for %q, and this is %q", ask.Node, check.Node)
|
||||
return HandOverAsk{}, nil, fmt.Sprintf("the ask is for %q, and this is %q", ask.Node, check.Node)
|
||||
case ask.Expires.IsZero() || !now.Before(ask.Expires):
|
||||
return HandOverAsk{}, "the ask has expired"
|
||||
return HandOverAsk{}, nil, "the ask has expired"
|
||||
case ask.Expires.After(now.Add(HandOverGood + HandOverSkew)):
|
||||
return HandOverAsk{}, "the ask is good for longer than an ask may be"
|
||||
return HandOverAsk{}, nil, "the ask is good for longer than an ask may be"
|
||||
case nonceErr != nil || len(nonce) < 16:
|
||||
return HandOverAsk{}, "the ask carries no nonce of its own"
|
||||
return HandOverAsk{}, nil, "the ask carries no nonce of its own"
|
||||
case ask.By == "":
|
||||
return HandOverAsk{}, "the ask names nobody as asking"
|
||||
return HandOverAsk{}, nil, "the ask names nobody as asking"
|
||||
case len(ask.By) > MaxHandOverBy || strings.ContainsAny(ask.By, "\r\n\x00"):
|
||||
return HandOverAsk{}, "who asked is not one short line"
|
||||
return HandOverAsk{}, nil, "who asked is not one short line"
|
||||
case withPath && ask.Path == "":
|
||||
return HandOverAsk{}, "the ask names no directory"
|
||||
return HandOverAsk{}, nil, "the ask names no directory"
|
||||
case !withPath && ask.Path != "":
|
||||
return HandOverAsk{}, "the ask names a path, and this ask takes none"
|
||||
return HandOverAsk{}, nil, "the ask names a path, and this ask takes none"
|
||||
case !nonces.take(ask.Nonce, ask.Expires, now):
|
||||
return HandOverAsk{}, "the ask was taken already"
|
||||
return HandOverAsk{}, nil, "the ask was taken already"
|
||||
}
|
||||
return ask, ""
|
||||
return ask, signed.Ask, ""
|
||||
}
|
||||
|
||||
// HandOverReply is what a hand-over request is answered with, and whether to answer: a request with no reply
|
||||
@@ -320,3 +327,114 @@ func JudgeDirectoriesReply(check HandOverCheck, handle JudgeDirectoriesFunc, rep
|
||||
type DirectoryJudgements interface {
|
||||
HearDirectoryJudgements(JudgeDirectoriesFunc)
|
||||
}
|
||||
|
||||
// An act on an accessed path asked over the bus (novox/hq ADR 0322 §6, ADR 0324, issue 567).
|
||||
//
|
||||
// An access waits for the operator when its writers are not one account and its path's group cannot write it or
|
||||
// keep its group on new folders (ADR 0322), or when its path is not there (ADR 0324). The operator approves a
|
||||
// warrant for the act that ends the wait (ADR 0259); the controller then 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 path as the engine states it, the warrant and — for the share act — the
|
||||
// path's group. The engine judges every value against what it was last declared and what is on disk, answers at
|
||||
// once that the act started or ran, or why it is refused, and states the act on the access's verdict (Act).
|
||||
|
||||
// AskAccessActSubject is where the controller asks this node's engine to run an act on one access's path.
|
||||
func AskAccessActSubject(node string) string { return "mesh.node." + node + ".ask.access-act" }
|
||||
|
||||
// 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 v1\n"
|
||||
|
||||
// The acts on an accessed path.
|
||||
const (
|
||||
// ActShare adds group write to the path and everything under it, and setgid to every folder (ADR 0322 §6).
|
||||
ActShare = "share"
|
||||
// ActCreate makes a missing path, each level as its nearest existing ancestor is (ADR 0324).
|
||||
ActCreate = "create"
|
||||
)
|
||||
|
||||
// AccessActAsk is an act's own fields, beside a hand-over's (node, path, by, expires, nonce): which act, the
|
||||
// access by id, the warrant the operator approved, and for the share act the path's group by number.
|
||||
type AccessActAsk struct {
|
||||
Act string `json:"act"`
|
||||
Access string `json:"access"`
|
||||
Warrant string `json:"warrant"`
|
||||
GID *int `json:"gid,omitempty"`
|
||||
}
|
||||
|
||||
// AccessAct is one act asked and believed, as the handler is given it.
|
||||
type AccessAct struct {
|
||||
Act, Access, Path, Warrant, By string
|
||||
// GID is the path's group for the share act; -1 for the create act.
|
||||
GID int
|
||||
}
|
||||
|
||||
// MaxActField bounds an access id and a warrant id.
|
||||
const MaxActField = 200
|
||||
|
||||
// AccessActFunc judges and runs one act: the words to answer with, or the refusal.
|
||||
type AccessActFunc func(AccessAct) (string, error)
|
||||
|
||||
func refusedAct(why string) HandOverAnswer {
|
||||
return HandOverAnswer{Refused: why + "; nothing was changed"}
|
||||
}
|
||||
|
||||
// AnswerAccessAct is the engine's judgement of one act ask's bytes: believed as a hand-over is, then the act, the
|
||||
// access, the group and the warrant — **nothing is done without a warrant** — and only then the handler.
|
||||
func AnswerAccessAct(check HandOverCheck, handle AccessActFunc, body []byte) HandOverAnswer {
|
||||
if handle == nil {
|
||||
return refusedAct("this engine runs no act on an accessed path")
|
||||
}
|
||||
ask, signed, refused := believedBytes(check, AccessActContext, true, body)
|
||||
if refused != "" {
|
||||
return refusedAct(refused)
|
||||
}
|
||||
var act AccessActAsk
|
||||
if err := json.Unmarshal(signed, &act); err != nil {
|
||||
return refusedAct("the signed ask is not an act's ask: " + err.Error())
|
||||
}
|
||||
act.Access, act.Warrant = strings.TrimSpace(act.Access), strings.TrimSpace(act.Warrant)
|
||||
switch {
|
||||
case act.Warrant == "":
|
||||
return refusedAct("the ask names no warrant, and an act on an accessed path runs only on one")
|
||||
case act.Access == "":
|
||||
return refusedAct("the ask names no access")
|
||||
case act.Act == ActShare && (act.GID == nil || *act.GID < 0):
|
||||
return refusedAct("the share act names no group")
|
||||
case act.Act == ActCreate && act.GID != nil:
|
||||
return refusedAct("the create act names no group: each level is made as its nearest existing ancestor is")
|
||||
case act.Act != ActShare && act.Act != ActCreate:
|
||||
return refusedAct(fmt.Sprintf("the ask names the act %q, and the acts are %q and %q", act.Act, ActShare, ActCreate))
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
gid := -1
|
||||
if act.GID != nil {
|
||||
gid = *act.GID
|
||||
}
|
||||
said, err := handle(AccessAct{Act: act.Act, Access: act.Access, Path: ask.Path, Warrant: act.Warrant, By: ask.By,
|
||||
GID: gid})
|
||||
if err != nil {
|
||||
return refusedAct(err.Error())
|
||||
}
|
||||
return HandOverAnswer{Said: said}
|
||||
}
|
||||
|
||||
// AccessActReply is what an act request is answered with, and whether to answer, as HandOverReply.
|
||||
func AccessActReply(check HandOverCheck, handle AccessActFunc, reply string, body []byte) ([]byte, bool) {
|
||||
if reply == "" {
|
||||
return nil, false
|
||||
}
|
||||
answer, err := json.Marshal(AnswerAccessAct(check, handle, body))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return answer, true
|
||||
}
|
||||
|
||||
// AccessActs is a link that hears the controller asking this node to run an act on an accessed path. Optional.
|
||||
type AccessActs interface {
|
||||
HearAccessActs(AccessActFunc)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ 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 an act on an access's path (novox/hq ADR 0322 §6, ADR 0324).
|
||||
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).
|
||||
signer ed25519.PublicKey
|
||||
@@ -259,6 +261,24 @@ func (l *natsLink) HearDirectoryJudgements(handle JudgeDirectoriesFunc) {
|
||||
}
|
||||
}
|
||||
|
||||
// HearAccessActs listens for the controller asking this node to run an act on an access's path (novox/hq ADR
|
||||
// 0322 §6, ADR 0324), 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 {
|
||||
return
|
||||
}
|
||||
check := HandOverCheck{Signer: l.signer, Node: l.node}
|
||||
acting, err := l.conn.Subscribe(AskAccessActSubject(l.node), func(msg *nats.Msg) {
|
||||
if body, answer := AccessActReply(check, handle, msg.Reply, msg.Data); answer {
|
||||
_ = msg.Respond(body)
|
||||
}
|
||||
})
|
||||
if err == nil {
|
||||
l.acting = acting
|
||||
}
|
||||
}
|
||||
|
||||
func (l *natsLink) Declarations() <-chan Declaration { return l.arrived }
|
||||
func (l *natsLink) AskedToReport() <-chan struct{} { return l.asked }
|
||||
func (l *natsLink) Lost() <-chan error { return l.lost }
|
||||
@@ -276,6 +296,9 @@ func (l *natsLink) Close() {
|
||||
if l.searching != nil {
|
||||
_ = l.searching.Unsubscribe()
|
||||
}
|
||||
if l.acting != nil {
|
||||
_ = l.acting.Unsubscribe()
|
||||
}
|
||||
if l.judging != nil {
|
||||
_ = l.judging.Unsubscribe()
|
||||
}
|
||||
|
||||
@@ -258,6 +258,12 @@ const RootContract = 3
|
||||
// know.
|
||||
const GrantContract = 4
|
||||
|
||||
// RunsAsContract is the statement of a host that also reads a container's `runs-as` (novox/hq ADR 0322): it judges
|
||||
// a read-write access by who writes it, states one not writable or not shared as waiting with a wait of kind access,
|
||||
// and hears the share act. Like the fields before it, its presence is what tells the controller this host may be
|
||||
// sent `runs-as`; an older host refuses the whole declaration for a field it does not know.
|
||||
const RunsAsContract = 5
|
||||
|
||||
// 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.
|
||||
@@ -413,6 +419,63 @@ 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 0322 §5): the share act on it. 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 act on an access's path (ADR 0322 §6, ADR 0324): which, running, done, stopped at its bound
|
||||
// or failed, under which warrant. Absent on every other verdict, and on an access never acted on.
|
||||
Act *AccessActRecord `json:"act,omitempty"`
|
||||
}
|
||||
|
||||
// WaitKindAccess is the kind of a wait on the share act of a read-write access (novox/hq ADR 0322 §5).
|
||||
const WaitKindAccess = "access"
|
||||
|
||||
// AccessWait is one access waiting for the operator to let its path's group write it: the access by module and
|
||||
// id, the part, and what the operator gives, in words with no path (ADR 0234 §6).
|
||||
type AccessWait struct {
|
||||
// Kind is WaitKindAccess.
|
||||
Kind string `json:"kind"`
|
||||
Module string `json:"module"`
|
||||
Access string `json:"access"`
|
||||
Part string `json:"part"`
|
||||
What string `json:"what"`
|
||||
}
|
||||
|
||||
// The states of an act on an accessed path.
|
||||
const (
|
||||
ActRunning = "running"
|
||||
ActDone = "done"
|
||||
// ActStopped is a share act that met its bound in entries or time: what it changed stays, and what it did not
|
||||
// reach is not changed. Asked again, it goes on.
|
||||
ActStopped = "stopped"
|
||||
ActFailed = "failed"
|
||||
)
|
||||
|
||||
// AccessActRecord is one act on an access's path, as the engine records it (novox/hq ADR 0322 §6, ADR 0324).
|
||||
// Counts only: never a path.
|
||||
type AccessActRecord struct {
|
||||
// Act is ActShare or ActCreate.
|
||||
Act string `json:"act"`
|
||||
Access string `json:"access"`
|
||||
Warrant string `json:"warrant"`
|
||||
By string `json:"by"`
|
||||
GID int `json:"gid"`
|
||||
State string `json:"state"`
|
||||
Started time.Time `json:"started"`
|
||||
Ended time.Time `json:"ended,omitzero"`
|
||||
// Made is how many levels the create act made. Folders and Files are how many the share act changed; Kept
|
||||
// how many already had the bits; Links the links it passed by unfollowed; Other the entries it left that are
|
||||
// neither a file nor a folder; Mounts the folders on another filesystem it did not enter.
|
||||
Made int `json:"made,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// SetuidSearch is how far a running search for setuid programs got, as of its last checkpoint. Counts, times
|
||||
|
||||
@@ -62,6 +62,10 @@ type Queue struct {
|
||||
// JudgeDirectories judges this node's declared directories read-only, asked by the controller over the
|
||||
// link (novox/hq issue 358). Nil answers every such ask with a refusal.
|
||||
JudgeDirectories JudgeDirectoriesFunc
|
||||
// AccessAct judges and runs an act on a read-write access's path — the share act, or making a missing
|
||||
// path — asked by the controller on an approved warrant (novox/hq ADR 0322 §6, ADR 0324). Nil answers every
|
||||
// such ask with a refusal.
|
||||
AccessAct AccessActFunc
|
||||
Say Announce
|
||||
Timeout time.Duration
|
||||
|
||||
|
||||
@@ -252,6 +252,10 @@ 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 an act on an access's path, on a warrant (novox/hq ADR 0322 §6, ADR 0324).
|
||||
if s, ok := link.(AccessActs); ok {
|
||||
s.HearAccessActs(queue.AccessAct)
|
||||
}
|
||||
|
||||
for {
|
||||
// Asked to stop — by standing aside, say — nothing further is taken in.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// ShareActsName is the file, beside the node's state, of the last share act on each read-write access's path
|
||||
// (novox/hq ADR 0322 §6): what was changed, under which warrant, by whom. Its own file for the reason the
|
||||
// hand-overs have theirs: an act runs beside an apply, whose save would lose a record written into the state.
|
||||
const ShareActsName = "share-acts.json"
|
||||
|
||||
// ShareActsPath is that file beside the state at statePath.
|
||||
func ShareActsPath(statePath string) string {
|
||||
return filepath.Join(filepath.Dir(statePath), ShareActsName)
|
||||
}
|
||||
|
||||
// LoadShareActs reads the last act on each access, by access id, as the engine wrote it. None is an empty map; a
|
||||
// file that cannot be read is an error.
|
||||
func LoadShareActs(statePath string) (map[string]json.RawMessage, error) {
|
||||
raw, err := os.ReadFile(ShareActsPath(statePath))
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return map[string]json.RawMessage{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the share acts: %w", err)
|
||||
}
|
||||
out := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("the share acts (%s) are unreadable: %w", ShareActsPath(statePath), err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveShareAct records the last act on one access, atomically, beside every other access's.
|
||||
func SaveShareAct(statePath, access string, act json.RawMessage) error {
|
||||
all, err := LoadShareActs(statePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all[access] = act
|
||||
raw, err := json.MarshalIndent(all, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := ShareActsPath(statePath)
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".share-acts-*.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(), path)
|
||||
}
|
||||
Reference in New Issue
Block a user