Say a machine waiting for its push as waiting, not uncomposable (hq issue 275)

Between assign and push a module's own secrets are not made yet; D1 composed
without making them and raised an urgent 'nothing can be sent' that the next
push resolved silently. D1 now composes as the push would (Foreseeing): a
secret the push makes gets a stand-in and is named, one the push is refused on
is refused with the push's words. Waiting is said only past 30 minutes, as a
warning. D3 and D13 expect a holder only once its machine was sent it and
reported or had ten minutes to.
This commit is contained in:
jochen
2026-10-06 18:05:38 +02:00
parent 2b5060789f
commit dcee8cb5bf
10 changed files with 567 additions and 45 deletions
+39 -5
View File
@@ -87,6 +87,12 @@ type Rendering struct {
// Needed is each module's own secrets, sealed to this node, keyed by module and then by the
// name the module gave it.
Needed map[string]map[string]string
// Foreseen is each own secret not made yet that the next send WILL make, keyed like Needed: a
// composition asked ahead of the send (the self-check's D1) composes it with ForeseenSealed in
// its place and lists it in Composed.Foreseen, rather than failing for a value only a send
// makes. Nil on every composition that is sent, and on every other question, which then refuse
// a secret not made with a *NotMadeError.
Foreseen map[string]map[string]bool
// Mesh is every node's address on the private network, which is what a rule saying "from the
// mesh" resolves to. Passed in for the same reason grants are: who else is on the network is
@@ -275,6 +281,25 @@ type Composed struct {
// compose. Its held things are kept and its containers untouched — the machine is told so —
// and it is told everything else.
LeftOut map[string]string
// Foreseen is every own secret composed with ForeseenSealed in its place (Rendering.Foreseen),
// as `module/name`, sorted: what the next send will make. Never set on a declaration that is
// sent — a placeholder in a sealed file is a credential the process cannot read.
Foreseen []string
}
// ForeseenSealed is what stands for an own secret a send will make, in a composition asked ahead of
// the send. Not a sealed value: nothing composed with it may be sent.
const ForeseenSealed = "foreseen: made by the next send"
// NotMadeError is a module's own secret the composition was given no value for (novox/hq issue 275):
// typed, so that a caller can tell "a send would make this, and none has yet" from a composition that
// fails for any other reason without reading the words.
type NotMadeError struct {
Module, Name string
}
func (e *NotMadeError) Error() string {
return fmt.Sprintf("%s needs a secret called %q and none was made for it", e.Module, e.Name)
}
// LeftOut is which of this machine's modules a declaration composed with these settings leaves
@@ -295,10 +320,12 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
owner := map[string]string{}
received := map[string]map[string][]Contribution{}
leftOut := map[string]string{}
resources, err := r.compose(with, owner, received, leftOut)
var foreseen []string
resources, err := r.compose(with, owner, received, leftOut, &foreseen)
if err != nil {
return Composed{}, err
}
sort.Strings(foreseen)
if with.BusMembership != "" {
// The machine's own, not any module's: how it reaches the mesh from now on. Sealed like a
// secret and placed where the host looks for exactly this (design 28, task 5.2).
@@ -307,7 +334,8 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
"sealed": with.BusMembership, "mode": "0600",
})
}
return Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut}, nil
return Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut,
Foreseen: foreseen}, nil
}
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
@@ -317,7 +345,8 @@ func BusMembershipID() string { return "bus-membership" }
const BusMembershipPath = "/var/lib/mesh/membership-next.json"
func (r Resolution) compose(with Rendering, owner map[string]string,
received map[string]map[string][]Contribution, leftOut map[string]string) ([]map[string]any, error) {
received map[string]map[string][]Contribution, leftOut map[string]string,
foreseen *[]string) ([]map[string]any, error) {
// **A setting is judged where it is stored, and an impossible one costs a module, not a
// machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes
// this module uncomposable; it is left out of the declaration — its held things kept, its
@@ -534,12 +563,17 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
}
for _, name := range sortedKeys(m.OwnSecrets) {
sealed := with.Needed[m.Module][name]
if sealed == "" && with.Foreseen[m.Module][name] {
// Not made, and the next send makes it: composed with a stand-in so that whatever
// else this composition would refuse is still found (novox/hq issue 275).
sealed = ForeseenSealed
*foreseen = append(*foreseen, m.Module+"/"+name)
}
if sealed == "" {
// Declared and not made. Refused rather than skipped: a module whose own
// credential is silently absent starts, fails to authenticate, and the reason is
// three layers away from the machine reporting it.
return nil, fmt.Errorf(
"%s needs a secret called %q and none was made for it", m.Module, name)
return nil, &NotMadeError{Module: m.Module, Name: name}
}
// The runtime's credential belongs to the account the runtime runs as (novox/hq ADR 0175,
// to-be 38 WP3): its process is composed `user: <account>` where the node has one, and a
+19
View File
@@ -976,6 +976,25 @@ func (i *Inventory) RecordSentUnder(ctx context.Context, node, digest string, bu
return err
}
// AwaitingSince is since when a machine has had something waiting for its next send (novox/hq issue
// 275): the oldest assignment on it made after it was last sent, or — when nothing was assigned since —
// when it was last sent, or when it joined if it never was. The moment a bound on "not pushed yet" is
// read from: every change a push carries happened after the last push, and an assignment is the one
// that says when.
func (i *Inventory) AwaitingSince(ctx context.Context, name string) (time.Time, error) {
var since time.Time
err := i.store.Pool().QueryRow(ctx,
`select coalesce(
(select min(a.assigned) from assignment a
where a.node = n.id and (n.sent_at is null or a.assigned > n.sent_at)),
n.sent_at, n.created)
from node n where n.name = $1`, name).Scan(&since)
if errors.Is(err, pgx.ErrNoRows) {
return time.Time{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name)
}
return since, err
}
// SentBuilds is the build of each module a machine was last sent, by its name: module to the commit
// its build was made from (novox/hq issue 259). Known is false when that was not kept — a machine
// last sent before it was, sent a declaration by hand, or one the mesh does not know.
+47 -7
View File
@@ -327,9 +327,7 @@ func (i *Inventory) SecretForModule(ctx context.Context, node, module, name stri
return "", err
}
if key == "" {
return "", fmt.Errorf(
"%s needs a secret and %s has no sealing key, so nothing can be sealed to it",
module, node)
return "", noSealingKey(module, node)
}
record, err := i.NodeByName(ctx, node)
if err != nil {
@@ -349,10 +347,7 @@ func (i *Inventory) SecretForModule(ctx context.Context, node, module, name stri
// would put 32 random bytes where a working credential was: the machine would apply it,
// report success, and whatever reads it would fail to authenticate somewhere else
// entirely — with the mesh insisting the secret was delivered, which it was.
return "", fmt.Errorf(
"%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
"since generated a new sealing key. The mesh cannot make another; issue it again",
module, node, name, node)
return "", acceptedUnderAnOldKey(module, node, name)
}
operator, err := i.OperatorKey(ctx)
@@ -381,6 +376,51 @@ func (i *Inventory) SecretForModule(ctx context.Context, node, module, name stri
return made.ForConsumer, nil
}
// WouldMakeSecretForModule says what SecretForModule would do about an own secret, without doing it:
// nil when it would make one (or one is held), and otherwise the very refusal it would meet. The read
// a question asked ahead of a send uses (novox/hq issue 275), so that "the next push makes this" is
// told apart from "the next push fails on this" by the same rules the push applies.
func (i *Inventory) WouldMakeSecretForModule(ctx context.Context, node, module, name string) error {
key, err := i.SealingKeyOf(ctx, node)
if err != nil {
return err
}
if key == "" {
return noSealingKey(module, node)
}
record, err := i.NodeByName(ctx, node)
if err != nil {
return err
}
var against, origin string
err = i.store.Pool().QueryRow(ctx,
`select node_key, origin from module_secret where node = $1 and module = $2 and name = $3`,
record.ID, module, name).Scan(&against, &origin)
switch {
case errors.Is(err, pgx.ErrNoRows):
return nil
case err != nil:
return err
case against != key && origin == "accepted":
return acceptedUnderAnOldKey(module, node, name)
}
return nil
}
// noSealingKey is the refusal for a secret on a machine that has no key to seal it to.
func noSealingKey(module, node string) error {
return fmt.Errorf("%s needs a secret and %s has no sealing key, so nothing can be sealed to it",
module, node)
}
// acceptedUnderAnOldKey is the refusal for a given secret sealed to a key the machine no longer has.
func acceptedUnderAnOldKey(module, node, name string) error {
return fmt.Errorf(
"%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
"since generated a new sealing key. The mesh cannot make another; issue it again",
module, node, name, node)
}
// AcceptSecretForModule keeps a value somebody supplied as a module's own secret.
//
// The counterpart to SecretForModule, which generates one. Some of what a module needs the mesh