Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2823238de5 | ||
|
|
ea7566806e | ||
|
|
db9e9c5b87 | ||
|
|
a23b190747 | ||
|
|
e737e13bbb |
@@ -47,10 +47,14 @@ type stalledLine struct {
|
||||
// issue 443): a time inside a finished sentence cannot be said again in another zone. A mesh-delivery from before
|
||||
// says none, and Waiting is said as it reads.
|
||||
Checks []waitingCheck `json:"checks,omitempty"`
|
||||
For string `json:"for"`
|
||||
Bound string `json:"bound"`
|
||||
H2 string `json:"h2"`
|
||||
Says string `json:"says"`
|
||||
// Held is the state of the delivery of a line of the state `unanswered`, ready or rejected, when one is held
|
||||
// (novox/hq issues 441, 451): its check answered once, so a recheck is the step. Empty for a head with no
|
||||
// delivery, and from a mesh-delivery that does not say it yet; the words are never read for it.
|
||||
Held string `json:"held,omitempty"`
|
||||
For string `json:"for"`
|
||||
Bound string `json:"bound"`
|
||||
H2 string `json:"h2"`
|
||||
Says string `json:"says"`
|
||||
}
|
||||
|
||||
// waitingCheck is one merge check a stalled line waits on, as mesh-delivery gives it: its context, its state —
|
||||
|
||||
@@ -885,6 +885,11 @@ func stalledWords(l stalledLine, o conditions.Observation) (headline, explanatio
|
||||
// within its bound (novox/hq issue 438): mesh-delivery holds no delivery of it, so there is nothing to stop, release
|
||||
// or close, and no action is offered. The operator's acts are a new commit, which asks the check again, or a look
|
||||
// at the build queue, where a check that never ran may still wait.
|
||||
//
|
||||
// A head whose delivery is held in ready or rejected is said the same way (novox/hq issue 441), and mesh-delivery
|
||||
// names that state as data (Held). Its check answered once, so the build queue is the wrong place to look: the step
|
||||
// is a recheck of the delivery, named by its id, which asks the check again (novox/hq issue 451). It is advised, not
|
||||
// offered as an action, since the controller is not granted the recheck.
|
||||
func unansweredWords(l stalledLine, o conditions.Observation) (headline, explanation, resolved, needs string,
|
||||
actions []conditions.Action) {
|
||||
repository, _, _ := strings.Cut(l.ID, "@")
|
||||
@@ -899,13 +904,23 @@ func unansweredWords(l stalledLine, o conditions.Observation) (headline, explana
|
||||
if d, err := time.ParseDuration(l.Bound); err == nil {
|
||||
limit = ", past its limit of " + humanDuration(d)
|
||||
}
|
||||
which := "Its merge check"
|
||||
check := "merge check"
|
||||
if said := uncheckedWaitWords(l); len(said) > 0 {
|
||||
which = "Its merge check (" + strings.Join(said, ", ") + ")"
|
||||
check = "merge check (" + strings.Join(said, ", ") + ")"
|
||||
}
|
||||
if o.Resolver == conditions.ResolverOperator {
|
||||
needs = "push a new commit to its branch, which asks the check again, or see whether its check still waits " +
|
||||
"in the build queue: mesh-controller.queue."
|
||||
which := "Its " + check
|
||||
switch l.Held {
|
||||
case "ready", "rejected":
|
||||
which = "Its delivery is " + l.Held + ", but on the forge its " + check
|
||||
if o.Resolver == conditions.ResolverOperator {
|
||||
needs = "recheck its delivery, which asks its check again — mesh-delivery.recheck with id " + l.ID +
|
||||
" and why — or push a new commit to its branch, " + FromMeshMCPServer
|
||||
}
|
||||
default:
|
||||
if o.Resolver == conditions.ResolverOperator {
|
||||
needs = "push a new commit to its branch, which asks the check again, or see whether its check still " +
|
||||
"waits in the build queue: mesh-controller.queue."
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s's merge check has not answered %s", pull, long),
|
||||
fmt.Sprintf("%s is open on a branch that requires the merge check. %s was asked and has not answered %s%s. "+
|
||||
|
||||
@@ -452,3 +452,49 @@ func TestAnUnansweredMergeChecksTimeIsSaidInLocalTime(t *testing.T) {
|
||||
t.Fatalf("a pending check without its time reads %q", o.Explanation)
|
||||
}
|
||||
}
|
||||
|
||||
// **An unanswered merge check on a ready or rejected delivery is told to recheck it** (novox/hq issue 451):
|
||||
// mesh-delivery says such a line too (issue 441) and names the delivery's state as data, `held`. That check already
|
||||
// answered once, so the build queue is the wrong place to look; a recheck of the delivery, named by its id, asks the
|
||||
// check again. A line with no delivery held keeps the advice of issue 438, and so does a line from a mesh-delivery
|
||||
// that does not say `held` yet: the controller never guesses the state from the words.
|
||||
func TestAnUnansweredMergeCheckOnAReadyOrRejectedDeliveryOffersARecheck(t *testing.T) {
|
||||
line := func(held string) stalledLine {
|
||||
var l stalledLine
|
||||
raw := `{"id":"novox/mesh-controller@c11222026a3b","number":212,"state":"unanswered",` +
|
||||
`"waiting":["mesh/merge-gate pending since 2026-10-11T00:11:39Z"],"for":"1h1m0s","bound":"1h0m0s",` +
|
||||
`"h2":"none: its delivery is ` + held + `, a state the operator moves — the operator's",` +
|
||||
`"says":"novox/mesh-controller#212 is open on main, which requires the merge check, and its delivery is ` +
|
||||
held + ` here, but on the forge its check has not answered: mesh/merge-gate pending since ` +
|
||||
`2026-10-11T00:11:39Z. The pull request waits on that status and nothing asks it again. A recheck of the ` +
|
||||
`delivery asks its check again, as does a new commit on its branch"`
|
||||
if held != "" {
|
||||
raw += `,"held":"` + held + `"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw+`}`), &l); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
for _, held := range []string{"ready", "rejected"} {
|
||||
o := stalledObservations([]stalledLine{line(held)})[0]
|
||||
t.Logf("%s: %s\nneeds: %s", held, o.Explanation, o.Needs)
|
||||
if !strings.Contains(o.Needs, "mesh-delivery.recheck") || !strings.Contains(o.Needs, "novox/mesh-controller@c11222026a3b") {
|
||||
t.Fatalf("a %s delivery's unanswered check is not offered its recheck: %q", held, o.Needs)
|
||||
}
|
||||
if strings.Contains(o.Needs, "build queue") || strings.Contains(o.Needs, "mesh-controller.queue") {
|
||||
t.Fatalf("a %s delivery's unanswered check, which answered once, is sent to the build queue: %q", held, o.Needs)
|
||||
}
|
||||
if !strings.Contains(o.Explanation, "Its delivery is "+held) {
|
||||
t.Fatalf("a %s delivery's explanation does not say its state: %q", held, o.Explanation)
|
||||
}
|
||||
if len(o.Actions) != 0 {
|
||||
t.Fatalf("it offers an action the controller is not granted: %+v", o.Actions)
|
||||
}
|
||||
}
|
||||
// No delivery held, or a mesh-delivery that does not say: the advice of issue 438, and no recheck.
|
||||
o := stalledObservations([]stalledLine{line("")})[0]
|
||||
if !strings.Contains(o.Needs, "in the build queue: mesh-controller.queue") || strings.Contains(o.Needs, "recheck") {
|
||||
t.Fatalf("a line with no delivery held says to %q", o.Needs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -918,14 +918,9 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
grantsGroups, err := engineGrantsGroups(ctx, inv, node)
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
return catalogue.Rendering{
|
||||
ReadsHealth: readsHealth,
|
||||
JudgesRoot: judgesRoot,
|
||||
GrantsGroups: grantsGroups,
|
||||
BusMembership: memberships[node],
|
||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports,
|
||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||
@@ -958,16 +953,6 @@ func engineJudgesRoot(ctx context.Context, inv *inventory.Inventory, node string
|
||||
return had && stated.Contract >= link.RootContract, nil
|
||||
}
|
||||
|
||||
// engineGrantsGroups says whether a machine's node-engine reads an access's `grant` (novox/hq ADR 0312), by its own
|
||||
// newest statement, for the same reason as engineReadsHealth: an older engine parses strictly.
|
||||
func engineGrantsGroups(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) {
|
||||
stated, had, err := inv.HealthOf(ctx, node)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return had && stated.Contract >= link.GrantContract, nil
|
||||
}
|
||||
|
||||
// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR
|
||||
// 0199): the zone settled from that node's settings, the node's private address, the port the
|
||||
// answering listen is published on there.
|
||||
|
||||
@@ -344,3 +344,58 @@ func TestAnyKeyOfAModuleWithATrustedMergeableFileIsRefusedThroughAVerb(t *testin
|
||||
t.Fatalf("a file marked untrusted through the verb: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Who owns a module's directory is said at the terminal alone (novox/hq issue 279): a directory's owner may name
|
||||
// the settings the module runs as, root chowns the folder to them at the next push, and through the settings verb
|
||||
// any caller could have set puid to 0 or to its own uid. The key is refused through every verb route even where a
|
||||
// file nothing trusts also asks for it, and taken at the terminal; the layer a refused call meets is unchanged.
|
||||
func TestADirectoryOwnersKeyIsRefusedThroughAVerb(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "sonarr", Version: "1",
|
||||
Resources: []map[string]any{
|
||||
{"id": "recycle-bin", "type": "directory", "mode": "0755", "owner": "${setting:puid}:${setting:pgid}"},
|
||||
{"id": "identity", "type": "file", "path": "/etc/sonarr.env", "mode": "0644", "trusted": false,
|
||||
"content": "PUID=${setting:puid}\nPGID=${setting:pgid}\nX=${setting:x}\n"}}})
|
||||
if err := atTheTerminal(t, "settings", "set", "sonarr", `{"puid":1001,"pgid":2000,"x":0}`,
|
||||
"--node", "anchor"); err != nil {
|
||||
t.Fatalf("the owner at the terminal: %v", err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "anchor", "sonarr"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layer := func() string {
|
||||
t.Helper()
|
||||
values, _, err := open.inventory.Layer(ctx, "anchor", "sonarr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(values)
|
||||
return string(raw)
|
||||
}
|
||||
kept := layer()
|
||||
refused := func(what string, err error) {
|
||||
t.Helper()
|
||||
if err == nil || !strings.Contains(err.Error(), "controller's terminal") {
|
||||
t.Fatalf("%s: %v", what, err)
|
||||
}
|
||||
}
|
||||
refused("puid 0 through the verb", throughVerb(t, "settings", map[string]any{"module": "sonarr",
|
||||
"node": "anchor", "values": `{"puid":0,"pgid":2000,"x":0}`}))
|
||||
refused("pgid through the verb, mesh-wide", throughVerb(t, "settings", map[string]any{"module": "sonarr",
|
||||
"values": `{"pgid":0}`}))
|
||||
refused("cleared through the verb", throughVerb(t, "settings", map[string]any{"module": "sonarr",
|
||||
"node": "anchor", "clear": "true"}))
|
||||
if err := throughVerb(t, "command", map[string]any{
|
||||
"command": `settings set sonarr '{"puid":0,"pgid":2000,"x":0}' --node anchor`}); err == nil {
|
||||
t.Fatal("the command verb set the owner")
|
||||
}
|
||||
if got := layer(); got != kept {
|
||||
t.Fatalf("a refused call changed the layer: %s, was %s", got, kept)
|
||||
}
|
||||
// Another key, the owner kept, is still the verb's.
|
||||
if err := throughVerb(t, "settings", map[string]any{"module": "sonarr", "node": "anchor",
|
||||
"values": `{"puid":1001,"pgid":2000,"x":1}`}); err != nil {
|
||||
t.Fatalf("another key through the verb, the owner kept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,4 +35,4 @@ require (
|
||||
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
||||
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
||||
// `go mod vendor` fails loudly, at once, everywhere.
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3 h1:VpH8CesuBOo7k8GcXZq1eRUfbvUoktfmcUynI5N3IFQ=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f h1:8N5OW2mdTNIck2pe4EciTYX5NsrPsHrTLENGNIWYNTU=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
|
||||
@@ -246,11 +246,6 @@ type Rendering struct {
|
||||
// statement's contract is link.RootContract or later). To an older, strict engine the field is not
|
||||
// sent, and the account it names is not judged — which the self-check says, as not judged.
|
||||
JudgesRoot bool
|
||||
|
||||
// GrantsGroups says this machine's node-engine reads an access's `grant` (novox/hq ADR 0312: its statement's
|
||||
// contract is link.GrantContract or later). To an older, strict engine the field is not sent, nor the
|
||||
// runtime's restarts on it, and a read-write access is reached as before: by whoever owns the path.
|
||||
GrantsGroups bool
|
||||
}
|
||||
|
||||
// RootField is a user resource's field saying the account must never become root without a person
|
||||
@@ -548,9 +543,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
|
||||
var out []map[string]any
|
||||
// The read-write accesses each module grants its tools' account, by module (novox/hq ADR 0312): the runtime
|
||||
// that runs as that account is restarted on them.
|
||||
grantedToTools := map[string][]string{}
|
||||
for _, m := range r.Modules {
|
||||
if with.Adopted && m.Filtering != nil {
|
||||
// Nothing of a module that loads a filter, on an adopted node: its table would drop
|
||||
@@ -1123,13 +1115,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
out = append(out, copied)
|
||||
}
|
||||
|
||||
// **A read-write access grants its path's group to what runs this module's code** (novox/hq ADR 0312):
|
||||
// the containers composed above that mount it, and the account its tools run as. Only to an engine that
|
||||
// reads the field.
|
||||
if with.GrantsGroups {
|
||||
grantedToTools[m.Module] = grantAccesses(out, owner, m.Module, r.toolsAccount(m, with))
|
||||
}
|
||||
|
||||
// **What only the mesh knows, where this module asked for it.** The graph is the control
|
||||
// plane's; making a name resolve is the module's software. Emitted as ordinary files under
|
||||
// this module's name, so they are applied, reported and removed exactly as anything else
|
||||
@@ -1156,12 +1141,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
owner[fmt.Sprint(process["id"])] = RuntimeModule
|
||||
out = append(out, process)
|
||||
// Restarted when an access moved its account's groups, so its process holds them (novox/hq ADR 0312 §6).
|
||||
for _, m := range r.Modules {
|
||||
if m.RunsAs == "" {
|
||||
restartOnGrants(process, grantedToTools[m.Module])
|
||||
}
|
||||
}
|
||||
// And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8).
|
||||
owns, err := r.ownRuntimes(with)
|
||||
if err != nil {
|
||||
@@ -1170,7 +1149,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
for _, p := range owns {
|
||||
id := fmt.Sprint(p["id"])
|
||||
owner[id] = strings.TrimSuffix(id, "."+OwnRuntimeID())
|
||||
restartOnGrants(p, grantedToTools[owner[id]])
|
||||
out = append(out, p)
|
||||
}
|
||||
// What each module's bundles are given is read as the account the runtime runs as — not what a
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A read-write access grants the path's own group to what runs the module's code (novox/hq ADR 0312, issue 537).
|
||||
//
|
||||
// **The controller says who; the node-engine reads which group.** Which group owns the operator's path is a fact
|
||||
// of the machine the controller cannot know, and the path is never changed (§2): so each read-write access
|
||||
// reaches a machine naming the identities its module's code runs as, and the node-engine reads the path's group
|
||||
// at apply and grants it to them. Two kinds of identity run a module's code here:
|
||||
//
|
||||
// - each of its containers that mounts the path — the source of one of its volumes is the path, or below it —
|
||||
// which gains the group as a supplementary group of its process and is recreated with it;
|
||||
// - the account its tools run as, where it has a tools bundle the machine's runtime loads: the operator's
|
||||
// account the runtime runs as, or the module's own account for a module served by a runtime of its own
|
||||
// (ADR 0259 §8). That runtime names the access under its `restart-on`, so it is restarted when the access
|
||||
// moved the account's groups, and its new process holds the group with no new login (§6).
|
||||
//
|
||||
// A read access grants nothing (§3): it is sent as before. A read-write access whose module runs nothing here is
|
||||
// sent an empty grant, so the node-engine still states it unhealthy while its group cannot write (§2).
|
||||
//
|
||||
// **Only to an engine that reads it.** An older node-engine parses strictly and refuses a whole declaration for a
|
||||
// field it does not know; the field and the restarts it brings are composed only where the engine's statement
|
||||
// says link.GrantContract or later (Rendering.GrantsGroups).
|
||||
|
||||
// GrantField is an access's field naming who its path's group is granted to (novox/hq ADR 0312).
|
||||
const GrantField = "grant"
|
||||
|
||||
// toolsAccount is the account module m's tools run as on this machine, or empty where they run nowhere as one:
|
||||
// no runtime here, no bundle it loads, a module left out of an adopted machine's declaration, or a runtime
|
||||
// running as root on a machine with no account.
|
||||
func (r Resolution) toolsAccount(m Manifest, with Rendering) string {
|
||||
if !r.runtimeHere() || (with.Adopted && m.Filtering != nil) {
|
||||
return ""
|
||||
}
|
||||
loads := false
|
||||
for _, b := range m.Bundles {
|
||||
if len(b.Loads) > 0 {
|
||||
loads = true
|
||||
}
|
||||
}
|
||||
if !loads {
|
||||
return ""
|
||||
}
|
||||
if m.RunsAs != "" {
|
||||
return m.RunsAs
|
||||
}
|
||||
return r.Account
|
||||
}
|
||||
|
||||
// grantAccesses writes the grant of every read-write access of module, among the resources composed so far: the
|
||||
// module's containers that mount the access's path, and account when it is not empty. The ids of the accesses
|
||||
// that grant to account, for the runtime that runs as it to be restarted on.
|
||||
func grantAccesses(out []map[string]any, owner map[string]string, module, account string) []string {
|
||||
var containers []map[string]any
|
||||
for _, res := range out {
|
||||
if res["type"] == "container" && owner[fmt.Sprint(res["id"])] == module {
|
||||
containers = append(containers, res)
|
||||
}
|
||||
}
|
||||
var toAccount []string
|
||||
for _, res := range out {
|
||||
if res["type"] != "access" || owner[fmt.Sprint(res["id"])] != module || res["mode"] != AccessReadWrite {
|
||||
continue
|
||||
}
|
||||
path, _ := res["path"].(string)
|
||||
grant := map[string]any{}
|
||||
var mounting []any
|
||||
for _, c := range containers {
|
||||
if mounts(c, path) {
|
||||
mounting = append(mounting, fmt.Sprint(c["id"]))
|
||||
}
|
||||
}
|
||||
if len(mounting) > 0 {
|
||||
grant["containers"] = mounting
|
||||
}
|
||||
if account != "" {
|
||||
grant["account"] = account
|
||||
toAccount = append(toAccount, fmt.Sprint(res["id"]))
|
||||
}
|
||||
res[GrantField] = grant
|
||||
}
|
||||
sort.Strings(toAccount)
|
||||
return toAccount
|
||||
}
|
||||
|
||||
// mounts says whether a container mounts path, or something below it, from the machine. A path that merely
|
||||
// begins with the same letters — /storage/media2 beside /storage/media — is another path. A read-only mount of a
|
||||
// read-write access still counts: the module declared the access read-write, the engine grants the group to what
|
||||
// mounts it, and a read-only mount only means that container chose not to write through it.
|
||||
func mounts(container map[string]any, path string) bool {
|
||||
volumes, _ := container["volumes"].([]any)
|
||||
path = filepath.Clean(path)
|
||||
for _, v := range volumes {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
source, _, _ := strings.Cut(s, ":")
|
||||
if !strings.HasPrefix(source, "/") {
|
||||
continue // a named volume: the runtime's, not the operator's path
|
||||
}
|
||||
source = filepath.Clean(source)
|
||||
if source == path || strings.HasPrefix(source, path+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// restartOnGrants adds to a runtime process's restart-on the accesses that grant to the account it runs as, so it
|
||||
// is restarted to hold a group the account was put in or taken out of (novox/hq ADR 0312 §6).
|
||||
func restartOnGrants(process map[string]any, ids []string) {
|
||||
if process == nil || len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
restarts, _ := process["restart-on"].([]any)
|
||||
seen := map[string]bool{}
|
||||
for _, id := range restarts {
|
||||
seen[fmt.Sprint(id)] = true
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !seen[id] {
|
||||
restarts = append(restarts, id)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
process["restart-on"] = restarts
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/validate"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0312 and issue 537 on the controller's side: each read-write access reaches the machine
|
||||
// naming who its path's group is granted to — the module's containers that mount it, and the account its tools
|
||||
// run as — and the tool runner is restarted on it; a read access grants nothing; and none of it is sent to a
|
||||
// node-engine older than the field, which would refuse the whole declaration.
|
||||
|
||||
const anImage = "x@sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
|
||||
// anImporter files into one library it writes and reads another: a container mounting both, a step mounting
|
||||
// neither, and tools served by the machine's runtime.
|
||||
func anImporter(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
m := aToolsModule(t, "importer", "tools/index.js")
|
||||
m.Accesses = []Access{
|
||||
{ID: "music", Path: "/storage/media/music", Mode: AccessReadWrite},
|
||||
{ID: "movies", Path: "/storage/media/movies", Mode: AccessRead},
|
||||
}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "app", "type": "container", "name": "importer", "image": anImage,
|
||||
"volumes": []any{"${access:music}/incoming:/music", "${access:movies}:/movies:ro"}},
|
||||
{"id": "sidecar", "type": "container", "name": "importer-sidecar", "image": anImage},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func composeImporter(t *testing.T, r Resolution, with Rendering) map[string]map[string]any {
|
||||
t.Helper()
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// What the node-engine this controller pins would make of it: nothing refused.
|
||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": out})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
||||
t.Fatalf("composed into something the node-engine refuses: %v", problems)
|
||||
}
|
||||
return byID(out)
|
||||
}
|
||||
|
||||
func TestAReadWriteAccessNamesTheContainersThatMountItAndTheToolsAccount(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{anImporter(t), theRuntime(t)}}
|
||||
got := composeImporter(t, r, Rendering{GrantsGroups: true})
|
||||
|
||||
music := got["importer."+AccessID("/storage/media/music")]
|
||||
if music == nil {
|
||||
t.Fatalf("no access resource for the library: %v", keysOf(got))
|
||||
}
|
||||
grant, _ := music[GrantField].(map[string]any)
|
||||
if fmt.Sprint(grant["containers"]) != "[importer.app]" || grant["account"] != "jochen" {
|
||||
t.Errorf("the read-write access grants %v; want the container that mounts it and the tools' account", grant)
|
||||
}
|
||||
// A read access grants nothing (§3).
|
||||
if movies := got["importer."+AccessID("/storage/media/movies")]; movies[GrantField] != nil {
|
||||
t.Errorf("a read access names a grant: %v", movies[GrantField])
|
||||
}
|
||||
// The tool runner is restarted when the access moved its account's groups (§6).
|
||||
runtime := got[RuntimeModule+"."+RuntimeProcessID()]
|
||||
found := false
|
||||
for _, id := range runtime["restart-on"].([]any) {
|
||||
if id == "importer."+AccessID("/storage/media/music") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the tool runner is not restarted on the access that grants its account: %v", runtime["restart-on"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleOfItsOwnAccountIsGrantedAsThatAccount(t *testing.T) {
|
||||
m := anImporter(t)
|
||||
m.RunsAs = "importer"
|
||||
m.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/mesh/importer/broker"}}
|
||||
runtime := theRuntime(t)
|
||||
runtime.Bundles[0].Binary = "node-tools"
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{m, runtime}}
|
||||
with := Rendering{GrantsGroups: true}
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed"}, "importer": {"broker": "sealed"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := byID(out)
|
||||
grant, _ := got["importer."+AccessID("/storage/media/music")][GrantField].(map[string]any)
|
||||
if grant["account"] != "importer" {
|
||||
t.Errorf("the access grants %v; want the module's own account", grant)
|
||||
}
|
||||
own := got["importer."+OwnRuntimeID()]
|
||||
if fmt.Sprint(own["restart-on"]) == "" || !restartsOnIt(own["restart-on"], "importer."+AccessID("/storage/media/music")) {
|
||||
t.Errorf("the module's own runtime is not restarted on its access: %v", own["restart-on"])
|
||||
}
|
||||
if restartsOnIt(got[RuntimeModule+"."+RuntimeProcessID()]["restart-on"], "importer."+AccessID("/storage/media/music")) {
|
||||
t.Error("the machine's runtime is restarted on an access of a module it does not serve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleThatRunsNothingHereSendsAnEmptyGrant(t *testing.T) {
|
||||
m := mod("spool", nil, nil, nil)
|
||||
m.Accesses = []Access{{Path: "/storage/downloads", Mode: AccessReadWrite}}
|
||||
out, err := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{m}}.Declaration(Rendering{GrantsGroups: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant, has := byID(out)["spool."+AccessID("/storage/downloads")][GrantField].(map[string]any)
|
||||
if !has || len(grant) != 0 {
|
||||
t.Errorf("a read-write access of a module running nothing here is sent %v (sent %v); want an empty grant, "+
|
||||
"so its group is still judged", grant, has)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoGrantIsSentToAnEngineOlderThanTheField(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{anImporter(t), theRuntime(t)}}
|
||||
got := composeImporter(t, r, Rendering{})
|
||||
for id, res := range got {
|
||||
if _, has := res[GrantField]; has {
|
||||
t.Errorf("%s names a grant to an engine that does not read it", id)
|
||||
}
|
||||
}
|
||||
if restartsOnIt(got[RuntimeModule+"."+RuntimeProcessID()]["restart-on"], "importer."+AccessID("/storage/media/music")) {
|
||||
t.Error("the tool runner is restarted on an access an older engine grants nothing for")
|
||||
}
|
||||
}
|
||||
|
||||
func restartsOnIt(list any, want string) bool {
|
||||
items, _ := list.([]any)
|
||||
for _, i := range items {
|
||||
if fmt.Sprint(i) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func keysOf(m map[string]map[string]any) []string {
|
||||
var out []string
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Only a container that mounts the access's path, or below it, is named: a path that begins with the same letters is
|
||||
// another path. A read-only mount of a read-write access is still named — the access is read-write, and the grant
|
||||
// follows what mounts it, not how.
|
||||
func TestOnlyAContainerMountingThePathIsNamedReadOnlyIncluded(t *testing.T) {
|
||||
m := mod("library", nil, nil, nil)
|
||||
m.Accesses = []Access{{ID: "media", Path: "/storage/media", Mode: AccessReadWrite}}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "beside", "type": "container", "name": "beside", "image": anImage, "volumes": []any{"/storage/media2:/x"}},
|
||||
{"id": "reader", "type": "container", "name": "reader", "image": anImage, "volumes": []any{"${access:media}:/media:ro"}},
|
||||
}
|
||||
out, err := Resolution{Node: "ace", Modules: []Manifest{m}}.Declaration(Rendering{GrantsGroups: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant, _ := byID(out)["library."+AccessID("/storage/media")][GrantField].(map[string]any)
|
||||
if fmt.Sprint(grant["containers"]) != "[library.reader]" {
|
||||
t.Errorf("the access grants %v; want the read-only mount and not /storage/media2", grant)
|
||||
}
|
||||
}
|
||||
@@ -1829,6 +1829,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
problems = append(problems, invokeProblems(m)...)
|
||||
problems = append(problems, replacesProblems(m)...)
|
||||
problems = append(problems, UnitSettingProblems(m)...)
|
||||
problems = append(problems, OwnerSettingProblems(m)...)
|
||||
problems = append(problems, endpointNameProblems(m)...)
|
||||
problems = append(problems, RouteProblems(m)...)
|
||||
for _, port := range m.Guards {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A declared directory may be owned by the account its module runs as (novox/hq issue 279): a media
|
||||
// manager runs as the puid and pgid its assignment sets per node, and a folder it must write was owned
|
||||
// by the manifest's literal, so the app refused it on a machine whose identity was not the image's.
|
||||
|
||||
func recycler() Manifest {
|
||||
return Manifest{Module: "sonarr",
|
||||
Resources: []map[string]any{
|
||||
{"id": "recycle-bin", "type": "directory", "path": "/srv/sonarr/recycle", "mode": "0755",
|
||||
"owner": "${setting:puid}:${setting:pgid}"},
|
||||
{"id": "identity", "type": "file", "path": "/srv/sonarr/identity.env", "mode": "0644",
|
||||
"content": "PUID=${setting:puid}\nPGID=${setting:pgid}\n"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func copied(r map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, v := range r {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The node's identity reaches the directory's owner, the node's layer over the mesh's, a number given
|
||||
// as a string or as a JSON number alike.
|
||||
func TestASettingOwnsADirectory(t *testing.T) {
|
||||
m := recycler()
|
||||
layers := []Layer{
|
||||
{From: "the mesh", Values: map[string]any{"puid": "1000", "pgid": "1000"}},
|
||||
{From: "this node", Values: map[string]any{"puid": float64(1001), "pgid": "2000"}},
|
||||
}
|
||||
r := copied(m.Resources[0])
|
||||
if err := settingInto(r, layers, m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r["owner"] != "1001:2000" {
|
||||
t.Fatalf("owned by %q, not 1001:2000", r["owner"])
|
||||
}
|
||||
user := copied(m.Resources[0])
|
||||
user["owner"] = "${setting:puid}"
|
||||
if err := settingInto(user, layers, m.Module); err != nil || user["owner"] != "1001" {
|
||||
t.Fatalf("user alone: %v, %v", user["owner"], err)
|
||||
}
|
||||
}
|
||||
|
||||
// Composed for a machine, the way a push writes it: the directory reaches the node-engine owned by the
|
||||
// number the node's settings name, never by the placeholder.
|
||||
func TestAComposedMachineGetsTheOwnerTheSettingNames(t *testing.T) {
|
||||
r := anAdoptedAnchor()
|
||||
r.Modules = append(r.Modules, recycler())
|
||||
with := anchorRendering(false)
|
||||
with.Settings["sonarr"] = []Layer{{From: "anchor", Values: map[string]any{"puid": "1001", "pgid": "2000"}}}
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, left := composed.LeftOut["sonarr"]; left {
|
||||
t.Fatalf("left out: %s", why)
|
||||
}
|
||||
got := byID(composed.Resources)["sonarr.recycle-bin"]
|
||||
if got["owner"] != "1001:2000" {
|
||||
t.Fatalf("composed owned by %v, not 1001:2000", got["owner"])
|
||||
}
|
||||
}
|
||||
|
||||
// Only a whole number owns a directory: an account's name, text, a sign, a fraction or a value past the
|
||||
// kernel's ids is refused by name, and the directory is left as it was. So is a key nothing sets.
|
||||
func TestAnOwnerSettingThatIsNoNumberIsRefused(t *testing.T) {
|
||||
m := recycler()
|
||||
for _, bad := range []any{"abc", "root", "", "-1", "+5", "1000:1000", "10 00", "1000\nx", float64(1.5),
|
||||
float64(-3), "4294967295", "99999999999", true} {
|
||||
r := copied(m.Resources[0])
|
||||
err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"puid": bad, "pgid": "1000"}}}, m.Module)
|
||||
if err == nil || !strings.Contains(err.Error(), `"puid"`) || !strings.Contains(err.Error(), "whole number") {
|
||||
t.Errorf("%v: %v", bad, err)
|
||||
}
|
||||
if r["owner"] != m.Resources[0]["owner"] {
|
||||
t.Errorf("%v: the owner was changed on refusal: %v", bad, r["owner"])
|
||||
}
|
||||
}
|
||||
r := copied(m.Resources[0])
|
||||
if err := settingInto(r, nil, m.Module); err == nil || !strings.Contains(err.Error(), "${setting:puid}") {
|
||||
t.Errorf("nothing set: %v", err)
|
||||
}
|
||||
if err := JudgeSettings(m, []Layer{{From: "ace", Values: map[string]any{"puid": "media", "pgid": "1000"}}}, false); err == nil ||
|
||||
!strings.Contains(err.Error(), "whole number") {
|
||||
t.Errorf("a stored name is not refused when judged: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Where the manifest is read: an owner holding a setting is a whole placeholder or a whole number on
|
||||
// each side of the colon, and every key is one the module asks for elsewhere or declares a default for.
|
||||
func TestAnOwnerSettingIsJudgedWhereTheManifestIsRead(t *testing.T) {
|
||||
ok := []string{"${setting:puid}:${setting:pgid}", "${setting:puid}", "${setting:puid}:1000", "1000:${setting:pgid}",
|
||||
"1000:1000", "media:media"}
|
||||
bad := []string{
|
||||
"${setting:puid}x",
|
||||
"x${setting:puid}",
|
||||
"${setting:puid}:${setting:pgid}:1",
|
||||
"${setting:puid}${setting:pgid}",
|
||||
"media:${setting:pgid}",
|
||||
"${setting:puid}:",
|
||||
"${setting:other}:${setting:pgid}",
|
||||
}
|
||||
for _, owner := range append(ok, bad...) {
|
||||
m := recycler()
|
||||
m.Resources[0] = copied(m.Resources[0])
|
||||
m.Resources[0]["owner"] = owner
|
||||
err := parsed(t, m)
|
||||
refused := err != nil && strings.Contains(err.Error(), "owner")
|
||||
want := false
|
||||
for _, b := range bad {
|
||||
want = want || b == owner
|
||||
}
|
||||
if refused != want {
|
||||
t.Errorf("%s: refused %v, want %v (%v)", owner, refused, want, err)
|
||||
}
|
||||
}
|
||||
// A key with a default is declared, though nothing else asks for it.
|
||||
m := recycler()
|
||||
m.Resources = m.Resources[:1]
|
||||
m.Resources[0] = copied(m.Resources[0])
|
||||
m.Resources[0]["owner"] = "${setting:workers}"
|
||||
m.Settings = map[string]SettingDeclaration{"workers": {Kind: KindPreference, Default: float64(4), Why: "a test"}}
|
||||
if err := parsed(t, m); err != nil && strings.Contains(err.Error(), "owner") {
|
||||
t.Errorf("a declared key was refused: %v", err)
|
||||
}
|
||||
// A file's owner takes no setting: the sweep says so.
|
||||
f := recycler()
|
||||
f.Resources[1] = copied(f.Resources[1])
|
||||
f.Resources[1]["owner"] = "${setting:puid}"
|
||||
if err := parsed(t, f); err == nil || !strings.Contains(err.Error(), "not filled in this field") {
|
||||
t.Errorf("a file's owner setting passed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A key a directory's owner asks for is a destination, so setting it is not called stray.
|
||||
func TestASettingAnOwnerAsksForIsNotStray(t *testing.T) {
|
||||
m := recycler()
|
||||
m.Resources = m.Resources[:1]
|
||||
stray := strings.Join(UnusedSettings(m, []Layer{{From: "ace", Values: map[string]any{"puid": "1001", "pgid": "2000"}}}), "; ")
|
||||
if strings.Contains(stray, "puid") || strings.Contains(stray, "pgid") {
|
||||
t.Errorf("called stray: %s", stray)
|
||||
}
|
||||
}
|
||||
|
||||
// An owner is written as the number parsed: "0001" and "00" reach the node-engine as 1 and 0, never as text.
|
||||
// Uid 0 is allowed, because a key an owner asks for is the terminal's alone.
|
||||
func TestAnOwnerSettingIsWrittenAsTheNumber(t *testing.T) {
|
||||
m := recycler()
|
||||
r := copied(m.Resources[0])
|
||||
if err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"puid": "0001", "pgid": "00"}}}, m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r["owner"] != "1:0" {
|
||||
t.Fatalf("owned by %q, not 1:0", r["owner"])
|
||||
}
|
||||
}
|
||||
|
||||
// Every key a directory's owner asks for is the controller's terminal's alone (novox/hq issue 279, issue 339):
|
||||
// root chowns the folder to it, so it is terminal-only whatever else asks for it — a file nothing trusts, a
|
||||
// default, or nothing at all.
|
||||
func TestAnOwnersKeyIsTheTerminals(t *testing.T) {
|
||||
m := recycler()
|
||||
m.Resources[1] = copied(m.Resources[1])
|
||||
m.Resources[1]["trusted"] = false
|
||||
m.Settings = map[string]SettingDeclaration{"pgid": {Kind: KindPreference, Default: "1000", Why: "a test"}}
|
||||
keys := strings.Join(TerminalKeys(m), ",")
|
||||
for _, k := range []string{"puid", "pgid"} {
|
||||
if !strings.Contains(","+keys+",", ","+k+",") {
|
||||
t.Errorf("%s is not the terminal's: %s", k, keys)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -15,7 +16,8 @@ import (
|
||||
// (novox/hq issues 122, 134). ADR 0112 names the operator as one of the four providers; this is the
|
||||
// operator answering.
|
||||
//
|
||||
// `${setting:<key>}` in a file's content, and in a service's unit name, is filled from the module's settings layers — the mesh's,
|
||||
// `${setting:<key>}` in a file's content, in a service's unit name and in a directory's owner (a uid or
|
||||
// gid only, issue 279) is filled from the module's settings layers — the mesh's,
|
||||
// then this node's — the same layers a mergeable JSON file and a contribution already take, so
|
||||
// `settings set <module>` is the one place a person's values go. **Refused when no layer sets it**,
|
||||
// naming the key and the remedy: a definition that carried a default for a mail domain would be
|
||||
@@ -59,6 +61,9 @@ func settingInto(resource map[string]any, layers []Layer, module string) error {
|
||||
if fmt.Sprint(resource["type"]) == "service" {
|
||||
return settingIntoUnit(resource, layers, module)
|
||||
}
|
||||
if fmt.Sprint(resource["type"]) == "directory" {
|
||||
return settingIntoOwner(resource, layers, module)
|
||||
}
|
||||
if fmt.Sprint(resource["type"]) != "file" {
|
||||
return nil
|
||||
}
|
||||
@@ -143,6 +148,107 @@ func settingIntoUnit(resource map[string]any, layers []Layer, module string) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// A declared directory may be owned by the account its module runs as (novox/hq issue 279).
|
||||
//
|
||||
// A containerised module runs as the identity its assignment sets per node — its puid and pgid
|
||||
// settings, written into the environment file its image reads (ADR 0112, issue 153) — and a directory
|
||||
// its app must write, beyond the one its image re-owns at start, was owned by whatever the manifest
|
||||
// spelt: right on a machine whose identity matched the image's default, refused by the app on one whose
|
||||
// did not. So a directory's `owner` may name the settings that identity is: `${setting:<key>}` for the
|
||||
// user alone, `${setting:<user>}:${setting:<group>}` for both, filled from the same layers at
|
||||
// composition.
|
||||
//
|
||||
// **Whole numbers only.** An owner a setting fills is a uid or a gid — never an account's name, never
|
||||
// text — so no value can put anything into the ownership the node-engine applies but a number; one that
|
||||
// is not is refused at composition, naming the key, and the directory is left as it was. The shape is
|
||||
// judged where the manifest is read (OwnerSettingProblems): each side of the colon a whole placeholder
|
||||
// or a whole number, and every key one the module itself asks for elsewhere — in a file, a unit, a
|
||||
// contribution or a served fact — or declares a default for, so the owner follows the identity its
|
||||
// process is told it runs as, never a key that reaches nothing else.
|
||||
|
||||
// ownerSide is one side of an owner a setting may fill: a whole placeholder or a whole number.
|
||||
var ownerSide = `(?:\$\{setting:[a-z0-9][a-z0-9_.-]*\}|[0-9]+)`
|
||||
|
||||
// ownerWithSetting is the only shape an owner holding a setting may have: user, or user:group.
|
||||
var ownerWithSetting = regexp.MustCompile(`^` + ownerSide + `(?::` + ownerSide + `)?$`)
|
||||
|
||||
// wholeID is a value as a uid or a gid, and whether it is one: a whole number below the kernel's "no id"
|
||||
// (2^32-1). Uid 0 is one: a key an owner asks for is the controller's terminal's alone (TerminalKeys).
|
||||
func wholeID(v string) (uint64, bool) {
|
||||
if v == "" || strings.TrimLeft(v, "0123456789") != "" {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseUint(v, 10, 32)
|
||||
return n, err == nil && n < 1<<32-1
|
||||
}
|
||||
|
||||
// OwnerSettingProblems refuses, where a manifest is read, a directory whose owner carries a setting
|
||||
// in any shape but a whole placeholder or number on each side of the colon, or names a key the module
|
||||
// asks for nowhere else and declares no default for.
|
||||
func OwnerSettingProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
asked := settingKeysAskedOutsideOwners(m)
|
||||
for k := range m.Settings {
|
||||
asked[k] = true
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
owner, _ := r["owner"].(string)
|
||||
if !strings.Contains(owner, "${setting:") {
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(r["type"]) != "directory" {
|
||||
// Said by the placeholder sweep: only a directory's owner takes a setting.
|
||||
continue
|
||||
}
|
||||
if !ownerWithSetting.MatchString(owner) {
|
||||
problems = append(problems, fmt.Sprintf("%s: the directory %v's owner %q carries a setting in a shape "+
|
||||
"the mesh does not fill; an owner names the account by number only, each side of the colon a whole "+
|
||||
"${setting:key} or a whole number (${setting:puid}:${setting:pgid}) (novox/hq issue 279)",
|
||||
m.Module, r["id"], owner))
|
||||
continue
|
||||
}
|
||||
for _, key := range settingsUsed(owner) {
|
||||
if !asked[key] {
|
||||
problems = append(problems, fmt.Sprintf("%s: the directory %v's owner says ${setting:%s}, and the "+
|
||||
"module asks for %q nowhere else and declares no default for it: an owner follows the identity "+
|
||||
"the module's process is told it runs as (its identity file's ${setting:%s}), never a key of its "+
|
||||
"own (novox/hq issue 279)", m.Module, r["id"], key, key, key))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// settingIntoOwner fills ${setting:…} in a directory's owner, refusing — with the key and why, the
|
||||
// resource left as it was — a key nothing sets and a value that is not a whole number.
|
||||
func settingIntoOwner(resource map[string]any, layers []Layer, module string) error {
|
||||
owner, ok := resource["owner"].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, key := range settingsUsed(owner) {
|
||||
value, set := settingValue(layers, key)
|
||||
if !set {
|
||||
return &UnsetSettingError{Module: module, Setting: key, said: fmt.Sprintf(
|
||||
"%s has a directory %v whose owner says ${setting:%s}, and nothing sets %q for it — an operator's "+
|
||||
"value is the assignment's, never the definition's (novox/hq ADR 0112): "+
|
||||
"`settings set %s <file>` with {%q: …}%s",
|
||||
module, resource["id"], key, key, module, key, orNoSettings(layers))}
|
||||
}
|
||||
v := plainly(value)
|
||||
id, whole := wholeID(v)
|
||||
if !whole {
|
||||
return fmt.Errorf("%s: the setting %q is %q, which cannot own the directory %v: an owner a setting "+
|
||||
"names is a uid or a gid, a whole number, never an account's name or other text (novox/hq issue 279)",
|
||||
module, key, v, resource["id"])
|
||||
}
|
||||
// Written as the number parsed, so "0001" reaches the node-engine as 1, never as text.
|
||||
owner = strings.ReplaceAll(owner, "${setting:"+key+"}", strconv.FormatUint(id, 10))
|
||||
}
|
||||
resource["owner"] = owner
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingValue(layers []Layer, key string) (any, bool) {
|
||||
var value any
|
||||
set := false
|
||||
@@ -171,9 +277,25 @@ func orNoSettings(layers []Layer) string {
|
||||
return "; set today: " + strings.Join(keys, ", ")
|
||||
}
|
||||
|
||||
// settingKeysUsedBy is every key a module's files, contributions and served facts ask for, so a
|
||||
// setting that lands in one is not called stray.
|
||||
// settingKeysUsedBy is every key a module's files, units, directory owners, contributions and served
|
||||
// facts ask for, so a setting that lands in one is not called stray.
|
||||
func settingKeysUsedBy(m Manifest) map[string]bool {
|
||||
used := settingKeysAskedOutsideOwners(m)
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "directory" {
|
||||
if owner, ok := r["owner"].(string); ok {
|
||||
for _, k := range settingsUsed(owner) {
|
||||
used[k] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// settingKeysAskedOutsideOwners is every key a module's files, units, contributions and served facts
|
||||
// ask for: what a directory's owner may follow (OwnerSettingProblems).
|
||||
func settingKeysAskedOutsideOwners(m Manifest) map[string]bool {
|
||||
used := map[string]bool{}
|
||||
note := func(s string) {
|
||||
for _, k := range settingsUsed(s) {
|
||||
|
||||
@@ -33,6 +33,9 @@ import (
|
||||
// 4. **A module's whole layer, when it has a mergeable file not marked `"trusted": false`** (novox/hq issue 340,
|
||||
// TrustedMergeable). A mergeable file takes any key a layer sets, not only those its content names, so no list
|
||||
// of keys covers it: an empty runtime configuration a provider reads would take a `url` of the caller's.
|
||||
// 5. **Every setting a directory's owner asks for** (novox/hq issue 279): root chowns the directory to the number
|
||||
// at the next push, so through a verb any caller could hand itself — or uid 0 — a module's folder. Whatever
|
||||
// else asks for the key, a file marked `"trusted": false` included, the owner makes it the terminal's.
|
||||
//
|
||||
// Derived from the manifest, never listed by hand, so a provider or a trusted file added tomorrow is covered.
|
||||
|
||||
@@ -42,8 +45,8 @@ import (
|
||||
const TrustedField = "trusted"
|
||||
|
||||
// TerminalKeys are the settings keys of a module that are set at the controller's terminal alone: places and
|
||||
// accesses, every key its provisions serve and every setting a served value asks for, and every setting a file
|
||||
// asks for unless it says `"trusted": false`. Places and accesses first, then the rest sorted.
|
||||
// accesses, every key its provisions serve and every setting a served value asks for, every setting a file
|
||||
// asks for unless it says `"trusted": false`, and every setting a directory's owner asks for. Places and accesses first, then the rest sorted.
|
||||
func TerminalKeys(m Manifest) []string {
|
||||
keys := map[string]bool{}
|
||||
for _, served := range m.Serves {
|
||||
@@ -57,6 +60,13 @@ func TerminalKeys(m Manifest) []string {
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "directory" {
|
||||
owner, _ := r["owner"].(string)
|
||||
for _, asked := range settingsUsed(owner) {
|
||||
keys[asked] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(r["type"]) != "file" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ type filler struct {
|
||||
// seatInto, machineInto and contributionsInto. A pass that comes to read another field adds it here,
|
||||
// or the sweep refuses the placeholder it would have filled.
|
||||
var fillers = []filler{
|
||||
{namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}}},
|
||||
{namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}, "directory": {"owner"}}},
|
||||
{namespace: "dir", patterns: []*regexp.Regexp{dirRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
|
||||
{namespace: "access", patterns: []*regexp.Regexp{accessRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
|
||||
{namespace: "secret", patterns: []*regexp.Regexp{placeholder}, fields: map[string][]string{"file": {"content"}},
|
||||
|
||||
@@ -436,12 +436,6 @@ const ReadinessContract = 2
|
||||
// parses strictly and would refuse the whole declaration for it.
|
||||
const RootContract = 3
|
||||
|
||||
// GrantContract is the statement of an engine that also reads an access's `grant` (novox/hq ADR 0312): it grants a
|
||||
// read-write access's path group to the containers and the account the grant names, takes it back, and states the
|
||||
// access unhealthy while the group cannot write. Only to such an engine is the field sent: an older one parses
|
||||
// strictly and would refuse the whole declaration for it.
|
||||
const GrantContract = 4
|
||||
|
||||
// ReasonRoot starts the reason of an account verdict that found a way to root (ADR 0266); the node-engine's
|
||||
// own words (mesh-host internal/accounts ReasonRoot).
|
||||
const ReasonRoot = "can become root without a person"
|
||||
@@ -493,11 +487,6 @@ const KindAccount = "account"
|
||||
// ReasonUsedAsFound.
|
||||
const KindDirectory = "directory"
|
||||
|
||||
// KindAccess is a read-write access that grants its path's group (novox/hq ADR 0312): healthy while the group may
|
||||
// write the path, unhealthy while it cannot — the operator's to decide, since the mesh changes nothing on the path
|
||||
// — or while the path is owned by a group that is never granted. The node-engine's own (mesh-host internal/link).
|
||||
const KindAccess = "access"
|
||||
|
||||
// ReasonUsedAsFound starts the reason of a directory used as found.
|
||||
const ReasonUsedAsFound = "used as found:"
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -8,7 +8,7 @@ private addresses. That is acceptable while mesh-controller is private; if it ev
|
||||
a second place to clean besides the catalogue itself.
|
||||
|
||||
mesh-catalog b9de001833b1b61b297182b8e3bcdae1cbdeace9 modules/*/module.json, modules/nats/Dockerfile
|
||||
mesh-host 3f7f797168d33ec22241bdd6ef2c64de86eea914 examples/foundation-first-node-nats.lock
|
||||
mesh-host a76baa4b47f07a8b2bab065603414530bd02b53a examples/foundation-first-node-nats.lock
|
||||
mesh-sdk 33917f91ac3cc213b879b32f3c70e2aa70395f98 conformance/events/module-event.json
|
||||
|
||||
To move them, from this repository's root, with the two repositories checked out beside it:
|
||||
|
||||
Vendored
+1
@@ -22,3 +22,4 @@
|
||||
./cmd/mesh-controller TestAWalkEndedByCancelOrClearIsSaidEnded
|
||||
./cmd/mesh-controller TestAWalkRestartedByARebuildIsSaidStarted
|
||||
./cmd/mesh-controller TestAWalkStoppedByHandAndRetriedEndsFailedWhenItFailsAgain
|
||||
./cmd/mesh-controller TestADirectoryOwnersKeyIsRefusedThroughAVerb
|
||||
|
||||
-78
@@ -464,35 +464,6 @@ type Access struct {
|
||||
Path string `json:"path"`
|
||||
// Mode is how this module reaches the path: read or read-write. Absent narrows to read.
|
||||
Mode string `json:"mode,omitempty"`
|
||||
|
||||
// Grant is who the path's own group is granted to, for a read-write access (novox/hq ADR 0312 §1): the
|
||||
// identities the module's code runs as. The controller says who; the node-engine reads which group owns
|
||||
// the path when it applies, because only the machine knows, and never changes the path (§2). Absent is a
|
||||
// controller older than the grant, or an access that grants nothing — and a read access always is one (§3).
|
||||
Grant *AccessGrant `json:"grant,omitempty"`
|
||||
}
|
||||
|
||||
// AccessGrant is the identities a read-write access grants its path's group to (novox/hq ADR 0312 §1).
|
||||
type AccessGrant struct {
|
||||
// Containers are the module's containers that mount the path, by id: each is created with the group as a
|
||||
// supplementary group of its process, and recreated when the group moves (§6).
|
||||
Containers []string `json:"containers,omitempty"`
|
||||
// Account is the account that runs the module's tools: put in the group as an account group, as ADR 0252
|
||||
// §1 puts one, and taken out again as §3 takes back (§5). Empty for a module whose code runs nowhere as
|
||||
// an account.
|
||||
Account string `json:"account,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads a grant as strictly as a resource is read: a field the engine does not know is a
|
||||
// thing the controller believes it asked for, and is refused rather than ignored.
|
||||
func (g *AccessGrant) UnmarshalJSON(raw []byte) error {
|
||||
type plain AccessGrant
|
||||
var p plain
|
||||
if err := strictDecode(raw, &p); err != nil {
|
||||
return fmt.Errorf("a grant: %w", err)
|
||||
}
|
||||
*g = AccessGrant(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Access) Identity() string { return a.ID }
|
||||
@@ -511,24 +482,6 @@ func (a *Access) validate(where string, _ bool) []string {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: an access is %q or %q, not %q", where, AccessRead, AccessReadWrite, a.Mode))
|
||||
}
|
||||
if a.Grant != nil && a.Mode != AccessReadWrite {
|
||||
// A read access reads as any account may, and gains no group (novox/hq ADR 0312 §3).
|
||||
problems = append(problems, where+": a read access grants nothing, and this one names a grant; "+
|
||||
"only a read-write access grants its path's group (novox/hq ADR 0312 §3)")
|
||||
}
|
||||
if a.Grant != nil {
|
||||
for _, id := range a.Grant.Containers {
|
||||
if id == "" {
|
||||
problems = append(problems, where+": a grant names a container with no id")
|
||||
}
|
||||
}
|
||||
if a.Grant.Account != "" && !accountName.MatchString(a.Grant.Account) {
|
||||
// Handed to id, usermod and gpasswd as an argument: a name starting with a dash would be read as an
|
||||
// option by each of them.
|
||||
problems = append(problems, fmt.Sprintf("%s: a grant's account is an account's name — letters, digits, "+
|
||||
"dots, underscores and dashes, not starting with a dash — and %q is not one", where, a.Grant.Account))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
@@ -1715,7 +1668,6 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
||||
d.Resources = append(d.Resources, resource)
|
||||
}
|
||||
problems = append(problems, checkWhileStopped(d.Resources)...)
|
||||
problems = append(problems, checkGrants(d.Resources)...)
|
||||
problems = append(problems, checkAdoption(env.Adoption, d.Resources, allowActions)...)
|
||||
problems = append(problems, checkReplaces(d.Resources)...)
|
||||
if env.Adoption == nil {
|
||||
@@ -1780,36 +1732,6 @@ func checkWhileStopped(resources []Resource) []string {
|
||||
return problems
|
||||
}
|
||||
|
||||
// accountName is what a grant's account may be: the portable account name the user tools take, optionally ending
|
||||
// in `$`, never starting with a dash.
|
||||
var accountName = regexp.MustCompile(`^[A-Za-z0-9_.][A-Za-z0-9_.-]*\$?$`)
|
||||
|
||||
// checkGrants judges each access's grant against the whole declaration (novox/hq ADR 0312): a container it
|
||||
// names is a container of this declaration, as a maintenance window's are. A grant to a container the machine
|
||||
// is not told about would be a group given to nothing, said as granted.
|
||||
func checkGrants(resources []Resource) []string {
|
||||
containers := map[string]bool{}
|
||||
for _, r := range resources {
|
||||
if r.Kind() == TypeContainer {
|
||||
containers[r.Identity()] = true
|
||||
}
|
||||
}
|
||||
var problems []string
|
||||
for _, r := range resources {
|
||||
a, ok := r.(*Access)
|
||||
if !ok || a.Grant == nil {
|
||||
continue
|
||||
}
|
||||
for _, id := range a.Grant.Containers {
|
||||
if id != "" && !containers[id] {
|
||||
problems = append(problems, fmt.Sprintf("resource %q: its grant names %q, and this declaration "+
|
||||
"has no container by that id", a.ID, id))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func strictDecode(raw []byte, into any) error {
|
||||
// DisallowUnknownFields is the whole point rather than strictness for its own sake: a
|
||||
// field the host does not know is a thing the control plane believes it asked for.
|
||||
|
||||
Vendored
+2
-2
@@ -78,7 +78,7 @@ github.com/nats-io/nkeys
|
||||
# github.com/nats-io/nuid v1.0.1
|
||||
## explicit
|
||||
github.com/nats-io/nuid
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f
|
||||
## explicit; go 1.26.0
|
||||
github.com/novox/mesh-host/internal/declaration
|
||||
github.com/novox/mesh-host/rootsearch
|
||||
@@ -135,4 +135,4 @@ golang.org/x/text/width
|
||||
# golang.org/x/time v0.15.0
|
||||
## explicit; go 1.25.0
|
||||
golang.org/x/time/rate
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f
|
||||
|
||||
Reference in New Issue
Block a user