Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ee32970ef | ||
|
|
11b654499b | ||
|
|
d69e19103c | ||
|
|
10f948e970 | ||
|
|
f421d4588c | ||
|
|
74b0dab34c | ||
|
|
41b20b2782 | ||
|
|
912e9f4e85 | ||
|
|
babd7b2f47 | ||
|
|
892dfd1d08 | ||
|
|
cfac579392 | ||
|
|
fe0d295490 | ||
|
|
d8a0238e02 | ||
|
|
dcf710a8d5 | ||
|
|
a2003ab616 | ||
|
|
f19a2254ac | ||
|
|
7d46e48b26 | ||
|
|
78915f9f7a |
+121
-21
@@ -39,7 +39,10 @@ import (
|
|||||||
//
|
//
|
||||||
// It costs a resolution per machine. Assignment is a person typing a command, and being told which
|
// It costs a resolution per machine. Assignment is a person typing a command, and being told which
|
||||||
// machines this just blocked is worth more than the milliseconds.
|
// machines this just blocked is worth more than the milliseconds.
|
||||||
func assign(ctx context.Context, open *stores, node, module string) (string, error) {
|
func assign(ctx context.Context, open *stores, node string, modules ...string) (string, error) {
|
||||||
|
if len(modules) == 0 {
|
||||||
|
return "", fmt.Errorf("assign %s names no module", node)
|
||||||
|
}
|
||||||
// Held while it is recorded, so it cannot land between a converge's preview and its flip and
|
// Held while it is recorded, so it cannot land between a converge's preview and its flip and
|
||||||
// be taken without ever having been previewed (novox/hq ADR 0100).
|
// be taken without ever having been previewed (novox/hq ADR 0100).
|
||||||
ctx, release, err := holdNodes(ctx, open, []string{node})
|
ctx, release, err := holdNodes(ctx, open, []string{node})
|
||||||
@@ -47,6 +50,16 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
|
// **The one assignment refused for what the node lacks** (novox/hq ADR 0207). Everything else
|
||||||
|
// an assignment leaves unresolved is kept, because assignment is not an ordering; a module whose
|
||||||
|
// resources are applied through a seat nothing on the node holds is refused, because that order
|
||||||
|
// — the service manager, the package manager and the runtime before anything that installs,
|
||||||
|
// runs or contains — is the mesh's to keep. Several modules in one act are judged together, so
|
||||||
|
// holders that depend on each other go on in one command.
|
||||||
|
said, err := seatDependenciesOnAssign(ctx, open, node, modules)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
// **Before the new assignment can unsettle a seat somebody holds only by being alone**
|
// **Before the new assignment can unsettle a seat somebody holds only by being alone**
|
||||||
// (novox/hq 04-ISSUES/170): what the mesh derived so far is written down, and then the
|
// (novox/hq 04-ISSUES/170): what the mesh derived so far is written down, and then the
|
||||||
// assignment resolves against a record rather than against a coincidence.
|
// assignment resolves against a record rather than against a coincidence.
|
||||||
@@ -54,65 +67,152 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
fresh, err := open.inventory.Assign(ctx, node, module)
|
var lines []string
|
||||||
if err != nil {
|
var added []string
|
||||||
return "", err
|
for _, module := range modules {
|
||||||
|
fresh, err := open.inventory.Assign(ctx, node, module)
|
||||||
|
if err != nil {
|
||||||
|
return strings.Join(lines, "\n"), err
|
||||||
|
}
|
||||||
|
if !fresh {
|
||||||
|
// Nothing changed, and saying "is assigned" would read as an action. One node runs one
|
||||||
|
// of each — the module's name is the assignment's identity (novox/hq ADR 0115).
|
||||||
|
lines = append(lines, fmt.Sprintf(
|
||||||
|
"%s already runs %s — one node runs one of each (ADR 0115); nothing changed", node, module))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
added = append(added, module)
|
||||||
|
lines = append(lines, fmt.Sprintf("%s is assigned %s", node, module))
|
||||||
}
|
}
|
||||||
if !fresh {
|
if len(added) == 0 {
|
||||||
// Nothing changed, and saying "is assigned" would read as an action. One node runs one
|
return strings.Join(lines, "\n"), nil
|
||||||
// of each — the module's name is the assignment's identity (novox/hq ADR 0115).
|
|
||||||
return fmt.Sprintf("%s already runs %s — one node runs one of each (ADR 0115); nothing changed",
|
|
||||||
node, module), nil
|
|
||||||
}
|
}
|
||||||
said := fmt.Sprintf("%s is assigned %s", node, module)
|
answer := strings.Join(lines, "\n")
|
||||||
for _, line := range settled {
|
for _, line := range settled {
|
||||||
said += "\n " + line
|
answer += "\n " + line
|
||||||
|
}
|
||||||
|
for _, line := range said {
|
||||||
|
answer += "\n but " + line
|
||||||
}
|
}
|
||||||
// Its bus credential, in the same act (novox/hq issue 203): an assignment pushed before its
|
// Its bus credential, in the same act (novox/hq issue 203): an assignment pushed before its
|
||||||
// credential exists delivers a process that cannot authenticate and crash-loops until somebody
|
// credential exists delivers a process that cannot authenticate and crash-loops until somebody
|
||||||
// runs a second verb and a second push. Issued here when the module speaks on the bus and has
|
// runs a second verb and a second push. Issued here when the module speaks on the bus and has
|
||||||
// no credential yet; kept when it has one, so re-assigning rotates nothing.
|
// no credential yet; kept when it has one, so re-assigning rotates nothing.
|
||||||
if line := issueOnAssign(ctx, open, node, module); line != "" {
|
for _, module := range added {
|
||||||
said += "\n " + line
|
if line := issueOnAssign(ctx, open, node, module); line != "" {
|
||||||
|
answer += "\n " + line
|
||||||
|
}
|
||||||
}
|
}
|
||||||
plan, _, err := planFor(ctx, open, node)
|
plan, _, err := planFor(ctx, open, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Kept, and still refused. Both halves are the answer, and the rest of the mesh is still
|
// Kept, and still refused. Both halves are the answer, and the rest of the mesh is still
|
||||||
// worth reporting: this machine's refusal is rarely the only consequence.
|
// worth reporting: this machine's refusal is rarely the only consequence.
|
||||||
return said + blockedElsewhere(ctx, open, node), err
|
return answer + blockedElsewhere(ctx, open, node), err
|
||||||
}
|
}
|
||||||
// Kept, and cannot be hosted here. Said at once rather than discovered at push: a module whose
|
// Kept, and cannot be hosted here. Said at once rather than discovered at push: a module whose
|
||||||
// capability the machine lacks is on the wrong machine, and the assignment records what a person
|
// capability the machine lacks is on the wrong machine, and the assignment records what a person
|
||||||
// meant while this line says it will not run until it moves. The rest of the node still pushes.
|
// meant while this line says it will not run until it moves. The rest of the node still pushes.
|
||||||
|
isAdded := map[string]bool{}
|
||||||
|
for _, m := range added {
|
||||||
|
isAdded[m] = true
|
||||||
|
}
|
||||||
for _, u := range plan.Unhostable {
|
for _, u := range plan.Unhostable {
|
||||||
if u.Module != module {
|
if !isAdded[u.Module] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, c := range u.Missing {
|
for _, c := range u.Missing {
|
||||||
said += "\n but " + catalogue.WrongMachine(u.Module, c, node)
|
answer += "\n but " + catalogue.WrongMachine(u.Module, c, node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return said + fmt.Sprintf("\n run `push %s` to send it", node) +
|
return answer + fmt.Sprintf("\n run `push %s` to send it", node) +
|
||||||
blockedElsewhere(ctx, open, node), nil
|
blockedElsewhere(ctx, open, node), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// unassign takes a module off a node. What it leaves behind is the host's business: a directory
|
// seatDependenciesOnAssign is the refusal ADR 0207 makes at assignment, or the lines an assignment
|
||||||
|
// says beside itself when a dependency has no holder in the catalogue to name. Modules already
|
||||||
|
// assigned are not new and are not judged again.
|
||||||
|
func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, modules []string) ([]string, error) {
|
||||||
|
shelf, err := open.inventory.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
assigned, err := open.inventory.Assigned(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
already := map[string]bool{}
|
||||||
|
for _, a := range assigned {
|
||||||
|
already[a] = true
|
||||||
|
}
|
||||||
|
var adding []string
|
||||||
|
for _, m := range modules {
|
||||||
|
if !already[m] {
|
||||||
|
adding = append(adding, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return catalogue.AssignRefusal(shelf, node, assigned, adding)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unassign takes modules off a node. What they leave behind is the host's business: a directory
|
||||||
// holding anything the mesh did not put there is kept (novox/hq ADR 0030).
|
// holding anything the mesh did not put there is kept (novox/hq ADR 0030).
|
||||||
//
|
//
|
||||||
// It reports the rest of the mesh for the same reason assign does, and more sharply: taking a
|
// It reports the rest of the mesh for the same reason assign does, and more sharply: taking a
|
||||||
// module off one machine is the ordinary way to stop providing something to another, and nothing
|
// module off one machine is the ordinary way to stop providing something to another, and nothing
|
||||||
// about the command's own output would ever have said so.
|
// about the command's own output would ever have said so.
|
||||||
func unassign(ctx context.Context, open *stores, node, module string) (string, error) {
|
//
|
||||||
|
// **Refused when it takes away the last holder of a seat a module left on the node depends on**
|
||||||
|
// (novox/hq ADR 0207) — the other side of refusing that module's assignment without one. Several
|
||||||
|
// modules in one act are judged together, so a holder and its dependents come off in one command.
|
||||||
|
func unassign(ctx context.Context, open *stores, node string, modules ...string) (string, error) {
|
||||||
|
if len(modules) == 0 {
|
||||||
|
return "", fmt.Errorf("unassign %s names no module", node)
|
||||||
|
}
|
||||||
ctx, release, err := holdNodes(ctx, open, []string{node})
|
ctx, release, err := holdNodes(ctx, open, []string{node})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
if err := open.inventory.Unassign(ctx, node, module); err != nil {
|
shelf, err := open.inventory.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
assigned, err := open.inventory.Assigned(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Every one checked before any is taken off, so a refusal leaves the node as it was.
|
||||||
|
runs := map[string]bool{}
|
||||||
|
for _, a := range assigned {
|
||||||
|
runs[a] = true
|
||||||
|
}
|
||||||
|
for _, module := range modules {
|
||||||
|
if !runs[module] {
|
||||||
|
return "", fmt.Errorf("%s is not assigned to %s", module, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := catalogue.UnassignRefusal(shelf, node, assigned, modules); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, module := range modules {
|
||||||
|
if err := open.inventory.Unassign(ctx, node, module); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
return fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so",
|
return fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so",
|
||||||
node, module, node) + blockedElsewhere(ctx, open, node), nil
|
node, strings.Join(modules, ", "), node) + blockedElsewhere(ctx, open, node), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitModules is a surface's one `module` field as the modules it names: several, comma-separated,
|
||||||
|
// are one act (novox/hq ADR 0207), so the holders that depend on each other go on together from the
|
||||||
|
// command API and the controller seat's verbs as they do from the command line.
|
||||||
|
func splitModules(field string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, m := range strings.Split(field, ",") {
|
||||||
|
if m = strings.TrimSpace(m); m != "" {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// blockedElsewhere is every OTHER machine that cannot be worked out as things now stand.
|
// blockedElsewhere is every OTHER machine that cannot be worked out as things now stand.
|
||||||
|
|||||||
@@ -95,10 +95,10 @@ func commands(who Authenticator) http.Handler {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.HandleFunc("POST /assign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
mux.HandleFunc("POST /assign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||||
return assign(ctx, open, in.Node, in.Module)
|
return assign(ctx, open, in.Node, splitModules(in.Module)...)
|
||||||
}))
|
}))
|
||||||
mux.HandleFunc("POST /unassign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
mux.HandleFunc("POST /unassign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||||
return unassign(ctx, open, in.Node, in.Module)
|
return unassign(ctx, open, in.Node, splitModules(in.Module)...)
|
||||||
}))
|
}))
|
||||||
// Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`.
|
// Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`.
|
||||||
mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||||
|
|||||||
@@ -650,6 +650,11 @@ type answers struct {
|
|||||||
// public name on the machine went dark. The holds were correct; they were recorded only in the
|
// public name on the machine went dark. The holds were correct; they were recorded only in the
|
||||||
// machine's own state file, and the one visible symptom was a count that did not add up.
|
// machine's own state file, and the one visible symptom was a count that did not add up.
|
||||||
untaken map[string]map[string]int
|
untaken map[string]map[string]int
|
||||||
|
// unheld is every module on a machine whose resources are applied through a seat nothing on
|
||||||
|
// that machine holds (novox/hq ADR 0207), with the modules that could hold it. Reported, not
|
||||||
|
// refused, until the switch — and while there is any, the mesh is not all well: the order the
|
||||||
|
// machines' modules are built in is the mesh's to keep, and this is where it says it is not kept.
|
||||||
|
unheld []catalogue.Unheld
|
||||||
}
|
}
|
||||||
|
|
||||||
// heldBy is every artifact this mesh has built, for a build that may need one as its base.
|
// heldBy is every artifact this mesh has built, for a build that may need one as its base.
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func collect(ctx context.Context, inv *inventory.Inventory) {
|
|||||||
store := artifacts.Store{Address: address}
|
store := artifacts.Store{Address: address}
|
||||||
|
|
||||||
var done []string
|
var done []string
|
||||||
var left int
|
var left, skipped int
|
||||||
for i, reference := range references {
|
for i, reference := range references {
|
||||||
if i >= mostPerSweep || within.Err() != nil {
|
if i >= mostPerSweep || within.Err() != nil {
|
||||||
left = len(references) - i
|
left = len(references) - i
|
||||||
@@ -73,10 +73,22 @@ func collect(ctx context.Context, inv *inventory.Inventory) {
|
|||||||
done = append(done, reference)
|
done = append(done, reference)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// **Stopped at the first refusal, not pushed through.** A store that refuses one refuses
|
if errors.Is(err, artifacts.ErrNotOurs) {
|
||||||
// all of them — deletion disabled, the store down, the network gone — so going on would
|
// **A fact about this record, so this record is skipped** (novox/hq issue 226). Not
|
||||||
// be a hundred identical failures and a hundred identical log lines in front of whoever
|
// marked collected — the mesh did not remove it and should not claim to — and not a
|
||||||
// was building something.
|
// 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",
|
fmt.Fprintf(os.Stderr, "the artifact store kept %s, so nothing more was asked of it: %v\n",
|
||||||
reference, err)
|
reference, err)
|
||||||
left = len(references) - i
|
left = len(references) - i
|
||||||
@@ -97,6 +109,9 @@ func collect(ctx context.Context, inv *inventory.Inventory) {
|
|||||||
if left > 0 {
|
if left > 0 {
|
||||||
fmt.Fprintf(os.Stderr, "%d more to collect; the next build asks again\n", left)
|
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
|
// mostPerSweep is how many artifacts one sweep will ask about. Enough that a mesh building
|
||||||
|
|||||||
@@ -179,8 +179,8 @@ func usage() {
|
|||||||
seat <name> --to <node>/<module> hand a seat to that assignment as one act; never empty in between (ADR 0131)
|
seat <name> --to <node>/<module> hand a seat to that assignment as one act; never empty in between (ADR 0131)
|
||||||
board [--listen ADDR] the same three questions, as a page that holds nothing
|
board [--listen ADDR] the same three questions, as a page that holds nothing
|
||||||
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
|
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
|
||||||
assign <node> <module> put a module on a node
|
assign <node> <module>... put modules on a node, judged together (ADR 0207)
|
||||||
unassign <node> <module> take it off
|
unassign <node> <module>... take them off
|
||||||
take <node> <module> preview a module's cutover on an adopted node: what runs beside
|
take <node> <module> preview a module's cutover on an adopted node: what runs beside
|
||||||
what it declares; --yes <digest> cuts it over as previewed
|
what it declares; --yes <digest> cuts it over as previewed
|
||||||
converge <node> [--yes <digest>] [--filter nftables] preview, then make, an adopted node converged
|
converge <node> [--yes <digest>] [--filter nftables] preview, then make, an adopted node converged
|
||||||
|
|||||||
@@ -334,8 +334,10 @@ func moduleCommand(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func assignCommand(ctx context.Context, verb string, args []string) error {
|
func assignCommand(ctx context.Context, verb string, args []string) error {
|
||||||
if len(args) != 2 {
|
// Several modules in one act (novox/hq ADR 0207): holders that depend on each other — the
|
||||||
return fmt.Errorf("%s <node> <module>", verb)
|
// service manager and the package manager — can only go on, or come off, together.
|
||||||
|
if len(args) < 2 {
|
||||||
|
return fmt.Errorf("%s <node> <module> [<module>…]", verb)
|
||||||
}
|
}
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -349,7 +351,7 @@ func assignCommand(ctx context.Context, verb string, args []string) error {
|
|||||||
if verb == "unassign" {
|
if verb == "unassign" {
|
||||||
act = unassign
|
act = unassign
|
||||||
}
|
}
|
||||||
said, err := act(ctx, open, args[0], args[1])
|
said, err := act(ctx, open, args[0], args[1:]...)
|
||||||
if said != "" {
|
if said != "" {
|
||||||
fmt.Println(said)
|
fmt.Println(said)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
@@ -127,6 +128,7 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
|||||||
// a mesh-wide gatherer may pass over — see notResolvable.
|
// a mesh-wide gatherer may pass over — see notResolvable.
|
||||||
return catalogue.Resolution{}, nil, notResolvable{err}
|
return catalogue.Resolution{}, nil, notResolvable{err}
|
||||||
}
|
}
|
||||||
|
logUnheld(nodeName, resolved.Unheld)
|
||||||
|
|
||||||
// The credential for each thing this node takes from elsewhere. Made once and kept, so the
|
// The credential for each thing this node takes from elsewhere. Made once and kept, so the
|
||||||
// password a provider is told to create is the one its consumer was given — and sealed to
|
// password a provider is told to create is the one its consumer was given — and sealed to
|
||||||
@@ -1325,3 +1327,34 @@ func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.C
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unheldLogged is what was last logged about each node's unmet seat dependencies, so the log says
|
||||||
|
// each change once (novox/hq ADR 0207), on stderr so `status --json` stays a document — planFor runs for every status, push and assignment, and a
|
||||||
|
// line per call would bury the one that changed.
|
||||||
|
var (
|
||||||
|
unheldLogged = map[string]string{}
|
||||||
|
unheldLoggedMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// logUnheld logs a node's unmet seat dependencies when they differ from what was last logged for
|
||||||
|
// it, including when they become none.
|
||||||
|
func logUnheld(node string, unheld []catalogue.Unheld) {
|
||||||
|
lines := make([]string, 0, len(unheld))
|
||||||
|
for _, u := range unheld {
|
||||||
|
lines = append(lines, u.String())
|
||||||
|
}
|
||||||
|
now := strings.Join(lines, "\n")
|
||||||
|
unheldLoggedMu.Lock()
|
||||||
|
before, seen := unheldLogged[node]
|
||||||
|
unheldLogged[node] = now
|
||||||
|
unheldLoggedMu.Unlock()
|
||||||
|
if (seen && before == now) || (!seen && now == "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if now == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: every seat its modules depend on is held (novox/hq ADR 0207)\n", node)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %d unmet seat dependenc(ies), reported and not refused (novox/hq ADR 0207):\n %s\n",
|
||||||
|
node, len(lines), strings.Join(lines, "\n "))
|
||||||
|
}
|
||||||
|
|||||||
@@ -747,6 +747,16 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
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
|
// 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
|
// 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.
|
// the push stands, the first failure is named once, and the next push tries again.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
@@ -73,6 +74,10 @@ type meshStatus struct {
|
|||||||
// alone. A document without this called a machine well while a predecessor's chain refused
|
// alone. A document without this called a machine well while a predecessor's chain refused
|
||||||
// what the mesh declared open.
|
// what the mesh declared open.
|
||||||
Filtered []machineFiltered `json:"filtered,omitempty"`
|
Filtered []machineFiltered `json:"filtered,omitempty"`
|
||||||
|
// Unheld is every module on a machine whose resources are applied through a seat nothing on
|
||||||
|
// that machine holds, with the modules that could hold it (novox/hq ADR 0207). Absent when every
|
||||||
|
// dependency is met. Reported, not refused, until the switch.
|
||||||
|
Unheld []catalogue.Unheld `json:"unheld,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// machineFiltered is one rule set on a converged machine that the mesh did not write and that
|
// machineFiltered is one rule set on a converged machine that the mesh did not write and that
|
||||||
@@ -204,6 +209,7 @@ func statusAsJSON(asked answers) ([]byte, error) {
|
|||||||
out.Filtered = append(out.Filtered, machineFiltered{Node: name, Where: x.Where, Owner: x.Owner, Refuses: x.Refuses})
|
out.Filtered = append(out.Filtered, machineFiltered{Node: name, Where: x.Where, Owner: x.Owner, Refuses: x.Refuses})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out.Unheld = asked.unheld
|
||||||
for name := range asked.refused {
|
for name := range asked.refused {
|
||||||
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
||||||
Node: name, Problem: asked.refused[name]})
|
Node: name, Problem: asked.refused[name]})
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0207 at the controller's acts: `assign` refuses a module whose resources a
|
||||||
|
// seat nothing on the node holds applies, `unassign` refuses taking the last holder from under its
|
||||||
|
// dependents, and `status` reports what composition does not yet refuse.
|
||||||
|
|
||||||
|
func serviceManagerHolder() catalogue.Manifest {
|
||||||
|
return catalogue.Manifest{Module: "systemd", Version: "1",
|
||||||
|
Claims: []catalogue.Claim{{Name: catalogue.ServiceManagerSeat, Scope: catalogue.ScopeNode,
|
||||||
|
Serves: []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal"}}},
|
||||||
|
Resources: []map[string]any{{"id": "systemd", "type": "package", "package": "systemd"}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func packageManagerHolder() catalogue.Manifest {
|
||||||
|
return catalogue.Manifest{Module: "pacman", Version: "1",
|
||||||
|
Claims: []catalogue.Claim{{Name: catalogue.PackageManagerSeat, Scope: catalogue.ScopeNode}},
|
||||||
|
Resources: []map[string]any{{"id": "refresh", "type": "service", "unit": "pacman-refresh.timer"}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func aDaemon() catalogue.Manifest {
|
||||||
|
return catalogue.Manifest{Module: "sshd", Version: "1",
|
||||||
|
Resources: []map[string]any{{"id": "sshd", "type": "service", "unit": "sshd.service"}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAssignmentWithoutItsHolderIsRefusedAndNotKept(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
register(t, open, serviceManagerHolder())
|
||||||
|
register(t, open, packageManagerHolder())
|
||||||
|
register(t, open, aDaemon())
|
||||||
|
|
||||||
|
_, err := assign(ctx, open, "laptop", "sshd")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("sshd went onto a machine nothing holds the service manager of")
|
||||||
|
}
|
||||||
|
for _, want := range []string{catalogue.ServiceManagerSeat, "systemd", "ADR 0207"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("the refusal does not say %q:\n%v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assigned, err := open.inventory.Assigned(ctx, "laptop")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if contains(assigned, "sshd") {
|
||||||
|
t.Fatalf("a refused assignment was kept: %v", assigned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The holders depend on each other, so neither goes on alone — and both go on in one act.
|
||||||
|
if _, err := assign(ctx, open, "laptop", "systemd"); err == nil {
|
||||||
|
t.Fatal("systemd went on alone though its package needs a package manager")
|
||||||
|
}
|
||||||
|
if said, err := assign(ctx, open, "laptop", "systemd", "pacman"); err != nil {
|
||||||
|
t.Fatalf("the two holders assigned together were refused: %v\n%s", err, said)
|
||||||
|
}
|
||||||
|
if said, err := assign(ctx, open, "laptop", "sshd"); err != nil {
|
||||||
|
t.Fatalf("sshd beside its holder was refused: %v\n%s", err, said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheControllerSeatsAssignTakesSeveralModulesAsOneAct(t *testing.T) {
|
||||||
|
argv, err := argvFor("assign", map[string]any{"node": "laptop", "module": "systemd, pacman"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Join(argv, " ") != "assign laptop systemd pacman" {
|
||||||
|
t.Errorf("the seat's assign became %v", argv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnassigningTheLastHolderUnderItsDependentsIsRefused(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
register(t, open, serviceManagerHolder())
|
||||||
|
register(t, open, packageManagerHolder())
|
||||||
|
register(t, open, aDaemon())
|
||||||
|
if _, err := assign(ctx, open, "laptop", "systemd", "pacman", "sshd"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := unassign(ctx, open, "laptop", "systemd")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("the service manager came off a machine still running services")
|
||||||
|
}
|
||||||
|
for _, want := range []string{catalogue.ServiceManagerSeat, "sshd", "pacman"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("the refusal does not name %q:\n%v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assigned, _ := open.inventory.Assigned(ctx, "laptop")
|
||||||
|
if !contains(assigned, "systemd") {
|
||||||
|
t.Fatalf("a refused unassignment took the module off anyway: %v", assigned)
|
||||||
|
}
|
||||||
|
if _, err := unassign(ctx, open, "laptop", "sshd"); err != nil {
|
||||||
|
t.Fatalf("a dependent could not come off: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusReportsAnUnheldDependencyWithoutRefusingTheMachine(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
register(t, open, serviceManagerHolder())
|
||||||
|
register(t, open, aDaemon())
|
||||||
|
// Assigned straight into the store: a machine whose modules predate the rule, which is every
|
||||||
|
// machine on the day it ships.
|
||||||
|
if _, err := open.inventory.Assign(ctx, "laptop", "sshd"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
asked, err := theThreeQuestions(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, refused := asked.refused["laptop"]; refused {
|
||||||
|
t.Fatalf("an unmet dependency refused the machine before the switch: %s", asked.refused["laptop"])
|
||||||
|
}
|
||||||
|
if asked.well() {
|
||||||
|
t.Error("a mesh with an unheld dependency reads as all well")
|
||||||
|
}
|
||||||
|
got := printed(t, func() error { return printStatus(asked) })
|
||||||
|
for _, want := range []string{"unheld", "laptop", "sshd", catalogue.ServiceManagerSeat, "systemd"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("status does not say %q:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, err := statusAsJSON(asked)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
Unheld []catalogue.Unheld `json:"unheld"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &doc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(doc.Unheld) != 1 || doc.Unheld[0].Module != "sshd" || doc.Unheld[0].Seat != catalogue.ServiceManagerSeat {
|
||||||
|
t.Errorf("the document's unheld is %+v", doc.Unheld)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,7 +113,9 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
|||||||
if err := need("node", "module"); err != nil {
|
if err := need("node", "module"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return []string{verb, str("node"), str("module")}, nil
|
// Several modules comma-separated, judged as one act (novox/hq ADR 0207): the holders of
|
||||||
|
// the seats that apply resources depend on each other and go on together.
|
||||||
|
return append([]string{verb, str("node")}, splitModules(str("module"))...), nil
|
||||||
case "pin":
|
case "pin":
|
||||||
if err := need("node", "provision", "from", "module"); err != nil {
|
if err := need("node", "provision", "from", "module"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -282,6 +282,22 @@ func printStatus(asked answers) error {
|
|||||||
fmt.Printf("\n `take <node> <module>` compares what runs against what it declares, and runs it\n\n")
|
fmt.Printf("\n `take <node> <module>` compares what runs against what it declares, and runs it\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(asked.unheld) > 0 {
|
||||||
|
// **Reported, and not refused yet** (novox/hq ADR 0207 §4). Each machine still resolves and
|
||||||
|
// is sent what it would be; this says which of its modules depend on a seat nothing there
|
||||||
|
// holds, until every machine has its holders and the switch makes it a refusal.
|
||||||
|
fmt.Printf("%d module dependenc(ies) on a seat nothing on the machine holds (unheld, ADR 0207):\n",
|
||||||
|
len(asked.unheld))
|
||||||
|
for _, u := range asked.unheld {
|
||||||
|
holders := "no module in the catalogue claims it yet"
|
||||||
|
if len(u.Holders) > 0 {
|
||||||
|
holders = "could be held by " + strings.Join(u.Holders, ", ")
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-12s %-24s %-24s %s\n", u.Node, u.Module, u.Seat, holders)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n `assign <node> <holder>` meets it; reported until every machine has its holders, then refused\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
if adopted := adoptedNodes(nodes); len(adopted) > 0 {
|
if adopted := adoptedNodes(nodes); len(adopted) > 0 {
|
||||||
// Said, because nothing forces the flip: a node left adopted is visible here rather than
|
// Said, because nothing forces the flip: a node left adopted is visible here rather than
|
||||||
// read as converged (novox/hq ADR 0100). Not a fault, so it does not break "all well".
|
// read as converged (novox/hq ADR 0100). Not a fault, so it does not break "all well".
|
||||||
@@ -386,6 +402,20 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return answers{}, err
|
return answers{}, err
|
||||||
}
|
}
|
||||||
|
// And which machines run a module whose resources a seat nothing there holds applies (novox/hq
|
||||||
|
// ADR 0207). Each machine resolved again rather than threaded through whoResolves, whose answer
|
||||||
|
// the private network is built from and should say nothing else; a machine that does not
|
||||||
|
// resolve is already in refused, and is passed over here.
|
||||||
|
for _, n := range out.nodes {
|
||||||
|
plan, _, err := planFor(ctx, open, n.Name)
|
||||||
|
if err != nil {
|
||||||
|
if unresolvable(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return answers{}, err
|
||||||
|
}
|
||||||
|
out.unheld = append(out.unheld, plan.Unheld...)
|
||||||
|
}
|
||||||
out.plans, err = inv.RecentPlans(ctx, 5)
|
out.plans, err = inv.RecentPlans(ctx, 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return answers{}, err
|
return answers{}, err
|
||||||
@@ -496,7 +526,7 @@ func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inven
|
|||||||
func (a answers) well() bool {
|
func (a answers) well() bool {
|
||||||
return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 &&
|
return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 &&
|
||||||
len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 &&
|
len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 &&
|
||||||
len(a.filtered) == 0
|
len(a.filtered) == 0 && len(a.unheld) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostSplit is which machines report which host version, for every version more than one machine
|
// hostSplit is which machines report which host version, for every version more than one machine
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ package artifacts
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -26,7 +27,15 @@ type Store struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gone is the answer when the store does not hold it: the outcome wanted, already true.
|
// 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.
|
// 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
|
// wants the artifact absent, and it is. It is distinguished from success only so a caller can say
|
||||||
// which of the two happened.
|
// which of the two happened.
|
||||||
func (s Store) LetGo(ctx context.Context, reference string) error {
|
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)
|
path, kept := catalogue.InArtifactStore(reference)
|
||||||
if !kept {
|
if !kept {
|
||||||
// Nothing the mesh put in its own store. Refused rather than attempted: composing a
|
// 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
|
// delete for a reference of unknown shape is how a sweep reaches something that is not
|
||||||
// the mesh's.
|
// the mesh's. Distinguished from a store that refuses, so a sweep skips this and goes on.
|
||||||
return fmt.Errorf("%s is not a reference into the mesh's artifact store", reference)
|
return fmt.Errorf("%w: %s", ErrNotOurs, reference)
|
||||||
}
|
}
|
||||||
if s.Address == "" {
|
if s.Address == "" {
|
||||||
return fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference)
|
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 {
|
if before, after, ok := strings.Cut(path, "/blobs/sha256:"); ok {
|
||||||
return before, "blobs", "sha256:" + after, nil
|
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)
|
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
|
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
|
// 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.
|
// — 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) {
|
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,
|
filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "", with.Foundation,
|
||||||
with.OutwardLinks, with.TunnelInterface)
|
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
|
var out []map[string]any
|
||||||
for _, m := range r.Modules {
|
for _, m := range r.Modules {
|
||||||
if with.Adopted && m.Filtering != nil {
|
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.
|
// and nothing would say so.
|
||||||
continue
|
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
|
// 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.
|
// where it keeps several (ADR 0094); the lab found two files with one id.
|
||||||
"id": GrantID(to, g.Consumer+"."+holderAs(g.From, g.Local)),
|
"id": GrantID(to, g.Consumer+"."+holderAs(g.From, g.Local)),
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": grantPath(m.Grants[to], g.Consumer, holderAs(g.From, g.Local)),
|
"path": grantPath(m.Grants[to], g.Consumer, holderAs(g.From, g.Local)),
|
||||||
"sealed": g.Sealed,
|
"sealed": g.Sealed,
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, to := range sortedKeys(m.Binds) {
|
for _, to := range sortedKeys(m.Binds) {
|
||||||
@@ -889,6 +896,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
publishedOn(copied, m.Module, with)
|
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"])
|
copied["id"] = m.Module + "." + fmt.Sprint(resource["id"])
|
||||||
// A service saying what it reflects names resources within its own module, so those
|
// A service saying what it reflects names resources within its own module, so those
|
||||||
// are prefixed too or they would point at nothing.
|
// are prefixed too or they would point at nothing.
|
||||||
@@ -1226,6 +1240,28 @@ type Contribution struct {
|
|||||||
Derived map[string]any `json:"derived,omitempty"`
|
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.
|
// 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
|
// Suffixed, so the directory can also hold whatever the module writing it keeps there and so a
|
||||||
|
|||||||
@@ -0,0 +1,558 @@
|
|||||||
|
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"}
|
||||||
|
// sessionFiles are the two files of the graphical session's start that read no directory, so a
|
||||||
|
// contribution to them is a slot rather than a drop-in (novox/hq ADR 0208 §4): `xinitrc` is POSIX
|
||||||
|
// code the session's start runs, `xresources` X resources merged at its start. Placed by the
|
||||||
|
// display server's holder, as a shell's slots are placed by the login shell's.
|
||||||
|
sessionFiles = []string{"xinitrc", "xresources"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// contributionTargets is every name a contribution's `for` may take.
|
||||||
|
func contributionTargets() []string {
|
||||||
|
return append(append([]string(nil), knownShells...), sessionFiles...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// placerOf is the seat whose holder places a contribution for this target (novox/hq ADR 0204,
|
||||||
|
// ADR 0208 §4).
|
||||||
|
func placerOf(target string) string {
|
||||||
|
if oneOf(sessionFiles, target) {
|
||||||
|
return DisplayServerSeat
|
||||||
|
}
|
||||||
|
return LoginShellSeat
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(contributionTargets(), c.For) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s's shell code %d is for %q; the shells are %s, and the session's files %s",
|
||||||
|
m.Module, i+1, c.For, strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", ")))
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
// Each placeholder judged by its own target: a shell's code is the login shell's holder's to
|
||||||
|
// place (ADR 0204), the session's files the display server's (ADR 0208 §4) — and a holder of
|
||||||
|
// one placing the other's would be a second writer of a file there is one of.
|
||||||
|
refusedFor := map[string]bool{}
|
||||||
|
for _, c := range code {
|
||||||
|
target, slot, two := strings.Cut(c[1], ":")
|
||||||
|
if !two || !oneOf(contributionTargets(), target) || !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 or the session's file one of %s, and the slot one of %s", m.Module, r["id"], c[0],
|
||||||
|
strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "),
|
||||||
|
strings.Join(knownSlots, ", ")))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seat := placerOf(target)
|
||||||
|
if m.ClaimsSeat(seat) || refusedFor[seat] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
refusedFor[seat] = true
|
||||||
|
if seat == DisplayServerSeat {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s's resource %v names %s and %s does not claim %s; every module's %s is placed by "+
|
||||||
|
"the display server's holder alone (novox/hq ADR 0208)",
|
||||||
|
m.Module, r["id"], c[0], m.Module, seat, target))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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"], c[0], m.Module, seat))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
// The graphical session's seats (novox/hq ADR 0208): one module per piece of software, each piece's
|
||||||
|
// role a node seat in the mesh's own set, so i3 and sway, xterm and foot, rofi and dmenu compete for
|
||||||
|
// a role rather than each inventing one — and a machine running two of one role is refused at
|
||||||
|
// assignment instead of found by two bars on one screen.
|
||||||
|
const (
|
||||||
|
LoginManagerSeat = "node-login-manager"
|
||||||
|
DisplayServerSeat = "node-display-server"
|
||||||
|
DisplaySessionSeat = "node-display-session"
|
||||||
|
TerminalEmulatorSeat = "node-terminal-emulator"
|
||||||
|
LauncherSeat = "node-launcher"
|
||||||
|
NotifierSeat = "node-notifier"
|
||||||
|
LockScreenSeat = "node-lock-screen"
|
||||||
|
ClipboardSeat = "node-clipboard"
|
||||||
|
BarSeat = "node-bar"
|
||||||
|
CompositorSeat = "node-compositor"
|
||||||
|
SecretServiceSeat = "node-secret-service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// graphicalSessionSeats are the eleven, in the order ADR 0208's table reads, each with the verbs
|
||||||
|
// research 026/05 starts it with. Three have none yet: the bar, the compositor and the secret
|
||||||
|
// service are roles a second holder competes for, and nothing has needed to ask them anything.
|
||||||
|
func graphicalSessionSeats() []Seat {
|
||||||
|
const decided = "novox/hq ADR 0208"
|
||||||
|
return []Seat{
|
||||||
|
{Name: LoginManagerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
|
{Name: "sessions", Description: "The sessions the login manager offers on this machine, and which " +
|
||||||
|
"one the operator account starts by default.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
}},
|
||||||
|
{Name: DisplayServerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
|
{Name: "displays", Description: "The monitors connected now, each with its identity, its modes and " +
|
||||||
|
"where it is placed; and the layout profile in force, if one matches.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
// Profiles are keyed by the monitors' identities and are the operator's data (ADR 0208 §6).
|
||||||
|
{Name: "layout", Description: "The monitor layout profiles, keyed by the connected monitors' " +
|
||||||
|
"identities: list them, save the current arrangement under a name, or apply one.",
|
||||||
|
Input: withEnum(schema(map[string]string{
|
||||||
|
"action": "list, save or apply",
|
||||||
|
"name": "the profile to save or apply (save and apply only)",
|
||||||
|
}, []string{"action"}), "action", "list", "save", "apply")},
|
||||||
|
}},
|
||||||
|
{Name: DisplaySessionSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
|
{Name: "reload", Description: "Reload the session's configuration in place, keeping its windows.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
{Name: "workspaces", Description: "The session's workspaces: each one's name, output, and whether " +
|
||||||
|
"it is visible or focused.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
{Name: "windows", Description: "The session's windows: each one's title, class, workspace and " +
|
||||||
|
"whether it has focus; narrowed to one workspace when named.",
|
||||||
|
Input: schema(map[string]string{"workspace": "one workspace (optional)"}, nil)},
|
||||||
|
}},
|
||||||
|
{Name: TerminalEmulatorSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
|
{Name: "open", Description: "Open a terminal window in the operator's session, running a command " +
|
||||||
|
"or the login shell, in a directory or the account's home.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"command": "what to run in it (optional; the login shell when absent)",
|
||||||
|
"directory": "where it starts (optional; the account's home when absent)",
|
||||||
|
}, nil)},
|
||||||
|
}},
|
||||||
|
{Name: LauncherSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
|
{Name: "menu", Description: "Put a menu of choices in front of the operator and answer with the " +
|
||||||
|
"one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.",
|
||||||
|
Input: map[string]any{"type": "object", "required": []string{"choices"},
|
||||||
|
"properties": map[string]any{
|
||||||
|
"choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||||
|
"description": "the lines to choose between, in order"},
|
||||||
|
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
|
||||||
|
}}},
|
||||||
|
}},
|
||||||
|
{Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
|
{Name: "send", Description: "Show the operator a notification.",
|
||||||
|
Input: withEnum(schema(map[string]string{
|
||||||
|
"title": "the notification's summary",
|
||||||
|
"body": "its text (optional)",
|
||||||
|
"urgency": "low, normal (the default) or critical",
|
||||||
|
}, []string{"title"}), "urgency", "low", "normal", "critical")},
|
||||||
|
{Name: "history", Description: "The notifications shown lately, newest first.",
|
||||||
|
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
|
||||||
|
}},
|
||||||
|
{Name: LockScreenSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
|
{Name: "lock", Description: "Lock the operator's session now.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
}},
|
||||||
|
{Name: ClipboardSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
|
{Name: "history", Description: "What the clipboard held lately, newest first.",
|
||||||
|
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
|
||||||
|
{Name: "copy", Description: "Put text on the operator's clipboard.",
|
||||||
|
Input: schema(map[string]string{"text": "the text"}, []string{"text"})},
|
||||||
|
}},
|
||||||
|
{Name: BarSeat, Scope: ScopeNode, Decision: decided},
|
||||||
|
{Name: CompositorSeat, Scope: ScopeNode, Decision: decided},
|
||||||
|
{Name: SecretServiceSeat, Scope: ScopeNode, Decision: decided},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withEnum narrows one string property of a schema to the values it may take, so a caller is told
|
||||||
|
// the choices by the schema rather than by a refusal.
|
||||||
|
func withEnum(s map[string]any, property string, values ...string) map[string]any {
|
||||||
|
props := s["properties"].(map[string]any)
|
||||||
|
p := props[property].(map[string]any)
|
||||||
|
p["enum"] = values
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0208: the graphical session is one module per piece, on the mesh's seats.
|
||||||
|
|
||||||
|
// §2: the eleven roles are the mesh's own node seats, each with the verbs it starts with.
|
||||||
|
func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) {
|
||||||
|
want := map[string][]string{
|
||||||
|
LoginManagerSeat: {"sessions"},
|
||||||
|
DisplayServerSeat: {"displays", "layout"},
|
||||||
|
DisplaySessionSeat: {"reload", "workspaces", "windows"},
|
||||||
|
TerminalEmulatorSeat: {"open"},
|
||||||
|
LauncherSeat: {"menu"},
|
||||||
|
NotifierSeat: {"send", "history"},
|
||||||
|
LockScreenSeat: {"lock"},
|
||||||
|
ClipboardSeat: {"history", "copy"},
|
||||||
|
BarSeat: nil,
|
||||||
|
CompositorSeat: nil,
|
||||||
|
SecretServiceSeat: nil,
|
||||||
|
}
|
||||||
|
for name, verbs := range want {
|
||||||
|
s, ok := SeatNamed(name)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s is not in the mesh's set", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0208" {
|
||||||
|
t.Errorf("%s is %s-scoped under %q", name, s.Scope, s.Decision)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for _, v := range s.Serves {
|
||||||
|
got = append(got, v.Name)
|
||||||
|
if v.Description == "" || v.Input["type"] != "object" {
|
||||||
|
t.Errorf("%s.%s has no description or no object schema", name, v.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, verbs) {
|
||||||
|
t.Errorf("%s serves %v, want %v", name, got, verbs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The launcher's menu takes a list, and the layout verb says its actions.
|
||||||
|
menu, _ := SeatNamed(LauncherSeat)
|
||||||
|
choices := menu.Serves[0].Input["properties"].(map[string]any)["choices"].(map[string]any)
|
||||||
|
if choices["type"] != "array" {
|
||||||
|
t.Errorf("menu's choices are %v, not a list", choices["type"])
|
||||||
|
}
|
||||||
|
display, _ := SeatNamed(DisplayServerSeat)
|
||||||
|
action := display.Serves[1].Input["properties"].(map[string]any)["action"].(map[string]any)
|
||||||
|
if !reflect.DeepEqual(action["enum"], []string{"list", "save", "apply"}) {
|
||||||
|
t.Errorf("layout's actions are %v", action["enum"])
|
||||||
|
}
|
||||||
|
// And they survive the store's JSON, which is where the live set comes from.
|
||||||
|
if _, err := json.Marshal(graphicalSessionSeats()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §2: a module may claim one of them, and may not declare it as its own.
|
||||||
|
func TestNoModuleMayDeclareAGraphicalSessionSeat(t *testing.T) {
|
||||||
|
raw := `{"module":"xorg","seats":[{"name":"node-display-server","scope":"node"}]}`
|
||||||
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "mesh's own namespace") {
|
||||||
|
t.Fatalf("a module declared node-display-server as its own: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayServer(name, display string, claims ...string) Manifest {
|
||||||
|
m := Manifest{Module: name, Provides: []Offer{{Name: display, Reach: ReachMachine}}}
|
||||||
|
for _, c := range claims {
|
||||||
|
m.Claims = append(m.Claims, Claim{Name: c})
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowManager() Manifest {
|
||||||
|
return Manifest{Module: "i3", Requires: []string{"x11-display"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §3: a display is resolved on the requiring module's own node.
|
||||||
|
func TestAMachineReachRequirementResolvesToTheProviderOnItsOwnNode(t *testing.T) {
|
||||||
|
cat := shelf(windowManager(), displayServer("xorg", "x11-display", DisplayServerSeat))
|
||||||
|
got, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("i3 beside xorg did not resolve: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(names(got), []string{"xorg", "i3"}) && !reflect.DeepEqual(names(got), []string{"i3", "xorg"}) {
|
||||||
|
t.Errorf("resolved %v", names(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §3: never answered by installing a provider, and never by another machine's.
|
||||||
|
func TestAMachineReachRequirementIsNotPulledInNorAnsweredFromAnotherNode(t *testing.T) {
|
||||||
|
cat := shelf(windowManager(),
|
||||||
|
displayServer("xorg", "x11-display", DisplayServerSeat),
|
||||||
|
displayServer("xwayland", "x11-display"))
|
||||||
|
// Another machine runs xorg and says so to the world; it does not count.
|
||||||
|
world := World{Offered: map[string][]Provider{
|
||||||
|
"x11-display": {{Node: "laptop", At: "laptop.mesh", Module: "xorg"}}}}
|
||||||
|
_, err := Resolve(cat, []string{"i3"}, workstation(), world)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("i3 resolved on a machine with no display of its own")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`"x11-display" is wanted by i3`, "usable only on the machine that provides it",
|
||||||
|
"assign one to workstation", "xorg (holds node-display-server)", "xwayland",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("the refusal does not say %q:\n%v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// With a single provider in the catalogue too: one candidate is still not a choice to make
|
||||||
|
// for somebody, unlike a node-scoped provision without the machine's reach.
|
||||||
|
_, err = Resolve(shelf(windowManager(), displayServer("xorg", "x11-display", DisplayServerSeat)),
|
||||||
|
[]string{"i3"}, workstation(), World{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("xorg was pulled in for i3")
|
||||||
|
}
|
||||||
|
// Not in the first pass, whose refusals take the machine off the network.
|
||||||
|
if _, err := Resolve(cat, []string{"i3"}, workstation(), World{Unchecked: true}); err != nil {
|
||||||
|
t.Errorf("the first pass refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheMachinesReachIsAProvisionsOnlyReachAndIsNodeScoped(t *testing.T) {
|
||||||
|
for _, c := range []struct{ provides, want string }{
|
||||||
|
{`{"name":"x11-display","reach":"internal"}`, `with reach "internal"; a provision's reach is "machine" or nothing`},
|
||||||
|
{`{"name":"x11-display","scope":"mesh","reach":"machine"}`, `at scope "mesh" with the machine's reach`},
|
||||||
|
} {
|
||||||
|
_, err := ParseManifest([]byte(`{"module":"xorg","provides":[` + c.provides + `]}`))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("%s: want %q, got %v", c.provides, c.want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m, err := ParseManifest([]byte(`{"module":"xorg","provides":[{"name":"x11-display","reach":"machine"}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !m.Provides[0].MachineReach() {
|
||||||
|
t.Fatal("the reach was not read")
|
||||||
|
}
|
||||||
|
back, _ := json.Marshal(m.Provides[0])
|
||||||
|
if string(back) != `{"name":"x11-display","reach":"machine"}` {
|
||||||
|
t.Errorf("written back as %s", back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACatalogueDisagreeingAboutAProvisionsReachIsRefused(t *testing.T) {
|
||||||
|
cat := shelf(windowManager(), displayServer("xorg", "x11-display"),
|
||||||
|
Manifest{Module: "fake-x", Provides: Offers("x11-display")})
|
||||||
|
_, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), World{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `the catalogue disagrees about "x11-display"`) {
|
||||||
|
t.Fatalf("a provision with and without the machine's reach gave %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §4: xinitrc and xresources slots, placed by the display server's holder alone.
|
||||||
|
func TestTheSessionsFilesArePlacedByTheDisplayServersHolderAlone(t *testing.T) {
|
||||||
|
xorg := Manifest{Module: "xorg", Claims: []Claim{{Name: DisplayServerSeat}},
|
||||||
|
Shell: []ShellCode{{For: "xinitrc", Slot: "first", Code: "xset s off"}},
|
||||||
|
Resources: []map[string]any{
|
||||||
|
{"id": "xinitrc", "type": "file", "path": "/home/op/.xinitrc",
|
||||||
|
"content": "${shell:xinitrc:first}${shell:xinitrc:normal}${shell:xinitrc:last}"},
|
||||||
|
{"id": "xresources", "type": "file", "path": "/home/op/.Xresources",
|
||||||
|
"content": "${shell:xresources:normal}"},
|
||||||
|
}}
|
||||||
|
i3 := Manifest{Module: "i3", Shell: []ShellCode{{For: "xinitrc", Slot: "last", Code: "exec i3"}}}
|
||||||
|
theme := Manifest{Module: "theme", Shell: []ShellCode{
|
||||||
|
{For: "xresources", Slot: "normal", Code: "Xft.dpi: 96"},
|
||||||
|
{For: "zsh", Slot: "normal", Code: "not for the session"},
|
||||||
|
}}
|
||||||
|
r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{xorg, i3, theme}}
|
||||||
|
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 got := by["xorg.xinitrc"]; got != "# xorg\nxset s off\n# i3\nexec i3\n" {
|
||||||
|
t.Errorf("the .xinitrc is %q", got)
|
||||||
|
}
|
||||||
|
if got := by["xorg.xresources"]; got != "# theme\nXft.dpi: 96\n" {
|
||||||
|
t.Errorf("the .Xresources is %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The contributions parse; the placeholders parse only in the holder.
|
||||||
|
if _, err := ParseManifest([]byte(`{"module":"i3","shell":[{"for":"xinitrc","slot":"last","code":"exec i3"},` +
|
||||||
|
`{"for":"xresources","slot":"normal","code":"i3.font: x"}]}`)); err != nil {
|
||||||
|
t.Fatalf("a session contribution was refused: %v", err)
|
||||||
|
}
|
||||||
|
for _, c := range []struct{ claims, content, want string }{
|
||||||
|
{``, "${shell:xinitrc:normal}", "does not claim node-display-server; every module's xinitrc is placed by the display server's holder alone"},
|
||||||
|
{`{"name":"node-login-shell"}`, "${shell:xresources:normal}", "does not claim node-display-server"},
|
||||||
|
{`{"name":"node-display-server"}`, "${shell:zsh:normal}", "does not claim node-login-shell"},
|
||||||
|
{`{"name":"node-display-server"}`, "${shell:xsession:normal}", "the session's file one of xinitrc, xresources"},
|
||||||
|
} {
|
||||||
|
raw := `{"module":"holder","claims":[` + c.claims + `],"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 with claims [%s]: want %q, got %v", c.content, c.claims, c.want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := ParseManifest([]byte(`{"module":"xorg","claims":[{"name":"node-display-server"}],` +
|
||||||
|
`"resources":[{"id":"rc","type":"file","path":"/home/op/.xinitrc","content":"${shell:xinitrc:last}"}]}`)); err != nil {
|
||||||
|
t.Errorf("the display server's holder could not place the session's slots: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -147,8 +147,17 @@ type Offer struct {
|
|||||||
// shared by every consumer (novox/hq ADR 0158): software that holds one password or one key
|
// shared by every consumer (novox/hq ADR 0158): software that holds one password or one key
|
||||||
// cannot give each consumer a login of its own. The named secret must say how it is taken.
|
// cannot give each consumer a login of its own. The named secret must say how it is taken.
|
||||||
Credential *OfferCredential `json:"credential,omitempty"`
|
Credential *OfferCredential `json:"credential,omitempty"`
|
||||||
|
// Reach is ReachMachine for a provision usable only on the provider's own machine — a display
|
||||||
|
// (novox/hq ADR 0208 §3). Node scope already keeps a provision off other machines; what this adds
|
||||||
|
// is that a requirement for it is never answered by installing a provider: the display server is
|
||||||
|
// a seat's holder gated by the machine's graphical session, and pulling one in for whatever asked
|
||||||
|
// is the misassignment research 026 found. Unmet, the requirement is refused naming who could.
|
||||||
|
Reach string `json:"reach,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MachineReach is whether a provision is usable only on its provider's own machine.
|
||||||
|
func (o Offer) MachineReach() bool { return o.Reach == ReachMachine }
|
||||||
|
|
||||||
// OfferCredential names which of the provider's own secrets a provision's consumers receive.
|
// OfferCredential names which of the provider's own secrets a provision's consumers receive.
|
||||||
type OfferCredential struct {
|
type OfferCredential struct {
|
||||||
Own string `json:"own"`
|
Own string `json:"own"`
|
||||||
@@ -196,27 +205,29 @@ func (o *Offer) UnmarshalJSON(raw []byte) error {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
Credential *OfferCredential `json:"credential,omitempty"`
|
Credential *OfferCredential `json:"credential,omitempty"`
|
||||||
|
Reach string `json:"reach,omitempty"`
|
||||||
}
|
}
|
||||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
if err := dec.Decode(&full); err != nil {
|
if err := dec.Decode(&full); err != nil {
|
||||||
return fmt.Errorf("a provided name is either a string or {name, scope, credential}: %w", err)
|
return fmt.Errorf("a provided name is either a string or {name, scope, credential, reach}: %w", err)
|
||||||
}
|
}
|
||||||
o.Name, o.Scope, o.Credential = full.Name, full.Scope, full.Credential
|
o.Name, o.Scope, o.Credential, o.Reach = full.Name, full.Scope, full.Credential, full.Reach
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON writes back the short form when there is nothing else to say, so a manifest that
|
// 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.
|
// went through the mesh comes out looking like the one that went in.
|
||||||
func (o Offer) MarshalJSON() ([]byte, error) {
|
func (o Offer) MarshalJSON() ([]byte, error) {
|
||||||
if o.Scope == "" && o.Credential == nil {
|
if o.Scope == "" && o.Credential == nil && o.Reach == "" {
|
||||||
return json.Marshal(o.Name)
|
return json.Marshal(o.Name)
|
||||||
}
|
}
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
Credential *OfferCredential `json:"credential,omitempty"`
|
Credential *OfferCredential `json:"credential,omitempty"`
|
||||||
}{o.Name, o.Scope, o.Credential})
|
Reach string `json:"reach,omitempty"`
|
||||||
|
}{o.Name, o.Scope, o.Credential, o.Reach})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manifest is everything a module says about itself.
|
// Manifest is everything a module says about itself.
|
||||||
@@ -517,6 +528,17 @@ type Manifest struct {
|
|||||||
// holder. Like Filtering: one module per node gathers what every module declared and writes it.
|
// holder. Like Filtering: one module per node gathers what every module declared and writes it.
|
||||||
Jailing *Jailing `json:"jailing,omitempty"`
|
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
|
// 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
|
// 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
|
// broker's management port. The ports the software uses; the mesh guards where the machine
|
||||||
@@ -1306,6 +1328,19 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
"%s provides %q at scope %q; a provision is %q or %q",
|
"%s provides %q at scope %q; a provision is %q or %q",
|
||||||
m.Module, p, s, ScopeNode, ScopeMesh))
|
m.Module, p, s, ScopeNode, ScopeMesh))
|
||||||
}
|
}
|
||||||
|
switch {
|
||||||
|
case offer.Reach == "":
|
||||||
|
case offer.Reach != ReachMachine:
|
||||||
|
// The one reach a provision has (novox/hq ADR 0208): a provision reached over the private
|
||||||
|
// network is mesh scope, and the world reaches nothing but a name.
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s provides %q with reach %q; a provision's reach is %q or nothing",
|
||||||
|
m.Module, p, offer.Reach, ReachMachine))
|
||||||
|
case offer.At() != ScopeNode:
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s provides %q at scope %q with the machine's reach; a provision usable only on its own "+
|
||||||
|
"machine is node-scoped (novox/hq ADR 0208)", m.Module, p, offer.At()))
|
||||||
|
}
|
||||||
if p == m.Module {
|
if p == m.Module {
|
||||||
// Harmless and worth saying: a module always provides its own name, so writing it
|
// Harmless and worth saying: a module always provides its own name, so writing it
|
||||||
// suggests the author expected it not to.
|
// suggests the author expected it not to.
|
||||||
@@ -1805,6 +1840,12 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
problems = append(problems, m.unknownDirRefs()...)
|
problems = append(problems, m.unknownDirRefs()...)
|
||||||
problems = append(problems, m.unknownAccessRefs()...)
|
problems = append(problems, m.unknownAccessRefs()...)
|
||||||
problems = append(problems, m.jailProblems()...)
|
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 {
|
for i, r := range m.Resources {
|
||||||
id, _ := r["id"].(string)
|
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 - "))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -147,6 +147,10 @@ type Resolution struct {
|
|||||||
// dropped nor fatal to the rest. A module that is *required* by something running here is a
|
// dropped nor fatal to the rest. A module that is *required* by something running here is a
|
||||||
// different case — that set is incoherent and is refused (see checkCapabilities).
|
// different case — that set is incoherent and is refused (see checkCapabilities).
|
||||||
Unhostable []Unhostable
|
Unhostable []Unhostable
|
||||||
|
// Unheld is every dependency of this node's modules on a seat nothing here holds (novox/hq ADR
|
||||||
|
// 0207) — reported rather than refused while enforceSeatDependencies is off, so a node short of a
|
||||||
|
// holder still converges and `status` says what it is short of.
|
||||||
|
Unheld []Unheld
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unhostable is one directly-assigned module the machine cannot run.
|
// Unhostable is one directly-assigned module the machine cannot run.
|
||||||
@@ -229,6 +233,27 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
local[o.Name] = true
|
local[o.Name] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Which names are usable only on their provider's own machine (novox/hq ADR 0208 §3). Also a
|
||||||
|
// property of the name: a display one provider says is the machine's and another says is not
|
||||||
|
// would be pulled in for one consumer and refused for the next.
|
||||||
|
machineReach := map[string]bool{}
|
||||||
|
plainLocal := map[string]bool{}
|
||||||
|
for _, m := range catalogue {
|
||||||
|
for _, o := range m.Provides {
|
||||||
|
if o.MachineReach() {
|
||||||
|
machineReach[o.Name] = true
|
||||||
|
} else if o.At() == ScopeNode {
|
||||||
|
plainLocal[o.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for want := range machineReach {
|
||||||
|
if plainLocal[want] {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"the catalogue disagrees about %q: some modules provide it with the machine's reach and "+
|
||||||
|
"others without, so a requirement for it would be met differently by each", want))
|
||||||
|
}
|
||||||
|
}
|
||||||
for want := range brokered {
|
for want := range brokered {
|
||||||
if local[want] {
|
if local[want] {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
@@ -495,6 +520,23 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Usable only on its provider's own machine, and not here: refused, never answered by
|
||||||
|
// installing a provider (novox/hq ADR 0208 §3). The display server is the machine's own role,
|
||||||
|
// gated by its graphical session; one pulled in for a window manager is the misassignment
|
||||||
|
// research 026 found. Another node's provider never counts — node scope is never brokered.
|
||||||
|
if machineReach[want] && !isModule(catalogue, want) {
|
||||||
|
reported[want] = true
|
||||||
|
if world.Unchecked {
|
||||||
|
// The first pass's refusals take a machine off the network; the second says it.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%q is wanted by %s and is usable only on the machine that provides it, and nothing "+
|
||||||
|
"assigned to %s does — %s", want, because[want], node.Name,
|
||||||
|
machineReachRemedy(catalogue, want, node.Name)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
candidates := offers[want]
|
candidates := offers[want]
|
||||||
switch len(candidates) {
|
switch len(candidates) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -628,6 +670,17 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
problems = append(problems, checkResources(resolution.Modules)...)
|
problems = append(problems, checkResources(resolution.Modules)...)
|
||||||
resolution.Claims = claims
|
resolution.Claims = claims
|
||||||
|
|
||||||
|
// Judged over the closure — what this node will actually run — so a holder pulled in by a
|
||||||
|
// requirement counts, and the holders' mutual dependence resolves (novox/hq ADR 0207 §3).
|
||||||
|
// Reported until the switch; refused after it, though never in the first pass, whose refusals
|
||||||
|
// make a machine vanish from the network rather than report anything.
|
||||||
|
resolution.Unheld = UnheldDependencies(catalogue, node.Name, resolution.Modules, nil)
|
||||||
|
if enforceSeatDependencies && !world.Unchecked {
|
||||||
|
for _, u := range resolution.Unheld {
|
||||||
|
problems = append(problems, u.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(problems) > 0 {
|
if len(problems) > 0 {
|
||||||
sort.Strings(problems)
|
sort.Strings(problems)
|
||||||
return Resolution{}, &Refusal{Problems: problems}
|
return Resolution{}, &Refusal{Problems: problems}
|
||||||
@@ -804,6 +857,28 @@ func checkResources(modules []Manifest) []string {
|
|||||||
// does not exist is the manifest's own problem, refused where it was made.
|
// does not exist is the manifest's own problem, refused where it was made.
|
||||||
dirs := dirsFor(m, Rendering{})
|
dirs := dirsFor(m, Rendering{})
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
|
if fmt.Sprint(r["type"]) == "user" {
|
||||||
|
// **An account is shared; what it is set to is not.** Several modules may need one
|
||||||
|
// login: the shell's module sets its shell, the container runtime's puts it in the
|
||||||
|
// `docker` group. The host only ever adds groups — it never takes the account out of
|
||||||
|
// one, not even when the resource that named it is undeclared — so groups from
|
||||||
|
// several modules cannot contradict each other and are not owned. A shell or a home
|
||||||
|
// is one value, and two modules setting it would each be undone by the other's
|
||||||
|
// apply: each stays one module's per node, and two are refused naming both.
|
||||||
|
name, _ := r["name"].(string)
|
||||||
|
for _, field := range []string{"shell", "home"} {
|
||||||
|
if v, ok := r[field].(string); !ok || v == "" || name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := "user " + field + " " + name
|
||||||
|
if other, taken := owner[key]; taken && other != m.Module {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s and %s both set the %s of the user %q", other, m.Module, field, name))
|
||||||
|
}
|
||||||
|
owner[key] = m.Module
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
for _, field := range []string{"path", "unit", "name", "package"} {
|
for _, field := range []string{"path", "unit", "name", "package"} {
|
||||||
value, ok := r[field].(string)
|
value, ok := r[field].(string)
|
||||||
if !ok || value == "" {
|
if !ok || value == "" {
|
||||||
@@ -1019,3 +1094,38 @@ func eachLocal(needs []Needed, catalogue map[string]Manifest, n Needed) []Needed
|
|||||||
}
|
}
|
||||||
return needs
|
return needs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// machineReachRemedy names what would meet a requirement with the machine's reach: every module in
|
||||||
|
// the catalogue that provides it, each with the node seats it holds — for a display, the holders of
|
||||||
|
// node-display-server (novox/hq ADR 0208 §3), named by the seat because that is the role being
|
||||||
|
// asked for, without this code knowing which seat any provision belongs to.
|
||||||
|
func machineReachRemedy(catalogue map[string]Manifest, want, node string) string {
|
||||||
|
var named []string
|
||||||
|
for _, name := range sortedKeys(catalogue) {
|
||||||
|
m := catalogue[name]
|
||||||
|
provides := false
|
||||||
|
for _, o := range m.Provides {
|
||||||
|
if o.Name == want {
|
||||||
|
provides = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !provides {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var held []string
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
if c.At() == ScopeNode {
|
||||||
|
held = append(held, c.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(held) > 0 {
|
||||||
|
named = append(named, fmt.Sprintf("%s (holds %s)", name, strings.Join(held, ", ")))
|
||||||
|
} else {
|
||||||
|
named = append(named, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(named) == 0 {
|
||||||
|
return "and nothing in the catalogue provides it"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("assign one to %s: %s", node, strings.Join(named, "; "))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module depends on the node seats that apply its resources (novox/hq ADR 0207).
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// through the container runtime. A *capability* only says that software is installed; it does not
|
||||||
|
// say that a module of the mesh holds the role and answers for it. So the dependency is derived from
|
||||||
|
// the resources — never stated in a manifest, because a module that adds a service and forgets a
|
||||||
|
// field would pass — and is met when some module assigned to the same node holds the seat.
|
||||||
|
|
||||||
|
// The three seats that apply resources (novox/hq ADR 0207 §1). Named once, because the derivation,
|
||||||
|
// the seed and the messages all turn on these strings.
|
||||||
|
const (
|
||||||
|
ServiceManagerSeat = "node-service-manager"
|
||||||
|
PackageManagerSeat = "node-package-manager"
|
||||||
|
ContainerRuntimeSeat = "node-container-runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// appliedThrough is which seat applies a resource of each type. **Only these three**: ADR 0207
|
||||||
|
// names them and no more. A process is supervised by the host itself, a file, a directory, an
|
||||||
|
// archive, a user or an action is the host's own act, and a module's other kinds reach the machine
|
||||||
|
// without a role in between — adding one here is a decision, not a refinement.
|
||||||
|
var appliedThrough = map[string]string{
|
||||||
|
"service": ServiceManagerSeat,
|
||||||
|
"package": PackageManagerSeat,
|
||||||
|
"container": ContainerRuntimeSeat,
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforceSeatDependencies is the one-line switch ADR 0207 §4 names. Off, an unmet dependency at
|
||||||
|
// composition is *reported* — in the resolution, in `status`, once in the log — and the node still
|
||||||
|
// resolves; on, it is refused like any unresolved requirement. Off until `status` reports none,
|
||||||
|
// which is when the three holders are assigned to every node: switching it before then would stop
|
||||||
|
// every machine lacking one from being sent anything at all.
|
||||||
|
//
|
||||||
|
// A variable rather than a constant only so a test can hold both behaviours; nothing else sets it.
|
||||||
|
var enforceSeatDependencies = false
|
||||||
|
|
||||||
|
// foundationModules are the pieces genesis lays before any module exists (novox/hq ADR 0207 §5):
|
||||||
|
// the host and the private network. Registered as modules so they can be assigned, but what they
|
||||||
|
// declare is the installation's, not a module's, so it is never judged. The third piece, the
|
||||||
|
// bootstrap container runtime, is not a module at all: its package and service are in the genesis
|
||||||
|
// bundle the host applies itself, and never pass through a resolution here.
|
||||||
|
//
|
||||||
|
// The private network's module is overlay.Name, written out because the overlay package composes
|
||||||
|
// on top of this one; its resources are computed, which exempts it by the rule below as well.
|
||||||
|
var foundationModules = map[string]bool{
|
||||||
|
"mesh-host": true,
|
||||||
|
"mesh-wireguard": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// isFoundation is whether a module's declarations are the foundation's rather than its own. A
|
||||||
|
// module whose resources are computed is the mesh's by construction — the private network's peer
|
||||||
|
// list and its tools are the controller's, written per node — so it counts whatever its name.
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
// **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 {
|
||||||
|
if isFoundation(m) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if seat, applied := appliedThrough[fmt.Sprint(r["type"])]; applied {
|
||||||
|
seen[seat] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for s := range seen {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
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 {
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
if c.At() != ScopeNode {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := c.Name
|
||||||
|
if s, known := SeatNamed(name); known {
|
||||||
|
name = s.Name
|
||||||
|
}
|
||||||
|
if name == seat {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PossibleHolders is every module in the catalogue that claims a seat at node scope — what a
|
||||||
|
// refusal names as the remedy.
|
||||||
|
func PossibleHolders(catalogue map[string]Manifest, seat string) []string {
|
||||||
|
var out []string
|
||||||
|
for name, m := range catalogue {
|
||||||
|
if claimsSeat(m, seat) {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unheld is one dependency of one module on a node that nothing on that node holds.
|
||||||
|
type Unheld struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
Seat string `json:"seat"`
|
||||||
|
// Holders are the modules in the catalogue that could hold the seat: assigning one meets it.
|
||||||
|
Holders []string `json:"holders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// String is the line a refusal and a report both say, so the two never drift.
|
||||||
|
func (u Unheld) String() string {
|
||||||
|
remedy := "and no module in the catalogue claims it yet"
|
||||||
|
if len(u.Holders) > 0 {
|
||||||
|
remedy = "— assign one that holds it: " + strings.Join(u.Holders, ", ")
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s on %s depends on %s, which nothing on %s holds (novox/hq ADR 0207) %s",
|
||||||
|
u.Module, u.Node, u.Seat, u.Node, remedy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnheldDependencies is every dependency of the modules in `judged` that the node's whole set
|
||||||
|
// leaves unmet (novox/hq ADR 0207 §3).
|
||||||
|
//
|
||||||
|
// **Judged over the whole set, never one module at a time.** The holders depend on each other:
|
||||||
|
// the service manager's own package needs the package manager, and the package manager's timer
|
||||||
|
// needs the service manager. Asked one by one, neither could ever be first; asked of the set, the
|
||||||
|
// two assigned together meet each other. A module holding a seat it depends on meets its own
|
||||||
|
// dependency. `judged` nil judges every module of the set.
|
||||||
|
func UnheldDependencies(catalogue map[string]Manifest, node string, set []Manifest, judged map[string]bool) []Unheld {
|
||||||
|
held := map[string]bool{}
|
||||||
|
for _, m := range set {
|
||||||
|
for seat := range seatsApplying() {
|
||||||
|
if claimsSeat(m, seat) {
|
||||||
|
held[seat] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []Unheld
|
||||||
|
for _, m := range set {
|
||||||
|
if judged != nil && !judged[m.Module] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, seat := range DependsOn(m) {
|
||||||
|
if held[seat] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, Unheld{Node: node, Module: m.Module, Seat: seat,
|
||||||
|
Holders: PossibleHolders(catalogue, seat)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Module != out[j].Module {
|
||||||
|
return out[i].Module < out[j].Module
|
||||||
|
}
|
||||||
|
return out[i].Seat < out[j].Seat
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func seatsApplying() map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, s := range appliedThrough {
|
||||||
|
out[s] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// manifestsOf is the catalogue's definitions of the named modules; a name the catalogue does not
|
||||||
|
// know contributes nothing, as it does to a resolution.
|
||||||
|
func manifestsOf(catalogue map[string]Manifest, names []string) []Manifest {
|
||||||
|
var out []Manifest
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, n := range names {
|
||||||
|
if m, known := catalogue[n]; known && !seen[n] {
|
||||||
|
seen[n] = true
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignRefusal is why assigning `adding` to a node already running `assigned` is refused, or
|
||||||
|
// nothing (novox/hq ADR 0207 §4): each new module's dependency the node's assignments, the new ones
|
||||||
|
// included, leave unmet.
|
||||||
|
//
|
||||||
|
// **Only the new modules are judged.** A node already short of a holder is reported by `status`;
|
||||||
|
// refusing an unrelated assignment for it would make the remedy — assigning the holder — refused too.
|
||||||
|
//
|
||||||
|
// **A dependency nothing in the catalogue can meet is said, not refused.** A refusal names the
|
||||||
|
// module that would meet it; with none registered there is no remedy to name, and refusing would
|
||||||
|
// stop every assignment of that kind until a module that does not exist yet is written. The answer
|
||||||
|
// still says it, and `status` reports it, until the switch (enforceSeatDependencies) makes the mesh
|
||||||
|
// refuse what it cannot meet. The first return is those lines.
|
||||||
|
func AssignRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) ([]string, error) {
|
||||||
|
set := manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...))
|
||||||
|
judged := map[string]bool{}
|
||||||
|
for _, a := range adding {
|
||||||
|
judged[a] = true
|
||||||
|
}
|
||||||
|
var refused, said []string
|
||||||
|
for _, u := range UnheldDependencies(catalogue, node, set, judged) {
|
||||||
|
if len(u.Holders) == 0 && !enforceSeatDependencies {
|
||||||
|
said = append(said, u.String())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
refused = append(refused, u.String())
|
||||||
|
}
|
||||||
|
if len(refused) > 0 {
|
||||||
|
return said, &Refusal{Problems: append(refused,
|
||||||
|
fmt.Sprintf("holders that depend on each other are assigned together: `assign %s <module> <module>…`", node))}
|
||||||
|
}
|
||||||
|
return said, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnassignRefusal is why taking `removing` off a node running `assigned` is refused, or nothing
|
||||||
|
// (novox/hq ADR 0207): a seat the removed modules hold that nothing left on the node holds, while
|
||||||
|
// a module left there depends on it. Names the dependents, because they are what must go first —
|
||||||
|
// or the holder's replacement come.
|
||||||
|
func UnassignRefusal(catalogue map[string]Manifest, node string, assigned, removing []string) error {
|
||||||
|
gone := map[string]bool{}
|
||||||
|
for _, r := range removing {
|
||||||
|
gone[r] = true
|
||||||
|
}
|
||||||
|
var left []string
|
||||||
|
for _, a := range assigned {
|
||||||
|
if !gone[a] {
|
||||||
|
left = append(left, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
before := map[string]bool{}
|
||||||
|
for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, assigned), nil) {
|
||||||
|
before[u.Module+"\x00"+u.Seat] = true
|
||||||
|
}
|
||||||
|
dependents := map[string][]string{}
|
||||||
|
for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, left), nil) {
|
||||||
|
if before[u.Module+"\x00"+u.Seat] {
|
||||||
|
continue // unmet already; not this removal's doing
|
||||||
|
}
|
||||||
|
dependents[u.Seat] = append(dependents[u.Seat], u.Module)
|
||||||
|
}
|
||||||
|
if len(dependents) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seats := make([]string, 0, len(dependents))
|
||||||
|
for s := range dependents {
|
||||||
|
seats = append(seats, s)
|
||||||
|
}
|
||||||
|
sort.Strings(seats)
|
||||||
|
var problems []string
|
||||||
|
for _, s := range seats {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s holds %s on %s, and %s depend on it (novox/hq ADR 0207) — unassign them with it, "+
|
||||||
|
"or assign another holder first", strings.Join(removing, ", "), s, node,
|
||||||
|
strings.Join(dependents[s], ", ")))
|
||||||
|
}
|
||||||
|
return &Refusal{Problems: problems}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0207: a module depends on the node seats that apply its resources.
|
||||||
|
|
||||||
|
func res(kind, id string) map[string]any {
|
||||||
|
r := map[string]any{"id": id, "type": kind}
|
||||||
|
switch kind {
|
||||||
|
case "service":
|
||||||
|
r["unit"] = id + ".service"
|
||||||
|
case "package":
|
||||||
|
r["package"] = id
|
||||||
|
case "container":
|
||||||
|
r["image"] = id
|
||||||
|
case "file":
|
||||||
|
r["path"] = "/etc/" + id
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func withResources(m Manifest, rs ...map[string]any) Manifest {
|
||||||
|
m.Resources = rs
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three holders as to-be 42 names them, each declaring what it really does: systemd's own
|
||||||
|
// package needs the package manager, pacman's timer needs the service manager, docker's package and
|
||||||
|
// service need both.
|
||||||
|
func coreThree() []Manifest {
|
||||||
|
return []Manifest{
|
||||||
|
withResources(mod("systemd", nil, nil, nil, Claim{Name: ServiceManagerSeat}), res("package", "systemd")),
|
||||||
|
withResources(mod("pacman", nil, nil, nil, Claim{Name: PackageManagerSeat}), res("service", "pacman-refresh")),
|
||||||
|
withResources(mod("docker", nil, nil, nil, Claim{Name: ContainerRuntimeSeat}),
|
||||||
|
res("package", "docker"), res("service", "docker")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADependencyIsDerivedFromEachResourceTypeThatAppliesThroughASeat(t *testing.T) {
|
||||||
|
cases := map[string][]string{
|
||||||
|
"service": {ServiceManagerSeat},
|
||||||
|
"package": {PackageManagerSeat},
|
||||||
|
"container": {ContainerRuntimeSeat},
|
||||||
|
// The host's own acts, or the mesh's: nothing in between holds a role for them.
|
||||||
|
"file": nil, "directory": nil, "process": nil, "archive": nil, "user": nil,
|
||||||
|
"action": nil, "network": nil, "access": nil,
|
||||||
|
}
|
||||||
|
for kind, want := range cases {
|
||||||
|
got := DependsOn(withResources(mod("m", nil, nil, nil), res(kind, "x")))
|
||||||
|
if len(got) == 0 {
|
||||||
|
got = nil
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("a %s resource depends on %v, want %v", kind, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all := DependsOn(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSeatsThatApplyResourcesAreTheMeshsOwnAtNodeScope(t *testing.T) {
|
||||||
|
for _, name := range []string{ServiceManagerSeat, PackageManagerSeat, ContainerRuntimeSeat} {
|
||||||
|
s, ok := SeatNamed(name)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s is not in the mesh's set", name)
|
||||||
|
}
|
||||||
|
if s.Scope != ScopeNode {
|
||||||
|
t.Errorf("%s is held per %s, want per node", name, s.Scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No verbs yet for either new seat: ADR 0207 seeds the package manager without a protocol, and
|
||||||
|
// the runtime's verbs wait for ADR 0166's acceptance.
|
||||||
|
for _, name := range []string{PackageManagerSeat, ContainerRuntimeSeat} {
|
||||||
|
if s, _ := SeatNamed(name); len(s.Serves)+len(s.Accepts)+len(s.Emits) > 0 {
|
||||||
|
t.Errorf("%s carries a protocol; ADR 0207 seeds it with none", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANodeWhoseAssignmentsHoldTheSeatsResolvesWithNothingUnheld(t *testing.T) {
|
||||||
|
web := withResources(mod("web", nil, nil, nil), res("container", "web"), res("service", "web-timer"))
|
||||||
|
cat := shelf(append(coreThree(), web)...)
|
||||||
|
got, err := Resolve(cat, []string{"systemd", "pacman", "docker", "web"}, workstation(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Unheld) != 0 {
|
||||||
|
t.Errorf("a node holding all three seats reports %v", got.Unheld)
|
||||||
|
}
|
||||||
|
if _, err := AssignRefusal(cat, "workstation", []string{"systemd", "pacman", "docker"}, []string{"web"}); err != nil {
|
||||||
|
t.Errorf("assigning beside the three holders was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAssignmentMissingAHolderIsRefusedNamingTheSeatAndItsPossibleHolders(t *testing.T) {
|
||||||
|
web := withResources(mod("web", nil, nil, nil), res("container", "web"), res("service", "web-timer"))
|
||||||
|
cat := shelf(append(coreThree(), web)...)
|
||||||
|
_, err := AssignRefusal(cat, "workstation", []string{"systemd", "pacman"}, []string{"web"})
|
||||||
|
var refusal *Refusal
|
||||||
|
if !errors.As(err, &refusal) {
|
||||||
|
t.Fatalf("web assigned to a node without a container runtime was not refused: %v", err)
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
for _, want := range []string{"web on workstation depends on " + ContainerRuntimeSeat, "docker", "ADR 0207"} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Errorf("the refusal does not say %q:\n%s", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What the node does hold is not named as missing.
|
||||||
|
if strings.Contains(msg, "depends on "+ServiceManagerSeat) {
|
||||||
|
t.Errorf("the refusal names a seat systemd already holds:\n%s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADependencyNoCatalogueModuleCanMeetIsSaidNotRefusedUntilTheSwitch(t *testing.T) {
|
||||||
|
// No runtime module in the catalogue: refusing would stop every container's assignment until one
|
||||||
|
// is written, with no remedy to name.
|
||||||
|
web := withResources(mod("web", nil, nil, nil), res("container", "web"))
|
||||||
|
cat := shelf(web)
|
||||||
|
said, err := AssignRefusal(cat, "workstation", nil, []string{"web"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a dependency nothing could meet was refused: %v", err)
|
||||||
|
}
|
||||||
|
if len(said) != 1 || !strings.Contains(said[0], "no module in the catalogue claims it yet") {
|
||||||
|
t.Errorf("the assignment does not say what it depends on: %v", said)
|
||||||
|
}
|
||||||
|
|
||||||
|
enforceSeatDependencies = true
|
||||||
|
defer func() { enforceSeatDependencies = false }()
|
||||||
|
if _, err := AssignRefusal(cat, "workstation", nil, []string{"web"}); err == nil {
|
||||||
|
t.Error("with the switch on, a dependency nothing could meet was not refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheHoldersMutualDependenceResolvesWhenAssignedTogether(t *testing.T) {
|
||||||
|
cat := shelf(coreThree()...)
|
||||||
|
// Alone, each needs the other.
|
||||||
|
if _, err := AssignRefusal(cat, "workstation", nil, []string{"systemd"}); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "pacman") {
|
||||||
|
t.Errorf("systemd alone was not refused naming pacman: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := AssignRefusal(cat, "workstation", nil, []string{"pacman"}); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "systemd") {
|
||||||
|
t.Errorf("pacman alone was not refused naming systemd: %v", err)
|
||||||
|
}
|
||||||
|
// Together, in one act, they meet each other — and docker meets its own seat.
|
||||||
|
if _, err := AssignRefusal(cat, "workstation", nil, []string{"systemd", "pacman", "docker"}); err != nil {
|
||||||
|
t.Errorf("the three holders assigned together were refused: %v", err)
|
||||||
|
}
|
||||||
|
got, err := Resolve(cat, []string{"systemd", "pacman"}, workstation(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Unheld) != 0 {
|
||||||
|
t.Errorf("systemd and pacman together report %v", got.Unheld)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusIsToldOfAnUnmetDependencyAndTheNodeStillResolves(t *testing.T) {
|
||||||
|
web := withResources(mod("web", nil, nil, nil), res("container", "web"))
|
||||||
|
cat := shelf(append(coreThree(), web)...)
|
||||||
|
got, err := Resolve(cat, []string{"web"}, workstation(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("an unmet dependency refused the node before the switch: %v", err)
|
||||||
|
}
|
||||||
|
want := []Unheld{{Node: "workstation", Module: "web", Seat: ContainerRuntimeSeat, Holders: []string{"docker"}}}
|
||||||
|
if !reflect.DeepEqual(got.Unheld, want) {
|
||||||
|
t.Errorf("reported %+v, want %+v", got.Unheld, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithTheSwitchFlippedAnUnmetDependencyRefusesTheNode(t *testing.T) {
|
||||||
|
enforceSeatDependencies = true
|
||||||
|
defer func() { enforceSeatDependencies = false }()
|
||||||
|
web := withResources(mod("web", nil, nil, nil), res("container", "web"))
|
||||||
|
cat := shelf(append(coreThree(), web)...)
|
||||||
|
_, err := Resolve(cat, []string{"web"}, workstation(), World{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), ContainerRuntimeSeat) || !strings.Contains(err.Error(), "docker") {
|
||||||
|
t.Fatalf("with the switch on, an unmet dependency gave %v", err)
|
||||||
|
}
|
||||||
|
// Never in the first pass, whose refusals take a machine off the network instead.
|
||||||
|
if _, err := Resolve(cat, []string{"web"}, workstation(), World{Unchecked: true}); err != nil {
|
||||||
|
t.Errorf("the first pass refused an unmet dependency: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheFoundationsDeclarationsAreNotJudged(t *testing.T) {
|
||||||
|
// The private network, as the controller computes it: its tools' package and its service are
|
||||||
|
// the mesh's, written per node, and so are never a module's dependency.
|
||||||
|
network := withResources(mod("mesh-wireguard", []string{"private-network"}, nil, nil),
|
||||||
|
res("package", "wireguard-tools"), res("service", "overlay-up"))
|
||||||
|
network.Computed = "mesh-wireguard"
|
||||||
|
// The host, whatever it declares.
|
||||||
|
host := withResources(mod("mesh-host", nil, nil, nil), res("file", "launcher"), res("service", "nox-mesh-host"))
|
||||||
|
cat := shelf(append(coreThree(), network, host)...)
|
||||||
|
|
||||||
|
for _, m := range []Manifest{network, host} {
|
||||||
|
if d := DependsOn(m); len(d) != 0 {
|
||||||
|
t.Errorf("%s, the foundation's, depends on %v", m.Module, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := Resolve(cat, []string{"mesh-wireguard", "mesh-host"}, workstation(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Unheld) != 0 {
|
||||||
|
t.Errorf("the foundation on a node with no holders reports %v", got.Unheld)
|
||||||
|
}
|
||||||
|
if _, err := AssignRefusal(cat, "workstation", nil, []string{"mesh-wireguard", "mesh-host"}); err != nil {
|
||||||
|
t.Errorf("assigning the foundation was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnassigningTheLastHolderWhileDependentsRemainIsRefused(t *testing.T) {
|
||||||
|
sshd := withResources(mod("sshd", nil, nil, nil), res("service", "sshd"))
|
||||||
|
cat := shelf(append(coreThree(), sshd)...)
|
||||||
|
on := []string{"systemd", "pacman", "docker", "sshd"}
|
||||||
|
|
||||||
|
err := UnassignRefusal(cat, "workstation", on, []string{"systemd"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("the last service manager came off a node still running services")
|
||||||
|
}
|
||||||
|
for _, want := range []string{ServiceManagerSeat, "sshd", "pacman", "docker"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("the refusal does not name %q:\n%v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A dependent comes off freely, and the holders with everything depending on them in one act.
|
||||||
|
if err := UnassignRefusal(cat, "workstation", on, []string{"sshd"}); err != nil {
|
||||||
|
t.Errorf("a dependent's unassignment was refused: %v", err)
|
||||||
|
}
|
||||||
|
if err := UnassignRefusal(cat, "workstation", on, on); err != nil {
|
||||||
|
t.Errorf("unassigning everything together was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ type Seat struct {
|
|||||||
// written; the store's table is seeded from it and thereafter is the live, editable copy.
|
// written; the store's table is seeded from it and thereafter is the live, editable copy.
|
||||||
//
|
//
|
||||||
// In the order a person reads it: the mesh's own, then a node's.
|
// In the order a person reads it: the mesh's own, then a node's.
|
||||||
var defaultSeats = []Seat{
|
var defaultSeats = append([]Seat{
|
||||||
// The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's
|
// The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's
|
||||||
// events belong to the role, so they keep their address while the holder is replaced. No accepts,
|
// events belong to the role, so they keep their address while the holder is replaced. No accepts,
|
||||||
// so no work queue is raised for it — only what its holder may say.
|
// so no work queue is raised for it — only what its holder may say.
|
||||||
@@ -148,8 +148,30 @@ var defaultSeats = []Seat{
|
|||||||
// system or user scope; the holder answers questions and operator acts about them, each verb
|
// system or user scope; the holder answers questions and operator acts about them, each verb
|
||||||
// taking the unit and an optional scope. The holder runs nothing of its own: its verbs are
|
// taking the unit and an optional scope. The holder runs nothing of its own: its verbs are
|
||||||
// served by the node tools runtime (ADR 0175).
|
// served by the node tools runtime (ADR 0175).
|
||||||
{Name: "node-service-manager", Scope: ScopeNode, Decision: "novox/hq ADR 0177",
|
{Name: ServiceManagerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0177",
|
||||||
Serves: serviceManagerVerbs()},
|
Serves: serviceManagerVerbs()},
|
||||||
|
// The machine's package manager (novox/hq ADR 0207). A module declaring a `package` depends on
|
||||||
|
// it being held on its node, as one declaring a `service` depends on node-service-manager: the
|
||||||
|
// mesh's word for "something on this machine answers for installing", where a capability only
|
||||||
|
// says the software is there. No verbs yet — the seat says who answers, and what may be asked
|
||||||
|
// of it is decided when someone needs to ask.
|
||||||
|
{Name: PackageManagerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0207"},
|
||||||
|
// The machine's container runtime (novox/hq ADR 0166, seeded now by ADR 0207): a module
|
||||||
|
// declaring a `container` depends on it being held on its node. Its verbs, and the host creating
|
||||||
|
// containers through its holder, wait for ADR 0166's acceptance — seeded without them so the
|
||||||
|
// dependency has a seat to name and the runtime's module has one to claim.
|
||||||
|
{Name: ContainerRuntimeSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0166, ADR 0207"},
|
||||||
|
// 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
|
// 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.
|
// model change, not a rename, so it stays until that is built.
|
||||||
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
||||||
@@ -161,7 +183,9 @@ var defaultSeats = []Seat{
|
|||||||
// rather than a condition in the resolver's module, so a machine running two managers is
|
// rather than a condition in the resolver's module, so a machine running two managers is
|
||||||
// refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117).
|
// refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117).
|
||||||
{Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117"},
|
{Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117"},
|
||||||
}
|
},
|
||||||
|
// The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's.
|
||||||
|
graphicalSessionSeats()...)
|
||||||
|
|
||||||
// A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for
|
// A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for
|
||||||
// a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused;
|
// a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused;
|
||||||
@@ -456,3 +480,17 @@ func serviceManagerVerbs() []Verb {
|
|||||||
Input: scoped(map[string]string{"unit": unit["unit"], "lines": "how many lines from the end (default 100)"}, []string{"unit"})},
|
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.
|
// and nothing to keep in step when a mesh seat is added.
|
||||||
const meshSeatPrefix = "mesh-"
|
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,
|
// 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
|
// and what it answers. A caller declares that it uses the *seat*, never the module, so the
|
||||||
// implementation can be replaced under it.
|
// 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+"*"))
|
"seats (novox/hq ADR 0118)", m.Module, s.Name, meshSeatPrefix+"*"))
|
||||||
continue
|
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] {
|
if seen[s.Name] {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s declares the seat %q twice", m.Module, s.Name))
|
"%s declares the seat %q twice", m.Module, s.Name))
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import (
|
|||||||
// vocabulary test follows. If this fails because a seat was added, the fix is a record in novox/hq
|
// vocabulary test follows. If this fails because a seat was added, the fix is a record in novox/hq
|
||||||
// and a row in to-be 26, not a new number here.
|
// and a row in to-be 26, not a new number here.
|
||||||
func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||||
record := regexp.MustCompile(`^novox/hq ADR \d{4}$`)
|
// A seat a later record extends names both, "novox/hq ADR 0166, ADR 0207": the one that defined
|
||||||
|
// it and the one that seeded it.
|
||||||
|
record := regexp.MustCompile(`^novox/hq ADR \d{4}(, ADR \d{4})*$`)
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
delivered := map[string]string{}
|
delivered := map[string]string{}
|
||||||
for _, s := range Seats() {
|
for _, s := range Seats() {
|
||||||
@@ -44,10 +46,12 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
|||||||
delivered[s.Delivers] = s.Name
|
delivered[s.Delivers] = s.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Seventeen since node-build-agent (novox/hq ADR 0190) — sixteen once the retired
|
// Thirty-two since the graphical session's eleven (novox/hq ADR 0208); twenty-one with
|
||||||
// mesh-build-machine row goes, when no registered manifest claims it any more.
|
// node-package-manager and node-container-runtime (ADR 0207); nineteen with node-environment and
|
||||||
if len(Seats()) != 17 {
|
// node-login-shell (ADR 0203, ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer
|
||||||
t.Errorf("the mesh defines %d seats rather than 17; the set is closed, so a change here is "+
|
// once the retired mesh-build-machine row goes, when no registered manifest claims it any more.
|
||||||
|
if len(Seats()) != 32 {
|
||||||
|
t.Errorf("the mesh defines %d seats rather than 32; the set is closed, so a change here is "+
|
||||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One account, several modules: the shell's module sets its shell, the container runtime's adds it
|
||||||
|
// to a group. Groups are only ever added by the host, so they are contributed; a shell or a home is
|
||||||
|
// one value, owned by one module per node.
|
||||||
|
|
||||||
|
func userResource(fields map[string]any) map[string]any {
|
||||||
|
r := map[string]any{"id": "operator", "type": "user", "name": "op"}
|
||||||
|
for k, v := range fields {
|
||||||
|
r[k] = v
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAShellAndAGroupOnOneAccountFromTwoModulesResolve(t *testing.T) {
|
||||||
|
zsh := Manifest{Module: "zsh", Resources: []map[string]any{userResource(map[string]any{"shell": "/usr/bin/zsh"})}}
|
||||||
|
docker := Manifest{Module: "docker", Resources: []map[string]any{userResource(map[string]any{"groups": []any{"docker"}})}}
|
||||||
|
other := Manifest{Module: "media", Resources: []map[string]any{userResource(map[string]any{"groups": []any{"video"}})}}
|
||||||
|
if problems := checkResources([]Manifest{zsh, docker, other}); len(problems) != 0 {
|
||||||
|
t.Fatalf("a shell and two modules' groups on one account were refused: %v", problems)
|
||||||
|
}
|
||||||
|
if _, err := Resolve(shelf(zsh, docker, other), []string{"zsh", "docker", "media"}, workstation(), World{}); err != nil {
|
||||||
|
t.Fatalf("the three did not resolve together: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTwoModulesSettingOneAccountsShellOrHomeAreRefused(t *testing.T) {
|
||||||
|
for _, field := range []string{"shell", "home"} {
|
||||||
|
a := Manifest{Module: "zsh", Resources: []map[string]any{userResource(map[string]any{field: "/one"})}}
|
||||||
|
b := Manifest{Module: "fish", Resources: []map[string]any{userResource(map[string]any{field: "/two", "groups": []any{"x"}})}}
|
||||||
|
problems := checkResources([]Manifest{a, b})
|
||||||
|
want := `zsh and fish both set the ` + field + ` of the user "op"`
|
||||||
|
if len(problems) != 1 || !strings.Contains(problems[0], want) {
|
||||||
|
t.Errorf("two modules setting %s gave %v, want %q", field, problems, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,10 +102,13 @@ var ControllerVerbs = []Verb{
|
|||||||
}, nil)},
|
}, nil)},
|
||||||
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it.",
|
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it.",
|
||||||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
||||||
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there.",
|
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there, " +
|
||||||
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
|
"or when a seat its resources are applied through is held by nothing on the machine (novox/hq ADR 0207).",
|
||||||
{Name: "unassign", Description: "Take a module off a machine.",
|
Input: schema(map[string]string{"node": "the machine's name",
|
||||||
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
|
"module": "the module's name; several comma-separated are judged together"}, []string{"node", "module"})},
|
||||||
|
{Name: "unassign", Description: "Take a module off a machine. Refused when it holds a seat a module left there depends on.",
|
||||||
|
Input: schema(map[string]string{"node": "the machine's name",
|
||||||
|
"module": "the module's name; several comma-separated are judged together"}, []string{"node", "module"})},
|
||||||
{Name: "pin", Description: "Tell a machine which provider answers a provision for it — the module, and the node " +
|
{Name: "pin", Description: "Tell a machine which provider answers a provision for it — the module, and the node " +
|
||||||
"it runs on, both. Asked for when more than one could answer; the refusal lists them.",
|
"it runs on, both. Asked for when more than one could answer; the refusal lists them.",
|
||||||
Input: schema(map[string]string{
|
Input: schema(map[string]string{
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"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).
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
for _, a := range made {
|
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
|
continue
|
||||||
}
|
}
|
||||||
seen[a.Reference] = true
|
seen[reference] = true
|
||||||
out = append(out, a.Reference)
|
out = append(out, reference)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
@@ -127,8 +130,8 @@ func (i *Inventory) keptReferences(ctx context.Context) (map[string]bool, error)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, a := range made {
|
for _, a := range made {
|
||||||
if a.Reference != "" {
|
if reference := asRecorded(a.Reference); reference != "" {
|
||||||
keep[a.Reference] = true
|
keep[reference] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,16 +189,35 @@ func (i *Inventory) everyReferenceMade(ctx context.Context) ([]string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, a := range made {
|
for _, a := range made {
|
||||||
if a.Reference == "" || seen[a.Reference] {
|
reference := asRecorded(a.Reference)
|
||||||
|
if reference == "" || seen[reference] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[a.Reference] = true
|
seen[reference] = true
|
||||||
out = append(out, a.Reference)
|
out = append(out, reference)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
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.
|
// digestIn is the `sha256:<hex>` a reference names, empty when it names none.
|
||||||
func digestIn(reference string) string {
|
func digestIn(reference string) string {
|
||||||
for _, marker := range []string{"@sha256:", "/sha256:"} {
|
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_)
|
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