Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c7c385839 | ||
|
|
65ff6159ad | ||
|
|
e6e1e3bc89 | ||
|
|
07e59c535e | ||
|
|
ba97297f66 | ||
|
|
e6b00e2e51 | ||
|
|
fa19a2d718 | ||
|
|
3e5086a2f6 | ||
|
|
ed331eb972 | ||
|
|
be59f29f46 | ||
|
|
5689553406 |
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -179,7 +180,21 @@ func (a *actor) release() {
|
|||||||
// holderOf is this process as the lease's holder.
|
// holderOf is this process as the lease's holder.
|
||||||
func holderOf(instance string) lease.Holder {
|
func holderOf(instance string) lease.Holder {
|
||||||
host, _ := os.Hostname()
|
host, _ := os.Hostname()
|
||||||
return lease.Holder{Instance: instance, Host: host, Build: version}
|
build := runningBuild()
|
||||||
|
if build == "" {
|
||||||
|
build = version
|
||||||
|
}
|
||||||
|
return lease.Holder{Instance: instance, Host: host, Build: build}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunningBuildVar is where the declaration tells this process which build it is (module.json, the
|
||||||
|
// controller process's env): the version its bundle is delivered as, `${version}` composed by the
|
||||||
|
// catalogue from the bundle's digest. Empty for a process placed by hand.
|
||||||
|
const RunningBuildVar = "MESH_CONTROLLER_VERSION"
|
||||||
|
|
||||||
|
// runningBuild is the version of the build this process is, or empty when the declaration did not say.
|
||||||
|
func runningBuild() string {
|
||||||
|
return strings.TrimSpace(os.Getenv(RunningBuildVar))
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveUnderTheLease takes the lease for the serving controller, waiting while another holds it, and
|
// serveUnderTheLease takes the lease for the serving controller, waiting while another holds it, and
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
"github.com/novox/mesh-controller/internal/secrets"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module's own secret given at the operator's desk (novox/hq ADR 0259 §10).
|
||||||
|
//
|
||||||
|
// **The value never passes through whoever asked for it.** An agent, or the operator at the mesh MCP
|
||||||
|
// server, calls `give` with the machine, the module, the secret's name and the desk — never a value. The
|
||||||
|
// controller makes a sealing keypair for this one call, asks the desk's `node-launcher.secret` to prompt the
|
||||||
|
// operator without showing what is typed, and is answered with what was typed **sealed to that key**: no
|
||||||
|
// plaintext on the bus, in a runtime's log or in any call's record. It opens it here, seals it to the
|
||||||
|
// module's machine exactly as `secret accept` does, and forgets it. What it answers says only that the
|
||||||
|
// value was taken, or why not.
|
||||||
|
//
|
||||||
|
// **What remains** (ADR 0234's accepted residual risk): on an X11 desk any program of the operator's
|
||||||
|
// account can read the keys as they are typed. And a program that calls the desk's prompt itself, with a
|
||||||
|
// key of its own, is answered with what the operator typed into a prompt they did not ask for — as it could
|
||||||
|
// draw a window of its own. The prompt says who asks and for what, so the operator types only into a
|
||||||
|
// prompt they started.
|
||||||
|
|
||||||
|
// deskPromptWithin is how long the prompt waits for the operator: below the runtime's thirty seconds for
|
||||||
|
// one call, as the launcher's menu is.
|
||||||
|
const deskPromptWithin = 25
|
||||||
|
|
||||||
|
// deskGive is the desk path, its four reaches given so a test needs no store and no bus.
|
||||||
|
type deskGive struct {
|
||||||
|
// declares refuses a module or a secret the mesh would refuse, before anybody is asked to type.
|
||||||
|
declares func(module, name string) error
|
||||||
|
// known refuses a machine the mesh does not know, before anybody is asked to type; nil knows every one
|
||||||
|
// (a test that does not look).
|
||||||
|
known func(machine string) error
|
||||||
|
// trusted says a module runs as an account of its own: its secret is never taken at a desk (below). Nil is
|
||||||
|
// never (a test that does not look).
|
||||||
|
trusted func(module string) (bool, error)
|
||||||
|
// ask asks one machine's node-launcher.secret and answers its result, or the holder's refusal.
|
||||||
|
ask func(machine string, args map[string]any) (json.RawMessage, error)
|
||||||
|
// accept seals the value as `secret accept` does, and says whether it lives until the module's start.
|
||||||
|
accept func(value string) (untilStart bool, err error)
|
||||||
|
// record writes the act in the hand-act log.
|
||||||
|
record func(link.HandAct) error
|
||||||
|
// announce raises the condition that says a module's own secret was given (secretGivenObservation), on
|
||||||
|
// every channel; nil announces nothing (a test that does not look).
|
||||||
|
announce func(node, module, name, how string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// errNothingGiven is a prompt dismissed, or not answered in time: nothing changes.
|
||||||
|
var errNothingGiven = errors.New("nothing was given")
|
||||||
|
|
||||||
|
// give asks the desk for the value and seals it; it answers the words said to the caller.
|
||||||
|
func (d deskGive) give(node, module, name, desk string) (string, error) {
|
||||||
|
for what, v := range map[string]string{"the machine": node, "the module": module, "the secret": name, "the desk": desk} {
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
return "", fmt.Errorf("%s is not named", what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.known != nil {
|
||||||
|
for what, machine := range map[string]string{"the machine the secret is for": node, "the desk": desk} {
|
||||||
|
if err := d.known(machine); err != nil {
|
||||||
|
return "", fmt.Errorf("nobody was asked to type anything: %s, %s, is not a machine this mesh knows: %w",
|
||||||
|
what, machine, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := d.declares(module, name); err != nil {
|
||||||
|
return "", fmt.Errorf("nobody was asked to type anything: %w", err)
|
||||||
|
}
|
||||||
|
// **A trusted party's secret is not taken at a desk** (the confirmation review of 2026-10-09, N1-give). The
|
||||||
|
// prompt's answer comes back over the desk machine's bus, on a subject that machine's runtime answers — and
|
||||||
|
// on a desk machine agents run as the operator, who holds that runtime's credential. An agent could answer
|
||||||
|
// first, with a bot token of its own sealed to this call's key, and the channel the operator's answers are
|
||||||
|
// proven on would be the agent's. So the value of a module running as its own account is typed at the
|
||||||
|
// controller's terminal, where no bus carries it.
|
||||||
|
if d.trusted != nil {
|
||||||
|
trusted, err := d.trusted(module)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("nobody was asked to type anything: whether %s is a trusted party could not be read: %w", module, err)
|
||||||
|
}
|
||||||
|
if trusted {
|
||||||
|
return "", fmt.Errorf("nobody was asked to type anything: %s runs as an account of its own and proves the "+
|
||||||
|
"operator's answers, so its %s is given at the controller's terminal alone — there, run `mesh-controller "+
|
||||||
|
"secret accept %s %s %s` and type it at its prompt. A desk's prompt is answered over the desk machine's "+
|
||||||
|
"bus, where an agent may answer first (novox/hq ADR 0259 §10)", module, name, node, module, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public, private, err := secrets.Keypair()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("no key could be made to take the value: %w", err)
|
||||||
|
}
|
||||||
|
// By name, never by words: the holder writes the prompt from these, and says the controller asks, which
|
||||||
|
// the bus alone makes true (broker.ControllerOnly).
|
||||||
|
raw, err := d.ask(desk, map[string]any{
|
||||||
|
"module": module,
|
||||||
|
"secret": name,
|
||||||
|
"node": node,
|
||||||
|
"seal_to": public,
|
||||||
|
"timeout_seconds": deskPromptWithin,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("the desk on %s could not be asked: %w", desk, err)
|
||||||
|
}
|
||||||
|
var answer struct {
|
||||||
|
Sealed string `json:"sealed"`
|
||||||
|
Cancelled bool `json:"cancelled"`
|
||||||
|
TimedOut bool `json:"timed_out"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||||
|
return "", fmt.Errorf("the desk on %s answered something that is not the prompt's answer", desk)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case answer.TimedOut:
|
||||||
|
return "", fmt.Errorf("%w: the prompt on %s was not answered within %d seconds", errNothingGiven, desk, deskPromptWithin)
|
||||||
|
case answer.Cancelled:
|
||||||
|
return "", fmt.Errorf("%w: the prompt on %s was dismissed", errNothingGiven, desk)
|
||||||
|
case answer.Sealed == "":
|
||||||
|
return "", fmt.Errorf("the desk on %s answered no sealed value", desk)
|
||||||
|
}
|
||||||
|
opened, err := secrets.Open(private, answer.Sealed)
|
||||||
|
if err != nil {
|
||||||
|
// Never the value, never what failed to open: only that it was not sealed to this call.
|
||||||
|
return "", fmt.Errorf("the desk on %s answered a value not sealed to this call; nothing was taken", desk)
|
||||||
|
}
|
||||||
|
value := asSupplied(string(opened))
|
||||||
|
for i := range opened {
|
||||||
|
opened[i] = 0
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return "", fmt.Errorf("%w: the prompt on %s was answered empty", errNothingGiven, desk)
|
||||||
|
}
|
||||||
|
untilStart, err := d.accept(value)
|
||||||
|
value = ""
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
act := link.HandAct{Verb: "secret accept", Args: []string{node, module, name, "--at-desk", desk},
|
||||||
|
Why: fmt.Sprintf("the operator gave %s for %s on %s at the desk on %s", name, module, node, desk),
|
||||||
|
Cause: "given-at-the-desk"}
|
||||||
|
recorded := ""
|
||||||
|
if err := d.record(act); err != nil {
|
||||||
|
recorded = fmt.Sprintf("\n this act could NOT be recorded in the hand-act log, and is done anyway: %v", err)
|
||||||
|
}
|
||||||
|
if d.announce != nil {
|
||||||
|
if err := d.announce(node, module, name, "at the desk on "+desk); err != nil {
|
||||||
|
recorded += fmt.Sprintf("\n this change could NOT be announced on the operator's channels: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
words := fmt.Sprintf("%s on %s now holds %q, given at the desk on %s and sealed to %s; the mesh cannot read it "+
|
||||||
|
"back.\n run `push %s` to send it", module, node, name, desk, node, node)
|
||||||
|
if untilStart {
|
||||||
|
words += fmt.Sprintf("\n it lives until %s next starts well under the mesh, and is then replaced with a value "+
|
||||||
|
"the mesh makes (ADR 0228)", module)
|
||||||
|
}
|
||||||
|
return words + recorded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// giveAtDesk is `secret accept <node> <module> <name> --at-desk <machine>`: the desk path, on this
|
||||||
|
// controller's stores and bus.
|
||||||
|
func giveAtDesk(ctx context.Context, node, module, name, desk string) error {
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
d := deskGive{
|
||||||
|
declares: func(module, name string) error { return open.inventory.DeclaresOwnSecret(ctx, module, name) },
|
||||||
|
known: func(machine string) error {
|
||||||
|
_, err := open.inventory.NodeByName(ctx, machine)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
trusted: func(module string) (bool, error) { return open.inventory.RunsAsItsOwnAccount(ctx, module) },
|
||||||
|
ask: func(machine string, args map[string]any) (json.RawMessage, error) {
|
||||||
|
var result json.RawMessage
|
||||||
|
err := onTheBus(func(conn *nats.Conn) error {
|
||||||
|
answer, err := link.AskSeatTool(ctx, conn, "node-launcher", "secret", machine, args,
|
||||||
|
time.Duration(deskPromptWithin+5)*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if answer.Error != "" {
|
||||||
|
return errors.New(answer.Error)
|
||||||
|
}
|
||||||
|
result = answer.Result
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
},
|
||||||
|
accept: func(value string) (bool, error) {
|
||||||
|
return open.inventory.AcceptGivenSecret(ctx, node, module, name, value)
|
||||||
|
},
|
||||||
|
record: func(act link.HandAct) error {
|
||||||
|
return onTheBus(func(conn *nats.Conn) error {
|
||||||
|
_, err := link.RecordHandAct(ctx, conn, act)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
},
|
||||||
|
announce: func(node, module, name, how string) error { return announceSecretGiven(ctx, node, module, name, how) },
|
||||||
|
}
|
||||||
|
words, err := d.give(node, module, name, desk)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(words)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindSecretGiven is the condition every value given for a module's own secret raises (the review of 2026-10-09,
|
||||||
|
// M4): on every channel, so a bot token changed by somebody else — a channel that now answers for them — is
|
||||||
|
// heard of. It stays until the operator silences or clears it.
|
||||||
|
const kindSecretGiven = "secret-given"
|
||||||
|
|
||||||
|
// secretGivenObservation is that condition: which secret, of which module on which machine, how and when.
|
||||||
|
func secretGivenObservation(node, module, name, how string, at time.Time) conditions.Observation {
|
||||||
|
key := node + "." + module + "." + name
|
||||||
|
return conditions.Observation{Scope: conditions.ScopeMachine, ID: key, Token: kindSecretGiven, Kind: kindSecretGiven,
|
||||||
|
Machine: node, Severity: conditions.Urgent, Source: kindSecretGiven,
|
||||||
|
Summary: fmt.Sprintf("%s of %s on %s was given %s at %s", name, module, node, how,
|
||||||
|
at.Local().Format("2006-01-02 15:04")),
|
||||||
|
Headline: "Secret of " + module + " changed",
|
||||||
|
Explanation: fmt.Sprintf("The secret %s of %s on %s was given %s at %s. If you did not do this, "+
|
||||||
|
"somebody else holds what %s acts with.", name, module, node, how, at.Local().Format("15:04"), module),
|
||||||
|
Needs: "silence this if you gave it; if you did not, give the secret again yourself and unlink what it serves.",
|
||||||
|
Resolved: "You saw that " + name + " of " + module + " was changed",
|
||||||
|
Actions: []conditions.Action{conditions.SilenceAction(conditions.Key(conditions.ScopeMachine, key, kindSecretGiven))}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// announceSecretGiven raises it on this controller's keeper.
|
||||||
|
func announceSecretGiven(ctx context.Context, node, module, name, how string) error {
|
||||||
|
return withKeeper(ctx, func(k *conditions.Keeper) error {
|
||||||
|
_, err := k.Observe(ctx, secretGivenObservation(node, module, name, how, time.Now()))
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
"github.com/novox/mesh-controller/internal/secrets"
|
||||||
|
)
|
||||||
|
|
||||||
|
const typed = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g"
|
||||||
|
|
||||||
|
// aDesk is the desk path with a prompt the test answers as the operator would, and what it was asked kept.
|
||||||
|
func aDesk(t *testing.T, answer func(args map[string]any) (json.RawMessage, error)) (deskGive, *[]string, *[]link.HandAct, *[]map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
var accepted []string
|
||||||
|
var acts []link.HandAct
|
||||||
|
var asked []map[string]any
|
||||||
|
return deskGive{
|
||||||
|
declares: func(module, name string) error {
|
||||||
|
if module != "telegram" || name != "telegram-token" {
|
||||||
|
return errors.New(module + " does not declare " + name + " as an own secret")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
ask: func(machine string, args map[string]any) (json.RawMessage, error) {
|
||||||
|
asked = append(asked, args)
|
||||||
|
return answer(args)
|
||||||
|
},
|
||||||
|
accept: func(value string) (bool, error) { accepted = append(accepted, value); return false, nil },
|
||||||
|
record: func(a link.HandAct) error { acts = append(acts, a); return nil },
|
||||||
|
}, &accepted, &acts, &asked
|
||||||
|
}
|
||||||
|
|
||||||
|
func sealedTo(t *testing.T, value string) func(args map[string]any) (json.RawMessage, error) {
|
||||||
|
return func(args map[string]any) (json.RawMessage, error) {
|
||||||
|
sealed, err := secrets.Seal(args["seal_to"].(string), []byte(value+"\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(map[string]any{"sealed": sealed})
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0259 §10: the value typed at the desk is sealed as `secret accept` seals it, and is in no
|
||||||
|
// answer, no prompt argument and no act recorded.
|
||||||
|
func TestASecretGivenAtTheDeskIsSealedAndSaidNowhere(t *testing.T) {
|
||||||
|
d, accepted, acts, asked := aDesk(t, sealedTo(t, typed))
|
||||||
|
words, err := d.give("anchor", "telegram", "telegram-token", "laptop")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(*accepted) != 1 || (*accepted)[0] != typed {
|
||||||
|
t.Fatalf("the value sealed is not what was typed, its line ending taken off")
|
||||||
|
}
|
||||||
|
if len(*acts) != 1 || (*acts)[0].Verb != "secret accept" || (*acts)[0].Cause != "given-at-the-desk" ||
|
||||||
|
!strings.Contains((*acts)[0].Why, "at the desk on laptop") {
|
||||||
|
t.Errorf("the act: %+v", *acts)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(struct {
|
||||||
|
Words string
|
||||||
|
Acts []link.HandAct
|
||||||
|
Asked []map[string]any
|
||||||
|
}{words, *acts, *asked})
|
||||||
|
if strings.Contains(string(raw), typed) || strings.Contains(string(raw), "AAEhBP0") {
|
||||||
|
t.Fatal("the value appears in what was said, asked or recorded")
|
||||||
|
}
|
||||||
|
if !strings.Contains(words, "push anchor") || !strings.Contains(words, "given at the desk on laptop") {
|
||||||
|
t.Errorf("%q", words)
|
||||||
|
}
|
||||||
|
if p := (*asked)[0]; p["seal_to"] == "" || p["timeout_seconds"] != deskPromptWithin {
|
||||||
|
t.Errorf("the prompt was asked %v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNothingIsAskedForASecretTheMeshWouldRefuse(t *testing.T) {
|
||||||
|
for _, c := range [][2]string{{"telegram", "chat-id"}, {"nobody", "telegram-token"}} {
|
||||||
|
d, accepted, _, asked := aDesk(t, sealedTo(t, typed))
|
||||||
|
if _, err := d.give("anchor", c[0], c[1], "laptop"); err == nil || !strings.Contains(err.Error(), "nobody was asked") {
|
||||||
|
t.Errorf("%v: %v", c, err)
|
||||||
|
}
|
||||||
|
if len(*asked) != 0 || len(*accepted) != 0 {
|
||||||
|
t.Errorf("%v: the operator was asked anyway", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d, _, _, _ := aDesk(t, sealedTo(t, typed))
|
||||||
|
if _, err := d.give("anchor", "telegram", "telegram-token", ""); err == nil {
|
||||||
|
t.Error("no desk was refused nowhere")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADismissedEmptyLateOrForeignAnswerTakesNothing(t *testing.T) {
|
||||||
|
for want, answer := range map[string]func(map[string]any) (json.RawMessage, error){
|
||||||
|
"not answered within 25 seconds": func(map[string]any) (json.RawMessage, error) {
|
||||||
|
return json.RawMessage(`{"cancelled":true,"timed_out":true}`), nil
|
||||||
|
},
|
||||||
|
"was dismissed": func(map[string]any) (json.RawMessage, error) { return json.RawMessage(`{"cancelled":true}`), nil },
|
||||||
|
"answered empty": sealedTo(t, " "),
|
||||||
|
"not sealed to this call": func(map[string]any) (json.RawMessage, error) {
|
||||||
|
other, _, _ := secrets.Keypair()
|
||||||
|
sealed, _ := secrets.Seal(other, []byte(typed))
|
||||||
|
raw, _ := json.Marshal(map[string]any{"sealed": sealed})
|
||||||
|
return raw, nil
|
||||||
|
},
|
||||||
|
"could not be asked": func(map[string]any) (json.RawMessage, error) { return nil, errors.New("no session answers") },
|
||||||
|
} {
|
||||||
|
d, accepted, acts, _ := aDesk(t, answer)
|
||||||
|
_, err := d.give("anchor", "telegram", "telegram-token", "laptop")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), want) || strings.Contains(err.Error(), typed) {
|
||||||
|
t.Errorf("want %q, got %v", want, err)
|
||||||
|
}
|
||||||
|
if len(*accepted) != 0 || len(*acts) != 0 {
|
||||||
|
t.Errorf("%s: something was taken or recorded", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheGiveVerbRunsTheDeskPathAndTheControllerMayAskTheDesk(t *testing.T) {
|
||||||
|
argv, err := argvFor("give", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token", "at": "laptop"})
|
||||||
|
if err != nil || strings.Join(argv, " ") != "secret accept anchor telegram telegram-token --at-desk laptop" {
|
||||||
|
t.Fatalf("%v %v", argv, err)
|
||||||
|
}
|
||||||
|
if _, err := argvFor("give", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token"}); err == nil {
|
||||||
|
t.Error("give without a desk was taken")
|
||||||
|
}
|
||||||
|
perms, err := broker.PermissionsFor(broker.Principal{Kind: broker.KindController})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, p := range perms.Publish {
|
||||||
|
found = found || p == "mesh.seat.node-launcher.tool.secret.*"
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("the controller may not ask the desk's prompt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The review of 2026-10-09 (M4): the desk's prompt says who asks in words the caller does not choose — the
|
||||||
|
// controller, which the bus alone lets ask it — and what for, from names the controller checked; the prompt
|
||||||
|
// carries no free text of the caller's.
|
||||||
|
func TestThePromptIsAskedByNameNeverByWordsTheCallerChose(t *testing.T) {
|
||||||
|
d, _, _, asked := aDesk(t, sealedTo(t, typed))
|
||||||
|
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p := (*asked)[0]
|
||||||
|
if p["module"] != "telegram" || p["secret"] != "telegram-token" || p["node"] != "anchor" {
|
||||||
|
t.Errorf("the prompt was not asked by name: %v", p)
|
||||||
|
}
|
||||||
|
for _, free := range []string{"prompt", "message"} {
|
||||||
|
if _, there := p[free]; there {
|
||||||
|
t.Errorf("the prompt carries the caller's %s: %v", free, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every value given for a module's own secret is announced as a condition, on every channel (the review of
|
||||||
|
// 2026-10-09, M4): a bot token changed by somebody else is a channel that now answers for them.
|
||||||
|
func TestAValueGivenAtTheDeskIsAnnounced(t *testing.T) {
|
||||||
|
d, _, _, _ := aDesk(t, sealedTo(t, typed))
|
||||||
|
var said []string
|
||||||
|
d.announce = func(node, module, name, how string) error {
|
||||||
|
said = append(said, node+" "+module+" "+name+" "+how)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(said) != 1 || !strings.Contains(said[0], "anchor telegram telegram-token") || !strings.Contains(said[0], "laptop") {
|
||||||
|
t.Fatalf("announced %v", said)
|
||||||
|
}
|
||||||
|
o := secretGivenObservation("anchor", "telegram", "telegram-token", "at the desk on laptop", time.Date(2026, 10, 9, 12, 3, 0, 0, time.UTC))
|
||||||
|
if o.Severity != conditions.Urgent || !strings.Contains(o.Explanation, "telegram-token") ||
|
||||||
|
len(o.Actions) == 0 || o.Key() == "" {
|
||||||
|
t.Errorf("the announcement %+v", o)
|
||||||
|
}
|
||||||
|
if strings.Contains(o.Summary+o.Explanation+o.Said, typed) {
|
||||||
|
t.Error("the announcement carries the value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bus lets the controller alone ask the desk's prompt (the review of 2026-10-09, M4): the runtime, which
|
||||||
|
// carries every agent's calls, and a person granted every tool are denied it, however wide their grant.
|
||||||
|
func TestOnlyTheControllerMayAskTheDesksPrompt(t *testing.T) {
|
||||||
|
for _, p := range []broker.Principal{
|
||||||
|
{Kind: broker.KindNodeTools, Node: "laptop"},
|
||||||
|
{Kind: broker.KindPerson, Module: "operator", Invokes: []string{"*"}},
|
||||||
|
{Kind: broker.KindModule, Node: "laptop", Module: "lab", Invokes: []string{"seat:node-launcher.secret"}},
|
||||||
|
} {
|
||||||
|
perms, err := broker.PermissionsFor(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, subject := range []string{"mesh.seat.node-launcher.tool.secret.laptop", "mesh.seat.node-launcher.tool.secret",
|
||||||
|
"mesh.mod.rofi.tool.node-launcher.secret", "mesh.mod.rofi.tool.node-launcher.secret.laptop"} {
|
||||||
|
if broker.MayPublish(perms, subject) {
|
||||||
|
t.Errorf("%s may publish %s", p.Username(), subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
perms, _ := broker.PermissionsFor(broker.Principal{Kind: broker.KindController})
|
||||||
|
if !broker.MayPublish(perms, "mesh.seat.node-launcher.tool.secret.laptop") {
|
||||||
|
t.Error("the controller may not ask the desk's prompt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A value for a secret comes from the terminal or the desk, never through a verb (the review of 2026-10-09,
|
||||||
|
// M4): `secret accept` with a value, run for a verb, is refused before anything is read.
|
||||||
|
func TestASecretValueIsNeverAcceptedThroughAVerb(t *testing.T) {
|
||||||
|
t.Setenv(verbVar, "mesh-controller.command")
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"accept", "anchor", "telegram", "telegram-token", "--from", "/dev/null"},
|
||||||
|
{"accept", "anchor", "app", "db", "--from", "/dev/null", "--provider", "store"},
|
||||||
|
} {
|
||||||
|
err := secretCommand(context.Background(), args)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "never through a verb") {
|
||||||
|
t.Errorf("%v: %v", args, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The `give` verb's own line passes the terminal-only rule of ADR 0266, and no other `secret accept` does: a
|
||||||
|
// value, a file, a provider or an extra word is still the terminal's alone.
|
||||||
|
func TestOnlyTheGiveLinePassesTheTerminalRuleForSecrets(t *testing.T) {
|
||||||
|
if err := terminalOnly([]string{"secret", "accept", "anchor", "telegram", "telegram-token", "--at-desk", "laptop"}); err != nil {
|
||||||
|
t.Errorf("give's line refused: %v", err)
|
||||||
|
}
|
||||||
|
for _, argv := range [][]string{
|
||||||
|
{"secret", "accept", "anchor", "telegram", "telegram-token"},
|
||||||
|
{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "/tmp/x"},
|
||||||
|
{"secret", "accept", "anchor", "telegram", "telegram-token", "--at-desk", "laptop", "--local"},
|
||||||
|
{"secret", "accept", "anchor", "telegram", "--provider", "--at-desk", "laptop"},
|
||||||
|
{"secret", "export", "anchor", "telegram", "telegram-token", "--at-desk", "laptop"},
|
||||||
|
} {
|
||||||
|
if err := terminalOnly(argv); err == nil {
|
||||||
|
t.Errorf("%v passed the terminal rule", argv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The confirmation review of 2026-10-09, N1-give: a desk's prompt is answered over the desk machine's bus, and
|
||||||
|
// on a desk machine agents run as the operator, who holds its runtime's credential — so a trusted party's
|
||||||
|
// secret (a module running as an account of its own: the Telegram bot's token) is never taken at a desk.
|
||||||
|
// Refused before anybody is asked to type, whoever called, naming the terminal's line.
|
||||||
|
func TestATrustedPartysSecretIsNeverTakenAtADesk(t *testing.T) {
|
||||||
|
d, accepted, acts, asked := aDesk(t, sealedTo(t, typed))
|
||||||
|
d.trusted = func(module string) (bool, error) { return module == "telegram", nil }
|
||||||
|
_, err := d.give("anchor", "telegram", "telegram-token", "laptop")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "controller's terminal alone") ||
|
||||||
|
!strings.Contains(err.Error(), "secret accept anchor telegram telegram-token") {
|
||||||
|
t.Fatalf("a trusted party's secret was taken at the desk, or refused without the line: %v", err)
|
||||||
|
}
|
||||||
|
if len(*asked)+len(*accepted)+len(*acts) != 0 {
|
||||||
|
t.Errorf("asked %v, accepted %d, recorded %v", *asked, len(*accepted), *acts)
|
||||||
|
}
|
||||||
|
d.trusted = func(string) (bool, error) { return false, errors.New("the store did not answer") }
|
||||||
|
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err == nil || len(*asked) != 0 {
|
||||||
|
t.Errorf("a module not known to be untrusted was asked at the desk: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And who may answer the desk's prompt at all: only the runtime of the machine it is asked on, carrying the
|
||||||
|
// launcher that holds the seat there — never the controller, another machine's runtime, or a module's own
|
||||||
|
// account (the confirmation review of 2026-10-09, N1-give).
|
||||||
|
func TestOnlyTheDeskMachinesLauncherMayAnswerItsPrompt(t *testing.T) {
|
||||||
|
launcher := broker.Declared{Module: "rofi", Holds: []broker.Seat{{Name: "node-launcher", Scope: "node",
|
||||||
|
Serves: []string{"run", "secret"}}}}
|
||||||
|
subject := "mesh.seat.node-launcher.tool.secret.laptop"
|
||||||
|
for _, c := range []struct {
|
||||||
|
p broker.Principal
|
||||||
|
answers bool
|
||||||
|
}{
|
||||||
|
{broker.Principal{Kind: broker.KindNodeTools, Node: "laptop", Module: broker.RuntimeModule, Carries: []broker.Declared{launcher}}, true},
|
||||||
|
{broker.Principal{Kind: broker.KindNodeTools, Node: "anchor", Module: broker.RuntimeModule, Carries: []broker.Declared{launcher}}, false},
|
||||||
|
{broker.Principal{Kind: broker.KindController}, false},
|
||||||
|
{broker.Principal{Kind: broker.KindModule, Node: "laptop", Module: "lab"}, false},
|
||||||
|
{broker.Principal{Kind: broker.KindNode, Node: "laptop"}, false},
|
||||||
|
} {
|
||||||
|
perms, err := broker.PermissionsFor(c.p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := broker.MaySubscribe(perms, subject); got != c.answers {
|
||||||
|
t.Errorf("%s may answer %s: %v, want %v", c.p.Username(), subject, got, c.answers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// N1-give at the controller's terminal (the confirmation review of 2026-10-09): a trusted party's secret is
|
||||||
|
// announced before it is kept, and not kept when the announcement fails; another module's is kept first and
|
||||||
|
// a failed announcement is said, not undone.
|
||||||
|
func TestATrustedPartysSecretGivenAtTheTerminalIsAnnouncedBeforeItIsKept(t *testing.T) {
|
||||||
|
var order []string
|
||||||
|
announce := func(fail bool) func() error {
|
||||||
|
return func() error {
|
||||||
|
order = append(order, "announce")
|
||||||
|
if fail {
|
||||||
|
return errors.New("no channel")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keep := func() (bool, error) { order = append(order, "keep"); return false, nil }
|
||||||
|
|
||||||
|
order = nil
|
||||||
|
if _, unannounced, err := keepGiven(true, announce(false), keep); err != nil || unannounced != nil ||
|
||||||
|
strings.Join(order, ",") != "announce,keep" {
|
||||||
|
t.Errorf("trusted: %v %v, order %v; want announced, then kept", unannounced, err, order)
|
||||||
|
}
|
||||||
|
order = nil
|
||||||
|
if _, _, err := keepGiven(true, announce(true), keep); err == nil || strings.Join(order, ",") != "announce" {
|
||||||
|
t.Errorf("trusted, announcement failed: %v, order %v; want refused and nothing kept", err, order)
|
||||||
|
}
|
||||||
|
order = nil
|
||||||
|
if _, unannounced, err := keepGiven(false, announce(true), keep); err != nil || unannounced == nil ||
|
||||||
|
strings.Join(order, ",") != "keep,announce" {
|
||||||
|
t.Errorf("not trusted: %v %v, order %v; want kept, then the failed announcement said", unannounced, err, order)
|
||||||
|
}
|
||||||
|
order = nil
|
||||||
|
failing := func() (bool, error) { order = append(order, "keep"); return false, errors.New("store away") }
|
||||||
|
if _, _, err := keepGiven(false, announce(false), failing); err == nil || strings.Join(order, ",") != "keep" {
|
||||||
|
t.Errorf("not trusted, keep failed: %v, order %v; want refused and nothing announced", err, order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine the mesh does not know, as the secret's or as the desk, is refused before anybody is asked to type.
|
||||||
|
func TestAGiveNamingAMachineTheMeshDoesNotKnowAsksNobody(t *testing.T) {
|
||||||
|
for _, unknown := range []string{"elsewhere", "nodesk"} {
|
||||||
|
d, accepted, acts, asked := aDesk(t, func(map[string]any) (json.RawMessage, error) {
|
||||||
|
t.Fatal("the desk was asked")
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
d.known = func(machine string) error {
|
||||||
|
if machine == unknown {
|
||||||
|
return errors.New("no node " + machine)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
node, desk := "anchor", "laptop"
|
||||||
|
if unknown == "elsewhere" {
|
||||||
|
node = unknown
|
||||||
|
} else {
|
||||||
|
desk = unknown
|
||||||
|
}
|
||||||
|
_, err := d.give(node, "telegram", "telegram-token", desk)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "nobody was asked") || !strings.Contains(err.Error(), unknown) {
|
||||||
|
t.Errorf("%s: %v", unknown, err)
|
||||||
|
}
|
||||||
|
if len(*accepted)+len(*acts)+len(*asked) != 0 {
|
||||||
|
t.Errorf("%s: something happened: %v %v %v", unknown, *accepted, *acts, *asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
-1
@@ -87,6 +87,9 @@ const (
|
|||||||
healthWaiting
|
healthWaiting
|
||||||
healthNotYet
|
healthNotYet
|
||||||
healthBroken
|
healthBroken
|
||||||
|
// healthSuperseded is a judging that cannot go on: the machine was sent another build of the module
|
||||||
|
// after the gate's send (novox/hq issue 352). No verdict on the build judged, and nothing put back.
|
||||||
|
healthSuperseded
|
||||||
)
|
)
|
||||||
|
|
||||||
// served is what one machine's node tools answered the bus's discovery with.
|
// served is what one machine's node tools answered the bus's discovery with.
|
||||||
@@ -122,6 +125,39 @@ type gateFacts struct {
|
|||||||
// groupsAdded is, per module, whether the move judged puts an account in a group its previous build did
|
// groupsAdded is, per module, whether the move judged puts an account in a group its previous build did
|
||||||
// not (issue 318 review): the only move whose wait for a new login is excused.
|
// not (issue 318 review): the only move whose wait for a new login is excused.
|
||||||
groupsAdded map[string]bool
|
groupsAdded map[string]bool
|
||||||
|
// sent is, per machine, the declaration the gate's own send carried there (novox/hq issue 352): a
|
||||||
|
// report is held against it, never against the send made last. sentBuilds is what each machine was
|
||||||
|
// last sent of every module, and judged the commit of each module this gate judges: a machine last
|
||||||
|
// sent another build of the module is not running the build judged.
|
||||||
|
sent map[string]inventory.SentDeclaration
|
||||||
|
sentBuilds map[string]map[string]string
|
||||||
|
commits map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportedOn says a machine's last report is on what the gate sent it (novox/hq issue 352): on that
|
||||||
|
// declaration, or one it was sent after it — or, for a gate kept before sends were kept on it, on the
|
||||||
|
// declaration last sent. On 2026-10-09 a release's gate read the control node's report against a newer
|
||||||
|
// send another plan had just made there, and failed three builds the machine had reported healthy as
|
||||||
|
// "has not reported on what it was sent".
|
||||||
|
func (f gateFacts) reportedOn(machine string, r inventory.Reported) bool {
|
||||||
|
if sent, kept := f.sent[machine]; kept {
|
||||||
|
return sent.ReportsOn(r)
|
||||||
|
}
|
||||||
|
return r.Current
|
||||||
|
}
|
||||||
|
|
||||||
|
// supersededOn says the machine was last sent another build of the module than the one this gate judges
|
||||||
|
// (novox/hq issue 352): the judging cannot go on, whatever the machine reports. On 2026-10-09 a controller
|
||||||
|
// put back by one gate judged another gate's newer controller build passed on the same machine, reading
|
||||||
|
// the put-back build's health as the newer one's.
|
||||||
|
func (f gateFacts) supersededOn(module, machine string) (string, bool) {
|
||||||
|
judged, known := f.commits[module]
|
||||||
|
sent, has := f.sentBuilds[machine][module]
|
||||||
|
if !known || !has || judged == "" || sent == "" || sameCommit(sent, judged) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s was sent %s %s after this gate's %s: the build judged no longer runs there, and "+
|
||||||
|
"this judging is superseded by that send's", machine, module, short(sent), short(judged)), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// gatherGateFacts reads what a judging needs, from the store, the bus and this controller's memory. A
|
// gatherGateFacts reads what a judging needs, from the store, the bus and this controller's memory. A
|
||||||
@@ -199,9 +235,12 @@ func judgeHealth(module, component string, m catalogue.Manifest, machine string,
|
|||||||
return healthBroken, fmt.Sprintf("the witness on %s judged the %s %s and %s: %s", machine, r.Component,
|
return healthBroken, fmt.Sprintf("the witness on %s judged the %s %s and %s: %s", machine, r.Component,
|
||||||
short(r.From), r.Outcome, r.Why)
|
short(r.From), r.Outcome, r.Why)
|
||||||
}
|
}
|
||||||
|
if why, superseded := f.supersededOn(module, machine); superseded {
|
||||||
|
return healthSuperseded, why
|
||||||
|
}
|
||||||
r, said := f.reports[machine]
|
r, said := f.reports[machine]
|
||||||
switch {
|
switch {
|
||||||
case !said || r.At == nil || !r.Current:
|
case !said || r.At == nil || !f.reportedOn(machine, r):
|
||||||
return healthNotYet, fmt.Sprintf("%s has not reported on what it was sent", machine)
|
return healthNotYet, fmt.Sprintf("%s has not reported on what it was sent", machine)
|
||||||
case r.Outcome == inventory.OutcomeFailed || r.Outcome == inventory.OutcomeRefused:
|
case r.Outcome == inventory.OutcomeFailed || r.Outcome == inventory.OutcomeRefused:
|
||||||
return healthBroken, fmt.Sprintf("%s %s what it was sent", machine, r.Outcome)
|
return healthBroken, fmt.Sprintf("%s %s what it was sent", machine, r.Outcome)
|
||||||
@@ -438,6 +477,21 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
facts.groupsAdded = movesAddingGroups(ctx, open.inventory, g, pairs, shelf)
|
facts.groupsAdded = movesAddingGroups(ctx, open.inventory, g, pairs, shelf)
|
||||||
|
facts.sent = g.Sent
|
||||||
|
facts.commits, facts.sentBuilds = judgedCommits(g, pairs), map[string]map[string]string{}
|
||||||
|
// A module this gate put back at once (putBackBroken) was sent its earlier build by the gate itself:
|
||||||
|
// not another send, and not a judging superseded.
|
||||||
|
for _, m := range g.Returned {
|
||||||
|
delete(facts.commits, m)
|
||||||
|
}
|
||||||
|
for _, j := range pairs {
|
||||||
|
if _, read := facts.sentBuilds[j.node]; read {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if builds, known, err := open.inventory.SentBuilds(ctx, j.node); err == nil && known {
|
||||||
|
facts.sentBuilds[j.node] = builds
|
||||||
|
}
|
||||||
|
}
|
||||||
// **What is wrong with a machine itself is the machine's** (novox/hq issue 281): read once for each
|
// **What is wrong with a machine itself is the machine's** (novox/hq issue 281): read once for each
|
||||||
// machine judged, apart from what is wrong with a module there, and never pinned on the module the
|
// machine judged, apart from what is wrong with a module there, and never pinned on the module the
|
||||||
// gate happens to be kept on.
|
// gate happens to be kept on.
|
||||||
@@ -474,6 +528,13 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
|||||||
if _, seen := reading[j.module]; !seen {
|
if _, seen := reading[j.module]; !seen {
|
||||||
modules = append(modules, j.module)
|
modules = append(modules, j.module)
|
||||||
}
|
}
|
||||||
|
if h == healthSuperseded {
|
||||||
|
// Decided at once (novox/hq issue 352): nothing of this gate's can be judged on a machine that
|
||||||
|
// was sent another build of it, and nothing is put back — the later send is what runs there.
|
||||||
|
g.Failing, g.Last = nil, ""
|
||||||
|
decide(g, inventory.GateSuperseded, said, now)
|
||||||
|
return g.Verdict, nil
|
||||||
|
}
|
||||||
if h == healthBroken && !slices.Contains(g.Broken, j.module) {
|
if h == healthBroken && !slices.Contains(g.Broken, j.module) {
|
||||||
g.Broken = append(g.Broken, j.module)
|
g.Broken = append(g.Broken, j.module)
|
||||||
if g.BrokenWhy == "" {
|
if g.BrokenWhy == "" {
|
||||||
@@ -577,6 +638,40 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
|||||||
return g.Verdict, nil
|
return g.Verdict, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// judgedCommits is the commit of each module a gate judges: the gate's own To for its module, and each
|
||||||
|
// carried move's. Pure.
|
||||||
|
func judgedCommits(g *inventory.PlanGate, pairs []judged) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, c := range g.Carried {
|
||||||
|
if c.To != "" {
|
||||||
|
out[c.Module] = c.To
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g.To != "" {
|
||||||
|
for _, j := range pairs {
|
||||||
|
if _, has := out[j.module]; !has && !slices.ContainsFunc(g.Carried, func(c inventory.CarriedMove) bool { return c.Module == j.module }) {
|
||||||
|
out[j.module] = g.To
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// sentNow is what each machine was just sent, read after a send for the gate to keep (novox/hq issue
|
||||||
|
// 352): a machine whose send is not on record is left out, and its report is read as before.
|
||||||
|
func sentNow(ctx context.Context, inv *inventory.Inventory, machines []string) map[string]inventory.SentDeclaration {
|
||||||
|
out := map[string]inventory.SentDeclaration{}
|
||||||
|
for _, n := range machines {
|
||||||
|
if s, found, err := inv.SentTo(ctx, n); err == nil && found {
|
||||||
|
out[n] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// whyFor is a passing gate's why as one module's verdict says it: the send's, and that module's own wait
|
// whyFor is a passing gate's why as one module's verdict says it: the send's, and that module's own wait
|
||||||
// for a person, never another's (issue 318 review).
|
// for a person, never another's (issue 318 review).
|
||||||
func whyFor(g *inventory.PlanGate, module string) string {
|
func whyFor(g *inventory.PlanGate, module string) string {
|
||||||
@@ -802,6 +897,16 @@ func gateFailed(ctx context.Context, open *stores, p *inventory.Plan, module str
|
|||||||
"back", module, short(state.Previous), inventory.KeptBuilds))
|
"back", module, short(state.Previous), inventory.KeptBuilds))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if g.Component == lease.ComponentController {
|
||||||
|
// **The controller is never put back to a build older than the store's schema** (novox/hq issue
|
||||||
|
// 352): the build before it carries fewer migrations than the failed one applied, starts behind
|
||||||
|
// its own records, and judges the next gate with what it can read. The current build is kept and
|
||||||
|
// the condition says so; a person decides.
|
||||||
|
if why, ok := controllerSchemaAllows(ctx, inv, previous); !ok {
|
||||||
|
notBack(why)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := inv.RestoreModule(ctx, previous); err != nil {
|
if err := inv.RestoreModule(ctx, previous); err != nil {
|
||||||
notBack(err.Error())
|
notBack(err.Error())
|
||||||
return
|
return
|
||||||
@@ -835,6 +940,53 @@ func gateFailed(ctx context.Context, open *stores, p *inventory.Plan, module str
|
|||||||
sayRollback(ctx, open, module, g, "")
|
sayRollback(ctx, open, module, g, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// controllerSchemaAllows says the store's schema lets this build of the controller be put back: the
|
||||||
|
// build recorded, when it served, a reach at or past the highest migration the store has applied. One
|
||||||
|
// that never recorded a reach is not proved safe, and is refused as such (novox/hq issue 352). Why
|
||||||
|
// says what is kept and why when it is not.
|
||||||
|
func controllerSchemaAllows(ctx context.Context, inv *inventory.Inventory, previous inventory.Build) (string, bool) {
|
||||||
|
applied, err := inv.SchemaApplied(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "what the store's schema reaches cannot be read, so whether the build before it can read it is not " +
|
||||||
|
"known; the current build is kept: " + err.Error(), false
|
||||||
|
}
|
||||||
|
build := versionOfBuild(previous)
|
||||||
|
if build == "" {
|
||||||
|
return fmt.Sprintf("the build before it (%s) names no bundle to know it by, so whether it can read the store's "+
|
||||||
|
"schema (migration %04d) is not known; the current build is kept, and a person decides", short(previous.Commit), applied), false
|
||||||
|
}
|
||||||
|
reach, known, err := inv.SchemaReachOf(ctx, build)
|
||||||
|
if err != nil {
|
||||||
|
return "what the build before it knows of the store's schema cannot be read; the current build is kept: " + err.Error(), false
|
||||||
|
}
|
||||||
|
if !known {
|
||||||
|
return fmt.Sprintf("the build before it (%s, %s) never recorded how far it reads the store's schema — a "+
|
||||||
|
"controller records that when it serves — so it is not proved to read migration %04d, which the store "+
|
||||||
|
"has applied; a controller older than its store starts behind its own records and judges with what it "+
|
||||||
|
"can read, so the current build is kept, and a person decides", short(previous.Commit), build, applied), false
|
||||||
|
}
|
||||||
|
if reach < applied {
|
||||||
|
return fmt.Sprintf("the build before it (%s, %s) reads the store's schema up to migration %04d, and the store "+
|
||||||
|
"is at %04d: a controller older than its store starts behind its own records and judges with what it "+
|
||||||
|
"can read, so the current build is kept, and a person decides", short(previous.Commit), build, reach, applied), false
|
||||||
|
}
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// versionOfBuild is the version a build's bundle is delivered as — its archive's digest, short, as the
|
||||||
|
// catalogue names it (`${version}`) — read from the build's artifacts; empty when none is a bundle.
|
||||||
|
func versionOfBuild(b inventory.Build) string {
|
||||||
|
for _, a := range b.Made {
|
||||||
|
if a.Kind != catalogue.ArtifactBundle && a.Kind != catalogue.ArtifactArchive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, hex, found := strings.Cut(a.Reference, "sha256:"); found && len(hex) >= 12 {
|
||||||
|
return hex[:12]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// rollbacks is what a failed send puts back, sent together (novox/hq issue 281): a gate that judged one
|
// rollbacks is what a failed send puts back, sent together (novox/hq issue 281): a gate that judged one
|
||||||
// send judges what it moved as one, and what it found wanting goes back in one send per machine — not
|
// send judges what it moved as one, and what it found wanting goes back in one send per machine — not
|
||||||
// in a send for each module, which is the churn that failed the gate in the first place.
|
// in a send for each module, which is the churn that failed the gate in the first place.
|
||||||
|
|||||||
@@ -113,9 +113,10 @@ func aGateMesh(t *testing.T) *gateMesh {
|
|||||||
}
|
}
|
||||||
for node, h := range g.health {
|
for node, h := range g.health {
|
||||||
if h == healthNotYet {
|
if h == healthNotYet {
|
||||||
|
// Not reported on the send: neither the send made last, nor the gate's own (issue 352).
|
||||||
f.rolledBack[node] = nil
|
f.rolledBack[node] = nil
|
||||||
r := f.reports[node]
|
r := f.reports[node]
|
||||||
r.Current = false
|
r.Current, r.Declared, r.ReportedSequence = false, "", 0
|
||||||
f.reports[node] = r
|
f.reports[node] = r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ var handActVerbs = []handActVerb{
|
|||||||
// to that judgement. Several values rotate for one leak, and a leak that recurs is a defect of the
|
// to that judgement. Several values rotate for one leak, and a leak that recurs is a defect of the
|
||||||
// module that prints them, an issue against it, not a healer that rotates. A rotation for any other
|
// module that prints them, an issue against it, not a healer that rotates. A rotation for any other
|
||||||
// cause — a credential that stopped working — counts: a schedule or a healer could take it over.
|
// cause — a credential that stopped working — counts: a schedule or a healer could take it over.
|
||||||
|
// A value given at the desk (novox/hq ADR 0259 §10): an outside party's key, such as a bot token, which
|
||||||
|
// only a person can give. Their word, never a repair.
|
||||||
|
{Verb: "secret accept", Decision: "a value an outside party issued is given by a person, at their desk"},
|
||||||
{Verb: "secret rotate", Decision: "a value a person judged disclosed is replaced on their word",
|
{Verb: "secret rotate", Decision: "a value a person judged disclosed is replaced on their word",
|
||||||
DecidedFor: []string{causeLeakedInLogs}},
|
DecidedFor: []string{causeLeakedInLogs}},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hideTyping turns a terminal's echo off while a secret is typed at it, and gives back what restores it. On
|
||||||
|
// anything that is not a terminal (a pipe, a file) it does nothing.
|
||||||
|
func hideTyping(f *os.File) func() {
|
||||||
|
fd := int(f.Fd())
|
||||||
|
before, err := unix.IoctlGetTermios(fd, unix.TCGETS)
|
||||||
|
if err != nil {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
hidden := *before
|
||||||
|
hidden.Lflag &^= unix.ECHO
|
||||||
|
if err := unix.IoctlSetTermios(fd, unix.TCSETS, &hidden); err != nil {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
_ = unix.IoctlSetTermios(fd, unix.TCSETS, before)
|
||||||
|
_, _ = os.Stderr.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/lease"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq issue 352: on 2026-10-09 a release's gate on the control node read the machine's report against a
|
||||||
|
// newer send another plan had just made there — not against its own send — and failed three builds the
|
||||||
|
// machine had reported healthy ("has not reported on what it was sent"), put them back on every machine,
|
||||||
|
// to a controller older than the store's schema, and that controller then judged the newer plan's
|
||||||
|
// controller passed from the put-back build's health.
|
||||||
|
|
||||||
|
// TestReplay352 replays the walk on the backlog fixture: the release sends anchor and anchor reports;
|
||||||
|
// another send reaches anchor, unreported; the gate still passes. And a send that moves a judged module
|
||||||
|
// to another build supersedes the judging: no verdict, nothing put back.
|
||||||
|
func TestReplay352(t *testing.T) {
|
||||||
|
t.Run("a newer send to the judged machine does not unreport the gate's", testANewerSendDoesNotUnreportTheGatesOwn)
|
||||||
|
t.Run("a send that moves the module supersedes the judging", testASendThatMovesTheModuleSupersedesTheJudging)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testANewerSendDoesNotUnreportTheGatesOwn(t *testing.T) {
|
||||||
|
b := aBacklog(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := b.open.inventory
|
||||||
|
advancePlans(ctx, b.open) // anchor is sent, and the fixture reports it applied
|
||||||
|
// 16:31:37 — another plan sends anchor a newer declaration, which it has not reported on.
|
||||||
|
carried, _, _ := inv.SentBuilds(ctx, "anchor")
|
||||||
|
if err := inv.RecordSent(ctx, nodeID(t, b.open, "anchor"), "d-anchor-newer", carried); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reports, _ := inv.LastReports(ctx)
|
||||||
|
for _, r := range reports {
|
||||||
|
if r.Node == "anchor" && r.Current {
|
||||||
|
t.Fatal("the fixture's newer send reads as reported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gateEvery, gateBound = 0, 0 // past the bound at once: before the fix, "has not reported" fails it here
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
advancePlans(ctx, b.open)
|
||||||
|
}
|
||||||
|
p := b.release(t)
|
||||||
|
if p.State == inventory.PlanFailed || strings.Contains(p.Note, "has not reported") {
|
||||||
|
t.Fatalf("the release failed on the newer send: %s %s", p.State, p.Note)
|
||||||
|
}
|
||||||
|
if g := p.Release.Gate; g != nil && (g.Sent == nil || g.Sent["anchor"].Digest == "") {
|
||||||
|
t.Fatalf("the gate does not keep what it sent: %+v", g)
|
||||||
|
}
|
||||||
|
if v, found, err := inv.GateOf(ctx, "build-app-c2"); err != nil || !found || v.Verdict != inventory.GatePassed {
|
||||||
|
t.Fatalf("app's pass on anchor was not kept: %+v %v %v", v, found, err)
|
||||||
|
}
|
||||||
|
if current, _ := inv.CurrentBuilds(ctx); current["app"].Commit != "c2" {
|
||||||
|
t.Fatalf("app was put back to %s", current["app"].Commit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plan's own first send waits while a release judges the same module on that machine with another build.
|
||||||
|
func TestAPlansFirstSendWaitsForAReleaseJudgingTheModuleThere(t *testing.T) {
|
||||||
|
b := aBacklog(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
advancePlans(ctx, b.open) // the release judges app c2 on anchor
|
||||||
|
_, _, err := gatedSend(ctx, b.open, "anchor", []inventory.CarriedMove{{Module: "app", Node: "anchor", From: "c2", To: "c3", Build: "build-app-c3"}})
|
||||||
|
if !errors.Is(err, errWalkedElsewhere) || !strings.Contains(err.Error(), "release-") {
|
||||||
|
t.Fatalf("a newer build of a judged module was sent under the release's gate: %v", err)
|
||||||
|
}
|
||||||
|
if len(b.sent) != 1 {
|
||||||
|
t.Fatalf("sent %v", b.sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A merge plan's judging is superseded the same way: another send moved its module on the first machine.
|
||||||
|
func TestAPlansJudgingIsSupersededByASendThatMovesItsModule(t *testing.T) {
|
||||||
|
g := aGateMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := g.open.inventory
|
||||||
|
advancePlans(ctx, g.open) // anchor is sent app c2 first
|
||||||
|
if err := inv.RecordSent(ctx, nodeID(t, g.open, "anchor"), "d-anchor-c3", map[string]string{"app": "c3"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gateEvery = 0
|
||||||
|
advancePlans(ctx, g.open)
|
||||||
|
p := g.plan(t)
|
||||||
|
if p.State != inventory.PlanSuperseded || !strings.Contains(p.Note, "superseded") || !strings.Contains(p.Note, "c3") {
|
||||||
|
t.Fatalf("the plan is %s: %s", p.State, p.Note)
|
||||||
|
}
|
||||||
|
// Nothing put back: the registered build stands, the build is not marked, and the plan's gate made no
|
||||||
|
// rollback (a release may walk what the other send left waiting on anchor; that is not a put-back).
|
||||||
|
if r := p.Modules["app"].Gate.Rollback; r != "" {
|
||||||
|
t.Fatalf("a superseded judging made a rollback: %q", r)
|
||||||
|
}
|
||||||
|
if current, _ := inv.CurrentBuilds(ctx); current["app"].Commit != "c2" {
|
||||||
|
t.Fatalf("app was put back to %s", current["app"].Commit)
|
||||||
|
}
|
||||||
|
if failed, _ := inv.GateFailed(ctx, "build-2"); failed {
|
||||||
|
t.Fatal("a superseded build was marked failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testASendThatMovesTheModuleSupersedesTheJudging(t *testing.T) {
|
||||||
|
b := aBacklog(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := b.open.inventory
|
||||||
|
advancePlans(ctx, b.open)
|
||||||
|
// Another send moves app on anchor to a build this gate does not judge.
|
||||||
|
carried, _, _ := inv.SentBuilds(ctx, "anchor")
|
||||||
|
carried["app"] = "c3"
|
||||||
|
if err := inv.RecordSent(ctx, nodeID(t, b.open, "anchor"), "d-anchor-c3", carried); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gateEvery = 0
|
||||||
|
advancePlans(ctx, b.open)
|
||||||
|
p := b.release(t)
|
||||||
|
if p.State != inventory.PlanSuperseded || !strings.Contains(p.Note, "superseded") || !strings.Contains(p.Note, "c3") {
|
||||||
|
t.Fatalf("the release is %s: %s", p.State, p.Note)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(b.sent, [][]string{{"anchor"}}) {
|
||||||
|
t.Fatalf("sent %v: a superseded judging puts nothing back", b.sent)
|
||||||
|
}
|
||||||
|
if _, found, _ := inv.GateOf(ctx, "build-app-c2"); found {
|
||||||
|
t.Fatal("a superseded judging kept a verdict")
|
||||||
|
}
|
||||||
|
if current, _ := inv.CurrentBuilds(ctx); current["app"].Commit != "c2" {
|
||||||
|
t.Fatalf("app was put back to %s", current["app"].Commit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A report is on the gate's own send: the declaration itself, or one sequenced after it; a gate kept
|
||||||
|
// without its send reads the report against the send made last, as before. Pure.
|
||||||
|
func TestAReportIsHeldAgainstTheGatesOwnSend(t *testing.T) {
|
||||||
|
sent := inventory.SentDeclaration{Digest: "d-490", Sequence: 490}
|
||||||
|
for _, c := range []struct {
|
||||||
|
r inventory.Reported
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{inventory.Reported{Declared: "d-490", Current: false}, true},
|
||||||
|
{inventory.Reported{Declared: "d-491", ReportedSequence: 491, Current: true}, true},
|
||||||
|
{inventory.Reported{Declared: "d-489", ReportedSequence: 489, Current: false}, false},
|
||||||
|
{inventory.Reported{Declared: "other", ReportedSequence: 490}, true}, // the same sequence, said by another digest
|
||||||
|
{inventory.Reported{Declared: "d-495", ReportedSequence: 495, Current: true}, true}, // the last send: this one or a later one
|
||||||
|
{inventory.Reported{Declared: "", Current: false}, false},
|
||||||
|
} {
|
||||||
|
if got := sent.ReportsOn(c.r); got != c.want {
|
||||||
|
t.Errorf("%+v on %+v: %v", c.r, sent, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byDigest := inventory.SentDeclaration{Digest: "d-1"}
|
||||||
|
if !byDigest.ReportsOn(inventory.Reported{Declared: "d-1"}) || byDigest.ReportsOn(inventory.Reported{ReportedSequence: 5}) ||
|
||||||
|
!byDigest.ReportsOn(inventory.Reported{Current: true}) {
|
||||||
|
t.Fatal("a send kept without a sequence is matched by its digest and by the last send alone")
|
||||||
|
}
|
||||||
|
f := gateFacts{sent: map[string]inventory.SentDeclaration{"anchor": sent}}
|
||||||
|
if !f.reportedOn("anchor", inventory.Reported{Declared: "d-490"}) || f.reportedOn("anchor", inventory.Reported{Declared: "d-1"}) {
|
||||||
|
t.Fatal("a gate that kept its send read the report against something other than it")
|
||||||
|
}
|
||||||
|
if !f.reportedOn("laptop", inventory.Reported{Current: true}) || f.reportedOn("laptop", inventory.Reported{Current: false}) {
|
||||||
|
t.Fatal("a gate that did not keep its send does not read the report against the send made last")
|
||||||
|
}
|
||||||
|
// Through the merge plan's first-machine wait too.
|
||||||
|
at := time.Now()
|
||||||
|
state := inventory.PlanModule{First: []string{"anchor"}, FirstAt: &at,
|
||||||
|
Gate: &inventory.PlanGate{Machines: []string{"anchor"}, Sent: map[string]inventory.SentDeclaration{"anchor": sent}}}
|
||||||
|
reports := []inventory.Reported{{Node: "anchor", At: &at, Outcome: inventory.OutcomeApplied, Current: false, Declared: "d-490"}}
|
||||||
|
if step := nextRollout(state, []string{"anchor", "laptop"}, false, reports, at.Add(time.Minute), time.Hour); step.waiting != "" || step.failed != "" {
|
||||||
|
t.Fatalf("the first machine's report on the plan's own send read as none: %+v", step)
|
||||||
|
}
|
||||||
|
reports[0].Declared = "d-480"
|
||||||
|
if step := nextRollout(state, []string{"anchor", "laptop"}, false, reports, at.Add(time.Minute), time.Hour); step.waiting == "" {
|
||||||
|
t.Fatalf("a report on an older send read as the plan's: %+v", step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A gate judges only the build the machine was last sent: last sent another build of the module, the
|
||||||
|
// judging is superseded, whatever the machine reports. Pure.
|
||||||
|
func TestAGateJudgesOnlyTheBuildTheMachineWasLastSent(t *testing.T) {
|
||||||
|
at := time.Now()
|
||||||
|
f := gateFacts{now: at, reports: map[string]inventory.Reported{"anchor": {Node: "anchor", Outcome: inventory.OutcomeApplied,
|
||||||
|
At: &at, Current: true}}, engines: map[string]string{}, served: map[string]served{}, rolledBack: map[string][]lease.Rollback{},
|
||||||
|
commits: map[string]string{"mesh-controller": "e6b00e2e"}, sentBuilds: map[string]map[string]string{"anchor": {"mesh-controller": "ef26d4cb"}}}
|
||||||
|
taken := at.Add(-30 * time.Second)
|
||||||
|
f.holder = &lease.Holder{Taken: taken, Health: &lease.Health{Ready: true}}
|
||||||
|
h, why := judgeHealth("mesh-controller", lease.ComponentController, catalogue.Manifest{}, "anchor", at.Add(-time.Minute), f)
|
||||||
|
if h != healthSuperseded || !strings.Contains(why, "ef26d4cb") || !strings.Contains(why, "e6b00e2e") {
|
||||||
|
t.Fatalf("a controller build the machine no longer runs: %v %q", h, why)
|
||||||
|
}
|
||||||
|
f.sentBuilds["anchor"]["mesh-controller"] = "e6b00e2e"
|
||||||
|
if h, why := judgeHealth("mesh-controller", lease.ComponentController, catalogue.Manifest{}, "anchor", at.Add(-time.Minute), f); h == healthSuperseded {
|
||||||
|
t.Fatalf("the build sent read as another: %q", why)
|
||||||
|
}
|
||||||
|
delete(f.sentBuilds, "anchor")
|
||||||
|
if h, why := judgeHealth("mesh-controller", lease.ComponentController, catalogue.Manifest{}, "anchor", at.Add(-time.Minute), f); h == healthSuperseded {
|
||||||
|
t.Fatalf("a machine whose send is not known read as superseded: %q", why)
|
||||||
|
}
|
||||||
|
g := &inventory.PlanGate{To: "c2", Carried: []inventory.CarriedMove{{Module: "late", Node: "anchor", To: "c5"}}}
|
||||||
|
if got := judgedCommits(g, []judged{{"app", "anchor"}, {"late", "anchor"}}); got["app"] != "c2" || got["late"] != "c5" {
|
||||||
|
t.Fatalf("judged commits %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A move of another build of a module to a machine where a release or a plan is judging that module
|
||||||
|
// waits for that judging; the same build to that machine is already there. Pure.
|
||||||
|
func TestAWalkWaitsForAJudgingOfTheSameModuleOnThatMachine(t *testing.T) {
|
||||||
|
at := time.Now()
|
||||||
|
release := inventory.Plan{ID: "release-1", State: inventory.PlanRolling, Release: &inventory.PlanRelease{
|
||||||
|
Gate: &inventory.PlanGate{Machines: []string{"novox"}, Carried: []inventory.CarriedMove{
|
||||||
|
{Module: "mesh-controller", Node: "novox", From: "ef26d4cb", To: "2913c54c"}}}}}
|
||||||
|
merge := inventory.Plan{ID: "plan-1", State: inventory.PlanRolling, Modules: map[string]*inventory.PlanModule{
|
||||||
|
"app": {First: []string{"anchor"}, FirstAt: &at, Commit: "c2", Gate: &inventory.PlanGate{Machines: []string{"anchor"}}}}}
|
||||||
|
f := moveFacts{plans: []inventory.Plan{release, merge}}
|
||||||
|
for _, c := range []struct {
|
||||||
|
module, node, to, want string
|
||||||
|
}{
|
||||||
|
{"mesh-controller", "novox", "e6b00e2e", "release-1"}, // the day's case: a newer controller to the judged machine
|
||||||
|
{"mesh-controller", "novox", "2913c54c", ""}, // the same build: already there
|
||||||
|
{"mesh-controller", "ace", "2913c54c", "release-1"}, // another machine while the first is judged
|
||||||
|
{"mesh-host", "novox", "x", ""}, // a module the release does not carry
|
||||||
|
{"app", "anchor", "c2", ""},
|
||||||
|
{"app", "anchor", "c3", "plan-1"},
|
||||||
|
{"app", "laptop", "c2", "plan-1"},
|
||||||
|
} {
|
||||||
|
if got := f.walkedBy(c.module, c.node, c.to); got != c.want {
|
||||||
|
t.Errorf("%s %s to %s: walked by %q, want %q", c.module, c.to, c.node, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
release.Release.Gate.Verdict = inventory.GatePassed
|
||||||
|
merge.Modules["app"].Gate.Verdict = inventory.GatePassed
|
||||||
|
if f.walkedBy("mesh-controller", "novox", "e6b00e2e") != "" || f.walkedBy("app", "laptop", "c3") != "" {
|
||||||
|
t.Fatal("a passed judging still holds a move")
|
||||||
|
}
|
||||||
|
release.Release.Gate.Verdict = ""
|
||||||
|
f.plans[0].State = inventory.PlanSuperseded
|
||||||
|
if f.walkedBy("mesh-controller", "novox", "e6b00e2e") != "" {
|
||||||
|
t.Fatal("a closed release still holds a move")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The controller is never put back to a build that reaches less of the store's schema than the store
|
||||||
|
// has, or to one that never said what it reaches: the current build is kept, and the condition says so.
|
||||||
|
func TestTheControllerIsNotPutBackToABuildOlderThanTheStoresSchema(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := open.inventory
|
||||||
|
keeper, _ := withConditionsInMemory(t)
|
||||||
|
told := &conditions.Told{}
|
||||||
|
was := doctorFrom
|
||||||
|
doctorFrom = &doctor{open: open, keeper: keeper, teller: told}
|
||||||
|
t.Cleanup(func() { doctorFrom = was })
|
||||||
|
wasSend := sendRollout
|
||||||
|
var sent [][]string
|
||||||
|
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
||||||
|
sent = append(sent, names)
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { sendRollout = wasSend })
|
||||||
|
|
||||||
|
build := func(id, commit, digest string, asked time.Time) inventory.Build {
|
||||||
|
manifest, _ := json.Marshal(catalogue.Manifest{Module: "mesh-controller", Version: commit})
|
||||||
|
b := inventory.Build{ID: id, Module: "mesh-controller", Commit: commit, Repository: "novox/mesh-controller", Path: ".",
|
||||||
|
Manifest: manifest, Asked: asked, At: asked, Made: []inventory.Artifact{{Name: "controller", Kind: catalogue.ArtifactBundle,
|
||||||
|
Reference: "mesh-artifact://mesh-controller/controller/blobs/sha256:" + digest}}}
|
||||||
|
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-controller", Version: commit},
|
||||||
|
inventory.Source{Repository: "novox/mesh-controller", Seat: "git", Path: ".", BuiltFrom: commit, Head: commit, Asked: asked}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
previous := build("build-old", "ef26d4cb", strings.Repeat("1", 64), time.Now().Add(-2*time.Hour))
|
||||||
|
failed := build("build-new", "e6b00e2e", strings.Repeat("2", 64), time.Now().Add(-time.Minute))
|
||||||
|
if versionOfBuild(previous) != strings.Repeat("1", 12) {
|
||||||
|
t.Fatalf("the build's version is %q", versionOfBuild(previous))
|
||||||
|
}
|
||||||
|
applied, err := inv.SchemaApplied(ctx)
|
||||||
|
if err != nil || applied < 87 {
|
||||||
|
t.Fatalf("the store's schema reaches %d (%v)", applied, err)
|
||||||
|
}
|
||||||
|
// The build before never recorded what it reads: not proved, refused.
|
||||||
|
if why, ok := controllerSchemaAllows(ctx, inv, previous); ok || !strings.Contains(why, "never recorded") {
|
||||||
|
t.Fatalf("an unknown reach: %v %q", ok, why)
|
||||||
|
}
|
||||||
|
// It reads less than the store has: refused, naming both.
|
||||||
|
if err := inv.RecordSchemaReach(ctx, versionOfBuild(previous), applied-1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if why, ok := controllerSchemaAllows(ctx, inv, previous); ok || !strings.Contains(why, "is at") {
|
||||||
|
t.Fatalf("a reach behind the store: %v %q", ok, why)
|
||||||
|
}
|
||||||
|
// Through the gate: the failed build is marked, nothing is put back, nothing is sent, the condition is urgent.
|
||||||
|
at := time.Now().Add(-5 * time.Minute)
|
||||||
|
state := &inventory.PlanModule{Build: failed.ID, Commit: failed.Commit, Previous: previous.Commit, First: []string{"anchor"}, FirstAt: &at}
|
||||||
|
p := inventory.Plan{ID: "plan-352", Repository: "novox/mesh-controller", Branch: "main", Commit: failed.Commit, Created: at,
|
||||||
|
State: inventory.PlanRolling, Tiers: [][]string{{"mesh-controller"}}, Modules: map[string]*inventory.PlanModule{"mesh-controller": state}}
|
||||||
|
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gateFailed(ctx, open, &p, "mesh-controller", state, []string{"anchor"}, "not healthy within 10m0s of its apply")
|
||||||
|
if state.Gate.Rollback != inventory.NotRolledBack || !strings.Contains(p.Note, "NOT put back") || !strings.Contains(p.Note, "the current build is kept") {
|
||||||
|
t.Fatalf("rollback %q: %s", state.Gate.Rollback, p.Note)
|
||||||
|
}
|
||||||
|
if len(sent) != 0 {
|
||||||
|
t.Fatalf("sent %v: nothing is put back", sent)
|
||||||
|
}
|
||||||
|
if current, _ := inv.CurrentBuilds(ctx); current["mesh-controller"].Commit != failed.Commit {
|
||||||
|
t.Fatalf("the module was put back to %s", current["mesh-controller"].Commit)
|
||||||
|
}
|
||||||
|
if marked, _ := inv.GateFailed(ctx, failed.ID); !marked {
|
||||||
|
t.Fatal("the failed build is not marked failed at its gate")
|
||||||
|
}
|
||||||
|
open2, _ := keeper.Open(ctx)
|
||||||
|
var found bool
|
||||||
|
for _, c := range open2 {
|
||||||
|
if c.Kind == kindRollbackFailed && c.Severity == conditions.Urgent && strings.Contains(c.Summary, "current build is kept") {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("no urgent rollback-failed condition saying the current build is kept: %+v", open2)
|
||||||
|
}
|
||||||
|
// Reaching the store: allowed.
|
||||||
|
if err := inv.RecordSchemaReach(ctx, versionOfBuild(previous), applied); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if why, ok := controllerSchemaAllows(ctx, inv, previous); !ok {
|
||||||
|
t.Fatalf("a build that reads the whole schema was refused: %q", why)
|
||||||
|
}
|
||||||
|
// A build with no bundle to know it by: refused.
|
||||||
|
if why, ok := controllerSchemaAllows(ctx, inv, inventory.Build{Commit: "x"}); ok || !strings.Contains(why, "names no bundle") {
|
||||||
|
t.Fatalf("a build without a bundle: %v %q", ok, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lease's holder names the build the declaration told it it is, and the version stamp only without one.
|
||||||
|
func TestTheHolderNamesTheBuildTheDeclarationToldIt(t *testing.T) {
|
||||||
|
t.Setenv(RunningBuildVar, " ad62528c47c7 ")
|
||||||
|
if h := holderOf("x"); h.Build != "ad62528c47c7" {
|
||||||
|
t.Fatalf("the holder's build is %q", h.Build)
|
||||||
|
}
|
||||||
|
t.Setenv(RunningBuildVar, "")
|
||||||
|
if h := holderOf("x"); h.Build != version {
|
||||||
|
t.Fatalf("without a declared version the holder's build is %q", h.Build)
|
||||||
|
}
|
||||||
|
if reach, err := schemaReach(); err != nil || reach < 87 {
|
||||||
|
t.Fatalf("this build's reach is %d (%v)", reach, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,12 @@ func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliV
|
|||||||
if v.refused != "" {
|
if v.refused != "" {
|
||||||
return link.CLIRefusal(v.refused)
|
return link.CLIRefusal(v.refused)
|
||||||
}
|
}
|
||||||
|
// Standard input is the terminal's alone: a secret given at the terminal reaches `secret accept`, and no ordinary
|
||||||
|
// call is handed what the asker's standard input held (novox/hq ADR 0259 §10, ADR 0272).
|
||||||
|
if len(asked.Stdin) > 0 && !v.terminal {
|
||||||
|
return link.CLIAnswer{Exit: 1, Why: v.why, Refused: "standard input is given to a line that runs as the " +
|
||||||
|
"controller's terminal alone, and this one does not. Nothing ran"}
|
||||||
|
}
|
||||||
if cliServers[asked.Line[0]] {
|
if cliServers[asked.Line[0]] {
|
||||||
return link.CLIAnswer{Exit: 1, Why: v.why, Refused: fmt.Sprintf("%s serves until stopped, and is not a "+
|
return link.CLIAnswer{Exit: 1, Why: v.why, Refused: fmt.Sprintf("%s serves until stopped, and is not a "+
|
||||||
"command line mesh-cli runs. Nothing ran", asked.Line[0])}
|
"command line mesh-cli runs. Nothing ran", asked.Line[0])}
|
||||||
@@ -155,8 +161,12 @@ func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliV
|
|||||||
}
|
}
|
||||||
cmd := selfCommand(ctx, line)
|
cmd := selfCommand(ctx, line)
|
||||||
cmd.Env = commandEnvironment(fmt.Sprintf("%s through mesh-cli on %s", asked.Account, node), verb, v.terminal)
|
cmd.Env = commandEnvironment(fmt.Sprintf("%s through mesh-cli on %s", asked.Account, node), verb, v.terminal)
|
||||||
// No standard input: a command that reads one gets nothing, and fails saying so (ADR 0272 §5).
|
// No standard input unless mesh-cli carried one for a terminal line: a command that reads one gets nothing, and
|
||||||
|
// fails saying so (ADR 0272 §5).
|
||||||
cmd.Stdin = nil
|
cmd.Stdin = nil
|
||||||
|
if len(asked.Stdin) > 0 {
|
||||||
|
cmd.Stdin = bytes.NewReader(asked.Stdin)
|
||||||
|
}
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// secretAcceptWants makes the test binary, run as a command line, read a secret as `secret accept` reads it and
|
||||||
|
// say whether it is the value whose SHA-256 the variable names (TestMain).
|
||||||
|
const secretAcceptWants = "MESH_TEST_SECRET_ACCEPT_WANTS"
|
||||||
|
|
||||||
|
// readAsSecretAccept is that process: `secret accept <node> <module> <name> [--from -]`, the value read by
|
||||||
|
// valueFor, compared by digest, and only the verdict printed.
|
||||||
|
func readAsSecretAccept(want string, argv []string) int {
|
||||||
|
if len(argv) < 5 || argv[0] != "secret" || argv[1] != "accept" {
|
||||||
|
fmt.Printf("not a secret accept line: %q\n", argv)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
from := ""
|
||||||
|
if len(argv) == 7 && argv[5] == "--from" {
|
||||||
|
from = argv[6]
|
||||||
|
}
|
||||||
|
value, err := valueFor(argv[2], argv[3], argv[4], from)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("secret accept read nothing: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(asSupplied(value)))
|
||||||
|
if hex.EncodeToString(sum[:]) != want {
|
||||||
|
fmt.Printf("secret accept read something else (%d bytes)\n", len(value))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Println("secret accept read the value it was given")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0259 §10, ADR 0272: what mesh-cli's standard input held reaches `secret accept` on a line that runs
|
||||||
|
// as the controller's terminal, and appears nowhere else — not in the answer, not in the journal, not in the calls
|
||||||
|
// record; an ordinary line carrying it is refused and nothing runs.
|
||||||
|
func TestStandardInputReachesSecretAcceptAtTheTerminalAndNowhereElse(t *testing.T) {
|
||||||
|
token := "123456789:AAEhBOweik6ad9r_QxGivenAtTheTerminal"
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
t.Setenv(secretAcceptWants, hex.EncodeToString(sum[:]))
|
||||||
|
var journal []string
|
||||||
|
var mu sync.Mutex
|
||||||
|
was := cliJournal
|
||||||
|
cliJournal = func(line string) { mu.Lock(); journal = append(journal, line); mu.Unlock() }
|
||||||
|
t.Cleanup(func() { cliJournal = was })
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, line := range [][]string{
|
||||||
|
{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"},
|
||||||
|
{"secret", "accept", "anchor", "telegram", "telegram-token"}, // the prompt's path, a line on standard input
|
||||||
|
} {
|
||||||
|
asked := cliAsked("operator", 1000, line...)
|
||||||
|
asked.Stdin = []byte(token + "\n")
|
||||||
|
a := runForMeshCLI(ctx, "control", asked, cliVerdict{terminal: true, why: "the terminal"})
|
||||||
|
if a.Exit != 0 || !strings.Contains(string(a.Stdout), "read the value it was given") {
|
||||||
|
t.Fatalf("%q: secret accept did not read what mesh-cli carried: %+v (%s)", line, a, a.Stdout)
|
||||||
|
}
|
||||||
|
if body, _ := json.Marshal(a); strings.Contains(string(body), "AAEh") || strings.Contains(string(body), base64.StdEncoding.EncodeToString([]byte(token))) {
|
||||||
|
t.Fatalf("the answer carries the secret: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without standard input, the line reads nothing, as before.
|
||||||
|
a := runForMeshCLI(ctx, "control", cliAsked("operator", 1000, "secret", "accept", "anchor", "telegram",
|
||||||
|
"telegram-token", "--from", "-"), cliVerdict{terminal: true, why: "the terminal"})
|
||||||
|
if a.Exit == 0 {
|
||||||
|
t.Fatalf("a line with no standard input read a value: %+v", a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ordinary call is never handed it: refused, and nothing ran.
|
||||||
|
asked := cliAsked("operator", 1000, "status")
|
||||||
|
asked.Stdin = []byte(token)
|
||||||
|
a = runForMeshCLI(ctx, "laptop", asked, cliVerdict{why: "not the terminal"})
|
||||||
|
if a.Exit == 0 || !strings.Contains(a.Refused, "terminal alone") || len(a.Stdout) != 0 {
|
||||||
|
t.Fatalf("an ordinary line was given standard input: %+v", a)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
for _, l := range journal {
|
||||||
|
if strings.Contains(l, "AAEh") {
|
||||||
|
t.Fatalf("the journal says the secret: %s", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(journal) == 0 {
|
||||||
|
t.Fatal("the lines were not said in the journal at all")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -76,6 +76,21 @@ func serve(ctx context.Context) (err error) {
|
|||||||
}
|
}
|
||||||
defer open.Close()
|
defer open.Close()
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
|
// **What this build reads of the store's schema, on record** (novox/hq issue 352): the highest migration
|
||||||
|
// it carries, by its version, so a gate that would put this build back later knows it reads the store
|
||||||
|
// as it is then. A build that does not know its version records nothing, and is never put back.
|
||||||
|
if build := runningBuild(); build != "" {
|
||||||
|
if reach, err := schemaReach(); err != nil {
|
||||||
|
fmt.Printf("what this build reads of the store's schema is not recorded: %v\n", err)
|
||||||
|
} else if err := inv.RecordSchemaReach(ctx, build, reach); err != nil {
|
||||||
|
fmt.Printf("what this build (%s) reads of the store's schema is not recorded: %v\n", build, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("this build (%s) reads the store's schema up to migration %04d; recorded\n", build, reach)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("this process was not told which build it is (%s), so what it reads of the store's schema is not "+
|
||||||
|
"recorded, and a gate will never put it back\n", RunningBuildVar)
|
||||||
|
}
|
||||||
|
|
||||||
ident, err := openIdentity(ctx)
|
ident, err := openIdentity(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1598,3 +1613,18 @@ func reportUnheldPushed(w io.Writer, named bool, asked []string, unheld map[stri
|
|||||||
fmt.Fprintf(w, "%s: %d unmet seat dependenc(ies) — see `status`\n", node, len(lines))
|
fmt.Fprintf(w, "%s: %d unmet seat dependenc(ies) — see `status`\n", node, len(lines))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// schemaReach is the highest migration this build carries for the inventory's store (novox/hq issue 352).
|
||||||
|
func schemaReach() (int, error) {
|
||||||
|
migrations, err := inventory.Migrations()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
reach := 0
|
||||||
|
for _, m := range migrations {
|
||||||
|
if m.Number > reach {
|
||||||
|
reach = m.Number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reach, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -180,12 +180,35 @@ func (f moveFacts) moves(node string, modules []string, sent map[string]string,
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// walkedBy is the open plan that has started walking a module's build — sent it to a first machine,
|
// walkedBy is the open plan that has started walking a module's build — sent it to a first machine, not
|
||||||
// not yet passed — other than to this machine; empty when none does.
|
// yet passed — and whose walk this move would cross; empty when none does. A move of the same build to a
|
||||||
func (f moveFacts) walkedBy(module, node string) string {
|
// machine that plan already sent it is not a crossing: the build is there. A move of **another** build of
|
||||||
|
// the module to that machine is (novox/hq issue 352): on 2026-10-09 a merge's plan sent the control node a
|
||||||
|
// newer controller while a release's gate was judging the controller there, the release's gate read the
|
||||||
|
// machine's report against the newer send, failed three builds and put them back under the new plan's
|
||||||
|
// feet. A release keeps no module records: its open gate's carried moves are its walk.
|
||||||
|
func (f moveFacts) walkedBy(module, node, to string) string {
|
||||||
for _, p := range f.plans {
|
for _, p := range f.plans {
|
||||||
|
if !p.Open() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Release != nil {
|
||||||
|
g := p.Release.Gate
|
||||||
|
if g == nil || g.Verdict != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range g.Carried {
|
||||||
|
if c.Module == module && !(slices.Contains(g.Machines, node) && sameCommit(c.To, to)) {
|
||||||
|
return p.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
s, holds := p.Modules[module]
|
s, holds := p.Modules[module]
|
||||||
if !p.Open() || !holds || s == nil || s.FirstAt == nil || s.SentAt != nil || slices.Contains(s.First, node) {
|
if !holds || s == nil || s.FirstAt == nil || s.SentAt != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains(s.First, node) && sameCommit(s.Commit, to) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if s.Gate != nil && s.Gate.Verdict == inventory.GatePassed {
|
if s.Gate != nil && s.Gate.Verdict == inventory.GatePassed {
|
||||||
@@ -277,11 +300,19 @@ func gatedSend(ctx context.Context, open *stores, node string, owns []inventory.
|
|||||||
if own(mv.Module) {
|
if own(mv.Module) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if id := f.walkedBy(mv.Module, node); id != "" {
|
if id := f.walkedBy(mv.Module, node, mv.To); id != "" {
|
||||||
return nil, nil, fmt.Errorf("%w: %s's build %s waits on %s, which %s is walking", errWalkedElsewhere,
|
return nil, nil, fmt.Errorf("%w: %s's build %s waits on %s, which %s is walking", errWalkedElsewhere,
|
||||||
mv.Module, short(mv.To), node, id)
|
mv.Module, short(mv.To), node, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// **And the plan's own modules wait too** (novox/hq issue 352): a walk of the same module by another
|
||||||
|
// plan, or a release, on this machine is not crossed with a newer build; this send waits for its gate.
|
||||||
|
for _, o := range owns {
|
||||||
|
if id := f.walkedBy(o.Module, node, o.To); id != "" {
|
||||||
|
return nil, nil, fmt.Errorf("%w: %s's build %s waits on %s, which %s is walking", errWalkedElsewhere,
|
||||||
|
o.Module, short(o.To), node, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, o := range owns {
|
for _, o := range owns {
|
||||||
i := slices.IndexFunc(moves, func(mv inventory.CarriedMove) bool { return mv.Module == o.Module })
|
i := slices.IndexFunc(moves, func(mv inventory.CarriedMove) bool { return mv.Module == o.Module })
|
||||||
switch {
|
switch {
|
||||||
@@ -526,7 +557,7 @@ func waitingMoves(ctx context.Context, open *stores, all bool) (map[string][]inv
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, mv := range moves {
|
for _, mv := range moves {
|
||||||
if f.walkedBy(mv.Module, n.Name) == "" {
|
if f.walkedBy(mv.Module, n.Name, mv.To) == "" {
|
||||||
out[n.Name] = append(out[n.Name], mv)
|
out[n.Name] = append(out[n.Name], mv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -658,7 +689,7 @@ func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool,
|
|||||||
r.Next++
|
r.Next++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
r.Gate = &inventory.PlanGate{Machines: sent, Since: &now, Carried: moves}
|
r.Gate = &inventory.PlanGate{Machines: sent, Since: &now, Carried: moves, Sent: sentNow(ctx, open.inventory, sent)}
|
||||||
p.Note = fmt.Sprintf("sent %s %d build(s) that waited for a gate; judging them there", node, len(moves))
|
p.Note = fmt.Sprintf("sent %s %d build(s) that waited for a gate; judging them there", node, len(moves))
|
||||||
if said := recreationsSaid(moves); said != "" {
|
if said := recreationsSaid(moves); said != "" {
|
||||||
p.Note += "; " + said
|
p.Note += "; " + said
|
||||||
@@ -693,6 +724,15 @@ func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool,
|
|||||||
r.Next++
|
r.Next++
|
||||||
r.Gate = nil
|
r.Gate = nil
|
||||||
return true, nil
|
return true, nil
|
||||||
|
case inventory.GateSuperseded:
|
||||||
|
// Another send moved a judged module on the judged machine (novox/hq issue 352): no verdict on what
|
||||||
|
// was carried, nothing put back, and this release ends; the builds still waiting are released again
|
||||||
|
// by the next pass, judged afresh.
|
||||||
|
p.State = inventory.PlanSuperseded
|
||||||
|
p.Note = fmt.Sprintf("superseded on %s: %s — nothing judged, nothing put back; what still waits is released again",
|
||||||
|
strings.Join(g.Machines, ", "), g.Why)
|
||||||
|
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||||
|
return true, nil
|
||||||
}
|
}
|
||||||
p.Note = ""
|
p.Note = ""
|
||||||
batched, back := batchingRollbacks(ctx)
|
batched, back := batchingRollbacks(ctx)
|
||||||
|
|||||||
@@ -793,6 +793,15 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
|||||||
case inventory.GateFailed:
|
case inventory.GateFailed:
|
||||||
failFirstSend(ctx, open, p, m, state, state.Gate.Machines, state.Gate.Why, step.rest)
|
failFirstSend(ctx, open, p, m, state, state.Gate.Machines, state.Gate.Why, step.rest)
|
||||||
return true, nil
|
return true, nil
|
||||||
|
case inventory.GateSuperseded:
|
||||||
|
// Another send moved this module on its first machine (novox/hq issue 352): the build is not
|
||||||
|
// judged, not marked, not put back; the plan ends here, said, and a newer plan carries on.
|
||||||
|
state.Why = "superseded: " + state.Gate.Why
|
||||||
|
p.State = inventory.PlanSuperseded
|
||||||
|
p.Note = fmt.Sprintf("%s's judging on %s was superseded: %s", m, strings.Join(state.Gate.Machines, ", "),
|
||||||
|
state.Gate.Why)
|
||||||
|
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||||
|
return true, nil
|
||||||
case inventory.GatePassed:
|
case inventory.GatePassed:
|
||||||
if !state.Gate.Kept {
|
if !state.Gate.Kept {
|
||||||
gatePassed(ctx, open, p, m, state)
|
gatePassed(ctx, open, p, m, state)
|
||||||
@@ -964,6 +973,9 @@ func firstSend(ctx context.Context, open *stores, p *inventory.Plan, node string
|
|||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
lead := modules[0]
|
lead := modules[0]
|
||||||
|
// What each machine was just sent, kept on the gate (novox/hq issue 352): its report is held against
|
||||||
|
// this send, whatever it is sent after.
|
||||||
|
sentWhat := sentNow(ctx, inv, sent)
|
||||||
for _, m := range modules {
|
for _, m := range modules {
|
||||||
s := p.Modules[m]
|
s := p.Modules[m]
|
||||||
// What the first machine ran before: what a failed gate puts back (ADR 0236).
|
// What the first machine ran before: what a failed gate puts back (ADR 0236).
|
||||||
@@ -972,7 +984,7 @@ func firstSend(ctx context.Context, open *stores, p *inventory.Plan, node string
|
|||||||
}
|
}
|
||||||
s.First, s.FirstAt = sent, &now
|
s.First, s.FirstAt = sent, &now
|
||||||
s.Gate = &inventory.PlanGate{Component: coreComponent(m), Machines: firstRunning(sent, runningOf[m]),
|
s.Gate = &inventory.PlanGate{Component: coreComponent(m), Machines: firstRunning(sent, runningOf[m]),
|
||||||
From: s.Previous, To: s.Commit, Since: &now}
|
From: s.Previous, To: s.Commit, Since: &now, Sent: sentWhat}
|
||||||
s.GatedBy = ""
|
s.GatedBy = ""
|
||||||
if m == lead {
|
if m == lead {
|
||||||
s.Gate.Carried = carried
|
s.Gate.Carried = carried
|
||||||
@@ -1131,8 +1143,15 @@ func nextRollout(s inventory.PlanModule, running []string, together bool, report
|
|||||||
var waiting, failed []string
|
var waiting, failed []string
|
||||||
for _, n := range s.First {
|
for _, n := range s.First {
|
||||||
r, said := byNode[n]
|
r, said := byNode[n]
|
||||||
// Only a report about what it was last sent says anything about this build.
|
// Only a report about what this plan sent it — or what it was sent after that — says anything about
|
||||||
if !said || r.At == nil || !r.Current {
|
// this build (novox/hq issue 352); a plan from before sends were kept on the gate reads the report
|
||||||
|
// against the send made last, as before.
|
||||||
|
reported := r.Current
|
||||||
|
if s.Gate != nil && s.Gate.Sent != nil {
|
||||||
|
sent, kept := s.Gate.Sent[n]
|
||||||
|
reported = kept && sent.ReportsOn(r)
|
||||||
|
}
|
||||||
|
if !said || r.At == nil || !reported {
|
||||||
waiting = append(waiting, n)
|
waiting = append(waiting, n)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ import (
|
|||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
// The process a mesh-cli test runs as a command line: it says the verb and the caller it was given, and
|
// The process a mesh-cli test runs as a command line: it says the verb and the caller it was given, and
|
||||||
// ends (meshcli_test.go).
|
// ends (meshcli_test.go).
|
||||||
|
// The process a mesh-cli test runs as `secret accept`: it reads its value exactly as `secret accept` does
|
||||||
|
// (valueFor) and says whether it is the one the test gave, never the value (meshcli_stdin_test.go).
|
||||||
|
if want := os.Getenv(secretAcceptWants); want != "" {
|
||||||
|
os.Exit(readAsSecretAccept(want, os.Args[1:]))
|
||||||
|
}
|
||||||
if os.Getenv(echoEnvironment) != "" {
|
if os.Getenv(echoEnvironment) != "" {
|
||||||
fmt.Printf("verb=%q caller=%q terminal=%v\n", os.Getenv("MESH_VERB"), os.Getenv("MESH_CALLER"), startedAtTheTerminal())
|
fmt.Printf("verb=%q caller=%q terminal=%v\n", os.Getenv("MESH_VERB"), os.Getenv("MESH_CALLER"), startedAtTheTerminal())
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
|
|||||||
@@ -762,6 +762,11 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
|||||||
argv = append(argv, "--probe", p)
|
argv = append(argv, "--probe", p)
|
||||||
}
|
}
|
||||||
return append(argv, "--json"), nil
|
return append(argv, "--json"), nil
|
||||||
|
case "give":
|
||||||
|
if err := need("node", "module", "secret", "at"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []string{"secret", "accept", str("node"), str("module"), str("secret"), "--at-desk", str("at")}, nil
|
||||||
case "rotate":
|
case "rotate":
|
||||||
if p := str("provision"); p != "" {
|
if p := str("provision"); p != "" {
|
||||||
argv := []string{"rotate", p}
|
argv := []string{"rotate", p}
|
||||||
@@ -1495,6 +1500,20 @@ var terminalOnlyCommands = map[string]string{
|
|||||||
"licence": "the licences' secrets",
|
"licence": "the licences' secrets",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// givenAtTheDesk is exactly the line the `give` verb composes, and nothing beside it: `secret accept <node>
|
||||||
|
// <module> <secret> --at-desk <machine>`, with no other word — no value, no file, no provider.
|
||||||
|
func givenAtTheDesk(argv []string) bool {
|
||||||
|
if len(argv) != 7 || argv[0] != "secret" || argv[1] != "accept" || argv[5] != "--at-desk" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, w := range argv[2:5] {
|
||||||
|
if w == "" || strings.HasPrefix(w, "-") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return argv[6] != "" && !strings.HasPrefix(argv[6], "-")
|
||||||
|
}
|
||||||
|
|
||||||
// terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal
|
// terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal
|
||||||
// alone (novox/hq ADR 0266). **Every `node` subcommand that is not a read**: `node account` and
|
// alone (novox/hq ADR 0266). **Every `node` subcommand that is not a read**: `node account` and
|
||||||
// `node agent-account` above all. Whoever may call a verb includes agents, and an agent that named itself
|
// `node agent-account` above all. Whoever may call a verb includes agents, and an agent that named itself
|
||||||
@@ -1508,8 +1527,10 @@ func terminalOnly(argv []string) error {
|
|||||||
return terminalRefusal("%s is run at the controller's terminal only, never through a verb: it holds %s, and "+
|
return terminalRefusal("%s is run at the controller's terminal only, never through a verb: it holds %s, and "+
|
||||||
"whoever may call a verb includes agents (novox/hq ADR 0266). Nothing was done", argv[0], what)
|
"whoever may call a verb includes agents (novox/hq ADR 0266). Nothing was done", argv[0], what)
|
||||||
}
|
}
|
||||||
// Of a secret's commands only rotation, which seals the new value to the machine that uses it.
|
// Of a secret's commands only rotation, which seals the new value to the machine that uses it, and the
|
||||||
if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") {
|
// `give` verb's own line: an own secret typed by the operator into the desk's hidden prompt, sealed to this
|
||||||
|
// call and then to the module's machine, so no value travels in the verb or its answer (hq ADR 0259 §10).
|
||||||
|
if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") && !givenAtTheDesk(argv) {
|
||||||
return terminalRefusal("secret %s is run at the controller's terminal only, never through a verb: accepting, "+
|
return terminalRefusal("secret %s is run at the controller's terminal only, never through a verb: accepting, "+
|
||||||
"recovering or exporting a secret hands it to whoever asks, and that includes agents (novox/hq ADR "+
|
"recovering or exporting a secret hands it to whoever asks, and that includes agents (novox/hq ADR "+
|
||||||
"0266). Nothing was done", strings.Join(argv[1:], " "))
|
"0266). Nothing was done", strings.Join(argv[1:], " "))
|
||||||
|
|||||||
@@ -275,6 +275,13 @@ var accountedFlags = map[string]map[string]string{
|
|||||||
"json": "set by the verb: the answer is data",
|
"json": "set by the verb: the answer is data",
|
||||||
"all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it",
|
"all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it",
|
||||||
},
|
},
|
||||||
|
// The desk path of `secret accept` (novox/hq ADR 0259 §10): a value is never an argument of a call.
|
||||||
|
"secret accept": {
|
||||||
|
"at-desk": "=at",
|
||||||
|
"from": "withheld: a file of the control node's is read at a shell, never named by a call",
|
||||||
|
"provider": "withheld: a pair credential's value is given at a shell; give takes a module's own secret",
|
||||||
|
"local": "withheld: it goes with --provider",
|
||||||
|
},
|
||||||
"hand-acts": {"json": "set by the verb: the answer is data"},
|
"hand-acts": {"json": "set by the verb: the answer is data"},
|
||||||
"conditions": {"json": "set by the verb: the answer is data"},
|
"conditions": {"json": "set by the verb: the answer is data"},
|
||||||
"retire": {"json": "set by the verb: the answer is data"},
|
"retire": {"json": "set by the verb: the answer is data"},
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
provider := set.String("provider", "",
|
provider := set.String("provider", "",
|
||||||
"the node providing <name>: the value becomes the PAIR credential between <module> on <node> "+
|
"the node providing <name>: the value becomes the PAIR credential between <module> on <node> "+
|
||||||
"and that provider, sealed to both — the vault's operator-delivered secret (ADR 0092)")
|
"and that provider, sealed to both — the vault's operator-delivered secret (ADR 0092)")
|
||||||
|
desk := set.String("at-desk", "",
|
||||||
|
"ask the operator for the value in a prompt that does not show it, on this machine's desk; the "+
|
||||||
|
"answer comes back sealed to this call alone (novox/hq ADR 0259 §10)")
|
||||||
local := set.String("local", "",
|
local := set.String("local", "",
|
||||||
"with --provider: the name the credential goes by inside <module>, where its manifest keeps "+
|
"with --provider: the name the credential goes by inside <module>, where its manifest keeps "+
|
||||||
"several for <name> (ADR 0094)")
|
"several for <name> (ADR 0094)")
|
||||||
@@ -65,6 +68,18 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
return errors.New(secretUsage)
|
return errors.New(secretUsage)
|
||||||
}
|
}
|
||||||
node, module, name := rest[0], rest[1], rest[2]
|
node, module, name := rest[0], rest[1], rest[2]
|
||||||
|
// A value comes from the terminal or the desk, never through a verb (the review of 2026-10-09, M4): a
|
||||||
|
// verb's caller may be an agent, and a value it chose would become what a module acts with.
|
||||||
|
if verb, through := throughAVerb(); through && *desk == "" {
|
||||||
|
return fmt.Errorf("a secret's value is given at the controller's terminal or at the desk (`give`), never "+
|
||||||
|
"through a verb (this line came through %q): nothing was read or sealed", verb)
|
||||||
|
}
|
||||||
|
if *desk != "" {
|
||||||
|
if *from != "" || *provider != "" {
|
||||||
|
return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider")
|
||||||
|
}
|
||||||
|
return giveAtDesk(ctx, node, module, name, *desk)
|
||||||
|
}
|
||||||
|
|
||||||
value, err := valueFor(node, module, name, *from)
|
value, err := valueFor(node, module, name, *from)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,10 +108,26 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
fmt.Printf(" run `push %s` and `push %s` to send it\n", *provider, node)
|
fmt.Printf(" run `push %s` and `push %s` to send it\n", *provider, node)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
untilStart, err := open.inventory.AcceptGivenSecret(ctx, node, module, name, value)
|
// A trusted party's secret is announced before it is kept (the confirmation review of 2026-10-09, N1-give):
|
||||||
|
// on every channel, the one it replaces among them, which still runs on its old value until the next push.
|
||||||
|
// Not announced, it is not kept: a channel whose token changed unheard of answers for somebody else.
|
||||||
|
trusted, err := open.inventory.RunsAsItsOwnAccount(ctx, module)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
untilStart, unannounced, err := keepGiven(trusted,
|
||||||
|
func() error { return announceSecretGiven(ctx, node, module, name, "at the controller's terminal") },
|
||||||
|
func() (bool, error) { return open.inventory.AcceptGivenSecret(ctx, node, module, name, value) })
|
||||||
|
if err != nil {
|
||||||
|
if trusted && unannounced != nil {
|
||||||
|
return fmt.Errorf("%s runs as an account of its own, and the change of its %s could not be announced on "+
|
||||||
|
"your channels first, so nothing was kept: %w", module, name, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if unannounced != nil {
|
||||||
|
fmt.Printf(" this change could NOT be announced on the operator's channels: %v\n", unannounced)
|
||||||
|
}
|
||||||
// Not printed back, and there is nowhere it could be printed from: it is sealed to that
|
// Not printed back, and there is nowhere it could be printed from: it is sealed to that
|
||||||
// machine and the mesh cannot read it again.
|
// machine and the mesh cannot read it again.
|
||||||
fmt.Printf("%s on %s now holds %q, sealed to that machine.\n", module, node, name)
|
fmt.Printf("%s on %s now holds %q, sealed to that machine.\n", module, node, name)
|
||||||
@@ -118,7 +149,7 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const secretUsage = "secret rotate <node> <module> <name> [--why <text> [--cause <word>]]\n" +
|
const secretUsage = "secret rotate <node> <module> <name> [--why <text> [--cause <word>]]\n" +
|
||||||
"secret accept <node> <module> <name> [--from <file>] [--provider <node> [--local <name>]]\n" +
|
"secret accept <node> <module> <name> [--from <file> | --at-desk <machine>] [--provider <node> [--local <name>]]\n" +
|
||||||
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
|
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
|
||||||
"secret export [--out <file>]"
|
"secret export [--out <file>]"
|
||||||
|
|
||||||
@@ -369,6 +400,8 @@ func valueFor(node, module, name, from string) (string, error) {
|
|||||||
fmt.Fprintf(os.Stderr,
|
fmt.Fprintf(os.Stderr,
|
||||||
"reading %s's %q for %s from standard input; it is not echoed anywhere\n",
|
"reading %s's %q for %s from standard input; it is not echoed anywhere\n",
|
||||||
module, name, node)
|
module, name, node)
|
||||||
|
// At a terminal, what is typed is not shown either: echo off while it is read.
|
||||||
|
defer hideTyping(os.Stdin)()
|
||||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
if err != nil && line == "" {
|
if err != nil && line == "" {
|
||||||
return "", fmt.Errorf("nothing was given on standard input: %w", err)
|
return "", fmt.Errorf("nothing was given on standard input: %w", err)
|
||||||
@@ -452,3 +485,23 @@ func whoAsked() string {
|
|||||||
}
|
}
|
||||||
return "the mesh"
|
return "the mesh"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keepGiven keeps a value given at the controller's terminal, and announces it on the operator's channels
|
||||||
|
// (the confirmation review of 2026-10-09, N1-give). **A trusted party's — a module running as an account of its
|
||||||
|
// own: the router, a verified channel — is announced before it is kept, and not kept when the announcement
|
||||||
|
// fails**: a channel whose token changed unheard of answers for somebody else. Any other module's is kept first
|
||||||
|
// and announced after, and a failed announcement is said (unannounced) without undoing it.
|
||||||
|
func keepGiven(trusted bool, announce func() error, keep func() (bool, error)) (untilStart bool, unannounced, err error) {
|
||||||
|
if trusted {
|
||||||
|
if err := announce(); err != nil {
|
||||||
|
return false, err, err
|
||||||
|
}
|
||||||
|
untilStart, err = keep()
|
||||||
|
return untilStart, nil, err
|
||||||
|
}
|
||||||
|
untilStart, err = keep()
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
return untilStart, announce(), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const (
|
|||||||
|
|
||||||
// callBounds are the verbs that may run longer than callDefault, and how long (S7).
|
// callBounds are the verbs that may run longer than callDefault, and how long (S7).
|
||||||
var callBounds = map[string]time.Duration{
|
var callBounds = map[string]time.Duration{
|
||||||
"push": 30 * time.Minute, "rotate": 30 * time.Minute, "assign": 15 * time.Minute,
|
"push": 30 * time.Minute, "rotate": 30 * time.Minute, "give": 5 * time.Minute, "assign": 15 * time.Minute,
|
||||||
"unassign": 15 * time.Minute, "command": 30 * time.Minute, "doctor": 3 * time.Minute,
|
"unassign": 15 * time.Minute, "command": 30 * time.Minute, "doctor": 3 * time.Minute,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ require (
|
|||||||
github.com/novox/mesh-host v0.0.0
|
github.com/novox/mesh-host v0.0.0
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
golang.org/x/net v0.58.0
|
golang.org/x/net v0.58.0
|
||||||
|
golang.org/x/sys v0.48.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -24,7 +25,6 @@ require (
|
|||||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
)
|
)
|
||||||
@@ -35,4 +35,4 @@ require (
|
|||||||
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
||||||
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
||||||
// `go mod vendor` fails loudly, at once, everywhere.
|
// `go mod vendor` fails loudly, at once, everywhere.
|
||||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d
|
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56
|
||||||
|
|||||||
@@ -1,23 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-host v0.0.0-20261006095519-3e80b7ae325e h1:g9h4QRaAMg5yaJLwqtb0FoOs23DVGUYpW6qvnQ3oY5A=
|
git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56 h1:Vut7OdwSAL0rFaavaFmv3+FIGS3ISM4jA+xTiS88nvg=
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261006095519-3e80b7ae325e/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56/go.mod h1:mBxSf6wULwn0bdpHkIHUnhTvNzqnULnjoRN28dUgSBU=
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac h1:KvnKtJ2rWeIE/t4GweK+JL0OjKSNxsrVP3/nMdpii8o=
|
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac h1:yLtFS0pDCCqIE9Zx8hgXEFG9fUWzf8L9WQoKV+Amk1E=
|
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4 h1:f4rBnKSemuN0Z9dTtRJMigIGfEs6ltFPOILJGHGab74=
|
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e h1:H7eVqDILL6e9cMbWSLHTbCqu9ZxDOmyeQhUmWl9QBV0=
|
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d h1:IrmJ+lz21n+eSqKrmXREtR/7raUCBJ+fZvs+BNhuXVI=
|
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6 h1:JT7xM1bnLNInW7/oImV2OlXTrcQ4/GSM0Y8tAb+AhmY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f h1:BNvyWq899GwP7F3sY4ACieB5a5fnFAq+sJ9lP6HQ5qI=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8 h1:soqhLNpEXThdq6PdiPy6ExxjJ+yjhh1N1n9E3j1CtrM=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39 h1:WHW6CgbuTxP7M+qRBOgzsiG9vT49xdkZ/rarc9/vKMA=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689 h1:Ti2P9nwders7YQ/hq3X/dPo+CXMj5pdUcfA5P/c12CU=
|
git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689 h1:Ti2P9nwders7YQ/hq3X/dPo+CXMj5pdUcfA5P/c12CU=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
@@ -56,8 +38,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
|||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
|
||||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
|
||||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
|
|||||||
+72
-5
@@ -183,6 +183,49 @@ var VerbsTheSelfCheckAsks = []SeatVerb{{Seat: "node-intrusion-prevention", Verb:
|
|||||||
// the controller's grant that acts, and only through the step a person starts.
|
// the controller's grant that acts, and only through the step a person starts.
|
||||||
var VerbsTheBusStepAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}}
|
var VerbsTheBusStepAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}}
|
||||||
|
|
||||||
|
// VerbsTheControllerAsksForASecret are the seat verbs `give` calls (novox/hq ADR 0259 §10): the operator's
|
||||||
|
// desk opens a prompt that does not show what is typed, and answers it sealed to the controller's call.
|
||||||
|
var VerbsTheControllerAsksForASecret = []SeatVerb{{Seat: "node-launcher", Verb: "secret"}}
|
||||||
|
|
||||||
|
// ControllerOnly are the subjects the controller alone may publish, however wide another's grant (the review
|
||||||
|
// of 2026-10-09, M4): the desk's hidden prompt, on its seat's subjects and on any holder's own module
|
||||||
|
// subjects. A grant of every tool — the runtime's, which carries every agent's calls, or a person's `*` — would
|
||||||
|
// otherwise reach it, and the prompt says the controller asks: only the bus makes that true.
|
||||||
|
func ControllerOnly() []string {
|
||||||
|
var out []string
|
||||||
|
for _, v := range VerbsTheControllerAsksForASecret {
|
||||||
|
for _, base := range []string{"mesh.seat." + v.Seat + ".tool." + v.Verb, "mesh.mod.*.tool." + v.Seat + "." + v.Verb} {
|
||||||
|
out = append(out, base, base+".*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MayPublish says whether permissions let a principal publish one subject: an allow covers it and no deny does.
|
||||||
|
func MayPublish(perms Permissions, subject string) bool {
|
||||||
|
for _, d := range perms.PublishDeny {
|
||||||
|
if SubjectsOverlap(d, subject) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, a := range perms.Publish {
|
||||||
|
if SubjectsOverlap(a, subject) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaySubscribe says whether a principal with these permissions may subscribe to (and so answer) a subject.
|
||||||
|
func MaySubscribe(perms Permissions, subject string) bool {
|
||||||
|
for _, a := range perms.Subscribe {
|
||||||
|
if SubjectsOverlap(a, subject) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// VerbsTheControllerAsksTheDeliveryOwner are the mesh-delivery seat's verbs the controller calls (novox/hq
|
// VerbsTheControllerAsksTheDeliveryOwner are the mesh-delivery seat's verbs the controller calls (novox/hq
|
||||||
// ADR 0239): its self-check reads `stalled`, and healer H2 takes the one transition the table allows
|
// ADR 0239): its self-check reads `stalled`, and healer H2 takes the one transition the table allows
|
||||||
// through `close`. A mesh seat's verb is flat: no machine in the subject.
|
// through `close`. A mesh seat's verb is flat: no machine in the subject.
|
||||||
@@ -253,8 +296,11 @@ func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" }
|
|||||||
|
|
||||||
// Permissions is what a principal may publish and subscribe, and whether it may answer.
|
// Permissions is what a principal may publish and subscribe, and whether it may answer.
|
||||||
type Permissions struct {
|
type Permissions struct {
|
||||||
Publish []string
|
Publish []string
|
||||||
Subscribe []string
|
// PublishDeny are subjects refused although an allow covers them: the controller's alone (ControllerOnly),
|
||||||
|
// denied to everybody whose grant is wide enough to reach them. The server's deny outranks its allow.
|
||||||
|
PublishDeny []string
|
||||||
|
Subscribe []string
|
||||||
// AllowResponses lets a principal reply to a request it received, on the reply subject that
|
// AllowResponses lets a principal reply to a request it received, on the reply subject that
|
||||||
// request carried, once.
|
// request carried, once.
|
||||||
//
|
//
|
||||||
@@ -392,6 +438,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
for _, v := range VerbsTheBusStepAsks {
|
for _, v := range VerbsTheBusStepAsks {
|
||||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
||||||
}
|
}
|
||||||
|
// And the operator's desk, for a secret given there (ADR 0259 §10).
|
||||||
|
for _, v := range VerbsTheControllerAsksForASecret {
|
||||||
|
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
||||||
|
}
|
||||||
// And the delivery's owner, a mesh seat, asked on its flat subjects (ADR 0239).
|
// And the delivery's owner, a mesh seat, asked on its flat subjects (ADR 0239).
|
||||||
for _, v := range VerbsTheControllerAsksTheDeliveryOwner {
|
for _, v := range VerbsTheControllerAsksTheDeliveryOwner {
|
||||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
|
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
|
||||||
@@ -796,9 +846,22 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
if err := CheckWriters(p, pub); err != nil {
|
if err := CheckWriters(p, pub); err != nil {
|
||||||
return Permissions{}, err
|
return Permissions{}, err
|
||||||
}
|
}
|
||||||
|
// What the controller alone may publish is denied to everybody else whose grant reaches it.
|
||||||
|
var deny []string
|
||||||
|
if p.Kind != KindController {
|
||||||
|
for _, only := range ControllerOnly() {
|
||||||
|
for _, a := range pub {
|
||||||
|
if SubjectsOverlap(a, only) {
|
||||||
|
deny = append(deny, only)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return Permissions{
|
return Permissions{
|
||||||
Publish: pub,
|
Publish: pub,
|
||||||
Subscribe: sub,
|
PublishDeny: deny,
|
||||||
|
Subscribe: sub,
|
||||||
// A module answers what it was asked — a tool call reaches it on its own namespace, so the
|
// A module answers what it was asked — a tool call reaches it on its own namespace, so the
|
||||||
// authority is bounded by having been asked — and so does the controller. A node and a
|
// authority is bounded by having been asked — and so does the controller. A node and a
|
||||||
// person are never asked anything, and are granted nothing here.
|
// person are never asked anything, and are granted nothing here.
|
||||||
@@ -1020,7 +1083,11 @@ func ComposeAccounts(principals []Principal) (string, error) {
|
|||||||
return "", fmt.Errorf("%s has no password hash: a user without one is a user anybody is", p.Username())
|
return "", fmt.Errorf("%s has no password hash: a user without one is a user anybody is", p.Username())
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, " { user: %q, password: %q, permissions: {\n", p.Username(), p.PasswordHash)
|
fmt.Fprintf(&b, " { user: %q, password: %q, permissions: {\n", p.Username(), p.PasswordHash)
|
||||||
fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish))
|
if len(perms.PublishDeny) > 0 {
|
||||||
|
fmt.Fprintf(&b, " publish: { allow: [%s], deny: [%s] }\n", quoted(perms.Publish), quoted(perms.PublishDeny))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish))
|
||||||
|
}
|
||||||
fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe))
|
fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe))
|
||||||
if perms.AllowResponses {
|
if perms.AllowResponses {
|
||||||
fmt.Fprintf(&b, " allow_responses: { max: 1, ttl: \"%dm\" }\n", int(ResponseTTL/time.Minute))
|
fmt.Fprintf(&b, " allow_responses: { max: 1, ttl: \"%dm\" }\n", int(ResponseTTL/time.Minute))
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ accounts {
|
|||||||
jetstream: enabled
|
jetstream: enabled
|
||||||
users = [
|
users = [
|
||||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-service-manager.tool.restart.*"] }
|
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*"] }
|
||||||
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] }
|
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
|
|||||||
@@ -173,11 +173,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
|||||||
// had — so re-composing a declaration moves nothing, where a commit would move the
|
// had — so re-composing a declaration moves nothing, where a commit would move the
|
||||||
// path of an identical binary and recreate everything that reads it.
|
// path of an identical binary and recreate everything that reads it.
|
||||||
for key, value := range filled {
|
for key, value := range filled {
|
||||||
text, isText := value.(string)
|
filled[key] = withVersion(value, versionOf(artifact.Digest))
|
||||||
if !isText || !strings.Contains(text, versionRef) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filled[key] = strings.ReplaceAll(text, versionRef, versionOf(artifact.Digest))
|
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return Manifest{}, fmt.Errorf("%s: %q is a %q, and an artifact is %q, %q, %q or %q",
|
return Manifest{}, fmt.Errorf("%s: %q is a %q, and an artifact is %q, %q, %q or %q",
|
||||||
@@ -189,6 +185,31 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withVersion is a resource's value with `${version}` filled: in a string, and in each string of a map —
|
||||||
|
// a process's env, where the controller is told which build it is (novox/hq issue 352). Anything else is
|
||||||
|
// left as it is.
|
||||||
|
func withVersion(value any, version string) any {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
if strings.Contains(v, versionRef) {
|
||||||
|
return strings.ReplaceAll(v, versionRef, version)
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
out := make(map[string]any, len(v))
|
||||||
|
for k, x := range v {
|
||||||
|
out[k] = withVersion(x, version)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case map[string]string:
|
||||||
|
out := make(map[string]string, len(v))
|
||||||
|
for k, x := range v {
|
||||||
|
out[k] = strings.ReplaceAll(x, versionRef, version)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// checkBuild is the manifest's own account of what it builds.
|
// checkBuild is the manifest's own account of what it builds.
|
||||||
func (b *Build) problems(module string) []string {
|
func (b *Build) problems(module string) []string {
|
||||||
if b == nil {
|
if b == nil {
|
||||||
|
|||||||
@@ -78,6 +78,22 @@ func graphicalSessionSeats() []Seat {
|
|||||||
"description": "the lines to choose between, in order"},
|
"description": "the lines to choose between, in order"},
|
||||||
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
|
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
|
||||||
}}},
|
}}},
|
||||||
|
// A value the operator types and nobody sees (novox/hq ADR 0259 §10): a hidden prompt whose answer
|
||||||
|
// is sealed to the asker's key, so it is never plaintext on the bus or in any call's record.
|
||||||
|
// **Optional while its holders catch up** (ADR 0246): rofi serves it once this is live.
|
||||||
|
{Name: "secret", Optional: true, Description: "Ask the operator for a value in a prompt that " +
|
||||||
|
"does not show what is typed, and answer it sealed to the key the asker gives — never in " +
|
||||||
|
"the clear — or cancelled when the prompt was dismissed or not answered in time.",
|
||||||
|
// By name, never by words (the review of 2026-10-09, M4): the holder writes the prompt from the
|
||||||
|
// module, the secret and the machine, and says the controller asks — the bus lets nobody else
|
||||||
|
// ask it (broker.ControllerOnly) — so no caller puts words of its own before the operator.
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"module": "the module whose own secret is asked for",
|
||||||
|
"secret": "the own secret's name",
|
||||||
|
"node": "the machine the module runs on",
|
||||||
|
"seal_to": "the asker's public sealing key: the answer is sealed to it",
|
||||||
|
"timeout_seconds": "give up after this long (optional)",
|
||||||
|
}, []string{"module", "secret", "node", "seal_to"})},
|
||||||
}},
|
}},
|
||||||
{Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
{Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
{Name: "send", Description: "Show the operator a notification.",
|
{Name: "send", Description: "Show the operator a notification.",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) {
|
|||||||
DisplayServerSeat: {"displays", "layout"},
|
DisplayServerSeat: {"displays", "layout"},
|
||||||
DisplaySessionSeat: {"reload", "workspaces", "windows"},
|
DisplaySessionSeat: {"reload", "workspaces", "windows"},
|
||||||
TerminalEmulatorSeat: {"open"},
|
TerminalEmulatorSeat: {"open"},
|
||||||
LauncherSeat: {"menu"},
|
LauncherSeat: {"menu", "secret"},
|
||||||
NotifierSeat: {"send", "history"},
|
NotifierSeat: {"send", "history"},
|
||||||
LockScreenSeat: {"lock"},
|
LockScreenSeat: {"lock"},
|
||||||
ClipboardSeat: {"history", "copy"},
|
ClipboardSeat: {"history", "copy"},
|
||||||
|
|||||||
@@ -230,6 +230,18 @@ var ControllerVerbs = []Verb{
|
|||||||
"why": "an own secret: why it is rotated — recorded in the hand-act log (optional)",
|
"why": "an own secret: why it is rotated — recorded in the hand-act log (optional)",
|
||||||
"cause": "with why: the cause in a word, the word a second rotation for the same reason uses (optional)",
|
"cause": "with why: the cause in a word, the word a second rotation for the same reason uses (optional)",
|
||||||
}, nil)},
|
}, nil)},
|
||||||
|
{Name: "give", Description: "Take a module's own secret from the operator at their desk (novox/hq ADR 0259 " +
|
||||||
|
"§10): a prompt that does not show what is typed opens on the machine named by at, its answer comes " +
|
||||||
|
"back sealed to this call alone, and is sealed to the module's machine as `secret accept` seals it. " +
|
||||||
|
"The value is never an argument and never in the answer: the answer says it was taken, or why not. " +
|
||||||
|
"Recorded in the hand-act log as a value given at the desk. The prompt waits 25 seconds; dismissed " +
|
||||||
|
"or unanswered, nothing changes. Then push the machine.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"node": "the machine the module runs on, which the secret is sealed to",
|
||||||
|
"module": "the module's name",
|
||||||
|
"secret": "the own secret's name in the module's definition",
|
||||||
|
"at": "the machine the operator sits at, where the prompt opens",
|
||||||
|
}, []string{"node", "module", "secret", "at"})},
|
||||||
{Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +
|
{Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +
|
||||||
"machine as the module's own secret named broker, read at the next push of that machine. For a module " +
|
"machine as the module's own secret named broker, read at the next push of that machine. For a module " +
|
||||||
"whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.",
|
"whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.",
|
||||||
|
|||||||
@@ -95,3 +95,28 @@ func TestAResourceWithoutAVersionReferenceIsUntouched(t *testing.T) {
|
|||||||
t.Fatalf("a path naming no version became %q", path)
|
t.Fatalf("a path naming no version became %q", path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A process's env can name the build's own version too (novox/hq issue 352): the controller is told which
|
||||||
|
// build it is, and records what that build reads of the store's schema under it.
|
||||||
|
func TestAProcessEnvCanNameTheBuildsOwnVersion(t *testing.T) {
|
||||||
|
m := Manifest{
|
||||||
|
Module: "mesh-controller",
|
||||||
|
Build: &Build{Artifacts: []Artifact{{Name: "controller", Kind: ArtifactBundle, Language: "go", System: "arch"}}},
|
||||||
|
Resources: []map[string]any{{
|
||||||
|
"id": "controller", "type": "process", "artifact": "controller", "run": []any{"./mesh-controller", "serve"},
|
||||||
|
"env": map[string]any{"MESH_CONTROLLER_VERSION": "${version}", "OTHER": "kept"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
got, err := m.Resolve([]Built{{Name: "controller", Kind: ArtifactBundle,
|
||||||
|
Reference: "artifact-store://mesh-controller/controller", Digest: aDigest}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env, _ := got.Resources[0]["env"].(map[string]any)
|
||||||
|
if env["MESH_CONTROLLER_VERSION"] != "ad62528c47c7" || env["OTHER"] != "kept" {
|
||||||
|
t.Fatalf("the env resolved to %v", env)
|
||||||
|
}
|
||||||
|
if run, _ := got.Resources[0]["run"].([]any); len(run) != 2 {
|
||||||
|
t.Fatalf("the run was changed: %v", run)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import (
|
|||||||
const (
|
const (
|
||||||
GatePassed = "passed"
|
GatePassed = "passed"
|
||||||
GateFailed = "failed"
|
GateFailed = "failed"
|
||||||
|
// GateSuperseded is a judging ended by a later send to the judged machine that moved the module to
|
||||||
|
// another build (novox/hq issue 352): no verdict on the build, and nothing put back.
|
||||||
|
GateSuperseded = "superseded"
|
||||||
|
|
||||||
RollingBack = "rolling-back"
|
RollingBack = "rolling-back"
|
||||||
RolledBack = "rolled-back"
|
RolledBack = "rolled-back"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// `give` takes only a value nobody but a person has (the review of 2026-10-09, M4): never the module's bus
|
||||||
|
// account, which `issue` mints, nor a secret the mesh may make itself.
|
||||||
|
func TestOnlyASecretThePersonHoldsIsGivenAtTheDesk(t *testing.T) {
|
||||||
|
m := catalogue.Manifest{Module: "telegram", OwnSecrets: catalogue.OwnSecrets{
|
||||||
|
"telegram-token": {Path: "/s/telegram-token"},
|
||||||
|
"broker": {Path: "/s/broker"},
|
||||||
|
"session": {Path: "/s/session", Taken: catalogue.TakenAtStart},
|
||||||
|
}}
|
||||||
|
if err := GivableAtDesk(m, "telegram-token"); err != nil {
|
||||||
|
t.Errorf("the bot token was refused: %v", err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"broker", "session", "chat-id"} {
|
||||||
|
if err := GivableAtDesk(m, name); err == nil {
|
||||||
|
t.Errorf("%s was givable", name)
|
||||||
|
} else if name != "chat-id" && !strings.Contains(err.Error(), "the mesh makes") {
|
||||||
|
t.Errorf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
-- A controller records, when it serves, how far the store's schema reaches in the build it is (novox/hq
|
||||||
|
-- issue 352): the highest migration it carries, by its build's version. A gate that fails the controller's
|
||||||
|
-- build puts the build before it back, and on 2026-10-09 that build was older than the migrations the
|
||||||
|
-- failed one had applied: it started, said it was behind its own row, and judged the next gate half-blind.
|
||||||
|
-- A put-back now reads this and keeps the current build when the one before it reaches less than the store.
|
||||||
|
create table controller_schema (
|
||||||
|
build text primary key,
|
||||||
|
reach integer not null,
|
||||||
|
recorded timestamptz not null default now()
|
||||||
|
);
|
||||||
@@ -1054,13 +1054,57 @@ type Reported struct {
|
|||||||
// acted on the current words, not merely spoken after they were written. False also covers
|
// acted on the current words, not merely spoken after they were written. False also covers
|
||||||
// a machine that has not said which, which is every host from before reports carried it.
|
// a machine that has not said which, which is every host from before reports carried it.
|
||||||
Current bool
|
Current bool
|
||||||
|
// Declared is the digest of the declaration the last report was about, and ReportedSequence that
|
||||||
|
// declaration's sequence as the report claimed it (zero from an engine that claims none): what a
|
||||||
|
// gate holds against the send it made, rather than against the send made last (novox/hq issue 352).
|
||||||
|
Declared string
|
||||||
|
ReportedSequence int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// SentDeclaration is what one send carried to a machine, as a gate keeps it: the declaration's digest and
|
||||||
|
// its sequence (novox/hq issue 352). A report about this declaration, or about one sequenced after it, is a
|
||||||
|
// report on what the gate sent — whatever the machine was sent since.
|
||||||
|
type SentDeclaration struct {
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
Sequence int64 `json:"sequence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportsOn says a report is about this send: the declaration itself; one the same machine was sequenced
|
||||||
|
// after it; or the declaration the machine was sent last (Current), which is this send or a later one —
|
||||||
|
// sends to a machine are made one after another. A send kept without a sequence is matched by its digest
|
||||||
|
// and by the last send alone.
|
||||||
|
func (s SentDeclaration) ReportsOn(r Reported) bool {
|
||||||
|
if r.Current || (s.Digest != "" && r.Declared == s.Digest) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return s.Sequence > 0 && r.ReportedSequence >= s.Sequence
|
||||||
|
}
|
||||||
|
|
||||||
|
// SentTo is the declaration a machine was last sent, by name: its digest and sequence, and false when it
|
||||||
|
// was never sent one.
|
||||||
|
func (i *Inventory) SentTo(ctx context.Context, name string) (SentDeclaration, bool, error) {
|
||||||
|
var digest *string
|
||||||
|
var seq *int64
|
||||||
|
err := i.store.Pool().QueryRow(ctx, `select sent, sequence from node where name = $1`, name).Scan(&digest, &seq)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return SentDeclaration{}, false, fmt.Errorf("%w: %s", ErrNoSuchNode, name)
|
||||||
|
}
|
||||||
|
if err != nil || digest == nil || *digest == "" {
|
||||||
|
return SentDeclaration{}, false, err
|
||||||
|
}
|
||||||
|
s := SentDeclaration{Digest: *digest}
|
||||||
|
if seq != nil {
|
||||||
|
s.Sequence = *seq
|
||||||
|
}
|
||||||
|
return s, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LastReports is every machine's last report beside when it was last sent a declaration.
|
// LastReports is every machine's last report beside when it was last sent a declaration.
|
||||||
func (i *Inventory) LastReports(ctx context.Context) ([]Reported, error) {
|
func (i *Inventory) LastReports(ctx context.Context) ([]Reported, error) {
|
||||||
rows, err := i.store.Pool().Query(ctx,
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
`select n.name, coalesce(r.outcome, ''), r.at, n.sent_at,
|
`select n.name, coalesce(r.outcome, ''), r.at, n.sent_at,
|
||||||
r.declared is not null and r.declared <> '' and r.declared = n.sent
|
r.declared is not null and r.declared <> '' and r.declared = n.sent,
|
||||||
|
coalesce(r.declared, ''), coalesce(r.reported_sequence, 0)
|
||||||
from node n left join node_report r on r.node = n.id
|
from node n left join node_report r on r.node = n.id
|
||||||
order by n.name`)
|
order by n.name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1070,7 +1114,7 @@ func (i *Inventory) LastReports(ctx context.Context) ([]Reported, error) {
|
|||||||
var out []Reported
|
var out []Reported
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r Reported
|
var r Reported
|
||||||
if err := rows.Scan(&r.Node, &r.Outcome, &r.At, &r.Sent, &r.Current); err != nil {
|
if err := rows.Scan(&r.Node, &r.Outcome, &r.At, &r.Sent, &r.Current, &r.Declared, &r.ReportedSequence); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ type PlanGate struct {
|
|||||||
To string `json:"to,omitempty"`
|
To string `json:"to,omitempty"`
|
||||||
// Since is when the judging began: the first machine reported the new build applied.
|
// Since is when the judging began: the first machine reported the new build applied.
|
||||||
Since *time.Time `json:"since,omitempty"`
|
Since *time.Time `json:"since,omitempty"`
|
||||||
|
// Sent is, per machine, the declaration the gate's send carried there (novox/hq issue 352): what a
|
||||||
|
// machine's report is held against. Absent on a gate kept before it was, which reads the report
|
||||||
|
// against the send made last, as before.
|
||||||
|
Sent map[string]SentDeclaration `json:"sent,omitempty"`
|
||||||
// Passes counts the consecutive judgings that found it healthy, LastPass the newest; a judging that
|
// Passes counts the consecutive judgings that found it healthy, LastPass the newest; a judging that
|
||||||
// does not resets them.
|
// does not resets them.
|
||||||
Passes int `json:"passes,omitempty"`
|
Passes int `json:"passes,omitempty"`
|
||||||
|
|||||||
@@ -115,3 +115,83 @@ func TestTheNewestMergeOfABranchIsTheOneMergedLast(t *testing.T) {
|
|||||||
t.Fatalf("one merge time, two plans: %s", p.ID)
|
t.Fatalf("one merge time, two plans: %s", p.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// novox/hq issue 352: what a machine was last sent is read back by name with its sequence, a report keeps
|
||||||
|
// the declaration it was about and that declaration's sequence, and a gate's sends are kept with the plan.
|
||||||
|
func TestASendAndAReportAreKnownByTheirDeclaration(t *testing.T) {
|
||||||
|
inv := ForTest(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
record, err := inv.AddNode(ctx, "anchor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, found, err := inv.SentTo(ctx, "anchor"); err != nil || found {
|
||||||
|
t.Fatalf("a machine never sent anything: %v %v", found, err)
|
||||||
|
}
|
||||||
|
if _, _, err := inv.SentTo(ctx, "nobody"); err == nil {
|
||||||
|
t.Fatal("a machine that does not exist was answered")
|
||||||
|
}
|
||||||
|
seq, err := inv.NextSequence(ctx, record.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RecordSent(ctx, record.ID, "d-1", map[string]string{"app": "c1"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sent, found, err := inv.SentTo(ctx, "anchor")
|
||||||
|
if err != nil || !found || sent.Digest != "d-1" || sent.Sequence != seq {
|
||||||
|
t.Fatalf("sent %+v %v %v", sent, found, err)
|
||||||
|
}
|
||||||
|
if _, err := inv.RecordOrderedDoing(ctx, record.ID, Doing{Node: "anchor", Outcome: OutcomeApplied, Declared: "d-1", Applied: 1},
|
||||||
|
ReportOrder{Sequence: seq, ReportSequence: 1}, func(ReportOrder) bool { return false }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reports, err := inv.LastReports(ctx)
|
||||||
|
if err != nil || len(reports) != 1 || reports[0].Declared != "d-1" || reports[0].ReportedSequence != seq || !reports[0].Current {
|
||||||
|
t.Fatalf("reports %+v %v", reports, err)
|
||||||
|
}
|
||||||
|
// Sent again, unreported: the report is no longer on the last send, and is still on the first.
|
||||||
|
if err := inv.RecordSent(ctx, record.ID, "d-2", map[string]string{"app": "c1"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reports, _ = inv.LastReports(ctx)
|
||||||
|
if reports[0].Current || !sent.ReportsOn(reports[0]) {
|
||||||
|
t.Fatalf("after a newer send: %+v", reports[0])
|
||||||
|
}
|
||||||
|
at := time.Now().UTC()
|
||||||
|
p := Plan{ID: "plan-352", Repository: "novox/x", Commit: "c", Created: at, State: PlanRolling, Tiers: [][]string{{"app"}},
|
||||||
|
Modules: map[string]*PlanModule{"app": {Gate: &PlanGate{Machines: []string{"anchor"}, Since: &at,
|
||||||
|
Sent: map[string]SentDeclaration{"anchor": sent}}}}}
|
||||||
|
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
kept, err := inv.PlanByID(ctx, "plan-352")
|
||||||
|
if err != nil || kept.Modules["app"].Gate.Sent["anchor"] != sent {
|
||||||
|
t.Fatalf("the gate's send was not kept with the plan: %+v %v", kept.Modules["app"].Gate, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A controller build's reach of the store's schema is kept by its version, and the store's own is read.
|
||||||
|
func TestASchemaReachIsKeptByBuild(t *testing.T) {
|
||||||
|
inv := ForTest(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
applied, err := inv.SchemaApplied(ctx)
|
||||||
|
if err != nil || applied < 87 {
|
||||||
|
t.Fatalf("applied %d %v", applied, err)
|
||||||
|
}
|
||||||
|
if _, known, err := inv.SchemaReachOf(ctx, "ad62528c47c7"); err != nil || known {
|
||||||
|
t.Fatalf("an unrecorded build: %v %v", known, err)
|
||||||
|
}
|
||||||
|
if err := inv.RecordSchemaReach(ctx, "", 87); err == nil {
|
||||||
|
t.Fatal("a reach without a build was recorded")
|
||||||
|
}
|
||||||
|
if err := inv.RecordSchemaReach(ctx, "ad62528c47c7", 86); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RecordSchemaReach(ctx, "ad62528c47c7", 87); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if reach, known, err := inv.SchemaReachOf(ctx, "ad62528c47c7"); err != nil || !known || reach != 87 {
|
||||||
|
t.Fatalf("reach %d %v %v", reach, known, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What a controller build knows of the store's schema (novox/hq issue 352): the highest migration it
|
||||||
|
// carries, recorded by its version when it serves, and the highest migration the store has applied. A
|
||||||
|
// put-back of the controller to a build that reaches less than the store is refused (gate.go), because
|
||||||
|
// such a controller starts behind its own records and judges with what it can read.
|
||||||
|
|
||||||
|
// RecordSchemaReach keeps the highest migration the build serving now carries.
|
||||||
|
func (i *Inventory) RecordSchemaReach(ctx context.Context, build string, reach int) error {
|
||||||
|
if build == "" {
|
||||||
|
return errors.New("a schema reach is recorded by a build's version, and this controller has none")
|
||||||
|
}
|
||||||
|
_, err := i.store.Pool().Exec(ctx,
|
||||||
|
`insert into controller_schema (build, reach) values ($1, $2)
|
||||||
|
on conflict (build) do update set reach = excluded.reach, recorded = now()`, build, reach)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaReachOf is the highest migration a build carries, as it recorded when it served; false for a
|
||||||
|
// build that never did.
|
||||||
|
func (i *Inventory) SchemaReachOf(ctx context.Context, build string) (int, bool, error) {
|
||||||
|
var reach int
|
||||||
|
err := i.store.Pool().QueryRow(ctx, `select reach from controller_schema where build = $1`, build).Scan(&reach)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
return reach, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaApplied is the highest migration the store has applied.
|
||||||
|
func (i *Inventory) SchemaApplied(ctx context.Context) (int, error) {
|
||||||
|
var n *int
|
||||||
|
if err := i.store.Pool().QueryRow(ctx, `select max(number) from migration`).Scan(&n); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if n == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return *n, nil
|
||||||
|
}
|
||||||
@@ -554,6 +554,50 @@ func (i *Inventory) declared(ctx context.Context, module string) (catalogue.Mani
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeclaresOwnSecret refuses, in words, a module the mesh does not know or an own secret its definition does
|
||||||
|
// not declare: asked before anybody is asked for a value, so nobody types one the mesh would refuse.
|
||||||
|
func (i *Inventory) DeclaresOwnSecret(ctx context.Context, module, name string) error {
|
||||||
|
m, err := i.declared(ctx, module)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return GivableAtDesk(m, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunsAsItsOwnAccount says a module runs as an account of its own (novox/hq ADR 0259 §8): a trusted party — the
|
||||||
|
// router, a channel that proves its sender or shows a link's code — whose own secret is what the operator's
|
||||||
|
// answers are believed by. Its value is given at the controller's terminal alone.
|
||||||
|
func (i *Inventory) RunsAsItsOwnAccount(ctx context.Context, module string) (bool, error) {
|
||||||
|
m, err := i.declared(ctx, module)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return m.RunsAs != "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrokerSecret is the own secret that is a module's bus account, which `issue` mints.
|
||||||
|
const BrokerSecret = "broker"
|
||||||
|
|
||||||
|
// GivableAtDesk refuses, in words, an own secret a module does not declare, and one the mesh makes itself
|
||||||
|
// (the review of 2026-10-09, M4): the module's bus account, which `issue` mints, and any the mesh may make
|
||||||
|
// in place of a value given (catalogue.OwnSecret.MeshMayMake). The desk takes only what a person holds and
|
||||||
|
// the mesh cannot make — a bot's token — so nobody is asked to type the mesh's own credential into a prompt.
|
||||||
|
func GivableAtDesk(m catalogue.Manifest, name string) error {
|
||||||
|
own, ok := m.OwnSecrets[name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s does not declare %q as an own secret; %s", m.Module, name, declaresOwn(m))
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case name == BrokerSecret:
|
||||||
|
return fmt.Errorf("%q is %s's account on the bus, which the mesh makes (`issue`), never a value a person gives",
|
||||||
|
name, m.Module)
|
||||||
|
case own.MeshMayMake():
|
||||||
|
return fmt.Errorf("%q of %s is a secret the mesh makes itself (it may replace a value given at the module's "+
|
||||||
|
"start, ADR 0228); a person gives it only at the controller's terminal, with `secret accept`", name, m.Module)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func declaresOwn(m catalogue.Manifest) string {
|
func declaresOwn(m catalogue.Manifest) string {
|
||||||
if len(m.OwnSecrets) == 0 {
|
if len(m.OwnSecrets) == 0 {
|
||||||
return "it declares no own secrets"
|
return "it declares no own secrets"
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ func kept(args json.RawMessage) json.RawMessage {
|
|||||||
for k, v := range given {
|
for k, v := range given {
|
||||||
s, isString := v.(string)
|
s, isString := v.(string)
|
||||||
switch {
|
switch {
|
||||||
case k == "values" || k == "secret":
|
case k == "values" || k == "secret" || k == "stdin":
|
||||||
out[k] = "(given, not kept)"
|
out[k] = "(given, not kept)"
|
||||||
case k == "line":
|
case k == "line":
|
||||||
// A mesh-cli line (ADR 0272): its command word, never the rest, which may carry settings.
|
// A mesh-cli line (ADR 0272): its command word, never the rest, which may carry settings.
|
||||||
|
|||||||
@@ -41,6 +41,30 @@ type CLIAsked struct {
|
|||||||
// (`session-<id>.scope` under the account's own slice), or empty: a service, a user unit. Only a login session is
|
// (`session-<id>.scope` under the account's own slice), or empty: a service, a user unit. Only a login session is
|
||||||
// the terminal (novox/hq ADR 0272).
|
// the terminal (novox/hq ADR 0272).
|
||||||
Session string `json:"session,omitempty"`
|
Session string `json:"session,omitempty"`
|
||||||
|
// Stdin is what mesh-cli's standard input held, at most CLIMaxStdin: given to a line that runs as the terminal,
|
||||||
|
// as its standard input, and refused with any other (novox/hq ADR 0259 §10, ADR 0272). It is kept nowhere: not in
|
||||||
|
// the calls record (cliRecorded), not in the journal, not in the answer.
|
||||||
|
Stdin []byte `json:"stdin,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLIMaxStdin bounds the standard input a mesh-cli line carries (mesh-sdk go/cli MaxStdin).
|
||||||
|
const CLIMaxStdin = 64 << 10
|
||||||
|
|
||||||
|
// cliRecorded is the request as the calls record keeps it: everything but the standard input, which the record
|
||||||
|
// never holds in any form. The line itself is cut to its command word by the record's own rule (kept).
|
||||||
|
func cliRecorded(raw json.RawMessage) json.RawMessage {
|
||||||
|
var m map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(raw, &m) != nil {
|
||||||
|
return json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
if _, given := m["stdin"]; given {
|
||||||
|
// Said as given, under a key of its own: the record still reads as the request it was (followCLI decodes
|
||||||
|
// it), and holds nothing of the input.
|
||||||
|
delete(m, "stdin")
|
||||||
|
m["stdin-given"] = json.RawMessage(`true`)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(m)
|
||||||
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLIAnswer is what the controller answers, as the `result` of a call's answer: what the command printed, how it
|
// CLIAnswer is what the controller answers, as the `result` of a call's answer: what the command printed, how it
|
||||||
@@ -135,6 +159,10 @@ func (b OverNATS) answerCLI(msg *nats.Msg, calls *CallLog, slots chan struct{},
|
|||||||
case len(asked.Line) == 0:
|
case len(asked.Line) == 0:
|
||||||
respond(cliEnvelope(CLIRefusal("the request names no command, so nothing ran")))
|
respond(cliEnvelope(CLIRefusal("the request names no command, so nothing ran")))
|
||||||
return
|
return
|
||||||
|
case len(asked.Stdin) > CLIMaxStdin:
|
||||||
|
respond(cliEnvelope(CLIRefusal(fmt.Sprintf("standard input of more than %d bytes is not taken, so nothing ran",
|
||||||
|
CLIMaxStdin))))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case slots <- struct{}{}:
|
case slots <- struct{}{}:
|
||||||
@@ -145,7 +173,7 @@ func (b OverNATS) answerCLI(msg *nats.Msg, calls *CallLog, slots chan struct{},
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
limit := b.Conn.MaxPayload()
|
limit := b.Conn.MaxPayload()
|
||||||
calls.serveCallWithin(CLISeat, node, msg.Data, msg.Reply, func(ctx context.Context, _ json.RawMessage) (any, error) {
|
calls.serveCallWithin(CLISeat, node, cliRecorded(msg.Data), msg.Reply, func(ctx context.Context, _ json.RawMessage) (any, error) {
|
||||||
return fitCLI(handle(ctx, node, asked), limit-4096), nil
|
return fitCLI(handle(ctx, node, asked), limit-4096), nil
|
||||||
}, msg.Respond, limit, logger)
|
}, msg.Respond, limit, logger)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ func TestTheMeshCLIRequestAndAnswerKeepTheirFieldNames(t *testing.T) {
|
|||||||
if got := keysIn(t, body); got != "cut exit refused stderr stdout terminal why" {
|
if got := keysIn(t, body); got != "cut exit refused stderr stdout terminal why" {
|
||||||
t.Fatalf("the answer's fields are %q", got)
|
t.Fatalf("the answer's fields are %q", got)
|
||||||
}
|
}
|
||||||
|
body, _ = json.Marshal(CLIAsked{Line: []string{"secret"}, Account: "a", UID: 1, Stdin: []byte("x")})
|
||||||
|
if got := keysIn(t, body); got != "account line stdin uid" {
|
||||||
|
t.Fatalf("a request with standard input has the fields %q", got)
|
||||||
|
}
|
||||||
body, _ = json.Marshal(CLIAsked{Follow: "call-1", Account: "a", UID: 1})
|
body, _ = json.Marshal(CLIAsked{Follow: "call-1", Account: "a", UID: 1})
|
||||||
if got := keysIn(t, body); got != "account follow uid" {
|
if got := keysIn(t, body); got != "account follow uid" {
|
||||||
t.Fatalf("a follow's fields are %q", got)
|
t.Fatalf("a follow's fields are %q", got)
|
||||||
@@ -267,3 +271,26 @@ func TestTheWithholdingTestsHoldSomething(t *testing.T) {
|
|||||||
t.Fatalf("a record carrying the answer is not found carrying it: %s", body)
|
t.Fatalf("a record carrying the answer is not found carrying it: %s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0259 §10: mesh-cli's standard input — a secret given at the terminal — is never in the calls record,
|
||||||
|
// in any form, whichever way the request reaches it.
|
||||||
|
func TestAMeshCLIRecordNeverKeepsItsStandardInput(t *testing.T) {
|
||||||
|
secret := "123456789:AAEhBOweik6ad9r_QxGivenAtTheTerminal"
|
||||||
|
raw, _ := json.Marshal(CLIAsked{Line: []string{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"},
|
||||||
|
Account: "operator", UID: 1000, Stdin: []byte(secret)})
|
||||||
|
for name, got := range map[string]json.RawMessage{"as answerCLI records it": kept(cliRecorded(raw)),
|
||||||
|
"as the record alone would keep it": kept(raw)} {
|
||||||
|
if carries(got, secret) || strings.Contains(string(got), base64.StdEncoding.EncodeToString([]byte(secret))[:20]) {
|
||||||
|
t.Fatalf("%s, the record keeps %s", name, got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(got), "secret") || !strings.Contains(string(got), "given, not kept") &&
|
||||||
|
!strings.Contains(string(got), "stdin-given") {
|
||||||
|
t.Fatalf("%s, the record does not say a line was asked with standard input: %s", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The record still reads as the request, so the asker can follow its call.
|
||||||
|
var asked CLIAsked
|
||||||
|
if err := json.Unmarshal(kept(cliRecorded(raw)), &asked); err != nil || asked.Account != "operator" || len(asked.Stdin) > 0 {
|
||||||
|
t.Fatalf("the recorded request reads as %+v (%v)", asked, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
-1
@@ -48,6 +48,7 @@
|
|||||||
"unpin",
|
"unpin",
|
||||||
"push",
|
"push",
|
||||||
"rotate",
|
"rotate",
|
||||||
|
"give",
|
||||||
"issue",
|
"issue",
|
||||||
"token",
|
"token",
|
||||||
"settings",
|
"settings",
|
||||||
@@ -117,7 +118,8 @@
|
|||||||
"MESH_STORE_LICENCES_PORT": "${seat:mesh-store:5432}",
|
"MESH_STORE_LICENCES_PORT": "${seat:mesh-store:5432}",
|
||||||
"MESH_BROKER_MANAGEMENT_PORT": "${seat:mesh-broker:15672}",
|
"MESH_BROKER_MANAGEMENT_PORT": "${seat:mesh-broker:15672}",
|
||||||
"MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}",
|
"MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}",
|
||||||
"MESH_BUS_NATS_FILE": "${dir:mesh-state}/bus"
|
"MESH_BUS_NATS_FILE": "${dir:mesh-state}/bus",
|
||||||
|
"MESH_CONTROLLER_VERSION": "${version}"
|
||||||
},
|
},
|
||||||
"replaces": [
|
"replaces": [
|
||||||
"server"
|
"server"
|
||||||
|
|||||||
Vendored
+2
-2
@@ -78,7 +78,7 @@ github.com/nats-io/nkeys
|
|||||||
# github.com/nats-io/nuid v1.0.1
|
# github.com/nats-io/nuid v1.0.1
|
||||||
## explicit
|
## explicit
|
||||||
github.com/nats-io/nuid
|
github.com/nats-io/nuid
|
||||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d
|
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56
|
||||||
## explicit; go 1.26.0
|
## explicit; go 1.26.0
|
||||||
github.com/novox/mesh-host/internal/declaration
|
github.com/novox/mesh-host/internal/declaration
|
||||||
github.com/novox/mesh-host/rootsearch
|
github.com/novox/mesh-host/rootsearch
|
||||||
@@ -135,4 +135,4 @@ golang.org/x/text/width
|
|||||||
# golang.org/x/time v0.15.0
|
# golang.org/x/time v0.15.0
|
||||||
## explicit; go 1.25.0
|
## explicit; go 1.25.0
|
||||||
golang.org/x/time/rate
|
golang.org/x/time/rate
|
||||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d
|
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56
|
||||||
|
|||||||
Reference in New Issue
Block a user