From dcb2e6e9669f85c530f706f608cfb774234f68e6 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 11:36:26 +0100 Subject: [PATCH] Remove the controller from the delivery contract, and its command line client and hand composer The controller ended (ADR 0590): the grants issued are the grants part's alone, so its grant value is Grant and GrantSource goes; a node trusts the vault's key alone, follows the grants part's user list alone and composes its own or none; the controller's composed and builds bucket names go. nox calls seat verbs (ADR 0590 clause 7), so the cli package that spoke to the node-engine's socket goes, and mesh-hand-compose, which wrote the controller's compositions. A have an engine from before wrote, composes controller, still reads. Issue 1126 B4a, B6. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/cli/cli.go | 201 ----------------------- go/cli/cli_test.go | 141 ----------------- go/cli/refusal_test.go | 123 --------------- go/cmd/mesh-hand-compose/main.go | 210 ------------------------- go/cmd/mesh-hand-compose/main_test.go | 145 ----------------- go/compose/composed.go | 2 +- go/delivery/assigner.go | 2 +- go/delivery/assigner_test.go | 49 ++---- go/delivery/builder.go | 4 +- go/delivery/builder_test.go | 5 +- go/delivery/buildrecord.go | 9 +- go/delivery/buildrecord_test.go | 3 - go/delivery/composed.go | 28 +--- go/delivery/composed_signature.go | 7 +- go/delivery/composed_signature_test.go | 6 +- go/delivery/delivery.go | 29 ++-- go/delivery/delivery_test.go | 20 +-- go/delivery/grants.go | 93 ++++------- go/delivery/placement_candidate.go | 14 +- go/delivery/placement_test.go | 16 +- go/delivery/signers.go | 16 +- go/delivery/userlist.go | 27 ++-- 22 files changed, 119 insertions(+), 1031 deletions(-) delete mode 100644 go/cli/cli.go delete mode 100644 go/cli/cli_test.go delete mode 100644 go/cli/refusal_test.go delete mode 100644 go/cmd/mesh-hand-compose/main.go delete mode 100644 go/cmd/mesh-hand-compose/main_test.go diff --git a/go/cli/cli.go b/go/cli/cli.go deleted file mode 100644 index 3a6bd10..0000000 --- a/go/cli/cli.go +++ /dev/null @@ -1,201 +0,0 @@ -// Package cli is the local protocol between `mesh-cli` and the node-engine on its machine (novox/hq ADR 0272). -// -// `mesh-cli ` asks the controller to run one of its command lines. It holds no credential and -// reads no file of the mesh: it connects to the node-engine's socket, writes one Request, and reads one Answer. -// The node-engine reads the connecting process's account from the kernel and asks the controller over its own -// link; that request is the node-engine's and the controller's, and lives in neither this package nor any SDK -// (ADR 0039 as ADR 0188 reads it: the transport lives in the runtime). -// -// One request and one answer per connection, each one line of JSON. Standard library only. -package cli - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "time" -) - -// Socket is where the node-engine listens for mesh-cli: a file under its runtime directory, with no network -// address (ADR 0272 §3). -const Socket = "/run/nox-mesh-host/cli.sock" - -// MaxStdin bounds the standard input mesh-cli carries for a line: a secret, a key, a settings file — kilobytes. More -// is refused by mesh-cli before anything is asked, and by the node-engine and the controller again. -const MaxStdin = 64 << 10 - -// MaxRequest bounds what the node-engine reads of a request: a command line of a few hundred bytes, settings given -// inline of a few kilobytes, and standard input of at most MaxStdin, which JSON writes as base64 (4 bytes for every 3). -const MaxRequest = 64<<10 + MaxStdin*4/3 + 4 - -// MaxAnswer bounds what mesh-cli reads of an answer. The controller cuts what it runs to fit one bus message, and -// says so; this is the local bound above that. -const MaxAnswer = 16 << 20 - -// AskWithin is how long mesh-cli waits for its answer: the controller's own bound on a call (five minutes), -// and a minute for the link on either side. -const AskWithin = 6 * time.Minute - -// Request is what mesh-cli asks: the controller's command line, as words, and what mesh-cli's own standard input -// held when it was not a terminal. -// -// **Stdin is for the controller's terminal alone** (novox/hq ADR 0259 §10, ADR 0272): a secret given with -// `secret accept … --from -`, so it never stands on a command line. The controller gives it to a line that runs as -// its terminal and refuses any other line that carries it; nobody keeps it — not the node-engine, not the -// controller's record of calls, not its journal. -type Request struct { - Line []string `json:"line"` - Stdin []byte `json:"stdin,omitempty"` -} - -// Answer is what comes back: what the command printed, how it exited, and whether it ran as the controller's -// terminal and why. Refused is set when nothing ran at all, and says why; Exit is then 1. -type Answer struct { - Stdout []byte `json:"stdout,omitempty"` - Stderr []byte `json:"stderr,omitempty"` - Exit int `json:"exit"` - Terminal bool `json:"terminal"` - // Why says whether the line ran as the terminal, and why or why not, in the controller's words. - Why string `json:"why,omitempty"` - // Refused says why nothing ran: the account, the node, the controller unreachable. - Refused string `json:"refused,omitempty"` - // Cut says the output was longer than one bus message and was cut there. - Cut bool `json:"cut,omitempty"` - // Unknown says the line was asked and its outcome is not known: no answer came in time, and it may have - // run. Never said as a refusal, which means nothing ran. - Unknown string `json:"unknown,omitempty"` -} - -// Refusal is an answer saying that nothing ran, and why. -func Refusal(why string) Answer { return Answer{Exit: 1, Refused: why} } - -// NotKnown is an answer saying the line was asked and what came of it is not known: it may have run. call is -// the controller's call the line runs as, when it is known, which `calls` answers. -func NotKnown(call, why string) Answer { - where := "the controller's `calls` lists what came of each line" - if call != "" { - where = "see the call " + call + " (`calls " + call + "`)" - } - return Answer{Exit: 1, Unknown: why + "; the line may have run — " + where} -} - -// ErrTooLong is a request or an answer over its bound. -var ErrTooLong = errors.New("longer than the protocol allows") - -// WriteRequest writes one request, as one line. -func WriteRequest(w io.Writer, r Request) error { return writeLine(w, r) } - -// ReadRequest reads one request, refusing one over MaxRequest or one that names no command. -func ReadRequest(r io.Reader) (Request, error) { - var req Request - if err := readLine(r, MaxRequest, &req); err != nil { - return Request{}, err - } - if len(req.Line) == 0 { - return Request{}, errors.New("the request names no command") - } - if len(req.Stdin) > MaxStdin { - return Request{}, fmt.Errorf("%w: standard input of more than %d bytes", ErrTooLong, MaxStdin) - } - return req, nil -} - -// WriteAnswer writes one answer, as one line. -func WriteAnswer(w io.Writer, a Answer) error { return writeLine(w, a) } - -// ReadAnswer reads one answer, refusing one over MaxAnswer. -func ReadAnswer(r io.Reader) (Answer, error) { - var a Answer - err := readLine(r, MaxAnswer, &a) - return a, err -} - -// Ask is mesh-cli's side: connect to the socket, ask, and wait for the answer within AskWithin or ctx. -func Ask(ctx context.Context, socket string, line []string) (Answer, error) { - return AskWith(ctx, socket, Request{Line: line}) -} - -// AskWith asks a whole request: a line, and the standard input it carries, at most MaxStdin. -func AskWith(ctx context.Context, socket string, req Request) (Answer, error) { - line := req.Line - if len(line) == 0 { - return Answer{}, errors.New("no command given") - } - if len(req.Stdin) > MaxStdin { - return Answer{}, fmt.Errorf("standard input is more than %d bytes, which mesh-cli does not carry; nothing was asked", MaxStdin) - } - ctx, cancel := context.WithTimeout(ctx, AskWithin) - defer cancel() - var d net.Dialer - conn, err := d.DialContext(ctx, "unix", socket) - if err != nil { - return Answer{}, fmt.Errorf("the node-engine on this machine does not answer at %s: %w", socket, err) - } - defer conn.Close() - if deadline, ok := ctx.Deadline(); ok { - _ = conn.SetDeadline(deadline) - } - if err := WriteRequest(conn, req); err != nil { - // The engine may have answered before it read the request — a refusal (busy, an unknown account) written - // and the connection closed — and the write then fails on the close. That answer is already waiting, and - // it says why where the write error says only that the pipe broke (novox/hq issue 444, found under issue - // 434). So read it, within the deadline the connection already has, and only when none came say the write. - // Only a refusal is taken: the request was never wholly written, so nothing can have run, and any other - // answer here is not one a correct engine gives — passing it on as success would hide the failure. - a, rerr := ReadAnswer(conn) - if rerr != nil { - return Answer{}, fmt.Errorf("asking the node-engine: %w; and no answer came (%v)", err, rerr) - } - if a.Refused == "" { - return Answer{}, fmt.Errorf("asking the node-engine: %w; and the answer that came is not a refusal, "+ - "though the request was never wholly written", err) - } - return a, nil - } - a, err := ReadAnswer(conn) - if err != nil { - return Answer{}, fmt.Errorf("reading the node-engine's answer: %w", err) - } - return a, nil -} - -func writeLine(w io.Writer, v any) error { - body, err := json.Marshal(v) - if err != nil { - return err - } - _, err = w.Write(append(body, '\n')) - return err -} - -// readLine reads one line of JSON of at most limit bytes. A line over the limit is refused rather than read -// whole: the reader is the side that pays for it. -func readLine(r io.Reader, limit int, into any) error { - br := bufio.NewReaderSize(io.LimitReader(r, int64(limit)+1), 64<<10) - var body []byte - for { - chunk, err := br.ReadSlice('\n') - body = append(body, chunk...) - if len(body) > limit { - return fmt.Errorf("%w: more than %d bytes", ErrTooLong, limit) - } - if err == nil { - break - } - if errors.Is(err, bufio.ErrBufferFull) { - continue - } - if errors.Is(err, io.EOF) && len(body) > 0 { - break - } - return err - } - if err := json.Unmarshal(body, into); err != nil { - return fmt.Errorf("not one: %w", err) - } - return nil -} diff --git a/go/cli/cli_test.go b/go/cli/cli_test.go deleted file mode 100644 index 92c5050..0000000 --- a/go/cli/cli_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "net" - "path/filepath" - "strings" - "testing" -) - -// A request and an answer survive a round trip, bytes and all. -func TestARequestAndAnAnswerSurviveTheirRoundTrip(t *testing.T) { - var b bytes.Buffer - line := []string{"settings", "set", "zsh", `{"execute":"withhold"}`, "--node", "a"} - if err := WriteRequest(&b, Request{Line: line}); err != nil { - t.Fatal(err) - } - got, err := ReadRequest(&b) - if err != nil { - t.Fatal(err) - } - if strings.Join(got.Line, "\x00") != strings.Join(line, "\x00") { - t.Fatalf("the line came back as %q", got.Line) - } - - b.Reset() - sent := Answer{Stdout: []byte("ok\n\xff"), Stderr: []byte("said"), Exit: 3, Terminal: true, Why: "the terminal", Cut: true, - Unknown: "no answer; the line may have run — see the call call-1"} - if err := WriteAnswer(&b, sent); err != nil { - t.Fatal(err) - } - a, err := ReadAnswer(&b) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(a.Stdout, sent.Stdout) || string(a.Stderr) != "said" || a.Exit != 3 || !a.Terminal || a.Why != sent.Why || !a.Cut || - a.Unknown != sent.Unknown { - t.Fatalf("the answer came back as %+v", a) - } -} - -// A request over the bound is refused, not read whole; one naming no command is refused. -func TestARequestOverTheBoundOrNamingNothingIsRefused(t *testing.T) { - long := Request{Line: []string{strings.Repeat("x", MaxRequest)}} - big := Request{Line: []string{"secret"}, Stdin: bytes.Repeat([]byte("s"), MaxStdin+1)} - var bb bytes.Buffer - if err := WriteRequest(&bb, big); err != nil { - t.Fatal(err) - } - if _, err := ReadRequest(&bb); !errors.Is(err, ErrTooLong) { - t.Fatalf("standard input over its bound was read: %v", err) - } - if _, err := AskWith(context.Background(), "/nonexistent.sock", big); err == nil || !strings.Contains(err.Error(), "nothing was asked") { - t.Fatalf("mesh-cli would ask with standard input over its bound: %v", err) - } - var b bytes.Buffer - if err := WriteRequest(&b, long); err != nil { - t.Fatal(err) - } - if _, err := ReadRequest(&b); !errors.Is(err, ErrTooLong) { - t.Fatalf("a request over the bound was read: %v", err) - } - if _, err := ReadRequest(strings.NewReader(`{"line":[]}` + "\n")); err == nil { - t.Fatal("a request naming no command was taken") - } - if _, err := ReadRequest(strings.NewReader("not json\n")); err == nil { - t.Fatal("a request that is not one was taken") - } -} - -// Ask speaks the protocol over a socket, as mesh-cli does to the node-engine. -func TestAskSpeaksTheProtocolOverASocket(t *testing.T) { - socket := filepath.Join(t.TempDir(), "cli.sock") - l, err := net.Listen("unix", socket) - if err != nil { - t.Skipf("no unix socket here: %v", err) - } - defer l.Close() - go func() { - conn, err := l.Accept() - if err != nil { - return - } - defer conn.Close() - req, err := ReadRequest(conn) - if err != nil { - _ = WriteAnswer(conn, Refusal(err.Error())) - return - } - _ = WriteAnswer(conn, Answer{Stdout: []byte(strings.Join(req.Line, " "))}) - }() - a, err := Ask(context.Background(), socket, []string{"status"}) - if err != nil { - t.Fatal(err) - } - if string(a.Stdout) != "status" || a.Exit != 0 || a.Refused != "" { - t.Fatalf("asked status and heard %+v", a) - } - if _, err := Ask(context.Background(), filepath.Join(t.TempDir(), "none.sock"), []string{"status"}); err == nil || - !strings.Contains(err.Error(), "does not answer") { - t.Fatalf("a missing socket was not said: %v", err) - } -} - -// An outcome not known is never a refusal: it says the line may have run, and exits non-zero. -func TestAnUnknownOutcomeIsNotARefusal(t *testing.T) { - a := NotKnown("call-7", "no answer within 6m0s") - if a.Refused != "" || a.Exit == 0 || !strings.Contains(a.Unknown, "may have run") || !strings.Contains(a.Unknown, "call-7") { - t.Fatalf("an unknown outcome reads as %+v", a) - } - if b := NotKnown("", "no answer"); strings.Contains(b.Unknown, "call ") { - t.Fatalf("an unknown outcome with no call names one: %+v", b) - } -} - -// Standard input up to its bound travels whole, beside the line: a secret given with --from -. -func TestStandardInputTravelsWithItsLine(t *testing.T) { - given := append(bytes.Repeat([]byte{0, 0xff, '\n', 'x'}, MaxStdin/4-1), "end"...) - var b bytes.Buffer - if err := WriteRequest(&b, Request{Line: []string{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"}, - Stdin: given}); err != nil { - t.Fatal(err) - } - if b.Len() > MaxRequest { - t.Fatalf("a request with standard input at its bound is %d bytes, over MaxRequest %d", b.Len(), MaxRequest) - } - got, err := ReadRequest(&b) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(got.Stdin, given) || got.Line[1] != "accept" { - t.Fatalf("standard input did not travel whole: %d bytes, want %d", len(got.Stdin), len(given)) - } - none, _ := json.Marshal(Request{Line: []string{"status"}}) - if strings.Contains(string(none), "stdin") { - t.Fatalf("a request without standard input says it has one: %s", none) - } -} diff --git a/go/cli/refusal_test.go b/go/cli/refusal_test.go deleted file mode 100644 index d05ec87..0000000 --- a/go/cli/refusal_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package cli - -import ( - "context" - "errors" - "net" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -// anEngineThatClosesFirst listens like the node-engine before mesh-host#80: it writes its answer, if any, and -// closes without reading the request (novox/hq issue 434). It answers one connection. -func anEngineThatClosesFirst(t *testing.T, answer *Answer) string { - t.Helper() - socket := filepath.Join(t.TempDir(), "cli.sock") - l, err := net.Listen("unix", socket) - if err != nil { - t.Skipf("no unix socket here: %v", err) - } - t.Cleanup(func() { l.Close() }) - go func() { - conn, err := l.Accept() - if err != nil { - return - } - if answer != nil { - _ = WriteAnswer(conn, *answer) - } - conn.Close() - }() - return socket -} - -// aLineTheSocketCannotHold is a request larger than a unix socket buffers, so its write cannot finish before -// the engine reads it — and an engine that closes first makes it fail every time, not only when it loses a race. -func aLineTheSocketCannotHold() []string { return []string{"status", strings.Repeat("x", 4<<20)} } - -// An engine that refuses and closes before reading the request is heard: its refusal reaches the caller, not -// the broken pipe its close left the client's write (novox/hq issue 444). -func TestARefusalWrittenBeforeTheRequestIsReadReachesTheCaller(t *testing.T) { - busy := Refusal("the node-engine is busy: too many lines at once") - socket := anEngineThatClosesFirst(t, &busy) - a, err := Ask(context.Background(), socket, aLineTheSocketCannotHold()) - if err != nil { - t.Fatalf("the engine said %q and the caller heard: %v", busy.Refused, err) - } - if a.Refused != busy.Refused || a.Exit != 1 { - t.Fatalf("the engine said %q and the caller heard %+v", busy.Refused, a) - } -} - -// When the write fails and no answer is waiting, the error says both: what failed, and that no answer came. -func TestAFailedWriteWithNoAnswerSaysBoth(t *testing.T) { - socket := anEngineThatClosesFirst(t, nil) - start := time.Now() - _, err := Ask(context.Background(), socket, aLineTheSocketCannotHold()) - if err == nil { - t.Fatal("a request the engine never read, and never answered, came back without an error") - } - if !strings.Contains(err.Error(), "asking the node-engine") || !strings.Contains(err.Error(), "no answer came") { - t.Fatalf("the error does not say what failed and that no answer came: %v", err) - } - if waited := time.Since(start); waited > 30*time.Second { - t.Fatalf("waited %s on a connection the engine had closed", waited) - } -} - -// After a failed write only a refusal is taken: nothing can have run, so an answer saying it ran and exited 0 -// is an error, never a success (novox/hq issue 444). -func TestAnAnswerOtherThanARefusalAfterAFailedWriteIsAnError(t *testing.T) { - ran := Answer{Exit: 0, Stdout: []byte("ok")} - socket := anEngineThatClosesFirst(t, &ran) - a, err := Ask(context.Background(), socket, aLineTheSocketCannotHold()) - if err == nil { - t.Fatalf("a request never wholly written came back as success: %+v", a) - } - if !strings.Contains(err.Error(), "not a refusal") { - t.Fatalf("the error does not say the answer was not a refusal: %v", err) - } -} - -// Reading after a failed write never waits longer than the ask already allowed: a peer that never reads and -// never answers is given up on at the context's deadline, not later (novox/hq issue 444). -func TestAFailedWriteWaitsNoLongerThanTheDeadline(t *testing.T) { - socket := filepath.Join(t.TempDir(), "cli.sock") - l, err := net.Listen("unix", socket) - if err != nil { - t.Skipf("no unix socket here: %v", err) - } - t.Cleanup(func() { l.Close() }) - held := make(chan net.Conn, 1) - go func() { - conn, err := l.Accept() - if err != nil { - return - } - held <- conn // neither read nor answered, nor closed, until the test ends - }() - t.Cleanup(func() { - select { - case c := <-held: - c.Close() - default: - } - }) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - start := time.Now() - _, err = Ask(ctx, socket, aLineTheSocketCannotHold()) - waited := time.Since(start) - if err == nil { - t.Fatal("a peer that never read nor answered gave no error") - } - if !errors.Is(err, os.ErrDeadlineExceeded) { - t.Fatalf("the error is not the deadline: %v", err) - } - if waited > time.Second { - t.Fatalf("waited %s with a deadline of 50ms", waited) - } -} diff --git a/go/cmd/mesh-hand-compose/main.go b/go/cmd/mesh-hand-compose/main.go deleted file mode 100644 index 624b0b2..0000000 --- a/go/cmd/mesh-hand-compose/main.go +++ /dev/null @@ -1,210 +0,0 @@ -// mesh-hand-compose composes one module for one node by hand, from the inputs the controller keeps, and signs it with the -// operator's key, for the hand path of novox/hq ADR 0539 clause 10: a fingerprint the controller never composed (a -// hand build), while the controller cannot compose its own fix. It composes with mesh-sdk's composition, the one the -// controller writes with (compose.Compositions), and prints the composition, its key and its digest; only with -write -// does it write them, with the operator's credential: the composition into the node's bucket composed-, each -// file over 64 KiB into the node's object bucket, and the digest into mesh-controller_composed. The want is the -// operator's next act, naming that digest; each act is recorded in the hand-act log. -// -// mesh-hand-compose -input -node -module -fp -key -// [-write -nats -creds ] -// -// The inputs are a compose.Input as JSON. The key is the operator's ed25519 private key, PKCS#8 in PEM; the -// node-engine trusts it beside the controller's (ADR 0539 clause 3). -package main - -import ( - "context" - "crypto/ed25519" - "crypto/x509" - "encoding/json" - "encoding/pem" - "errors" - "flag" - "fmt" - "io" - "os" - "time" - - "github.com/nats-io/nats.go" - "github.com/nats-io/nats.go/jetstream" - - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/kvlimit" -) - -func main() { - if err := run(os.Args[1:], os.Stdout); err != nil { - fmt.Fprintln(os.Stderr, "mesh-hand-compose:", err) - os.Exit(1) - } -} - -// printed is what mesh-hand-compose prints: where the composition goes, its digest, the composition, and whether it was -// written. -type printed struct { - Bucket string `json:"bucket"` - Key string `json:"key"` - Digest string `json:"digest"` - Composition json.RawMessage `json:"composition"` - Objects []string `json:"objects,omitempty"` - Written bool `json:"written"` - Next string `json:"next"` -} - -func run(args []string, stdout io.Writer) error { - fs := flag.NewFlagSet("mesh-hand-compose", flag.ContinueOnError) - input := fs.String("input", "", "the controller's kept inputs, a compose.Input as JSON") - node := fs.String("node", "", "the node to compose for") - module := fs.String("module", "", "the module to compose") - fp := fs.String("fp", "", "the module's fingerprint, src2: and 64 hex digits") - keyFile := fs.String("key", "", "the operator's ed25519 private key, PKCS#8 in PEM") - write := fs.Bool("write", false, "write the composition, its files and its digest, with the operator's credential") - url := fs.String("nats", nats.DefaultURL, "with -write: the bus") - creds := fs.String("creds", "", "with -write: the operator's credential file") - if err := fs.Parse(args); err != nil { - return err - } - switch { - case *input == "" || *node == "" || *module == "" || *fp == "": - return errors.New("-input, -node, -module and -fp are each required") - case *keyFile == "": - return errors.New("-key is required: a composition made by hand is signed by the operator's key, and the node-engine applies no other") - case *write && *creds == "": - return errors.New("-write needs -creds: the operator's credential is the only hand writer of a composition") - } - key, err := readKey(*keyFile) - if err != nil { - return err - } - key2, err := delivery.ComposedKey(*node, *module, *fp) - if err != nil { - return err - } - bucket, err := delivery.ComposedBucketFor(*node) - if err != nil { - return err - } - raw, err := os.ReadFile(*input) - if err != nil { - return err - } - var in compose.Input - if err := json.Unmarshal(raw, &in); err != nil { - return fmt.Errorf("the inputs in %s are not a compose.Input: %w", *input, err) - } - all, _, err := compose.Compositions(in, *node, map[string]string{*module: *fp}, compose.Reading) - if err != nil { - return err - } - c, ok := all[key2] - if !ok { - return fmt.Errorf("%s does not run %s: its composition holds none of it", *node, *module) - } - c, objects, err := c.ByDigest() - if err != nil { - return err - } - if c, err = delivery.SignComposed(c, key); err != nil { - return err - } - encoded, err := delivery.EncodeComposed(c) - if err != nil { - return err - } - digest, err := delivery.ComposedDigestOf(c) - if err != nil { - return err - } - out := printed{Bucket: bucket, Key: key2, Digest: digest, Composition: encoded, - Next: fmt.Sprintf("write want.%s.%s naming fp %s and composed %s with the operator's credential, and record each act in the hand-act log", *node, *module, *fp, digest)} - for d := range objects { - out.Objects = append(out.Objects, d) - } - if *write { - if err := writeIt(*url, *creds, *node, bucket, key2, encoded, digest, objects); err != nil { - return err - } - out.Written = true - } - enc := json.NewEncoder(stdout) - enc.SetIndent("", " ") - return enc.Encode(out) -} - -func readKey(path string) (ed25519.PrivateKey, error) { - raw, err := os.ReadFile(path) - if err != nil { - return nil, err - } - block, _ := pem.Decode(raw) - if block == nil { - return nil, fmt.Errorf("%s holds no PEM block", path) - } - parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) - if err != nil { - return nil, fmt.Errorf("%s: %w", path, err) - } - key, ok := parsed.(ed25519.PrivateKey) - if !ok { - return nil, fmt.Errorf("%s is not an ed25519 key", path) - } - return key, nil -} - -// writeIt writes the files by digest, then the composition, then its digest: a reader that finds the digest finds the -// composition and its files already there. -func writeIt(url, creds, node, bucket, key string, encoded []byte, digest string, objects map[string][]byte) error { - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - nc, err := nats.Connect(url, nats.UserCredentials(creds), nats.Name("mesh-hand-compose")) - if err != nil { - return err - } - defer nc.Close() - js, err := jetstream.New(nc) - if err != nil { - return err - } - if len(objects) > 0 { - filesBucket, err := declaration.FileBucketFor(node) - if err != nil { - return err - } - store, err := js.ObjectStore(ctx, filesBucket) - if err != nil { - return fmt.Errorf("the node's object bucket %s: %w", filesBucket, err) - } - for d, body := range objects { - name, err := declaration.ObjectName(d) - if err != nil { - return err - } - if _, err := store.PutBytes(ctx, name, body); err != nil { - return fmt.Errorf("the file %s: %w", d, err) - } - } - } - composed, err := js.KeyValue(ctx, bucket) - if err != nil { - return fmt.Errorf("the bucket %s: %w", bucket, err) - } - if _, err := kvlimit.Put(ctx, composed, key, encoded); err != nil { - return fmt.Errorf("%s in %s: %w", key, bucket, err) - } - value, err := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: digest, Judgement: delivery.JudgedCannot, - Words: "composed by hand with mesh-hand-compose (ADR 0539 clause 10): the operator writes the want"}) - if err != nil { - return err - } - digests, err := js.KeyValue(ctx, delivery.ControllerComposedBucket) - if err != nil { - return fmt.Errorf("the bucket %s: %w", delivery.ControllerComposedBucket, err) - } - if _, err := kvlimit.Put(ctx, digests, key, value); err != nil { - return fmt.Errorf("%s in %s: %w", key, delivery.ControllerComposedBucket, err) - } - return nil -} diff --git a/go/cmd/mesh-hand-compose/main_test.go b/go/cmd/mesh-hand-compose/main_test.go deleted file mode 100644 index 96ffd48..0000000 --- a/go/cmd/mesh-hand-compose/main_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package main - -import ( - "bytes" - "crypto/ed25519" - "crypto/rand" - "crypto/x509" - "encoding/base64" - "encoding/json" - "encoding/pem" - "os" - "path/filepath" - "sort" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// aMesh is the Input of mesh-controller's aMesh, as mesh-sdk's compose tests make it (compose/fixture_test.go): two -// machines on the private network, the module the mesh provides for itself assigned to both. The manifest is the one -// the controller provides (overlay.Manifest), read back as the catalogue reads it. -func aMesh(t *testing.T) compose.Input { - t.Helper() - var m catalogue.Manifest - b, _ := json.Marshal(overlay.Manifest()) - if err := json.Unmarshal(b, &m); err != nil { - t.Fatal(err) - } - in := compose.Input{Catalogue: map[string]catalogue.Manifest{overlay.Name: m}} - for i, name := range []string{"anchor", "laptop"} { - pub, _, _ := ed25519.GenerateKey(rand.Reader) - in.Nodes = append(in.Nodes, compose.Node{Name: name, - Place: compose.Place{Key: base64.StdEncoding.EncodeToString(pub), Endpoint: name + ".example:51820", Site: "here", Hub: name == "anchor", Address: []string{"10.77.0.1", "10.77.0.2"}[i]}, - Assigned: []string{overlay.Name}, Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}}) - } - return in -} - -func writeKey(t *testing.T, dir string) ed25519.PublicKey { - t.Helper() - pub, priv, _ := ed25519.GenerateKey(rand.Reader) - der, err := x509.MarshalPKCS8PrivateKey(priv) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "operator.key"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600); err != nil { - t.Fatal(err) - } - return pub -} - -// 1943/AC5: mesh-hand-compose, given the kept inputs, a node, a module and its fingerprint, prints that module's -// composition for that node signed by the operator's key and its digest: its resources are the controller's for that -// module on that node (compose.Compose), and it verifies against the operator's key. Without --write it writes nothing. -func TestMeshComposeGivesTheControllersResourcesSignedByTheOperator(t *testing.T) { - dir := t.TempDir() - in := aMesh(t) - raw, err := json.Marshal(in) - if err != nil { - t.Fatal(err) - } - input := filepath.Join(dir, "input.json") - if err := os.WriteFile(input, raw, 0o600); err != nil { - t.Fatal(err) - } - operator := writeKey(t, dir) - fp := delivery.SourcePrefix2 + strings.Repeat("a", 64) - - var out bytes.Buffer - if err := run([]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", fp, - "-key", filepath.Join(dir, "operator.key")}, &out); err != nil { - t.Fatal(err) - } - var printed struct { - Key string `json:"key"` - Bucket string `json:"bucket"` - Digest string `json:"digest"` - Composition json.RawMessage `json:"composition"` - Written bool `json:"written"` - } - if err := json.Unmarshal(out.Bytes(), &printed); err != nil { - t.Fatalf("%s: %v", out.String(), err) - } - c, err := delivery.DecodeComposed(printed.Composition) - if err != nil { - t.Fatal(err) - } - if digest, err := delivery.VerifyComposed(c, []ed25519.PublicKey{operator}); err != nil || digest != printed.Digest { - t.Errorf("the composition verifies %q, %v against the operator's key; printed %s", digest, err, printed.Digest) - } - wantKey, _ := delivery.ComposedKey("laptop", overlay.Name, fp) - if printed.Key != wantKey || printed.Bucket != "composed-laptop" || printed.Written { - t.Errorf("printed key %s in %s, written %v", printed.Key, printed.Bucket, printed.Written) - } - - d, _, err := compose.Compose(in, "laptop") - if err != nil { - t.Fatal(err) - } - var want, got []string - for _, r := range d.Resources { - want = append(want, r["id"].(string)) - } - for _, r := range c.Resources { - var head struct{ ID string } - _ = json.Unmarshal(r, &head) - got = append(got, head.ID) - } - sort.Strings(want) - sort.Strings(got) - if len(got) == 0 || strings.Join(got, " ") != strings.Join(want, " ") { - t.Errorf("mesh-hand-compose gives %v, the controller's composition %v", got, want) - } -} - -// 1943/AC5: mesh-hand-compose refuses what it cannot compose, naming why: no key, a fingerprint not of the new form, a -// module the node does not run, a node the mesh does not have. -func TestMeshComposeRefuses(t *testing.T) { - dir := t.TempDir() - raw, _ := json.Marshal(aMesh(t)) - input := filepath.Join(dir, "input.json") - _ = os.WriteFile(input, raw, 0o600) - writeKey(t, dir) - key := filepath.Join(dir, "operator.key") - fp := delivery.SourcePrefix2 + strings.Repeat("a", 64) - for _, c := range []struct { - args []string - why string - }{ - {[]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", fp}, "-key"}, - {[]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", "src2:1", "-key", key}, "fingerprint"}, - {[]string{"-input", input, "-node", "laptop", "-module", "mesh-mail", "-fp", fp, "-key", key}, "does not run mesh-mail"}, - {[]string{"-input", input, "-node", "nowhere", "-module", overlay.Name, "-fp", fp, "-key", key}, "nowhere"}, - {[]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", fp, "-key", key, "-write"}, "-creds"}, - } { - var out bytes.Buffer - if err := run(c.args, &out); err == nil || !strings.Contains(err.Error(), c.why) { - t.Errorf("mesh-hand-compose %v = %v, want a refusal saying %q", c.args, err, c.why) - } - } -} diff --git a/go/compose/composed.go b/go/compose/composed.go index fd15ffe..07258d3 100644 --- a/go/compose/composed.go +++ b/go/compose/composed.go @@ -18,7 +18,7 @@ import ( // the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own // (`.node`), by key (delivery.ComposedKey). Each resource says its module, whatever the node reported it reads. // A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module -// (mesh-hand-compose) gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the +// gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the // private network's always), which fps names at no fingerprint of the new form: its resources go into the node's own, // their module taken off and their ids still naming it, and its filled paths with them, as the controller's composed // writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are not diff --git a/go/delivery/assigner.go b/go/delivery/assigner.go index 5587977..1c9cdf0 100644 --- a/go/delivery/assigner.go +++ b/go/delivery/assigner.go @@ -140,7 +140,7 @@ const ( RuleStep = 4 // at most step other nodes are ahead (step 0: no limit, ADR 0528 §3) RuleProviders = 5 // every provider the module requires runs healthy on that node, or at its canary RuleGrant = 6 // the module's credential for F exists (grants..) - RuleComposed = 7 // the module's composition for that node at F exists (mesh-controller_composed, ADR 0539 §6) + RuleComposed = 7 // the module's composition for that node at F exists (ADR 0539 §6) // RuleProvisions: every provision the module requires on that node is provided (novox/hq ADR 0570 §4): a want's // words name the key and its state that hold it, as "the database of keycloak on novox is asked of postgres". RuleProvisions = 8 diff --git a/go/delivery/assigner_test.go b/go/delivery/assigner_test.go index 77d7d2a..e11920d 100644 --- a/go/delivery/assigner_test.go +++ b/go/delivery/assigner_test.go @@ -268,58 +268,39 @@ func TestCompositionVerdictRefused(t *testing.T) { // 1394/B1b: a grant key round-trips; a want or held key is refused, naming it; a grant round-trips and one naming an // empty account is refused. -func TestControllerGrant(t *testing.T) { +func TestTheGrantsIssued(t *testing.T) { fp := fp2of('c') - key, err := ControllerGrantKey("mesh-delivery-assigner", fp) + key, err := PartGrantKey("mesh-delivery-assigner", fp) if err != nil || key != "mesh-delivery-assigner."+strings.Repeat("c", 64) { - t.Fatalf("ControllerGrantKey = %q, %v", key, err) + t.Fatalf("PartGrantKey = %q, %v", key, err) } - module, back, err := ParseControllerGrantKey(key) + module, back, err := ParsePartGrantKey(key) if err != nil || module != "mesh-delivery-assigner" || back != fp { - t.Errorf("ParseControllerGrantKey = %q, %q, %v", module, back, err) + t.Errorf("ParsePartGrantKey = %q, %q, %v", module, back, err) } held, _ := HeldKey("mesh-mail", fp) for _, bad := range []string{"want.ace.mesh-mail", held, "ace.mesh-mail", ""} { - if _, _, err := ParseControllerGrantKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ParseControllerGrantKey(%q) = %v, want a refusal naming it", bad, err) + if _, _, err := ParsePartGrantKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParsePartGrantKey(%q) = %v, want a refusal naming it", bad, err) } } - for _, g := range []ControllerGrant{{}, {Accounts: []string{"UABC", "UDEF"}}} { - data, err := EncodeControllerGrant(g) + for _, g := range []Grant{{}, {Accounts: []string{"UABC", "UDEF"}}} { + data, err := EncodeGrant(g) if err != nil { t.Fatal(err) } - got, err := DecodeControllerGrant(data) + got, err := DecodeGrant(data) g.V = Version - if err != nil || !SameControllerGrant(got, g) { + if err != nil || !SameGrant(got, g) { t.Errorf("grant %+v came back %+v, %v", g, got, err) } } - if _, err := EncodeControllerGrant(ControllerGrant{Accounts: []string{""}}); err == nil { + if _, err := EncodeGrant(Grant{Accounts: []string{""}}); err == nil { t.Errorf("a grant naming an empty account was accepted") } - // The grants part's keys are the controller's: the same key round-trips; the source is the part's bucket only for a - // handed-away lease (issue 774). - if pk, err := PartGrantKey("mesh-delivery-assigner", fp); err != nil || pk != key { - t.Errorf("PartGrantKey = %q, %v, want the controller's %q", pk, err, key) - } - if m, f, err := ParsePartGrantKey(key); err != nil || m != "mesh-delivery-assigner" || f != fp { - t.Errorf("ParsePartGrantKey = %q, %q, %v", m, f, err) - } + g := Grant{Accounts: []string{"ace.mesh-build-go", "shanks.mesh-mail.reader"}} for _, c := range []struct { - away bool - bucket, read string - }{ - {false, "mesh-controller_grants", "mesh-controller.grants"}, - {true, "mesh-grants_grants", "mesh-grants.grants"}, - } { - if b, r := GrantSource(c.away); b != c.bucket || r != c.read { - t.Errorf("GrantSource(%v) = %q, %q, want %q, %q", c.away, b, r, c.bucket, c.read) - } - } - g := ControllerGrant{Accounts: []string{"ace.mesh-build-go", "shanks.mesh-mail.reader"}} - for _, c := range []struct { - g ControllerGrant + g Grant node, module string names bool }{ @@ -327,7 +308,7 @@ func TestControllerGrant(t *testing.T) { {g, "shanks", "mesh-mail", true}, {g, "g14", "mesh-build-go", false}, {g, "ace", "mesh-build", false}, - {ControllerGrant{}, "novox", "nats", true}, + {Grant{}, "novox", "nats", true}, } { if got := c.g.NamesNode(c.node, c.module); got != c.names { t.Errorf("%v names %s.%s: %v, want %v", c.g.Accounts, c.node, c.module, got, c.names) diff --git a/go/delivery/builder.go b/go/delivery/builder.go index 447196d..c940019 100644 --- a/go/delivery/builder.go +++ b/go/delivery/builder.go @@ -10,8 +10,8 @@ import ( // The builder's keys (novox/hq ADR 0429 clauses 5 and 16, ADR 0437 clause 5, ADR 0405 clauses 2 and 7, ADR 0453 // clause 4): a build is `.` in the builder's bucket, one key per fingerprint, since equal // fingerprints are equal content and the same source is never built twice. They are written only by the builder part -// from R4c and read by the checker, the assigner and the view, and from R4e by the controller. They are not the -// controller's build records (buildrecord.go: BuildsBucket, BuildKey), which stay as they are. +// from R4c and read by the checker, the assigner and the view. They are not the ended controller's build records +// (buildrecord.go: BuildKey). // // **The rules its writer keeps.** A builder claims a fingerprint that has no build key by creating it queued or // building, a create that fails when the key exists, and moves it by compare-and-set; a builder reclaims only a build diff --git a/go/delivery/builder_test.go b/go/delivery/builder_test.go index cdcb8f5..de480ae 100644 --- a/go/delivery/builder_test.go +++ b/go/delivery/builder_test.go @@ -166,10 +166,7 @@ func TestBuilderLayoutDoesNotCollideWithTheControllers(t *testing.T) { if BuilderBuildsBucket != "mesh-delivery-builder_builds" || BuilderBuildsHistory != 5 || BuilderModule != "mesh-delivery-builder" { t.Errorf("bucket %s history %d module %s", BuilderBuildsBucket, BuilderBuildsHistory, BuilderModule) } - if BuilderBuildsBucket == BuildsBucket || BuilderModule == "mesh-controller" { - t.Error("the builder's bucket is the controller's") - } - if BuildsBucket != "mesh-controller_builds" || BuildKey("m", "7") != "m.build.7" || NewestKey("m") != "m.newest" { + if BuildKey("m", "7") != "m.build.7" || NewestKey("m") != "m.newest" { t.Error("the controller's layout changed") } // the two key forms are never read as each other diff --git a/go/delivery/buildrecord.go b/go/delivery/buildrecord.go index 1bccc22..08fd35f 100644 --- a/go/delivery/buildrecord.go +++ b/go/delivery/buildrecord.go @@ -2,8 +2,8 @@ package delivery import "time" -// BuildRecord is the layout mesh-controller internal/chain/records.go Build writes (line 91), the fields a reader outside -// the controller reads (novox/hq ADR 0437 §4 §5, issue 1223). The controller's record carries more; a decode ignores +// BuildRecord is the layout the ended controller's chain wrote (mesh-controller internal/chain/records.go Build, line 91), +// the fields a reader outside it read (novox/hq ADR 0437 §4 §5, issue 1223). The controller's record carries more; a decode ignores // what is not here. Path is the module's directory in its repository, as the controller writes it (Build.Path, records.go:100, // set from the build request trimmed of slashes at internal/inventory/chain_requests.go:19); empty for a module whose // manifest is the repository's root. SourceInputs has no json keys of its own: it is written under its Go field names, as the controller @@ -32,11 +32,6 @@ type Artifact struct { Reference string `json:"reference"` } -// BuildsBucket is the bucket the builds are kept in: mesh-controller internal/broker/controller_buckets.go:101, -// BucketName(ControllerSeat, ChainBuilds), with ControllerSeat "mesh-controller" (internal/broker/streams.go:359) and -// ChainBuilds "builds" (controller_buckets.go:113). -const BuildsBucket = "mesh-controller_builds" - // BuildKey is a build's record under its module, a build that knew none under the request's key: copied from // mesh-controller internal/chain/keys.go:35-40 (BuildKey, with RequestKey at line 42). func BuildKey(module, id string) string { diff --git a/go/delivery/buildrecord_test.go b/go/delivery/buildrecord_test.go index 2a1d4f0..a50ad6d 100644 --- a/go/delivery/buildrecord_test.go +++ b/go/delivery/buildrecord_test.go @@ -114,9 +114,6 @@ func TestBuildRecordWithoutInputs(t *testing.T) { // 1223/AC2 func TestBuildKeys(t *testing.T) { // 1223/AC3 // Literal values of mesh-controller internal/broker/controller_buckets.go:101 (BucketName(ControllerSeat, ChainBuilds) // is "mesh-controller_builds") and internal/chain/keys.go:35-44. - if BuildsBucket != "mesh-controller_builds" { - t.Errorf("BuildsBucket = %q", BuildsBucket) - } if got := BuildKey("build-agent", "build-1"); got != "build-agent.build.build-1" { t.Errorf("BuildKey = %q", got) } diff --git a/go/delivery/composed.go b/go/delivery/composed.go index fa771cf..659a19b 100644 --- a/go/delivery/composed.go +++ b/go/delivery/composed.go @@ -7,29 +7,17 @@ import ( "strings" ) -// The controller's compositions (novox/hq ADR 0539 clauses 1 to 4). A **composition** is one module's resources -// composed for one node at one fingerprint, written by the controller, which keeps the inputs. It lives in a bucket of -// each node, `composed-`, which only that node's node-engine reads (a composition can carry a sealed value), under -// `..`, and the node's own resources (adoption, firewall openings, accounts, the -// resolver, identities and groups) under `.node`. Beside it, under the same key in `mesh-controller_composed`, -// which the assigner reads, the controller writes the composition's digest and its judgement of the move from the -// composition the node runs now (ComposedDigest). The controller writes both by compare-and-set while it holds the -// lease; the operator's credential is a second writer on the hand path alone (clause 10, mesh-hand-compose). -// -// A composition is derived state (clause 4): its key and its digest are deleted by the controller when its fingerprint -// is no longer among the module's five newest and no want names it, and each bucket keeps five revisions of a key. +// The compositions (novox/hq ADR 0539 clauses 1 to 4). A **composition** is one module's resources composed for one node +// at one fingerprint: a node composes its own (ADR 0547 §2), and the ended controller wrote them into a bucket of each +// node, `composed-`, which nothing writes since it ended (ADR 0590), under `..`, and +// the node's own resources (adoption, firewall openings, accounts, the resolver, identities and groups) under +// `.node`. A composition's digest and its judgement of the move from the composition the node runs now is +// ComposedDigest. const ( // ComposedBucketPrefix starts the name of a node's bucket of compositions, ComposedBucketFor(node). ComposedBucketPrefix = "composed-" // ComposedHistory is the revisions a node's composition bucket keeps per key (ADR 0539 clause 4). ComposedHistory = 5 - // ControllerModule is the module of the controller, the writer of the compositions. - ControllerModule = "mesh-controller" - // ControllerComposedBucket is the controller's bucket of each composition's digest and judgement, `_`, read by the assigner (ADR 0539 clauses 2 and 6). - ControllerComposedBucket = ControllerModule + "_composed" - // ControllerComposedHistory is the revisions that bucket keeps per key. - ControllerComposedHistory = 5 // ComposedKeep is how many of a module's newest fingerprints keep their compositions though no want names them // (ADR 0539 clause 4). ComposedKeep = 5 @@ -142,8 +130,8 @@ func checkDigest(digest, of string) error { // // Policy is the module's upgrade policy as the controller holds it (ADR 0236, ADR 0539 clause 14), with PolicyFrom // where it comes from and PolicyWhy why: the assigner holds a move of a module of policy record from it, the operator's -// own choice (`upgrade` with policy) included. Rewritten when the policy changes. Empty on a composition made by hand -// (mesh-hand-compose), whose want the operator writes. +// own choice (`upgrade` with policy) included. Rewritten when the policy changes. Empty on a composition made by hand, +// whose want the operator wrote. type ComposedDigest struct { V int `json:"v"` Digest string `json:"digest"` diff --git a/go/delivery/composed_signature.go b/go/delivery/composed_signature.go index ad3d042..8786c71 100644 --- a/go/delivery/composed_signature.go +++ b/go/delivery/composed_signature.go @@ -6,9 +6,8 @@ import ( "fmt" ) -// A composition's signature (novox/hq ADR 0539 clause 3): the controller signs each composition it writes with its -// key, and the operator signs one made by hand with mesh-hand-compose with theirs. The node-engine trusts exactly these two -// signers and applies nothing of a composition whose signature fails or whose signer is neither. The signature is an +// A composition's signature (novox/hq ADR 0539 clause 3): the signer of a composition signs it with its key (the ended +// controller signed those it wrote, the operator one made by hand). The node-engine trusts the signers it is given and applies nothing of a composition whose signature fails or whose signer is neither. The signature is an // ed25519 signature, as a declaration's is, of the composition's digest (ComposedDigestOf) after a context line, so a // signature made for anything else never verifies here. const composedSigningContext = "novox composition v1\n" @@ -84,7 +83,7 @@ func VerifyComposed(c Composed, trusted []ed25519.PublicKey) (string, error) { } } if key == nil { - return "", fmt.Errorf("delivery: the composition of %q on %s is signed by %s, which is not a trusted signer (the controller's key or the operator's)", + return "", fmt.Errorf("delivery: the composition of %q on %s is signed by %s, which is not a trusted signer (the vault's key or the operator's)", c.Module, c.Node, c.Signature.Signer) } digest, err := ComposedDigestOf(c) diff --git a/go/delivery/composed_signature_test.go b/go/delivery/composed_signature_test.go index 3098e27..3a531d0 100644 --- a/go/delivery/composed_signature_test.go +++ b/go/delivery/composed_signature_test.go @@ -108,7 +108,7 @@ func TestACompositionIsSignedByAKeptKey(t *testing.T) { // signers a node trusts and names which; an unknown key and another kind's context are refused. vaultPub, vault := aSigningKey(t) controllerPub, controller := aSigningKey(t) - trusted := []Signer{{Name: SignerController, Key: controllerPub}, {Name: SignerVault, Key: vaultPub}} + trusted := []Signer{{Name: "operator", Key: controllerPub}, {Name: SignerVault, Key: vaultPub}} enrol, _ := SignContext(KindEnrolment) certCtx, _ := SignContext(KindNodeCertificate) msg := []byte("the reply") @@ -120,7 +120,7 @@ func TestACompositionIsSignedByAKeptKey(t *testing.T) { want string }{ {"the vault's key verifies", vault, enrol, enrol, SignerVault}, - {"the controller's key verifies", controller, enrol, enrol, SignerController}, + {"the controller's key verifies", controller, enrol, enrol, "operator"}, {"an unknown key is refused", other, enrol, enrol, ""}, {"the wrong context is refused", vault, certCtx, enrol, ""}, } { @@ -148,7 +148,7 @@ func TestACompositionIsSignedByAKeptKey(t *testing.T) { } } if got := Trusts(trusted, nil); len(got) != 2 || !TrustsKey(got, SignerVault, SignerFingerprint(vaultPub)) || - !TrustsKey(got, SignerController, SignerFingerprint(controllerPub)) { + !TrustsKey(got, "operator", SignerFingerprint(controllerPub)) { t.Errorf("trusts: %v", got) } // A certificate the vault's authority signs for a node's name and serving key checks against a bundle holding that diff --git a/go/delivery/delivery.go b/go/delivery/delivery.go index 595bcc2..3b72bb7 100644 --- a/go/delivery/delivery.go +++ b/go/delivery/delivery.go @@ -367,8 +367,7 @@ type Have struct { // holds now. The bus reloads the file itself when its digest changes (the module's entrypoint), so Digest is the list // the bus loads. Wanted differs from Digest only while a write failed, and Error says why. type UserListWritten struct { - // Source is the key the list was taken from: the controller's composition of the bus module for this node - // (`..`), or the grants part's `user-list` once it took over. + // Source is the key the list was taken from: the grants part's `user-list` (GrantsUserListSource). Source string `json:"source"` Revision uint64 `json:"revision"` // Wanted is the digest of the list the source holds, `sha256:`. @@ -378,8 +377,8 @@ type UserListWritten struct { Users int `json:"users"` At time.Time `json:"at"` Error string `json:"error,omitempty"` - // Follows is which list the node follows now (FollowController or FollowGrants, novox/hq ADR 0547 clause 8), empty - // from a node-engine before the switch. + // Follows is which list the node follows now (FollowGrants, novox/hq ADR 0547 clause 8), empty from a node-engine + // before the switch. Follows string `json:"follows,omitempty"` // Key is the bus node's sealing key, its public half as SPKI PEM: the key the grants part seals its list to // (clause 7). Public, as the vault holds it. @@ -417,19 +416,19 @@ type HaveNode struct { // its shadow composer whenever an answer changes, removed when the candidate key goes. The node applies nothing of a // candidate. Candidates map[string]CandidateAnswer `json:"candidates,omitempty"` - // Composes is whose compositions the node applies: its own (ComposesOwn, placement places it) or the - // controller's (ComposesController); ComposesWhy says why in words. Written by the node agent at each start, so a - // node returning to the controller's compositions when its placement key is deleted says so (issue 1126). Empty - // from an engine older than the field. + // Composes is whose compositions the node applies: its own (ComposesOwn, placement places it), or none, empty, with + // ComposesWhy saying why in words. Written by the node agent at each start (issue 1126). An engine from before the + // controller ended may still say `controller` (composedByTheEndedController), which is read and never written. Composes string `json:"composes,omitempty"` ComposesWhy string `json:"composes-why,omitempty"` } -// Whose compositions a node applies (HaveNode.Composes). -const ( - ComposesOwn = "own" - ComposesController = "controller" -) +// ComposesOwn is a node applying its own compositions (HaveNode.Composes). +const ComposesOwn = "own" + +// composedByTheEndedController is what an engine from before the controller ended (ADR 0590) wrote in Composes while +// it applied the controller's compositions: still read, so its have is not refused, and written by no engine now. +const composedByTheEndedController = "controller" // Presence is whether a node or a part's instance is online; the entry's own time says since when. The watchman // writes it. @@ -526,9 +525,9 @@ func DecodeHave(data []byte) (Have, error) { func (n HaveNode) check() error { switch n.Composes { - case "", ComposesOwn, ComposesController: + case "", ComposesOwn, composedByTheEndedController: default: - return fmt.Errorf("delivery: a node composes %q, neither %s nor %s", n.Composes, ComposesOwn, ComposesController) + return fmt.Errorf("delivery: a node composes %q, not %s", n.Composes, ComposesOwn) } for group, a := range n.Candidates { if group == "" { diff --git a/go/delivery/delivery_test.go b/go/delivery/delivery_test.go index 8bdc8bd..328fad7 100644 --- a/go/delivery/delivery_test.go +++ b/go/delivery/delivery_test.go @@ -242,29 +242,21 @@ func TestRoundTrip(t *testing.T) { t.Fatal(err) } back, err := DecodeGrantsUserList(raw) - if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants || back.LeaseHanded { + if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants { t.Fatalf("decoded %+v, %v", back, err) } - // ADR 0582 clause 3 (issue 774, 774/S3): a key written before the flag existed reads not handed; one written with - // it reads handed. + // Issue 1126 B4a (ADR 0590): a key written with the lease's flag still reads; the controller's list is no source. if old, err := DecodeGrantsUserList([]byte(`{"v":1,"follow":"grants","digest":"","object":"","sealed_to":"",` + - `"node":"novox","revision":3,"users":40,"left_out":[],"at":"2026-10-30T10:00:00Z"}`)); err != nil || old.LeaseHanded { - t.Errorf("a key from before the lease write decodes %+v, %v", old, err) + `"node":"novox","revision":3,"users":40,"left_out":[],"at":"2026-10-30T10:00:00Z","lease-handed":true}`)); err != nil || old.Revision != 3 { + t.Errorf("a key with the lease's flag decodes %+v, %v", old, err) } - g.LeaseHanded = true - if raw, err := EncodeGrantsUserList(g); err != nil || !bytes.Contains(raw, []byte(`"lease-handed":true`)) { - t.Errorf("the handed flag is written as %s, %v", raw, err) - } else if back, err := DecodeGrantsUserList(raw); err != nil || !back.LeaseHanded { - t.Errorf("the handed flag reads back %+v, %v", back, err) - } - g.LeaseHanded = false - for _, bad := range []GrantsUserList{{Follow: "both", Digest: digest, Object: g.Object}, + for _, bad := range []GrantsUserList{{Follow: "both", Digest: digest, Object: g.Object}, {Follow: "controller"}, {Follow: FollowGrants, Digest: digest, Object: "other"}, {Follow: FollowGrants, Digest: "md5:x", Object: "md5:x"}} { if _, err := EncodeGrantsUserList(bad); err == nil { t.Errorf("%+v was written", bad) } } - if _, err := EncodeGrantsUserList(GrantsUserList{Follow: FollowController}); err != nil { + if _, err := EncodeGrantsUserList(GrantsUserList{Follow: FollowGrants}); err != nil { t.Errorf("a switch with no list yet refused: %v", err) } object, err := SealUserList(list, node.PublicKey) diff --git a/go/delivery/grants.go b/go/delivery/grants.go index 9da49a0..3ed03e6 100644 --- a/go/delivery/grants.go +++ b/go/delivery/grants.go @@ -7,41 +7,44 @@ import ( "strings" ) -// The controller's grants (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6): when the controller has issued a module's -// bus credential for a fingerprint, it writes `.` in its bucket grants. The assigner reads -// it: a node's want moves to a fingerprint only once its grant exists. Only the controller writes the bucket. -// -// The names say Controller, since the catalogue's GrantKey (catalogue/streams.go) is a stream's grant pattern, unrelated. +// **The grants issued** (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, ADR 0547 §4, §5, ADR 0590): when the grants +// part has issued a module's bus credentials for a fingerprint, it writes `.` in its state +// `grants` (PartGrantsBucket). The assigner reads it: a node's want moves to a fingerprint only once its grant exists. +// Only the grants part writes the bucket. const ( - // ControllerGrantsBucket is the controller's bucket of issued credentials by fingerprint. - ControllerGrantsBucket = "mesh-controller_grants" - // ControllerGrantsHistory is the revisions the bucket keeps per key. - ControllerGrantsHistory = 5 + // PartGrantsState is the grants part's state of issued credentials by fingerprint, as its manifest names it. + PartGrantsState = "grants" + // PartGrantsBucket is that state's bucket, `_`. + PartGrantsBucket = "mesh-grants_" + PartGrantsState + // PartGrantsRead is that state as a reader names it in `reads`. + PartGrantsRead = "mesh-grants." + PartGrantsState + // PartGrantsHistory is the revisions the bucket keeps per key. + PartGrantsHistory = 5 ) -// ControllerGrantKey is the key, in ControllerGrantsBucket, of a module's credential issued for a fingerprint: -// `.`, the hex being the fingerprint's key part (FingerprintKeyPart). -func ControllerGrantKey(module, fingerprint string) (string, error) { +// PartGrantKey is the key, in PartGrantsBucket, of a module's credential issued for a fingerprint: `.`, +// the hex being the fingerprint's key part (FingerprintKeyPart). +func PartGrantKey(module, fingerprint string) (string, error) { return BuilderBuildKey(module, fingerprint) } -// ParseControllerGrantKey is the inverse of ControllerGrantKey; it refuses a want or held key, naming it. -func ParseControllerGrantKey(key string) (module, fingerprint string, err error) { +// ParsePartGrantKey is the inverse of PartGrantKey; it refuses a want or held key, naming it. +func ParsePartGrantKey(key string) (module, fingerprint string, err error) { if module, fingerprint, err = ParseBuilderBuildKey(key); err != nil { return "", "", fmt.Errorf("delivery: %q is not a grant key, .: %w", key, err) } return module, fingerprint, nil } -// ControllerGrant is the value of a grant key: the bus accounts (their public keys) the module's processes were issued +// Grant is the value of a grant key: the bus accounts (their public keys) the module's processes were issued // for that fingerprint, none for a module whose grants the tool runner's account carries, so a reissue for another // account is a new revision. The entry's own time says when it was issued. -type ControllerGrant struct { +type Grant struct { V int `json:"v"` Accounts []string `json:"accounts,omitempty"` } -func (g ControllerGrant) check() error { +func (g Grant) check() error { for _, a := range g.Accounts { if strings.TrimSpace(a) == "" || strings.ContainsAny(a, " \t\n") { return fmt.Errorf("delivery: a grant names an empty or spaced account %q", a) @@ -50,8 +53,8 @@ func (g ControllerGrant) check() error { return nil } -// EncodeControllerGrant is g as JSON at Version; it refuses an empty or spaced account. -func EncodeControllerGrant(g ControllerGrant) ([]byte, error) { +// EncodeGrant is g as JSON at Version; it refuses an empty or spaced account. +func EncodeGrant(g Grant) ([]byte, error) { if err := g.check(); err != nil { return nil, err } @@ -59,26 +62,26 @@ func EncodeControllerGrant(g ControllerGrant) ([]byte, error) { return json.Marshal(g) } -// DecodeControllerGrant reads a grant; it refuses an unknown version (ErrUnknownVersion) and an empty or spaced account. -func DecodeControllerGrant(data []byte) (ControllerGrant, error) { - var g ControllerGrant +// DecodeGrant reads a grant; it refuses an unknown version (ErrUnknownVersion) and an empty or spaced account. +func DecodeGrant(data []byte) (Grant, error) { + var g Grant if err := decode(data, &g); err != nil { - return ControllerGrant{}, err + return Grant{}, err } if err := g.check(); err != nil { - return ControllerGrant{}, err + return Grant{}, err } return g, nil } -// SameControllerGrant says whether two grants are the same value. -func SameControllerGrant(a, b ControllerGrant) bool { return reflect.DeepEqual(a, b) } +// SameGrant says whether two grants are the same value. +func SameGrant(a, b Grant) bool { return reflect.DeepEqual(a, b) } // NamesNode says whether the grant names module's accounts on node: its own `.` or a process's // `..`. A grant that names no account (a module that does not speak on the bus) names every // node. The assigner wants a node only once the grant names its account (issue 1945), and a put waits for it (ADR // 0546 clause 4). -func (g ControllerGrant) NamesNode(node, module string) bool { +func (g Grant) NamesNode(node, module string) bool { if len(g.Accounts) == 0 { return true } @@ -90,39 +93,3 @@ func (g ControllerGrant) NamesNode(node, module string) bool { } return false } - -// **The grants part's grants** (novox/hq ADR 0547 §4, §5, ADR 0561 §2, §6; issue 774). The grants part writes the same -// keys, with the same value, into its own state `grants` (the bucket PartGrantsBucket), beside the controller's: first in -// shadow, which nobody reads, so a tool can compare the two before anything switches. The assigner reads the part's keys -// only once the controller's lease is handed away (GrantSource), and the controller's until then. -const ( - // PartGrantsState is the grants part's state of issued credentials by fingerprint, as its manifest names it. - PartGrantsState = "grants" - // PartGrantsBucket is that state's bucket, `_`. - PartGrantsBucket = "mesh-grants_" + PartGrantsState - // PartGrantsRead is that state as a reader names it in `reads`. - PartGrantsRead = "mesh-grants." + PartGrantsState - // PartGrantsHistory is the revisions the bucket keeps per key. - PartGrantsHistory = ControllerGrantsHistory -) - -// PartGrantKey is the key, in PartGrantsBucket, of a module's credential issued for a fingerprint: the layout of the -// controller's key (ControllerGrantKey), so a key of one is the key of the other and a compare is by key. -func PartGrantKey(module, fingerprint string) (string, error) { - return ControllerGrantKey(module, fingerprint) -} - -// ParsePartGrantKey is the inverse of PartGrantKey. -func ParsePartGrantKey(key string) (module, fingerprint string, err error) { - return ParseControllerGrantKey(key) -} - -// GrantSource names the bucket the assigner reads grants from, and the `reads` name that grants it: the grants part's -// once the controller's lease is handed away (buscompose.LeaseHandedAway, which refuses every other value of the lease, -// an unreadable one included), the controller's in every other case. -func GrantSource(leaseHandedAway bool) (bucket, read string) { - if leaseHandedAway { - return PartGrantsBucket, PartGrantsRead - } - return ControllerGrantsBucket, "mesh-controller.grants" -} diff --git a/go/delivery/placement_candidate.go b/go/delivery/placement_candidate.go index cef4c32..fe97e98 100644 --- a/go/delivery/placement_candidate.go +++ b/go/delivery/placement_candidate.go @@ -127,10 +127,9 @@ type CandidateAnswer struct { Held []ModuleRefused `json:"held,omitempty"` // Refused is why the node could not compose with the candidate at all; Refusals is then empty. Refused string `json:"refused,omitempty"` - // Returns is, for a proposal that takes the node's placement key away, what the node does then: it returns to the - // controller's compositions (ReturnsToController), as a node with no placement key composes none of its own. Same, Differs, Added and Removed then compare the controller's compositions at - // the node's wants with what it runs, and the verdict is theirs; a want the controller holds no composition for is - // refused (Refused, without Returns). + // Returns is, for a proposal that takes the node's placement key away, what the node does then (ReturnsToNothing): a + // node with no placement key composes nothing (ADR 0590), so Removed names every module it runs and the verdict is + // theirs. Returns string `json:"returns,omitempty"` // Note is what the node says of how it answered, in words, beside the verdict: a pass that ran over a minute, inputs // it waited for (issue 1126: the shadow shows its work). It changes no verdict. @@ -141,8 +140,8 @@ type CandidateAnswer struct { ValuesDiffer int `json:"values_differ,omitempty"` } -// ReturnsToController is what a node answers a proposal that takes its placement key away. -const ReturnsToController = "the proposal takes this node's placement away: it returns to the controller's compositions" +// ReturnsToNothing is what a node answers a proposal that takes its placement key away. +const ReturnsToNothing = "the proposal takes this node's placement away: it composes nothing then" // ModuleDiffers is one module the node runs and the candidate still places whose composition is not the one it runs: // both digests (Composed with the candidate, Running what runs), the first resource that differs by its id, or empty @@ -227,8 +226,7 @@ func (a CandidateAnswer) check() error { return fmt.Errorf("delivery: a candidate the node could not compose names modules") } if a.Returns != "" && (a.Refused != "" || len(a.Refusals) > 0) { - return fmt.Errorf("delivery: a candidate the node returns to the controller's compositions on is compared, never refused: " + - "a composition the controller holds none of is refused without returning") + return fmt.Errorf("delivery: a candidate that takes the node's placement away is compared, never refused") } for _, d := range a.Differs { if d.Module == "" || d.Why == "" { diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go index d4a5f96..6e662c4 100644 --- a/go/delivery/placement_test.go +++ b/go/delivery/placement_test.go @@ -388,8 +388,8 @@ func TestPlacement(t *testing.T) { "a value": {Proposal: id, Revision: 1, Verdict: CandidateRefused, Same: []string{"node"}, Refusals: []ModuleRefused{{Module: "db", Why: "stands in for db/password"}}}, // A head that deletes the node's placement file: the node returns to the controller's compositions (issue 1126, // the seed's revert, mesh-catalog #526). - "returns": {Proposal: id, Revision: 1, Verdict: CandidateSame, Returns: ReturnsToController, Same: []string{"app", "node"}}, - "returns, the controller's differs": {Proposal: id, Revision: 1, Verdict: CandidateDiffers, Returns: ReturnsToController, Same: []string{"node"}, + "returns": {Proposal: id, Revision: 1, Verdict: CandidateSame, Returns: ReturnsToNothing, Same: []string{"app", "node"}}, + "returns, differs": {Proposal: id, Revision: 1, Verdict: CandidateDiffers, Returns: ReturnsToNothing, Same: []string{"node"}, Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}, Removed: []string{"old"}}, } n := HaveNode{Composed: "sha256:" + strings.Repeat("a", 64), Sealed: []string{"box"}, Candidates: answers, @@ -407,11 +407,15 @@ func TestPlacement(t *testing.T) { t.Error("a node's have without its answers counts as the same") } if SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Candidates: answers, - Composes: ComposesController, ComposesWhy: n.ComposesWhy}) { + ComposesWhy: n.ComposesWhy}) { t.Error("a node's have composing otherwise counts as the same") } if _, err := EncodeHaveNode(HaveNode{Composes: "both"}); err == nil { - t.Error("a node composing neither its own nor the controller's was taken") + t.Error("a node composing neither its own nor none was taken") + } + // Issue 1126 B4a: a have an engine from before the controller ended wrote still reads. + if _, err := DecodeHaveNode([]byte(`{"v":1,"composes":"controller"}`)); err != nil { + t.Errorf("an older engine's have is refused: %v", err) } // CompareComposed: the bus's user list is left out on both sides; anything else that differs is named. file := func(id, content string) json.RawMessage { @@ -605,8 +609,8 @@ func TestPlacement(t *testing.T) { "same with an added": {Proposal: "p-x1", Revision: 1, Verdict: CandidateSame, Added: []ModuleAdded{{Module: "a"}}}, "differs refusing": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Removed: []string{"a"}, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}}, "refused and modules": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Refused: "x", Same: []string{"a"}}, - "returns refusing": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToController, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}}, - "returns refused": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToController, Refused: "x"}, + "returns refusing": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToNothing, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}}, + "returns refused": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToNothing, Refused: "x"}, } { if _, err := EncodeHaveNode(HaveNode{Candidates: map[string]CandidateAnswer{"g": a}}); err == nil { t.Errorf("%s: taken", name) diff --git a/go/delivery/signers.go b/go/delivery/signers.go index d98f430..04c86bf 100644 --- a/go/delivery/signers.go +++ b/go/delivery/signers.go @@ -24,9 +24,8 @@ import ( // serving key from its record), and says `signed` naming the caller, the kind, the node and the digest, never the key. // // A node trusts the vault's key through the bus grants alone (the lead's design limit, 2026-10-28): only the vault may -// write `signers`, the same trust the wants and the placement rest on. Until the controller's lease is handed away the -// node trusts the controller's key beside it; from then on the vault's alone (LeaseHandedAway). What a node trusts now -// it states in its record, NodeRecord.Trusts, so the gate before the lease write reads it from the bus. +// write `signers`, the same trust the wants and the placement rest on. A node trusts the vault's key alone since the +// controller ended (ADR 0590). What a node trusts now it states in its record, NodeRecord.Trusts. const ( // VaultModule is the module holding the seat mesh-vault, whose state `signers` is. @@ -54,12 +53,11 @@ const ( // KindAuthority is the vault's own authority certificate, which the vault signs for itself. KindAuthority = "authority" - // SignerController and SignerVault name the two signers a node may trust. - SignerController = "controller" - SignerVault = "vault" + // SignerVault names the signer a node trusts. + SignerVault = "vault" - // CertificateLife is how long a certificate the vault signs for a node lasts: the controller's ten years - // (identity.forever), renewed by asking again, never by a clock running out unseen. + // CertificateLife is how long a certificate the vault signs for a node lasts: ten years, renewed by asking again, + // never by a clock running out unseen. CertificateLife = 10 * 365 * 24 * time.Hour ) @@ -166,7 +164,7 @@ func DecodeSignerAuthority(raw []byte) (SignerAuthority, error) { return a, nil } -// Signer is one key a node trusts, by who holds it (SignerController, SignerVault, or the operator's). +// Signer is one key a node trusts, by who holds it (SignerVault, or the operator's). type Signer struct { Name string Key ed25519.PublicKey diff --git a/go/delivery/userlist.go b/go/delivery/userlist.go index ea27e77..be703a1 100644 --- a/go/delivery/userlist.go +++ b/go/delivery/userlist.go @@ -21,11 +21,10 @@ import ( // list's digest (UserListObjectName). // - It writes the key GrantsUserListKey of its state `user-list` (bucket GrantsUserListBucket): the GrantsUserList // below, which names the object, the list's digest, its revision, its user count and the users left out, and which -// source the bus node follows (Follow: FollowGrants from take-over, FollowController in shadow and after hand-back). -// - The node-engine of the bus node watches that key alone. While it says FollowGrants it opens the object, checks -// the digest, writes the bus's user-list file (mode 0600) only when the file's digest differs, and says what it -// loaded in its have of the bus module (UserListWritten, Source GrantsUserListSource); otherwise it follows the -// controller's compositions as before (clause 8). Never both. +// source the bus node follows (Follow: FollowGrants, the one source since the controller ended, ADR 0590). +// - The node-engine of the bus node watches that key alone. It opens the object, checks the digest, writes the bus's +// user-list file (mode 0600) only when the file's digest differs, and says what it loaded in its have of the bus +// module (UserListWritten, Source GrantsUserListSource). // The names of the grants part's user list on the bus. const ( @@ -41,16 +40,13 @@ const ( GrantsUserListSource = GrantsUserListBucket + "." + GrantsUserListKey ) -// Which list the bus node follows. -const ( - FollowController = "controller" - FollowGrants = "grants" -) +// FollowGrants is the list the bus node follows: the grants part's, the one source (ADR 0590). +const FollowGrants = "grants" // GrantsUserList is the value of the grants part's key `user-list.sealed`. type GrantsUserList struct { V int `json:"v"` - // Follow is which list the bus node follows: FollowGrants from take-over, FollowController otherwise. + // Follow is which list the bus node follows: FollowGrants, the one value it takes. Follow string `json:"follow"` // Digest is the list's digest, `sha256:` of the file's bytes as the bus loads them. Digest string `json:"digest"` @@ -65,10 +61,6 @@ type GrantsUserList struct { // LeftOut are the users the list leaves out for want of a hash in the vault. LeftOut []string `json:"left_out"` At time.Time `json:"at"` - // LeaseHanded says the controller's lease is handed to the grants part (novox/hq ADR 0582 clause 3, ADR 0571 clause - // 8): from then on the bus node follows this list alone and never falls back to the controller's. A key written - // before it reads false. - LeaseHanded bool `json:"lease-handed,omitempty"` } // EncodeGrantsUserList is g as JSON at Version; it refuses a value the bus node could not follow. @@ -96,8 +88,9 @@ func DecodeGrantsUserList(data []byte) (GrantsUserList, error) { } func (g GrantsUserList) check() error { - if g.Follow != FollowGrants && g.Follow != FollowController { - return fmt.Errorf("delivery: the user list's follow %q is not %s or %s", g.Follow, FollowGrants, FollowController) + if g.Follow != FollowGrants { + return fmt.Errorf("delivery: the user list's follow %q is not %s, the one list since the controller ended (ADR 0590)", + g.Follow, FollowGrants) } if g.Digest == "" { return nil // a switch with no list written yet: the bus node keeps what it follows