Merge remote-tracking branch 'origin/main' into adr-0297-module-logs
This commit is contained in:
@@ -79,23 +79,38 @@ func TestAHolderMeetsItsOwnContribution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoModulesDeclaringOnePackageAreRefusedBeforeAnythingIsRecorded(t *testing.T) {
|
||||
func TestTwoModulesMayDeclareOnePackage(t *testing.T) {
|
||||
pacman := withResources(mod("pacman", nil, nil, nil, Claim{Name: PackageManagerSeat}),
|
||||
res("package", "pacman-contrib"))
|
||||
bar := withResources(mod("bar", nil, nil, nil), res("package", "bar"), res("package", "pacman-contrib"))
|
||||
other := withResources(mod("other", nil, nil, nil), res("package", "other"))
|
||||
catalogue := map[string]Manifest{"pacman": pacman, "bar": bar, "other": other}
|
||||
catalogue := map[string]Manifest{"pacman": pacman, "bar": bar}
|
||||
if err := CollisionRefusal(catalogue, "laptop", []string{"pacman"}, []string{"bar"}); err != nil {
|
||||
t.Fatalf("a second declaration of a package was refused (novox/hq ADR 0303): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err := CollisionRefusal(catalogue, "laptop", []string{"pacman"}, []string{"bar"})
|
||||
if err == nil || !strings.Contains(err.Error(), "pacman-contrib") || !strings.Contains(err.Error(), "bar") {
|
||||
t.Fatalf("the second owner of a package was not refused by name: %v", err)
|
||||
func TestAPackageDeclaredPresentAndAbsentIsRefusedNamingBoth(t *testing.T) {
|
||||
gone := res("package", "ufw")
|
||||
gone["absent"] = true
|
||||
filter := withResources(mod("nftables", nil, nil, nil), gone)
|
||||
wants := withResources(mod("legacy", nil, nil, nil), res("package", "ufw"))
|
||||
other := withResources(mod("other", nil, nil, nil), res("package", "other"))
|
||||
catalogue := map[string]Manifest{"nftables": filter, "legacy": wants, "other": other}
|
||||
|
||||
err := CollisionRefusal(catalogue, "laptop", []string{"nftables"}, []string{"legacy"})
|
||||
if err == nil || !strings.Contains(err.Error(), "ufw") || !strings.Contains(err.Error(), "nftables") ||
|
||||
!strings.Contains(err.Error(), "legacy") || !strings.Contains(err.Error(), "ADR 0303") {
|
||||
t.Fatalf("a package declared present and absent was not refused naming both: %v", err)
|
||||
}
|
||||
if err := CollisionRefusal(catalogue, "laptop", []string{"pacman"}, []string{"other"}); err != nil {
|
||||
t.Errorf("a module declaring nothing shared is refused: %v", err)
|
||||
if strings.Contains(err.Error(), "one owner per node") {
|
||||
t.Errorf("the refusal gives the one-owner rule, which no longer holds for packages: %v", err)
|
||||
}
|
||||
// A collision already on the node is status's, not a reason to refuse an unrelated assignment.
|
||||
if err := CollisionRefusal(catalogue, "laptop", []string{"pacman", "bar"}, []string{"other"}); err != nil {
|
||||
t.Errorf("an unrelated assignment is refused for a collision already there: %v", err)
|
||||
if err := CollisionRefusal(catalogue, "laptop", []string{"nftables"}, []string{"other"}); err != nil {
|
||||
t.Errorf("a module declaring nothing contradictory is refused: %v", err)
|
||||
}
|
||||
// A contradiction already on the node is status's, not a reason to refuse an unrelated assignment.
|
||||
if err := CollisionRefusal(catalogue, "laptop", []string{"nftables", "legacy"}, []string{"other"}); err != nil {
|
||||
t.Errorf("an unrelated assignment is refused for a contradiction already there: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -342,18 +342,38 @@ func (e *NotMadeError) Error() string {
|
||||
// compose. Empty when every module composes. The same judgement SetSettings makes before storing.
|
||||
func (r Resolution) LeftOut(settings SettingsBy, adopted bool) map[string]string {
|
||||
out := map[string]string{}
|
||||
for module, why := range r.LeftOutBecause(settings, adopted) {
|
||||
out[module] = why.Error()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LeftOutBecause is LeftOut with each reason as the error it was, so a reader can tell a setting nobody
|
||||
// gave (an *UnsetSettingError) from any other cause and say it as the operator's to give (novox/hq issue
|
||||
// 380). An UnreadManifestError for a stored manifest this controller cannot read whole.
|
||||
func (r Resolution) LeftOutBecause(settings SettingsBy, adopted bool) map[string]error {
|
||||
out := map[string]error{}
|
||||
for _, m := range r.Modules {
|
||||
if why := UnknownFieldReason(m); why != "" {
|
||||
out[m.Module] = why
|
||||
out[m.Module] = &UnreadManifestError{Module: m.Module, said: why}
|
||||
continue
|
||||
}
|
||||
if err := JudgeSettings(m, settings[m.Module], adopted); err != nil {
|
||||
out[m.Module] = err.Error()
|
||||
out[m.Module] = err
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UnreadManifestError is a module left out because its stored manifest has a key this controller does not know
|
||||
// (novox/hq ADR 0262): said once for the whole mesh, by the catalogue's own condition, not per machine.
|
||||
type UnreadManifestError struct {
|
||||
Module string
|
||||
said string
|
||||
}
|
||||
|
||||
func (e *UnreadManifestError) Error() string { return e.said }
|
||||
|
||||
// Compose is Declaration with the owner of every resource said.
|
||||
func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||
owner := map[string]string{}
|
||||
|
||||
@@ -21,7 +21,7 @@ func deliveryVerbs() []Verb {
|
||||
// Delivery time against the delivery budgets (novox/hq ADR 0282): what probe D16 reads, optional until its
|
||||
// holder serves it.
|
||||
{Name: "times", Description: "Delivery time: from a merge to every machine of its walk running the build. " +
|
||||
"Each class's delivery budget (a leaf module five minutes, a core module ten), the last days' median and " +
|
||||
"Each class's delivery budget (a leaf module three minutes, a core module ten), the last days' median and " +
|
||||
"worst, how many within and over, the newest delivery of each class, every delivery over its budget with its " +
|
||||
"longest phase, and the delivered ones whose time is not known. Given a delivery's id, its time phase by phase.",
|
||||
Input: schema(map[string]string{"days": "how many days back (default 7)", "id": "one delivery's id: its phases"}, nil),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/beside"
|
||||
)
|
||||
|
||||
// The controller refuses a definition's resolved path by the node-engine's own rules, no more (novox/hq issue
|
||||
// 496): the same lists, read here from mesh-host's internal/apply/placement_guard.go — in a merge check the clone
|
||||
// beside it at the commit the mesh runs, elsewhere the copy captured in testdata/beside. When the engine's lists
|
||||
// move, this fails until the controller's move with them, so the gate never refuses what the engine applies nor
|
||||
// passes what it refuses by path alone.
|
||||
func TestTheResolvedPathRulesAreTheNodeEnginesOwn(t *testing.T) {
|
||||
file := filepath.Join(beside.Dir(t, "mesh-host"), "internal", "apply", "placement_guard.go")
|
||||
parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("the node-engine's guard does not parse: %v", err)
|
||||
}
|
||||
lists := map[string][]string{}
|
||||
consts := map[string]string{}
|
||||
ast.Inspect(parsed, func(n ast.Node) bool {
|
||||
spec, ok := n.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
for i, name := range spec.Names {
|
||||
if i >= len(spec.Values) {
|
||||
continue
|
||||
}
|
||||
switch v := spec.Values[i].(type) {
|
||||
case *ast.CompositeLit:
|
||||
for _, elt := range v.Elts {
|
||||
if lit, ok := elt.(*ast.BasicLit); ok && lit.Kind == token.STRING {
|
||||
s, _ := strconv.Unquote(lit.Value)
|
||||
lists[name.Name] = append(lists[name.Name], s)
|
||||
}
|
||||
}
|
||||
case *ast.BasicLit:
|
||||
if v.Kind == token.STRING {
|
||||
consts[name.Name], _ = strconv.Unquote(v.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
for name, ours := range map[string][]string{
|
||||
"protectedRoots": protectedRoots, "forbiddenBelow": forbiddenBelow, "engineTrees": engineTrees,
|
||||
} {
|
||||
theirs := lists[name]
|
||||
if len(theirs) == 0 {
|
||||
t.Errorf("the node-engine's guard names no %s any more; read it and say where its rule went", name)
|
||||
continue
|
||||
}
|
||||
a, b := slices.Clone(ours), slices.Clone(theirs)
|
||||
slices.Sort(a)
|
||||
slices.Sort(b)
|
||||
if !slices.Equal(a, b) {
|
||||
t.Errorf("%s differs from the node-engine's:\n controller %v\n node-engine %v", name, a, b)
|
||||
}
|
||||
}
|
||||
if consts["engineModule"] != engineModule {
|
||||
t.Errorf("the node-engine's own module is %q there and %q here", consts["engineModule"], engineModule)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A provision's credential may be issued by its provider's software (novox/hq ADR 0305, issue 526): the offer
|
||||
// names the tool that answers it and no own secret, and every other shape is refused at parse.
|
||||
func TestAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"media","version":"1",
|
||||
"provides":[{"name":"media-api","scope":"mesh","credential":{"issued-by":"software","tool":"media_issued_credential"}}],
|
||||
"serves":{"media-api":{"port":32400}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tool, issued := m.IssuedCredentialOf("media-api"); !issued || tool != "media_issued_credential" {
|
||||
t.Fatalf("the offer's issued credential was not read: %q %v", tool, issued)
|
||||
}
|
||||
if _, shared := m.SharedCredentialOf("media-api"); shared {
|
||||
t.Fatal("an issued credential was read as an own secret shared")
|
||||
}
|
||||
if _, issued := m.IssuedCredentialOf("other"); issued {
|
||||
t.Fatal("a provision the module does not offer reads as issued")
|
||||
}
|
||||
|
||||
for want, raw := range map[string]string{
|
||||
"the one issuer an offer may name": `{"module":"d","version":"1",
|
||||
"provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`,
|
||||
"names no tool that answers it": `{"module":"d","version":"1",
|
||||
"provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`,
|
||||
"names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}},
|
||||
"provides":[{"name":"d-api","credential":{"issued-by":"software","tool":"d_token","own":"token"}}]}`,
|
||||
"the one issuer an offer may name ": `{"module":"d","version":"1",
|
||||
"provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`,
|
||||
} {
|
||||
want = strings.TrimSpace(want)
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("expected a refusal saying %q, got %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -268,9 +268,29 @@ func KeepsConsumerData(catalogue map[string]Manifest, provision string) bool {
|
||||
// MachineReach is whether a provision is usable only on its provider's own machine.
|
||||
func (o Offer) MachineReach() bool { return o.Reach == ReachMachine }
|
||||
|
||||
// OfferCredential names which of the provider's own secrets a provision's consumers receive.
|
||||
// OfferCredential names which of the provider's own secrets a provision's consumers receive — or, with
|
||||
// IssuedBy, that the provider's software issues the provision's one credential itself (novox/hq ADR 0305,
|
||||
// issue 526): a media server's sign-in token from its vendor, which the mesh can neither make nor rotate.
|
||||
// Tool is then the provider's own tool that answers that value, sealed to a key made for the controller's
|
||||
// one call, and to the controller alone.
|
||||
type OfferCredential struct {
|
||||
Own string `json:"own"`
|
||||
Own string `json:"own,omitempty"`
|
||||
IssuedBy string `json:"issued-by,omitempty"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
}
|
||||
|
||||
// IssuedBySoftware says a provision's credential is issued by its provider's software (novox/hq ADR 0305).
|
||||
const IssuedBySoftware = "software"
|
||||
|
||||
// IssuedCredentialOf is the tool through which this module answers the credential its software issued for a
|
||||
// provision, and whether its offer says the software issues it (novox/hq ADR 0305).
|
||||
func (m Manifest) IssuedCredentialOf(provision string) (string, bool) {
|
||||
for _, o := range m.Provides {
|
||||
if o.Name == provision && o.Credential != nil && o.Credential.IssuedBy == IssuedBySoftware && o.Credential.Tool != "" {
|
||||
return o.Credential.Tool, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// SharedCredentialOf is the own secret an offer of this module names as the provision's credential,
|
||||
@@ -1555,7 +1575,24 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
"makes is %d", m.Module, p, id.Max, shortest))
|
||||
}
|
||||
}
|
||||
if offer.Credential != nil {
|
||||
if c := offer.Credential; c != nil && (c.IssuedBy != "" || c.Tool != "") {
|
||||
// The software issues it (novox/hq ADR 0305): named by the tool that answers it, never by an own
|
||||
// secret, which would be a second copy for the mesh to keep in step with the software's.
|
||||
switch {
|
||||
case c.IssuedBy != IssuedBySoftware:
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s provides %q with a credential issued by %q; the one issuer an offer may name is %q (ADR 0305)",
|
||||
m.Module, p, c.IssuedBy, IssuedBySoftware))
|
||||
case c.Own != "":
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s provides %q with a credential its software issues and names its own secret %q too; the "+
|
||||
"software's value is the one, so the offer names no own secret (ADR 0305)", m.Module, p, c.Own))
|
||||
case strings.TrimSpace(c.Tool) == "":
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s provides %q with a credential its software issues and names no tool that answers it: "+
|
||||
"\"tool\": \"<its tool>\" (ADR 0305)", m.Module, p))
|
||||
}
|
||||
} else if offer.Credential != nil {
|
||||
own, declared := m.OwnSecrets[offer.Credential.Own]
|
||||
switch {
|
||||
case offer.Credential.Own == "":
|
||||
@@ -2145,6 +2182,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
problems = append(problems, m.undeclaredMounts()...)
|
||||
problems = append(problems, m.unknownDirRefs()...)
|
||||
problems = append(problems, m.unknownAccessRefs()...)
|
||||
problems = append(problems, m.resolvedPathProblems()...)
|
||||
problems = append(problems, m.jailProblems()...)
|
||||
// What a module adds to the account's environment and to the login shell, and the holder's
|
||||
// placeholders for them (novox/hq ADR 0203, ADR 0204) — here, so the catalogue check refuses
|
||||
|
||||
@@ -92,6 +92,105 @@ func systemPath(path string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Where no directory or file a module's definition resolves to may be (novox/hq issue 496).
|
||||
//
|
||||
// mesh-catalog #205 gave docker's `state` directory `"place": "."`, which resolves to <root>/docker: with the
|
||||
// default root, /var/lib/docker, every container's filesystem. systemPath judged only the `places` and `accesses`
|
||||
// settings, so the default layout and a definition's own paths reached the merge gate unjudged; it composed every
|
||||
// machine and passed, and only the node-engine refused the directory, at apply, failing the walk.
|
||||
//
|
||||
// **Judged where a definition is judged, never where a machine is composed.** resolvedPathProblems runs in
|
||||
// ParseManifest, which every route a definition takes into the mesh passes: `module check`, registration of a
|
||||
// build (the builder's and the registry verbs'), and the merge gate's reading of the changed repository. A
|
||||
// refusal there stops one definition before it reaches any machine. Composition reads registered manifests
|
||||
// without ParseManifest, and judges nothing of this: refusing there would fail the whole machine's declaration and
|
||||
// freeze every module on it for one module's path, where the node-engine fails only that resource.
|
||||
//
|
||||
// **The node-engine's rules, no more** (mesh-host's internal/apply/placement_guard.go, with files judged as
|
||||
// directories are after novox/hq issue 495, rule 6). A path is refused when it is one of protectedRoots or holds
|
||||
// one, when it is at or below a tree in forbiddenBelow, or at or below one of engineTrees and its module is not
|
||||
// the node-engine's. What the engine judges with what only the machine knows stays the engine's: where the
|
||||
// runtimes really keep their data, links, the accounts' homes, a directory's owner below /etc. The lists are the
|
||||
// engine's own words, and a test (engine_guard_test.go) holds them equal to mesh-host's beside this repository.
|
||||
// systemPath stays the stricter rule for a setting: a setting is an operator's word about one machine, and the
|
||||
// trees it lists (/etc, /usr, /run, the mesh's own) are where the mesh's own modules write by design.
|
||||
var (
|
||||
protectedRoots = []string{"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64",
|
||||
"/media", "/mnt", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp", "/usr", "/usr/bin",
|
||||
"/usr/lib", "/usr/lib64", "/usr/local", "/usr/local/bin", "/usr/local/lib", "/usr/local/sbin", "/usr/sbin",
|
||||
"/usr/share", "/var", "/var/cache", "/var/lib", "/var/lib/mesh", "/var/log", "/var/run", "/var/tmp",
|
||||
"/var/spool"}
|
||||
forbiddenBelow = []string{"/proc", "/sys", "/dev", "/boot", "/root", "/var/spool", "/opt", "/var/lib/docker",
|
||||
"/var/lib/containers", "/var/lib/containerd"}
|
||||
engineTrees = []string{"/var/lib/mesh-host", "/usr/lib/nox-mesh-host"}
|
||||
)
|
||||
|
||||
// engineModule is the node-engine's own module, the one that places in engineTrees.
|
||||
const engineModule = "mesh-host"
|
||||
|
||||
// enginePath says why the node-engine refuses a directory or file at path for module, or "".
|
||||
func enginePath(path, module string) string {
|
||||
path = filepath.Clean(path)
|
||||
if !filepath.IsAbs(path) {
|
||||
return ""
|
||||
}
|
||||
atOrBelow := func(tree string) bool { return path == tree || strings.HasPrefix(path, tree+"/") }
|
||||
for _, root := range protectedRoots {
|
||||
if path == root || path == "/" || strings.HasPrefix(root, path+"/") {
|
||||
return root + " is one of the machine's own directories, and owning it is owning everything in it"
|
||||
}
|
||||
}
|
||||
for _, tree := range forbiddenBelow {
|
||||
if atOrBelow(tree) {
|
||||
return "nothing is placed in " + tree
|
||||
}
|
||||
}
|
||||
if module != engineModule {
|
||||
for _, tree := range engineTrees {
|
||||
if atOrBelow(tree) {
|
||||
return tree + " is the node-engine's own, placed in by its own module alone"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolvedPathProblems is every directory and file of the definition whose path, resolved as a node with the
|
||||
// default root resolves it, the node-engine would refuse (novox/hq issue 496). A path still holding a placeholder
|
||||
// only a machine fills (a setting, an access the definition gives no default) is the engine's to judge.
|
||||
func (m Manifest) resolvedPathProblems() []string {
|
||||
dirs := dirsFor(m, Rendering{})
|
||||
accesses := map[string]string{}
|
||||
for _, a := range m.Accesses {
|
||||
if a.ID != "" && a.Path != "" {
|
||||
accesses[a.ID] = a.Path
|
||||
}
|
||||
}
|
||||
var problems []string
|
||||
for _, r := range m.Resources {
|
||||
kind := fmt.Sprint(r["type"])
|
||||
if kind != "directory" && kind != "file" {
|
||||
continue
|
||||
}
|
||||
id := fmt.Sprint(r["id"])
|
||||
path, _ := r["path"].(string)
|
||||
if kind == "directory" && path == "" {
|
||||
path = dirs[id]
|
||||
}
|
||||
path, _ = dirFill(path, dirs, m.Module)
|
||||
path, _ = accessFill(path, accesses, m.Module)
|
||||
if path == "" || strings.Contains(path, "${") {
|
||||
continue
|
||||
}
|
||||
if why := enginePath(path, m.Module); why != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s's %s %q resolves to %s, which the node-engine refuses: %s. "+
|
||||
"A module's directories and files are judged when its definition is, so the merge gate refuses it "+
|
||||
"before any machine does (novox/hq issue 496)", m.Module, kind, id, filepath.Clean(path), why))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// accessRef is how a module names one of its accesses: ${access:<id>}.
|
||||
var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`)
|
||||
|
||||
|
||||
@@ -960,6 +960,7 @@ func checkResources(modules []Manifest) []string {
|
||||
var problems []string
|
||||
owner := map[string]string{}
|
||||
ownedPath := map[string]string{} // path → owning module, for the access check below
|
||||
packagePresent, packageAbsent := map[string][]string{}, map[string][]string{}
|
||||
|
||||
for _, m := range modules {
|
||||
// Compared placed, not as written (novox/hq ADR 0112): ${dir:state} is the same six
|
||||
@@ -992,7 +993,22 @@ func checkResources(modules []Manifest) []string {
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, field := range []string{"path", "unit", "name", "package"} {
|
||||
if fmt.Sprint(r["type"]) == "package" {
|
||||
// **A package may be declared by several modules** (novox/hq ADR 0303): declaring it says
|
||||
// it is needed, and the host installs a missing one, never removes one a declaration
|
||||
// stopped naming, so a second declaration changes nothing on the machine. What two
|
||||
// modules can contradict is presence: one declaring it present and another absent
|
||||
// (ADR 0180) would have the host install and remove it in turn, so that pair is refused.
|
||||
if name, _ := r["package"].(string); name != "" {
|
||||
if r["absent"] == true {
|
||||
packageAbsent[name] = append(packageAbsent[name], m.Module)
|
||||
} else {
|
||||
packagePresent[name] = append(packagePresent[name], m.Module)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, field := range []string{"path", "unit", "name"} {
|
||||
value, ok := r[field].(string)
|
||||
if !ok || value == "" {
|
||||
if field == "path" && fmt.Sprint(r["type"]) == "directory" {
|
||||
@@ -1038,6 +1054,12 @@ func checkResources(modules []Manifest) []string {
|
||||
ownedPath[fact.Path] = m.Module
|
||||
}
|
||||
}
|
||||
for _, name := range packageNames(packageAbsent) {
|
||||
if wanted := packagePresent[name]; len(wanted) > 0 {
|
||||
problems = append(problems, fmt.Sprintf("%s declares the package %q absent and %s declare it present (novox/hq ADR 0303)",
|
||||
strings.Join(packageAbsent[name], ", "), name, strings.Join(wanted, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
// An accessed path is the operator's, so no module may declare it as one of its own
|
||||
// (novox/hq ADR 0051). Refused here rather than silently tolerated: an owner would create and
|
||||
@@ -1308,3 +1330,13 @@ func answeredElsewhere(want string, node Node, world World, brokered, keeps map[
|
||||
holder, held := HolderAmong(want, world.Offered[want], world.Held)
|
||||
return held && holder.Node != node.Name
|
||||
}
|
||||
|
||||
// packageNames are the packages a map names, in order.
|
||||
func packageNames(m map[string][]string) []string {
|
||||
names := make([]string, 0, len(m))
|
||||
for n := range m {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package catalogue
|
||||
|
||||
// A definition's directories and files are judged at their resolved paths when the definition is (novox/hq issue
|
||||
// 496). mesh-catalog #205 gave docker's `state` directory `"place": "."`, which resolves to <root>/docker —
|
||||
// /var/lib/docker, every container's filesystem. The merge gate composed every machine and passed it; only the
|
||||
// node-engine refused it, at apply, and failed the walk. ParseManifest is what `module check`, registration and the
|
||||
// merge gate's reading of a changed repository all run, so a refusal here is a refusal at each of them.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestADirectoryPlacedInDockersDataIsRefusedWhereTheDefinitionIsJudged(t *testing.T) {
|
||||
// The #205 shape, as it was merged.
|
||||
_, err := ParseManifest([]byte(`{"module": "docker", "version": "1",
|
||||
"resources": [{"id": "state", "type": "directory", "place": "."}]}`))
|
||||
if err == nil {
|
||||
t.Fatal("a directory resolving to /var/lib/docker was accepted; the node-engine refuses it at apply")
|
||||
}
|
||||
for _, said := range []string{"docker", `"state"`, "/var/lib/docker", "issue 496"} {
|
||||
if !strings.Contains(err.Error(), said) {
|
||||
t.Errorf("the refusal names the module, the resource, the path and why; %q is missing from %q", said, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMeshsPlaceForDockerIsAccepted(t *testing.T) {
|
||||
// The fix #205 needed: the mesh's own directory for the module, <root>/mesh/docker.
|
||||
m, err := ParseManifest([]byte(`{"module": "docker", "version": "1", "resources": [
|
||||
{"id": "state", "type": "directory", "place": "mesh"},
|
||||
{"id": "marker", "type": "file", "path": "${dir:state}/applied", "content": "x"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("place %q is in the mesh's tree, where the mesh writes for every module: %v", "mesh", err)
|
||||
}
|
||||
if got := dirsFor(m, Rendering{}); got["state"] != "/var/lib/mesh/docker" {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The node-engine's rules, for directories and — as the engine judges them since novox/hq issue 495 — files.
|
||||
func TestADefinitionIsRefusedWhereTheNodeEngineRefusesItsPaths(t *testing.T) {
|
||||
refused := map[string]string{
|
||||
"a stated directory in docker's data": `{"module": "sidecar", "version": "1", "resources": [
|
||||
{"id": "volumes", "type": "directory", "path": "/var/lib/docker/volumes/x"}]}`,
|
||||
"a file in containerd's data": `{"module": "images", "version": "1", "resources": [
|
||||
{"id": "f", "type": "file", "path": "/var/lib/containerd/x", "content": "x"}]}`,
|
||||
"a file beneath a placed directory that climbs out of it": `{"module": "docker", "version": "1", "resources": [
|
||||
{"id": "state", "type": "directory", "place": "mesh"},
|
||||
{"id": "f", "type": "file", "path": "${dir:state}/../../containers/x", "content": "x"}]}`,
|
||||
"a file in /boot": `{"module": "grub", "version": "1", "resources": [
|
||||
{"id": "cfg", "type": "file", "path": "/boot/grub/custom.cfg", "content": "x"}]}`,
|
||||
"a directory that is the mesh's whole tree": `{"module": "mesh", "version": "1", "resources": [
|
||||
{"id": "all", "type": "directory", "place": "."}]}`,
|
||||
"a directory that holds /etc": `{"module": "x", "version": "1", "resources": [
|
||||
{"id": "d", "type": "directory", "path": "/"}]}`,
|
||||
"a directory in the node-engine's own tree, by another module": `{"module": "intruder", "version": "1",
|
||||
"resources": [{"id": "d", "type": "directory", "path": "/var/lib/mesh-host/x"}]}`,
|
||||
}
|
||||
for name, raw := range refused {
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "issue 496") {
|
||||
t.Errorf("%s: accepted, or refused for another reason: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// What the engine applies, the mesh's own modules' paths among them (read from every machine's live plan):
|
||||
// the machine's configuration, run directories, programs below /usr/local, the node-engine's own trees by its
|
||||
// own module, an account's keys, a module's own root, and — not on the engine's lists, so not refused here —
|
||||
// below /lib and at /storage, /data, /services and /var/lock.
|
||||
accepted := map[string]string{
|
||||
"a unit in /etc": `{"module": "power", "version": "1", "resources": [
|
||||
{"id": "d", "type": "directory", "path": "/etc/systemd/system/x.service.d"},
|
||||
{"id": "u", "type": "file", "path": "/etc/systemd/system/x.service.d/a.conf", "content": "x"}]}`,
|
||||
"a run directory": `{"module": "fail2ban", "version": "1", "resources": [
|
||||
{"id": "run-dir", "type": "directory", "path": "/var/run/fail2ban"}]}`,
|
||||
"a program in /usr/local/bin": `{"module": "claude-code", "version": "1", "resources": [
|
||||
{"id": "start", "type": "file", "path": "/usr/local/bin/claude-agent", "content": "x"}]}`,
|
||||
"the node-engine's launcher and state, by the node-engine": `{"module": "mesh-host", "version": "1", "resources": [
|
||||
{"id": "launcher", "type": "file", "path": "/usr/lib/nox-mesh-host/launch", "content": "x"},
|
||||
{"id": "state", "type": "directory", "path": "/var/lib/mesh-host"}]}`,
|
||||
"an account's .ssh": `{"module": "ssh-client", "version": "1", "resources": [
|
||||
{"id": "ssh-dir", "type": "directory", "path": "/home/someone/.ssh"},
|
||||
{"id": "config", "type": "file", "path": "/home/someone/.ssh/config", "content": "x"}]}`,
|
||||
"a module's own root": `{"module": "mailu", "version": "1", "resources": [
|
||||
{"id": "state", "type": "directory", "place": "."}]}`,
|
||||
"a file below /lib": `{"module": "udev", "version": "1", "resources": [
|
||||
{"id": "rule", "type": "file", "path": "/lib/udev/rules.d/99-x.rules", "content": "x"}]}`,
|
||||
"directories at /storage, /data, /services and /var/lock": `{"module": "roots", "version": "1", "resources": [
|
||||
{"id": "a", "type": "directory", "path": "/storage"}, {"id": "b", "type": "directory", "path": "/data"},
|
||||
{"id": "c", "type": "directory", "path": "/services"}, {"id": "d", "type": "directory", "path": "/var/lock"}]}`,
|
||||
}
|
||||
for name, raw := range accepted {
|
||||
if _, err := ParseManifest([]byte(raw)); err != nil {
|
||||
t.Errorf("%s: refused: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPathThroughAnAccessIsJudgedWithTheAccessFilledIn(t *testing.T) {
|
||||
// A file's path may name an access; the access's default path is the definition's, so it is judged with it.
|
||||
_, err := ParseManifest([]byte(`{"module": "backup", "version": "1",
|
||||
"accesses": [{"id": "images", "path": "/var/lib/docker/volumes"}],
|
||||
"resources": [{"id": "marker", "type": "file", "path": "${access:images}/marker", "content": "x"}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "/var/lib/docker/volumes/marker") ||
|
||||
!strings.Contains(err.Error(), "issue 496") {
|
||||
t.Fatalf("a file reaching Docker's data through an access's default path was accepted: %v", err)
|
||||
}
|
||||
// An access the definition gives no path is placed by a setting, which Places and AccessPlaces judge, and on
|
||||
// the machine by the node-engine: nothing here to resolve it against, so nothing is refused for it.
|
||||
if _, err := ParseManifest([]byte(`{"module": "backup", "version": "1",
|
||||
"accesses": [{"id": "images"}],
|
||||
"resources": [{"id": "marker", "type": "file", "path": "${access:images}/marker", "content": "x"}]}`)); err != nil {
|
||||
t.Fatalf("an access placed only by a setting cannot be judged at the definition: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposingAMachineIsNeverStoppedByOneModulesPath(t *testing.T) {
|
||||
// A module registered from outside the catalogue never passes the gate. Refusing its path while a machine's
|
||||
// declaration is composed would fail the whole declaration and freeze every module on that machine; the
|
||||
// node-engine fails only the one resource. So composition leaves it to the engine.
|
||||
m := Manifest{Module: "docker", Version: "1", Resources: []map[string]any{
|
||||
{"id": "state", "type": "directory", "place": "."},
|
||||
}}
|
||||
if _, err := (Resolution{Node: "anchor", Modules: []Manifest{m}}).Declaration(Rendering{}); err != nil {
|
||||
t.Fatalf("the machine's declaration failed for one module's path: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -349,6 +349,10 @@ func CollisionRefusal(catalogue map[string]Manifest, node string, assigned, addi
|
||||
if before[p] {
|
||||
continue // on the node already; not this assignment's doing
|
||||
}
|
||||
if strings.Contains(p, "ADR 0303") {
|
||||
problems = append(problems, p) // a package declared present and absent says its own rule
|
||||
continue
|
||||
}
|
||||
problems = append(problems, p+" (novox/hq ADR 0210: one owner per node; the other module "+
|
||||
"depends on the owner's seat instead)")
|
||||
}
|
||||
|
||||
@@ -38,6 +38,17 @@ func settingsUsed(content string) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// UnsetSettingError is a module whose definition says ${setting:<key>} where nothing sets that key: typed, so
|
||||
// that whoever reads why a module was left out of a machine can tell a setting nobody gave — the operator's
|
||||
// to give, named with the command that gives it — from any other reason (novox/hq issue 380). Its words are
|
||||
// the refusal's, unchanged.
|
||||
type UnsetSettingError struct {
|
||||
Module, Setting string
|
||||
said string
|
||||
}
|
||||
|
||||
func (e *UnsetSettingError) Error() string { return e.said }
|
||||
|
||||
// settingInto fills a file's ${setting:…} placeholders from the layers over a module.
|
||||
//
|
||||
// The last layer setting a key wins, which is the node's over the mesh's over the module's own
|
||||
@@ -58,12 +69,12 @@ func settingInto(resource map[string]any, layers []Layer, module string) error {
|
||||
for _, key := range settingsUsed(content) {
|
||||
value, set := settingValue(layers, key)
|
||||
if !set {
|
||||
return fmt.Errorf(
|
||||
return &UnsetSettingError{Module: module, Setting: key, said: fmt.Sprintf(
|
||||
"%s has a file that 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), and only a "+
|
||||
"preference has a default in the definition (ADR 0262): "+
|
||||
"`settings set %s <file>` with {%q: …}%s",
|
||||
module, key, key, module, key, orNoSettings(layers))
|
||||
module, key, key, module, key, orNoSettings(layers))}
|
||||
}
|
||||
content = strings.ReplaceAll(content, "${setting:"+key+"}", plainly(value))
|
||||
}
|
||||
@@ -114,11 +125,11 @@ func settingIntoUnit(resource map[string]any, layers []Layer, module string) err
|
||||
for _, key := range settingsUsed(unit) {
|
||||
value, set := settingValue(layers, key)
|
||||
if !set {
|
||||
return fmt.Errorf(
|
||||
return &UnsetSettingError{Module: module, Setting: key, said: fmt.Sprintf(
|
||||
"%s has a service whose unit 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, key, key, module, key, orNoSettings(layers))
|
||||
module, key, key, module, key, orNoSettings(layers))}
|
||||
}
|
||||
v := plainly(value)
|
||||
if !unitPart.MatchString(v) {
|
||||
|
||||
@@ -160,8 +160,14 @@ var ControllerVerbs = []Verb{
|
||||
Input: schema(map[string]string{"plan": "the walk's id", "why": "what lets it go: its turn, a person's release"},
|
||||
[]string{"plan"})},
|
||||
{Name: "delivery-stop", Description: "End a walk on its delivery's word (novox/hq ADR 0239): failed, said as stopped " +
|
||||
"by whom and why; what it asked still builds and registers, nothing further is asked or sent.",
|
||||
Input: schema(map[string]string{"plan": "the walk's id", "why": "why", "by": "who stopped the delivery"},
|
||||
"by whom and why; what it asked still builds and registers, nothing further is asked or sent. Given merges to " +
|
||||
"leave out, every other merge the walk answered is walked again — in the open batch of its kind, or a new one — " +
|
||||
"without them, without each later merge of their repository, which contains one, and without each merge moving " +
|
||||
"a module one moves, each left out too and named (novox/hq ADR 0299); a batch not yet cut drops the merges. The " +
|
||||
"answer names the batch, the merges walked again, the merges left out, the modules the stopped walk sent that " +
|
||||
"stay on their machines, and the merges elsewhere that will still deliver a stopped change.",
|
||||
Input: schema(map[string]string{"plan": "the walk's id", "why": "why", "by": "who stopped the delivery",
|
||||
"without": "merges to leave out, <repository>@<commit>, comma-separated"},
|
||||
[]string{"plan", "why"})},
|
||||
{Name: "delivery-walks", Description: "The walks the controller keeps (novox/hq ADR 0239): every open one and the " +
|
||||
"last ended ones, each whole — its tiers, each module's state, first machines and first-node gate with its readings, and its " +
|
||||
@@ -243,6 +249,15 @@ var ControllerVerbs = []Verb{
|
||||
"secret": "the own secret's name in the module's definition",
|
||||
"at": "the machine the operator sits at, where the prompt opens",
|
||||
}, []string{"node", "module", "secret", "at"})},
|
||||
{Name: "issued", Description: "Take the credential a provider's software issued for a provision — a media " +
|
||||
"server's sign-in token — and keep it as that provision's one credential for every consumer (novox/hq ADR " +
|
||||
"0305): the controller asks the provider's own tool on its machine, in the controller's own name, for the " +
|
||||
"value sealed to a key made for this call alone, seals it to every holder and forgets it, and sends their " +
|
||||
"machines when it changed. Carries no value and answers none; a value every holder already holds is not " +
|
||||
"sealed again, so asking again restarts nothing. Refused for a provision no offer says its software issues.",
|
||||
Input: schema(map[string]string{
|
||||
"provision": "the provision whose provider's software issues its credential, e.g. plex-api",
|
||||
}, []string{"provision"})},
|
||||
{Name: "secret-ask", Description: "Ask the operator for a module's own secret (novox/hq ADR 0277): a prompt " +
|
||||
"that shows nothing of what is typed opens at the desk — the module's machine, or the one at names — " +
|
||||
"naming the module, the secret and who asked; the operator types the value there, never on a channel " +
|
||||
|
||||
Reference in New Issue
Block a user