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
This commit is contained in:
-201
@@ -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 <command line>` 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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-<node>, 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 <inputs.json> -node <node> -module <module> -fp <src2:...> -key <operator key>
|
||||
// [-write -nats <url> -creds <operator credential>]
|
||||
//
|
||||
// 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>.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
|
||||
|
||||
@@ -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.<module>.<fp>)
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 `<module>.<fingerprint key part>` 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+8
-20
@@ -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-<node>`, which only that node's node-engine reads (a composition can carry a sealed value), under
|
||||
// `<node>.<module>.<fingerprint hex>`, and the node's own resources (adoption, firewall openings, accounts, the
|
||||
// resolver, identities and groups) under `<node>.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-<node>`, which nothing writes since it ended (ADR 0590), under `<node>.<module>.<fingerprint hex>`, and
|
||||
// the node's own resources (adoption, firewall openings, accounts, the resolver, identities and groups) under
|
||||
// `<node>.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, `<owner
|
||||
// module>_<name>`, 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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-15
@@ -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
|
||||
// (`<node>.<module>.<fp>`), 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:<hex>`.
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+30
-63
@@ -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 `<module>.<fingerprint hex>` 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 `<module>.<fingerprint hex>` 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, `<owner module>_<name>`.
|
||||
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:
|
||||
// `<module>.<hex>`, 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: `<module>.<hex>`,
|
||||
// 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, <module>.<fingerprint hex>: %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 `<node>.<module>` or a process's
|
||||
// `<node>.<module>.<process>`. 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, `<owner module>_<name>`.
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-17
@@ -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:<hex>` 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
|
||||
|
||||
Reference in New Issue
Block a user