Land feature 57's controller work on its release branch
The controller plans config, templates and moved providers' provisions, hands bus accounts to the vault and judges the new manifest words, on the feature's SDK and node-engine validator (feature 57, config and provisioning).
This commit is contained in:
@@ -56,7 +56,7 @@ func anchorRendering(adopted bool) Rendering {
|
||||
Generators: map[string]Generator{"overlay": hub{}},
|
||||
Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}},
|
||||
Settings: SettingsBy{"distribution": {{From: "node anchor",
|
||||
Values: map[string]any{ExposeSetting: map[string]any{"5000": FromEverywhere}}}}},
|
||||
Placement: map[string]any{ExposeSetting: map[string]any{"5000": FromEverywhere}}}}},
|
||||
Mesh: []string{"10.42.0.1"},
|
||||
Foundation: []int{5671},
|
||||
// What the machine reported faces outside, which every rule in the filter is written
|
||||
@@ -310,14 +310,14 @@ func TestAGivenPortIsUsedEverywhereThePortIs(t *testing.T) {
|
||||
func TestAGivenPortIsTheNodesAndReachesSomething(t *testing.T) {
|
||||
store := anAdoptedAnchor().Modules[1]
|
||||
node := func(v any) []Layer {
|
||||
return []Layer{{From: "anchor", Values: map[string]any{PortsSetting: v}}}
|
||||
return []Layer{{From: "anchor", Placement: map[string]any{PortsSetting: v}}}
|
||||
}
|
||||
if got, err := GivenPorts(store, node(map[string]any{"5432": float64(5433)})); err != nil ||
|
||||
got[5432] != 5433 {
|
||||
t.Fatalf("a node's given port was not read: %v %v", got, err)
|
||||
}
|
||||
if _, err := GivenPorts(store, []Layer{{From: MeshWideLayer,
|
||||
Values: map[string]any{PortsSetting: map[string]any{"5432": float64(5433)}}}}); err == nil {
|
||||
Placement: map[string]any{PortsSetting: map[string]any{"5432": float64(5433)}}}}); err == nil {
|
||||
t.Fatal("a port given for the whole mesh was accepted")
|
||||
}
|
||||
if _, err := GivenPorts(store, node(map[string]any{"6000": float64(6001)})); err == nil {
|
||||
@@ -409,7 +409,7 @@ func TestAGivenPortIsRefusedForAPortNoContainerPublishes(t *testing.T) {
|
||||
onTheMachine := Manifest{Module: "daemon",
|
||||
Listens: []Listening{{Port: 9000, From: FromMesh}}, Guards: []int{9000}}
|
||||
layers := []Layer{{From: "node anchor",
|
||||
Values: map[string]any{PortsSetting: map[string]any{"9000": float64(9100)}}}}
|
||||
Placement: map[string]any{PortsSetting: map[string]any{"9000": float64(9100)}}}}
|
||||
_, err := GivenPorts(onTheMachine, layers)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not publish") {
|
||||
t.Fatalf("a port no container publishes was given: %v", err)
|
||||
@@ -421,7 +421,7 @@ func TestAGivenPortIsRefusedForAPortNoContainerPublishes(t *testing.T) {
|
||||
func TestAGuardedPortOpenedToEveryoneIsNotGuarded(t *testing.T) {
|
||||
with := anchorRendering(true)
|
||||
with.Settings["postgres"] = []Layer{{From: "node anchor",
|
||||
Values: map[string]any{ExposeSetting: map[string]any{"5432": FromEverywhere}}}}
|
||||
Placement: map[string]any{ExposeSetting: map[string]any{"5432": FromEverywhere}}}}
|
||||
composed, err := anAdoptedAnchor().Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -477,7 +477,7 @@ func portsAsThePlanWould(m Manifest, given map[int]int) map[int]int {
|
||||
func TestAGivenPortNamesEitherEndOfWhatTheModulePublishes(t *testing.T) {
|
||||
forge := aForge()
|
||||
node := func(v any) []Layer {
|
||||
return []Layer{{From: "node anchor", Values: map[string]any{PortsSetting: v}}}
|
||||
return []Layer{{From: "node anchor", Placement: map[string]any{PortsSetting: v}}}
|
||||
}
|
||||
|
||||
// The machine side — the number this module says it listens on, and the one the predecessor
|
||||
@@ -573,7 +573,7 @@ func TestAGivenPortNamesEitherEndOfWhatTheModulePublishes(t *testing.T) {
|
||||
func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T) {
|
||||
forge := aForge()
|
||||
given, err := GivenPorts(forge, []Layer{{From: "node anchor",
|
||||
Values: map[string]any{PortsSetting: map[string]any{"2222": float64(222)}}}})
|
||||
Placement: map[string]any{PortsSetting: map[string]any{"2222": float64(222)}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("the machine side of a mapping cannot be given a port: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// What the machine's modules back up is placement (design 57 L3, issue 794). The holder of node-backup names it as
|
||||
// `${backup:lines}` (what to run and which paths to keep) and `${backup:data}` (every item with its class and
|
||||
// protection): the text `${contribution:node-backup:backup}` and `${contribution:node-backup:data}` render, from the
|
||||
// same function, derived from each placed module's declared data (novox/hq ADR 0233). A handed holder has the text in
|
||||
// its plan's placement for its client to fill; one not handed has the controller fill it where it stands.
|
||||
|
||||
// BackupNamespace is the placeholder kind of the machine's backup lines and data.
|
||||
const BackupNamespace = "backup"
|
||||
|
||||
// ofBackup is where a holder places them: ${backup:<key>}. Loose inside the braces, so a misspelt key is found and
|
||||
// refused rather than written out as text.
|
||||
var ofBackup = regexp.MustCompile(`\$\{backup:([^}]*)\}`)
|
||||
|
||||
// backupKinds is the contribution kind each key of the backup placement renders.
|
||||
var backupKinds = map[string]string{"lines": BackupKindBackup, "data": BackupKindData}
|
||||
|
||||
// backupPlacer is the machine's modules the backup placement is composed over, as the old contributions are.
|
||||
type backupPlacer struct {
|
||||
modules []Manifest
|
||||
with Rendering
|
||||
facts map[string]string
|
||||
caps map[string]bool
|
||||
unplaced *[]string
|
||||
}
|
||||
|
||||
// placed is the text of one key for a holder, or what is wrong with naming it.
|
||||
func (b backupPlacer) placed(holder Manifest, key string, item bool) (string, string) {
|
||||
kind, known := backupKinds[key]
|
||||
switch {
|
||||
case item:
|
||||
return "", "an item is kept per item; the machine's backup lines are placed only in a file"
|
||||
case !known:
|
||||
return "", fmt.Sprintf("the backup placement has `lines` and `data`, not %q", key)
|
||||
case !holder.ClaimsSeat(BackupSeat):
|
||||
return "", fmt.Sprintf("%s does not claim %s; the machine's backup lines are placed by its holder alone "+
|
||||
"(novox/hq ADR 0212)", holder.Module, BackupSeat)
|
||||
}
|
||||
text, left, err := seatContributions(b.modules, holder, BackupSeat+":"+kind, b.with, b.facts, b.caps)
|
||||
if b.unplaced != nil {
|
||||
*b.unplaced = append(*b.unplaced, left...)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
return text, ""
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A machine's backup lines are placement (design 57 L3, issue 794): the holder of node-backup names them as
|
||||
// `${backup:lines}` and `${backup:data}`, which a handed holder has its client fill from the plan and an unhanded
|
||||
// one has the controller fill as it fills `${contribution:node-backup:backup}` and `${...:data}`: the same text.
|
||||
|
||||
const (
|
||||
oldBackupText = "# lines\n${contribution:node-backup:backup}"
|
||||
oldDataText = "# data\n${contribution:node-backup:data}"
|
||||
placedBackupTxt = "# lines\n${backup:lines}"
|
||||
placedDataTxt = "# data\n${backup:data}"
|
||||
backupHolder = `{"module":"restic","version":"1","moved":true,"claims":[{"name":"node-backup","scope":"node"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"declared","type":"file","path":"/etc/backups.conf","template":"files/backups"},
|
||||
{"id":"data-declared","type":"file","path":"/etc/data.conf","template":"files/data"}]}`
|
||||
)
|
||||
|
||||
func dataModule(name string) string {
|
||||
return `{"module":"` + name + `","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}],
|
||||
"data":{"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable","why":"the ` + name + ` data"}]}}`
|
||||
}
|
||||
|
||||
// backupsOn is a machine of a holder whose files say these texts and the modules that declare data.
|
||||
func backupsOn(t *testing.T, lines, data string, modules ...string) Resolution {
|
||||
t.Helper()
|
||||
all := []Manifest{held(t, backupHolder, builtWith("restic", map[string]string{"backups": lines, "data": data}))}
|
||||
names := []string{"restic"}
|
||||
for _, name := range modules {
|
||||
all = append(all, mustParse(t, dataModule(name)))
|
||||
names = append(names, name)
|
||||
}
|
||||
r, err := Resolve(shelf(all...), names, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
var backupModules = []string{"alpha", "beta", "gamma"}
|
||||
|
||||
// gammaLeftOut is a machine whose gamma does not place: its lines are kept, best effort.
|
||||
func gammaLeftOut(with Rendering) Rendering {
|
||||
with.NotPlaced = map[string]string{"gamma": "its placement setting does not read"}
|
||||
return with
|
||||
}
|
||||
|
||||
func TestTheBackupPlacementIsTheTextTheOldPlaceholdersRender(t *testing.T) {
|
||||
// The old form is an unmoved holder: its placeholders are the controller's alone.
|
||||
oldHolder := strings.Replace(backupHolder, `"moved":true,`, "", 1)
|
||||
all := []Manifest{held(t, oldHolder, builtWith("restic", map[string]string{"backups": oldBackupText, "data": oldDataText}))}
|
||||
for _, name := range backupModules {
|
||||
all = append(all, mustParse(t, dataModule(name)))
|
||||
}
|
||||
old, err := Resolve(shelf(all...), append([]string{"restic"}, backupModules...), workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old.Account = "op"
|
||||
wantLines, wantData := fileOf(t, old, gammaLeftOut(Rendering{}), "restic.declared"), fileOf(t, old, gammaLeftOut(Rendering{}), "restic.data-declared")
|
||||
if !strings.Contains(wantLines, "# alpha\npath ") || !strings.Contains(wantLines, "# gamma\n") || !strings.Contains(wantData, "item d") {
|
||||
t.Fatalf("the old form renders\n%s\n%s", wantLines, wantData)
|
||||
}
|
||||
moved := backupsOn(t, placedBackupTxt, placedDataTxt, backupModules...)
|
||||
// Not handed: the controller fills the new placeholders with the old text.
|
||||
if got := fileOf(t, moved, gammaLeftOut(Rendering{}), "restic.declared"); got != wantLines {
|
||||
t.Errorf("not handed, the lines are\n%s\nwant\n%s", got, wantLines)
|
||||
}
|
||||
if got := fileOf(t, moved, gammaLeftOut(Rendering{}), "restic.data-declared"); got != wantData {
|
||||
t.Errorf("not handed, the data is\n%s\nwant\n%s", got, wantData)
|
||||
}
|
||||
// Handed: the plan carries the same text as placement, and the files are the client's.
|
||||
c, _ := composeWith(t, moved, gammaLeftOut(handingTo("store:5000")))
|
||||
p := c.Plans["restic"].Placement
|
||||
if got := "# lines\n" + p["backup:lines"]; got != wantLines {
|
||||
t.Errorf("handed, the plan's lines are\n%s\nwant\n%s", got, wantLines)
|
||||
}
|
||||
if got := "# data\n" + p["backup:data"]; got != wantData {
|
||||
t.Errorf("handed, the plan's data is\n%s\nwant\n%s", got, wantData)
|
||||
}
|
||||
if len(p) != 2 {
|
||||
t.Errorf("the plan's placement is %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleAddedOrTakenAwayChangesTheBackupPlan(t *testing.T) {
|
||||
plan := func(modules ...string) map[string]string {
|
||||
c, _ := composeWith(t, backupsOn(t, placedBackupTxt, placedDataTxt, modules...), handingTo("store:5000"))
|
||||
return c.Plans["restic"].Placement
|
||||
}
|
||||
two, three := plan("alpha", "beta"), plan("alpha", "beta", "gamma")
|
||||
if reflect.DeepEqual(two, three) || strings.Contains(two["backup:lines"], "gamma") || !strings.Contains(three["backup:lines"], "# gamma\n") {
|
||||
t.Errorf("two modules give %v, three give %v", two, three)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownBackupKeyIsRefused(t *testing.T) {
|
||||
r := backupsOn(t, "${backup:rows}", placedDataTxt, backupModules...)
|
||||
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "backup:rows") {
|
||||
t.Errorf("handed, an unknown key is %v", err)
|
||||
}
|
||||
if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), "backup:rows") {
|
||||
t.Errorf("not handed, an unknown key is %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func fileOf(t *testing.T, r Resolution, with Rendering, id string) string {
|
||||
t.Helper()
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, res := range out {
|
||||
if res["id"] == id {
|
||||
return res["content"].(string)
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s was not composed", id)
|
||||
return ""
|
||||
}
|
||||
@@ -111,6 +111,7 @@ func boundNeed(n Needed, bound Chosen, catalogue map[string]Manifest, node Node,
|
||||
}
|
||||
held.From, held.At, held.Module = node.Name, at, m.Module
|
||||
held.Serves, held.SharedOwn, held.Identity = servedByOne(m, n.Name), sharedByOne(m, n.Name), m.IdentityBoundOf(n.Name)
|
||||
held.CredentialForm = m.CredentialFormOf(n.Name)
|
||||
return held, true, ""
|
||||
}
|
||||
matching := bound.among(world.Offered[n.Name])
|
||||
@@ -124,6 +125,7 @@ func boundNeed(n Needed, bound Chosen, catalogue map[string]Manifest, node Node,
|
||||
identity := DefaultIdentityBound
|
||||
if pm, known := catalogue[p.Module]; known {
|
||||
held.SharedOwn, _ = pm.SharedCredentialOf(n.Name)
|
||||
held.CredentialForm = pm.CredentialFormOf(n.Name)
|
||||
identity = pm.IdentityBoundOf(n.Name)
|
||||
}
|
||||
held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity
|
||||
|
||||
@@ -77,26 +77,42 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
|
||||
// **Each template's text, as the build read it back from its templates archive** (novox/hq ADR 0349 §2): into
|
||||
// the file's content, so what the mesh holds and composes is what the same text inline would be.
|
||||
var handed *Handed
|
||||
if m.Templated() {
|
||||
name := TemplatesArtifact(m)
|
||||
texts := by[name].Texts
|
||||
if name == "" || texts == nil {
|
||||
return Manifest{}, fmt.Errorf("%s names templates, and the build carried no text read back from the "+
|
||||
"archive it packs %s/ into (%q): the builder that built it does not read templates",
|
||||
m.Module, TemplatesDir, name)
|
||||
m.Module, m.TemplatesDir(), name)
|
||||
}
|
||||
filled, err := fillTemplates(m.Module, m.Resources, func(template string) (string, bool) {
|
||||
t, ok := texts[strings.TrimPrefix(template, TemplatesDir+"/")]
|
||||
dir := m.TemplatesDir()
|
||||
filled, err := fillTemplates(m.Module, dir, m.Resources, func(template string) (string, bool) {
|
||||
t, ok := texts[strings.TrimPrefix(template, dir+"/")]
|
||||
return t, ok
|
||||
}, fmt.Sprintf("the archive %q", name))
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
items, err := fillItemTemplates(m, m.Items, func(template string) (string, bool) {
|
||||
t, ok := texts[strings.TrimPrefix(template, dir+"/")]
|
||||
return t, ok
|
||||
}, fmt.Sprintf("the archive %q", name))
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
// And, for a moved module, where its templates are and which one each file and item is filled from
|
||||
// (handed.go): read while its resources and items still name their templates.
|
||||
handed = handedBy(m, by[name])
|
||||
m.Resources = filled
|
||||
m.Items = items
|
||||
}
|
||||
|
||||
out := m
|
||||
out.Handed = handed
|
||||
out.Build = nil
|
||||
// Read with the build, like Build: the manifest the mesh holds carries each file's text (issue 731).
|
||||
out.Templates = ""
|
||||
out.Resources = nil
|
||||
// What the build compiled, kept on the resolved manifest (novox/hq ADR 0175): a tools bundle is
|
||||
// named by no resource of the module's own — the node's runtime loads it — so this is the only
|
||||
|
||||
@@ -21,6 +21,16 @@ func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) {
|
||||
t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err)
|
||||
}
|
||||
|
||||
// Its tools bundle says what it emits (novox/hq ADR 0357): the node's runtime carries the bundle and is
|
||||
// granted the module's events, so a bus module that ships one may declare them.
|
||||
bundled := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
||||
"emits":["bus.slow-consumer"],"tools":["bus_streams"],
|
||||
"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/t","binary":"t"}]}}`)
|
||||
if _, err := ParseManifest(bundled); err != nil {
|
||||
t.Fatalf("a bus module whose tools bundle declares what it emits was refused: %v", err)
|
||||
}
|
||||
|
||||
quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
||||
"tools":["bus_streams"]}`)
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Collections (novox/hq ADR 0349 clause 13, design 57 L1, issue 684): a provision with many providers.
|
||||
//
|
||||
// **A collector requires a collection; a contributor provides an item from a template.** A module taking additions
|
||||
// from others names a collection in a file of its own, `${collected:<collection>}`, and declares it under
|
||||
// `collections`, with the comment its tool writes a line in. Each contributor ships its item as a template of its
|
||||
// own repository and declares it under `items`: `{"collection": …, "template": "files/<name>"}`. Its build packs the
|
||||
// template with the module's others, and the mesh holds its text as it holds a file's (template.go).
|
||||
//
|
||||
// **The node's provisioner client fills both.** An item is filled on the node by the client of the node its
|
||||
// contributor is placed on, from the same owners as a file, and recorded in mesh-provisioner_collect; a collector's
|
||||
// file is filled by the same client from every item of its collection recorded for that node, so an item goes when
|
||||
// its contributor leaves. So a module contributing an item is a moved module (handed.go), and the controller renders
|
||||
// no item: the plan of a contributor names its items and their templates, and the plan of a collector names each
|
||||
// collection it reads and its comment. Neither carries a contributor's text.
|
||||
//
|
||||
// **The three old namespaces read as collections.** `${shell:<shell>:<slot>}`, `${environment:<format>}` and
|
||||
// `${contribution:<seat>:<kind>}` name, in a handed collector's file, the collection of the same name —
|
||||
// `shell:zsh:normal` — with the comment the controller wrote before each module's piece. An item is contributed to
|
||||
// one by that name, by a module the old form would have let contribute it. Each file is filled one way, never
|
||||
// both (ADR 0349 clause 17): a handed collector is filled from items alone and an unhanded one from the old forms
|
||||
// alone, and a piece of the other form on the same machine is said as not placed, never dropped quietly.
|
||||
|
||||
// Item is one item a module contributes to a collection: its template in the module's repository, or, in the
|
||||
// manifest the mesh holds, the template's text, read back from the build's templates archive like a file's.
|
||||
//
|
||||
// **Attributes, a capability and a name** (design 57 L3, issue 777): an item may carry `attributes` (string keys and
|
||||
// values; `order`, a whole number 0–99 defaulting to 50, orders the items of one placeholder) and `if-capability`,
|
||||
// the capability a machine must report for the item to be placed on it. A module may give several items to one
|
||||
// collection, each then with a `name` unique within the module and the collection. A collector takes only the items
|
||||
// whose attributes match, `${collected:<collection>:<k>=<v>,…}`, and all of them without the selector.
|
||||
type Item struct {
|
||||
Collection string `json:"collection"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Template string `json:"template,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
IfCapability string `json:"if-capability,omitempty"`
|
||||
}
|
||||
|
||||
// ItemKey is an item's key among its module's: the collection, and `/<name>` for a named item.
|
||||
func ItemKey(collection, name string) string {
|
||||
if name == "" {
|
||||
return collection
|
||||
}
|
||||
return collection + "/" + name
|
||||
}
|
||||
|
||||
func (it Item) key() string { return ItemKey(it.Collection, it.Name) }
|
||||
|
||||
// order is the item's place among the items of one placeholder: its `order` attribute, or OrderDefault.
|
||||
func (it Item) order() int {
|
||||
if n, err := strconv.Atoi(it.Attributes[OrderAttribute]); err == nil && n >= 0 && n <= 99 {
|
||||
return n
|
||||
}
|
||||
return OrderDefault
|
||||
}
|
||||
|
||||
// OrderAttribute is the attribute that orders items.
|
||||
const OrderAttribute = "order"
|
||||
|
||||
// matches is whether the item's attributes carry every pair of the selector.
|
||||
func (it Item) matches(where map[string]string) bool {
|
||||
for k, v := range where {
|
||||
if got, has := it.Attributes[k]; !has || got != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// itemsInOrder is the module's items by order, then name; the order the old form placed a module's pieces in.
|
||||
func itemsInOrder(items []Item) []Item {
|
||||
out := append([]Item(nil), items...)
|
||||
sort.SliceStable(out, func(a, b int) bool {
|
||||
if oa, ob := out[a].order(), out[b].order(); oa != ob {
|
||||
return oa < ob
|
||||
}
|
||||
return out[a].Name < out[b].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Collection is one collection a module collects, and the comment its tool writes a line in: each contributor's item
|
||||
// is introduced by `<comment> <contributor>`, and by nothing without one.
|
||||
type Collection struct {
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// CollectedNamespace is a collection's placeholder namespace, `${collected:<collection>}`.
|
||||
const CollectedNamespace = "collected"
|
||||
|
||||
// EnvironmentCollection is the one collection of the session environment (design 57 L3, issue 795): its items are
|
||||
// typed by attributes, a variable (`kind=variable`, `name`) or a PATH entry (`kind=path`, `at=start|end`), and the
|
||||
// template's text is the value or the entry. A collector names the rendering, `${collected:environment:posix}` or
|
||||
// `${collected:environment:systemd}`, which is not a selector.
|
||||
const EnvironmentCollection = "environment"
|
||||
|
||||
// oldCollectionNamespaces are the namespaces read as collections of their own name.
|
||||
var oldCollectionNamespaces = []string{"shell", "environment", "contribution"}
|
||||
|
||||
var (
|
||||
// ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused.
|
||||
ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`)
|
||||
// collectionName is words joined by colons, so an old namespace's placeholder names its collection as it reads.
|
||||
collectionName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)*$`)
|
||||
// itemName, attributeKey and attributeValue are the words an item's name and attributes are made of; none holds
|
||||
// a colon, a comma or an equals sign, which tell a selector from a collection's name.
|
||||
itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
|
||||
)
|
||||
|
||||
// splitSelector is a placeholder's name without its selector, and the selector: `widgets:bar=top,place=left` is
|
||||
// `widgets` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon
|
||||
// before the first one.
|
||||
func splitSelector(name string) (collection, selector string) {
|
||||
eq := strings.Index(name, "=")
|
||||
if eq < 0 {
|
||||
return name, ""
|
||||
}
|
||||
colon := strings.LastIndex(name[:eq], ":")
|
||||
if colon < 0 {
|
||||
return name, ""
|
||||
}
|
||||
return name[:colon], name[colon+1:]
|
||||
}
|
||||
|
||||
// selectorOf is a selector's pairs, or why it is none.
|
||||
func selectorOf(selector string) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
if selector == "" {
|
||||
return out, nil
|
||||
}
|
||||
for _, pair := range strings.Split(selector, ",") {
|
||||
k, v, ok := strings.Cut(pair, "=")
|
||||
switch {
|
||||
case !ok || !attributeKey.MatchString(k) || !attributeValue.MatchString(v):
|
||||
return nil, fmt.Errorf("%q in %q is not <attribute>=<value>", pair, selector)
|
||||
case out[k] != "":
|
||||
return nil, fmt.Errorf("%q names %s twice", selector, k)
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// collectionOfPlaceholder is the collection a placeholder's namespace and key name, and whether they name one.
|
||||
func collectionOfPlaceholder(namespace, key string) (string, bool) {
|
||||
key, _ = splitSelector(key)
|
||||
switch {
|
||||
case namespace == CollectedNamespace && strings.HasPrefix(key, EnvironmentCollection+":"):
|
||||
// The environment is one collection, and the rest of the name picks the rendering (issue 795).
|
||||
return EnvironmentCollection, true
|
||||
case namespace == CollectedNamespace:
|
||||
return key, true
|
||||
case namespace == "environment":
|
||||
return EnvironmentCollection, true
|
||||
case slices.Contains(oldCollectionNamespaces, namespace):
|
||||
return namespace + ":" + key, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// oldCollection is an old namespace's collection by its name: its comment, or why no item may be contributed to it.
|
||||
// ok is false for a name that is not an old namespace's.
|
||||
//
|
||||
// forItem is whether an item is to be contributed to it: an item may be contributed to a kind received as data,
|
||||
// which a collector may place as a collection only where its shape says its pieces are text (design 57 L3).
|
||||
func oldCollection(name string, forItem bool) (comment string, old bool, wrong error) {
|
||||
namespace, key, _ := strings.Cut(name, ":")
|
||||
if !slices.Contains(oldCollectionNamespaces, namespace) {
|
||||
return "", false, nil
|
||||
}
|
||||
switch namespace {
|
||||
case "shell":
|
||||
target, slot, two := strings.Cut(key, ":")
|
||||
if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) {
|
||||
return "", true, fmt.Errorf("%s is no slot: shell code is shell:<shell>:<slot>, the shell one of %s, "+
|
||||
"the session's file one of %s or the power moment one of %s, and the slot one of %s", name,
|
||||
strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "),
|
||||
strings.Join(knownSlots, ", "))
|
||||
}
|
||||
// shellCode's line before each module's code.
|
||||
return "#", true, nil
|
||||
case "environment":
|
||||
if key == "" {
|
||||
// The collection itself, which an item is contributed to; a placeholder names a rendering.
|
||||
return "#", true, nil
|
||||
}
|
||||
if key != EnvironmentPOSIX && key != EnvironmentSystemd {
|
||||
return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s",
|
||||
name, EnvironmentPOSIX, EnvironmentSystemd)
|
||||
}
|
||||
return "#", true, nil
|
||||
}
|
||||
seat, kind, _ := strings.Cut(key, ":")
|
||||
s, r, ok := receivable(seat, kind)
|
||||
switch {
|
||||
case !ok && s.Name == "":
|
||||
return "", true, fmt.Errorf("%s names the seat %q, which the mesh does not define", name, seat)
|
||||
case !ok:
|
||||
return "", true, fmt.Errorf("%s: %s; %s", name, s.Name, kindsOf(s))
|
||||
case r.Shape != nil && (forItem || collectableAsText(r)):
|
||||
return r.Comment, true, nil
|
||||
case r.Shape != nil:
|
||||
return "", true, fmt.Errorf("%s is received by %s as data in its own shape, which no item of text is "+
|
||||
"(novox/hq ADR 0255)", name, s.Name)
|
||||
case r.Dirs:
|
||||
return "", true, fmt.Errorf("%s names its contributors' directories, which the holder reads as paths "+
|
||||
"the controller fills; it is not yet a collection (design 57 L1)", name)
|
||||
case s.Name != canonicalSeat(seat):
|
||||
return "", true, fmt.Errorf("%s names the seat by another name; a collection names it %s", name, s.Name)
|
||||
}
|
||||
return r.Comment, true, nil
|
||||
}
|
||||
|
||||
// placerOfOld is the seat whose holder alone may collect an old namespace's collection (ADR 0203, 0204, 0208,
|
||||
// 0211, 0212): the same seat that places it the old way.
|
||||
func placerOfOld(name string) string {
|
||||
namespace, key, _ := strings.Cut(name, ":")
|
||||
switch namespace {
|
||||
case "shell":
|
||||
target, _, _ := strings.Cut(key, ":")
|
||||
return placerOf(target)
|
||||
case "environment":
|
||||
return EnvironmentSeat
|
||||
}
|
||||
seat, _, _ := strings.Cut(key, ":")
|
||||
return canonicalSeat(seat)
|
||||
}
|
||||
|
||||
// collectionProblems is what is wrong with a module's collections and items, from the manifest alone.
|
||||
func collectionProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
declared := map[string]bool{}
|
||||
for i, c := range m.Collections {
|
||||
switch {
|
||||
case !collectionName.MatchString(c.Name):
|
||||
problems = append(problems, fmt.Sprintf("%s's collection %d is %q, which is not a collection's name: "+
|
||||
"words of lower-case letters, digits and hyphens, joined by colons", m.Module, i+1, c.Name))
|
||||
case declared[c.Name]:
|
||||
problems = append(problems, fmt.Sprintf("%s collects %s twice", m.Module, c.Name))
|
||||
}
|
||||
if _, old, _ := oldCollection(c.Name, false); old {
|
||||
problems = append(problems, fmt.Sprintf("%s collects %s, which is read from its placeholder: a module "+
|
||||
"places it as ${%s} where it holds the seat that places it, and declares nothing", m.Module, c.Name,
|
||||
c.Name))
|
||||
}
|
||||
if strings.ContainsAny(c.Comment, "\n\r") {
|
||||
problems = append(problems, fmt.Sprintf("%s's collection %s has a comment holding a line break",
|
||||
m.Module, c.Name))
|
||||
}
|
||||
declared[c.Name] = true
|
||||
}
|
||||
// A collection declared and placed nowhere is a word read by nothing; judged where every file's text is here.
|
||||
if !m.Templated() {
|
||||
named := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if text, ok := r["content"].(string); ok {
|
||||
for _, f := range ofCollected.FindAllStringSubmatch(text, -1) {
|
||||
base, _ := splitSelector(f[1])
|
||||
named[base] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range m.Collections {
|
||||
if !named[c.Name] && collectionName.MatchString(c.Name) {
|
||||
problems = append(problems, fmt.Sprintf("%s collects %s and no file of its places it as "+
|
||||
"${collected:%s}", m.Module, c.Name, c.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
contributed := map[string]bool{}
|
||||
for i, it := range m.Items {
|
||||
where := fmt.Sprintf("%s's item %d", m.Module, i+1)
|
||||
if !collectionName.MatchString(it.Collection) {
|
||||
problems = append(problems, fmt.Sprintf("%s is to %q, which is not a collection's name", where,
|
||||
it.Collection))
|
||||
} else if _, old, wrong := oldCollection(it.Collection, true); old && wrong != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s: %v", where, wrong))
|
||||
}
|
||||
switch {
|
||||
case it.Name != "" && !itemName.MatchString(it.Name):
|
||||
problems = append(problems, fmt.Sprintf("%s: the name %q is not a name: lower-case letters, digits and "+
|
||||
"hyphens", where, it.Name))
|
||||
case contributed[it.key()]:
|
||||
problems = append(problems, fmt.Sprintf("%s contributes to %s twice; a module contributes one item to "+
|
||||
"a collection, or several each with a name of its own", m.Module, it.key()))
|
||||
case it.Name == "" && slices.ContainsFunc(m.Items, func(o Item) bool {
|
||||
return o.Collection == it.Collection && o.Name != ""
|
||||
}):
|
||||
problems = append(problems, fmt.Sprintf("%s contributes several items to %s and %s has no name; each "+
|
||||
"of several items has a name of its own", m.Module, it.Collection, where))
|
||||
}
|
||||
contributed[it.key()] = true
|
||||
problems = append(problems, itemAttributeProblems(m.Module, where, it)...)
|
||||
if it.Collection == EnvironmentCollection {
|
||||
problems = append(problems, environmentItemProblems(where, it)...)
|
||||
}
|
||||
switch {
|
||||
case it.Template == "" && it.Content == "":
|
||||
problems = append(problems, fmt.Sprintf("%s names no template: an item is a template of the module's "+
|
||||
"own, \"template\": \"%s/<name>\" (novox/hq ADR 0349 §13)", where, m.TemplatesDir()))
|
||||
case it.Template != "" && it.Content != "":
|
||||
problems = append(problems, fmt.Sprintf("%s names its template and says its content too", where))
|
||||
case it.Template != "":
|
||||
if wrong := itemTemplateProblem(m, it.Template); wrong != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: %s", where, wrong))
|
||||
}
|
||||
// **Filled by the provisioner's client alone**: the controller renders no item.
|
||||
if !m.Moved {
|
||||
problems = append(problems, fmt.Sprintf("%s: an item is filled by the provisioner's client of the "+
|
||||
"node its contributor is placed on, so a module contributing one is marked moved "+
|
||||
"(\"moved\": true; novox/hq ADR 0349 §13, §17)", where))
|
||||
}
|
||||
}
|
||||
if it.Content != "" {
|
||||
problems = append(problems, itemTextProblems(m.Module, it)...)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// itemAttributeProblems is what is wrong with an item's attributes and capability. The attributes of an item of a kind
|
||||
// received as data are the fields its shape places by (and `order`), each with a value the shape takes.
|
||||
func itemAttributeProblems(module, where string, it Item) []string {
|
||||
var problems []string
|
||||
for _, k := range sortedKeys(it.Attributes) {
|
||||
v := it.Attributes[k]
|
||||
switch {
|
||||
case !attributeKey.MatchString(k):
|
||||
problems = append(problems, fmt.Sprintf("%s: the attribute %q is not a name: lower-case letters, digits "+
|
||||
"and hyphens", where, k))
|
||||
continue
|
||||
case !attributeFits(it.Collection, k, v):
|
||||
problems = append(problems, fmt.Sprintf("%s: %s=%q is not a value: lower-case letters, digits, hyphens, "+
|
||||
"dots and underscores (an environment variable's name, letters, digits and underscores)", where, k, v))
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.Atoi(v); k == OrderAttribute && (err != nil || n < 0 || n > 99) {
|
||||
problems = append(problems, fmt.Sprintf("%s: order=%q is not a whole number from 0 to 99", where, v))
|
||||
}
|
||||
}
|
||||
if namespace, key, _ := strings.Cut(it.Collection, ":"); namespace == "contribution" {
|
||||
seat, kind, _ := strings.Cut(key, ":")
|
||||
if _, r, ok := receivable(seat, kind); ok && r.Shape != nil {
|
||||
for _, k := range sortedKeys(it.Attributes) {
|
||||
var place *PlaceField
|
||||
for i := range r.Shape.Places {
|
||||
if r.Shape.Places[i].Field == k {
|
||||
place = &r.Shape.Places[i]
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case k == OrderAttribute:
|
||||
case place == nil:
|
||||
problems = append(problems, fmt.Sprintf("%s: %s is placed by %s, and %q is none of them", where,
|
||||
it.Collection, placeFields(r), k))
|
||||
case !oneOf(place.Values, it.Attributes[k]):
|
||||
problems = append(problems, fmt.Sprintf("%s: %s=%s; %s is one of %s", where, k, it.Attributes[k], k,
|
||||
strings.Join(place.Values, ", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case it.IfCapability == "":
|
||||
case !capabilityName.MatchString(it.IfCapability):
|
||||
problems = append(problems, fmt.Sprintf("%s: if-capability %q is not a capability's name", where, it.IfCapability))
|
||||
case !oneOf(KnownCapabilities, it.IfCapability):
|
||||
problems = append(problems, fmt.Sprintf("%s: if-capability %q is one no machine reports; the node-engine "+
|
||||
"detects %s", where, it.IfCapability, strings.Join(KnownCapabilities, ", ")))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// AttributeFits is whether an item's attribute is of the shape its collection allows: words of lower case, except
|
||||
// the environment's `name`, which is a variable's name (design 57 L3, issue 795). The same rule is mesh-catalog's
|
||||
// provisioner client's, and the inventory's.
|
||||
func attributeFits(collection, k, v string) bool {
|
||||
if collection == EnvironmentCollection && k == "name" {
|
||||
return variableName.MatchString(v)
|
||||
}
|
||||
return attributeKey.MatchString(k) && attributeValue.MatchString(v)
|
||||
}
|
||||
|
||||
// environmentItemProblems is what is wrong with an item of the environment collection: typed by `kind`, a variable
|
||||
// with its `name`, a PATH entry `at` the start or the end, and no attribute besides `order`.
|
||||
func environmentItemProblems(where string, it Item) []string {
|
||||
var problems []string
|
||||
for _, k := range sortedKeys(it.Attributes) {
|
||||
if !slices.Contains([]string{"kind", "name", "at", OrderAttribute}, k) {
|
||||
problems = append(problems, fmt.Sprintf("%s: %s is typed by kind, name and at, and %q is none of them",
|
||||
where, it.Collection, k))
|
||||
}
|
||||
}
|
||||
switch kind := it.Attributes["kind"]; kind {
|
||||
case "variable":
|
||||
if it.Attributes["name"] == "" || it.Attributes["at"] != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: a variable names itself with name=<NAME> and has no at", where))
|
||||
} else if it.Attributes["name"] == "PATH" {
|
||||
problems = append(problems, fmt.Sprintf("%s: PATH is not a variable; a PATH entry is kind=path", where))
|
||||
}
|
||||
case "path":
|
||||
if it.Attributes["name"] != "" || (it.Attributes["at"] != PathAtStart && it.Attributes["at"] != PathAtEnd) {
|
||||
problems = append(problems, fmt.Sprintf("%s: a PATH entry is at=%s or at=%s and has no name", where,
|
||||
PathAtStart, PathAtEnd))
|
||||
}
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf("%s: an item of %s is kind=variable or kind=path, not %q", where,
|
||||
it.Collection, kind))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// itemTemplateProblem is what is wrong with an item's template path, or "".
|
||||
func itemTemplateProblem(m Manifest, name string) string {
|
||||
dir := m.TemplatesDir()
|
||||
if strings.HasPrefix(name, "/") || path.Clean(name) != name || !strings.HasPrefix(name, dir+"/") ||
|
||||
strings.Contains(name, "\\") {
|
||||
return fmt.Sprintf("the template %q is not a file under %s/ of its own repository: a template is %q, a "+
|
||||
"plain relative path, and the build packs only %s/", name, dir, dir+"/<name>", dir)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// itemTextProblems is what an item's text may not name: a secret or a bound value, since an item is kept in a
|
||||
// bucket and no secret is (ADR 0349 clause 7), and a collection, since an item is not a collector. And every
|
||||
// placeholder no pass fills, as in a file's content.
|
||||
func itemTextProblems(module string, it Item) []string {
|
||||
var problems []string
|
||||
for _, f := range templatePlaceholder.FindAllStringSubmatch(it.Content, -1) {
|
||||
namespace, key := f[1], f[2]
|
||||
switch _, collection := collectionOfPlaceholder(namespace, key); {
|
||||
case namespace == "secret" || namespace == "bound":
|
||||
problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is kept in a bucket, where "+
|
||||
"no secret is (novox/hq ADR 0349 §7)", module, it.Collection, f[0]))
|
||||
case collection:
|
||||
problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is not a collector",
|
||||
module, it.Collection, f[0]))
|
||||
}
|
||||
}
|
||||
return append(problems, unconsumedPlaceholders(module, map[string]any{"id": "item:" + it.key(),
|
||||
"type": "file", "content": it.Content})...)
|
||||
}
|
||||
|
||||
// isOld is whether a collection's name is an old namespace's.
|
||||
func isOld(name string) bool {
|
||||
_, old, _ := oldCollection(name, true)
|
||||
return old
|
||||
}
|
||||
|
||||
// collectedPlaceholderProblems is what is wrong with one resource's ${collected:…}: placed only in a file's content,
|
||||
// and naming a collection the module declares it collects.
|
||||
func collectedPlaceholderProblems(m Manifest, r map[string]any) []string {
|
||||
var problems []string
|
||||
for _, field := range sortedKeys(r) {
|
||||
v, ok := r[field].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, f := range ofCollected.FindAllStringSubmatch(v, -1) {
|
||||
base, selector := splitSelector(f[1])
|
||||
if _, err := selectorOf(selector); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err))
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case field != "content":
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a collection is placed "+
|
||||
"only in a file's content", m.Module, r["id"], f[0], field))
|
||||
case isOld(base):
|
||||
if _, _, wrong := oldCollection(base, false); wrong != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], wrong))
|
||||
}
|
||||
case !slices.ContainsFunc(m.Collections, func(c Collection) bool { return c.Name == base }):
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not collect %q: "+
|
||||
"declare it under collections (novox/hq ADR 0349 §13)", m.Module, r["id"], f[0], m.Module, base))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// collectionComment is the comment of a collection a module's file names: declared, or the old namespace's.
|
||||
func collectionComment(m Manifest, name string) (string, error) {
|
||||
if comment, old, wrong := oldCollection(name, false); old {
|
||||
return comment, wrong
|
||||
}
|
||||
for _, c := range m.Collections {
|
||||
if c.Name == name {
|
||||
return c.Comment, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%s names ${collected:%s} and does not collect it", m.Module, name)
|
||||
}
|
||||
|
||||
// itemsLeftOut says each item of a collection a file is filled with the old way that the controller could not render
|
||||
// there (heldItemText): the collector is not handed to the provisioner on this machine (ADR 0349 clause 17). Said,
|
||||
// never dropped quietly.
|
||||
func itemsLeftOut(modules []Manifest, holder Manifest, collection string, facts map[string]string) []string {
|
||||
return itemsLeftOutFor(modules, holder, collection, nil, facts, nil)
|
||||
}
|
||||
|
||||
// itemsLeftOutFor is itemsLeftOut for a placeholder that selects by attributes (where), on a machine with these
|
||||
// capabilities: an item the selector does not take, or whose capability the machine lacks, is not left out but not
|
||||
// wanted here.
|
||||
func itemsLeftOutFor(modules []Manifest, holder Manifest, collection string, where map[string]string,
|
||||
facts map[string]string, caps map[string]bool) []string {
|
||||
var out []string
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
for _, it := range itemsInOrder(m.Items) {
|
||||
if it.Collection != collection || !it.matches(where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
|
||||
continue
|
||||
}
|
||||
if _, rendered := heldText(it, facts); rendered && renderedOldWay(collection) {
|
||||
continue
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s's item of %s is not placed: %s is filled the old way on this "+
|
||||
"machine, from contributions, and an item only by the provisioner's client (design 57 L1)",
|
||||
m.Module, it.key(), holder.Module))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// oldFormsLeftOut says each piece of an old form a handed collector's collections would have held: the collector is
|
||||
// filled from items alone, and the piece's module contributes none (ADR 0349 clause 17).
|
||||
func oldFormsLeftOut(modules []Manifest, holder Manifest, collections []PlanCollection) []string {
|
||||
var out []string
|
||||
for _, c := range collections {
|
||||
namespace, key, _ := strings.Cut(c.Name, ":")
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
old := false
|
||||
switch namespace {
|
||||
case "shell":
|
||||
target, slot, _ := strings.Cut(key, ":")
|
||||
old = slices.ContainsFunc(m.Shell, func(s ShellCode) bool { return s.For == target && s.Slot == slot })
|
||||
case "environment":
|
||||
old = m.Environment != nil && (len(m.Environment.Variables) > 0 || len(m.Environment.Path) > 0)
|
||||
case "contribution":
|
||||
seat, kind, _ := strings.Cut(key, ":")
|
||||
old = slices.ContainsFunc(m.allContributions(), func(s SeatContribution) bool {
|
||||
return canonicalSeat(s.Seat) == canonicalSeat(seat) && s.Kind == kind
|
||||
})
|
||||
}
|
||||
if old {
|
||||
out = append(out, fmt.Sprintf("%s's %s is not placed: %s is filled by the provisioner's client on "+
|
||||
"this machine, from items, and %s contributes it the old way (design 57 L1)", m.Module, c.Name,
|
||||
holder.Module, m.Module))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// heldItemText is a moved contributor's items of an old namespace's collection as the controller renders them into a
|
||||
// collector not handed on this machine (design 57 L2, issue 681): the text the mesh holds, read back from the
|
||||
// contributor's templates archive, with the machine's facts filled, each ending its line. As a moved module's file
|
||||
// keeps its text for a machine that cannot take it handed (handed.go), an item keeps its place in a collector filled
|
||||
// the old way, so the file stays what its old form made it. Only `shell:` and `contribution:` collections, which the
|
||||
// controller renders as text; false for a module with no such item, or an item whose text the mesh does not hold or
|
||||
// which names anything but a machine fact this machine has, which is then said as not placed.
|
||||
func heldItemText(m Manifest, collection string, facts map[string]string) (string, bool) {
|
||||
items := heldItems(m, collection, nil, facts, nil)
|
||||
var b strings.Builder
|
||||
for _, h := range items {
|
||||
b.WriteString(h.text)
|
||||
}
|
||||
return b.String(), len(items) > 0
|
||||
}
|
||||
|
||||
// renderedOldWay is whether the controller renders a collection's items into a collector not handed.
|
||||
func renderedOldWay(collection string) bool {
|
||||
namespace, _, _ := strings.Cut(collection, ":")
|
||||
return namespace == "shell" || namespace == "contribution" || namespace == EnvironmentCollection
|
||||
}
|
||||
|
||||
// heldItem is one item of a module rendered the old way.
|
||||
type heldItem struct {
|
||||
item Item
|
||||
text string
|
||||
}
|
||||
|
||||
// heldItems is a module's items of the collection that the selector takes and the machine's capabilities allow, by
|
||||
// order then name, each rendered as heldText does; the ones it cannot render are left out (itemsLeftOutFor says so).
|
||||
func heldItems(m Manifest, collection string, where map[string]string, facts map[string]string,
|
||||
caps map[string]bool) []heldItem {
|
||||
if !renderedOldWay(collection) {
|
||||
return nil
|
||||
}
|
||||
var out []heldItem
|
||||
for _, it := range itemsInOrder(m.Items) {
|
||||
if it.Collection != collection || !it.matches(where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
|
||||
continue
|
||||
}
|
||||
if text, ok := heldText(it, facts); ok {
|
||||
out = append(out, heldItem{item: it, text: text})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// heldText is an item's text with the machine's facts filled, ending its line; false for an item whose text the mesh
|
||||
// does not hold or which names anything but a machine fact this machine has.
|
||||
func heldText(it Item, facts map[string]string) (string, bool) {
|
||||
if it.Content == "" {
|
||||
return "", false
|
||||
}
|
||||
text := it.Content
|
||||
for _, f := range templatePlaceholder.FindAllStringSubmatch(text, -1) {
|
||||
value, has := facts[f[2]]
|
||||
if f[1] != "machine" || !has {
|
||||
return "", false
|
||||
}
|
||||
text = strings.ReplaceAll(text, f[0], value)
|
||||
}
|
||||
if !strings.HasSuffix(text, "\n") {
|
||||
text += "\n"
|
||||
}
|
||||
return text, true
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A bar's blocks are a collection of text (design 57 L3, issue 781): the holder of node-bar names the kind as
|
||||
// `${collected:contribution:node-bar:block:bar=<b>,place=<p>}`, which a handed collector has its client fill from the
|
||||
// items and an unhanded one has the controller fill as it fills the old placeholder.
|
||||
|
||||
const (
|
||||
collectedBarsText = "top\n${collected:contribution:node-bar:block:bar=top,place=status}" +
|
||||
"bottom\n${collected:contribution:node-bar:block:bar=bottom,place=status}"
|
||||
collectedBars = `{"module":"bars","version":"1","moved":true,
|
||||
"claims":[{"name":"node-bar","scope":"node","renders":{"block":"{{.shows}}"}}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}`
|
||||
itemsOfPower = `{"module":"power","version":"1","moved":true,
|
||||
"items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"},
|
||||
{"collection":"contribution:node-bar:block","name":"draw","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
|
||||
)
|
||||
|
||||
func collectedBarsOn(t *testing.T, caps map[string]bool) Resolution {
|
||||
t.Helper()
|
||||
bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText}))
|
||||
power := held(t, itemsOfPower, builtWith("power", map[string]string{"battery": "B\n", "draw": "D ${machine:name}\n"}))
|
||||
r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account, r.Capabilities = "op", caps
|
||||
return r
|
||||
}
|
||||
|
||||
func TestACollectorMayNameTheBarsBlocksAsACollectionAndNoOtherDataKind(t *testing.T) {
|
||||
bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText}))
|
||||
if _, err := collectedBarsOn(t, nil).Compose(handingTo("store:5000")); err != nil {
|
||||
t.Fatalf("the handed collector is refused: %v", err)
|
||||
}
|
||||
if got := collectedPlaceholderProblems(bars, map[string]any{"id": "x",
|
||||
"content": "${collected:contribution:node-bar:block:bar=top,place=status}"}); len(got) != 0 {
|
||||
t.Fatalf("the check refuses it: %v", got)
|
||||
}
|
||||
// A data kind whose shape does not say its items are text stays refused.
|
||||
_, plain, _ := receivable(BarSeat, BarKindBlock)
|
||||
plain.Shape = &Shape{}
|
||||
if collectableAsText(plain) {
|
||||
t.Fatal("a shape not marked as text is collectable")
|
||||
}
|
||||
if _, _, err := oldCollection("contribution:node-bar:block", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBarsCollectionIsFilledTheOldWayWhereTheCollectorIsNotHanded(t *testing.T) {
|
||||
for _, caps := range []map[string]bool{nil, {"battery": true}} {
|
||||
got := composedFile(t, collectedBarsOn(t, caps), "bars.conf")
|
||||
want := "top\nbottom\n# power\nD workstation\n"
|
||||
if caps["battery"] {
|
||||
want = "top\nbottom\n# power\nB\n# power\nD workstation\n"
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("caps %v: the file is %q, want %q", caps, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBarsCollectionIsNamedInTheHandedPlan(t *testing.T) {
|
||||
c, _ := composeWith(t, collectedBarsOn(t, map[string]bool{"battery": true}), handingTo("store:5000"))
|
||||
if !reflect.DeepEqual(c.Plans["bars"].Collections, []PlanCollection{{Name: "contribution:node-bar:block", Comment: "#"}}) {
|
||||
t.Fatalf("the plan collects %+v", c.Plans["bars"].Collections)
|
||||
}
|
||||
if n := len(c.Plans["power"].Items); n != 2 {
|
||||
t.Fatalf("the contributor's plan names %d items", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The bars of the catalogue's i3status-rust, with the blocks of power's items, compose to the files the old shaped
|
||||
// contribution made (issue 781). testdata/bars holds the catalogue's bars and block texts as of the move and the bars as
|
||||
// composed before it, for a machine with neither capability, the battery, the power meter and both. Where the
|
||||
// collector is not handed the controller fills the files; where it is, it names the items for the client, which
|
||||
// fills the same files (mesh-catalog's provisioner client holds that half against the same golden files).
|
||||
func TestTheBarsOfTheCatalogueAreTheSameHandedOrNot(t *testing.T) {
|
||||
read := func(name string) string {
|
||||
b, err := os.ReadFile(filepath.Join("testdata", "bars", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
collector := `{"module":"bars","version":"1","moved":true,"claims":[{"name":"node-bar","scope":"node"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"top","type":"file","path":"/t","template":"files/top"},
|
||||
{"id":"bottom","type":"file","path":"/b","template":"files/bottom"}]}`
|
||||
power := `{"module":"power","version":"1","moved":true,
|
||||
"items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"},
|
||||
{"collection":"contribution:node-bar:block","name":"power-draw","if-capability":"power-meter","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
|
||||
for name, caps := range map[string]map[string]bool{"none": nil, "battery": {"battery": true},
|
||||
"power-meter": {"power-meter": true}, "both": {"battery": true, "power-meter": true}} {
|
||||
bars := held(t, collector, builtWith("bars", map[string]string{"top": read("top-bar.toml"), "bottom": read("bottom-bar.toml")}))
|
||||
blocks := held(t, power, builtWith("power", map[string]string{"battery": read("bar-battery"), "draw": read("bar-power-draw")}))
|
||||
r, err := Resolve(shelf(bars, blocks), []string{"bars", "power"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account, r.Capabilities = "op", caps
|
||||
// Not handed: the controller fills them.
|
||||
for id, which := range map[string]string{"bars.top": "top", "bars.bottom": "bottom"} {
|
||||
if got := composedFile(t, r, id); got != read(name+"."+which+"-bar.toml") {
|
||||
t.Errorf("%s, %s bar, not handed:\n%s\nwant\n%s", name, which, got, read(name+"."+which+"-bar.toml"))
|
||||
}
|
||||
}
|
||||
// Handed: the plan names the items the machine's capabilities allow, for the client to fill.
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
var names []string
|
||||
for _, it := range c.Plans["power"].Items {
|
||||
names = append(names, it.Name)
|
||||
}
|
||||
want := []string{}
|
||||
if caps["battery"] {
|
||||
want = append(want, "battery")
|
||||
}
|
||||
if caps["power-meter"] {
|
||||
want = append(want, "power-draw")
|
||||
}
|
||||
if !reflect.DeepEqual(append([]string{}, names...), want) {
|
||||
t.Errorf("%s: the plan names %v, want %v", name, names, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The collectors design 57 L2 moved (issue 681), composed with nothing handed: zsh's rc, triggerhappy's triggers and
|
||||
// power's after-wake moment, from the catalogue's real templates and its moved contributors' real items, are byte
|
||||
// for byte what the controller composed from the old forms before the move (testdata/l2-moved: templates/ and items/
|
||||
// copied from mesh-catalog at the move, want/ composed at the feature branch before it).
|
||||
|
||||
func l2Moved(t *testing.T, parts ...string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(append([]string{"testdata", "l2-moved"}, parts...)...))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestTheCollectorsL2MovedComposeAsBeforeWhereNothingIsHanded(t *testing.T) {
|
||||
// Each moved contributor, its items read back from its templates archive as the mesh holds them.
|
||||
items := map[string][]string{
|
||||
"asus-zephyrus-g14": {"shell:after-wake:normal", "contribution:node-hotkeys:trigger"},
|
||||
"claude-code": {"shell:zsh:normal"},
|
||||
"mesh-cli": {"shell:zsh:normal"},
|
||||
"powerlevel10k": {"shell:zsh:normal"},
|
||||
"zsh-autosuggestions": {"shell:zsh:normal"},
|
||||
"zsh-syntax-highlighting": {"shell:zsh:last"},
|
||||
}
|
||||
var modules []Manifest
|
||||
for _, module := range sortedKeys(items) {
|
||||
m := Manifest{Module: module, Moved: true}
|
||||
for _, collection := range items[module] {
|
||||
m.Items = append(m.Items, Item{Collection: collection,
|
||||
Content: l2Moved(t, "items", module+"."+strings.ReplaceAll(collection, ":", "-"))})
|
||||
}
|
||||
modules = append(modules, m)
|
||||
}
|
||||
for _, c := range []struct{ module, seat string }{{"zsh", LoginShellSeat}, {"triggerhappy", HotkeysSeat},
|
||||
{"power", PowerSeat}} {
|
||||
holder := Manifest{Module: c.module, Moved: true, Claims: []Claim{{Name: c.seat}}}
|
||||
resource := map[string]any{"id": "file", "type": "file", "path": "/etc/" + c.module,
|
||||
"content": l2Moved(t, "templates", c.module)}
|
||||
var unplaced []string
|
||||
if err := contributionsInto(resource, holder, append([]Manifest{holder}, modules...),
|
||||
map[string]string{"name": "g14"}, Rendering{}, map[string]bool{}, &unplaced); err != nil {
|
||||
t.Fatalf("%s: %v", c.module, err)
|
||||
}
|
||||
if got, want := resource["content"], l2Moved(t, "want", c.module); got != want {
|
||||
t.Errorf("%s's file is not what the old forms made:\n%s\nwant:\n%s", c.module, got, want)
|
||||
}
|
||||
if len(unplaced) != 0 {
|
||||
t.Errorf("%s: unplaced %v", c.module, unplaced)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An item may carry attributes and a capability, a module may give several items to one collection, and a
|
||||
// placeholder may select by attributes (design 57 L3, issue 777). The controller leaves out of a plan an item whose
|
||||
// capability the machine lacks, accepts a selecting placeholder where it accepts the collection, and renders a
|
||||
// selected moved item into an old placeholder exactly as the contribution it replaces.
|
||||
|
||||
const (
|
||||
barCollector = `{"module":"bars","version":"1","moved":true,
|
||||
"collections":[{"name":"blocks","comment":"#"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}`
|
||||
barsText = "top\n${collected:blocks:bar=top,place=status}bottom\n${collected:blocks:bar=bottom,place=status}${collected:blocks}"
|
||||
barContribute = `{"module":"power","version":"1","moved":true,
|
||||
"items":[{"collection":"blocks","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"20"},"template":"files/battery"},
|
||||
{"collection":"blocks","name":"clock","attributes":{"bar":"top","place":"status"},"template":"files/clock"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
|
||||
)
|
||||
|
||||
func blocksOn(t *testing.T, caps map[string]bool) Composed {
|
||||
t.Helper()
|
||||
bars := held(t, barCollector, builtWith("bars", map[string]string{"bars": barsText}))
|
||||
power := held(t, barContribute, builtWith("power", map[string]string{"battery": "B\n", "clock": "C\n"}))
|
||||
r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account, r.Capabilities = "op", caps
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
return c
|
||||
}
|
||||
|
||||
func TestAnItemWhoseCapabilityTheMachineLacksIsLeftOutOfItsPlan(t *testing.T) {
|
||||
with := blocksOn(t, map[string]bool{"battery": true}).Plans["power"]
|
||||
want := []PlanItem{
|
||||
{Collection: "blocks", Name: "battery", Template: "battery",
|
||||
Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}},
|
||||
{Collection: "blocks", Name: "clock", Template: "clock", Attributes: map[string]string{"bar": "top", "place": "status"}},
|
||||
}
|
||||
if !reflect.DeepEqual(with.Items, want) {
|
||||
t.Fatalf("with the capability the plan carries %+v", with.Items)
|
||||
}
|
||||
without := blocksOn(t, map[string]bool{"seat": true}).Plans["power"]
|
||||
if !reflect.DeepEqual(without.Items, want[1:]) {
|
||||
t.Fatalf("without it the plan carries %+v", without.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACollectorSelectingByAttributesCollectsTheCollectionOnce(t *testing.T) {
|
||||
bars := blocksOn(t, nil).Plans["bars"]
|
||||
if !reflect.DeepEqual(bars.Collections, []PlanCollection{{Name: "blocks", Comment: "#"}}) {
|
||||
t.Fatalf("the plan collects %+v", bars.Collections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testing.T) {
|
||||
for placeholder, want := range map[string]string{
|
||||
"${collected:blocks:bar}": "", // no equals sign: a collection named blocks:bar
|
||||
"${collected:blocks:bar=top,bar=bottom}": "names bar twice",
|
||||
"${collected:blocks:bar=}": "is not <attribute>=<value>",
|
||||
"${collected:other:bar=top}": `does not collect "other"`,
|
||||
} {
|
||||
raw := strings.Replace(barCollector, `"template":"files/bars"`, `"content":"`+placeholder+`"`, 1)
|
||||
raw = strings.Replace(raw, `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1)
|
||||
raw = strings.Replace(raw, `"moved":true,`, "", 1)
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
if err == nil {
|
||||
_, err = (Resolution{Node: "n", Modules: []Manifest{m}}).Declaration(Rendering{})
|
||||
}
|
||||
if want == "" {
|
||||
continue
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: %v, want %q", placeholder, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemNamesAttributesAndCapabilitiesAreJudged(t *testing.T) {
|
||||
item := func(extra string) string {
|
||||
return `{"module":"p","moved":true,"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` +
|
||||
`"items":[` + extra + `]}`
|
||||
}
|
||||
for items, want := range map[string]string{
|
||||
`{"collection":"c","name":"a","template":"files/a"},{"collection":"c","template":"files/b"}`: "has no name",
|
||||
`{"collection":"c","name":"a","template":"files/a"},{"collection":"c","name":"a","template":"files/b"}`: "twice",
|
||||
`{"collection":"c","name":"Big","template":"files/a"}`: "is not a name",
|
||||
`{"collection":"c","attributes":{"order":"100"},"template":"files/a"}`: "0 to 99",
|
||||
`{"collection":"c","attributes":{"bar":"a b"},"template":"files/a"}`: "is not a value",
|
||||
`{"collection":"c","if-capability":"batery","template":"files/a"}`: "no machine reports",
|
||||
`{"collection":"contribution:node-bar:block","attributes":{"bar":"left"},"template":"files/a"}`: "bar=left",
|
||||
`{"collection":"contribution:node-bar:block","attributes":{"colour":"red"},"template":"files/a"}`: `"colour" is none of them`,
|
||||
`{"collection":"c","name":"a","template":"files/a","colour":"red"}`: "colour",
|
||||
} {
|
||||
_, err := ParseManifest([]byte(item(items)))
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: %v, want %q", items, err, want)
|
||||
}
|
||||
}
|
||||
ok := item(`{"collection":"contribution:node-bar:block","name":"a","attributes":{"bar":"top","place":"status","order":"5"},"if-capability":"battery","template":"files/a"}`)
|
||||
if _, err := ParseManifest([]byte(ok)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// A moved contributor's items, selected by the old placeholder's pair, render in the old order (order, module, item
|
||||
// name) and with the same lines as the contributions they replace.
|
||||
func TestASelectedMovedItemRendersIntoTheOldBarPlaceholderAsTheContributionDid(t *testing.T) {
|
||||
piece := func(order, command string) string {
|
||||
return "[[block]]\nblock = \"custom\"\ncommand = \"" + command + "\"\ninterval = 5\n"
|
||||
}
|
||||
asOld := Manifest{Module: "power", Contributions: []SeatContribution{
|
||||
block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"two","interval":5}}`),
|
||||
block(`{"bar":"bottom","place":"status","shows":"command","options":{"command":"one","interval":5}}`),
|
||||
block(`{"bar":"top","place":"resources","shows":"command","options":{"command":"up","interval":5}}`),
|
||||
}}
|
||||
asItems := Manifest{Module: "power", Moved: true, Items: []Item{
|
||||
{Collection: "contribution:node-bar:block", Name: "b", Content: piece("", "one"),
|
||||
Attributes: map[string]string{"bar": "bottom", "place": "status"}},
|
||||
{Collection: "contribution:node-bar:block", Name: "a", Content: piece("", "two"),
|
||||
Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}},
|
||||
{Collection: "contribution:node-bar:block", Name: "c", Content: piece("", "up"),
|
||||
Attributes: map[string]string{"bar": "top", "place": "resources"}},
|
||||
{Collection: "contribution:node-bar:block", Name: "d", Content: "x\n", IfCapability: "battery",
|
||||
Attributes: map[string]string{"bar": "bottom", "place": "status"}},
|
||||
}}
|
||||
other := Manifest{Module: "another", Contributions: []SeatContribution{
|
||||
block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"mid","interval":5}}`)}}
|
||||
for _, caps := range []map[string]bool{nil, {"battery": true}} {
|
||||
was := composedFile(t, Resolution{Node: "n", Account: "op", Capabilities: caps,
|
||||
Modules: []Manifest{barHolder(), asOld, other}}, "a-bar.bottom")
|
||||
is := composedFile(t, Resolution{Node: "n", Account: "op", Capabilities: caps,
|
||||
Modules: []Manifest{barHolder(), asItems, other}}, "a-bar.bottom")
|
||||
// The old form puts the contributions of a module under one line naming it and the item form puts one under
|
||||
// each item: the same pieces in the same order, otherwise the same bytes.
|
||||
want := "own-first\nown-middle\n# another\n" + piece("", "mid") + "# power\n" + piece("", "two") +
|
||||
"# power\n" + piece("", "one")
|
||||
if caps["battery"] {
|
||||
want += "# power\nx\n"
|
||||
}
|
||||
want += "own-last\n"
|
||||
if is != want {
|
||||
t.Errorf("caps %v: the items render\n%s\nnot\n%s", caps, is, want)
|
||||
}
|
||||
if !strings.Contains(was, piece("", "two")) {
|
||||
t.Errorf("the contributions made\n%s", was)
|
||||
}
|
||||
}
|
||||
if got := composedFile(t, Resolution{Node: "n", Account: "op", Modules: []Manifest{barHolder(), asItems}}, "a-bar.top"); !strings.Contains(got, "\"up\"") || strings.Contains(got, `"one"`) {
|
||||
t.Errorf("the top bar renders\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A collection is declared and filled by the client (novox/hq ADR 0349 §13, design 57 L1, issue 684): a collector
|
||||
// names a collection it collects, a contributor an item from a template of its own, and a handed composition plans
|
||||
// both for the node's provisioner client and renders neither. The three old namespaces read as collections of their
|
||||
// own name. Each file is filled one way: a piece of the other form on the machine is said as not placed.
|
||||
|
||||
const (
|
||||
collectorZsh = `{"module":"zsh","version":"1","moved":true,
|
||||
"claims":[{"name":"node-login-shell","scope":"node"}],
|
||||
"collections":[{"name":"widgets","comment":"#"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"rc","type":"file","path":"${machine:account-home}/.zshrc","owner":"${machine:account}",
|
||||
"mode":"0644","template":"files/zshrc"}]}`
|
||||
zshrcText = "# zsh\n${shell:zsh:normal}${collected:widgets}"
|
||||
contributorText = "source ${machine:account-home}/.p10k.zsh\n"
|
||||
contributorP10k = `{"module":"prompt","version":"1","moved":true,
|
||||
"items":[{"collection":"shell:zsh:normal","template":"files/zsh"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
|
||||
oldFormAliases = `{"module":"aliases","version":"1",
|
||||
"shell":[{"for":"zsh","slot":"normal","code":"alias ll='ls -l'\n"}]}`
|
||||
)
|
||||
|
||||
func builtWith(module string, texts map[string]string) []Built {
|
||||
return []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + module + "/files/blobs/sha256:" + strings.Repeat("e", 64),
|
||||
Digest: "sha256:" + strings.Repeat("e", 64), Texts: texts}}
|
||||
}
|
||||
|
||||
func collectionsOn(t *testing.T) Resolution {
|
||||
t.Helper()
|
||||
zsh := held(t, collectorZsh, builtWith("zsh", map[string]string{"zshrc": zshrcText}))
|
||||
prompt := held(t, contributorP10k, builtWith("prompt", map[string]string{"zsh": contributorText}))
|
||||
aliases, err := ParseManifest([]byte(oldFormAliases))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := Resolve(shelf(zsh, prompt, aliases), []string{"zsh", "prompt", "aliases"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAContributorsBuildKeepsItsItemsTemplateAndText(t *testing.T) {
|
||||
m := held(t, contributorP10k, builtWith("prompt", map[string]string{"zsh": contributorText}))
|
||||
if m.Handed == nil || !reflect.DeepEqual(m.Handed.Items, map[string]string{"shell:zsh:normal": "zsh"}) {
|
||||
t.Fatalf("the build kept %+v", m.Handed)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Items, []Item{{Collection: "shell:zsh:normal", Content: contributorText}}) {
|
||||
t.Fatalf("the held items are %+v: the template's text, read back", m.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHandedCollectorAndContributorArePlannedAndNeitherIsRendered(t *testing.T) {
|
||||
c, got := composeWith(t, collectionsOn(t), handingTo("store:5000"))
|
||||
if strings.Contains(got, ".p10k.zsh") || strings.Contains(got, "alias ll") {
|
||||
t.Fatalf("the controller rendered a contribution into a handed collector: %s", got)
|
||||
}
|
||||
zsh, prompt := c.Plans["zsh"], c.Plans["prompt"]
|
||||
wantCollections := []PlanCollection{{Name: "shell:zsh:normal", Comment: "#"}, {Name: "widgets", Comment: "#"}}
|
||||
if !reflect.DeepEqual(zsh.Collections, wantCollections) || len(zsh.Items) != 0 {
|
||||
t.Fatalf("zsh's plan collects %+v and contributes %+v", zsh.Collections, zsh.Items)
|
||||
}
|
||||
if !reflect.DeepEqual(prompt.Items, []PlanItem{{Collection: "shell:zsh:normal", Template: "zsh"}}) ||
|
||||
len(prompt.Files) != 0 || prompt.Placement["machine:account-home"] != "/home/op" {
|
||||
t.Fatalf("prompt's plan is %+v: its item, by template, and the placement its template names", prompt)
|
||||
}
|
||||
// The old form on the same machine is not placed, and said so.
|
||||
if len(c.Unplaced) != 1 || !strings.Contains(c.Unplaced[0], "aliases's shell:zsh:normal is not placed") {
|
||||
t.Fatalf("unplaced: %v", c.Unplaced)
|
||||
}
|
||||
}
|
||||
|
||||
// A collector filled the old way holds the old forms and each moved contributor's item, rendered from the text the
|
||||
// mesh holds as its old form was (design 57 L2, issue 681), so the file loses no line where nothing is handed; a
|
||||
// collection of the new form stays empty.
|
||||
func TestACollectorFilledTheOldWayHoldsTheOldFormsAndEachHeldItem(t *testing.T) {
|
||||
c, got := composeWith(t, collectionsOn(t), Rendering{ArtifactStore: "store:5000"})
|
||||
if c.Plans != nil {
|
||||
t.Fatalf("handed on a machine that cannot take it: %v", c.Plans)
|
||||
}
|
||||
if !strings.Contains(got, `"content":"# zsh\n# aliases\nalias ll='ls -l'\n# prompt\nsource /home/op/.p10k.zsh\n"`) {
|
||||
t.Fatalf("the old way does not hold the old forms and the held item in module order: %s", got)
|
||||
}
|
||||
if len(c.Unplaced) != 0 {
|
||||
t.Fatalf("unplaced: %v", c.Unplaced)
|
||||
}
|
||||
}
|
||||
|
||||
// An item the controller cannot render the old way (it names a value but a machine fact) is said as not placed.
|
||||
func TestAHeldItemNamingMoreThanAFactIsSaidNotPlacedTheOldWay(t *testing.T) {
|
||||
zsh := held(t, collectorZsh, builtWith("zsh", map[string]string{"zshrc": zshrcText}))
|
||||
if _, ok := heldItemText(Manifest{Module: "p", Items: []Item{{Collection: "shell:zsh:normal",
|
||||
Content: "export THEME=${config:theme}\n"}}}, "shell:zsh:normal", map[string]string{}); ok {
|
||||
t.Fatal("an item naming a config value was rendered by the controller")
|
||||
}
|
||||
if _, ok := heldItemText(Manifest{Module: "p", Items: []Item{{Collection: "shell:zsh:normal",
|
||||
Content: "cd ${machine:account-home}\n"}}}, "shell:zsh:normal", map[string]string{}); ok {
|
||||
t.Fatal("an item naming a fact this machine lacks was rendered")
|
||||
}
|
||||
if _, ok := heldItemText(Manifest{Module: "p", Items: []Item{{Collection: "widgets", Content: "x\n"}}},
|
||||
"widgets", nil); ok {
|
||||
t.Fatal("an item of a collection of the new form was rendered the old way")
|
||||
}
|
||||
left := itemsLeftOut([]Manifest{zsh, {Module: "p", Items: []Item{{Collection: "shell:zsh:normal",
|
||||
Content: "export THEME=${config:theme}\n"}}}}, zsh, "shell:zsh:normal", map[string]string{})
|
||||
if len(left) != 1 || !strings.Contains(left[0], "p's item of shell:zsh:normal is not placed") {
|
||||
t.Fatalf("left out: %v", left)
|
||||
}
|
||||
}
|
||||
|
||||
// Each old namespace L2 moves renders a moved contributor's item exactly as its old form did, where the collector is
|
||||
// not handed: the same contributor written both ways composes the same bytes (design 57 L2, issue 681).
|
||||
func TestAMovedContributorsItemRendersAsItsOldFormDid(t *testing.T) {
|
||||
code := "# two lines\nalias a=b"
|
||||
asOld := Manifest{Module: "laptop", Shell: []ShellCode{{For: "zsh", Slot: "normal", Code: code}},
|
||||
Contributions: []SeatContribution{{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_F21 1 touchpad\n"}}}
|
||||
asItem := Manifest{Module: "laptop", Moved: true, Items: []Item{{Collection: "shell:zsh:normal", Content: code},
|
||||
{Collection: "contribution:node-hotkeys:trigger", Content: "KEY_F21 1 touchpad\n"}}}
|
||||
other := Manifest{Module: "zz-other", Shell: []ShellCode{{For: "zsh", Slot: "normal", Code: "x=1\n"}},
|
||||
Contributions: []SeatContribution{{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_MUTE 1 mute\n"}}}
|
||||
first := Manifest{Module: "aa-first", Shell: []ShellCode{{For: "zsh", Slot: "normal", Code: "y=2\n"}}}
|
||||
for _, mods := range [][]Manifest{{other, first}, {}} {
|
||||
was := append(append([]Manifest(nil), mods...), asOld)
|
||||
is := append(append([]Manifest(nil), mods...), asItem)
|
||||
if a, b := shellCodeOn(was, "zsh", "normal", nil), shellCodeOn(is, "zsh", "normal", nil); a != b || a == "" {
|
||||
t.Errorf("shell: the old form made %q, the item %q", a, b)
|
||||
}
|
||||
a, _, errA := seatContributions(was, hotkeysHolder(), "node-hotkeys:trigger", Rendering{}, nil, nil)
|
||||
b, left, errB := seatContributions(is, hotkeysHolder(), "node-hotkeys:trigger", Rendering{}, nil, nil)
|
||||
if errA != nil || errB != nil || a != b || a == "" || len(left) != 0 {
|
||||
t.Errorf("hotkeys: the old form made %q, the item %q (%v %v %v)", a, b, errA, errB, left)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionsAndItemsAreJudged(t *testing.T) {
|
||||
for name, c := range map[string]struct{ raw, says string }{
|
||||
"an item of a module not moved": {`{"module":"m","version":"1",
|
||||
"items":[{"collection":"shell:zsh:normal","template":"files/x"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is marked moved"},
|
||||
"two items to one collection": {`{"module":"m","version":"1","moved":true,
|
||||
"items":[{"collection":"a","template":"files/x"},{"collection":"a","template":"files/y"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "contributes to a twice"},
|
||||
"an item to no slot": {`{"module":"m","version":"1","moved":true,
|
||||
"items":[{"collection":"shell:zsh:middle","template":"files/x"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is no slot"},
|
||||
"an item outside the templates": {`{"module":"m","version":"1","moved":true,
|
||||
"items":[{"collection":"a","template":"x"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is not a file under files/"},
|
||||
"an item naming a secret": {`{"module":"m","version":"1",
|
||||
"items":[{"collection":"a","content":"t=${secret:token}"}]}`, "where no secret is"},
|
||||
"a collection not declared": {`{"module":"m","version":"1",
|
||||
"resources":[{"id":"f","type":"file","path":"/f","content":"${collected:a}"}]}`, "does not collect"},
|
||||
"an old namespace declared": {`{"module":"m","version":"1","collections":[{"name":"shell:zsh:normal"}],
|
||||
"resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "read from its placeholder"},
|
||||
"a collection placed nowhere": {`{"module":"m","version":"1","collections":[{"name":"a"}],
|
||||
"resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "no file of its places it"},
|
||||
"a collection in a path": {`{"module":"m","version":"1","collections":[{"name":"a"}],
|
||||
"resources":[{"id":"f","type":"file","path":"/${collected:a}","content":"${collected:a}"}]}`,
|
||||
"only in a file's content"},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(c.raw))
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v, want it to say %q", name, err, c.says)
|
||||
}
|
||||
}
|
||||
if _, err := ParseManifest([]byte(collectorZsh)); err != nil {
|
||||
t.Errorf("a collector: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,8 @@ func ConfigProblems(m Manifest) []string {
|
||||
}
|
||||
var problems []string
|
||||
placed := configPlaced(m)
|
||||
fallsBack := fileFallsBack(m)
|
||||
renamed := renamedTaken(m)
|
||||
for _, key := range sortedSettingKeys(m.Settings) {
|
||||
d := m.Settings[key]
|
||||
say := func(format string, args ...any) {
|
||||
@@ -239,12 +241,18 @@ func ConfigProblems(m Manifest) []string {
|
||||
say("neither a default nor required: give a default, or say \"required\": true for a value that is " +
|
||||
"the operator's own (novox/hq ADR 0348 §4)")
|
||||
}
|
||||
if as := renamed[key]; as != "" && d.Required {
|
||||
say("is laid into its file as %q, and only when it is set, so the file and its code fall back when "+
|
||||
"it is not: it is never required; give it the file's default, the empty value of its type "+
|
||||
"(novox/hq issue 751)", as)
|
||||
}
|
||||
for _, wrong := range validationProblems(d) {
|
||||
say("%s", wrong)
|
||||
}
|
||||
if word := operatorsWord(key); word != "" && d.Default != nil {
|
||||
say("a key naming %q is the operator's value: declare it \"required\": true, with no default "+
|
||||
"(novox/hq ADR 0348 §4, ADR 0112)", word)
|
||||
if word := operatorsWord(key); word != "" && d.Default != nil && !fallsBack[key] {
|
||||
say("a key naming %q is the operator's value: declare it \"required\": true, with no default, "+
|
||||
"unless a mergeable file takes it and no placeholder asks for it, where the file falls back "+
|
||||
"(novox/hq ADR 0348 §4, ADR 0112, issue 735)", word)
|
||||
}
|
||||
if d.Predefined != "" {
|
||||
p, listed := m.predefined[d.Predefined]
|
||||
@@ -294,13 +302,51 @@ func ConfigProblems(m Manifest) []string {
|
||||
func configPlaced(m Manifest) map[string]bool {
|
||||
placed := settingKeysUsedBy(m)
|
||||
for _, r := range m.Resources {
|
||||
for _, k := range stringsOf(r[TakesSettingsField]) {
|
||||
for _, k := range takenKeyList(r) {
|
||||
placed[k] = true
|
||||
}
|
||||
}
|
||||
return placed
|
||||
}
|
||||
|
||||
// fileFallsBack is every value a mergeable file takes and no placeholder asks for. Unset, such a value is simply
|
||||
// not laid over the file: the file's own content, or the module's code reading the file, falls back. So one named
|
||||
// after the operator's value (a url, a user, an address) is not `required`, and its default is the file's: its
|
||||
// value in the content, else the empty value of its type, never laid over the file (the operator's decision on
|
||||
// issue 664, issue 735). A placeholder has no such fallback: its default would be filled in where the operator's
|
||||
// value belongs (ADR 0112).
|
||||
func fileFallsBack(m Manifest) map[string]bool {
|
||||
asked := settingKeysUsedBy(m)
|
||||
out := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how == "" {
|
||||
continue
|
||||
}
|
||||
for _, k := range takenKeyList(r) {
|
||||
if !asked[k] {
|
||||
out[k] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renamedTaken is every value a mergeable file takes under a name of its own, with that name (issue 751).
|
||||
func renamedTaken(m Manifest) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how == "" {
|
||||
continue
|
||||
}
|
||||
for _, e := range takenEntries(r[TakesSettingsField]) {
|
||||
if e.renamed {
|
||||
out[e.key] = e.as
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validationProblems is what is wrong with a declaration's validation, and its default against it.
|
||||
func validationProblems(d SettingDeclaration) []string {
|
||||
var problems []string
|
||||
@@ -388,7 +434,7 @@ func configTakesProblems(m Manifest) []string {
|
||||
continue
|
||||
}
|
||||
naming = true
|
||||
for _, k := range stringsOf(r[TakesSettingsField]) {
|
||||
for _, k := range takenKeyList(r) {
|
||||
_, declared := m.Settings[k]
|
||||
switch {
|
||||
case accountFacts[k]:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package catalogue
|
||||
|
||||
import "strings"
|
||||
|
||||
// What the move of config reads of a module's declaration (novox/hq ADR 0348 §9, design 57 K5, issue 664): where a
|
||||
// layer's key goes, how two layers' values become one, and whether a value is one its declaration takes.
|
||||
|
||||
// TakenAs is each name a mergeable file takes a config value under that is not the value's config key, with that
|
||||
// key (issue 751): a layer set before the module moved may still say the file's name, and the move sets it under
|
||||
// the config key.
|
||||
func TakenAs(m Manifest) map[string]string {
|
||||
out := map[string]string{}
|
||||
for key, as := range renamedTaken(m) {
|
||||
out[as] = key
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AskedKeys is every value a placeholder of the module asks for: a file, a unit, a directory's owner, a
|
||||
// contribution or a served fact. Such a value is taken whole from the last layer that sets it, where a mergeable
|
||||
// file lays one object over another key by key.
|
||||
func AskedKeys(m Manifest) map[string]bool { return settingKeysUsedBy(m) }
|
||||
|
||||
// LaidOver is a value laid over another as a later layer is laid over an earlier: two objects key by key, anything
|
||||
// else replaced whole.
|
||||
func LaidOver(under, over any) any { return mergeValue(under, over) }
|
||||
|
||||
// ValueProblems is what is wrong with a value for a declaration: its type, and its enum, bounds or pattern.
|
||||
func ValueProblems(d SettingDeclaration, v any) []string {
|
||||
if v == nil {
|
||||
return []string{"no value"}
|
||||
}
|
||||
d.Default = v
|
||||
d.Required = false
|
||||
var problems []string
|
||||
for _, p := range validationProblems(d) {
|
||||
problems = append(problems, strings.ReplaceAll(p, "its default", "the value"))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A takes-config entry may lay a value under the name the file's software reads (issue 751): the key stays a
|
||||
// config key, lower-case, and the file names its own variable, such as ATLASSIAN_URL.
|
||||
|
||||
// renaming is inConfigForm with its rules file taking `url` as NOTIFIER_URL, a value the code reading the file
|
||||
// falls back on when the file lacks it.
|
||||
func renaming() map[string]any {
|
||||
m := inConfigForm()
|
||||
m["config"].(map[string]any)["url"] = map[string]any{"type": "string",
|
||||
"description": "the address the notifier posts to, a URL; unset, the notifier's own environment says it",
|
||||
"default": ""}
|
||||
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme",
|
||||
map[string]any{"key": "url", "as": "NOTIFIER_URL"}}
|
||||
return m
|
||||
}
|
||||
|
||||
// rulesComposed is the rules file's content as the module's defaults and these layers make it.
|
||||
func rulesComposed(t *testing.T, m Manifest, layers ...Layer) string {
|
||||
t.Helper()
|
||||
all := layers
|
||||
if d, ok := Defaults(m); ok {
|
||||
all = append([]Layer{d}, layers...)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] != "rules" {
|
||||
continue
|
||||
}
|
||||
out, err := ApplySettings(r, all)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, left := out[TakesSettingsField]; left {
|
||||
t.Fatalf("the composed file still says %s: %v", TakesSettingsField, out)
|
||||
}
|
||||
return out["content"].(string)
|
||||
}
|
||||
t.Fatal("no rules file")
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestATakenValueIsLaidUnderTheFilesOwnName(t *testing.T) {
|
||||
m, err := parseRaw(t, renaming())
|
||||
if err != nil {
|
||||
t.Fatalf("a renamed takes-config entry was refused: %v", err)
|
||||
}
|
||||
got := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{"url": "https://hooks.example.test/n"}})
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc["NOTIFIER_URL"] != "https://hooks.example.test/n" {
|
||||
t.Fatalf("url was not laid as NOTIFIER_URL: %s", got)
|
||||
}
|
||||
if _, said := doc["url"]; said {
|
||||
t.Fatalf("url was laid under the config key too: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnsetRenamedValueLeavesTheFileAsItWas(t *testing.T) {
|
||||
plain := inConfigForm()
|
||||
base, err := parseRaw(t, plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := parseRaw(t, renaming())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := rulesComposed(t, base)
|
||||
if got := rulesComposed(t, m); got != want {
|
||||
t.Fatalf("with no layer the file is\n%s\nwanted, as without the entry,\n%s", got, want)
|
||||
}
|
||||
other := Layer{From: "ace", Values: map[string]any{"theme": "light"}}
|
||||
if got, want := rulesComposed(t, m, other), rulesComposed(t, base, other); got != want {
|
||||
t.Fatalf("with url unset the file is\n%s\nwanted\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A layer said before its module moved names the file's own spelling, such as nodered's timeZone on one machine.
|
||||
// Until K5 moves the layer and renames the key, that spelling still reaches the file, so the move changes no
|
||||
// machine's file; the config key wins where one layer says both.
|
||||
func TestALayerStillSayingTheFilesNameReachesItUntilItsMove(t *testing.T) {
|
||||
m, err := parseRaw(t, renaming())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, said := range []string{"url", "NOTIFIER_URL"} {
|
||||
if stray := UnusedSettings(m, []Layer{{From: "ace", Values: map[string]any{said: "https://x.test"}}}); len(stray) > 0 {
|
||||
t.Fatalf("%s, which the rules file takes, is said stray: %v", said, stray)
|
||||
}
|
||||
got := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{said: "https://x.test"}})
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc["NOTIFIER_URL"] != "https://x.test" || doc["url"] != nil {
|
||||
t.Fatalf("a layer saying %s made the file %s", said, got)
|
||||
}
|
||||
}
|
||||
both := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{"url": "https://key.test", "NOTIFIER_URL": "https://old.test"}})
|
||||
if !strings.Contains(both, "https://key.test") || strings.Contains(both, "old.test") {
|
||||
t.Fatalf("where one layer says both, the config key did not win: %s", both)
|
||||
}
|
||||
later := rulesComposed(t, m, Layer{From: "mesh", Values: map[string]any{"url": "https://mesh.test"}},
|
||||
Layer{From: "ace", Values: map[string]any{"NOTIFIER_URL": "https://ace.test"}})
|
||||
if !strings.Contains(later, "https://ace.test") {
|
||||
t.Fatalf("a later layer's old spelling did not beat an earlier layer's key: %s", later)
|
||||
}
|
||||
// Who may set it is the same under either spelling: the rules file is trusted, so both are the terminal's.
|
||||
terminal := TerminalKeys(m)
|
||||
for _, k := range []string{"url", "NOTIFIER_URL"} {
|
||||
if !listHolds(terminal, k) {
|
||||
t.Fatalf("%s is not the terminal's in a trusted file: %v", k, terminal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func listHolds(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestARenamedEntryIsWrittenBackAsItWasSaid(t *testing.T) {
|
||||
m, err := parseRaw(t, renaming())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"takes-config":["position","theme",{"as":"NOTIFIER_URL","key":"url"}]`) {
|
||||
t.Fatalf("written back as %s", raw)
|
||||
}
|
||||
if _, err := ParseManifest(raw); err != nil {
|
||||
t.Fatalf("read again: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARenamedEntryIsRefusedWhereItCouldNotHold(t *testing.T) {
|
||||
entry := func(m map[string]any, e any) map[string]any {
|
||||
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", e}
|
||||
return m
|
||||
}
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url"}), "notifier's rules", `"key" and "as"`)
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL", "when": "set"}), `"key" and "as"`)
|
||||
refused(t, entry(renaming(), map[string]any{"key": "URL", "as": "NOTIFIER_URL"}), "not a usable key")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER URL"}), "not a usable name")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "endpoints"}), `"endpoints"`, "the mesh reads")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "theme"}), `"theme"`, "twice")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "theme", "as": "THEME"}), `"theme"`, "twice")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "nothing", "as": "NOTHING"}), `"nothing"`, "declares no such value")
|
||||
|
||||
kept := entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL"})
|
||||
resourceOf(kept, "rules")["protected"] = []any{"NOTIFIER_URL"}
|
||||
refused(t, kept, `"NOTIFIER_URL"`, "protected")
|
||||
|
||||
// Laid only when set, so the code's own fallback holds: never required.
|
||||
required := renaming()
|
||||
declaration(required, "url")["required"] = true
|
||||
delete(declaration(required, "url"), "default")
|
||||
refused(t, required, `"url"`, "only when it is set", "never required")
|
||||
}
|
||||
|
||||
func TestARenamedEntryIsTheConfigFormsOnly(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"module": "notifier", "version": "1",
|
||||
"resources": []any{
|
||||
map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", "content": "{}",
|
||||
"takes-settings": []any{map[string]any{"key": "url", "as": "NOTIFIER_URL"}}},
|
||||
},
|
||||
}
|
||||
refused(t, m, "notifier's rules", "config form")
|
||||
}
|
||||
@@ -452,3 +452,33 @@ func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) {
|
||||
t.Fatalf("the settings form's refusal changed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A value named after the operator's own (a url, a user, an address) is `required` where nothing falls back when
|
||||
// it is unset; where a mergeable file takes it and no placeholder asks for it, the file's own content (or the
|
||||
// module's code reading the file) falls back, so it keeps a default and stays optional, as it is today: the
|
||||
// operator's decision on issue 664's second question (issue 735).
|
||||
func TestAnOperatorNamedValueAFileFallsBackOnMayHaveADefault(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
m["config"].(map[string]any)["url"] = map[string]any{"type": "string",
|
||||
"description": "the address the notifier posts to, a URL; empty for the one the file's software picks",
|
||||
"default": ""}
|
||||
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "url"}
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatalf("a taken value named after the operator's, with a default, was refused: %v", err)
|
||||
}
|
||||
|
||||
// A placeholder asking for it has no fallback but the default, which would put a value in the definition
|
||||
// where the operator's belongs (ADR 0112).
|
||||
asked := inConfigForm()
|
||||
asked["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL", "default": ""}
|
||||
resourceOf(asked, "rules")["takes-config"] = []any{"position", "theme", "url"}
|
||||
resourceOf(asked, "configuration")["content"] = "font = Inter ${config:font-size}\nwidth = ${config:width}\n" +
|
||||
"from = ${config:sender}\npost = ${config:url}\n"
|
||||
refused(t, asked, `"url"`, `a key naming "url" is the operator's value`)
|
||||
|
||||
// Read by the module's code alone, nothing in the definition says it falls back.
|
||||
runtime := inConfigForm()
|
||||
runtime["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL",
|
||||
"default": "", "runtime": true}
|
||||
refused(t, runtime, `"url"`, `a key naming "url" is the operator's value`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// A provider's consumers' credentials, in a file of the provider's own (novox/hq ADR 0373, issue 679).
|
||||
//
|
||||
// A provider in the tool runner may not ask the vault (ADR 0349 clause 11, ADR 0370 clause 2), so its template names
|
||||
// `${consumers:<provision>}` and its machine's provisioner client fills it: one entry per consumer bound to the
|
||||
// provider for that provision, each carrying the credential the vault keeps for the pair. Only the client fills it,
|
||||
// so the placeholder is a provider's alone, for a provision it provides, in a file only the provider reads (mode 0600
|
||||
// or 0400, owned at runtime, so a change on disk is written back); and a module not handed to the client on a machine
|
||||
// is refused there, never written the placeholder's text, nor an empty list the provider would read as no consumers.
|
||||
|
||||
// ConsumersNamespace is a provider's consumers' credentials' placeholder namespace, `${consumers:<provision>}`.
|
||||
const ConsumersNamespace = "consumers"
|
||||
|
||||
// ofConsumers is where a provider places its consumers' credentials. Loose inside the braces, so a misspelt name is
|
||||
// refused rather than passed over.
|
||||
var ofConsumers = regexp.MustCompile(`\$\{consumers:([^}]*)\}`)
|
||||
|
||||
// consumersPlaceholderProblems is what is wrong with one resource's ${consumers:…}: placed only in a file's content,
|
||||
// for a provision the module provides, in a file only the provider reads and the client writes back.
|
||||
func consumersPlaceholderProblems(m Manifest, r map[string]any) []string {
|
||||
var problems []string
|
||||
for _, field := range sortedKeys(r) {
|
||||
v, ok := r[field].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, f := range ofConsumers.FindAllStringSubmatch(v, -1) {
|
||||
switch {
|
||||
case field != "content" || fmt.Sprint(r["type"]) != "file":
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a provider's consumers "+
|
||||
"are placed only in a file's content (novox/hq ADR 0373)", m.Module, r["id"], f[0], field))
|
||||
continue
|
||||
case !slices.ContainsFunc(m.Provides, func(o Offer) bool { return o.Name == f[1] }):
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not provide %q: only a "+
|
||||
"provider's template holds its consumers' credentials (novox/hq ADR 0373)", m.Module, r["id"], f[0],
|
||||
m.Module, f[1]))
|
||||
}
|
||||
if mode := fmt.Sprint(r["mode"]); mode != "0600" && mode != "0400" {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) with "+
|
||||
"mode %q: the provider alone reads it, 0600 or 0400 (novox/hq ADR 0373)", m.Module, r["id"], f[0],
|
||||
r["mode"]))
|
||||
}
|
||||
if owner := ownershipOf(r); owner != RuntimeOwned {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) and is "+
|
||||
"%s at runtime: it is owned, written back whenever it differs (novox/hq ADR 0373)", m.Module,
|
||||
r["id"], f[0], owner))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// consumersHandedOnly says what a file naming ${consumers:…} is where it would be written the old way: the provisioner's
|
||||
// client alone fills it, so on a machine that does not hand the module it is **left out** of the declaration, never
|
||||
// written as that text or as an empty list the provider would read as no consumers, and never the reason the module is
|
||||
// refused: a provider not handed there keeps working as it did, from its grant files (novox/hq ADR 0373, issue 780).
|
||||
// A moved module judged from its repository's tree (WithTemplatesFrom: `module check`, the merge gate), on a machine
|
||||
// that would hand it, has no templates archive to hand and is judged as it composes; there the placeholder is judged
|
||||
// and taken out of the text, since what is composed so is never sent (judged answers whether the file is so judged).
|
||||
func consumersHandedOnly(m Manifest, r map[string]any, with Rendering) (judged, leftOut bool) {
|
||||
content, _ := r["content"].(string)
|
||||
switch {
|
||||
case ofConsumers.FindString(content) == "":
|
||||
return false, false
|
||||
case m.Moved && m.Handed == nil && with.Hands && with.Provisioner != "":
|
||||
return true, false
|
||||
}
|
||||
return false, true
|
||||
}
|
||||
|
||||
// consumersLeftOutLine is the plan's one line for a file left out because the module is not handed.
|
||||
func consumersLeftOutLine(m Manifest, r map[string]any) string {
|
||||
return fmt.Sprintf("%s's %v names ${consumers:…}, which the provisioner's client alone fills: left out, handed only "+
|
||||
"(novox/hq ADR 0373)", m.Module, r["id"])
|
||||
}
|
||||
|
||||
// consumersNotHanded is consumersHandedOnly as a refusal, for a caller that has no way to leave the file out.
|
||||
func consumersNotHanded(m Manifest, r map[string]any, with Rendering) (judged bool, err error) {
|
||||
judged, left := consumersHandedOnly(m, r, with)
|
||||
if left {
|
||||
return false, fmt.Errorf("%s's resource %v names %s, which the provisioner's client alone fills, and %s is not "+
|
||||
"handed to it on this machine: it would be written as that text, or empty, and the provider would read no "+
|
||||
"consumers (novox/hq ADR 0373)", m.Module, r["id"], ofConsumers.FindString(fmt.Sprint(r["content"])), m.Module)
|
||||
}
|
||||
return judged, nil
|
||||
}
|
||||
|
||||
// consumersOfTemplate is what is wrong with a handed file's template naming ${consumers:…}, judged on the file's
|
||||
// resource as if its text were inline.
|
||||
func consumersOfTemplate(m Manifest, id, text string) []string {
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["id"]) != id {
|
||||
continue
|
||||
}
|
||||
withText := map[string]any{}
|
||||
for k, v := range r {
|
||||
withText[k] = v
|
||||
}
|
||||
withText["content"] = text
|
||||
return consumersPlaceholderProblems(m, withText)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) {
|
||||
"an empty environment": {empty, nil},
|
||||
}
|
||||
for name, c := range cases {
|
||||
got := DependsOn(c.m)
|
||||
got := DependsOn(nil, c.m)
|
||||
if len(got) == 0 {
|
||||
got = nil
|
||||
}
|
||||
@@ -118,7 +118,7 @@ func TestCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) {
|
||||
for _, moment := range powerMoments {
|
||||
m := mod("laptop", nil, nil, nil)
|
||||
m.Shell = []ShellCode{{For: moment, Slot: "normal", Code: "true"}}
|
||||
if got := DependsOn(m); !reflect.DeepEqual(got, []string{PowerSeat}) {
|
||||
if got := DependsOn(nil, m); !reflect.DeepEqual(got, []string{PowerSeat}) {
|
||||
t.Errorf("code for %s depends on %v, want %s", moment, got, PowerSeat)
|
||||
}
|
||||
if p := m.shellProblems(); len(p) != 0 {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// The core's own credentials (novox/hq ADR 0349 §16, issue 755). The bus, the node-engine, the controller and
|
||||
// mesh-provisioner need their files before any provisioner client runs, so their files stay placement and their
|
||||
// credentials travel in the declaration as values the vault sealed to the machine they run on, which the controller
|
||||
// carries and cannot open; the node-engine opens them there (mesh-host's VaultBoxContract).
|
||||
|
||||
// CoreCredentialModules are the modules whose own secrets are the core's credentials: the bus, mesh-provisioner and
|
||||
// its client. The node-engine and the controller hold no own secret of a module's. **Fixed here, in the controller's
|
||||
// code, as the clause says** — and the vault keeps the same list (mesh-vault's CoreModules), answering the controller
|
||||
// for these alone.
|
||||
var CoreCredentialModules = []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}
|
||||
|
||||
// CarriesVaultSealed says whether module's own secrets are the core's credentials.
|
||||
func CarriesVaultSealed(module string) bool { return slices.Contains(CoreCredentialModules, module) }
|
||||
|
||||
// VaultSealedField is the field of a file the vault sealed to its machine (mesh-host's File.VaultSealed).
|
||||
const VaultSealedField = "vault-sealed"
|
||||
|
||||
// ForeseenVaultBox stands for a core credential's box in a composition asked ahead of the send, when none is kept
|
||||
// yet: the send asks the vault for it. Never sent.
|
||||
var ForeseenVaultBox = json.RawMessage(`{"v":1,"eph":"","iv":"","tag":"","ct":"","foreseen":"asked of the vault by the next send"}`)
|
||||
|
||||
// vaultSealedOf is the vault's box for one of module's own secrets, if the rendering was given one.
|
||||
func (with Rendering) vaultSealedOf(module, name string) (json.RawMessage, bool) {
|
||||
box, ok := with.VaultSealed[module][name]
|
||||
return box, ok && len(box) > 0
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The core's own credentials travel sealed by the vault to their machine (novox/hq ADR 0349 §16, issue 755): the
|
||||
// controller carries the vault's box and cannot open it. The list is fixed here, and the composition carries the box
|
||||
// in place of the controller's own sealed value only where the rendering was given one.
|
||||
|
||||
func TestTheCoresOwnCredentialsAreTheBusTheProvisionerAndItsClient(t *testing.T) {
|
||||
if want := []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}; !reflect.DeepEqual(CoreCredentialModules, want) {
|
||||
t.Fatalf("the core's modules are %v, want %v (mesh-vault's CoreModules is the same list)", CoreCredentialModules, want)
|
||||
}
|
||||
for _, m := range []string{"nats", "mesh-provisioner", "mesh-provisioner-client"} {
|
||||
if !CarriesVaultSealed(m) {
|
||||
t.Errorf("%s is not on the list", m)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"postgres", "mesh-vault", "mesh-controller", ""} {
|
||||
if CarriesVaultSealed(m) {
|
||||
t.Errorf("%s is on the list", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const provisionerClient = `{"module":"mesh-provisioner-client","version":"1",
|
||||
"own-secrets":{"provisioner-broker":"${dir:state}/broker"},
|
||||
"resources":[{"id":"state","type":"directory","mode":"0700","place":"."}]}`
|
||||
|
||||
func clientOn(t *testing.T) Resolution {
|
||||
t.Helper()
|
||||
m, err := ParseManifest([]byte(provisionerClient))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return notifierOn(t, m)
|
||||
}
|
||||
|
||||
func brokerFile(t *testing.T, c Composed) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range c.Resources {
|
||||
if r["id"] == "mesh-provisioner-client.needs-provisioner-broker" {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var aVaultBox = json.RawMessage(`{"v":1,"eph":"ZQ==","iv":"aQ==","tag":"dA==","ct":"Yw=="}`)
|
||||
|
||||
func TestACoreCredentialCarriesTheVaultsBoxAndNoSealOfTheControllers(t *testing.T) {
|
||||
r := clientOn(t)
|
||||
with := Rendering{Needed: map[string]map[string]string{"mesh-provisioner-client": {"provisioner-broker": "nacl"}},
|
||||
VaultSealed: map[string]map[string]json.RawMessage{"mesh-provisioner-client": {"provisioner-broker": aVaultBox}}}
|
||||
c, got := composeWith(t, r, with)
|
||||
f := brokerFile(t, c)
|
||||
if f == nil {
|
||||
t.Fatalf("the credential's file is not declared: %s", got)
|
||||
}
|
||||
if f["sealed"] != nil || !reflect.DeepEqual(f["vault-sealed"], aVaultBox) || strings.Contains(got, `"nacl"`) {
|
||||
t.Fatalf("the file is %v: the vault's box alone, never the controller's seal", f)
|
||||
}
|
||||
// Without the controller's own value at all: the vault's box is enough.
|
||||
with.Needed = nil
|
||||
if c, _ := composeWith(t, r, with); brokerFile(t, c) == nil {
|
||||
t.Fatal("a core credential with the vault's box and no seal of the controller's was refused or dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACoreModuleTheVaultGaveNothingForIsLeftOutWithWhy(t *testing.T) {
|
||||
r := clientOn(t)
|
||||
c, got := composeWith(t, r, Rendering{NotPlaced: map[string]string{
|
||||
"mesh-provisioner-client": "the vault did not answer for its credential"}})
|
||||
if brokerFile(t, c) != nil || strings.Contains(got, "mesh-provisioner-client.") {
|
||||
t.Fatalf("a core module the vault gave nothing for was placed: %s", got)
|
||||
}
|
||||
if !strings.Contains(c.LeftOut["mesh-provisioner-client"], "the vault did not answer") {
|
||||
t.Fatalf("left out %v", c.LeftOut)
|
||||
}
|
||||
}
|
||||
|
||||
// An engine below VaultBoxContract is rendered with neither field: its composition is the controller's sealed value,
|
||||
// byte for byte what it was.
|
||||
func TestWithoutTheVaultsBoxTheCompositionIsAsBefore(t *testing.T) {
|
||||
r := clientOn(t)
|
||||
with := Rendering{Needed: map[string]map[string]string{"mesh-provisioner-client": {"provisioner-broker": "nacl"}}}
|
||||
before, _ := composeWith(t, r, with)
|
||||
with.VaultSealed, with.NotPlaced = map[string]map[string]json.RawMessage{}, map[string]string{}
|
||||
after, _ := composeWith(t, r, with)
|
||||
if !reflect.DeepEqual(before, after) || brokerFile(t, before)["sealed"] != "nacl" {
|
||||
t.Fatalf("the composition changed: %v", brokerFile(t, after))
|
||||
}
|
||||
}
|
||||
|
||||
// Asked ahead of a send that has no box kept yet: a stand-in, named as foreseen, never sent.
|
||||
func TestAForeseenVaultBoxIsNamed(t *testing.T) {
|
||||
r := clientOn(t)
|
||||
c, _ := composeWith(t, r, Rendering{VaultSealed: map[string]map[string]json.RawMessage{
|
||||
"mesh-provisioner-client": {"provisioner-broker": ForeseenVaultBox}}})
|
||||
if !reflect.DeepEqual(c.Foreseen, []string{"mesh-provisioner-client/provisioner-broker"}) {
|
||||
t.Fatalf("foreseen %v", c.Foreseen)
|
||||
}
|
||||
}
|
||||
@@ -197,9 +197,9 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) {
|
||||
}
|
||||
// What keeps something irreplaceable depends on the machine's backup holder — it backs it up or
|
||||
// watches its array; valuable data alone is backed up where a holder is, and refused nowhere.
|
||||
if !slices.Contains(DependsOn(media), BackupSeat) || !slices.Contains(DependsOn(pg), BackupSeat) ||
|
||||
slices.Contains(DependsOn(agent), BackupSeat) {
|
||||
t.Fatalf("depends: media %v, pg %v, agent %v", DependsOn(media), DependsOn(pg), DependsOn(agent))
|
||||
if !slices.Contains(DependsOn(nil, media), BackupSeat) || !slices.Contains(DependsOn(nil, pg), BackupSeat) ||
|
||||
slices.Contains(DependsOn(nil, agent), BackupSeat) {
|
||||
t.Fatalf("depends: media %v, pg %v, agent %v", DependsOn(nil, media), DependsOn(nil, pg), DependsOn(nil, agent))
|
||||
}
|
||||
if it, _ := media.DataItem("films"); it.OwnedByModule() || it.Protection() != "redundancy" {
|
||||
t.Fatalf("an operator's path reads %+v", it)
|
||||
|
||||
@@ -93,6 +93,15 @@ type Rendering struct {
|
||||
// makes. Nil on every composition that is sent, and on every other question, which then refuse
|
||||
// a secret not made with a *NotMadeError.
|
||||
Foreseen map[string]map[string]bool
|
||||
// VaultSealed is, for a module on the core's list (CoreCredentialModules), each own secret as the vault sealed it
|
||||
// to this machine (the mesh's sealed box, as the vault answered it), keyed like Needed (novox/hq ADR 0349 §16,
|
||||
// issue 755). Given only for a machine whose engine opens the box (VaultBoxContract): its file then carries the
|
||||
// box and never the controller's own sealed value. Nil everywhere else, and the composition is as it was.
|
||||
VaultSealed map[string]map[string]json.RawMessage
|
||||
// NotPlaced is each core module left out of this machine's declaration because the vault gave nothing for its
|
||||
// credential and no box was kept, with why (issue 755): the machine keeps what it holds of it, and a module not
|
||||
// yet there is not placed. Nil everywhere else.
|
||||
NotPlaced map[string]string
|
||||
|
||||
// Mesh is every node's address on the private network, which is what a rule saying "from the
|
||||
// mesh" resolves to. Passed in for the same reason grants are: who else is on the network is
|
||||
@@ -257,6 +266,13 @@ type Rendering struct {
|
||||
// contract is link.BusyContract or later). To an older, strict engine the field is not sent, and the
|
||||
// container is replaced at once, as before.
|
||||
ReadsBusy bool
|
||||
|
||||
// Hands says this machine's node-engine writes a filled file on the provisioner client's word alone (its
|
||||
// statement's contract is link.HandedContract or later, design 57 P7), and Provisioner is the account the
|
||||
// provisioner's client runs as here, empty where none runs (novox/hq ADR 0349 §4). With both, a moved module's
|
||||
// templated files are handed to the client (handed.go); without either, it composes as before.
|
||||
Hands bool
|
||||
Provisioner string
|
||||
}
|
||||
|
||||
// RootField is a user resource's field saying the account must never become root without a person
|
||||
@@ -331,6 +347,15 @@ type Composed struct {
|
||||
// naming its module and why (novox/hq ADR 0255): that piece is left out and the machine is told
|
||||
// everything else. Push and plan say it; the merge gate refuses a change that adds one.
|
||||
Unplaced []string
|
||||
// HandedOnly is each file left out because only the provisioner's client fills it and the module is not handed on
|
||||
// this machine (a provider's consumers file, ADR 0373), said once in the plan. Not an unplaced contribution, which
|
||||
// the merge gate refuses: a module not handed composes as it did.
|
||||
HandedOnly []string
|
||||
// Filled is the path of every file handed to the provisioner, in the order composed: the declaration's
|
||||
// filled files (novox/hq ADR 0349 §4, §5). Plans is each handed module's plan on this machine, by module.
|
||||
// Both empty where nothing is handed, and the declaration is then what it was.
|
||||
Filled []string
|
||||
Plans map[string]Plan
|
||||
}
|
||||
|
||||
// ForeseenSealed is what stands for an own secret a send will make, in a composition asked ahead of
|
||||
@@ -390,8 +415,9 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||
owner := map[string]string{}
|
||||
received := map[string]map[string][]Contribution{}
|
||||
leftOut := map[string]string{}
|
||||
var foreseen, unplaced []string
|
||||
resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced)
|
||||
var foreseen, unplaced, handedOnly []string
|
||||
handed := &handedFiles{plans: map[string]Plan{}}
|
||||
resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced, &handedOnly, handed)
|
||||
if err != nil {
|
||||
return Composed{}, err
|
||||
}
|
||||
@@ -405,8 +431,13 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||
})
|
||||
}
|
||||
sort.Strings(unplaced)
|
||||
return Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut,
|
||||
Foreseen: foreseen, Unplaced: unplaced}, nil
|
||||
sort.Strings(handedOnly)
|
||||
out := Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut,
|
||||
Foreseen: foreseen, Unplaced: unplaced, HandedOnly: handedOnly, Filled: handed.paths}
|
||||
if plans := handed.finished(r.Needs); len(plans) > 0 {
|
||||
out.Plans = plans
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
||||
@@ -417,7 +448,7 @@ const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
||||
|
||||
func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
received map[string]map[string][]Contribution, leftOut map[string]string,
|
||||
foreseen, unplaced *[]string) ([]map[string]any, error) {
|
||||
foreseen, unplaced, handedOnly *[]string, handed *handedFiles) ([]map[string]any, error) {
|
||||
// **A setting is judged where it is stored, and an impossible one costs a module, not a
|
||||
// machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes
|
||||
// this module uncomposable; it is left out of the declaration — its held things kept, its
|
||||
@@ -426,6 +457,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
left := r.LeftOut(with.Settings, with.Adopted)
|
||||
kept := make([]Manifest, 0, len(r.Modules))
|
||||
var stillBackedUp []Manifest
|
||||
for module, why := range with.NotPlaced {
|
||||
if _, already := left[module]; !already {
|
||||
left[module] = why
|
||||
}
|
||||
}
|
||||
for _, m := range r.Modules {
|
||||
if why, isLeft := left[m.Module]; isLeft {
|
||||
if leftOut != nil {
|
||||
@@ -648,7 +684,36 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
"id": NeedID(name), "type": "file", "path": own.Path, "sealed": with.Needed[m.Module][name],
|
||||
}))
|
||||
}
|
||||
// **A handed module's own secrets are the vault's** (design 57 V4, issue 647): each file holding one is
|
||||
// declared placed and without a value, filled by the machine's provisioner client from the vault; the
|
||||
// controller makes and seals none of them here.
|
||||
handsSecrets := with.Handing(m)
|
||||
for _, name := range sortedKeys(m.OwnSecrets.Plain()) {
|
||||
if handsSecrets {
|
||||
owner := m.SecretsOwner
|
||||
if m.Module == RuntimeModule && r.Account != "" {
|
||||
owner = r.Account
|
||||
}
|
||||
first = append(first, ownedBy(owner, map[string]any{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, HandedSecretField: name,
|
||||
}))
|
||||
continue
|
||||
}
|
||||
// **A core credential is the vault's box** (ADR 0349 §16, issue 755): carried as the vault sealed it to this
|
||||
// machine, which the controller cannot open; the controller's own sealed value is not sent with it.
|
||||
if box, ok := with.vaultSealedOf(m.Module, name); ok {
|
||||
if string(box) == string(ForeseenVaultBox) {
|
||||
*foreseen = append(*foreseen, m.Module+"/"+name)
|
||||
}
|
||||
owner := m.SecretsOwner
|
||||
if m.Module == RuntimeModule && r.Account != "" {
|
||||
owner = r.Account
|
||||
}
|
||||
first = append(first, ownedBy(owner, map[string]any{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, VaultSealedField: box,
|
||||
}))
|
||||
continue
|
||||
}
|
||||
sealed := with.Needed[m.Module][name]
|
||||
if sealed == "" && with.Foreseen[m.Module][name] {
|
||||
// Not made, and the next send makes it: composed with a stand-in so that whatever
|
||||
@@ -718,6 +783,16 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
found = &r.Needs[i]
|
||||
}
|
||||
}
|
||||
// **A credential the vault keeps for this holder alone** (issue 757): where the module is handed, its
|
||||
// file is declared placed and without a value, and the machine's client fills it from the vault.
|
||||
if found != nil && found.IssuedToHolder && with.Handing(m) {
|
||||
first = append(first, ownedBy(m.SecretsOwner, map[string]any{
|
||||
"id": SecretID(SecretLocal(to, file.Local)), "type": "file", "path": file.Path,
|
||||
HandedProvisionField: PlanProvision{Name: SecretLocal(to, file.Local), Provision: to,
|
||||
Local: file.Local, Provider: found.From, ProviderModule: found.Module},
|
||||
}))
|
||||
continue
|
||||
}
|
||||
if found != nil && found.ByRecord && found.Sealed == "" && !found.Manager {
|
||||
// Answered by a record whose key has not been supplied since this consumer was
|
||||
// put on it. **Refused, not skipped.** The mesh discarded the plaintext when the
|
||||
@@ -837,7 +912,8 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
first = append(first, file)
|
||||
// Read by the module as its secrets' owner when it names one: the vault, as its own account (issue 723).
|
||||
first = append(first, ownedBy(m.SecretsOwner, file))
|
||||
}
|
||||
if m.Computed != "" {
|
||||
mine, part := generated[m.Module]
|
||||
@@ -973,7 +1049,100 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
// ADR 0163, rule 4); judged above, so an invalid one is not here.
|
||||
keptNetworks, _ := KeptNetworks(m, with.Settings[m.Module], with.Adopted)
|
||||
|
||||
// **A moved module's templated files are the provisioner's** (novox/hq ADR 0349 §4, §6, design 57 P6): on a
|
||||
// machine that can take them, each is declared placed and without its text, and no pass below fills it.
|
||||
handing, err := handed.begin(r.Node, m, dirs, accessPaths, thisMachine, with, r.Capabilities, r.Needs,
|
||||
backupPlacer{append(append([]Manifest(nil), r.Modules...), stillBackedUp...), with, thisMachine, r.Capabilities,
|
||||
unplaced})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// **What the mesh calls the module through each provision it requires** (issue 792): the names its binding
|
||||
// file carries, composed by the same call, handed to the provider through the plan's entry.
|
||||
if _, ok := handed.plans[m.Module]; ok && handing != nil {
|
||||
for _, to := range requirementsContributedTo(m) {
|
||||
own, err := r.ownNames(m, to, with.Settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handed.name(m.Module, to, own)
|
||||
}
|
||||
}
|
||||
// A handed collector is filled from items alone: each old form its collections would have held is said
|
||||
// (design 57 L1).
|
||||
if plan, ok := handed.plans[m.Module]; ok && handing != nil && len(plan.Collections) > 0 {
|
||||
*unplaced = append(*unplaced, oldFormsLeftOut(append(append([]Manifest(nil), r.Modules...),
|
||||
stillBackedUp...), m, plan.Collections)...)
|
||||
}
|
||||
for _, unsettled := range resources {
|
||||
if pr, ok := unsettled[HandedProvisionField].(PlanProvision); ok {
|
||||
if handing == nil {
|
||||
return nil, fmt.Errorf("%s: its credential of %s is handed to the provisioner and the module is "+
|
||||
"not: nothing would fill it", m.Module, pr.Provision)
|
||||
}
|
||||
marked := map[string]any{}
|
||||
for k, v := range unsettled {
|
||||
marked[k] = v
|
||||
}
|
||||
delete(marked, HandedProvisionField)
|
||||
marked[HandedSecretField] = pr.Name
|
||||
file, sec, err := handedSecretFile(marked, m, dirs, accessPaths, thisMachine, placed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"])
|
||||
owner[fmt.Sprint(file["id"])] = m.Module
|
||||
pr.Path = sec.Path
|
||||
if err := handed.addProvisionFile(m.Module, pr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, file)
|
||||
continue
|
||||
}
|
||||
if unsettled[HandedSecretField] != nil {
|
||||
if handing == nil {
|
||||
return nil, fmt.Errorf("%s: its own secret %v is handed to the provisioner and the module is "+
|
||||
"not: nothing would fill it", m.Module, unsettled[HandedSecretField])
|
||||
}
|
||||
file, sec, err := handedSecretFile(unsettled, m, dirs, accessPaths, thisMachine, placed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"])
|
||||
owner[fmt.Sprint(file["id"])] = m.Module
|
||||
if err := handed.addSecretFile(m.Module, sec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, file)
|
||||
continue
|
||||
}
|
||||
if handing != nil && fmt.Sprint(unsettled["type"]) == "file" {
|
||||
if text, _ := unsettled["content"].(string); len(secretsUsed(text)) > 0 &&
|
||||
!handing[fmt.Sprint(unsettled["id"])] {
|
||||
return nil, fmt.Errorf("%s: its file %v names a secret in its text, and a handed module's "+
|
||||
"secrets reach only the files its provisioner client fills: keep it in a template "+
|
||||
"(design 57 V4)", m.Module, unsettled["id"])
|
||||
}
|
||||
}
|
||||
if handing[fmt.Sprint(unsettled["id"])] {
|
||||
file, line, err := handedFile(unsettled, m, dirs, accessPaths, thisMachine, placed, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"])
|
||||
owner[fmt.Sprint(file["id"])] = m.Module
|
||||
if err := handed.add(m.Module, line); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, file)
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(unsettled["type"]) == "file" {
|
||||
if _, left := consumersHandedOnly(m, unsettled, with); left {
|
||||
*handedOnly = append(*handedOnly, consumersLeftOutLine(m, unsettled))
|
||||
continue
|
||||
}
|
||||
}
|
||||
resource, err := ApplySettings(unsettled, with.Settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1202,6 +1371,18 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
process["restart-on"] = restarts
|
||||
}
|
||||
// **And what a module of its own account is given, its own account reads** (issue 723): the files and
|
||||
// directories its words name that say no owner, given to the account it runs as, never the operator's.
|
||||
for _, m := range r.Modules {
|
||||
if m.RunsAs == "" {
|
||||
continue
|
||||
}
|
||||
w, err := bundleWords(m, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
givenTo(out, owner, map[string]map[string]string{m.Module: w}, m.RunsAs)
|
||||
}
|
||||
}
|
||||
if with.Adopted {
|
||||
// First, before anything a module declares: what the mesh needs reachable, then its guard.
|
||||
@@ -1449,6 +1630,10 @@ type Contribution struct {
|
||||
// Empty is root, which is what it was and what a module with no bundle and no declared owner
|
||||
// still wants.
|
||||
func (r Resolution) provisionsAs(m Manifest) string {
|
||||
// A module of its own account reads them as that account (ADR 0259 §8, issue 723).
|
||||
if m.RunsAs != "" {
|
||||
return m.RunsAs
|
||||
}
|
||||
if len(m.Bundles) > 0 && r.Account != "" {
|
||||
return r.Account
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
func configured(block map[string]any) SettingsBy {
|
||||
return SettingsBy{"media": {{From: "node anchor",
|
||||
Values: map[string]any{EndpointsSetting: block}}}}
|
||||
Placement: map[string]any{EndpointsSetting: block}}}}
|
||||
}
|
||||
|
||||
// **One block per endpoint, saying all three things.** The machine port, the subdomain and the reach
|
||||
@@ -109,7 +109,7 @@ func TestAReachInABlockIsHeldToTheFourValues(t *testing.T) {
|
||||
// **Two places giving one endpoint a machine port is the confusion this key exists to end.**
|
||||
func TestAnEndpointGivenAPortTwiceIsRefused(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
_, err := GivenPorts(m, []Layer{{From: "node anchor", Values: map[string]any{
|
||||
_, err := GivenPorts(m, []Layer{{From: "node anchor", Placement: map[string]any{
|
||||
EndpointsSetting: map[string]any{"web": map[string]any{"port": 20009}},
|
||||
PortsSetting: map[string]any{"80": 30000},
|
||||
}}})
|
||||
@@ -120,7 +120,7 @@ func TestAnEndpointGivenAPortTwiceIsRefused(t *testing.T) {
|
||||
|
||||
// And the same for its reach, said once here and once through the older key.
|
||||
func TestAnEndpointWhoseReachIsAlsoExposedIsRefused(t *testing.T) {
|
||||
_, err := Endpoints(aMediaServer(), []Layer{{From: "node anchor", Values: map[string]any{
|
||||
_, err := Endpoints(aMediaServer(), []Layer{{From: "node anchor", Placement: map[string]any{
|
||||
EndpointsSetting: map[string]any{"web": map[string]any{"reach": ReachInternal}},
|
||||
ExposeSetting: map[string]any{"80": FromEverywhere},
|
||||
}}})
|
||||
|
||||
@@ -111,7 +111,7 @@ var (
|
||||
ofEnvironment = regexp.MustCompile(`\$\{environment:([^}]*)\}`)
|
||||
ofShell = regexp.MustCompile(`\$\{shell:([^}]*)\}`)
|
||||
// ofContributed is either kind of contributed text, matched together so both fill in one pass.
|
||||
ofContributed = regexp.MustCompile(`\$\{(shell|contribution):([^}]*)\}`)
|
||||
ofContributed = regexp.MustCompile(`\$\{(shell|contribution|backup):([^}]*)\}`)
|
||||
)
|
||||
|
||||
// variableName is a POSIX shell variable name, which is also what environment.d accepts.
|
||||
@@ -226,6 +226,8 @@ func (m Manifest) contributionPlaceholderProblems() []string {
|
||||
for _, r := range m.Resources {
|
||||
problems = append(problems, placeholderProblems(m, r)...)
|
||||
problems = append(problems, seatPlaceholderProblems(m, r)...)
|
||||
problems = append(problems, collectedPlaceholderProblems(m, r)...)
|
||||
problems = append(problems, consumersPlaceholderProblems(m, r)...)
|
||||
// And every placeholder no pass fills where it stands: a misspelt namespace, a key its
|
||||
// namespace cannot take, or a field its pass does not read (novox/hq issue 231).
|
||||
problems = append(problems, unconsumedPlaceholders(m.Module, r)...)
|
||||
@@ -353,10 +355,11 @@ func inModuleOrder(modules []Manifest) []Manifest {
|
||||
func variablesSetOnce(modules []Manifest) error {
|
||||
setter := map[string]string{}
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
if m.Environment == nil {
|
||||
e := environmentOf(m)
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
for _, n := range sortedKeys(m.Environment.Variables) {
|
||||
for _, n := range sortedKeys(e.Variables) {
|
||||
if first, taken := setter[n]; taken {
|
||||
return fmt.Errorf(
|
||||
"%s and %s both set %s on this machine; the account has one environment, so one "+
|
||||
@@ -379,13 +382,14 @@ func environmentOn(modules []Manifest, facts map[string]string) (contributedEnvi
|
||||
}
|
||||
placed := map[string]bool{}
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
if m.Environment == nil {
|
||||
e := environmentOf(m)
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if len(m.Environment.Variables) > 0 {
|
||||
if len(e.Variables) > 0 {
|
||||
set := setBy{module: m.Module, values: map[string]string{}}
|
||||
for _, n := range sortedKeys(m.Environment.Variables) {
|
||||
v, err := factsIn(m.Environment.Variables[n], facts, m.Module, n)
|
||||
for _, n := range sortedKeys(e.Variables) {
|
||||
v, err := factsIn(e.Variables[n], facts, m.Module, n)
|
||||
if err != nil {
|
||||
return env, err
|
||||
}
|
||||
@@ -394,7 +398,7 @@ func environmentOn(modules []Manifest, facts map[string]string) (contributedEnvi
|
||||
}
|
||||
env.variables = append(env.variables, set)
|
||||
}
|
||||
for _, p := range m.Environment.Path {
|
||||
for _, p := range e.Path {
|
||||
entry, err := factsIn(p.Entry, facts, m.Module, "a PATH entry")
|
||||
if err != nil {
|
||||
return env, err
|
||||
@@ -417,6 +421,37 @@ func environmentOn(modules []Manifest, facts map[string]string) (contributedEnvi
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// environmentOf is a module's environment as the old form and the items of the environment collection say it: an
|
||||
// item's template is the variable's value or the PATH entry, and its attributes the kind, the name and where on PATH
|
||||
// (design 57 L3, issue 795). A moved contributor's items are rendered here into a collector not handed, as the
|
||||
// provisioner's client renders them into one that is. An item whose text the mesh does not hold is not here; it is
|
||||
// said as not placed (itemsLeftOutFor).
|
||||
func environmentOf(m Manifest) *Environment {
|
||||
var out *Environment
|
||||
if m.Environment != nil {
|
||||
out = &Environment{Variables: map[string]string{}, Path: append([]PathEntry(nil), m.Environment.Path...)}
|
||||
for k, v := range m.Environment.Variables {
|
||||
out.Variables[k] = v
|
||||
}
|
||||
}
|
||||
for _, it := range itemsInOrder(m.Items) {
|
||||
if it.Collection != EnvironmentCollection || it.Content == "" {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = &Environment{Variables: map[string]string{}}
|
||||
}
|
||||
value := strings.TrimSuffix(it.Content, "\n")
|
||||
switch it.Attributes["kind"] {
|
||||
case "variable":
|
||||
out.Variables[it.Attributes["name"]] = value
|
||||
case "path":
|
||||
out.Path = append(out.Path, PathEntry{Entry: value, At: it.Attributes["at"]})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// factsIn resolves a contributed value's ${machine:…} facts with this machine's — first, before
|
||||
// either format is written, so both say the same thing (novox/hq ADR 0203).
|
||||
func factsIn(v string, facts map[string]string, module, what string) (string, error) {
|
||||
@@ -521,22 +556,91 @@ func entriesOf(entries []placedOn) string {
|
||||
// order, each module's pieces in the order it declared them, each preceded by a line naming the
|
||||
// module, and empty when nothing is contributed.
|
||||
func shellCode(modules []Manifest, shell, slot string) string {
|
||||
var b strings.Builder
|
||||
return shellCodeOn(modules, shell, slot, nil)
|
||||
}
|
||||
|
||||
// shellCodeOn is shellCode on a machine with these facts: a moved contributor's item of the slot's collection is
|
||||
// rendered in its module's place as its old form was (heldItemText), so a collector not handed keeps every line.
|
||||
func shellCodeOn(modules []Manifest, shell, slot string, facts map[string]string) string {
|
||||
return shellCodeFor(modules, shell, slot, facts, nil)
|
||||
}
|
||||
|
||||
// oldFormOfCollected is a file's text with each `${collected:contribution:<seat>:<kind>:…}` of a kind received as data
|
||||
// read as the old `${contribution:<seat>:<kind>:…}`: where the collector is not handed, the controller renders the
|
||||
// kind's items as it rendered its contributions, so the two forms place the same text (design 57 L3, issue 781).
|
||||
func oldFormOfCollected(content string) string {
|
||||
return ofCollected.ReplaceAllStringFunc(content, func(placeholder string) string {
|
||||
name := ofCollected.FindStringSubmatch(placeholder)[1]
|
||||
base, _ := splitSelector(name)
|
||||
if format, is := strings.CutPrefix(base, EnvironmentCollection+":"); is {
|
||||
return "${environment:" + format + "}"
|
||||
}
|
||||
if !strings.HasPrefix(base, "contribution:") {
|
||||
return placeholder
|
||||
}
|
||||
seat, kind, _ := strings.Cut(strings.TrimPrefix(base, "contribution:"), ":")
|
||||
if _, r, ok := receivable(seat, kind); !ok || !collectableAsText(r) {
|
||||
return placeholder
|
||||
}
|
||||
return "${" + name + "}"
|
||||
})
|
||||
}
|
||||
|
||||
// shellCodeFor is shellCodeOn on a machine with these capabilities, which an item's `if-capability` is judged by. The
|
||||
// pieces are placed by order (an item's `order` attribute, the default for the old form), then module, as the
|
||||
// provisioner's client places them.
|
||||
func shellCodeFor(modules []Manifest, shell, slot string, facts map[string]string, caps map[string]bool) string {
|
||||
var blocks []textBlock
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
named := false
|
||||
var old strings.Builder
|
||||
for _, c := range m.Shell {
|
||||
if c.For != shell || c.Slot != slot {
|
||||
continue
|
||||
}
|
||||
if !named {
|
||||
fmt.Fprintf(&b, "# %s\n", m.Module)
|
||||
named = true
|
||||
if old.Len() == 0 {
|
||||
fmt.Fprintf(&old, "# %s\n", m.Module)
|
||||
}
|
||||
b.WriteString(c.Code)
|
||||
old.WriteString(c.Code)
|
||||
if !strings.HasSuffix(c.Code, "\n") {
|
||||
b.WriteString("\n")
|
||||
old.WriteString("\n")
|
||||
}
|
||||
}
|
||||
blocks = append(blocks, moduleBlocks(m, "#", old.String(), heldItems(m, "shell:"+shell+":"+slot, nil, facts, caps))...)
|
||||
}
|
||||
return joinBlocks(blocks)
|
||||
}
|
||||
|
||||
// textBlock is a stretch of a collector's text and the order it is placed by.
|
||||
type textBlock struct {
|
||||
order int
|
||||
text string
|
||||
}
|
||||
|
||||
// moduleBlocks is one module's pieces of a placeholder: what its old form made (placed by the default order), and
|
||||
// each of its held items under a line naming the module, as the provisioner's client writes one per item. An item
|
||||
// of the default order that follows the old form's text shares its line, as a module's item always did.
|
||||
func moduleBlocks(m Manifest, comment, old string, items []heldItem) []textBlock {
|
||||
var blocks []textBlock
|
||||
if old != "" {
|
||||
blocks = append(blocks, textBlock{OrderDefault, old})
|
||||
}
|
||||
for i, h := range items {
|
||||
text := h.text
|
||||
if i == 0 && old != "" && h.item.order() == OrderDefault {
|
||||
blocks[0].text += text
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, textBlock{h.item.order(), fmt.Sprintf("%s %s\n", comment, m.Module) + text})
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
// joinBlocks is the blocks by order, those of one order in the order given (module order).
|
||||
func joinBlocks(blocks []textBlock) string {
|
||||
sort.SliceStable(blocks, func(a, b int) bool { return blocks[a].order < blocks[b].order })
|
||||
var b strings.Builder
|
||||
for _, blk := range blocks {
|
||||
b.WriteString(blk.text)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -551,13 +655,34 @@ func shellCode(modules []Manifest, shell, slot string) string {
|
||||
// is filled before the shell's code is, and each is replaced in a single pass over what the holder
|
||||
// wrote, so a contributed piece is never scanned again.
|
||||
func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, facts map[string]string, with Rendering, caps map[string]bool, unplaced *[]string) error {
|
||||
if problems := append(placeholderProblems(m, resource), seatPlaceholderProblems(m, resource)...); len(problems) > 0 {
|
||||
problems := append(placeholderProblems(m, resource), seatPlaceholderProblems(m, resource)...)
|
||||
if problems = append(problems, collectedPlaceholderProblems(m, resource)...); len(problems) > 0 {
|
||||
return fmt.Errorf("%s", problems[0])
|
||||
}
|
||||
judged, err := consumersNotHanded(m, resource, with)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if judged {
|
||||
resource["content"] = ofConsumers.ReplaceAllString(fmt.Sprint(resource["content"]), "")
|
||||
}
|
||||
content, ok := resource["content"].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
content = oldFormOfCollected(content)
|
||||
// **An item is the provisioner client's** (design 57 L1): a collector filled here, the old way, holds the old
|
||||
// forms alone, and each item of its collections is said as not placed. A collection of the new form is empty.
|
||||
if unplaced != nil {
|
||||
for _, f := range templatePlaceholder.FindAllStringSubmatch(content, -1) {
|
||||
if collection, is := collectionOfPlaceholder(f[1], f[2]); is {
|
||||
_, selector := splitSelector(f[2])
|
||||
where, _ := selectorOf(selector)
|
||||
*unplaced = append(*unplaced, itemsLeftOutFor(modules, m, collection, where, facts, caps)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
content = ofCollected.ReplaceAllString(content, "")
|
||||
if ofEnvironment.MatchString(content) {
|
||||
env, err := environmentOn(modules, facts)
|
||||
if err != nil {
|
||||
@@ -578,6 +703,13 @@ func contributionsInto(resource map[string]any, m Manifest, modules []Manifest,
|
||||
content = ofContributed.ReplaceAllStringFunc(content, func(placeholder string) string {
|
||||
found := ofContributed.FindStringSubmatch(placeholder)
|
||||
first, second, _ := strings.Cut(found[2], ":")
|
||||
if found[1] == BackupNamespace {
|
||||
placed, problem := backupPlacer{modules, with, facts, caps, unplaced}.placed(m, found[2], false)
|
||||
if problem != "" && failed == nil {
|
||||
failed = fmt.Errorf("%s names %s: %s", m.Module, placeholder, problem)
|
||||
}
|
||||
return placed
|
||||
}
|
||||
if found[1] == "contribution" {
|
||||
placed, left, err := seatContributions(modules, m, found[2], with, facts, caps)
|
||||
if err != nil && failed == nil {
|
||||
@@ -588,7 +720,7 @@ func contributionsInto(resource map[string]any, m Manifest, modules []Manifest,
|
||||
}
|
||||
return placed
|
||||
}
|
||||
return shellCode(modules, first, second)
|
||||
return shellCodeFor(modules, first, second, facts, caps)
|
||||
})
|
||||
if failed != nil {
|
||||
return failed
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The session environment is a collection of typed items (design 57 L3, issue 795): a variable is an item with
|
||||
// kind=variable and name=<NAME>, a PATH entry one with kind=path and at=start|end, each a template whose text is the
|
||||
// value or the entry. Where node-env is not handed the controller renders the items as the old form was rendered;
|
||||
// where it is, it names the collection for the client, which fills the same files (mesh-catalog's provisioner client
|
||||
// holds that half against the same golden files). testdata/environment holds the environment of the catalogue's five
|
||||
// contributors as the old form composed it, with all five and with zsh alone.
|
||||
|
||||
type envDecl struct{ module, item, name, value string }
|
||||
|
||||
func environmentOfTheCatalogue() []envDecl {
|
||||
return []envDecl{
|
||||
{"adwaita", "gtk-theme", "GTK_THEME", "Adwaita:dark"},
|
||||
{"adwaita", "gtk2-rc-files", "GTK2_RC_FILES", "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc"},
|
||||
{"adwaita", "qt-qpa-platformtheme", "QT_QPA_PLATFORMTHEME", "qt6ct"},
|
||||
{"adwaita", "qt-style-override", "QT_STYLE_OVERRIDE", "Fusion"},
|
||||
{"adwaita", "qt-select", "QT_SELECT", "6"},
|
||||
{"adwaita", "xcursor-theme", "XCURSOR_THEME", "Adwaita"},
|
||||
{"adwaita", "xcursor-size", "XCURSOR_SIZE", "24"},
|
||||
{"clipmenu", "cm-selections", "CM_SELECTIONS", "clipboard"},
|
||||
{"clipmenu", "cm-max-clips", "CM_MAX_CLIPS", "500"},
|
||||
{"clipmenu", "cm-histlength", "CM_HISTLENGTH", "15"},
|
||||
{"i3", "xdg-current-desktop", "XDG_CURRENT_DESKTOP", "i3"},
|
||||
{"i3", "xdg-session-desktop", "XDG_SESSION_DESKTOP", "i3"},
|
||||
{"xterm", "terminal", "TERMINAL", "xterm"},
|
||||
{"zsh", "editor", "EDITOR", "vim"},
|
||||
{"zsh", "visual", "VISUAL", "vim"},
|
||||
{"zsh", "xdg-config-home", "XDG_CONFIG_HOME", "${machine:account-home}/.config"},
|
||||
{"zsh", "path-local-bin", "path:10", "${machine:account-home}/.local/bin"},
|
||||
{"zsh", "path-scripts", "path:20", "${machine:account-home}/scripts"},
|
||||
{"zsh", "path-scripts-bin", "path:30", "${machine:account-home}/scripts/bin"},
|
||||
}
|
||||
}
|
||||
|
||||
const environmentCollector = `{"module":"node-env","version":"1","moved":true,
|
||||
"claims":[{"name":"node-environment","scope":"node"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"posix","type":"file","path":"/home/op/.config/mesh/environment.sh","template":"files/posix"},
|
||||
{"id":"systemd","type":"file","path":"/home/op/.config/environment.d/50-mesh.conf","template":"files/systemd"}]}`
|
||||
|
||||
// nodeEnvWith is node-env, its file placing the environment as plan says, with the declared contributors' items.
|
||||
func nodeEnvWith(t *testing.T, decls []envDecl, plan string) Resolution {
|
||||
t.Helper()
|
||||
collector := held(t, environmentCollector, builtWith("node-env", map[string]string{
|
||||
"posix": "# header\n" + plan, "systemd": "${environment:systemd}"}))
|
||||
mods := []Manifest{collector}
|
||||
order := []string{"node-env"}
|
||||
byModule := map[string][]envDecl{}
|
||||
var modules []string
|
||||
for _, d := range decls {
|
||||
if byModule[d.module] == nil {
|
||||
modules = append(modules, d.module)
|
||||
}
|
||||
byModule[d.module] = append(byModule[d.module], d)
|
||||
}
|
||||
for _, name := range modules {
|
||||
var items []string
|
||||
texts := map[string]string{}
|
||||
for _, d := range byModule[name] {
|
||||
attrs := fmt.Sprintf(`{"kind":"variable","name":%q}`, d.name)
|
||||
if order, isPath := strings.CutPrefix(d.name, "path:"); isPath {
|
||||
attrs = fmt.Sprintf(`{"kind":"path","at":"start","order":%q}`, order)
|
||||
}
|
||||
items = append(items, fmt.Sprintf(`{"collection":"environment","name":%q,"attributes":%s,"template":"files/env-%s"}`,
|
||||
d.item, attrs, d.item))
|
||||
texts["env-"+d.item] = d.value + "\n"
|
||||
}
|
||||
raw := fmt.Sprintf(`{"module":%q,"version":"1","moved":true,"items":[%s],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, name, strings.Join(items, ","))
|
||||
mods = append(mods, held(t, raw, builtWith(name, texts)))
|
||||
order = append(order, name)
|
||||
}
|
||||
r, err := Resolve(shelf(mods...), order, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
func TestTheEnvironmentOfTheCatalogueIsTheSameHandedOrNot(t *testing.T) {
|
||||
read := func(name string) string {
|
||||
b, err := os.ReadFile(filepath.Join("testdata", "environment", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
for name, only := range map[string]string{"all": "", "zsh": "zsh"} {
|
||||
var decls []envDecl
|
||||
for _, d := range environmentOfTheCatalogue() {
|
||||
if only == "" || d.module == only {
|
||||
decls = append(decls, d)
|
||||
}
|
||||
}
|
||||
// Named as the collector names it, and as the old placeholder does.
|
||||
for _, plan := range []string{"${collected:environment:posix}", "${environment:posix}"} {
|
||||
r := nodeEnvWith(t, decls, plan)
|
||||
// Not handed: the controller renders each moved contributor's items as the old form was rendered.
|
||||
if got, want := composedFile(t, r, "node-env.posix"), "# header\n"+read(name+".posix"); got != want {
|
||||
t.Errorf("%s, %s, not handed, the POSIX file is\n%s\nwant\n%s", name, plan, got, want)
|
||||
}
|
||||
if got, want := composedFile(t, r, "node-env.systemd"), read(name+".systemd"); got != want {
|
||||
t.Errorf("%s, %s, not handed, the environment.d file is\n%s\nwant\n%s", name, plan, got, want)
|
||||
}
|
||||
// Handed: one collection, named for the client; each contributor's plan names its items.
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
if got := c.Plans["node-env"].Collections; !reflect.DeepEqual(got, []PlanCollection{{Name: "environment", Comment: "#"}}) {
|
||||
t.Errorf("%s: the plan collects %+v", name, got)
|
||||
}
|
||||
items, got := len(decls), 0
|
||||
for m := range c.Plans {
|
||||
got += len(c.Plans[m].Items)
|
||||
}
|
||||
if got != items {
|
||||
t.Errorf("%s: the plans name %d items, want %d", name, got, items)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEnvironmentItemIsTypedAndNamesWhatItNeeds(t *testing.T) {
|
||||
item := func(attrs map[string]string) []string {
|
||||
return environmentItemProblems("x", Item{Collection: "environment", Attributes: attrs})
|
||||
}
|
||||
for _, ok := range []map[string]string{
|
||||
{"kind": "variable", "name": "GTK_THEME"},
|
||||
{"kind": "path", "at": "end", "order": "10"},
|
||||
} {
|
||||
if p := item(ok); len(p) != 0 {
|
||||
t.Errorf("%v is refused: %v", ok, p)
|
||||
}
|
||||
}
|
||||
for _, bad := range []map[string]string{
|
||||
nil, {"kind": "other"}, {"kind": "variable"}, {"kind": "variable", "name": "PATH"},
|
||||
{"kind": "path"}, {"kind": "path", "at": "middle"}, {"kind": "path", "at": "start", "name": "X"},
|
||||
{"kind": "variable", "name": "A", "color": "red"},
|
||||
} {
|
||||
if p := item(bad); len(p) == 0 {
|
||||
t.Errorf("%v is taken", bad)
|
||||
}
|
||||
}
|
||||
if !attributeFits("environment", "name", "GTK_THEME") || attributeFits("shell:zsh:normal", "name", "GTK_THEME") ||
|
||||
attributeFits("environment", "kind", "Variable") {
|
||||
t.Error("an upper-case name is the environment's name alone")
|
||||
}
|
||||
// A module that sets a variable twice, in two modules, is refused as the old form refused it.
|
||||
a := Manifest{Module: "a", Items: []Item{{Collection: "environment", Content: "1\n",
|
||||
Attributes: map[string]string{"kind": "variable", "name": "X"}}}}
|
||||
b := Manifest{Module: "b", Items: []Item{{Collection: "environment", Content: "2\n",
|
||||
Attributes: map[string]string{"kind": "variable", "name": "X"}}}}
|
||||
if err := variablesSetOnce([]Manifest{a, b}); err == nil || !strings.Contains(err.Error(), "both set X") {
|
||||
t.Errorf("two modules setting X: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func Exposure(m Manifest, layers []Layer) (map[int]string, error) {
|
||||
|
||||
out := map[int]string{}
|
||||
for _, layer := range layers {
|
||||
raw, ok := layer.Values[ExposeSetting]
|
||||
raw, ok := layer.Placement[ExposeSetting]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -651,7 +651,7 @@ func GivenPorts(m Manifest, layers []Layer) (map[int]int, error) {
|
||||
out := map[int]int{} // the port the module names → the machine port it is on
|
||||
by := map[int]int{} // and which of the setting's ports put it there, for the refusal
|
||||
for _, layer := range layers {
|
||||
raw, ok := layer.Values[PortsSetting]
|
||||
raw, ok := layer.Placement[PortsSetting]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -951,7 +951,7 @@ func Reaches(m Manifest, layers []Layer) (map[int]string, error) {
|
||||
out[declared[name]] = ep.Reach
|
||||
}
|
||||
for _, layer := range layers {
|
||||
raw, ok := layer.Values[ReachSetting]
|
||||
raw, ok := layer.Placement[ReachSetting]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -1069,7 +1069,7 @@ func Endpoints(m Manifest, layers []Layer) (map[string]Endpoint, error) {
|
||||
|
||||
out := map[string]Endpoint{}
|
||||
for _, layer := range layers {
|
||||
raw, ok := layer.Values[EndpointsSetting]
|
||||
raw, ok := layer.Placement[EndpointsSetting]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -752,7 +752,7 @@ func TestExposureSettingOverridesAListensSource(t *testing.T) {
|
||||
func TestExposureRefusesAPortNotListenedOnAndABadSource(t *testing.T) {
|
||||
postgres := Manifest{Module: "postgres", Listens: []Listening{{Port: 5432, From: FromMesh}}}
|
||||
layer := func(port, source string) []Layer {
|
||||
return []Layer{{From: "node", Values: map[string]any{ExposeSetting: map[string]any{port: source}}}}
|
||||
return []Layer{{From: "node", Placement: map[string]any{ExposeSetting: map[string]any{port: source}}}}
|
||||
}
|
||||
|
||||
if _, err := Exposure(postgres, layer("5432", FromEverywhere)); err != nil {
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestTheForgesPortIsGivenLikeAnyOtherProvidersPort(t *testing.T) {
|
||||
|
||||
// The catalogue's number is a default and the node's setting moves it.
|
||||
given, err := GivenPorts(forge, []Layer{{From: "anchor",
|
||||
Values: map[string]any{PortsSetting: map[string]any{"3000": float64(3100)}}}})
|
||||
Placement: map[string]any{PortsSetting: map[string]any{"3000": float64(3100)}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("the forge's port cannot be given on a node: %v", err)
|
||||
}
|
||||
@@ -298,7 +298,7 @@ func TestTheForgesSshPortIsTheMeshsFixedConventionByDefault(t *testing.T) {
|
||||
func TestANodeMayGiveTheForgesSshPortADifferentNumber(t *testing.T) {
|
||||
forge := catalogueManifest(t, "gitea")
|
||||
given, err := GivenPorts(forge, []Layer{{From: "anchor",
|
||||
Values: map[string]any{PortsSetting: map[string]any{"22": float64(9022)}}}})
|
||||
Placement: map[string]any{PortsSetting: map[string]any{"22": float64(9022)}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("the forge's ssh port cannot be moved on a node: %v", err)
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@ type barOption struct {
|
||||
// - shows: battery or command; options: what that kind takes.
|
||||
func barBlockShape() *Shape {
|
||||
return &Shape{
|
||||
Text: true,
|
||||
Places: []PlaceField{{Field: "bar", Values: []string{"bottom", "top"}},
|
||||
{Field: "place", Values: []string{"resources", "status"}}},
|
||||
Order: "order",
|
||||
|
||||
@@ -0,0 +1,799 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module handed to the provisioner (novox/hq ADR 0349 clauses 2, 4, 6 and 17, design 57 P6, issue 672).
|
||||
//
|
||||
// **Marked in its manifest, one module at a time.** A module whose manifest says `"moved": true` has moved: each
|
||||
// file of it that names a template is filled by the provisioner's client on the node, from the templates archive
|
||||
// its build packed, and the controller fills nothing in it. Every other resource of the module, and every file of
|
||||
// a module not marked, is composed exactly as before (clause 17: each file the old way or the new way, never both).
|
||||
//
|
||||
// **The build keeps what the client needs.** Resolving a moved module's build keeps, beside the files' text the
|
||||
// mesh holds, where its templates archive is and what it hashes to, and which template each file is filled from
|
||||
// (Handed). The text stays, so a machine whose node-engine cannot take a handed file is still sent the module the
|
||||
// old way, byte for byte.
|
||||
//
|
||||
// **Handed only where the machine can take it.** A node's declaration hands a moved module's files over only when
|
||||
// its node-engine writes a filled file on the client's word alone (Rendering.Hands) and the node runs a provisioner
|
||||
// client (Rendering.Provisioner): then each such file is declared with its path, mode, owner and who owns it at
|
||||
// runtime and without its text, the declaration names it among the filled files, and the module's plan says the
|
||||
// client where its templates are and the value of every placement placeholder they name. Anywhere else the module
|
||||
// composes as if it were not marked.
|
||||
|
||||
// MovedField is the manifest's key marking a module moved.
|
||||
const MovedField = "moved"
|
||||
|
||||
// Handed is what a moved module's build kept for the provisioner (novox/hq ADR 0349 §2): its templates archive, as
|
||||
// recorded (store-relative, routed at composition like any archive), and its digest; and, for each file resource
|
||||
// filled from a template, that template's path inside the archive. Written by Resolve, never by hand.
|
||||
type Handed struct {
|
||||
Source string `json:"source"`
|
||||
Digest string `json:"digest"`
|
||||
Templates map[string]string `json:"templates"`
|
||||
// Items is, for each item the module contributes, by ItemKey (its collection, and `/<name>` for a named one),
|
||||
// that item's template inside the archive (collections.go, design 57 L1).
|
||||
Items map[string]string `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// Plan is a moved module's plan on one node: what the node's provisioner client fills its files from. The shape
|
||||
// of the assignments mirror's `provisioning` (mesh-controller internal/inventory Provisioning).
|
||||
type Plan struct {
|
||||
Templates PlanTemplates `json:"templates"`
|
||||
Files []PlanFile `json:"files"`
|
||||
Placement map[string]string `json:"placement,omitempty"`
|
||||
// Secrets are the module's own secrets this declaration names (design 57 V4, issue 647): each its templates
|
||||
// name, and each it keeps in a file of its own, with that file's path. By name, never a value: the client asks
|
||||
// the vault for each, and the vault hands one only when the plan names it.
|
||||
Secrets []PlanSecret `json:"secrets,omitempty"`
|
||||
// Items are the module's items of collections (design 57 L1): each filled by the client like a file and recorded
|
||||
// in mesh-provisioner_collect for its node. Collections are the collections its files name, as their collector,
|
||||
// each with its comment. By template and name, never a contributor's text.
|
||||
Items []PlanItem `json:"items,omitempty"`
|
||||
Collections []PlanCollection `json:"collections,omitempty"`
|
||||
// Provisions are what it requires here, each with the provider resolution chose to serve it (issue 760), so the
|
||||
// vault sees a consumer bind to a provider and unbind from it (novox/hq ADR 0349 clause 8); and, for a
|
||||
// credential the vault keeps for it alone, the name its files call it and the file holding it (issue 757). By
|
||||
// name, never a value.
|
||||
Provisions []PlanProvision `json:"provisions,omitempty"`
|
||||
}
|
||||
|
||||
// PlanItem is one item the module contributes: to which collection, and its template inside the archive.
|
||||
//
|
||||
// A named item has its Name and Attributes (design 57 L3, issue 777): the client records them with the item and
|
||||
// places it under a placeholder selecting by them. An item whose `if-capability` the machine lacks is not in the plan.
|
||||
type PlanItem struct {
|
||||
Collection string `json:"collection"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
Template string `json:"template"`
|
||||
}
|
||||
|
||||
// PlanCollection is one collection the module collects, and the comment introducing each contributor's item.
|
||||
type PlanCollection struct {
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// PlanProvision is one provision the module requires, under one local name, and the provider serving it: the module
|
||||
// (ProviderModule) on the machine (Provider) that resolution chose for this consumer. Where several providers offer
|
||||
// a mesh-scoped provision, it is the one this consumer is bound to (a pin, the seat's holder, or where its data is),
|
||||
// never the others (issue 760). Local is the name the consumer keeps the credential under where it keeps several
|
||||
// from one requirement (ADR 0094), empty for the ordinary one; a consumer has one provider per provision and local
|
||||
// name.
|
||||
//
|
||||
// Name and Path are set only for a credential the vault keeps for this holder alone, which its machine's client
|
||||
// fills (issue 757): the value the provider's software issued for this module on this machine. Name is what the
|
||||
// module's files call it (`${secret:<name>}`) and Path the file holding it alone.
|
||||
//
|
||||
// CredentialForm is the form the provider's offer declares for its credential (Manifest.CredentialFormOf, issue
|
||||
// 768): the vault makes a first value in that form for a provider it holds no row of. Empty where the provider's
|
||||
// manifest was not in hand, and the vault then makes nothing.
|
||||
type PlanProvision struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Provision string `json:"provision"`
|
||||
Local string `json:"local,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderModule string `json:"provider-module"`
|
||||
CredentialForm string `json:"credential-form,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
// Contributes is what the consumer's manifest tells its provider for this provision (Manifest.Contributes: a
|
||||
// database's name, its extensions), so the provider's helper learns it from the bus (issue 776). Nil where the
|
||||
// manifest contributes nothing for it, and the entry is then as it was.
|
||||
Contributes map[string]any `json:"contributes,omitempty"`
|
||||
// At, From and As are what today's binding file tells the consumer of this provision (issue 788): where the
|
||||
// provider is on the private network, the machine, and who the consumer is at its end (its slug first), so the
|
||||
// provider's helper writes them into the consumer's entry and the client fills `${bound:<provision>:at|from|as}`
|
||||
// from there. Empty where the resolution knew none.
|
||||
At string `json:"at,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
As string `json:"as,omitempty"`
|
||||
// Names are what the mesh calls the consumer through this provision (issue 792), keyed as its binding file names
|
||||
// them (`name`, `internal-name`, `name-<local>`, `internal-name-<local>`): composed by the controller, which
|
||||
// alone knows the node's domains, and carried unchanged by the provider's helper into the consumer's entry. Nil
|
||||
// where the consumer contributes nothing to the provision, and the entry is then as it was.
|
||||
Names map[string]string `json:"names,omitempty"`
|
||||
}
|
||||
|
||||
// provisionsOf is, from a resolution's needs, each provision module requires and its provider, by provision then
|
||||
// local name. A need answered by a record (a licence) or by no module names no provider on a machine and is left
|
||||
// out: the vault has no provider's value to make for it.
|
||||
func provisionsOf(needs []Needed, module, as string, contributes map[string]map[string]any,
|
||||
named map[string]bool) []PlanProvision {
|
||||
var out []PlanProvision
|
||||
for _, n := range needs {
|
||||
if n.For != module || n.ByRecord || n.Module == "" || n.From == "" {
|
||||
continue
|
||||
}
|
||||
p := PlanProvision{Provision: n.Name, Local: n.Local, Provider: n.From, ProviderModule: n.Module,
|
||||
CredentialForm: n.CredentialForm, Contributes: maps.Clone(contributes[n.Name]),
|
||||
At: n.At, From: n.From, As: as}
|
||||
// **A credential only a template names** (issue 788): named like a file's, so the client fills it from the
|
||||
// vault's row for this holder.
|
||||
if (named[n.Name] || named[SecretLocal(n.Name, n.Local)]) && n.IssuedToHolder {
|
||||
p.Name = SecretLocal(n.Name, n.Local)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sortProvisions orders a plan's provisions by provision, then local name.
|
||||
func sortProvisions(ps []PlanProvision) {
|
||||
sort.SliceStable(ps, func(i, j int) bool {
|
||||
if ps[i].Provision != ps[j].Provision {
|
||||
return ps[i].Provision < ps[j].Provision
|
||||
}
|
||||
return ps[i].Local < ps[j].Local
|
||||
})
|
||||
}
|
||||
|
||||
// PlanSecret is one of the module's own secrets, by its name in the manifest; Path is the file holding it alone,
|
||||
// when the manifest's own-secrets places one.
|
||||
type PlanSecret struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// PlanTemplates is where the templates archive is fetched from on this node, and what it hashes to.
|
||||
type PlanTemplates struct {
|
||||
Source string `json:"source"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
// PlanFile is one file the client fills.
|
||||
type PlanFile struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Template string `json:"template"`
|
||||
OwnerAtRuntime string `json:"owner-at-runtime"`
|
||||
Takes string `json:"takes,omitempty"`
|
||||
}
|
||||
|
||||
// The words a placed file says who owns it at runtime with (novox/hq ADR 0349 §5).
|
||||
const (
|
||||
RuntimeOwned = "owned"
|
||||
RuntimeSeed = "seed"
|
||||
RuntimeManaged = "managed"
|
||||
)
|
||||
|
||||
// OwnerAtRuntimeField is the key a declared file says who owns it at runtime by (the node-engine's
|
||||
// `owner-at-runtime`).
|
||||
const OwnerAtRuntimeField = "owner-at-runtime"
|
||||
|
||||
// ownershipOf is who owns a file at runtime, by its form, as the node-engine maps the old forms (mesh-host
|
||||
// declaration.File.Ownership): written into is managed, created once is a seed, any other is owned.
|
||||
func ownershipOf(r map[string]any) string {
|
||||
switch {
|
||||
case r["into"] != nil && fmt.Sprint(r["into"]) != "":
|
||||
return RuntimeManaged
|
||||
case r["create-once"] == true:
|
||||
return RuntimeSeed
|
||||
}
|
||||
return RuntimeOwned
|
||||
}
|
||||
|
||||
// takesOf is how the program reading a file takes a change, by what names the file among the module's resources:
|
||||
// restart-on wins over reload-on; empty when nothing names it (the program reads it when it uses it).
|
||||
func takesOf(m Manifest, id string) string {
|
||||
takes := ""
|
||||
for _, r := range m.Resources {
|
||||
if slices.Contains(stringsIn(r["restart-on"]), id) {
|
||||
return "restart-on"
|
||||
}
|
||||
if slices.Contains(stringsIn(r["reload-on"]), id) {
|
||||
takes = "reload-on"
|
||||
}
|
||||
}
|
||||
return takes
|
||||
}
|
||||
|
||||
// movedProblems is what is wrong with a module's mark: a moved module must name a template (as written) or carry
|
||||
// what its build kept (as held), and Handed is the build's word, never a repository's.
|
||||
func movedProblems(m Manifest) []string {
|
||||
switch {
|
||||
case m.Handed != nil && !m.Moved:
|
||||
return []string{fmt.Sprintf("%s carries what a moved module's build keeps (handed) and is not marked moved",
|
||||
m.Module)}
|
||||
case m.Handed != nil && m.Build != nil:
|
||||
return []string{fmt.Sprintf("%s says handed in its repository: that is what its build keeps, never "+
|
||||
"written by hand (novox/hq ADR 0349 §2)", m.Module)}
|
||||
case m.Moved && m.Handed == nil && !m.Templated():
|
||||
return []string{fmt.Sprintf("%s is marked moved and names no template: a moved module's files are filled "+
|
||||
"from template files of its own, `\"template\": \"%s/<name>\"` (novox/hq ADR 0349 §2)", m.Module,
|
||||
m.TemplatesDir())}
|
||||
}
|
||||
if !m.Moved {
|
||||
return nil
|
||||
}
|
||||
// **A moved module's secrets are the vault's, handed to its machine's client** (design 57 V4): a secret the
|
||||
// mesh makes, by name. A family's members and a value issued outside the mesh are given by a person and may be
|
||||
// absent, which the client cannot tell from one the vault does not hold yet; they move with a package of their
|
||||
// own.
|
||||
var problems []string
|
||||
if fams := m.OwnSecrets.Families(); len(fams) > 0 {
|
||||
problems = append(problems, fmt.Sprintf("%s is marked moved and keeps a secret family (%s): a moved module's "+
|
||||
"secrets are handed by the vault by name, and a family's members are not yet (design 57 V4)", m.Module,
|
||||
strings.Join(fams, ", ")))
|
||||
}
|
||||
for _, name := range sortedKeys(m.OwnSecrets.Plain()) {
|
||||
if m.OwnSecrets[name].IssuedBy == IssuedOutside {
|
||||
problems = append(problems, fmt.Sprintf("%s is marked moved and its secret %s is issued outside the mesh: "+
|
||||
"a value that may not be given is not yet handed by the vault (design 57 V4)", m.Module, name))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// handedBy is what a moved module's build keeps, from its templates archive as built, or nil for a module not
|
||||
// moved.
|
||||
func handedBy(m Manifest, archive Built) *Handed {
|
||||
if !m.Moved {
|
||||
return nil
|
||||
}
|
||||
dir := m.TemplatesDir()
|
||||
h := &Handed{Source: Recorded(archive.Reference), Digest: archive.Digest, Templates: map[string]string{}}
|
||||
for _, r := range m.Resources {
|
||||
name, has, wrong := templateOf(m.Module, dir, r)
|
||||
if !has || wrong != "" {
|
||||
continue
|
||||
}
|
||||
h.Templates[fmt.Sprint(r["id"])] = strings.TrimPrefix(name, dir+"/")
|
||||
}
|
||||
for _, it := range m.Items {
|
||||
if it.Template == "" || itemTemplateProblem(m, it.Template) != "" {
|
||||
continue
|
||||
}
|
||||
if h.Items == nil {
|
||||
h.Items = map[string]string{}
|
||||
}
|
||||
h.Items[it.key()] = strings.TrimPrefix(it.Template, dir+"/")
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// handing is whether this composition hands the module's templated files to the provisioner.
|
||||
func (with Rendering) handing(m Manifest) bool {
|
||||
return m.Moved && m.Handed != nil && with.Hands && with.Provisioner != ""
|
||||
}
|
||||
|
||||
// Handing is whether a machine composed with this rendering hands the module to the provisioner, its files and
|
||||
// its own secrets (design 57 P6, V4): a moved module there, assigned or only pulled in by another (issue 765), on a
|
||||
// machine that can take it. The controller makes and seals none of such a module's secrets for that machine: its
|
||||
// client asks the vault.
|
||||
func (with Rendering) Handing(m Manifest) bool { return with.handing(m) }
|
||||
|
||||
// requiredByWords begins why a module only pulled in by another is on a machine (Resolution.Because): `required by
|
||||
// <module>`.
|
||||
const requiredByWords = "required by "
|
||||
|
||||
// RequiredBy is the module that requires module on this machine when nothing assigned it there, only pulled it in
|
||||
// (issue 765): what its value in the assignments mirror names. Empty for a module assigned there, or not there.
|
||||
func (r Resolution) RequiredBy(module string) string {
|
||||
by, _ := strings.CutPrefix(r.Because[module], requiredByWords)
|
||||
if by == r.Because[module] {
|
||||
return ""
|
||||
}
|
||||
return by
|
||||
}
|
||||
|
||||
// HandedProvisionField marks, between composing a handed module's credential file for something it requires and
|
||||
// placing it, one the vault keeps for this holder alone (issue 757): its value is the PlanProvision without its path.
|
||||
// Never on the wire.
|
||||
const HandedProvisionField = "handed-provision"
|
||||
|
||||
// HandedSecretField marks, between composing a module's own secret files and placing them, the one a handing
|
||||
// composition hands to the provisioner: its value is the secret's name. Never on the wire.
|
||||
const HandedSecretField = "handed-secret"
|
||||
|
||||
// handedSecretFile is a handed module's own secret file as the declaration carries it: placed, the secret's
|
||||
// alone (0600 unless the manifest says otherwise), owned at runtime, without a value; the client fills it from the
|
||||
// vault.
|
||||
func handedSecretFile(r map[string]any, m Manifest, dirs, accessPaths, facts map[string]string,
|
||||
placed map[string]Placement) (map[string]any, PlanSecret, error) {
|
||||
name := fmt.Sprint(r[HandedSecretField])
|
||||
out := map[string]any{}
|
||||
for k, v := range r {
|
||||
out[k] = v
|
||||
}
|
||||
delete(out, HandedSecretField)
|
||||
delete(out, "sealed")
|
||||
if err := dirInto(out, dirs, m.Module); err != nil {
|
||||
return nil, PlanSecret{}, err
|
||||
}
|
||||
if err := accessInto(out, accessPaths, m.Module); err != nil {
|
||||
return nil, PlanSecret{}, err
|
||||
}
|
||||
ownerInto(out, placed)
|
||||
if err := machineInto(out, facts, m.Module); err != nil {
|
||||
return nil, PlanSecret{}, err
|
||||
}
|
||||
if out["mode"] == nil {
|
||||
out["mode"] = "0600"
|
||||
}
|
||||
out[OwnerAtRuntimeField] = RuntimeOwned
|
||||
return out, PlanSecret{Name: name, Path: fmt.Sprint(out["path"])}, nil
|
||||
}
|
||||
|
||||
// templatePlaceholder is a placeholder as a template writes it: its namespace and its key.
|
||||
var templatePlaceholder = regexp.MustCompile(`\$\{([a-z][a-z-]*):([^}\s$]+)\}`)
|
||||
|
||||
// configNamespaces are the namespaces the config domain answers: `config`, and `setting`, which the manifest's
|
||||
// reading writes it as (toSettingsForm).
|
||||
var configNamespaces = []string{"config", SettingField}
|
||||
|
||||
// SettingField is the settings form's word for a config placeholder's namespace.
|
||||
const SettingField = "setting"
|
||||
|
||||
// mergesFile is whether a file resource says `merge` or `takes-settings`: the layers' settings are merged into it.
|
||||
func mergesFile(r map[string]any) bool {
|
||||
_, merges := r["merge"]
|
||||
_, takes := r[TakesSettingsField]
|
||||
return merges || takes
|
||||
}
|
||||
|
||||
// mergingIn is whether the module's resource id is a merging file (mergesFile).
|
||||
func mergingIn(m Manifest, id string) bool {
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["id"]) == id {
|
||||
return mergesFile(r)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handedFile is a moved module's templated file as a handing declaration carries it: placed (its path and owner
|
||||
// filled by placement alone), saying who owns it at runtime, without its text. And its line of the plan.
|
||||
func handedFile(r map[string]any, m Manifest, dirs, accessPaths, facts map[string]string, placed map[string]Placement,
|
||||
with Rendering) (map[string]any, PlanFile, error) {
|
||||
id := fmt.Sprint(r["id"])
|
||||
out := map[string]any{}
|
||||
for k, v := range r {
|
||||
out[k] = v
|
||||
}
|
||||
// **No text, and nothing filled into it**: the client fills it from the template; the declaration says where
|
||||
// it goes and how it is written.
|
||||
delete(out, "content")
|
||||
delete(out, TrustedField)
|
||||
delete(out, NamesOnPurpose)
|
||||
delete(out, SecretsInEnvironment)
|
||||
if err := dirInto(out, dirs, m.Module); err != nil {
|
||||
return nil, PlanFile{}, err
|
||||
}
|
||||
if err := accessInto(out, accessPaths, m.Module); err != nil {
|
||||
return nil, PlanFile{}, err
|
||||
}
|
||||
ownerInto(out, placed)
|
||||
if err := machineInto(out, facts, m.Module); err != nil {
|
||||
return nil, PlanFile{}, err
|
||||
}
|
||||
out[OwnerAtRuntimeField] = ownershipOf(out)
|
||||
path := fmt.Sprint(out["path"])
|
||||
return out, PlanFile{ID: id, Path: path, Template: m.Handed.Templates[id],
|
||||
OwnerAtRuntime: fmt.Sprint(out[OwnerAtRuntimeField]), Takes: takesOf(m, id)}, nil
|
||||
}
|
||||
|
||||
// placementOf is the value of every placement placeholder the module's handed templates name, keyed as the
|
||||
// placeholder names it (`machine:account`), each filled by the same pass that fills it in a declaration. A
|
||||
// template naming a value only another domain answers — a secret, a binding, a contribution — is refused by
|
||||
// name: the client fills config and placement, and the rest move with their own packages (design 57).
|
||||
func placementOf(node string, m Manifest, texts, items map[string]string, dirs, accessPaths, facts map[string]string,
|
||||
with Rendering, moved map[string]bool, backups backupPlacer) (map[string]string, []string, []string, []PlanCollection, error) {
|
||||
out := map[string]string{}
|
||||
var secrets, required []string
|
||||
var problems []string
|
||||
var collections []PlanCollection
|
||||
all := map[string]string{}
|
||||
templateOf := map[string]string{}
|
||||
for id, text := range texts {
|
||||
all[id], templateOf[id] = text, m.Handed.Templates[id]
|
||||
}
|
||||
for key, text := range items {
|
||||
all["item:"+key], templateOf["item:"+key] = text, m.Handed.Items[key]
|
||||
}
|
||||
judgedConsumers := map[string]bool{}
|
||||
for _, id := range sortedKeys(all) {
|
||||
item := strings.HasPrefix(id, "item:")
|
||||
for _, found := range templatePlaceholder.FindAllStringSubmatch(all[id], -1) {
|
||||
kind, key := found[1], found[2]
|
||||
if collection, is := collectionOfPlaceholder(kind, key); is {
|
||||
// **A collection its file names, read by the client from the items there** (design 57 L1).
|
||||
comment, err := collectionComment(m, collection)
|
||||
_, selector := splitSelector(key)
|
||||
_, selErr := selectorOf(selector)
|
||||
switch {
|
||||
case selErr != nil && !item:
|
||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %v", m.Module,
|
||||
templateOf[id], found[0], selErr))
|
||||
case item:
|
||||
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is not a "+
|
||||
"collector", m.Module, templateOf[id], found[0]))
|
||||
case err != nil:
|
||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %v", m.Module,
|
||||
templateOf[id], found[0], err))
|
||||
case !slices.ContainsFunc(collections, func(c PlanCollection) bool { return c.Name == collection }):
|
||||
collections = append(collections, PlanCollection{Name: collection, Comment: comment})
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case slices.Contains(configNamespaces, kind):
|
||||
continue
|
||||
case kind == ConsumersNamespace:
|
||||
// **A provider's consumers' credentials** (ADR 0373): its machine's client fills them from the vault,
|
||||
// for a provision the module provides, in a file only it reads (consumersPlaceholderProblems).
|
||||
if item {
|
||||
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is kept in a "+
|
||||
"bucket, where no secret is (novox/hq ADR 0349 §7)", m.Module, templateOf[id], found[0]))
|
||||
} else if !judgedConsumers[id] {
|
||||
judgedConsumers[id] = true
|
||||
problems = append(problems, consumersOfTemplate(m, id, all[id])...)
|
||||
}
|
||||
continue
|
||||
case item && (kind == "secret" || kind == "bound"):
|
||||
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is kept in a "+
|
||||
"bucket, where no secret is (novox/hq ADR 0349 §7)", m.Module, templateOf[id], found[0]))
|
||||
continue
|
||||
case kind == "secret":
|
||||
// **The module's own secret, by name** (design 57 V4): its machine's client asks the vault for it.
|
||||
// A requirement's credential is a binding's, and moves with the providers (design 57 B1–B3).
|
||||
if _, own := m.OwnSecrets.Plain()[key]; !own && moved[key] {
|
||||
// **A credential it requires, from a provider that has moved** (issue 788): the client fills it
|
||||
// from the vault's row for this holder, which the plan names.
|
||||
if !slices.Contains(required, key) {
|
||||
required = append(required, key)
|
||||
}
|
||||
} else if !own {
|
||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s, which is no secret of its "+
|
||||
"own; a credential it requires moves with its provider (design 57 B1–B3)", m.Module,
|
||||
templateOf[id], found[0]))
|
||||
} else if !slices.Contains(secrets, key) {
|
||||
secrets = append(secrets, key)
|
||||
}
|
||||
continue
|
||||
case kind == "bound" && moved[strings.SplitN(key, ":", 2)[0]]:
|
||||
// **What its moved provider told it** (issue 788): the client fills it from the provider's entry.
|
||||
continue
|
||||
case !slices.Contains(PlacementKinds, kind):
|
||||
if known(kind) {
|
||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s, and the provisioner's "+
|
||||
"client fills only config, secrets, collections and placement until that domain moves "+
|
||||
"(design 57)", m.Module, templateOf[id], found[0]))
|
||||
}
|
||||
continue
|
||||
}
|
||||
name := kind + ":" + key
|
||||
if _, done := out[name]; done {
|
||||
continue
|
||||
}
|
||||
if kind == BackupNamespace {
|
||||
// **What the machine's modules back up** (design 57 L3, issue 794): the text the old contribution
|
||||
// placeholders render, from the same function, over the modules placed on this machine.
|
||||
value, problem := backups.placed(m, key, item)
|
||||
if problem != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %s", m.Module,
|
||||
templateOf[id], found[0], problem))
|
||||
continue
|
||||
}
|
||||
out[name] = value
|
||||
continue
|
||||
}
|
||||
value, err := placed(node, found[0], m, dirs, accessPaths, facts, with)
|
||||
if err != nil {
|
||||
problems = append(problems, err.Error())
|
||||
continue
|
||||
}
|
||||
out[name] = value
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
sort.Strings(problems)
|
||||
return nil, nil, nil, nil, errors.New(strings.Join(problems, "; "))
|
||||
}
|
||||
sort.Slice(collections, func(a, b int) bool { return collections[a].Name < collections[b].Name })
|
||||
return out, secrets, required, collections, nil
|
||||
}
|
||||
|
||||
// PlacementKinds are the placeholder namespaces placement answers (ADR 0349 clause 2), as a plan's keys begin.
|
||||
var PlacementKinds = []string{"dir", "access", "port", "seat", "machine", BackupNamespace}
|
||||
|
||||
// known is whether a namespace is one the mesh fills somewhere.
|
||||
func known(kind string) bool {
|
||||
for _, f := range fillers {
|
||||
if f.namespace == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// placed is one placement placeholder's value, filled by the passes that fill it in a file's content.
|
||||
func placed(node, placeholder string, m Manifest, dirs, accessPaths, facts map[string]string, with Rendering) (string, error) {
|
||||
r := map[string]any{"id": "placement", "type": "file", "path": "/placement", "content": placeholder}
|
||||
if err := dirInto(r, dirs, m.Module); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := accessInto(r, accessPaths, m.Module); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := portInto(r, m.Module, m.Listens, with); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := seatInto(r, m.Module, with); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := machineInto(r, facts, m.Module); err != nil {
|
||||
return "", err
|
||||
}
|
||||
value := fmt.Sprint(r["content"])
|
||||
if value == placeholder {
|
||||
return "", fmt.Errorf("%s: %s is not answered by placement on %s", m.Module, placeholder, node)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// routedTemplates is the templates archive as this node fetches it, through the artifact store it reaches.
|
||||
func routedTemplates(m Manifest, with Rendering) (PlanTemplates, error) {
|
||||
r := map[string]any{"id": "templates", "source": m.Handed.Source}
|
||||
if err := artifactsInto(r, m.Module, with); err != nil {
|
||||
return PlanTemplates{}, err
|
||||
}
|
||||
return PlanTemplates{Source: fmt.Sprint(r["source"]), Digest: m.Handed.Digest}, nil
|
||||
}
|
||||
|
||||
// handedFiles gathers, over one composition, the files handed to the provisioner and each handed module's plan.
|
||||
type handedFiles struct {
|
||||
paths []string
|
||||
plans map[string]Plan
|
||||
// secrets is each handed module's own secrets its declaration names, by name: the path of the file holding one
|
||||
// alone, or "" for one only its templates name.
|
||||
secrets map[string]map[string]string
|
||||
// provisions is each handed module's credentials of what it requires that the vault keeps for it (issue 757).
|
||||
provisions map[string][]PlanProvision
|
||||
// contributes is each handed module's manifest `contributes`, by the requirement it is about (issue 776).
|
||||
contributes map[string]map[string]map[string]any
|
||||
// identities is who each handed module is at its providers' ends (issue 788), and required the credentials of
|
||||
// what it requires that its templates name.
|
||||
identities map[string]string
|
||||
required map[string]map[string]bool
|
||||
// names is the names composed for each handed module through each requirement it contributes to (issue 792).
|
||||
names map[string]map[string]map[string]string
|
||||
}
|
||||
|
||||
// name records the names composed for a handed module through one requirement, as its binding file carries them.
|
||||
func (h *handedFiles) name(module, provision string, own map[string]any) {
|
||||
values := map[string]string{}
|
||||
withOwnNames(values, own)
|
||||
if len(values) == 0 {
|
||||
return
|
||||
}
|
||||
if h.names == nil {
|
||||
h.names = map[string]map[string]map[string]string{}
|
||||
}
|
||||
if h.names[module] == nil {
|
||||
h.names[module] = map[string]map[string]string{}
|
||||
}
|
||||
h.names[module][provision] = values
|
||||
}
|
||||
|
||||
// begin starts a module's plan where this composition hands it, and answers the ids of the files handed: none for
|
||||
// a module that is not handed here. The plan's templates and placement values are composed now; each file is added
|
||||
// as it is composed.
|
||||
//
|
||||
// **Assigned or only pulled in by another** (issue 765): the plan travels in the module's value of the assignments
|
||||
// mirror, `<node>.<module>`, which the client reads (inventory.Provisioning); a module pulled in has a value there of
|
||||
// its own, which says what pulled it in.
|
||||
func (h *handedFiles) begin(node string, m Manifest, dirs, accessPaths, facts map[string]string,
|
||||
with Rendering, caps map[string]bool, needs []Needed, backups backupPlacer) (map[string]bool, error) {
|
||||
if !with.handing(m) {
|
||||
return nil, nil
|
||||
}
|
||||
texts := map[string]string{}
|
||||
ids := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
id := fmt.Sprint(r["id"])
|
||||
if _, named := m.Handed.Templates[id]; !named {
|
||||
continue
|
||||
}
|
||||
if mergesFile(r) {
|
||||
// **A merging file is filled the old way** (issue 831, ADR 0349 clause 17): handing it needs the client
|
||||
// to merge the layers, which nothing builds yet. Its text stays, the controller fills and merges it.
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(r["type"]) != "file" {
|
||||
return nil, fmt.Errorf("%s: %s is a %v and its build kept a template for it; only a file is filled "+
|
||||
"from one", m.Module, id, r["type"])
|
||||
}
|
||||
text, _ := r["content"].(string)
|
||||
texts[id] = text
|
||||
ids[id] = true
|
||||
}
|
||||
for _, id := range sortedKeys(m.Handed.Templates) {
|
||||
if !ids[id] && !mergingIn(m, id) {
|
||||
return nil, fmt.Errorf("%s: its build kept a template for %s, which it does not declare", m.Module, id)
|
||||
}
|
||||
}
|
||||
// **Its items, by template** (design 57 L1): the client fills each and records it for this node.
|
||||
items := map[string]string{}
|
||||
var planned []PlanItem
|
||||
for _, it := range m.Items {
|
||||
template, kept := m.Handed.Items[it.key()]
|
||||
if !kept {
|
||||
return nil, fmt.Errorf("%s: its build kept no template for its item of %s", m.Module, it.key())
|
||||
}
|
||||
// **A capability the machine lacks leaves the item out of its plan** (design 57 L3): the client judges none.
|
||||
if it.IfCapability != "" && !caps[it.IfCapability] {
|
||||
continue
|
||||
}
|
||||
items[it.key()] = it.Content
|
||||
planned = append(planned, PlanItem{Collection: it.Collection, Name: it.Name,
|
||||
Attributes: it.Attributes, Template: template})
|
||||
}
|
||||
if len(ids) == 0 && len(planned) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
templates, err := routedTemplates(m, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// What it requires from a provider that has moved (issue 788): its templates may name what that provider tells it.
|
||||
moved := map[string]bool{}
|
||||
for _, n := range needs {
|
||||
if n.For == m.Module && n.ProviderMoved {
|
||||
moved[n.Name] = true
|
||||
// A credential kept per local name (issue 793) is named by its local in a template.
|
||||
moved[SecretLocal(n.Name, n.Local)] = true
|
||||
}
|
||||
}
|
||||
placement, secrets, required, collections, err := placementOf(node, m, texts, items, dirs, accessPaths, facts,
|
||||
with, moved, backups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.identities == nil {
|
||||
h.identities = map[string]string{}
|
||||
h.required = map[string]map[string]bool{}
|
||||
}
|
||||
h.identities[m.Module] = ConsumerIdentity(node, IdentitySource(m.Slug, m.Module))
|
||||
h.required[m.Module] = map[string]bool{}
|
||||
for _, name := range required {
|
||||
h.required[m.Module][name] = true
|
||||
}
|
||||
for _, name := range secrets {
|
||||
h.secret(m.Module, PlanSecret{Name: name})
|
||||
}
|
||||
plan := Plan{Templates: templates, Items: planned, Collections: collections}
|
||||
if len(placement) > 0 {
|
||||
plan.Placement = placement
|
||||
}
|
||||
h.plans[m.Module] = plan
|
||||
if len(m.Contributes) > 0 {
|
||||
if h.contributes == nil {
|
||||
h.contributes = map[string]map[string]map[string]any{}
|
||||
}
|
||||
h.contributes[m.Module] = m.Contributes
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// add is one handed file of a module, composed. A path handed twice is refused: the node-engine writes a filled
|
||||
// file for exactly one resource, and the client's write names only the path (mesh-host internal/apply).
|
||||
func (h *handedFiles) add(module string, f PlanFile) error {
|
||||
if slices.Contains(h.paths, f.Path) {
|
||||
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
|
||||
"resource and one writer (novox/hq ADR 0349 §5)", module, f.Path)
|
||||
}
|
||||
plan := h.plans[module]
|
||||
plan.Files = append(plan.Files, f)
|
||||
h.plans[module] = plan
|
||||
h.paths = append(h.paths, f.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// secret is one of a handed module's own secrets its declaration names; a file holding it alone is a filled file.
|
||||
func (h *handedFiles) secret(module string, sec PlanSecret) {
|
||||
if h.secrets == nil {
|
||||
h.secrets = map[string]map[string]string{}
|
||||
}
|
||||
if h.secrets[module] == nil {
|
||||
h.secrets[module] = map[string]string{}
|
||||
}
|
||||
if sec.Path != "" || h.secrets[module][sec.Name] == "" {
|
||||
h.secrets[module][sec.Name] = sec.Path
|
||||
}
|
||||
}
|
||||
|
||||
// addSecretFile is a handed module's own secret file, composed: a filled file, and its secret in the plan.
|
||||
func (h *handedFiles) addSecretFile(module string, sec PlanSecret) error {
|
||||
if slices.Contains(h.paths, sec.Path) {
|
||||
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
|
||||
"resource and one writer (novox/hq ADR 0349 §5)", module, sec.Path)
|
||||
}
|
||||
h.paths = append(h.paths, sec.Path)
|
||||
h.secret(module, sec)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addProvisionFile is a handed module's credential file for something it requires, which the vault keeps for it
|
||||
// alone (issue 757): a filled file, and the credential in the plan.
|
||||
func (h *handedFiles) addProvisionFile(module string, pr PlanProvision) error {
|
||||
if slices.Contains(h.paths, pr.Path) {
|
||||
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
|
||||
"resource and one writer (novox/hq ADR 0349 §5)", module, pr.Path)
|
||||
}
|
||||
h.paths = append(h.paths, pr.Path)
|
||||
if h.provisions == nil {
|
||||
h.provisions = map[string][]PlanProvision{}
|
||||
}
|
||||
h.provisions[module] = append(h.provisions[module], pr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// finished is every plan with its secrets, by name, and what its module requires, each with its provider.
|
||||
func (h *handedFiles) finished(needs []Needed) map[string]Plan {
|
||||
if len(h.plans) == 0 {
|
||||
return nil
|
||||
}
|
||||
for module, plan := range h.plans {
|
||||
// **Every provision it requires, with its provider** (issue 760); a credential the vault keeps for it alone
|
||||
// is that provision's entry, with its name and file (issue 757).
|
||||
provisions := provisionsOf(needs, module, h.identities[module], h.contributes[module], h.required[module])
|
||||
for _, credential := range h.provisions[module] {
|
||||
i := slices.IndexFunc(provisions, func(p PlanProvision) bool {
|
||||
return p.Provision == credential.Provision && p.Local == credential.Local
|
||||
})
|
||||
if i < 0 {
|
||||
credential.As = h.identities[module]
|
||||
provisions = append(provisions, credential)
|
||||
continue
|
||||
}
|
||||
provisions[i].Name, provisions[i].Path = credential.Name, credential.Path
|
||||
}
|
||||
for i := range provisions {
|
||||
provisions[i].Names = maps.Clone(h.names[module][provisions[i].Provision])
|
||||
}
|
||||
sortProvisions(provisions)
|
||||
plan.Provisions = provisions
|
||||
h.plans[module] = plan
|
||||
}
|
||||
for module, named := range h.secrets {
|
||||
plan, ok := h.plans[module]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
plan.Secrets = nil
|
||||
for _, name := range sortedKeys(named) {
|
||||
plan.Secrets = append(plan.Secrets, PlanSecret{Name: name, Path: named[name]})
|
||||
}
|
||||
h.plans[module] = plan
|
||||
}
|
||||
return h.plans
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// gitea's server.env, as the catalogue keeps it: its database as the provider told it and the consumer's own login.
|
||||
const giteaServerEnv = "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\n" +
|
||||
"GITEA__database__DB_TYPE=postgres\n" +
|
||||
"GITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\n" +
|
||||
"GITEA__database__NAME=${bound:postgres-database:as}\n" +
|
||||
"GITEA__database__USER=${bound:postgres-database:as}\n" +
|
||||
"GITEA__database__PASSWD=${secret:postgres-database}\n"
|
||||
|
||||
// databaseConsumer is a handed module, with the template gitea's server.env is, requiring a database from a provider
|
||||
// that has moved or not (issue 788); slug is the module's identity source when it declares one.
|
||||
func databaseConsumer(t *testing.T, slug string, moved bool) Resolution {
|
||||
t.Helper()
|
||||
raw := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
|
||||
`"requires":["postgres-database"],"own-secrets":{"internal-token":"${dir:state}/internal-token"}`, 1)
|
||||
if slug != "" {
|
||||
raw = strings.Replace(raw, `"version":"1",`, `"version":"1","slug":"`+slug+`",`, 1)
|
||||
}
|
||||
return withDatabase(t, raw, giteaServerEnv, moved)
|
||||
}
|
||||
|
||||
// withDatabase resolves the module in raw, whose template is text, against a provider of postgres-database that has
|
||||
// moved or not.
|
||||
func withDatabase(t *testing.T, raw, text string, moved bool) Resolution {
|
||||
t.Helper()
|
||||
r, err := Resolve(shelf(held(t, raw, builtBot(text)),
|
||||
Manifest{Module: "postgres", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeNode}}}),
|
||||
[]string{"bot"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
// Answered on this machine, it states no need; one from the provider's machine is stated, as the plan does.
|
||||
r.Needs = []Needed{{Name: "postgres-database", For: "bot", Module: "postgres", From: "novox", At: "10.7.0.1",
|
||||
CredentialForm: CredentialFormPair, IssuedToHolder: moved, ProviderMoved: moved}}
|
||||
return r
|
||||
}
|
||||
|
||||
// The forge's server.env composes for a handed module when what it requires has moved, its plan entry carrying the
|
||||
// provider's address, machine and the consumer's identity as today's binding file gives them; and is refused with the
|
||||
// text it has always had when the provider has not.
|
||||
func TestAHandedModuleMayNameWhatItRequiresWhereItsProviderHasMoved(t *testing.T) {
|
||||
c, got := composeWith(t, databaseConsumer(t, "", true), handingTo("store:5000"))
|
||||
if strings.Contains(got, "sealed") {
|
||||
t.Fatalf("a handed module's declaration carries a sealed value: %s", got)
|
||||
}
|
||||
want := []PlanProvision{{Name: "postgres-database", Provision: "postgres-database", Provider: "novox",
|
||||
ProviderModule: "postgres", CredentialForm: CredentialFormPair, At: "10.7.0.1", From: "novox",
|
||||
As: ConsumerIdentity("workstation", "bot")}}
|
||||
if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want)
|
||||
}
|
||||
for _, unmoved := range []Resolution{databaseConsumer(t, "", false)} {
|
||||
_, err := unmoved.Compose(handingTo("store:5000"))
|
||||
if err == nil || !strings.Contains(err.Error(), "which is no secret of its own; a credential it requires moves "+
|
||||
"with its provider (design 57 B1–B3)") {
|
||||
t.Fatalf("a provider not moved, the credential: %v", err)
|
||||
}
|
||||
bound := strings.Replace(giteaServerEnv, "${secret:postgres-database}", "x", 1)
|
||||
r := withDatabase(t, strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
|
||||
`"requires":["postgres-database"],"own-secrets":{"internal-token":"${dir:state}/internal-token"}`, 1),
|
||||
bound, false)
|
||||
_, err = r.Compose(handingTo("store:5000"))
|
||||
if err == nil || !strings.Contains(err.Error(), "the provisioner's client fills only config, secrets, "+
|
||||
"collections and placement until that domain moves (design 57)") {
|
||||
t.Fatalf("a provider not moved, the binding: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A module with a slug is told who it is as its binding file says, the slug first (issue 788).
|
||||
func TestAHandedModulesPlanEntryCarriesItsIdentityAsTheBindingFileGivesIt(t *testing.T) {
|
||||
c, _ := composeWith(t, databaseConsumer(t, "agent", true), handingTo("store:5000"))
|
||||
if got := c.Plans["bot"].Provisions[0].As; got != ConsumerIdentity("workstation", "agent") {
|
||||
t.Fatalf("the plan entry says %q", got)
|
||||
}
|
||||
// Nothing known of where the provider is: the entry says none.
|
||||
c, _ = composeWith(t, forgeReader(t, true, true), handingTo("store:5000"))
|
||||
if p := c.Plans["bot"].Provisions[0]; p.At != "" || p.From != "novox" {
|
||||
t.Fatalf("the entry says at %q from %q", p.At, p.From)
|
||||
}
|
||||
}
|
||||
|
||||
// A handed module's credentials the vault keeps as one login per local name (issue 793): gitea's internal token and
|
||||
// admin password, which the `secret` provision hands as `${secret:<local>}`. Each is a plan entry naming its local,
|
||||
// its file declared placed and without a value; the template naming the local is allowed.
|
||||
func TestAHandedModulesLocalCredentialsOfTheVaultAreNamedByLocalInItsPlan(t *testing.T) {
|
||||
raw := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
|
||||
`"requires":["secret"],"secrets":{"secret":{"internal-token":"${dir:state}/internal-token.secret",`+
|
||||
`"admin":"${dir:state}/admin.secret"}},"own-secrets":{"token":"${dir:state}/token"}`, 1)
|
||||
r, err := Resolve(shelf(held(t, raw, builtBot("T=${secret:internal-token}\n")),
|
||||
Manifest{Module: "vault", Version: "1", Provides: []Offer{{Name: "secret", Scope: ScopeNode}}}),
|
||||
[]string{"bot"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
r.Needs = []Needed{
|
||||
{Name: "secret", Local: "internal-token", For: "bot", Module: "vault", From: "novox",
|
||||
CredentialForm: CredentialFormPair, IssuedToHolder: true, ProviderMoved: true},
|
||||
{Name: "secret", Local: "admin", For: "bot", Module: "vault", From: "novox",
|
||||
CredentialForm: CredentialFormPair, IssuedToHolder: true, ProviderMoved: true},
|
||||
}
|
||||
c, got := composeWith(t, r, handingTo("store:5000"))
|
||||
if strings.Contains(got, "sealed") {
|
||||
t.Fatalf("the declaration carries a sealed value: %s", got)
|
||||
}
|
||||
var names []string
|
||||
for _, p := range c.Plans["bot"].Provisions {
|
||||
if p.Provision != "secret" || p.Provider != "novox" || p.Path == "" || p.Name != p.Local {
|
||||
t.Errorf("the plan entry is %+v", p)
|
||||
}
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(names, []string{"admin", "internal-token"}) {
|
||||
t.Fatalf("the plan names %v, want admin and internal-token", names)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A provider's consumers' credentials reach it in a file its machine's provisioner client fills (novox/hq ADR 0373,
|
||||
// issue 679): `${consumers:<provision>}`, which only a provider's template may hold, for a provision it provides, in a
|
||||
// file of mode 0600 owned at runtime; filled by the client alone, so a module not handed to it on a machine is
|
||||
// refused there rather than written the placeholder's text, or nothing.
|
||||
|
||||
const (
|
||||
handedProvider = `{"module":"db","version":"1","moved":true,
|
||||
"provides":[{"name":"postgres-database","scope":"mesh","identity":{"max":63,"in":"a PostgreSQL role"}}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[
|
||||
{"id":"state","type":"directory","mode":"0700","place":"."},
|
||||
{"id":"consumers","type":"file","path":"${dir:state}/consumers.json","mode":"0600","template":"files/consumers.json"}]}`
|
||||
consumersText = "${consumers:postgres-database}"
|
||||
)
|
||||
|
||||
func builtProvider(text string) []Built {
|
||||
return []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + "db/files/blobs/sha256:" + strings.Repeat("f", 64),
|
||||
Digest: "sha256:" + strings.Repeat("f", 64), Texts: map[string]string{"consumers.json": text}}}
|
||||
}
|
||||
|
||||
func TestAHandedProvidersConsumersFileIsHandedToTheClient(t *testing.T) {
|
||||
r := notifierOn(t, held(t, handedProvider, builtProvider(consumersText)))
|
||||
c, got := composeWith(t, r, handingTo("store:5000"))
|
||||
if strings.Contains(got, "${consumers:") {
|
||||
t.Fatalf("the declaration carries the placeholder: %s", got)
|
||||
}
|
||||
plan := c.Plans["db"]
|
||||
if len(plan.Files) != 1 || plan.Files[0].Template != "consumers.json" || plan.Files[0].OwnerAtRuntime != RuntimeOwned {
|
||||
t.Fatalf("the plan fills %+v, want the consumers' file owned at runtime", plan.Files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatAConsumersPlaceholderMayNotBeIsRefused(t *testing.T) {
|
||||
for why, c := range map[string]struct{ raw, text, want string }{
|
||||
"a provision the module does not provide": {handedProvider, "${consumers:cache}", "does not provide"},
|
||||
"a module providing nothing": {strings.Replace(handedProvider, `"provides":[{"name":"postgres-database","scope":"mesh","identity":{"max":63,"in":"a PostgreSQL role"}}],`,
|
||||
"", 1), consumersText, "does not provide"},
|
||||
"a file others may read": {strings.Replace(handedProvider, `"mode":"0600","template"`,
|
||||
`"mode":"0640","template"`, 1), consumersText, "0600"},
|
||||
"a file the program keeps": {strings.Replace(handedProvider, `"mode":"0600","template"`,
|
||||
`"mode":"0600","create-once":true,"template"`, 1), consumersText, "owned"},
|
||||
} {
|
||||
m, err := ParseManifest([]byte(c.raw))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", why, err)
|
||||
}
|
||||
resolved, err := m.Resolve(builtProvider(c.text))
|
||||
if err == nil {
|
||||
r := notifierOn(t, resolved)
|
||||
_, err = r.Compose(handingTo("store:5000"))
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v, want a refusal saying %q", why, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Where the provider is not handed, nothing fills the placeholder, and it is never written as its text or as an empty
|
||||
// list the provider would read as no consumers: the file is left out, said once, and the module composes exactly as
|
||||
// it would without the file, so a provider below the handing contract keeps working from its grant files (issue 780).
|
||||
func TestAProvidersConsumersFileIsLeftOutWhereItIsNotHanded(t *testing.T) {
|
||||
withFile := notifierOn(t, held(t, handedProvider, builtProvider(consumersText)))
|
||||
without := strings.Replace(handedProvider, `,
|
||||
{"id":"consumers","type":"file","path":"${dir:state}/consumers.json","mode":"0600","template":"files/consumers.json"}`, "", 1)
|
||||
if without == handedProvider {
|
||||
t.Fatal("the manifest without the file was not made")
|
||||
}
|
||||
bare := notifierOn(t, held(t, strings.Replace(strings.Replace(without, `"moved":true,`, "", 1),
|
||||
`"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1), nil))
|
||||
for name, with := range map[string]Rendering{
|
||||
"a node-engine that fills no file": {},
|
||||
"a node-engine that fills, no client": {Hands: true, ArtifactStore: "store:5000"},
|
||||
"a client, a node-engine that does not": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"},
|
||||
} {
|
||||
c, got := composeWith(t, withFile, with)
|
||||
_, want := composeWith(t, bare, with)
|
||||
if got != want {
|
||||
t.Errorf("%s: composes\n%s\nwithout its file, it composed\n%s", name, got, want)
|
||||
}
|
||||
if strings.Contains(got, "consumers") {
|
||||
t.Errorf("%s: the declaration names the consumers' file: %s", name, got)
|
||||
}
|
||||
if len(c.HandedOnly) != 1 || !strings.Contains(c.HandedOnly[0], "left out, handed only") {
|
||||
t.Errorf("%s: said %q, want the one line saying it is left out", name, c.HandedOnly)
|
||||
}
|
||||
if len(c.Unplaced) != 0 || len(c.Filled) != 0 || len(c.Plans) != 0 {
|
||||
t.Errorf("%s: unplaced %v, filled %v, plans %v", name, c.Unplaced, c.Filled, c.Plans)
|
||||
}
|
||||
}
|
||||
// Where it is handed, the file is there and nothing is said.
|
||||
c, _ := composeWith(t, withFile, handingTo("store:5000"))
|
||||
if len(c.HandedOnly) != 0 || len(c.Filled) != 1 {
|
||||
t.Fatalf("handed: said %q, filled %v", c.HandedOnly, c.Filled)
|
||||
}
|
||||
}
|
||||
|
||||
// What `module check` and the merge gate judge: the provider read from its tree, its text inline. Judged by the same
|
||||
// rules, a provider naming a provision it does not provide is refused there too, and one that does composes.
|
||||
func TestAProviderReadFromItsTreeIsJudgedByTheSameRules(t *testing.T) {
|
||||
read := func(text string) (Manifest, error) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "files", "consumers.json"), []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := ParseManifest([]byte(handedProvider))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return WithTemplatesFrom(m, dir)
|
||||
}
|
||||
filled, err := read(consumersText)
|
||||
if err != nil {
|
||||
t.Fatalf("a provider read from its tree is refused: %v", err)
|
||||
}
|
||||
if _, err := notifierOn(t, filled).Compose(handingTo("store:5000")); err != nil {
|
||||
t.Fatalf("a provider read from its tree does not compose where it would be handed: %v", err)
|
||||
}
|
||||
if _, err := read("${consumers:cache}"); err == nil || !strings.Contains(err.Error(), "does not provide") {
|
||||
t.Fatalf("a provider read from its tree naming a provision it does not provide: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// issue 831: a moved module's merging file is not handed (ADR 0349 clause 17, the lead's limit): the controller
|
||||
// fills and merges it as for a module not moved, and the module's other files stay handed.
|
||||
func TestAMovedModulesMergingFileIsFilledTheOldWayAndItsPlainFileIsHanded(t *testing.T) {
|
||||
raw := strings.Replace(handedNotifier, `{"id":"legacy"`,
|
||||
`{"id":"settings","type":"file","path":"${machine:account-home}/.config/notify/settings.json","owner":"${machine:account}","mode":"0644","template":"files/settings.json","merge":"json","takes-config":["width"]},
|
||||
{"id":"legacy"`, 1)
|
||||
moved := strings.Replace(raw, `"version":"1",`, `"version":"1","moved":true,`, 1)
|
||||
built := []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + "notify/files/blobs/sha256:" + strings.Repeat("d", 64),
|
||||
Digest: "sha256:" + strings.Repeat("d", 64),
|
||||
Texts: map[string]string{"rc": notifierText, "settings.json": "{\"theme\": \"dark\"}\n"}}}
|
||||
cb, _ := composeWith(t, notifierOn(t, held(t, raw, built)), handingTo("store:5000"))
|
||||
c, got := composeWith(t, notifierOn(t, held(t, moved, built)), handingTo("store:5000"))
|
||||
var merged, plain map[string]any
|
||||
for _, r := range c.Resources {
|
||||
switch r["id"] {
|
||||
case "notify.settings":
|
||||
merged = r
|
||||
case "notify.configuration":
|
||||
plain = r
|
||||
}
|
||||
}
|
||||
if merged == nil || plain == nil {
|
||||
t.Fatalf("not composed: %s", got)
|
||||
}
|
||||
if text, _ := merged["content"].(string); !strings.Contains(text, `"theme"`) || merged["owner-at-runtime"] != nil {
|
||||
t.Fatalf("the merging file is not filled the old way: %v", merged)
|
||||
}
|
||||
if plain["content"] != nil || plain["owner-at-runtime"] != "owned" {
|
||||
t.Fatalf("the plain file is not handed: %v", plain)
|
||||
}
|
||||
for _, p := range c.Filled {
|
||||
if strings.HasSuffix(p, "settings.json") {
|
||||
t.Fatalf("the merging file is named among the filled files: %v", c.Filled)
|
||||
}
|
||||
}
|
||||
if len(c.Plans["notify"].Files) != 1 || c.Plans["notify"].Files[0].ID != "configuration" {
|
||||
t.Fatalf("the plan is %+v", c.Plans)
|
||||
}
|
||||
if len(c.Unplaced) > 0 {
|
||||
t.Fatalf("unplaced: %v", c.Unplaced)
|
||||
}
|
||||
// byte-identical to the not-moved composition
|
||||
for _, r := range cb.Resources {
|
||||
if r["id"] == "notify.settings" && !reflect.DeepEqual(r, merged) {
|
||||
t.Fatalf("the merging file differs from the not-moved composition:\nbefore %v\nnow %v", r, merged)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A handed module's plan names the provider serving each provision it requires (design 57 V5a, issue 760): the
|
||||
// module on the machine resolution chose for this consumer, and the local name it keeps the credential under where
|
||||
// it keeps several, so the vault, which reads the plan in the assignments mirror, sees a consumer bind and unbind
|
||||
// (novox/hq ADR 0349 clause 8). One entry per provision and local name, the same entry that names a credential
|
||||
// the vault keeps for it alone (issue 757).
|
||||
|
||||
// notifierConsumer is the notifier requiring a mesh-scoped database, marked moved when moved, with its credential
|
||||
// kept under two local names (ADR 0094) when locals is set.
|
||||
func notifierConsumer(t *testing.T, moved, locals bool) Manifest {
|
||||
t.Helper()
|
||||
raw := handedNotifier
|
||||
if moved {
|
||||
raw = notifierMoved()
|
||||
}
|
||||
add := `"requires":["postgres-database"],`
|
||||
if locals {
|
||||
add += `"secrets":{"postgres-database":{"hot":"/var/lib/notify/hot","cold":"/var/lib/notify/cold"}},`
|
||||
}
|
||||
raw = strings.Replace(raw, `"build":`, add+`"build":`, 1)
|
||||
return held(t, raw, builtNotifier())
|
||||
}
|
||||
|
||||
// twoStores is a mesh where two machines each run a store providing the database, the consumer pinned to one.
|
||||
func twoStores(pin string) World {
|
||||
return World{Offered: map[string][]Provider{"postgres-database": {
|
||||
{Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}},
|
||||
{Node: "home", At: "home.internal", Module: "store-two", Serves: map[string]any{"port": 5434}},
|
||||
}}, Pinned: map[string]Chosen{"postgres-database": {Node: pin}}}
|
||||
}
|
||||
|
||||
func consumerOn(t *testing.T, m Manifest, w World) Resolution {
|
||||
t.Helper()
|
||||
node := workstation()
|
||||
node.At = "workstation.internal"
|
||||
stores := shelf(m,
|
||||
Manifest{Module: "store", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}},
|
||||
Manifest{Module: "store-two", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5434}}})
|
||||
r, err := Resolve(stores, []string{m.Module}, node, w)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAHandedModulesPlanNamesTheProviderServingEachProvision(t *testing.T) {
|
||||
c, _ := composeWith(t, consumerOn(t, notifierConsumer(t, true, false), twoStores("anchor")), handingTo("store:5000"))
|
||||
want := []PlanProvision{{Provision: "postgres-database", Provider: "anchor", ProviderModule: "store",
|
||||
CredentialForm: CredentialFormPair, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify")}}
|
||||
if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
// **A changed binding changes the plan**: pinned to the other store, the plan names it and not the first.
|
||||
c, _ = composeWith(t, consumerOn(t, notifierConsumer(t, true, false), twoStores("home")), handingTo("store:5000"))
|
||||
want = []PlanProvision{{Provision: "postgres-database", Provider: "home", ProviderModule: "store-two",
|
||||
CredentialForm: CredentialFormPair, At: "home.internal", From: "home", As: ConsumerIdentity("workstation", "notify")}}
|
||||
if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("rebound, the plan names %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer keeping one provider's credential under several local names is bound once per local name.
|
||||
func TestAHandedModulesPlanNamesEachLocalName(t *testing.T) {
|
||||
c, _ := composeWith(t, consumerOn(t, notifierConsumer(t, true, true), twoStores("anchor")), handingTo("store:5000"))
|
||||
want := []PlanProvision{
|
||||
{Provision: "postgres-database", Local: "cold", Provider: "anchor", ProviderModule: "store",
|
||||
CredentialForm: CredentialFormPair, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify")},
|
||||
{Provision: "postgres-database", Local: "hot", Provider: "anchor", ProviderModule: "store",
|
||||
CredentialForm: CredentialFormPair, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify")},
|
||||
}
|
||||
if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A module not moved, or moved where it is not handed, composes as before and has no plan.
|
||||
func TestAConsumerNotHandedComposesAsBefore(t *testing.T) {
|
||||
_, before := composeWith(t, consumerOn(t, notifierConsumer(t, false, false), twoStores("anchor")),
|
||||
handingTo("store:5000"))
|
||||
c, got := composeWith(t, consumerOn(t, notifierConsumer(t, true, false), twoStores("anchor")),
|
||||
Rendering{Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"})
|
||||
if got != before {
|
||||
t.Fatalf("the moved consumer composes differently where it is not handed:\nbefore: %s\nnow: %s", before, got)
|
||||
}
|
||||
if c.Plans != nil {
|
||||
t.Fatalf("a consumer not handed has plans %+v", c.Plans)
|
||||
}
|
||||
}
|
||||
|
||||
// A credential the vault keeps for the holder alone is its provision's one entry, with its provider's module too.
|
||||
func TestAHandedModulesIssuedCredentialIsItsProvisionsOneEntry(t *testing.T) {
|
||||
r := forgeReader(t, true, true)
|
||||
r.Needs = append(r.Needs, Needed{Name: "s3-bucket", From: "ace", For: "bot", Module: "minio"},
|
||||
Needed{Name: "openai", From: "personal", For: "bot", ByRecord: true})
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
got := c.Plans["bot"].Provisions
|
||||
if len(got) != 2 || got[0].Provision != "forge-read" || got[0].Name != "forge-read" ||
|
||||
got[0].ProviderModule != "forge" || got[0].Path == "" ||
|
||||
!reflect.DeepEqual(got[1], PlanProvision{Provision: "s3-bucket", Provider: "ace", ProviderModule: "minio",
|
||||
From: "ace", As: ConsumerIdentity("workstation", "bot")}) {
|
||||
t.Fatalf("the plan names %+v: one entry per provision, the issued credential's with its name and file, "+
|
||||
"none for a licence", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Each entry names the credential form the provider's offer declares (issue 768), from the provider's manifest, so
|
||||
// the vault makes a first value in that form for a provider it holds no row of: one of its own secrets shared by
|
||||
// every consumer, a login per consumer when the offer says no credential, issued by its software, or given by a
|
||||
// person (the offer's own secret issued outside).
|
||||
func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.T) {
|
||||
storeOffering := func(credential string) Manifest {
|
||||
raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` +
|
||||
credential + `}],"serves":{"postgres-database":{"port":5432}}`
|
||||
if strings.Contains(credential, `"own"`) {
|
||||
raw += `,"own-secrets":{"admin":{"path":"/var/lib/store/admin","taken":"at-start"},` +
|
||||
`"token":{"path":"/var/lib/store/token","taken":"at-start","issued-by":"outside"}}`
|
||||
}
|
||||
return mustParse(t, raw+`}`)
|
||||
}
|
||||
for credential, want := range map[string]string{
|
||||
``: CredentialFormPair,
|
||||
`,"credential":{"own":"admin"}`: CredentialFormShared,
|
||||
`,"credential":{"own":"token"}`: CredentialFormGiven,
|
||||
`,"credential":{"issued-by":"outside"}`: CredentialFormGiven,
|
||||
`,"credential":{"issued-by":"software","tool":"store_issued_token"}`: CredentialFormIssued,
|
||||
} {
|
||||
node := workstation()
|
||||
node.At = "workstation.internal"
|
||||
consumer := notifierConsumer(t, true, false)
|
||||
r, err := Resolve(shelf(consumer, storeOffering(credential)), []string{consumer.Module}, node, twoStores("anchor"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
got := c.Plans["notify"].Provisions
|
||||
if len(got) != 1 || got[0].CredentialForm != want {
|
||||
t.Errorf("an offer with %q: the plan names %+v, want the form %q", credential, got, want)
|
||||
}
|
||||
}
|
||||
if got := (Manifest{Module: "x"}).CredentialFormOf("postgres-database"); got != "" {
|
||||
t.Errorf("a module offering nothing has the credential form %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer's plan entry carries what its manifest contributes to that provision (issue 776), so the provider's
|
||||
// helper learns a database's name from the bus; a consumer contributing nothing has the entry it had.
|
||||
func TestAHandedModulesPlanEntryCarriesWhatItContributesToItsProvider(t *testing.T) {
|
||||
m := notifierConsumer(t, true, false)
|
||||
m.Contributes = map[string]map[string]any{"postgres-database": {"name": "nextcloud"}}
|
||||
c, _ := composeWith(t, consumerOn(t, m, twoStores("anchor")), handingTo("store:5000"))
|
||||
want := []PlanProvision{{Provision: "postgres-database", Provider: "anchor", ProviderModule: "store",
|
||||
CredentialForm: CredentialFormPair, Contributes: map[string]any{"name": "nextcloud"}, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify"),
|
||||
// What the binding file says as `${bound:postgres-database:name}` (issue 792).
|
||||
Names: map[string]string{"name": "nextcloud"}}}
|
||||
if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
m = notifierConsumer(t, true, false)
|
||||
c, _ = composeWith(t, consumerOn(t, m, twoStores("anchor")), handingTo("store:5000"))
|
||||
raw, err := json.Marshal(c.Plans["notify"].Provisions)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const before = `[{"provision":"postgres-database","provider":"anchor","provider-module":"store",` +
|
||||
`"credential-form":"pair","at":"anchor.internal","from":"anchor","as":"mesh_workstation_notify"}]`
|
||||
if string(raw) != before {
|
||||
t.Fatalf("a consumer contributing nothing has the entry %s, want %s", raw, before)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer's plan entry carries the names the mesh composed for it through that provision (issue 792): what its
|
||||
// binding file names as `${bound:<provision>:name|internal-name|name-<local>|internal-name-<local>}`, so a moved
|
||||
// provider's helper copies them into the consumer's entry and the client fills the same text. A consumer
|
||||
// contributing no label has the entry it had.
|
||||
func TestAHandedModulesPlanEntryCarriesTheNamesComposedForIt(t *testing.T) {
|
||||
m := notifierConsumer(t, true, false)
|
||||
m.ContributesMany = map[string]map[string]map[string]any{
|
||||
"postgres-database": {"web": {"label": "git"}, "api": {"label": "git-api"}}}
|
||||
r := consumerOn(t, m, twoStores("anchor"))
|
||||
r.PublicDomain = "example.org"
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
got := c.Plans["notify"].Provisions
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("the plan names %+v", got)
|
||||
}
|
||||
want := map[string]string{"name-web": "git.example.org", "name-api": "git-api.example.org"}
|
||||
own, err := r.ownNames(m, "postgres-database", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := map[string]string{}
|
||||
withOwnNames(file, own)
|
||||
for k, v := range file {
|
||||
if strings.HasPrefix(k, "internal-") {
|
||||
want[k] = v
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(got[0].Names, want) {
|
||||
t.Fatalf("the entry carries %v, the binding file names %v", got[0].Names, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The flag a provides entry carries, `"answered-by": "helper"`, is read and written back, and refused with any other
|
||||
// word (issue 792). It is no part of moving the module: the flag alone hands none of the module's files, and the
|
||||
// module composes byte for byte as it did without it.
|
||||
func TestAnOfferAnsweredByTheHelperIsReadAndHandsNoFile(t *testing.T) {
|
||||
with := strings.Replace(handedNotifier, `"version":"1",`,
|
||||
`"version":"1","provides":[{"name":"route","scope":"mesh","answered-by":"helper"}],`, 1)
|
||||
without := strings.Replace(handedNotifier, `"version":"1",`,
|
||||
`"version":"1","provides":[{"name":"route","scope":"mesh"}],`, 1)
|
||||
flagged := held(t, with, builtNotifier())
|
||||
if flagged.Moved || len(flagged.Provides) != 1 || flagged.Provides[0].AnsweredBy != AnsweredByHelper {
|
||||
t.Fatalf("the flag was read as %+v (moved %v)", flagged.Provides, flagged.Moved)
|
||||
}
|
||||
if flagged.Handed != nil {
|
||||
t.Fatalf("the flag alone kept what a moved module's build keeps: %+v", flagged.Handed)
|
||||
}
|
||||
raw, err := json.Marshal(flagged.Provides[0])
|
||||
if err != nil || !strings.Contains(string(raw), `"answered-by":"helper"`) {
|
||||
t.Fatalf("written back as %s: %v", raw, err)
|
||||
}
|
||||
if _, err := ParseManifest([]byte(strings.Replace(with, `"helper"`, `"harness"`, 1))); err == nil {
|
||||
t.Fatal("an unknown answerer was accepted")
|
||||
}
|
||||
c1, before := composeWith(t, notifierOn(t, held(t, without, builtNotifier())), handingTo("store:5000"))
|
||||
c2, got := composeWith(t, notifierOn(t, flagged), handingTo("store:5000"))
|
||||
if got != before || len(c2.Filled) > 0 || c2.Plans != nil || c1.Plans != nil {
|
||||
t.Fatalf("the flag changed what is composed or handed:\nbefore: %s\nnow: %s\n%v %v", before, got, c2.Filled, c2.Plans)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module only pulled in by another is handed exactly like an assigned one (issue 765): its templated files, its
|
||||
// own secrets and its items go to the machine's provisioner client wherever the machine can take them. A module not
|
||||
// moved composes as before, pulled in or not.
|
||||
|
||||
// pulledInBy is a module that requires what raw's module provides, so resolving it alone pulls that module in.
|
||||
const pulledInBy = `{"module":"desk","version":"1","requires":["pulled-in-here"]}`
|
||||
|
||||
// providing is raw's module providing what desk requires.
|
||||
func providing(raw string) string {
|
||||
return strings.Replace(raw, `"version":"1",`, `"version":"1","provides":[{"name":"pulled-in-here"}],`, 1)
|
||||
}
|
||||
|
||||
// onWorkstation resolves m on the workstation either assigned beside desk, or pulled in by desk alone.
|
||||
func onWorkstation(t *testing.T, m Manifest, assigned bool) Resolution {
|
||||
t.Helper()
|
||||
desk, err := ParseManifest([]byte(pulledInBy))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"desk"}
|
||||
if assigned {
|
||||
want = append(want, m.Module)
|
||||
}
|
||||
r, err := Resolve(shelf(m, desk), want, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
why := map[bool]string{true: "assigned", false: "required by desk"}[assigned]
|
||||
if r.Because[m.Module] != why {
|
||||
t.Fatalf("%s is on the workstation because %q, want %q", m.Module, r.Because[m.Module], why)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
// resourcesOf is the module's resources in a composition, as JSON.
|
||||
func resourcesOf(t *testing.T, c Composed, module string) string {
|
||||
t.Helper()
|
||||
var out []map[string]any
|
||||
for _, r := range c.Resources {
|
||||
if strings.HasPrefix(r["id"].(string), module+".") {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestAModulePulledInIsHandedLikeAnAssignedOne(t *testing.T) {
|
||||
for name, m := range map[string]struct {
|
||||
module string
|
||||
held Manifest
|
||||
with Rendering
|
||||
}{
|
||||
"its templated file": {"notify", held(t, providing(notifierMoved()), builtNotifier()), handingTo("store:5000")},
|
||||
"its own secret": {"bot", held(t, providing(botMoved()), builtBot(botText)),
|
||||
sealedForBot(handingTo("store:5000"))},
|
||||
"its item": {"prompt", held(t, providing(contributorP10k),
|
||||
builtWith("prompt", map[string]string{"zsh": contributorText})), handingTo("store:5000")},
|
||||
} {
|
||||
assigned, _ := composeWith(t, onWorkstation(t, m.held, true), m.with)
|
||||
pulled, got := composeWith(t, onWorkstation(t, m.held, false), m.with)
|
||||
plan, handed := pulled.Plans[m.module]
|
||||
if !handed {
|
||||
t.Errorf("%s: a pulled-in moved module is not handed: plans %+v", name, pulled.Plans)
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(plan, assigned.Plans[m.module]) || !reflect.DeepEqual(pulled.Filled, assigned.Filled) {
|
||||
t.Errorf("%s: pulled in, its plan is %+v and the filled files %v; assigned, %+v and %v", name, plan,
|
||||
pulled.Filled, assigned.Plans[m.module], assigned.Filled)
|
||||
}
|
||||
if a, p := resourcesOf(t, assigned, m.module), resourcesOf(t, pulled, m.module); a != p {
|
||||
t.Errorf("%s: pulled in, its resources are\n%s\nassigned,\n%s", name, p, a)
|
||||
}
|
||||
if strings.Contains(got, "sealed-to-the-node") || strings.Contains(got, "width = ") {
|
||||
t.Errorf("%s: the controller filled or sealed something of a handed module: %s", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModulePulledInAndNotMovedComposesAsBefore(t *testing.T) {
|
||||
for name, m := range map[string]Manifest{
|
||||
"a file": held(t, providing(handedNotifier), builtNotifier()),
|
||||
"a secret": held(t, providing(handedBot), builtBot(botText)),
|
||||
} {
|
||||
_, before := composeWith(t, onWorkstation(t, m, false), sealedForBot(Rendering{ArtifactStore: "store:5000"}))
|
||||
c, got := composeWith(t, onWorkstation(t, m, false), sealedForBot(handingTo("store:5000")))
|
||||
if got != before || c.Plans != nil || len(c.Filled) > 0 {
|
||||
t.Errorf("%s: a pulled-in module not moved composes differently where the machine hands:\nbefore: %s\nnow: %s"+
|
||||
"\nplans %v, filled %v", name, before, got, c.Plans, c.Filled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What requires a module pulled in is said by name, for its value in the assignments mirror; nothing for one
|
||||
// assigned.
|
||||
func TestAPulledInModuleSaysWhatRequiresIt(t *testing.T) {
|
||||
m := held(t, providing(notifierMoved()), builtNotifier())
|
||||
if by := onWorkstation(t, m, false).RequiredBy("notify"); by != "desk" {
|
||||
t.Errorf("pulled in, notify is required by %q, want desk", by)
|
||||
}
|
||||
assigned := onWorkstation(t, m, true)
|
||||
if by := assigned.RequiredBy("notify"); by != "" {
|
||||
t.Errorf("assigned, notify is required by %q", by)
|
||||
}
|
||||
if by := assigned.RequiredBy("absent"); by != "" {
|
||||
t.Errorf("a module not on the machine is required by %q", by)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A moved module's own secrets come from the vault (novox/hq ADR 0349 clauses 7 and 11, design 57 V4, issue 647):
|
||||
// where the module is handed, its declaration carries no sealed value — each file holding a secret alone is placed
|
||||
// without one and filled by the client, and the plan names every secret its templates and those files need, by
|
||||
// name. Where it is not handed, and for every module not moved, the composition is byte for byte what it was.
|
||||
|
||||
const (
|
||||
handedBot = `{"module":"bot","version":"1",
|
||||
"own-secrets":{"token":"${dir:state}/token"},
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[
|
||||
{"id":"state","type":"directory","mode":"0700","place":"."},
|
||||
{"id":"conf","type":"file","path":"/etc/bot/bot.conf","mode":"0640","template":"files/bot.conf"},
|
||||
{"id":"daemon","type":"process","name":"bot","run":["bot"],"env":{"BOT_TOKEN_FILE":"${dir:state}/token"}}]}`
|
||||
botText = "token = ${secret:token}\nhome = ${machine:account-home}\n"
|
||||
)
|
||||
|
||||
func botMoved() string {
|
||||
return strings.Replace(handedBot, `"version":"1",`, `"version":"1","moved":true,`, 1)
|
||||
}
|
||||
|
||||
func builtBot(text string) []Built {
|
||||
return []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + "bot/files/blobs/sha256:" + strings.Repeat("e", 64),
|
||||
Digest: "sha256:" + strings.Repeat("e", 64), Texts: map[string]string{"bot.conf": text}}}
|
||||
}
|
||||
|
||||
// What the controller seals for the bot when it fills it the old way: a stand-in sealed value.
|
||||
func sealedForBot(with Rendering) Rendering {
|
||||
with.Needed = map[string]map[string]string{"bot": {"token": "sealed-to-the-node"}}
|
||||
return with
|
||||
}
|
||||
|
||||
func TestAHandedModulesSecretsAreNamedAndNoValueIsSealed(t *testing.T) {
|
||||
r := notifierOn(t, held(t, botMoved(), builtBot(botText)))
|
||||
c, got := composeWith(t, r, handingTo("store:5000"))
|
||||
if strings.Contains(got, "sealed") || strings.Contains(got, "${secret:") {
|
||||
t.Fatalf("a handed module's declaration carries a sealed value or a secret placeholder: %s", got)
|
||||
}
|
||||
var token map[string]any
|
||||
for _, res := range c.Resources {
|
||||
if res["id"] == "bot.needs-token" {
|
||||
token = res
|
||||
}
|
||||
}
|
||||
if token == nil {
|
||||
t.Fatalf("the token's file is not declared: %s", got)
|
||||
}
|
||||
if token["path"] == nil || strings.Contains(token["path"].(string), "${") || token["mode"] != "0600" ||
|
||||
token["owner-at-runtime"] != "owned" || token[HandedSecretField] != nil {
|
||||
t.Fatalf("the token's file is declared %v: placed, 0600, owned at runtime, no value", token)
|
||||
}
|
||||
path := token["path"].(string)
|
||||
if !reflect.DeepEqual(c.Filled, []string{path, "/etc/bot/bot.conf"}) {
|
||||
t.Fatalf("the filled files are %v", c.Filled)
|
||||
}
|
||||
if want := []PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(c.Plans["bot"].Secrets, want) {
|
||||
t.Fatalf("the plan names secrets %+v, want %+v", c.Plans["bot"].Secrets, want)
|
||||
}
|
||||
// A process naming the token's file still restarts when its client writes it.
|
||||
for _, res := range c.Resources {
|
||||
if res["id"] == "bot.daemon" && !strings.Contains(strings.Join(stringsIn(res["restart-on"]), " "), "bot.needs-token") {
|
||||
t.Errorf("the daemon does not restart on its token's file: %v", res["restart-on"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A secret only a template names is in the plan by name alone.
|
||||
func TestASecretOnlyATemplateNamesIsPlannedByName(t *testing.T) {
|
||||
raw := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"},`,
|
||||
`"own-secrets":{"token":"${dir:state}/token","api-key":"${dir:state}/api-key"},`, 1)
|
||||
r := notifierOn(t, held(t, raw, builtBot(botText+"key = ${secret:api-key}\n")))
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
names := []string{}
|
||||
for _, s := range c.Plans["bot"].Secrets {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if strings.Join(names, " ") != "api-key token" {
|
||||
t.Fatalf("the plan names %v", c.Plans["bot"].Secrets)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing changes for a module that has not moved, nor for a moved one where it is not handed: its secrets are
|
||||
// sealed into the declaration as before.
|
||||
func TestAMovedModulesSecretsAreSealedAsBeforeWhereItIsNotHanded(t *testing.T) {
|
||||
_, before := composeWith(t, notifierOn(t, held(t, handedBot, builtBot(botText))),
|
||||
sealedForBot(handingTo("store:5000")))
|
||||
if !strings.Contains(before, "sealed-to-the-node") {
|
||||
t.Fatalf("the unmoved module is not sealed the old way: %s", before)
|
||||
}
|
||||
for name, with := range map[string]Rendering{
|
||||
"an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"},
|
||||
"no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"},
|
||||
} {
|
||||
c, got := composeWith(t, notifierOn(t, held(t, botMoved(), builtBot(botText))), sealedForBot(with))
|
||||
if got != before {
|
||||
t.Errorf("%s: the moved module composes differently from its unmarked twin:\nbefore: %s\nnow: %s",
|
||||
name, before, got)
|
||||
}
|
||||
if len(c.Filled) > 0 || c.Plans != nil {
|
||||
t.Errorf("%s: something was handed: %v %v", name, c.Filled, c.Plans)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatAHandedModuleCannotHaveFromTheVaultIsRefused(t *testing.T) {
|
||||
// A template naming a credential the module requires: a binding's, which moves with its provider.
|
||||
requires := strings.Replace(botMoved(), `"own-secrets":`, `"requires":["database"],"secrets":{"database":"/etc/bot/db"},"own-secrets":`, 1)
|
||||
r, err := Resolve(shelf(held(t, requires, builtBot("db = ${secret:database}\n")),
|
||||
Manifest{Module: "db", Version: "1", Provides: []Offer{{Name: "database", Scope: ScopeNode}}}),
|
||||
[]string{"bot"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "moves with its provider") {
|
||||
t.Errorf("a template naming a required credential: %v", err)
|
||||
}
|
||||
// A file with its text inline naming a secret, in a handed module.
|
||||
inline := strings.Replace(botMoved(), `{"id":"daemon"`,
|
||||
`{"id":"legacy","type":"file","path":"/etc/bot/legacy","content":"t = ${secret:token}\n"},{"id":"daemon"`, 1)
|
||||
r = notifierOn(t, held(t, inline, builtBot(botText)))
|
||||
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "keep it in a template") {
|
||||
t.Errorf("an inline file naming a secret in a handed module: %v", err)
|
||||
}
|
||||
// A family and a secret issued outside the mesh are not handed by name yet: the mark is refused.
|
||||
for why, raw := range map[string]string{
|
||||
"a family": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":"${dir:state}/token","key-*":"${dir:state}/keys"}`, 1),
|
||||
"issued outside": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":{"path":"${dir:state}/token","issued-by":"outside"}}`, 1),
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "design 57 V4") {
|
||||
t.Errorf("a moved module with %s: %v", why, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A credential of what a handed module requires that the vault keeps for it alone (issue 757): the forge's read
|
||||
// token for one agent, issued by the forge's software per holder. Where the module is handed, its file is declared
|
||||
// placed and without a value, and the plan names it with its provision and provider; anywhere else, and for a
|
||||
// credential the mesh makes, the composition is what it was.
|
||||
func forgeReader(t *testing.T, moved bool, issued bool) Resolution {
|
||||
t.Helper()
|
||||
raw := strings.Replace(handedBot, `"own-secrets":`,
|
||||
`"requires":[{"name":"forge-read","optional":true}],"secrets":{"forge-read":"${dir:state}/forge.secret"},"own-secrets":`, 1)
|
||||
if moved {
|
||||
raw = strings.Replace(raw, `"version":"1",`, `"version":"1","moved":true,`, 1)
|
||||
}
|
||||
r := notifierOn(t, held(t, raw, builtBot(botText)))
|
||||
r.Needs = append(r.Needs, Needed{Name: "forge-read", From: "novox", For: "bot", Module: "forge",
|
||||
Sealed: "sealed-forge-read", IssuedToHolder: issued})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAHandedModulesCredentialIssuedForItIsNamedInItsPlanAndNoValueIsSealed(t *testing.T) {
|
||||
c, got := composeWith(t, forgeReader(t, true, true), handingTo("store:5000"))
|
||||
if strings.Contains(got, "sealed") || strings.Contains(got, HandedProvisionField) {
|
||||
t.Fatalf("a handed module's declaration carries a sealed value or the marker: %s", got)
|
||||
}
|
||||
var file map[string]any
|
||||
for _, res := range c.Resources {
|
||||
if res["id"] == "bot."+SecretID("forge-read") {
|
||||
file = res
|
||||
}
|
||||
}
|
||||
if file == nil {
|
||||
t.Fatalf("the credential's file is not declared: %s", got)
|
||||
}
|
||||
path, _ := file["path"].(string)
|
||||
if path == "" || strings.Contains(path, "${") || file["mode"] != "0600" || file["owner-at-runtime"] != "owned" {
|
||||
t.Fatalf("the credential's file is declared %v: placed, 0600, owned at runtime, no value", file)
|
||||
}
|
||||
if !strings.Contains(strings.Join(c.Filled, " "), path) {
|
||||
t.Fatalf("the credential's file is not among the filled files %v", c.Filled)
|
||||
}
|
||||
want := []PlanProvision{{Name: "forge-read", Provision: "forge-read", Provider: "novox", ProviderModule: "forge",
|
||||
Path: path, From: "novox", As: ConsumerIdentity("workstation", "bot")}}
|
||||
if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACredentialIssuedForAHolderIsSealedAsBeforeWhereItsModuleIsNotHanded(t *testing.T) {
|
||||
_, before := composeWith(t, forgeReader(t, false, false), sealedForBot(handingTo("store:5000")))
|
||||
if !strings.Contains(before, "sealed-forge-read") {
|
||||
t.Fatalf("the unmoved module's credential is not sealed the old way: %s", before)
|
||||
}
|
||||
for why, r := range map[string]Resolution{
|
||||
"a module not moved": forgeReader(t, false, true),
|
||||
"a moved module whose credential is made": forgeReader(t, true, false),
|
||||
} {
|
||||
c, got := composeWith(t, r, sealedForBot(handingTo("store:5000")))
|
||||
if !strings.Contains(got, "sealed-forge-read") {
|
||||
t.Errorf("%s: the credential is not sealed the old way: %s", why, got)
|
||||
}
|
||||
// Its provider is named (issue 760), and no credential for the client to fill.
|
||||
if p, ok := c.Plans["bot"]; ok && slices.ContainsFunc(p.Provisions, func(pr PlanProvision) bool {
|
||||
return pr.Name != "" || pr.Path != ""
|
||||
}) {
|
||||
t.Errorf("%s: the plan names a credential to fill: %+v", why, p.Provisions)
|
||||
}
|
||||
}
|
||||
c, got := composeWith(t, forgeReader(t, true, true), sealedForBot(Rendering{Provisioner: "mesh-provisioner-client",
|
||||
ArtifactStore: "store:5000"}))
|
||||
if !strings.Contains(got, "sealed-forge-read") || c.Plans != nil {
|
||||
t.Errorf("where the engine does not take a filled file alone, the credential is not sealed the old way: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A handed consumer's own login from a provider that gives each consumer one (issue 779): the same entry as for an
|
||||
// issued credential, with the provider's form on it, and nothing sealed; where the consumer is not handed, the
|
||||
// login is sealed as it was.
|
||||
func TestAHandedConsumersOwnLoginFromAPerLoginProviderIsNamedInItsPlanAndNoValueIsSealed(t *testing.T) {
|
||||
pair := func(handed bool) Resolution {
|
||||
r := forgeReader(t, true, handed)
|
||||
r.Needs[len(r.Needs)-1].CredentialForm = CredentialFormPair
|
||||
return r
|
||||
}
|
||||
c, got := composeWith(t, pair(true), handingTo("store:5000"))
|
||||
if strings.Contains(got, "sealed") || strings.Contains(got, HandedProvisionField) {
|
||||
t.Fatalf("a handed consumer's declaration carries its login sealed or the marker: %s", got)
|
||||
}
|
||||
var path string
|
||||
for _, res := range c.Resources {
|
||||
if res["id"] == "bot."+SecretID("forge-read") {
|
||||
path, _ = res["path"].(string)
|
||||
if res["mode"] != "0600" || res["owner-at-runtime"] != "owned" || res["content"] != nil || res["sealed"] != nil {
|
||||
t.Fatalf("the login's file is declared %v: placed, 0600, owned at runtime, no value", res)
|
||||
}
|
||||
}
|
||||
}
|
||||
if path == "" || !strings.Contains(strings.Join(c.Filled, " "), path) {
|
||||
t.Fatalf("the login's file %q is not declared and filled: %v", path, c.Filled)
|
||||
}
|
||||
want := []PlanProvision{{Name: "forge-read", Provision: "forge-read", Provider: "novox", ProviderModule: "forge",
|
||||
CredentialForm: CredentialFormPair, Path: path, From: "novox", As: ConsumerIdentity("workstation", "bot")}}
|
||||
if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want)
|
||||
}
|
||||
// Not marked as the vault's, the login is sealed as before.
|
||||
_, before := composeWith(t, pair(false), sealedForBot(handingTo("store:5000")))
|
||||
if !strings.Contains(before, "sealed-forge-read") {
|
||||
t.Fatalf("a consumer whose login the vault does not keep for it has it sealed no more: %s", before)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module marked moved hands its templated files to the provisioner (novox/hq ADR 0349 §4, §6, §17, design 57 P6,
|
||||
// issue 672): on a machine whose node-engine takes a filled file on the client's word and that runs a client, each
|
||||
// is declared placed and without its text, named among the filled files, and planned for the client; anywhere
|
||||
// else, and for every module not marked, the composition is byte for byte what it was.
|
||||
|
||||
const (
|
||||
handedNotifier = `{"module":"notify","version":"1",
|
||||
"config":{"width":{"type":"number","description":"How wide a notification is, in pixels.","default":250}},
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[
|
||||
{"id":"configuration-dir","type":"directory","path":"${machine:account-home}/.config/notify","owner":"${machine:account}","mode":"0755"},
|
||||
{"id":"configuration","type":"file","path":"${machine:account-home}/.config/notify/rc","owner":"${machine:account}","mode":"0644","template":"files/rc"},
|
||||
{"id":"legacy","type":"file","path":"/etc/notify.conf","content":"plain = yes\n"},
|
||||
{"id":"notifier","type":"service","unit":"notify.service","scope":"user","user":"${machine:account}","reload-on":["configuration"]}]}`
|
||||
notifierText = "width = ${config:width}\nhome = ${machine:account-home}\n"
|
||||
)
|
||||
|
||||
func notifierMoved() string {
|
||||
return strings.Replace(handedNotifier, `"version":"1",`, `"version":"1","moved":true,`, 1)
|
||||
}
|
||||
|
||||
func builtNotifier() []Built {
|
||||
return []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + "notify/files/blobs/sha256:" + strings.Repeat("d", 64),
|
||||
Digest: "sha256:" + strings.Repeat("d", 64), Texts: map[string]string{"rc": notifierText}}}
|
||||
}
|
||||
|
||||
func notifierOn(t *testing.T, m Manifest) Resolution {
|
||||
t.Helper()
|
||||
r, err := Resolve(shelf(m), []string{m.Module}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
func composeWith(t *testing.T, r Resolution, with Rendering) (Composed, string) {
|
||||
t.Helper()
|
||||
c, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(c.Resources)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c, string(raw)
|
||||
}
|
||||
|
||||
func handingTo(store string) Rendering {
|
||||
return Rendering{Hands: true, Provisioner: "mesh-provisioner-client", ArtifactStore: store}
|
||||
}
|
||||
|
||||
func TestAMovedModulesBuildKeepsWhatTheProvisionerNeeds(t *testing.T) {
|
||||
m := held(t, notifierMoved(), builtNotifier())
|
||||
want := &Handed{Source: ArtifactStoreScheme + "notify/files/blobs/sha256:" + strings.Repeat("d", 64),
|
||||
Digest: "sha256:" + strings.Repeat("d", 64), Templates: map[string]string{"configuration": "rc"}}
|
||||
if !reflect.DeepEqual(m.Handed, want) {
|
||||
t.Fatalf("the build kept %+v, want %+v", m.Handed, want)
|
||||
}
|
||||
if notMoved := held(t, handedNotifier, builtNotifier()); notMoved.Handed != nil {
|
||||
t.Fatalf("a module not marked moved kept %+v", notMoved.Handed)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing changes for a module that has not moved, nor for a moved one where it cannot be handed.
|
||||
func TestAMovedModuleComposesAsBeforeWhereItIsNotHanded(t *testing.T) {
|
||||
_, before := composeWith(t, notifierOn(t, held(t, handedNotifier, builtNotifier())), handingTo("store:5000"))
|
||||
for name, with := range map[string]Rendering{
|
||||
"an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"},
|
||||
"no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"},
|
||||
} {
|
||||
c, got := composeWith(t, notifierOn(t, held(t, notifierMoved(), builtNotifier())), with)
|
||||
if got != before {
|
||||
t.Errorf("%s: the moved module composes differently from its unmarked twin:\nbefore: %s\nnow: %s",
|
||||
name, before, got)
|
||||
}
|
||||
if len(c.Filled) > 0 || c.Plans != nil {
|
||||
t.Errorf("%s: something was handed: %v %v", name, c.Filled, c.Plans)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(before, `width = 250`) {
|
||||
t.Fatalf("the unmarked module is not filled the old way: %s", before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMovedModulesTemplatedFileIsHandedToTheProvisioner(t *testing.T) {
|
||||
c, got := composeWith(t, notifierOn(t, held(t, notifierMoved(), builtNotifier())), handingTo("store:5000"))
|
||||
var file, legacy, dir map[string]any
|
||||
for _, r := range c.Resources {
|
||||
switch r["id"] {
|
||||
case "notify.configuration":
|
||||
file = r
|
||||
case "notify.legacy":
|
||||
legacy = r
|
||||
case "notify.configuration-dir":
|
||||
dir = r
|
||||
}
|
||||
}
|
||||
if file == nil || legacy == nil || dir == nil {
|
||||
t.Fatalf("not composed: %s", got)
|
||||
}
|
||||
wantFile := map[string]any{"id": "notify.configuration", "type": "file", "path": "/home/op/.config/notify/rc",
|
||||
"owner": "op", "mode": "0644", "owner-at-runtime": "owned"}
|
||||
if !reflect.DeepEqual(file, wantFile) {
|
||||
t.Fatalf("the handed file is declared %v, want %v (placed, no text, owned at runtime)", file, wantFile)
|
||||
}
|
||||
if legacy["content"] != "plain = yes\n" || legacy["owner-at-runtime"] != nil {
|
||||
t.Fatalf("a file with its text inline is not composed the old way: %v", legacy)
|
||||
}
|
||||
if dir["path"] != "/home/op/.config/notify" {
|
||||
t.Fatalf("the module's other resources are not placed as before: %v", dir)
|
||||
}
|
||||
if strings.Contains(got, "width = ") {
|
||||
t.Fatalf("the controller filled the handed file: %s", got)
|
||||
}
|
||||
if !reflect.DeepEqual(c.Filled, []string{"/home/op/.config/notify/rc"}) {
|
||||
t.Fatalf("filled files %v", c.Filled)
|
||||
}
|
||||
want := map[string]Plan{"notify": {
|
||||
Templates: PlanTemplates{Source: "http://store:5000/v2/notify/files/blobs/sha256:" + strings.Repeat("d", 64),
|
||||
Digest: "sha256:" + strings.Repeat("d", 64)},
|
||||
Files: []PlanFile{{ID: "configuration", Path: "/home/op/.config/notify/rc", Template: "rc",
|
||||
OwnerAtRuntime: "owned", Takes: "reload-on"}},
|
||||
Placement: map[string]string{"machine:account-home": "/home/op"},
|
||||
}}
|
||||
if !reflect.DeepEqual(c.Plans, want) {
|
||||
t.Fatalf("the plans are %+v, want %+v", c.Plans, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMovedModulesMarkIsJudged(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"marked moved and naming no template": `{"module":"bare","version":"1","moved":true,
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`,
|
||||
"handed written in a repository": `{"module":"bare","version":"1","moved":true,
|
||||
"handed":{"source":"s","digest":"d","templates":{}},
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bare","template":"files/bare"}]}`,
|
||||
"handed and not moved": `{"module":"bare","version":"1",
|
||||
"handed":{"source":"s","digest":"d","templates":{}},
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`,
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What `module check` and the merge gate judge: a moved module read from its tree, its text inline. It is still
|
||||
// marked moved, and composes as before, since nothing of its build's archive is there to hand.
|
||||
func TestAMovedModuleReadFromItsTreeIsJudgedAsItComposesUnhanded(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "files", "rc"), []byte(notifierText), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := ParseManifest([]byte(notifierMoved()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filled, err := WithTemplatesFrom(m, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("a moved module read from its tree is refused: %v", err)
|
||||
}
|
||||
if !filled.Moved || filled.Handed != nil {
|
||||
t.Fatalf("read from its tree: moved %v, handed %+v", filled.Moved, filled.Handed)
|
||||
}
|
||||
_, got := composeWith(t, notifierOn(t, filled), handingTo("store:5000"))
|
||||
_, want := composeWith(t, notifierOn(t, held(t, handedNotifier, builtNotifier())), handingTo("store:5000"))
|
||||
if got != want {
|
||||
t.Fatalf("read from its tree it composes differently:\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
@@ -196,13 +196,31 @@ func healthProblems(m Manifest) []string {
|
||||
}
|
||||
var toolChecks []string
|
||||
otherKinds := 0
|
||||
for _, r := range m.Resources {
|
||||
resources := m.Resources
|
||||
// A module's own runtime is judged as the process the controller writes for it (novox/hq ADR 0366 §1),
|
||||
// and only a module that runs as its own account has one.
|
||||
own, hasOwn := OwnRuntimeHealth(m)
|
||||
if hasOwn {
|
||||
if m.RunsAs == "" {
|
||||
problems = append(problems, fmt.Sprintf("%s declares runtime-health and runs as no account of its own "+
|
||||
"(runs-as): only a module served by a runtime of its own has that runtime's health to declare "+
|
||||
"(novox/hq ADR 0366 §1)", m.Module))
|
||||
hasOwn = false
|
||||
} else {
|
||||
resources = append(append([]map[string]any(nil), m.Resources...), own)
|
||||
}
|
||||
}
|
||||
for i, r := range resources {
|
||||
h, has, read := ReadHealth(r)
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
ownRuntime := hasOwn && i == len(resources)-1
|
||||
id, typ := fmt.Sprint(r["id"]), fmt.Sprint(r["type"])
|
||||
where := m.Module + ": " + id
|
||||
if ownRuntime {
|
||||
where = m.Module + ": its runtime-health"
|
||||
}
|
||||
for _, p := range read {
|
||||
problems = append(problems, m.Module+": "+p)
|
||||
}
|
||||
@@ -279,7 +297,11 @@ func healthProblems(m Manifest) []string {
|
||||
problems = append(problems, fmt.Sprintf("%s's tool check asks %q, which %s does not serve (its tools: %s)",
|
||||
where, h.Tool, m.Module, orNoneWords(m.Tools)))
|
||||
}
|
||||
toolChecks = append(toolChecks, id)
|
||||
// Its own runtime being alive, judged by the node-engine, is the check the module does not run
|
||||
// itself that this tool check stands beside (novox/hq ADR 0366 §2).
|
||||
if !ownRuntime {
|
||||
toolChecks = append(toolChecks, id)
|
||||
}
|
||||
} else {
|
||||
if h.Tool != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s's %s check names a tool, which only a tool check asks", where, h.Kind))
|
||||
@@ -372,7 +394,11 @@ func Undeclared(m Manifest) []string {
|
||||
// is granted to ask of its own node tools (to-be 48 §3).
|
||||
func HealthChecks(m Manifest) []string {
|
||||
var out []string
|
||||
for _, r := range m.Resources {
|
||||
resources := m.Resources
|
||||
if own, has := OwnRuntimeHealth(m); has && m.RunsAs != "" {
|
||||
resources = append(append([]map[string]any(nil), resources...), own)
|
||||
}
|
||||
for _, r := range resources {
|
||||
h, has, _ := ReadHealth(r)
|
||||
if has && h.Kind == HealthTool && h.Tool != "" && LongRunning(r) {
|
||||
out = append(out, m.Module+"."+h.Tool)
|
||||
@@ -382,6 +408,15 @@ func HealthChecks(m Manifest) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// OwnRuntimeHealth is a module's `runtime-health` as the resource it is put on (novox/hq ADR 0366 §1): the
|
||||
// process `<module>.own-runtime`, carrying it as its `health`. False when the module declares none.
|
||||
func OwnRuntimeHealth(m Manifest) (map[string]any, bool) {
|
||||
if m.RuntimeHealth == nil {
|
||||
return nil, false
|
||||
}
|
||||
return map[string]any{"id": m.Module + "." + OwnRuntimeID(), "type": "process", HealthField: m.RuntimeHealth}, true
|
||||
}
|
||||
|
||||
// healthInto composes a resource's `health` into the node-engine's words, or takes it away (to-be 48 §2,
|
||||
// §3): for an engine that reads it, the endpoint becomes the port this machine published it on and the
|
||||
// defaults are written out; for one that does not — older and strict — the field is not sent, and the
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0377 §3: a move under a module's own `record` rolls out when it recreates none of its containers
|
||||
// and moves none of its data, judged from the two builds' manifests.
|
||||
func TestWhatAMoveUnderRecordHoldsFor(t *testing.T) {
|
||||
base := func() Manifest {
|
||||
raw := `{"module":"mailu","tools":["mailu_status"],
|
||||
"requires":["postgres-database"],
|
||||
"data":{"own":[{"id":"mail","path":"${dir:data-mail}","class":"valuable"}]},
|
||||
"bundles":[{"name":"tools","source":"store/mailu-tools@sha256:aaa","digest":"sha256:aaa"}],
|
||||
"resources":[
|
||||
{"id":"state","type":"directory"},
|
||||
{"id":"mesh-state","type":"directory"},
|
||||
{"id":"data-mail","type":"directory"},
|
||||
{"id":"config-env","type":"file","path":"${dir:state}/mailu.env","content":"A=1"},
|
||||
{"id":"runtime-config","type":"file","path":"${dir:mesh-state}/config.json","content":"{}"},
|
||||
{"id":"inbox-note","type":"file","path":"${dir:data-mail}/README","content":"x"},
|
||||
{"id":"imap","type":"container","image":"registry/imap@sha256:1","volumes":["${dir:data-mail}:/mail"],
|
||||
"env-file":["${dir:state}/mailu.env"]},
|
||||
{"id":"watcher","type":"process","command":"watch"},
|
||||
{"id":"installation","type":"archive","path":"/opt/mailu-tools","artifact":"archive"},
|
||||
{"id":"guard","type":"package","package":"nftables"}]}`
|
||||
var m Manifest
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
resource := func(m *Manifest, id string) map[string]any {
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %s", id)
|
||||
return nil
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(m *Manifest)
|
||||
holds string // empty: it rolls out
|
||||
}{
|
||||
{"nothing", func(*Manifest) {}, ""},
|
||||
{"a tools bundle", func(m *Manifest) {
|
||||
m.Bundles[0].Digest, m.Bundles[0].Source = "sha256:bbb", "store/mailu-tools@sha256:bbb"
|
||||
}, ""},
|
||||
{"the tool list", func(m *Manifest) { m.Tools = append(m.Tools, "mailu_queue") }, ""},
|
||||
{"a file no container mounts", func(m *Manifest) { resource(m, "runtime-config")["content"] = `{"a":1}` }, ""},
|
||||
{"a container's image", func(m *Manifest) { resource(m, "imap")["image"] = "registry/imap@sha256:2" }, "recreates 1 of mailu's 1"},
|
||||
{"a container's declaration", func(m *Manifest) { resource(m, "imap")["health"] = map[string]any{"kind": "runtime"} }, "recreates 1"},
|
||||
{"a container added", func(m *Manifest) {
|
||||
m.Resources = append(m.Resources, map[string]any{"id": "smtp", "type": "container", "image": "x"})
|
||||
}, "recreates"},
|
||||
{"a mounted file", func(m *Manifest) { resource(m, "config-env")["content"] = "A=2" }, "which its container imap mounts"},
|
||||
{"a file within declared data", func(m *Manifest) { resource(m, "inbox-note")["content"] = "y" }, "which its container imap mounts"},
|
||||
{"a directory holding declared data", func(m *Manifest) { resource(m, "data-mail")["mode"] = "0700" }, "data-mail"},
|
||||
{"the data declaration", func(m *Manifest) { m.Data.Own[0].Class = ClassIrreplaceable }, "data declaration"},
|
||||
{"a provider", func(m *Manifest) { m.Requires = append(m.Requires, "s3") }, "what it requires"},
|
||||
{"what it provides", func(m *Manifest) { m.Provides = append(m.Provides, Offer{Name: "smtp"}) }, "what it provides"},
|
||||
{"a process of a module with data", func(m *Manifest) { resource(m, "watcher")["command"] = "watch2" }, "long-running process watcher"},
|
||||
{"an archive resource", func(m *Manifest) { resource(m, "installation")["artifact"] = "archive2" }, "the archive installation"},
|
||||
{"a package resource", func(m *Manifest) { resource(m, "guard")["package"] = "nftables2" }, "the package guard"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
from, to := base(), base()
|
||||
c.change(&to)
|
||||
got := Holds(from, to)
|
||||
switch {
|
||||
case c.holds == "" && got != "":
|
||||
t.Fatalf("held: %s", got)
|
||||
case c.holds != "" && !strings.Contains(got, c.holds):
|
||||
t.Fatalf("holds %q; want it to say %q", got, c.holds)
|
||||
}
|
||||
})
|
||||
}
|
||||
// A data directory nothing mounts still holds its data.
|
||||
from, to := base(), base()
|
||||
from.Data.Own = append(from.Data.Own, DataItem{ID: "archive", Path: "${dir:mesh-state}/archive", Class: "valuable"})
|
||||
to.Data = &Data{Own: append([]DataItem(nil), from.Data.Own...)}
|
||||
resource(&to, "runtime-config")["path"] = "${dir:mesh-state}/archive/config.json"
|
||||
resource(&from, "runtime-config")["path"] = "${dir:mesh-state}/archive/config.json"
|
||||
resource(&to, "runtime-config")["content"] = "new"
|
||||
if got := Holds(from, to); !strings.Contains(got, "lies within its data archive") {
|
||||
t.Fatalf("a file within data nothing mounts: %q", got)
|
||||
}
|
||||
// A process of a module with no data of its own may change.
|
||||
from, to = base(), base()
|
||||
from.Data, to.Data = nil, nil
|
||||
resource(&to, "watcher")["command"] = "watch2"
|
||||
if got := Holds(from, to); got != "" {
|
||||
t.Fatalf("a process of a module with no data held: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Scopes a claim can have.
|
||||
@@ -175,8 +176,16 @@ type Offer struct {
|
||||
// bound to its data, and moves only by a person (novox/hq ADR 0232). `false` for a provision any
|
||||
// provider answers alike, the mesh's resolver; unsaid, see KeepsConsumerData.
|
||||
KeepsConsumerData *bool `json:"keeps-consumer-data,omitempty"`
|
||||
// AnsweredBy says who answers this provision's consumers: AnsweredByHelper when the module runs the SDK's provider
|
||||
// helper for it, so a handed consumer's template may name what the provider tells it (`${bound:<provision>:<key>}`)
|
||||
// from the helper's entry on the bus (issue 792). It is separate from `moved`: it hands none of the module's own
|
||||
// files. Unsaid, the controller answers the consumer.
|
||||
AnsweredBy string `json:"answered-by,omitempty"`
|
||||
}
|
||||
|
||||
// AnsweredByHelper is the only answerer an offer may name besides the controller.
|
||||
const AnsweredByHelper = "helper"
|
||||
|
||||
// OfferIdentity is what an offer says about the names its backend keeps for its consumers.
|
||||
type OfferIdentity struct {
|
||||
// None is set by `"identity": false`: the provision keeps no name derived from its consumer.
|
||||
@@ -333,6 +342,47 @@ func (m Manifest) SharedCredentialOf(provision string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// The credential forms an offer declares (issue 768), as a consumer's plan names them for the vault, which makes
|
||||
// a first value in that form for a provider it holds no row of.
|
||||
const (
|
||||
// CredentialFormShared is one of the provider's own secrets, shared by every consumer (novox/hq ADR 0158).
|
||||
CredentialFormShared = "shared"
|
||||
// CredentialFormPair is a login of its own for each consumer (novox/hq ADR 0048, ADR 0158 §11): the offer says
|
||||
// no credential.
|
||||
CredentialFormPair = "pair"
|
||||
// CredentialFormIssued is issued by the provider's software (novox/hq ADR 0305); the mesh never makes it.
|
||||
CredentialFormIssued = "issued"
|
||||
// CredentialFormGiven is given by a person (novox/hq ADR 0337): the offer's credential, or the own secret it
|
||||
// shares, is issued outside the mesh; the mesh never makes it.
|
||||
CredentialFormGiven = "given"
|
||||
)
|
||||
|
||||
// CredentialFormOf is the credential form this module's offer of a provision declares, read from the offer's
|
||||
// `credential` alone (issue 768): no new manifest field, since that field already says whether the provider shares
|
||||
// one of its own secrets, its software issues it, or a person gives it, and saying none is a login per consumer.
|
||||
// "" when the module does not offer the provision.
|
||||
func (m Manifest) CredentialFormOf(provision string) string {
|
||||
for _, o := range m.Provides {
|
||||
if o.Name != provision {
|
||||
continue
|
||||
}
|
||||
switch c := o.Credential; {
|
||||
case c == nil:
|
||||
return CredentialFormPair
|
||||
case c.IssuedBy == IssuedOutside:
|
||||
return CredentialFormGiven
|
||||
case c.IssuedBy == IssuedBySoftware:
|
||||
return CredentialFormIssued
|
||||
case c.Own != "" && m.OwnSecrets[c.Own].IssuedBy == IssuedOutside:
|
||||
return CredentialFormGiven
|
||||
case c.Own != "":
|
||||
return CredentialFormShared
|
||||
}
|
||||
return CredentialFormPair
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ProvisionsSharing is every provision of this module whose credential is the named own secret.
|
||||
func (m Manifest) ProvisionsSharing(own string) []string {
|
||||
var out []string
|
||||
@@ -367,13 +417,19 @@ func (o *Offer) UnmarshalJSON(raw []byte) error {
|
||||
Reach string `json:"reach,omitempty"`
|
||||
Identity *OfferIdentity `json:"identity,omitempty"`
|
||||
Keeps *bool `json:"keeps-consumer-data,omitempty"`
|
||||
AnsweredBy string `json:"answered-by,omitempty"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&full); err != nil {
|
||||
return fmt.Errorf("a provided name is either a string or {name, scope, credential, reach, identity, "+
|
||||
"keeps-consumer-data}: %w", typedUnknown(err))
|
||||
"keeps-consumer-data, answered-by}: %w", typedUnknown(err))
|
||||
}
|
||||
if full.AnsweredBy != "" && full.AnsweredBy != AnsweredByHelper {
|
||||
return fmt.Errorf("%s says answered-by %q: the only answerer an offer names is %q", full.Name, full.AnsweredBy,
|
||||
AnsweredByHelper)
|
||||
}
|
||||
o.AnsweredBy = full.AnsweredBy
|
||||
o.Name, o.Scope, o.Credential, o.Reach, o.Identity = full.Name, full.Scope, full.Credential, full.Reach, full.Identity
|
||||
o.KeepsConsumerData = full.Keeps
|
||||
return nil
|
||||
@@ -382,7 +438,8 @@ func (o *Offer) UnmarshalJSON(raw []byte) error {
|
||||
// MarshalJSON writes back the short form when there is nothing else to say, so a manifest that
|
||||
// went through the mesh comes out looking like the one that went in.
|
||||
func (o Offer) MarshalJSON() ([]byte, error) {
|
||||
if o.Scope == "" && o.Credential == nil && o.Reach == "" && o.Identity == nil && o.KeepsConsumerData == nil {
|
||||
if o.Scope == "" && o.Credential == nil && o.Reach == "" && o.Identity == nil && o.KeepsConsumerData == nil &&
|
||||
o.AnsweredBy == "" {
|
||||
return json.Marshal(o.Name)
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
@@ -392,7 +449,19 @@ func (o Offer) MarshalJSON() ([]byte, error) {
|
||||
Reach string `json:"reach,omitempty"`
|
||||
Identity *OfferIdentity `json:"identity,omitempty"`
|
||||
Keeps *bool `json:"keeps-consumer-data,omitempty"`
|
||||
}{o.Name, o.Scope, o.Credential, o.Reach, o.Identity, o.KeepsConsumerData})
|
||||
AnsweredBy string `json:"answered-by,omitempty"`
|
||||
}{o.Name, o.Scope, o.Credential, o.Reach, o.Identity, o.KeepsConsumerData, o.AnsweredBy})
|
||||
}
|
||||
|
||||
// AnsweredByHelperFor is whether this module's offer of a provision says its consumers are answered by the provider
|
||||
// helper (issue 792).
|
||||
func (m Manifest) AnsweredByHelperFor(provision string) bool {
|
||||
for _, o := range m.Provides {
|
||||
if o.Name == provision && o.AnsweredBy == AnsweredByHelper {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Manifest is everything a module says about itself.
|
||||
@@ -513,6 +582,13 @@ type Manifest struct {
|
||||
// person's account (design 25 §7) already had the same shape.
|
||||
Invokes []string `json:"invokes,omitempty"`
|
||||
|
||||
// Waits are the event families a session may wait on through the mesh MCP server's `mesh_wait`, each
|
||||
// `<emitter>.<event>` as `consumes` names one, a seat's or a module's (novox/hq ADR 0367 §2). Only the tool
|
||||
// runner's module declares them, one by one and never by a wildcard. **A grant apart from `consumes`**: the
|
||||
// controller grants the tool runner's account a plain subscription on those subjects alone, with no
|
||||
// durable consumer, and issues the same list in its membership; no call or setting adds one (§6).
|
||||
Waits []string `json:"waits,omitempty"`
|
||||
|
||||
// State is the current state this module keeps on the bus, by local name: each a key-value
|
||||
// bucket the controller creates, which every instance of the module writes and reads
|
||||
// (novox/hq ADR 0201). Not history — that is an event — and never a secret, sealed or not.
|
||||
@@ -523,6 +599,12 @@ type Manifest struct {
|
||||
// (novox/hq ADR 0297). A log is its owner's alone: no other module reads it.
|
||||
Logs []LogDeclaration `json:"logs,omitempty"`
|
||||
|
||||
// Streams are the streams this module owns on the bus (novox/hq ADR 0363): each named locally, the
|
||||
// events it carries named as `emits` names them but with `*` and `**`, and bounded in age and size; the
|
||||
// controller creates each. One may be restricted by a trusted setting of this module, so a consumer
|
||||
// hears a part of it only where that setting's value names it.
|
||||
Streams []StreamDeclaration `json:"streams,omitempty"`
|
||||
|
||||
// Settings are the defaults this module gives its settings (novox/hq ADR 0262): each key a file,
|
||||
// a contribution or a served fact asks for as `${setting:<key>}`, its default, and why that
|
||||
// default. Only a preference has one — a font size, a width, a number of workers — and a value
|
||||
@@ -561,6 +643,10 @@ type Manifest struct {
|
||||
// why. A person's choice through the `upgrade` verb stands over it; the bus records whatever it says.
|
||||
Upgrade *UpgradePolicy `json:"upgrade,omitempty"`
|
||||
|
||||
// Unhealthy is how loud this module's unhealthy condition is (novox/hq ADR 0355): a warning when unsaid;
|
||||
// `urgent`, with why, for a module whose failure costs people something they rely on.
|
||||
Unhealthy *UnhealthyPolicy `json:"unhealthy,omitempty"`
|
||||
|
||||
// Reads are other modules' state this module reads and watches, each `<module>.<name>`
|
||||
// (novox/hq ADR 0201). Read-only: only the owner's instances write.
|
||||
Reads []string `json:"reads,omitempty"`
|
||||
@@ -643,6 +729,19 @@ type Manifest struct {
|
||||
// wrong the moment anybody edits anything.
|
||||
Build *Build `json:"build,omitempty"`
|
||||
|
||||
// Templates is the module's directory its template files are kept in, and its build packs into its templates
|
||||
// archive: files/ when it says none (DefaultTemplatesDir; novox/hq ADR 0349 §2, issue 731). A module whose files/
|
||||
// is already an archive unpacked onto the machine names another, so its templates neither land beside what it
|
||||
// unpacks nor change that archive's digest. A word of the repository's manifest only, like Build: the manifest
|
||||
// the mesh holds carries each file's text and names no templates directory.
|
||||
Templates string `json:"templates,omitempty"`
|
||||
|
||||
// Moved marks a module handed to the provisioner (novox/hq ADR 0349 §17, design 57 P6): each of its files that
|
||||
// names a template is filled on the node by the provisioner's client, and the controller fills nothing in it.
|
||||
// Handed is what its build kept for that (handed.go): written by Resolve, never in a repository.
|
||||
Moved bool `json:"moved,omitempty"`
|
||||
Handed *Handed `json:"handed,omitempty"`
|
||||
|
||||
// Binds is where this module wants to be told about something it requires, per requirement.
|
||||
//
|
||||
// Because "this machine needs a database from the anchor" is useless to the program that
|
||||
@@ -721,6 +820,11 @@ type Manifest struct {
|
||||
// that says a warrant, or speaks for a channel kind that proves its sender.
|
||||
RunsAs string `json:"runs-as,omitempty"`
|
||||
|
||||
// RuntimeHealth is how the runtime of its own a module of its own account is served by is ready
|
||||
// (novox/hq ADR 0366): one `health` in the form a resource's takes (ADR 0240), put by the controller on
|
||||
// the process it writes for that runtime (`<module>.own-runtime`). Refused without runs-as.
|
||||
RuntimeHealth any `json:"runtime-health,omitempty"`
|
||||
|
||||
// Keeps is where this module wants every operator-sealed secret in the mesh written — the
|
||||
// vault's field, and so far nobody else's (novox/hq ADR 0085, amended).
|
||||
//
|
||||
@@ -774,6 +878,12 @@ type Manifest struct {
|
||||
// holder places them; the module depends on the seat (ADR 0210 §3).
|
||||
Contributions []SeatContribution `json:"contributions,omitempty"`
|
||||
|
||||
// Collections are the collections this module collects (novox/hq ADR 0349 §13, design 57 L1): each placed in a
|
||||
// file of its own as ${collected:<name>}, filled on the node by the provisioner's client from every item of it
|
||||
// there. Items are what it contributes to others' collections, each a template of its own (collections.go).
|
||||
Collections []Collection `json:"collections,omitempty"`
|
||||
Items []Item `json:"items,omitempty"`
|
||||
|
||||
// Guards are ports of this module's the mesh refuses on an adopted node except from the
|
||||
// private network and from the machine itself (novox/hq ADR 0100) — the store's port and the
|
||||
// broker's management port. The ports the software uses; the mesh guards where the machine
|
||||
@@ -1675,16 +1785,22 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
// **The bus's own account copies the bus and says nothing on it** (novox/hq ADR 0235). The module
|
||||
// holding mesh-broker is granted the snapshot API and nothing else, so anything it declared to say
|
||||
// or hear on the bus would be granted nothing — refused here rather than silently dropped.
|
||||
//
|
||||
// **But its tools bundle may say what it declares** (novox/hq ADR 0357): the node's runtime carries
|
||||
// the bundle and is granted the events the module emits, under the module's name, as for any module it
|
||||
// carries — the bus's account is not who publishes them. So emits are refused only for a bus module
|
||||
// with no bundle to say them.
|
||||
if _, account := m.OwnSecrets["broker"]; account && m.ClaimsSeat(BrokerSeat) {
|
||||
var said []string
|
||||
if len(m.EmitsAll()) > 0 {
|
||||
if len(m.EmitsAll()) > 0 && !m.shipsABundle() {
|
||||
said = append(said, "emits")
|
||||
}
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
n int
|
||||
}{{"consumes", len(m.Consumes)}, {"uses", len(m.Uses)}, {"invokes", len(m.Invokes)},
|
||||
{"state", len(m.State)}, {"logs", len(m.Logs)}, {"reads", len(m.Reads)}} {
|
||||
{"state", len(m.State)}, {"logs", len(m.Logs)}, {"reads", len(m.Reads)},
|
||||
{"streams", len(m.Streams)}} {
|
||||
if f.n > 0 {
|
||||
said = append(said, f.name)
|
||||
}
|
||||
@@ -1828,6 +1944,8 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
problems = append(problems, StateProblems(m)...)
|
||||
// And what it may call its logs, and how large it may ask them to grow (logs.go, novox/hq ADR 0297).
|
||||
problems = append(problems, LogProblems(m)...)
|
||||
// And the streams it owns, and who may hear them (streams.go, novox/hq ADR 0363).
|
||||
problems = append(problems, StreamProblems(m)...)
|
||||
// And the defaults it gives its settings (setting_defaults.go, novox/hq ADR 0262), or, written in the
|
||||
// config form, the whole declaration of each value (config.go, novox/hq ADR 0348).
|
||||
if m.DeclaresConfig() {
|
||||
@@ -1907,6 +2025,8 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
// A file's text in a template file of the module's repository (novox/hq ADR 0349 §2): only a file, never beside
|
||||
// content, only under files/, and packed by the module's build.
|
||||
problems = append(problems, templateProblems(m)...)
|
||||
problems = append(problems, movedProblems(m)...)
|
||||
problems = append(problems, collectionProblems(m)...)
|
||||
problems = append(problems, undeliveredBundles(m)...)
|
||||
// **What provides the artifact store cannot be delivered through it** (novox/hq 04-ISSUES/029).
|
||||
//
|
||||
@@ -2007,6 +2127,7 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
}
|
||||
}
|
||||
problems = append(problems, invokeProblems(m)...)
|
||||
problems = append(problems, waitProblems(m)...)
|
||||
problems = append(problems, replacesProblems(m)...)
|
||||
problems = append(problems, UnitSettingProblems(m)...)
|
||||
problems = append(problems, OwnerSettingProblems(m)...)
|
||||
@@ -2218,6 +2339,7 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
"may make it (novox/hq ADR 0228)",
|
||||
m.Module, name, own.IssuedBy, IssuedOutside))
|
||||
}
|
||||
problems = append(problems, ownSecretPromptProblems(m, name, own.Prompt, IsFamily(name))...)
|
||||
}
|
||||
localOf := map[string]string{}
|
||||
for _, to := range m.SecretRequirements() {
|
||||
@@ -2375,6 +2497,7 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
problems = append(problems, m.placementProblems()...)
|
||||
problems = append(problems, m.dataProblems()...)
|
||||
problems = append(problems, m.upgradeProblems()...)
|
||||
problems = append(problems, m.unhealthyProblems()...)
|
||||
|
||||
for i, r := range m.Resources {
|
||||
id, _ := r["id"].(string)
|
||||
@@ -2737,6 +2860,46 @@ func invokeProblems(m Manifest) []string {
|
||||
return problems
|
||||
}
|
||||
|
||||
// waitToken is one name of an event family's subject: never a wildcard, never a dot, never empty.
|
||||
var waitToken = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
// waitProblems judges the event families a module says a session may wait on (novox/hq ADR 0367 §2, §6):
|
||||
// the tool runner's alone, each named whole — emitter and event, with no wildcard — and each once. Refused
|
||||
// here, in the manifest's words, rather than at the composition of the bus's users.
|
||||
func waitProblems(m Manifest) []string {
|
||||
if len(m.Waits) == 0 {
|
||||
return nil
|
||||
}
|
||||
if m.Module != RuntimeModule {
|
||||
return []string{fmt.Sprintf("%s says what a session waits on; only %s, the tool runner's module, "+
|
||||
"declares waits (novox/hq ADR 0367 §2)", m.Module, RuntimeModule)}
|
||||
}
|
||||
var problems []string
|
||||
seen := map[string]bool{}
|
||||
for _, w := range m.Waits {
|
||||
if strings.ContainsAny(w, "*>") {
|
||||
problems = append(problems, fmt.Sprintf("%s waits on %q: a family is named one by one, never a "+
|
||||
"wildcard (novox/hq ADR 0367 §2)", m.Module, w))
|
||||
continue
|
||||
}
|
||||
tokens := strings.Split(w, ".")
|
||||
whole := len(tokens) >= 2 && name.MatchString(tokens[0])
|
||||
for _, t := range tokens[1:] {
|
||||
whole = whole && waitToken.MatchString(t)
|
||||
}
|
||||
if !whole {
|
||||
problems = append(problems, fmt.Sprintf("%s waits on %q, which does not name an emitter and an "+
|
||||
"event: a family is <emitter>.<event>, as consumes names one", m.Module, w))
|
||||
continue
|
||||
}
|
||||
if seen[w] {
|
||||
problems = append(problems, fmt.Sprintf("%s waits on %q twice", m.Module, w))
|
||||
}
|
||||
seen[w] = true
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// How a module takes one of its own secrets (ADR 0114): read from the file when it starts, or
|
||||
// applied by its own code to a backend that takes it once.
|
||||
const (
|
||||
@@ -2754,6 +2917,11 @@ type OwnSecret struct {
|
||||
Path string
|
||||
Taken string
|
||||
IssuedBy string
|
||||
// Prompt is the one line the desk asks for this secret as, in place of its bare name (novox/hq
|
||||
// issue 789): at most PromptMax characters, one line, holding only the {member} placeholder — a
|
||||
// family's member suffix, filled where the secret is asked for. Optional; reviewed in the
|
||||
// module's pull request like the rest of the manifest.
|
||||
Prompt string
|
||||
}
|
||||
|
||||
// MeshMayMake says the mesh may put a value it makes in place of the one the secret holds: the
|
||||
@@ -2764,6 +2932,50 @@ func (s OwnSecret) MeshMayMake() bool {
|
||||
return s.Taken == TakenAtStart && s.IssuedBy != IssuedOutside
|
||||
}
|
||||
|
||||
// PromptMax is the longest an own secret's prompt may be (novox/hq issue 789): one line, short enough that the
|
||||
// desk's prompt reads at a glance.
|
||||
const PromptMax = 120
|
||||
|
||||
// promptPlaceholder is a `{word}` token in a prompt's line; the only one a prompt may hold is `{member}`, a
|
||||
// family's member suffix (novox/hq issue 789).
|
||||
var promptPlaceholder = regexp.MustCompile(`\{[^{}]*\}`)
|
||||
|
||||
// ownSecretPromptProblems is what is wrong with an own secret's declared prompt, in the module check's words:
|
||||
// empty is fine (no prompt is declared, and the desk asks by name alone as it does today); otherwise one line,
|
||||
// at most PromptMax characters — counting what a person reads, not its bytes — holding only the `{member}`
|
||||
// placeholder, and only where the secret is a family: a single secret's prompt has no member to fill, so
|
||||
// `{member}` there would stay in the prompt literally (novox/hq issue 789).
|
||||
func ownSecretPromptProblems(m Manifest, name, prompt string, family bool) []string {
|
||||
if prompt == "" {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
if strings.ContainsAny(prompt, "\n\r") {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s names its secret %q with a prompt that holds more than one line; a prompt is one line (novox/hq issue 789)",
|
||||
m.Module, name))
|
||||
}
|
||||
if n := utf8.RuneCountInString(prompt); n > PromptMax {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s names its secret %q with a prompt %d characters long; a prompt is at most %d characters",
|
||||
m.Module, name, n, PromptMax))
|
||||
}
|
||||
for _, ph := range promptPlaceholder.FindAllString(prompt, -1) {
|
||||
if ph != "{member}" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s names its secret %q with a prompt that holds %s; a prompt holds only the {member} placeholder, "+
|
||||
"filled with a family's member suffix (novox/hq issue 789)",
|
||||
m.Module, name, ph))
|
||||
} else if !family {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s names its secret %q with a prompt that holds {member}; {member} is replaced only for a "+
|
||||
"family's member (novox/hq issue 789)",
|
||||
m.Module, name))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// OwnSecrets is a module's own secrets by name. On the wire each is a path, or an object naming
|
||||
// the path and how it is taken; written back the way it was read, so a manifest the mesh holds
|
||||
// keeps its bytes.
|
||||
@@ -2785,13 +2997,14 @@ func (o *OwnSecrets) UnmarshalJSON(raw []byte) error {
|
||||
Path string `json:"path"`
|
||||
Taken string `json:"taken,omitempty"`
|
||||
IssuedBy string `json:"issued-by,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&long); err != nil {
|
||||
return fmt.Errorf("own-secrets.%s: a path, or {\"path\", \"taken\", \"issued-by\"}: %w", name, typedUnknown(err))
|
||||
return fmt.Errorf("own-secrets.%s: a path, or {\"path\", \"taken\", \"issued-by\", \"prompt\"}: %w", name, typedUnknown(err))
|
||||
}
|
||||
out[name] = OwnSecret{Path: long.Path, Taken: long.Taken, IssuedBy: long.IssuedBy}
|
||||
out[name] = OwnSecret{Path: long.Path, Taken: long.Taken, IssuedBy: long.IssuedBy, Prompt: long.Prompt}
|
||||
}
|
||||
*o = out
|
||||
return nil
|
||||
@@ -2800,7 +3013,7 @@ func (o *OwnSecrets) UnmarshalJSON(raw []byte) error {
|
||||
func (o OwnSecrets) MarshalJSON() ([]byte, error) {
|
||||
entries := make(map[string]any, len(o))
|
||||
for name, s := range o {
|
||||
if s.Taken == "" && s.IssuedBy == "" {
|
||||
if s.Taken == "" && s.IssuedBy == "" && s.Prompt == "" {
|
||||
entries[name] = s.Path
|
||||
continue
|
||||
}
|
||||
@@ -2811,6 +3024,9 @@ func (o OwnSecrets) MarshalJSON() ([]byte, error) {
|
||||
if s.IssuedBy != "" {
|
||||
long["issued-by"] = s.IssuedBy
|
||||
}
|
||||
if s.Prompt != "" {
|
||||
long["prompt"] = s.Prompt
|
||||
}
|
||||
entries[name] = long
|
||||
}
|
||||
return json.Marshal(entries)
|
||||
@@ -2849,7 +3065,8 @@ func (o OwnSecrets) Lookup(name string) (s OwnSecret, family string, ok bool) {
|
||||
continue
|
||||
}
|
||||
if family == "" || len(declared) > len(family) {
|
||||
s, family, ok = OwnSecret{Path: strings.Replace(f.Path, "*", rest, 1), Taken: f.Taken, IssuedBy: f.IssuedBy}, declared, true
|
||||
s, family, ok = OwnSecret{Path: strings.Replace(f.Path, "*", rest, 1), Taken: f.Taken, IssuedBy: f.IssuedBy,
|
||||
Prompt: f.Prompt}, declared, true
|
||||
}
|
||||
}
|
||||
return s, family, ok
|
||||
@@ -2959,3 +3176,16 @@ func whileStoppedProblems(m Manifest, r map[string]any, scheduled bool) []string
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// shipsABundle says the module builds a tools bundle, which the node's runtime carries and speaks for.
|
||||
func (m Manifest) shipsABundle() bool {
|
||||
if m.Build == nil {
|
||||
return false
|
||||
}
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Kind == ArtifactBundle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An own secret (or a family) may declare `prompt`, one line naming what it is for a desk's prompt in place of
|
||||
// its bare name (novox/hq issue 789): reviewed in the module's pull request, and the controller validates it in
|
||||
// the module check.
|
||||
func TestAnOwnSecretsPromptParsesAndIsWrittenBack(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{
|
||||
"mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside",
|
||||
"prompt":"the password of the mail account {member}"},
|
||||
"smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start",
|
||||
"prompt":"the key for the outbound relay"}}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("a manifest with a well-formed prompt was refused: %v", err)
|
||||
}
|
||||
if got := m.OwnSecrets["mail-credential-*"].Prompt; got != "the password of the mail account {member}" {
|
||||
t.Fatalf("the family's prompt: %q", got)
|
||||
}
|
||||
if got := m.OwnSecrets["smtp-relay-key"].Prompt; got != "the key for the outbound relay" {
|
||||
t.Fatalf("the plain secret's prompt: %q", got)
|
||||
}
|
||||
// Written back the way it was read, so a registered manifest keeps its bytes.
|
||||
out, err := json.Marshal(m.OwnSecrets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var again OwnSecrets
|
||||
if err := json.Unmarshal(out, &again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again["mail-credential-*"].Prompt != "the password of the mail account {member}" ||
|
||||
again["smtp-relay-key"].Prompt != "the key for the outbound relay" {
|
||||
t.Fatalf("the round trip lost the prompt: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// No prompt declared is fine: the desk asks by name alone, as it does today.
|
||||
func TestAnOwnSecretWithNoPromptIsFine(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"telegram","version":"1","own-secrets":{
|
||||
"telegram-token":{"path":"/var/lib/telegram/telegram-token","issued-by":"outside"}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.OwnSecrets["telegram-token"].Prompt != "" {
|
||||
t.Fatalf("an undeclared prompt: %q", m.OwnSecrets["telegram-token"].Prompt)
|
||||
}
|
||||
out, err := json.Marshal(m.OwnSecrets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(out), "prompt") {
|
||||
t.Fatalf("an empty prompt was written: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// PromptMax counts characters, not bytes: a prompt of 100 multi-byte characters is well within it even
|
||||
// though it holds well over 120 bytes (novox/hq issue 789).
|
||||
func TestAPromptOfMultiByteCharactersIsCountedByCharacter(t *testing.T) {
|
||||
prompt := strings.Repeat("é", 100)
|
||||
if n := len(prompt); n <= 120 {
|
||||
t.Fatalf("the test prompt is %d bytes, not over 120 as intended", n)
|
||||
}
|
||||
_, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{
|
||||
"smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start",
|
||||
"prompt":` + jsonString(prompt) + `}}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("a 100-character prompt was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// {member} is replaced only for a family's member: a single secret's prompt may not hold it (novox/hq issue 789).
|
||||
func TestAPromptHoldingMemberOnASingleSecretIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{
|
||||
"smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start",
|
||||
"prompt":"For {member}"}}}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "{member} is replaced only for a family's member") {
|
||||
t.Fatalf("a single secret's prompt holding {member}: %v; want a refusal saying so", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A prompt that is not one short line naming only {member} is refused by the module check (novox/hq issue 789).
|
||||
func TestAMalformedPromptIsRefused(t *testing.T) {
|
||||
for name, c := range map[string]struct{ prompt, says string }{
|
||||
"a newline": {"the password of\nthe mail account {member}", "one line"},
|
||||
"over 120 characters": {"the password of the mail account {member}, which this mesh needs to log in to " +
|
||||
"send and receive mail for the operator's account on this machine today", "120 characters"},
|
||||
"a placeholder other than {member}": {"the password of the mail account {name}", "{member}"},
|
||||
"two placeholders, one unknown": {"the {kind} secret of the mail account {member}", "{member}"},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{
|
||||
"mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside",
|
||||
"prompt":` + jsonString(c.prompt) + `}}}`))
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v; want a refusal saying %q", name, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jsonString quotes a Go string the way encoding/json would, for building a literal into a test's manifest JSON.
|
||||
func jsonString(s string) string {
|
||||
out, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -34,14 +34,28 @@ const PlacesSetting = "places"
|
||||
// AccessesSetting is the settings key that says where a module's accesses are on this node, by id.
|
||||
const AccessesSetting = "accesses"
|
||||
|
||||
// **Placement is not config** (novox/hq ADR 0348 clause 11, issue 651). `places`, `accesses` and
|
||||
// `networks` are facts of an assignment the controller decides, held beside the assignment and apart
|
||||
// from its settings: a Layer carries them in Placement, never in Values, and only Places, AccessPlaces
|
||||
// and KeptNetworks read them. A person still says them as keys of a layer through `settings`; LayerOf
|
||||
// splits such a layer, so what they type is routed to the placement and nothing they do changes.
|
||||
// **Placement is not config** (novox/hq ADR 0348 clause 11, issues 651 and 736). `places`, `accesses` and
|
||||
// `networks` — where an assignment's things are — and `endpoints`, `expose`, `reach` and `ports` — where it
|
||||
// is reached and from how far — are facts of an assignment the controller decides, held beside the
|
||||
// assignment and apart from its settings: a Layer carries them in Placement, never in Values, and only
|
||||
// their readers (Places, AccessPlaces, KeptNetworks, Endpoints, Exposure, Reaches, GivenPorts) read them.
|
||||
// A person still says them as keys of a layer through `settings`; LayerOf splits such a layer, so what
|
||||
// they type is routed to the placement and nothing they do changes.
|
||||
|
||||
// placementKeys are the keys of a layer that are placement, not config.
|
||||
var placementKeys = map[string]bool{PlacesSetting: true, AccessesSetting: true, NetworksSetting: true}
|
||||
var placementKeys = map[string]bool{PlacesSetting: true, AccessesSetting: true, NetworksSetting: true,
|
||||
EndpointsSetting: true, ExposeSetting: true, ReachSetting: true, PortsSetting: true}
|
||||
|
||||
// PlacementKeys are the keys of a layer that are placement, not config, sorted: what a settings row must
|
||||
// no longer hold once the move of placement has run (issue 736).
|
||||
func PlacementKeys() []string {
|
||||
keys := make([]string, 0, len(placementKeys))
|
||||
for k := range placementKeys {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// SplitPlacement divides a layer as a person says it into its config values and its placement. Each is
|
||||
// nil when it holds nothing; the values themselves are not copied.
|
||||
|
||||
@@ -219,3 +219,64 @@ func TestPlacementIsReadFromThePlacementOnly(t *testing.T) {
|
||||
t.Fatalf("today's mergeable file composes %q, was %q", split["content"], whole["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// **The four words of where an assignment is reached are placement too** (novox/hq ADR 0348 clause 11, issue 736).
|
||||
// `endpoints`, `expose`, `reach` and `ports` among a layer's config values say nothing to the filter, the ports or
|
||||
// the names; the same in Placement do, and LayerOf puts them there. A mergeable file of today's form still takes
|
||||
// every key of the layer as it is said, so it composes as it did while they were kept among the values.
|
||||
func TestTheFourWordsAreReadFromThePlacementOnly(t *testing.T) {
|
||||
m := Manifest{Module: "forge", Version: "1",
|
||||
Listens: []Listening{{Name: "web", Port: 3000, Protocol: "tcp", From: FromMesh}, {Name: "ssh", Port: 22, Protocol: "tcp", From: FromMesh},
|
||||
{Name: "api", Port: 8086, Protocol: "tcp", From: FromMesh}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "image": "x", "ports": []any{"3000", "222:22", "8086"}}}}
|
||||
said := map[string]any{
|
||||
ExposeSetting: map[string]any{"22": FromEverywhere},
|
||||
PortsSetting: map[string]any{"22": float64(2222)},
|
||||
ReachSetting: map[string]any{"3000": "internal"},
|
||||
EndpointsSetting: map[string]any{"api": map[string]any{"label": "influx", "port": float64(18086), "reach": "public"}},
|
||||
"log": "warn",
|
||||
}
|
||||
among := []Layer{{From: "anchor", Values: said}}
|
||||
if e, err := Exposure(m, among); err != nil || e != nil {
|
||||
t.Fatalf("an expose among the config values exposed %v (%v)", e, err)
|
||||
}
|
||||
if g, err := GivenPorts(m, among); err != nil || g != nil {
|
||||
t.Fatalf("a ports among the config values gave %v (%v)", g, err)
|
||||
}
|
||||
if r, err := Reaches(m, among); err != nil || r != nil {
|
||||
t.Fatalf("a reach among the config values reached %v (%v)", r, err)
|
||||
}
|
||||
if b, err := Endpoints(m, among); err != nil || len(b) != 0 {
|
||||
t.Fatalf("an endpoints among the config values configured %v (%v)", b, err)
|
||||
}
|
||||
|
||||
beside := []Layer{LayerOf("anchor", said)}
|
||||
if len(beside[0].Values) != 1 || beside[0].Values["log"] != "warn" || len(beside[0].Placement) != 4 {
|
||||
t.Fatalf("LayerOf split %+v", beside[0])
|
||||
}
|
||||
if e, err := Exposure(m, beside); err != nil || e[22] != FromEverywhere {
|
||||
t.Fatalf("the placement did not expose: %v (%v)", e, err)
|
||||
}
|
||||
if g, err := GivenPorts(m, beside); err != nil || g[22] != 2222 || g[8086] != 18086 {
|
||||
t.Fatalf("the placement did not give the ports: %v (%v)", g, err)
|
||||
}
|
||||
if r, err := Reaches(m, beside); err != nil || r[3000] != "internal" || r[8086] != "public" {
|
||||
t.Fatalf("the placement did not reach: %v (%v)", r, err)
|
||||
}
|
||||
if b, err := Endpoints(m, beside); err != nil || b["api"].Label != "influx" {
|
||||
t.Fatalf("the placement did not configure the endpoint: %v (%v)", b, err)
|
||||
}
|
||||
|
||||
file := map[string]any{"id": "runtime", "type": "file", "path": "/x", "content": `{"a":1}`, "merge": "json"}
|
||||
whole, err := ApplySettings(file, among)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
split, err := ApplySettings(file, beside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if whole["content"] != split["content"] || !strings.Contains(split["content"].(string), `"endpoints"`) {
|
||||
t.Fatalf("today's mergeable file composes %q, was %q", split["content"], whole["content"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,17 +111,17 @@ func TestTemplatesFilledKeepTheListTheManifestWasJudgedAgainst(t *testing.T) {
|
||||
withPredefined(t, nil)
|
||||
list, _ := ParsePredefined([]byte(theFirstKeys))
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, TemplatesDir), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(dir, DefaultTemplatesDir), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, TemplatesDir, "rc"), []byte("font = ${config:font-size}\n"), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, DefaultTemplatesDir, "rc"), []byte("font = ${config:font-size}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := inConfigForm()
|
||||
declaration(m, "font-size")["predefined"] = "font-size"
|
||||
r := resourceOf(m, "configuration")
|
||||
delete(r, "content")
|
||||
r["template"] = TemplatesDir + "/rc"
|
||||
r["template"] = DefaultTemplatesDir + "/rc"
|
||||
m["build"] = map[string]any{"artifacts": []any{map[string]any{"name": "files", "kind": "archive", "from": "files"}}}
|
||||
declaration(m, "width")["runtime"] = true
|
||||
declaration(m, "sender")["runtime"] = true
|
||||
|
||||
@@ -18,7 +18,7 @@ func aRoutedWeb() Manifest {
|
||||
|
||||
func reachSet(reach string) SettingsBy {
|
||||
return SettingsBy{"web": {{From: "node anchor",
|
||||
Values: map[string]any{ReachSetting: map[string]any{"3000": reach}}}}}
|
||||
Placement: map[string]any{ReachSetting: map[string]any{"3000": reach}}}}}
|
||||
}
|
||||
|
||||
// namesFor renders the contribution a routed module makes and returns the two names it carries.
|
||||
@@ -145,7 +145,7 @@ func TestAPublicNameLeavesARoutedPortAsTheManifestSaid(t *testing.T) {
|
||||
// written rather than accepted and ignored.
|
||||
func TestAReachForAPortTheModuleDoesNotListenOnIsRefused(t *testing.T) {
|
||||
_, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor",
|
||||
Values: map[string]any{ReachSetting: map[string]any{"9999": ReachInternal}}}})
|
||||
Placement: map[string]any{ReachSetting: map[string]any{"9999": ReachInternal}}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "reaches nothing") {
|
||||
t.Fatalf("a reach naming an undeclared port was accepted: %v", err)
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func TestAReachForAPortTheModuleDoesNotListenOnIsRefused(t *testing.T) {
|
||||
func TestAValueThatIsNotAReachIsRefused(t *testing.T) {
|
||||
for _, wrong := range []string{"mesh", "anywhere", "private", "true"} {
|
||||
_, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor",
|
||||
Values: map[string]any{ReachSetting: map[string]any{"3000": wrong}}}})
|
||||
Placement: map[string]any{ReachSetting: map[string]any{"3000": wrong}}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "a reach is") {
|
||||
t.Fatalf("%q was accepted as a reach: %v", wrong, err)
|
||||
}
|
||||
@@ -168,7 +168,7 @@ func TestAValueThatIsNotAReachIsRefused(t *testing.T) {
|
||||
// and accepting both would have the filter follow one while the names followed the other — the
|
||||
// disagreement ADR 0138 exists to remove, reintroduced by the migration away from the older word.
|
||||
func TestReachAndExposeForOnePortAreRefused(t *testing.T) {
|
||||
_, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor", Values: map[string]any{
|
||||
_, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor", Placement: map[string]any{
|
||||
ReachSetting: map[string]any{"3000": ReachInternal},
|
||||
ExposeSetting: map[string]any{"3000": FromEverywhere},
|
||||
}}})
|
||||
|
||||
@@ -109,3 +109,224 @@ func sameDeclaration(a, b any) bool {
|
||||
_ = json.Unmarshal(rb, &nb)
|
||||
return reflect.DeepEqual(na, nb)
|
||||
}
|
||||
|
||||
// What a move under a module's own `record` may do without a person (novox/hq ADR 0377).
|
||||
//
|
||||
// A module records because its manifest says so or because it keeps irreplaceable data: mail, whose containers
|
||||
// recreated together interrupt everyone's mail, and the database server, whose restart drops every consumer. That
|
||||
// hold also held the moves that interrupt nothing — on 2026-10-15 the database server moved with only its tools
|
||||
// bundle changed, its walk said it "recreates none of its containers", and the machine stayed behind until a
|
||||
// person pushed. So such a move rolls out like any other, and only a move that recreates a container or touches
|
||||
// data still waits.
|
||||
|
||||
// Holds is why a move from one build's manifest to another's still waits for a person under a module's own
|
||||
// `record` (ADR 0377 §3), empty when it recreates none of its containers and moves none of its data: no container
|
||||
// changed, added or gone; no file or directory changed, added or gone that a container mounts, or that holds or
|
||||
// lies within a path of the module's declared data; no change to its `data` declaration, nor to what it provides,
|
||||
// requires or contributes; and, for a module that declares data of its own, no change to a long-running process.
|
||||
// Its tools bundles, a file nothing mounts that holds none of its data, and manifest fields that recreate
|
||||
// nothing — its tool list — may differ.
|
||||
func Holds(from, to Manifest) string {
|
||||
module := to.Module
|
||||
if module == "" {
|
||||
module = from.Module
|
||||
}
|
||||
if said := Recreates(from, to).Say(module); said != "" {
|
||||
return said
|
||||
}
|
||||
if !sameDeclaration(from.Data, to.Data) {
|
||||
return "it changes " + module + "'s data declaration"
|
||||
}
|
||||
for _, f := range []struct {
|
||||
what string
|
||||
was, s any
|
||||
}{
|
||||
{"what it provides", from.Provides, to.Provides},
|
||||
{"what it requires", from.Requires, to.Requires},
|
||||
{"which of its requirements are optional", from.OptionalRequires, to.OptionalRequires},
|
||||
{"what it contributes to a provider", from.Contributes, to.Contributes},
|
||||
{"what it contributes to a provider", from.ContributesMany, to.ContributesMany},
|
||||
// A setting is filled into the files the module writes: a changed default changes what a container may
|
||||
// mount without the file's own declaration changing.
|
||||
{"its settings, which the files it writes are filled from", from.Settings, to.Settings},
|
||||
} {
|
||||
if !sameDeclaration(emptyAsNil(f.was), emptyAsNil(f.s)) {
|
||||
return "it changes " + f.what
|
||||
}
|
||||
}
|
||||
mounted := append(mountSources(from), mountSources(to)...)
|
||||
data := append(dataPaths(from), dataPaths(to)...)
|
||||
keepsData := (from.Data != nil && len(from.Data.Own) > 0) || (to.Data != nil && len(to.Data.Own) > 0)
|
||||
before, after := resourcesByID(from), resourcesByID(to)
|
||||
var changed []string
|
||||
for id, r := range after {
|
||||
if was, held := before[id]; !held || !sameDeclaration(was, r) {
|
||||
changed = append(changed, id)
|
||||
}
|
||||
}
|
||||
for id := range before {
|
||||
if _, kept := after[id]; !kept {
|
||||
changed = append(changed, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(changed)
|
||||
dirs := absoluteDirectories(from, to)
|
||||
for _, key := range changed {
|
||||
for _, r := range []map[string]any{before[key], after[key]} {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
kind, id := fmt.Sprint(r["type"]), fmt.Sprint(r["id"])
|
||||
switch kind {
|
||||
case "file", "directory":
|
||||
for _, p := range pathsOf(r, dirs) {
|
||||
for _, m := range mounted {
|
||||
if pathsMeet(p, expandDir(m.source, dirs)) {
|
||||
return fmt.Sprintf("it changes the %s %s, which its container %s mounts", kind, id, m.container)
|
||||
}
|
||||
}
|
||||
for _, d := range data {
|
||||
if pathsMeet(p, expandDir(d.path, dirs)) {
|
||||
return fmt.Sprintf("it changes the %s %s, which holds or lies within its data %s", kind, id, d.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "process", "service":
|
||||
if keepsData {
|
||||
return fmt.Sprintf("it changes its long-running %s %s, and it keeps data of its own", kind, id)
|
||||
}
|
||||
default:
|
||||
// Anything that is not a file or directory (judged above by whether a container mounts it
|
||||
// or it holds data) always holds: an archive, a package, a user, a network, or any type
|
||||
// added later (novox/hq ADR 0377 §3's allowlist names only tools bundles and manifest
|
||||
// fields that recreate nothing).
|
||||
return fmt.Sprintf("it changes the %s %s", kind, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type mountSource struct{ container, source string }
|
||||
|
||||
// mountSources is every path a container of the module reads from its machine: its volumes' sources and its
|
||||
// environment files.
|
||||
func mountSources(m Manifest) []mountSource {
|
||||
var out []mountSource
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "container" {
|
||||
continue
|
||||
}
|
||||
id := fmt.Sprint(r["id"])
|
||||
vols, _ := r["volumes"].([]any)
|
||||
for _, v := range vols {
|
||||
s, _ := v.(string)
|
||||
if mount := volumeMount.FindStringSubmatch(s); mount != nil {
|
||||
out = append(out, mountSource{id, mount[1]})
|
||||
}
|
||||
}
|
||||
switch files := r["env-file"].(type) {
|
||||
case string:
|
||||
out = append(out, mountSource{id, files})
|
||||
case []any:
|
||||
for _, f := range files {
|
||||
if s, ok := f.(string); ok {
|
||||
out = append(out, mountSource{id, s})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type dataPath struct{ id, path string }
|
||||
|
||||
func dataPaths(m Manifest) []dataPath {
|
||||
if m.Data == nil {
|
||||
return nil
|
||||
}
|
||||
var out []dataPath
|
||||
for _, it := range m.Data.Own {
|
||||
out = append(out, dataPath{it.ID, it.Path})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resourcesByID(m Manifest) map[string]map[string]any {
|
||||
out := map[string]map[string]any{}
|
||||
for _, r := range m.Resources {
|
||||
out[fmt.Sprint(r["type"])+":"+fmt.Sprint(r["id"])] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// absoluteDirectories is, per directory id, the absolute path a directory of either build states.
|
||||
func absoluteDirectories(ms ...Manifest) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, m := range ms {
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "directory" {
|
||||
continue
|
||||
}
|
||||
if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") {
|
||||
out[fmt.Sprint(r["id"])] = strings.TrimRight(p, "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pathsOf is every way a file or directory resource is named: its path, and a directory's `${dir:<id>}`.
|
||||
func pathsOf(r map[string]any, dirs map[string]string) []string {
|
||||
var out []string
|
||||
if p, ok := r["path"].(string); ok && p != "" {
|
||||
out = append(out, expandDir(p, dirs))
|
||||
}
|
||||
if fmt.Sprint(r["type"]) == "directory" {
|
||||
out = append(out, expandDir("${dir:"+fmt.Sprint(r["id"])+"}", dirs))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// expandDir writes a leading `${dir:<id>}` as the directory's absolute path when one is stated, so a path named
|
||||
// either way is one path.
|
||||
func expandDir(p string, dirs map[string]string) string {
|
||||
p = strings.TrimRight(p, "/")
|
||||
if ref := dirPlain.FindStringSubmatch(p); ref != nil {
|
||||
if abs, ok := dirs[ref[1]]; ok {
|
||||
return abs
|
||||
}
|
||||
return p
|
||||
}
|
||||
if strings.HasPrefix(p, "${dir:") {
|
||||
end := strings.Index(p, "}")
|
||||
if abs, ok := dirs[p[len("${dir:"):end]]; ok {
|
||||
return abs + p[end+1:]
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// pathsMeet is whether one path is the other or lies within it.
|
||||
func pathsMeet(a, b string) bool {
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
|
||||
}
|
||||
|
||||
// emptyAsNil reads an empty list or map as nothing, so a field written empty is no change from one left out.
|
||||
func emptyAsNil(v any) any {
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Slice, reflect.Map:
|
||||
if rv.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
case reflect.Ptr:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -227,6 +227,9 @@ type Needed struct {
|
||||
// by every consumer (novox/hq ADR 0158): the name of that secret in the provider's definition.
|
||||
// The plan mints the pair's copy from the provider's value rather than a value of its own.
|
||||
SharedOwn string
|
||||
// CredentialForm is the credential form the provider's offer declares (Manifest.CredentialFormOf, issue 768),
|
||||
// named in a handed consumer's plan for the vault; empty where the provider's manifest is not in hand.
|
||||
CredentialForm string
|
||||
// Manager is set when this holder is a refreshable-grant licence's MANAGER, delivered the refresh
|
||||
// token rather than an access token (novox/hq ADR 0050). It changes one thing downstream: an empty
|
||||
// Sealed is tolerated — the manager has not adopted a refresh token yet, which is a real waiting
|
||||
@@ -248,6 +251,16 @@ type Needed struct {
|
||||
// consumer's side for `status` and on the provider's before it grants. No bound for a provision
|
||||
// answered by a record, which keeps no name of anybody's.
|
||||
Identity IdentityBound
|
||||
// IssuedToHolder is set when the vault keeps this need's credential for this holder alone (issue 757): the
|
||||
// providing module is marked moved and its software issues the provision's credential for each holder (issue
|
||||
// 749), kept as provision/<From>/<Name>/<node>/<For>. Where the consumer is handed to the provisioner, its
|
||||
// machine's client asks the vault for it and the declaration carries no value. Set by the plan, which reads the
|
||||
// provider's manifest.
|
||||
IssuedToHolder bool
|
||||
// ProviderMoved is set when the providing module is marked moved (issue 788): a handed consumer's template may
|
||||
// then name `${bound:<provision>:<key>}` and `${secret:<provision>}`, which its machine's client fills from the
|
||||
// provider's entry and the vault. Set by the plan, which reads the provider's manifest.
|
||||
ProviderMoved bool
|
||||
}
|
||||
|
||||
// Refusal is why a set of assignments cannot become a declaration.
|
||||
@@ -530,7 +543,8 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
needs = append(needs, Needed{
|
||||
Name: want, From: node.Name, At: at, Module: by.Module,
|
||||
Serves: servedByOne(by, want), For: because[want],
|
||||
SharedOwn: sharedByOne(by, want), Identity: by.IdentityBoundOf(want)})
|
||||
SharedOwn: sharedByOne(by, want), CredentialForm: by.CredentialFormOf(want),
|
||||
Identity: by.IdentityBoundOf(want)})
|
||||
} else if served := servedByOne(by, want); len(served) > 0 {
|
||||
// Answered here with no credential to mint, but the provider serves facts the
|
||||
// consumer cannot guess — a port, a model name — and so still needs a binding.
|
||||
@@ -550,7 +564,8 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
at = "127.0.0.1"
|
||||
}
|
||||
needs = append(needs, Needed{
|
||||
Name: want, From: node.Name, At: at, Serves: served, For: because[want], Module: by.Module})
|
||||
Name: want, From: node.Name, At: at, Serves: served, For: because[want], Module: by.Module,
|
||||
CredentialForm: by.CredentialFormOf(want)})
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -579,16 +594,18 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
node.Name, want, p.Node, meshNetwork))
|
||||
return
|
||||
}
|
||||
shared := ""
|
||||
shared, form := "", ""
|
||||
// A provider whose definition is not in hand is held to the tightest bound the
|
||||
// mesh knows, not to none: what it keeps is not known here (ADR 0225).
|
||||
bound := DefaultIdentityBound
|
||||
if pm, known := catalogue[p.Module]; known {
|
||||
shared, _ = pm.SharedCredentialOf(want)
|
||||
form = pm.CredentialFormOf(want)
|
||||
bound = pm.IdentityBoundOf(want)
|
||||
}
|
||||
needs = append(needs, Needed{Name: want, From: p.Node, At: p.At, Module: p.Module,
|
||||
Serves: p.Serves, For: because[want], SharedOwn: shared, Identity: bound, LeftOut: p.LeftOut})
|
||||
Serves: p.Serves, For: because[want], SharedOwn: shared, CredentialForm: form, Identity: bound,
|
||||
LeftOut: p.LeftOut})
|
||||
}
|
||||
switch {
|
||||
case world.Unchecked:
|
||||
@@ -735,7 +752,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
satisfied[o] = true
|
||||
}
|
||||
if _, ok := because[m.Module]; !ok {
|
||||
because[m.Module] = fmt.Sprintf("required by %s", because[want])
|
||||
because[m.Module] = requiredByWords + because[want]
|
||||
}
|
||||
|
||||
for _, r := range m.Wants() {
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestTheResolverConfigSeatIsGone(t *testing.T) {
|
||||
t.Error("node-resolver-config is still in the mesh's set; the uplink's holder writes the resolver file")
|
||||
}
|
||||
// An uplink's holder needs no seat beside it for the file: it is its own.
|
||||
if got := DependsOn(mod("networkmanager", nil, nil, nil, Claim{Name: "node-uplink"})); len(got) != 0 {
|
||||
if got := DependsOn(nil, mod("networkmanager", nil, nil, nil, Claim{Name: "node-uplink"})); len(got) != 0 {
|
||||
t.Errorf("an uplink holder with nothing declared depends on %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,6 +320,11 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
|
||||
"run": []any{"./" + program.Binary}, "env": env, "restart-on": toAny(restartOn),
|
||||
"user": m.RunsAs,
|
||||
}
|
||||
// How it is ready, as the module declared it (novox/hq ADR 0366 §1), in the node-engine's words.
|
||||
if _, has := OwnRuntimeHealth(m); has {
|
||||
process[HealthField] = m.RuntimeHealth
|
||||
healthInto(process, m, with)
|
||||
}
|
||||
if err := artifactsInto(process, RuntimeModule, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/validate"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0366: a module that runs as its own account declares its runtime's health under one manifest
|
||||
// key, `runtime-health`, in the form a resource's `health` takes; the controller puts it on the process it
|
||||
// writes for that runtime, and a tool check there stands beside the node-engine's judgement that the runtime
|
||||
// is alive. A module that runs as no account of its own is refused the key.
|
||||
|
||||
// ownAccountManifest is a module of its own account with nothing long-running but its own runtime, given
|
||||
// runtime-health (or none, when health is nil), and running as no account when runsAs is false.
|
||||
func ownAccountManifest(t *testing.T, runsAs bool, health any) []byte {
|
||||
t.Helper()
|
||||
m := map[string]any{"module": "keeper", "tools": []any{"keeper_status", "keeper_health"},
|
||||
"own-secrets": map[string]any{"broker": map[string]any{"path": "/var/lib/keeper/broker"}},
|
||||
"resources": []any{map[string]any{"id": "account", "type": "user", "name": "keeper"}}}
|
||||
if runsAs {
|
||||
m["runs-as"], m["secrets-owner"] = "keeper", "keeper"
|
||||
}
|
||||
if health != nil {
|
||||
m["runtime-health"] = health
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestAModuleOfItsOwnAccountDeclaresItsRuntimesHealthByItsOwnTool(t *testing.T) {
|
||||
m, err := ParseManifest(ownAccountManifest(t, true, map[string]any{"kind": "tool", "tool": "keeper_health"}))
|
||||
if err != nil {
|
||||
t.Fatalf("a tool check on its own runtime was refused: %v", err)
|
||||
}
|
||||
if got := HealthChecks(m); len(got) != 1 || got[0] != "keeper.keeper_health" {
|
||||
t.Errorf("the engine is granted %v, want keeper.keeper_health", got)
|
||||
}
|
||||
if r, has := OwnRuntimeHealth(m); !has || r["id"] != "keeper."+OwnRuntimeID() {
|
||||
t.Errorf("its runtime's health reads as %v %v", r, has)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeHealthIsRefusedWithoutAnAccountOfItsOwn(t *testing.T) {
|
||||
_, err := ParseManifest(ownAccountManifest(t, false, map[string]any{"kind": "tool", "tool": "keeper_health"}))
|
||||
if err == nil || !strings.Contains(err.Error(), "runtime-health") || !strings.Contains(err.Error(), "runs-as") {
|
||||
t.Errorf("runtime-health without runs-as: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARuntimeHealthIsHeldToTheHealthRules(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
health any
|
||||
says string
|
||||
}{
|
||||
{map[string]any{"kind": "tool", "tool": "keeper_restart"}, "does not serve"},
|
||||
{map[string]any{"kind": "tool", "tool": "keeper_health", "interval": "2s"}, "no more often"},
|
||||
{map[string]any{"kind": "exec", "command": "true"}, "only a container"},
|
||||
{map[string]any{"kind": "tool", "tool": "keeper_health", "port": 80}, "names a port"},
|
||||
{"tool", "is an object"},
|
||||
} {
|
||||
_, err := ParseManifest(ownAccountManifest(t, true, c.health))
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%v: %v, want it refused saying %q", c.health, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime-health is composed onto <module>.own-runtime, in the words the node-engine takes; to an engine
|
||||
// that does not read health it is not sent, as for every resource.
|
||||
func TestARuntimeHealthIsComposedOntoTheModulesOwnRuntime(t *testing.T) {
|
||||
goRuntime := Manifest{Module: RuntimeModule, Version: "1",
|
||||
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/node-tools"}}}}
|
||||
goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keeper := aToolsModule(t, "keeper", "tools/index.js")
|
||||
keeper.Tools = []string{"keeper_health"}
|
||||
keeper.RunsAs, keeper.SecretsOwner = "keeper", "keeper"
|
||||
keeper.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/keeper/broker"}}
|
||||
keeper.RuntimeHealth = map[string]any{"kind": "tool", "tool": "keeper_health", "interval": "60s", "timeout": "10s",
|
||||
"looks": 2.0}
|
||||
for _, reads := range []bool{true, false} {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101", ReadsHealth: reads,
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed"}, "keeper": {"broker": "own"}}}
|
||||
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{keeper, goRuntime}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
own := fileNamed(out, "keeper."+OwnRuntimeID())
|
||||
if own == nil {
|
||||
t.Fatalf("no own runtime: %v", ids(out))
|
||||
}
|
||||
h, has := own[HealthField].(map[string]any)
|
||||
if !reads {
|
||||
if has {
|
||||
t.Errorf("health sent to an engine that does not read it: %v", h)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !has || h["kind"] != "tool" || h["tool"] != "keeper_health" || h["interval"] != "1m0s" ||
|
||||
h["timeout"] != "10s" || h["looks"] != 2 || h["grace"] != "1m0s" {
|
||||
t.Errorf("its own runtime's health: %v", own[HealthField])
|
||||
}
|
||||
if machine := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()); machine[HealthField] != nil {
|
||||
t.Errorf("the machine's runtime was given the module's health: %v", machine[HealthField])
|
||||
}
|
||||
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.Errorf("composed into something the node-engine refuses: %v", problems)
|
||||
}
|
||||
}
|
||||
// The catalogue's own manifest is not written into.
|
||||
if _, ok := keeper.RuntimeHealth.(map[string]any)["grace"]; ok {
|
||||
t.Error("composing wrote the defaults into the catalogue's manifest")
|
||||
}
|
||||
}
|
||||
@@ -529,3 +529,59 @@ func TestAModuleOfItsOwnAccountIsRefusedTheOperatorsAndTheAgentsAccount(t *testi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 723: the vault runs as its own account, so what the mesh writes for it — the export it keeps, the grant
|
||||
// files it holds for its consumers, and what its words name — belongs to that account, never the operator's.
|
||||
func TestWhatAModuleOfItsOwnAccountReadsIsItsAccounts(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, "vault": {"broker": "own"}},
|
||||
Kept: &KeptExport{Export: 1, OperatorKey: "k"},
|
||||
Grants: []Grant{{Provision: "secret", Consumer: "anchor", From: "telegram",
|
||||
Values: map[string]any{"name": "telegram"}}}}
|
||||
goRuntime := Manifest{Module: RuntimeModule, Version: "1",
|
||||
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/node-tools"}}}}
|
||||
goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vault := aToolsModule(t, "vault", "tools/index.js")
|
||||
vault.RunsAs, vault.SecretsOwner = "vault", "vault"
|
||||
vault.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/vault/broker"}}
|
||||
vault.Keeps = "/var/lib/vault/root"
|
||||
vault.Grants = map[string]string{"secret": "/var/lib/vault/grants"}
|
||||
vault.Receives = map[string]string{"secret": "/var/lib/vault/grants/mesh.json"}
|
||||
vault.Bundles[0].Env = map[string]string{"MESH_RECEIVES": "/var/lib/vault/grants/mesh.json"}
|
||||
out, err2 := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{vault, goRuntime}}.Declaration(with)
|
||||
if err = err2; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := 0
|
||||
for _, r := range out {
|
||||
path := fmt.Sprint(r["path"])
|
||||
if !strings.HasPrefix(path, "/var/lib/vault/") || r["type"] != "file" {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
if r["owner"] != "vault" {
|
||||
t.Errorf("%s (%v) belongs to %v, not the vault's own account", path, r["id"], r["owner"])
|
||||
}
|
||||
}
|
||||
if seen < 4 {
|
||||
t.Errorf("only %d of the vault's files were composed: %v", seen, ids(out))
|
||||
}
|
||||
// Carried by the machine's runtime instead, nothing of it changes: the operator's account reads them.
|
||||
carried := vault
|
||||
carried.RunsAs, carried.SecretsOwner = "", ""
|
||||
out, err = Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{carried, goRuntime}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range out {
|
||||
if path := fmt.Sprint(r["path"]); strings.HasPrefix(path, "/var/lib/vault/") && r["owner"] == "vault" {
|
||||
t.Errorf("%s belongs to the vault's account though it runs as none", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,13 +209,13 @@ func seatContributions(modules []Manifest, holder Manifest, placeholder string,
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return shapedContributions(modules, holder, facts["name"], s, r, where, caps)
|
||||
return shapedContributions(modules, holder, facts["name"], facts, s, r, where, caps)
|
||||
}
|
||||
var failed error
|
||||
var unplacedLines []string
|
||||
var b strings.Builder
|
||||
var blocks []textBlock
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
named := false
|
||||
var old strings.Builder
|
||||
// A left-out module's lines are best effort (novox/hq ADR 0262): what cannot be placed is said,
|
||||
// and the machine is declared without it. A placement setting that does not read is not
|
||||
// replaced by the definition's own path, which may not be where the data is.
|
||||
@@ -273,15 +273,19 @@ func seatContributions(modules []Manifest, holder Manifest, placeholder string,
|
||||
failed = lineFailed
|
||||
}
|
||||
}
|
||||
if !named {
|
||||
fmt.Fprintf(&b, "%s %s\n", r.Comment, m.Module)
|
||||
named = true
|
||||
if old.Len() == 0 {
|
||||
fmt.Fprintf(&old, "%s %s\n", r.Comment, m.Module)
|
||||
}
|
||||
b.WriteString(content)
|
||||
old.WriteString(content)
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
b.WriteString("\n")
|
||||
old.WriteString("\n")
|
||||
}
|
||||
}
|
||||
var held []heldItem
|
||||
if !r.Dirs {
|
||||
held = heldItems(m, "contribution:"+s.Name+":"+kind, nil, facts, caps)
|
||||
}
|
||||
blocks = append(blocks, moduleBlocks(m, r.Comment, old.String(), held)...)
|
||||
}
|
||||
return b.String(), unplacedLines, failed
|
||||
return joinBlocks(blocks), unplacedLines, failed
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestAContributionDependsOnItsSeat(t *testing.T) {
|
||||
{Seat: HotkeysSeat, Kind: "trigger", Content: "x"},
|
||||
{Seat: DisplaySessionSeat, Kind: "config", Content: "y"},
|
||||
}}
|
||||
if got, want := DependsOn(m), []string{DisplaySessionSeat, HotkeysSeat}; !reflect.DeepEqual(got, want) {
|
||||
if got, want := DependsOn(nil, m), []string{DisplaySessionSeat, HotkeysSeat}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("depends on %v, want %v", got, want)
|
||||
}
|
||||
catalogue := map[string]Manifest{"laptop": m, "triggerhappy": hotkeysHolder()}
|
||||
|
||||
@@ -42,8 +42,14 @@ type Shape struct {
|
||||
// (novox/hq ADR 0260): a contributor offers only state it keeps itself, and the holder on the same
|
||||
// machine is granted to read that key, so the holder's manifest names no contributor.
|
||||
Reads func(data map[string]any) []StateRead `json:"-"`
|
||||
// Text says every piece of this kind is, rendered, text that a collector may place as a collection of items
|
||||
// (design 57 L3): a collector then names the kind as `${collected:contribution:<seat>:<kind>:<field>=<value>,…}`.
|
||||
Text bool
|
||||
}
|
||||
|
||||
// collectableAsText is whether a collector may name a kind received as data as a collection.
|
||||
func collectableAsText(r Receivable) bool { return r.Shape != nil && r.Shape.Text }
|
||||
|
||||
// PlaceField is one field a holder places by, and the values it takes.
|
||||
type PlaceField struct {
|
||||
Field string
|
||||
@@ -289,6 +295,10 @@ type placedPiece struct {
|
||||
module string
|
||||
data map[string]any
|
||||
order int
|
||||
// text is a moved contributor's item, rendered (collections.go heldItems): placed as it is, not through the
|
||||
// holder's template.
|
||||
text string
|
||||
item bool
|
||||
}
|
||||
|
||||
// shapedContributions is every module's data of one kind to one seat, narrowed by where and by the
|
||||
@@ -298,7 +308,7 @@ type placedPiece struct {
|
||||
// A piece the template renders to nothing — a `shows` the holder does not know yet — or fails on is
|
||||
// left out and answered as unplaced, naming its module: the other pieces and the rest of the machine's
|
||||
// declaration go on (novox/hq ADR 0255).
|
||||
func shapedContributions(modules []Manifest, holder Manifest, machine string, s Seat, r Receivable, where map[string]string, caps map[string]bool) (string, []string, error) {
|
||||
func shapedContributions(modules []Manifest, holder Manifest, machine string, facts map[string]string, s Seat, r Receivable, where map[string]string, caps map[string]bool) (string, []string, error) {
|
||||
var pieces []placedPiece
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
for _, c := range m.allContributions() {
|
||||
@@ -323,18 +333,37 @@ func shapedContributions(modules []Manifest, holder Manifest, machine string, s
|
||||
}
|
||||
pieces = append(pieces, placedPiece{module: m.Module, data: c.Data, order: order})
|
||||
}
|
||||
// A moved contributor's items of the kind, by their attributes (design 57 L3, issue 777): each in its
|
||||
// module's place, by its order, then its name.
|
||||
if !r.Dirs {
|
||||
for _, h := range heldItems(m, "contribution:"+s.Name+":"+r.Kind, where, facts, caps) {
|
||||
pieces = append(pieces, placedPiece{module: m.Module, order: h.item.order(), text: h.text, item: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(pieces) == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
t, err := holderTemplate(holder, s, r.Kind)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
var t *template.Template
|
||||
for _, p := range pieces {
|
||||
if p.item {
|
||||
continue
|
||||
}
|
||||
var err error
|
||||
if t, err = holderTemplate(holder, s, r.Kind); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
sort.SliceStable(pieces, func(a, b int) bool { return pieces[a].order < pieces[b].order })
|
||||
var b strings.Builder
|
||||
var unplaced []string
|
||||
for _, p := range pieces {
|
||||
if p.item {
|
||||
fmt.Fprintf(&b, "%s %s\n", r.Comment, p.module)
|
||||
b.WriteString(p.text)
|
||||
continue
|
||||
}
|
||||
out, err := renderPiece(t, p.module, machine, p.data)
|
||||
if err == nil && strings.TrimSpace(out) == "" {
|
||||
err = fmt.Errorf("%s's template renders nothing for it", holder.Module)
|
||||
|
||||
@@ -333,9 +333,9 @@ func TestAnOfferedBlockMakesNoDependencyOnTheBar(t *testing.T) {
|
||||
block(`{"bar":"bottom","place":"status","shows":"battery"}`),
|
||||
{Seat: HotkeysSeat, Kind: "trigger", Content: "x"},
|
||||
}}
|
||||
for _, seat := range DependsOn(power) {
|
||||
for _, seat := range DependsOn(nil, power) {
|
||||
if seat == BarSeat {
|
||||
t.Fatalf("a block made the power module depend on the bar: %v", DependsOn(power))
|
||||
t.Fatalf("a block made the power module depend on the bar: %v", DependsOn(nil, power))
|
||||
}
|
||||
}
|
||||
// And on a machine with no bar it is placed nowhere, and nothing fails.
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module depends on the node seats that apply its resources (novox/hq ADR 0207) and on the
|
||||
// seats it contributes to (novox/hq ADR 0210). A third source — what a seat it holds needs beside it
|
||||
// (novox/hq ADR 0220) — had one user, node-resolver-config needing node-uplink, and went with that
|
||||
// seat when the resolver file became the uplink's own (novox/hq ADR 0223).
|
||||
// A module depends on the node seats that apply its resources (novox/hq ADR 0207), on the
|
||||
// seats it contributes to (novox/hq ADR 0210), and on the seats a seat it holds needs beside it
|
||||
// (novox/hq ADR 0220). The third source once served only compiled seats and had one user,
|
||||
// node-resolver-config needing node-uplink, which went with that seat when the resolver file became
|
||||
// the uplink's own (novox/hq ADR 0223) — and now serves module-declared seats instead: mail-notifier
|
||||
// needing node-notifier, and the like (novox/hq ADR 0378 §8).
|
||||
//
|
||||
// Some of what a module declares is applied through software on the machine that is itself a
|
||||
// module: a service through the service manager, a package through the package manager, a container
|
||||
@@ -76,13 +78,16 @@ func isFoundation(m Manifest) bool {
|
||||
return foundationModules[m.Module] || m.Computed != ""
|
||||
}
|
||||
|
||||
// DependsOn is every seat a module needs held on its node, derived from the resource types it
|
||||
// declares itself (novox/hq ADR 0207 §2), sorted.
|
||||
// DependsOn is every seat a module needs held on its node: derived from the resource types it
|
||||
// declares itself (novox/hq ADR 0207 §2), from what it contributes to (ADR 0210), and from what a
|
||||
// node seat it claims needs beside it — read from whichever manifest in `catalogue` declares that
|
||||
// seat (novox/hq ADR 0220, ADR 0378 §8). Sorted.
|
||||
//
|
||||
// **The module's own `resources` only.** What the controller composes around a module — its
|
||||
// filter, jails, certificates, kept files, bundles, the guard — is the mesh's, put there because the
|
||||
// module is assigned, and depending on it would make the module answer for the mesh's choices.
|
||||
func DependsOn(m Manifest) []string {
|
||||
// **The module's own `resources` only**, for the first source. What the controller composes around
|
||||
// a module — its filter, jails, certificates, kept files, bundles, the guard — is the mesh's, put
|
||||
// there because the module is assigned, and depending on it would make the module answer for the
|
||||
// mesh's choices.
|
||||
func DependsOn(catalogue map[string]Manifest, m Manifest) []string {
|
||||
if isFoundation(m) {
|
||||
return nil
|
||||
}
|
||||
@@ -95,6 +100,9 @@ func DependsOn(m Manifest) []string {
|
||||
for _, seat := range contributedTo(m) {
|
||||
seen[seat] = true
|
||||
}
|
||||
for _, seat := range neededBesideClaims(catalogue, m) {
|
||||
seen[seat] = true
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for s := range seen {
|
||||
out = append(out, s)
|
||||
@@ -135,6 +143,25 @@ func contributedTo(m Manifest) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// neededBesideClaims is every seat a node seat the module claims needs held on the same node
|
||||
// (novox/hq ADR 0220, ADR 0378 §8): a module-declared seat's needs are read from whichever manifest
|
||||
// in the catalogue declares that seat, never written again in the claimant's own manifest. Derived
|
||||
// from the claim, as a resource's seat is derived from its type, so a module that claims the seat
|
||||
// cannot leave the dependency out. A compiled seat carries no needs (novox/hq ADR 0378 §8).
|
||||
func neededBesideClaims(catalogue map[string]Manifest, m Manifest) []string {
|
||||
var out []string
|
||||
for _, seat := range nodeSeatsClaimed(m) {
|
||||
for _, candidate := range catalogue {
|
||||
for _, s := range candidate.DefinesSeats {
|
||||
if s.Name == seat {
|
||||
out = append(out, s.Needs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// claimsSeat is whether a module claims a node seat, by its current name or one it used to have
|
||||
// (ADR 0122), so a rename leaves the dependency met.
|
||||
func claimsSeat(m Manifest, seat string) bool {
|
||||
@@ -205,7 +232,7 @@ func UnheldDependencies(catalogue map[string]Manifest, node string, set []Manife
|
||||
if judged != nil && !judged[m.Module] {
|
||||
continue
|
||||
}
|
||||
for _, seat := range DependsOn(m) {
|
||||
for _, seat := range DependsOn(catalogue, m) {
|
||||
if held[seat] {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestADependencyIsDerivedFromEachResourceTypeThatAppliesThroughASeat(t *test
|
||||
"action": nil, "network": nil, "access": nil,
|
||||
}
|
||||
for kind, want := range cases {
|
||||
got := DependsOn(withResources(mod("m", nil, nil, nil), res(kind, "x")))
|
||||
got := DependsOn(nil, withResources(mod("m", nil, nil, nil), res(kind, "x")))
|
||||
if len(got) == 0 {
|
||||
got = nil
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func TestADependencyIsDerivedFromEachResourceTypeThatAppliesThroughASeat(t *test
|
||||
t.Errorf("a %s resource depends on %v, want %v", kind, got, want)
|
||||
}
|
||||
}
|
||||
all := DependsOn(withResources(mod("m", nil, nil, nil),
|
||||
all := DependsOn(nil, withResources(mod("m", nil, nil, nil),
|
||||
res("container", "a"), res("service", "b"), res("package", "c"), res("package", "d")))
|
||||
if want := []string{ContainerRuntimeSeat, PackageManagerSeat, ServiceManagerSeat}; !reflect.DeepEqual(all, want) {
|
||||
t.Errorf("a module of every kind depends on %v, want each seat once: %v", all, want)
|
||||
@@ -207,7 +207,7 @@ func TestTheFoundationsDeclarationsAreNotJudged(t *testing.T) {
|
||||
cat := shelf(append(coreThree(), network, host)...)
|
||||
|
||||
for _, m := range []Manifest{network, host} {
|
||||
if d := DependsOn(m); len(d) != 0 {
|
||||
if d := DependsOn(cat, m); len(d) != 0 {
|
||||
t.Errorf("%s, the foundation's, depends on %v", m.Module, d)
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,36 @@ func TestTheFoundationsDeclarationsAreNotJudged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A module-declared seat's needs are judged exactly as a compiled seat's once were (novox/hq ADR
|
||||
// 0220, ADR 0378 §8): "mail" declares the node seat "mail-notifier" needing node-notifier, the
|
||||
// mesh's own (desktop notifications); "notifier" claims mail-notifier; "dunst" claims node-notifier,
|
||||
// as it does in the real catalogue.
|
||||
func mailNotifierAndDunst() map[string]Manifest {
|
||||
return shelf(
|
||||
Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{
|
||||
Name: "mail-notifier", Scope: ScopeNode, Needs: []string{NotifierSeat},
|
||||
}}},
|
||||
mod("notifier", nil, nil, nil, Claim{Name: "mail-notifier"}),
|
||||
mod("dunst", nil, nil, nil, Claim{Name: NotifierSeat}),
|
||||
)
|
||||
}
|
||||
|
||||
func TestAModuleDeclaredSeatsNeedsAreJudgedLikeACompiledSeatsOnceWere(t *testing.T) {
|
||||
cat := mailNotifierAndDunst()
|
||||
_, err := AssignRefusal(cat, "workstation", nil, []string{"notifier"})
|
||||
var refusal *Refusal
|
||||
if !errors.As(err, &refusal) || !strings.Contains(err.Error(), "node-notifier") {
|
||||
t.Fatalf("notifier assigned without dunst was not refused naming node-notifier: %v", err)
|
||||
}
|
||||
if _, err := AssignRefusal(cat, "workstation", []string{"dunst"}, []string{"notifier"}); err != nil {
|
||||
t.Errorf("notifier beside dunst was refused: %v", err)
|
||||
}
|
||||
if err := UnassignRefusal(cat, "workstation", []string{"dunst", "notifier"}, []string{"dunst"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "node-notifier") {
|
||||
t.Errorf("taking dunst from under notifier was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnassigningTheLastHolderWhileDependentsRemainIsRefused(t *testing.T) {
|
||||
sshd := withResources(mod("sshd", nil, nil, nil), res("service", "sshd"))
|
||||
cat := shelf(append(coreThree(), sshd)...)
|
||||
|
||||
+126
-4
@@ -107,7 +107,9 @@ var defaultSeats = append([]Seat{
|
||||
// A pull request's merge check, judged (novox/hq to-be 45 §9).
|
||||
"checked",
|
||||
// A walk kept in a new state, for the delivery it walks (novox/hq ADR 0239).
|
||||
"plan-moved"},
|
||||
"plan-moved",
|
||||
// A key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3; design 57 F3).
|
||||
"assignment-changed", "declared-config-changed", "predefined-changed"},
|
||||
Serves: ControllerVerbs},
|
||||
// The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable,
|
||||
// served by whichever module holds the seat with tools of these names.
|
||||
@@ -129,9 +131,11 @@ var defaultSeats = append([]Seat{
|
||||
// test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a
|
||||
// second claimant, refused by name, rather than a candidate for a pin.
|
||||
//
|
||||
// Its one verb, `get`, hands a secret on request and reply, sealed to the key the asker sends and only
|
||||
// to an asker the vault allows (novox/hq ADR 0349 §11, issue 674). Optional until the vault that runs
|
||||
// serves it (design 33 §7).
|
||||
// Its verb `get` hands a secret on request and reply, sealed to the key the asker sends and only to an
|
||||
// asker the vault allows (novox/hq ADR 0349 §11, issue 674). `accept`, `secret-ask`, `issued` and `rotate`
|
||||
// take a value in and make one anew (ADR 0349 clauses 8 to 10, issue 675): the first three in two steps from
|
||||
// the controller alone, which carries the value sealed to a key the vault made for the call and cannot open
|
||||
// it. All optional until the vault that runs serves them (design 33 §7).
|
||||
{Name: "mesh-vault", Scope: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161",
|
||||
Serves: []Verb{
|
||||
{Name: "get", Optional: true, Description: "One secret, sealed to the public key the asker sends (the " +
|
||||
@@ -140,6 +144,82 @@ var defaultSeats = append([]Seat{
|
||||
Input: schema(map[string]string{"name": "the secret's name",
|
||||
"public_key": "the asker's X25519 public key as SPKI PEM — never a private key"},
|
||||
[]string{"name", "public_key"})},
|
||||
{Name: "accept", Optional: true, Description: "Take a value given at the controller's terminal, from the " +
|
||||
"controller alone: name the secret and get a call and a key made for it, then send the call and the " +
|
||||
"value sealed to that key. The vault alone opens it, and never remakes an accepted credential.",
|
||||
Input: schema(vaultIntake(map[string]string{"node": "the machine, for a module's own secret",
|
||||
"module": "the module, for a module's own secret", "name": "the secret's name in the module",
|
||||
"provider": "the provider's machine, for a provision's credential", "provision": "the provision",
|
||||
"by": "who gave it at the terminal",
|
||||
"origin": "issued for a bus account the controller minted for a core or moved module and hands " +
|
||||
"at once (ADR 0374, issue 787); left out for a value given at the terminal"}), nil)},
|
||||
{Name: "secret-ask", Optional: true, Description: "Ask the operator for a module's own secret at a desk, " +
|
||||
"from the controller alone: the ask is recorded in the vault's store and bounded before the prompt " +
|
||||
"opens, and what is typed is sealed to a key the vault made for the call and opens there alone.",
|
||||
Input: schema(vaultIntake(map[string]string{"node": "the machine the secret is for",
|
||||
"module": "the module", "name": "the secret's name in the module",
|
||||
"desk": "the machine whose prompt asks", "asked_by": "who asked, as the bus named its caller",
|
||||
"outcome": "instead of sealed: how the prompt ended"}), nil)},
|
||||
{Name: "issued", Optional: true, Description: "Take the credential a provider's software issued for a " +
|
||||
"provision, from the controller alone, which asks the provider's tool with a key the vault made for " +
|
||||
"the call; kept as the provision's one credential, or, when the tool answers a value per holder named, " +
|
||||
"each as that holder's own; the same value again writes nothing.",
|
||||
Input: withHolders(schema(vaultIntake(map[string]string{"provision": "the provision",
|
||||
"provider": "the provider's machine", "module": "the provider's module"}), nil))},
|
||||
{Name: "rotate", Optional: true, Description: "Make a secret anew. Refused for a value a provider's " +
|
||||
"software issued and for an accepted credential. Never answers the value.",
|
||||
Input: schema(map[string]string{"node": "the machine, for a module's own secret", "module": "the module",
|
||||
"name": "the secret's name in the module", "provider": "the provider's machine",
|
||||
"provision": "the provision", "by": "who asked, when the controller carries the act"}, nil)},
|
||||
// The move of the controller's secrets (design 57's V3, issue 676): each machine's node-engine hands in what
|
||||
// was sealed to its machine, which the controller cannot open; the controller copies its record of asks.
|
||||
{Name: "move-in", Optional: true, Description: "Take one value of the controller's store in, as it is, " +
|
||||
"from the node-engine of a machine it was sealed to and no other caller: name the row, its origin " +
|
||||
"and generation and get a call and a key made for it, then send the call, the value sealed to that " +
|
||||
"key and a key to read back to. Never replaces a value the vault holds; answers what it kept, read " +
|
||||
"back sealed to that key.",
|
||||
Input: schema(vaultIntake(map[string]string{"node": "a module's own secret: its machine",
|
||||
"module": "its module", "name": "its name in the module",
|
||||
"provider": "a pair credential: the provider's machine", "provision": "the provision",
|
||||
"consumer": "the consumer's machine", "consumer_module": "the consuming module",
|
||||
"local": "the credential's local name, when it has one",
|
||||
"origin": "made, accepted or issued, as the controller's store says",
|
||||
"generation": "the controller's generation for the value", "made_at": "when it was made (RFC 3339)",
|
||||
"read_back_to": "the second step: a raw X25519 public key the read-back is sealed to"}), nil)},
|
||||
// A machine's node-engine states its own sealing key (novox/hq ADR 0372, issue 786).
|
||||
{Name: "node-key", Optional: true, Description: "State this machine's sealing key to the vault, in its " +
|
||||
"own name, from the node-engine of the machine alone: at its start and whenever the key changes. A " +
|
||||
"first or changed key is recorded and the machine's core secrets are said changed; the same key " +
|
||||
"again changes nothing.",
|
||||
Input: schema(map[string]string{"key": "the machine's sealing key, a raw X25519 public key in base64"}, nil)},
|
||||
{Name: "move-asks", Optional: true, Description: "Copy rows of the controller's record of asks at a desk " +
|
||||
"into the vault's, as they are and once, from the controller alone. No value is in any of them.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"asks": map[string]any{
|
||||
"type": "array", "items": map[string]any{"type": "object"},
|
||||
"description": "the controller's rows: id, node, module, name, desk, asked_by, opened_at, ended_at, outcome"}}}},
|
||||
}},
|
||||
// The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets
|
||||
// mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10).
|
||||
// Optional until the mesh-config that runs serves it (design 33 §7).
|
||||
{Name: "mesh-config", Scope: ScopeMesh, Decision: "novox/hq ADR 0348",
|
||||
Serves: []Verb{
|
||||
{Name: "config", Optional: true, Description: "Set, clear, list or read the history of config values: " +
|
||||
"mesh config or node config for a predefined key, or a module's own value on one node, each set " +
|
||||
"with why, validated, and naming each module on each node whose value moves. A trusted value only " +
|
||||
"from the controller's terminal.",
|
||||
Input: configInput()},
|
||||
}},
|
||||
// The provisioner's server (novox/hq ADR 0349 clause 3, design 57 P4): its holder keeps the registry of which
|
||||
// domain answers which kind of placeholder, and the buckets mesh-provisioner_needs, _applied and _collect,
|
||||
// which it and each machine's provisioner client (for its own machine's keys) alone write. Its one verb reads
|
||||
// them. Optional until the mesh-provisioner that runs serves it (design 33 §7).
|
||||
{Name: "mesh-provisioner", Scope: ScopeMesh, Decision: "novox/hq ADR 0349",
|
||||
Serves: []Verb{
|
||||
{Name: "provisioning", Optional: true, Description: "Read what the provisioner keeps: the registry " +
|
||||
"(which domain answers which kind of placeholder), the needs each module on each node recorded, what " +
|
||||
"is applied where, or the items of every collection, narrowed by node and module. Reads only.",
|
||||
Input: schema(map[string]string{"what": "registry (when not given), needs, applied or collect",
|
||||
"node": "a node", "module": "a module: for collect, a contributor", "collection": "for collect: a collection"}, nil)},
|
||||
}},
|
||||
// Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as
|
||||
// an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes —
|
||||
@@ -273,6 +353,10 @@ var defaultSeats = append([]Seat{
|
||||
// and their path is its protocol: a shell sources ~/.config/mesh/environment.sh without knowing
|
||||
// which module wrote it.
|
||||
{Name: EnvironmentSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0203"},
|
||||
// The provisioner client on every machine (novox/hq ADR 0349 clause 3, design 57 P5): a service of its own on a
|
||||
// bus account of its own (mesh-controller internal/broker NodeProvisionerSeat, design 57 P3), which fills the
|
||||
// machine's templates. It serves no verb an agent calls.
|
||||
{Name: "node-provisioner", Scope: ScopeNode, Decision: "novox/hq ADR 0349"},
|
||||
// The login shell (novox/hq ADR 0204, replacing the module-declared `login-shell` of ADR 0176):
|
||||
// the mesh's, so a second shell module claims the seat rather than declaring a second one, and
|
||||
// the seat exists whether or not zsh's definition is registered. `execute` is the contract a caller
|
||||
@@ -356,6 +440,21 @@ func isSystemSeatName(name string) bool {
|
||||
// change to data, not to this code.
|
||||
var seats = defaultSeats
|
||||
|
||||
// configInput is the schema of mesh-config's verb: a value of the key's own type (a number for a number, never its
|
||||
// text), and a limit that is a number; every other argument a string.
|
||||
func configInput() map[string]any {
|
||||
in := schema(map[string]string{"action": "list (when not given), set, clear or history",
|
||||
"key": "the config key", "value": "for set: the value, of the key's type",
|
||||
"module": "a module: its own value, with node", "node": "a node",
|
||||
"why": "for set and clear: why", "by": "who gave the value at the controller's terminal",
|
||||
"limit": "for history: at most this many, newest"}, nil)
|
||||
props := in["properties"].(map[string]any)
|
||||
props["value"] = map[string]any{"description": "for set: the value, of the key's type: a number, a string, " +
|
||||
"a boolean, or for a trusted key an object or a list"}
|
||||
props["limit"] = map[string]any{"type": "number", "description": "for history: at most this many, newest"}
|
||||
return in
|
||||
}
|
||||
|
||||
// DefaultSeats is the set the mesh ships with, for seeding the store's seat table.
|
||||
func DefaultSeats() []Seat { return append([]Seat(nil), defaultSeats...) }
|
||||
|
||||
@@ -822,3 +921,26 @@ func buildAgentVerbs() []Verb {
|
||||
// BrokerSeat is the mesh's bus. Its holder is the bus, and its account — when it declares one — may
|
||||
// snapshot the bus's streams and do nothing else (novox/hq ADR 0235).
|
||||
const BrokerSeat = "mesh-broker"
|
||||
|
||||
// vaultIntake is a two-step vault verb's arguments: what its first step names, then the call and what was sealed.
|
||||
// withHolders adds to `issued` the provision's holders from that provider (issue 749): a provider's tool may answer a
|
||||
// value for each, which the vault keeps as that holder's own and hands to it alone.
|
||||
func withHolders(input map[string]any) map[string]any {
|
||||
props, _ := input["properties"].(map[string]any)
|
||||
props["holders"] = map[string]any{"type": "array",
|
||||
"description": "the provision's holders from that provider, each {node, module}: its tool may answer a value " +
|
||||
"for each, kept as that holder's own and handed to it alone",
|
||||
"items": map[string]any{"type": "object", "properties": map[string]any{
|
||||
"node": map[string]any{"type": "string", "description": "the holder's machine"},
|
||||
"module": map[string]any{"type": "string", "description": "the consuming module"}}}}
|
||||
return input
|
||||
}
|
||||
|
||||
func vaultIntake(first map[string]string) map[string]string {
|
||||
out := map[string]string{"call": "the second step: the call the first step answered",
|
||||
"sealed": "the second step: the value sealed to the call's key — never the value"}
|
||||
for k, v := range first {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -71,6 +71,12 @@ type SeatDeclaration struct {
|
||||
// mesh's own seats name theirs by: a node seat only, each absolute and literal, and none another seat
|
||||
// names. A module's seat never steps back.
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
// Needs is every node seat this seat's holder needs held on its own node (novox/hq ADR 0220, ADR
|
||||
// 0378 §8): a role whose holder is only right while another role is filled beside it. A module
|
||||
// claiming this seat depends on each, exactly as a module declaring a service depends on the
|
||||
// service manager (ADR 0207) — derived from the claim, never written again in the claimant's own
|
||||
// manifest. Only a node seat may carry needs, and a compiled seat carries none.
|
||||
Needs []string `json:"needs,omitempty"`
|
||||
}
|
||||
|
||||
// KindedBenches are the seats that may be kinded (novox/hq ADR 0234 §2): `channel` sends to the operator,
|
||||
@@ -168,6 +174,7 @@ func declaredSeatProblems(m Manifest) []string {
|
||||
}
|
||||
problems = append(problems, trafficProblems(m, s)...)
|
||||
problems = append(problems, declaredSeatPathProblems(m, s)...)
|
||||
problems = append(problems, declaredSeatNeedsProblems(m, s)...)
|
||||
}
|
||||
|
||||
for _, u := range m.Uses {
|
||||
@@ -228,6 +235,30 @@ func trafficProblems(m Manifest, s SeatDeclaration) []string {
|
||||
return problems
|
||||
}
|
||||
|
||||
// declaredSeatNeedsProblems is what one declaration's needs can be judged on alone (novox/hq ADR
|
||||
// 0378 §8): only a node seat is ever held beside another on the same node, so only a node seat may
|
||||
// carry needs; and a seat needing itself names nothing that was not already true of holding it.
|
||||
// Whether each named need itself exists as a node seat somewhere in the catalogue is CatalogueProblems'
|
||||
// to say, once every manifest is known.
|
||||
func declaredSeatNeedsProblems(m Manifest, s SeatDeclaration) []string {
|
||||
var problems []string
|
||||
if len(s.Needs) == 0 {
|
||||
return problems
|
||||
}
|
||||
if s.At() != ScopeNode {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares %s needing %s, and only a node seat needs another held beside it "+
|
||||
"(novox/hq ADR 0378 §8)", m.Module, s.Name, strings.Join(s.Needs, ", ")))
|
||||
}
|
||||
for _, need := range s.Needs {
|
||||
if need == s.Name {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares %s needing itself (novox/hq ADR 0378 §8)", m.Module, s.Name))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// A Shelf is every manifest the mesh has registered, by module name.
|
||||
type Shelf map[string]Manifest
|
||||
|
||||
@@ -245,6 +276,10 @@ func IsUndeclaredSeat(problem string) bool { return strings.HasSuffix(problem, U
|
||||
|
||||
func CatalogueProblems(shelf Shelf) []string {
|
||||
var problems []string
|
||||
// A stream name has one owner (novox/hq ADR 0363).
|
||||
problems = append(problems, StreamNameProblems(shelf)...)
|
||||
// A module that hears a restricted stream runs as an account of its own (novox/hq ADR 0376 §2).
|
||||
problems = append(problems, RestrictedHearingProblems(shelf)...)
|
||||
|
||||
// Who declares what, and who declared it first.
|
||||
declaredBy := map[string]string{}
|
||||
@@ -276,6 +311,36 @@ func CatalogueProblems(shelf Shelf) []string {
|
||||
return ok
|
||||
}
|
||||
|
||||
// atNode says whether a seat, compiled or declared, is a node seat — judged here because only
|
||||
// the whole catalogue knows a declared one's scope (novox/hq ADR 0378 §8).
|
||||
atNode := func(seat string) bool {
|
||||
if s, isMesh := SeatNamed(seat); isMesh {
|
||||
return s.Scope == ScopeNode
|
||||
}
|
||||
if s, ok := declared[seat]; ok {
|
||||
return s.At() == ScopeNode
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// A module-declared seat's needs name node seats that hold beside it (novox/hq ADR 0378 §8):
|
||||
// whether one exists at all, compiled or declared by any module, only the whole catalogue can
|
||||
// say — a single manifest's own problems (declaredSeatProblems) judge its needs no further.
|
||||
for _, module := range shelfOrder(shelf) {
|
||||
for _, s := range shelf[module].DefinesSeats {
|
||||
for _, need := range s.Needs {
|
||||
if !exists(need) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares %s needing %s, "+UndeclaredSeat, module, s.Name, need))
|
||||
} else if !atNode(need) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares %s needing %s, which is not a node seat (novox/hq ADR 0378 §8)",
|
||||
module, s.Name, need))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Which seats name each path (novox/hq ADR 0287): a module's seat naming one another seat names loses.
|
||||
named, pathProblems := pathSeats(shelfOrder(shelf), shelf, declaredBy)
|
||||
problems = append(problems, pathProblems...)
|
||||
|
||||
@@ -116,6 +116,53 @@ func TestTheMeshsOwnSeatsAreStillClaimable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Only a node seat may carry needs (novox/hq ADR 0378 §8): a per-mesh seat's holder is not on any
|
||||
// one node to depend on anything beside it there.
|
||||
func TestOnlyANodeSeatMayCarryNeeds(t *testing.T) {
|
||||
m := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{
|
||||
Name: "mail-notifier", Scope: ScopeMesh, Needs: []string{"node-notifier"}}}}
|
||||
got := strings.Join(declaredSeatProblems(m), "; ")
|
||||
if !strings.Contains(got, "needing") || !strings.Contains(got, "node seat") {
|
||||
t.Fatalf("a mesh-scope seat's needs were accepted: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A seat needing itself names nothing (novox/hq ADR 0378 §8).
|
||||
func TestASeatCannotNeedItself(t *testing.T) {
|
||||
m := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{
|
||||
Name: "mail-notifier", Scope: ScopeNode, Needs: []string{"mail-notifier"}}}}
|
||||
got := strings.Join(declaredSeatProblems(m), "; ")
|
||||
if !strings.Contains(got, "needing itself") {
|
||||
t.Fatalf("a seat needing itself was accepted: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A need naming nothing node-scope — undeclared, or declared at another scope — is the whole
|
||||
// catalogue's to say (novox/hq ADR 0378 §8).
|
||||
func TestANeedMustNameANodeSeatSomewhereInTheCatalogue(t *testing.T) {
|
||||
mail := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{
|
||||
Name: "mail-notifier", Scope: ScopeNode, Needs: []string{"desk-beeper"}}}}
|
||||
|
||||
// Nobody declares desk-beeper at all.
|
||||
got := problemsFor(t, Shelf{"mail": mail})
|
||||
if !strings.Contains(got, "desk-beeper") || !strings.Contains(got, "no module declares") {
|
||||
t.Fatalf("needing an undeclared seat was accepted: %q", got)
|
||||
}
|
||||
|
||||
// Declared, but at mesh scope: still not a node seat to hold beside it.
|
||||
notNode := Manifest{Module: "dunst", DefinesSeats: []SeatDeclaration{{Name: "desk-beeper", Scope: ScopeMesh}}}
|
||||
got = problemsFor(t, Shelf{"mail": mail, "dunst": notNode})
|
||||
if !strings.Contains(got, "desk-beeper") || !strings.Contains(got, "not a node seat") {
|
||||
t.Fatalf("needing a mesh-scope seat was accepted: %q", got)
|
||||
}
|
||||
|
||||
// Declared at node scope: accepted.
|
||||
dunst := Manifest{Module: "dunst", DefinesSeats: []SeatDeclaration{{Name: "desk-beeper", Scope: ScopeNode}}}
|
||||
if got := problemsFor(t, Shelf{"mail": mail, "dunst": dunst}); got != "" {
|
||||
t.Fatalf("needing a node seat another module declares was refused: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal that reorders itself between runs is a refusal nobody can diff.
|
||||
func TestTheProblemsAreStable(t *testing.T) {
|
||||
shelf := Shelf{"telegram": telegram(), "shop": {Module: "shop", Uses: []string{"nope"}},
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
delivered[s.Delivers] = s.Name
|
||||
}
|
||||
}
|
||||
// Forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired
|
||||
// Forty-four with node-provisioner (novox/hq ADR 0349 clause 3); forty-three with mesh-provisioner (novox/hq ADR 0349 clause 3); forty-two with mesh-config (novox/hq ADR 0348 §9); forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired
|
||||
// into node-uplink (novox/hq ADR 0223); thirty-seven
|
||||
// since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with
|
||||
// node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215);
|
||||
@@ -59,8 +59,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
// node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203,
|
||||
// ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired
|
||||
// mesh-build-machine row goes, when no registered manifest claims it.
|
||||
if len(Seats()) != 41 {
|
||||
t.Errorf("the mesh defines %d seats rather than 41; the set is closed, so a change here is "+
|
||||
if len(Seats()) != 44 {
|
||||
t.Errorf("the mesh defines %d seats rather than 44; the set is closed, so a change here is "+
|
||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -179,3 +181,33 @@ func TestAnOwnersKeyIsTheTerminals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A key asked only in a template is not refused while the manifest still names its templates (issue 817), and
|
||||
// is judged once they are filled: WithTemplatesFrom over a tree whose template does not ask for it refuses it.
|
||||
func TestAnOwnerSettingAskedOnlyInATemplateIsJudgedOnceFilled(t *testing.T) {
|
||||
raw := `{"module":"sonarr","version":"1",
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"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","template":"files/identity.env"}]}`
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("a templated manifest was refused: %v", err)
|
||||
}
|
||||
write := func(text string) string {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "files", "identity.env"), []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
if _, err := WithTemplatesFrom(m, write("PUID=${setting:puid}\nPGID=${setting:pgid}\n")); err != nil {
|
||||
t.Errorf("a template asking for both keys was refused: %v", err)
|
||||
}
|
||||
_, err = WithTemplatesFrom(m, write("NOTHING=1\n"))
|
||||
if err == nil || !strings.Contains(err.Error(), "asks for \"puid\" nowhere else") {
|
||||
t.Errorf("a template not asking for puid was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,12 @@ func OwnerSettingProblems(m Manifest) []string {
|
||||
m.Module, r["id"], owner))
|
||||
continue
|
||||
}
|
||||
// A template's text is not here yet, so what the module asks for elsewhere cannot be known: WithTemplatesFrom
|
||||
// parses its result again with every template filled, and this half is judged then (as config.go skips its
|
||||
// "placed nowhere" rule while m.Templated()).
|
||||
if m.Templated() {
|
||||
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 "+
|
||||
|
||||
@@ -18,29 +18,104 @@ import (
|
||||
// TakesSettingsField is the key a mergeable file carries to name the top-level setting keys it takes.
|
||||
const TakesSettingsField = "takes-settings"
|
||||
|
||||
// takenKeys is the keys a resource names in takes-settings, and whether it names any list at all. An
|
||||
// empty list is a list: that file takes no setting.
|
||||
func takenKeys(resource map[string]any) (map[string]bool, bool) {
|
||||
// **A taken value may be laid under the file's own name** (issue 751). An entry of takes-config is a key, laid
|
||||
// under that key, or {"key": <the config key>, "as": <the file's name for it>}: the key stays a config key,
|
||||
// lower-case, and the file is written in its software's spelling, `atlassian-url` as ATLASSIAN_URL. Like every
|
||||
// taken value it is laid only when a layer sets it, never from a default, so an unset one leaves the file's bytes
|
||||
// and the code's own fallback (its environment) as they were; for that reason it is never `required`
|
||||
// (ConfigProblems). The settings form has no such entry: it is the config form's alone.
|
||||
|
||||
// Fields of a renamed takes-config entry.
|
||||
const (
|
||||
takenKeyField = "key"
|
||||
takenAsField = "as"
|
||||
)
|
||||
|
||||
// takenEntry is one value a mergeable file takes: its key and the name the file has for it.
|
||||
type takenEntry struct {
|
||||
key, as string
|
||||
renamed bool
|
||||
}
|
||||
|
||||
// takenEntries is the entries of a takes-settings list in their order, a usable entry only: a string is a key
|
||||
// under its own name, an object with exactly key and as a key under another (TakenSettingsProblems says the rest).
|
||||
func takenEntries(raw any) []takenEntry {
|
||||
list, _ := raw.([]any)
|
||||
var out []takenEntry
|
||||
for _, item := range list {
|
||||
if e, ok := asTakenEntry(item); ok {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// asTakenEntry reads one item of a takes-settings list, and whether it is shaped as an entry at all.
|
||||
func asTakenEntry(item any) (takenEntry, bool) {
|
||||
switch v := item.(type) {
|
||||
case string:
|
||||
return takenEntry{key: v, as: v}, true
|
||||
case map[string]any:
|
||||
key, keyOK := v[takenKeyField].(string)
|
||||
as, asOK := v[takenAsField].(string)
|
||||
if !keyOK || !asOK || len(v) != 2 {
|
||||
return takenEntry{}, false
|
||||
}
|
||||
return takenEntry{key: key, as: as, renamed: true}, true
|
||||
}
|
||||
return takenEntry{}, false
|
||||
}
|
||||
|
||||
// takenKeyList is the keys a resource takes, in order: the config keys, never the file's names for them.
|
||||
func takenKeyList(resource map[string]any) []string {
|
||||
var keys []string
|
||||
for _, e := range takenEntries(resource[TakesSettingsField]) {
|
||||
keys = append(keys, e.key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// takenKeys is the keys a resource names in takes-settings, each with the name the file has for it, and whether
|
||||
// it names any list at all. An empty list is a list: that file takes no setting.
|
||||
func takenKeys(resource map[string]any) (map[string]string, bool) {
|
||||
raw, said := resource[TakesSettingsField]
|
||||
if !said {
|
||||
return nil, false
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
for _, k := range stringsOf(raw) {
|
||||
keys[k] = true
|
||||
keys := map[string]string{}
|
||||
for _, e := range takenEntries(raw) {
|
||||
keys[e.key] = e.as
|
||||
}
|
||||
return keys, true
|
||||
}
|
||||
|
||||
// onlyTaken is the layers with every key the file does not name left out. The file's own defaults and
|
||||
// what the mesh reads itself are not touched here: settle already keeps both out of a file.
|
||||
func onlyTaken(layers []Layer, taken map[string]bool) []Layer {
|
||||
// onlyTaken is the layers with every key the file does not name left out, and each key it takes under the
|
||||
// file's name for it. The file's own defaults and what the mesh reads itself are not touched here: settle
|
||||
// already keeps both out of a file.
|
||||
//
|
||||
// **A layer said before its module moved still reaches the file** (issue 751): such a layer names a renamed value
|
||||
// by the file's own spelling (nodered's timeZone on one machine), and until K5 moves the layer and renames the key
|
||||
// that spelling is taken as the value's, so moving the module changes no machine's file. Where one layer says
|
||||
// both, the config key wins.
|
||||
func onlyTaken(layers []Layer, taken map[string]string) []Layer {
|
||||
oldSpelling := map[string]string{}
|
||||
for key, as := range taken {
|
||||
if as != key {
|
||||
oldSpelling[as] = key
|
||||
}
|
||||
}
|
||||
kept := make([]Layer, 0, len(layers))
|
||||
for _, layer := range layers {
|
||||
values := map[string]any{}
|
||||
for key, value := range layer.Values {
|
||||
if taken[key] {
|
||||
values[key] = value
|
||||
if as, takes := taken[key]; takes {
|
||||
values[as] = value
|
||||
continue
|
||||
}
|
||||
if newKey, old := oldSpelling[key]; old {
|
||||
if _, said := layer.Values[newKey]; !said {
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default})
|
||||
@@ -48,6 +123,19 @@ func onlyTaken(layers []Layer, taken map[string]bool) []Layer {
|
||||
return kept
|
||||
}
|
||||
|
||||
// takenNames is every key a layer may say for the values a resource takes: each config key and, for a renamed
|
||||
// one, the file's own spelling a layer said before its module moved (onlyTaken).
|
||||
func takenNames(resource map[string]any) []string {
|
||||
var names []string
|
||||
for _, e := range takenEntries(resource[TakesSettingsField]) {
|
||||
names = append(names, e.key)
|
||||
if e.renamed {
|
||||
names = append(names, e.as)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// namesItsKeys says whether a module's mergeable files name the keys they take (ADR 0326 §2): true
|
||||
// only when it has one and every one carries takes-settings. A module with a silent mergeable file
|
||||
// keeps today's form (§3), where every key reaches every mergeable file.
|
||||
@@ -138,26 +226,42 @@ func takenSettingsProblems(m Manifest) []string {
|
||||
for _, k := range stringsOf(r["protected"]) {
|
||||
protected[k] = true
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
seen, landed := map[string]bool{}, map[string]bool{}
|
||||
for _, item := range list {
|
||||
if _, isObject := item.(map[string]any); isObject {
|
||||
e, shaped := asTakenEntry(item)
|
||||
switch {
|
||||
case !m.DeclaresConfig():
|
||||
problems = append(problems, fmt.Sprintf("%s's %s: %v in %s lays a value under another name, "+
|
||||
"which only the config form says: write the key alone (novox/hq issue 751)",
|
||||
m.Module, id, item, TakesSettingsField))
|
||||
continue
|
||||
case !shaped:
|
||||
problems = append(problems, fmt.Sprintf("%s's %s: %v in %s is a key alone, or an object "+
|
||||
"saying exactly %q and %q: the config key and the file's name for it (novox/hq issue 751)",
|
||||
m.Module, id, item, TakesSettingsField, takenKeyField, takenAsField))
|
||||
continue
|
||||
case !settingRef.MatchString("${setting:" + e.key + "}"):
|
||||
problems = append(problems, fmt.Sprintf("%s's %s: %q in %s is not a usable key: a config "+
|
||||
"key is lower-case letters, digits, dots, dashes and underscores; the file's own spelling "+
|
||||
"is its %q", m.Module, id, e.key, TakesSettingsField, takenAsField))
|
||||
continue
|
||||
case !usableKey(e.as):
|
||||
problems = append(problems, fmt.Sprintf("%s's %s: %q, the name %s takes %q as, is not a "+
|
||||
"usable name in a file", m.Module, id, e.as, TakesSettingsField, e.key))
|
||||
continue
|
||||
}
|
||||
problems = append(problems, takenEntryProblems(m.Module, id, e, seen, landed, protected)...)
|
||||
continue
|
||||
}
|
||||
key, isString := item.(string)
|
||||
switch {
|
||||
case !isString || !usableKey(key):
|
||||
if !isString || !usableKey(key) {
|
||||
problems = append(problems, fmt.Sprintf("%s's %s: %v in %s is not a usable setting key",
|
||||
m.Module, id, item, TakesSettingsField))
|
||||
case seen[key]:
|
||||
problems = append(problems, fmt.Sprintf("%s's %s names %q twice in %s",
|
||||
m.Module, id, key, TakesSettingsField))
|
||||
case meshWords[key]:
|
||||
problems = append(problems, fmt.Sprintf("%s's %s names %q in %s, and the mesh reads %q itself "+
|
||||
"(novox/hq ADR 0164 §14)", m.Module, id, key, TakesSettingsField, key))
|
||||
case protected[key]:
|
||||
problems = append(problems, fmt.Sprintf("%s's %s names %q in %s and keeps it as protected: a "+
|
||||
"key is the file's or the setting's, never both", m.Module, id, key, TakesSettingsField))
|
||||
}
|
||||
if isString {
|
||||
seen[key] = true
|
||||
continue
|
||||
}
|
||||
problems = append(problems, takenEntryProblems(m.Module, id, takenEntry{key: key, as: key}, seen,
|
||||
landed, protected)...)
|
||||
}
|
||||
}
|
||||
if len(naming) > 0 {
|
||||
@@ -170,6 +274,32 @@ func takenSettingsProblems(m Manifest) []string {
|
||||
return problems
|
||||
}
|
||||
|
||||
// takenEntryProblems is what is wrong with one usable entry of a file's takes-settings, against those before it:
|
||||
// a key taken twice, or two values laid under one name in the file; a word the mesh reads itself, as the key or as
|
||||
// the file's name; a name the file keeps as protected. seen and landed are the keys and the names so far, added to.
|
||||
func takenEntryProblems(module, id string, e takenEntry, seen, landed, protected map[string]bool) []string {
|
||||
var problems []string
|
||||
switch {
|
||||
case seen[e.key]:
|
||||
problems = append(problems, fmt.Sprintf("%s's %s names %q twice in %s", module, id, e.key, TakesSettingsField))
|
||||
case landed[e.as]:
|
||||
problems = append(problems, fmt.Sprintf("%s's %s names %q twice in %s, as a key or as the name a key is "+
|
||||
"laid under: one name in a file holds one value", module, id, e.as, TakesSettingsField))
|
||||
case meshWords[e.key] || meshWords[e.as]:
|
||||
word := e.key
|
||||
if !meshWords[word] {
|
||||
word = e.as
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf("%s's %s names %q in %s, and the mesh reads %q itself "+
|
||||
"(novox/hq ADR 0164 §14)", module, id, word, TakesSettingsField, word))
|
||||
case protected[e.as]:
|
||||
problems = append(problems, fmt.Sprintf("%s's %s names %q in %s and keeps it as protected: a "+
|
||||
"key is the file's or the setting's, never both", module, id, e.as, TakesSettingsField))
|
||||
}
|
||||
seen[e.key], landed[e.as] = true, true
|
||||
return problems
|
||||
}
|
||||
|
||||
// usableKey is a key a file can be said to take: not empty, and no space, line end or control
|
||||
// character. Not the lower-case rule of a `${setting:<key>}`: a file's own keys are its software's
|
||||
// spelling, such as `timeZone`.
|
||||
@@ -196,7 +326,7 @@ func takenSaid(m Manifest) string {
|
||||
if how, _ := r["merge"].(string); how == "" {
|
||||
continue
|
||||
}
|
||||
keys := stringsOf(r[TakesSettingsField])
|
||||
keys := takenKeyList(r)
|
||||
if len(keys) == 0 {
|
||||
files = append(files, fmt.Sprintf("%v takes none", r["id"]))
|
||||
continue
|
||||
|
||||
@@ -37,10 +37,10 @@ type Layer struct {
|
||||
// Default marks the layer a module's own defaults make (novox/hq ADR 0262). Marked rather than
|
||||
// recognised by From, which is a node's name for a node's layer, and a node may be called anything.
|
||||
Default bool
|
||||
// Placement is the assignment's placement at this layer's scope — `places`, `accesses`, `networks`
|
||||
// — which the controller holds apart from settings (novox/hq ADR 0348 clause 11, issue 651): read
|
||||
// by Places, AccessPlaces and KeptNetworks, and never from Values. LayerOf splits a layer as a person
|
||||
// says it into the two.
|
||||
// Placement is the assignment's placement at this layer's scope — `places`, `accesses`, `networks`,
|
||||
// `endpoints`, `expose`, `reach`, `ports` — which the controller holds apart from settings (novox/hq
|
||||
// ADR 0348 clause 11, issues 651 and 736): read by their readers only, and never from Values. LayerOf
|
||||
// splits a layer as a person says it into the two.
|
||||
Placement map[string]any
|
||||
}
|
||||
|
||||
@@ -387,8 +387,7 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
|
||||
lands := settingKeysUsedBy(m)
|
||||
// A mergeable file takes the keys it names (novox/hq ADR 0326 §5).
|
||||
for _, r := range m.Resources {
|
||||
taken, _ := takenKeys(r)
|
||||
for key := range taken {
|
||||
for _, key := range takenNames(r) {
|
||||
lands[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestASettingThatCannotComposeIsRefusedByNameAndLeavesOnlyItsModuleOut(t *te
|
||||
t.Errorf("%s: judged %v, want %q", c.name, err, c.refuse)
|
||||
}
|
||||
}
|
||||
if err := JudgeSettings(web, []Layer{{From: "anchor", Values: map[string]any{PortsSetting: map[string]any{"8080": 10000}}}}, true); err != nil {
|
||||
if err := JudgeSettings(web, []Layer{{From: "anchor", Placement: map[string]any{PortsSetting: map[string]any{"8080": 10000}}}}, true); err != nil {
|
||||
t.Fatalf("a port the module publishes was refused: %v", err)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestASettingThatCannotComposeIsRefusedByNameAndLeavesOnlyItsModuleOut(t *te
|
||||
// the machine is declared.
|
||||
r := anAdoptedAnchor()
|
||||
with := anchorRendering(false)
|
||||
with.Settings = SettingsBy{"hello-web": {{From: "anchor", Values: map[string]any{PortsSetting: map[string]any{"9999": 10000}}}}}
|
||||
with.Settings = SettingsBy{"hello-web": {{From: "anchor", Placement: map[string]any{PortsSetting: map[string]any{"9999": 10000}}}}}
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -124,6 +124,14 @@ func StateProblems(m Manifest) []string {
|
||||
return problems
|
||||
}
|
||||
|
||||
// ControllerMirrors are the controller's record mirrors a module may read, each `mesh-controller.<name>`
|
||||
// (novox/hq ADR 0289, design 54 §5, design 57 F3). **The controller keeps them, not its manifest's `state`**:
|
||||
// they are asserted with the controller's own buckets, with options of their own, and written by the
|
||||
// controller's account alone (the writers table); declared as a module's state, they would be asserted a
|
||||
// second way and granted to the module's own principal as well. Named here so a read of one is a read of
|
||||
// kept state. The bus's list is broker.ControllerMirrors; a test keeps the two agreeing.
|
||||
var ControllerMirrors = []string{"assignments", "declared-config", "predefined"}
|
||||
|
||||
// StateReadsNothingDeclares is every read across a catalogue whose owner is present and declares no
|
||||
// such state. An absent owner says nothing — a module may be installed long before the one whose
|
||||
// state it reads, as a consumer may before its emitter (design 32 §1).
|
||||
@@ -136,6 +144,11 @@ func StateReadsNothingDeclares(manifests []Manifest) []string {
|
||||
}
|
||||
declared[m.Module] = own
|
||||
}
|
||||
if own, present := declared[ControllerSeatName]; present {
|
||||
for _, name := range ControllerMirrors {
|
||||
own[name] = true
|
||||
}
|
||||
}
|
||||
var problems []string
|
||||
for _, m := range manifests {
|
||||
for _, r := range m.Reads {
|
||||
|
||||
@@ -67,6 +67,21 @@ func TestAReadNamesStateItsOwnerKeeps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// **The controller's record mirrors are kept state** (design 54 §5, design 57 F3): a module may read
|
||||
// `mesh-controller.assignments` and `mesh-controller.declared-config` although the controller's manifest
|
||||
// declares no `state`; a read of anything else of the controller's is still named.
|
||||
func TestAReadOfTheControllersMirrorsIsAReadOfKeptState(t *testing.T) {
|
||||
controller := Manifest{Module: ControllerSeatName}
|
||||
reader := Manifest{Module: "mesh-config", Reads: []string{"mesh-controller.assignments", "mesh-controller.declared-config"}}
|
||||
if p := StateReadsNothingDeclares([]Manifest{controller, reader}); len(p) != 0 {
|
||||
t.Fatalf("a read of the controller's mirrors was refused: %v", p)
|
||||
}
|
||||
other := Manifest{Module: "other", Reads: []string{"mesh-controller.calls"}}
|
||||
if p := StateReadsNothingDeclares([]Manifest{controller, other}); len(p) != 1 {
|
||||
t.Fatalf("a read of the controller's own calls was not named: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// **Across the whole catalogue**: every state name is local, and every read whose owner is present
|
||||
// names state that owner keeps.
|
||||
func TestEveryManifestsStateIsLocalAndEveryReadIsKept(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module's own stream (novox/hq ADR 0363).
|
||||
//
|
||||
// A module declares a stream as it declares a log (ADR 0297) or a bucket (ADR 0201): named locally, the
|
||||
// events it carries named locally as `emits` names them, its age and its size within the mesh's bounds.
|
||||
// The controller creates it and stays the only writer of stream definitions. A stream may be restricted
|
||||
// by a trusted config value of its own module: then a consumer hears a part of it only where its own
|
||||
// `consumes` asks and that value names it.
|
||||
|
||||
// The mesh's bounds on a module's stream: its age in days and its size in MiB, and what it gets when it
|
||||
// says nothing.
|
||||
const (
|
||||
StreamLeastDays = 1
|
||||
StreamMostDays = 30
|
||||
StreamDefaultDays = 7
|
||||
StreamLeastMiB = 1
|
||||
StreamMostMiB = 8192
|
||||
StreamDefaultMiB = 1024
|
||||
)
|
||||
|
||||
// GrantKey is the name in a grant's pattern that each key of the config value fills.
|
||||
const GrantKey = "{key}"
|
||||
|
||||
// ReservedStreamPrefixes are the starts of the stream names the mesh derives, which no module's stream
|
||||
// may take: a seat's work queue, a log, a bucket, an object store, and the dead letters.
|
||||
var ReservedStreamPrefixes = []string{"SEAT_", "LOG_", "KV_", "OBJ_", "DEAD_"}
|
||||
|
||||
// MeshStreamNames are the mesh's own streams by name. Written here as well as in the broker, which this
|
||||
// package cannot import; a test keeps them agreeing.
|
||||
var MeshStreamNames = []string{"CONTROL", "NODES", "ASSIGNMENTS", "EVENTS", "DEAD_LETTER_NOTICES", "DEAD_LETTERS", "PROV_APPLIED"}
|
||||
|
||||
// StreamDeclaration is one stream a module owns.
|
||||
type StreamDeclaration struct {
|
||||
Name string `json:"name"`
|
||||
// Events are the events it carries, named locally; `*` is one name and `**`, last only, the rest.
|
||||
Events []string `json:"events"`
|
||||
// MaxAgeDays and MaxMiB bound it; zero is the default.
|
||||
MaxAgeDays int `json:"max-age-days,omitempty"`
|
||||
MaxMiB int `json:"max-mib,omitempty"`
|
||||
// Grant restricts it, when said.
|
||||
Grant *StreamGrant `json:"grant,omitempty"`
|
||||
}
|
||||
|
||||
// StreamGrant says which trusted config value of the module grants a consumer a part of the stream, and
|
||||
// which part each of its keys names.
|
||||
type StreamGrant struct {
|
||||
// Config is the trusted config value of the module that grants it (ADR 0348 §8): set only at the
|
||||
// controller's terminal or on the operator's Approve.
|
||||
Config string `json:"config"`
|
||||
// Events is a pattern holding GrantKey exactly once, which each key of the config value fills.
|
||||
Events string `json:"events"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads a stream strictly: an unknown key is refused here, not passed over.
|
||||
func (s *StreamDeclaration) UnmarshalJSON(raw []byte) error {
|
||||
type plain StreamDeclaration
|
||||
var p plain
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
return fmt.Errorf("a stream is {name, events, max-age-days, max-mib, grant}: %w", typedUnknown(err))
|
||||
}
|
||||
*s = StreamDeclaration(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads a grant strictly.
|
||||
func (g *StreamGrant) UnmarshalJSON(raw []byte) error {
|
||||
type plain StreamGrant
|
||||
var p plain
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
return fmt.Errorf("a stream's grant is {config, events}: %w", typedUnknown(err))
|
||||
}
|
||||
*g = StreamGrant(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Days is the stream's age in days, the default where none is said.
|
||||
func (s StreamDeclaration) Days() int {
|
||||
if s.MaxAgeDays == 0 {
|
||||
return StreamDefaultDays
|
||||
}
|
||||
return s.MaxAgeDays
|
||||
}
|
||||
|
||||
// Cap is the stream's size in MiB, the default where none is said.
|
||||
func (s StreamDeclaration) Cap() int {
|
||||
if s.MaxMiB == 0 {
|
||||
return StreamDefaultMiB
|
||||
}
|
||||
return s.MaxMiB
|
||||
}
|
||||
|
||||
// BusName is the stream's name on the bus: its local name in capitals, a hyphen an underscore.
|
||||
func (s StreamDeclaration) BusName() string { return StreamBusName(s.Name) }
|
||||
|
||||
// StreamBusName is a local stream name as the bus holds it.
|
||||
func StreamBusName(local string) string {
|
||||
return strings.ToUpper(strings.ReplaceAll(local, "-", "_"))
|
||||
}
|
||||
|
||||
// Carries says whether an event, or every event a pattern names, is one this stream carries: within one
|
||||
// of its patterns.
|
||||
func (s StreamDeclaration) Carries(pattern string) bool {
|
||||
for _, p := range s.Events {
|
||||
if within(pattern, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StreamProblems is what is wrong with a manifest's streams, judged on the one manifest.
|
||||
func StreamProblems(m Manifest) []string {
|
||||
if len(m.Streams) == 0 {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
if !stateName.MatchString(m.Module) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares a stream, and a module's name is part of its stream's subjects, which take one plain "+
|
||||
"name — no dot (novox/hq ADR 0363)", m.Module))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
terminal := TerminalKeys(m)
|
||||
for i, s := range m.Streams {
|
||||
switch {
|
||||
case !stateName.MatchString(s.Name):
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares stream %q: a stream is named locally — lower-case letters, digits and hyphens "+
|
||||
"(novox/hq ADR 0363)", m.Module, s.Name))
|
||||
case seen[s.BusName()]:
|
||||
problems = append(problems, fmt.Sprintf("%s declares stream %q twice", m.Module, s.Name))
|
||||
case slices.Contains(MeshStreamNames, s.BusName()):
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares stream %q, which is the mesh's own stream %s (novox/hq ADR 0363)", m.Module, s.Name, s.BusName()))
|
||||
default:
|
||||
for _, prefix := range ReservedStreamPrefixes {
|
||||
if strings.HasPrefix(s.BusName(), prefix) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares stream %q, whose name on the bus starts with %s, which the mesh derives names "+
|
||||
"with (novox/hq ADR 0363)", m.Module, s.Name, prefix))
|
||||
}
|
||||
}
|
||||
}
|
||||
seen[s.BusName()] = true
|
||||
if s.MaxAgeDays != 0 && (s.MaxAgeDays < StreamLeastDays || s.MaxAgeDays > StreamMostDays) {
|
||||
problems = append(problems, fmt.Sprintf("%s keeps stream %q %d days; a stream keeps between %d and %d "+
|
||||
"(novox/hq ADR 0363)", m.Module, s.Name, s.MaxAgeDays, StreamLeastDays, StreamMostDays))
|
||||
}
|
||||
if s.MaxMiB != 0 && (s.MaxMiB < StreamLeastMiB || s.MaxMiB > StreamMostMiB) {
|
||||
problems = append(problems, fmt.Sprintf("%s caps stream %q at %d MiB; a stream holds between %d and %d "+
|
||||
"(novox/hq ADR 0363)", m.Module, s.Name, s.MaxMiB, StreamLeastMiB, StreamMostMiB))
|
||||
}
|
||||
if len(s.Events) == 0 {
|
||||
problems = append(problems, fmt.Sprintf("%s declares stream %q with no events", m.Module, s.Name))
|
||||
}
|
||||
for _, e := range s.Events {
|
||||
if err := eventPattern(e, false); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s's stream %q carries %q: %v", m.Module, s.Name, e, err))
|
||||
}
|
||||
}
|
||||
// **An event goes to one place** (ADR 0363 §2): an emitted event a stream carries, or two streams
|
||||
// carrying one event, would leave the module's runtime to choose.
|
||||
for _, e := range m.EmitsAll() {
|
||||
if s.Carries(e) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s emits %q, which its stream %q carries: an event goes to EVENTS or to the module's stream, "+
|
||||
"so name it in one place (novox/hq ADR 0363)", m.Module, e, s.Name))
|
||||
}
|
||||
}
|
||||
for _, other := range m.Streams[i+1:] {
|
||||
for _, a := range s.Events {
|
||||
for _, b := range other.Events {
|
||||
if patternsOverlap(a, b) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's streams %q and %q both carry %q and %q: an event goes to one stream (novox/hq "+
|
||||
"ADR 0363)", m.Module, s.Name, other.Name, a, b))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if g := s.Grant; g != nil {
|
||||
if !slices.Contains(terminal, g.Config) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's stream %q is granted by %q, which is not a trusted config value of the module, set only at "+
|
||||
"the controller's terminal or on the operator's Approve: a grant of what a module hears is the "+
|
||||
"operator's (novox/hq ADR 0348 §8, ADR 0363)", m.Module, s.Name, g.Config))
|
||||
}
|
||||
if strings.Count(g.Events, GrantKey) != 1 {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's stream %q grants %q, which must hold %s exactly once, as a name of its own (novox/hq ADR 0363)",
|
||||
m.Module, s.Name, g.Events, GrantKey))
|
||||
} else if err := eventPattern(g.Events, true); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s's stream %q grants %q: %v", m.Module, s.Name, g.Events, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// eventPattern checks one local event pattern: names, `*` for one, `**` last for the rest; with key,
|
||||
// GrantKey is a name too.
|
||||
func eventPattern(pattern string, key bool) error {
|
||||
if pattern == "" {
|
||||
return fmt.Errorf("a pattern needs a name")
|
||||
}
|
||||
parts := strings.Split(pattern, ".")
|
||||
for i, part := range parts {
|
||||
switch {
|
||||
case part == TheRest:
|
||||
if i != len(parts)-1 {
|
||||
return fmt.Errorf("%q stands for the rest of a name, so nothing may follow it", TheRest)
|
||||
}
|
||||
case part == OneName:
|
||||
case key && part == GrantKey:
|
||||
case !eventName.MatchString(part):
|
||||
return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StreamsHearing is every stream of a module that carries some of a consumed pattern's events, and
|
||||
// whether one of them carries all of them.
|
||||
func (m Manifest) StreamsHearing(pattern string) (hearing []StreamDeclaration, whole bool) {
|
||||
for _, s := range m.Streams {
|
||||
for _, p := range s.Events {
|
||||
if patternsOverlap(pattern, p) {
|
||||
hearing = append(hearing, s)
|
||||
break
|
||||
}
|
||||
}
|
||||
if s.Carries(pattern) {
|
||||
whole = true
|
||||
}
|
||||
}
|
||||
return hearing, whole
|
||||
}
|
||||
|
||||
// within says every event pattern a names is one b names too.
|
||||
func within(a, b string) bool {
|
||||
x, y := strings.Split(a, "."), strings.Split(b, ".")
|
||||
for i := 0; ; i++ {
|
||||
switch {
|
||||
case i == len(y):
|
||||
return i == len(x)
|
||||
case y[i] == TheRest:
|
||||
return i < len(x)
|
||||
case i == len(x):
|
||||
return false
|
||||
case x[i] == TheRest:
|
||||
return false
|
||||
case y[i] == OneName:
|
||||
continue
|
||||
case x[i] == OneName:
|
||||
return false
|
||||
case x[i] != y[i]:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// patternsOverlap says some event both patterns name.
|
||||
func patternsOverlap(a, b string) bool {
|
||||
x, y := strings.Split(a, "."), strings.Split(b, ".")
|
||||
for i := 0; ; i++ {
|
||||
switch {
|
||||
case i == len(x) && i == len(y):
|
||||
return true
|
||||
case i == len(x) || i == len(y):
|
||||
return false
|
||||
case x[i] == TheRest || y[i] == TheRest:
|
||||
return true
|
||||
case x[i] == OneName || y[i] == OneName || x[i] == y[i]:
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StreamNameProblems are the rules about stream names no single manifest can be judged against: two
|
||||
// modules declaring one name.
|
||||
func StreamNameProblems(shelf Shelf) []string {
|
||||
var problems []string
|
||||
declaredBy := map[string]string{}
|
||||
for _, module := range shelfOrder(shelf) {
|
||||
for _, s := range shelf[module].Streams {
|
||||
name := s.BusName()
|
||||
if first, taken := declaredBy[name]; taken && first != module {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares the stream %s, which %s already declares; a stream has one owner (novox/hq ADR 0363)",
|
||||
module, name, first))
|
||||
continue
|
||||
}
|
||||
declaredBy[name] = module
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// RestrictedHearingProblems refuses a module that would hear a restricted stream without an account of its own
|
||||
// (novox/hq ADR 0376 §2): a `consumes` naming a module literally and asking for events a restricted stream of
|
||||
// that module carries, by a manifest that declares no `runs-as`. A module the machine's tool runner carries
|
||||
// reaches the bus as the tool runner's account, which every agent session there shares, so the controller would
|
||||
// compose nothing for it. Judged only where the provider is on the shelf, as a consumer may be installed before
|
||||
// its emitter; a wildcard emitter never reaches a module's stream (ADR 0363 §5) and is not judged.
|
||||
func RestrictedHearingProblems(shelf Shelf) []string {
|
||||
var problems []string
|
||||
for _, module := range shelfOrder(shelf) {
|
||||
m := shelf[module]
|
||||
if m.RunsAs != "" {
|
||||
continue
|
||||
}
|
||||
for _, c := range m.Consumes {
|
||||
emitter, rest, named := strings.Cut(c, ".")
|
||||
provider, known := shelf[emitter]
|
||||
if !named || emitter == OneName || emitter == TheRest || !known {
|
||||
continue
|
||||
}
|
||||
hearing, _ := provider.StreamsHearing(rest)
|
||||
for _, s := range hearing {
|
||||
if s.Grant == nil {
|
||||
continue
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s consumes %s, which %s's restricted stream %s carries, so %s must run as an account of its "+
|
||||
"own (runs-as): the machine's runtime runs as the operator's account, which every agent "+
|
||||
"runs as, and never hears a restricted stream (novox/hq ADR 0376)",
|
||||
module, c, emitter, s.BusName(), module))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// GrantedKeys reads a restricting config value (novox/hq ADR 0363 §4): each key whose list names the
|
||||
// module wherever it runs, or the module on this machine as `<machine>.<module>`. A value of another shape
|
||||
// grants nothing; a key that is not one usable name grants nothing.
|
||||
func GrantedKeys(value any, module, machine string) []string {
|
||||
if text, isText := value.(string); isText {
|
||||
// A layer may hold the object as its JSON text.
|
||||
var parsed any
|
||||
if json.Unmarshal([]byte(text), &parsed) != nil {
|
||||
return nil
|
||||
}
|
||||
value = parsed
|
||||
}
|
||||
entries, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for key, listed := range entries {
|
||||
if !eventName.MatchString(key) {
|
||||
continue
|
||||
}
|
||||
names, ok := listed.([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, n := range names {
|
||||
s, _ := n.(string)
|
||||
if s == module || (machine != "" && s == machine+"."+module) {
|
||||
out = append(out, key)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Granted is a grant's pattern with its key filled.
|
||||
func (g StreamGrant) Granted(key string) string { return strings.Replace(g.Events, GrantKey, key, 1) }
|
||||
|
||||
// narrow is the events both patterns name, as one pattern; false when they name none in common.
|
||||
func narrow(a, b string) (string, bool) {
|
||||
x, y := strings.Split(a, "."), strings.Split(b, ".")
|
||||
var out []string
|
||||
for i := 0; ; i++ {
|
||||
switch {
|
||||
case i == len(x) && i == len(y):
|
||||
return strings.Join(out, "."), true
|
||||
case i == len(x) || i == len(y):
|
||||
return "", false
|
||||
case x[i] == TheRest:
|
||||
return strings.Join(append(out, y[i:]...), "."), true
|
||||
case y[i] == TheRest:
|
||||
return strings.Join(append(out, x[i:]...), "."), true
|
||||
case x[i] == OneName:
|
||||
out = append(out, y[i])
|
||||
case y[i] == OneName || x[i] == y[i]:
|
||||
out = append(out, x[i])
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HeardPart is what a consumer hears of one module's stream: the patterns, local, it is granted.
|
||||
type HeardPart struct {
|
||||
Emitter string
|
||||
Stream StreamDeclaration
|
||||
Patterns []string
|
||||
}
|
||||
|
||||
// Hearing is what a consumer's `consumes` hears of the module streams the shelf declares, on one machine
|
||||
// (novox/hq ADR 0363 §5): for each stream, its `consumes` narrowed to the stream's events and, for a
|
||||
// restricted stream, to the parts the provider's trusted config value grants it. values answers a restricting config
|
||||
// value on each machine running the provider. Answered too: whether any `consumes` names a module owning a
|
||||
// stream, granted or not, and the `consumes` left for EVENTS — those naming something the provider emits
|
||||
// there.
|
||||
//
|
||||
// Only an entry naming its emitter literally reaches a module's stream: a wildcard emitter, `**` among them,
|
||||
// is EVENTS' alone, so nothing that hears every event hears a restricted stream by saying so.
|
||||
func Hearing(consumer Manifest, shelf map[string]Manifest, machine string,
|
||||
values func(provider, setting string) []any) (parts []HeardPart, touches bool, onEvents []string) {
|
||||
byStream := map[string]*HeardPart{}
|
||||
var order []string
|
||||
for _, c := range consumer.Consumes {
|
||||
emitter, rest, named := strings.Cut(c, ".")
|
||||
provider, known := shelf[emitter]
|
||||
if !named || emitter == OneName || emitter == TheRest || !known || len(provider.Streams) == 0 {
|
||||
onEvents = append(onEvents, c)
|
||||
continue
|
||||
}
|
||||
hearing, whole := provider.StreamsHearing(rest)
|
||||
if len(hearing) > 0 {
|
||||
touches = true
|
||||
}
|
||||
// Kept for EVENTS while the provider says something there it names: a stream carrying it whole, or
|
||||
// a provider that says nothing of it on EVENTS, leaves it nothing to hear there.
|
||||
if !whole && slices.ContainsFunc(provider.EmitsAll(), func(e string) bool { return patternsOverlap(rest, e) }) {
|
||||
onEvents = append(onEvents, c)
|
||||
}
|
||||
for _, s := range hearing {
|
||||
var granted []string
|
||||
if s.Grant != nil {
|
||||
for _, v := range values(emitter, s.Grant.Config) {
|
||||
for _, key := range GrantedKeys(v, consumer.Module, machine) {
|
||||
granted = append(granted, s.Grant.Granted(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
var got []string
|
||||
for _, p := range s.Events {
|
||||
x, ok := narrow(rest, p)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if s.Grant == nil {
|
||||
got = append(got, x)
|
||||
continue
|
||||
}
|
||||
for _, g := range granted {
|
||||
if y, ok := narrow(x, g); ok {
|
||||
got = append(got, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(got) == 0 {
|
||||
continue
|
||||
}
|
||||
id := emitter + "/" + s.BusName()
|
||||
if byStream[id] == nil {
|
||||
byStream[id] = &HeardPart{Emitter: emitter, Stream: s}
|
||||
order = append(order, id)
|
||||
}
|
||||
byStream[id].Patterns = append(byStream[id].Patterns, got...)
|
||||
}
|
||||
}
|
||||
for _, id := range order {
|
||||
h := byStream[id]
|
||||
h.Patterns = uncovered(h.Patterns)
|
||||
parts = append(parts, *h)
|
||||
}
|
||||
return parts, touches, onEvents
|
||||
}
|
||||
|
||||
// uncovered is a list of patterns without those another names whole, and without repeats: the bus refuses
|
||||
// a consumer whose filters overlap.
|
||||
func uncovered(patterns []string) []string {
|
||||
var out []string
|
||||
for i, p := range patterns {
|
||||
covered := false
|
||||
for j, q := range patterns {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
if within(p, q) && (!within(q, p) || j < i) {
|
||||
covered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !covered {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mailManifest is a module owning a restricted stream, as mesh-mail declares it (novox/hq ADR 0363), with
|
||||
// the trusted mergeable file that takes its grants setting; extra is spliced in at the top.
|
||||
func mailManifest(t *testing.T, streams, extra string) string {
|
||||
t.Helper()
|
||||
return `{"module":"mesh-mail","version":"1",` + extra + `"streams":` + streams + `,` +
|
||||
`"resources":[{"id":"state","type":"directory","mode":"0700","place":"."},` +
|
||||
`{"id":"grants","trusted":true,"type":"file","path":"${dir:state}/grants.json","mode":"0600",` +
|
||||
`"merge":"json","takes-settings":["grants"],"content":"{\"grants\": {}}"}]}`
|
||||
}
|
||||
|
||||
const mailStream = `[{"name":"mail","events":["mail.*.message.added","mail.*.message.changed",` +
|
||||
`"mail.*.message.removed"],"max-age-days":7,"grant":{"config":"grants","events":"mail.{key}.**"}}]`
|
||||
|
||||
// A module declares a stream of its own (ADR 0363 §1): its events with a variable name (§3), its age and
|
||||
// size with defaults, and a grant by a trusted setting of its own (§4).
|
||||
func TestAModuleDeclaresAStreamOfItsOwn(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(mailManifest(t, mailStream, "")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Streams) != 1 {
|
||||
t.Fatalf("streams not read: %+v", m.Streams)
|
||||
}
|
||||
s := m.Streams[0]
|
||||
if s.BusName() != "MAIL" || s.Days() != 7 || s.Cap() != StreamDefaultMiB || s.Grant == nil ||
|
||||
s.Grant.Config != "grants" || s.Grant.Granted("mesh") != "mail.mesh.**" {
|
||||
t.Fatalf("stream read as %+v", s)
|
||||
}
|
||||
if !s.Carries("mail.work.message.added") || s.Carries("mail.work.body") || !s.Carries("mail.*.message.removed") {
|
||||
t.Fatal("the stream's patterns do not say what it carries")
|
||||
}
|
||||
plain, err := ParseManifest([]byte(`{"module":"feed","version":"1","streams":[{"name":"feed-items","events":["item.**"]}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("a stream without a grant was refused: %v", err)
|
||||
}
|
||||
if got := plain.Streams[0]; got.BusName() != "FEED_ITEMS" || got.Days() != StreamDefaultDays || got.Grant != nil {
|
||||
t.Fatalf("defaults read as %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Registration refuses a stream the mesh could not hold or would leave ambiguous (ADR 0363 §1, §2, §4).
|
||||
func TestAStreamIsNamedBoundedAndGrantedByATrustedConfigValue(t *testing.T) {
|
||||
grants := `"grant":{"config":"grants","events":"mail.{key}.**"}`
|
||||
for _, c := range []struct{ manifest, says string }{
|
||||
{mailManifest(t, `[{"name":"Mail","events":["mail.**"]}]`, ""), `a stream is named locally`},
|
||||
{mailManifest(t, `[{"name":"mail.x","events":["mail.**"]}]`, ""), `a stream is named locally`},
|
||||
{mailManifest(t, `[{"name":"events","events":["mail.**"]}]`, ""), `the mesh's own stream EVENTS`},
|
||||
{mailManifest(t, `[{"name":"log-x","events":["mail.**"]}]`, ""), `starts with LOG_`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"]},{"name":"mail","events":["x.**"]}]`, ""), `twice`},
|
||||
{mailManifest(t, `[{"name":"mail","events":[]}]`, ""), `with no events`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**.x"]}]`, ""), `nothing may follow it`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"max-age-days":31}]`, ""), `between 1 and 30`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"max-mib":9000}]`, ""), `between 1 and 8192`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"stream":"MAIL"}]`, ""), `{name, events`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"]}]`, `"emits":["mail.mesh.message.added"],`),
|
||||
`which its stream "mail" carries`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"]},{"name":"more","events":["mail.x.*"]}]`, ""),
|
||||
`both carry`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"tags","events":"mail.{key}.**"}}]`, ""),
|
||||
`is not a trusted config value`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"grants","events":"mail.**"}}]`, ""),
|
||||
`exactly once`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"grants","events":"{key}.{key}"}}]`, ""),
|
||||
`exactly once`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"],`+grants+`,"why":1}]`, ""), `{name, events`},
|
||||
{`{"module":"a.b","version":"1","streams":[{"name":"x","events":["x"]}]}`, `no dot`},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(c.manifest))
|
||||
if err == nil {
|
||||
t.Errorf("%s was accepted", c.manifest)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s refused for the wrong reason: %v", c.manifest, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A stream name has one owner across the catalogue (ADR 0363 §1).
|
||||
func TestTwoModulesCannotDeclareOneStream(t *testing.T) {
|
||||
a := Manifest{Module: "a", Streams: []StreamDeclaration{{Name: "mail", Events: []string{"x"}}}}
|
||||
b := Manifest{Module: "b", Streams: []StreamDeclaration{{Name: "mail", Events: []string{"y"}}}}
|
||||
got := strings.Join(CatalogueProblems(Shelf{"a": a, "b": b}), "; ")
|
||||
if !strings.Contains(got, "b declares the stream MAIL, which a already declares") {
|
||||
t.Fatalf("a second owner of MAIL was not refused: %s", got)
|
||||
}
|
||||
if got := StreamNameProblems(Shelf{"a": a}); len(got) != 0 {
|
||||
t.Fatalf("one owner refused: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The setting's value names who hears each key: a module wherever it runs, or on one machine (ADR 0363 §4).
|
||||
func TestARestrictingSettingNamesAModuleOrAModuleOnAMachine(t *testing.T) {
|
||||
var value any
|
||||
if err := json.Unmarshal([]byte(`{"mesh":["reader","person.jochen"],"work":["ace.reader"],"home":["other"],`+
|
||||
`"Bad.Key":["reader"]}`), &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := GrantedKeys(value, "reader", "ace"); !slices.Equal(got, []string{"mesh", "work"}) {
|
||||
t.Fatalf("on ace reader is granted %v", got)
|
||||
}
|
||||
if got := GrantedKeys(value, "reader", "shanks"); !slices.Equal(got, []string{"mesh"}) {
|
||||
t.Fatalf("on shanks reader is granted %v", got)
|
||||
}
|
||||
if got := GrantedKeys(`{"mesh":["reader"]}`, "reader", "ace"); !slices.Equal(got, []string{"mesh"}) {
|
||||
t.Fatalf("a value held as its JSON text grants %v", got)
|
||||
}
|
||||
if got := GrantedKeys([]any{"reader"}, "reader", "ace"); len(got) != 0 {
|
||||
t.Fatalf("a value of another shape granted %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer hears a restricted stream only where its consumes asks and the setting grants it, and only
|
||||
// by naming the emitter (ADR 0363 §5); what a stream carries whole leaves EVENTS.
|
||||
func TestAConsumerHearsOnlyWhatItAsksAndIsGranted(t *testing.T) {
|
||||
provider, err := ParseManifest([]byte(mailManifest(t, mailStream, `"emits":["account.added"],`)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shelf := map[string]Manifest{"mesh-mail": provider}
|
||||
var value any
|
||||
_ = json.Unmarshal([]byte(`{"mesh":["reader"],"work":["shanks.reader"]}`), &value)
|
||||
values := func(module, setting string) []any {
|
||||
if module == "mesh-mail" && setting == "grants" {
|
||||
return []any{value}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
reader := Manifest{Module: "reader", Consumes: []string{"mesh-mail.mail.**", "mesh-mail.account.added", "billing.paid"}}
|
||||
|
||||
parts, touches, onEvents := Hearing(reader, shelf, "ace", values)
|
||||
if !touches || len(parts) != 1 || parts[0].Stream.BusName() != "MAIL" {
|
||||
t.Fatalf("heard %+v", parts)
|
||||
}
|
||||
want := []string{"mail.mesh.message.added", "mail.mesh.message.changed", "mail.mesh.message.removed"}
|
||||
if !slices.Equal(parts[0].Patterns, want) {
|
||||
t.Fatalf("on ace reader hears %v, want %v", parts[0].Patterns, want)
|
||||
}
|
||||
if !slices.Equal(onEvents, []string{"mesh-mail.account.added", "billing.paid"}) {
|
||||
t.Fatalf("left on EVENTS: %v", onEvents)
|
||||
}
|
||||
parts, _, _ = Hearing(reader, shelf, "shanks", values)
|
||||
if len(parts) != 1 || len(parts[0].Patterns) != 6 {
|
||||
t.Fatalf("on shanks reader hears %+v", parts)
|
||||
}
|
||||
|
||||
narrow := Manifest{Module: "reader", Consumes: []string{"mesh-mail.mail.*.message.added"}}
|
||||
parts, _, _ = Hearing(narrow, shelf, "ace", values)
|
||||
if len(parts) != 1 || !slices.Equal(parts[0].Patterns, []string{"mail.mesh.message.added"}) {
|
||||
t.Fatalf("a narrower consumes heard %+v", parts)
|
||||
}
|
||||
|
||||
stranger := Manifest{Module: "stranger", Consumes: []string{"mesh-mail.mail.**"}}
|
||||
parts, touches, onEvents = Hearing(stranger, shelf, "ace", values)
|
||||
if len(parts) != 0 || !touches || len(onEvents) != 0 {
|
||||
t.Fatalf("a module not granted heard %+v (touches %v, on EVENTS %v)", parts, touches, onEvents)
|
||||
}
|
||||
everything := Manifest{Module: "audit", Consumes: []string{"**", "*.mail.**"}}
|
||||
parts, touches, _ = Hearing(everything, shelf, "ace", values)
|
||||
if len(parts) != 0 || touches {
|
||||
t.Fatalf("a wildcard emitter reached a module's stream: %+v", parts)
|
||||
}
|
||||
parts, _, _ = Hearing(reader, shelf, "ace", func(string, string) []any { return nil })
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("with no setting reader heard %+v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
// A stream without a grant is heard by whoever asks, within its patterns.
|
||||
func TestAnUnrestrictedStreamIsHeardByWhoeverAsks(t *testing.T) {
|
||||
feed := Manifest{Module: "feed", Streams: []StreamDeclaration{{Name: "items", Events: []string{"item.*.added", "item.*.gone"}}}}
|
||||
reader := Manifest{Module: "reader", Consumes: []string{"feed.item.**", "feed.item.x.added"}}
|
||||
parts, touches, onEvents := Hearing(reader, map[string]Manifest{"feed": feed}, "ace", nil)
|
||||
if !touches || len(onEvents) != 0 || len(parts) != 1 ||
|
||||
!slices.Equal(parts[0].Patterns, []string{"item.*.added", "item.*.gone"}) {
|
||||
t.Fatalf("heard %+v, on EVENTS %v", parts, onEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrowingTwoPatterns(t *testing.T) {
|
||||
for _, c := range []struct{ a, b, want string }{
|
||||
{"mail.**", "mail.*.message.added", "mail.*.message.added"},
|
||||
{"mail.*.message.added", "mail.mesh.**", "mail.mesh.message.added"},
|
||||
{"a.*", "*.b", "a.b"},
|
||||
{"**", "a.**", "a.**"},
|
||||
{"a.b", "a.c", ""},
|
||||
{"a", "a.**", ""},
|
||||
} {
|
||||
got, ok := narrow(c.a, c.b)
|
||||
if (c.want == "") == ok || got != c.want {
|
||||
t.Errorf("narrow(%s, %s) = %q, %v; want %q", c.a, c.b, got, ok, c.want)
|
||||
}
|
||||
}
|
||||
if got := uncovered([]string{"a.**", "a.b", "a.b", "c"}); !slices.Equal(got, []string{"a.**", "c"}) {
|
||||
t.Fatalf("uncovered: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **A module that hears a restricted stream runs as an account of its own** (ADR 0376 §2): registration refuses a
|
||||
// `consumes` naming a module and asking for events its restricted stream carries, unless the manifest declares
|
||||
// `runs-as`; a stream without a grant, an event the provider says on EVENTS, and a wildcard emitter stay as they
|
||||
// were, and a consumer whose provider is not on the shelf is judged once it is.
|
||||
func TestHearingARestrictedStreamNeedsAnAccountOfItsOwn(t *testing.T) {
|
||||
provider, err := ParseManifest([]byte(mailManifest(t, mailStream, `"emits":["account.added"],`)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
feed := Manifest{Module: "feed", Streams: []StreamDeclaration{{Name: "items", Events: []string{"item.**"}}}}
|
||||
carried := Manifest{Module: "reader", Consumes: []string{"mesh-mail.mail.*.message.added"}}
|
||||
got := RestrictedHearingProblems(Shelf{"mesh-mail": provider, "reader": carried})
|
||||
if len(got) != 1 || !strings.Contains(got[0], "reader consumes mesh-mail.mail.*.message.added") ||
|
||||
!strings.Contains(got[0], "MAIL") || !strings.Contains(got[0], "runs-as") || !strings.Contains(got[0], "ADR 0376") {
|
||||
t.Fatalf("a carried module hearing MAIL was not refused as it should be: %v", got)
|
||||
}
|
||||
if all := strings.Join(CatalogueProblems(Shelf{"mesh-mail": provider, "reader": carried}), "; "); !strings.Contains(all, got[0]) {
|
||||
t.Fatalf("the catalogue's check does not refuse it: %s", all)
|
||||
}
|
||||
own := carried
|
||||
own.RunsAs = "reader"
|
||||
if got := RestrictedHearingProblems(Shelf{"mesh-mail": provider, "reader": own}); len(got) != 0 {
|
||||
t.Fatalf("a module of its own account was refused: %v", got)
|
||||
}
|
||||
for _, m := range []Manifest{
|
||||
{Module: "events", Consumes: []string{"mesh-mail.account.added"}},
|
||||
{Module: "items", Consumes: []string{"feed.item.**"}},
|
||||
{Module: "audit", Consumes: []string{"**", "*.mail.**"}},
|
||||
} {
|
||||
if got := RestrictedHearingProblems(Shelf{"mesh-mail": provider, "feed": feed, m.Module: m}); len(got) != 0 {
|
||||
t.Errorf("%s was refused: %v", m.Module, got)
|
||||
}
|
||||
}
|
||||
if got := RestrictedHearingProblems(Shelf{"reader": carried}); len(got) != 0 {
|
||||
t.Fatalf("judged without its provider: %v", got)
|
||||
}
|
||||
}
|
||||
+168
-48
@@ -24,6 +24,10 @@ import (
|
||||
// byte-identically to the same module with it inline. Where there is no build — `module check`, the merge gate,
|
||||
// `module add` — the text is read from the repository tree beside the manifest (WithTemplatesFrom).
|
||||
//
|
||||
// **files/ unless the manifest names another directory** (issue 731). A module whose files/ is already an archive
|
||||
// unpacked onto the machine names its own (`"templates": "templates"`), packed into an archive no resource names, so
|
||||
// its templates neither land beside what it unpacks nor change that archive's digest.
|
||||
//
|
||||
// **Read back by the builder, carried in the resolved manifest.** The builder reads each template from the very
|
||||
// bytes it hashed into the archive's digest, so the text the mesh holds is the archive's. The controller
|
||||
// registers the resolved manifest as the build result carries it and fetches nothing from the artifact store; a
|
||||
@@ -33,23 +37,58 @@ import (
|
||||
// TemplateField is the key a file resource names its template file by.
|
||||
const TemplateField = "template"
|
||||
|
||||
// TemplatesDir is the module's directory its template files are kept in, and the directory its templates archive
|
||||
// is built from.
|
||||
const TemplatesDir = "files"
|
||||
// DefaultTemplatesDir is the directory a module's template files are kept in, and its templates archive is built
|
||||
// from, when its manifest names none (Manifest.Templates, issue 731).
|
||||
const DefaultTemplatesDir = "files"
|
||||
|
||||
// Templated is whether any of the manifest's resources names a template.
|
||||
// TemplatesDir is the module's directory its template files are kept in, and the directory its templates archive is
|
||||
// built from: the one its manifest names, else files/.
|
||||
func (m Manifest) TemplatesDir() string {
|
||||
if m.Templates != "" {
|
||||
return m.Templates
|
||||
}
|
||||
return DefaultTemplatesDir
|
||||
}
|
||||
|
||||
// templatesDirProblem is what is wrong with the templates directory a manifest names, or "": one directory of the
|
||||
// module's own, a plain relative path inside it — never absolute, never leaving it, never the module's directory
|
||||
// itself — and named only by a module that names a template, since a word read by nothing is refused.
|
||||
func templatesDirProblem(m Manifest, templated bool) string {
|
||||
d := m.Templates
|
||||
if d == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(d, "/") || strings.Contains(d, "\\") || path.Clean(d) != d || d == "." || d == ".." ||
|
||||
strings.HasPrefix(d, "../") {
|
||||
return fmt.Sprintf("%s names the templates directory %q, which is not a directory of its own: a plain "+
|
||||
"relative path inside the module's directory, such as \"templates\" (issue 731)", m.Module, d)
|
||||
}
|
||||
if !templated {
|
||||
return fmt.Sprintf("%s names the templates directory %q and names no template: a file resource names its "+
|
||||
"template as %q, or the manifest says no templates directory", m.Module, d, d+"/<name>")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Templated is whether any of the manifest's resources, or any item it contributes (collections.go), names a
|
||||
// template.
|
||||
func (m Manifest) Templated() bool {
|
||||
for _, r := range m.Resources {
|
||||
if _, has := r[TemplateField]; has {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, it := range m.Items {
|
||||
if it.Template != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TemplatesArtifact is the name of the archive artifact the module packs its templates into — the one built from
|
||||
// files/ — or "" when it declares none, or more than one (both refused by templateProblems for a module naming a
|
||||
// template).
|
||||
// TemplatesArtifact is the name of the archive artifact the module packs its templates into — the one built from its
|
||||
// templates directory (TemplatesDir) — or "" when it declares none, or more than one (both refused by templateProblems
|
||||
// for a module naming a template).
|
||||
func TemplatesArtifact(m Manifest) string {
|
||||
found := templatesArtifacts(m)
|
||||
if len(found) != 1 {
|
||||
@@ -64,7 +103,7 @@ func templatesArtifacts(m Manifest) []string {
|
||||
}
|
||||
var found []string
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Kind == ArtifactArchive && a.URL == "" && cleanFrom(a.From) == TemplatesDir {
|
||||
if a.Kind == ArtifactArchive && a.URL == "" && cleanFrom(a.From) == m.TemplatesDir() {
|
||||
found = append(found, a.Name)
|
||||
}
|
||||
}
|
||||
@@ -77,8 +116,9 @@ func cleanFrom(from string) string {
|
||||
return strings.TrimPrefix(c, "./")
|
||||
}
|
||||
|
||||
// templateOf is the template a resource names, whether it names one, and what is wrong with it ("" when nothing).
|
||||
func templateOf(module string, r map[string]any) (string, bool, string) {
|
||||
// templateOf is the template a resource names, whether it names one, and what is wrong with it ("" when nothing):
|
||||
// dir is the module's templates directory.
|
||||
func templateOf(module, dir string, r map[string]any) (string, bool, string) {
|
||||
raw, has := r[TemplateField]
|
||||
if !has {
|
||||
return "", false, ""
|
||||
@@ -91,31 +131,34 @@ func templateOf(module string, r map[string]any) (string, bool, string) {
|
||||
name, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(name) == "" {
|
||||
return "", true, fmt.Sprintf("%s: %s names a template that is not a path: %v; a template is %q",
|
||||
module, id, raw, TemplatesDir+"/<name>")
|
||||
module, id, raw, dir+"/<name>")
|
||||
}
|
||||
if _, both := r["content"]; both {
|
||||
return name, true, fmt.Sprintf("%s: %s names the template %q and says its content too; a file's text is "+
|
||||
"in one place, the template or the content, never both (novox/hq ADR 0349 §2)", module, id, name)
|
||||
}
|
||||
if strings.HasPrefix(name, "/") || path.Clean(name) != name || !strings.HasPrefix(name, TemplatesDir+"/") ||
|
||||
if strings.HasPrefix(name, "/") || path.Clean(name) != name || !strings.HasPrefix(name, dir+"/") ||
|
||||
strings.Contains(name, "\\") {
|
||||
return name, true, fmt.Sprintf("%s: %s names the template %q, which is not a file under %s/ of its own "+
|
||||
"repository: a template is %q, a plain relative path, and the build packs only %s/",
|
||||
module, id, name, TemplatesDir, TemplatesDir+"/<name>", TemplatesDir)
|
||||
module, id, name, dir, dir+"/<name>", dir)
|
||||
}
|
||||
return name, true, ""
|
||||
}
|
||||
|
||||
// templateProblems is what is wrong with the manifest's templates, as written: a template on something not a file,
|
||||
// beside content, outside files/, or a module naming templates whose build packs no archive from files/. Whether
|
||||
// each template exists is judged where a tree is (ReadTemplates) or the archive's text is (Resolve).
|
||||
// beside content, or outside the module's templates directory; a templates directory that is not one, or that no
|
||||
// template is in; or a module naming templates whose build packs no archive from that directory. Whether each
|
||||
// template exists is judged where a tree is (ReadTemplates) or the archive's text is (Resolve).
|
||||
func templateProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
templated := false
|
||||
templated := m.Templated()
|
||||
if wrong := templatesDirProblem(m, templated); wrong != "" {
|
||||
return append(problems, wrong)
|
||||
}
|
||||
dir := m.TemplatesDir()
|
||||
for _, r := range m.Resources {
|
||||
_, has, wrong := templateOf(m.Module, r)
|
||||
templated = templated || has
|
||||
if wrong != "" {
|
||||
if _, _, wrong := templateOf(m.Module, dir, r); wrong != "" {
|
||||
problems = append(problems, wrong)
|
||||
}
|
||||
}
|
||||
@@ -125,26 +168,26 @@ func templateProblems(m Manifest) []string {
|
||||
switch found := templatesArtifacts(m); len(found) {
|
||||
case 0:
|
||||
problems = append(problems, fmt.Sprintf("%s names templates and its build packs no archive from %s/: "+
|
||||
"declare an artifact {\"name\": \"files\", \"kind\": \"archive\", \"from\": %q} (novox/hq ADR 0349 §2)",
|
||||
m.Module, TemplatesDir, TemplatesDir))
|
||||
"declare an artifact {\"name\": %q, \"kind\": \"archive\", \"from\": %q} (novox/hq ADR 0349 §2)",
|
||||
m.Module, dir, path.Base(dir), dir))
|
||||
case 1:
|
||||
default:
|
||||
sort.Strings(found)
|
||||
problems = append(problems, fmt.Sprintf("%s packs %s/ into %s; its templates are read from one archive",
|
||||
m.Module, TemplatesDir, strings.Join(found, " and ")))
|
||||
m.Module, dir, strings.Join(found, " and ")))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// fillTemplates is resources with each template's text as its content and the template key dropped: text says the
|
||||
// text of a template by its path ("files/<name>"), and whether it has one. A template it has no text for, or text
|
||||
// that is not UTF-8, is refused by name. Every resource is copied; the manifest's own are left as they were.
|
||||
func fillTemplates(module string, resources []map[string]any, text func(string) (string, bool),
|
||||
// fillTemplates is resources with each template's text as its content and the template key dropped: text says the text
|
||||
// of a template by its path ("<templates directory>/<name>"), and whether it has one. A template it has no text for, or
|
||||
// text that is not UTF-8, is refused by name. Every resource is copied; the manifest's own are left as they were.
|
||||
func fillTemplates(module, dir string, resources []map[string]any, text func(string) (string, bool),
|
||||
where string) ([]map[string]any, error) {
|
||||
out := make([]map[string]any, 0, len(resources))
|
||||
var problems []string
|
||||
for _, r := range resources {
|
||||
name, has, wrong := templateOf(module, r)
|
||||
name, has, wrong := templateOf(module, dir, r)
|
||||
if !has {
|
||||
out = append(out, r)
|
||||
continue
|
||||
@@ -179,24 +222,78 @@ func fillTemplates(module string, resources []map[string]any, text func(string)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadTemplates is the text of each template the manifest names, read from the module's directory in a repository
|
||||
// tree, by template path. Refused by name: a template the directory lacks, and anything under files/ the build's
|
||||
// packing refuses — files/ itself, or any directory or file under it, that is a symlink or not a plain file — so
|
||||
// nothing judged here can be read from outside the tree, and no tree passes here that the build would refuse. Each
|
||||
// template is read through an os.Root of the module's directory besides, which no path element can leave.
|
||||
// fillItemTemplates is items with each template's text as its content and the template dropped, as fillTemplates
|
||||
// does for files (collections.go).
|
||||
func fillItemTemplates(m Manifest, items []Item, text func(string) (string, bool), where string) ([]Item, error) {
|
||||
if len(items) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
out := make([]Item, 0, len(items))
|
||||
var problems []string
|
||||
for _, it := range items {
|
||||
if it.Template == "" {
|
||||
out = append(out, it)
|
||||
continue
|
||||
}
|
||||
if wrong := itemTemplateProblem(m, it.Template); wrong != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s's item of %s: %s", m.Module, it.Collection, wrong))
|
||||
continue
|
||||
}
|
||||
body, ok := text(it.Template)
|
||||
switch {
|
||||
case !ok:
|
||||
problems = append(problems, fmt.Sprintf("%s: its item of %s names the template %q, and %s holds no "+
|
||||
"such file", m.Module, it.Collection, it.Template, where))
|
||||
continue
|
||||
case !utf8.ValidString(body):
|
||||
problems = append(problems, fmt.Sprintf("%s: its item of %s names the template %q, which is not UTF-8 "+
|
||||
"text", m.Module, it.Collection, it.Template))
|
||||
continue
|
||||
}
|
||||
it.Template, it.Content = "", body
|
||||
out = append(out, it)
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return nil, errors.New(strings.Join(problems, "; "))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadTemplates is the text of each template the manifest names, read from the module's directory in a repository tree,
|
||||
// by template path. Refused by name: a template the directory lacks, and anything under its templates directory
|
||||
// (TemplatesDir) the build's packing refuses — that directory itself, or any directory or file under it, that is a
|
||||
// symlink or not a plain file — so nothing judged here can be read from outside the tree, and no tree passes here that
|
||||
// the build would refuse. Each template is read through an os.Root of the module's directory besides, which no path
|
||||
// element can leave.
|
||||
func ReadTemplates(m Manifest, dir string) (map[string]string, error) {
|
||||
problems := templatesTreeProblems(m.Module, dir)
|
||||
problems := templatesTreeProblems(m.Module, dir, m.TemplatesDir())
|
||||
root, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: the module's directory cannot be opened to read its templates: %w", m.Module, err)
|
||||
}
|
||||
defer root.Close()
|
||||
texts := map[string]string{}
|
||||
type named struct {
|
||||
id any
|
||||
template string
|
||||
wrong string
|
||||
}
|
||||
var templates []named
|
||||
for _, r := range m.Resources {
|
||||
name, has, wrong := templateOf(m.Module, r)
|
||||
if !has {
|
||||
continue
|
||||
name, has, wrong := templateOf(m.Module, m.TemplatesDir(), r)
|
||||
if has {
|
||||
templates = append(templates, named{r["id"], name, wrong})
|
||||
}
|
||||
}
|
||||
for _, it := range m.Items {
|
||||
if it.Template != "" {
|
||||
templates = append(templates, named{"its item of " + it.key(), it.Template,
|
||||
itemTemplateProblem(m, it.Template)})
|
||||
}
|
||||
}
|
||||
for _, t := range templates {
|
||||
r := map[string]any{"id": t.id}
|
||||
name, wrong := t.template, t.wrong
|
||||
if wrong != "" {
|
||||
problems = append(problems, wrong)
|
||||
continue
|
||||
@@ -233,22 +330,22 @@ func ReadTemplates(m Manifest, dir string) (map[string]string, error) {
|
||||
return texts, nil
|
||||
}
|
||||
|
||||
// templatesTreeProblems is everything under the module's files/ that the build's packing refuses: files/ itself not
|
||||
// a plain directory, and any entry under it that is neither a directory nor a plain file — a symlink, to a file or a
|
||||
// directory, a device, a pipe. Each said by its path. A files/ that does not exist is no problem here: each template
|
||||
// it would have held is refused as missing.
|
||||
func templatesTreeProblems(module, dir string) []string {
|
||||
top := filepath.Join(dir, TemplatesDir)
|
||||
// templatesTreeProblems is everything under the module's templates directory (templates, inside dir) that the
|
||||
// build's packing refuses: that directory itself not a plain directory, and any entry under it that is neither a
|
||||
// directory nor a plain file — a symlink, to a file or a directory, a device, a pipe. Each said by its path. A
|
||||
// templates directory that does not exist is no problem here: each template it would have held is refused as missing.
|
||||
func templatesTreeProblems(module, dir, templates string) []string {
|
||||
top := filepath.Join(dir, filepath.FromSlash(templates))
|
||||
info, err := os.Lstat(top)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf("%s: %s/ cannot be read: %v", module, TemplatesDir, err)}
|
||||
return []string{fmt.Sprintf("%s: %s/ cannot be read: %v", module, templates, err)}
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return []string{fmt.Sprintf("%s: %s/ is %s, not a directory of the module's own; its templates are read "+
|
||||
"only from inside the repository, and the build packs only directories and files", module, TemplatesDir,
|
||||
"only from inside the repository, and the build packs only directories and files", module, templates,
|
||||
kindOf(info.Mode()))}
|
||||
}
|
||||
var problems []string
|
||||
@@ -261,11 +358,11 @@ func templatesTreeProblems(module, dir string) []string {
|
||||
}
|
||||
rel, _ := filepath.Rel(dir, at)
|
||||
problems = append(problems, fmt.Sprintf("%s: %s is %s; the build packs only directories and files, so "+
|
||||
"%s/ holds nothing else", module, filepath.ToSlash(rel), kindOf(d.Type()), TemplatesDir))
|
||||
"%s/ holds nothing else", module, filepath.ToSlash(rel), kindOf(d.Type()), templates))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s: %s/ cannot be read: %v", module, TemplatesDir, err))
|
||||
problems = append(problems, fmt.Sprintf("%s: %s/ cannot be read: %v", module, templates, err))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
@@ -299,7 +396,14 @@ func WithTemplatesFrom(m Manifest, dir string) (Manifest, error) {
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
filled, err := fillTemplates(m.Module, m.Resources, func(name string) (string, bool) {
|
||||
filled, err := fillTemplates(m.Module, m.TemplatesDir(), m.Resources, func(name string) (string, bool) {
|
||||
t, ok := texts[name]
|
||||
return t, ok
|
||||
}, "the module's directory")
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
items, err := fillItemTemplates(m, m.Items, func(name string) (string, bool) {
|
||||
t, ok := texts[name]
|
||||
return t, ok
|
||||
}, "the module's directory")
|
||||
@@ -308,11 +412,22 @@ func WithTemplatesFrom(m Manifest, dir string) (Manifest, error) {
|
||||
}
|
||||
out := m
|
||||
out.Resources = filled
|
||||
out.Items = items
|
||||
// Nothing names a template now, so nothing names its directory.
|
||||
out.Templates = ""
|
||||
// A moved module's mark was judged above, while it still named its templates; read again with its text inline,
|
||||
// it is judged as it composes without its build's templates archive — as before, nothing handed (handed.go).
|
||||
out.Moved = false
|
||||
raw, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
return parseManifest(raw, m.predefined)
|
||||
again, err := parseManifest(raw, m.predefined)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
again.Moved = m.Moved
|
||||
return again, nil
|
||||
}
|
||||
|
||||
// HeldTemplateProblem is why a manifest cannot be held by the mesh because it still names a template, or "": the
|
||||
@@ -325,6 +440,11 @@ func HeldTemplateProblem(m Manifest) string {
|
||||
named = append(named, fmt.Sprintf("%v (%v)", r["id"], t))
|
||||
}
|
||||
}
|
||||
for _, it := range m.Items {
|
||||
if it.Template != "" {
|
||||
named = append(named, fmt.Sprintf("its item of %s (%s)", it.Collection, it.Template))
|
||||
}
|
||||
}
|
||||
if len(named) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module may name the directory its templates are kept in (`"templates": "<dir>"`, default files/; issue 731): a
|
||||
// module whose files/ is already an archive unpacked onto the machine keeps its templates in a directory of their
|
||||
// own, packed into an archive no resource names, so its unpacked files/ is exactly what it was.
|
||||
|
||||
// The two forms of one module whose files/ is unpacked onto the machine: its text inline, and in templates/.
|
||||
const (
|
||||
unpackingInline = `{"module":"tidy","version":"1",
|
||||
"build":{"artifacts":[{"name":"scripts","kind":"archive","from":"files"}]},
|
||||
"resources":[
|
||||
{"id":"scripts","type":"archive","path":"/usr/local/lib/tidy","artifact":"scripts"},
|
||||
{"id":"conf","type":"directory","path":"${dir:conf}"},
|
||||
{"id":"main","type":"file","path":"/etc/tidy.conf","mode":"0644","content":"` + mainText + `"},
|
||||
{"id":"extra","type":"file","path":"/etc/extra.conf","content":"` + extraText + `"}]}`
|
||||
unpackingTemplated = `{"module":"tidy","version":"1",
|
||||
"templates":"templates",
|
||||
"build":{"artifacts":[{"name":"scripts","kind":"archive","from":"files"},
|
||||
{"name":"templates","kind":"archive","from":"templates"}]},
|
||||
"resources":[
|
||||
{"id":"scripts","type":"archive","path":"/usr/local/lib/tidy","artifact":"scripts"},
|
||||
{"id":"conf","type":"directory","path":"${dir:conf}"},
|
||||
{"id":"main","type":"file","path":"/etc/tidy.conf","mode":"0644","template":"templates/tidy.conf"},
|
||||
{"id":"extra","type":"file","path":"/etc/extra.conf","template":"templates/deeper/extra.conf"}]}`
|
||||
)
|
||||
|
||||
func builtUnpacking(templates map[string]string) []Built {
|
||||
built := []Built{{Name: "scripts", Kind: ArtifactArchive,
|
||||
Reference: "https://store.invalid/scripts.tar.gz",
|
||||
Digest: "sha256:" + strings.Repeat("a", 64)}}
|
||||
if templates != nil {
|
||||
built = append(built, Built{Name: "templates", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + "tidy/templates/blobs/sha256:" + strings.Repeat("b", 64),
|
||||
Digest: "sha256:" + strings.Repeat("b", 64), Texts: templates})
|
||||
}
|
||||
return built
|
||||
}
|
||||
|
||||
func TestAModuleWithItsOwnTemplatesDirectoryComposesByteIdenticallyToItsInlineTwin(t *testing.T) {
|
||||
inline := composed(t, held(t, unpackingInline, builtUnpacking(nil)))
|
||||
templated := composed(t, held(t, unpackingTemplated, builtUnpacking(templateTexts(t))))
|
||||
if inline != templated {
|
||||
t.Fatalf("the two forms compose differently:\ninline: %s\ntemplated: %s", inline, templated)
|
||||
}
|
||||
// The unpacked archive is the files/ one, by its digest; the templates archive reaches no machine.
|
||||
if !strings.Contains(templated, strings.Repeat("a", 64)) || strings.Contains(templated, strings.Repeat("b", 64)) {
|
||||
t.Fatalf("the composition does not unpack files/ alone: %s", templated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheManifestTheMeshHoldsNamesNoTemplatesDirectory(t *testing.T) {
|
||||
m := held(t, unpackingTemplated, builtUnpacking(templateTexts(t)))
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `"templates"`) {
|
||||
t.Fatalf("the held manifest still names its templates directory: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleWithItsOwnTemplatesDirectoryIsReadFromItsTree(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for name, text := range templateTexts(t) {
|
||||
full := filepath.Join(dir, "templates", filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// files/ holds what the machine unpacks, a symlink among it judged by nobody here: it is not the templates.
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files", "bin"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "files", "bin", "tidy"), []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := ParseManifest([]byte(unpackingTemplated))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filled, err := WithTemplatesFrom(m, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := filled.Resolve(builtUnpacking(map[string]string{}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := composed(t, resolved), composed(t, held(t, unpackingInline, builtUnpacking(nil))); got != want {
|
||||
t.Fatalf("read from the tree, the module composes differently:\ninline: %s\ntemplated: %s", want, got)
|
||||
}
|
||||
|
||||
// A symlink under the templates directory is refused by its path, as under files/.
|
||||
if err := os.Symlink("/etc/hostname", filepath.Join(dir, "templates", "elsewhere")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := WithTemplatesFrom(m, dir); err == nil || !strings.Contains(err.Error(), "templates/elsewhere is a symlink") {
|
||||
t.Fatalf("a symlink under templates/ was not refused by its path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplateOutsideTheNamedTemplatesDirectoryIsRefused(t *testing.T) {
|
||||
raw := strings.Replace(unpackingTemplated, `"template":"templates/tidy.conf"`, `"template":"files/tidy.conf"`, 1)
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "files/tidy.conf") ||
|
||||
!strings.Contains(err.Error(), "templates/") {
|
||||
t.Fatalf("a template outside the named templates directory was not refused by name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplatesDirectoryNoArchiveIsBuiltFromIsRefused(t *testing.T) {
|
||||
raw := strings.Replace(unpackingTemplated, `,
|
||||
{"name":"templates","kind":"archive","from":"templates"}`, "", 1)
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "no archive from templates/") {
|
||||
t.Fatalf("a module whose build packs no archive from its templates directory was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplatesDirectoryOnAModuleNamingNoTemplateIsRefused(t *testing.T) {
|
||||
raw := strings.Replace(unpackingInline, `"version":"1",`, `"version":"1","templates":"templates",`, 1)
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "names no template") {
|
||||
t.Fatalf("a templates directory read by nothing was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplatesDirectoryThatIsNotAPlainRelativeDirectoryIsRefused(t *testing.T) {
|
||||
for _, dir := range []string{"/etc", "../other", "..", "templates/../files", "./templates", ".", "a\\b", "templates/"} {
|
||||
raw := strings.Replace(unpackingTemplated, `"templates":"templates"`, `"templates":`+jsonString(dir), 1)
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "templates directory") {
|
||||
t.Errorf("the templates directory %q was not refused: %v", dir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplatesDirectoryNamedFilesIsTheDefault(t *testing.T) {
|
||||
raw := strings.Replace(templatedTwin, `"version":"1",`, `"version":"1","templates":"files",`, 1)
|
||||
if got, want := composed(t, held(t, raw, builtFiles(templateTexts(t)))),
|
||||
composed(t, held(t, inlineTwin, builtFiles(nil))); got != want {
|
||||
t.Fatalf("naming files/ as the templates directory composes differently:\n%s\n%s", got, want)
|
||||
}
|
||||
}
|
||||
@@ -145,11 +145,10 @@ func asksFor(r map[string]any) []string {
|
||||
content, _ := r["content"].(string)
|
||||
asked := settingsUsed(content)
|
||||
if how, _ := r["merge"].(string); how != "" {
|
||||
if taken, names := takenKeys(r); names {
|
||||
// The file takes exactly the keys it names (ADR 0326), whatever its content holds.
|
||||
for key := range taken {
|
||||
asked = append(asked, key)
|
||||
}
|
||||
if _, names := takenKeys(r); names {
|
||||
// The file takes exactly the keys it names (ADR 0326), whatever its content holds, and a renamed
|
||||
// one under the file's own spelling too, which a layer said before its module moved (issue 751).
|
||||
asked = append(asked, takenNames(r)...)
|
||||
sort.Strings(asked)
|
||||
return asked
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[[block]]
|
||||
block = "battery"
|
||||
format = "$icon $percentage{ $time|}"
|
||||
charging_format = "$icon $percentage{ $time|}"
|
||||
not_charging_format = "$icon $percentage"
|
||||
full_format = "$icon $percentage"
|
||||
empty_format = "$icon $percentage"
|
||||
missing_format = ""
|
||||
@@ -0,0 +1,5 @@
|
||||
[[block]]
|
||||
block = "custom"
|
||||
command = "~/.local/bin/i3status-value power.draw ${machine:name}"
|
||||
interval = 5
|
||||
hide_when_empty = true
|
||||
@@ -0,0 +1,119 @@
|
||||
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
|
||||
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
|
||||
# person's devices left out: a block that depends on the hardware is contributed by the module that
|
||||
# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written
|
||||
# here works the same on any machine.
|
||||
icons_format = "{icon}"
|
||||
|
||||
[theme]
|
||||
theme = "plain"
|
||||
[theme.overrides]
|
||||
idle_bg = "#000000"
|
||||
idle_fg = "#f1f1f1"
|
||||
info_bg = "#000000"
|
||||
info_fg = "#f1f1f1"
|
||||
good_bg = "#000000"
|
||||
good_fg = "#859900"
|
||||
warning_bg = "#000000"
|
||||
warning_fg = "#de5200"
|
||||
critical_bg = "#000000"
|
||||
critical_fg = "#ff0000"
|
||||
separator = " | "
|
||||
separator_fg = "#ffffff"
|
||||
separator_bg = "#000000"
|
||||
|
||||
[icons]
|
||||
icons = "custom-icons"
|
||||
|
||||
[[block]]
|
||||
block = "tea_timer"
|
||||
format = "$icon{ $minutes:$seconds|}"
|
||||
done_cmd = "notify-send 'Timer finished'"
|
||||
|
||||
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
|
||||
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
|
||||
# list, in the launcher's menu.
|
||||
[[block]]
|
||||
block = "custom"
|
||||
command = "~/.local/bin/i3status-updates 10"
|
||||
json = true
|
||||
interval = 1800
|
||||
[[block.click]]
|
||||
button = "left"
|
||||
cmd = "checkupdates | dmenu -l 20 -p Updates"
|
||||
|
||||
[[block]]
|
||||
block = "cpu"
|
||||
|
||||
[[block]]
|
||||
block = "disk_space"
|
||||
path = "/"
|
||||
info_type = "used"
|
||||
alert_unit = "GB"
|
||||
interval = 20
|
||||
warning = 850
|
||||
alert = 920
|
||||
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
|
||||
|
||||
[[block]]
|
||||
block = "disk_space"
|
||||
path = "/home"
|
||||
info_type = "used"
|
||||
alert_unit = "GB"
|
||||
interval = 20
|
||||
warning = 850
|
||||
alert = 920
|
||||
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
|
||||
|
||||
[[block]]
|
||||
block = "memory"
|
||||
# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure
|
||||
# module judges by. Counting the cache as used read near full on a machine with room to spare.
|
||||
format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"
|
||||
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
|
||||
|
||||
# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from
|
||||
# what they show by this module's template, and only on a machine that has what they show.
|
||||
|
||||
[[block]]
|
||||
block = "docker"
|
||||
interval = 2
|
||||
format = "$icon $running/$total"
|
||||
|
||||
[[block]]
|
||||
block = "sound"
|
||||
[[block.click]]
|
||||
button = "left"
|
||||
cmd = "pavucontrol"
|
||||
|
||||
# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and
|
||||
# power draw, where the machine has them.
|
||||
# power
|
||||
[[block]]
|
||||
block = "battery"
|
||||
format = "$icon $percentage{ $time|}"
|
||||
charging_format = "$icon $percentage{ $time|}"
|
||||
not_charging_format = "$icon $percentage"
|
||||
full_format = "$icon $percentage"
|
||||
empty_format = "$icon $percentage"
|
||||
missing_format = ""
|
||||
|
||||
|
||||
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
|
||||
[[block]]
|
||||
block = "weather"
|
||||
format = "$icon $weather_verbose ($location) $temp"
|
||||
autolocate = true
|
||||
autolocate_interval = 600
|
||||
[block.service]
|
||||
name = "metno"
|
||||
|
||||
[[block]]
|
||||
block = "notify"
|
||||
driver = "dunst"
|
||||
format = " $icon {($notification_count.eng(w:1))|}"
|
||||
|
||||
[[block]]
|
||||
block = "time"
|
||||
interval = 1
|
||||
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
|
||||
@@ -0,0 +1,36 @@
|
||||
# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.
|
||||
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
|
||||
icons_format = "{icon}"
|
||||
|
||||
[theme]
|
||||
theme = "plain"
|
||||
[theme.overrides]
|
||||
idle_bg = "#000000"
|
||||
idle_fg = "#f1f1f1"
|
||||
info_bg = "#000000"
|
||||
info_fg = "#f1f1f1"
|
||||
good_bg = "#000000"
|
||||
good_fg = "#859900"
|
||||
warning_bg = "#000000"
|
||||
warning_fg = "#de5200"
|
||||
critical_bg = "#000000"
|
||||
critical_fg = "#ff0000"
|
||||
separator = " | "
|
||||
separator_fg = "#ffffff"
|
||||
separator_bg = "#000000"
|
||||
|
||||
[icons]
|
||||
icons = "custom-icons"
|
||||
|
||||
[[block]]
|
||||
block = "focused_window"
|
||||
[block.format]
|
||||
full = " $title.str(max_w:50) |"
|
||||
short = " $title.str(max_w:100) |"
|
||||
|
||||
[[block]]
|
||||
block = "uptime"
|
||||
interval = 60
|
||||
|
||||
# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept
|
||||
# so that one that does is never dropped.
|
||||
@@ -0,0 +1,126 @@
|
||||
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
|
||||
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
|
||||
# person's devices left out: a block that depends on the hardware is contributed by the module that
|
||||
# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written
|
||||
# here works the same on any machine.
|
||||
icons_format = "{icon}"
|
||||
|
||||
[theme]
|
||||
theme = "plain"
|
||||
[theme.overrides]
|
||||
idle_bg = "#000000"
|
||||
idle_fg = "#f1f1f1"
|
||||
info_bg = "#000000"
|
||||
info_fg = "#f1f1f1"
|
||||
good_bg = "#000000"
|
||||
good_fg = "#859900"
|
||||
warning_bg = "#000000"
|
||||
warning_fg = "#de5200"
|
||||
critical_bg = "#000000"
|
||||
critical_fg = "#ff0000"
|
||||
separator = " | "
|
||||
separator_fg = "#ffffff"
|
||||
separator_bg = "#000000"
|
||||
|
||||
[icons]
|
||||
icons = "custom-icons"
|
||||
|
||||
[[block]]
|
||||
block = "tea_timer"
|
||||
format = "$icon{ $minutes:$seconds|}"
|
||||
done_cmd = "notify-send 'Timer finished'"
|
||||
|
||||
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
|
||||
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
|
||||
# list, in the launcher's menu.
|
||||
[[block]]
|
||||
block = "custom"
|
||||
command = "~/.local/bin/i3status-updates 10"
|
||||
json = true
|
||||
interval = 1800
|
||||
[[block.click]]
|
||||
button = "left"
|
||||
cmd = "checkupdates | dmenu -l 20 -p Updates"
|
||||
|
||||
[[block]]
|
||||
block = "cpu"
|
||||
|
||||
[[block]]
|
||||
block = "disk_space"
|
||||
path = "/"
|
||||
info_type = "used"
|
||||
alert_unit = "GB"
|
||||
interval = 20
|
||||
warning = 850
|
||||
alert = 920
|
||||
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
|
||||
|
||||
[[block]]
|
||||
block = "disk_space"
|
||||
path = "/home"
|
||||
info_type = "used"
|
||||
alert_unit = "GB"
|
||||
interval = 20
|
||||
warning = 850
|
||||
alert = 920
|
||||
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
|
||||
|
||||
[[block]]
|
||||
block = "memory"
|
||||
# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure
|
||||
# module judges by. Counting the cache as used read near full on a machine with room to spare.
|
||||
format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"
|
||||
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
|
||||
|
||||
# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from
|
||||
# what they show by this module's template, and only on a machine that has what they show.
|
||||
|
||||
[[block]]
|
||||
block = "docker"
|
||||
interval = 2
|
||||
format = "$icon $running/$total"
|
||||
|
||||
[[block]]
|
||||
block = "sound"
|
||||
[[block.click]]
|
||||
button = "left"
|
||||
cmd = "pavucontrol"
|
||||
|
||||
# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and
|
||||
# power draw, where the machine has them.
|
||||
# power
|
||||
[[block]]
|
||||
block = "battery"
|
||||
format = "$icon $percentage{ $time|}"
|
||||
charging_format = "$icon $percentage{ $time|}"
|
||||
not_charging_format = "$icon $percentage"
|
||||
full_format = "$icon $percentage"
|
||||
empty_format = "$icon $percentage"
|
||||
missing_format = ""
|
||||
|
||||
# power
|
||||
[[block]]
|
||||
block = "custom"
|
||||
command = "~/.local/bin/i3status-value power.draw workstation"
|
||||
interval = 5
|
||||
hide_when_empty = true
|
||||
|
||||
|
||||
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
|
||||
[[block]]
|
||||
block = "weather"
|
||||
format = "$icon $weather_verbose ($location) $temp"
|
||||
autolocate = true
|
||||
autolocate_interval = 600
|
||||
[block.service]
|
||||
name = "metno"
|
||||
|
||||
[[block]]
|
||||
block = "notify"
|
||||
driver = "dunst"
|
||||
format = " $icon {($notification_count.eng(w:1))|}"
|
||||
|
||||
[[block]]
|
||||
block = "time"
|
||||
interval = 1
|
||||
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
|
||||
@@ -0,0 +1,36 @@
|
||||
# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.
|
||||
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
|
||||
icons_format = "{icon}"
|
||||
|
||||
[theme]
|
||||
theme = "plain"
|
||||
[theme.overrides]
|
||||
idle_bg = "#000000"
|
||||
idle_fg = "#f1f1f1"
|
||||
info_bg = "#000000"
|
||||
info_fg = "#f1f1f1"
|
||||
good_bg = "#000000"
|
||||
good_fg = "#859900"
|
||||
warning_bg = "#000000"
|
||||
warning_fg = "#de5200"
|
||||
critical_bg = "#000000"
|
||||
critical_fg = "#ff0000"
|
||||
separator = " | "
|
||||
separator_fg = "#ffffff"
|
||||
separator_bg = "#000000"
|
||||
|
||||
[icons]
|
||||
icons = "custom-icons"
|
||||
|
||||
[[block]]
|
||||
block = "focused_window"
|
||||
[block.format]
|
||||
full = " $title.str(max_w:50) |"
|
||||
short = " $title.str(max_w:100) |"
|
||||
|
||||
[[block]]
|
||||
block = "uptime"
|
||||
interval = 60
|
||||
|
||||
# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept
|
||||
# so that one that does is never dropped.
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
|
||||
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
|
||||
# person's devices left out: a block that depends on the hardware is contributed by the module that
|
||||
# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written
|
||||
# here works the same on any machine.
|
||||
icons_format = "{icon}"
|
||||
|
||||
[theme]
|
||||
theme = "plain"
|
||||
[theme.overrides]
|
||||
idle_bg = "#000000"
|
||||
idle_fg = "#f1f1f1"
|
||||
info_bg = "#000000"
|
||||
info_fg = "#f1f1f1"
|
||||
good_bg = "#000000"
|
||||
good_fg = "#859900"
|
||||
warning_bg = "#000000"
|
||||
warning_fg = "#de5200"
|
||||
critical_bg = "#000000"
|
||||
critical_fg = "#ff0000"
|
||||
separator = " | "
|
||||
separator_fg = "#ffffff"
|
||||
separator_bg = "#000000"
|
||||
|
||||
[icons]
|
||||
icons = "custom-icons"
|
||||
|
||||
[[block]]
|
||||
block = "tea_timer"
|
||||
format = "$icon{ $minutes:$seconds|}"
|
||||
done_cmd = "notify-send 'Timer finished'"
|
||||
|
||||
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
|
||||
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
|
||||
# list, in the launcher's menu.
|
||||
[[block]]
|
||||
block = "custom"
|
||||
command = "~/.local/bin/i3status-updates 10"
|
||||
json = true
|
||||
interval = 1800
|
||||
[[block.click]]
|
||||
button = "left"
|
||||
cmd = "checkupdates | dmenu -l 20 -p Updates"
|
||||
|
||||
[[block]]
|
||||
block = "cpu"
|
||||
|
||||
[[block]]
|
||||
block = "disk_space"
|
||||
path = "/"
|
||||
info_type = "used"
|
||||
alert_unit = "GB"
|
||||
interval = 20
|
||||
warning = 850
|
||||
alert = 920
|
||||
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
|
||||
|
||||
[[block]]
|
||||
block = "disk_space"
|
||||
path = "/home"
|
||||
info_type = "used"
|
||||
alert_unit = "GB"
|
||||
interval = 20
|
||||
warning = 850
|
||||
alert = 920
|
||||
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
|
||||
|
||||
[[block]]
|
||||
block = "memory"
|
||||
# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure
|
||||
# module judges by. Counting the cache as used read near full on a machine with room to spare.
|
||||
format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"
|
||||
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
|
||||
|
||||
# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from
|
||||
# what they show by this module's template, and only on a machine that has what they show.
|
||||
${collected:contribution:node-bar:block:bar=bottom,place=resources}
|
||||
[[block]]
|
||||
block = "docker"
|
||||
interval = 2
|
||||
format = "$icon $running/$total"
|
||||
|
||||
[[block]]
|
||||
block = "sound"
|
||||
[[block.click]]
|
||||
button = "left"
|
||||
cmd = "pavucontrol"
|
||||
|
||||
# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and
|
||||
# power draw, where the machine has them.
|
||||
${collected:contribution:node-bar:block:bar=bottom,place=status}
|
||||
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
|
||||
[[block]]
|
||||
block = "weather"
|
||||
format = "$icon $weather_verbose ($location) $temp"
|
||||
autolocate = true
|
||||
autolocate_interval = 600
|
||||
[block.service]
|
||||
name = "metno"
|
||||
|
||||
[[block]]
|
||||
block = "notify"
|
||||
driver = "dunst"
|
||||
format = " $icon {($notification_count.eng(w:1))|}"
|
||||
|
||||
[[block]]
|
||||
block = "time"
|
||||
interval = 1
|
||||
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
|
||||
@@ -0,0 +1,109 @@
|
||||
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
|
||||
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
|
||||
# person's devices left out: a block that depends on the hardware is contributed by the module that
|
||||
# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written
|
||||
# here works the same on any machine.
|
||||
icons_format = "{icon}"
|
||||
|
||||
[theme]
|
||||
theme = "plain"
|
||||
[theme.overrides]
|
||||
idle_bg = "#000000"
|
||||
idle_fg = "#f1f1f1"
|
||||
info_bg = "#000000"
|
||||
info_fg = "#f1f1f1"
|
||||
good_bg = "#000000"
|
||||
good_fg = "#859900"
|
||||
warning_bg = "#000000"
|
||||
warning_fg = "#de5200"
|
||||
critical_bg = "#000000"
|
||||
critical_fg = "#ff0000"
|
||||
separator = " | "
|
||||
separator_fg = "#ffffff"
|
||||
separator_bg = "#000000"
|
||||
|
||||
[icons]
|
||||
icons = "custom-icons"
|
||||
|
||||
[[block]]
|
||||
block = "tea_timer"
|
||||
format = "$icon{ $minutes:$seconds|}"
|
||||
done_cmd = "notify-send 'Timer finished'"
|
||||
|
||||
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
|
||||
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
|
||||
# list, in the launcher's menu.
|
||||
[[block]]
|
||||
block = "custom"
|
||||
command = "~/.local/bin/i3status-updates 10"
|
||||
json = true
|
||||
interval = 1800
|
||||
[[block.click]]
|
||||
button = "left"
|
||||
cmd = "checkupdates | dmenu -l 20 -p Updates"
|
||||
|
||||
[[block]]
|
||||
block = "cpu"
|
||||
|
||||
[[block]]
|
||||
block = "disk_space"
|
||||
path = "/"
|
||||
info_type = "used"
|
||||
alert_unit = "GB"
|
||||
interval = 20
|
||||
warning = 850
|
||||
alert = 920
|
||||
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
|
||||
|
||||
[[block]]
|
||||
block = "disk_space"
|
||||
path = "/home"
|
||||
info_type = "used"
|
||||
alert_unit = "GB"
|
||||
interval = 20
|
||||
warning = 850
|
||||
alert = 920
|
||||
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
|
||||
|
||||
[[block]]
|
||||
block = "memory"
|
||||
# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure
|
||||
# module judges by. Counting the cache as used read near full on a machine with room to spare.
|
||||
format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"
|
||||
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
|
||||
|
||||
# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from
|
||||
# what they show by this module's template, and only on a machine that has what they show.
|
||||
|
||||
[[block]]
|
||||
block = "docker"
|
||||
interval = 2
|
||||
format = "$icon $running/$total"
|
||||
|
||||
[[block]]
|
||||
block = "sound"
|
||||
[[block.click]]
|
||||
button = "left"
|
||||
cmd = "pavucontrol"
|
||||
|
||||
# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and
|
||||
# power draw, where the machine has them.
|
||||
|
||||
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
|
||||
[[block]]
|
||||
block = "weather"
|
||||
format = "$icon $weather_verbose ($location) $temp"
|
||||
autolocate = true
|
||||
autolocate_interval = 600
|
||||
[block.service]
|
||||
name = "metno"
|
||||
|
||||
[[block]]
|
||||
block = "notify"
|
||||
driver = "dunst"
|
||||
format = " $icon {($notification_count.eng(w:1))|}"
|
||||
|
||||
[[block]]
|
||||
block = "time"
|
||||
interval = 1
|
||||
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
|
||||
@@ -0,0 +1,36 @@
|
||||
# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.
|
||||
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
|
||||
icons_format = "{icon}"
|
||||
|
||||
[theme]
|
||||
theme = "plain"
|
||||
[theme.overrides]
|
||||
idle_bg = "#000000"
|
||||
idle_fg = "#f1f1f1"
|
||||
info_bg = "#000000"
|
||||
info_fg = "#f1f1f1"
|
||||
good_bg = "#000000"
|
||||
good_fg = "#859900"
|
||||
warning_bg = "#000000"
|
||||
warning_fg = "#de5200"
|
||||
critical_bg = "#000000"
|
||||
critical_fg = "#ff0000"
|
||||
separator = " | "
|
||||
separator_fg = "#ffffff"
|
||||
separator_bg = "#000000"
|
||||
|
||||
[icons]
|
||||
icons = "custom-icons"
|
||||
|
||||
[[block]]
|
||||
block = "focused_window"
|
||||
[block.format]
|
||||
full = " $title.str(max_w:50) |"
|
||||
short = " $title.str(max_w:100) |"
|
||||
|
||||
[[block]]
|
||||
block = "uptime"
|
||||
interval = 60
|
||||
|
||||
# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept
|
||||
# so that one that does is never dropped.
|
||||
@@ -0,0 +1,116 @@
|
||||
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
|
||||
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
|
||||
# person's devices left out: a block that depends on the hardware is contributed by the module that
|
||||
# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written
|
||||
# here works the same on any machine.
|
||||
icons_format = "{icon}"
|
||||
|
||||
[theme]
|
||||
theme = "plain"
|
||||
[theme.overrides]
|
||||
idle_bg = "#000000"
|
||||
idle_fg = "#f1f1f1"
|
||||
info_bg = "#000000"
|
||||
info_fg = "#f1f1f1"
|
||||
good_bg = "#000000"
|
||||
good_fg = "#859900"
|
||||
warning_bg = "#000000"
|
||||
warning_fg = "#de5200"
|
||||
critical_bg = "#000000"
|
||||
critical_fg = "#ff0000"
|
||||
separator = " | "
|
||||
separator_fg = "#ffffff"
|
||||
separator_bg = "#000000"
|
||||
|
||||
[icons]
|
||||
icons = "custom-icons"
|
||||
|
||||
[[block]]
|
||||
block = "tea_timer"
|
||||
format = "$icon{ $minutes:$seconds|}"
|
||||
done_cmd = "notify-send 'Timer finished'"
|
||||
|
||||
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
|
||||
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
|
||||
# list, in the launcher's menu.
|
||||
[[block]]
|
||||
block = "custom"
|
||||
command = "~/.local/bin/i3status-updates 10"
|
||||
json = true
|
||||
interval = 1800
|
||||
[[block.click]]
|
||||
button = "left"
|
||||
cmd = "checkupdates | dmenu -l 20 -p Updates"
|
||||
|
||||
[[block]]
|
||||
block = "cpu"
|
||||
|
||||
[[block]]
|
||||
block = "disk_space"
|
||||
path = "/"
|
||||
info_type = "used"
|
||||
alert_unit = "GB"
|
||||
interval = 20
|
||||
warning = 850
|
||||
alert = 920
|
||||
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
|
||||
|
||||
[[block]]
|
||||
block = "disk_space"
|
||||
path = "/home"
|
||||
info_type = "used"
|
||||
alert_unit = "GB"
|
||||
interval = 20
|
||||
warning = 850
|
||||
alert = 920
|
||||
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
|
||||
|
||||
[[block]]
|
||||
block = "memory"
|
||||
# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure
|
||||
# module judges by. Counting the cache as used read near full on a machine with room to spare.
|
||||
format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"
|
||||
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
|
||||
|
||||
# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from
|
||||
# what they show by this module's template, and only on a machine that has what they show.
|
||||
|
||||
[[block]]
|
||||
block = "docker"
|
||||
interval = 2
|
||||
format = "$icon $running/$total"
|
||||
|
||||
[[block]]
|
||||
block = "sound"
|
||||
[[block.click]]
|
||||
button = "left"
|
||||
cmd = "pavucontrol"
|
||||
|
||||
# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and
|
||||
# power draw, where the machine has them.
|
||||
# power
|
||||
[[block]]
|
||||
block = "custom"
|
||||
command = "~/.local/bin/i3status-value power.draw workstation"
|
||||
interval = 5
|
||||
hide_when_empty = true
|
||||
|
||||
|
||||
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
|
||||
[[block]]
|
||||
block = "weather"
|
||||
format = "$icon $weather_verbose ($location) $temp"
|
||||
autolocate = true
|
||||
autolocate_interval = 600
|
||||
[block.service]
|
||||
name = "metno"
|
||||
|
||||
[[block]]
|
||||
block = "notify"
|
||||
driver = "dunst"
|
||||
format = " $icon {($notification_count.eng(w:1))|}"
|
||||
|
||||
[[block]]
|
||||
block = "time"
|
||||
interval = 1
|
||||
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
|
||||
@@ -0,0 +1,36 @@
|
||||
# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.
|
||||
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
|
||||
icons_format = "{icon}"
|
||||
|
||||
[theme]
|
||||
theme = "plain"
|
||||
[theme.overrides]
|
||||
idle_bg = "#000000"
|
||||
idle_fg = "#f1f1f1"
|
||||
info_bg = "#000000"
|
||||
info_fg = "#f1f1f1"
|
||||
good_bg = "#000000"
|
||||
good_fg = "#859900"
|
||||
warning_bg = "#000000"
|
||||
warning_fg = "#de5200"
|
||||
critical_bg = "#000000"
|
||||
critical_fg = "#ff0000"
|
||||
separator = " | "
|
||||
separator_fg = "#ffffff"
|
||||
separator_bg = "#000000"
|
||||
|
||||
[icons]
|
||||
icons = "custom-icons"
|
||||
|
||||
[[block]]
|
||||
block = "focused_window"
|
||||
[block.format]
|
||||
full = " $title.str(max_w:50) |"
|
||||
short = " $title.str(max_w:100) |"
|
||||
|
||||
[[block]]
|
||||
block = "uptime"
|
||||
interval = 60
|
||||
|
||||
# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept
|
||||
# so that one that does is never dropped.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.
|
||||
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
|
||||
icons_format = "{icon}"
|
||||
|
||||
[theme]
|
||||
theme = "plain"
|
||||
[theme.overrides]
|
||||
idle_bg = "#000000"
|
||||
idle_fg = "#f1f1f1"
|
||||
info_bg = "#000000"
|
||||
info_fg = "#f1f1f1"
|
||||
good_bg = "#000000"
|
||||
good_fg = "#859900"
|
||||
warning_bg = "#000000"
|
||||
warning_fg = "#de5200"
|
||||
critical_bg = "#000000"
|
||||
critical_fg = "#ff0000"
|
||||
separator = " | "
|
||||
separator_fg = "#ffffff"
|
||||
separator_bg = "#000000"
|
||||
|
||||
[icons]
|
||||
icons = "custom-icons"
|
||||
|
||||
[[block]]
|
||||
block = "focused_window"
|
||||
[block.format]
|
||||
full = " $title.str(max_w:50) |"
|
||||
short = " $title.str(max_w:100) |"
|
||||
|
||||
[[block]]
|
||||
block = "uptime"
|
||||
interval = 60
|
||||
|
||||
# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept
|
||||
# so that one that does is never dropped.
|
||||
${collected:contribution:node-bar:block:bar=top,place=resources}${collected:contribution:node-bar:block:bar=top,place=status}
|
||||
@@ -0,0 +1,26 @@
|
||||
# adwaita
|
||||
export GTK2_RC_FILES='/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc'
|
||||
export GTK_THEME='Adwaita:dark'
|
||||
export QT_QPA_PLATFORMTHEME='qt6ct'
|
||||
export QT_SELECT='6'
|
||||
export QT_STYLE_OVERRIDE='Fusion'
|
||||
export XCURSOR_SIZE='24'
|
||||
export XCURSOR_THEME='Adwaita'
|
||||
# clipmenu
|
||||
export CM_HISTLENGTH='15'
|
||||
export CM_MAX_CLIPS='500'
|
||||
export CM_SELECTIONS='clipboard'
|
||||
# i3
|
||||
export XDG_CURRENT_DESKTOP='i3'
|
||||
export XDG_SESSION_DESKTOP='i3'
|
||||
# xterm
|
||||
export TERMINAL='xterm'
|
||||
# zsh
|
||||
export EDITOR='vim'
|
||||
export VISUAL='vim'
|
||||
export XDG_CONFIG_HOME='/home/op/.config'
|
||||
# zsh
|
||||
case ":${PATH}:" in *':/home/op/scripts/bin:'*) ;; *) PATH='/home/op/scripts/bin'"${PATH:+:${PATH}}" ;; esac
|
||||
case ":${PATH}:" in *':/home/op/scripts:'*) ;; *) PATH='/home/op/scripts'"${PATH:+:${PATH}}" ;; esac
|
||||
case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac
|
||||
export PATH
|
||||
@@ -0,0 +1,23 @@
|
||||
# adwaita
|
||||
GTK2_RC_FILES=/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc
|
||||
GTK_THEME=Adwaita:dark
|
||||
QT_QPA_PLATFORMTHEME=qt6ct
|
||||
QT_SELECT=6
|
||||
QT_STYLE_OVERRIDE=Fusion
|
||||
XCURSOR_SIZE=24
|
||||
XCURSOR_THEME=Adwaita
|
||||
# clipmenu
|
||||
CM_HISTLENGTH=15
|
||||
CM_MAX_CLIPS=500
|
||||
CM_SELECTIONS=clipboard
|
||||
# i3
|
||||
XDG_CURRENT_DESKTOP=i3
|
||||
XDG_SESSION_DESKTOP=i3
|
||||
# xterm
|
||||
TERMINAL=xterm
|
||||
# zsh
|
||||
EDITOR=vim
|
||||
VISUAL=vim
|
||||
XDG_CONFIG_HOME=/home/op/.config
|
||||
# zsh
|
||||
PATH=/home/op/.local/bin:/home/op/scripts:/home/op/scripts/bin${PATH:+:$PATH}
|
||||
@@ -0,0 +1,9 @@
|
||||
# zsh
|
||||
export EDITOR='vim'
|
||||
export VISUAL='vim'
|
||||
export XDG_CONFIG_HOME='/home/op/.config'
|
||||
# zsh
|
||||
case ":${PATH}:" in *':/home/op/scripts/bin:'*) ;; *) PATH='/home/op/scripts/bin'"${PATH:+:${PATH}}" ;; esac
|
||||
case ":${PATH}:" in *':/home/op/scripts:'*) ;; *) PATH='/home/op/scripts'"${PATH:+:${PATH}}" ;; esac
|
||||
case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac
|
||||
export PATH
|
||||
@@ -0,0 +1,6 @@
|
||||
# zsh
|
||||
EDITOR=vim
|
||||
VISUAL=vim
|
||||
XDG_CONFIG_HOME=/home/op/.config
|
||||
# zsh
|
||||
PATH=/home/op/.local/bin:/home/op/scripts:/home/op/scripts/bin${PATH:+:$PATH}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,
|
||||
# and the touchpad key. Each runs this module's own script, as the operator's account.
|
||||
KEY_PROG1 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause
|
||||
KEY_PROG3 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous
|
||||
KEY_PROG4 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next
|
||||
KEY_BRIGHTNESSDOWN 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
||||
KEY_BRIGHTNESSDOWN 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
||||
KEY_BRIGHTNESSUP 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
||||
KEY_BRIGHTNESSUP 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
||||
KEY_F21 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# The touchpad's settings again after waking, in the operator's session: X applies the module's
|
||||
# input class when the device appears, and this covers a wake that does not initialise it again.
|
||||
# Runs as root from the power module; the reset itself runs as the session's owner.
|
||||
sleep 2
|
||||
owner=$(ps -o user= -C i3 | head -n 1)
|
||||
[ -n "$owner" ] && runuser -u "$owner" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
||||
true
|
||||
@@ -0,0 +1,12 @@
|
||||
# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the
|
||||
# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where
|
||||
# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs
|
||||
# the real claude: exec, sudo and its sh see no alias.
|
||||
if [[ -r /etc/claude-code/agent-account ]]; then
|
||||
() {
|
||||
local agent=$(</etc/claude-code/agent-account)
|
||||
if [[ -n $agent && $agent != $USERNAME ]]; then
|
||||
alias claude=claude-agent
|
||||
fi
|
||||
}
|
||||
fi
|
||||
@@ -0,0 +1,3 @@
|
||||
# The operator's command (module mesh-cli, novox/hq ADR 0272): nox is mesh-cli, in interactive zsh only.
|
||||
# A script, sudo and a document meant to work everywhere say mesh-cli.
|
||||
alias nox=mesh-cli
|
||||
@@ -0,0 +1,3 @@
|
||||
# The prompt: powerlevel10k v1.20.0, pinned in this module (novox/hq ADR 0205), and its configuration.
|
||||
[[ ! -f ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme ]] || source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme
|
||||
[[ ! -f ~/.config/powerlevel10k/p10k.zsh ]] || source ~/.config/powerlevel10k/p10k.zsh
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# Suggestions from history as you type, from the distribution's package.
|
||||
[[ ! -f /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh ]] || source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
# Syntax highlighting of the command line, from the distribution's package. Last, as upstream asks:
|
||||
# it wraps every widget defined before it.
|
||||
[[ ! -f /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh ]] || source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
|
||||
@@ -0,0 +1,4 @@
|
||||
# What every module on this machine runs at the moment after-wake, as the mesh placed it (module power,
|
||||
# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a
|
||||
# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.
|
||||
${shell:after-wake:first}${shell:after-wake:normal}${shell:after-wake:last}
|
||||
@@ -0,0 +1,4 @@
|
||||
# The machine's hotkeys, written by the mesh (module triggerhappy, novox/hq ADR 0212). Replaced on
|
||||
# every push: a key is added by the module it belongs to, as a contribution to node-hotkeys.
|
||||
# A line is: the key, its state (1 pressed, 2 held, 0 released), the command.
|
||||
${contribution:node-hotkeys:trigger}
|
||||
@@ -0,0 +1,60 @@
|
||||
# The mesh's block (module zsh, novox/hq ADR 0204): the defaults every machine shares, and the code
|
||||
# other modules contribute in three slots. It is replaced at every push. Everything below it is
|
||||
# yours, kept as you wrote it, and runs after it, so your lines win.
|
||||
${shell:zsh:first}
|
||||
# History, kept across sessions and shared between open shells. Without these zsh saves none:
|
||||
# SAVEHIST defaults to 0 (novox/hq to-be 41, found on review 2026-10-04).
|
||||
HISTFILE="$HOME/.zsh_history"
|
||||
HISTSIZE=100000
|
||||
SAVEHIST=100000
|
||||
setopt EXTENDED_HISTORY SHARE_HISTORY HIST_IGNORE_DUPS HIST_IGNORE_SPACE HIST_REDUCE_BLANKS
|
||||
|
||||
# The completion system. Nothing initialised it, so only zsh's basic completion ran. Its cache
|
||||
# goes under ~/.cache rather than beside ~/.zshrc.
|
||||
[[ -d "$HOME/.cache/zsh" ]] || mkdir -p "$HOME/.cache/zsh"
|
||||
autoload -Uz compinit && compinit -d "$HOME/.cache/zsh/zcompdump"
|
||||
zstyle ':completion:*' menu select
|
||||
|
||||
# Terminal title: host, directory, git branch
|
||||
function set_terminal_title() {
|
||||
local git_branch=""
|
||||
if git rev-parse --is-inside-work-tree &>/dev/null; then
|
||||
git_branch=" ($(git branch --show-current 2>/dev/null))"
|
||||
fi
|
||||
print -Pn "\e]2;%m: %~${git_branch}\a"
|
||||
}
|
||||
precmd_functions+=(set_terminal_title)
|
||||
|
||||
# Keybindings
|
||||
bindkey "^[[H" beginning-of-line # Home
|
||||
bindkey "^[OH" beginning-of-line # Home
|
||||
bindkey "^A" beginning-of-line # Ctrl + A
|
||||
bindkey "^[[F" end-of-line # End
|
||||
bindkey "^[OF" end-of-line # End
|
||||
bindkey "^E" end-of-line # Ctrl + E
|
||||
bindkey "^[[3~" delete-char # Del
|
||||
|
||||
# Colour support of ls and friends
|
||||
if [ -x /usr/bin/dircolors ]; then
|
||||
test -r $HOME/.dircolors && eval "$(dircolors -b $HOME/.dircolors)" || eval "$(dircolors -b)"
|
||||
alias ls='ls --color=auto'
|
||||
alias dir='dir --color=auto'
|
||||
alias vdir='vdir --color=auto'
|
||||
alias grep='grep --color=auto'
|
||||
alias fgrep='fgrep --color=auto'
|
||||
alias egrep='egrep --color=auto'
|
||||
fi
|
||||
alias ll='ls -alhF'
|
||||
alias la='ls -Ah'
|
||||
alias l='ls -CFh'
|
||||
|
||||
# A throwaway container, and what takes the space under a directory
|
||||
alias drun='docker run -it --rm'
|
||||
disksize() {
|
||||
du -h --max-depth=1 "${1:-.}" | sort -h
|
||||
}
|
||||
sudo-disksize() {
|
||||
sudo du -h --max-depth=1 "${1:-.}" | sort -h
|
||||
}
|
||||
|
||||
${shell:zsh:normal}${shell:zsh:last}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# What every module on this machine runs at the moment after-wake, as the mesh placed it (module power,
|
||||
# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a
|
||||
# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.
|
||||
# asus-zephyrus-g14
|
||||
# The touchpad's settings again after waking, in the operator's session: X applies the module's
|
||||
# input class when the device appears, and this covers a wake that does not initialise it again.
|
||||
# Runs as root from the power module; the reset itself runs as the session's owner.
|
||||
sleep 2
|
||||
owner=$(ps -o user= -C i3 | head -n 1)
|
||||
[ -n "$owner" ] && runuser -u "$owner" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
||||
true
|
||||
@@ -0,0 +1,14 @@
|
||||
# The machine's hotkeys, written by the mesh (module triggerhappy, novox/hq ADR 0212). Replaced on
|
||||
# every push: a key is added by the module it belongs to, as a contribution to node-hotkeys.
|
||||
# A line is: the key, its state (1 pressed, 2 held, 0 released), the command.
|
||||
# asus-zephyrus-g14
|
||||
# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,
|
||||
# and the touchpad key. Each runs this module's own script, as the operator's account.
|
||||
KEY_PROG1 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause
|
||||
KEY_PROG3 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous
|
||||
KEY_PROG4 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next
|
||||
KEY_BRIGHTNESSDOWN 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
||||
KEY_BRIGHTNESSDOWN 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
||||
KEY_BRIGHTNESSUP 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
||||
KEY_BRIGHTNESSUP 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
||||
KEY_F21 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user