Merge pull request 'The node-reads seat: its verbs, its holder's own account, and the node's secret places (hq ADR 0310, issue 418)' (#257) from fix/418-node-reads into main
This commit was merged in pull request #257.
This commit is contained in:
@@ -1718,6 +1718,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
// facts about the catalogue and are checked at registration (CatalogueProblems).
|
||||
problems = append(problems, declaredSeatProblems(m)...)
|
||||
problems = append(problems, RunsAsProblems(m)...)
|
||||
problems = append(problems, ReadsHolderProblems(m)...)
|
||||
if m.Computed != "" && len(m.Resources) > 0 {
|
||||
// One or the other. A module that both ships files and has them computed would leave
|
||||
// nobody able to say where a given file came from.
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A node's files and accounts, read (novox/hq ADR 0310, extending ADR 0268 §5; hq to-be 41 WP8).
|
||||
//
|
||||
// **A seat of its own on every node.** Reading a file, searching one, listing a directory, the owner and mode
|
||||
// of each component of a path, and looking up an account or a group are the reads ADR 0268 §5 has narrow tools
|
||||
// take over from the login shell's `execute`, so an approval is asked for an act and never for a read. Every
|
||||
// node needs them, the control-node included, and another module could offer them instead of the tool
|
||||
// runner's (ADR 0126 §9): a seat, so the mesh finds them on every node by one name.
|
||||
//
|
||||
// **Read as an account that holds no secret.** The holder runs as an account of its own (runs-as, ADR 0259
|
||||
// §8), so the node's file modes bound what it sees — never the operator's account, which sees the
|
||||
// operator's keys and the agent's credentials. On top, it refuses by name the places a secret is kept,
|
||||
// which the controller names for it (secretPlaces), and every answer passes the content rule's secret class
|
||||
// (mesh-sdk's outward, ADR 0310 §4).
|
||||
|
||||
// ReadsSeat is the role that reads a node's files and accounts (novox/hq ADR 0310).
|
||||
const ReadsSeat = "node-reads"
|
||||
|
||||
// ReadsSecretPlaces is the word the holder of node-reads is given: the places on its node where a secret is
|
||||
// kept, as a JSON list of absolute paths, each refused by name before any read (novox/hq ADR 0310 §5).
|
||||
const ReadsSecretPlaces = "MESH_SECRET_PLACES"
|
||||
|
||||
// readsVerbs is the contract every holder of node-reads serves (novox/hq ADR 0310 §2). The bounds are the
|
||||
// build's to set and each verb's description to state (ADR 0310, Consequences); the holder states them in
|
||||
// every answer it cuts.
|
||||
func readsVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "stat", Description: "Each component of one path or several, from / down: its type, owner and group " +
|
||||
"(name and number), mode, whether it is a link and to what, and whether it is a mount point and of which " +
|
||||
"file system. A link is not followed unless follow is \"true\". Read as the holder's own account, which " +
|
||||
"holds no secret: a component it may not reach says so. At most 200 paths in one call.",
|
||||
Input: schema(map[string]string{
|
||||
"paths": "one absolute path, or several separated by line breaks",
|
||||
"follow": "\"true\": also say where the path leads once every link is followed",
|
||||
}, []string{"paths"}, "follow"),
|
||||
Replaces: []string{"stat", "ls -ld", "namei -l", "findmnt -T"}},
|
||||
{Name: "readlink", Description: "What one link points to, as written, and where the path leads once every " +
|
||||
"link is followed, and whether that exists.",
|
||||
Input: schema(map[string]string{"path": "an absolute path"}, []string{"path"}),
|
||||
Replaces: []string{"readlink", "readlink -f", "realpath"}},
|
||||
{Name: "list", Description: "Every entry of one directory with its type, owner, group, mode, size and, for a " +
|
||||
"link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept " +
|
||||
"is refused by name.",
|
||||
Input: schema(map[string]string{"path": "an absolute path to a directory"}, []string{"path"}),
|
||||
Replaces: []string{"ls", "ls -la"}},
|
||||
{Name: "read", Description: "One text file, or a part of it by line: at most 256 KiB in one answer " +
|
||||
"(64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is " +
|
||||
"kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its " +
|
||||
"number said. /proc and /sys are read as far as the holder's account may.",
|
||||
Input: schema(map[string]string{
|
||||
"path": "an absolute path to a file",
|
||||
"from": "the first line to answer, counted from 1 (optional)",
|
||||
"lines": "how many lines to answer (optional)",
|
||||
"bytes": "the most bytes to answer, up to 262144 (optional, 65536 unless given)",
|
||||
}, []string{"path"}),
|
||||
Replaces: []string{"cat", "head", "tail", "less"}},
|
||||
{Name: "search", Description: "Find a fixed string (never a pattern) in one file or under one directory: " +
|
||||
"every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), " +
|
||||
"8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and " +
|
||||
"binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a " +
|
||||
"secret is withheld. The answer says which bound it met.",
|
||||
Input: schema(map[string]string{
|
||||
"text": "the fixed string to find",
|
||||
"path": "an absolute path to a file or a directory",
|
||||
"depth": "how many directories deep, up to 8 (optional)",
|
||||
"matches": "the most matches to answer, up to 500 (optional)",
|
||||
"case": "\"false\": match whatever the case (optional)",
|
||||
}, []string{"text", "path"}, "case"),
|
||||
Replaces: []string{"grep", "grep -r", "grep -F"}},
|
||||
{Name: "account", Description: "One account or group by name or number, as this node's account files say: " +
|
||||
"an account's number, group, home, shell and every group it is in; a group's number and members. With " +
|
||||
"neither, every account and group. Never a password field.",
|
||||
Input: schema(map[string]string{
|
||||
"name": "an account's name or number (optional)",
|
||||
"group": "a group's name or number (optional)",
|
||||
}, nil),
|
||||
Replaces: []string{"getent passwd", "getent group", "id", "groups"}},
|
||||
}
|
||||
}
|
||||
|
||||
// ReadsHolderProblems is what a holder of node-reads is held to (novox/hq ADR 0310 §3): it reads as an
|
||||
// account of its own, so the node's file modes bound what it sees. Carried by the machine's runtime it would
|
||||
// read as the operator's account, which sees the operator's keys and the agent's credentials.
|
||||
func ReadsHolderProblems(m Manifest) []string {
|
||||
if !m.ClaimsSeat(ReadsSeat) {
|
||||
return nil
|
||||
}
|
||||
if m.RunsAs == "" {
|
||||
return []string{fmt.Sprintf("%s claims %s and must run as an account of its own (runs-as): its reads are "+
|
||||
"bounded by that account's file modes, and the machine's runtime runs as the operator's account "+
|
||||
"(novox/hq ADR 0310 §3)", m.Module, ReadsSeat)}
|
||||
}
|
||||
// In no group beside its own: a group is a way to a secret (docker's is root's), and the account's file modes
|
||||
// are what bound its reads.
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "user" || fmt.Sprint(r["name"]) != m.RunsAs {
|
||||
continue
|
||||
}
|
||||
if groups, said := r["groups"]; said {
|
||||
if list, ok := groups.([]any); !ok || len(list) > 0 {
|
||||
return []string{fmt.Sprintf("%s claims %s, and its account %s is put in groups %v: the account that "+
|
||||
"reads is in no group beside its own (novox/hq ADR 0310 §3)", m.Module, ReadsSeat, m.RunsAs, groups)}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// secretPlaces is every place on this node a secret is kept, as the placed manifests say (novox/hq ADR 0310
|
||||
// §5): the directory of every module's own secret, every credential file a module is given and every grant
|
||||
// directory a provider is handed its consumers' credentials in, the place a module keeps the operator-sealed
|
||||
// secrets, and every directory of the module that provides `secret` — the vault — wherever the node places it,
|
||||
// a directory declared without a path included. Sorted, each once, and a place under another dropped, so the
|
||||
// list says each refusal once.
|
||||
//
|
||||
// **Failure is loud.** A place still holding a placeholder, or not absolute, fails the composition with the
|
||||
// module named: a list that silently shrank would be a refusal the decision promises and nothing does.
|
||||
func (r Resolution) secretPlaces(with Rendering) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
var problems []string
|
||||
add := func(module, what, path string) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") || strings.Contains(path, "${") {
|
||||
problems = append(problems, fmt.Sprintf("%s's %s is %q, which is not a place on %s", module, what, path, r.Node))
|
||||
return
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if path == "/" {
|
||||
problems = append(problems, fmt.Sprintf("%s's %s is the root directory", module, what))
|
||||
return
|
||||
}
|
||||
seen[path] = true
|
||||
}
|
||||
for _, m := range r.Modules {
|
||||
for name, own := range m.OwnSecrets {
|
||||
add(m.Module, "own secret "+name, own.Path)
|
||||
if strings.HasPrefix(own.Path, "/") {
|
||||
add(m.Module, "own secret "+name+"'s directory", filepath.Dir(own.Path))
|
||||
}
|
||||
}
|
||||
for _, to := range m.SecretRequirements() {
|
||||
for _, f := range m.SecretFiles(to) {
|
||||
add(m.Module, "credential file for "+to, f.Path)
|
||||
}
|
||||
}
|
||||
for to, where := range m.Grants {
|
||||
add(m.Module, "grant directory for "+to, where)
|
||||
}
|
||||
add(m.Module, "place for the operator-sealed secrets (keeps)", m.Keeps)
|
||||
if providesSecret(m) {
|
||||
dirs := dirsFor(m, with)
|
||||
for _, id := range sortedKeys(dirs) {
|
||||
add(m.Module, "directory "+id, dirs[id])
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
sort.Strings(problems)
|
||||
return nil, fmt.Errorf("the holder of %s on %s cannot be told where a secret is kept, so it would read "+
|
||||
"one (novox/hq ADR 0310 §5): %s", ReadsSeat, r.Node, strings.Join(problems, "; "))
|
||||
}
|
||||
all := make([]string, 0, len(seen))
|
||||
for p := range seen {
|
||||
all = append(all, p)
|
||||
}
|
||||
sort.Strings(all)
|
||||
var out []string
|
||||
for _, p := range all {
|
||||
if n := len(out); n > 0 && strings.HasPrefix(p, out[n-1]+"/") {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func providesSecret(m Manifest) bool {
|
||||
for _, p := range m.Provides {
|
||||
if p.Name == "secret" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readsWords is what the holder of node-reads is given beside its own words: the node's secret places.
|
||||
func (r Resolution) readsWords(m Manifest, words map[string]string, with Rendering) (map[string]string, error) {
|
||||
if !m.ClaimsSeat(ReadsSeat) {
|
||||
return words, nil
|
||||
}
|
||||
places, err := r.secretPlaces(with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := json.Marshal(places)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if words == nil {
|
||||
words = map[string]string{}
|
||||
}
|
||||
if _, has := words[ReadsSecretPlaces]; has {
|
||||
return nil, fmt.Errorf("%s gives its own %s; the node's secret places are the mesh's to say (novox/hq ADR 0310 §5)",
|
||||
m.Module, ReadsSecretPlaces)
|
||||
}
|
||||
words[ReadsSecretPlaces] = string(body)
|
||||
return words, nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0310 §1, §2: node-reads is a node seat, and its verbs say the commands they replace, so
|
||||
// mesh_search finds them by `cat`, `grep` and `getent passwd` (ADR 0245 §3).
|
||||
func TestNodeReadsIsANodeSeatWhoseVerbsSayWhatTheyReplace(t *testing.T) {
|
||||
s, ok := SeatNamed(ReadsSeat)
|
||||
if !ok {
|
||||
t.Fatalf("the mesh defines no seat %s", ReadsSeat)
|
||||
}
|
||||
if s.Scope != ScopeNode || s.Delivers != "" || !strings.Contains(s.Decision, "0310") {
|
||||
t.Errorf("%s: scope %q, delivers %q, decision %q", ReadsSeat, s.Scope, s.Delivers, s.Decision)
|
||||
}
|
||||
replaced := map[string]string{}
|
||||
for _, v := range s.Serves {
|
||||
if v.Optional {
|
||||
t.Errorf("%s.%s is optional: a new seat's holder serves every verb", ReadsSeat, v.Name)
|
||||
}
|
||||
if len(v.Replaces) == 0 {
|
||||
t.Errorf("%s.%s says nothing it replaces", ReadsSeat, v.Name)
|
||||
}
|
||||
for _, c := range v.Replaces {
|
||||
replaced[c] = v.Name
|
||||
}
|
||||
}
|
||||
for command, verb := range map[string]string{"cat": "read", "grep": "search", "getent passwd": "account",
|
||||
"getent group": "account", "id": "account", "stat": "stat", "readlink": "readlink", "ls": "list"} {
|
||||
if replaced[command] != verb {
|
||||
t.Errorf("%q is replaced by %q, want %s.%s", command, replaced[command], ReadsSeat, verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0310 §3: a holder of node-reads reads as an account of its own; one the machine's runtime would
|
||||
// carry — as the operator's account — is refused at parse.
|
||||
func TestAHolderOfNodeReadsWithoutAnAccountOfItsOwnIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module": "reads", "version": "1",
|
||||
"claims": [{"name": "node-reads", "scope": "node"}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "runs-as") || !strings.Contains(err.Error(), "0310") {
|
||||
t.Errorf("a holder of node-reads without runs-as was not refused for it: %v", err)
|
||||
}
|
||||
if p := ReadsHolderProblems(Manifest{Module: "reads", RunsAs: "mesh-reads",
|
||||
Claims: []Claim{{Name: ReadsSeat}}}); len(p) != 0 {
|
||||
t.Errorf("a holder running as its own account was refused: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0310 §5: the holder is told, by the composition, where its node keeps a secret — the directory
|
||||
// of every module's own secret, every credential file and grant directory, the vault's directories and the
|
||||
// place the operator-sealed secrets are kept — and no other module is told.
|
||||
func TestTheHolderOfNodeReadsIsToldWhereItsNodeKeepsASecret(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"},
|
||||
"reads": {"broker": "own"}, "desk": {"broker": "own"}}}
|
||||
goRuntime := Manifest{Module: RuntimeModule, Version: "1",
|
||||
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/node-tools"}}}}
|
||||
goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reads := aToolsModule(t, "reads", "tools/index.js")
|
||||
reads.RunsAs, reads.SecretsOwner = "mesh-reads", "mesh-reads"
|
||||
reads.Claims = []Claim{{Name: ReadsSeat}}
|
||||
reads.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/mesh-reads/broker"}}
|
||||
desk := aToolsModule(t, "desk", "tools/index.js")
|
||||
desk.RunsAs, desk.SecretsOwner = "desk", "desk"
|
||||
desk.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/desk/broker"}}
|
||||
// The vault as the catalogue declares it: its directories have no path of their own, and the node places them.
|
||||
vault, err := placedManifest(catalogueManifest(t, "mesh-vault"), with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := Manifest{Module: "app", Version: "1", Secrets: map[string]string{"postgres-database": "/etc/app/db.secret"},
|
||||
OwnSecrets: OwnSecrets{"token": {Path: "/home/ops/.config/app/token"}}}
|
||||
r := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{reads, desk, vault, app, goRuntime}}
|
||||
owns, err := r.ownRuntimes(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var given map[string]map[string]string
|
||||
for _, p := range owns {
|
||||
if p["id"] == "reads."+OwnRuntimeID() {
|
||||
if err := json.Unmarshal([]byte(p["env"].(map[string]string)[RuntimeToolEnv]), &given); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if p["id"] == "desk."+OwnRuntimeID() && strings.Contains(p["env"].(map[string]string)[RuntimeToolEnv], ReadsSecretPlaces) {
|
||||
t.Error("a module not holding node-reads is told the node's secret places")
|
||||
}
|
||||
}
|
||||
var places []string
|
||||
if err := json.Unmarshal([]byte(given["reads"][ReadsSecretPlaces]), &places); err != nil {
|
||||
t.Fatalf("the holder is not told its node's secret places: %v (%v)", err, given)
|
||||
}
|
||||
want := []string{"/etc/app/db.secret", "/home/ops/.config/app", "/var/lib/desk", "/var/lib/mesh-reads",
|
||||
"/var/lib/mesh-vault", "/var/lib/mesh/" + RuntimeModule}
|
||||
if strings.Join(places, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("secret places:\n got %v\nwant %v", places, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The review of #257: a secret place the controller cannot resolve fails the composition, naming the module,
|
||||
// rather than leaving the list one place shorter.
|
||||
func TestASecretPlaceThatCannotBeResolvedFailsTheComposition(t *testing.T) {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{
|
||||
{Module: "app", Version: "1", Keeps: "${dir:keep}/sealed"},
|
||||
}}
|
||||
if _, err := r.secretPlaces(Rendering{}); err == nil || !strings.Contains(err.Error(), "app") {
|
||||
t.Errorf("an unresolved place was dropped: %v", err)
|
||||
}
|
||||
r.Modules[0].Keeps = "relative/place"
|
||||
if _, err := r.secretPlaces(Rendering{}); err == nil {
|
||||
t.Error("a relative place was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0310 §3: the account that reads is in no group beside its own; one put in a group is refused.
|
||||
func TestTheReadsAccountIsInNoGroup(t *testing.T) {
|
||||
m := Manifest{Module: "reads", RunsAs: "mesh-reads", Claims: []Claim{{Name: ReadsSeat}},
|
||||
Resources: []map[string]any{{"id": "account", "type": "user", "name": "mesh-reads", "groups": []any{"docker"}}}}
|
||||
if p := ReadsHolderProblems(m); len(p) != 1 || !strings.Contains(p[0], "docker") {
|
||||
t.Errorf("an account in docker was not refused: %v", p)
|
||||
}
|
||||
m.Resources[0]["groups"] = []any{}
|
||||
if p := ReadsHolderProblems(m); len(p) != 0 {
|
||||
t.Errorf("an account in no group was refused: %v", p)
|
||||
}
|
||||
}
|
||||
@@ -303,6 +303,10 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The holder of node-reads is told where this node keeps its secrets (novox/hq ADR 0310 §5).
|
||||
if words, err = r.readsWords(m, words, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(words) > 0 {
|
||||
body, err := json.Marshal(map[string]map[string]string{m.Module: words})
|
||||
if err != nil {
|
||||
|
||||
@@ -310,6 +310,9 @@ var defaultSeats = append([]Seat{
|
||||
// node-mounts writes a mount and an automount unit per share on a machine that asks (shares.go).
|
||||
{Name: NFSServerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: nfsServerVerbs()},
|
||||
{Name: MountsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: mountsVerbs()},
|
||||
// A node's files and accounts, read (novox/hq ADR 0310, reads.go): by an account of the holder's own that holds
|
||||
// no secret, on every node, so the reads ADR 0268 §5 takes over from the login shell's execute have a tool.
|
||||
{Name: ReadsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0310", Serves: readsVerbs()},
|
||||
},
|
||||
// The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's.
|
||||
graphicalSessionSeats()...)
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
delivered[s.Delivers] = s.Name
|
||||
}
|
||||
}
|
||||
// Forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired
|
||||
// Forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired
|
||||
// into node-uplink (novox/hq ADR 0223); thirty-seven
|
||||
// since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with
|
||||
// node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215);
|
||||
@@ -59,8 +59,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
// node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203,
|
||||
// ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired
|
||||
// mesh-build-machine row goes, when no registered manifest claims it.
|
||||
if len(Seats()) != 40 {
|
||||
t.Errorf("the mesh defines %d seats rather than 40; the set is closed, so a change here is "+
|
||||
if len(Seats()) != 41 {
|
||||
t.Errorf("the mesh defines %d seats rather than 41; the set is closed, so a change here is "+
|
||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user