Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cfc121670 | ||
|
|
db7e4869ae | ||
|
|
d4de4543cc | ||
|
|
3e10ad1535 | ||
|
|
74bd90df7e | ||
|
|
30599c1858 | ||
|
|
10d4712f82 |
@@ -6,3 +6,5 @@
|
||||
/mesh-controller
|
||||
/postgres-provisioner
|
||||
/redis-provisioner
|
||||
# And where `go build` in cmd/mesh-controller leaves it (novox/hq issue 233).
|
||||
/cmd/mesh-controller/mesh-controller
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// kindApplyFailing is S21's kind: a machine whose declarations have been refused, or applied only in part,
|
||||
// for longer than one apply interval (novox/hq issue 233).
|
||||
const kindApplyFailing = "apply-failing"
|
||||
|
||||
// applyEvery is how often a node-engine applies what it was last told when nothing new arrives: its
|
||||
// ReconcileEvery. A failure still there after one more of them is not one the next apply clears.
|
||||
const applyEvery = 5 * time.Minute
|
||||
|
||||
// packageManagerKey is how a node-engine names a package manager that does not answer among what failed
|
||||
// (mesh-host's probe.go): "the package manager (pacman)".
|
||||
const packageManagerKey = "the package manager ("
|
||||
|
||||
// watchApplyFailing is S21: **a machine that does not apply what it was sent is said by the mesh, not only
|
||||
// to someone who asks for `status`** (novox/hq issue 233, issue 187's family). A workstation refused every
|
||||
// declaration for an hour and a half because its package manager did not answer, and the mesh's only word
|
||||
// on it was a line in `status`. Raised once the machine's first report of this failure is older than one apply
|
||||
// interval **while the machine is heard** — judged by time, not by how many reports said it: a node-engine
|
||||
// says a re-apply only when how it went changed, so the same failure is one report however long it lasts.
|
||||
// Names the refusal's or the failure's words; cleared by the clean apply the node-engine then says. Not while
|
||||
// the machine is silent, asleep or not known: S1 says silent, and asleep is not lost.
|
||||
func watchApplyFailing(f *signalFacts) []conditions.Observation {
|
||||
heard := map[string]bool{}
|
||||
for _, m := range f.machines {
|
||||
if !m.asleep() && !m.lastHeard.IsZero() && f.now.Sub(m.lastHeard) <= heartbeatBound(m.every) {
|
||||
heard[m.name] = true
|
||||
}
|
||||
}
|
||||
var out []conditions.Observation
|
||||
for _, d := range f.doing {
|
||||
if !d.Wrong() || d.Since == nil || f.now.Sub(*d.Since) <= applyEvery || !heard[d.Node] {
|
||||
continue
|
||||
}
|
||||
out = append(out, applyFailing(d, f.now))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// applyFailing is one machine's S21 condition.
|
||||
func applyFailing(d inventory.Doing, now time.Time) conditions.Observation {
|
||||
m := d.Node
|
||||
since := d.Since.UTC().Format("2006-01-02 15:04 MST")
|
||||
o := conditions.Observation{Scope: conditions.ScopeMachine, ID: m, Kind: kindApplyFailing, Machine: m,
|
||||
Severity: conditions.Warning, Said: doingWords(d),
|
||||
Resolved: m + " applies what it is sent again"}
|
||||
how := "refused its declaration whole"
|
||||
if d.Outcome != inventory.OutcomeRefused {
|
||||
how = "applied its declaration only in part"
|
||||
}
|
||||
o.Summary = fmt.Sprintf("%s has %s since %s (bound %s): %s", m, how, since, ago(applyEvery), failedNames(d))
|
||||
o.Headline = m + " is not applying its update"
|
||||
o.Explanation = fmt.Sprintf("%s has %s for %s, and trying again has not helped. The details say what fails.",
|
||||
m, plainHow(d), humanDuration(now.Sub(*d.Since)))
|
||||
if tool := unansweringTool(d); tool != "" {
|
||||
o.Summary = fmt.Sprintf("%s: %s does not answer, so no package reaches it; everything else it was sent "+
|
||||
"is applied, since %s (bound %s)", m, tool, since, ago(applyEvery))
|
||||
o.Headline = fmt.Sprintf("%s cannot install packages", m)
|
||||
o.Explanation = fmt.Sprintf("The package manager on %s (%s) has not answered for %s, so no package reaches "+
|
||||
"it. Everything else is still applied, so an update that repairs it can land; if none does, it needs a "+
|
||||
"look on the machine.", m, tool, humanDuration(now.Sub(*d.Since)))
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// plainHow is how a machine failed, as the operator reads it.
|
||||
func plainHow(d inventory.Doing) string {
|
||||
if d.Outcome == inventory.OutcomeRefused {
|
||||
return "refused every update"
|
||||
}
|
||||
return "applied its updates only in part"
|
||||
}
|
||||
|
||||
// unansweringTool is the package manager a machine said does not answer, or "".
|
||||
func unansweringTool(d inventory.Doing) string {
|
||||
for _, r := range d.Failed {
|
||||
if rest, ok := strings.CutPrefix(r.ID, packageManagerKey); ok {
|
||||
return strings.TrimSuffix(rest, ")")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// failedNames is what failed, by name: the refusal, or the failed resources' ids.
|
||||
func failedNames(d inventory.Doing) string {
|
||||
if d.Outcome == inventory.OutcomeRefused {
|
||||
return "refused"
|
||||
}
|
||||
ids := make([]string, 0, len(d.Failed))
|
||||
for _, r := range d.Failed {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return "failed " + strings.Join(ids, ", ")
|
||||
}
|
||||
|
||||
// doingWords is the machine's own words on what failed: the evidence, kept inside the mesh.
|
||||
func doingWords(d inventory.Doing) string {
|
||||
if d.Outcome == inventory.OutcomeRefused {
|
||||
return "refused: " + oneLine(d.Refused)
|
||||
}
|
||||
var parts []string
|
||||
for _, r := range d.Failed {
|
||||
parts = append(parts, r.ID+": "+oneLine(r.Error))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// unansweringPackageManager is anchor's last report as the workstation's read on 2026-10-04, once its
|
||||
// node-engine applies the rest (novox/hq issue 233): the package failed and the package manager did not answer,
|
||||
// the same failure on times reports since for before now.
|
||||
func unansweringPackageManager(now time.Time, forHowLong time.Duration, times int) inventory.Doing {
|
||||
since := now.Add(-forHowLong)
|
||||
return inventory.Doing{Node: "anchor", Outcome: inventory.OutcomeFailed, At: now.Add(-time.Second),
|
||||
Since: &since, Times: times, Applied: 41, Failed: []inventory.FailedResource{
|
||||
{ID: "apply", Error: `applying "pacman.base": nothing can be said about "base": this is the arch host and ` +
|
||||
"pacman does not answer here. Either this machine is not Arch, or its package database is broken: " +
|
||||
"pacman exited 1: error: config file /etc/pacman.conf could not be read: No such file or directory"},
|
||||
{ID: "the package manager (pacman)", Error: "pacman does not answer here, so every package in this " +
|
||||
"declaration fails and the rest of it is applied (novox/hq issue 233): pacman exited 1: error: config " +
|
||||
"file /etc/pacman.conf could not be read: No such file or directory"}}}
|
||||
}
|
||||
|
||||
// **A machine whose package manager does not answer is raised by the mesh itself, naming the tool and the
|
||||
// probe's words** (novox/hq issue 233): the words in the evidence, the path kept out of the summary.
|
||||
func TestAPackageManagerThatDoesNotAnswerIsRaisedNamingItsWords(t *testing.T) {
|
||||
now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC)
|
||||
f := calm(now)
|
||||
f.doing = []inventory.Doing{unansweringPackageManager(now, 90*time.Minute, 18)}
|
||||
got := watchApplyFailing(f)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("raised %d conditions, want one: %+v", len(got), got)
|
||||
}
|
||||
o := got[0]
|
||||
if o.Key() != "machine.anchor.apply-failing" || o.Machine != "anchor" {
|
||||
t.Errorf("the condition is not the machine's: %s", o.Key())
|
||||
}
|
||||
if !strings.Contains(o.Summary, "pacman does not answer") || !strings.Contains(o.Summary, "since 2026-10-04 12:30") {
|
||||
t.Errorf("the summary does not name the tool and since when: %s", o.Summary)
|
||||
}
|
||||
if strings.Contains(o.Summary, "/etc/") {
|
||||
t.Errorf("the summary carries a path, which belongs in the evidence: %s", o.Summary)
|
||||
}
|
||||
if !strings.Contains(o.Said, "could not be read") {
|
||||
t.Errorf("the evidence does not carry the probe's words: %s", o.Said)
|
||||
}
|
||||
if !strings.Contains(o.Headline, "cannot install packages") || o.Explanation == "" || o.Resolved == "" {
|
||||
t.Errorf("the plain words do not say it: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// **A whole refusal is raised too**, with the node-engine's words: what the workstation's report said before the
|
||||
// node-engine applied the rest.
|
||||
func TestARefusalThatGoesOnIsRaised(t *testing.T) {
|
||||
now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC)
|
||||
since := now.Add(-time.Hour)
|
||||
f := calm(now)
|
||||
f.doing = []inventory.Doing{{Node: "anchor", Outcome: inventory.OutcomeRefused, Since: &since, Times: 12,
|
||||
Refused: "this is the arch host and pacman does not answer here", At: now.Add(-time.Minute)}}
|
||||
got := watchApplyFailing(f)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Summary, "refused its declaration whole") ||
|
||||
!strings.Contains(got[0].Said, "pacman does not answer") {
|
||||
t.Fatalf("a refusal that went on for an hour was not raised with its words: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **Judged by time while the machine is heard, not by how many reports said it** (review of novox/hq issue 233):
|
||||
// a node-engine says the same failure once, so one report an apply interval old is a fault that goes on; one
|
||||
// younger is not yet; and a silent or sleeping machine is S1's.
|
||||
func TestOneReportOlderThanAnApplyIsRaisedAndAQuietMachineIsNot(t *testing.T) {
|
||||
now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC)
|
||||
f := calm(now)
|
||||
f.doing = []inventory.Doing{unansweringPackageManager(now, 6*time.Minute, 1)}
|
||||
if got := watchApplyFailing(f); len(got) != 1 {
|
||||
t.Errorf("one report of a failure six minutes old was not raised: %+v", got)
|
||||
}
|
||||
f.doing = []inventory.Doing{unansweringPackageManager(now, 4*time.Minute, 1)}
|
||||
if got := watchApplyFailing(f); len(got) != 0 {
|
||||
t.Errorf("a failure four minutes old was raised before one apply could clear it: %+v", got)
|
||||
}
|
||||
f.doing = []inventory.Doing{unansweringPackageManager(now, time.Hour, 1)}
|
||||
f.machines[0].lastHeard = now.Add(-time.Hour)
|
||||
if got := watchApplyFailing(f); len(got) != 0 {
|
||||
t.Errorf("a silent machine was raised for its last report: %+v", got)
|
||||
}
|
||||
f = calm(now)
|
||||
f.doing = []inventory.Doing{unansweringPackageManager(now, time.Hour, 1)}
|
||||
f.doing[0].Node = "a-machine-the-mesh-does-not-know"
|
||||
if got := watchApplyFailing(f); len(got) != 0 {
|
||||
t.Errorf("a machine not heard of was raised: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **A failed re-apply followed by a clean one clears S21** (review of novox/hq issue 233): the node-engine says
|
||||
// the clean re-apply, the store keeps it as applied with no failure since, and the condition the failure raised
|
||||
// is cleared through the keeper on the next tick.
|
||||
func TestACleanReapplyAfterAFailedOneClearsTheCondition(t *testing.T) {
|
||||
now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC)
|
||||
store := conditions.NewInMemory()
|
||||
k := conditions.NewKeeper(t.Context(), conditions.Options{Store: store, History: store,
|
||||
Teller: &conditions.Told{}, Now: func() time.Time { return now }})
|
||||
defer k.Close(context.Background())
|
||||
w := &watchdogs{keeper: k, started: now.Add(-time.Hour)}
|
||||
|
||||
failing := calm(now)
|
||||
failing.doing = []inventory.Doing{unansweringPackageManager(now, 20*time.Minute, 1)}
|
||||
w.see(t.Context(), failing)
|
||||
open, err := k.Open(t.Context())
|
||||
if err != nil || len(open) != 1 || open[0].Kind != kindApplyFailing {
|
||||
t.Fatalf("the failed re-apply raised %+v (%v), want apply-failing", open, err)
|
||||
}
|
||||
|
||||
clean := calm(now.Add(5 * time.Minute))
|
||||
clean.doing = nil // the clean re-apply recorded: NotDoingWhatTheyWereTold no longer lists the machine
|
||||
w.see(t.Context(), clean)
|
||||
if open, _ := k.Open(t.Context()); len(open) != 0 {
|
||||
t.Fatalf("the condition stayed open after the clean re-apply: %+v", open)
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,13 @@ var plainWordings = map[string]func(conditions.Observation) words{
|
||||
"It usually catches up by itself.", m, m),
|
||||
Resolved: m + " applied the change"}
|
||||
}),
|
||||
kindApplyFailing: worded(func(o conditions.Observation) words {
|
||||
m := machineOr(o, "a machine")
|
||||
return words{Headline: m + " is not applying its update",
|
||||
Explanation: fmt.Sprintf("%s has refused or only partly applied what the mesh sent it, again and again. "+
|
||||
"The details say what fails.", conditions.Capital(m)),
|
||||
Resolved: m + " applies what it is sent again"}
|
||||
}),
|
||||
"tools-silent": worded(func(o conditions.Observation) words {
|
||||
m := machineOr(o, "a machine")
|
||||
return words{Headline: "Tools on " + m + " are not answering",
|
||||
|
||||
@@ -947,9 +947,14 @@ 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,
|
||||
@@ -982,6 +987,16 @@ 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.
|
||||
|
||||
@@ -254,6 +254,16 @@ var signalsTable = []signalRow{
|
||||
newest: func(f *signalFacts) time.Time {
|
||||
return newestOf(f.bus.waits, func(w busWaitFacts) time.Time { return w.since })
|
||||
}},
|
||||
{Row: "S21", Signal: "a clean apply", Emitter: "node-engine", Trigger: "each report of a declaration refused or applied only in part (novox/hq issue 233)",
|
||||
Bound: "one apply interval (5 min) past the first report of the same failure, judged by time while the machine " +
|
||||
"is heard — the node-engine says a re-apply only when how it went changed; not while it is silent or " +
|
||||
"asleep; naming the refusal, or the package manager that does not answer with its words; cleared by the " +
|
||||
"clean apply it then says",
|
||||
Kind: kindApplyFailing, Severity: conditions.Warning, Phase: 2,
|
||||
needs: func(f *signalFacts) error { return f.doingErr }, watch: watchApplyFailing,
|
||||
newest: func(f *signalFacts) time.Time {
|
||||
return newestOf(f.doing, func(d inventory.Doing) time.Time { return d.At })
|
||||
}},
|
||||
}
|
||||
|
||||
// watchFacts is S14: the snapshot a merge check is fed is older than its bound, or none was kept since
|
||||
|
||||
@@ -186,6 +186,14 @@ var suppressions = map[string]suppression{
|
||||
},
|
||||
past: func(f *signalFacts) { f.refusedSends = []inventory.Send{refusedSend(f.now.Add(-time.Second))} },
|
||||
},
|
||||
// A machine whose package manager does not answer (novox/hq issue 233): said once its first report of the
|
||||
// failure is older than one apply interval while it is heard. Inside: exactly five minutes old.
|
||||
"S21": {
|
||||
inside: func(f *signalFacts) { f.doing = []inventory.Doing{unansweringPackageManager(f.now, 5*time.Minute, 1)} },
|
||||
past: func(f *signalFacts) {
|
||||
f.doing = []inventory.Doing{unansweringPackageManager(f.now, 5*time.Minute+time.Second, 1)}
|
||||
},
|
||||
},
|
||||
// Twice by hand within a fortnight is a healer wanted; once, or the first of two a day too old, is not.
|
||||
"S15": {
|
||||
inside: func(f *signalFacts) {
|
||||
|
||||
@@ -59,6 +59,10 @@ type signalFacts struct {
|
||||
// from, each naming its sender (S20, novox/hq issue 234).
|
||||
refusedSends []inventory.Send
|
||||
refusedSendsErr error
|
||||
// doing is every machine whose last report was not a clean apply, with since when it has said the same
|
||||
// failure (S21, novox/hq issue 233).
|
||||
doing []inventory.Doing
|
||||
doingErr error
|
||||
|
||||
loop loopFacts
|
||||
loopErr error
|
||||
@@ -369,6 +373,7 @@ func (w *watchdogs) gather(ctx context.Context) *signalFacts {
|
||||
}
|
||||
f.handActs, f.handActsErr = w.gatherHandActs(ctx, now)
|
||||
f.refusedSends, f.refusedSendsErr = inv.RefusedSendsSince(ctx, now.Add(-generationRefusalsSaid))
|
||||
f.doing, f.doingErr = inv.NotDoingWhatTheyWereTold(ctx)
|
||||
f.facts.taken, _, f.facts.began, f.facts.err = exportedFacts.last()
|
||||
return f
|
||||
}
|
||||
|
||||
@@ -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-20261011092230-20d5af9b2d5f
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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-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-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,6 +246,11 @@ 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
|
||||
@@ -543,6 +548,9 @@ 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
|
||||
@@ -1115,6 +1123,13 @@ 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
|
||||
@@ -1141,6 +1156,12 @@ 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 {
|
||||
@@ -1149,6 +1170,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -436,6 +436,12 @@ 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"
|
||||
@@ -487,6 +493,11 @@ 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 a76baa4b47f07a8b2bab065603414530bd02b53a examples/foundation-first-node-nats.lock
|
||||
mesh-host 3f7f797168d33ec22241bdd6ef2c64de86eea914 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:
|
||||
|
||||
+78
@@ -464,6 +464,35 @@ 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 }
|
||||
@@ -482,6 +511,24 @@ 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
|
||||
}
|
||||
|
||||
@@ -1668,6 +1715,7 @@ 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 {
|
||||
@@ -1732,6 +1780,36 @@ 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-20261011092230-20d5af9b2d5f
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3
|
||||
## 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-20261011092230-20d5af9b2d5f
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3
|
||||
|
||||
Reference in New Issue
Block a user