Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74b0dab34c | ||
|
|
41b20b2782 | ||
|
|
912e9f4e85 | ||
|
|
babd7b2f47 | ||
|
|
892dfd1d08 | ||
|
|
cfac579392 | ||
|
|
fe0d295490 | ||
|
|
d8a0238e02 | ||
|
|
dcf710a8d5 | ||
|
|
a2003ab616 | ||
|
|
f19a2254ac | ||
|
|
7d46e48b26 | ||
|
|
78915f9f7a |
@@ -60,7 +60,7 @@ func collect(ctx context.Context, inv *inventory.Inventory) {
|
||||
store := artifacts.Store{Address: address}
|
||||
|
||||
var done []string
|
||||
var left int
|
||||
var left, skipped int
|
||||
for i, reference := range references {
|
||||
if i >= mostPerSweep || within.Err() != nil {
|
||||
left = len(references) - i
|
||||
@@ -73,10 +73,22 @@ func collect(ctx context.Context, inv *inventory.Inventory) {
|
||||
done = append(done, reference)
|
||||
continue
|
||||
}
|
||||
// **Stopped at the first refusal, not pushed through.** A store that refuses one refuses
|
||||
// all of them — deletion disabled, the store down, the network gone — so going on would
|
||||
// be a hundred identical failures and a hundred identical log lines in front of whoever
|
||||
// was building something.
|
||||
if errors.Is(err, artifacts.ErrNotOurs) {
|
||||
// **A fact about this record, so this record is skipped** (novox/hq issue 226). Not
|
||||
// marked collected — the mesh did not remove it and should not claim to — and not a
|
||||
// reason to stop, because the store was never asked. One of these at the front of
|
||||
// the oldest-first order ended every sweep until this.
|
||||
skipped++
|
||||
if skipped == 1 {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"the sweep will not address %s and went on: %v\n", reference, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// **Stopped at the first refusal by the STORE, not pushed through.** A store that refuses
|
||||
// one refuses all of them — deletion disabled, the store down, the network gone — so
|
||||
// going on would be a hundred identical failures and a hundred identical log lines in
|
||||
// front of whoever was building something.
|
||||
fmt.Fprintf(os.Stderr, "the artifact store kept %s, so nothing more was asked of it: %v\n",
|
||||
reference, err)
|
||||
left = len(references) - i
|
||||
@@ -97,6 +109,9 @@ func collect(ctx context.Context, inv *inventory.Inventory) {
|
||||
if left > 0 {
|
||||
fmt.Fprintf(os.Stderr, "%d more to collect; the next build asks again\n", left)
|
||||
}
|
||||
if skipped > 0 {
|
||||
fmt.Fprintf(os.Stderr, "%d artifact(s) the sweep will not address were skipped\n", skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// mostPerSweep is how many artifacts one sweep will ask about. Enough that a mesh building
|
||||
|
||||
@@ -747,6 +747,16 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// **Every declared state's bucket, before the memberships that name it** (novox/hq ADR 0201). The
|
||||
// raise at start asserts them too, but a module registered and assigned since would otherwise have
|
||||
// its bucket only after the control plane next restarts — found the first time a module declared
|
||||
// state: its bundle asked for a bucket that did not exist. Idempotent and cheap; a failure is said
|
||||
// and the push stands, as a membership's is.
|
||||
if buckets, err := open.inventory.DeclaredBuckets(ctx); err != nil {
|
||||
fmt.Printf(" the modules' state could not be read, so no bucket was asserted: %v\n", err)
|
||||
} else if _, err := broker.RaiseBuckets(broker.OnConn(bus.Conn), buckets); err != nil {
|
||||
fmt.Printf(" the modules' state could not be asserted on the bus: %v — the next push tries again\n", err)
|
||||
}
|
||||
// The declarations are sent and recorded by now; a membership that cannot be issued is said
|
||||
// and does not unsay them. Every runtime without one serves the shape it derives (ADR 0160), so
|
||||
// the push stands, the first failure is named once, and the next push tries again.
|
||||
|
||||
@@ -8,6 +8,7 @@ package artifacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -26,7 +27,15 @@ type Store struct {
|
||||
}
|
||||
|
||||
// Gone is the answer when the store does not hold it: the outcome wanted, already true.
|
||||
var Gone = fmt.Errorf("the store does not hold it")
|
||||
var Gone = errors.New("the store does not hold it")
|
||||
|
||||
// ErrNotOurs is a reference this sweep will not address: not the mesh's own, or naming nothing
|
||||
// the store holds by digest.
|
||||
//
|
||||
// **A fact about the record, not about the store** (novox/hq issue 226). The two deserve opposite
|
||||
// responses — skip one and go on, abandon the sweep for the other — and collapsing them into "an
|
||||
// error" is how a cautious loop became one that did nothing while reporting the right number.
|
||||
var ErrNotOurs = errors.New("not a reference into the mesh's artifact store")
|
||||
|
||||
// LetGo asks the store to drop one artifact the mesh recorded making.
|
||||
//
|
||||
@@ -38,12 +47,17 @@ var Gone = fmt.Errorf("the store does not hold it")
|
||||
// wants the artifact absent, and it is. It is distinguished from success only so a caller can say
|
||||
// which of the two happened.
|
||||
func (s Store) LetGo(ctx context.Context, reference string) error {
|
||||
// **Strict, and deliberately** (novox/hq issue 226). Only a reference the mesh keeps in its
|
||||
// own vocabulary is addressed here. `Recorded` would read `docker.io/library/registry@sha256:…`
|
||||
// as the mesh's too — it cannot tell one registry host from another — so normalising belongs
|
||||
// where the provenance is known, which is the sweep reading its own build records, not here
|
||||
// where the only job is to refuse anything that is not plainly ours.
|
||||
path, kept := catalogue.InArtifactStore(reference)
|
||||
if !kept {
|
||||
// Nothing the mesh put in its own store. Refused rather than attempted: composing a
|
||||
// delete for a reference of unknown shape is how a sweep reaches something that is not
|
||||
// the mesh's.
|
||||
return fmt.Errorf("%s is not a reference into the mesh's artifact store", reference)
|
||||
// the mesh's. Distinguished from a store that refuses, so a sweep skips this and goes on.
|
||||
return fmt.Errorf("%w: %s", ErrNotOurs, reference)
|
||||
}
|
||||
if s.Address == "" {
|
||||
return fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference)
|
||||
@@ -95,5 +109,5 @@ func split(path string) (repository, kind, digest string, err error) {
|
||||
if before, after, ok := strings.Cut(path, "/blobs/sha256:"); ok {
|
||||
return before, "blobs", "sha256:" + after, nil
|
||||
}
|
||||
return "", "", "", fmt.Errorf("%q names nothing the store holds by digest", path)
|
||||
return "", "", "", fmt.Errorf("%w: %q names nothing the store holds by digest", ErrNotOurs, path)
|
||||
}
|
||||
|
||||
@@ -92,3 +92,26 @@ func TestAReferenceThatIsNotTheMeshsOwnIsNeverAsked(t *testing.T) {
|
||||
t.Fatalf("the store was asked about %v", *asked)
|
||||
}
|
||||
}
|
||||
|
||||
// A reference this sweep will not address says so as ErrNotOurs, which is a fact about the
|
||||
// record and not about the store (novox/hq issue 226).
|
||||
//
|
||||
// The sweep skips one and abandons itself for the other, so they cannot be the same error. The
|
||||
// first live run met a reference recorded with the store's old address, read the refusal as "the
|
||||
// store refuses everything", and collected none of the 1681 it had found.
|
||||
func TestAReferenceThisSweepWillNotAddressIsToldApartFromAStoreRefusing(t *testing.T) {
|
||||
store, asked := fakeStore(t, http.StatusAccepted)
|
||||
for _, reference := range []string{
|
||||
"docker.io/library/registry@sha256:abc123",
|
||||
"127.0.0.1:5100/mesh-tools/build@sha256:abc123",
|
||||
"1.4.2",
|
||||
} {
|
||||
err := store.LetGo(context.Background(), reference)
|
||||
if !errors.Is(err, ErrNotOurs) {
|
||||
t.Errorf("%s answered %v; a sweep must be able to skip it and go on", reference, err)
|
||||
}
|
||||
}
|
||||
if len(*asked) != 0 {
|
||||
t.Fatalf("the store was asked about %v", *asked)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,13 @@ func pinnedTo(path string) (*tls.Config, error) {
|
||||
return PinnedToFingerprint(want), nil
|
||||
}
|
||||
|
||||
// OnConn is the JetStream handle over a connection the caller already holds — the control plane's
|
||||
// link — for asserting what the bus holds without dialling a second time.
|
||||
func OnConn(conn *nats.Conn) *JetStream {
|
||||
js, _ := conn.JetStream()
|
||||
return &JetStream{conn: conn, js: js}
|
||||
}
|
||||
|
||||
// DialPinned is Dial with the server's certificate pinned by a fingerprint the caller already holds
|
||||
// — a module or a build machine that was handed one beside its credential, and has no file.
|
||||
func DialPinned(url, fingerprint string, opts ...nats.Option) (*JetStream, error) {
|
||||
|
||||
@@ -164,3 +164,17 @@ func TestABucketIsAssertedInPlace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Against a real server: the handle over a connection the control plane already holds asserts a
|
||||
// bucket as Dial's does — what a push uses, so a module registered since the last start has its
|
||||
// bucket before its membership names it.
|
||||
func TestABucketIsAssertedOverAHeldConnection(t *testing.T) {
|
||||
js := aLiveBus(t)
|
||||
held := OnConn(js.Conn())
|
||||
if _, err := RaiseBuckets(held, []Bucket{{Module: "statetest", Name: "held"}}); err != nil {
|
||||
t.Fatalf("asserting over a held connection failed: %v", err)
|
||||
}
|
||||
if _, err := js.Context().KeyValue("statetest_held"); err != nil {
|
||||
t.Fatalf("the bucket is not there: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +415,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "", with.Foundation,
|
||||
with.OutwardLinks, with.TunnelInterface)
|
||||
|
||||
// **A variable two modules set is refused whether or not anything places it** (novox/hq ADR
|
||||
// 0203 §5): the account has one environment, and a machine whose holder arrives later should not
|
||||
// be the moment two modules are found to disagree about it.
|
||||
if err := variablesSetOnce(r.Modules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []map[string]any
|
||||
for _, m := range r.Modules {
|
||||
if with.Adopted && m.Filtering != nil {
|
||||
@@ -603,14 +610,14 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
// and nothing would say so.
|
||||
continue
|
||||
}
|
||||
first = append(first, map[string]any{
|
||||
first = append(first, ownedBy(r.provisionsAs(m), map[string]any{
|
||||
// One file per holder — the consumer's module with its local name after it
|
||||
// where it keeps several (ADR 0094); the lab found two files with one id.
|
||||
"id": GrantID(to, g.Consumer+"."+holderAs(g.From, g.Local)),
|
||||
"type": "file",
|
||||
"path": grantPath(m.Grants[to], g.Consumer, holderAs(g.From, g.Local)),
|
||||
"sealed": g.Sealed,
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
for _, to := range sortedKeys(m.Binds) {
|
||||
@@ -889,6 +896,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
return nil, err
|
||||
}
|
||||
publishedOn(copied, m.Module, with)
|
||||
// The account's environment and every module's shell code, where this module holds the
|
||||
// seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on
|
||||
// the node, as the jails are, and **last of every placeholder pass**: shell code is a
|
||||
// shell's own syntax, full of `${…}` no pass above should ever be shown.
|
||||
if err := contributionsInto(copied, m, r.Modules, thisMachine); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copied["id"] = m.Module + "." + fmt.Sprint(resource["id"])
|
||||
// A service saying what it reflects names resources within its own module, so those
|
||||
// are prefixed too or they would point at nothing.
|
||||
@@ -1226,6 +1240,28 @@ type Contribution struct {
|
||||
Derived map[string]any `json:"derived,omitempty"`
|
||||
}
|
||||
|
||||
// provisionsAs is the account that reads what the mesh writes for this provider: the one secret
|
||||
// per consumer it must open to set that consumer's password (novox/hq issue 225).
|
||||
//
|
||||
// **A root-owned 0600 file is one that process cannot read**, which is the same sentence already
|
||||
// written above for a module's own secrets — and the grant secret is the other kind of secret
|
||||
// the mesh writes for a module, so it is the same rule.
|
||||
//
|
||||
// Which account depends on where the module's code runs. A module whose code is a bundle is run
|
||||
// by the node's tool runtime, as the node's account ([ADR 0198](0198)); one still in a container
|
||||
// is whatever it declares as its secrets owner. Nothing names these paths, so the rule that
|
||||
// claims a bundle's other files by the words that name them (givenTo) cannot reach them: the
|
||||
// harness composes a grant secret's path from the contributions file, not from a word.
|
||||
//
|
||||
// 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 {
|
||||
if len(m.Bundles) > 0 && r.Account != "" {
|
||||
return r.Account
|
||||
}
|
||||
return m.SecretsOwner
|
||||
}
|
||||
|
||||
// grantPath is where one consumer's sealed credential lands on the providing machine.
|
||||
//
|
||||
// Suffixed, so the directory can also hold whatever the module writing it keeps there and so a
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The account's environment and the login shell's code, composed from the modules a node runs
|
||||
// (novox/hq ADR 0203, ADR 0204).
|
||||
//
|
||||
// **The same shape as the jails.** Every module may contribute — a toolchain its directory on PATH,
|
||||
// a version manager a variable naming its home, a prompt the code that loads it — naming no node, no
|
||||
// path and no file of the shell's (ADR 0112). The one module holding the matching seat places the
|
||||
// result with a placeholder in its own file, and the controller fills it from every module on the
|
||||
// node. A node not running a module has none of its contribution, and unassigning one takes its
|
||||
// lines away at the next composition.
|
||||
//
|
||||
// **Two kinds of contribution, kept apart on purpose.** The environment is facts, which the
|
||||
// controller writes in two standard formats — POSIX assignment and the service manager's
|
||||
// environment.d — so a terminal, a script, the login shell's `execute` and a graphical session all
|
||||
// read the same values (ADR 0203). Shell code is not a fact: it is text in one shell's syntax, which
|
||||
// the controller sorts into a slot and pastes without reading, as it pastes a jail's stanza (ADR
|
||||
// 0204).
|
||||
|
||||
// EnvironmentSeat and LoginShellSeat are the seats whose holders may place what the modules
|
||||
// contributed: the account's environment, and the login shell's code.
|
||||
const (
|
||||
EnvironmentSeat = "node-environment"
|
||||
LoginShellSeat = "node-login-shell"
|
||||
)
|
||||
|
||||
// Where an environment entry on PATH goes: before the account's existing PATH, or after it.
|
||||
const (
|
||||
PathAtStart = "start"
|
||||
PathAtEnd = "end"
|
||||
)
|
||||
|
||||
// Environment is what one module adds to the account's environment (novox/hq ADR 0203).
|
||||
type Environment struct {
|
||||
// Variables are names and literal values. A value may name the machine's own facts with
|
||||
// ${machine:…}, resolved before anything is written, and nothing else that expands.
|
||||
Variables map[string]string `json:"variables,omitempty"`
|
||||
// Path is entries on the account's PATH, each at its start or its end, in the order declared.
|
||||
Path []PathEntry `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// PathEntry is one directory a module puts on the account's PATH.
|
||||
type PathEntry struct {
|
||||
Entry string `json:"entry"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
// ShellCode is one piece of code a module adds to a shell's startup (novox/hq ADR 0204).
|
||||
type ShellCode struct {
|
||||
// For is the shell the code is written in.
|
||||
For string `json:"for"`
|
||||
// Slot is where it runs among the other modules' code: first, normal or last. Named rather
|
||||
// than numbered, because every contributor would guess a number and a collision says nothing.
|
||||
Slot string `json:"slot"`
|
||||
// Code is never interpreted — it is the shell's syntax, and only the shell reads it.
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// The shells and slots a contribution may name (novox/hq ADR 0204). Closed, so a typo is a refusal
|
||||
// at the check rather than code that silently lands in no placeholder.
|
||||
var (
|
||||
knownShells = []string{"zsh", "bash", "fish"}
|
||||
knownSlots = []string{"first", "normal", "last"}
|
||||
)
|
||||
|
||||
// The two renderings of the environment a holder may place (novox/hq ADR 0203, decision 3).
|
||||
const (
|
||||
EnvironmentPOSIX = "posix"
|
||||
EnvironmentSystemd = "systemd"
|
||||
)
|
||||
|
||||
// ofEnvironment and ofShell are where a holder places what was contributed: ${environment:posix},
|
||||
// ${environment:systemd} and ${shell:<shell>:<slot>}. Loose inside the braces on purpose, so a
|
||||
// misspelt key is found and refused rather than left in a file as a literal nobody reads.
|
||||
var (
|
||||
ofEnvironment = regexp.MustCompile(`\$\{environment:([^}]*)\}`)
|
||||
ofShell = regexp.MustCompile(`\$\{shell:([^}]*)\}`)
|
||||
)
|
||||
|
||||
// variableName is a POSIX shell variable name, which is also what environment.d accepts.
|
||||
var variableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
// environmentProblems is what is wrong with this module's environment contribution, from the
|
||||
// manifest alone.
|
||||
func (m Manifest) environmentProblems() []string {
|
||||
if m.Environment == nil {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
for _, n := range sortedKeys(m.Environment.Variables) {
|
||||
switch {
|
||||
case !variableName.MatchString(n):
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s sets the variable %q, which is not a name a shell accepts: a letter or an "+
|
||||
"underscore, then letters, digits and underscores", m.Module, n))
|
||||
continue
|
||||
case n == "PATH":
|
||||
// PATH is the one variable every module shares, so no module may set it whole: a second
|
||||
// setter would replace the first's entries, and the account's own PATH with them.
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s sets PATH as a variable; a module adds an entry under environment.path, at the "+
|
||||
"start or the end, and PATH is composed from every module's (novox/hq ADR 0203)", m.Module))
|
||||
continue
|
||||
}
|
||||
if why := literalProblem(m.Environment.Variables[n]); why != "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s sets %s to %q, which %s — %s", m.Module, n, m.Environment.Variables[n], why, literalRule))
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, p := range m.Environment.Path {
|
||||
switch {
|
||||
case p.Entry == "":
|
||||
problems = append(problems, fmt.Sprintf("%s's PATH entry %d names no directory", m.Module, i+1))
|
||||
case strings.Contains(ofMachine.ReplaceAllString(p.Entry, ""), ":"):
|
||||
// A colon is PATH's own separator, so an entry holding one is two entries, and the
|
||||
// check that it is already present would look for the wrong thing.
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s puts %q on PATH, which holds a colon, PATH's own separator", m.Module, p.Entry))
|
||||
case seen[p.Entry]:
|
||||
problems = append(problems, fmt.Sprintf("%s puts %q on PATH twice", m.Module, p.Entry))
|
||||
default:
|
||||
if why := literalProblem(p.Entry); why != "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s puts %q on PATH, which %s — %s", m.Module, p.Entry, why, literalRule))
|
||||
}
|
||||
}
|
||||
seen[p.Entry] = true
|
||||
if p.At != PathAtStart && p.At != PathAtEnd {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s puts %q on PATH at %q; an entry goes at %q or %q of the account's PATH",
|
||||
m.Module, p.Entry, p.At, PathAtStart, PathAtEnd))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// literalRule is why a value must be literal, said with every refusal of one.
|
||||
const literalRule = "a value is literal, so a POSIX shell and the service manager read it alike, and " +
|
||||
"names the machine only through the mesh's own ${machine:…} facts (novox/hq ADR 0203)"
|
||||
|
||||
// literalProblem is why a value cannot be written, unquoted by either reader, as the same string in
|
||||
// both formats — or nothing. A `$` would expand differently in each; a quote or a backslash is
|
||||
// quoting in one and a character in the other; a line break ends the line in both.
|
||||
func literalProblem(v string) string {
|
||||
switch {
|
||||
case strings.ContainsAny(v, `'"`):
|
||||
return "holds a quote"
|
||||
case strings.Contains(v, `\`):
|
||||
return "holds a backslash"
|
||||
case strings.ContainsAny(v, "\n\r"):
|
||||
return "holds a line break"
|
||||
case strings.ContainsRune(v, 0):
|
||||
return "holds a NUL"
|
||||
case strings.Contains(ofMachine.ReplaceAllString(v, ""), "$"):
|
||||
return "holds a $ that is not one of the machine's ${machine:…} facts"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// shellProblems is what is wrong with this module's shell code, from the manifest alone. The code
|
||||
// itself is not judged: it is the shell's syntax, which the controller does not read.
|
||||
func (m Manifest) shellProblems() []string {
|
||||
var problems []string
|
||||
for i, c := range m.Shell {
|
||||
if !oneOf(knownShells, c.For) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's shell code %d is for %q; the shells are %s", m.Module, i+1, c.For,
|
||||
strings.Join(knownShells, ", ")))
|
||||
}
|
||||
if !oneOf(knownSlots, c.Slot) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's shell code %d goes in the slot %q; the slots are %s", m.Module, i+1, c.Slot,
|
||||
strings.Join(knownSlots, ", ")))
|
||||
}
|
||||
if strings.TrimSpace(c.Code) == "" {
|
||||
problems = append(problems, fmt.Sprintf("%s's shell code %d has no code", m.Module, i+1))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// contributionPlaceholderProblems is every place this module's resources name the environment or
|
||||
// the shell's code and may not — judged from the manifest, so the catalogue check refuses it before
|
||||
// a mesh does, and again at composition in the same words.
|
||||
func (m Manifest) contributionPlaceholderProblems() []string {
|
||||
var problems []string
|
||||
for _, r := range m.Resources {
|
||||
problems = append(problems, placeholderProblems(m, r)...)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// placeholderProblems is what is wrong with one resource's ${environment:…} and ${shell:…}.
|
||||
//
|
||||
// **The seat authorises it, not the placeholder** (novox/hq ADR 0203 §5, ADR 0204 §3), as the seat
|
||||
// authorises the bus's user list: a module that does not hold the account's environment writing it
|
||||
// would be a second writer of a file there is one of, and a module that does not hold the login
|
||||
// shell writing every module's shell code would be a second shell.
|
||||
func placeholderProblems(m Manifest, r map[string]any) []string {
|
||||
var problems []string
|
||||
for _, field := range sortedKeys(r) {
|
||||
s, ok := r[field].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
env := ofEnvironment.FindAllStringSubmatch(s, -1)
|
||||
code := ofShell.FindAllStringSubmatch(s, -1)
|
||||
if len(env)+len(code) == 0 {
|
||||
continue
|
||||
}
|
||||
if field != "content" {
|
||||
// Placed only where a file's bytes are, which is where every one of them is meant to go:
|
||||
// a path or an owner holding several lines of shell is nothing the host could act on.
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's resource %v names %s in its %s; the environment and the shell's code are placed "+
|
||||
"only in a file's content", m.Module, r["id"], placeholderOf(env, code), field))
|
||||
continue
|
||||
}
|
||||
for _, e := range env {
|
||||
if e[1] != EnvironmentPOSIX && e[1] != EnvironmentSystemd {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's resource %v names %s; the environment is ${environment:%s} or ${environment:%s}",
|
||||
m.Module, r["id"], e[0], EnvironmentPOSIX, EnvironmentSystemd))
|
||||
}
|
||||
}
|
||||
if len(env) > 0 && !m.ClaimsSeat(EnvironmentSeat) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's resource %v names %s and %s does not claim %s; the account's environment is "+
|
||||
"written by that seat's holder alone (novox/hq ADR 0203)",
|
||||
m.Module, r["id"], env[0][0], m.Module, EnvironmentSeat))
|
||||
}
|
||||
for _, c := range code {
|
||||
shell, slot, two := strings.Cut(c[1], ":")
|
||||
if !two || !oneOf(knownShells, shell) || !oneOf(knownSlots, slot) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's resource %v names %s; shell code is ${shell:<shell>:<slot>}, the shell one of "+
|
||||
"%s and the slot one of %s", m.Module, r["id"], c[0],
|
||||
strings.Join(knownShells, ", "), strings.Join(knownSlots, ", ")))
|
||||
}
|
||||
}
|
||||
if len(code) > 0 && !m.ClaimsSeat(LoginShellSeat) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's resource %v names %s and %s does not claim %s; every module's shell code is "+
|
||||
"placed by the login shell's holder alone (novox/hq ADR 0204)",
|
||||
m.Module, r["id"], code[0][0], m.Module, LoginShellSeat))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func placeholderOf(env, code [][]string) string {
|
||||
if len(env) > 0 {
|
||||
return env[0][0]
|
||||
}
|
||||
return code[0][0]
|
||||
}
|
||||
|
||||
// contributedEnvironment is one node's environment, gathered and in the order it is written.
|
||||
type contributedEnvironment struct {
|
||||
// variables is by module in name order, each module's sorted by name.
|
||||
variables []setBy
|
||||
// start and end are PATH's entries in their final order, each once.
|
||||
start, end []placedOn
|
||||
}
|
||||
|
||||
type setBy struct {
|
||||
module string
|
||||
names []string
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
type placedOn struct {
|
||||
module, entry string
|
||||
}
|
||||
|
||||
// inModuleOrder is the modules sorted by name — the order contributions are written in (novox/hq
|
||||
// ADR 0203, ADR 0204), so the same set composes byte for byte whatever order they were assigned in.
|
||||
func inModuleOrder(modules []Manifest) []Manifest {
|
||||
out := append([]Manifest(nil), modules...)
|
||||
sort.SliceStable(out, func(a, b int) bool { return out[a].Module < out[b].Module })
|
||||
return out
|
||||
}
|
||||
|
||||
// variablesSetOnce refuses a variable two modules on one node both set (novox/hq ADR 0203 §5),
|
||||
// naming both. Neither is chosen: whichever was written last would win in one reader and not
|
||||
// necessarily in the other, and the module that lost would not be told.
|
||||
func variablesSetOnce(modules []Manifest) error {
|
||||
setter := map[string]string{}
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
if m.Environment == nil {
|
||||
continue
|
||||
}
|
||||
for _, n := range sortedKeys(m.Environment.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 "+
|
||||
"of them must stop setting it (novox/hq ADR 0203)", first, m.Module, n)
|
||||
}
|
||||
setter[n] = m.Module
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// environmentOn gathers every module's environment on a node, with the machine's facts in place.
|
||||
//
|
||||
// A PATH entry two modules both add is written once, where the first puts it: two toolchains
|
||||
// sharing ~/.local/bin is ordinary, and nothing about it is in conflict.
|
||||
func environmentOn(modules []Manifest, facts map[string]string) (contributedEnvironment, error) {
|
||||
var env contributedEnvironment
|
||||
if err := variablesSetOnce(modules); err != nil {
|
||||
return env, err
|
||||
}
|
||||
placed := map[string]bool{}
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
if m.Environment == nil {
|
||||
continue
|
||||
}
|
||||
if len(m.Environment.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)
|
||||
if err != nil {
|
||||
return env, err
|
||||
}
|
||||
set.names = append(set.names, n)
|
||||
set.values[n] = v
|
||||
}
|
||||
env.variables = append(env.variables, set)
|
||||
}
|
||||
for _, p := range m.Environment.Path {
|
||||
entry, err := factsIn(p.Entry, facts, m.Module, "a PATH entry")
|
||||
if err != nil {
|
||||
return env, err
|
||||
}
|
||||
if strings.Contains(entry, ":") {
|
||||
return env, fmt.Errorf("%s puts %q on PATH on this machine, which holds a colon, PATH's own separator",
|
||||
m.Module, entry)
|
||||
}
|
||||
if placed[entry] {
|
||||
continue
|
||||
}
|
||||
placed[entry] = true
|
||||
if p.At == PathAtEnd {
|
||||
env.end = append(env.end, placedOn{m.Module, entry})
|
||||
} else {
|
||||
env.start = append(env.start, placedOn{m.Module, entry})
|
||||
}
|
||||
}
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
for _, key := range machineUsed(v) {
|
||||
value, has := facts[key]
|
||||
if !has {
|
||||
return "", fmt.Errorf("%s sets %s to a value that says ${machine:%s}, and this machine says %s",
|
||||
module, what, key, orNothing(namesOfFacts(facts)))
|
||||
}
|
||||
v = strings.ReplaceAll(v, fmt.Sprintf("${machine:%s}", key), value)
|
||||
}
|
||||
// Judged again once filled: a fact is the mesh's, and still has to be a literal both readers
|
||||
// take alike.
|
||||
if why := literalProblem(v); why != "" {
|
||||
return "", fmt.Errorf("%s sets %s to %q on this machine, which %s — %s", module, what, v, why, literalRule)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// posix is the environment as lines a POSIX shell sources (novox/hq ADR 0203 §3): every variable
|
||||
// exported, every PATH entry added only when it is missing, so sourcing the file twice — a login
|
||||
// shell that starts another — changes nothing. POSIX sh only, because sh, bash and zsh all read it.
|
||||
//
|
||||
// The start entries are written last-first: each is put in front of PATH, so the last written ends
|
||||
// up first, and the result reads in module order, then the order each module declared.
|
||||
func (e contributedEnvironment) posix() string {
|
||||
var b strings.Builder
|
||||
for _, set := range e.variables {
|
||||
fmt.Fprintf(&b, "# %s\n", set.module)
|
||||
for _, n := range set.names {
|
||||
fmt.Fprintf(&b, "export %s='%s'\n", n, set.values[n])
|
||||
}
|
||||
}
|
||||
named := ""
|
||||
for i := len(e.start) - 1; i >= 0; i-- {
|
||||
p := e.start[i]
|
||||
if p.module != named {
|
||||
fmt.Fprintf(&b, "# %s\n", p.module)
|
||||
named = p.module
|
||||
}
|
||||
fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH='%s'\"${PATH:+:${PATH}}\" ;; esac\n",
|
||||
p.entry, p.entry)
|
||||
}
|
||||
named = ""
|
||||
for _, p := range e.end {
|
||||
if p.module != named {
|
||||
fmt.Fprintf(&b, "# %s\n", p.module)
|
||||
named = p.module
|
||||
}
|
||||
fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH=\"${PATH:+${PATH}:}\"'%s' ;; esac\n",
|
||||
p.entry, p.entry)
|
||||
}
|
||||
if len(e.start)+len(e.end) > 0 {
|
||||
b.WriteString("export PATH\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// systemd is the same environment as the service manager's environment.d reads it (novox/hq ADR
|
||||
// 0203 §3), for the account's user manager and so for everything a graphical session starts. Read
|
||||
// once per manager start, so it needs no guard against running twice; the account's existing PATH
|
||||
// sits between the start and the end entries.
|
||||
func (e contributedEnvironment) systemd() string {
|
||||
var b strings.Builder
|
||||
for _, set := range e.variables {
|
||||
fmt.Fprintf(&b, "# %s\n", set.module)
|
||||
for _, n := range set.names {
|
||||
fmt.Fprintf(&b, "%s=%s\n", n, set.values[n])
|
||||
}
|
||||
}
|
||||
if len(e.start) > 0 {
|
||||
fmt.Fprintf(&b, "# %s\nPATH=%s${PATH:+:$PATH}\n", modulesOf(e.start), entriesOf(e.start))
|
||||
}
|
||||
if len(e.end) > 0 {
|
||||
fmt.Fprintf(&b, "# %s\nPATH=${PATH:+$PATH:}%s\n", modulesOf(e.end), entriesOf(e.end))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// modulesOf names who contributed a line holding several modules' entries, in the order they appear.
|
||||
func modulesOf(entries []placedOn) string {
|
||||
var names []string
|
||||
seen := map[string]bool{}
|
||||
for _, p := range entries {
|
||||
if !seen[p.module] {
|
||||
seen[p.module] = true
|
||||
names = append(names, p.module)
|
||||
}
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func entriesOf(entries []placedOn) string {
|
||||
out := make([]string, len(entries))
|
||||
for i, p := range entries {
|
||||
out[i] = p.entry
|
||||
}
|
||||
return strings.Join(out, ":")
|
||||
}
|
||||
|
||||
// shellCode is every module's code for one shell and one slot (novox/hq ADR 0204 §3): in module
|
||||
// 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
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
named := false
|
||||
for _, c := range m.Shell {
|
||||
if c.For != shell || c.Slot != slot {
|
||||
continue
|
||||
}
|
||||
if !named {
|
||||
fmt.Fprintf(&b, "# %s\n", m.Module)
|
||||
named = true
|
||||
}
|
||||
b.WriteString(c.Code)
|
||||
if !strings.HasSuffix(c.Code, "\n") {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// contributionsInto fills a holder's file with the node's environment and its shell code.
|
||||
//
|
||||
// **Last, after every other placeholder pass, and in one pass each.** Shell code is contributed text
|
||||
// in a shell's own syntax — `${XDG_CACHE_HOME:-$HOME/.cache}`, `${(%):-%n}` — and the rendered
|
||||
// environment holds `${PATH:+…}`: a scanner for the mesh's own placeholders that ran after these
|
||||
// were in place would read the shell's expansions as the mesh's and refuse them, or fill a
|
||||
// `${machine:…}` some module wrote for its shell to see. So nothing runs after them, the environment
|
||||
// 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) error {
|
||||
if problems := placeholderProblems(m, resource); len(problems) > 0 {
|
||||
return fmt.Errorf("%s", problems[0])
|
||||
}
|
||||
content, ok := resource["content"].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if ofEnvironment.MatchString(content) {
|
||||
env, err := environmentOn(modules, facts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content = ofEnvironment.ReplaceAllStringFunc(content, func(placeholder string) string {
|
||||
if ofEnvironment.FindStringSubmatch(placeholder)[1] == EnvironmentSystemd {
|
||||
return env.systemd()
|
||||
}
|
||||
return env.posix()
|
||||
})
|
||||
}
|
||||
if ofShell.MatchString(content) {
|
||||
content = ofShell.ReplaceAllStringFunc(content, func(placeholder string) string {
|
||||
shell, slot, _ := strings.Cut(ofShell.FindStringSubmatch(placeholder)[1], ":")
|
||||
return shellCode(modules, shell, slot)
|
||||
})
|
||||
}
|
||||
resource["content"] = content
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0203 (the account's environment is one module's, and every module
|
||||
// contributes to it) and ADR 0204 (shell code in named slots, placed by the login shell's holder).
|
||||
|
||||
// contributors is a fixed set of contributions, in no particular order: what the renderings are
|
||||
// asserted against byte for byte. go-toolchain and zsh both put ~/.local/bin on PATH, which is the
|
||||
// ordinary case of two modules sharing a directory, and is written once.
|
||||
func contributors() []Manifest {
|
||||
return []Manifest{
|
||||
{Module: "zsh", Environment: &Environment{
|
||||
Variables: map[string]string{"XDG_CONFIG_HOME": "${machine:account-home}/.config", "EDITOR": "vim"},
|
||||
Path: []PathEntry{
|
||||
{Entry: "${machine:account-home}/.local/bin", At: PathAtStart},
|
||||
{Entry: "${machine:account-home}/bin", At: PathAtStart},
|
||||
{Entry: "/opt/scripts", At: PathAtEnd},
|
||||
},
|
||||
}},
|
||||
{Module: "go-toolchain", Environment: &Environment{
|
||||
Variables: map[string]string{"GOPATH": "${machine:account-home}/go"},
|
||||
Path: []PathEntry{
|
||||
{Entry: "${machine:account-home}/go/bin", At: PathAtStart},
|
||||
{Entry: "/usr/local/go/bin", At: PathAtStart},
|
||||
{Entry: "${machine:account-home}/.local/bin", At: PathAtStart},
|
||||
},
|
||||
}},
|
||||
{Module: "agent", Environment: &Environment{
|
||||
Variables: map[string]string{"DISABLE_AUTOUPDATER": "1"},
|
||||
Path: []PathEntry{{Entry: "/opt/agent/bin", At: PathAtEnd}},
|
||||
}},
|
||||
// A module contributing nothing is in the set and writes nothing.
|
||||
{Module: "postgres"},
|
||||
}
|
||||
}
|
||||
|
||||
var operatorFacts = map[string]string{"name": "workstation", "account": "op", "account-home": "/home/op"}
|
||||
|
||||
// The final PATH this set composes, around whatever the account had: the start entries in module
|
||||
// order and then declared order, the account's own, then the end entries.
|
||||
const composedPOSIX = `# agent
|
||||
export DISABLE_AUTOUPDATER='1'
|
||||
# go-toolchain
|
||||
export GOPATH='/home/op/go'
|
||||
# zsh
|
||||
export EDITOR='vim'
|
||||
export XDG_CONFIG_HOME='/home/op/.config'
|
||||
# zsh
|
||||
case ":${PATH}:" in *':/home/op/bin:'*) ;; *) PATH='/home/op/bin'"${PATH:+:${PATH}}" ;; esac
|
||||
# go-toolchain
|
||||
case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac
|
||||
case ":${PATH}:" in *':/usr/local/go/bin:'*) ;; *) PATH='/usr/local/go/bin'"${PATH:+:${PATH}}" ;; esac
|
||||
case ":${PATH}:" in *':/home/op/go/bin:'*) ;; *) PATH='/home/op/go/bin'"${PATH:+:${PATH}}" ;; esac
|
||||
# agent
|
||||
case ":${PATH}:" in *':/opt/agent/bin:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/agent/bin' ;; esac
|
||||
# zsh
|
||||
case ":${PATH}:" in *':/opt/scripts:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/scripts' ;; esac
|
||||
export PATH
|
||||
`
|
||||
|
||||
const composedSystemd = `# agent
|
||||
DISABLE_AUTOUPDATER=1
|
||||
# go-toolchain
|
||||
GOPATH=/home/op/go
|
||||
# zsh
|
||||
EDITOR=vim
|
||||
XDG_CONFIG_HOME=/home/op/.config
|
||||
# go-toolchain, zsh
|
||||
PATH=/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin${PATH:+:$PATH}
|
||||
# agent, zsh
|
||||
PATH=${PATH:+$PATH:}/opt/agent/bin:/opt/scripts
|
||||
`
|
||||
|
||||
func TestTheEnvironmentRendersForAPOSIXShellByteForByte(t *testing.T) {
|
||||
env, err := environmentOn(contributors(), operatorFacts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := env.posix(); got != composedPOSIX {
|
||||
t.Fatalf("the POSIX rendering is\n%s\nnot\n%s", got, composedPOSIX)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheEnvironmentRendersForTheServiceManagerByteForByte(t *testing.T) {
|
||||
env, err := environmentOn(contributors(), operatorFacts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := env.systemd(); got != composedSystemd {
|
||||
t.Fatalf("the environment.d rendering is\n%s\nnot\n%s", got, composedSystemd)
|
||||
}
|
||||
}
|
||||
|
||||
// Sourcing twice changes nothing (ADR 0203 §3): a login shell that starts another reads the file
|
||||
// again, and a PATH that grew each time would be the symptom. Run by a real `sh`, because the claim
|
||||
// is about what a shell does with the file, not about what the file looks like.
|
||||
func TestThePOSIXEnvironmentSourcedTwiceLeavesPATHAsOnce(t *testing.T) {
|
||||
sh, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skip("no sh on this machine")
|
||||
}
|
||||
script := "PATH=/usr/bin:/bin\n" + composedPOSIX + "once=$PATH\n" + composedPOSIX +
|
||||
`[ "$PATH" = "$once" ] || { echo "changed: $once -> $PATH"; exit 1; }` + "\n" +
|
||||
`echo "$PATH"; echo "$GOPATH"`
|
||||
out, err := exec.Command(sh, "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("sourcing twice: %v\n%s", err, out)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
want := "/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/usr/bin:/bin:/opt/agent/bin:/opt/scripts"
|
||||
if lines[0] != want {
|
||||
t.Fatalf("PATH is %s, not %s", lines[0], want)
|
||||
}
|
||||
if lines[1] != "/home/op/go" {
|
||||
t.Fatalf("GOPATH was not exported: %q", lines[1])
|
||||
}
|
||||
// And an entry the account already has stays where it is, and once.
|
||||
out, err = exec.Command(sh, "-c", "PATH=/opt/scripts:/usr/bin\n"+composedPOSIX+`echo "$PATH"`).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if got := strings.TrimSpace(string(out)); got !=
|
||||
"/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/opt/scripts:/usr/bin:/opt/agent/bin" {
|
||||
t.Fatalf("an entry already on PATH was added again or moved: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The environment.d rendering, read by the service manager's own generator where this machine has
|
||||
// one — the same reader an account's user manager runs, so the PATH it composes is the one asserted.
|
||||
func TestTheServiceManagerReadsTheSystemdRenderingAsMeant(t *testing.T) {
|
||||
generator := "/usr/lib/systemd/user-environment-generators/30-systemd-environment-d-generator"
|
||||
if _, err := os.Stat(generator); err != nil {
|
||||
t.Skip("no environment.d generator on this machine")
|
||||
}
|
||||
config := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(config, "environment.d"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(config, "environment.d", "50-mesh.conf"), []byte(composedSystemd), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command(generator)
|
||||
cmd.Env = []string{"PATH=/usr/bin:/bin", "HOME=" + config, "XDG_CONFIG_HOME=" + config}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
want := "PATH=/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/usr/bin:/bin:/opt/agent/bin:/opt/scripts"
|
||||
if !strings.Contains(string(out), want+"\n") || !strings.Contains(string(out), "GOPATH=/home/op/go\n") {
|
||||
t.Fatalf("the service manager read\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing contributed renders nothing, in both formats — not an empty `export PATH`.
|
||||
func TestNoContributionsRenderNothing(t *testing.T) {
|
||||
env, err := environmentOn([]Manifest{{Module: "postgres"}}, operatorFacts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.posix() != "" || env.systemd() != "" {
|
||||
t.Fatalf("an empty environment rendered %q and %q", env.posix(), env.systemd())
|
||||
}
|
||||
}
|
||||
|
||||
// A ${machine:…} fact the machine does not have is refused naming the module, as a file's is.
|
||||
func TestAContributedFactTheMachineLacksIsRefused(t *testing.T) {
|
||||
_, err := environmentOn(contributors(), map[string]string{"name": "server"})
|
||||
if err == nil || !strings.Contains(err.Error(), "go-toolchain sets GOPATH") ||
|
||||
!strings.Contains(err.Error(), "${machine:account-home}") {
|
||||
t.Fatalf("a missing account home was not refused by name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0203 §5: two modules setting one variable are refused, both named — neither silently wins.
|
||||
func TestAVariableTwoModulesSetIsRefusedNamingBoth(t *testing.T) {
|
||||
modules := append(contributors(), Manifest{Module: "neovim", Environment: &Environment{
|
||||
Variables: map[string]string{"EDITOR": "nvim"}}})
|
||||
_, err := environmentOn(modules, operatorFacts)
|
||||
if err == nil || err.Error() != "neovim and zsh both set EDITOR on this machine; the account has one "+
|
||||
"environment, so one of them must stop setting it (novox/hq ADR 0203)" {
|
||||
t.Fatalf("a variable set twice was not refused naming both: %v", err)
|
||||
}
|
||||
// And at composition, whether or not the node holds the environment.
|
||||
r := Resolution{Node: "workstation", Account: "op", Modules: modules}
|
||||
if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), "neovim and zsh both set EDITOR") {
|
||||
t.Fatalf("composition accepted a variable set twice: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// shells contributes code for several shells and slots, in no order.
|
||||
func shells() []Manifest {
|
||||
return []Manifest{
|
||||
{Module: "zsh-syntax-highlighting", Shell: []ShellCode{
|
||||
{For: "zsh", Slot: "last", Code: "source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh"},
|
||||
}},
|
||||
{Module: "powerlevel10k", Shell: []ShellCode{
|
||||
{For: "zsh", Slot: "first", Code: "if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" +
|
||||
" source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n"},
|
||||
{For: "zsh", Slot: "normal", Code: "source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme"},
|
||||
{For: "zsh", Slot: "normal", Code: "[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh"},
|
||||
}},
|
||||
{Module: "zsh-autosuggestions", Shell: []ShellCode{
|
||||
{For: "zsh", Slot: "normal", Code: "source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh"},
|
||||
{For: "bash", Slot: "normal", Code: "echo not for zsh"},
|
||||
}},
|
||||
{Module: "direnv", Shell: []ShellCode{
|
||||
{For: "fish", Slot: "last", Code: "direnv hook fish | source"},
|
||||
{For: "bash", Slot: "last", Code: "eval \"$(direnv hook bash)\""},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0204 §3: a slot holds that shell's code only, in module order, each module's pieces in the
|
||||
// order it declared them under a line naming it; empty when nothing is contributed.
|
||||
func TestShellCodeLandsInItsSlotInModuleOrderForItsShellOnly(t *testing.T) {
|
||||
if got, want := shellCode(shells(), "zsh", "normal"), "# powerlevel10k\n"+
|
||||
"source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n"+
|
||||
"[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh\n"+
|
||||
"# zsh-autosuggestions\n"+
|
||||
"source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n"; got != want {
|
||||
t.Fatalf("zsh's normal slot is\n%s\nnot\n%s", got, want)
|
||||
}
|
||||
if got, want := shellCode(shells(), "zsh", "last"), "# zsh-syntax-highlighting\n"+
|
||||
"source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n"; got != want {
|
||||
t.Fatalf("zsh's last slot is\n%s\nnot\n%s", got, want)
|
||||
}
|
||||
if got, want := shellCode(shells(), "bash", "last"), "# direnv\neval \"$(direnv hook bash)\"\n"; got != want {
|
||||
t.Fatalf("bash's last slot is %q, not %q", got, want)
|
||||
}
|
||||
if got := shellCode(shells(), "fish", "first"); got != "" {
|
||||
t.Fatalf("a slot nobody contributed to holds %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The holder of node-login-shell, as WP3's zsh module writes its block, with its own zsh around the
|
||||
// slots — which holds `${…}` of the shell's own that no mesh pass may touch either.
|
||||
func zshHolder() Manifest {
|
||||
return Manifest{Module: "zsh", Claims: []Claim{{Name: LoginShellSeat, Scope: ScopeNode}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "zshrc", "type": "file", "path": "${machine:account-home}/.zshrc", "content": "" +
|
||||
"${shell:zsh:first}" +
|
||||
"PROMPT='%n@%m ${PWD/#$HOME/~} '\n" +
|
||||
"${shell:zsh:normal}" +
|
||||
"alias ll='ls -l'\n" +
|
||||
"${shell:zsh:last}"},
|
||||
}}
|
||||
}
|
||||
|
||||
// The case the ordering exists for: contributed zsh code full of `${…}` reaches the file byte for
|
||||
// byte, because the shell's code is placed after every other placeholder pass and in one pass — a
|
||||
// scanner for the mesh's placeholders that ran after it would read `${XDG_CACHE_HOME:-…}` and
|
||||
// `${(%):-%n}` as the mesh's, or fill a `${machine:…}` some module wrote for its shell to see.
|
||||
func TestShellCodeReachesTheHoldersFileByteForByte(t *testing.T) {
|
||||
modules := append(shells(), zshHolder(), Manifest{Module: "sly", Shell: []ShellCode{
|
||||
{For: "zsh", Slot: "last", Code: "echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}"},
|
||||
}})
|
||||
r := Resolution{Node: "workstation", Account: "op", Modules: modules}
|
||||
out, err := r.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var zshrc map[string]any
|
||||
for _, res := range out {
|
||||
if res["id"] == "zsh.zshrc" {
|
||||
zshrc = res
|
||||
}
|
||||
}
|
||||
if zshrc == nil {
|
||||
t.Fatalf("the holder's file was not composed: %v", out)
|
||||
}
|
||||
if zshrc["path"] != "/home/op/.zshrc" {
|
||||
t.Fatalf("the holder's own placeholders were not filled first: %v", zshrc["path"])
|
||||
}
|
||||
want := "# powerlevel10k\n" +
|
||||
"if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" +
|
||||
" source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n" +
|
||||
"PROMPT='%n@%m ${PWD/#$HOME/~} '\n" +
|
||||
"# powerlevel10k\n" +
|
||||
"source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n" +
|
||||
"[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh\n" +
|
||||
"# zsh-autosuggestions\n" +
|
||||
"source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n" +
|
||||
"alias ll='ls -l'\n" +
|
||||
"# sly\n" +
|
||||
"echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}\n" +
|
||||
"# zsh-syntax-highlighting\n" +
|
||||
"source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n"
|
||||
if got := zshrc["content"]; got != want {
|
||||
t.Fatalf("the holder's .zshrc is\n%s\nnot\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The holder of node-environment places both renderings, and they are the same as rendered alone.
|
||||
func TestTheEnvironmentHolderPlacesBothRenderings(t *testing.T) {
|
||||
holder := Manifest{Module: "node-env", Claims: []Claim{{Name: EnvironmentSeat, Scope: ScopeNode}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "posix", "type": "file", "path": "${machine:account-home}/.config/mesh/environment.sh",
|
||||
"content": "# The mesh's environment.\n${environment:posix}"},
|
||||
{"id": "systemd", "type": "file", "path": "${machine:account-home}/.config/environment.d/50-mesh.conf",
|
||||
"content": "${environment:systemd}"},
|
||||
}}
|
||||
r := Resolution{Node: "workstation", Account: "op", Modules: append(contributors(), holder)}
|
||||
out, err := r.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
by := map[string]any{}
|
||||
for _, res := range out {
|
||||
by[res["id"].(string)] = res["content"]
|
||||
}
|
||||
if by["node-env.posix"] != "# The mesh's environment.\n"+composedPOSIX {
|
||||
t.Fatalf("the POSIX file is\n%v", by["node-env.posix"])
|
||||
}
|
||||
if by["node-env.systemd"] != composedSystemd {
|
||||
t.Fatalf("the environment.d file is\n%v", by["node-env.systemd"])
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0203 §5 and ADR 0204 §3: a placeholder outside the seat's holder is refused — by the parser,
|
||||
// which is what the catalogue check and registration run, and again at composition, in the same words.
|
||||
func TestAPlaceholderOutsideTheHolderIsRefused(t *testing.T) {
|
||||
for _, c := range []struct{ content, want string }{
|
||||
{"${environment:posix}", "toolchain's resource rc names ${environment:posix} and toolchain does not claim node-environment"},
|
||||
{"${shell:zsh:normal}", "toolchain's resource rc names ${shell:zsh:normal} and toolchain does not claim node-login-shell"},
|
||||
} {
|
||||
raw := `{"module":"toolchain","resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("the catalogue check accepted %s outside its holder: %v", c.content, err)
|
||||
}
|
||||
m := Manifest{Module: "toolchain", Resources: []map[string]any{
|
||||
{"id": "rc", "type": "file", "path": "/etc/rc", "content": c.content}}}
|
||||
r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}}
|
||||
if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("composition accepted %s outside its holder: %v", c.content, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A key nobody renders is refused, not left in the file as a literal.
|
||||
func TestAnUnknownPlaceholderKeyIsRefused(t *testing.T) {
|
||||
for _, c := range []struct{ content, want string }{
|
||||
{"${environment:foo}", "names ${environment:foo}; the environment is ${environment:posix} or ${environment:systemd}"},
|
||||
{"${shell:zsh:middle}", "names ${shell:zsh:middle}; shell code is ${shell:<shell>:<slot>}"},
|
||||
{"${shell:tcsh:first}", "names ${shell:tcsh:first}; shell code is ${shell:<shell>:<slot>}"},
|
||||
{"${shell:zsh}", "names ${shell:zsh}; shell code is ${shell:<shell>:<slot>}"},
|
||||
} {
|
||||
raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"},{"name":"node-login-shell","scope":"node"}],` +
|
||||
`"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s was accepted: %v", c.content, err)
|
||||
}
|
||||
}
|
||||
// And outside a file's content, where nothing could be placed.
|
||||
raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"}],` +
|
||||
`"resources":[{"id":"rc","type":"file","path":"/etc/${environment:posix}","content":"x"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil ||
|
||||
!strings.Contains(err.Error(), "names ${environment:posix} in its path; the environment and the shell's code are placed only in a file's content") {
|
||||
t.Errorf("a placeholder in a path was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// What ADR 0203 §2 allows a contribution to say, refused at parse when it says anything else.
|
||||
func TestAMalformedEnvironmentIsRefusedAtParse(t *testing.T) {
|
||||
for _, c := range []struct{ environment, want string }{
|
||||
{`{"variables":{"1X":"a"}}`, `tool sets the variable "1X", which is not a name a shell accepts`},
|
||||
{`{"variables":{"MY-VAR":"a"}}`, `tool sets the variable "MY-VAR", which is not a name a shell accepts`},
|
||||
{`{"variables":{"PATH":"/bin"}}`, `tool sets PATH as a variable; a module adds an entry under environment.path`},
|
||||
{`{"variables":{"A":"$HOME/x"}}`, `tool sets A to "$HOME/x", which holds a $ that is not one of the machine's ${machine:…} facts`},
|
||||
{`{"variables":{"A":"${HOME}/x"}}`, `tool sets A to "${HOME}/x", which holds a $`},
|
||||
{`{"variables":{"A":"it's"}}`, `tool sets A to "it's", which holds a quote`},
|
||||
{`{"variables":{"A":"say \"hi\""}}`, `which holds a quote`},
|
||||
{`{"variables":{"A":"a\\b"}}`, `which holds a backslash`},
|
||||
{`{"variables":{"A":"a\nb"}}`, `which holds a line break`},
|
||||
{`{"variables":{"A":"a\u0000b"}}`, `which holds a NUL`},
|
||||
{`{"path":[{"entry":"","at":"start"}]}`, `tool's PATH entry 1 names no directory`},
|
||||
{`{"path":[{"entry":"/a:/b","at":"start"}]}`, `tool puts "/a:/b" on PATH, which holds a colon`},
|
||||
{`{"path":[{"entry":"$HOME/bin","at":"start"}]}`, `tool puts "$HOME/bin" on PATH, which holds a $`},
|
||||
{`{"path":[{"entry":"/a","at":"middle"}]}`, `tool puts "/a" on PATH at "middle"; an entry goes at "start" or "end"`},
|
||||
{`{"path":[{"entry":"/a"}]}`, `tool puts "/a" on PATH at ""`},
|
||||
{`{"path":[{"entry":"/a","at":"start"},{"entry":"/a","at":"end"}]}`, `tool puts "/a" on PATH twice`},
|
||||
{`{"variables":{"A":"x"},"paths":[]}`, `unknown field "paths"`},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(`{"module":"tool","environment":` + c.environment + `}`))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: want %q, got %v", c.environment, c.want, err)
|
||||
}
|
||||
}
|
||||
// What is allowed: a literal, and the machine's own facts.
|
||||
if _, err := ParseManifest([]byte(`{"module":"tool","environment":{` +
|
||||
`"variables":{"GOPATH":"${machine:account-home}/go","DISABLE_X":"1","ANSWER":"a b+c=d"},` +
|
||||
`"path":[{"entry":"${machine:account-home}/go/bin","at":"start"},{"entry":"/opt/x","at":"end"}]}}`)); err != nil {
|
||||
t.Fatalf("a well-formed environment was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedShellCodeIsRefusedAtParse(t *testing.T) {
|
||||
for _, c := range []struct{ shell, want string }{
|
||||
{`[{"for":"tcsh","slot":"normal","code":"x"}]`, `tool's shell code 1 is for "tcsh"; the shells are zsh, bash, fish`},
|
||||
{`[{"for":"zsh","slot":"middle","code":"x"}]`, `tool's shell code 1 goes in the slot "middle"; the slots are first, normal, last`},
|
||||
{`[{"for":"zsh","slot":"last","code":"x"},{"for":"zsh","slot":"last","code":" \n"}]`, `tool's shell code 2 has no code`},
|
||||
{`[{"for":"zsh","slot":"last","code":"x","order":1}]`, `unknown field "order"`},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(`{"module":"tool","shell":` + c.shell + `}`))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: want %q, got %v", c.shell, c.want, err)
|
||||
}
|
||||
}
|
||||
// The code itself is never judged: a shell's own `${…}` is not the mesh's.
|
||||
if _, err := ParseManifest([]byte(`{"module":"tool","shell":[{"for":"zsh","slot":"first",` +
|
||||
`"code":"source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\""}]}`)); err != nil {
|
||||
t.Fatalf("shell code was judged as if it were the mesh's: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0203 §1 and ADR 0204 §1: both seats are the mesh's own, held once per machine; the login
|
||||
// shell's contract is `execute`, described and with a schema an agent can call.
|
||||
func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) {
|
||||
env, ok := SeatNamed("node-environment")
|
||||
if !ok || env.Scope != ScopeNode || env.Decision != "novox/hq ADR 0203" ||
|
||||
len(env.Serves)+len(env.Accepts)+len(env.Emits) != 0 || env.Delivers != "" {
|
||||
t.Fatalf("node-environment is not a node seat with no protocol: %+v (defined %v)", env, ok)
|
||||
}
|
||||
shell, ok := SeatNamed("node-login-shell")
|
||||
if !ok || shell.Scope != ScopeNode || shell.Decision != "novox/hq ADR 0204" {
|
||||
t.Fatalf("node-login-shell is not a node seat: %+v (defined %v)", shell, ok)
|
||||
}
|
||||
if len(shell.Serves) != 1 || shell.Serves[0].Name != "execute" || shell.Serves[0].Description == "" {
|
||||
t.Fatalf("the login shell serves %+v, not execute alone", shell.Serves)
|
||||
}
|
||||
props, _ := shell.Serves[0].Input["properties"].(map[string]any)
|
||||
required, _ := shell.Serves[0].Input["required"].([]string)
|
||||
if _, has := props["command"]; !has || len(required) != 1 || required[0] != "command" {
|
||||
t.Fatalf("execute does not require a command: %v", shell.Serves[0].Input)
|
||||
}
|
||||
if _, has := props["timeout_seconds"]; !has {
|
||||
t.Fatalf("execute takes no timeout: %v", props)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0204 §1: the login shell is the mesh's, so no module declares it — neither under the mesh's
|
||||
// name nor under the name a module gave it before.
|
||||
func TestNoModuleMayDeclareTheLoginShell(t *testing.T) {
|
||||
for _, n := range []string{"login-shell", "node-login-shell", "node-environment"} {
|
||||
raw := `{"module":"zsh","seats":[{"name":"` + n + `","scope":"node","serves":["execute"]}],"tools":["execute"]}`
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil {
|
||||
t.Errorf("a module declaring %q was accepted", n)
|
||||
}
|
||||
}
|
||||
got := strings.Join(declaredSeatProblems(Manifest{Module: "zsh",
|
||||
DefinesSeats: []SeatDeclaration{{Name: "login-shell", Scope: ScopeNode}}}), "; ")
|
||||
if !strings.Contains(got, `zsh declares a seat named "login-shell"; the login shell is the mesh's own seat node-login-shell`) {
|
||||
t.Fatalf("declaring login-shell was not refused by name: %q", got)
|
||||
}
|
||||
// And a shell module claiming the mesh's seat, serving execute, is what the seat is for.
|
||||
m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` +
|
||||
`"claims":[{"name":"node-login-shell","scope":"node"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CanHold(m, Seat{Name: LoginShellSeat, Scope: ScopeNode, Serves: loginShellVerbs()}); err != nil {
|
||||
t.Fatalf("a shell module claiming the seat cannot hold it: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A grant secret is read by whatever provisions, and that stopped being root (novox/hq issue 225).
|
||||
//
|
||||
// The mesh seals one credential per consumer beside the provider's contributions file. The
|
||||
// provider's harness reads both: the file to learn who asked, the secret to set their password.
|
||||
// While a module's own code ran in a container as root, a root-owned 0600 file was readable by
|
||||
// the thing that needed it. ADR 0198 moved that code under the node's runtime, which runs as the
|
||||
// operator's account — and the secret stayed root's.
|
||||
//
|
||||
// **The cost was silence.** The harness says `secret not readable yet`, which is true and
|
||||
// ordinary on the first pass, so four thousand refusals in three hours read as patience. No user
|
||||
// was ever created, and two consumers crash-looped against a database that had never heard of
|
||||
// them.
|
||||
//
|
||||
// The same reasoning is already written for a module's *own* secrets, three hundred lines above:
|
||||
// "a root-owned 0600 file is one that process cannot read". This is that rule reaching the other
|
||||
// kind of secret the mesh writes for a module.
|
||||
|
||||
// aProviderWithABundle is a provider whose code is a bundle the node's runtime runs — the shape
|
||||
// every TypeScript provisioner has since ADR 0198.
|
||||
func aProviderWithABundle() Manifest {
|
||||
return Manifest{
|
||||
Module: "mongodb", Version: "1",
|
||||
Provides: FromAnywhere("mongodb-database"),
|
||||
Receives: map[string]string{"mongodb-database": "/var/lib/mongodb/grants/mesh.json"},
|
||||
Grants: map[string]string{"mongodb-database": "/var/lib/mongodb/grants"},
|
||||
Bundles: []Bundle{{Name: "code", Language: "typescript"}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "server", "type": "container", "name": "mongodb-server",
|
||||
"image": "mongo@sha256:" + strings.Repeat("a", 64),
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGrantSecretIsOwnedByTheAccountThatProvisions(t *testing.T) {
|
||||
r, err := Resolve(shelf(aProviderWithABundle()), []string{"mongodb"}, reachable(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "operator"
|
||||
out, err := r.Declaration(Rendering{Grants: []Grant{{
|
||||
Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos",
|
||||
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var secret map[string]any
|
||||
for _, res := range out {
|
||||
if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") {
|
||||
secret = res
|
||||
}
|
||||
}
|
||||
if secret == nil {
|
||||
t.Fatalf("no grant secret was composed at all: %v", out)
|
||||
}
|
||||
if got := secret["owner"]; got != "operator" {
|
||||
t.Fatalf("the grant secret at %v belongs to %v; the provisioner runs as %q and a "+
|
||||
"root-owned 0600 file is one it cannot read — which is silent, because the harness "+
|
||||
"calls it \"not readable yet\"", fmtPath(secret), got, "operator")
|
||||
}
|
||||
}
|
||||
|
||||
// And a provider whose code still runs in a container keeps the owner it declares, so this
|
||||
// changes nothing for the modules the runtime has not taken.
|
||||
func TestAContainerProvidersGrantSecretKeepsItsDeclaredOwner(t *testing.T) {
|
||||
m := aProviderWithABundle()
|
||||
m.Bundles = nil
|
||||
m.SecretsOwner = "65534:65534"
|
||||
r, err := Resolve(shelf(m), []string{"mongodb"}, reachable(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "operator"
|
||||
out, err := r.Declaration(Rendering{Grants: []Grant{{
|
||||
Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos",
|
||||
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, res := range out {
|
||||
if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") {
|
||||
if got := res["owner"]; got != "65534:65534" {
|
||||
t.Fatalf("a container provider's grant secret belongs to %v, not what it declares", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("no grant secret was composed")
|
||||
}
|
||||
|
||||
func fmtPath(r map[string]any) string {
|
||||
p, _ := r["path"].(string)
|
||||
return p
|
||||
}
|
||||
@@ -517,6 +517,17 @@ type Manifest struct {
|
||||
// holder. Like Filtering: one module per node gathers what every module declared and writes it.
|
||||
Jailing *Jailing `json:"jailing,omitempty"`
|
||||
|
||||
// Environment is what this module adds to the operator account's environment: variables, and
|
||||
// entries on PATH (novox/hq ADR 0203). Facts, not lines of one shell's syntax — the holder of
|
||||
// node-environment places them, and the controller writes them in each reader's format. Like
|
||||
// Jails: any module contributes, gathered from every module on the node, written by the holder.
|
||||
Environment *Environment `json:"environment,omitempty"`
|
||||
|
||||
// Shell is code this module adds to the login shell's startup, for a named shell in a named
|
||||
// slot (novox/hq ADR 0204). The controller never reads it: it is placed, in module order, where
|
||||
// the holder of node-login-shell put the slot's placeholder.
|
||||
Shell []ShellCode `json:"shell,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
|
||||
@@ -1805,6 +1816,12 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
problems = append(problems, m.unknownDirRefs()...)
|
||||
problems = append(problems, m.unknownAccessRefs()...)
|
||||
problems = append(problems, m.jailProblems()...)
|
||||
// What a module adds to the account's environment and to the login shell, and the holder's
|
||||
// placeholders for them (novox/hq ADR 0203, ADR 0204) — here, so the catalogue check refuses
|
||||
// them in the words registration does.
|
||||
problems = append(problems, m.environmentProblems()...)
|
||||
problems = append(problems, m.shellProblems()...)
|
||||
problems = append(problems, m.contributionPlaceholderProblems()...)
|
||||
|
||||
for i, r := range m.Resources {
|
||||
id, _ := r["id"].(string)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A container publishes only a port its module declares (novox/hq issue 227).
|
||||
//
|
||||
// **The short form is a question the mesh answers.** `"80"` means *publish what the software
|
||||
// calls 80*, and the mesh fills in the machine's half from the port it assigned
|
||||
// ([ADR 0038](0038)). It can only assign one for a port the module declared in `listens` — so a
|
||||
// container publishing a number that appears nowhere in `listens` gets no assignment, and
|
||||
// `publishedOn` falls back to the number as written. It escapes to the machine.
|
||||
//
|
||||
// That is how the photo module asked for port 80 on the control node, where the reverse proxy
|
||||
// holds it: it declared its web endpoint at 4001, published a bare 80, and the container never
|
||||
// started. Four other modules publish 80 quite safely — because they declare 80, so the mesh
|
||||
// gives them a machine port for it. The difference is the declaration, not the number.
|
||||
//
|
||||
// A mapping written the long way is a module pinning both halves on purpose and is left alone.
|
||||
func TestEveryPublishedPortIsOneItsModuleDeclares(t *testing.T) {
|
||||
root := catalogueRoot(t)
|
||||
entries, err := os.ReadDir(filepath.Join(root, "modules"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var escaped []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "modules", entry.Name(), "module.json"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
// Whether every manifest parses is TestEveryCatalogueManifestParses's question.
|
||||
continue
|
||||
}
|
||||
declared := map[int]bool{}
|
||||
for _, l := range m.Listens {
|
||||
declared[l.Port] = true
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "container" {
|
||||
continue
|
||||
}
|
||||
listed, _ := r["ports"].([]any)
|
||||
for _, p := range listed {
|
||||
written := strings.Split(fmt.Sprint(p), "/")[0]
|
||||
if strings.Contains(written, ":") {
|
||||
continue // pinned by hand, both halves, on purpose
|
||||
}
|
||||
port, err := strconv.Atoi(strings.TrimSpace(written))
|
||||
if err != nil || declared[port] {
|
||||
continue
|
||||
}
|
||||
escaped = append(escaped, fmt.Sprintf(
|
||||
"%s's %v publishes %d, and %s declares no such port — the mesh has nothing "+
|
||||
"to assign, so %d reaches the machine as written",
|
||||
m.Module, r["id"], port, m.Module, port))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(escaped) > 0 {
|
||||
t.Fatalf("a container may publish only a port its module declares:\n - %s",
|
||||
strings.Join(escaped, "\n - "))
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,17 @@ var defaultSeats = []Seat{
|
||||
// served by the node tools runtime (ADR 0175).
|
||||
{Name: "node-service-manager", Scope: ScopeNode, Decision: "novox/hq ADR 0177",
|
||||
Serves: serviceManagerVerbs()},
|
||||
// The operator account's environment (novox/hq ADR 0203): one module per machine writes it, and
|
||||
// every module contributes to it. No verbs — the seat says who places the environment's files,
|
||||
// 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 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 any
|
||||
// node may call; the holder places every module's shell code in its slots.
|
||||
{Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204",
|
||||
Serves: loginShellVerbs()},
|
||||
// Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client
|
||||
// model change, not a rename, so it stays until that is built.
|
||||
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
||||
@@ -456,3 +467,17 @@ func serviceManagerVerbs() []Verb {
|
||||
Input: scoped(map[string]string{"unit": unit["unit"], "lines": "how many lines from the end (default 100)"}, []string{"unit"})},
|
||||
}
|
||||
}
|
||||
|
||||
// loginShellVerbs is the contract every holder of node-login-shell serves (novox/hq ADR 0176, ADR
|
||||
// 0204): one command, run the way the operator's own terminal would run it, bounded below the
|
||||
// runtime's thirty-second call limit so a hung command answers rather than times the caller out.
|
||||
func loginShellVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "execute", Description: "Run one command on this machine as the operator account, in a " +
|
||||
"non-interactive login shell in its home; answers with what it printed and how it exited.",
|
||||
Input: schema(map[string]string{
|
||||
"command": "the command line, as you would type it",
|
||||
"timeout_seconds": "give up after this long, at most 25 (default 20)",
|
||||
}, []string{"command"})},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ import (
|
||||
// and nothing to keep in step when a mesh seat is added.
|
||||
const meshSeatPrefix = "mesh-"
|
||||
|
||||
// retiredLoginShell is the one name outside the prefix a module may not declare: the login shell's,
|
||||
// from when a module declared it (novox/hq ADR 0176), before it became the mesh's (ADR 0204).
|
||||
const retiredLoginShell = "login-shell"
|
||||
|
||||
// A SeatDeclaration is a role a module offers on the bus: what may be sent to it, what it says,
|
||||
// and what it answers. A caller declares that it uses the *seat*, never the module, so the
|
||||
// implementation can be replaced under it.
|
||||
@@ -88,6 +92,15 @@ func declaredSeatProblems(m Manifest) []string {
|
||||
"seats (novox/hq ADR 0118)", m.Module, s.Name, meshSeatPrefix+"*"))
|
||||
continue
|
||||
}
|
||||
if s.Name == retiredLoginShell {
|
||||
// The name ADR 0176 gave the login shell when the zsh module declared it. The seat is
|
||||
// the mesh's now, so a module declaring the old name would be a second login shell
|
||||
// beside it, with a protocol of its own (novox/hq ADR 0204).
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares a seat named %q; the login shell is the mesh's own seat %s, which a shell "+
|
||||
"module claims and none declares (novox/hq ADR 0204)", m.Module, s.Name, LoginShellSeat))
|
||||
continue
|
||||
}
|
||||
if seen[s.Name] {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares the seat %q twice", m.Module, s.Name))
|
||||
|
||||
@@ -44,10 +44,11 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
delivered[s.Delivers] = s.Name
|
||||
}
|
||||
}
|
||||
// Seventeen since node-build-agent (novox/hq ADR 0190) — sixteen once the retired
|
||||
// mesh-build-machine row goes, when no registered manifest claims it any more.
|
||||
if len(Seats()) != 17 {
|
||||
t.Errorf("the mesh defines %d seats rather than 17; the set is closed, so a change here is "+
|
||||
// Nineteen since node-environment and node-login-shell (novox/hq ADR 0203, ADR 0204), after
|
||||
// node-build-agent made seventeen (ADR 0190) — eighteen once the retired mesh-build-machine row
|
||||
// goes, when no registered manifest claims it any more.
|
||||
if len(Seats()) != 19 {
|
||||
t.Errorf("the mesh defines %d seats rather than 19; the set is closed, so a change here is "+
|
||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// What the artifact store keeps, and what it may let go (novox/hq ADR 0189, issue 108).
|
||||
@@ -71,11 +73,12 @@ func (i *Inventory) ToCollect(ctx context.Context) ([]string, error) {
|
||||
continue
|
||||
}
|
||||
for _, a := range made {
|
||||
if a.Reference == "" || keep[a.Reference] || collected[a.Reference] || seen[a.Reference] {
|
||||
reference := asRecorded(a.Reference)
|
||||
if reference == "" || keep[reference] || collected[reference] || seen[reference] {
|
||||
continue
|
||||
}
|
||||
seen[a.Reference] = true
|
||||
out = append(out, a.Reference)
|
||||
seen[reference] = true
|
||||
out = append(out, reference)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -127,8 +130,8 @@ func (i *Inventory) keptReferences(ctx context.Context) (map[string]bool, error)
|
||||
continue
|
||||
}
|
||||
for _, a := range made {
|
||||
if a.Reference != "" {
|
||||
keep[a.Reference] = true
|
||||
if reference := asRecorded(a.Reference); reference != "" {
|
||||
keep[reference] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,16 +189,35 @@ func (i *Inventory) everyReferenceMade(ctx context.Context) ([]string, error) {
|
||||
continue
|
||||
}
|
||||
for _, a := range made {
|
||||
if a.Reference == "" || seen[a.Reference] {
|
||||
reference := asRecorded(a.Reference)
|
||||
if reference == "" || seen[reference] {
|
||||
continue
|
||||
}
|
||||
seen[a.Reference] = true
|
||||
out = append(out, a.Reference)
|
||||
seen[reference] = true
|
||||
out = append(out, reference)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// asRecorded is an artifact reference in the one vocabulary the sweep speaks (novox/hq issue 226).
|
||||
//
|
||||
// **Every reference here came from a build record, so every one of them is the mesh's own.** That
|
||||
// is what makes it safe to normalise: references kept before the store's address stopped being
|
||||
// written are `<host>:<port>/<path>@sha256:…` (04-ISSUES/102), and `Recorded` reads those as the
|
||||
// `artifact-store://` references the rest of the mesh uses. Done here rather than when the store
|
||||
// is asked, because `Recorded` cannot tell one registry host from another — only the provenance
|
||||
// can, and the provenance is here.
|
||||
//
|
||||
// The oldest artifacts are exactly the ones recorded the old way, and exactly the ones a
|
||||
// sweep reaches first. Untranslated, the first of them ended every sweep.
|
||||
func asRecorded(reference string) string {
|
||||
if reference == "" {
|
||||
return ""
|
||||
}
|
||||
return catalogue.Recorded(reference)
|
||||
}
|
||||
|
||||
// digestIn is the `sha256:<hex>` a reference names, empty when it names none.
|
||||
func digestIn(reference string) string {
|
||||
for _, marker := range []string{"@sha256:", "/sha256:"} {
|
||||
|
||||
@@ -145,3 +145,48 @@ func TestAFailedBuildNamesNothingToCollectAndEachModuleIsCountedOnItsOwn(t *test
|
||||
t.Fatalf("offered %v; want only web's oldest — db's three are all within its five", go_)
|
||||
}
|
||||
}
|
||||
|
||||
// An artifact recorded with the store's old address is offered for collection, in the vocabulary
|
||||
// the rest of the mesh speaks (novox/hq issue 226).
|
||||
//
|
||||
// Before references were kept without an address the mesh recorded
|
||||
// `<host>:<port>/<path>@sha256:…` (04-ISSUES/102). Those are the oldest artifacts, which makes
|
||||
// them exactly the ones an oldest-first sweep reaches first — and the first live run met one,
|
||||
// read "I will not address this" as "the store refuses everything", and collected none of 1681.
|
||||
func TestAnArtifactRecordedWithAnAddressIsOfferedAsTheMeshRecordsOne(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// The oldest build published the old way; five newer ones fill the module's five.
|
||||
old := aBuild("a00", "tools", "")
|
||||
old.Made = []Artifact{{Name: "build", Kind: "image",
|
||||
Reference: "127.0.0.1:5100/tools/build@sha256:" + fmt.Sprintf("%064x", 1)}}
|
||||
if err := inv.RecordBuild(ctx, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 2; i <= 6; i++ {
|
||||
built(t, inv, fmt.Sprintf("a%02d", i), "tools", i)
|
||||
}
|
||||
|
||||
go_, err := inv.ToCollect(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := ref("tools", "build", 1)
|
||||
if len(go_) != 1 || go_[0] != want {
|
||||
t.Fatalf("offered %v; want %q — the address is a route to the artifact, not part of its "+
|
||||
"name, and the sweep speaks the name", go_, want)
|
||||
}
|
||||
// And marking it collected uses that same name, so the next sweep does not offer it again
|
||||
// under a spelling it has not seen.
|
||||
if err := inv.MarkCollected(ctx, go_); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := inv.ToCollect(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(again) != 0 {
|
||||
t.Fatalf("offered %v again after collecting it", again)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user