Author SHA1 Message Date
jschoubben a397492503 The link tests are given the seat list as the node-engine gives it from the bus, so the node seat has its verbs to serve (issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 01:43:37 +01:00
jschoubben dae2aa0be1 gofmt the files the group try listed
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 01:38:43 +01:00
jschoubben d064c41057 Take tidy's go.mod whole: mesh requirements at the zero version only, unused ones dropped
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 00:52:18 +01:00
jschoubben 393f6902b3 Complete go.mod and go.sum for the externals each module builds with
A check's tests run without network, so each module must carry every external module it builds with.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 00:51:22 +01:00
jschoubben 015304f4a3 Fold three tests into existing ones, drive the marker test by channels, keep the last view through set(nil) and drop it once the node is held
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 23:09:00 +01:00
jschoubben c55672ce5e A fill while the agent restarts is judged by the last view held and, refused, is owed and told again (issue 924)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 23:03:58 +01:00
jschoubben 7add29cffe Merge fix/compose-pass-bounded: the composition pass never waits on marking haves
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 22:35:09 +01:00
jschoubben c1b8b9d115 The composition pass never waits on marking haves, and the compose verb answers while a pass runs
The marks of the haves (a refusal, the words a module keeps) read and write each module's have in turn, and each write
walks every have: on a slow bus the pass waited minutes on them (shanks, 2026-11-05 22:22). They now run off the pass,
newest word per kind, each bounded; an ask of compose during a pass is answered at once and queues one more pass.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 22:33:21 +01:00
jschoubben 8717ec76d4 The node-engine follows the module-assignment seat: assignments, not placement; the seat's packages move to mesh-contracts and mesh-delivery (issue 1966)
The state, events and types of the seat mesh-module-assigner say assignments; the legacy node key form is gone from the
reads; the dead seat-verb list of the retired name is deleted. The placement guard and the placed-file sense stay.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 22:08:40 +01:00
jschoubben e1b9f3e095 No client names the holder of the seat mesh-provisioner: each reader keeps its own constant, held to its manifest (ADR 0625 section 4, issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 19:36:21 +01:00
jschoubben c40a79e601 The manifest test lists the view's settled among the engine's consumes (issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 19:25:19 +01:00
jschoubben b75495594a The node-engine's account hears what its manifest consumes: the view's settled is a consumes line, not a special case in the grants part (ADR 0625 section 4, issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 19:23:39 +01:00
jschoubben 24b7740b80 RemovedDeliveryVerbs leaves mesh-sdk for the seat mesh-delivery's client (ADR 0634 section 2, issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 18:45:50 +01:00
jschoubben aa40b107c9 No client names a holder: each reader keeps its own constant, held to its manifest (ADR 0625 section 4, issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 18:37:28 +01:00
jschoubben d69fd304cc Require every mesh package of the group at the floor version (ADR 0573 sections 2 and 4, issue 1966)
A member's go.mod names the oldest version a module accepts; the check builds the group's own heads whatever it names. go.sum lines of mesh packages are left out.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:56:39 +00:00
jschoubben 4a57d8c90c The node-engine reads, records and routes artifact references through the registry clients' reference forms (registryforms), not mesh-sdk catalogue's (ADR 0634 §4, S-8; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:38:04 +01:00
jschoubben b8dbee28cc Merge branch 'b6/registry' into feat/registry-milestone 2026-11-05 16:31:43 +01:00
jschoubben 2bcc34a59b Node-engine test: a node whose own composition folds the holder of the seat mesh-catalog starts it before the list is read and composes the rest after (ADR 0636 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:30:45 +01:00
jschoubben e282e08917 Node-engine: a held own composition that was handed stays handed, as a held module's does (ADR 0636 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:28:02 +01:00
jschoubben b0baa17836 Node-engine: a reader of the seat list that could not start holds the composer, where none gated nothing (ADR 0636 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:22:53 +01:00
jschoubben ae26376a90 Node-engine: while the seat list is unread, the node's own composition lets through the holder of the seat mesh-catalog's resources and accounts over what runs (ADR 0636 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:22:15 +01:00
jschoubben 58f45a1b12 Merge branch 'b6/registry' into feat/registry-milestone 2026-11-05 15:52:59 +01:00
jschoubben c1a1efa62a Node-engine: provisions come from the provisioner's client, the catalogue's state addresses from its client and the builder's module; the health answer and the bus principal stay mesh-sdk's (ADR 0634 §1, §5; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:52:24 +01:00
jschoubben 5b9e9d872d Merge branch 'b6/registry' into feat/registry-milestone 2026-11-05 15:48:43 +01:00
jschoubben 13e2fe14aa Node-engine: logins, signers, enrolment tokens and redeems, and the grants part's sealed user list come from the vault and node-enroller clients and the grants state package; no file imports go/delivery (ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:47:59 +01:00
jschoubben 4bfa94dfef Node-engine: a builder's build, its keys and codec come from the builder's builds package (ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:44:31 +01:00
jschoubben 10852161c9 Node-engine: the catalogue's modules, manifests, verdicts and candidates are the mesh-catalog seat's types and its client's keys and codecs; the composer case is the delivery SDK's, its verdicts and placements raw; the provided module is the private network's (ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:42:34 +01:00
jschoubben 669589b76b Node-engine: the placement's bucket and read names come from the assigner client (ADR 0634 §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:36:05 +01:00
jschoubben c35cc1d9e4 Node-engine: the placement, the wants and the placement candidates are the seats' contract types and the assigner client's keys and codecs; the found tunnel is the placement seat's typed record (ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:34:02 +01:00
jschoubben 3cf4fbaf3d Merge branch 'b6/registry' into feat/registry-milestone 2026-11-05 15:23:24 +01:00
jschoubben 7eb7b9fa62 Node-engine: the have, the node's facts, the record, the health report and the data items are the seat's contract types, written through the node-engine client; the interim bridge is gone (Have group, ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:21:48 +01:00
jschoubben ec3a65fa8d Merge branch 'b6/registry' into feat/registry-milestone 2026-11-05 15:12:02 +01:00
jschoubben 5906e18028 Node-engine: a composition, its codec, digest and keys come from the node-engine client; the candidate answer and its limits from the seat's contract; the declared packages are the engine's own (H-2, H-3, ADR 0634 §1; issue 1966)
The engine's Composed keeps the catalogue's Plan and the declaration types and goes through nodeengineclient's codec; the fingerprint key part is meshcatalogclient's. The candidate answer is nodeengine.CandidateAnswer; the have still holds it through an interim JSON bridge (link.interimAnswers) until the Have group moves.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:10:45 +01:00
jschoubben 0871fa7cc4 Node-engine: the value version comes from mesh-sdk go/keys, not go/delivery (H-2, ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:59:39 +01:00
jschoubben cdd428a7d8 Node-engine: the whole seat-list case proves a fresh node asks the seat over its own grant while its own composition and web are held, no part needs letting through (ruling 15:1x b, ADR 0636 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:57:50 +01:00
jschoubben 514ffc30da Merge branch 'b6/registry' into feat/registry-milestone 2026-11-05 14:54:25 +01:00
jschoubben df44d21c82 Node-engine: composition comparison, seals and differing paths are the engine's own package, internal/compositions, with the tests moved from mesh-sdk's delivery (H-1, ADR 0634 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:53:06 +01:00
jschoubben dcbdc70919 Node composer: while the seat list is unread the node's own composition is held too, whole, under the scope node; only what runs and the bootstrapped modules go on (ADR 0636 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:48:20 +01:00
jschoubben 84e50beb65 Node-engine: the private network's names and generator come from mesh-wireguard's overlay package; the host's internal/overlay is deleted, mesh-sdk's go/overlay has no importer left here; contracts imports follow the domain folders (ADR 0636 §6, ADR 0634; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:45:43 +01:00
jschoubben 308dbb94b8 Node-engine: seat-list and seat-list-again answer every operator's session and the healers, no other caller (ADR 0636 §7; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:45:43 +01:00
jschoubben c642471c16 Node-engine: the first read of the seat list is awaited on the reader's Ready, not on Start's return, and its end is told to the waiter and the composer (B9c, ADR 0636 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:45:43 +01:00
jschoubben 24cfc18383 Merge b6/registry into the registry milestone's join (issue 1966) 2026-11-05 14:39:37 +01:00
jschoubben f29b19aff2 Node-engine manifest: invokes the seat mesh-catalog's seat-definitions, consumes its two events, keeps the state findings (H-7, ADR 0636; issue 1966) 2026-11-05 14:30:08 +01:00
jschoubben 855f4471e2 Node-engine: reads the seat list through the seat mesh-catalog, holds what is new until it was read, writes its finding after five minutes; verbs seat-list and seat-list-again; whole-module case against a stand-in of the seat (H-7, ADR 0636 §1, §7, §8; issue 1966) 2026-11-05 14:26:15 +01:00
jschoubben 6ebad21cbe Node-engine: the seats are called by their names mesh-module-assigner and mesh-node-enroller (ADR 0634 §9; H-5; issue 1966) 2026-11-05 14:17:46 +01:00
jschoubben a222db8c69 Node-engine composer: a module new to the node is held until the seat list was read, unless its seats are bootstrapped; the read hands it (H-7, ADR 0636 §1; issue 1966) 2026-11-05 14:17:28 +01:00
jschoubben 66fe7c8a0d Node-engine: the finding of a seat list unread for five minutes, written once and cleared at the first full read (H-7, ADR 0636 §1; issue 1966) 2026-11-05 14:16:04 +01:00
jschoubben 7623279efe Node-engine: no compiled seat list; the seat's name and verbs come from its contract, the tests give catalogue their seats (H-7, ADR 0635 §1; issue 1966) 2026-11-05 14:14:46 +01:00
jschoubben 570b66230c Composer: find the store by the holder of any of its names, the old seat's too (ADR 0632 §2; issue 1966)
While the indexer holds distribution's new manifest, every node reads main's,
which claims only mesh-artifact-store: a new engine must find the store there.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 21:29:12 +01:00
jschoubben e5d9753368 Resolution test: the runtime's trust is filled from every name of the store (ADR 0632 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 21:10:55 +01:00
jschoubben 6adaf44f65 Composer: the store's address is the image seat holder's listen open to the mesh (ADR 0632 §1; issue 1966)
The provision artifact-store retires, so the store is found by the holder of
container-image-registry and the listen it opens to the mesh, on the port its
node put it on; every name of the store, oci-artifact-registry's too, answers
that address. The engine states form:seat-reach with its contract.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 20:56:43 +01:00
jschoubben 66e89b71a2 Offer the catalogue forge's provisions in the toolchain test's stand-in (issue 1966)
The group's mesh-build-go requires go-package-registry, which the forge
now provides; the stand-in listed the forge's offers by hand and missed it,
so the test failed against the group's catalogue. It now takes them from
the catalogue's gitea manifest, so a registry the forge gains needs no edit.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 19:56:18 +01:00
jschoubben 32c5df952c Pin mesh-sdk 05b3e2e1b94f and mesh-contracts ae5266e77639, the group's final heads (issue 1966)
mesh/delivery ready: it is merged on the Approve
mesh/check failed (7): shanks: novox/mesh-catalog#612 does not merge into the trunk: files in conflict: mod…; and 6 more | 32c5df952cbf on e73aae3686a6
mesh/delivery-group group feat/registry-milestone rejected: composed together: the checker's verdict at the members' heads (068b7d23cf1a): shanks
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:47:39 +01:00
jschoubben 4363c371c5 Pin mesh-sdk e5d89c5eedb5 and mesh-contracts 347a3053580a, the group's heads after review round 2 (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:26:33 +01:00
jschoubben 1b01d065ac Pin mesh-sdk c54648e74346 and mesh-contracts ec9529022c44, the group's heads (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:46:08 +01:00
jschoubben d3ee49d6f3 F-host 2, ENG-5: on a fresh mesh the bootstrap pushes the holders' own images to the store on its loopback with no credential, and names them under the seat's reach (lead's design limit, review of issue 1966)
The store's front is distribution's program, a bundle the mesh builds, so nothing serves it before the builder: the
bootstrap's /v2/ wait and its pushes now go to the loopback port the store's manifest publishes, as the publish contract
allows on the store's own node. The one-off credential and its file go: the runtime could not carry it on a push, and no
push needs it. The tags list is asked for the repository being published, not always the control plane's.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:29:56 +01:00
jschoubben ded92a8137 F-host 3: the agent's tests of a pull waiting on its holder wait for the loop to come to rest, not 50-100 ms, before they count applies (review of issue 1966)
waitIdle returns once every change sent to the loop is taken in and applied and no backoff wait runs, so a retry the
clock or a wrong settled would start is counted, with no real sleep.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:26:16 +01:00
jschoubben 20614822df F-host 2, ENG-5: the bootstrap's one-off push credential goes to distribution's account, mode 0600, only once the node-engine made that account and its state directory, so the holder's program can read it (review of issue 1966)
The owner is the account the manifest names for the directory that holds the file; the bootstrap no longer makes that
directory itself, and a run that reaches it first is refused naming what it waits on.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:24:14 +01:00
jschoubben 9740238ec9 F-host 1: ignore the binaries go build leaves in cmd/, so a built node-engine is never committed again (review of issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:20:46 +01:00
jschoubben a2313057c0 ENG-9: a failed have carries the pull it waits on, read through node_haves
The image, the seat and its holder, when the pull began, and the last retry with what triggered it ride in the failed
have's pull-wait (mesh-sdk delivery.Have.PullWait, asked of the lead in j1-sdk-requests.patch), from the agent through
the link's module fact to the have bucket; the agent's own read view of them goes with the verb. Rows in the agent's
pull test and the have bucket's test; each failed without the code.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:02:44 +01:00
jschoubben 2b14f166de The node-engine speaks operator-channel, mesh-vault and node-engine through their contracts and clients; it serves no pull-waits
ADR 0627 §1, §4: mesh-sdk keeps no asks, asker, vault answers or compose-candidate checks, so the node-engine takes them
from mesh-contracts: operator-channel's contract and client (asks, warrants, the asker's client), mesh-vault's sign verb
and answer, and node-engine's own contract and client for compose-candidate's ask, answer, verdicts and bound. The
lead's ruling on pull-waits (no record gives the seat that verb): the verb and its handler go; the row that read the
waits now holds the seat refusing the verb, and failed before the change. The go.mod requires of mesh-contracts' modules
come with the group's pin.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:02:44 +01:00
jschoubben d8207358b0 Join mesh-host's main (issue 1585's held-answer fix, #208) into the registry milestone
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:46:50 +01:00
jschoubben c6ae0fc405 The node-engine answers compose-candidate in its seat's contract: the input as a JSON object, a module's fingerprint Fp
mesh-sdk's ComposeCandidateAnswer and ModuleComposed are now the generated nodeengine types (ADR 0613 §3, §5), so the
input sent for the checker's sandbox is decoded once into the object the contract carries, its numbers kept as written
(json.Number), and its size still counted from the bytes it was marshalled to.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 12:40:34 +01:00
jschoubben 0128b962ab The node-engine speaks its seat's generated contract: manifest-reads and manifest-reads-changed in mesh-contracts' types (ADR 0618 §3, §4, ADR 0613 §3)
The verb's answer and the event's body are mesh-contracts' nodeengine types, not mesh-sdk's, and the seat's verb and event
names are the contract's. The two reasons of the event, started and returned, are the engine's own words beside it.
Requires mesh-contracts and the registry milestone's mesh-sdk (a go.work, uncommitted, until the group pins them).

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 12:38:50 +01:00
jschoubben 271de0108d The node-engine serves manifest-reads and says manifest-reads-changed once per link (ADR 0618 §3, §4, issue 774)
Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 12:38:30 +01:00
jschoubben c93feeb30f ENG-9: the node seat's read verb pull-waits answers which failed applies wait on which store's holder and the last retry with its trigger (design 51, issue 1966; needs the verb in the node-engine seat's definition)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:15:26 +01:00
jschoubben 7f33c8b499 ENG-3, ENG-4: the node-engine keeps no seat table of its own (mesh-contracts' list through mesh-sdk), answers ${seat:...:reach} for both names of the store, and reads both reference forms in composition, image seat and bootstrap (ADR 0605 §9, design 26, issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:11:34 +01:00
jschoubben 18c1735f7c ENG-5: the bootstrap makes a one-off push credential for the holders' first images, writes it for the store's holder and forgets it when its run ends (design 51, issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:07:34 +01:00
jschoubben 2a71a8ff76 ENG-8, ENG-9, ENG-11: a failed pull from a store is tried again once per settled of the store's holder, heard on every node, and the failed have says whom it waits on and what triggered the last retry (design 51, issue 1966; needs mesh-sdk request R1, R2)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 11:49:44 +01:00
jschoubben 3eef84d979 ENG-10: the composition writes beside each routed image the seat it came through and that seat's holder, no part of a container's spec (design 51, issue 1966; needs mesh-sdk request R1)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 11:46:33 +01:00
jschoubben 60358d01e9 ENG-note: notHandedWhy's impossible branch says it is a fault in the node-engine's composition (review of issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 11:43:46 +01:00
jschoubben 05418b4fe5 ENG-7: every new image of a module of two containers or more is pulled before any of its containers is set aside, and a pull that fails replaces nothing (design 51, issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 11:43:17 +01:00
jschoubben 2779f5312b ENG-2: the node's record says the capabilities its tool runner states of its calls, asked of the runner's PING at each report (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 11:41:11 +01:00
188 changed files with 7621 additions and 5258 deletions
+2
View File
@@ -4,3 +4,5 @@
# The placeholder `make bootstrap` moves aside while a saved image is embedded. Ignored so an
# interrupted release build cannot commit a twenty-megabyte tar by accident.
/internal/image/builder.tar.placeholder
/cmd/mesh-host/mesh-host
/cmd/mesh-bootstrap/mesh-bootstrap
+7 -4
View File
@@ -3,6 +3,9 @@ package main
import (
"bytes"
"encoding/json"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"github.com/novox/mesh-host/internal/buckets/bucketstest"
"go/build"
"os"
"os/exec"
@@ -14,7 +17,6 @@ import (
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/beside"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/composer"
@@ -94,7 +96,7 @@ func TestTheCommandWritesOneVerdictPerManifestAndMachine(t *testing.T) {
r := request(t, composer.DepthResolve)
r.Cases[0].Manifests["later"] = composer.Manifest{Raw: json.RawMessage(`{"module":"later","version":"1","shares":["x"]}`)}
r.Cases[0].Manifests["refused"] = composer.Manifest{Raw: json.RawMessage(`{"module":"refused","version":"1"}`)}
r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"refused": {State: delivery.VerdictRefused, Problems: []string{"it is wrong"}}}
r.Cases[0].Verdicts = bucketstest.Raw(map[string]meshcatalog.VerdictsByDigestValue{"refused": {V: 1, Module: "refused", Judge: "src2:j", State: meshcatalogclient.VerdictRefused, Problems: []string{"it is wrong"}}})
code, v, stderr := runJSON(t, nil, encode(t, r))
if code != 0 {
t.Fatalf("exit %d: %s", code, stderr)
@@ -280,7 +282,8 @@ func ownPackages(t *testing.T) map[string][]string {
}
// 1621/AC2 and AC4: the command imports, through every package of this repository it reaches, only the standard
// library, mesh-sdk and this repository's own packages, and none that reaches a bus, a store, a network service or
// library, mesh-sdk, the contracts modules' pure packages (seats' types, clients' codecs), the delivery SDK's composer (the command's own request and verdict types), the builder's builds package (a build's value and its keys), the private network's overlay package (mesh-wireguard's own pure package: names, graph and
// generator, no bus) and this repository's own packages, and none that reaches a bus, a store, a network service or
// another process.
func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) {
forbidden := []string{"net/http", "net/rpc", "net/smtp", "os/exec", "database/sql", "plugin", "syscall"}
@@ -291,7 +294,7 @@ func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) {
for path, imports := range own {
for _, imp := range imports {
std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".")
if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") {
if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-contracts/") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/contracts/") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/sdk/composer") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") {
t.Errorf("%s imports %s, which is neither the standard library, mesh-sdk nor this repository", path, imp)
}
for _, f := range forbidden {
+1
View File
@@ -27,6 +27,7 @@ func TestAnApplyOutlivesTheLinksContext(t *testing.T) {
// An apply is bounded all the same: a step that hangs (a docker call that never returns) cannot hold the node agent for
// ever (the review of issue 1945).
func TestAnApplyIsBounded(t *testing.T) {
t.Run("the marks of the haves never hold the pass", marksOfTheHavesNeverHoldThePass)
ctx, done := applyContext(context.Background())
defer done()
at, ok := ctx.Deadline()
+2 -2
View File
@@ -2,12 +2,12 @@ package main
import (
"encoding/json"
engcomp "github.com/novox/mesh-host/internal/compositions"
"os"
"path/filepath"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// authorityPath is where this engine keeps the mesh's own certificate as it last applied it (public): beside the
@@ -17,7 +17,7 @@ func authorityPath(state string) string { return filepath.Join(filepath.Dir(stat
// keepAuthority keeps the mesh's own certificate a composition carries (the resource a module's certificate.authority
// asks for, catalogue.AuthorityID), so the record says the authority this machine holds. A composition carrying none
// changes nothing.
func keepAuthority(state string, c delivery.Composed) error {
func keepAuthority(state string, c engcomp.Composed) error {
for _, raw := range c.Resources {
var r struct {
ID string `json:"id"`
+26 -24
View File
@@ -7,14 +7,16 @@ import (
"encoding/base64"
"encoding/json"
"encoding/pem"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
engcomp "github.com/novox/mesh-host/internal/compositions"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/overlay"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"github.com/novox/mesh-host/internal/identity"
)
@@ -28,13 +30,13 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) {
raw, _ := json.Marshal(map[string]any{"id": id, "type": "file", "path": "/etc/x", "content": content})
return raw
}
if err := keepAuthority(state, delivery.Composed{Resources: []json.RawMessage{res("web.conf", "x")}}); err != nil {
if err := keepAuthority(state, engcomp.Composed{Resources: []json.RawMessage{res("web.conf", "x")}}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(authorityPath(state)); !os.IsNotExist(err) {
t.Fatalf("a composition with no authority kept one: %v", err)
}
if err := keepAuthority(state, delivery.Composed{Resources: []json.RawMessage{res("ca-trust.certificate-authority", "CA-1")}}); err != nil {
if err := keepAuthority(state, engcomp.Composed{Resources: []json.RawMessage{res("ca-trust.certificate-authority", "CA-1")}}); err != nil {
t.Fatal(err)
}
if got := recordKeys(state, nil)(); got.Authority != "CA-1" {
@@ -50,38 +52,38 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) {
told := 0
trust.onChange(func() { told++ })
keys := recordKeys(state, trust.Trusts)
ctrlFP, vaultFP := delivery.SignerFingerprint(controllerPub), delivery.SignerFingerprint(vaultPub)
enrol, _ := delivery.SignContext(delivery.KindEnrolment)
ctrlFP, vaultFP := meshvaultclient.SignerFingerprint(controllerPub), meshvaultclient.SignerFingerprint(vaultPub)
enrol, _ := meshvaultclient.SignContext(meshvaultclient.KindEnrolment)
msg := []byte("a reply")
if got := keys().Trusts; len(got) != 0 {
t.Errorf("before the signers are read the record trusts %v", got)
}
if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(controller, enrol, msg)); err == nil {
if _, err := trust.Verify(meshvaultclient.KindEnrolment, msg, meshvaultclient.SignMessage(controller, enrol, msg)); err == nil {
t.Error("a controller-signed message is accepted")
}
if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(vault, enrol, msg)); err == nil {
if _, err := trust.Verify(meshvaultclient.KindEnrolment, msg, meshvaultclient.SignMessage(vault, enrol, msg)); err == nil {
t.Error("a vault-signed message is accepted before the vault's key is read")
}
vaultCA := testAuthority(t, vaultPub, vault)
keyValue, _ := json.Marshal(delivery.NewSignerKey(vaultPub, time.Now()))
fp, _ := delivery.AuthorityFingerprint(vaultCA)
authValue, _ := json.Marshal(delivery.SignerAuthority{Certificate: vaultCA, Fingerprint: fp, Made: time.Now()})
trust.heardSigner(delivery.SignersKeyKey, keyValue, false, func(string) {})
trust.heardSigner(delivery.SignersAuthorityKey, authValue, false, func(string) {})
if s, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(vault, enrol, msg)); err != nil || s.Name != delivery.SignerVault {
keyValue, _ := json.Marshal(meshvaultclient.NewSignerKey(vaultPub, time.Now()))
fp, _ := meshvaultclient.AuthorityFingerprint(vaultCA)
authValue, _ := json.Marshal(meshvault.SignersByAuthorityValue{Certificate: vaultCA, Fingerprint: fp, Made: time.Now()})
trust.heardSigner(meshvault.SignersByKeyKey(), keyValue, false, func(string) {})
trust.heardSigner(meshvault.SignersByAuthorityKey(), authValue, false, func(string) {})
if s, err := trust.Verify(meshvaultclient.KindEnrolment, msg, meshvaultclient.SignMessage(vault, enrol, msg)); err != nil || s.Name != meshvaultclient.SignerVault {
t.Errorf("a vault-signed message on a node that read signers: %+v, %v", s, err)
}
if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(controller, enrol, msg)); err == nil {
if _, err := trust.Verify(meshvaultclient.KindEnrolment, msg, meshvaultclient.SignMessage(controller, enrol, msg)); err == nil {
t.Error("a controller-signed message is accepted once the vault's key is read")
}
if got := keys().Trusts; strings.Contains(strings.Join(got, " "), ctrlFP) || !delivery.TrustsKey(got, delivery.SignerVault, vaultFP) ||
if got := keys().Trusts; strings.Contains(strings.Join(got, " "), ctrlFP) || !meshvaultclient.TrustsKey(got, meshvaultclient.SignerVault, vaultFP) ||
trust.VaultAuthority() != vaultCA || told != 2 {
t.Errorf("after the signers are read the record trusts %v (told %d)", got, told)
}
// A signers' key naming another fingerprint is not trusted.
bad, _ := json.Marshal(delivery.SignerKey{Key: base64.StdEncoding.EncodeToString(controllerPub), Fingerprint: vaultFP})
trust.heardSigner(delivery.SignersKeyKey, bad, false, func(string) {})
if !delivery.TrustsKey(keys().Trusts, delivery.SignerVault, vaultFP) {
bad, _ := json.Marshal(meshvault.SignersByKeyValue{Key: base64.StdEncoding.EncodeToString(controllerPub), Fingerprint: vaultFP})
trust.heardSigner(meshvault.SignersByKeyKey(), bad, false, func(string) {})
if !meshvaultclient.TrustsKey(keys().Trusts, meshvaultclient.SignerVault, vaultFP) {
t.Error("a signers' key that does not check replaced the vault's")
}
@@ -96,8 +98,8 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) {
}
servingPub, _ := base64.StdEncoding.DecodeString(serving.Public)
signedBy := func(ca string, key ed25519.PrivateKey) string {
parent, _ := delivery.ParseCertificate(ca)
der, err := x509.CreateCertificate(nil, delivery.NodeCertificateTemplate(overlay.InternalName("n1"), servingPub, time.Now().Add(-time.Hour)),
parent, _ := meshvaultclient.ParseCertificate(ca)
der, err := x509.CreateCertificate(nil, meshvaultclient.NodeCertificateTemplate(overlay.InternalName("n1"), servingPub, time.Now().Add(-time.Hour)),
parent, ed25519.PublicKey(servingPub), key)
if err != nil {
t.Fatal(err)
@@ -108,13 +110,13 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) {
answer := signedBy(vaultCA, vault)
ask := func(_ context.Context, verb string, args any) (json.RawMessage, error) {
asked = append(asked, verb, args)
return json.Marshal(delivery.SignAnswer{Kind: delivery.KindNodeCertificate, Node: "n1", Certificate: answer})
return json.Marshal(meshvault.SignAnswer{Kind: meshvaultclient.KindNodeCertificate, Node: "n1", Certificate: answer})
}
written := 0
if err := certifyThisNode(context.Background(), state, "n1", trust, ask, func() { written++ }); err != nil {
t.Fatal(err)
}
if got := recordKeys(state, nil)(); got.Certificate != answer || written != 1 || len(asked) != 2 || asked[0] != delivery.SignVerb {
if got := recordKeys(state, nil)(); got.Certificate != answer || written != 1 || len(asked) != 2 || asked[0] != meshvault.VerbSign {
t.Errorf("the vault's certificate: kept %v, written %d, asked %v", got.Certificate == answer, written, asked)
}
answer = signedBy(testAuthority(t, controllerPub, controller), controller)
@@ -158,7 +160,7 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) {
// testAuthority is a self-signed authority certificate for key, as the vault makes its own.
func testAuthority(t *testing.T, pub ed25519.PublicKey, key ed25519.PrivateKey) string {
t.Helper()
tpl := delivery.AuthorityTemplate(pub, time.Now().Add(-time.Hour))
tpl := meshvaultclient.AuthorityTemplate(pub, time.Now().Add(-time.Hour))
der, err := x509.CreateCertificate(nil, tpl, tpl, pub, key)
if err != nil {
t.Fatal(err)
+61
View File
@@ -6,10 +6,13 @@ import (
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/declaration"
"github.com/novox/mesh-host/internal/agent"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/link"
)
@@ -85,4 +88,62 @@ func TestABackupEndsWhenItsEventSaysSo(t *testing.T) {
t.Errorf("%s: asked %d times, %v", row.name, asked, err)
}
}
// The events of the same kind (ENG-11): the status view's settled is told to the agent by the module that settled, on
// whichever node; a body with no module, or no JSON, tells nothing; the end of the link ends the hearing.
told := &settledRecorder{}
events := make(chan []byte, 4)
for _, body := range []string{`{"node":"n2","module":"distribution"}`, `{"node":"n1"}`, `not json`, `{"module":"gitea"}`} {
events <- []byte(body)
}
linked, end := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { defer close(done); listen(linked, events, func() settledTold { return told }, func(string) {}) }()
deadline := time.Now().Add(2 * time.Second)
for len(told.heard()) < 2 && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
end()
<-done
if got := strings.Join(told.heard(), ","); got != "distribution,gitea" {
t.Errorf("the agent was told %q settled, want distribution,gitea", got)
}
// And the images an apply could not pull are said with the holder the declaration names beside each, none for a public
// one (ENG-8, ENG-10).
d, err := declaration.ParseFileTrusted([]byte(`{"declaration":1,"resources":[` +
`{"id":"a.first","type":"container","name":"first","image":"reg/x@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","image-seat":"mesh-artifact-store","image-holder":"distribution"},` +
`{"id":"a.second","type":"container","name":"second","image":"public/y@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}`))
if err != nil {
t.Fatal(err)
}
pull := func(image string) error {
return &apply.Error{Resource: "x", Err: &apply.PullError{Image: image, Err: errors.New("manifest unknown")}}
}
if got := pullsOf(d, pull("reg/x@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); len(got) != 1 || got[0].Seat != "mesh-artifact-store" || got[0].Holder != "distribution" {
t.Errorf("a store's image is said as %+v", got)
}
if got := pullsOf(d, pull("public/y@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); len(got) != 1 || got[0].Holder != "" || got[0].Seat != "" {
t.Errorf("a public image is said as %+v", got)
}
if got := pullsOf(d, errors.New("another failure")); len(got) != 0 {
t.Errorf("a failure that is no pull is said as %+v", got)
}
}
// settledRecorder is the agent a hearing tells.
type settledRecorder struct {
mu sync.Mutex
list []string
}
func (r *settledRecorder) Settled(holder string) {
r.mu.Lock()
r.list = append(r.list, holder)
r.mu.Unlock()
}
func (r *settledRecorder) heard() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.list...)
}
+3 -4
View File
@@ -2,10 +2,9 @@ package main
import (
"encoding/json"
engcomp "github.com/novox/mesh-host/internal/compositions"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// A composition that names a file the provisioner fills without saying it is filled would have this node-engine write
@@ -13,7 +12,7 @@ import (
// when ace, g14 and shanks wrote claude-code's, node-env's and power's files empty).
func TestAFileItCannotProduceIsNeverWrittenEmpty(t *testing.T) {
unit := json.RawMessage(`{"id":"power.after-boot-unit","type":"file","path":"/etc/systemd/system/mesh-power-after-boot.service","mode":"0644","owner-at-runtime":"owned"}`)
c := delivery.Composed{Node: "ace", Resources: []json.RawMessage{unit}}
c := engcomp.Composed{Node: "ace", Resources: []json.RawMessage{unit}}
d, err := declarationOf(c)
if err != nil {
t.Fatal(err)
@@ -25,7 +24,7 @@ func TestAFileItCannotProduceIsNeverWrittenEmpty(t *testing.T) {
if d, _ = declarationOf(c); refuseEmptyWrites(d) != nil {
t.Fatalf("a filled file was refused: %v", refuseEmptyWrites(d))
}
plain := delivery.Composed{Node: "ace", Resources: []json.RawMessage{json.RawMessage(
plain := engcomp.Composed{Node: "ace", Resources: []json.RawMessage{json.RawMessage(
`{"id":"x.f","type":"file","path":"/etc/x","mode":"0644","content":"x"}`)}}
if d, _ = declarationOf(plain); refuseEmptyWrites(d) != nil {
t.Fatalf("a file with its content was refused: %v", refuseEmptyWrites(d))
+14 -14
View File
@@ -13,10 +13,10 @@ import (
"syscall"
"time"
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
stdio "git.novox.be/novox/mesh-sdk/go"
meshasker "git.novox.be/novox/mesh-sdk/go/asker"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/accounts"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/link"
@@ -255,9 +255,9 @@ func engineActs(opts options, node string, queue *link.Session, search *accounts
})
return judgeRootFree(node, lastAccounts.Load(), time.Now(), served, why)
},
Say: queue.SayEngine}
asker := asks.EngineAsker(node)
h.Asker = &meshasker.Client{Module: asker, State: state, Publish: queue.PublishAsk,
ManifestReads: queue.StatedReads, Say: queue.SayEngine}
asker := occ.EngineAsker(node)
h.Asker = &occ.Client{Module: asker, State: state, Publish: queue.PublishAsk,
Record: func(id string) (*stdio.StateEntry, error) { return queue.RouterRecord(asker + "." + id) }}
return h
}
@@ -364,26 +364,26 @@ func packagesFrom(node string, session *link.Session, composer *atomic.Pointer[s
// composeCandidateFrom answers the verb compose-candidate (novox/hq ADR 0593 §7): the check's candidate composed on this
// node in shadow by the run's shadow, applying and writing nothing, within the shadow's own bound (shorter than
// delivery.ComposeAnswerBound, selfcompose candidateWithin). The answer is
// the seat's shape (delivery.ComposeCandidateAnswer), checked as it is encoded; a node whose shadow does not run yet
// nec.AnswerBound, selfcompose candidateWithin). The answer is
// the seat's shape (nodeengine.ComposeCandidateAnswer), checked as it is encoded; a node whose shadow does not run yet
// answers unready, which the checker takes as no answer.
func composeCandidateFrom(node string, shadow *atomic.Pointer[selfcompose.Shadow]) func(candidate string, input bool) (any, error) {
return func(candidate string, input bool) (any, error) {
ask := delivery.ComposeCandidateAsk{Candidate: candidate, Input: input}
if _, err := delivery.EncodeComposeCandidateAsk(ask); err != nil {
ask := nodeengine.ComposeCandidateInput{Candidate: candidate, Input: input}
if _, err := nec.EncodeAsk(ask); err != nil {
return nil, err
}
var a delivery.ComposeCandidateAnswer
var a nodeengine.ComposeCandidateAnswer
if s := shadow.Load(); s == nil {
// Unready, never an error the checker reads words of: its agent is starting (ADR 0593 §9).
a = delivery.ComposeCandidateAnswer{Node: node, Candidate: candidate, Verdict: delivery.ComposeCandidateUnready,
a = nodeengine.ComposeCandidateAnswer{Node: node, Candidate: candidate, Verdict: nec.Unready,
Refused: "this node's shadow does not run now (its node agent is starting), so it composes no candidate"}
} else {
ctx, cancel := context.WithTimeout(context.Background(), delivery.ComposeAnswerBound)
ctx, cancel := context.WithTimeout(context.Background(), nec.AnswerBound)
defer cancel()
a = s.ComposeCandidate(ctx, ask)
}
raw, err := delivery.EncodeComposeCandidateAnswer(a)
raw, err := nec.EncodeAnswer(a)
if err != nil {
return nil, fmt.Errorf("this node's answer to %s does not fit the seat's shape: %w", candidate, err)
}
+9 -10
View File
@@ -6,22 +6,21 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
"os"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/identity"
"github.com/novox/mesh-host/internal/link"
)
// enrolThroughSeat joins this machine through the seat mesh-enrolment (novox/hq ADR 0587 §6, issue 774): it redeems the token
// enrolThroughSeat joins this machine through the seat mesh-node-enroller (novox/hq ADR 0587 §6, issue 774): it redeems the token
// with the keys it made, verifies the vault's signed answer against the key the token carries before it keeps anything, keeps
// its membership and its login, dials as the login node-logins names and confirms. A bus that does not serve the seat to this
// token (link.ErrNoSeat) is said as the error: nothing was sent or kept.
func enrolThroughSeat(ctx context.Context, opts options, token identity.Token, node string, mine identity.Identity,
sealing identity.SealingKey, serving identity.ServingKey, reported map[string]any, found *link.Tunnel, identityPath string) error {
req := delivery.Redeem{Node: node, PublicKey: base64.StdEncoding.EncodeToString(mine.Public), OverlayKey: mine.Overlay.Public,
req := meshnodeenrollerclient.Redeem{Node: node, PublicKey: base64.StdEncoding.EncodeToString(mine.Public), OverlayKey: mine.Overlay.Public,
SealingKey: sealing.Public, ServingKey: serving.Public, Profile: reported}
if found != nil {
var err error
@@ -29,18 +28,18 @@ func enrolThroughSeat(ctx context.Context, opts options, token identity.Token, n
return err
}
}
req.Proof = base64.StdEncoding.EncodeToString(mine.Sign(delivery.RedeemProof(node, req.PublicKey, req.OverlayKey, req.SealingKey, req.ServingKey)))
req.Proof = base64.StdEncoding.EncodeToString(mine.Sign(meshnodeenrollerclient.RedeemProof(node, req.PublicKey, req.OverlayKey, req.SealingKey, req.ServingKey)))
to := link.Approach{Address: token.Broker, Fingerprint: token.Fingerprint, Transport: link.OnNATS}
membership := link.Membership{Node: node, Broker: token.Broker, Fingerprint: token.Fingerprint, Transport: link.OnNATS}
j := link.Joining{Node: node, Signer: token.SignerKey(),
Redeem: func(ctx context.Context) (delivery.RedeemAnswer, error) {
Redeem: func(ctx context.Context) (meshnodeenrollerclient.RedeemAnswer, error) {
return link.Redeem(ctx, to, node, token.Secret, req, opts.timeout)
},
Open: func(a delivery.RedeemAnswer) (string, error) {
Open: func(a meshnodeenrollerclient.RedeemAnswer) (string, error) {
value, err := sealing.Open(a.Account)
return string(value), err
},
Keep: func(a delivery.RedeemAnswer, password string) error {
Keep: func(a meshnodeenrollerclient.RedeemAnswer, password string) error {
// The mesh's name for this node wins over what the machine called itself, as on the controller's path.
mine.Node = a.Node
mine.Membership = identity.Membership{Broker: token.Broker, Fingerprint: token.Fingerprint, Signer: token.Signer,
@@ -64,9 +63,9 @@ func enrolThroughSeat(ctx context.Context, opts options, token identity.Token, n
if err != nil {
return err
}
fmt.Printf("\nenrolled as %s through the seat mesh-enrolment, dialled as %s and confirmed\n", answer.Node, answer.Login)
fmt.Printf("\nenrolled as %s through the seat mesh-node-enroller, dialled as %s and confirmed\n", answer.Node, answer.Login)
fmt.Printf(" identity %s\n", identityPath)
fmt.Println(" nothing is applied until the placement of this node names a module")
fmt.Println(" nothing is applied until the assignment of this node names a module")
return nil
}
+2 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
engcomp "github.com/novox/mesh-host/internal/compositions"
"os"
"path/filepath"
"strconv"
@@ -11,7 +12,6 @@ import (
"testing"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-host/internal/link"
)
@@ -140,7 +140,7 @@ func TestADeclarationWithAFileByDigestAppliesFromTheObject(t *testing.T) {
t.Fatal(err)
}
}
runs := runsAsWritten([]delivery.Composed{{Node: "n", Module: "m", Resources: []json.RawMessage{stale}}}, opts.state)
runs := runsAsWritten([]engcomp.Composed{{Node: "n", Module: "m", Resources: []json.RawMessage{stale}}}, opts.state)
var got struct {
Digest string `json:"digest"`
}
+3 -3
View File
@@ -10,8 +10,8 @@ import (
"sync/atomic"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/provisions"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/buckets"
"github.com/novox/mesh-host/internal/filling"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
@@ -130,7 +130,7 @@ func provisionSecretsOn(queue *link.Session, node, statePath string) provisionSe
p.list = func(ctx context.Context, prefix string) (map[string][]byte, error) {
wctx, cancel := context.WithCancel(ctx)
defer cancel()
updates, err := queue.KVWatch(wctx, provisions.ProvisionsBucket, prefix+">")
updates, err := queue.KVWatch(wctx, buckets.Provisions, prefix+">")
if err != nil {
return nil, err
}
@@ -141,7 +141,7 @@ func provisionSecretsOn(queue *link.Session, node, statePath string) provisionSe
return nil, ctx.Err()
case u, ok := <-updates:
if !ok {
return nil, fmt.Errorf("the watch of %s ended before it caught up", provisions.ProvisionsBucket)
return nil, fmt.Errorf("the watch of %s ended before it caught up", buckets.Provisions)
}
switch {
case u.Caught:
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/provisions"
provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient"
"github.com/novox/mesh-host/internal/filling"
"github.com/novox/mesh-host/internal/store"
+9 -9
View File
@@ -3,15 +3,15 @@ package main
import (
"context"
"errors"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/liveness"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/liveness"
)
// owedBus is a link that takes reports and statements and keeps a have bucket in memory.
@@ -44,10 +44,10 @@ func (b *owedBus) Set(_ context.Context, key string, value []byte, revision uint
return nil
}
func (b *owedBus) have(key string) (delivery.Have, bool) {
func (b *owedBus) have(key string) (nodeengine.HaveByNodeModuleValue, bool) {
b.mu.Lock()
defer b.mu.Unlock()
h, err := delivery.DecodeHave(b.kv[key])
h, err := nodeengineclient.DecodeHave(b.kv[key])
return h, err == nil
}
@@ -66,19 +66,19 @@ func TestAModuleWithNothingLongRunningIsOkWithinALookOfItsApply(t *testing.T) {
}
fact := link.ModuleFact{Module: "xorg", FP: "src2:" + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("b", 64)}
applying := fact
applying.Phase = string(delivery.PhaseApplying)
applying.Phase = string(nodeengineclient.PhaseApplying)
queue.NoteModule(ctx, applying)
go judgeWhatRuns(ctx, j, queue, func(string) {}, nil, nil)
// The facts look inside the apply, while the module is still applying.
queue.SayFacts(ctx, link.Report{Health: &link.Health{Contract: link.LivenessContract, At: time.Now().UTC()}})
if h, _ := bus.have("n1.xorg"); h.Health != delivery.HealthUnknown {
if h, _ := bus.have("n1.xorg"); h.Health != nodeengineclient.HealthUnknown {
t.Fatalf("judged while applying: %+v", h)
}
queue.NoteModule(ctx, fact)
deadline := time.Now().Add(2 * time.Second) // well within one look (liveness.LookEvery, 15 s)
for {
h, _ := bus.have("n1.xorg")
if h.Phase == delivery.PhaseRunning && h.Health == delivery.HealthOK {
if h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK {
return
}
if time.Now().After(deadline) {
+3 -3
View File
@@ -2,9 +2,9 @@ package main
import (
"context"
engcomp "github.com/novox/mesh-host/internal/compositions"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/liveness"
)
@@ -26,8 +26,8 @@ func heldBy(windows *apply.Windows) func(time.Time) map[string]bool {
// long-running resource the composition declares running not active, read from the service manager and the container
// runtime and from no record. A resource held by an open maintenance window, or waiting for a library, is not counted;
// one that cannot be read is not counted either (unknown is not inactive), and neither is one the manager is restarting.
func inactiveIn(rt liveness.Runtime, held func(time.Time) map[string]bool) func(context.Context, delivery.Composed) bool {
return func(ctx context.Context, c delivery.Composed) bool {
func inactiveIn(rt liveness.Runtime, held func(time.Time) map[string]bool) func(context.Context, engcomp.Composed) bool {
return func(ctx context.Context, c engcomp.Composed) bool {
d, err := declarationOf(c)
if err != nil {
return false
+2 -2
View File
@@ -4,9 +4,9 @@ import (
"context"
"encoding/json"
"errors"
engcomp "github.com/novox/mesh-host/internal/compositions"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/liveness"
)
@@ -77,7 +77,7 @@ func testTheMachineShowsAResourceNotActive(t *testing.T) {
{"unread", fakeRuntime{err: errors.New("the service manager could not be read")}, nil, false},
} {
t.Run(c.name, func(t *testing.T) {
d, err := declarationOf(delivery.Composed{Node: "n1", Module: "dnsmasq", Resources: []json.RawMessage{svc, ctr}})
d, err := declarationOf(engcomp.Composed{Node: "n1", Module: "dnsmasq", Resources: []json.RawMessage{svc, ctr}})
if err != nil {
t.Fatal(err)
}
+6 -6
View File
@@ -8,8 +8,8 @@ import (
"testing"
"time"
meshasker "git.novox.be/novox/mesh-sdk/go/asker"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/declaration"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/link"
@@ -58,16 +58,16 @@ func TestALibrarySaysItsWaitWithoutWordsToParse(t *testing.T) {
// 774 (ADR 0563 §4): the library's home engine asks its take of the operator itself, once: no second ask while one is
// open, nor after it ended; and a library waiting for its disk is not asked.
t.Run("the home engine asks the take once", func(t *testing.T) {
var asked []asks.Ask
var asked []oc.Ask
state := &link.MemState{}
h := &link.HandActs{Node: "n1", State: state,
JudgeAccess: func(act, access string) (link.Judged, error) {
return link.Judged{Subject: access, Path: root, Sets: "the group mesh-media", Disk: "0:0 755"}, nil
},
AccessAct: func(link.AccessAct) (string, error) { return "taken", nil }}
h.Asker = &meshasker.Client{Module: asks.EngineAsker("n1"), State: state,
h.Asker = &occ.Client{Module: occ.EngineAsker("n1"), State: state,
Publish: func(subject string, body any, id string) error {
if a, ok := body.(asks.Ask); ok {
if a, ok := body.(oc.Ask); ok {
asked = append(asked, a)
}
return nil
@@ -78,7 +78,7 @@ func TestALibrarySaysItsWaitWithoutWordsToParse(t *testing.T) {
if len(waiting) != 1 || waiting[0] != "library:media" || len(asked) != 1 {
t.Fatalf("waiting %v asked %d times", waiting, len(asked))
}
h.Heard(asks.Warrant{Ask: asked[0].ID, Asker: asks.EngineAsker("n1"), Outcome: asks.OutcomeExpired, At: time.Now()})
h.Heard(oc.Warrant{Ask: asked[0].ID, Asker: occ.EngineAsker("n1"), Outcome: occ.OutcomeExpired, At: occ.FormatTime(time.Now())})
askTakes(h, waiting, nil)
if len(asked) != 1 {
t.Fatalf("the take was asked again after its ask ended: %d", len(asked))
+7 -6
View File
@@ -4,12 +4,13 @@ import (
"context"
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
"github.com/novox/mesh-host/internal/buckets"
"os"
"path/filepath"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/identity"
"github.com/novox/mesh-host/internal/link"
)
@@ -48,7 +49,7 @@ var loginsFollowAgain = 2 * time.Minute
// followLogins follows this node's key of the vault's node-logins on every link, as a watch (never a poll), for as long as ctx
// lasts, and hands each value to hear. A watch that cannot be made is said once and tried again.
func followLogins(ctx context.Context, session *link.Session, node string, hear func(delivery.NodeLogin), say link.Announce) {
func followLogins(ctx context.Context, session *link.Session, node string, hear func(meshvault.NodeLoginsByNodeValue), say link.Announce) {
said := ""
for ctx.Err() == nil {
linked, cancel, err := session.WhenLinked(ctx)
@@ -56,7 +57,7 @@ func followLogins(ctx context.Context, session *link.Session, node string, hear
return
}
watching, stop := context.WithCancel(linked)
updates, werr := session.KVWatch(watching, delivery.NodeLoginsBucket, node)
updates, werr := session.KVWatch(watching, buckets.NodeLogins, node)
if werr != nil {
stop()
if line := "this node's login is not followed yet (" + werr.Error() + "): it dials as it last did, and looks again in " + loginsFollowAgain.String(); line != said {
@@ -69,9 +70,9 @@ func followLogins(ctx context.Context, session *link.Session, node string, hear
if u.Caught || u.Deleted || len(u.Value) == 0 {
continue
}
nl, err := delivery.DecodeNodeLogin(u.Value)
nl, err := meshvaultclient.DecodeNodeLogin(u.Value)
if err != nil {
say("the key " + node + " of " + delivery.NodeLoginsRead + " is not read: " + err.Error())
say("the key " + node + " of " + buckets.NodeLoginsRead + " is not read: " + err.Error())
continue
}
hear(nl)
+13 -4
View File
@@ -16,6 +16,7 @@ import (
"errors"
"flag"
"fmt"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
"io"
"net"
"os"
@@ -30,7 +31,6 @@ import (
"time"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/accounts"
"github.com/novox/mesh-host/internal/agent"
"github.com/novox/mesh-host/internal/apply"
@@ -819,7 +819,7 @@ func enrol(ctx context.Context, opts options) error {
detected := profile.Detect(ctx, profile.Default(nil), opts.timeout)
reported := profileAsReported(detected)
// Through the seat mesh-enrolment (novox/hq ADR 0587 §6), the one way in: the controller's enrolment subject ended
// Through the seat mesh-node-enroller (novox/hq ADR 0587 §6), the one way in: the controller's enrolment subject ended
// with the controller (issue 1126).
return enrolThroughSeat(ctx, opts, token, *name, mine, sealing, serving, reported, found, identityPath)
}
@@ -1017,6 +1017,7 @@ func runLink(ctx context.Context, opts options) error {
queue.Acts.Composition = compositionFrom(current)
queue.Acts.Compose = composeFrom(current, &composerNow)
queue.Acts.Packages = packagesFrom(mine.Node, queue, &composerNow)
queue.Acts.SeatList, queue.Acts.SeatListAgain = seatListRead, seatListAgain
queue.Acts.ComposeCandidate = composeCandidateFrom(mine.Node, &shadowNow)
setRunsHere(runsFromAgent(current))
factsOfNow := func(ctx context.Context, ag *agent.Agent) {
@@ -1064,9 +1065,17 @@ func runLink(ctx context.Context, opts options) error {
// And what it trusts, followed on the bus, said in its record whenever it changes (ADR 0550 §2).
trust.onChange(func() { factsOfNow(aside, current.get()) })
go followTrust(aside, queue, trust, say)
// And the status view's `settled`, for a failed pull from a store to be tried again once the store's holder has settled
// (design 51, ENG-11).
go followSettled(aside, queue, func() settledTold {
if ag := current.get(); ag != nil {
return ag
}
return nil
}, say)
// And the login it dials as, which the vault's node-logins names for this node (novox/hq ADR 0583 §1).
rotator := newRotator(queue, opts.state, opts.timeout, say)
go followLogins(aside, queue, mine.Node, func(nl delivery.NodeLogin) {
go followLogins(aside, queue, mine.Node, func(nl meshvault.NodeLoginsByNodeValue) {
if line := membership.Logins.Heard(nl); line != "" {
say(line)
}
@@ -1404,7 +1413,7 @@ func healthAsReported(st liveness.Statement, ns *network.Statement) *link.Health
h.Network = &link.NetworkHealth{State: ns.State, Since: ns.Since.UTC(), Parts: []link.NetworkPart{}}
for _, p := range ns.Parts {
h.Network.Parts = append(h.Network.Parts, link.NetworkPart{Part: p.Part, State: p.State, Reason: p.Reason,
Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since.UTC(), Streak: p.Streak})
Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since.UTC(), Streak: int64(p.Streak)})
}
}
return h
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"context"
"sync"
"time"
)
// markWithin bounds one marking of the haves (what the composer tells its agent: a refusal, the words it keeps). The marks
// read and write each module's have over the bus one after the other; a bus that answers slowly or not at all must hold
// the marking, never the composition pass (issue 1965, shanks 2026-11-05 22:22: a pass waited minutes on them).
var markWithin = 45 * time.Second
// marker runs the composer's marks of the haves off the composition pass: set returns at once, one worker runs what was
// set last under each name, in the order the names were first set, each within markWithin. A mark set while one runs
// replaces the pending mark of its name, so the haves are marked once for the newest word, not once for every pass.
type marker struct {
run context.Context
mu sync.Mutex
pending map[string]func(context.Context)
order []string
working bool
idle *sync.Cond
}
func newMarker(run context.Context) *marker {
m := &marker{run: run, pending: map[string]func(context.Context){}}
m.idle = sync.NewCond(&m.mu)
return m
}
// set takes the mark name should carry; it never waits on the bus.
func (m *marker) set(name string, mark func(context.Context)) {
m.mu.Lock()
defer m.mu.Unlock()
if _, queued := m.pending[name]; !queued {
m.order = append(m.order, name)
}
m.pending[name] = mark
if m.working {
return
}
m.working = true
go m.work()
}
func (m *marker) work() {
for {
m.mu.Lock()
if len(m.order) == 0 || m.run.Err() != nil {
m.order, m.pending = nil, map[string]func(context.Context){}
m.working = false
m.idle.Broadcast()
m.mu.Unlock()
return
}
name := m.order[0]
m.order = m.order[1:]
mark := m.pending[name]
delete(m.pending, name)
m.mu.Unlock()
ctx, cancel := context.WithTimeout(m.run, markWithin)
mark(ctx)
cancel()
}
}
// wait returns once no mark is pending or running (for tests).
func (m *marker) wait() {
m.mu.Lock()
defer m.mu.Unlock()
for m.working {
m.idle.Wait()
}
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"context"
"testing"
"time"
)
// Issue 1965 (shanks 2026-11-05 22:22): the marks of the haves never hold the composition pass. A mark that stalls on the
// bus is cut at markWithin, a mark set while one runs replaces the pending one of its name, and set itself never waits.
func marksOfTheHavesNeverHoldThePass(t *testing.T) {
// The bound is the test's own: the stalled mark is released by a channel, never by a real wait.
setMarkWithin(t, time.Hour)
m := newMarker(context.Background())
release := make(chan struct{})
var ran []string
stalled := make(chan struct{})
m.set("refused", func(ctx context.Context) { // a have write that stalls until the test lets it go
close(stalled)
<-release
ran = append(ran, "refused-stalled")
})
<-stalled
// Set while the first runs: only the newest of a name is kept.
m.set("kept", func(context.Context) { ran = append(ran, "kept-old") })
m.set("kept", func(context.Context) { ran = append(ran, "kept-new") })
m.set("refused", func(context.Context) { ran = append(ran, "refused-new") })
// All four sets above returned while the first mark still stalled: set never waits on it.
close(release)
m.wait()
want := []string{"refused-stalled", "kept-new", "refused-new"}
if len(ran) != len(want) {
t.Fatalf("ran %v; want %v", ran, want)
}
for i := range want {
if ran[i] != want[i] {
t.Fatalf("ran %v; want %v", ran, want)
}
}
}
func setMarkWithin(t *testing.T, d time.Duration) {
t.Helper()
old := markWithin
markWithin = d
t.Cleanup(func() { markWithin = old })
}
+145 -54
View File
@@ -6,6 +6,10 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/buckets"
"github.com/novox/mesh-host/internal/compositions"
"os"
"path/filepath"
"sort"
@@ -14,9 +18,8 @@ import (
"sync/atomic"
"time"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/overlay"
"github.com/novox/mesh-host/internal/agent"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/compose/frombus"
@@ -43,7 +46,7 @@ func (w bucketWatch) Watch(ctx context.Context, pattern string) (<-chan agent.Up
return w.updates(ctx, in), nil
}
// WatchAwait is Watch for a state that its holder makes when it starts (the placement seat's): a bucket not on the bus
// WatchAwait is Watch for a state that its holder makes when it starts (the assignment seat's): a bucket not on the bus
// yet is waited for on the bus's word that it was created (link.Session.KVWatchAwait), and waiting is told why.
func (w bucketWatch) WatchAwait(ctx context.Context, pattern string, waiting func(error)) (<-chan agent.Update, error) {
in, err := w.session.KVWatchAwait(ctx, w.bucket, pattern, waiting)
@@ -84,7 +87,7 @@ func (h sessionHaves) Read(ctx context.Context, module string) (agent.Have, bool
if !ok {
return agent.Have{}, false
}
return agent.Have{Module: module, FP: got.FP, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail,
return agent.Have{Module: module, FP: got.Fp, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail,
Sealed: got.SealedAt}, true
}
@@ -102,25 +105,25 @@ func (h sessionHaves) ReadErr(ctx context.Context, module string) (agent.Have, b
if !ok || err != nil {
return agent.Have{}, false, err
}
return agent.Have{Module: module, FP: got.FP, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail,
return agent.Have{Module: module, FP: got.Fp, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail,
Sealed: got.SealedAt}, true, nil
}
func (h sessionHaves) Write(ctx context.Context, x agent.Have) {
if x.Module == agent.NodeModule {
// The node's own key holds its facts and the digest of the composition it runs.
if x.Phase == "" || x.Phase == string(delivery.PhaseRunning) {
if x.Phase == "" || x.Phase == string(nodeengineclient.PhaseRunning) {
h.session.NoteNode(ctx, x.Composed, x.Sealed)
}
return
}
phase := x.Phase
if phase == string(delivery.PhaseRunning) {
if phase == string(nodeengineclient.PhaseRunning) {
phase = ""
}
h.session.NoteModule(ctx, link.ModuleFact{Module: x.Module, FP: x.FP, Composed: x.Composed, Phase: phase,
Failed: phase == string(delivery.PhaseFailed), Detail: shorten(x.Detail), Judgement: x.Judgement, Waits: x.Waits,
BuildAlone: x.BuildAlone, Kept: x.Kept, Sealed: x.Sealed})
Failed: phase == string(nodeengineclient.PhaseFailed), Detail: shorten(x.Detail), Judgement: x.Judgement, Waits: x.Waits,
BuildAlone: x.BuildAlone, Kept: x.Kept, Sealed: x.Sealed, PullWait: x.PullWait})
}
func (h sessionHaves) Removed(ctx context.Context, module string) {
@@ -255,16 +258,16 @@ func awaitBackup(ctx context.Context, events <-chan []byte, node, module string,
// the controller put modules it knew no fingerprint of the new form for into the node's own composition while their
// wants named their own, and the declaration joined of both declared each such file twice, which the filling refuses
// ("2 resources declare X") on every node. The node's own copy is left out, and which were left out is said once.
func declarationOf(cs ...delivery.Composed) (*declaration.Declaration, error) {
func declarationOf(cs ...compositions.Composed) (*declaration.Declaration, error) {
d := &declaration.Declaration{Version: declaration.Version}
var modules []delivery.Composed
var modules []compositions.Composed
for _, c := range cs {
if c.Module != "" {
modules = append(modules, c)
}
}
if len(modules) > 0 {
joined := make([]delivery.Composed, 0, len(cs))
joined := make([]compositions.Composed, 0, len(cs))
for _, c := range cs {
if c.Module == "" {
var dropped []string
@@ -299,7 +302,7 @@ func declarationOf(cs ...delivery.Composed) (*declaration.Declaration, error) {
// the same kind and id. A resource that shares only its target (a unit or container of the same name, another id) is
// not the module's, and stays: dropped, its record would be an orphan the apply removes, stopping the module's unit. It
// answers what it left out, as "<id> (<module>)".
func ownWithout(own delivery.Composed, modules []delivery.Composed) (delivery.Composed, []string) {
func ownWithout(own compositions.Composed, modules []compositions.Composed) (compositions.Composed, []string) {
byID := map[string]string{}
for _, c := range modules {
if c.Module == "" {
@@ -355,14 +358,14 @@ func sayDroppedOnce(dropped []string) {
"declares, and they are left out of it: %s (issue 1945)\n", len(dropped), key)
}
func scopeName(c delivery.Composed) string {
func scopeName(c compositions.Composed) string {
if c.Module == "" {
return agent.NodeModule
}
return c.Module
}
func resourcesOf(cs []delivery.Composed) []declaration.Resource {
func resourcesOf(cs []compositions.Composed) []declaration.Resource {
var out []declaration.Resource
for _, c := range cs {
for _, raw := range c.Resources {
@@ -444,14 +447,14 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
}
began := time.Now()
node := session.Membership.Node
composedBucket, err := delivery.ComposedBucketFor(node)
composedBucket, err := compositions.BucketFor(node)
if err != nil {
cancel()
say("the node agent cannot name this node's composition bucket: " + err.Error())
return
}
var ag *agent.Agent
// The kept release-5 declaration is no longer read once every module the seat's placement puts here has a have of its
// The kept release-5 declaration is no longer read once every module the seat's assignment puts here has a have of its
// own (ADR 0550 §2: its signature is the controller's, trusted no more after the lease write).
// Nor before this engine keeps the node's own as it runs (applied/node.json): until then the kept declaration is the one
// record of what the node's own composition carries, its folded modules among them (issue 774, review of the
@@ -468,7 +471,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
// composes its own (ADR 0555 §3): without it a module that asks for a certificate would be stood in for.
if byModule, _ := kept(); byModule != nil {
for module, resources := range byModule {
if err := keepAuthority(opts.state, delivery.Composed{Module: module, Resources: resources}); err != nil {
if err := keepAuthority(opts.state, compositions.Composed{Module: module, Resources: resources}); err != nil {
say("the mesh's certificate the kept declaration carries could not be kept for this machine's record: " + err.Error())
}
}
@@ -477,7 +480,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
// bus module this node runs declares, as its agent holds what runs (its own composition, or the declaration kept
// before the switch): never read from the node's composition bucket, which the controller wrote. On every node;
// a node that runs no list writes nothing. Looked at again whenever what runs changed (RunsChanged, Settled).
users := userListFollower(opts, session, func() []delivery.Composed {
users := userListFollower(opts, session, func() []compositions.Composed {
if now := current.get(); now != nil {
return now.Everything()
}
@@ -498,7 +501,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
}()
env := agent.Env{
Node: node,
Wants: bucketWatch{session, delivery.AssignerWantsBucket},
Wants: bucketWatch{session, buckets.Wants},
Composed: bucketWatch{session, composedBucket},
Haves: sessionHaves{session},
Kept: func() map[string][]json.RawMessage { by, _ := kept(); return by },
@@ -544,7 +547,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
Say: say,
// A failure of the bus not answering for a moment is tried again: 2 s, 5 s, 15 s, 60 s, then every 5 min.
Backoff: link.Backoff,
Apply: func(ctx context.Context, c delivery.Composed, others []delivery.Composed) agent.Result {
Apply: func(ctx context.Context, c compositions.Composed, others []compositions.Composed) agent.Result {
actx, done := applyContext(session.WithLink(ctx))
// While the grants part's list is followed, the controller's list in the bus module's composition is not
// written: the apply keeps the list on the machine (ADR 0547 clause 8, never both).
@@ -570,7 +573,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
sayFacts(ctx, ag)
return res
},
Remove: func(ctx context.Context, module string, others []delivery.Composed) agent.Result {
Remove: func(ctx context.Context, module string, others []compositions.Composed) agent.Result {
actx, done := applyContext(session.WithLink(ctx))
res := removeComposition(actx, opts, module, others, ag.Placed(module), sched, say)
done()
@@ -586,8 +589,19 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
// The agent of this run alone: its composer tells it, never the agent of a run begun since (issue 1126: a pass in
// flight at the switch marked the next agent's modules failed).
own := &agentNow{}
// And the seat list it reads through the seat mesh-catalog, from its bootstrapped definitions until it has: the
// composer hands nothing new meanwhile, and the node says it has waited five minutes (novox/hq ADR 0636 §1).
seats, err := startSeatList(run, session, node, say)
if err != nil {
// No reader is no gate that lets everything through: the node acts on nothing new, as one that waits does.
say(err.Error() + "; the node composes nothing new until it is started again and reads the list (novox/hq ADR 0636 §1)")
}
// The marks of the haves run off the composition pass, each bounded (see marker).
marks := newMarker(run)
hooks := composerHooks{
running: func() []delivery.Composed {
seatList: seats,
seatListFailed: err != nil,
running: func() []compositions.Composed {
if now := own.get(); now != nil {
return runsAsWritten(now.Everything(), opts.state)
}
@@ -596,7 +610,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
unseal: sealOpener(opts.state),
// And what each module's have says it runs sealed: after a restart the agent has applied nothing yet.
// A have the bus did not answer for is said as such, never as one holding no seals (issue 1126, 2026-10-28).
held: func(module string) (delivery.Seals, []string, error) {
held: func(module string) (compositions.Seals, []string, error) {
if module == agent.NodeModule {
got, _, err := session.ReadNodeHaveErr(linked)
return got.SealedAt, got.Sealed, err
@@ -609,13 +623,14 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
},
refused: func(why string, held map[string]string) {
if now := own.get(); now != nil && run.Err() == nil {
now.NotComposedEach(run, why, held) // a module held alone marks that module, never the others (issue 1126)
// A module held alone marks that module, never the others (issue 1126); the marking never holds the pass.
marks.set("refused", func(ctx context.Context) { now.NotComposedEach(ctx, why, held) })
}
},
// And what it keeps of a module as it runs, for a manifest that does not say it yet: on the module's have (ADR 0601 §4).
kept: func(kept map[string][]string) {
if now := own.get(); now != nil && run.Err() == nil {
now.KeptNow(run, kept)
marks.set("kept", func(ctx context.Context) { now.KeptNow(ctx, kept) })
}
},
// And the vault's authority beside the controller's, and this node's certificate asked of the vault when a
@@ -625,26 +640,27 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
return certifyThisNode(ctx, opts.state, node, trust, session.AskVault, func() { sayFacts(ctx, ag) })
},
}
// And its answer to every placement candidate, placed or not, applying nothing (novox/hq ADR 0559 §4): written into
// And its answer to every assignment candidate, placed or not, applying nothing (novox/hq ADR 0559 §4): written into
// its own key, `<node>.node`, under candidates.
shadowHooks := hooks
shadowHooks.refused, shadowHooks.kept, shadowHooks.certify = nil, nil, nil
shadowHooks.refused, shadowHooks.kept, shadowHooks.certify, shadowHooks.seatList = nil, nil, nil, nil
shadowHooks.seatListFailed = false
shadowEnv := composerEnv(session, node, say, shadowHooks)
shadow := selfcompose.NewShadow(shadowEnv,
func(ctx context.Context, answers map[string]delivery.CandidateAnswer) {
session.NoteCandidates(ctx, delivery.WithValuesDiffer(answers))
func(ctx context.Context, answers map[string]nodeengine.CandidateAnswer) {
session.NoteCandidates(ctx, compositions.WithValuesDiffer(answers))
})
go shadow.Keep(run, link.Backoff) // this run's alone: a switch begins a run, and its shadow, again
shadowNow.Store(shadow) // what the verb compose-candidate composes a check's candidate with
// Its own composer, always (issue 1126, A7): a composition made elsewhere (the controller's, in the node's
// composition bucket) is never applied. While the seat's placement holds no key for this node it composes
// nothing and keeps running what it runs; a placement it cannot watch stops the agent, said, and it begins again.
// composition bucket) is never applied. While the seat's assignment holds no key for this node it composes
// nothing and keeps running what it runs; a assignment it cannot watch stops the agent, said, and it begins again.
composer := selfComposer(session, node, hooks, say)
machinesNow.Store(composer) // the machines a failing network part points at, by the placements (issue 774)
machinesNow.Store(composer) // the machines a failing network part points at, by the assignments (issue 774)
composerNow.Store(composer) // what the verb compose asks a pass of, this run's alone
env.Composed, env.SelfComposed, env.Policy, env.ComposedToday = composer, true, composer.Policy, composer.ComposedToday
env.HoldsData = composer.HoldsData // what runs from the kept declaration says Data as its manifest does
session.NoteComposes(linked, delivery.ComposesOwn, "it composes its own modules, and applies no composition made elsewhere")
session.NoteComposes(linked, nodeengineclient.ComposesOwn, "it composes its own modules, and applies no composition made elsewhere")
ag = agent.New(env)
own.set(ag)
current.set(ag)
@@ -683,14 +699,60 @@ var reloadSettles = 10 * time.Second
type agentNow struct {
mu sync.Mutex
ag *agent.Agent
// last is the view of what runs the previous agent held when it stopped, kept in memory: while the next agent has
// read no haves yet, what runs is this and never the retired kept declaration (a bus link that drops restarts the
// agent, and every fill in that window was refused; issue 924).
last *runsView
// view reads an agent's view; viewOf unless a test stands in for an agent that holds the node.
view func(*agent.Agent) *runsView
}
func viewOf(a *agent.Agent) *runsView { return &runsView{a.Everything(), a.HoldsNode()} }
// runsView is what an agent said runs, to be read after the agent stopped.
type runsView struct {
everything []compositions.Composed
holdsNode bool
}
func (c *agentNow) set(a *agent.Agent) {
c.mu.Lock()
if a == nil && c.ag != nil {
read := c.view
if read == nil {
read = viewOf
}
if v := read(c.ag); len(v.everything) > 0 || v.holdsNode {
c.last = v
}
}
c.ag = a
c.mu.Unlock()
}
func (c *agentNow) viewFn() func(*agent.Agent) *runsView {
c.mu.Lock()
defer c.mu.Unlock()
if c.view == nil {
return viewOf
}
return c.view
}
// holdsNode drops the kept view once the agent in hand holds the node: its own view is complete from then on.
func (c *agentNow) holdsNode() {
c.mu.Lock()
c.last = nil
c.mu.Unlock()
}
// lastView is the view the last agent held when it stopped, nil when none held any.
func (c *agentNow) lastView() *runsView {
c.mu.Lock()
defer c.mu.Unlock()
return c.last
}
func (c *agentNow) get() *agent.Agent {
c.mu.Lock()
defer c.mu.Unlock()
@@ -704,7 +766,7 @@ func (c *agentNow) get() *agent.Agent {
//
// removed is the modules whose want says removed: in the node's own apply, a resource of any other module goes only when
// its module is among them (issue 774).
func applyComposition(ctx context.Context, opts options, c delivery.Composed, others []delivery.Composed, placed, removed map[string]bool,
func applyComposition(ctx context.Context, opts options, c compositions.Composed, others []compositions.Composed, placed, removed map[string]bool,
sched *apply.Scheduler, say link.Announce) agent.Result {
name := scopeName(c)
if c.Module == "" {
@@ -731,12 +793,19 @@ func applyComposition(ctx context.Context, opts options, c delivery.Composed, ot
if got.Refused != "" {
return failedWith(got.Refused)
}
// An image that could not be pulled from a store is said with its holder, for the engine to try the apply again once the
// holder has settled (design 51, ENG-8).
failedOn := func(why string) agent.Result {
res := failedWith(why)
res.Pulls = pullsOf(d, got.Err)
return res
}
results := apply.ModuleResults(d, got.Outcome, got.Err)
if r, ok := results[name]; ok && r.Failed {
return failedWith(r.Detail)
return failedOn(r.Detail)
}
if got.Err != nil {
return failedWith(got.Err.Error())
return failedOn(got.Err.Error())
}
if got.Probe != nil {
return failedWith(got.Probe.partial().Error())
@@ -745,7 +814,7 @@ func applyComposition(ctx context.Context, opts options, c delivery.Composed, ot
}
// removeComposition removes one module's own resources and nothing of another's.
func removeComposition(ctx context.Context, opts options, module string, others []delivery.Composed, placed map[string]bool,
func removeComposition(ctx context.Context, opts options, module string, others []compositions.Composed, placed map[string]bool,
sched *apply.Scheduler, say link.Announce) agent.Result {
scope := apply.Scope{Module: module, Others: resourcesOf(others), Placed: placed}
got := applyModuleHeld(ctx, opts, &declaration.Declaration{Version: declaration.Version}, &scope, sched, say)
@@ -772,7 +841,7 @@ func busyModules() []string {
return out
}
// machinesNow is the composer whose placements name the mesh's machines by address, for the health report's targets
// machinesNow is the composer whose assignments name the mesh's machines by address, for the health report's targets
// (writeHealthReport); nil on a node that does not compose its own, whose parts then name no target.
var machinesNow atomic.Pointer[selfcompose.Composer]
@@ -780,11 +849,11 @@ var machinesNow atomic.Pointer[selfcompose.Composer]
// compositions or no run is going: what the verb compose asks a pass of (issue 774).
var composerNow atomic.Pointer[selfcompose.Composer]
// shadowNow is the shadow of the run now, which answers placement candidates and the verb compose-candidate; nil
// shadowNow is the shadow of the run now, which answers assignment candidates and the verb compose-candidate; nil
// between runs.
var shadowNow atomic.Pointer[selfcompose.Shadow]
// keptRetired says the kept declaration is read no more: every module the seat's placement puts on this node has a have of
// keptRetired says the kept declaration is read no more: every module the seat's assignment puts on this node has a have of
// its own (placedAllHave), so what runs is the agent's alone (ADR 0550 §2, design 47's L3 s9).
var keptRetired atomic.Bool
@@ -818,7 +887,7 @@ func keptDeclaration(statePath string, signer ed25519.PublicKey) (*declaration.D
// (adoption, identities, groups, libraries) from the node's composition once the agent holds it and from the kept
// declaration until then. What the node judges its health by, and what the filling, the take act and the judgement of
// directories read.
func whatRuns(everything []delivery.Composed, holdsNode bool, kept func() (*declaration.Declaration, error)) (*declaration.Declaration, error) {
func whatRuns(everything []compositions.Composed, holdsNode bool, kept func() (*declaration.Declaration, error)) (*declaration.Declaration, error) {
d, err := declarationOf(everything...)
if err != nil {
return nil, err
@@ -867,11 +936,23 @@ func declaredNow(statePath string, signer ed25519.PublicKey) (*declaration.Decla
func runsFromAgent(current *agentNow) func(string, ed25519.PublicKey) (*declaration.Declaration, error) {
return func(statePath string, signer ed25519.PublicKey) (*declaration.Declaration, error) {
kept := func() (*declaration.Declaration, error) { return keptDeclaration(statePath, signer) }
ag := current.get()
if ag == nil {
var view *runsView
if ag := current.get(); ag != nil {
view = current.viewFn()(ag)
if view.holdsNode {
current.holdsNode()
}
}
// An agent that has read no haves yet, or none: the last view held, never the retired kept declaration.
if view == nil || (len(view.everything) == 0 && !view.holdsNode) {
if last := current.lastView(); last != nil {
view = last
}
}
if view == nil {
return kept()
}
return whatRuns(ag.Everything(), ag.HoldsNode(), kept)
return whatRuns(view.everything, view.holdsNode, kept)
}
}
@@ -904,8 +985,8 @@ func refuseEmptyWrites(d *declaration.Declaration) error {
// recordedIn says the engine's applied state holds a record of every resource of a composition: what the agent believes
// of a have or the kept declaration only when it does, so a resource removed since is made again (issue 1945). A state
// that cannot be read says yes: the agent then believes what it was told, as before.
func recordedIn(statePath string, say func(string)) func(ctx context.Context, c delivery.Composed) bool {
return func(_ context.Context, c delivery.Composed) bool {
func recordedIn(statePath string, say func(string)) func(ctx context.Context, c compositions.Composed) bool {
return func(_ context.Context, c compositions.Composed) bool {
known, err := store.Load(statePath)
if err != nil {
if say != nil {
@@ -967,18 +1048,22 @@ func applyContext(ctx context.Context) (context.Context, context.CancelFunc) {
// composerHooks are what the composer reads of the agent and the machine (selfcompose.Env Running, Unseal, Refused).
type composerHooks struct {
running func() []delivery.Composed
running func() []compositions.Composed
unseal func(string) ([]byte, error)
refused func(why string, held map[string]string)
kept func(kept map[string][]string)
held func(module string) (delivery.Seals, []string, error)
held func(module string) (compositions.Seals, []string, error)
// vaultAuthority and certify are selfcompose.Env's VaultAuthority and Certify.
vaultAuthority func() string
certify func(ctx context.Context) error
// seatList is this run's reader of the seat list: the composer hands nothing new until it was read (ADR 0636 §1).
seatList *seatListState
// seatListFailed says the reader could not start: the composer then holds like a reader that waits (selfcompose.Unread).
seatListFailed bool
}
// selfComposer is the node's own composer: it composes what the seat's placement places on the node, and nothing while
// the placement holds no key for it (issue 1126).
// selfComposer is the node's own composer: it composes what the seat's assignment places on the node, and nothing while
// the assignment holds no key for it (issue 1126).
func selfComposer(session *link.Session, node string, hooks composerHooks, say func(string)) *selfcompose.Composer {
env := composerEnv(session, node, say, hooks)
// And it keeps each placed module's data items in its own state data, which the node's disk-load watches (novox/hq
@@ -994,11 +1079,11 @@ func selfComposer(session *link.Session, node string, hooks composerHooks, say f
}
// composerEnv is what the node's composers read of the bus, the vault and the agent: its own composer and its answer
// to placement candidates (selfcompose.Shadow) alike.
// to assignment candidates (selfcompose.Shadow) alike.
func composerEnv(session *link.Session, node string, say func(string), hooks composerHooks) selfcompose.Env {
return selfcompose.Env{Node: node,
env := selfcompose.Env{Node: node,
Watch: func(ctx context.Context, bucket, pattern string) (<-chan agent.Update, error) {
if bucket == delivery.PlacementBucket || bucket == delivery.PlacementCandidatesBucket {
if bucket == buckets.Assignments || bucket == buckets.ProposalCandidates {
// The seat's states are made when their holder starts: a node-engine that starts first waits for the
// bus's word that the bucket was created, and the shadow and the composer say they wait.
return bucketWatch{session, bucket}.WatchAwait(ctx, pattern, func(err error) {
@@ -1026,4 +1111,10 @@ func composerEnv(session *link.Session, node string, say func(string), hooks com
Fetch: apply.Fetch,
Running: hooks.running, Unseal: hooks.unseal, Refused: hooks.refused, Held: hooks.held, Kept: hooks.kept,
VaultAuthority: hooks.vaultAuthority, Certify: hooks.certify}
if hooks.seatList != nil {
env.SeatList, env.SeatListChanged = hooks.seatList.reader, hooks.seatList.changed
} else if hooks.seatListFailed {
env.SeatList = selfcompose.Unread{}
}
return env
}
+320 -128
View File
@@ -7,22 +7,36 @@ import (
"crypto/ed25519"
"encoding/json"
"errors"
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
builderbuilds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"github.com/novox/mesh-host/internal/buckets"
"github.com/novox/mesh-host/internal/buckets/bucketstest"
engcomp "github.com/novox/mesh-host/internal/compositions"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-host/internal/agent"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/nodegrant"
"github.com/novox/mesh-host/internal/seatwait"
"github.com/novox/mesh-host/internal/selfcompose"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
@@ -54,6 +68,11 @@ type itBed struct {
// refusals are the bus's asynchronous errors on the node's connection: each permissions violation it met.
refusals []string
mu sync.Mutex
// seatSilent makes the stand-in of the seat mesh-catalog refuse the seat list (it is served from the start otherwise).
seatSilent atomic.Bool
// seatAsked counts the node's asks of the stand-in seat, answered or refused: the node reached the bus and asked.
seatAsked atomic.Int32
findings nats.KeyValue
}
// aServer is an embedded bus with JetStream and two users: the test's, unrestricted, and the node's, granted exactly what
@@ -119,19 +138,23 @@ func newItBedAs(t *testing.T, perms nodegrant.Permissions) *itBed {
if b.nodeJS, err = b.nodeConn.JetStream(); err != nil {
t.Fatal(err)
}
composedBucket, _ := delivery.ComposedBucketFor(itNode)
composedBucket, _ := engcomp.BucketFor(itNode)
for _, c := range []struct {
name string
into *nats.KeyValue
history uint8
}{{delivery.AssignerWantsBucket, &b.wants, 8}, {composedBucket, &b.composed, delivery.ComposedHistory},
{delivery.HaveBucket, &b.haves, delivery.HaveHistory}} {
}{{buckets.Wants, &b.wants, 8}, {composedBucket, &b.composed, engcomp.History},
{buckets.Have, &b.haves, buckets.HaveHistory}} {
kv, err := b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: c.name, History: c.history})
if err != nil {
t.Fatal(err)
}
*c.into = kv
}
if b.findings, err = b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: link.FindingsBucket, History: 1}); err != nil {
t.Fatal(err)
}
b.standInSeat()
b.opts = options{state: filepath.Join(b.dir, "state", "state.json"), timeout: 2 * time.Second}
if err := os.MkdirAll(filepath.Dir(b.opts.state), 0o755); err != nil {
t.Fatal(err)
@@ -164,17 +187,17 @@ func (b *itBed) say(line string) {
b.t.Log(line)
}
func fpIt(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) }
func fpIt(c string) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(c, 64) }
func fpDigest(c string) string { return "sha256:" + strings.Repeat(c, 64) }
func (b *itBed) want(module, fp, digest, backup string) {
b.t.Helper()
raw, err := delivery.EncodeWant(delivery.Want{FP: fp, Composed: digest, Backup: backup})
raw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Composed: digest, Backup: backup})
if err != nil {
b.t.Fatal(err)
}
key, _ := delivery.WantKey(itNode, module)
key := meshwants.WantsByWantNodeModuleKey(itNode, module)
if _, err := b.wants.Put(key, raw); err != nil {
b.t.Fatal(err)
}
@@ -185,13 +208,13 @@ func (b *itBed) file(module string) string {
return string(got)
}
func (b *itBed) have(module string) delivery.Have {
key, _ := delivery.HaveKey(itNode, module)
func (b *itBed) have(module string) nodeengine.HaveByNodeModuleValue {
key, _ := nec.HaveKey(itNode, module)
e, err := b.haves.Get(key)
if err != nil {
return delivery.Have{}
return nodeengine.HaveByNodeModuleValue{}
}
h, _ := delivery.DecodeHave(e.Value())
h, _ := nec.DecodeHave(e.Value())
return h
}
@@ -207,16 +230,16 @@ func within(t *testing.T, d time.Duration, what string, ok func() bool) {
}
// Issue 1126 (ADR 0547 §2, the milestone's H8): the node agent started whole against a real bus, once the catalogue
// places its node (the placement written after it started), composes a module from its want, the indexer's manifest, its record and its mesh-config value, and applies it,
// places its node (the assignment written after it started), composes a module from its want, the indexer's manifest, its record and its mesh-config value, and applies it,
// with no key in the controller's compositions; a changed config value is composed again on its event and applied as a
// revision of what runs. And a catalogue pull request's placement candidate equal to its placement is answered "same"
// revision of what runs. And a catalogue pull request's assignment candidate equal to its assignment is answered "same"
// in the node's own key, and nothing is applied for it (ADR 0559 §4, subtask 10 of the milestone).
//
// And (issue 1126, ADR 0560) it reads, through its own session under the grant the grants part composes for it
// (nodegrant.For with the served key, as the grants part grants it), the build of a placed module that declares one and the one config value
// a provider on another node serves from (a mail domain); under a grant without that served key the bus refuses the
// read and the node composes nothing.
func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) {
func TestNatsANodeTheAssignmentsNameComposesAndAppliesItsOwn(t *testing.T) {
t.Run("without the served key the bus refuses the read", func(t *testing.T) {
b := newItBed(t)
stop, _ := h8(t, b, false)
@@ -237,12 +260,12 @@ func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) {
b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain"))
stop, st := h8(t, b, true)
defer stop()
placement, resolved, candidates := st.placement, st.resolved, st.candidates
assignment, resolved, candidates := st.assignment, st.resolved, st.candidates
hkey, place, fp := st.hkey, st.place, st.fp
within(t, 5*time.Second, "the node did not compose and apply its own module once placed", func() bool { return b.file("web") == "greeting=bonjour\n" })
// ADR 0565 §6 (1953): the node's engine writes its own key of the state packages, under the grant the grants part
// composes for it, with each package its compositions declare and the module declaring it; once, while nothing changes.
wantPkgs := `{"node":"` + itNode + `","packages":[{"package":"web-tools","modules":["web"],"absent":false}]}`
wantPkgs := `{"node":"` + itNode + `","packages":[{"absent":false,"modules":["web"],"package":"web-tools"}]}`
within(t, 5*time.Second, "the node did not write its key of the state packages", func() bool {
e, err := st.packages.Get(itNode)
return err == nil && string(e.Value()) == wantPkgs
@@ -254,7 +277,7 @@ func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) {
t.Errorf("the node's packages key was written again with no package changed: %v, was %v", e, written)
}
t.Run("one answer per candidate revision", func(t *testing.T) { h8once(t, b, resolved, candidates, place) })
h8back(t, b, placement, resolved, candidates, hkey)
h8back(t, b, assignment, resolved, candidates, hkey)
}
// h8once is issue 1126, 2026-10-28 (shanks answered probe/1126-seed-7 at revision 84 every 13-17 s, its bus flooded):
@@ -267,7 +290,7 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place []
defer b.mu.Unlock()
n := 0
for _, l := range b.said {
if strings.HasPrefix(l, "answering placement candidate feat-x ") {
if strings.HasPrefix(l, "answering assignment candidate feat-x ") {
n++
}
}
@@ -278,11 +301,11 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place []
if settled == 0 {
t.Fatal("feat-x was never answered")
}
hkey, _ := delivery.HaveKey(itNode, "web")
hkey, _ := nec.HaveKey(itNode, "web")
for _, detail := range []string{"its tool web_health: no link to the bus is open", "its tool web_health: no answer within 10s"} {
h := b.have("web")
h.Health, h.Detail = delivery.HealthFailed, detail
raw, err := delivery.EncodeHave(h)
h.Health, h.Detail = nec.HealthFailed, detail
raw, err := nec.EncodeHave(h)
if err != nil {
t.Fatal(err)
}
@@ -297,12 +320,12 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place []
if got := answering(); got != settled {
t.Errorf("feat-x answered %d more time(s) at the same revision, with nothing it is composed from changed", got-settled)
}
same, _ := delivery.DecodePlacement(place)
craw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "feat-x", Placement: &same})
same, _ := meshmoduleassignerclient.DecodeAssignments(place)
craw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "feat-x", Assignments: &same})
if err != nil {
t.Fatal(err)
}
ckey, _ := delivery.PlacementCandidateKey("feat-x", itNode)
ckey := meshmoduleassigner.CandidatesByProposalNodeKey("feat-x", itNode)
if _, err := candidates.Put(ckey, craw); err != nil {
t.Fatal(err)
}
@@ -313,66 +336,66 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place []
}
}
// h8back is A7 of issue 1126: no composition made elsewhere is applied. A candidate deleting the node's placement is
// refused, never answered as a return to the controller's compositions; the placement key deleted leaves the agent on
// h8back is A7 of issue 1126: no composition made elsewhere is applied. A candidate deleting the node's assignment is
// refused, never answered as a return to the controller's compositions; the assignment key deleted leaves the agent on
// its own composer, composing nothing and keeping what runs, said in the journal, the node's have still saying it
// composes its own.
func h8back(t *testing.T, b *itBed, placement, resolved, candidates nats.KeyValue, hkey string) {
func h8back(t *testing.T, b *itBed, assignment, resolved, candidates nats.KeyValue, hkey string) {
t.Helper()
node := func() delivery.HaveNode {
node := func() nodeengine.HaveByNodeNodeValue {
e, err := b.haves.Get(hkey)
if err != nil {
return delivery.HaveNode{}
return nodeengine.HaveByNodeNodeValue{}
}
n, _ := delivery.DecodeHaveNode(e.Value())
n, _ := nec.DecodeHaveNode(e.Value())
return n
}
if n := node(); n.Composes != delivery.ComposesOwn {
if n := node(); n.Composes != nec.ComposesOwn {
t.Errorf("a node composing its own says it composes %q", n.Composes)
}
craw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "revert-x"})
craw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "revert-x"})
if err != nil {
t.Fatal(err)
}
ckey, _ := delivery.PlacementCandidateKey("revert-x", itNode)
ckey := meshmoduleassigner.CandidatesByProposalNodeKey("revert-x", itNode)
if _, err := candidates.Put(ckey, craw); err != nil {
t.Fatal(err)
}
within(t, 10*time.Second, "the node did not refuse a candidate taking its placement away", func() bool {
within(t, 10*time.Second, "the node did not refuse a candidate taking its assignment away", func() bool {
a := node().Candidates["revert-x"]
return a.Proposal == "revert-x" && a.Returns == "" && a.Verdict == delivery.CandidateRefused &&
return a.Proposal == "revert-x" && a.Returns == "" && a.Verdict == nodeengine.CandidateAnswerVerdictRefused &&
strings.Contains(a.Refused, "composes nothing")
})
ran := b.have("web").Composed
pkey, _ := delivery.PlacementNodeKey(itNode)
pkey, _ := meshmoduleassignerclient.NodeKey(itNode)
// A pass in flight when the key goes (issue 1126, 2026-10-28): a config value changed starts one, and the key goes at once.
if _, err := resolved.Put(itNode+".web.greeting", []byte(`{"value":"salut","source":"module"}`)); err != nil {
t.Fatal(err)
}
if err := placement.Delete(pkey); err != nil {
if err := assignment.Delete(pkey); err != nil {
t.Fatal(err)
}
within(t, 15*time.Second, "the node did not say it composes nothing once its placement went", func() bool {
within(t, 15*time.Second, "the node did not say it composes nothing once its assignment went", func() bool {
b.mu.Lock()
said := strings.Join(b.said, "\n")
b.mu.Unlock()
return strings.Contains(said, "key in the seat's placement state is gone: it composes nothing of its own")
return strings.Contains(said, "key in the seat's assignment state is gone: it composes nothing of its own")
})
time.Sleep(3 * time.Second) // what a pass in flight would mark lands within it
b.mu.Lock()
said := strings.Join(b.said, "\n")
b.mu.Unlock()
if n := node(); n.Composes != delivery.ComposesOwn || strings.Contains(said, "applying the controller's compositions") {
t.Errorf("without its placement the node turned to the controller's compositions: %q", n.Composes)
if n := node(); n.Composes != nec.ComposesOwn || strings.Contains(said, "applying the controller's compositions") {
t.Errorf("without its assignment the node turned to the controller's compositions: %q", n.Composes)
}
if h := b.have("web"); h.Composed != ran || h.Phase == delivery.PhaseFailed || b.file("web") != "greeting=salut\n" {
t.Errorf("without its placement what runs moved: %+v, %q", h, b.file("web"))
if h := b.have("web"); h.Composed != ran || h.Phase == nec.PhaseFailed || b.file("web") != "greeting=salut\n" {
t.Errorf("without its assignment what runs moved: %+v, %q", h, b.file("web"))
}
}
// h8state is what h8 set up that the rest of H8 writes to.
type h8state struct {
placement, resolved, candidates nats.KeyValue
assignment, resolved, candidates nats.KeyValue
// packages is the engine's state packages (ADR 0565 §6), as the grants part creates it from the engine's manifest.
packages nats.KeyValue
hkey string
@@ -391,42 +414,42 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) {
}
return kv
}
placement, modules, manifests := kvOf(delivery.PlacementBucket), kvOf(delivery.ModulesBucket), kvOf(delivery.ManifestsBucket)
assignment, modules, manifests := kvOf(buckets.Assignments), kvOf(buckets.Modules), kvOf(buckets.Manifests)
resolved := kvOf(selfcompose.ResolvedBucket)
builds := kvOf(delivery.BuilderBuildsBucket)
candidates := kvOf(delivery.PlacementCandidatesBucket)
builds := kvOf(builderbuilds.BuilderBuildsBucket)
candidates := kvOf(buckets.ProposalCandidates)
packages := kvOf(link.PackagesBucket)
path := filepath.Join(b.dir, "web.conf")
man := `{"module":"web","version":"1","config":{"greeting":{"type":"string","description":"what the file says","default":"hello"}},` +
`"resources":[{"id":"conf","type":"file","path":"` + path + `","mode":"0644","content":"greeting=${config:greeting}\n"},` +
`{"id":"tools","type":"package","package":"web-tools"}],` +
`"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
digest := delivery.ManifestDigest([]byte(man))
mraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "web", JSON: man})
mkey, _ := delivery.ManifestsKey(digest)
digest := meshcatalogclient.ManifestDigest([]byte(man))
mraw, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "web", Manifest: man})
mkey, _ := meshcatalogclient.ManifestsKey(digest)
fp := fpIt("a")
modv, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40)})
place, _ := delivery.EncodePlacement(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "web"}}})
rec, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: itNode, Capabilities: []string{"service-manager", "package-manager"}}})
modv, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40)})
place, _ := meshmoduleassignerclient.EncodeAssignments(meshmoduleassigner.Assignments{Node: itNode, Modules: []meshmoduleassigner.PlacedModule{{Module: "web"}}})
rec, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: itNode, Capabilities: []string{"service-manager", "package-manager"}}})
// it1 in one key per module and node (research 072 row 37), it2 in the form before: a node reads both.
pkey, _ := delivery.PlacementNodeKey(itNode)
places, _ := delivery.PlacementValues(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "web"}}})
pkey, _ := meshmoduleassignerclient.NodeKey(itNode)
places, _ := meshmoduleassignerclient.AssignmentsValues(meshmoduleassigner.Assignments{Node: itNode, Modules: []meshmoduleassigner.PlacedModule{{Module: "web"}}})
place = places[pkey]
hkey, _ := delivery.HaveNodeKey(itNode)
hkey, _ := nec.HaveNodeKey(itNode)
// A provider on it2 serving from its own config value: a mail domain, which it1's composition settles.
mailer := `{"module":"mailer","version":"1","config":{"domain":{"type":"string","description":"the mail domain","required":true}},` +
`"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`
mdigest := delivery.ManifestDigest([]byte(mailer))
mlraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "mailer", JSON: mailer})
mlkey, _ := delivery.ManifestsKey(mdigest)
mlmod, _ := delivery.EncodeModule(delivery.Module{FP2: fpIt("b"), Manifest: mdigest, Repository: "novox/mailer", Commit: strings.Repeat("2", 40)})
place2, _ := delivery.EncodePlacement(delivery.Placement{Node: "it2", Modules: []delivery.PlacedModule{{Module: "mailer"}}})
rec2, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "it2", Capabilities: []string{"service-manager"}}})
pkey2, _ := delivery.LegacyPlacementKey("it2")
hkey2, _ := delivery.HaveNodeKey("it2")
buildKey, _ := delivery.BuilderBuildKey("web", fp)
done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest,
Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://web/files/blobs/" + fpDigest("f"), Digest: fpDigest("f")}}})
mdigest := meshcatalogclient.ManifestDigest([]byte(mailer))
mlraw, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "mailer", Manifest: mailer})
mlkey, _ := meshcatalogclient.ManifestsKey(mdigest)
mlmod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpIt("b"), Manifest: mdigest, Repository: "novox/mailer", Commit: strings.Repeat("2", 40)})
places2, _ := meshmoduleassignerclient.AssignmentsValues(meshmoduleassigner.Assignments{Node: "it2", Modules: []meshmoduleassigner.PlacedModule{{Module: "mailer"}}})
rec2, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "it2", Capabilities: []string{"service-manager"}}})
pkey2, _ := meshmoduleassignerclient.NodeKey("it2")
hkey2, _ := nec.HaveNodeKey("it2")
buildKey, _ := builderbuilds.BuilderBuildKey("web", fp)
done, _ := builderbuilds.EncodeBuilderBuild(builderbuilds.BuilderBuild{State: builderbuilds.BuildDone, Manifest: digest,
Artifacts: []builderbuilds.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://web/files/blobs/" + fpDigest("f"), Digest: fpDigest("f")}}})
firstBuild := done
if checkBuild {
firstBuild = []byte(`{"v":1,"state":"no such state"}`)
@@ -437,14 +460,14 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) {
value []byte
}{{manifests, mkey, mraw}, {modules, "web", modv}, {b.haves, hkey, rec},
{resolved, itNode + ".web.greeting", []byte(`{"value":"bonjour","source":"module"}`)},
{manifests, mlkey, mlraw}, {modules, "mailer", mlmod}, {b.haves, hkey2, rec2}, {placement, pkey2, place2},
{manifests, mlkey, mlraw}, {modules, "mailer", mlmod}, {b.haves, hkey2, rec2}, {assignment, pkey2, places2[pkey2]}, {assignment, "it2.mailer", places2["it2.mailer"]},
{resolved, "it2.mailer.domain", []byte(`{"value":"example.org","source":"module"}`)}, {builds, buildKey, firstBuild}} {
if _, err := w.kv.Put(w.key, w.value); err != nil {
t.Fatal(err)
}
}
want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true})
wkey, _ := delivery.WantKey(itNode, "web")
want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true})
wkey := meshwants.WantsByWantNodeModuleKey(itNode, "web")
if _, err := b.wants.Put(wkey, want); err != nil {
t.Fatal(err)
}
@@ -456,10 +479,10 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) {
if b.file("web") != "" {
t.Fatal("a node the catalogue does not place composed its own")
}
if _, err := placement.Put(itNode+".web", places[itNode+".web"]); err != nil {
if _, err := assignment.Put(itNode+".web", places[itNode+".web"]); err != nil {
t.Fatal(err)
}
if _, err := placement.Put(pkey, place); err != nil {
if _, err := assignment.Put(pkey, place); err != nil {
t.Fatal(err)
}
if checkBuild {
@@ -480,7 +503,7 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) {
t.Fatal(err)
}
}
return stop, h8state{placement: placement, resolved: resolved, candidates: candidates, packages: packages, hkey: hkey, place: place, fp: fp}
return stop, h8state{assignment: assignment, resolved: resolved, candidates: candidates, packages: packages, hkey: hkey, place: place, fp: fp}
}
// h8rest is H8 once the node composes its own: the have, a config revision, and the candidate answered "same".
@@ -488,22 +511,22 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str
t.Helper()
within(t, 2*time.Second, "the have does not name the build", func() bool {
h := b.have("web")
return h.FP == fp && h.Composed != "" && h.Phase == delivery.PhaseRunning
return h.Fp == fp && h.Composed != "" && h.Phase == nec.PhaseRunning
})
// ADR 0593 §6: the node publishes what it composed in its composition state, under its own grant: web at the build
// its want names, with the digest its have names (the plan left out of it), and the node's own.
first := b.have("web").Composed
webKey, _ := delivery.ComposedKey(itNode, "web", fp)
webKey, _ := engcomp.Key(itNode, "web", fp)
within(t, 5*time.Second, "the node did not publish web's composition in its composition state", func() bool {
e, err := b.composed.Get(webKey)
if err != nil {
return false
}
c, err := delivery.DecodeComposed(e.Value())
c, err := engcomp.Decode(e.Value())
if err != nil {
return false
}
d, err := delivery.ComposedDigestOf(c)
d, err := engcomp.DigestOf(c)
return err == nil && d == first
})
if _, err := resolved.Put(itNode+".web.greeting", []byte(`{"value":"salut","source":"module"}`)); err != nil {
@@ -512,36 +535,37 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str
within(t, 3*time.Second, "a changed config value was not composed and applied again", func() bool { return b.file("web") == "greeting=salut\n" })
within(t, 2*time.Second, "the have does not name the revision", func() bool {
h := b.have("web")
return h.FP == fp && h.Composed != first && h.Phase == delivery.PhaseRunning
return h.Fp == fp && h.Composed != first && h.Phase == nec.PhaseRunning
})
// The node says its facts (its record, as the test wrote it), then a candidate equal to its placement is written: the
// The node says its facts (its record, as the test wrote it), then a candidate equal to its assignment is written: the
// node answers "same" at the candidate's head and revision in its own key, and applies nothing.
b.session.SayFacts(context.Background(), link.Report{Profile: map[string]any{"capabilities": []any{
map[string]any{"name": "service-manager", "present": true}}}})
second := b.have("web").Composed
same, _ := delivery.DecodePlacement(place)
craw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "feat-x", Placement: &same})
same, _ := meshmoduleassignerclient.DecodeAssignments(place)
craw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "feat-x", Assignments: &same})
if err != nil {
t.Fatal(err)
}
ckey, _ := delivery.PlacementCandidateKey("feat-x", itNode)
ckey := meshmoduleassigner.CandidatesByProposalNodeKey("feat-x", itNode)
crev, err := candidates.Put(ckey, craw)
if err != nil {
t.Fatal(err)
}
var answer delivery.CandidateAnswer
var answer nodeengine.CandidateAnswer
within(t, 10*time.Second, "the node did not answer its candidate", func() bool {
e, err := b.haves.Get(hkey)
if err != nil {
return false
}
n, err := delivery.DecodeHaveNode(e.Value())
answer = n.Candidates["feat-x"]
n, err := nec.DecodeHaveNode(e.Value())
wire, _ := json.Marshal(n.Candidates["feat-x"])
_ = json.Unmarshal(wire, &answer)
return err == nil && answer.Proposal == "feat-x" && n.Record != nil
})
if answer.Verdict != delivery.CandidateSame || answer.Revision != crev || len(answer.Same) != 1 || answer.Same[0] != "web" {
t.Errorf("the answer to a candidate equal to the placement: %+v", answer)
if answer.Verdict != nodeengine.CandidateAnswerVerdictSame || answer.Revision != int64(crev) || len(answer.Same) != 1 || answer.Same[0] != "web" {
t.Errorf("the answer to a candidate equal to the assignment: %+v", answer)
}
if h := b.have("web"); h.Composed != second || b.file("web") != "greeting=salut\n" {
t.Errorf("a candidate moved what runs: %+v, %q", h, b.file("web"))
@@ -561,8 +585,8 @@ func h8candidate(t *testing.T, b *itBed, fp, running string) {
}
return kv
}
cands, _ := kvOf(delivery.CandidatesBucket), kvOf(delivery.VerdictsBucket)
manifests, err := b.js.KeyValue(delivery.ManifestsBucket)
cands, _ := kvOf(buckets.Candidates), kvOf(buckets.Verdicts)
manifests, err := b.js.KeyValue(buckets.Manifests)
if err != nil {
t.Fatal(err)
}
@@ -570,25 +594,25 @@ func h8candidate(t *testing.T, b *itBed, fp, running string) {
man := `{"module":"web","version":"2","config":{"greeting":{"type":"string","description":"what the file says","default":"hello"}},` +
`"resources":[{"id":"conf","type":"file","path":"` + path + `","mode":"0640","content":"greeting=${config:greeting}\n"},` +
`{"id":"tools","type":"package","package":"web-tools"}]}`
digest := delivery.ManifestDigest([]byte(man))
mraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "web", JSON: man})
mkey, _ := delivery.ManifestsKey(digest)
digest := meshcatalogclient.ManifestDigest([]byte(man))
mraw, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "web", Manifest: man})
mkey, _ := meshcatalogclient.ManifestsKey(digest)
if _, err := manifests.Put(mkey, mraw); err != nil {
t.Fatal(err)
}
head := delivery.Head{Repository: "novox/web", Number: 3, Head: strings.Repeat("3", 40)}
heads, _ := delivery.HeadsHash([]delivery.Head{head})
pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"web"},
Fps: map[string]delivery.CandidateModule{"web": {FP2: fpIt("c"), Manifest: digest}}}}
head := meshdeliveryclient.Head{Repository: "novox/web", Number: 3, Head: strings.Repeat("3", 40)}
heads, _ := meshdeliveryclient.HeadsHash([]meshdeliveryclient.Head{head})
pulls := []meshcatalog.CandidatesByGroupValuePullsItem{bucketstest.Pull(head, []string{"web"},
map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{"web": {Fp2: fpIt("c"), Manifest: digest}})}
// The indexer keys the candidate by its group (a branch name, slash allowed); the checker asks by the composition key.
const group = "fix/3-web"
craw, err := delivery.EncodeCandidate(delivery.Candidate{Group: group, HeadsHash: heads, Pulls: pulls,
FPSet: delivery.FingerprintSet(pulls)})
craw, err := bucketstest.GroupCandidate(meshcatalog.CandidatesByGroupValue{Group: group, Heads: heads, Pulls: pulls,
Fpset: meshcatalogclient.FingerprintSet(pulls)})
if err != nil {
t.Fatal(err)
}
ckey, _ := delivery.CandidatesKey(group)
key, _ := delivery.CompositionKey(group, heads)
ckey, _ := buckets.CandidateKey(group)
key, _ := nec.CompositionKey(group, heads)
if _, err := cands.Put(ckey, craw); err != nil {
t.Fatal(err)
}
@@ -598,12 +622,12 @@ func h8candidate(t *testing.T, b *itBed, fp, running string) {
t.Fatal(err)
}
raw, _ := got.(json.RawMessage)
a, err := delivery.DecodeComposeCandidateAnswer(raw)
a, err := nec.DecodeAnswer(raw)
if err != nil {
t.Fatalf("the answer %s: %v", raw, err)
}
if a.Verdict != delivery.ComposeCandidateComposed || len(a.Modules) != 1 || a.Modules[0].Module != "web" ||
a.Modules[0].FP != fpIt("c") || !a.Modules[0].Changed || a.Took > delivery.ComposeAnswerBound.Milliseconds() {
if a.Verdict != nec.Composed || len(a.Modules) != 1 || a.Modules[0].Module != "web" ||
a.Modules[0].Fp != fpIt("c") || !a.Modules[0].Changed || a.Took > nec.AnswerBound.Milliseconds() {
t.Errorf("the node's answer to a candidate changing web: %+v", a)
}
after, _ := b.composed.Status()
@@ -618,7 +642,7 @@ func h8candidate(t *testing.T, b *itBed, fp, running string) {
func mustComposedKey(t *testing.T, module, fp string) string {
t.Helper()
key, err := delivery.ComposedKey(itNode, module, fp)
key, err := engcomp.Key(itNode, module, fp)
if err != nil {
t.Fatal(err)
}
@@ -744,10 +768,10 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
}
return kv
}
placement, modules, manifests := kvOf(delivery.PlacementBucket), kvOf(delivery.ModulesBucket), kvOf(delivery.ManifestsBucket)
assignment, modules, manifests := kvOf(buckets.Assignments), kvOf(buckets.Modules), kvOf(buckets.Manifests)
kvOf(selfcompose.ResolvedBucket)
builds := kvOf(delivery.BuilderBuildsBucket)
kvOf(delivery.PlacementCandidatesBucket)
builds := kvOf(builderbuilds.BuilderBuildsBucket)
kvOf(buckets.ProposalCandidates)
kvOf(link.PackagesBucket)
// The bus module at a build, done: its manifest written and the indexer's module naming it at fp.
publish := func(fp, env string) {
@@ -755,15 +779,15 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
`"data":{"own":[{"id":"jetstream","path":"` + b.dir + `","class":"valuable","why":"the bus's streams"}]},"resources":[` +
`{"id":"server-conf","type":"file","path":"` + conf + `","mode":"0644","content":"listen: new\n"},` +
`{"id":"server","type":"container","name":"` + server + `","image":"` + image + `"` + env + `}]}`
digest := delivery.ManifestDigest([]byte(man))
mraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "nats", JSON: man})
mkey, _ := delivery.ManifestsKey(digest)
modv, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/mesh-catalog", Commit: strings.Repeat("3", 40)})
digest := meshcatalogclient.ManifestDigest([]byte(man))
mraw, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "nats", Manifest: man})
mkey, _ := meshcatalogclient.ManifestsKey(digest)
modv, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/mesh-catalog", Commit: strings.Repeat("3", 40)})
if _, err := manifests.Put(mkey, mraw); err != nil {
t.Fatal(err)
}
bkey, _ := delivery.BuilderBuildKey("nats", fp)
done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest})
bkey, _ := builderbuilds.BuilderBuildKey("nats", fp)
done, _ := builderbuilds.EncodeBuilderBuild(builderbuilds.BuilderBuild{State: builderbuilds.BuildDone, Manifest: digest})
if _, err := builds.Put(bkey, done); err != nil {
t.Fatal(err)
}
@@ -771,24 +795,24 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
t.Fatal(err)
}
}
rec, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: itNode,
rec, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: itNode,
Capabilities: []string{"service-manager", "package-manager", "container-runtime"}}})
hkey, _ := delivery.HaveNodeKey(itNode)
places, _ := delivery.PlacementValues(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "nats"}}})
hkey, _ := nec.HaveNodeKey(itNode)
places, _ := meshmoduleassignerclient.AssignmentsValues(meshmoduleassigner.Assignments{Node: itNode, Modules: []meshmoduleassigner.PlacedModule{{Module: "nats"}}})
// The have the engine wrote of the folded bus: the old-form build, no fingerprint.
natsHave, _ := delivery.HaveKey(itNode, "nats")
oldHave, _ := delivery.EncodeHave(delivery.Have{Build: "build-1792674595610176744", Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
natsHave, _ := nec.HaveKey(itNode, "nats")
oldHave, _ := nec.EncodeHave(nodeengine.HaveByNodeModuleValue{Build: "build-1792674595610176744", Phase: nec.PhaseRunning, Health: nec.HealthOK})
for key, value := range map[string][]byte{hkey: rec, natsHave: oldHave} {
if _, err := b.haves.Put(key, value); err != nil {
t.Fatal(err)
}
}
busWant := func(w delivery.Want) {
raw, err := delivery.EncodeWant(w)
busWant := func(w meshwants.WantsByWantNodeModuleValue) {
raw, err := bucketstest.Want(w)
if err != nil {
t.Fatal(err)
}
key, _ := delivery.WantKey(itNode, "nats")
key := meshwants.WantsByWantNodeModuleKey(itNode, "nats")
if _, err := b.wants.Put(key, raw); err != nil {
t.Fatal(err)
}
@@ -820,11 +844,11 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
}
first := fpIt("e")
publish(first, "")
busWant(delivery.Want{FP: first, Self: true})
busWant(meshwants.WantsByWantNodeModuleValue{Fp: first, Self: true})
stop := b.start()
defer stop()
for key, value := range places {
if _, err := placement.Put(key, value); err != nil {
if _, err := assignment.Put(key, value); err != nil {
t.Fatal(err)
}
}
@@ -832,7 +856,7 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
within(t, 10*time.Second, "the bus module's first want was not applied", func() bool {
h := b.have("nats")
got, _ := os.ReadFile(conf)
return h.FP == first && h.Phase == delivery.PhaseRunning && string(got) == "listen: new\n"
return h.Fp == first && h.Phase == nec.PhaseRunning && string(got) == "listen: new\n"
})
if moved := runtime.moved(server); len(moved) != 0 || runtime.containers[server] != made {
t.Fatalf("the first want moved the bus server its build does not change: %v", moved)
@@ -852,13 +876,13 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
// Approve for a move the node judged to recreate it (ADR 0599 §2, §4).
next := fpIt("f")
publish(next, `,"env":{"CHANGED":"1"}`)
busWant(delivery.Want{FP: next, Self: true})
busWant(meshwants.WantsByWantNodeModuleValue{Fp: next, Self: true})
within(t, 10*time.Second, "the build that changes the server was not applied", func() bool {
h := b.have("nats")
if h.Phase == delivery.PhaseWaitingYes && h.Waits != "" {
busWant(delivery.Want{FP: next, Self: true, Yes: h.Waits})
if h.Phase == nec.PhaseWaitingYes && h.Waits != "" {
busWant(meshwants.WantsByWantNodeModuleValue{Fp: next, Self: true, Yes: h.Waits})
}
return h.FP == next && h.Phase == delivery.PhaseRunning
return h.Fp == next && h.Phase == nec.PhaseRunning
})
var runs int
for _, a := range runtime.moved(server) {
@@ -874,3 +898,171 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
runtime.containers[server] == made, runtime.moved(server))
}
}
// standInSeat is the seat mesh-catalog as the node reads the seat list from it (novox/hq ADR 0636 §4): with no seat, every
// bootstrapped seat with a digest; with one, its definition. While seatSilent it refuses.
func (b *itBed) standInSeat() {
defs := map[string]json.RawMessage{}
var entries []seatlist.Entry
for _, d := range seatlist.Bootstrapped() {
raw, err := json.Marshal(d)
if err != nil {
b.t.Fatal(err)
}
defs[d.Seat] = raw
entries = append(entries, seatlist.Entry{Seat: d.Seat, Source: seatlist.SourceTrunk, Digest: "d-" + d.Seat})
}
sub, err := b.conn.Subscribe("mesh.seat."+seatlist.Seat+".call."+seatlist.Verb+".*", func(m *nats.Msg) {
var q struct {
Seat string `json:"seat"`
}
_ = json.Unmarshal(m.Data, &q)
b.seatAsked.Add(1)
var result any
switch {
case b.seatSilent.Load():
_ = m.Respond([]byte(`{"error":"the indexer has not started"}`))
return
case q.Seat == "":
result = seatlist.Listing{Revision: 1, Seats: entries}
default:
result = seatlist.Answer{Seat: q.Seat, Found: true, Revision: 1,
Entry: &seatlist.Entry{Seat: q.Seat, Source: seatlist.SourceTrunk, Digest: "d-" + q.Seat, Definition: defs[q.Seat]}}
}
out, _ := json.Marshal(map[string]any{"result": result})
_ = m.Respond(out)
})
if err != nil {
b.t.Fatal(err)
}
b.t.Cleanup(func() { _ = sub.Unsubscribe() })
}
// fiveMinutes is a clock of the test: the bound of the node's wait fires when the test says so, nothing sleeps.
type fiveMinutes struct {
mu sync.Mutex
timers []*time.Timer
fire []func()
}
func (c *fiveMinutes) Now() time.Time { return time.Now() }
func (c *fiveMinutes) AfterFunc(_ time.Duration, f func()) seatwait.Timer {
c.mu.Lock()
defer c.mu.Unlock()
t := &stoppable{}
c.fire = append(c.fire, func() {
if !t.stopped.Load() {
f()
}
})
return t
}
type stoppable struct{ stopped atomic.Bool }
func (s *stoppable) Stop() bool { return !s.stopped.Swap(true) }
func (c *fiveMinutes) pass() {
c.mu.Lock()
fire := c.fire
c.fire = nil
c.mu.Unlock()
for _, f := range fire {
f()
}
}
// ADR 0636 §1, §8, whole against a real bus and the stand-in of the seat: while the seat refuses, the node composes and
// applies nothing new, and keeps running what it ran; after the bound it writes one finding in its state findings; when
// the seat names its list the module is applied and the finding is cleared; a node started again with the seat gone
// applies the composition it applied last.
func TestNatsANodeThatCannotReadTheSeatListActsOnNothingNew(t *testing.T) {
clock := &fiveMinutes{}
was := seatWaitClock
seatWaitClock = clock
t.Cleanup(func() { seatWaitClock = was })
b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain"))
b.seatSilent.Store(true)
stop, st := h8(t, b, false)
defer stop()
_ = st
finding := "machine." + itNode + ".seat-list-unread"
within(t, 5*time.Second, "the node did not start its bound while the seat refused", func() bool {
clock.mu.Lock()
defer clock.mu.Unlock()
return len(clock.fire) > 0
})
// A fresh node, nothing applied, reaches the bus and asks the seat while its own composition is held: the read needs
// the bus the node composes from and the grant it has from its start, nothing of what the hold keeps back.
if b.seatAsked.Load() == 0 {
t.Fatal("the fresh node did not ask the seat for its list while its own composition was held")
}
within(t, 5*time.Second, "the fresh node did not say it holds web and its own composition for the unread list", func() bool {
b.mu.Lock()
defer b.mu.Unlock()
said := strings.Join(b.said, "\n")
return strings.Contains(said, selfcompose.WaitsForTheSeatListOwn) && strings.Contains(said, selfcompose.WaitsForTheSeatList)
})
if b.file("web") != "" {
t.Fatalf("the node applied web before it read the seat list: %q", b.file("web"))
}
if _, err := b.findings.Get(finding); err == nil {
t.Fatal("the node wrote its finding before the bound")
}
clock.pass()
within(t, 5*time.Second, "the node did not write its finding after the bound", func() bool {
_, err := b.findings.Get(finding)
return err == nil
})
if b.file("web") != "" {
t.Fatalf("the node applied web with its finding written: %q", b.file("web"))
}
b.seatSilent.Store(false)
// The hand act of the operator and the healers (the verb seat-list-again): a read now, as the seat's event would make.
if out, err := seatListAgain(); err != nil || out.(map[string]any)["error"] != nil {
t.Fatalf("seat-list-again: %v, %v", out, err)
}
within(t, 50*time.Second, "the node did not apply web once the seat named its list", func() bool { return b.file("web") == "greeting=bonjour\n" })
within(t, 5*time.Second, "the node did not clear its finding at the first full read", func() bool {
_, err := b.findings.Get(finding)
return errors.Is(err, nats.ErrKeyNotFound)
})
// Started again with the seat refusing: nothing new is composed, and what ran is neither removed nor changed.
stop()
b.seatSilent.Store(true)
stopAgain := b.start()
defer stopAgain()
within(t, 30*time.Second, "the node did not hold web as it was after a start with the seat gone", func() bool {
b.mu.Lock()
defer b.mu.Unlock()
return strings.Contains(strings.Join(b.said, "\n"), "it1 has read no complete seat list") || b.file("web") != ""
})
if b.file("web") != "greeting=bonjour\n" {
t.Errorf("web does not stay as it ran after a start with the seat gone: %q", b.file("web"))
}
}
// The first read of the seat list ends the wait whether or not it changed what the reader held: Start returns at once, and
// the node waits on the reader's Ready before it says the read is over (ADR 0636 §1).
func TestNatsTheFirstReadOfTheSeatListEndsTheNodesWait(t *testing.T) {
clock := &fiveMinutes{}
was := seatWaitClock
seatWaitClock = clock
t.Cleanup(func() { seatWaitClock = was })
b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain"))
stop, _ := h8(t, b, false)
defer stop()
within(t, 30*time.Second, "the node did not end its wait at the first read of the seat list", func() bool {
st := seatListNow.Load()
if st == nil {
return false
}
<-st.reader.Ready()
waiting, _ := st.waiter.Waiting()
return st.reader.Status().Heard && !waiting
})
}
+3 -2
View File
@@ -8,8 +8,9 @@ import (
"sort"
"strings"
provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/provisions"
"github.com/novox/mesh-host/internal/buckets"
)
// provisionSecrets fills a provision's value into a file the provisioner's client hands the node-engine to write
@@ -92,7 +93,7 @@ func byName(keys map[string][]byte, name string) (provisions.Provision, string,
}
switch len(hit) {
case 0:
return provisions.Provision{}, "", fmt.Errorf("no provision of %s goes by that name: nothing asks for it", provisions.ProvisionsRead)
return provisions.Provision{}, "", fmt.Errorf("no provision of %s goes by that name: nothing asks for it", buckets.ProvisionsRead)
case 1:
return got, hit[0], nil
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"git.novox.be/novox/mesh-sdk/go/declaration"
"github.com/novox/mesh-host/internal/agent"
"github.com/novox/mesh-host/internal/apply"
)
// pullsOf is the images an apply could not find or pull, each with the seat it came through and that seat's holder as the
// declaration names them beside the container (design 51 *distribution's first delivery*, ENG-8, ENG-10). An image the
// declaration names no holder for (a public registry's) carries none: its pull is not tried again on a store settling.
func pullsOf(d *declaration.Declaration, err error) []agent.PullFailure {
var out []agent.PullFailure
for _, p := range apply.PullErrors(err) {
f := agent.PullFailure{Image: p.Image, Started: p.Started}
for _, r := range d.Resources {
if c, ok := r.(*declaration.Container); ok && c.Image == p.Image && c.ImageHolder != "" {
f.Seat, f.Holder = c.ImageSeat, c.ImageHolder
break
}
}
out = append(out, f)
}
return out
}
+3 -4
View File
@@ -3,11 +3,10 @@ package main
import (
"context"
"encoding/json"
engcomp "github.com/novox/mesh-host/internal/compositions"
"path/filepath"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/liveness"
"github.com/novox/mesh-host/internal/store"
)
@@ -21,7 +20,7 @@ func TestACompositionIsRecordedOnlyWhenEveryResourceIs(t *testing.T) {
if err := store.Save(path, s); err != nil {
t.Fatal(err)
}
c := delivery.Composed{Node: "ace", Module: "ca-trust", Resources: []json.RawMessage{
c := engcomp.Composed{Node: "ace", Module: "ca-trust", Resources: []json.RawMessage{
json.RawMessage(`{"id":"ca-trust.trust","type":"file","module":"ca-trust","path":"/etc/ca","mode":"0644","content":"x"}`)}}
recorded := recordedIn(path, func(string) {})
if !recorded(context.Background(), c) {
@@ -87,7 +86,7 @@ func TestAFilledFileDoesNotMakeItsCompositionUnrecorded(t *testing.T) {
if err := store.Save(path, s); err != nil {
t.Fatal(err)
}
c := delivery.Composed{Node: "ace", Module: "ca-trust", Filled: []string{"/etc/ca-filled"}, Resources: []json.RawMessage{
c := engcomp.Composed{Node: "ace", Module: "ca-trust", Filled: []string{"/etc/ca-filled"}, Resources: []json.RawMessage{
json.RawMessage(`{"id":"ca-trust.trust","type":"file","module":"ca-trust","path":"/etc/ca","mode":"0644","content":"x"}`),
json.RawMessage(`{"id":"ca-trust.filled","type":"file","module":"ca-trust","path":"/etc/ca-filled","mode":"0644","owner-at-runtime":"owned"}`)}}
if !recordedIn(path, func(string) {})(context.Background(), c) {
+3 -3
View File
@@ -4,10 +4,10 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
engcomp "github.com/novox/mesh-host/internal/compositions"
"os"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/store"
)
@@ -20,7 +20,7 @@ import (
// names what is on disk: **only when the node's record says it wrote exactly that content there** (store Applied.Wrote,
// Target), never a file someone else changed. A file that cannot be read, or that the record does not vouch for, is
// left as the composition names it.
func runsAsWritten(running []delivery.Composed, statePath string) []delivery.Composed {
func runsAsWritten(running []engcomp.Composed, statePath string) []engcomp.Composed {
if len(running) == 0 || statePath == "" {
return running
}
@@ -28,7 +28,7 @@ func runsAsWritten(running []delivery.Composed, statePath string) []delivery.Com
if err != nil {
return running
}
out := make([]delivery.Composed, len(running))
out := make([]engcomp.Composed, len(running))
for i, c := range running {
out[i] = c
var changed []json.RawMessage
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync/atomic"
"time"
conditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"git.novox.be/novox/mesh-sdk/go/views"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/seatwait"
)
// The node-engine reads the seat list as every part does (novox/hq ADR 0635 §1, ADR 0636): through the seat mesh-catalog,
// from the bootstrapped definitions until it has been read, acting on nothing new meanwhile (the composer's gate), and
// following the seat's events. What it holds is given to catalogue at every change, which compiles no seat list.
// seatListNow is this run's reader, what the node-engine's verbs seat-list and seat-list-again answer from.
var seatListNow atomic.Pointer[seatListState]
// seatListRead answers the verb seat-list: what the engine holds of the list and what it waits for (ADR 0636 §7).
func seatListRead() (any, error) {
st := seatListNow.Load()
if st == nil {
return nil, errors.New("this engine's node agent has not started, so it holds no seat list yet")
}
status := st.reader.Status()
return map[string]any{"status": status, "health": status.Health()}, nil
}
// seatListAgain answers the verb seat-list-again: the list read now from the seat, and what is held afterwards.
func seatListAgain() (any, error) {
st := seatListNow.Load()
if st == nil {
return nil, errors.New("this engine's node agent has not started, so it holds no seat list yet")
}
err := st.reader.Reload()
st.waiter.Changed() // a read that changed nothing held still ends the wait
out, _ := seatListRead()
if err != nil {
out.(map[string]any)["error"] = err.Error()
}
return out, nil
}
// seatListState is one run's reader, the finding that it waits, and the channel on which the composer hears a change.
type seatListState struct {
reader *seatlist.Reader
waiter *seatwait.Waiter
changed chan struct{}
}
// sessionBus is the reader's bus over the link: a verb of the seat is asked in the engine's own name, an event heard on
// the seat's event subject for as long as ctx lives.
type sessionBus struct {
ctx context.Context
session *link.Session
}
func (b sessionBus) Ask(key string, body any) (json.RawMessage, error) {
seat, verb, _ := strings.Cut(strings.TrimPrefix(key, "seat:"), ".")
return b.session.AskMeshSeat(b.ctx, seat, verb, body)
}
func (b sessionBus) Subscribe(pattern string, handle func(string, json.RawMessage) error) error {
seat, event, _ := strings.Cut(pattern, ".")
heard, stop, err := b.session.Hear(seatdef.EventSubject(seat, event))
if err != nil {
return err
}
go func() {
defer stop()
for {
select {
case <-b.ctx.Done():
return
case body, ok := <-heard:
if !ok {
return
}
_ = handle(pattern, body)
}
}
}()
return nil
}
// seatWaitClock is the clock of the node's bound; the tests give their own.
var seatWaitClock seatwait.Clock = realClock{}
type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }
func (realClock) AfterFunc(d time.Duration, f func()) seatwait.Timer {
return time.AfterFunc(d, f)
}
// findingsOfTheNode writes the node's findings in its state `findings`, in the layout of the seat mesh-conditions.
type findingsOfTheNode struct{ session *link.Session }
func (w findingsOfTheNode) encode(f views.Finding) (string, []byte, error) {
key := conditions.Key(conditions.ScopeMachine, f.ID, f.Kind)
v := condseat.FindingsByKeyValue{V: 1, Key: key, Kind: f.Kind, Observer: f.Observer, Severity: conditions.Warning,
Subject: condseat.Subject{Scope: conditions.ScopeMachine, ID: f.ID, Machine: f.ID}, Summary: f.Summary,
Headline: f.Headline, Explanation: f.Explanation, Source: "seat-list-unread", At: f.At.UTC()}
if err := conditions.CheckFinding(v); err != nil {
return "", nil, err
}
raw, err := json.Marshal(v)
return key, raw, err
}
func (w findingsOfTheNode) Put(f views.Finding) error {
key, raw, err := w.encode(f)
if err != nil {
return err
}
return w.session.PutFinding(key, raw)
}
func (w findingsOfTheNode) Delete(f views.Finding) error {
key, _, err := w.encode(f)
if err != nil {
return err
}
return w.session.DeleteFinding(key)
}
// startSeatList starts this run's reader: its bootstrapped seats are given to catalogue at once, and every change of what
// it holds again, saying so to the composer and to the waiter that writes the five-minute finding.
func startSeatList(ctx context.Context, session *link.Session, node string, say func(string)) (*seatListState, error) {
st := &seatListState{changed: make(chan struct{}, 1)}
// Start returns at once and reads the seat on a goroutine, which may announce a change before the waiter exists.
var announce atomic.Pointer[func()]
reader, err := seatlist.Start(seatlist.Config{Bus: sessionBus{ctx, session}, Bootstrap: seatlist.Bootstrapped(),
OnChange: func(seats []catalogue.Seat) {
catalogue.UseSeats(seats)
if f := announce.Load(); f != nil { // the first read may end before the waiter exists
(*f)()
}
}})
if err != nil {
return nil, fmt.Errorf("the node-engine cannot follow the seat list: %w", err)
}
st.reader = reader
st.waiter = &seatwait.Waiter{Node: node, Observer: "node-engine", Reader: reader, Findings: findingsOfTheNode{session},
Clock: seatWaitClock, Say: say}
told := func() {
st.waiter.Changed()
select {
case st.changed <- struct{}{}:
default:
}
}
announce.Store(&told)
catalogue.UseSeats(reader.Seats())
told()
// The first read ends whether the seat answered or not, and a read that held what the reader held already says no
// change: its end is told to the waiter and the composer, so a status that moved from reading to heard starts or ends
// the bound (ADR 0636 §1). Start's return held nobody's start.
go func() {
select {
case <-reader.Ready():
catalogue.UseSeats(reader.Seats())
told()
case <-ctx.Done():
}
}()
seatListNow.Store(st)
return st, nil
}
+22
View File
@@ -0,0 +1,22 @@
package main
import (
"testing"
"github.com/novox/mesh-host/internal/selfcompose"
)
// ADR 0636 §1: when the node's reader of the seat list could not start, the composer is gated by a reader that never
// allows, where a missing reader would let the node act on everything; the shadow, which applies nothing, stays ungated.
func TestAReaderThatCouldNotStartHoldsTheComposer(t *testing.T) {
failed := composerEnv(nil, "n1", func(string) {}, composerHooks{seatListFailed: true})
if failed.SeatList == nil || failed.SeatList.MayAct() || failed.SeatList.MayAct("node-engine") {
t.Fatalf("the composer of a node whose reader did not start is gated by %v, want a gate that allows nothing", failed.SeatList)
}
if _, ok := failed.SeatList.(selfcompose.Unread); !ok {
t.Errorf("gate is %T", failed.SeatList)
}
if got := composerEnv(nil, "n1", func(string) {}, composerHooks{}).SeatList; got != nil {
t.Errorf("a composer with no reader asked for gates by %v, want none (the shadow)", got)
}
}
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/novox/mesh-host/internal/link"
)
// settledEvent is where the status view says a module has settled on a node: its have reached its want, running and healthy
// (design 47; the view's `settled`, said once for a module on a node at one composition). The engine hears it for every module
// and tells its agent, which tries again, once per settled, a failed apply that waits on that module as the holder of the store
// its image comes from (design 51 *distribution's first delivery*, issue 1966 ENG-11). The engine's manifest consumes it (`mesh-delivery-view.settled`),
// and that line alone grants the engine's account the subject (TestTheSettledEventIsTheManifestsConsume); a refused subscription is said, and made again when the next link opens.
const settledEvent = "mesh.mod.mesh-delivery-view.event.settled"
// settledBody is what of the event the engine reads: the module that settled, on whichever node.
type settledBody struct {
Module string `json:"module"`
}
// settledTold is who is told a holder has settled.
type settledTold interface{ Settled(holder string) }
// followSettled hears the status view's `settled` on every link, never on a timer, until ctx ends. now names the agent running
// now (nil while the node agent begins again): a settled heard while there is none is not lost for it, since a new agent applies
// again every have that failed at its start.
func followSettled(ctx context.Context, session *link.Session, now func() settledTold, say link.Announce) {
said := ""
for ctx.Err() == nil {
linked, cancel, err := session.WhenLinked(ctx)
if err != nil {
return
}
events, stop, herr := session.Hear(settledEvent)
if herr != nil {
if line := fmt.Sprintf("a store settling is not heard yet (%s: %v): a failed pull waits for the next link, or for compose", settledEvent, herr); line != said {
said = line
say(line)
}
} else {
said = ""
listen(linked, events, now, say)
stop()
}
<-linked.Done()
cancel()
}
}
// listen tells the agent each settled it hears, until the link ends.
func listen(linked context.Context, events <-chan []byte, now func() settledTold, say link.Announce) {
for {
select {
case <-linked.Done():
return
case raw, ok := <-events:
if !ok {
return
}
var b settledBody
if json.Unmarshal(raw, &b) != nil || b.Module == "" {
continue
}
if told := now(); told != nil {
told.Settled(b.Module)
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"encoding/json"
"os"
"slices"
"strings"
"testing"
)
// The subject the engine hears for a store settling is the event its manifest consumes: the grants part grants an account
// what its manifest consumes and nothing named in code (novox/hq ADR 0625 §4).
func TestTheSettledEventIsTheManifestsConsume(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Consumes []string `json:"consumes"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
emitter, event, _ := strings.Cut(strings.TrimPrefix(settledEvent, "mesh.mod."), ".event.")
if !slices.Contains(m.Consumes, emitter+"."+event) {
t.Errorf("the engine hears %s, its manifest consumes %v", settledEvent, m.Consumes)
}
}
+56 -56
View File
@@ -7,14 +7,21 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"github.com/novox/mesh-host/internal/buckets"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/overlay"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"github.com/novox/mesh-host/internal/identity"
"github.com/novox/mesh-host/internal/link"
@@ -22,7 +29,7 @@ import (
// **What this engine trusts a mesh signature from** (novox/hq ADR 0550 §2; design 47's L3 item d; issue 1126): the
// vault's key alone, read from the vault's state `signers`, which only the vault may write: the bus grants are its
// anchor, as the wants' and the placement's. The controller's key, which the node was enrolled with (its membership's
// anchor, as the wants' and the assignment's. The controller's key, which the node was enrolled with (its membership's
// signer), is trusted never: the controller ended by its unassignment (the operator, 2026-11-02) and its lease is never
// handed, so no gate waits on it. What it trusts now the node states in its record (NodeRecord.Trusts).
type meshTrust struct {
@@ -44,23 +51,23 @@ func (t *meshTrust) onChange(f func()) {
}
// Signers are the keys a mesh signature is verified against now: the vault's once read, and none before.
func (t *meshTrust) Signers() []delivery.Signer {
func (t *meshTrust) Signers() []meshvaultclient.Signer {
t.mu.Lock()
defer t.mu.Unlock()
var out []delivery.Signer
var out []meshvaultclient.Signer
if t.vault != nil {
out = append(out, delivery.Signer{Name: delivery.SignerVault, Key: t.vault})
out = append(out, meshvaultclient.Signer{Name: meshvaultclient.SignerVault, Key: t.vault})
}
return out
}
// Verify is the signer that signed msg as kind, or why none it trusts did.
func (t *meshTrust) Verify(kind string, msg, sig []byte) (delivery.Signer, error) {
context, err := delivery.SignContext(kind)
func (t *meshTrust) Verify(kind string, msg, sig []byte) (meshvaultclient.Signer, error) {
context, err := meshvaultclient.SignContext(kind)
if err != nil {
return delivery.Signer{}, err
return meshvaultclient.Signer{}, err
}
return delivery.VerifyBy(t.Signers(), context, msg, sig)
return meshvaultclient.VerifyBy(t.Signers(), context, msg, sig)
}
// VaultAuthority is the vault's authority certificate as read from `signers`, or "".
@@ -80,9 +87,9 @@ func (t *meshTrust) Trusts() []string {
}
authorities := map[string]string{}
if a := t.VaultAuthority(); a != "" {
authorities[delivery.SignerVault] = a
authorities[meshvaultclient.SignerVault] = a
}
return delivery.Trusts(t.Signers(), authorities)
return meshvaultclient.Trusts(t.Signers(), authorities)
}
// change applies f under the lock and tells who follows when Trusts differs after it.
@@ -101,25 +108,25 @@ func (t *meshTrust) change(f func()) {
// check; a deleted key is no longer trusted.
func (t *meshTrust) heardSigner(key string, value []byte, deleted bool, say link.Announce) {
switch key {
case delivery.SignersKeyKey:
case meshvault.SignersByKeyKey():
var pub ed25519.PublicKey
if !deleted && len(value) > 0 {
k, err := delivery.DecodeSignerKey(value)
k, err := meshvaultclient.DecodeSignerKey(value)
if err == nil {
pub, err = k.PublicKey()
pub, err = meshvaultclient.SignerPublicKey(k)
}
if err != nil {
say("the vault's signing key in " + delivery.SignersRead + " is not trusted: " + err.Error())
say("the vault's signing key in " + buckets.SignersRead + " is not trusted: " + err.Error())
return
}
}
t.change(func() { t.vault = pub })
case delivery.SignersAuthorityKey:
case meshvault.SignersByAuthorityKey():
pem := ""
if !deleted && len(value) > 0 {
a, err := delivery.DecodeSignerAuthority(value)
a, err := meshvaultclient.DecodeSignerAuthority(value)
if err != nil {
say("the vault's authority in " + delivery.SignersRead + " is not trusted: " + err.Error())
say("the vault's authority in " + buckets.SignersRead + " is not trusted: " + err.Error())
return
}
pem = a.Certificate
@@ -150,7 +157,7 @@ func followTrust(ctx context.Context, session *link.Session, trust *meshTrust, s
return
}
watching, stop := context.WithCancel(linked)
signers, serr := session.KVWatch(watching, delivery.SignersBucket, ">")
signers, serr := session.KVWatch(watching, buckets.Signers, ">")
if serr != nil {
stop()
sayOnce(fmt.Sprintf("what this node trusts a mesh signature from is not followed yet (the vault's signers: %v): "+
@@ -172,52 +179,45 @@ func followTrust(ctx context.Context, session *link.Session, trust *meshTrust, s
}
}
// placedAllHave says whether every module the seat mesh-placement places on node has a have of its own, read from the
// bus: then the kept declaration is read no more (keptRetired). A node the seat does not place, or whose placement or haves
// placedAllHave says whether every module the seat mesh-module-assigner places on node has a have of its own, read from the
// bus: then the kept declaration is read no more (keptRetired). A node the seat does not place, or whose assignment or haves
// cannot be read, keeps reading it.
func placedAllHave(ctx context.Context, session *link.Session, node string) (bool, string) {
key, err := delivery.PlacementNodeKey(node)
key, err := meshmoduleassignerclient.NodeKey(node)
if err != nil {
return false, err.Error()
}
legacy, _ := delivery.LegacyPlacementKey(node)
// Its node key names every module placed (research 072 row 37); before the holder converted it, its legacy key does.
// Its node key names every module placed (research 072 row 37).
values := map[string][]byte{}
for _, k := range []string{key, legacy} {
if h, err := session.KVHistory(ctx, delivery.PlacementBucket, k); err == nil && len(h) > 0 && !h[len(h)-1].Deleted {
values[k] = h[len(h)-1].Value
}
if h, err := session.KVHistory(ctx, buckets.Assignments, key); err == nil && len(h) > 0 && !h[len(h)-1].Deleted {
values[key] = h[len(h)-1].Value
}
p, err := placedModules(node, values)
if err != nil {
return false, err.Error()
}
return allHave(p, func(module string) (delivery.Have, bool, error) {
return allHave(p, func(module string) (nodeengine.HaveByNodeModuleValue, bool, error) {
return session.ReadHaveErr(ctx, module)
})
}
// placedModules is the modules node's node key names (a placement of their names alone), or its legacy key's placement
// where it has no node key; an error when neither reads.
func placedModules(node string, values map[string][]byte) (delivery.Placement, error) {
key, _ := delivery.PlacementNodeKey(node)
if raw, ok := values[key]; ok {
n, err := delivery.DecodePlacementNode(raw)
if err != nil {
return delivery.Placement{}, err
}
p := delivery.Placement{Node: node}
for _, r := range n.Modules {
p.Modules = append(p.Modules, delivery.PlacedModule{Module: r.Module})
}
return p, nil
}
legacy, _ := delivery.LegacyPlacementKey(node)
raw, ok := values[legacy]
// placedModules is the modules node's node key names (an assignment of their names alone); an error when it does not
// read.
func placedModules(node string, values map[string][]byte) (meshmoduleassigner.Assignments, error) {
key, _ := meshmoduleassignerclient.NodeKey(node)
raw, ok := values[key]
if !ok {
return delivery.Placement{}, errors.New("its placement is not read")
return meshmoduleassigner.Assignments{}, errors.New("its assignment is not read")
}
return delivery.DecodePlacement(raw)
n, err := meshmoduleassignerclient.DecodeNode(raw)
if err != nil {
return meshmoduleassigner.Assignments{}, err
}
p := meshmoduleassigner.Assignments{Node: node}
for _, r := range n.Modules {
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: r.Module})
}
return p, nil
}
// allHave says whether has finds, for every module p places, a have that names a fingerprint of the new form, and why
@@ -225,9 +225,9 @@ func placedModules(node string, values map[string][]byte) (delivery.Placement, e
// declaration is the one record of what runs of it: retired then, the node's own composed it as nothing and refused to
// remove what runs (issue 774, 2026-10-30 19:43: systemd-resolved on shanks, nats and restic on novox, each node's own
// apply refused from then on).
func allHave(p delivery.Placement, has func(module string) (delivery.Have, bool, error)) (bool, string) {
func allHave(p meshmoduleassigner.Assignments, has func(module string) (nodeengine.HaveByNodeModuleValue, bool, error)) (bool, string) {
if len(p.Modules) == 0 {
return false, "the seat mesh-placement places no module here"
return false, "the seat mesh-module-assigner places no module here"
}
for _, m := range p.Modules {
h, ok, err := has(m.Module)
@@ -238,8 +238,8 @@ func allHave(p delivery.Placement, has func(module string) (delivery.Have, bool,
return false, m.Module + " has no have yet"
}
// A module the mesh provides (mesh-wireguard) never has a fingerprint: the node's own composes it from the manifest
// mesh-sdk provides (delivery.ProvidedManifest), never from the kept declaration, so it keeps no read.
if h.Phase != delivery.PhaseRemoved && !strings.HasPrefix(h.FP, delivery.SourcePrefix2) && !delivery.ProvidedByMesh(m.Module) {
// mesh-sdk provides (overlay.ProvidedManifest), never from the kept declaration, so it keeps no read.
if h.Phase != nodeengineclient.PhaseRemoved && !strings.HasPrefix(h.Fp, meshcatalogclient.SourcePrefix2) && !overlay.ProvidedByMesh(m.Module) {
return false, m.Module + " runs at a build of the old form (" + h.Build + "), which only the kept declaration records"
}
}
@@ -259,11 +259,11 @@ func certifyThisNode(ctx context.Context, state, node string, trust *meshTrust,
if err != nil || len(key) != ed25519.PublicKeySize {
return fmt.Errorf("this node's serving key is not an ed25519 key")
}
raw, err := ask(ctx, delivery.SignVerb, map[string]any{"kind": delivery.KindNodeCertificate, "node": node})
raw, err := ask(ctx, meshvault.VerbSign, map[string]any{"kind": meshvaultclient.KindNodeCertificate, "node": node})
if err != nil {
return err
}
var a delivery.SignAnswer
var a meshvault.SignAnswer
if err := json.Unmarshal(raw, &a); err != nil {
return fmt.Errorf("the vault's answer is not a signing: %w", err)
}
@@ -271,7 +271,7 @@ func certifyThisNode(ctx context.Context, state, node string, trust *meshTrust,
if trusted == "" {
return fmt.Errorf("this node has read no authority of the vault's to check the certificate against")
}
if err := delivery.CheckNodeCertificate(a.Certificate, []string{trusted}, overlay.InternalName(node),
if err := meshvaultclient.CheckNodeCertificate(a.Certificate, []string{trusted}, overlay.InternalName(node),
ed25519.PublicKey(key), time.Now()); err != nil {
return err
}
+4 -3
View File
@@ -5,11 +5,12 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate"
engcomp "github.com/novox/mesh-host/internal/compositions"
"os"
"path/filepath"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/seal"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/identity"
@@ -25,7 +26,7 @@ import (
// userListFollower is the follower of the bus's user list for one link: the node's composition bucket, the keys the
// agent trusts, the write alone, and the have.
func userListFollower(opts options, session *link.Session, running func() []delivery.Composed,
func userListFollower(opts options, session *link.Session, running func() []engcomp.Composed,
say link.Announce, wrote func()) *userlist.Follower {
return userlist.New(userlist.Env{
Node: session.Membership.Node,
@@ -43,7 +44,7 @@ func userListFollower(opts options, session *link.Session, running func() []deli
},
// From take-over, the grants part's list (ADR 0547 clauses 7, 8): its one key watched, its object read by name and
// opened with this machine's sealing key, the key's public half said in the have so the grants part seals to it.
Grants: bucketWatch{session, delivery.GrantsUserListBucket},
Grants: bucketWatch{session, grantsstate.GrantsUserListBucket},
Fetch: func(ctx context.Context, name string) ([]byte, error) {
return session.GrantsObject(ctx, name, grantsListLimit)
},
+95 -25
View File
@@ -5,16 +5,22 @@ import (
"crypto/ed25519"
"encoding/json"
"errors"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
engcomp "github.com/novox/mesh-host/internal/compositions"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/overlay"
"github.com/novox/mesh-host/internal/agent"
"github.com/novox/mesh-host/internal/filling"
"github.com/novox/mesh-host/internal/liveness"
"github.com/novox/mesh-host/internal/store"
)
@@ -31,7 +37,7 @@ func aFileOf(module, id string) json.RawMessage {
func TestWhatRunsIsTheAgentsCompositionsAndTheKeptRest(t *testing.T) {
kept := &declaration.Declaration{Identities: []declaration.Identity{{Module: "db", Name: "mesh-db"}},
Libraries: []declaration.Library{{Library: "media"}}}
everything := []delivery.Composed{
everything := []engcomp.Composed{
{Node: "n1", Module: "web", Resources: []json.RawMessage{aFileOf("web", "a")}},
{Node: "n1", Module: "db", Resources: []json.RawMessage{aFileOf("db", "a")}}, // still the kept declaration's
}
@@ -45,7 +51,7 @@ func TestWhatRunsIsTheAgentsCompositionsAndTheKeptRest(t *testing.T) {
}
// Once the agent holds the node's composition, its parts are the node's.
node := delivery.Composed{Node: "n1", Own: delivery.NodeOwn{Identities: []declaration.Identity{{Module: "web", Name: "mesh-web"}}}}
node := engcomp.Composed{Node: "n1", Own: engcomp.NodeOwn{Identities: []declaration.Identity{{Module: "web", Name: "mesh-web"}}}}
d, err = whatRuns(append(everything, node), true, func() (*declaration.Declaration, error) { return nil, errors.New("not read") })
if err != nil {
t.Fatal(err)
@@ -57,6 +63,7 @@ func TestWhatRunsIsTheAgentsCompositionsAndTheKeptRest(t *testing.T) {
// What the take act, the judgement of directories and the filling read is what runs, not the frozen kept declaration.
func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
t.Run("a fill while the agent restarts", aFillWhileTheAgentRestartsIsWrittenAfterwards)
setRunsHere(func(string, ed25519.PublicKey) (*declaration.Declaration, error) {
return &declaration.Declaration{Version: 1, Resources: nil, Provisioner: "from-agent"}, nil
})
@@ -88,7 +95,7 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
for _, name := range row.runs {
raws = append(raws, process(name))
}
runs, err := whatRuns([]delivery.Composed{{Node: "n1", Module: "restic", Resources: raws}}, false,
runs, err := whatRuns([]engcomp.Composed{{Node: "n1", Module: "restic", Resources: raws}}, false,
func() (*declaration.Declaration, error) { return nil, errors.New("none kept") })
if err != nil {
t.Fatal(err)
@@ -116,27 +123,27 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
if d, err := keptDeclaration(state, nil); err != nil || len(d.Resources) != 1 {
t.Fatalf("before the read is retired: %+v, %v", d, err)
}
placed := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "zsh"}, {Module: "lemurs"}}}
placed := meshmoduleassigner.Assignments{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "zsh"}, {Module: "lemurs"}}}
// Issue 774, 2026-10-30 19:43: a have naming only a build of the old form (systemd-resolved on shanks, folded in the
// node's own) keeps the kept declaration read, since it is the one record of what runs of it.
newForm := delivery.Have{FP: delivery.SourcePrefix2 + strings.Repeat("a", 64), Phase: delivery.PhaseRunning}
oldForm := delivery.Have{Build: "build-1791404191702719760", Phase: delivery.PhaseRunning}
newForm := nodeengine.HaveByNodeModuleValue{Fp: meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64), Phase: nodeengineclient.PhaseRunning}
oldForm := nodeengine.HaveByNodeModuleValue{Build: "build-1791404191702719760", Phase: nodeengineclient.PhaseRunning}
for _, row := range []struct {
name string
has map[string]delivery.Have
has map[string]nodeengine.HaveByNodeModuleValue
want bool
}{
{"every placed module has a have of the new form", map[string]delivery.Have{"zsh": newForm, "lemurs": newForm}, true},
{"one has none yet", map[string]delivery.Have{"zsh": newForm}, false},
{"one runs at a build of the old form", map[string]delivery.Have{"zsh": newForm, "lemurs": oldForm}, false},
{"every placed module has a have of the new form", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm, "lemurs": newForm}, true},
{"one has none yet", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm}, false},
{"one runs at a build of the old form", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm, "lemurs": oldForm}, false},
} {
if got, why := allHave(placed, func(m string) (delivery.Have, bool, error) { h, ok := row.has[m]; return h, ok, nil }); got != row.want {
if got, why := allHave(placed, func(m string) (nodeengine.HaveByNodeModuleValue, bool, error) { h, ok := row.has[m]; return h, ok, nil }); got != row.want {
t.Errorf("%s: %v (%s)", row.name, got, why)
}
}
// The module the mesh provides never has a fingerprint, and keeps no read (review of issue 774).
withWireguard := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "zsh"}, {Module: overlay.Name}}}
if got, why := allHave(withWireguard, func(m string) (delivery.Have, bool, error) {
withWireguard := meshmoduleassigner.Assignments{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "zsh"}, {Module: overlay.Name}}}
if got, why := allHave(withWireguard, func(m string) (nodeengine.HaveByNodeModuleValue, bool, error) {
if m == overlay.Name {
return oldForm, true, nil
}
@@ -144,7 +151,7 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
}); !got {
t.Errorf("%s without a fingerprint beside zsh at the new form keeps the read: %s", overlay.Name, why)
}
if got, _ := allHave(delivery.Placement{Node: "n1"}, nil); got {
if got, _ := allHave(meshmoduleassigner.Assignments{Node: "n1"}, nil); got {
t.Error("a node with nothing placed retires the read")
}
keptRetired.Store(true)
@@ -161,9 +168,9 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
// the module's: the declaration joined of both names each file once, so the filling writes it; the node's own resources
// no module declares stay. (Live from 2026-10-25 12:43: "2 resources declare X" on every node.)
func TestTheNodesOwnCopyOfAModulesResourceIsLeftOut(t *testing.T) {
own := delivery.Composed{Node: "n1", Resources: []json.RawMessage{aFileOf("zsh", "rc"), aFileOf("mesh-wireguard", "conf")},
own := engcomp.Composed{Node: "n1", Resources: []json.RawMessage{aFileOf("zsh", "rc"), aFileOf("mesh-wireguard", "conf")},
Filled: []string{"/tmp/r6/zsh/rc"}}
zsh := delivery.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")},
zsh := engcomp.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")},
Filled: []string{"/tmp/r6/zsh/rc"}}
d, err := declarationOf(own, zsh)
if err != nil {
@@ -181,7 +188,7 @@ func TestTheNodesOwnCopyOfAModulesResourceIsLeftOut(t *testing.T) {
}
// The same at the node's own apply: what the others declare is theirs.
trimmed, dropped := ownWithout(own, []delivery.Composed{zsh})
trimmed, dropped := ownWithout(own, []engcomp.Composed{zsh})
if len(trimmed.Resources) != 1 || len(dropped) != 1 || dropped[0] != "zsh.rc (zsh)" {
t.Errorf("the node's own without zsh's: %d resources, dropped %v", len(trimmed.Resources), dropped)
}
@@ -211,9 +218,9 @@ func TestTheKeptDeclarationsFilledPathsGoToTheirModules(t *testing.T) {
strings.Join(filled[agent.NodeModule], ",") != "/etc/motd" || len(filled["zsh"]) != 0 {
t.Fatalf("filled by module: %v", filled)
}
cc := delivery.Composed{Node: "n1", Module: "claude-code", Resources: by["claude-code"], Filled: filled["claude-code"]}
node := delivery.Composed{Node: "n1"}
d, err := whatRuns([]delivery.Composed{cc, node}, true, func() (*declaration.Declaration, error) { return nil, errors.New("not read") })
cc := engcomp.Composed{Node: "n1", Module: "claude-code", Resources: by["claude-code"], Filled: filled["claude-code"]}
node := engcomp.Composed{Node: "n1"}
d, err := whatRuns([]engcomp.Composed{cc, node}, true, func() (*declaration.Declaration, error) { return nil, errors.New("not read") })
if err != nil || !d.Fills("/etc/cc.json") {
t.Fatalf("what runs does not name claude-code's file filled: %v (%v)", d, err)
}
@@ -226,10 +233,73 @@ func TestTheKeptDeclarationsFilledPathsGoToTheirModules(t *testing.T) {
// unit or a container of the same name) is not the module's, and is neither dropped nor left to be removed.
func TestASharedTargetWithAnotherIDIsNotDropped(t *testing.T) {
ownFile, _ := json.Marshal(map[string]any{"id": "host.zshrc", "type": "file", "path": "/tmp/r6/zsh/rc", "content": "x"})
own := delivery.Composed{Node: "n1", Resources: []json.RawMessage{ownFile}}
zsh := delivery.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")}}
trimmed, dropped := ownWithout(own, []delivery.Composed{zsh})
own := engcomp.Composed{Node: "n1", Resources: []json.RawMessage{ownFile}}
zsh := engcomp.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")}}
trimmed, dropped := ownWithout(own, []engcomp.Composed{zsh})
if len(dropped) != 0 || len(trimmed.Resources) != 1 {
t.Errorf("a resource sharing only its target was dropped: %v", dropped)
}
}
// Issue 924: the bus link drops, the agent restarts, and the filling is asked while it holds nothing yet. What runs is
// then the last view the node held, never the retired kept declaration; and a fill refused for want of any is owed and
// told once the agent holds the node again, so every file is written afterwards.
func aFillWhileTheAgentRestartsIsWrittenAfterwards(t *testing.T) {
keptRetired.Store(true)
defer keptRetired.Store(false)
node := engcomp.Composed{Node: "n1", Own: engcomp.NodeOwn{Provisioner: "mesh-node-provisioner"}}
files := []engcomp.Composed{{Node: "n1", Module: "db", Resources: []json.RawMessage{aFileOf("db", "a")}, Filled: []string{"/tmp/r6/db/a"}}}
view := &runsView{everything: append(files, node), holdsNode: true}
var placed []string
// The agent holds the node, then the link drops and it stops: set(nil) keeps its view.
holding := true
current := &agentNow{view: func(*agent.Agent) *runsView {
if holding {
return view
}
return &runsView{}
}}
current.set(agent.New(agent.Env{}))
e := &filling.Engine{
Declared: func() (*declaration.Declaration, error) { return runsFromAgent(current)("/nowhere", nil) },
UIDOf: func(string) (uint32, error) { return 4242, nil },
Lock: func() (func(), error) { return func() {}, nil },
Place: func(_ context.Context, _ *declaration.Declaration, path string, _ []byte) (string, string, error) {
placed = append(placed, path)
return "updated", "d", nil
},
}
fill := func() filling.Answer {
return e.Write(context.Background(), 4242, filling.Request{Path: "/tmp/r6/db/a", Content: []byte("x")})
}
socket := filepath.Join(t.TempDir(), "sock")
if err := os.WriteFile(socket, nil, 0o600); err != nil {
t.Fatal(err)
}
// The next agent has read no haves and the previous one's view is gone (a cold start): refused, and owed.
cold := &agentNow{}
cold.set(agent.New(agent.Env{}))
cold.set(nil)
e.Declared = func() (*declaration.Declaration, error) { return runsFromAgent(cold)("/nowhere", nil) }
if a := fill(); a.Failed == "" && a.Refused == "" {
t.Fatalf("a fill with nothing read was accepted: %+v", a)
}
// The link drops: set(nil) keeps the view the agent held, and the fill during the restart is written.
e.Declared = func() (*declaration.Declaration, error) { return runsFromAgent(current)("/nowhere", nil) }
current.set(nil)
if told, err := e.Knock(socket); err != nil || told == "" {
t.Fatalf("the refused fill was not owed and told: %q, %v", told, err)
}
if a := fill(); a.Failed != "" || a.Refused != "" || len(placed) != 1 {
t.Fatalf("the fill during the restart was not written: %+v, placed %v", a, placed)
}
// The new agent holds the node: its own view answers and the kept one is dropped.
next := agent.New(agent.Env{})
current.set(next)
holding = true
if _, err := runsFromAgent(current)("/nowhere", nil); err != nil || current.lastView() != nil {
t.Fatalf("the kept view stays after the new agent holds the node: %v, %v", err, current.lastView())
}
}
+33 -5
View File
@@ -3,7 +3,23 @@ module github.com/novox/mesh-host
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0
@@ -11,12 +27,24 @@ require (
)
require (
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/klauspost/compress v1.20.1 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/jwt/v2 v2.8.2 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/time v0.16.0 // indirect
)
require (
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-networking/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork v0.0.0-00010101000000-000000000000 // indirect
)
+8 -10
View File
@@ -1,15 +1,13 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
@@ -23,5 +21,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
+320 -118
View File
@@ -17,15 +17,19 @@ import (
"context"
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
"github.com/novox/mesh-host/internal/compositions"
"slices"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/judge"
)
@@ -59,15 +63,18 @@ type Have struct {
// waits for a person's yes (ADR 0547 §3).
Judgement string
Waits string
// BuildAlone says, in phase waiting-yes, the move changes the build alone (novox/hq ADR 0601 §1, delivery.Have.BuildAlone).
// BuildAlone says, in phase waiting-yes, the move changes the build alone (novox/hq ADR 0601 §1, nodeengine.HaveByNodeModuleValue.BuildAlone).
BuildAlone bool
// Kept says, in words, what the node keeps of the module as it runs because its manifest does not yet say it (ADR 0601
// §4, delivery.Have.Kept): the watchman raises a condition from it.
// §4, nodeengine.HaveByNodeModuleValue.Kept): the watchman raises a condition from it.
Kept []string
// Sealed is, on a node composing its own, each value the composition it names carries sealed to the node's key, by
// slot (delivery.Have.SealedAt): kept in the have so the composer reuses them after a restart (ADR 0547 §3), each at
// slot (nodeengine.HaveByNodeModuleValue.SealedAt): kept in the have so the composer reuses them after a restart (ADR 0547 §3), each at
// its own slot alone (issue 1126, C1).
Sealed delivery.Seals
Sealed compositions.Seals
// PullWait is, on a failed have, the pull from a store its apply failed on and waits on the holder of (issue 1966
// ENG-9, nodeengine.HaveByNodeModuleValue.PullWait): read through the have, as node_haves shows it.
PullWait *nodeengine.PullWait
}
// Haves is the node's have bucket as the agent uses it.
@@ -82,6 +89,9 @@ type Haves interface {
// Result is how one apply or removal came out.
type Result struct {
// Pulls are the images the apply could not find or pull, each with the seat it came through and that seat's holder
// where the declaration names them (a store's image, ENG-10): what a failed apply waits on (ENG-8).
Pulls []PullFailure
Failed bool
// Transient says the failure was the bus not answering for a moment (no responders, a timeout, a reconnect, a
// JetStream API error while the bus reloads), not the module's: the apply is tried again without a new revision.
@@ -89,6 +99,14 @@ type Result struct {
Detail string
}
// PullFailure is one image an apply could not find or pull. Holder is the module holding the seat the image came through,
// "" for an image no store holds (a public registry's): its pull is tried again only by the changes that always start an
// apply. Started is when the pull began.
type PullFailure struct {
Image, Seat, Holder string
Started time.Time
}
// Backup is the answer of the backup asked before a module moves.
// BackupInterrupted is the words of a backup the node agent itself stopped waiting for (its context ended): no failure, so
// the next start backs the module up again rather than holding it for the operator.
@@ -110,9 +128,9 @@ type Env struct {
// Apply applies one module's composition (scope NodeModule for the node's own), given the compositions the node's
// other modules run so a path, unit or group they hold is not removed or taken. It is called with one module at a
// time.
Apply func(ctx context.Context, c delivery.Composed, others []delivery.Composed) Result
Apply func(ctx context.Context, c compositions.Composed, others []compositions.Composed) Result
// Remove removes the module's own resources and nothing of another's.
Remove func(ctx context.Context, module string, others []delivery.Composed) Result
Remove func(ctx context.Context, module string, others []compositions.Composed) Result
// Backup backs a module up before a new composition of it is applied; nil backs up nothing.
Backup func(ctx context.Context, module string) Backup
// Kept is the resources the node last applied, by module, from the declaration it kept before the switch (ADR 0539
@@ -127,12 +145,12 @@ type Env struct {
KeptFilled func() map[string][]string
// Recorded says the engine holds a record of every resource of the composition, so it was applied and not removed
// since; nil says yes. A composition the kept declaration or a have says runs is applied again when it says no.
Recorded func(ctx context.Context, c delivery.Composed) bool
Recorded func(ctx context.Context, c compositions.Composed) bool
// Inactive says a resource the composition declares running is not active on the machine now (a unit stopped, a
// container not running), read from the machine and not from any record (ADR 0568 §1). A have or a kept declaration
// that says the module runs is believed at the agent's start only when this says no; nil says no, and an unreadable
// machine says no (unknown is not inactive).
Inactive func(ctx context.Context, c delivery.Composed) bool
Inactive func(ctx context.Context, c compositions.Composed) bool
// Revisions are a composition key's revisions the bucket keeps, oldest first; nil reads none. The composition a want
// names is taken from them when a newer one replaced it under the same key (ADR 0539 clause 1).
Revisions func(ctx context.Context, key string) ([]Update, error)
@@ -150,6 +168,9 @@ type Env struct {
// client to fill again what it refused (issue 924, the second meeting: ace, g14 and shanks on 2026-10-29 at 00:09
// UTC refused power's three new drop-ins from an engine start on, as no module settled after it). Nil tells nothing.
RunsChanged func(why string)
// Now is the clock the agent compares a pull's start with the time it heard a store's holder settled by; nil is
// time.Now.
Now func() time.Time
// Backoff is how long to wait before the try-th retry (1, 2, …) of an apply that failed transiently. Nil retries a
// transient failure only on RetryTransient or a change of either key.
Backoff func(try int) time.Duration
@@ -201,7 +222,7 @@ type Agent struct {
mu sync.Mutex
wants map[string]wantNow // by module
comps map[string]compNow // by composed key
running map[string]delivery.Have
running map[string]nodeengine.HaveByNodeModuleValue
// applied is the composition each module was last applied from: a revision a want named from the key's history is
// not the key's latest value, and what it holds is still held out of the others' applies.
applied map[string]compNow
@@ -232,11 +253,21 @@ type Agent struct {
failedAgain chan string
// tries counts the transient failures in a row of each module; again carries a retry whose wait ran out, and
// transient asks for every transiently failed module at once. stopped is closed when Run returns.
tries map[string]int
gen uint64
again chan againAsk
transient chan struct{}
stopped chan struct{}
tries map[string]int
// pulls is, for each module whose last apply failed on a pull from a store, what it waits on (ENG-8); settledAt and
// settledSeq are, by the module holding a store, when its settled was last heard since this agent started and how many
// have been. Under mu.
pulls map[string]*pullWait
settledAt map[string]time.Time
settledSeq map[string]int
gen uint64
again chan againAsk
transient chan struct{}
stopped chan struct{}
// quiet is asked by waitIdle: the loop closes the channel it is given once nothing waits to be taken in or applied.
quiet chan chan struct{}
// armed counts the backoff waits running: the loop is not idle while one may still ask an apply again.
armed atomic.Int32
}
type againAsk struct {
@@ -245,7 +276,7 @@ type againAsk struct {
}
type wantNow struct {
Want delivery.Want
Want meshwants.WantsByWantNodeModuleValue
Node bool // the node's own want
Present bool
Rev uint64
@@ -253,7 +284,7 @@ type wantNow struct {
type compNow struct {
Raw []byte
C delivery.Composed
C compositions.Composed
Dig string
Rev uint64
Err error
@@ -274,10 +305,11 @@ func New(env Env) *Agent {
if env.Say == nil {
env.Say = func(string) {}
}
return &Agent{env: env, wants: map[string]wantNow{}, comps: map[string]compNow{}, running: map[string]delivery.Have{}, applied: map[string]compNow{}, had: map[string]compNow{}, keptData: map[string]bool{},
return &Agent{env: env, wants: map[string]wantNow{}, comps: map[string]compNow{}, running: map[string]nodeengine.HaveByNodeModuleValue{}, applied: map[string]compNow{}, had: map[string]compNow{}, keptData: map[string]bool{},
attempt: map[string]attempt{}, checked: map[string]bool{}, prior: map[string]bool{}, said: map[string]string{}, notComposed: map[string]notComposedMark{},
pulls: map[string]*pullWait{}, settledAt: map[string]time.Time{}, settledSeq: map[string]int{},
retry: make(chan []string, 8), reconcile: make(chan []string, 64), failedAgain: make(chan string, 8), tries: map[string]int{}, again: make(chan againAsk, 64),
transient: make(chan struct{}, 1), stopped: make(chan struct{})}
transient: make(chan struct{}, 1), stopped: make(chan struct{}), quiet: make(chan chan struct{})}
}
// Run follows the wants until ctx ends. The keys there when it starts are taken first, the node's own composition
@@ -331,6 +363,19 @@ func (a *Agent) Run(ctx context.Context) error {
dirty[m] = true
}
a.mu.Unlock()
case done := <-a.quiet:
// Idle only when every change already sent is taken in and applied; asked again otherwise, after the rest.
if len(dirty) == 0 && len(a.retry)+len(a.reconcile)+len(a.failedAgain)+len(a.again)+len(a.transient)+len(wants)+len(comps) == 0 &&
a.armed.Load() == 0 {
close(done)
} else {
go func() {
select {
case a.quiet <- done:
case <-a.stopped:
}
}()
}
case ask := <-a.again:
// The wait of a transiently failed apply ran out: tried again from the keys it has, if nothing tried it since.
a.mu.Lock()
@@ -389,6 +434,27 @@ func (a *Agent) Run(ctx context.Context) error {
}
}
// waitIdle returns once the loop has taken in and applied every change sent to it before the call (a want or composition
// put, a settled heard, a retry asked), with no backoff wait still running, so a caller can count what was applied with no clock; false when ctx ends first.
func (a *Agent) waitIdle(ctx context.Context) bool {
done := make(chan struct{})
select {
case a.quiet <- done:
case <-ctx.Done():
return false
case <-a.stopped:
return false
}
select {
case <-done:
return true
case <-ctx.Done():
return false
case <-a.stopped:
return false
}
}
// Retry asks for the modules to be applied again from the keys they have, though nothing changed: what ADR 0360 §11
// needs for a container that waits because it said busy. It never blocks; a retry asked while eight wait is dropped, and
// asked again by its caller's next look.
@@ -431,7 +497,7 @@ const NotComposedDetail = "the node cannot compose it, and keeps running what it
// composerMark says h is a mark NotComposed wrote: failed, its detail opening with NotComposedDetail.
func composerMark(h Have) bool {
return h.Phase == string(delivery.PhaseFailed) && strings.HasPrefix(h.Detail, NotComposedDetail)
return h.Phase == string(nodeengineclient.PhaseFailed) && strings.HasPrefix(h.Detail, NotComposedDetail)
}
// HavesReadErr is what a Haves may answer besides Read: a have absent (false, nil) told from one that could not be read
@@ -526,7 +592,7 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string
a.mu.Unlock()
back := mark.prior
if ran {
back = Have{Module: module, FP: r.FP, Composed: r.Composed, Phase: string(delivery.PhaseRunning)}
back = Have{Module: module, FP: r.Fp, Composed: r.Composed, Phase: string(nodeengineclient.PhaseRunning)}
if r.Phase != "" {
back.Phase = string(r.Phase)
}
@@ -539,9 +605,9 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string
}
if !marked {
if ran {
mark.prior = Have{Module: module, FP: r.FP, Composed: r.Composed, Phase: string(r.Phase)}
mark.prior = Have{Module: module, FP: r.Fp, Composed: r.Composed, Phase: string(r.Phase)}
if mark.prior.Phase == "" {
mark.prior.Phase = string(delivery.PhaseRunning)
mark.prior.Phase = string(nodeengineclient.PhaseRunning)
}
} else if h, found, unread := a.readHave(ctx, module); found {
mark.prior = h // what its last have said runs: this agent has run nothing of it since its start
@@ -560,7 +626,7 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string
// A have that already says another cause (an apply that failed, a backup held, an apply under way) keeps it: a
// composer's mark over it would hide that cause, and baseline would take the mark for what runs (review of the
// switch, round 1).
if p := mark.prior.Phase; !marked && p != "" && p != string(delivery.PhaseRunning) && !composerMark(mark.prior) {
if p := mark.prior.Phase; !marked && p != "" && p != string(nodeengineclient.PhaseRunning) && !composerMark(mark.prior) {
a.mu.Lock()
a.sayOnce("unmarked-cause:"+module, fmt.Sprintf("%s is not marked: its have says %s (%s), and the node cannot compose it: %s",
module, p, mark.prior.Detail, why))
@@ -575,7 +641,7 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string
a.notComposed[module] = mark
a.mu.Unlock()
a.writeHave(ctx, Have{Module: module, FP: mark.prior.FP, Composed: mark.prior.Composed, Sealed: mark.prior.Sealed,
Phase: string(delivery.PhaseFailed), Detail: NotComposedDetail + why})
Phase: string(nodeengineclient.PhaseFailed), Detail: NotComposedDetail + why})
}
}
@@ -632,22 +698,22 @@ func (a *Agent) KeptNow(ctx context.Context, kept map[string][]string) {
a.mu.Unlock()
slices.Sort(changed)
for _, m := range changed {
if h, ok := a.env.Haves.Read(ctx, m); ok && m != NodeModule && h.Phase != string(delivery.PhaseWaitingYes) {
if h, ok := a.env.Haves.Read(ctx, m); ok && m != NodeModule && h.Phase != string(nodeengineclient.PhaseWaitingYes) {
h.Module = m
a.writeHave(ctx, h)
}
}
}
func (a *Agent) sealedOf(ctx context.Context, module, fp, composed string) delivery.Seals {
func (a *Agent) sealedOf(ctx context.Context, module, fp, composed string) compositions.Seals {
a.mu.Lock()
if c, ok := a.applied[module]; ok && c.Err == nil && c.Dig == composed {
a.mu.Unlock()
return delivery.SealsIn(c.C)
return compositions.SealsIn(c.C)
}
if c, ok := a.compositionFor(module, fp); ok && c.Err == nil && c.Dig == composed {
a.mu.Unlock()
return delivery.SealsIn(c.C)
return compositions.SealsIn(c.C)
}
a.mu.Unlock()
if h, ok := a.env.Haves.Read(ctx, module); ok && h.Composed == composed {
@@ -658,7 +724,7 @@ func (a *Agent) sealedOf(ctx context.Context, module, fp, composed string) deliv
// Running is the compositions the node runs now: each module's that the agent applied or found already running, and
// the node's own. What the node judges its health by and says of itself is built from them.
func (a *Agent) Running() []delivery.Composed {
func (a *Agent) Running() []compositions.Composed {
a.mu.Lock()
defer a.mu.Unlock()
mods := make([]string, 0, len(a.running))
@@ -666,13 +732,13 @@ func (a *Agent) Running() []delivery.Composed {
mods = append(mods, m)
}
sort.Strings(mods)
var out []delivery.Composed
var out []compositions.Composed
for _, m := range mods {
h := a.running[m]
if h.Phase == delivery.PhaseRemoved {
if h.Phase == nodeengineclient.PhaseRemoved {
continue
}
if c, ok := a.compositionFor(m, h.FP); ok && c.Err == nil && c.Dig == h.Composed {
if c, ok := a.compositionFor(m, h.Fp); ok && c.Err == nil && c.Dig == h.Composed {
out = append(out, c.C)
} else if c, ok := a.applied[m]; ok && c.Dig == h.Composed {
out = append(out, c.C)
@@ -685,26 +751,26 @@ func (a *Agent) Running() []delivery.Composed {
func (a *Agent) takeWant(u Update) string {
a.mu.Lock()
defer a.mu.Unlock()
if _, err := delivery.ParseWantNodeKey(u.Key); err == nil {
if _, ok := meshwants.ParseWantsByWantNodeNodeKey(u.Key); ok {
w := wantNow{Node: true, Rev: u.Revision}
if !u.Deleted {
nw, err := delivery.DecodeNodeWant(u.Value)
nw, err := meshwants.DecodeWantsByWantNodeNodeValue(u.Value)
if err != nil {
a.sayOnce("want:node", fmt.Sprintf("the node's own want is not one: %v", err))
return ""
}
w.Present, w.Want = true, delivery.Want{FP: "", Composed: nw.Composed}
w.Present, w.Want = true, meshwants.WantsByWantNodeModuleValue{Fp: "", Composed: nw.Composed}
}
a.wants[NodeModule] = w
return NodeModule
}
node, module, err := delivery.ParseWantKey(u.Key)
if err != nil || node != a.env.Node {
node, module, ok := meshwants.ParseWantsByWantNodeModuleKey(u.Key)
if !ok || node != a.env.Node {
return ""
}
w := wantNow{Rev: u.Revision}
if !u.Deleted {
want, err := delivery.DecodeWant(u.Value)
want, err := meshwants.DecodeWantsByWantNodeModuleValue(u.Value)
if err != nil {
a.sayOnce("want:"+module, fmt.Sprintf("the want of %s is not one: %v", module, err))
return ""
@@ -717,7 +783,7 @@ func (a *Agent) takeWant(u Update) string {
// takeComposition takes a change of a composition key and returns the module it concerns.
func (a *Agent) takeComposition(u Update) string {
node, module, _, isNode, err := delivery.ParseComposedKey(u.Key)
node, module, _, isNode, err := compositions.ParseKey(u.Key)
if err != nil || node != a.env.Node {
return ""
}
@@ -731,14 +797,14 @@ func (a *Agent) takeComposition(u Update) string {
return module
}
now := compNow{Raw: u.Value, Rev: u.Revision}
c, err := delivery.DecodeComposed(u.Value)
c, err := compositions.Decode(u.Value)
if err == nil {
// The node composed it itself: it is applied as composed, never verified against a signer, since no composition
// read from the bus is applied (novox/hq ADR 0547 §2, ADR 0593 §6).
now.C = c
now.Dig, err = delivery.ComposedDigestOf(c)
now.Dig, err = compositions.DigestOf(c)
if err != nil {
now.C = delivery.Composed{}
now.C = compositions.Composed{}
}
} else {
a.sayOnce("decode:"+u.Key, fmt.Sprintf("the composition %s cannot be read, and nothing of it is applied: %v", u.Key, err))
@@ -780,9 +846,9 @@ func (a *Agent) compositionFor(module, fp string) (compNow, bool) {
var key string
var err error
if module == NodeModule {
key, err = delivery.ComposedNodeKey(a.env.Node)
key, err = compositions.NodeKey(a.env.Node)
} else {
key, err = delivery.ComposedKey(a.env.Node, module, fp)
key, err = compositions.Key(a.env.Node, module, fp)
}
if err != nil {
return compNow{}, false
@@ -815,8 +881,8 @@ func (a *Agent) revisionNamed(ctx context.Context, module, fp, digest string) (c
// others are what the node's other modules run now, so what they hold is not taken: the compositions the agent runs and,
// for every module the agent does not hold yet, the resources the kept declaration has for it (ADR 0429 §4).
func (a *Agent) others(except string) []delivery.Composed {
var out []delivery.Composed
func (a *Agent) others(except string) []compositions.Composed {
var out []compositions.Composed
mods := make([]string, 0, len(a.running))
for m := range a.running {
if m != except {
@@ -826,10 +892,10 @@ func (a *Agent) others(except string) []delivery.Composed {
sort.Strings(mods)
for _, m := range mods {
h := a.running[m]
if h.Phase == delivery.PhaseRemoved {
if h.Phase == nodeengineclient.PhaseRemoved {
continue
}
if c, ok := a.compositionFor(m, h.FP); ok && c.Err == nil && c.Dig == h.Composed {
if c, ok := a.compositionFor(m, h.Fp); ok && c.Err == nil && c.Dig == h.Composed {
out = append(out, c.C)
} else if c, ok := a.applied[m]; ok && c.Dig == h.Composed {
out = append(out, c.C)
@@ -947,7 +1013,7 @@ func (a *Agent) loadApplied(ctx context.Context) {
if rs, ok := a.env.Kept()[NodeModule]; ok && len(rs) > 0 {
c, _ := a.keptComposed(NodeModule, rs)
c.Module = ""
raw, eerr := delivery.EncodeComposed(c)
raw, eerr := compositions.Encode(c)
if eerr == nil {
eerr = a.env.Applied.Keep(NodeModule, raw)
}
@@ -971,10 +1037,10 @@ func (a *Agent) loadApplied(ctx context.Context) {
if ctx.Err() != nil {
return
}
c, err := delivery.DecodeComposed(kept[m])
c, err := compositions.Decode(kept[m])
var dig string
if err == nil {
dig, err = delivery.ComposedDigestOf(c)
dig, err = compositions.DigestOf(c)
}
if err != nil {
a.env.Say(fmt.Sprintf("the composition this engine kept of %s cannot be read, so what runs of it is read from its have: %v", m, err))
@@ -1013,7 +1079,7 @@ func (a *Agent) keepApplied(module string, comp compNow) {
raw := comp.Raw
if len(raw) == 0 {
var err error
if raw, err = delivery.EncodeComposed(comp.C); err != nil {
if raw, err = compositions.Encode(comp.C); err != nil {
a.env.Say(fmt.Sprintf("the composition %s was applied from cannot be kept: %v", module, err))
return
}
@@ -1027,8 +1093,8 @@ func (a *Agent) keepApplied(module string, comp compNow) {
// backup, or a composer's mark over what still runs. A have backing up, applying, checking, failed or waiting names a
// composition not applied, or not yet (issue 924, review of 031e106).
func haveRuns(h Have) bool {
switch delivery.Phase(h.Phase) {
case "", delivery.PhaseRunning, delivery.PhaseHeld:
switch string(h.Phase) {
case "", nodeengineclient.PhaseRunning, nodeengineclient.PhaseHeld:
return true
}
return composerMark(h)
@@ -1062,7 +1128,7 @@ func (a *Agent) unreadableModules() []string {
}
current := map[string]pick{}
for key, c := range a.comps {
_, m, fp, isNode, err := delivery.ParseComposedKey(key)
_, m, fp, isNode, err := compositions.ParseKey(key)
if err != nil || isNode {
continue
}
@@ -1070,8 +1136,8 @@ func (a *Agent) unreadableModules() []string {
if w.Present && w.Want.Removed {
continue
}
if w.Present && w.Want.FP != "" {
if w.Want.FP == fp {
if w.Present && w.Want.Fp != "" {
if w.Want.Fp == fp {
current[m] = pick{key, c}
}
continue
@@ -1095,7 +1161,7 @@ func (a *Agent) unreadableModules() []string {
// until that one is applied, so the node's own apply never removes them in between (issue 1945, live on 2026-10-25:
// shanks lost systemd-resolved's guard while its composition waited for a credential). The composition its want names,
// else its newest. Not part of Everything: what has not been applied is not what runs.
func (a *Agent) heldOut(except string) []delivery.Composed {
func (a *Agent) heldOut(except string) []compositions.Composed {
out := a.others(except)
held := map[string]bool{}
for _, c := range out {
@@ -1103,21 +1169,21 @@ func (a *Agent) heldOut(except string) []delivery.Composed {
}
waiting := map[string]compNow{}
for key, c := range a.comps {
_, m, fp, isNode, err := delivery.ParseComposedKey(key)
_, m, fp, isNode, err := compositions.ParseKey(key)
if err != nil || isNode || c.Err != nil || m == except || held[m] {
continue
}
if h, ok := a.running[m]; ok && h.Phase != delivery.PhaseRemoved {
if h, ok := a.running[m]; ok && h.Phase != nodeengineclient.PhaseRemoved {
continue
}
if w := a.wants[m]; w.Present && w.Want.Removed {
continue // a module the assigner removed holds nothing back
}
if w := a.wants[m]; w.Present && !w.Want.Removed && w.Want.FP == fp {
if w := a.wants[m]; w.Present && !w.Want.Removed && w.Want.Fp == fp {
waiting[m] = c
continue
}
if w := a.wants[m]; w.Present && !w.Want.Removed && waiting[m].Rev != 0 && waiting[m].C.FP == w.Want.FP {
if w := a.wants[m]; w.Present && !w.Want.Removed && waiting[m].Rev != 0 && waiting[m].C.FP == w.Want.Fp {
continue
}
if prev, ok := waiting[m]; !ok || c.Rev > prev.Rev {
@@ -1139,7 +1205,7 @@ func (a *Agent) heldOut(except string) []delivery.Composed {
if len(a.heldAlone) == 0 {
return out
}
var own *delivery.Composed
var own *compositions.Composed
for _, c := range a.others("\x00") {
if c.Module == "" {
own = &c
@@ -1173,7 +1239,7 @@ func (a *Agent) heldOut(except string) []delivery.Composed {
}
if len(rs) > 0 {
// Held out, never judged: its Data from its manifest where known, as every other composition says it.
out = append(out, delivery.Composed{Node: a.env.Node, Module: m, Resources: rs, Data: a.keptData[m]})
out = append(out, compositions.Composed{Node: a.env.Node, Module: m, Resources: rs, Data: a.keptData[m]})
}
}
return out
@@ -1182,7 +1248,7 @@ func (a *Agent) heldOut(except string) []delivery.Composed {
// Everything is what the node runs: the compositions the agent runs, and the kept declaration's resources for each module
// the agent does not hold yet. What the node judges its health by, and what the filling, the take act and the judgement
// of directories read, is built from it.
func (a *Agent) Everything() []delivery.Composed {
func (a *Agent) Everything() []compositions.Composed {
a.mu.Lock()
defer a.mu.Unlock()
return a.others("\x00")
@@ -1217,7 +1283,7 @@ func (a *Agent) RetryFailed(module string) error {
return fmt.Errorf("%s has no want on this machine, so there is no apply of it to try again", module)
case tried && att.Held:
return fmt.Errorf("%s is held for a failed backup: the operator's answer releases it", module)
case !tried && ran && r.FP == w.Want.FP && r.Phase != delivery.PhaseRemoved:
case !tried && ran && r.Fp == w.Want.Fp && r.Phase != nodeengineclient.PhaseRemoved:
return fmt.Errorf("%s runs what its want names, nothing to retry", module)
case !tried:
return fmt.Errorf("%s has no failed apply to try again: it waits for its composition, or settles on its own", module)
@@ -1250,7 +1316,7 @@ func (a *Agent) HoldsNode() bool {
a.mu.Lock()
defer a.mu.Unlock()
h, ok := a.running[NodeModule]
return ok && h.Phase != delivery.PhaseRemoved
return ok && h.Phase != nodeengineclient.PhaseRemoved
}
func (a *Agent) settleOne(ctx context.Context, module string) {
@@ -1265,7 +1331,7 @@ func (a *Agent) settleOne(ctx context.Context, module string) {
return
}
a.mu.Lock()
comp, have := a.compositionFor(module, w.Want.FP)
comp, have := a.compositionFor(module, w.Want.Fp)
a.mu.Unlock()
if a.env.SelfComposed {
if !have || comp.Err != nil {
@@ -1279,7 +1345,7 @@ func (a *Agent) settleOne(ctx context.Context, module string) {
} else if (!have || comp.Err != nil || comp.Dig != w.Want.Composed) && w.Want.Composed != "" {
// The composition the want names may be an older revision of the key, replaced by a newer one at the same
// fingerprint (a want held there keeps its digest): taken from the revisions the bucket keeps (ADR 0539 clause 1).
if old, found := a.revisionNamed(ctx, module, w.Want.FP, w.Want.Composed); found {
if old, found := a.revisionNamed(ctx, module, w.Want.Fp, w.Want.Composed); found {
comp, have = old, true
}
}
@@ -1290,7 +1356,7 @@ func (a *Agent) settleOne(ctx context.Context, module string) {
// A composition whose digest no want names is not applied.
a.mu.Lock()
a.sayOnce("digest:"+module, fmt.Sprintf("the composition of %s at %s has digest %s, and its want names %s; nothing is applied until they agree",
module, w.Want.FP, comp.Dig, w.Want.Composed))
module, w.Want.Fp, comp.Dig, w.Want.Composed))
a.mu.Unlock()
return
}
@@ -1306,19 +1372,19 @@ func (a *Agent) settleOne(ctx context.Context, module string) {
}
a.mu.Lock()
r, runs := a.running[module]
runsWant := runs && r.FP == w.Want.FP && r.Composed == w.Want.Composed && r.Phase != delivery.PhaseRemoved
runsWant := runs && r.Fp == w.Want.Fp && r.Composed == w.Want.Composed && r.Phase != nodeengineclient.PhaseRemoved
att, tried := a.attempt[module]
same := tried && att.FP == w.Want.FP && att.Composed == w.Want.Composed
same := tried && att.FP == w.Want.Fp && att.Composed == w.Want.Composed
if same && att.Held {
// Held for a failed backup: looked at before what runs, since a hold may name what the want names (its have kept
// the composition it held, nats on novox, 2026-11-04), and an answer in a later revision of the want would then
// be swallowed by "it runs what its want names" (issue 1585, the second failure). The operator's answer, again or
// skip, in a revision after the one the hold took, is acted on whatever runs; any other new revision of the want
// is the answer only for a move (ADR 0568 §4), as before.
answered := w.Rev > att.AnsweredRev && (w.Want.Backup == delivery.BackupAgain || w.Want.Backup == delivery.BackupSkip)
answered := w.Rev > att.AnsweredRev && (w.Want.Backup == meshwants.WantsByWantNodeModuleValueBackupAgain || w.Want.Backup == meshwants.WantsByWantNodeModuleValueBackupSkip)
if !answered && (runsWant || (att.AnsweredRev >= w.Rev && att.CompRev == comp.Rev)) {
a.sayOnce("held:"+module, fmt.Sprintf("%s is held for a failed backup before %s: it waits for the operator's "+
"answer, and its want (revision %d) carries none since the hold (revision %d)", module, w.Want.FP, w.Rev, att.AnsweredRev))
"answer, and its want (revision %d) carries none since the hold (revision %d)", module, w.Want.Fp, w.Rev, att.AnsweredRev))
a.mu.Unlock()
return
}
@@ -1381,7 +1447,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
a.mu.Lock()
a.prior[module] = true
a.mu.Unlock()
if h.Phase == string(delivery.PhaseHeld) && h.Detail == BackupInterrupted && w.Want.Backup != delivery.BackupSkip {
if h.Phase == string(nodeengineclient.PhaseHeld) && h.Detail == BackupInterrupted && w.Want.Backup != meshwants.WantsByWantNodeModuleValueBackupSkip {
// Held only because this node agent stopped while the backup ran (postgres on novox, 2026-10-31): no
// failure and nothing for the operator to answer. It is backed up again and applied (a.prior makes the
// apply back it up first); the want still asking a backup is all the answer needed.
@@ -1390,7 +1456,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
a.mu.Unlock()
return false
}
if h.Phase == string(delivery.PhaseHeld) && (w.Want.Backup == delivery.BackupAgain || w.Want.Backup == delivery.BackupSkip) {
if h.Phase == string(nodeengineclient.PhaseHeld) && (w.Want.Backup == meshwants.WantsByWantNodeModuleValueBackupAgain || w.Want.Backup == meshwants.WantsByWantNodeModuleValueBackupSkip) {
// Held for a failed backup, and the want already carries the operator's answer, written before this engine
// started (nats on novox, 2026-11-04: "Back up again" answered, then the engine restarted and took the
// answer for the hold's own revision, so it was never acted on; issue 1585). The answer is acted on now:
@@ -1403,7 +1469,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
a.mu.Unlock()
return false
}
if h.Phase == string(delivery.PhaseHeld) {
if h.Phase == string(nodeengineclient.PhaseHeld) {
// Held for a failed backup, and the operator's answer is still to come: the hold is kept, nothing is
// backed up again or applied, and the next revision of the want is the answer (ADR 0568 §4). Said, so
// why it holds is read from the journal and not guessed (issue 1585: this branch was silent).
@@ -1411,10 +1477,10 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
a.sayOnce("held:"+module, fmt.Sprintf("%s is held for a failed backup (%s) at this start: it keeps what "+
"its have names, %s, and waits for the operator's answer; its want (revision %d) carries none",
module, h.Detail, short(h.Composed), w.Rev))
a.attempt[module] = attempt{FP: w.Want.FP, Composed: w.Want.Composed, WantRev: w.Rev, CompRev: comp.Rev, Held: true,
a.attempt[module] = attempt{FP: w.Want.Fp, Composed: w.Want.Composed, WantRev: w.Rev, CompRev: comp.Rev, Held: true,
AnsweredRev: w.Rev}
if h.Composed != "" {
a.running[module] = delivery.Have{FP: h.FP, Composed: h.Composed, Phase: delivery.PhaseRunning}
a.running[module] = nodeengine.HaveByNodeModuleValue{Fp: h.FP, Composed: h.Composed, Phase: nodeengineclient.PhaseRunning}
}
a.mu.Unlock()
a.runsChanged(module + " was taken as held")
@@ -1430,19 +1496,19 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
if marked {
h.Detail = ""
}
if h.FP == w.Want.FP && h.Composed == w.Want.Composed &&
(h.Phase == "" || h.Phase == string(delivery.PhaseRunning) || marked) {
if h.FP == w.Want.Fp && h.Composed == w.Want.Composed &&
(h.Phase == "" || h.Phase == string(nodeengineclient.PhaseRunning) || marked) {
if a.inactiveAtStart(ctx, module, comp) {
return false
}
a.mu.Lock()
a.running[module] = delivery.Have{FP: h.FP, Composed: h.Composed, Phase: delivery.PhaseRunning}
a.running[module] = nodeengine.HaveByNodeModuleValue{Fp: h.FP, Composed: h.Composed, Phase: nodeengineclient.PhaseRunning}
a.mu.Unlock()
a.runsChanged(module + " was taken from its have")
// Said again, so the session holds the module and its key carries the health this engine judges,
// not the one an older engine left there (xorg's unknown on g14 and shanks, 2026-10-25).
a.writeHave(ctx, Have{Module: module, FP: h.FP, Composed: h.Composed,
Phase: string(delivery.PhaseRunning), Detail: h.Detail, Sealed: h.Sealed})
Phase: string(nodeengineclient.PhaseRunning), Detail: h.Detail, Sealed: h.Sealed})
return true
}
return false
@@ -1465,10 +1531,10 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
return false
}
a.mu.Lock()
a.running[module] = delivery.Have{FP: w.Want.FP, Composed: w.Want.Composed, Phase: delivery.PhaseRunning}
a.running[module] = nodeengine.HaveByNodeModuleValue{Fp: w.Want.Fp, Composed: w.Want.Composed, Phase: nodeengineclient.PhaseRunning}
a.mu.Unlock()
a.runsChanged(module + " was taken from the kept declaration")
a.writeHave(ctx, Have{Module: module, FP: w.Want.FP, Composed: w.Want.Composed, Phase: string(delivery.PhaseRunning)})
a.writeHave(ctx, Have{Module: module, FP: w.Want.Fp, Composed: w.Want.Composed, Phase: string(nodeengineclient.PhaseRunning)})
return true
}
@@ -1486,8 +1552,8 @@ func (a *Agent) inactiveAtStart(ctx context.Context, module string, comp compNow
return true
}
func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNow, others []delivery.Composed) {
fp, dig := w.Want.FP, w.Want.Composed
func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNow, others []compositions.Composed) {
fp, dig := w.Want.Fp, w.Want.Composed
a.mu.Lock()
_, ranBefore := a.running[module]
ranBefore = ranBefore || a.prior[module]
@@ -1495,7 +1561,7 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
// A data module is backed up before a new composition of it moves (ADR 0429 §14), unless the want says to go on
// without one (ADR 0539 §16).
if comp.C.Data && ranBefore && a.env.Backup != nil && w.Want.Backup != delivery.BackupSkip {
if comp.C.Data && ranBefore && a.env.Backup != nil && w.Want.Backup != meshwants.WantsByWantNodeModuleValueBackupSkip {
// What runs, read before the have says backing-up: a module known only from its have (a.prior) has no running
// entry, and its held have kept naming nothing (postgres on novox, 2026-10-31), as the not-composed mark keeps it.
a.mu.Lock()
@@ -1503,10 +1569,10 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
a.mu.Unlock()
if !runs {
if h, found, _ := a.readHave(ctx, module); found {
prev = delivery.Have{FP: h.FP, Composed: h.Composed}
prev = nodeengine.HaveByNodeModuleValue{Fp: h.FP, Composed: h.Composed}
}
}
a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseBackingUp)})
a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseBackingUp)})
if got := a.env.Backup(ctx, module); !got.Done {
words := got.Words
if got.Interrupted || ctx.Err() != nil {
@@ -1521,14 +1587,17 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
a.attempt[module] = attempt{FP: fp, Composed: dig, WantRev: w.Rev, CompRev: comp.Rev, Held: true, AnsweredRev: w.Rev}
a.mu.Unlock()
// It keeps running what it has: the have says what it holds and why it waits.
a.writeHave(ctx, Have{Module: module, FP: prev.FP, Composed: prev.Composed, Phase: string(delivery.PhaseHeld),
a.writeHave(ctx, Have{Module: module, FP: prev.Fp, Composed: prev.Composed, Phase: string(nodeengineclient.PhaseHeld),
Detail: words})
return
}
}
a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseApplying)})
a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseApplying)})
res := a.env.Apply(ctx, comp.C, others)
a.mu.Lock()
if !res.Failed || res.Transient {
delete(a.pulls, module)
}
what := fmt.Sprintf("%s at %s", module, fp)
if module == NodeModule {
what = "the node's own composition"
@@ -1540,11 +1609,13 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
gen := a.gen
a.attempt[module] = attempt{FP: fp, Composed: dig, WantRev: w.Rev, CompRev: comp.Rev, Transient: true, Gen: gen}
a.mu.Unlock()
phase, when := delivery.PhaseApplying, "when the bus answers again"
phase, when := nodeengineclient.PhaseApplying, "when the bus answers again"
if a.env.Backoff != nil {
wait := a.env.Backoff(try)
when = "in " + wait.String()
a.armed.Add(1)
time.AfterFunc(wait, func() {
defer a.armed.Add(-1)
select {
case a.again <- againAsk{module, gen}:
case <-a.stopped:
@@ -1553,7 +1624,7 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
})
}
if try > transientFor {
phase = delivery.PhaseFailed // still retried, and said as failed: no longer a moment
phase = nodeengineclient.PhaseFailed // still retried, and said as failed: no longer a moment
}
a.env.Say(fmt.Sprintf("applying %s failed because the bus did not answer (try %d), retried %s: %s", what, try, when, res.Detail))
a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(phase),
@@ -1563,21 +1634,32 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
delete(a.tries, module)
if res.Failed {
a.attempt[module] = attempt{FP: fp, Composed: dig, WantRev: w.Rev, CompRev: comp.Rev}
detail := res.Detail
again := a.notePullFailed(module, res.Pulls)
if words := a.pullWords(module); words != "" {
detail += words
}
wait := a.pullWaitOf(module)
a.mu.Unlock()
a.env.Say(fmt.Sprintf("applying %s failed: %s", what, res.Detail))
a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed), Detail: res.Detail})
a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseFailed), Detail: detail,
PullWait: wait})
if again != "" {
a.askPullAgain(module, again)
}
return
}
delete(a.pulls, module)
delete(a.attempt, module)
delete(a.notComposed, module) // the apply says what it runs now
a.running[module] = delivery.Have{FP: fp, Composed: dig, Phase: delivery.PhaseRunning}
a.running[module] = nodeengine.HaveByNodeModuleValue{Fp: fp, Composed: dig, Phase: nodeengineclient.PhaseRunning}
a.applied[module] = comp
a.mu.Unlock()
a.keepApplied(module, comp)
if a.env.Settled != nil {
a.env.Settled(module)
}
a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseRunning), Detail: res.Detail})
a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseRunning), Detail: res.Detail})
}
func (a *Agent) remove(ctx context.Context, module string, w wantNow) {
@@ -1588,7 +1670,7 @@ func (a *Agent) remove(ctx context.Context, module string, w wantNow) {
a.mu.Unlock()
return
}
if ran && r.Phase == delivery.PhaseRemoved {
if ran && r.Phase == nodeengineclient.PhaseRemoved {
a.mu.Unlock()
return
}
@@ -1603,8 +1685,9 @@ func (a *Agent) remove(ctx context.Context, module string, w wantNow) {
return
}
delete(a.attempt, module)
delete(a.pulls, module)
delete(a.had, module)
a.running[module] = delivery.Have{Phase: delivery.PhaseRemoved}
a.running[module] = nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseRemoved}
a.mu.Unlock()
if a.env.Applied != nil {
if err := a.env.Applied.Drop(module); err != nil {
@@ -1685,17 +1768,17 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp
a.readKeptData(ctx, module) // the manifest may read now though it did not at the start
a.mu.Lock()
r, ran := a.running[module]
if ran && r.Composed == comp.Dig && r.Phase != delivery.PhaseRemoved {
if ran && r.Composed == comp.Dig && r.Phase != nodeengineclient.PhaseRemoved {
a.mu.Unlock()
// Composed back to what runs: a have still waiting for a yes for a move since gone says running again, or it waits
// for a yes nobody needs (issue 774, 2026-10-31: four providers stayed waiting-yes once their consumers composed).
if h, ok := a.env.Haves.Read(ctx, module); ok && h.Phase == string(delivery.PhaseWaitingYes) {
if h, ok := a.env.Haves.Read(ctx, module); ok && h.Phase == string(nodeengineclient.PhaseWaitingYes) {
// Its sealed values only when they are of what runs; else writeHave reads them (sealedOf).
var sealed delivery.Seals
var sealed compositions.Seals
if h.Composed == r.Composed {
sealed = h.Sealed
}
a.writeHave(ctx, Have{Module: module, FP: r.FP, Composed: r.Composed, Phase: string(delivery.PhaseRunning), Sealed: sealed})
a.writeHave(ctx, Have{Module: module, FP: r.Fp, Composed: r.Composed, Phase: string(nodeengineclient.PhaseRunning), Sealed: sealed})
}
return false
}
@@ -1718,38 +1801,38 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp
case known:
j = judge.JudgeMove(&from, comp.C)
case prior:
j = judge.Judged{Judgement: delivery.JudgedCannot, Words: "what " + module + " runs on this node cannot be read"}
j = judge.Judged{Judgement: nodeengineclient.JudgedCannot, Words: "what " + module + " runs on this node cannot be read"}
default:
j = judge.JudgeMove(nil, comp.C)
}
switch j.Judgement {
case delivery.JudgedRecreates, delivery.JudgedMovesData, delivery.JudgedCannot:
case nodeengineclient.JudgedRecreates, nodeengineclient.JudgedMovesData, nodeengineclient.JudgedCannot:
default:
return false
}
a.mu.Lock()
a.sayOnce("yes:"+module+":"+comp.Dig, fmt.Sprintf("%s at %s waits for a person's yes: %s", module, w.Want.FP, j.Words))
a.sayOnce("yes:"+module+":"+comp.Dig, fmt.Sprintf("%s at %s waits for a person's yes: %s", module, w.Want.Fp, j.Words))
prev := a.running[module]
a.mu.Unlock()
a.writeHave(ctx, Have{Module: module, FP: prev.FP, Composed: prev.Composed, Phase: string(delivery.PhaseWaitingYes),
Detail: j.Words, Judgement: string(j.Judgement), Waits: comp.Dig, BuildAlone: a.buildAlone(ctx, module, prev, w.Want.FP)})
a.writeHave(ctx, Have{Module: module, FP: prev.Fp, Composed: prev.Composed, Phase: string(nodeengineclient.PhaseWaitingYes),
Detail: j.Words, Judgement: string(j.Judgement), Waits: comp.Dig, BuildAlone: a.buildAlone(ctx, module, prev, w.Want.Fp)})
return true
}
// buildAlone says a move of module from what runs to the build want changes the build alone (ADR 0601 §1): another build than
// the one it runs, and the build it runs composed with today's settings and secrets is the composition it runs. Anything it
// cannot tell is no: the move is then asked of a person.
func (a *Agent) buildAlone(ctx context.Context, module string, prev delivery.Have, want string) bool {
if prev.FP == "" && prev.Composed == "" {
func (a *Agent) buildAlone(ctx context.Context, module string, prev nodeengine.HaveByNodeModuleValue, want string) bool {
if prev.Fp == "" && prev.Composed == "" {
// Nothing of it run since this agent's start: its last have says what runs, as the have written keeps it.
if h, ok := a.env.Haves.Read(ctx, module); ok {
prev.FP, prev.Composed = h.FP, h.Composed
prev.Fp, prev.Composed = h.FP, h.Composed
}
}
if a.env.ComposedToday == nil || prev.FP == "" || prev.Composed == "" || prev.FP == want {
if a.env.ComposedToday == nil || prev.Fp == "" || prev.Composed == "" || prev.Fp == want {
return false
}
dig, ok := a.env.ComposedToday(ctx, module, prev.FP)
dig, ok := a.env.ComposedToday(ctx, module, prev.Fp)
return ok && dig == prev.Composed
}
@@ -1759,7 +1842,7 @@ func (a *Agent) buildAlone(ctx context.Context, module string, prev delivery.Hav
// The composition its have named at the start comes before the kept declaration (issue 924). From the kept declaration,
// what runs carries Data as its manifest declares it; a module whose manifest could not be read cannot be judged against
// it (false), and that is said, rather than taking it to hold no data and judging a false move of its data.
func (a *Agent) runningComposition(module string) (delivery.Composed, bool) {
func (a *Agent) runningComposition(module string) (compositions.Composed, bool) {
if c, ok := a.applied[module]; ok && c.Err == nil {
return c.C, true
}
@@ -1774,14 +1857,14 @@ func (a *Agent) runningComposition(module string) (delivery.Composed, bool) {
}
return c, known
}
return delivery.Composed{}, false
return compositions.Composed{}, false
}
// keptComposed is what runs of a module from the kept declaration: its resources there, the paths it names as filled,
// and Data as the module's manifest declares it; false when the manifest could not be read, so Data is not known. Called
// with a.mu held.
func (a *Agent) keptComposed(module string, rs []json.RawMessage) (delivery.Composed, bool) {
c := delivery.Composed{Node: a.env.Node, Module: module, Resources: rs}
func (a *Agent) keptComposed(module string, rs []json.RawMessage) (compositions.Composed, bool) {
c := compositions.Composed{Node: a.env.Node, Module: module, Resources: rs}
if a.env.KeptFilled != nil {
c.Filled = a.env.KeptFilled()[module]
}
@@ -1813,3 +1896,122 @@ func (a *Agent) readKeptData(ctx context.Context, modules ...string) {
}
}
}
// pullWait is what a module whose apply failed on a pull from a store waits on: the store's holder settling (ENG-8,
// design 51 *distribution's first delivery*).
type pullWait struct {
PullFailure
// retriedFor is the count of the holder's settled the last retry answered: a settled answers once.
retriedFor int
// last is what triggered the last retry, in words, and lastAt when; the have and the journal say it.
last string
lastAt time.Time
}
func (a *Agent) now() time.Time {
if a.env.Now != nil {
return a.env.Now()
}
return time.Now()
}
// notePullFailed records the pull a failed apply met from a store (the first image with a holder), keeping the settled it was
// last tried again for. It answers, when the holder's settled was heard after the pull started and not yet answered, the
// words of the retry to make at once (the race just before `settled`: ENG-11), else "". a.mu is held.
func (a *Agent) notePullFailed(module string, pulls []PullFailure) string {
var found *PullFailure
for i := range pulls {
if pulls[i].Holder != "" {
found = &pulls[i]
break
}
}
if found == nil {
delete(a.pulls, module)
return ""
}
pw := &pullWait{PullFailure: *found}
if before := a.pulls[module]; before != nil {
pw.retriedFor, pw.last, pw.lastAt = before.retriedFor, before.last, before.lastAt
}
a.pulls[module] = pw
holder := found.Holder
if seq := a.settledSeq[holder]; seq > pw.retriedFor && a.settledAt[holder].After(found.Started) {
return a.markPullRetry(pw, seq, fmt.Sprintf("%s settled at %s, after the pull of %s began at %s", holder,
a.settledAt[holder].Format("15:04:05"), found.Image, found.Started.Format("15:04:05")))
}
return ""
}
// markPullRetry notes that pw is tried again for the holder's settled number seq, and answers the words that say so. a.mu
// is held.
func (a *Agent) markPullRetry(pw *pullWait, seq int, why string) string {
pw.retriedFor, pw.last, pw.lastAt = seq, why, a.now()
return why
}
// pullWords is what a failed have says of the wait: whose settling it is tried again on and what triggered the last retry.
// "" for a failure that waits on no holder. a.mu is held.
func (a *Agent) pullWords(module string) string {
pw := a.pulls[module]
if pw == nil {
return ""
}
words := fmt.Sprintf("; it is tried again once %s has settled", pw.Holder)
if pw.last != "" {
words += "; last tried again at " + pw.lastAt.Format("15:04:05") + ": " + pw.last
}
return words
}
// askPullAgain asks the apply of a module again, because why, as a person's verb compose does (RetryFailed): said in the
// journal, never blocking.
func (a *Agent) askPullAgain(module, why string) {
select {
case a.failedAgain <- module:
a.env.Say(fmt.Sprintf("%s: its image could not be pulled from the store; applied again because %s", module, why))
default:
a.env.Say(fmt.Sprintf("%s: its image could not be pulled from the store, and %s, but the engine has too many retries "+
"asked; ask it with compose", module, why))
}
}
// Settled is the status view's word that a module has settled on some node (ENG-11): the engine's hearing tells it for every
// module it hears. When the module holds a store a failed apply of this node waits on, that apply is tried again once for
// this settled; a settled of any other module starts nothing. A pull that began after this settled, or has failed before it
// and is answered, is not tried again for it (notePullFailed).
func (a *Agent) Settled(holder string) {
a.mu.Lock()
a.settledSeq[holder]++
a.settledAt[holder] = a.now()
seq := a.settledSeq[holder]
var asks []string
var modules []string
for module, pw := range a.pulls {
att, failed := a.attempt[module]
if pw.Holder != holder || !failed || att.Held || att.Transient || pw.retriedFor >= seq {
continue
}
modules = append(modules, module)
asks = append(asks, a.markPullRetry(pw, seq, fmt.Sprintf("%s settled", holder)))
}
a.mu.Unlock()
for i, module := range modules {
a.askPullAgain(module, asks[i])
}
}
// pullWaitOf is the pull a failed apply of module waits on, as its have carries it (ENG-9): the image, the seat and its
// holder, when the pull began, and the last retry with what triggered it; nil when it waits on no holder. a.mu is held.
func (a *Agent) pullWaitOf(module string) *nodeengine.PullWait {
pw := a.pulls[module]
if pw == nil {
return nil
}
out := &nodeengine.PullWait{Image: pw.Image, Seat: pw.Seat, Holder: pw.Holder, Began: pw.Started, Retried: pw.last}
if !pw.lastAt.IsZero() {
at := pw.lastAt
out.RetriedAt = &at
}
return out
}
+270 -144
View File
@@ -4,21 +4,28 @@ import (
"context"
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
"github.com/novox/mesh-host/internal/buckets/bucketstest"
"github.com/novox/mesh-host/internal/compositions"
"github.com/novox/mesh-host/internal/judge"
"maps"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
const node = "n1"
func fpOf(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) }
func fpOf(c string) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(c, 64) }
// A watcher the test writes to; it says Caught once the keys written before the watch began are out.
type feed struct {
@@ -115,10 +122,14 @@ type bed struct {
// transient marks failWith as the bus not answering; failTimes > 0 fails only the first that many applies.
transient bool
failTimes int
backup func(module string) Backup
kept map[string][]json.RawMessage
others [][]delivery.Composed
said []string
// pullsFn, when set, is the images a failed apply could not pull (called as the apply fails); during runs at the start of
// each apply, before it answers.
pullsFn func() []PullFailure
during func()
backup func(module string) Backup
kept map[string][]json.RawMessage
others [][]compositions.Composed
said []string
// settled is each module Settled was told, and settledRan whether what runs held it then.
settled []string
settledRan []bool
@@ -129,7 +140,10 @@ type bed struct {
func newBed(t *testing.T) *bed {
b := &bed{t: t, wants: newFeed(), comps: newFeed(), haves: &haves{}}
b.env = Env{Node: node, Wants: b.wants, Composed: b.comps, Haves: b.haves,
Apply: func(_ context.Context, c delivery.Composed, others []delivery.Composed) Result {
Apply: func(_ context.Context, c compositions.Composed, others []compositions.Composed) Result {
if b.during != nil {
b.during()
}
b.mu.Lock()
defer b.mu.Unlock()
b.others = append(b.others, others)
@@ -139,11 +153,15 @@ func newBed(t *testing.T) *bed {
}
b.applied = append(b.applied, name)
if b.failWith != "" && (b.failTimes == 0 || len(b.applied) <= b.failTimes) {
return Result{Failed: true, Transient: b.transient, Detail: b.failWith}
res := Result{Failed: true, Transient: b.transient, Detail: b.failWith}
if b.pullsFn != nil {
res.Pulls = b.pullsFn()
}
return res
}
return Result{}
},
Remove: func(_ context.Context, m string, _ []delivery.Composed) Result {
Remove: func(_ context.Context, m string, _ []compositions.Composed) Result {
b.mu.Lock()
defer b.mu.Unlock()
b.removed = append(b.removed, m)
@@ -181,6 +199,17 @@ func newBed(t *testing.T) *bed {
return b
}
// idle waits until the agent has taken in and applied everything sent to it so far: what is counted after it is all there
// will be, with no clock.
func (b *bed) idle() {
b.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if !b.ag.waitIdle(ctx) {
b.t.Fatalf("the agent's loop did not come to rest")
}
}
func (b *bed) run() (stop func()) {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
@@ -198,13 +227,13 @@ func file(module, id, content string) json.RawMessage {
// composition is a module's composition at a fingerprint: its key, its bytes and its digest, from the writer's code.
func composition(t *testing.T, module, fp string, data bool, rs ...json.RawMessage) (key string, raw []byte, digest string) {
t.Helper()
c := delivery.Composed{Node: node, Module: module, FP: fp, Data: data, Resources: rs}
c := compositions.Composed{Node: node, Module: module, FP: fp, Data: data, Resources: rs}
var err error
if module == NodeModule {
c.Module, c.FP = "", ""
key, err = delivery.ComposedNodeKey(node)
key, err = compositions.NodeKey(node)
} else {
key, err = delivery.ComposedKey(node, module, fp)
key, err = compositions.Key(node, module, fp)
}
if err != nil {
t.Fatal(err)
@@ -213,13 +242,13 @@ func composition(t *testing.T, module, fp string, data bool, rs ...json.RawMessa
}
// encodedAs is c as the node's composer hands it: unsigned (ADR 0593 §6), its key, its bytes and its digest.
func encodedAs(t *testing.T, c delivery.Composed, key string) (string, []byte, string) {
func encodedAs(t *testing.T, c compositions.Composed, key string) (string, []byte, string) {
t.Helper()
raw, err := delivery.EncodeComposed(c)
raw, err := compositions.Encode(c)
if err != nil {
t.Fatal(err)
}
digest, err := delivery.ComposedDigestOf(c)
digest, err := compositions.DigestOf(c)
if err != nil {
t.Fatal(err)
}
@@ -228,11 +257,8 @@ func encodedAs(t *testing.T, c delivery.Composed, key string) (string, []byte, s
func wantFor(t *testing.T, module, fp, digest, backup string) (string, []byte) {
t.Helper()
key, err := delivery.WantKey(node, module)
if err != nil {
t.Fatal(err)
}
raw, err := delivery.EncodeWant(delivery.Want{FP: fp, Composed: digest, Backup: backup})
key := meshwants.WantsByWantNodeModuleKey(node, module)
raw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Composed: digest, Backup: backup})
if err != nil {
t.Fatal(err)
}
@@ -276,7 +302,7 @@ func TestAWantAndItsCompositionApplyTheModuleAndTheHaveNamesBoth(t *testing.T) {
eventually(t, "the module was not applied", func() bool { return len(b.appliedList()) == 1 })
eventually(t, "the have does not name both", func() bool {
h := b.haves.last("web")
return h.FP == fp && h.Composed == dig && h.Phase == string(delivery.PhaseRunning)
return h.FP == fp && h.Composed == dig && h.Phase == string(nodeengineclient.PhaseRunning)
})
// Settled is told once what runs holds the module, so a declaration read then names its files (the filling's Knock;
// ace, 2026-10-25 21:17).
@@ -313,7 +339,7 @@ func TestStartingOnWhatTheKeptDeclarationHoldsAppliesNothing(t *testing.T) {
stop := b.run()
defer stop()
eventually(t, "the have was not written", func() bool { return b.haves.last("web").Composed == dig })
if h := b.haves.last("web"); h.FP != fp || h.Phase != string(delivery.PhaseRunning) {
if h := b.haves.last("web"); h.FP != fp || h.Phase != string(nodeengineclient.PhaseRunning) {
t.Fatalf("the have: %+v", h)
}
time.Sleep(50 * time.Millisecond)
@@ -333,7 +359,7 @@ func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) {
fp := fpOf("a")
ck, craw, dig := composition(t, "avahi", fp, false, file("avahi", "x", "1"))
wk, wraw := wantFor(t, "avahi", fp, dig, "")
b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed),
b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseFailed),
Detail: "apply: unit failed to start"}}
b.comps.before(ck, craw)
b.wants.before(wk, wraw)
@@ -348,7 +374,7 @@ func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) {
fp := fpOf("a")
ck, craw, dig := composition(t, "avahi", fp, false, file("avahi", "x", "1"))
wk, wraw := wantFor(t, "avahi", fp, dig, "")
b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed),
b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseFailed),
Detail: NotComposedDetail + "frombus: the credentials of ace: context canceled"}}
b.comps.before(ck, craw)
b.wants.before(wk, wraw)
@@ -356,7 +382,7 @@ func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) {
defer stop()
eventually(t, "the mark was not cleared", func() bool {
h := b.haves.last("avahi")
return h.Phase == string(delivery.PhaseRunning) && h.FP == fp && h.Composed == dig && h.Detail == ""
return h.Phase == string(nodeengineclient.PhaseRunning) && h.FP == fp && h.Composed == dig && h.Detail == ""
})
if got := b.appliedList(); len(got) != 0 {
t.Fatalf("a module whose mark names what the want names was applied: %v", got)
@@ -403,7 +429,7 @@ func testStartingOnItsOwnHaveSaysTheHaveAgainSoItsHealthIsThisEngines(t *testing
if len(log) != 1 {
t.Fatalf("the have was said %d times, want once: %+v", len(log), log)
}
if h := log[0]; h.Module != "xorg" || h.FP != fp || h.Composed != dig || h.Phase != string(delivery.PhaseRunning) || h.Detail != "kept" {
if h := log[0]; h.Module != "xorg" || h.FP != fp || h.Composed != dig || h.Phase != string(nodeengineclient.PhaseRunning) || h.Detail != "kept" {
t.Fatalf("the have said again: %+v", h)
}
if got := b.appliedList(); len(got) != 0 {
@@ -430,7 +456,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 })
eventually(t, "the have does not say failed", func() bool {
h := b.haves.last("web")
return h.Phase == string(delivery.PhaseFailed) && strings.Contains(h.Detail, "no space") && h.FP == fp
return h.Phase == string(nodeengineclient.PhaseFailed) && strings.Contains(h.Detail, "no space") && h.FP == fp
})
// An unrelated key changes: not a retry.
ck9, craw9, _ := composition(t, "web", fpOf("9"), false, file("web", "x", "9"))
@@ -448,7 +474,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
t.Fatalf("a failed apply was refused a retry: %v", err)
}
eventually(t, "the asked retry did not apply", func() bool { return len(b.appliedList()) == 2 })
eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(delivery.PhaseRunning) })
eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(nodeengineclient.PhaseRunning) })
for module, words := range map[string]string{"web": "runs what its want names", "ghost": "has no want"} {
if err := b.ag.RetryFailed(module); err == nil || !strings.Contains(err.Error(), words) {
t.Errorf("a retry of %s: %v, want refused saying %q", module, err, words)
@@ -461,6 +487,115 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
t.Errorf("a module running its want was applied again (%d applies)", n)
}
})
// design 51 (ENG-8, ENG-11): a pull from a store that failed is tried again when the store's holder settles, once per
// settled, never on a clock; the have says whom it waits on and what triggered the last retry.
t.Run("a pull from a store waits for its holder to settle", func(t *testing.T) {
b := newBed(t)
var clock atomic.Int64
now := func() time.Time { return time.Unix(1_000_000, clock.Load()) }
tick := func() time.Time { clock.Add(int64(time.Second)); return now() }
b.env.Now = now
b.env.Backoff = func(int) time.Duration { return time.Millisecond }
b.failWith = "pulling image reg/x@sha256:1: manifest unknown"
b.pullsFn = func() []PullFailure {
return []PullFailure{{Image: "reg/x@sha256:1", Seat: "mesh-artifact-store", Holder: "distribution", Started: tick()},
{Image: "public/y@sha256:2"}}
}
stop := b.run()
defer stop()
fp := fpOf("a")
ck, craw, dig := composition(t, "web", fp, false, file("web", "x", "1"))
wk, wraw := wantFor(t, "web", fp, dig, "")
b.comps.put(ck, craw)
b.wants.put(wk, wraw)
eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 })
eventually(t, "the have does not say whom it waits on", func() bool {
h := b.haves.last("web")
return h.Phase == string(nodeengineclient.PhaseFailed) && strings.Contains(h.Detail, "tried again once distribution has settled")
})
b.idle()
if n := len(b.appliedList()); n != 1 {
t.Fatalf("a failed pull was tried again by the clock (%d applies)", n)
}
// A settled of another module starts nothing.
b.ag.Settled("gitea")
b.idle()
if n := len(b.appliedList()); n != 1 {
t.Fatalf("a settled of another module applied the module (%d applies)", n)
}
// The holder's settled: tried again once, whose pull fails again after it (started later): failed again, no more.
tick()
b.ag.Settled("distribution")
eventually(t, "not tried again on the holder's settled", func() bool { return len(b.appliedList()) == 2 })
eventually(t, "the have does not say what triggered the retry", func() bool {
h := b.haves.last("web")
return h.Phase == string(nodeengineclient.PhaseFailed) && strings.Contains(h.Detail, "last tried again at") && strings.Contains(h.Detail, "distribution settled")
})
b.idle()
if n := len(b.appliedList()); n != 2 {
t.Fatalf("a second failure after the same settled started a further apply (%d applies)", n)
}
// The failed have carries the pull it waits on (ENG-9, read through node_haves): the image, whose settled it waits
// on, and what triggered the last retry.
if w := b.haves.last("web").PullWait; w == nil || w.Holder != "distribution" || w.Image != "reg/x@sha256:1" ||
w.Began.IsZero() || !strings.Contains(w.Retried, "distribution settled") || w.RetriedAt == nil {
t.Fatalf("the failed have says it waits on %+v", w)
}
// The next settled answers once more, and the pull works: the module runs, and waits on nothing.
b.mu.Lock()
b.failWith = ""
b.mu.Unlock()
tick()
b.ag.Settled("distribution")
eventually(t, "not tried again on the next settled", func() bool { return len(b.appliedList()) == 3 })
eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(nodeengineclient.PhaseRunning) })
if w := b.haves.last("web").PullWait; w != nil {
t.Fatalf("a running module's have still waits on %+v", w)
}
b.ag.Settled("distribution")
b.idle()
if n := len(b.appliedList()); n != 3 {
t.Fatalf("a running module was applied again by a settled (%d applies)", n)
}
})
// The race just before `settled`: the holder's settled is heard while the pull is in flight, and the pull fails after it;
// applied again at once, once, and a settled heard before the pull began applies nothing at once.
t.Run("a settled heard while the pull ran is not missed", func(t *testing.T) {
b := newBed(t)
var clock atomic.Int64
now := func() time.Time { return time.Unix(1_000_000, clock.Load()) }
tick := func() time.Time { clock.Add(int64(time.Second)); return now() }
b.env.Now = now
b.failWith = "pulling image reg/x@sha256:1: connection refused"
began := tick()
first := true
b.during = func() {
if first {
first = false
tick()
b.ag.Settled("distribution") // the holder settles while this apply's pull is in flight
}
}
b.pullsFn = func() []PullFailure {
started := began
if len(b.applied) > 1 {
started = tick() // the retry's pull begins after the settled
}
return []PullFailure{{Image: "reg/x@sha256:1", Seat: "mesh-artifact-store", Holder: "distribution", Started: started}}
}
stop := b.run()
defer stop()
fp := fpOf("a")
ck, craw, dig := composition(t, "web", fp, false, file("web", "x", "1"))
wk, wraw := wantFor(t, "web", fp, dig, "")
b.comps.put(ck, craw)
b.wants.put(wk, wraw)
eventually(t, "the settled heard during the pull was missed", func() bool { return len(b.appliedList()) == 2 })
b.idle()
if n := len(b.appliedList()); n != 2 {
t.Fatalf("the race retry failing again started a further apply (%d applies)", n)
}
})
// The bus did not answer for a moment (mesh-vault on the bus node, 2026-10-25 23:04:24): tried again with no new
// revision of either key, by the backoff or by RetryTransient once the engine knows the bus is back; the have says
// retrying meanwhile, never failed.
@@ -487,11 +622,11 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
b.ag.RetryTransient()
}
eventually(t, "not applied again without a new revision", func() bool { return len(b.appliedList()) == 2 })
eventually(t, "not running", func() bool { return b.haves.last("vault").Phase == string(delivery.PhaseRunning) })
eventually(t, "not running", func() bool { return b.haves.last("vault").Phase == string(nodeengineclient.PhaseRunning) })
b.haves.mu.Lock()
defer b.haves.mu.Unlock()
for _, h := range b.haves.log {
if h.Phase == string(delivery.PhaseFailed) {
if h.Phase == string(nodeengineclient.PhaseFailed) {
t.Errorf("a transient failure was written failed: %+v", h)
}
}
@@ -531,7 +666,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
})
eventually(t, "the have does not say held", func() bool {
h := b.haves.last("db")
return h.Phase == string(delivery.PhaseHeld) && strings.Contains(h.Detail, "repository is locked") && h.FP == fp1
return h.Phase == string(nodeengineclient.PhaseHeld) && strings.Contains(h.Detail, "repository is locked") && h.FP == fp1
})
if got := b.appliedList(); len(got) != 0 {
t.Fatalf("something was applied behind a failed backup: %v", got)
@@ -549,7 +684,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
}
// The answer arrives in the module's own want: back up again (it fails again), then skip.
wk, again := wantFor(t, "db", fp2, dig2, delivery.BackupAgain)
wk, again := wantFor(t, "db", fp2, dig2, meshwants.WantsByWantNodeModuleValueBackupAgain)
b.wants.put(wk, again)
eventually(t, "back up again did not ask the backup", func() bool {
b.mu.Lock()
@@ -559,7 +694,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
if got := b.appliedList(); len(got) != 0 {
t.Fatalf("applied behind a second failed backup: %v", got)
}
_, skip := wantFor(t, "db", fp2, dig2, delivery.BackupSkip)
_, skip := wantFor(t, "db", fp2, dig2, meshwants.WantsByWantNodeModuleValueBackupSkip)
b.wants.put(wk, skip)
eventually(t, "skip did not apply the module", func() bool { return len(b.appliedList()) == 1 })
b.mu.Lock()
@@ -570,7 +705,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
}
eventually(t, "not running at the new fingerprint", func() bool {
h := b.haves.last("db")
return h.Phase == string(delivery.PhaseRunning) && h.FP == fp2 && h.Composed == dig2
return h.Phase == string(nodeengineclient.PhaseRunning) && h.FP == fp2 && h.Composed == dig2
})
// The node agent stopped while the backup ran, the module known only from its have: the held have names what runs
@@ -578,9 +713,9 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
ib := newBed(t)
ib.comps.before(ck1, craw1)
ib.comps.before(ck2, craw2)
iwk, iwraw := wantFor(t, "db", fp2, dig2, delivery.BackupAgain)
iwk, iwraw := wantFor(t, "db", fp2, dig2, meshwants.WantsByWantNodeModuleValueBackupAgain)
ib.wants.before(iwk, iwraw)
ib.haves.stored = map[string]Have{"db": {Module: "db", FP: fp1, Composed: dig1, Phase: string(delivery.PhaseRunning)}}
ib.haves.stored = map[string]Have{"db": {Module: "db", FP: fp1, Composed: dig1, Phase: string(nodeengineclient.PhaseRunning)}}
began := make(chan struct{})
ib.env.Backup = func(ctx context.Context, _ string) Backup {
close(began)
@@ -590,7 +725,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
stopI := ib.run()
<-began
stopI()
if h := ib.haves.last("db"); h.Phase != string(delivery.PhaseHeld) || h.FP != fp1 || h.Composed != dig1 || h.Detail != BackupInterrupted {
if h := ib.haves.last("db"); h.Phase != string(nodeengineclient.PhaseHeld) || h.FP != fp1 || h.Composed != dig1 || h.Detail != BackupInterrupted {
t.Errorf("held after an interrupted backup: %+v", h)
}
@@ -600,24 +735,24 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
for _, row := range []struct {
detail, backup string
backups, applied int
phase delivery.Phase
phase string
namesNothing bool // the have names no fp and no composition
}{
{BackupInterrupted, delivery.BackupAgain, 1, 1, delivery.PhaseRunning, false},
{BackupInterrupted, meshwants.WantsByWantNodeModuleValueBackupAgain, 1, 1, nodeengineclient.PhaseRunning, false},
// novox's postgres: its held have, written before mesh-host #189, names nothing it runs.
{BackupInterrupted, "", 1, 1, delivery.PhaseRunning, true},
{BackupInterrupted, "", 1, 1, nodeengineclient.PhaseRunning, true},
// A backup that failed, and the want carries the operator's answer, written before this start (nats on novox,
// 2026-11-04, issue 1585): again backs it up and applies, skip applies without one; no answer keeps the hold.
{"restic: the repository is locked", delivery.BackupAgain, 1, 1, delivery.PhaseRunning, false},
{"restic: the repository is locked", delivery.BackupSkip, 0, 1, delivery.PhaseRunning, false},
{"restic: the repository is locked", "", 0, 0, delivery.PhaseHeld, false},
{"restic: the repository is locked", meshwants.WantsByWantNodeModuleValueBackupAgain, 1, 1, nodeengineclient.PhaseRunning, false},
{"restic: the repository is locked", meshwants.WantsByWantNodeModuleValueBackupSkip, 0, 1, nodeengineclient.PhaseRunning, false},
{"restic: the repository is locked", "", 0, 0, nodeengineclient.PhaseHeld, false},
} {
r := newBed(t)
r.comps.before(ck1, craw1)
r.comps.before(ck2, craw2)
wk, wraw := wantFor(t, "db", fp2, dig2, row.backup)
r.wants.before(wk, wraw)
held := Have{Module: "db", FP: fp1, Composed: dig1, Phase: string(delivery.PhaseHeld), Detail: row.detail}
held := Have{Module: "db", FP: fp1, Composed: dig1, Phase: string(nodeengineclient.PhaseHeld), Detail: row.detail}
if row.namesNothing {
held.FP, held.Composed = "", ""
}
@@ -648,9 +783,9 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
backups int
holdsTill bool // it is held, and said held, before the later revision
}{
{"answered before the start", delivery.BackupAgain, "", 1, false},
{"answered while held", "", delivery.BackupAgain, 1, true},
{"skipped while held", "", delivery.BackupSkip, 0, true},
{"answered before the start", meshwants.WantsByWantNodeModuleValueBackupAgain, "", 1, false},
{"answered while held", "", meshwants.WantsByWantNodeModuleValueBackupAgain, 1, true},
{"skipped while held", "", meshwants.WantsByWantNodeModuleValueBackupSkip, 0, true},
} {
t.Run(row.name, func(t *testing.T) {
r := newBed(t)
@@ -658,16 +793,16 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
r.env.Policy = func(context.Context, string) string { return catalogue.PolicyRecord }
ck, craw, dig := composition(t, "nats", fp2, true, file("nats", "x", "2"))
r.comps.before(ck, craw)
wk, _ := delivery.WantKey(node, "nats")
wk := meshwants.WantsByWantNodeModuleKey(node, "nats")
selfWant := func(backup string) []byte {
raw, err := delivery.EncodeWant(delivery.Want{FP: fp2, Self: true, Yes: dig, Backup: backup})
raw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp2, Self: true, Yes: dig, Backup: backup})
if err != nil {
t.Fatal(err)
}
return raw
}
r.wants.before(wk, selfWant(row.atStart))
r.haves.stored = map[string]Have{"nats": {Module: "nats", FP: fp2, Composed: dig, Phase: string(delivery.PhaseHeld),
r.haves.stored = map[string]Have{"nats": {Module: "nats", FP: fp2, Composed: dig, Phase: string(nodeengineclient.PhaseHeld),
Detail: "the backup failed: sh: exit status 1"}}
stopR := r.run()
defer stopR()
@@ -698,7 +833,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
eventually(t, "the answer was not acted on", func() bool { return len(r.appliedList()) == 1 })
eventually(t, "not running after the answer", func() bool {
h := r.haves.last("nats")
return h.Phase == string(delivery.PhaseRunning) && h.Composed == dig
return h.Phase == string(nodeengineclient.PhaseRunning) && h.Composed == dig
})
r.mu.Lock()
n := len(r.backups)
@@ -753,7 +888,7 @@ func TestARemovedWantRemovesThatModuleOnly(t *testing.T) {
wk, wraw := wantFor(t, "web", fp, dig, "")
b.wants.put(wk, wraw)
eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 })
gone, _ := delivery.EncodeWant(delivery.Want{Removed: true})
gone, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Removed: true})
b.wants.put(wk, gone)
eventually(t, "not removed", func() bool {
b.mu.Lock()
@@ -774,11 +909,8 @@ func TestTheNodesOwnCompositionComesBeforeAnyModules(t *testing.T) {
ck, craw, dig := composition(t, "web", fp, false, file("web", "x", "1"))
wk, wraw := wantFor(t, "web", fp, dig, "")
nk, nraw, ndig := composition(t, NodeModule, "", false)
nwk, err := delivery.WantNodeKey(node)
if err != nil {
t.Fatal(err)
}
nwraw, err := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig})
nwk := meshwants.WantsByWantNodeNodeKey(node)
nwraw, err := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig})
if err != nil {
t.Fatal(err)
}
@@ -826,9 +958,9 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
if got := a.Everything(); len(got) != 2 {
t.Fatalf("before the agent holds anything: %d compositions, want the 2 kept", len(got))
}
a.running["web"] = delivery.Have{FP: fpOf("a"), Composed: "sha256:" + strings.Repeat("1", 64), Phase: delivery.PhaseRunning}
a.running["web"] = nodeengine.HaveByNodeModuleValue{Fp: fpOf("a"), Composed: "sha256:" + strings.Repeat("1", 64), Phase: nodeengineclient.PhaseRunning}
ck, craw, dig := composition(t, "web", fpOf("a"), false, file("web", "x", "new"))
a.running["web"] = delivery.Have{FP: fpOf("a"), Composed: dig, Phase: delivery.PhaseRunning}
a.running["web"] = nodeengine.HaveByNodeModuleValue{Fp: fpOf("a"), Composed: dig, Phase: nodeengineclient.PhaseRunning}
a.takeComposition(Update{Key: ck, Value: craw, Revision: 1})
got := a.Everything()
if len(got) != 2 {
@@ -854,20 +986,20 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
b.env.Applied = store
// A folded module's resource in the node's own composition: its id names the module, as the controller folds it.
natsConf, _ := json.Marshal(map[string]any{"id": "nats.conf", "type": "file", "path": "/tmp/r6/nats/conf", "content": "listen 4222"})
own := delivery.Composed{Node: node, Resources: []json.RawMessage{natsConf}}
ownRaw, err := delivery.EncodeComposed(own)
own := compositions.Composed{Node: node, Resources: []json.RawMessage{natsConf}}
ownRaw, err := compositions.Encode(own)
if err != nil {
t.Fatal(err)
}
ownDig, _ := delivery.ComposedDigestOf(own)
web := delivery.Composed{Node: node, Module: "web", FP: fpOf("a"), Resources: []json.RawMessage{file("web", "x", "1")}}
webRaw, _ := delivery.EncodeComposed(web)
webDig, _ := delivery.ComposedDigestOf(web)
ownDig, _ := compositions.DigestOf(own)
web := compositions.Composed{Node: node, Module: "web", FP: fpOf("a"), Resources: []json.RawMessage{file("web", "x", "1")}}
webRaw, _ := compositions.Encode(web)
webDig, _ := compositions.DigestOf(web)
store.kept[NodeModule], store.kept["web"] = ownRaw, webRaw
b.haves.Write(context.Background(), Have{Module: NodeModule, Composed: ownDig})
b.haves.Write(context.Background(), Have{Module: "web", FP: fpOf("a"), Composed: webDig, Phase: string(delivery.PhaseRunning)})
b.haves.Write(context.Background(), Have{Module: "web", FP: fpOf("a"), Composed: webDig, Phase: string(nodeengineclient.PhaseRunning)})
// The composer's first pass reads what runs when its watch begins, before the agent reads any have.
firstCh := make(chan []delivery.Composed, 1)
firstCh := make(chan []compositions.Composed, 1)
comps := b.comps
var ag *Agent
b.env.Composed = watchFunc(func(ctx context.Context, p string) (<-chan Update, error) {
@@ -880,7 +1012,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
done := make(chan struct{})
go func() { defer close(done); _ = ag.Run(ctx) }()
defer func() { cancel(); <-done }()
var first []delivery.Composed
var first []compositions.Composed
select {
case first = <-firstCh:
case <-time.After(5 * time.Second):
@@ -896,15 +1028,15 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
ranOwn, ranWeb, len(first))
}
// An apply keeps the composition it applied; a removal forgets it.
web2 := delivery.Composed{Node: node, Module: "web", FP: fpOf("b"), Resources: []json.RawMessage{file("web", "x", "2")}}
ck, _ := delivery.ComposedKey(node, "web", fpOf("b"))
raw2, _ := delivery.EncodeComposed(web2)
web2 := compositions.Composed{Node: node, Module: "web", FP: fpOf("b"), Resources: []json.RawMessage{file("web", "x", "2")}}
ck, _ := compositions.Key(node, "web", fpOf("b"))
raw2, _ := compositions.Encode(web2)
comps.put(ck, raw2)
wk, _ := delivery.WantKey(node, "web")
wraw, _ := delivery.EncodeWant(delivery.Want{FP: fpOf("b"), Self: true})
wk := meshwants.WantsByWantNodeModuleKey(node, "web")
wraw, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fpOf("b"), Self: true})
b.wants.put(wk, wraw)
eventually(t, "the applied composition was not kept", func() bool { return string(store.get("web")) == string(raw2) })
rraw, _ := delivery.EncodeWant(delivery.Want{Removed: true})
rraw, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Removed: true})
b.wants.put(wk, rraw)
eventually(t, "the removed module's composition was not forgotten", func() bool { return store.get("web") == nil })
})
@@ -917,10 +1049,10 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
store := &memApplied{kept: map[string][]byte{}}
h := &haves{}
h.Write(context.Background(), Have{Module: NodeModule, Composed: "sha256:" + strings.Repeat("c", 64)})
firstPass := func(kept map[string][]json.RawMessage) []delivery.Composed {
firstPass := func(kept map[string][]json.RawMessage) []compositions.Composed {
b := newBed(t)
b.env.SelfComposed, b.kept, b.env.Applied, b.env.Haves = true, kept, store, h
firstCh := make(chan []delivery.Composed, 1)
firstCh := make(chan []compositions.Composed, 1)
comps := b.comps
var ag *Agent
b.env.Composed = watchFunc(func(ctx context.Context, p string) (<-chan Update, error) {
@@ -940,7 +1072,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
}
return nil
}
runsNats := func(cs []delivery.Composed) bool {
runsNats := func(cs []compositions.Composed) bool {
for _, c := range cs {
if c.Module == "" && len(c.Resources) == 1 && strings.Contains(string(c.Resources[0]), "4222") {
return true
@@ -1010,7 +1142,7 @@ func TestAFailedApplyIsAppliedAgainAfterARestartWhateverIsKept(t *testing.T) {
b.comps.before(ck, craw)
b.wants.before(wk, wraw)
b.kept = map[string][]json.RawMessage{"web": {file("web", "x", "1")}}
b.haves.stored = map[string]Have{"web": {Module: "web", FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed)}}
b.haves.stored = map[string]Have{"web": {Module: "web", FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseFailed)}}
stop := b.run()
defer stop()
eventually(t, "a failed apply was marked running from the kept declaration", func() bool { return len(b.appliedList()) == 1 })
@@ -1047,11 +1179,8 @@ func TestTheNodesOwnApplyHoldsOutAModuleThatMovedToItsOwnComposition(t *testing.
rk, rraw, _ := composition(t, "systemd-resolved", fp, false, file("systemd-resolved", "guard", "g"))
b.comps.before(rk, rraw)
nk, nraw, ndig := composition(t, NodeModule, "", false)
nwk, err := delivery.WantNodeKey(node)
if err != nil {
t.Fatal(err)
}
nwraw, err := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig})
nwk := meshwants.WantsByWantNodeNodeKey(node)
nwraw, err := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig})
if err != nil {
t.Fatal(err)
}
@@ -1081,7 +1210,7 @@ func TestTheNodesOwnApplyHoldsOutAModuleThatMovedToItsOwnComposition(t *testing.
spiegel, _ = json.Marshal(bare)
b2.env.Kept = func() map[string][]json.RawMessage { return map[string][]json.RawMessage{NodeModule: {spiegel}} }
nk2, nraw2, ndig2 := composition(t, NodeModule, "", false)
nwraw2, _ := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig2})
nwraw2, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig2})
b2.comps.before(nk2, nraw2)
b2.wants.before(nwk, nwraw2)
ctx2, cancel2 := context.WithCancel(context.Background())
@@ -1109,18 +1238,15 @@ func testARemovedModulesCompositionHoldsNothingBack(t *testing.T) {
fp := fpOf("b")
rk, rraw, _ := composition(t, "gone", fp, false, file("gone", "x", "1"))
b.comps.before(rk, rraw)
gk, err := delivery.WantKey(node, "gone")
if err != nil {
t.Fatal(err)
}
graw, err := delivery.EncodeWant(delivery.Want{Removed: true})
gk := meshwants.WantsByWantNodeModuleKey(node, "gone")
graw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Removed: true})
if err != nil {
t.Fatal(err)
}
b.wants.before(gk, graw)
nk, nraw, ndig := composition(t, NodeModule, "", false)
nwk, _ := delivery.WantNodeKey(node)
nwraw, _ := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig})
nwk := meshwants.WantsByWantNodeNodeKey(node)
nwraw, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig})
b.comps.before(nk, nraw)
b.wants.before(nwk, nwraw)
stop := b.run()
@@ -1148,14 +1274,14 @@ func testARemovedModulesCompositionHoldsNothingBack(t *testing.T) {
// it (issue 1945, the review's gap 2). It is said, naming the composition, and applied once that one reads.
func TestTheNodesOwnWaitsWhileAModulesCompositionCannotBeRead(t *testing.T) {
b := newBed(t)
bad, err := delivery.ComposedKey(node, "broken", fpOf("c"))
bad, err := compositions.Key(node, "broken", fpOf("c"))
if err != nil {
t.Fatal(err)
}
b.comps.before(bad, []byte(`{"v":1,"not":"a composition"}`))
nk, nraw, ndig := composition(t, NodeModule, "", false)
nwk, _ := delivery.WantNodeKey(node)
nwraw, _ := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig})
nwk := meshwants.WantsByWantNodeNodeKey(node)
nwraw, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig})
b.comps.before(nk, nraw)
b.wants.before(nwk, nwraw)
stop := b.run()
@@ -1184,7 +1310,7 @@ func TestTheNodesOwnWaitsWhileAModulesCompositionCannotBeRead(t *testing.T) {
// compositions written before the filled paths, which the SDK now refuses, and nobody applies them).
func TestAStaleRefusedCompositionDoesNotHoldTheNodesOwnBack(t *testing.T) {
b := newBed(t)
stale, err := delivery.ComposedKey(node, "power", fpOf("a"))
stale, err := compositions.Key(node, "power", fpOf("a"))
if err != nil {
t.Fatal(err)
}
@@ -1192,8 +1318,8 @@ func TestAStaleRefusedCompositionDoesNotHoldTheNodesOwnBack(t *testing.T) {
gk, graw, _ := composition(t, "power", fpOf("b"), false, file("power", "x", "1"))
b.comps.before(gk, graw)
nk, nraw, ndig := composition(t, NodeModule, "", false)
nwk, _ := delivery.WantNodeKey(node)
nwraw, _ := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig})
nwk := meshwants.WantsByWantNodeNodeKey(node)
nwraw, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig})
b.comps.before(nk, nraw)
b.wants.before(nwk, nwraw)
stop := b.run()
@@ -1220,7 +1346,7 @@ func TestACompositionWhoseResourcesWereRemovedIsAppliedAgain(t *testing.T) {
} else {
b.haves.stored = map[string]Have{"ca-trust": {Module: "ca-trust", FP: fp, Composed: dig, Phase: "running"}}
}
b.env.Recorded = func(context.Context, delivery.Composed) bool { return false }
b.env.Recorded = func(context.Context, compositions.Composed) bool { return false }
stop := b.run()
defer stop()
eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 })
@@ -1269,8 +1395,8 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) {
`","mode":"0644","owner-at-runtime":"owned"}`)
}
fp := fpOf("a")
ck, _ := delivery.ComposedKey(node, "power", fp)
applied := delivery.Composed{Node: node, Module: "power", FP: fp, Filled: []string{old, added}, Data: true,
ck, _ := compositions.Key(node, "power", fp)
applied := compositions.Composed{Node: node, Module: "power", FP: fp, Filled: []string{old, added}, Data: true,
Resources: []json.RawMessage{filledFile("settings"), filledFile("bound")}}
_, appliedRaw, appliedDig := encodedAs(t, applied, ck)
newer := applied
@@ -1292,7 +1418,7 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) {
{name: "a have naming the bucket's value", have: &Have{Module: "power", FP: fp, Composed: appliedDig},
bucket: appliedRaw, filled: old + "," + added, told: "the haves were read at the start", data: true},
{name: "a have applying names nothing that runs yet", have: &Have{Module: "power", FP: fp, Composed: appliedDig,
Phase: string(delivery.PhaseApplying)}, bucket: appliedRaw, filled: old, told: "the haves were read at the start"},
Phase: string(nodeengineclient.PhaseApplying)}, bucket: appliedRaw, filled: old, told: "the haves were read at the start"},
{name: "a data module from the kept declaration says Data as its manifest does", filled: old,
told: "the haves were read at the start", data: true,
holdsData: func(context.Context, string) (bool, bool) { return true, true }},
@@ -1394,18 +1520,18 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
stop := b.run()
defer stop()
unsigned := func(module, fp string, rs ...json.RawMessage) (string, []byte, string) {
c := delivery.Composed{Node: node, Module: module, FP: fp, Resources: rs}
key, _ := delivery.ComposedKey(node, module, fp)
raw, err := delivery.EncodeComposed(c)
c := compositions.Composed{Node: node, Module: module, FP: fp, Resources: rs}
key, _ := compositions.Key(node, module, fp)
raw, err := compositions.Encode(c)
if err != nil {
t.Fatal(err)
}
dig, _ := delivery.ComposedDigestOf(c)
dig, _ := compositions.DigestOf(c)
return key, raw, dig
}
want := func(module, fp, yes string) {
key, _ := delivery.WantKey(node, module)
raw, err := delivery.EncodeWant(delivery.Want{FP: fp, Yes: yes, Self: true})
key := meshwants.WantsByWantNodeModuleKey(node, module)
raw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Yes: yes, Self: true})
if err != nil {
t.Fatal(err)
}
@@ -1434,13 +1560,13 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
// The node's own composition carries its sealed values in its key, as a module's have does, so a restart reuses its
// core boxes and applies nothing again (review of L2, round 3, finding 1).
box, _ := json.Marshal(map[string]any{"id": "core-box", "type": "file", "path": "/tmp/r6/core-box", "sealed": "core"})
own := delivery.Composed{Node: node, Resources: []json.RawMessage{box}}
nk, _ := delivery.ComposedNodeKey(node)
nraw, _ := delivery.EncodeComposed(own)
ndig, _ := delivery.ComposedDigestOf(own)
own := compositions.Composed{Node: node, Resources: []json.RawMessage{box}}
nk, _ := compositions.NodeKey(node)
nraw, _ := compositions.Encode(own)
ndig, _ := compositions.DigestOf(own)
b.comps.put(nk, nraw)
nwk, _ := delivery.WantNodeKey(node)
nwraw, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true})
nwk := meshwants.WantsByWantNodeNodeKey(node)
nwraw, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true})
b.wants.put(nwk, nwraw)
eventually(t, "the node's own key does not carry its sealed values", func() bool {
h := b.haves.last(NodeModule)
@@ -1450,8 +1576,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
// names what runs; composed again, the have is what it runs. web was applied by this agent; idle was not since the
// agent's start (the switch and every delivery of the node-engine restart it), so what runs is what its have said.
idleFP, idleDig := fpOf("c"), "sha256:"+strings.Repeat("9", 64)
b.haves.Write(context.Background(), Have{Module: "idle", FP: idleFP, Composed: idleDig, Phase: string(delivery.PhaseRunning),
Sealed: delivery.Seals{"idle.key#sealed": `{"box":"idle"}`}})
b.haves.Write(context.Background(), Have{Module: "idle", FP: idleFP, Composed: idleDig, Phase: string(nodeengineclient.PhaseRunning),
Sealed: compositions.Seals{"idle.key#sealed": `{"box":"idle"}`}})
want("idle", idleFP, "")
eventually(t, "idle's want was not taken", func() bool {
b.ag.mu.Lock()
@@ -1466,7 +1592,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
module, fp, composed, slot, sealed string
}{{"web", fp, dig, "web.pw#sealed", "1"}, {"idle", idleFP, idleDig, "idle.key#sealed", `{"box":"idle"}`}} {
h := b.haves.last(row.module)
if h.Phase != string(delivery.PhaseFailed) || h.FP != row.fp || h.Composed != row.composed ||
if h.Phase != string(nodeengineclient.PhaseFailed) || h.FP != row.fp || h.Composed != row.composed ||
len(h.Sealed) != 1 || h.Sealed[row.slot] != row.sealed || !strings.Contains(h.Detail, "web's password") {
t.Errorf("%s's have while the node cannot compose: %+v", row.module, h)
}
@@ -1481,7 +1607,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
want("fresh", fpOf("e"), "") // never ran, its have absent: marked, naming nothing it runs
// A have that already says another cause keeps it (review of the switch, round 1): an apply that failed.
brokenFP, brokenDig := fpOf("f"), "sha256:"+strings.Repeat("8", 64)
b.haves.Write(context.Background(), Have{Module: "broken", FP: brokenFP, Composed: brokenDig, Phase: string(delivery.PhaseFailed),
b.haves.Write(context.Background(), Have{Module: "broken", FP: brokenFP, Composed: brokenDig, Phase: string(nodeengineclient.PhaseFailed),
Detail: "apply: unit failed to start"})
want("broken", brokenFP, "")
eventually(t, "unread's want was not taken", func() bool {
@@ -1493,7 +1619,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
if h := b.haves.last("unread"); h.Module != "" {
t.Errorf("a module whose have could not be read was marked: %+v", h)
}
if h := b.haves.last("fresh"); h.Phase != string(delivery.PhaseFailed) || h.FP != "" {
if h := b.haves.last("fresh"); h.Phase != string(nodeengineclient.PhaseFailed) || h.FP != "" {
t.Errorf("a module that never ran, its have absent, is not marked: %+v", h)
}
if h := b.haves.last("broken"); h.Detail != "apply: unit failed to start" || h.Composed != brokenDig {
@@ -1509,7 +1635,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
}
b.ag.NotComposed(context.Background(), "")
for _, row := range []struct{ module, composed string }{{"web", dig}, {"idle", idleDig}} {
if h := b.haves.last(row.module); h.Phase != string(delivery.PhaseRunning) || h.Composed != row.composed || len(h.Sealed) != 1 {
if h := b.haves.last(row.module); h.Phase != string(nodeengineclient.PhaseRunning) || h.Composed != row.composed || len(h.Sealed) != 1 {
t.Errorf("%s's have once the node composes again: %+v", row.module, h)
}
}
@@ -1517,14 +1643,14 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
// novox) is marked alone, naming its own words; web, which composes, keeps running, unmarked. Composed again, idle's
// have is what it runs.
b.ag.NotComposedEach(context.Background(), "", map[string]string{"idle": "idle says ${config:name}, and nothing sets it"})
if h := b.haves.last("idle"); h.Phase != string(delivery.PhaseFailed) || h.Composed != idleDig || !strings.Contains(h.Detail, "${config:name}") {
if h := b.haves.last("idle"); h.Phase != string(nodeengineclient.PhaseFailed) || h.Composed != idleDig || !strings.Contains(h.Detail, "${config:name}") {
t.Errorf("idle's have while it alone is held: %+v", h)
}
if h := b.haves.last("web"); h.Phase != string(delivery.PhaseRunning) || h.Composed != dig {
if h := b.haves.last("web"); h.Phase != string(nodeengineclient.PhaseRunning) || h.Composed != dig {
t.Errorf("web, which composes, is marked beside idle: %+v", h)
}
b.ag.NotComposedEach(context.Background(), "", nil)
if h := b.haves.last("idle"); h.Phase != string(delivery.PhaseRunning) || h.Composed != idleDig {
if h := b.haves.last("idle"); h.Phase != string(nodeengineclient.PhaseRunning) || h.Composed != idleDig {
t.Errorf("idle's have once it composes again: %+v", h)
}
// A revision at the same build (a config value changed) is applied.
@@ -1549,7 +1675,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
b.comps.put(dk, drawMoved)
eventually(t, "db's revision did not wait for a yes", func() bool {
h := b.haves.last("db")
return h.Phase == string(delivery.PhaseWaitingYes) && h.Waits == ddigMoved
return h.Phase == string(nodeengineclient.PhaseWaitingYes) && h.Waits == ddigMoved
})
if h := b.haves.last("db"); h.BuildAlone {
t.Errorf("a revision at the build it runs is said to change the build alone: %+v", h)
@@ -1559,14 +1685,14 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
keep(dk, draw)
keep(dk, drawMoved)
view, err := b.ag.Composition(context.Background(), "db", "", true)
if err != nil || view.Have == nil || view.Have.Phase != string(delivery.PhaseWaitingYes) || view.Composition.Digest != ddigMoved ||
view.Runs == nil || view.Runs.Composed != ddig || view.Judged.Judgement != string(delivery.JudgedRecreates) ||
if err != nil || view.Have == nil || view.Have.Phase != string(nodeengineclient.PhaseWaitingYes) || view.Composition.Digest != ddigMoved ||
view.Runs == nil || view.Runs.Composed != ddig || view.Judged.Judgement != string(nodeengineclient.JudgedRecreates) ||
!view.Judged.WaitsForYes || len(view.Diff) != 1 || view.Diff[0].ID != "db.server" || view.Diff[0].Change != "changed" ||
len(view.Revisions) != 2 {
t.Errorf("the composition of db waiting for a yes reads %+v (%v)", view, err)
}
if view, err := b.ag.Composition(context.Background(), "db", ddig, false); err != nil || view.Composition.Digest != ddig ||
view.Judged.Judgement != string(delivery.JudgedUnchanged) || view.Judged.WaitsForYes || view.Diff != nil {
view.Judged.Judgement != judge.JudgedUnchanged || view.Judged.WaitsForYes || view.Diff != nil {
t.Errorf("db's running revision, asked by its digest, reads %+v (%v)", view, err)
}
if _, err := b.ag.Composition(context.Background(), "db", "sha256:"+strings.Repeat("0", 64), false); err == nil {
@@ -1574,8 +1700,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
}
// Issue 774 (ace, 2026-11-04 01:29): the node's own revision shows its own fields, the provisioner client's account
// and the adoption among them, and what the composer's last pass held.
ownKey, ownRaw, ownDig := encodedAs(t, delivery.Composed{Node: node, Own: delivery.NodeOwn{Provisioner: "mesh-provisioner-client",
Adoption: &declaration.Adoption{Taken: []string{"web"}}}}, func() string { k, _ := delivery.ComposedNodeKey(node); return k }())
ownKey, ownRaw, ownDig := encodedAs(t, compositions.Composed{Node: node, Own: compositions.NodeOwn{Provisioner: "mesh-provisioner-client",
Adoption: &declaration.Adoption{Taken: []string{"web"}}}}, func() string { k, _ := compositions.NodeKey(node); return k }())
keep(ownKey, ownRaw)
if view, err := b.ag.Composition(context.Background(), NodeModule, ownDig, false); err != nil || view.Own == nil ||
view.Own.Provisioner != "mesh-provisioner-client" || view.Own.Adoption == nil || view.Own.Adoption.Taken[0] != "web" ||
@@ -1584,9 +1710,9 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
}
// novox/hq ADR 0593 §6: the engine publishes each composition with its plan in its composition state; the verb reads
// that revision by the digest its have and want name, its resources' (the plan left out of the digest).
planned, _ := delivery.DecodeComposed(draw)
planned, _ := compositions.Decode(draw)
planned.Plan = &catalogue.Plan{}
praw, err := delivery.EncodeComposed(planned)
praw, err := compositions.Encode(planned)
if err != nil {
t.Fatal(err)
}
@@ -1598,7 +1724,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
b.comps.put(dk, draw)
eventually(t, "db's have stays waiting-yes after its composition went back to what runs", func() bool {
h := b.haves.last("db")
return h.Phase == string(delivery.PhaseRunning) && h.Composed == ddig && h.Waits == ""
return h.Phase == string(nodeengineclient.PhaseRunning) && h.Composed == ddig && h.Waits == ""
})
if got := len(b.appliedList()); got != 4 {
t.Fatalf("a composition equal to what runs was applied again: %d applies", got)
@@ -1609,8 +1735,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
// node-engine restart it): its have alone says what runs. Its move waits for a yes as db's does, and its have keeps
// what runs (fingerprint, digest, sealed values) rather than naming nothing (review of L2, round 3).
recFP, recDig := fpOf("d"), "sha256:"+strings.Repeat("8", 64)
b.haves.Write(context.Background(), Have{Module: "rec", FP: recFP, Composed: recDig, Phase: string(delivery.PhaseRunning),
Sealed: delivery.Seals{"rec.key#sealed": `{"box":"rec"}`}})
b.haves.Write(context.Background(), Have{Module: "rec", FP: recFP, Composed: recDig, Phase: string(nodeengineclient.PhaseRunning),
Sealed: compositions.Seals{"rec.key#sealed": `{"box":"rec"}`}})
rk, rraw, rdig := unsigned("rec", fpB, container("rec@sha256:"+strings.Repeat("3", 64)))
b.comps.put(dk2, draw2)
b.comps.put(rk, rraw)
@@ -1619,12 +1745,12 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
applied := len(b.appliedList())
for _, row := range []struct {
module, fp, composed, waits string
sealed delivery.Seals
sealed compositions.Seals
alone bool
}{{"db", fp, "", ddig2, nil, false}, {"rec", recFP, recDig, rdig, delivery.Seals{"rec.key#sealed": `{"box":"rec"}`}, true}} {
}{{"db", fp, "", ddig2, nil, false}, {"rec", recFP, recDig, rdig, compositions.Seals{"rec.key#sealed": `{"box":"rec"}`}, true}} {
eventually(t, row.module+"'s move did not wait for a yes", func() bool {
h := b.haves.last(row.module)
return h.Phase == string(delivery.PhaseWaitingYes) && h.Waits == row.waits
return h.Phase == string(nodeengineclient.PhaseWaitingYes) && h.Waits == row.waits
})
h := b.haves.last(row.module)
if h.FP != row.fp || (row.composed != "" && h.Composed != row.composed) || h.Composed == "" ||
@@ -1642,8 +1768,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
want("rec", fpB, rdig)
eventually(t, "the yes did not apply the move", func() bool {
h, r := b.haves.last("db"), b.haves.last("rec")
return len(b.appliedList()) == applied+2 && h.Phase == string(delivery.PhaseRunning) && h.FP == fpB &&
r.Phase == string(delivery.PhaseRunning) && r.FP == fpB && r.Composed == rdig
return len(b.appliedList()) == applied+2 && h.Phase == string(nodeengineclient.PhaseRunning) && h.FP == fpB &&
r.Phase == string(nodeengineclient.PhaseRunning) && r.FP == fpB && r.Composed == rdig
})
}
@@ -1683,14 +1809,14 @@ func testAnEngineStartReconcilesAModuleFromTheMachinesFacts(t *testing.T) {
}
if c.held {
b.haves.stored = map[string]Have{"web": {Module: "web", FP: fpOf("0"), Composed: "sha256:" + strings.Repeat("0", 64),
Phase: string(delivery.PhaseHeld), Detail: "restic: the repository is locked"}}
Phase: string(nodeengineclient.PhaseHeld), Detail: "restic: the repository is locked"}}
}
if c.kept {
b.kept = map[string][]json.RawMessage{"web": rs}
}
var mu sync.Mutex
asked := false
b.env.Inactive = func(context.Context, delivery.Composed) bool {
b.env.Inactive = func(context.Context, compositions.Composed) bool {
mu.Lock()
defer mu.Unlock()
asked = true
@@ -1734,7 +1860,7 @@ func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone(
stop := b.run()
defer stop()
eventually(t, "web was not settled", func() bool { return b.haves.last("web").Composed == digW })
eventually(t, "db was not held", func() bool { return b.haves.last("db").Phase == string(delivery.PhaseHeld) })
eventually(t, "db was not held", func() bool { return b.haves.last("db").Phase == string(nodeengineclient.PhaseHeld) })
if got := b.appliedList(); len(got) != 0 {
t.Fatalf("applied at the start: %v", got)
}
@@ -1749,13 +1875,13 @@ func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone(
if got := b.appliedList(); len(got) != 0 {
t.Fatalf("a held module and an unwanted one were applied: %v", got)
}
if h := b.haves.last("db"); h.Phase != string(delivery.PhaseHeld) {
if h := b.haves.last("db"); h.Phase != string(nodeengineclient.PhaseHeld) {
t.Fatalf("the held module's have changed: %+v", h)
}
b.ag.Reconcile("web")
eventually(t, "web was not applied again", func() bool { return len(b.appliedList()) == 1 })
eventually(t, "web is not running again", func() bool { return b.haves.last("web").Phase == string(delivery.PhaseRunning) })
eventually(t, "web is not running again", func() bool { return b.haves.last("web").Phase == string(nodeengineclient.PhaseRunning) })
b.mu.Lock()
b.failWith = "systemctl start web.service: exit status 1"
@@ -1763,7 +1889,7 @@ func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone(
b.ag.Reconcile("web")
eventually(t, "the failed re-apply did not write the have failed with its words", func() bool {
h := b.haves.last("web")
return h.Phase == string(delivery.PhaseFailed) && h.Detail == "systemctl start web.service: exit status 1"
return h.Phase == string(nodeengineclient.PhaseFailed) && h.Detail == "systemctl start web.service: exit status 1"
})
time.Sleep(50 * time.Millisecond)
if got := b.appliedList(); len(got) != 2 {
+13 -12
View File
@@ -4,10 +4,11 @@ import (
"context"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
engcomp "github.com/novox/mesh-host/internal/compositions"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/judge"
)
@@ -45,7 +46,7 @@ type CompositionView struct {
Own *OwnView `json:"own,omitempty"`
}
// OwnView is the node's own fields of a composition (delivery.NodeOwn) beside what runs of them, and what the node's
// OwnView is the node's own fields of a composition (engcomp.NodeOwn) beside what runs of them, and what the node's
// composer held in its last pass.
type OwnView struct {
// Provisioner is the account the provisioner's client runs as, as the revision read names it; RunsProvisioner as
@@ -146,7 +147,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
have = &h
phase := h.Phase
if phase == "" {
phase = string(delivery.PhaseRunning)
phase = string(nodeengineclient.PhaseRunning)
}
view.Have = &HaveView{FP: h.FP, Composed: h.Composed, Phase: phase, Detail: h.Detail, Judgement: h.Judgement, Waits: h.Waits}
}
@@ -155,8 +156,8 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
w := a.wants[module]
fp := ""
if w.Present {
fp = w.Want.FP
view.Want = &WantView{FP: w.Want.FP, Composed: w.Want.Composed, Yes: w.Want.Yes, Removed: w.Want.Removed,
fp = w.Want.Fp
view.Want = &WantView{FP: w.Want.Fp, Composed: w.Want.Composed, Yes: w.Want.Yes, Removed: w.Want.Removed,
Self: w.Want.Self, Backup: w.Want.Backup, Words: w.Want.Words, Revision: w.Rev}
} else if have != nil {
fp = have.FP
@@ -218,12 +219,12 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
view.Composition.Refused = read.Err.Error()
return view, nil
}
var running *delivery.Composed
var running *engcomp.Composed
if known {
running = &from
rv := &RunsView{From: source, Resources: len(from.Resources), FP: from.FP}
if ran {
rv.FP, rv.Composed, rv.Phase = r.FP, r.Composed, string(r.Phase)
rv.FP, rv.Composed, rv.Phase = r.Fp, r.Composed, string(r.Phase)
}
view.Runs = rv
}
@@ -242,7 +243,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
view.Judged = JudgedView{Judgement: string(j.Judgement), Words: j.Words}
if a.env.SelfComposed && a.env.Policy != nil && module != NodeModule && (!w.Present || w.Want.Yes != read.Dig) {
switch j.Judgement {
case delivery.JudgedRecreates, delivery.JudgedMovesData, delivery.JudgedCannot:
case nodeengineclient.JudgedRecreates, nodeengineclient.JudgedMovesData, nodeengineclient.JudgedCannot:
view.Judged.WaitsForYes = a.env.Policy(ctx, module) == catalogue.PolicyRecord
}
}
@@ -255,9 +256,9 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
// compositionKey is a module's composition key at a build, or the node's own.
func (a *Agent) compositionKey(module, fp string) (string, error) {
if module == NodeModule {
return delivery.ComposedNodeKey(a.env.Node)
return engcomp.NodeKey(a.env.Node)
}
return delivery.ComposedKey(a.env.Node, module, fp)
return engcomp.Key(a.env.Node, module, fp)
}
// revisionsOf is every revision of a composition key the state keeps, oldest first, each with its digest (unsigned, ADR
@@ -276,12 +277,12 @@ func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error)
if u.Deleted || u.Key != key {
continue
}
c, err := delivery.DecodeComposed(u.Value)
c, err := engcomp.Decode(u.Value)
if err != nil {
out = append(out, compNow{Raw: u.Value, Rev: u.Revision, Err: err})
continue
}
dig, err := delivery.ComposedDigestOf(c) // unsigned: a record, never verified (ADR 0593 §6)
dig, err := engcomp.DigestOf(c) // unsigned: a record, never verified (ADR 0593 §6)
out = append(out, compNow{Raw: u.Value, C: c, Dig: dig, Rev: u.Revision, Err: err})
}
return out, nil
+103 -6
View File
@@ -939,6 +939,13 @@ func applyScoped(
}
}
setRunnerWarnings(warnings)
// **Every new image of a module is on the machine before any of its containers is set aside** (design 51, issue 1966
// ENG-7). A container replaced after the first one's turn pulled its image only then, and for a module whose first
// container serves the store its second image comes from, that pull met the stopped one. A pull that fails
// replaces nothing of the module: its containers keep running what they ran, the failure names the image, and what
// follows in the module is skipped as it is for any step that did not complete.
in.pulled = map[string]bool{}
pullFirst(ctx, ordered, d, in.pulled, run, &failures, gated, gatedWhy, log)
for i, resource := range ordered {
if !orphansRemoved && i == guardFirst {
// **Only a guard that is up may let the filter go.** Removing the derived filter's
@@ -2759,6 +2766,9 @@ type inputs struct {
files map[string]bool
// handed is every group a record that went this apply handed to a resource still asking for it.
handed Handed
// pulled is every image this apply found on the machine, or pulled, before any container was replaced (pullFirst):
// applyContainer does not ask for it again.
pulled map[string]bool
}
// fileDigest is what a file the container reads holds, by digest.
@@ -3165,8 +3175,10 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner,
// gone: an image the registry does not hold then left the service down and every later apply
// failing. Pulled (or found present) first, an unobtainable image fails this resource loudly,
// naming the image, and the old container keeps running.
if err := ensureImage(ctx, cri, r.Image, run); err != nil {
return out, fmt.Errorf("not replacing container %s, which keeps running: %w", r.Name, err)
if !in.pulled[r.Image] {
if err := ensureImage(ctx, cri, r.Image, run); err != nil {
return out, fmt.Errorf("not replacing container %s, which keeps running: %w", r.Name, err)
}
}
// **The running container is set aside, never removed, until its replacement runs** (issue 1945): stopped and
// renamed, so its name and ports are free, and started again under its own name when the replacement does not
@@ -3469,16 +3481,17 @@ func ensureImage(ctx context.Context, cri, image string, run Runner) error {
// already hold — built here, or handed over. There is no registry that answers for it, so
// pulling would fail somewhere that names a network problem instead of a missing image.
if strings.HasPrefix(image, "sha256:") {
return fmt.Errorf(
return &PullError{Image: image, Started: time.Now(), Err: fmt.Errorf(
"image %s is not on this machine, and an image named by its own digest cannot be "+
"fetched: nothing serves it. Build it here, or load it, before applying this",
image)
image)}
}
started := time.Now()
if _, err := run(ctx, cri, "pull", image); err != nil {
return fmt.Errorf("pulling image %s: %w", image, err)
return &PullError{Image: image, Started: started, Err: fmt.Errorf("pulling image %s: %w", image, err)}
}
if _, err := run(ctx, cri, "image", "inspect", image); err != nil {
return fmt.Errorf("image %s is not present after pulling it: %w", image, err)
return &PullError{Image: image, Started: time.Now(), Err: fmt.Errorf("image %s is not present after pulling it: %w", image, err)}
}
return nil
}
@@ -4340,3 +4353,87 @@ func ownerText(info os.FileInfo) string {
}
return fmt.Sprintf("%d:%d", uid, gid)
}
// pullFirst finds on the machine, or pulls, the image of each container the declaration runs before any is replaced
// (ENG-7) for a module of two containers or more. A container that is run once is left to its marker, and a runtime that
// does not answer to applyContainer, which says so. The first image that cannot be had fails its container with the image named and gates its module, so none of
// its containers is touched; the images found before it stay pulled.
func pullFirst(ctx context.Context, ordered []declaration.Resource, d *declaration.Declaration, pulled map[string]bool,
run Runner, failures *[]*Error, gated map[string]bool, gatedWhy map[string]string, log func(string)) {
// A module of one container already pulls its image before it sets that container aside (applyContainer), and nothing
// of it comes before: only a module of two or more has an image pulled after one of its containers was replaced.
count := map[string]int{}
for _, resource := range ordered {
if c, isContainer := resource.(*declaration.Container); isContainer && c.Image != "" && !c.RunOnce && c.Schedule == "" {
module, _ := d.ModuleOfID(c.Identity())
count[module]++
}
}
var cri string
for _, resource := range ordered {
c, isContainer := resource.(*declaration.Container)
if !isContainer || c.Image == "" || c.RunOnce || c.Schedule != "" || pulled[c.Image] {
continue
}
module, ours := d.ModuleOfID(c.Identity())
if count[module] < 2 {
continue
}
if ours && gated[module] {
continue
}
if cri == "" {
var err error
if cri, err = containerRuntime(ctx, run); err != nil {
return
}
}
if err := ensureImage(ctx, cri, c.Image, run); err != nil {
err = fmt.Errorf("not replacing container %s, or any other container of its module, which keep running: %w", c.Name, err)
*failures = append(*failures, &Error{Resource: c.Identity(), Err: err})
log(fmt.Sprintf(" failed %s (%s): %v", c.Identity(), c.Target(), err))
if ours {
gated[module] = true
gatedWhy[module] = "its image " + c.Image + " could not be had, so none of its containers was replaced"
}
continue
}
pulled[c.Image] = true
}
}
// PullError is an image that could not be found or pulled, as ensureImage fails: what the node agent reads to tell a failed
// pull from another failed apply (ENG-8).
type PullError struct {
Image string
// Started is when the pull began, which the node agent compares with when a store's holder settled (ENG-11).
Started time.Time
Err error
}
// PullErrors are the pull errors of every failure an apply reports, the first among them and the rest in Failures, in the
// order they happened: what the node agent tells a failed pull from another failed apply by (ENG-8). Nil for an error
// that is no apply's, or whose failures met none.
func PullErrors(err error) []*PullError {
var out []*PullError
var e *Error
if errors.As(err, &e) {
for _, f := range e.Failures {
var p *PullError
if errors.As(f.Err, &p) {
out = append(out, p)
}
}
if len(out) > 0 {
return out
}
}
var p *PullError
if errors.As(err, &p) {
out = append(out, p)
}
return out
}
func (e *PullError) Error() string { return e.Err.Error() }
func (e *PullError) Unwrap() error { return e.Err }
+67
View File
@@ -47,6 +47,63 @@ func TestARunningContainerIsKeptWhenItsReplacementImageCannotBeObtained(t *testi
if !strings.Contains(err.Error(), pinned) {
t.Errorf("the failure does not name the image %s: %v", pinned, err)
}
var pull *PullError
if !errors.As(err, &pull) || pull.Image != pinned {
t.Errorf("the failure is not a pull error naming %s: %v", pinned, err)
}
// design 51, ENG-7: a module of two containers whose second new image cannot be pulled replaces neither, and says the
// image; with both pullable, both are pulled before the first is set aside.
second := strings.Replace(pinned, "postgres", "other", 1)
two := `{"declaration":1,"resources":[
{"id":"a.first","type":"container","name":"first","image":"` + pinned + `","env":{"X":"1"}},
{"id":"a.second","type":"container","name":"second","image":"` + second + `","env":{"X":"2"}}
]}`
for _, c := range []struct {
name string
unpullable string
wantTouched bool
wantPulled string
}{
{"the second image cannot be pulled", second, false, "pull " + pinned},
{"both can", "", true, "pull " + pinned + "; pull " + second},
} {
d := parseTrusted(t, two)
var steps []string
present := map[string]bool{}
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch args[0] {
case "info":
return "27.0\n", nil
case "container":
return "true\tsome-older-spec", nil
case "image":
if present[args[len(args)-1]] {
return "", nil
}
return "", errors.New("no such image")
case "pull":
if args[1] == c.unpullable {
return "", errors.New("manifest unknown")
}
present[args[1]] = true
steps = append(steps, "pull "+args[1])
case "rm", "rename", "stop", "run":
steps = append(steps, args[0]+" "+strings.Join(args[1:], " "))
}
return "", nil
}
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
got := strings.Join(steps, "; ")
touched := strings.Contains(got, "rename") || strings.Contains(got, "run ")
if touched != c.wantTouched || !strings.HasPrefix(got, c.wantPulled) {
t.Errorf("%s: steps %q (touched %v), want pulls %q first and touched=%v", c.name, got, touched, c.wantPulled, c.wantTouched)
}
if !c.wantTouched && (err == nil || !strings.Contains(err.Error(), second)) {
t.Errorf("%s: the failure does not name %s: %v", c.name, second, err)
}
}
}
func TestAReplacementImageIsPresentBeforeTheRunningContainerIsRemoved(t *testing.T) {
@@ -54,6 +111,16 @@ func TestAReplacementImageIsPresentBeforeTheRunningContainerIsRemoved(t *testing
{"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}}
]}`)
want := containerSpec(d.Resources[0].(*declaration.Container), inputs{})
// The seat and holder a routed image names (ENG-10) are no part of what a container is: another holder is the same spec,
// so a declaration composed again for it replaces no container.
for _, holder := range []string{"distribution", "elsewhere"} {
named := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"store","image":"`+pinned+`","image-seat":"mesh-artifact-store","image-holder":"`+holder+`","env":{"PGDATA":"/data"}}
]}`)
if got := containerSpec(named.Resources[0].(*declaration.Container), inputs{}); got != want {
t.Errorf("a container naming the holder %s has another spec than one naming none", holder)
}
}
var order []string
created := false
run := func(ctx context.Context, name string, args ...string) (string, error) {
+1 -2
View File
@@ -183,7 +183,7 @@ type Options struct {
// SDKSource is where the mesh's shared library is built from. It is published to the package
// registry before the base is built, because the base resolves it by version (novox/hq ADR 0076).
SDKSource Source
// Site is where this machine sits, for the private network's placement.
// Site is where this machine sits, for the private network's assignment.
Site string
// Answers are the choices, answered by flag: name → answer. What a terminal would be asked.
Answers map[string]string
@@ -815,7 +815,6 @@ func RegistryAndPublish(ctx context.Context, o Options, d Deps, sys system.Syste
if err != nil {
return Published{}, failed(StepRegistry, err)
}
// ---- 8. publish -----------------------------------------------------------------------
say("publish — the control plane's image gets its first manifest digest")
// **The image this mesh built, not the one the installer carried.** The carried one is the
+3 -2
View File
@@ -69,8 +69,9 @@ func adoptTheBus(ctx context.Context, o Options, control controlPlane, bus *decl
return nil
}
// meshBuiltImage is an image the mesh built, as it records it: `artifact-store://<module>/<artifact>@sha256:…`.
var meshBuiltImage = regexp.MustCompile(`artifact-store://([a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*@sha256:[0-9a-f]{64})`)
// meshBuiltImage is an image the mesh built, as it records it in either form it reads (ADR 0605 §9, issue 1966 ENG-4):
// `artifact-store://<module>/<artifact>@sha256:…`, or the registry seat's `container-image-registry/<module>/<artifact>@sha256:…`.
var meshBuiltImage = regexp.MustCompile(`(?:artifact-store://|container-image-registry/)([a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*@sha256:[0-9a-f]{64})`)
// theModulesBusImage is the nats module's server image, as the build just made it, pulled from this
// mesh's registry: the image that carries the snapshot program.
+8
View File
@@ -139,4 +139,12 @@ func TestTheBundlesBusRunsTheReleaseTheModulesImageIsBuiltOn(t *testing.T) {
if got, want := containerNamed(rewritten.Declaration, "mesh-broker-nats").Image, repository+"@"+digest; got != want {
t.Errorf("the bundle raises the bus on %s; the nats module's server is %s (%s)", got, want, from)
}
// The image the mesh built is read in either form it is recorded in (ENG-4).
sum := strings.Repeat("c", 64)
for _, said := range []string{"artifact-store://nats/server@sha256:" + sum, "container-image-registry/nats/server@sha256:" + sum} {
if m := meshBuiltImage.FindStringSubmatch("build 1: " + said); m == nil || m[1] != "nats/server@sha256:"+sum {
t.Errorf("%s is read as %v", said, m)
}
}
}
+1 -1
View File
@@ -146,7 +146,7 @@ func InstallFromCatalogue(ctx context.Context, o Options, control controlPlane,
//
// **Assigning is not being on the network** — a lesson paid for: the module installed, the names
// file was written with no names in it, and everything reported success, because nobody had said
// where this machine IS. So placement is part of the step, not a separate act.
// where this machine IS. So assignment is part of the step, not a separate act.
func PlaceOnTheNetwork(ctx context.Context, o Options, control controlPlane,
brokerAddress string, say func(string)) error {
+22 -15
View File
@@ -69,17 +69,24 @@ func PublishControlPlane(ctx context.Context, o Options, d Deps, imageID string,
func publishAs(ctx context.Context, o Options, d Deps, imageID, repository string,
say func(string)) (Published, error) {
remote := o.Registry + "/" + repository
// Pushed to the store's own loopback with no credential (StoreLoopback); recorded under the seat's reach, the
// address every node pulls by, with the digest the store gave.
loopback, err := StoreLoopback(o.Catalogue)
if err != nil {
return Published{}, err
}
remote := loopback + "/" + repository
out := Published{Tagged: remote + ":" + genesisTag}
reach := func(digest string) string { return o.Registry + "/" + repository + strings.TrimPrefix(digest, remote) }
// Asked first. A digest already served is a fact about the registry, and re-pushing an image
// the registry already holds is asking it to store what it already has under the name it
// already has.
if held, err := digestOf(ctx, o, d, remote); err != nil {
if held, err := digestOf(ctx, o, d, loopback, repository); err != nil {
return out, err
} else if held != "" {
out.Reference, out.Already = held, true
say(" already published " + held)
out.Reference, out.Already = reach(held), true
say(" already published " + out.Reference)
return out, nil
}
@@ -93,12 +100,12 @@ func publishAs(ctx context.Context, o Options, d Deps, imageID, repository strin
"over the mesh's own network, which is already the encrypted and authenticated "+
"thing. A runtime refusing it for being insecure is refusing a registry on %s, "+
"which it does not do for a loopback address",
out.Tagged, err, o.Registry)
out.Tagged, err, loopback)
}
// Read back, from the registry's own answer rather than computed here. What matters is what
// the registry will serve for that reference, and only it can say (novox/hq ADR 0018).
pinned, err := digestOf(ctx, o, d, remote)
pinned, err := digestOf(ctx, o, d, loopback, repository)
if err != nil {
return out, err
}
@@ -107,10 +114,10 @@ func publishAs(ctx context.Context, o Options, d Deps, imageID, repository strin
"%s was pushed and the registry does not serve it.\n"+
"The next step names this module by the digest this was supposed to produce, so "+
"there is nothing to name. Check `docker push` and "+
"http://%s/v2/%s/tags/list", out.Tagged, o.Registry, repository)
"http://%s/v2/%s/tags/list", out.Tagged, loopback, repository)
}
out.Reference = pinned
say(" published " + pinned)
out.Reference = reach(pinned)
say(" published " + out.Reference)
return out, nil
}
@@ -121,13 +128,13 @@ func publishAs(ctx context.Context, o Options, d Deps, imageID, repository strin
// whether the registry still has it — a registry whose volume was recreated would leave the
// runtime remembering a digest nothing serves, and the module registered against it would pin the
// mesh to an image that cannot be pulled.
func digestOf(ctx context.Context, o Options, d Deps, remote string) (string, error) {
func digestOf(ctx context.Context, o Options, d Deps, store, repository string) (string, error) {
remote := store + "/" + repository
asking, cancel := context.WithTimeout(ctx, o.Timeout)
status, body, err := d.Fetch(asking,
"http://"+o.Registry+"/v2/"+ControlPlaneRepository+"/tags/list")
status, body, err := d.Fetch(asking, "http://"+store+"/v2/"+repository+"/tags/list")
cancel()
if err != nil {
return "", fmt.Errorf("cannot ask the registry at %s what it holds: %w", o.Registry, err)
return "", fmt.Errorf("cannot ask the registry at %s what it holds: %w", store, err)
}
if status == http.StatusNotFound {
// Nothing has ever been pushed under this name. An answer, not a failure.
@@ -135,14 +142,14 @@ func digestOf(ctx context.Context, o Options, d Deps, remote string) (string, er
}
if status != http.StatusOK {
return "", fmt.Errorf("the registry answered %d when asked what it holds for %s",
status, ControlPlaneRepository)
status, repository)
}
var listed struct {
Tags []string `json:"tags"`
}
if err := json.Unmarshal([]byte(body), &listed); err != nil {
return "", fmt.Errorf("the registry's answer about %s is not readable: %w",
ControlPlaneRepository, err)
repository, err)
}
if !contains(listed.Tags, genesisTag) {
return "", nil
+2 -1
View File
@@ -22,7 +22,8 @@ func publishing(t *testing.T, fetch func(string) (int, string, error),
// Each value named before it is returned: a return of several composite literals spanning lines is laid
// out differently by the gofmt of one Go release and the next, and the build seat's is the one that
// judges (novox/hq issue 286).
options := Options{Registry: "127.0.0.1:5000", Timeout: time.Second, Wait: 0}
options := Options{Registry: "127.0.0.1:5000", Timeout: time.Second, Wait: 0,
Catalogue: catalogueWith(t, RegistryModule, upstreamRegistryManifest)}
deps := Deps{
Run: runtime.run,
Fetch: func(_ context.Context, url string) (int, string, error) {
+50 -5
View File
@@ -9,10 +9,10 @@ import (
"time"
)
// RegistryModule is the module that provides the mesh's artifact store.
// RegistryModule is the module that holds the mesh's artifact store.
//
// **Named for the software, not the job.** The job is `artifact-store`, which is the provision this
// module offers; the software is Distribution, the OCI reference implementation. Calling the module
// **Named for the software, not the job.** The job is the artifact store, which this module holds as
// the seat container-image-registry (novox/hq ADR 0632 §1); the software is Distribution, the OCI reference implementation. Calling the module
// `registry` named neither — and promised that any registry could sit there, which is the false
// genericity the naming rule forbids (novox/hq ADR 0040).
const RegistryModule = "distribution"
@@ -95,9 +95,14 @@ func InstallRegistry(ctx context.Context, o Options, d Deps, control controlPlan
return out, nil
}
// waitForTheRegistry asks `/v2/` until it answers.
// waitForTheRegistry asks the store's own `/v2/`, on its loopback (StoreLoopback), until it answers: the pushes that
// follow go there, and the store's front is not served yet on a fresh mesh.
func waitForTheRegistry(ctx context.Context, d Deps, o Options, say func(string)) (int, error) {
where := "http://" + o.Registry + "/v2/"
loopback, err := StoreLoopback(o.Catalogue)
if err != nil {
return 0, err
}
where := "http://" + loopback + "/v2/"
deadline := time.Now().Add(o.Wait)
var last string
@@ -207,3 +212,43 @@ func containerIn(manifest []byte, id string) (name, image string, err error) {
"the %s module declares no container %q, so the installer does not know what to wait for. "+
"It declares: %s", m.Module, id, strings.Join(containers, ", "))
}
// storeContainerPort is the port the registry image listens on inside its container (Distribution's own default).
const storeContainerPort = "5000"
// StoreLoopback is host:port where this machine reaches the store's container itself, read from the port its manifest
// publishes for it: on a fresh mesh the bootstrap pushes the holders' own images there, with no credential, because the
// store's front (distribution's program, a bundle the mesh builds) is not served until the builder has built it (the
// publish contract allows a push on the store's own node to its loopback). A port published on every address is reached
// on 127.0.0.1. What the bootstrap records is named under the seat's reach (Options.Registry), never this address.
func StoreLoopback(catalogue string) (string, error) {
manifest, err := readManifest(catalogue, RegistryModule)
if err != nil {
return "", err
}
var m struct {
Resources []struct {
ID string `json:"id"`
Ports []string `json:"ports"`
} `json:"resources"`
}
if err := json.Unmarshal(manifest, &m); err != nil {
return "", fmt.Errorf("the %s manifest is not readable as JSON: %w", RegistryModule, err)
}
for _, r := range m.Resources {
if r.ID != registryResource {
continue
}
for _, p := range r.Ports {
parts := strings.Split(strings.SplitN(p, "/", 2)[0], ":")
switch {
case len(parts) == 3 && parts[2] == storeContainerPort:
return parts[0] + ":" + parts[1], nil
case len(parts) == 2 && parts[1] == storeContainerPort:
return "127.0.0.1:" + parts[0], nil
}
}
}
return "", fmt.Errorf("the %s module's container %q publishes no port for the store's %s, so the bootstrap cannot "+
"reach the store to push to it", RegistryModule, registryResource, storeContainerPort)
}
+29 -3
View File
@@ -226,8 +226,15 @@ func TestARangeTheRuntimeTrustsCoversAnAddressInIt(t *testing.T) {
// moving the trust after the push, or dropping it, fails this test.
func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) {
o, runtime, sys := trustSetup(t, `{"data-root":"/srv/docker"}`)
o.Catalogue = catalogueWith(t, RegistryModule, upstreamRegistryManifest)
// The store as distribution's manifest declares it: its container on this machine's loopback, its front not yet served,
// and its state directory, where no credential is ever written (design 51 *On a fresh mesh*, a design limit of issue 1966).
stateDir := t.TempDir()
o.Catalogue = catalogueWith(t, RegistryModule, strings.NewReplacer(
`"path": "/var/lib/mesh/registry"`, `"path": "`+stateDir+`"`,
`"ports": ["5000:5000"]`, `"ports": ["127.0.0.1:5001:5000"]`).Replace(upstreamRegistryManifest))
o.Registry = "192.0.2.10:5100"
const loopback = "127.0.0.1:5001"
var fetched []string
pushed := false
mesh := aMeshThatAgrees(nil)
@@ -241,7 +248,8 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) {
case "tag":
return "", nil
case "push":
trusted := false
// The runtime trusts a loopback registry with no configuration; any other only once told.
trusted := strings.HasPrefix(args[1], "127.0.0.1:")
for _, n := range runtime.trusted {
trusted = trusted || strings.HasPrefix(args[1], n+"/")
}
@@ -251,12 +259,13 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) {
pushed = true
return "", nil
case "image":
return `["` + o.Registry + `/mesh-controller@sha256:` + strings.Repeat("a", 64) + `"]`, nil
return `["` + loopback + `/mesh-controller@sha256:` + strings.Repeat("a", 64) + `"]`, nil
}
}
return mesh(name, args)
}
d := Deps{Run: run, Fetch: func(_ context.Context, url string) (int, string, error) {
fetched = append(fetched, url)
if strings.HasSuffix(url, "/v2/") || pushed {
return http.StatusOK, `{"name":"mesh-controller","tags":["genesis"]}`, nil
}
@@ -287,6 +296,23 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) {
t.Errorf("the trust was not written (%d) and confirmed (%d) before the first tag or push (%d): %v",
reloaded, confirmed, firstPush, runtime.commands)
}
// The push went to the store on its loopback, and nothing the run asked or pushed reached the seat's reach; the
// reference is named under the reach all the same, and no credential file was written for the store's holder.
for _, c := range runtime.commands {
if strings.HasPrefix(c, "docker tag") || strings.HasPrefix(c, "docker push") {
if !strings.Contains(c, " "+loopback+"/mesh-controller:") {
t.Errorf("a push not to the loopback store: %q", c)
}
}
}
for _, u := range fetched {
if !strings.HasPrefix(u, "http://"+loopback+"/v2/") {
t.Errorf("the run asked %s, not the loopback store", u)
}
}
if entries, err := os.ReadDir(stateDir); err != nil || len(entries) != 0 {
t.Errorf("the run wrote into the store's state directory: %v (%v)", entries, err)
}
if fmt.Sprint(result.RegistryTrusted) != "[192.0.2.10:5100 anchor.internal:5100]" {
t.Errorf("the result says it trusted %v", result.RegistryTrusted)
}
+85
View File
@@ -0,0 +1,85 @@
// Package buckets holds the names of the bus buckets the node-engine reads and writes, and how many revisions it keeps of
// each. A contract carries the local name of a state, not the module that holds it (ADR 0625 §4, §7); the node-engine
// reads through grants composed in code, so the holder's name is kept here, in its own code (ADR 0634 §1, finding F4).
package buckets
import (
"errors"
vault "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
wants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// The node-engine's own states and the have state, written by every node's engine for its own keys.
const (
// Have holds a module's have on a node, `<node>.<module>`, and the node's own facts, `<node>.node`.
Have = "node-engine_have"
// Data holds a module's data items on a node.
Data = "mesh-host_data"
// Health holds a node's own health report.
Health = "mesh-host_health"
)
// HaveHistory is the number of revisions the have bucket keeps per key.
const HaveHistory = 20
// The states of the assigner seat's holder and of the wants seat's holder that the engine reads, named by the holder's
// module as every state is (ADR 0634 §1, finding F4: the holder's name is kept here, in the engine's code).
const (
// Assignments holds a node's assignments, `<node>.node`, and each of its modules, `<node>.<module>`.
Assignments = AssignerHolder + "_" + seat.StateAssignments
// ProposalCandidates holds a node's candidate of a proposal, `<proposal>.<node>`.
ProposalCandidates = AssignerHolder + "_" + seat.StateCandidates
// Wants holds what each node should run, `want.<node>.<module>` and `want.<node>.node`.
Wants = AssignerHolder + "_" + wants.StateWants
// AssignerHolder is the module holding the seat mesh-module-assigner and the seat mesh-wants.
AssignerHolder = "mesh-delivery-assigner"
)
// The state of the provisioner seat's holder that the engine reads, named by the holder's module (ADR 0634 §1, finding F4).
const (
// ProvisionerHolder is the module holding the seat mesh-provisioner; Provisions its state provisions, which the engine
// watches for the key of a provision it fills (`<node>.>`), ProvisionsRead the name a manifest's reads gives it.
ProvisionerHolder = "mesh-provisioner"
Provisions = ProvisionerHolder + "_provisions"
ProvisionsRead = ProvisionerHolder + ".provisions"
)
// The states of the catalogue seat's holder that the engine reads, named by the holder's module (ADR 0634 §1, finding F4).
const (
// Modules holds a module's source, `<module>`; Manifests a manifest by its digest; Verdicts the judge's verdict of a
// manifest by its digest; Candidates a group's candidate, by the group's key part (CandidateKey).
Modules = CatalogHolder + "_modules"
Manifests = CatalogHolder + "_manifests"
Verdicts = CatalogHolder + "_verdicts"
Candidates = CatalogHolder + "_candidates"
// CatalogHolder is the module holding the seat mesh-catalog.
CatalogHolder = "mesh-delivery-indexer"
)
// CandidateKey is the key of a group's candidate in Candidates: the group as a key part, a branch such as `fix/1.2`
// escaped so it keys.
func CandidateKey(group string) (string, error) {
if group == "" {
return "", errors.New("buckets: the group name is empty")
}
return catalog.CandidatesByGroupKey(keys.EscapeName(group)), nil
}
// The states of the vault seat's holder that the engine reads, named by the holder's module (ADR 0634 §1, finding F4).
const (
// Signers holds the vault's public signing halves; NodeLogins each node's logins, `<node>`.
Signers = vaultHolder + "_" + vault.StateSigners
NodeLogins = vaultHolder + "_" + vault.StateNodeLogins
// SignersRead and NodeLoginsRead are the states as a manifest's reads names them.
SignersRead = vaultHolder + "." + vault.StateSigners
NodeLoginsRead = vaultHolder + "." + vault.StateNodeLogins
vaultHolder = "mesh-vault"
)
+72
View File
@@ -0,0 +1,72 @@
package buckets
import (
"strings"
"encoding/json"
"git.novox.be/novox/mesh-sdk/go/keys"
"os"
"testing"
)
// The engine's own states are named <module>_<state> by the manifest; a constant that drifts from it reads and writes a
// bucket nobody else knows (ADR 0634 §1, finding F4: the holder's name is kept in code and held to the manifest by this row).
func TestTheOwnStatesAreTheManifestsStates(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Module string `json:"module"`
State []struct {
Name string `json:"name"`
} `json:"state"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
declared := map[string]bool{}
for _, s := range m.State {
declared[m.Module+"_"+s.Name] = true
}
for name, bucket := range map[string]string{"data": Data, "health": Health} {
if !declared[bucket] {
t.Errorf("the bucket %s for the state %s is not one the manifest of %s declares (%v)", bucket, name, m.Module, declared)
}
}
}
// A group's candidate key is its name as a key part: a branch with a slash keeps it, a dot is escaped, and an empty group does not.
func TestACandidateKeyEscapesTheGroup(t *testing.T) {
for _, group := range []string{"mesh-sdk#7", "fix/940-no-retire", "fix/1.2"} {
key, err := CandidateKey(group)
if err != nil {
t.Fatalf("%s: %v", group, err)
}
if strings.Contains(key, ".") {
t.Errorf("the key %q of %q holds a dot", key, group)
}
if back, err := keys.UnescapeName(key); err != nil || back != group {
t.Errorf("%q came back as %q (%v)", group, back, err)
}
}
if _, err := CandidateKey(""); err == nil {
t.Error("a key was made for no group")
}
}
// The holders' buckets are the names the grants part grants and the holders' manifests declare: no client carries a
// holder (ADR 0625 §4), so these rows are the one place the node-engine's code says them.
func TestTheHoldersBucketsAreTheirStatesBuckets(t *testing.T) {
for name, c := range map[string][2]string{
"Assignments": {Assignments, "mesh-delivery-assigner_assignments"}, "ProposalCandidates": {ProposalCandidates, "mesh-delivery-assigner_candidates"},
"Wants": {Wants, "mesh-delivery-assigner_wants"}, "Modules": {Modules, "mesh-delivery-indexer_modules"},
"Manifests": {Manifests, "mesh-delivery-indexer_manifests"}, "Verdicts": {Verdicts, "mesh-delivery-indexer_verdicts"},
"Candidates": {Candidates, "mesh-delivery-indexer_candidates"}, "Provisions": {Provisions, "mesh-provisioner_provisions"},
"ProvisionsRead": {ProvisionsRead, "mesh-provisioner.provisions"},
} {
if c[0] != c[1] {
t.Errorf("%s = %q, want %q", name, c[0], c[1])
}
}
}
@@ -0,0 +1,62 @@
// Package bucketstest writes the values of the states the engine only reads (a want, a assignment candidate) as their
// holders do, for the tests that stand in for the holders.
package bucketstest
import (
"encoding/json"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
)
// Version is the value version the holders write.
const Version = 1
// Want is a module's want as the wants seat's holder writes it.
func Want(w meshwants.WantsByWantNodeModuleValue) ([]byte, error) {
w.V = Version
return json.Marshal(w)
}
// NodeWant is a node's own want as the wants seat's holder writes it.
func NodeWant(w meshwants.WantsByWantNodeNodeValue) ([]byte, error) {
w.V = Version
return json.Marshal(w)
}
// Candidate is a node's candidate of a proposal as the assignment seat's holder writes it.
func Candidate(c meshmoduleassigner.CandidatesByProposalNodeValue) ([]byte, error) {
c.V = Version
if c.Assignments != nil {
p := *c.Assignments
p.V = Version
c.Assignments = &p
}
return json.Marshal(c)
}
// GroupCandidate is a group's candidate as the catalogue seat's holder writes it.
func GroupCandidate(c meshcatalog.CandidatesByGroupValue) ([]byte, error) {
c.V = Version
return json.Marshal(c)
}
// Pull is one pull request of a candidate, from its head.
func Pull(head meshdeliveryclient.Head, modules []string, fps map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue) meshcatalog.CandidatesByGroupValuePullsItem {
return meshcatalog.CandidatesByGroupValuePullsItem{Repository: head.Repository, Number: int64(head.Number), Head: head.Head, Modules: modules, Fps: fps}
}
// Raw is each value as JSON, as a composer case carries verdicts and assignments.
func Raw[V any](in map[string]V) map[string]json.RawMessage {
out := map[string]json.RawMessage{}
for k, v := range in {
raw, err := json.Marshal(v)
if err != nil {
panic(err)
}
out[k] = raw
}
return out
}
+22 -19
View File
@@ -8,23 +8,26 @@ import (
"sort"
"strings"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
engcomp "github.com/novox/mesh-host/internal/compositions"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose/resolution"
"github.com/novox/mesh-host/internal/overlay"
"github.com/novox/mesh-host/internal/registryforms"
)
// Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as
// the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own
// (`<node>.node`), by key (delivery.ComposedKey). Each resource says its module, whatever the node reported it reads.
// (`<node>.node`), by key (engcomp.Key). Each resource says its module, whatever the node reported it reads.
// A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module
// gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the
// private network's always), which fps names at no fingerprint of the new form: its resources go into the node's own,
// their module taken off and their ids still naming it, and its filled paths with them, as the controller's composed
// writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are unsigned
// (ADR 0593 §6) and not yet by digest (Composed.ByDigest).
func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) {
func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]engcomp.Composed, skipped []string, err error) {
out, skipped, _, err = compositions(in, node, fps, mode, false)
return out, skipped, err
}
@@ -41,25 +44,25 @@ const BusUsersStandIn = "the bus's user list"
// 1126): a declaration that stands in for anything (Declared.StoodIn, Foreseen, ForeseenPorts) is refused with
// ErrStoodIn, naming each, so a stand-in is never applied as if it were the value; but for the bus's user list
// (BusUsersStandIn), which the bus node holds out of every apply of a composition it made itself.
func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]delivery.Composed, []string, error) {
func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]engcomp.Composed, []string, error) {
out, skipped, _, err := compositions(in, node, fps, Reading, true)
return out, skipped, err
}
// CompositionsShadow is CompositionsApplied for a composition that is compared and never applied (a node's answer to a
// placement candidate, novox/hq ADR 0559 §4): composed in the Foreseeing mode, so an own secret never issued (a module
// assignment candidate, novox/hq ADR 0559 §4): composed in the Foreseeing mode, so an own secret never issued (a module
// the candidate adds) is a named stand-in rather than an error, and what it stands in for is answered (stood, sorted,
// the bus's user list left out) rather than refused, so the caller judges each stand-in: a value not yet issued for a
// module the candidate adds, or one missing for a module the node runs. A module whose every value is held composes
// as CompositionsApplied composes it: the modes differ only in an own secret never issued.
func CompositionsShadow(in Input, node string, fps map[string]string) (out map[string]delivery.Composed, skipped, stood []string, err error) {
func CompositionsShadow(in Input, node string, fps map[string]string) (out map[string]engcomp.Composed, skipped, stood []string, err error) {
return compositions(in, node, fps, Foreseeing, false)
}
// CompositionsIn is the node's compositions in mode with what they stand in for answered (stood, sorted, the bus's user
// list left out) rather than refused: CompositionsShadow in Foreseeing, and in Reading what CompositionsApplied refuses
// before it is refused, so a caller that applies refuses each stand-in to the module it is for alone (issue 1126).
func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped, stood []string, err error) {
func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (out map[string]engcomp.Composed, skipped, stood []string, err error) {
return compositions(in, node, fps, mode, false)
}
@@ -69,10 +72,10 @@ func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (ou
// not folded (issue 1585: nats on novox, folded by the reader and composed at src2: here, refused for its server
// artifact no build had published).
func Folds(inOwn bool, fp string) bool {
return inOwn && !strings.HasPrefix(fp, delivery.SourcePrefix2)
return inOwn && !strings.HasPrefix(fp, meshcatalogclient.SourcePrefix2)
}
func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) {
func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]engcomp.Composed, skipped, stood []string, err error) {
reading := in
reading.Nodes = append([]Node(nil), in.Nodes...)
found := false
@@ -102,7 +105,7 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
// (Carried), so what it provides and requires still shapes the rest and what the others read of it (its published
// ports, the paths it owns) is what runs, and its resources are then the ones the node runs.
carried := map[string]bool{}
var runsOwn *delivery.Composed
var runsOwn *engcomp.Composed
for i := range reading.Nodes {
n := &reading.Nodes[i]
if n.Name != node || n.RunsOwn == nil {
@@ -264,13 +267,13 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
filled = append(filled, p)
}
}
out, err = delivery.SplitComposed(node, kept, fps, filled,
delivery.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner})
out, err = engcomp.Split(node, kept, fps, filled,
engcomp.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner})
if err != nil {
return nil, nil, nil, err
}
if len(ownFilled) > 0 {
own, err := delivery.ComposedNodeKey(node)
own, err := engcomp.NodeKey(node)
if err != nil {
return nil, nil, nil, err
}
@@ -296,7 +299,7 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
}
// RunsAnyOf says runs (a composition as it runs) holds a resource of module, by its module or the module its id names.
func RunsAnyOf(runs *delivery.Composed, module string) bool {
func RunsAnyOf(runs *engcomp.Composed, module string) bool {
if runs == nil {
return false
}
@@ -324,7 +327,7 @@ func RunsAnyOf(runs *delivery.Composed, module string) bool {
// (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads
// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build
// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process.
func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
func ToolsRunning(running []engcomp.Composed) map[string][]catalogue.Bundle {
type resource struct {
ID string `json:"id"`
Module string `json:"module"`
@@ -353,7 +356,7 @@ func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+resolution.BundleID("")):
name := strings.TrimPrefix(r.ID, owner+"."+resolution.BundleID(""))
if r.Path == resolution.BundlePath(owner, name) {
out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest})
out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: registryforms.Forms.Recorded(r.Source), Digest: r.Digest})
}
}
}
@@ -476,7 +479,7 @@ func Stub(m catalogue.Manifest) catalogue.Manifest {
// is composed into what is applied either way (compositions leaves a carried module's out, and carries the ones that
// run). What it provides, requires, listens on and grants is its manifest's. A module whose resources runs holds none
// of is its Stub, as before.
func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catalogue.Manifest {
func Carried(m catalogue.Manifest, module string, runs *engcomp.Composed) catalogue.Manifest {
if runs == nil {
return Stub(m)
}
@@ -521,7 +524,7 @@ func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catal
// carry puts into the node's resources (kept, in the declaration's order) the resources of the node's own composition as
// it runs (runs) that belong to a carried module, each after the resource it runs after, or first when that one is
// not among them; a file among them is a folded path.
func carry(kept []json.RawMessage, foldedPaths map[string]bool, runs *delivery.Composed, carried func(module, id string) bool) ([]json.RawMessage, map[string]bool, error) {
func carry(kept []json.RawMessage, foldedPaths map[string]bool, runs *engcomp.Composed, carried func(module, id string) bool) ([]json.RawMessage, map[string]bool, error) {
idOf := func(raw json.RawMessage) (string, string, string, string, error) {
var head struct {
ID string `json:"id"`
+20 -33
View File
@@ -2,17 +2,18 @@ package compose
import (
"encoding/json"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
engcomp "github.com/novox/mesh-host/internal/compositions"
"sort"
"strings"
"testing"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose/resolution"
"github.com/novox/mesh-host/internal/overlay"
)
func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
func fpFor(c byte) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(string(c), 64) }
// 1943/AC5: a node's compositions are its declaration as Compose gives it, split by the module each resource belongs
// to: every resource once, each in its module's composition at the fingerprint given, the module's ownership said on
@@ -36,14 +37,14 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
}
var got []string
for key, c := range out {
node, module, fp, isNode, err := delivery.ParseComposedKey(key)
node, module, fp, isNode, err := engcomp.ParseKey(key)
if err != nil || node != "laptop" || module != c.Module || fp != c.FP || (isNode != (module == "")) {
t.Errorf("key %s holds %s at %s (%v)", key, c.Module, c.FP, err)
}
if !isNode && fp != fps[module] {
t.Errorf("%s is composed at %s, given %s", module, fp, fps[module])
}
if _, err := delivery.EncodeComposed(c); err != nil {
if _, err := engcomp.Encode(c); err != nil {
t.Errorf("%s: %v", key, err)
}
for _, raw := range c.Resources {
@@ -81,7 +82,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
}
}
all, skipped, err := Compositions(folding, "laptop", nil, Foreseeing)
nodeKey, _ := delivery.ComposedNodeKey("laptop")
nodeKey, _ := engcomp.NodeKey("laptop")
if err != nil || len(skipped) != 0 || len(all) != 1 || len(all[nodeKey].Resources) != len(want) {
t.Fatalf("folded: %v, skipped %v, %d compositions, %d of %d resources in the node's own", err, skipped, len(all), len(all[nodeKey].Resources), len(want))
}
@@ -92,7 +93,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
t.Errorf("folded resource %d is %v, the declaration's %v", i, r["id"], d.Resources[i]["id"])
}
}
if _, err := delivery.EncodeComposed(all[nodeKey]); err != nil {
if _, err := engcomp.Encode(all[nodeKey]); err != nil {
t.Errorf("the node's own with the folded modules: %v", err)
}
if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil {
@@ -109,7 +110,8 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'),
Digest: dg('a'), Language: "go", Binary: "node-tools"}}}
store := catalogue.Manifest{Module: "artifacts", Version: "1",
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}},
Listens: []catalogue.Listening{{Name: "registry", Port: 5100, From: catalogue.FromMesh}}}
lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}},
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle,
@@ -120,35 +122,35 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
t.Fatal(err)
}
served := "lamp=" + resolution.BundlePath("lamp", "tools") + "/lamp-tools"
runtimeRuns := func(dropped bool) delivery.Composed {
runtimeRuns := func(dropped bool) engcomp.Composed {
env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"`
if dropped {
env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29)
}
return delivery.Composed{Node: "anchor", Module: "node-tools", Resources: []json.RawMessage{json.RawMessage(
return engcomp.Composed{Node: "anchor", Module: "node-tools", Resources: []json.RawMessage{json.RawMessage(
`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}}
}
lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') +
`","digest":"` + dg('b') + `","path":"` + resolution.BundlePath("lamp", "tools") + `"}`
for _, row := range []struct {
name string
set func(n *Node) delivery.Composed // and what runs of lamp
set func(n *Node) engcomp.Composed // and what runs of lamp
fps map[string]string
dropped bool
}{
{"folded at a build of the old form", func(n *Node) delivery.Composed {
{"folded at a build of the old form", func(n *Node) engcomp.Composed {
n.InOwn = map[string]bool{"lamp": true}
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
n.RunsOwn = &engcomp.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
return *n.RunsOwn
}, map[string]string{}, false},
{"folded, the runtime already dropped it", func(n *Node) delivery.Composed {
{"folded, the runtime already dropped it", func(n *Node) engcomp.Composed {
n.InOwn = map[string]bool{"lamp": true}
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
n.RunsOwn = &engcomp.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
return *n.RunsOwn
}, map[string]string{}, true},
{"held, its new build not delivered", func(n *Node) delivery.Composed {
{"held, its new build not delivered", func(n *Node) engcomp.Composed {
n.Held = map[string]bool{"lamp": true}
lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage(
lr := engcomp.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage(
strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}}
n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)}
return lr
@@ -160,7 +162,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
anchor.Assigned = append(anchor.Assigned, "artifacts", "node-tools", "lamp")
tools.BusMinted = []string{"anchor." + "node-tools"}
anchor.SealingKey = keyOf(t, "anchor")
anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)})
anchor.RunsTools = ToolsRunning([]engcomp.Composed{runtimeRuns(row.dropped), row.set(anchor)})
row.fps[overlay.Name], row.fps["artifacts"], row.fps["node-tools"] = fpFor('a'), fpFor('5'), fpFor('e')
out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing)
if err != nil {
@@ -184,18 +186,3 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
}
}
}
// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words.
func TestTheComposedPoliciesAreTheCatalogues(t *testing.T) {
for _, p := range []string{catalogue.PolicyRoll, catalogue.PolicyTogether, catalogue.PolicyRecord} {
for _, from := range []string{catalogue.FromPerson, catalogue.FromModule, "the bus", catalogue.FromData, catalogue.FromDefault} { // "the bus": an older reader's word, still read
v := delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("a", 64), Judgement: delivery.JudgedNeither, Policy: p, PolicyFrom: from}
if _, err := delivery.EncodeComposedDigest(v); err != nil {
t.Errorf("%s from %s: %v", p, from, err)
}
}
}
if delivery.PolicyRecord != catalogue.PolicyRecord {
t.Errorf("delivery.PolicyRecord = %q, the catalogue's %q", delivery.PolicyRecord, catalogue.PolicyRecord)
}
}
+1 -1
View File
@@ -351,7 +351,7 @@ func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing c
if !held && mode == Foreseeing {
// Asked ahead of the send: what the send would do about it, by the send's own rules. Made by it -- a stand-in,
// named; refused by it -- refused here, the same words (novox/hq issue 275). A bus account not yet issued is
// foreseen, never refused: the vault mints it from the grants part's wanted list once the placement is written
// foreseen, never refused: the vault mints it from the grants part's wanted list once the assignment is written
// on the operator's Approve (ADR 0561 §1, ADR 0567 §5), so a composition asked ahead of that cannot hold it
// (issue 774, 2026-10-30: every proposal placing mesh-conditions, mesh-self-check or mesh-healers refused).
if err := c.wouldMakeSecretForModule(node, m.Module, name); err != nil {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/overlay"
)
// secretValue is a value the Input carries that must never appear in anything the composition says.
@@ -118,7 +118,7 @@ func TestAnOutsideSecretIsPlacedOnlyOnceAPersonGaveIt(t *testing.T) {
// 1364/AC3 (the send's refusals, novox/hq issue 275): in the Foreseeing mode a given secret under an old key is refused in the
// controller's words. No secret value is in an error. A bus account not yet issued is foreseen, issued or not (issue 774: the vault
// mints it once the placement is written on the Approve, ADR 0561 §1, ADR 0567 §5; mesh-self-check's proposal on ace was refused
// mints it once the assignment is written on the Approve, ADR 0561 §1, ADR 0567 §5; mesh-self-check's proposal on ace was refused
// "has no bus credential: nothing was issued for ace.mesh-self-check").
func TestTheSendsRefusalsAreRefusedInTheSendsWords(t *testing.T) {
broker := catalogue.Manifest{Module: "mesh-self-check", Version: "1", RunsAs: "mesh-self-check",
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"reflect"
"testing"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/overlay"
)
// aPublicKey is a key a machine could have reported, made as mesh-controller's mesh_for_test.go aPublicKey makes it.
+36 -34
View File
@@ -4,6 +4,9 @@ import (
"context"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/compositions"
"maps"
"regexp"
"slices"
@@ -11,14 +14,13 @@ import (
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/resolution"
)
// Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with
// s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as
// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes
// Answer is a node's answer to its proposal's assignment candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with
// s.Assignments laid over the trunk's assignment (compose.CompositionsShadow, its own sealed values and boxes through r as
// for its own composition), compared with what it runs (compositions.CompareComposed). It applies nothing and writes
// nothing; the proposal and the revision are the candidate's, said back in the answer.
//
// The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the
@@ -26,16 +28,16 @@ import (
// composition stands in for is judged: a value of a module the proposal adds (on this node or another: its credential, own
// secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other
// stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own).
func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, proposal string, revision uint64) delivery.CandidateAnswer {
a := delivery.CandidateAnswer{Proposal: proposal, Revision: revision}
refuse := func(err error) delivery.CandidateAnswer {
func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []compositions.Composed, proposal string, revision uint64) nodeengine.CandidateAnswer {
a := nodeengine.CandidateAnswer{Proposal: proposal, Revision: int64(revision)}
refuse := func(err error) nodeengine.CandidateAnswer {
a.Refused = err.Error()
a.Verdict = delivery.CandidateRefused
a.Verdict = nodeengine.CandidateAnswerVerdictRefused
return a
}
trunk, err := r.Placements(ctx)
trunk, err := r.Assignments(ctx)
if err != nil {
return refuse(fmt.Errorf("frombus: placement: %w", err))
return refuse(fmt.Errorf("frombus: assignment: %w", err))
}
if s.Running == nil {
s.Running = running
@@ -79,7 +81,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
if len(failed) == 0 {
return refuse(err)
}
failed[delivery.HaveNodeWord] = err.Error()
failed[nodeengineclient.NodeWord] = err.Error()
composed, skipped, stood = nil, nil, nil
}
// The node's own composition is judged only where the node runs one: one that runs none applies it only once a want
@@ -95,19 +97,19 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
}
}
}
if _, whole := failed[delivery.HaveNodeWord]; whole {
if _, whole := failed[nodeengineclient.NodeWord]; whole {
// Nothing composed to compare: every refusal is said, and nothing else.
for _, m := range sortedKeys(failed) {
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]})
a.Refusals = append(a.Refusals, nodeengine.CandidateRefusal{Module: m, Why: failed[m]})
}
heldAsToday(&a, s.HeldToday)
a.Verdict = delivery.VerdictOf(a)
a.Verdict = compositions.VerdictOf(a)
return a
}
// A module the composition folds inside the node's own is judged there: the agent also says it runs by module, from
// the declaration it kept before the switch (agent.Everything), which is no removal (issue 1126, seed #531).
if len(s.InOwn) > 0 {
var judged []delivery.Composed
var judged []compositions.Composed
for _, c := range running {
if c.Module == "" || !slices.Contains(s.InOwn, c.Module) {
judged = append(judged, c)
@@ -115,7 +117,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
}
running = judged
}
cmp := delivery.CompareComposedWith(composed, running, s.SameValue)
cmp := compositions.CompareComposedWith(composed, running, s.SameValue)
// The answer rides in the node's have key: its shown bodies are bounded, the rest named only.
budget := MaxShown
for i := range cmp.Differs {
@@ -125,7 +127,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
budget -= n
kept = append(kept, b)
} else {
kept = append(kept, delivery.ResourceBodies{ID: b.ID, Why: b.Why})
kept = append(kept, nodeengine.CandidateBodies{ID: b.ID, Why: b.Why})
a.Note = "some differing resources are named without their bodies: the answer holds at most " +
fmt.Sprint(MaxShown) + " bytes of them"
}
@@ -136,13 +138,13 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
notFailed := func(m string) bool { _, ok := failed[m]; return !ok }
a.Same = slices.DeleteFunc(cmp.Same, func(m string) bool { return !notFailed(m) })
a.Removed = slices.DeleteFunc(cmp.Removed, func(m string) bool { return !notFailed(m) })
a.Differs = slices.DeleteFunc(cmp.Differs, func(d delivery.ModuleDiffers) bool { return !notFailed(d.Module) })
cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) })
a.Differs = slices.DeleteFunc(cmp.Differs, func(d nodeengine.CandidateDiffers) bool { return !notFailed(d.Module) })
cmp.Added = slices.DeleteFunc(cmp.Added, func(d nodeengine.CandidateAdded) bool { return !notFailed(d.Module) })
skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) })
// What the proposal adds, by node and module: placed by a candidate and not by the trunk on that node.
added := map[string]bool{}
for node, p := range s.Placements {
for node, p := range s.Assignments {
if p == nil {
continue
}
@@ -171,17 +173,17 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
} // another node's addition: the module here that carries it differs, which the assigner judges
case module == "" || node != s.Node:
// The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's.
refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x)
refusals[nodeengineclient.NodeWord] = append(refusals[nodeengineclient.NodeWord], x)
default:
refusals[module] = append(refusals[module], x)
}
}
addedHere := map[string]delivery.ModuleAdded{}
addedHere := map[string]nodeengine.CandidateAdded{}
for _, m := range cmp.Added {
addedHere[m.Module] = m
}
for _, m := range skipped { // placed, and no build given to compose it at
addedHere[m] = delivery.ModuleAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}}
addedHere[m] = nodeengine.CandidateAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}}
}
for m, xs := range unissued {
got := addedHere[m]
@@ -199,14 +201,14 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
if _, isFailed := failed[m]; isFailed {
continue // said below, as why it could not be composed
}
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")})
a.Refusals = append(a.Refusals, nodeengine.CandidateRefusal{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")})
}
for _, m := range sortedKeys(failed) {
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]})
a.Refusals = append(a.Refusals, nodeengine.CandidateRefusal{Module: m, Why: failed[m]})
}
sort.Slice(a.Refusals, func(i, j int) bool { return a.Refusals[i].Module < a.Refusals[j].Module })
heldAsToday(&a, s.HeldToday)
a.Verdict = delivery.VerdictOf(a)
a.Verdict = compositions.VerdictOf(a)
return a
}
@@ -214,7 +216,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
// held, failed, running a build of the old form): the candidate keeps it as the node does, which is no change (issue
// 1126: a seed reproduces today, and today keeps them so). Each says how it stands today (ModuleRefused.Today). No
// module is held behind another (issue 1960): a module that reads from a held one composes with what it last offered.
func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) {
func heldAsToday(a *nodeengine.CandidateAnswer, today map[string]string) {
if len(today) == 0 {
return
}
@@ -236,7 +238,7 @@ func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) {
// maxCulprits bounds the modules one refusal is tried on, each a whole composition.
const maxCulprits = 8
// MaxShown bounds the bytes of shown resource bodies in one answer (delivery.ModuleDiffers.Bodies).
// MaxShown bounds the bytes of shown resource bodies in one answer (nodeengine.CandidateDiffers.Bodies).
const MaxShown = 256 << 10
var (
@@ -290,13 +292,13 @@ func standsInFor(x, node string) (string, string) {
// else the manifest it is composed from. Only the part of the offer that cannot compose is left out, by provision
// (culprit): de-spiegel's routes, its names unset, leave route-proxy without them while mailu keeps its smtp account,
// and the hold says so.
func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []delivery.Composed, failed map[string]string,
ran func(module string) (catalogue.Manifest, bool)) (out map[string]delivery.Composed, skipped, stood []string, err error) {
func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []compositions.Composed, failed map[string]string,
ran func(module string) (catalogue.Manifest, bool)) (out map[string]compositions.Composed, skipped, stood []string, err error) {
mode := compose.Foreseeing
if applied {
mode = compose.Reading
}
runs := map[string]*delivery.Composed{}
runs := map[string]*compositions.Composed{}
for i := range running {
if m := running[i].Module; m != "" {
runs[m] = &running[i]
@@ -428,7 +430,7 @@ const (
leaveOwn = "own-secrets"
)
// assignedOn is the modules placed on node, in placement order.
// assignedOn is the modules placed on node, in assignment order.
func assignedOn(in compose.Input, node string) []string {
for _, n := range in.Nodes {
if n.Name == node {
@@ -452,7 +454,7 @@ func manifestOn(in compose.Input, node, module string) (catalogue.Manifest, bool
}
// culprit is the module placed on node whose composition the error is of, and what of its offer to leave out: the
// first, in placement order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave
// first, in assignment order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave
// ""); else the first whose stub without what it contributes to one provision does (leave that provision: de-spiegel's
// routes, its names unset, and not its smtp account, issue 1960); else the first whose bare stub does (leaveAll: its
// stub contributing nothing, its own secret never issued, issue 1126 seed-8). Empty when none does (the error is not
@@ -542,7 +544,7 @@ func stubbed(in compose.Input, node, module, leave string) compose.Input {
// its offer: a provision's contributions, with leaveOwn its own secrets alone, or with leaveAll everything it contributes
// and its own secrets, none of a held module's own composition being applied, so what it has not been issued (restic's
// repository on a node it never ran on) is no refusal of the rest.
func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *delivery.Composed) compose.Input {
func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *compositions.Composed) compose.Input {
out := in
out.Nodes = append([]compose.Node(nil), in.Nodes...)
for i := range out.Nodes {
+101 -93
View File
@@ -4,17 +4,23 @@ import (
"context"
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
engcomp "github.com/novox/mesh-host/internal/compositions"
"os"
"reflect"
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/resolution"
"github.com/novox/mesh-host/internal/overlay"
)
// Issue 1126 (the seed's shadow, round 2): a node's answer names every module it cannot compose, not the first only;
@@ -35,48 +41,48 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
lemurs := []byte(`{"module":"lemurs","version":"1","resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`)
fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) }
mk := func(placed ...string) *bus {
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
p := meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}}
for _, m := range placed {
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: m})
}
b := &bus{placements: map[string]delivery.Placement{"n1": p},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}},
modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}},
manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs},
builds: map[string]*delivery.BuilderBuild{}}
b := &bus{assignments: map[string]meshmoduleassigner.Assignments{"n1": p},
records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}},
modules: map[string]meshcatalog.ModulesByModuleValue{"lemurs": {Manifest: meshcatalogclient.ManifestDigest(lemurs)}},
manifests: map[string][]byte{meshcatalogclient.ManifestDigest(lemurs): lemurs},
builds: map[string]*builds.BuilderBuild{}}
for _, m := range []string{"web", "api", "spiegel"} {
b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "token", Origin: "made", SealedTo: "sk", Sealed: `"box"`})
}
for _, m := range []string{"web", "api", "spiegel"} {
raw := manifest(m)
b.manifests[delivery.ManifestDigest(raw)] = raw
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)}
}
// web and api are built, their keys done and waiting for the reference of their image; spiegel has no build of
// the new form at all.
for i, m := range []string{"web", "api"} {
mod := b.modules[m]
mod.FP2 = fp(string(rune('1' + i)))
mod.Fp2 = fp(string(rune('1' + i)))
b.modules[m] = mod
b.builds[m+" "+mod.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}}
b.builds[m+" "+mod.Fp2] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: mod.Manifest,
Artifacts: []builds.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}}
}
return b
}
given := func(b *bus) map[string]*delivery.Placement {
p := b.placements["n1"]
return map[string]*delivery.Placement{"n1": &p}
given := func(b *bus) map[string]*meshmoduleassigner.Assignments {
p := b.assignments["n1"]
return map[string]*meshmoduleassigner.Assignments{"n1": &p}
}
// Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named.
b := mk("lemurs", "web", "api", "spiegel")
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b)},
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b)},
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
var refused []string
for _, r := range a.Refusals {
refused = append(refused, r.Module)
}
if a.Verdict != delivery.CandidateRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) {
if a.Verdict != nodeengine.CandidateAnswerVerdictRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) {
t.Errorf("every refusal: %+v, want api, spiegel and web refused, each named", a)
}
for _, r := range a.Refusals {
@@ -86,27 +92,27 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want)
}
}
if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil {
if raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Candidates: map[string]nodeengine.CandidateAnswer{"g": a}}); err != nil {
t.Errorf("the answer is not one a have holds: %v (%s)", err, raw)
}
if lemursOK := slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }); !lemursOK {
if lemursOK := slices.ContainsFunc(a.Added, func(m nodeengine.CandidateAdded) bool { return m.Module == "lemurs" }); !lemursOK {
t.Errorf("lemurs, which composes, is not answered beside the refusals: %+v", a)
}
// Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every
// refused module held today the verdict is no refusal, and each is named under Held.
today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}},
today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}},
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" {
if today.Verdict == nodeengine.CandidateAnswerVerdictRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" {
t.Errorf("held as today: %+v", today)
}
// A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused.
part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held"}},
part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), HeldToday: map[string]string{"api": "held"}},
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" {
if part.Verdict != nodeengine.CandidateAnswerVerdictRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" {
t.Errorf("held as today in part: %+v", part)
}
// No module is held behind one kept today (issue 1960): mailu's own refusal stays a refusal beside de-spiegel's hold.
if a := (delivery.CandidateAnswer{Refusals: []delivery.ModuleRefused{{Module: "de-spiegel", Why: "a value is unset"},
if a := (nodeengine.CandidateAnswer{Refusals: []nodeengine.CandidateRefusal{{Module: "de-spiegel", Why: "a value is unset"},
{Module: "mailu", Why: "no build"}}}); true {
heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"})
if len(a.Refusals) != 1 || a.Refusals[0].Module != "mailu" || len(a.Held) != 1 {
@@ -117,9 +123,9 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
// The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as
// they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself.
b = mk("lemurs", overlay.Name, "spiegel")
p := b.placements["n1"]
p := b.assignments["n1"]
p.Decided.Endpoint = "n1.example:51820"
b.placements["n1"] = p
b.assignments["n1"] = p
fps := map[string]string{"lemurs": fp("e")}
stub := Scope{Node: "n1"}
in, err := Input(ctx, b, Mesh{}, stub)
@@ -136,14 +142,14 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
if m, _ := culprit(in, "n1", fps, compose.Foreseeing, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" {
t.Errorf("a refusal naming no placed module was laid on %s", m)
}
ownKey, _ := delivery.ComposedNodeKey("n1")
ownKey, _ := engcomp.NodeKey("n1")
own := base[ownKey]
carriedRes := []json.RawMessage{
json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`),
json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)}
// They run after the node's first own resource.
own.Resources = append(append(append([]json.RawMessage{}, own.Resources[:1]...), carriedRes...), own.Resources[1:]...)
var running []delivery.Composed
var running []engcomp.Composed
for k, c := range base {
if k == ownKey {
c = own
@@ -163,27 +169,27 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
if string(gotOwn) != string(wantOwn) {
t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn)
}
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3)
if a.Verdict != delivery.CandidateSame {
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3)
if a.Verdict != nodeengine.CandidateAnswerVerdictSame {
t.Errorf("the fold's answer: %+v, want the same", a)
}
// (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a
// module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer).
keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes})
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 {
keptToo := append(append([]engcomp.Composed{}, running...), engcomp.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes})
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != nodeengine.CandidateAnswerVerdictSame || len(a.Removed) != 0 {
t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a)
}
// (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed
// the same, not 'in what runs, not composed' (seed #531, novox).
membership := json.RawMessage(`{"id":"bus-membership","type":"file","path":"` + catalogue.BusMembershipPath + `","sealed":"{\"box\":\"m\"}","mode":"0600"}`)
withMembership := make([]delivery.Composed, 0, len(running))
withMembership := make([]engcomp.Composed, 0, len(running))
for _, c := range running {
if c.Module == "" {
c.Resources = append(append([]json.RawMessage{}, c.Resources...), membership)
}
withMembership = append(withMembership, c)
}
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != delivery.CandidateSame {
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != nodeengine.CandidateAnswerVerdictSame {
t.Errorf("the node's own with its bus membership is answered %+v, want the same", a)
}
// Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it.
@@ -196,20 +202,20 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
// "not built for this mesh".
{
bb := mk("lemurs", overlay.Name, "spiegel")
bp := bb.placements["n1"]
bp := bb.assignments["n1"]
bp.Decided.Endpoint = "n1.example:51820"
bb.placements["n1"] = bp
bb.assignments["n1"] = bp
sp := bb.modules["spiegel"]
sp.FP2 = fp("3")
sp.Fp2 = fp("3")
bb.modules["spiegel"] = sp
image := "registry.example:5100/spiegel/site@sha256:" + strings.Repeat("7", 64)
bb.builds["spiegel "+sp.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: sp.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "upstream", Reference: image, Digest: "sha256:" + strings.Repeat("7", 64)}}}
bb.builds["spiegel "+sp.Fp2] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: sp.Manifest,
Artifacts: []builds.BuiltArtifact{{Name: "site", Kind: "upstream", Reference: image, Digest: "sha256:" + strings.Repeat("7", 64)}}}
read, err := ComposingInput(ctx, bb, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}},
map[string]string{"lemurs": fp("e"), "spiegel": sp.FP2})
map[string]string{"lemurs": fp("e"), "spiegel": sp.Fp2})
raw, _ := json.Marshal(read.Catalogue["spiegel"])
if err != nil || read.ComposedHow.Held["spiegel"] != "" || !strings.Contains(string(raw), strings.Repeat("7", 64)) {
t.Errorf("spiegel in InOwn wanted at %s: %v, held %v, read as %s; want its build's image", sp.FP2, err,
t.Errorf("spiegel in InOwn wanted at %s: %v, held %v, read as %s; want its build's image", sp.Fp2, err,
read.ComposedHow, raw)
}
}
@@ -228,17 +234,17 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
`"resources":[{"id":"service","type":"service","unit":"vault.service","state":"running","boot":"enabled"}]}`)
b = mk("lemurs", overlay.Name, "spiegel", "nft", "vault")
for m, raw := range map[string][]byte{"spiegel": listening, "nft": nft, "vault": vault} {
b.manifests[delivery.ManifestDigest(raw)] = raw
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)}
}
p = b.placements["n1"]
p = b.assignments["n1"]
p.Decided.Endpoint = "n1.example:51820"
for i := range p.Modules {
if p.Modules[i].Module == "spiegel" {
p.Modules[i].Ports = map[string]int{"8080": 20005}
p.Modules[i].Ports = map[string]int64{"8080": 20005}
}
}
b.placements["n1"] = p
b.assignments["n1"] = p
rec := b.records["n1"]
rec.OutwardLinks = []string{"eth0"}
b.records["n1"] = rec
@@ -309,7 +315,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
if err != nil {
t.Fatal(err)
}
f1runs := delivery.Composed{Node: "ace", Resources: []json.RawMessage{
f1runs := engcomp.Composed{Node: "ace", Resources: []json.RawMessage{
json.RawMessage(`{"id":"f1-circuits.state","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits","mode":"0700"}`),
json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`),
json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`),
@@ -366,14 +372,14 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
`"resources":[{"id":"service","type":"service","unit":"mailu.service","state":"running","boot":"enabled"}]}`)
b = mk("lemurs", overlay.Name, "mailu", "proxy", "de-spiegel")
for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy, "mailu": mailu} {
b.manifests[delivery.ManifestDigest(raw)] = raw
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)}
}
p = b.placements["n1"]
p = b.assignments["n1"]
p.Decided.Endpoint = "n1.example:51820"
b.placements["n1"] = p
b.assignments["n1"] = p
fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f"), "mailu": fp("d")}
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3)
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3)
refused = nil
for _, r := range a.Refusals {
refused = append(refused, r.Module)
@@ -385,7 +391,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node, mailu and proxy not", a)
}
for _, m := range []string{"lemurs", "mailu", "proxy"} {
if !slices.ContainsFunc(a.Added, func(x delivery.ModuleAdded) bool { return x.Module == m }) {
if !slices.ContainsFunc(a.Added, func(x nodeengine.CandidateAdded) bool { return x.Module == m }) {
t.Errorf("%s, which composes, is not judged beside de-spiegel's refusal: %+v", m, a)
}
}
@@ -425,11 +431,11 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
t.Errorf("held %v, want de-spiegel alone, in its config form's words, composed without its routes", held)
}
for _, m := range []string{"lemurs", "mailu", "proxy"} {
if key, _ := delivery.ComposedKey("n1", m, fps[m]); got[key].Module != m {
if key, _ := engcomp.Key("n1", m, fps[m]); got[key].Module != m {
t.Errorf("%s is not composed beside de-spiegel's hold: %v", m, sortedKeys(got))
}
}
carries := func(c delivery.Composed, what string) {
carries := func(c engcomp.Composed, what string) {
t.Helper()
ownRes, _ := json.Marshal(c.Resources)
for _, raw := range spiegelRuns {
@@ -450,7 +456,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
if err != nil || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") {
t.Fatalf("de-spiegel leaving the fold: %v, held %v", err, held)
}
spiegelKey, _ := delivery.ComposedKey("n1", "de-spiegel", fps["de-spiegel"])
spiegelKey, _ := engcomp.Key("n1", "de-spiegel", fps["de-spiegel"])
if _, ok := got[spiegelKey]; ok {
t.Errorf("held de-spiegel's own composition is handed")
}
@@ -466,26 +472,26 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
`{"id":"server","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}]}`)
b = mk("lemurs", overlay.Name, "site", "nft")
for m, raw := range map[string][]byte{"site": site, "nft": nft} {
b.manifests[delivery.ManifestDigest(raw)] = raw
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)}
}
p = b.placements["n1"]
p = b.assignments["n1"]
p.Decided.Endpoint = "n1.example:51820"
for i := range p.Modules {
if p.Modules[i].Module == "site" {
p.Modules[i].Ports = map[string]int{"8080": 20005}
p.Modules[i].Ports = map[string]int64{"8080": 20005}
}
}
b.placements["n1"] = p
b.assignments["n1"] = p
rec = b.records["n1"]
rec.OutwardLinks = []string{"eth0"}
b.records["n1"] = rec
fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "site": fp("8")}
siteRuns := delivery.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{
siteRuns := engcomp.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{
json.RawMessage(`{"id":"site.state","module":"site","type":"directory","path":"/var/lib/site","mode":"0700"}`),
json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["10.77.0.1:20005:8080"]}`)}}
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
nftKey, _ := delivery.ComposedKey("n1", "nft", fps["nft"])
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []engcomp.Composed{siteRuns}}, fps)
nftKey, _ := engcomp.Key("n1", "nft", fps["nft"])
if err != nil || held["site"] == "" || held["nft"] != "" || got[nftKey].Module != "nft" {
t.Fatalf("site, its own secret never issued: %v, held %v; want site held alone, and the filter composed", err, held)
}
@@ -507,22 +513,22 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
`"resources":[{"id":"service","type":"service","unit":"app.service","state":"running","boot":"enabled"}]}`)
b2 := mk("lemurs", overlay.Name, "site", "nft", "app")
for m, raw := range map[string][]byte{"site": provider, "nft": nft, "app": app} {
b2.manifests[delivery.ManifestDigest(raw)] = raw
b2.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
b2.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
b2.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)}
}
p2 := b2.placements["n1"]
p2 := b2.assignments["n1"]
p2.Decided.Endpoint = "n1.example:51820"
for i := range p2.Modules {
if p2.Modules[i].Module == "site" {
p2.Modules[i].Ports = map[string]int{"8080": 20005}
p2.Modules[i].Ports = map[string]int64{"8080": 20005}
}
}
b2.placements["n1"] = p2
b2.assignments["n1"] = p2
b2.records["n1"] = rec
b2.held = true
fps["app"] = fp("9")
got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
appKey, _ := delivery.ComposedKey("n1", "app", fps["app"])
got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []engcomp.Composed{siteRuns}}, fps)
appKey, _ := engcomp.Key("n1", "app", fps["app"])
if _, composed := got[appKey]; err != nil || held["site"] == "" || held["app"] != "" || !composed {
t.Errorf("a consumer of held site: %v, held %v; want site held, and app composed against what runs of it", err, held)
}
@@ -550,17 +556,17 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
// build that runs (7) listens on 8080. The filter composes with what site last offered, its manifest at the build
// that runs (issue 1960: a hold never spreads), so it opens 8080 and nothing applied opens 9090.
moved := []byte(strings.Replace(string(site), `"port":8080`, `"port":9090`, 1))
b.manifests[delivery.ManifestDigest(moved)] = moved
b.modules["site"] = delivery.Module{Manifest: delivery.ManifestDigest(moved)}
b.manifests[meshcatalogclient.ManifestDigest(moved)] = moved
b.modules["site"] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(moved)}
for i := range p.Modules {
p.Modules[i].Ports = nil
}
b.placements["n1"] = p
b.assignments["n1"] = p
siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`)
siteRuns.FP = fp("7")
b.manifests[delivery.ManifestDigest(site)] = site
b.builds["site "+fp("7")] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: delivery.ManifestDigest(site)}
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
b.manifests[meshcatalogclient.ManifestDigest(site)] = site
b.builds["site "+fp("7")] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(site)}
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []engcomp.Composed{siteRuns}}, fps)
if err != nil || held["site"] == "" || held["nft"] != "" {
t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held alone", err, held)
}
@@ -585,19 +591,19 @@ func otherNodeNotResolvedIsNamed(t *testing.T) {
prov := []byte(`{"module":"prov","version":"1","capabilities":["container-runtime"],"provides":[{"name":"thing","scope":"mesh"}]}`)
cons := []byte(`{"module":"cons","version":"1","requires":["thing"]}`)
// n2 runs prov, which needs a container runtime n2's record does not say it has: n2's set does not resolve here.
b := &bus{placements: map[string]delivery.Placement{
"n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}},
"n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}},
modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}},
manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}}
given := map[string]*delivery.Placement{}
for n, p := range b.placements {
b := &bus{assignments: map[string]meshmoduleassigner.Assignments{
"n1": {Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}},
"n2": {Node: "n2", Decided: meshmoduleassigner.NodeDecided{Address: "10.77.0.2"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}},
records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}},
modules: map[string]meshcatalog.ModulesByModuleValue{"prov": {Manifest: meshcatalogclient.ManifestDigest(prov)}, "cons": {Manifest: meshcatalogclient.ManifestDigest(cons)}},
manifests: map[string][]byte{meshcatalogclient.ManifestDigest(prov): prov, meshcatalogclient.ManifestDigest(cons): cons}}
given := map[string]*meshmoduleassigner.Assignments{}
for n, p := range b.assignments {
p := p
given[n] = &p
}
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1)
if a.Verdict != delivery.CandidateRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") {
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1)
if a.Verdict != nodeengine.CandidateAnswerVerdictRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") {
t.Errorf("a refusal for want of n2's provider = %+v", a)
}
}
@@ -606,13 +612,15 @@ func otherNodeNotResolvedIsNamed(t *testing.T) {
// "reaches no artifact store", and nothing said why).
func noStoreReachedSaysWhy(t *testing.T) {
in := compose.Input{Catalogue: map[string]catalogue.Manifest{
"distribution": {Module: "distribution", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}},
Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita"}}, {Name: "novox", Assigned: []string{"distribution"}}}}
if got := storeSought(in); got != "the composition holds g14, novox; artifact-store is served by distribution on novox, none of it on the private network with a port" {
"distribution": {Module: "distribution", Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}}},
// A module serving the retired provision artifact-store is no store (ADR 0632 §1).
"old-store": {Module: "old-store", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}},
Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita", "old-store"}}, {Name: "novox", Assigned: []string{"distribution"}}}}
if got := storeSought(in); got != "the composition holds g14, novox; container-image-registry is held by distribution on novox, none of it on the private network with a listen open to the mesh" {
t.Errorf("said %q", got)
}
in.Nodes = in.Nodes[:1]
if got := storeSought(in); got != "the composition holds g14; no module placed there serves artifact-store" {
if got := storeSought(in); got != "the composition holds g14; no module placed there holds container-image-registry" {
t.Errorf("said %q", got)
}
}
+23 -22
View File
@@ -2,20 +2,21 @@ package frombus
import (
"fmt"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
sdkkeys "git.novox.be/novox/mesh-sdk/go/keys"
"sort"
"strconv"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
)
// Export is one node's placement as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567
// §7): the inverse of Input for what a person decides, so the controller's one read-only verb `placement-export`
// Export is one node's assignment as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567
// §7): the inverse of Input for what a person decides, so the controller's one read-only export verb
// answers, from its own store by its own code, exactly what the seat's adopt proposes as the seed. Its answer must stay
// stable for the same store, since adopt checks its digest again at the warrant.
// What the machine observes (its record) is not in it.
func Export(in compose.Input, node string) (delivery.Placement, error) {
func Export(in compose.Input, node string) (meshmoduleassigner.Assignments, error) {
var n *compose.Node
for i := range in.Nodes {
if in.Nodes[i].Name == node {
@@ -23,14 +24,14 @@ func Export(in compose.Input, node string) (delivery.Placement, error) {
}
}
if n == nil {
return delivery.Placement{}, fmt.Errorf("frombus: the input has no node %q", node)
return meshmoduleassigner.Assignments{}, fmt.Errorf("frombus: the input has no node %q", node)
}
// At the version a placement key is read at, with a list of modules even when none is placed: the export is the
// At the version a assignment key is read at, with a list of modules even when none is placed: the export is the
// seed's value, and the reader refuses a value of version 0 or without its modules.
p := delivery.Placement{V: delivery.Version, Node: node, Modules: []delivery.PlacedModule{}, Decided: delivery.NodeDecided{
p := meshmoduleassigner.Assignments{V: sdkkeys.Version, Node: node, Modules: []meshmoduleassigner.PlacedModule{}, Decided: meshmoduleassigner.NodeDecided{
Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address,
PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount,
AgentHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}}
AgentAccountHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}}
// The tunnel found at the node's adoption: the one its private network took over, or, on the hub, the mesh's adopted
// one whether or not the hub took it; and the names a person gave the hub's carried peers.
if t, ok := in.Tunnels[node]; ok {
@@ -51,27 +52,27 @@ func Export(in compose.Input, node string) (delivery.Placement, error) {
}
}
for _, module := range n.Assigned {
m := delivery.PlacedModule{Module: module}
m := meshmoduleassigner.PlacedModule{Module: module}
for port, at := range n.Ports[module] {
if m.Ports == nil {
m.Ports = map[string]int{}
m.Ports = map[string]int64{}
}
m.Ports[strconv.Itoa(port)] = at
m.Ports[strconv.Itoa(port)] = int64(at)
}
for provision, to := range n.Bound[module] {
if m.Bindings == nil {
m.Bindings = map[string]delivery.Provider{}
m.Bindings = map[string]meshmoduleassigner.Provider{}
}
m.Bindings[provision] = delivery.Provider{Node: to.Node, Module: to.Module}
m.Bindings[provision] = meshmoduleassigner.Provider{Node: to.Node, Module: to.Module}
}
// A module in the settings form keeps its config values in its layers, not in mesh-config: they are exported with
// the placement. A module in the config form has mesh-config's values laid into its layer, which are not.
// the assignment. A module in the config form has mesh-config's values laid into its layer, which are not.
settingsForm := !in.Catalogue[module].DeclaresConfig()
for _, layer := range n.Settings[module] {
if layer.From == catalogue.MeshWideLayer {
m.HasMesh, m.MeshWide = true, layer.Placement
m.HasMeshWide, m.MeshWide = true, layer.Placement
if settingsForm && len(layer.Values) > 0 {
m.MeshValues = layer.Values
m.MeshWideValues = layer.Values
}
} else {
m.HasLayer, m.Layer = true, layer.Placement
@@ -84,13 +85,13 @@ func Export(in compose.Input, node string) (delivery.Placement, error) {
}
for provision, to := range n.Pins {
if p.Pins == nil {
p.Pins = map[string]delivery.Provider{}
p.Pins = map[string]meshmoduleassigner.Provider{}
}
p.Pins[provision] = delivery.Provider{Node: to.Node, Module: to.Module}
p.Pins[provision] = meshmoduleassigner.Provider{Node: to.Node, Module: to.Module}
}
for _, h := range in.Holdings {
if h.Node == node {
p.Holds = append(p.Holds, delivery.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind})
p.Holds = append(p.Holds, meshmoduleassigner.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind})
}
}
sort.SliceStable(p.Holds, func(i, j int) bool {
@@ -102,11 +103,11 @@ func Export(in compose.Input, node string) (delivery.Placement, error) {
return p, nil
}
func recordTunnelOf(t compose.Tunnel) delivery.RecordTunnel {
out := delivery.RecordTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU,
func recordTunnelOf(t compose.Tunnel) meshmoduleassigner.FoundTunnel {
out := meshmoduleassigner.FoundTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: int64(t.Port), Mtu: int64(t.MTU),
Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
for _, peer := range t.Peers {
out.Peers = append(out.Peers, delivery.RecordTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address})
out.Peers = append(out.Peers, meshmoduleassigner.FoundTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address})
}
return out
}
+148 -122
View File
@@ -2,18 +2,18 @@
// and the grants part compose with one composer over the same inputs (novox/hq ADR 0547 §2, ADR 0549 §3; issue 1126).
//
// **One composer, three callers.** compose.Compositions is the composer; Input is how each caller fills its
// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the seat mesh-placement's holder judging a
// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the seat mesh-module-assigner's holder judging a
// proposal for every node (ADR 0567 §4), and the grants part (ADR 0547 §5). Each reads the bus with its own credential
// through a Reader, so this package holds no bus client, no store and no timer: what it is given is all it reads.
//
// **What it reads, and from whom.**
// - which modules run on each node, with their pins, holdings, host ports, bindings and settings placement: the
// seat mesh-placement's state `placement`, one key per node (ADR 0567 §1);
// - which modules run on each node, with their pins, holdings, host ports, bindings and settings assignment: the
// seat mesh-module-assigner's state `assignment`, one key per node (ADR 0567 §1);
// - what a person decided of each node (its endpoint, site, hub, address on the private network, public domain and
// accounts): the same placement key, Placement.Decided;
// accounts): the same assignment key, Assignments.Decided;
// - what each node observes of itself (its tunnel key, capabilities, keys, certificate, links and networks): the
// node's own have key, `<node>.node` (HaveNode.Record); the tunnel found at a node's adoption and the names of the hub's peers are decided,
// in its placement, and whether the hub took the tunnel over and who enrolled under a peer's key are read from both;
// in its assignment, and whether the hub took the tunnel over and who enrolled under a peer's key are read from both;
// - every module's manifest: the indexer's `modules.<module>` and its manifests by digest; for each module placed on
// the composing node, its built manifest instead: the builder's done key at the trunk's FP2, or at the fingerprint
// the node's want names where it runs another build (Scope.KeptAt), resolved by the controller's own take-in rule
@@ -41,26 +41,35 @@ import (
"sort"
"strings"
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
engcomp "github.com/novox/mesh-host/internal/compositions"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/resolution"
"github.com/novox/mesh-host/internal/overlay"
"github.com/novox/mesh-host/internal/registryforms"
"github.com/novox/mesh-host/internal/takein"
)
// Reader is the bus as a caller reads it with its own credential. Each method reads one part's state; an error is a
// read that failed, never an absent key (an absent key is an empty answer).
type Reader interface {
// Placements is every node's placement, by node (delivery.PlacementBucket).
Placements(ctx context.Context) (map[string]delivery.Placement, error)
// Assignments is every node's assignment, by node (buckets.Assignments).
Assignments(ctx context.Context) (map[string]meshmoduleassigner.Assignments, error)
// Records is every node's record, by node: HaveNode.Record of each node's own have key, `<node>.node` of
// delivery.HaveBucket (ADR 0555 §3).
Records(ctx context.Context) (map[string]delivery.NodeRecord, error)
// Modules is every module the indexer holds, by module (delivery.ModulesBucket).
Modules(ctx context.Context) (map[string]delivery.Module, error)
// Manifest is the raw manifest of a digest (delivery.ManifestsBucket).
// buckets.Have (ADR 0555 §3).
Records(ctx context.Context) (map[string]nodeengine.NodeRecord, error)
// Modules is every module the indexer holds, by module (buckets.Modules).
Modules(ctx context.Context) (map[string]meshcatalog.ModulesByModuleValue, error)
// Manifest is the raw manifest of a digest (buckets.Manifests).
Manifest(ctx context.Context, digest string) ([]byte, error)
// Values is one module's config values on one node as mesh-config resolved them (mesh-config's state `resolved`,
// keys `<node>.<module>.<key>`), decoded; nil when it has none.
@@ -72,11 +81,11 @@ type Reader interface {
// and each it grants as a provider, with the copy sealed to it (the vault's `own` with the keys, ADR 0550 §5). A key
// the vault holds no value for is left out; the composition then stands in for it, and Compose refuses it.
Credentials(ctx context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error)
// Build is a module's build at a fingerprint, the builder's key (delivery.BuilderBuildsBucket, BuilderBuildKey); nil
// Build is a module's build at a fingerprint, the builder's key (builds.BuilderBuildsBucket, BuilderBuildKey); nil
// when the builder holds none. The composing node composes each of its modules from its build (issue 1126).
Build(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error)
Build(ctx context.Context, module, fp string) (*builds.BuilderBuild, error)
// Archive is the archive at url, a kept reference routed to the artifact store as the composing node reaches it
// (catalogue.Routed), checked against digest: a moved module's templates archive, read back for its texts as the
// (registryforms.Forms.Routed), checked against digest: a moved module's templates archive, read back for its texts as the
// controller's take-in reads it (takein.Built).
Archive(ctx context.Context, url, digest string) ([]byte, error)
// Served is, of one module's config values on another node than the composing one (mesh-config's state `resolved`,
@@ -86,11 +95,11 @@ type Reader interface {
Served(ctx context.Context, node, module string, keys []string) (map[string]any, error)
}
// SignersReader is a Reader that also reads the vault's public authority from its state `signers` (delivery.SignersBucket,
// key delivery.SignersAuthorityKey; ADR 0550 §2). A Reader that has it composes the mesh's authority as a bundle, the
// SignersReader is a Reader that also reads the vault's public authority from its state `signers` (buckets.Signers,
// key meshvault.SignersByAuthorityKey(); ADR 0550 §2). A Reader that has it composes the mesh's authority as a bundle, the
// controller's and the vault's; one that has not, or that answers none, composes the controller's alone, as before.
type SignersReader interface {
// VaultAuthority is the vault's authority certificate as PEM, checked (delivery.DecodeSignerAuthority); empty when the
// VaultAuthority is the vault's authority certificate as PEM, checked (meshvaultclient.DecodeSignerAuthority); empty when the
// vault has published none. A read that fails is said by the reader and answered empty: a node that may not read the
// state yet composes as before rather than not at all.
VaultAuthority(ctx context.Context) string
@@ -128,11 +137,11 @@ type Mesh struct {
// controller composes from the manifest its take-in resolved: so a moved module is handed to the provisioner, a resource
// naming an artifact names what was built, and a templated file has its text. A placed module the builder holds no done
// build of at the trunk's FP2 composes from the indexer's manifest, as before. Every leaves the catalogue as the indexer
// read it: the check of every node judges placement, and reads no archive.
// read it: the check of every node judges assignment, and reads no archive.
//
// Placements, when set, stands in for the placement of the nodes it names: a proposal's candidates
// (delivery.PlacementCandidate), so a node composes in shadow with a proposal laid over and compares the result with
// what it runs (ADR 0567 §3); a nil value removes the node's placement. The rest is read as always.
// Assignments, when set, stands in for the assignment of the nodes it names: a proposal's candidates
// (meshmoduleassigner.CandidatesByProposalNodeValue), so a node composes in shadow with a proposal laid over and compares the result with
// what it runs (ADR 0567 §3); a nil value removes the node's assignment. The rest is read as always.
//
// InOwn is the modules the composing node runs inside its own composition (InOwn of what it runs), composed there again
// (compose.Node.InOwn) rather than cut out as modules no build is given for (issue 1126).
@@ -141,18 +150,18 @@ type Mesh struct {
// (compose.Node.RunsOwn), so a module folded there that names a build of the old form is left to the node, carried
// as it runs (issue 1126). Nil composes every folded module from its manifest.
type Scope struct {
Node string
Every bool
KeptAt map[string]string
Placements map[string]*delivery.Placement
InOwn []string
Running []delivery.Composed
Node string
Every bool
KeptAt map[string]string
Assignments map[string]*meshmoduleassigner.Assignments
InOwn []string
Running []engcomp.Composed
// HeldToday is, in an answer, the modules the node keeps today as they run ("node" for its own composition), each with
// how ("held", "failed", "runs a build of the old form"): a candidate that cannot compose one, or holds one only
// behind one of them, keeps it as today, which is said under CandidateAnswer.Held and is no refusal (issue 1126).
HeldToday map[string]string
// SameValue, in an answer, says whether two sealed values open to the same value (the node opens them with its own
// key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them
// key), so a differing resource shows a value sealed again as such (compositions.CompareComposedWith). Nil shows them
// as differing.
SameValue func(a, b string) (same, known bool)
// Kept is told, in words, each module composed as what runs shows because its manifest does not yet say it (the tool
@@ -165,7 +174,7 @@ type Scope struct {
// controller's composed writer folded there because no fingerprint of the new form names them (mesh-wireguard, a module
// run at a build of the old form; issue 1126). A node composing itself passes it as Scope.InOwn, so its own
// composition keeps them as it runs them until a want names a build of the new form for one.
func InOwn(running []delivery.Composed, p delivery.Placement) []string {
func InOwn(running []engcomp.Composed, p meshmoduleassigner.Assignments) []string {
placed := map[string]bool{}
for _, m := range p.Modules {
placed[m.Module] = true
@@ -194,7 +203,7 @@ func InOwn(running []delivery.Composed, p delivery.Placement) []string {
// Input is the composition input for s, read through r.
//
// **A refused manifest never ends the read** (novox/hq ADR 0578 §3): each manifest is decoded leniently
// (catalogue.Decode) and the indexer's verdict on it taken from its modules value (delivery.TakeManifestVerdict, a missing
// (catalogue.Decode) and the indexer's verdict on it taken from its modules value (meshcatalogclient.TakeVerdict, a missing
// one accepted); a module whose manifest the indexer's judge refused, or whose bytes do not decode, stands in the
// catalogue as its stub (unusable), so what it provides and requires still shapes the rest, and is named with why in
// the input's Refused; a field set aside is named in its SetAside. Every other module of the read goes on.
@@ -205,10 +214,10 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er
// taken is a module's manifest as a reader takes it (novox/hq ADR 0578 §3): decoded leniently, with the indexer's verdict
// from its modules value. A refused one, or bytes that do not decode, stands as its stub (unusable) and why says so; a
// manifest not read past a field it set aside holds its name alone and is held the same.
func taken(name string, raw []byte, m delivery.Module) (man catalogue.Manifest, why string) {
if v := m.VerdictOf(name); v != nil {
if refused, _ := delivery.TakeManifestVerdict(v); refused {
return unusable(name, raw), v.Why()
func taken(name string, raw []byte, m meshcatalog.ModulesByModuleValue) (man catalogue.Manifest, why string) {
if v := meshcatalogclient.ModuleVerdict(name, m); v != nil {
if refused, _ := meshcatalogclient.TakeVerdict(v); refused {
return unusable(name, raw), meshcatalogclient.VerdictWhy(*v)
}
}
man, err := catalogue.Decode(raw)
@@ -249,10 +258,10 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
// base is the part of a node's input that the fingerprints it composes its modules at leave alone (issue 1954): read once
// for its plans at the wanted builds and at the newest, whose builds alone differ (PlansHeldTwice).
type base struct {
in compose.Input
placements map[string]delivery.Placement
records map[string]delivery.NodeRecord
modules map[string]delivery.Module
in compose.Input
assignments map[string]meshmoduleassigner.Assignments
records map[string]nodeengine.NodeRecord
modules map[string]meshcatalog.ModulesByModuleValue
// ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order
// read: every scope holds it.
ownRefused [][2]string
@@ -282,29 +291,29 @@ func (b base) cloned() compose.Input {
return in
}
// readBase is input up to the builds: placement, records, the indexer's modules and manifests, each node's values.
// readBase is input up to the builds: assignment, records, the indexer's modules and manifests, each node's values.
func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
var b base
placements, err := r.Placements(ctx)
assignments, err := r.Assignments(ctx)
if err != nil {
return b, fmt.Errorf("frombus: placement: %w", err)
return b, fmt.Errorf("frombus: assignment: %w", err)
}
if len(s.Placements) > 0 {
given := make(map[string]delivery.Placement, len(placements)+len(s.Placements))
for node, p := range placements {
if len(s.Assignments) > 0 {
given := make(map[string]meshmoduleassigner.Assignments, len(assignments)+len(s.Assignments))
for node, p := range assignments {
given[node] = p
}
for node, p := range s.Placements {
for node, p := range s.Assignments {
if p == nil {
delete(given, node)
continue
}
if p.Node != node {
return b, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
return b, fmt.Errorf("frombus: the assignment given for %s names the node %q", node, p.Node)
}
given[node] = *p
}
placements = given
assignments = given
}
records, err := r.Records(ctx)
if err != nil {
@@ -314,8 +323,8 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
if err != nil {
return b, fmt.Errorf("frombus: the indexer's modules: %w", err)
}
if _, ok := placements[s.Node]; !ok && !s.Every {
return b, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node)
if _, ok := assignments[s.Node]; !ok && !s.Every {
return b, fmt.Errorf("frombus: %s has no assignment in the catalogue", s.Node)
}
in := compose.Input{OverlayCIDR: mesh.OverlayCIDR, InternalSuffix: mesh.InternalSuffix, Authority: mesh.Authority,
@@ -338,7 +347,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
// against the bundle. The controller's stays while a certificate it issued is in a node's record.
if sr, ok := r.(SignersReader); ok {
if vault := sr.VaultAuthority(ctx); vault != "" {
in.Authority = delivery.AuthorityBundle(in.Authority, vault)
in.Authority = meshvaultclient.AuthorityBundle(in.Authority, vault)
}
}
named := func(field *map[string]string, module, why string) {
@@ -348,7 +357,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
(*field)[module] = why
}
ownPlaced := map[string]bool{}
for _, pm := range placements[s.Node].Modules {
for _, pm := range assignments[s.Node].Modules {
ownPlaced[pm.Module] = true
}
for _, name := range sortedKeys(modules) {
@@ -379,7 +388,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
// The module the mesh provides itself (the private network) has no repository the indexer reads: its manifest comes
// with mesh-sdk and always wins, as the controller's providedModules and the grants part have it.
for _, name := range delivery.ProvidedModules() {
for _, name := range overlay.ProvidedModules() {
raw, err := json.Marshal(overlay.Manifest())
if err != nil {
return b, err
@@ -392,9 +401,9 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
}
var missing []string
for _, node := range nodesOf(placements, records) {
for _, node := range nodesOf(assignments, records) {
rec, hasRecord := records[node]
p := placements[node]
p := assignments[node]
if !hasRecord {
if len(p.Modules) > 0 {
missing = append(missing, node)
@@ -417,7 +426,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
strings.Join(keys, ", "), err)
}
}
if layers := m.Layers(node, values); layers != nil {
if layers := meshmoduleassignerclient.Layers(m, node, values); layers != nil {
if n.Settings == nil {
n.Settings = map[string][]catalogue.Layer{}
}
@@ -466,7 +475,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
}
}
in.Nodes = append(in.Nodes, n)
in.Holdings = append(in.Holdings, p.Held()...)
in.Holdings = append(in.Holdings, meshmoduleassignerclient.Held(p)...)
// A tunnel found at adoption is the node's private network's when the node's tunnel key is the tunnel's (the
// controller's inv.Tunnels); the hub's found tunnel is the mesh's adopted one whether or not the hub took it over
// (inv.AdoptedTunnel), and its peers are carried once it did (inv.CarriedPeers).
@@ -483,19 +492,19 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
}
}
if in.Adopted != nil && in.Adopted.Adopted {
in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, placements[in.Adopted.Hub].Decided.PeerNames, records)
in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, assignments[in.Adopted.Hub].Decided.PeerNames, records)
}
if len(missing) > 0 {
return b, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet",
strings.Join(missing, ", "))
}
b.in, b.placements, b.records, b.modules = in, placements, records, modules
b.in, b.assignments, b.records, b.modules = in, assignments, records, modules
return b, nil
}
// finish is input from its base on: the composing node's modules from their builds (build reads one), its own secrets.
// in is the base's input, or a copy of it (base.cloned) where one base is finished more than once.
func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error),
func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*builds.BuilderBuild, error),
mesh Mesh, s Scope, in compose.Input, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) {
var err error
parse := parser(mesh)
@@ -514,7 +523,7 @@ func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Conte
if _, ok := b.records[s.Node]; !ok {
return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node)
}
if err := fromBuilds(ctx, r, build, &in, s, b.placements[s.Node], b.modules, read, parse, refuse, wanted); err != nil {
if err := fromBuilds(ctx, r, build, &in, s, b.assignments[s.Node], b.modules, read, parse, refuse, wanted); err != nil {
return compose.Input{}, err
}
// Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5).
@@ -554,7 +563,7 @@ func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Conte
// why, and none of its own composition is in out, so the node keeps what it last applied of it and applies the rest; a
// node never applies a stand-in. Only a refusal that is no single module's (the node's own resources, a value served
// elsewhere, a stand-in for the node's own) refuses the node, err.
func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]delivery.Composed, skipped []string, held map[string]string, err error) {
func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]engcomp.Composed, skipped []string, held map[string]string, err error) {
held = map[string]string{}
// A node refused whole says what it held on the way, since the refusal can be of a held module's stub (issue 774,
// 2026-10-30 19:30: node-tools held, then "its build produced 0 bundle(s)" with its own refusal unsaid).
@@ -608,7 +617,7 @@ func ComposingInput(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[s
// ComposeSent composes node from an input ComposingInput read, through the path Compose takes (composeEach, the read's holds
// first): what a check's sandbox runs. False when the input says no way of composing node (an input from a node-engine
// before ComposedHow): the caller cannot compose it as the node does.
func ComposeSent(in compose.Input, node string) (map[string]delivery.Composed, bool, error) {
func ComposeSent(in compose.Input, node string) (map[string]engcomp.Composed, bool, error) {
c := in.ComposedHow
if c == nil || c.Node != node {
return nil, false, nil
@@ -763,7 +772,7 @@ func parser(Mesh) func([]byte) (catalogue.Manifest, error) {
// the builder holds it done. False where nothing is known of it (it runs no composition of its own, it runs the build
// it is composed at, or its build is unread): the manifest it is composed from is its offer then. Only what it says to
// others is taken of it, which needs no take-in of its artifacts.
func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed, fps map[string]string) func(string) (catalogue.Manifest, bool) {
func ranOf(ctx context.Context, r Reader, mesh Mesh, running []engcomp.Composed, fps map[string]string) func(string) (catalogue.Manifest, bool) {
parse := parser(mesh)
return func(module string) (catalogue.Manifest, bool) {
for _, c := range running {
@@ -771,7 +780,7 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed
continue
}
b, err := r.Build(ctx, module, c.FP)
if err != nil || b == nil || b.State != delivery.BuildDone || b.Manifest == "" {
if err != nil || b == nil || b.State != builds.BuildDone || b.Manifest == "" {
return catalogue.Manifest{}, false
}
raw, err := r.Manifest(ctx, b.Manifest)
@@ -789,8 +798,8 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed
// module it keeps at another build (Scope.KeptAt, into its node's Kept), read from the builder's done key and resolved
// by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it
// (issue 1126).
func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error),
in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module,
func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*builds.BuilderBuild, error),
in *compose.Input, s Scope, p meshmoduleassigner.Assignments, modules map[string]meshcatalog.ModulesByModuleValue,
read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error),
refuse func(module string, err error), wanted map[string]string) error {
// stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides
@@ -827,9 +836,9 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
case address == "":
return nil, fmt.Errorf("%s reaches no artifact store (%s)", s.Node, storeSought(*in))
}
return r.Archive(ctx, catalogue.Routed(reference, address), digest)
return r.Archive(ctx, registryforms.Forms.Routed(reference, address), digest)
}
built := func(module, fp string, b *delivery.BuilderBuild) (catalogue.Manifest, error) {
built := func(module, fp string, b *builds.BuilderBuild) (catalogue.Manifest, error) {
if b.Manifest == "" {
return catalogue.Manifest{}, fmt.Errorf("frombus: the build of %s at %s names no manifest", module, fp)
}
@@ -877,7 +886,7 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
// the scope keeps at another build is.
keptAt := maps.Clone(s.KeptAt)
for _, pm := range p.Modules {
if m, ok := modules[pm.Module]; ok && m.FP2 != "" && wanted[pm.Module] != "" && wanted[pm.Module] != m.FP2 &&
if m, ok := modules[pm.Module]; ok && m.Fp2 != "" && wanted[pm.Module] != "" && wanted[pm.Module] != m.Fp2 &&
keptAt[pm.Module] == "" && !compose.Folds(slices.Contains(s.InOwn, pm.Module), wanted[pm.Module]) {
if keptAt == nil {
keptAt = map[string]string{}
@@ -893,7 +902,7 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
}
for _, pm := range p.Modules {
m, ok := modules[pm.Module]
if !ok || m.FP2 == "" || keptAt[pm.Module] != "" || compose.Folds(slices.Contains(s.InOwn, pm.Module), wanted[pm.Module]) {
if !ok || m.Fp2 == "" || keptAt[pm.Module] != "" || compose.Folds(slices.Contains(s.InOwn, pm.Module), wanted[pm.Module]) {
// A module it runs inside its own composition is composed there as before while it is folded: no build of the
// new form is what it runs (Scope.InOwn). Wanted at a build of the new form it is no longer folded (compose.Folds,
// the composition's own rule), so its build is taken in (issue 1585).
@@ -905,26 +914,26 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
if man, ok := in.Catalogue[pm.Module]; !ok || (man.Build == nil && !man.Templated()) {
continue // nothing is built of it: its manifest is what it composes from
}
b, err := build(ctx, pm.Module, m.FP2)
b, err := build(ctx, pm.Module, m.Fp2)
if err != nil {
err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.FP2, err)
err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.Fp2, err)
if refuse == nil {
return err
}
stub(pm.Module, err)
continue
}
if b == nil || b.State != delivery.BuildDone {
if b == nil || b.State != builds.BuildDone {
if refuse != nil && runs[pm.Module] {
// A module that runs is never composed from the indexer's manifest, which names no build's artifacts (no
// runtime's bundle): it is held, keeping what runs, until its build lands (issue 774, 2026-10-30 19:30:
// node-tools on ace, g14 and shanks, "its build produced 0 bundle(s)", and every module refused).
stub(pm.Module, fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build yet; it "+
"keeps running what it runs", pm.Module, m.FP2))
"keeps running what it runs", pm.Module, m.Fp2))
}
continue // nothing of it runs: composed from the indexer's manifest, as before
}
resolved, err := built(pm.Module, m.FP2, b)
resolved, err := built(pm.Module, m.Fp2, b)
if err != nil {
if refuse == nil {
return err
@@ -944,7 +953,7 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
switch {
case err != nil:
err = fmt.Errorf("frombus: the build of %s at %s: %w", module, fp, err)
case b == nil || b.State != delivery.BuildDone:
case b == nil || b.State != builds.BuildDone:
err = fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build", module, fp)
}
var resolved catalogue.Manifest
@@ -986,15 +995,24 @@ func declaresOwnSecrets(in compose.Input, node string) bool {
return false
}
// nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person
// attachedOf is a record's attached networks as the composer holds them (the same two fields).
func attachedOf(in []nodeengine.AttachedNetwork) []catalogue.AttachedNetwork {
var out []catalogue.AttachedNetwork
for _, n := range in {
out = append(out, catalogue.AttachedNetwork{Link: n.Link, Network: n.Network})
}
return out
}
// nodeOf is the composition's node from its record (what the machine observes) and its assignment (what a person
// decided of it, and where its modules run).
func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node {
func nodeOf(rec nodeengine.NodeRecord, p meshmoduleassigner.Assignments) compose.Node {
n := compose.Node{
Name: rec.Node,
Place: compose.Place{Key: rec.TunnelKey},
SealingKey: rec.SealingKey,
OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid,
ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0,
OutwardLinks: rec.OutwardLinks, Networks: attachedOf(rec.Networks), NetworksSaid: rec.NetworksSaid,
ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: int(rec.Contract), Stated: rec.Contract > 0,
}
placeNode(&n, p)
if len(rec.Capabilities) > 0 {
@@ -1006,26 +1024,26 @@ func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node {
return n
}
// placeNode sets what a person decided of n and where its modules run, from its placement; what the machine observes
// placeNode sets what a person decided of n and where its modules run, from its assignment; what the machine observes
// (its keys, links, networks, certificate) and its settings layers are left as they are.
func placeNode(n *compose.Node, p delivery.Placement) {
func placeNode(n *compose.Node, p meshmoduleassigner.Assignments) {
d := p.Decided
n.Place = compose.Place{Key: n.Place.Key, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address}
n.Assigned, n.PublicDomain = p.Assigned(), d.PublicDomain
n.Account, n.AccountHome, n.AgentAccount, n.AgentAccountHome = d.Account, d.AccountHome, d.AgentAccount, d.AgentHome
n.Assigned, n.PublicDomain = meshmoduleassignerclient.Assigned(p), d.PublicDomain
n.Account, n.AccountHome, n.AgentAccount, n.AgentAccountHome = d.Account, d.AccountHome, d.AgentAccount, d.AgentAccountHome
n.Adopted, n.Taken = d.Adopted, d.Taken
n.Pins, n.Bound, n.Ports = p.ChosenPins(), p.Bound(), p.HostPorts()
n.Pins, n.Bound, n.Ports = meshmoduleassignerclient.ChosenPins(p), meshmoduleassignerclient.Bound(p), meshmoduleassignerclient.HostPorts(p)
}
// Placed is in with the placements given standing in for those of the nodes they name (issue 1126): each such node's
// decided parts, modules, pins, bindings, host ports, settings placement and holdings are the given placement's, its
// Placed is in with the assignments given standing in for those of the nodes they name (issue 1126): each such node's
// decided parts, modules, pins, bindings, host ports, settings assignment and holdings are the given assignment's, its
// config values those in already carries for it (the layer of the node's own name), and what the machine observes is
// kept. A nil placement leaves the node in the mesh with nothing placed on it. A node in does not hold is refused: it has
// kept. A nil assignment leaves the node in the mesh with nothing placed on it. A node in does not hold is refused: it has
// no record to compose it from. The found tunnel and the hub's peers are read from in as they are, not from the given
// placement. It is how a check composes a change's own placement over an input already filled (composer.Case's
// Placements).
func Placed(in compose.Input, placements map[string]*delivery.Placement) (compose.Input, error) {
if len(placements) == 0 {
// assignment. It is how a check composes a change's own assignment over an input already filled (composer.Case's
// Assignments).
func Placed(in compose.Input, assignments map[string]*meshmoduleassigner.Assignments) (compose.Input, error) {
if len(assignments) == 0 {
return in, nil
}
out := in
@@ -1034,17 +1052,17 @@ func Placed(in compose.Input, placements map[string]*delivery.Placement) (compos
for i, n := range out.Nodes {
index[n.Name] = i
}
for _, node := range sortedKeys(placements) {
p := placements[node]
for _, node := range sortedKeys(assignments) {
p := assignments[node]
i, ok := index[node]
if !ok {
return compose.Input{}, fmt.Errorf("frombus: a placement is given for %s, which the input holds no node for", node)
return compose.Input{}, fmt.Errorf("frombus: a assignment is given for %s, which the input holds no node for", node)
}
if p == nil {
p = &delivery.Placement{Node: node}
p = &meshmoduleassigner.Assignments{Node: node}
}
if p.Node != node {
return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
return compose.Input{}, fmt.Errorf("frombus: the assignment given for %s names the node %q", node, p.Node)
}
n := out.Nodes[i]
placeNode(&n, *p)
@@ -1056,7 +1074,7 @@ func Placed(in compose.Input, placements map[string]*delivery.Placement) (compos
values = l.Values
}
}
if layers := m.Layers(node, values); layers != nil {
if layers := meshmoduleassignerclient.Layers(m, node, values); layers != nil {
settings[m.Module] = layers
}
}
@@ -1068,20 +1086,20 @@ func Placed(in compose.Input, placements map[string]*delivery.Placement) (compos
}
out.Holdings = nil
for _, h := range in.Holdings {
if _, given := placements[h.Node]; !given {
if _, given := assignments[h.Node]; !given {
out.Holdings = append(out.Holdings, h)
}
}
for _, node := range sortedKeys(placements) {
if p := placements[node]; p != nil {
out.Holdings = append(out.Holdings, p.Held()...)
for _, node := range sortedKeys(assignments) {
if p := assignments[node]; p != nil {
out.Holdings = append(out.Holdings, meshmoduleassignerclient.Held(*p)...)
}
}
return out, nil
}
func tunnelOf(t delivery.RecordTunnel) compose.Tunnel {
out := compose.Tunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU,
func tunnelOf(t meshmoduleassigner.FoundTunnel) compose.Tunnel {
out := compose.Tunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: int(t.Port), MTU: int(t.Mtu),
Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
for _, p := range t.Peers {
out.Peers = append(out.Peers, compose.TunnelPeer{PublicKey: p.PublicKey, Address: p.Address})
@@ -1091,7 +1109,7 @@ func tunnelOf(t delivery.RecordTunnel) compose.Tunnel {
// carriedPeers is every peer of the hub's adopted tunnel, by address, each with the node that enrolled under its key
// where one has (a node whose tunnel key is the peer's) and the name a person gave it (inv.CarriedPeers).
func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]delivery.NodeRecord) []compose.CarriedPeer {
func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]nodeengine.NodeRecord) []compose.CarriedPeer {
byKey := map[string]string{}
for node, rec := range records {
if rec.TunnelKey != "" {
@@ -1114,10 +1132,10 @@ func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]
return out
}
// nodesOf is every node named by a placement or a record, by name: the order the controller reads them in.
func nodesOf(placements map[string]delivery.Placement, records map[string]delivery.NodeRecord) []string {
// nodesOf is every node named by a assignment or a record, by name: the order the controller reads them in.
func nodesOf(assignments map[string]meshmoduleassigner.Assignments, records map[string]nodeengine.NodeRecord) []string {
seen := map[string]bool{}
for n := range placements {
for n := range assignments {
seen[n] = true
}
for n := range records {
@@ -1136,16 +1154,14 @@ func sortedKeys[V any](m map[string]V) []string {
}
// storeSought says, for a node that reaches no artifact store, what the composition holds of one: the nodes it holds and
// where each module serving the artifact store is placed, so the refusal names why (issue 1126).
// where each module claiming the store's seat is placed, so the refusal names why (issue 1126, ADR 0632 §1).
func storeSought(in compose.Input) string {
var nodes, where []string
for _, n := range in.Nodes {
nodes = append(nodes, n.Name)
for _, a := range n.Assigned {
if m, ok := in.Catalogue[a]; ok {
if _, serves := m.Serves[catalogue.ArtifactStoreProvision]; serves {
where = append(where, a+" on "+n.Name)
}
if m, ok := in.Catalogue[a]; ok && claimsStore(m) {
where = append(where, a+" on "+n.Name)
}
}
}
@@ -1153,14 +1169,24 @@ func storeSought(in compose.Input) string {
sort.Strings(where)
said := "the composition holds " + strings.Join(nodes, ", ")
if len(where) == 0 {
return said + "; no module placed there serves " + catalogue.ArtifactStoreProvision
return said + "; no module placed there holds " + compose.StoreSeat
}
return said + "; " + catalogue.ArtifactStoreProvision + " is served by " + strings.Join(where, ", ") +
", none of it on the private network with a port"
return said + "; " + compose.StoreSeat + " is held by " + strings.Join(where, ", ") +
", none of it on the private network with a listen open to the mesh"
}
// claimsStore says whether m claims a name of the store (resolution.StoreSeats) at the mesh's scope.
func claimsStore(m catalogue.Manifest) bool {
for _, cl := range m.Claims {
if cs, ok := catalogue.SeatNamed(cl.Name); ok && slices.Contains(resolution.StoreSeats, cs.Name) && cl.At() == catalogue.ScopeMesh {
return true
}
}
return false
}
// keptOf is the operator export's file (catalogue.KeptID) of a module's composition as it runs, or nil.
func keptOf(c delivery.Composed) map[string]any {
func keptOf(c engcomp.Composed) map[string]any {
for _, raw := range c.Resources {
var r map[string]any
if json.Unmarshal(raw, &r) == nil && r["id"] == c.Module+"."+resolution.KeptID() {
@@ -1172,7 +1198,7 @@ func keptOf(c delivery.Composed) map[string]any {
// membershipOf is the sealed value of the bus membership resource (catalogue.BusMembershipID) of a node's own
// composition, or "".
func membershipOf(own delivery.Composed) string {
func membershipOf(own engcomp.Composed) string {
for _, raw := range own.Resources {
var r struct {
ID string `json:"id"`
@@ -1236,7 +1262,7 @@ func withoutHeldData(in compose.Input, node string) compose.Input {
// runningProvisioner names, on node, the account its provisioner client runs as now (compose.Node.RunningProvisioner): the
// account its own composition as it runs names, else the user of the running process of the module that runs the client.
func runningProvisioner(in *compose.Input, node string, running []delivery.Composed) {
func runningProvisioner(in *compose.Input, node string, running []engcomp.Composed) {
account := ""
for _, c := range running {
if c.Module == "" && c.Own.Provisioner != "" {
+144 -133
View File
@@ -11,6 +11,14 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"github.com/novox/mesh-host/internal/compositions"
"os"
"reflect"
"slices"
@@ -18,30 +26,29 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/overlay"
)
// bus is a Reader over values held in memory, counting whose config values were read.
type bus struct {
vaultAuthority string
handed map[string]bool
placements map[string]delivery.Placement
records map[string]delivery.NodeRecord
modules map[string]delivery.Module
assignments map[string]meshmoduleassigner.Assignments
records map[string]nodeengine.NodeRecord
modules map[string]meshcatalog.ModulesByModuleValue
manifests map[string][]byte
values map[string]map[string]any // by node.module
valuesRead []string
own []compose.OwnSecretRow
ownErr error
// asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming
// module its placement does not put on the node.
// module its assignment does not put on the node.
asked []compose.CredentialKey
// builds are the builder's keys, by "<module> <fp>"; archives the artifact store's blobs, by digest, and fetched
// every URL asked; served another node's values by "<node>.<module>", and servedRead every ask with its keys.
builds map[string]*delivery.BuilderBuild
builds map[string]*builds.BuilderBuild
archives map[string][]byte
fetched []string
served map[string]map[string]any
@@ -51,7 +58,7 @@ type bus struct {
held bool
}
func (b *bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
func (b *bus) Build(_ context.Context, module, fp string) (*builds.BuilderBuild, error) {
b.buildsRead = append(b.buildsRead, module)
return b.builds[module+" "+fp], nil
}
@@ -76,13 +83,17 @@ func (b *bus) Served(_ context.Context, node, module string, keys []string) (map
return out, nil
}
func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) {
return b.placements, nil
func (b *bus) Assignments(context.Context) (map[string]meshmoduleassigner.Assignments, error) {
return b.assignments, nil
}
func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority }
func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed }
func (b *bus) Records(context.Context) (map[string]nodeengine.NodeRecord, error) {
return b.records, nil
}
func (b *bus) Modules(context.Context) (map[string]meshcatalog.ModulesByModuleValue, error) {
return b.modules, nil
}
func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority }
func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed }
func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil }
func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil }
func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) {
raw, ok := b.manifests[digest]
if !ok {
@@ -98,7 +109,7 @@ func (b *bus) Credentials(_ context.Context, node string, asConsumer, asProvider
b.asked = append(b.asked, asConsumer...)
for _, k := range asConsumer {
placed := false
for _, m := range b.placements[node].Modules {
for _, m := range b.assignments[node].Modules {
placed = placed || m.Module == k.ConsumerModule
}
if !placed {
@@ -132,7 +143,7 @@ func publicKey(t *testing.T) string {
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
}
// Issue 1126 (S3, S4, S5): the bus's placement, node records, manifests and config values fill the same compose.Input the
// Issue 1126 (S3, S4, S5): the bus's assignment, node records, manifests and config values fill the same compose.Input the
// controller's store fills for compose's own two-machine mesh (compose's aMesh: anchor the hub, laptop), and that Input
// composes; a node reads only its own config values; what cannot be composed is refused, naming it.
func TestInput(t *testing.T) {
@@ -154,35 +165,35 @@ func TestInput(t *testing.T) {
if err != nil {
t.Fatal(err)
}
digest := delivery.ManifestDigest(xterm)
digest := meshcatalogclient.ManifestDigest(xterm)
// A module in the settings form (plex and the media modules on one node, 2026-10-26): its config values are in its
// layers, not mesh-config, so the placement carries them (MeshValues, Values) and mesh-config's are not read for it.
// layers, not mesh-config, so the assignment carries them (MeshValues, Values) and mesh-config's are not read for it.
media := []byte(`{"module":"media","version":"1","settings":{"lines":{"kind":"preference","default":0,"why":"kept"},` +
`"name":{"kind":"preference","default":"x","why":"shown"}},"own-secrets":{"token":"/srv/media/token"},"resources":[{"id":"conf","type":"file",` +
`"path":"/srv/media/conf","mode":"0644","content":"uid=${setting:lines} name=${setting:name}\n"}]}`)
mediaDigest := delivery.ManifestDigest(media)
mediaDigest := meshcatalogclient.ManifestDigest(media)
keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)}
caps := []string{"container-runtime", "systemd", "wireguard"}
tunnel := &delivery.RecordTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
Peers: []delivery.RecordTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}}
tunnel := &meshmoduleassigner.FoundTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
Peers: []meshmoduleassigner.FoundTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}}
mk := func() *bus {
return &bus{
placements: map[string]delivery.Placement{
"anchor": {Node: "anchor", Decided: delivery.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1",
assignments: map[string]meshmoduleassigner.Assignments{
"anchor": {Node: "anchor", Decided: meshmoduleassigner.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1",
Tunnel: tunnel, PeerNames: map[string]string{"peer": "phone"}},
Modules: []delivery.PlacedModule{{Module: overlay.Name}},
Holds: []delivery.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}},
"laptop": {Node: "laptop", Decided: delivery.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"},
Modules: []delivery.PlacedModule{{Module: overlay.Name},
{Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}},
{Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true,
Modules: []meshmoduleassigner.PlacedModule{{Module: overlay.Name}},
Holds: []meshmoduleassigner.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}},
"laptop": {Node: "laptop", Decided: meshmoduleassigner.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"},
Modules: []meshmoduleassigner.PlacedModule{{Module: overlay.Name},
{Module: "xterm", HasLayer: true, Ports: map[string]int64{"80": 8080}},
{Module: "media", HasMeshWide: true, MeshWideValues: map[string]any{"name": "mesh"}, HasLayer: true,
Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}},
},
records: map[string]delivery.NodeRecord{
records: map[string]nodeengine.NodeRecord{
"anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2},
"laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"},
},
modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}},
modules: map[string]meshcatalog.ModulesByModuleValue{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}},
manifests: map[string][]byte{digest: xterm, mediaDigest: media},
values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"},
"laptop.media": {"lines": "not mesh-config's"}},
@@ -278,37 +289,37 @@ func TestInput(t *testing.T) {
t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore))
}
// Issue 1126: a candidate placement equal to the node's composes what it runs, and CompareComposed says so; one
// Issue 1126: a candidate assignment equal to the node's composes what it runs, and CompareComposed says so; one
// thing changed in the group's candidate of another node (the hub's endpoint) names exactly the module it moves
// here. Placed puts the same placement over the store's input unchanged.
running := make([]delivery.Composed, 0, len(fromBus))
// here. Placed puts the same assignment over the store's input unchanged.
running := make([]compositions.Composed, 0, len(fromBus))
for _, c := range fromBus {
running = append(running, c)
}
laptop := mk().placements["laptop"]
asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps)
laptop := mk().assignments["laptop"]
asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Assignments: map[string]*meshmoduleassigner.Assignments{"laptop": &laptop}}, fps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatal(err)
}
if c := delivery.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) {
t.Errorf("the candidate equal to the placement: %+v", c)
if c := compositions.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) {
t.Errorf("the candidate equal to the assignment: %+v", c)
}
moved := mk().placements["anchor"]
moved := mk().assignments["anchor"]
moved.Decided.Endpoint = "anchor.example:51821"
changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps)
changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Assignments: map[string]*meshmoduleassigner.Assignments{"laptop": &laptop, "anchor": &moved}}, fps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatal(err)
}
if c := delivery.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" {
if c := compositions.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" {
t.Errorf("the hub's endpoint changed: %+v, want %s alone, naming its config", c, overlay.Name)
}
if c := delivery.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) {
if c := compositions.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) {
t.Errorf("nothing runs: %+v", c)
}
@@ -324,16 +335,16 @@ func TestInput(t *testing.T) {
files, _ := json.Marshal(catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"},
Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", "mode": "0600", "content": "{}", "merge": "json"}}})
one := func(placed ...string) *bus {
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
p := meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}}
for _, m := range placed {
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: m})
}
return &bus{placements: map[string]delivery.Placement{"n1": p},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}},
modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)},
"objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}},
manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs,
delivery.ManifestDigest(objects): objects, delivery.ManifestDigest(files): files},
return &bus{assignments: map[string]meshmoduleassigner.Assignments{"n1": p},
records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}},
modules: map[string]meshcatalog.ModulesByModuleValue{"keeper": {Manifest: meshcatalogclient.ManifestDigest(keeper)}, "lemurs": {Manifest: meshcatalogclient.ManifestDigest(lemurs)},
"objects": {Manifest: meshcatalogclient.ManifestDigest(objects)}, "files": {Manifest: meshcatalogclient.ManifestDigest(files)}},
manifests: map[string][]byte{meshcatalogclient.ManifestDigest(keeper): keeper, meshcatalogclient.ManifestDigest(lemurs): lemurs,
meshcatalogclient.ManifestDigest(objects): objects, meshcatalogclient.ManifestDigest(files): files},
own: []compose.OwnSecretRow{{Node: "n1", Module: "keeper", Name: "password", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}}
}
oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64),
@@ -345,7 +356,7 @@ func TestInput(t *testing.T) {
if err != nil {
t.Fatal(err)
}
var runsNow []delivery.Composed
var runsNow []compositions.Composed
for _, c := range runs {
runsNow = append(runsNow, c)
}
@@ -356,12 +367,12 @@ func TestInput(t *testing.T) {
if err != nil {
t.Fatal(err)
}
var consumerRuns []delivery.Composed
var consumerRuns []compositions.Composed
for _, c := range objectsRun {
consumerRuns = append(consumerRuns, c)
}
withoutOwn := func(cs []delivery.Composed) []delivery.Composed {
var out []delivery.Composed
withoutOwn := func(cs []compositions.Composed) []compositions.Composed {
var out []compositions.Composed
for _, c := range cs {
if c.Module != "" {
out = append(out, c)
@@ -374,16 +385,16 @@ func TestInput(t *testing.T) {
// node's own, each in its place, its module taken off; the rest split by module.
tunnelled := func() *bus {
b := one("keeper", "lemurs", overlay.Name)
p := b.placements["n1"]
p := b.assignments["n1"]
p.Decided.Endpoint = "n1.example:51820"
b.placements["n1"] = p
b.assignments["n1"] = p
return b
}
tunnelledAs := func() map[string]*delivery.Placement {
p := tunnelled().placements["n1"]
return map[string]*delivery.Placement{"n1": &p}
tunnelledAs := func() map[string]*meshmoduleassigner.Assignments {
p := tunnelled().assignments["n1"]
return map[string]*meshmoduleassigner.Assignments{"n1": &p}
}
folded := func() []delivery.Composed {
folded := func() []compositions.Composed {
b := tunnelled()
in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"})
if err != nil {
@@ -416,40 +427,40 @@ func TestInput(t *testing.T) {
raw, _ := json.Marshal(r)
resources = append(resources, raw)
}
split, err := delivery.SplitComposed("n1", resources, map[string]string{"keeper": oneFps["keeper"]}, nil, delivery.NodeOwn{})
split, err := compositions.Split("n1", resources, map[string]string{"keeper": oneFps["keeper"]}, nil, compositions.NodeOwn{})
if err != nil {
t.Fatal(err)
}
var out []delivery.Composed
var out []compositions.Composed
for _, c := range split {
out = append(out, c)
}
return out
}()
if got := InOwn(folded, tunnelled().placements["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) {
if got := InOwn(folded, tunnelled().assignments["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) {
t.Errorf("InOwn of the controller's composition = %v, want lemurs and %s", got, overlay.Name)
}
placedAs := func(modules ...string) map[string]*delivery.Placement {
p := one(modules...).placements["n1"]
return map[string]*delivery.Placement{"n1": &p}
placedAs := func(modules ...string) map[string]*meshmoduleassigner.Assignments {
p := one(modules...).assignments["n1"]
return map[string]*meshmoduleassigner.Assignments{"n1": &p}
}
for name, c := range map[string]struct {
b *bus
given map[string]*delivery.Placement
running []delivery.Composed
given map[string]*meshmoduleassigner.Assignments
running []compositions.Composed
verdict string
check func(delivery.CandidateAnswer) bool
check func(nodeengine.CandidateAnswer) bool
}{
"the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame,
func(a delivery.CandidateAnswer) bool {
"the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, nodeengine.CandidateAnswerVerdictSame,
func(a nodeengine.CandidateAnswer) bool {
return len(a.Same) == 3 && a.Proposal == "p-h1" && a.Revision == 7
}},
"the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame,
func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
"one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers,
func(a delivery.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }},
"the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), nodeengine.CandidateAnswerVerdictSame,
func(a nodeengine.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
"one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, nodeengine.CandidateAnswerVerdictDiffers,
func(a nodeengine.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }},
"one added, its secret not yet issued": {func() *bus { b := one("lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
nil, delivery.CandidateDiffers, func(a delivery.CandidateAnswer) bool {
nil, nodeengine.CandidateAnswerVerdictDiffers, func(a nodeengine.CandidateAnswer) bool {
for _, m := range a.Added {
if m.Module == "keeper" {
return reflect.DeepEqual(m.Unissued, []string{"keeper/password"}) && len(a.Refusals) == 0
@@ -458,62 +469,62 @@ func TestInput(t *testing.T) {
return false
}},
"a value missing for a module that runs": {func() *bus { b := one("keeper", "lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool {
runsNow, nodeengine.CandidateAnswerVerdictRefused, func(a nodeengine.CandidateAnswer) bool {
return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && strings.Contains(a.Refusals[0].Why, "keeper/password")
}},
// Review of L2b, round 1, finding 1: a consumer the candidate adds has no credential yet, and the vault refuses an
// ask for a module not on the node; it is not asked, and the module is added, its credential not yet issued.
"a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, delivery.CandidateDiffers,
func(a delivery.CandidateAnswer) bool {
"a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, nodeengine.CandidateAnswerVerdictDiffers,
func(a nodeengine.CandidateAnswer) bool {
return len(a.Added) == 1 && a.Added[0].Module == "files" && len(a.Added[0].Unissued) > 0 && len(a.Refusals) == 0
}},
// And with no trunk at all (the seed), a stand-in of a module the node runs is a refusal, not an addition.
"the seed, a value missing for a module that runs": {func() *bus { b := one(); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool {
runsNow, nodeengine.CandidateAnswerVerdictRefused, func(a nodeengine.CandidateAnswer) bool {
return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0
}},
"cannot compose": {one("keeper"), map[string]*delivery.Placement{"n1": {Node: "n2"}}, runsNow, delivery.CandidateRefused,
func(a delivery.CandidateAnswer) bool { return a.Refused != "" }},
"cannot compose": {one("keeper"), map[string]*meshmoduleassigner.Assignments{"n1": {Node: "n2"}}, runsNow, nodeengine.CandidateAnswerVerdictRefused,
func(a nodeengine.CandidateAnswer) bool { return a.Refused != "" }},
// Issue 1126 (the seed's review): the private network's module and one at a build of the old form, which no want
// names and the controller folds into the node's own, are folded there again: the node's own is the same, and
// neither is added.
"mesh-wireguard and an old-form module, folded": {tunnelled(), tunnelledAs(),
folded, delivery.CandidateSame, func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
folded, nodeengine.CandidateAnswerVerdictSame, func(a nodeengine.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
} {
fps := oneFps
if name == "mesh-wireguard and an old-form module, folded" {
fps = map[string]string{"keeper": oneFps["keeper"]} // the node's wants: no build of lemurs or the private network
}
a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7)
a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Assignments: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7)
if a.Verdict != c.verdict || !c.check(a) {
t.Errorf("%s: %+v", name, a)
}
if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil {
if raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Candidates: map[string]nodeengine.CandidateAnswer{"g": a}}); err != nil {
t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw)
}
}
if got, err := Placed(want, map[string]*delivery.Placement{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("Placed with the node's own placement changes the input: %v", err)
if got, err := Placed(want, map[string]*meshmoduleassigner.Assignments{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("Placed with the node's own assignment changes the input: %v", err)
}
if got, err := Placed(want, map[string]*delivery.Placement{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] {
t.Errorf("Placed with no placement: %+v, %v", got.Nodes[1], err)
if got, err := Placed(want, map[string]*meshmoduleassigner.Assignments{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] {
t.Errorf("Placed with no assignment: %+v, %v", got.Nodes[1], err)
}
if _, err := Placed(want, map[string]*delivery.Placement{"stranger": {Node: "stranger"}}); err == nil {
if _, err := Placed(want, map[string]*meshmoduleassigner.Assignments{"stranger": {Node: "stranger"}}); err == nil {
t.Error("Placed a node the input holds no node for")
}
if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": {Node: "anchor"}}}); err == nil {
t.Error("a given placement naming another node was taken")
if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Assignments: map[string]*meshmoduleassigner.Assignments{"laptop": {Node: "anchor"}}}); err == nil {
t.Error("a given assignment naming another node was taken")
}
// ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the
// placement the bus holds, and read back it is the store's input again.
for node, placed := range mk().placements {
// assignment the bus holds, and read back it is the store's input again.
for node, placed := range mk().assignments {
got, err := Export(want, node)
if err != nil {
t.Fatal(err)
}
if gj, _ := delivery.EncodePlacement(got); !delivery.SamePlacement(got, placed) {
pj, _ := delivery.EncodePlacement(placed)
if gj, _ := meshmoduleassignerclient.EncodeAssignments(got); !meshmoduleassignerclient.SameAssignments(got, placed) {
pj, _ := meshmoduleassignerclient.EncodeAssignments(placed)
t.Errorf("%s exported as %s, want %s", node, gj, pj)
}
}
@@ -549,8 +560,8 @@ func TestInput(t *testing.T) {
kept.manifests["old"] = oldXterm
oldFP := "src2:" + strings.Repeat("9", 64)
toolsRef := "artifact-store://xterm/tools/blobs/sha256:" + strings.Repeat("1", 64)
kept.builds = map[string]*delivery.BuilderBuild{"xterm " + oldFP: {State: delivery.BuildDone, Manifest: "old",
Artifacts: []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}}
kept.builds = map[string]*builds.BuilderBuild{"xterm " + oldFP: {State: builds.BuildDone, Manifest: "old",
Artifacts: []builds.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}}
parsedOld, err := catalogue.Decode(oldXterm)
if err != nil {
t.Fatal(err)
@@ -577,8 +588,8 @@ func TestInput(t *testing.T) {
if err != nil {
t.Fatal(err)
}
store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` +
`"serves":{"artifact-store":{"port":5000}}}`)
store := []byte(`{"module":"store","version":"1",` +
`"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5000,"from":"mesh"}]}`)
texts := map[string]string{}
var packed bytes.Buffer
zw := gzip.NewWriter(&packed)
@@ -602,35 +613,35 @@ func TestInput(t *testing.T) {
if err := zw.Close(); err != nil {
t.Fatal(err)
}
archiveDigest := delivery.ManifestDigest(packed.Bytes())
archiveDigest := meshcatalogclient.ManifestDigest(packed.Bytes())
digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
movedFps := map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64),
"store": "src2:" + strings.Repeat("5", 64)}
resticArtifacts := []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
resticArtifacts := []builds.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
{Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archiveDigest, Digest: archiveDigest}}
clientArtifacts := []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}
clientArtifacts := []builds.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}
movedMesh := func(built bool) *bus {
b := one()
p := b.placements["n1"]
p.Modules = []delivery.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}
p := b.assignments["n1"]
p.Modules = []meshmoduleassigner.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}
p.Decided.Account, p.Decided.AccountHome = "operator", "/home/operator"
b.placements["n1"] = p
b.assignments["n1"] = p
rec := b.records["n1"]
rec.Contract, rec.Capabilities = compose.HandedContract, []string{"systemd", "package-manager"}
b.records["n1"] = rec
b.modules = map[string]delivery.Module{}
b.modules = map[string]meshcatalog.ModulesByModuleValue{}
b.manifests = map[string][]byte{}
for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store} {
b.modules[module] = delivery.Module{FP2: movedFps[module], Manifest: delivery.ManifestDigest(raw)}
b.manifests[delivery.ManifestDigest(raw)] = raw
b.modules[module] = meshcatalog.ModulesByModuleValue{Fp2: movedFps[module], Manifest: meshcatalogclient.ManifestDigest(raw)}
b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
}
// The vault's own answer: the client's own secret; restic's is the client's to ask, so own leaves it out.
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
if built {
b.builds = map[string]*delivery.BuilderBuild{
"restic " + movedFps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: resticArtifacts},
"mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw), Artifacts: clientArtifacts},
"store " + movedFps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)},
b.builds = map[string]*builds.BuilderBuild{
"restic " + movedFps["restic"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(resticRaw), Artifacts: resticArtifacts},
"mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(clientRaw), Artifacts: clientArtifacts},
"store " + movedFps["store"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(store)},
}
b.archives = map[string][]byte{archiveDigest: packed.Bytes()}
}
@@ -657,13 +668,13 @@ func TestInput(t *testing.T) {
if !reflect.DeepEqual(builtBus.fetched, []string{"http://127.0.0.1:5000/v2/restic/files/blobs/" + archiveDigest}) {
t.Errorf("the templates archive was read at %v", builtBus.fetched)
}
resticKey, _ := delivery.ComposedKey("n1", "restic", movedFps["restic"])
resticKey, _ := compositions.Key("n1", "restic", movedFps["restic"])
if c := fromBuild[resticKey]; len(c.Filled) != 4 { // its three templated files and its own secret
t.Errorf("restic is not handed to the provisioner: its filled files are %v", c.Filled)
}
// A done key an artifact of which names no reference is waited for, in the controller's words (ADR 0498 §8).
unreferenced := movedMesh(true)
unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]}
unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []builds.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]}
if _, _, held, err := Compose(ctx, unreferenced, Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], "the build of restic is done and waits for the reference of tools") {
t.Errorf("a done key with an unreferenced artifact: %v %v, want restic held alone", err, held)
}
@@ -760,11 +771,11 @@ func TestInput(t *testing.T) {
`"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`)
withMailer := func(set bool) *bus {
b := mk()
p := b.placements["anchor"]
p.Modules = append(p.Modules, delivery.PlacedModule{Module: "mailer"})
b.placements["anchor"] = p
b.modules["mailer"] = delivery.Module{Manifest: delivery.ManifestDigest(mailer)}
b.manifests[delivery.ManifestDigest(mailer)] = mailer
p := b.assignments["anchor"]
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: "mailer"})
b.assignments["anchor"] = p
b.modules["mailer"] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(mailer)}
b.manifests[meshcatalogclient.ManifestDigest(mailer)] = mailer
if set {
b.served = map[string]map[string]any{"anchor.mailer": {"domain": "example.org"}}
}
@@ -787,7 +798,7 @@ func TestInput(t *testing.T) {
says string
}{
"no record": {func(b *bus) { delete(b.records, "laptop") }, Scope{Node: "anchor"}, "laptop, whose agent has written no record"},
"no placement": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no placement"},
"no assignment": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no assignment"},
"a manifest unread": {func(b *bus) { delete(b.manifests, digest) }, Scope{Node: "laptop"}, "the manifest of xterm"},
"a kept one unbuilt": {func(b *bus) {}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": "src2:gone"}}, "holds no done build"},
} {
@@ -829,34 +840,34 @@ func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates {
}
out[state][key] = raw
}
placements, _ := b.Placements(ctx)
for _, p := range placements {
values, err := delivery.PlacementValues(p)
assignments, _ := b.Assignments(ctx)
for _, p := range assignments {
values, err := meshmoduleassignerclient.AssignmentsValues(p)
for k, raw := range values {
put(PlacementRead, k, raw, err)
put(AssignmentsRead, k, raw, err)
}
}
records, _ := b.Records(ctx)
for node, r := range records {
k, _ := delivery.HaveNodeKey(node)
k, _ := nodeengineclient.HaveNodeKey(node)
rec := r
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &rec})
put(HaveRead, k, raw, err)
}
modules, _ := b.Modules(ctx)
for name, m := range modules {
raw, err := delivery.EncodeModule(m)
raw, err := meshcatalogclient.EncodeModule(m)
put(ModulesRead, name, raw, err)
var mf struct{ Module string }
_ = json.Unmarshal(b.manifests[m.Manifest], &mf)
k, _ := delivery.ManifestsKey(m.Manifest)
raw, err = delivery.EncodeManifest(delivery.Manifest{Module: mf.Module, JSON: string(b.manifests[m.Manifest])})
k, _ := meshcatalogclient.ManifestsKey(m.Manifest)
raw, err = meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: mf.Module, Manifest: string(b.manifests[m.Manifest])})
put(ManifestsRead, k, raw, err)
}
for key, build := range b.builds {
module, fp, _ := strings.Cut(key, " ")
k, _ := delivery.BuilderBuildKey(module, fp)
raw, err := delivery.EncodeBuilderBuild(*build)
k, _ := builds.BuilderBuildKey(module, fp)
raw, err := builds.EncodeBuilderBuild(*build)
put(BuildsRead, k, raw, err)
}
for nm, values := range b.values {
@@ -874,7 +885,7 @@ func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates {
// composed as what runs shows, said, by what runs and never by a name; a manifest that says it, or nothing running so, marks
// nothing.
func whatRunsIsKeptWhileAManifestDoesNotSayIt(t *testing.T) {
running := []delivery.Composed{
running := []compositions.Composed{
{Node: "a", Module: "runner-x", Resources: []json.RawMessage{json.RawMessage(`{"id":"runner-x.runtime","type":"process"}`)}},
{Node: "a", Module: "prov-y", Resources: []json.RawMessage{json.RawMessage(`{"id":"prov-y.need-broker","type":"file","vault-sealed":{"box":"b"}}`)}},
{Node: "a", Module: "app", Resources: []json.RawMessage{json.RawMessage(`{"id":"app.need-broker","type":"file"}`)}},
@@ -12,11 +12,14 @@ import (
"embed"
"encoding/json"
"errors"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/frombus"
"strings"
)
//go:embed testdata
@@ -24,26 +27,30 @@ var testdata embed.FS
// Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set.
type Bus struct {
Placement map[string]delivery.Placement
Record map[string]delivery.NodeRecord
Module map[string]delivery.Module
ByNode map[string]meshmoduleassigner.Assignments
Record map[string]nodeengine.NodeRecord
Module map[string]meshcatalog.ModulesByModuleValue
Manifests map[string][]byte
Value map[string]map[string]any // by node.module
Own []compose.OwnSecretRow
Builds map[string]*delivery.BuilderBuild // by "<module> <fp>"
Archives map[string][]byte // by digest
Serves map[string]map[string]any // another node's values, by node.module
Builds map[string]*builds.BuilderBuild // by "<module> <fp>"
Archives map[string][]byte // by digest
Serves map[string]map[string]any // another node's values, by node.module
Held bool
}
var _ frombus.Reader = (*Bus)(nil)
func (b *Bus) Placements(context.Context) (map[string]delivery.Placement, error) {
return b.Placement, nil
func (b *Bus) Assignments(context.Context) (map[string]meshmoduleassigner.Assignments, error) {
return b.ByNode, nil
}
func (b *Bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.Record, nil }
func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil }
func (b *Bus) VaultAuthority(context.Context) string { return "" }
func (b *Bus) Records(context.Context) (map[string]nodeengine.NodeRecord, error) {
return b.Record, nil
}
func (b *Bus) Modules(context.Context) (map[string]meshcatalog.ModulesByModuleValue, error) {
return b.Module, nil
}
func (b *Bus) VaultAuthority(context.Context) string { return "" }
func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) {
raw, ok := b.Manifests[digest]
@@ -73,7 +80,7 @@ func (b *Bus) Credentials(_ context.Context, _ string, asConsumer, asProvider []
return out, issued, nil
}
func (b *Bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
func (b *Bus) Build(_ context.Context, module, fp string) (*builds.BuilderBuild, error) {
return b.Builds[module+" "+fp], nil
}
@@ -119,8 +126,8 @@ func Provisioned() (*Bus, error) {
if err != nil {
return nil, err
}
store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` +
`"serves":{"artifact-store":{"port":5000}}}`)
store := []byte(`{"module":"store","version":"1",` +
`"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5000,"from":"mesh"}]}`)
postgres := []byte(`{"module":"postgres","version":"1","provides":[{"name":"postgres-database","scope":"mesh"}],` +
`"serves":{"postgres-database":{"port":5432}}}`)
@@ -145,33 +152,33 @@ func Provisioned() (*Bus, error) {
if err := zw.Close(); err != nil {
return nil, err
}
archive := delivery.ManifestDigest(packed.Bytes())
archive := meshcatalogclient.ManifestDigest(packed.Bytes())
digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
fps := FPs()
b := &Bus{
Placement: map[string]delivery.Placement{Node: {Node: Node,
Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"},
Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}},
Record: map[string]delivery.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=",
ByNode: map[string]meshmoduleassigner.Assignments{Node: {Node: Node,
Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"},
Modules: []meshmoduleassigner.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}},
Record: map[string]nodeengine.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=",
SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}},
Module: map[string]delivery.Module{}, Manifests: map[string][]byte{},
Module: map[string]meshcatalog.ModulesByModuleValue{}, Manifests: map[string][]byte{},
Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}},
Builds: map[string]*delivery.BuilderBuild{
"restic " + fps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: []delivery.BuiltArtifact{
Builds: map[string]*builds.BuilderBuild{
"restic " + fps["restic"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(resticRaw), Artifacts: []builds.BuiltArtifact{
{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
{Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archive, Digest: archive}}},
"mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw),
Artifacts: []delivery.BuiltArtifact{{Name: "client", Kind: "bundle",
"mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(clientRaw),
Artifacts: []builds.BuiltArtifact{{Name: "client", Kind: "bundle",
Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}},
"store " + fps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)},
"postgres " + fps["postgres"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(postgres)},
"store " + fps["store"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(store)},
"postgres " + fps["postgres"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(postgres)},
},
Archives: map[string][]byte{archive: packed.Bytes()},
Held: true,
}
for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store, "postgres": postgres} {
b.Module[module] = delivery.Module{FP2: fps[module], Manifest: delivery.ManifestDigest(raw)}
b.Manifests[delivery.ManifestDigest(raw)] = raw
b.Module[module] = meshcatalog.ModulesByModuleValue{Fp2: fps[module], Manifest: meshcatalogclient.ManifestDigest(raw)}
b.Manifests[meshcatalogclient.ManifestDigest(raw)] = raw
}
return b, nil
}
@@ -3,6 +3,14 @@ package frombustest
import (
"context"
"encoding/json"
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
engcomp "github.com/novox/mesh-host/internal/compositions"
"reflect"
"slices"
"strings"
@@ -10,7 +18,6 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose/frombus"
"github.com/novox/mesh-host/internal/compose/fromruntime"
)
@@ -63,22 +70,22 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
}
if row.bad != "" {
if !row.elsewhere {
p := b.Placement[Node]
p.Modules = append(p.Modules, delivery.PlacedModule{Module: "backup"})
b.Placement[Node] = p
p := b.ByNode[Node]
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: "backup"})
b.ByNode[Node] = p
}
digest := delivery.ManifestDigest([]byte(row.bad))
digest := meshcatalogclient.ManifestDigest([]byte(row.bad))
b.Manifests[digest] = []byte(row.bad)
b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest}
b.Module["backup"] = meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest}
if row.refused != "" {
b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest, Verdict: delivery.VerdictRefused,
b.Module["backup"] = meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Verdict: meshcatalogclient.VerdictRefused,
Problems: []string{row.refused}}
}
if row.built != "" {
builtDigest := delivery.ManifestDigest([]byte(row.built))
builtDigest := meshcatalogclient.ManifestDigest([]byte(row.built))
b.Manifests[builtDigest] = []byte(row.built)
b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest,
Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}}
b.Builds["backup "+fp] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: builtDigest,
Artifacts: []builds.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}}
}
if row.dataOnly {
items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
@@ -105,7 +112,7 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
if err != nil {
t.Fatalf("%s: the node's composition: %v", row.name, err)
}
if key, _ := delivery.ComposedKey(Node, "restic", fps["restic"]); out[key].Module != "restic" {
if key, _ := engcomp.Key(Node, "restic", fps["restic"]); out[key].Module != "restic" {
t.Errorf("%s: restic is not composed (held %v)", row.name, held)
}
if why, ok := held["backup"]; (row.bad == "" && len(held) > 0) || (row.bad != "" && (!ok || !strings.Contains(why, row.why) || len(held) != 1)) {
@@ -159,19 +166,19 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
byKey := map[string]int{}
ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error {
byKey[state+" watched"]++
return handle(stdio.StateChange{Key: "placement." + Node, Op: stdio.OpPut, Value: []byte(`{}`), Current: true})
return handle(stdio.StateChange{Key: Node + ".node", Op: stdio.OpPut, Value: []byte(`{}`), Current: true})
}, func(state, key string) ([]byte, bool, error) { byKey[state]++; return []byte(key), true, nil })
w := fromruntime.Watched{WatchedStates: ws}
for range 2 {
_, _, _ = w.Get(frombus.BuildsRead, "restic."+fp)
_, _, _ = w.Get(frombus.ManifestsRead, "sha256-d")
if _, found, _ := w.Get(frombus.PlacementRead, "placement."+Node); !found {
t.Error("the watched reader does not answer the placement from its copy")
if _, found, _ := w.Get(frombus.AssignmentsRead, Node+".node"); !found {
t.Error("the watched reader does not answer the assignment from its copy")
}
}
if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.PlacementRead] != 0 ||
byKey[frombus.PlacementRead+" watched"] != 1 {
t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the placement watched once", byKey)
if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.AssignmentsRead] != 0 ||
byKey[frombus.AssignmentsRead+" watched"] != 1 {
t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the assignment watched once", byKey)
}
fetched := 0
keep := fromruntime.KeptFetch(func(context.Context, string) ([]byte, error) { fetched++; return []byte("tar"), nil })
@@ -214,14 +221,14 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
t.Fatal(err)
}
fps, ownKey := FPs(), ""
ownKey, _ = delivery.ComposedNodeKey(Node)
resticKey, _ := delivery.ComposedKey(Node, "restic", fps["restic"])
ownKey, _ = engcomp.NodeKey(Node)
resticKey, _ := engcomp.Key(Node, "restic", fps["restic"])
before, _, _, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps)
account := before[ownKey].Own.Provisioner
if err != nil || account == "" || len(before[resticKey].Filled) == 0 {
t.Fatalf("the node composes with its client: account %q, restic's filled files %v: %v", account, before[resticKey].Filled, err)
}
var running []delivery.Composed
var running []engcomp.Composed
for _, c := range before {
running = append(running, c)
}
@@ -260,22 +267,22 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
delete(r.(map[string]any), "user")
}
raw, _ := json.Marshal(m)
mod.Manifest = delivery.ManifestDigest(raw)
mod.Manifest = meshcatalogclient.ManifestDigest(raw)
bb.Module["mesh-provisioner-client"], bb.Manifests[mod.Manifest] = mod, raw
built := *bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]]
built.Manifest = mod.Manifest
bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &built
want = ""
} else if row.filledHeld {
bb.Builds["restic "+fps["restic"]] = &delivery.BuilderBuild{State: delivery.BuildBuilding}
bb.Builds["restic "+fps["restic"]] = &builds.BuilderBuild{State: builds.BuildBuilding}
} else if row.placed {
// held: its build not done while it runs
bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &delivery.BuilderBuild{State: delivery.BuildBuilding}
bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &builds.BuilderBuild{State: builds.BuildBuilding}
} else {
// taken out of the node's placement while what runs still names it: the account goes with it, never kept stale
p := bb.Placement[Node]
p.Modules = slices.DeleteFunc(slices.Clone(p.Modules), func(m delivery.PlacedModule) bool { return m.Module == "mesh-provisioner-client" })
bb.Placement[Node] = p
// taken out of the node's assignment while what runs still names it: the account goes with it, never kept stale
p := bb.ByNode[Node]
p.Modules = slices.DeleteFunc(slices.Clone(p.Modules), func(m meshmoduleassigner.PlacedModule) bool { return m.Module == "mesh-provisioner-client" })
bb.ByNode[Node] = p
want = ""
}
after, _, held, err := frombus.Compose(context.Background(), bb, frombus.Mesh{}, frombus.Scope{Node: Node, Running: runs}, fps)
@@ -307,28 +314,33 @@ func statesOf(t *testing.T, b *Bus) frombus.States {
}
s[state][key] = raw
}
for node, p := range b.Placement {
raw, err := delivery.EncodePlacement(p)
put(frombus.PlacementRead, "placement."+node, raw, err)
for _, p := range b.ByNode {
vals, err := meshmoduleassignerclient.AssignmentsValues(p)
if err != nil {
put(frombus.AssignmentsRead, p.Node+".node", nil, err)
}
for key, raw := range vals {
put(frombus.AssignmentsRead, key, raw, nil)
}
}
for node, rec := range b.Record {
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &rec})
put(frombus.HaveRead, node+".node", raw, err)
}
for module, m := range b.Module {
key, _ := delivery.ModulesKey(module)
raw, err := delivery.EncodeModule(m)
key, _ := meshcatalogclient.ModulesKey(module)
raw, err := meshcatalogclient.EncodeModule(m)
put(frombus.ModulesRead, key, raw, err)
}
for digest, man := range b.Manifests {
key, _ := delivery.ManifestsKey(digest)
raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: string(man)})
key, _ := meshcatalogclient.ManifestsKey(digest)
raw, err := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "m", Manifest: string(man)})
put(frombus.ManifestsRead, key, raw, err)
}
for at, build := range b.Builds {
module, fp, _ := strings.Cut(at, " ")
key, _ := delivery.BuilderBuildKey(module, fp)
raw, err := delivery.EncodeBuilderBuild(*build)
key, _ := builds.BuilderBuildKey(module, fp)
raw, err := builds.EncodeBuilderBuild(*build)
put(frombus.BuildsRead, key, raw, err)
}
return s
@@ -0,0 +1,10 @@
package frombustest
import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest"
)
// The seats the tests judge against are given to catalogue as the node-engine gives the ones it read from the bus
// (novox/hq ADR 0635 §1); catalogue compiles none.
func init() { catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) }
+33 -29
View File
@@ -5,29 +5,33 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/resolution"
engcomp "github.com/novox/mesh-host/internal/compositions"
"strings"
"testing"
)
// aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given.
func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) {
t.Helper()
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
b := &bus{records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}},
modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}}
p := meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}}
b := &bus{records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}},
modules: map[string]meshcatalog.ModulesByModuleValue{}, manifests: map[string][]byte{}, builds: map[string]*builds.BuilderBuild{}}
fps := map[string]string{}
for i, raw := range manifests {
module := strings.SplitN(strings.TrimPrefix(raw, `{"module":"`), `"`, 2)[0]
p.Modules = append(p.Modules, delivery.PlacedModule{Module: module})
b.modules[module] = delivery.Module{Manifest: delivery.ManifestDigest([]byte(raw))}
b.manifests[delivery.ManifestDigest([]byte(raw))] = []byte(raw)
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: module})
b.modules[module] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest([]byte(raw))}
b.manifests[meshcatalogclient.ManifestDigest([]byte(raw))] = []byte(raw)
fps[module] = "src2:" + strings.Repeat(string(rune('1'+i)), 64)
}
b.placements = map[string]delivery.Placement{"n1": p}
b.assignments = map[string]meshmoduleassigner.Assignments{"n1": p}
return b, fps
}
@@ -48,7 +52,7 @@ func aNodeRefusedWholeSaysWhatItHeld(t *testing.T) {
// refuse writes the indexer's refusal of module's manifest into its modules value (novox/hq ADR 0578 §1).
func refuse(b *bus, module string, problems ...string) {
m := b.modules[module]
m.Verdict, m.Problems = delivery.VerdictRefused, problems
m.Verdict, m.Problems = meshcatalogclient.VerdictRefused, problems
b.modules[module] = m
}
@@ -119,10 +123,10 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) {
tools := `{"module":"node-tools","version":"1","tool-runner":true,"own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
`"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}`
lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5100,"from":"mesh"}],` +
`"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64)
running := []delivery.Composed{{Node: "n1", Module: "node-tools", FP: old, Resources: []json.RawMessage{
running := []engcomp.Composed{{Node: "n1", Module: "node-tools", FP: old, Resources: []json.RawMessage{
json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`),
json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` +
dg("1") + `","digest":"` + dg("1") + `","path":"` + resolution.BundlePath("node-tools", "runtime") + `"}`)}}}
@@ -141,14 +145,14 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) {
b.held = true // every grant held, so a contribution's credential stands in for nothing
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
mod := b.modules["node-tools"]
mod.FP2 = next
mod.Fp2 = next
b.modules["node-tools"] = mod
b.builds["node-tools "+old] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}}
b.builds["node-tools "+next] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest}
b.builds["node-tools "+old] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: mod.Manifest,
Artifacts: []builds.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}}
b.builds["node-tools "+next] = &builds.BuilderBuild{State: "building", Manifest: mod.Manifest}
if row.done {
b.builds["node-tools "+next] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("2"), Digest: dg("2")}}}
b.builds["node-tools "+next] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: mod.Manifest,
Artifacts: []builds.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("2"), Digest: dg("2")}}}
}
fps["node-tools"] = row.want
out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1", Running: running}, fps)
@@ -156,14 +160,14 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) {
t.Errorf("%s: the node is refused: %v", row.name, err)
continue
}
if lampKey, _ := delivery.ComposedKey("n1", "lamp", fps["lamp"]); out[lampKey].Module != "lamp" {
if lampKey, _ := engcomp.Key("n1", "lamp", fps["lamp"]); out[lampKey].Module != "lamp" {
t.Errorf("%s: lamp is not composed (held %v)", row.name, held)
}
if why := held["node-tools"]; (row.held == "") != (why == "") || !strings.Contains(why, row.held) {
t.Errorf("%s: node-tools held %q, want %q", row.name, why, row.held)
}
if row.digest != "" {
key, _ := delivery.ComposedKey("n1", "node-tools", row.want)
key, _ := engcomp.Key("n1", "node-tools", row.want)
if c := out[key]; !strings.Contains(fmt.Sprint(rawStrings(c.Resources)), row.digest) {
t.Errorf("%s: node-tools is composed as %v, want its bundle %s", row.name, rawStrings(c.Resources), row.digest)
}
@@ -215,7 +219,7 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) {
`"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}`
}
lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5100,"from":"mesh"}],` +
`"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
for _, row := range []struct {
name string
@@ -251,12 +255,12 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) {
name = "runtime"
}
mod := b.modules[m]
mod.FP2 = fps[m]
mod.Fp2 = fps[m]
b.modules[m] = mod
b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}}
b.builds[m+" "+fps[m]] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: mod.Manifest,
Artifacts: []builds.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}}
if m == "node-tools" && row.building {
b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest}
b.builds[m+" "+fps[m]] = &builds.BuilderBuild{State: "building", Manifest: mod.Manifest}
}
}
out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps)
@@ -267,7 +271,7 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) {
// A held module keeps what it contributes (issue 774, 2026-10-31: nextcloud held for its admin secret dropped out
// of postgres's received file, and postgres's helper would have locked its login): only its own secrets are left
// out of its stand-in, never its contributions, so the provider's received file names every consumer.
storeKey, _ := delivery.ComposedKey("n1", "artifacts", fps["artifacts"])
storeKey, _ := engcomp.Key("n1", "artifacts", fps["artifacts"])
// And never a stand-in for a grant not yet issued: the provider is applied, so a held module whose grant is
// not issued is composed without that contribution, never with "foreseen: made by the next send".
for key, c := range out {
@@ -284,7 +288,7 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) {
}
}
for _, m := range []string{"node-tools", "check", "lamp", "heal"} {
key, _ := delivery.ComposedKey("n1", m, fps[m])
key, _ := engcomp.Key("n1", m, fps[m])
_, composed := out[key]
why, isHeld := held[m]
want, wantHeld := row.held[m]
+2 -2
View File
@@ -3,13 +3,13 @@ package frombus
import (
"encoding/json"
"fmt"
engcomp "github.com/novox/mesh-host/internal/compositions"
"maps"
"slices"
"sort"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/resolution"
)
@@ -25,7 +25,7 @@ import (
// keepWhatRuns marks, in in, each module placed on node that what runs shows is the tool runner or carries the core's
// credentials, where its manifest does not say so; it returns, sorted by module, what it marked, in words. The tool runner is
// marked only where no placed module's manifest says it is one.
func keepWhatRuns(in *compose.Input, node string, running []delivery.Composed) []KeptAsRuns {
func keepWhatRuns(in *compose.Input, node string, running []engcomp.Composed) []KeptAsRuns {
placed := assignedOn(*in, node)
if len(placed) == 0 || len(running) == 0 {
return nil
@@ -0,0 +1,10 @@
package frombus
import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest"
)
// The seats the tests judge against are given to catalogue as the node-engine gives the ones it read from the bus
// (novox/hq ADR 0635 §1); catalogue compiles none.
func init() { catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) }
+35 -26
View File
@@ -2,7 +2,7 @@ package frombus
// **The bus read through a module's declared `reads`** (the lead, 2026-10-29: one reader in place of the copies in
// mesh-provisioner and disk-load): a Reader over the states a module reads, by a runtime's Get and Keys (a bundle's
// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: placement, each
// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: assignment, each
// node's record in its own have key, the indexer's modules and manifests, the builder's builds and mesh-config's resolved
// values. No own secret and no credential is read (compose.Planning). Pure as the rest of this package: a bundle's
// runtime states and an HTTP fetch are compose/fromruntime's.
@@ -15,23 +15,32 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"github.com/novox/mesh-host/internal/buckets"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/resolution"
)
// The states a module names in its manifest's `reads` to compose through StatesReader.
const (
PlacementRead = delivery.PlacementRead
ModulesRead = delivery.IndexerModule + ".modules"
ManifestsRead = delivery.IndexerModule + ".manifests"
BuildsRead = delivery.BuilderModule + ".builds"
HaveRead = "node-engine.have"
ResolvedRead = "mesh-config.resolved"
// The holders' names are kept here, in the engine's own code, as the names of the states a manifest's reads use
// (ADR 0634 §1, finding F4); a seat's contract names a state locally, not the module that holds it.
AssignmentsRead = buckets.AssignerHolder + ".assignment"
ModulesRead = buckets.CatalogHolder + ".modules"
ManifestsRead = buckets.CatalogHolder + ".manifests"
BuildsRead = builds.BuilderModule + ".builds"
HaveRead = "node-engine.have"
ResolvedRead = "mesh-config.resolved"
)
// StatesReads are those reads, in a manifest's order.
var StatesReads = []string{PlacementRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead}
var StatesReads = []string{AssignmentsRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead}
// States is a module's read states as its runtime gives them: a key's value, false when it has none.
type States interface {
@@ -76,19 +85,19 @@ func (r statesReader) each(read string, visit func(key string, value []byte)) er
return nil
}
func (r statesReader) Placements(context.Context) (map[string]delivery.Placement, error) {
keys := delivery.NewPlacementKeys()
err := r.each(PlacementRead, func(k string, v []byte) {
func (r statesReader) Assignments(context.Context) (map[string]meshmoduleassigner.Assignments, error) {
keys := meshmoduleassignerclient.NewKeys()
err := r.each(AssignmentsRead, func(k string, v []byte) {
_, _ = keys.Put(k, v, 0, false) // a key that does not read places nothing, as before
})
return keys.All(), err
}
func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord, error) {
out := map[string]delivery.NodeRecord{}
func (r statesReader) Records(context.Context) (map[string]nodeengine.NodeRecord, error) {
out := map[string]nodeengine.NodeRecord{}
err := r.each(HaveRead, func(k string, v []byte) {
if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode {
if h, err := delivery.DecodeHaveNode(v); err == nil && h.Record != nil {
if node, _, isNode, err := nodeengineclient.ParseHaveKey(k); err == nil && isNode {
if h, err := nodeengineclient.DecodeHaveNode(v); err == nil && h.Record != nil {
out[node] = *h.Record
}
}
@@ -96,11 +105,11 @@ func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord,
return out, err
}
func (r statesReader) Modules(context.Context) (map[string]delivery.Module, error) {
out := map[string]delivery.Module{}
func (r statesReader) Modules(context.Context) (map[string]meshcatalog.ModulesByModuleValue, error) {
out := map[string]meshcatalog.ModulesByModuleValue{}
err := r.each(ModulesRead, func(k string, v []byte) {
if module, err := delivery.ParseModulesKey(k); err == nil {
if m, err := delivery.DecodeModule(v); err == nil {
if module, err := meshcatalogclient.ParseModulesKey(k); err == nil {
if m, err := meshcatalogclient.DecodeModule(v); err == nil {
out[module] = m
}
}
@@ -109,7 +118,7 @@ func (r statesReader) Modules(context.Context) (map[string]delivery.Module, erro
}
func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error) {
key, err := delivery.ManifestsKey(digest)
key, err := meshcatalogclient.ManifestsKey(digest)
if err != nil {
return nil, err
}
@@ -120,11 +129,11 @@ func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error)
if !found {
return nil, fmt.Errorf("the indexer holds no manifest %s", digest)
}
m, err := delivery.DecodeManifest(v)
m, err := meshcatalogclient.DecodeManifest(v)
if err != nil {
return nil, err
}
return []byte(m.JSON), nil
return []byte(m.Manifest), nil
}
func (r statesReader) Values(_ context.Context, node, module string) (map[string]any, error) {
@@ -153,8 +162,8 @@ func (statesReader) Credentials(context.Context, string, []compose.CredentialKey
return nil, nil, nil
}
func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
key, err := delivery.BuilderBuildKey(module, fp)
func (r statesReader) Build(_ context.Context, module, fp string) (*builds.BuilderBuild, error) {
key, err := builds.BuilderBuildKey(module, fp)
if err != nil {
return nil, err
}
@@ -162,7 +171,7 @@ func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.Bui
if err != nil || !found {
return nil, err
}
b, err := delivery.DecodeBuilderBuild(v)
b, err := builds.DecodeBuilderBuild(v)
if err != nil {
return nil, err
}
+6 -6
View File
@@ -2,7 +2,7 @@ package frombus
// **A node's plans at two sets of builds, over one read** (issue 1954; novox/hq ADR 0570 clause 2, ADR 0580 clause 2):
// the seat mesh-provisioner's holder composes each node at the builds its wants name and at the newest build of each
// placed module. Both are composed from one read of the bus (placement, records, the indexer's modules and manifests,
// placed module. Both are composed from one read of the bus (assignment, records, the indexer's modules and manifests,
// values: base); only the builds the node's modules are composed from differ, each build read once for both, and a
// build taken in once per content by a reader that keeps it (StatesReader). Where the time went is said by phase.
@@ -12,11 +12,11 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// HeldPlans is one scope's plans, as PlansHeld answers them: err a refusal that is no single module's.
@@ -75,13 +75,13 @@ func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newe
// onceBuilds is r's Build, each module's build at a fingerprint read once: both scopes of one composition read the
// builds of the same moment.
func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*builds.BuilderBuild, error) {
type answer struct {
b *delivery.BuilderBuild
b *builds.BuilderBuild
err error
}
read := map[string]answer{}
return func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
return func(ctx context.Context, module, fp string) (*builds.BuilderBuild, error) {
key := module + " " + fp
if a, ok := read[key]; ok {
return a.b, a.err
@@ -101,7 +101,7 @@ type takenInKeeper interface {
// takenInKey is what a take-in is made from: the artifact store's address and the build's record (its manifest's digest, its artifacts by digest), read
// whole; an archive is checked against its digest on every read, so the same record takes in to the same bytes.
func takenInKey(module, fp, address string, b delivery.BuilderBuild) (string, bool) {
func takenInKey(module, fp, address string, b builds.BuilderBuild) (string, bool) {
raw, err := json.Marshal(b)
if err != nil {
return "", false
+1 -1
View File
@@ -34,7 +34,7 @@ func (States) Keys(state string) ([]string, error) { return stdio.State(state).K
func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) }
// Watched is frombus's States over a watched copy (novox/hq ADR 0580 clause 1): the states a composition reads whole
// (placement, haves, modules, resolved values) from the copy, a build by key when asked, a manifest once per digest.
// (assignment, haves, modules, resolved values) from the copy, a build by key when asked, a manifest once per digest.
type Watched struct{ *stdio.WatchedStates }
// Get is a key's value: a build read now, a manifest kept once read, any other state's from the copy.
+4 -4
View File
@@ -19,11 +19,11 @@ import (
"encoding/json"
"errors"
"fmt"
engcomp "github.com/novox/mesh-host/internal/compositions"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Input is everything the composition reads. Each field is what one read of the controller's inventory answers, named
@@ -224,7 +224,7 @@ type Node struct {
// there (InOwn) whose manifest names a build (NamesBuild) runs a build of the old form only the controller holds,
// so it is left to the node: Compositions composes it as a stub (Stub) and carries its resources as they run, each
// after the resource it runs after (issue 1126). Nil composes every folded module from its manifest, as before.
RunsOwn *delivery.Composed
RunsOwn *engcomp.Composed
// RunningProvisioner is the account the provisioner's client runs as on this node now, read from what runs (its own
// composition's provisioner account, else the user of the provisioner client's running process), empty when nothing
// says it. A client held as its stub this pass composes no process, so the node keeps this account and its handing
@@ -254,13 +254,13 @@ type ComposedHow struct {
// Held is each module the read held, with why: composed as what runs of it, and the rest without it.
Held map[string]string
// Running is what runs of each held module, its own composition, by which it is carried as it runs.
Running []delivery.Composed
Running []engcomp.Composed
// HeldBefore is each module the node holds composing without the change (the candidate's base), held by the read or
// blamed later for a refusal: the change refuses none of them (issue 774, review round 1).
HeldBefore map[string]string
}
// Place is where a node is, as its placement says (inv.Overlay).
// Place is where a node is, as its assignment says (inv.Overlay).
type Place struct {
Key string
Endpoint string
+4 -4
View File
@@ -8,9 +8,9 @@ import (
"strconv"
"strings"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/compose/resolution"
"github.com/novox/mesh-host/internal/overlay"
)
// The private network: who is on it, where, and what they are called. Ported from mesh-controller
@@ -123,10 +123,10 @@ func (c *composer) network(on map[string]bool, refused map[string]string) (*over
Site: p.Site, Hub: p.Hub, Address: p.Address,
}
if t, takes := tunnels[node.Name]; takes && t.NodeAdopted {
// Only an adopted node is told to take the found unit over. A hub whose placement disagrees with the tunnel is
// Only an adopted node is told to take the found unit over. A hub whose assignment disagrees with the tunnel is
// refused, not composed.
if wrong := disagrees(p, t.Tunnel); wrong != "" {
return nil, fmt.Errorf("%s takes over the tunnel on %s and its placement disagrees with it: %s. "+
return nil, fmt.Errorf("%s takes over the tunnel on %s and its assignment disagrees with it: %s. "+
"Re-place it — `overlay place %s --hub --endpoint <host>:%d …` — and push again; "+
"nothing was composed", node.Name, t.Interface, wrong, node.Name, t.Port)
}
@@ -217,7 +217,7 @@ func (c *composer) generators() (map[string]catalogue.Generator, error) {
return map[string]catalogue.Generator{overlay.Name: net}, nil
}
// disagrees says how a node's placement differs from the tunnel it takes over — its address not the tunnel's, its
// disagrees says how a node's assignment differs from the tunnel it takes over — its address not the tunnel's, its
// endpoint not on the tunnel's port — or nothing when both agree.
func disagrees(p Place, t Tunnel) string {
var wrong []string
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/overlay"
)
// onTheTunnel makes the hub of an Input what mesh-controller's network_test.go makes of anchor in
@@ -80,7 +80,7 @@ func TestTheOverlaysRangeIsTheAdoptedTunnels(t *testing.T) {
}
}
// A takeover is composed only for a hub whose placement agrees with the tunnel (mesh-controller network_test.go
// A takeover is composed only for a hub whose assignment agrees with the tunnel (mesh-controller network_test.go
// TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel): an address or an endpoint port that differs would have the host stop
// the found interface and raise the mesh's where no peer is listening.
func TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel(t *testing.T) {
+1 -1
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/overlay"
)
func resourceIDs(d Declared) []string {
+1 -1
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/overlay"
)
// fourOnTheNetwork is aMesh and two more machines on the private network at the site `there`: `ace`, dialled at
+1 -1
View File
@@ -78,7 +78,7 @@ func (c *composer) render(nodeName string, mode Mode) (Rendered, error) {
}
r := placed.Rendering
out.ForeseenPorts = placed.ForeseenPorts
// A plan carries its contributions' machine ports, refused where the placement assigned none (issue 774).
// A plan carries its contributions' machine ports, refused where the assignment assigned none (issue 774).
r.Planned = mode == Planning
r.Grants, r.Withheld, r.Unbound = grants, withheld, unbound
out.Withheld, out.Unbound = withheld, unbound
+92 -15
View File
@@ -3,12 +3,13 @@ package compose
import (
"errors"
"fmt"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/compose/resolution"
"github.com/novox/mesh-host/internal/overlay"
)
// The contracts of the node-engine's statement that gate what a machine is sent, as the controller's link package names them
@@ -167,7 +168,21 @@ func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) {
// Where this machine reaches each mesh seat's holder, for ${seat:<seat>:reach} (novox/hq ADR 0222).
if store != "" {
r.SeatReach = map[string]string{"mesh-artifact-store": store}
// Every name the store is reached through answers the one address (ENG-3, ADR 0632 §1), each with its holder from the
// holdings on record, written beside every image routed through it (design 51, ENG-10).
r.SeatReach, r.SeatHolder = map[string]string{}, map[string]string{}
for _, seat := range resolution.StoreSeats {
r.SeatReach[seat] = store
for _, h := range c.in.Holdings {
if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat && h.Scope == catalogue.ScopeMesh && h.Module != "" {
r.SeatHolder[seat] = h.Module
break
}
}
}
if len(r.SeatHolder) == 0 {
r.SeatHolder = nil
}
}
r.Settings = settings
r.Given = given
@@ -312,18 +327,74 @@ func (c *composer) namesInTheMesh() map[string]string {
return out
}
// artifactStoreOnNetwork is the machine on this network that offers the artifact store, and the port that machine put it on.
func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string, found bool, err error) {
providers := map[string]catalogue.Manifest{}
for name, m := range c.in.Catalogue {
if _, offers := m.Serves[catalogue.ArtifactStoreProvision]; offers {
providers[name] = m
// StoreSeat is the seat whose holder is the artifact store (novox/hq ADR 0632 §1): the store's address is that holder's
// listen open to the mesh, on the machine assignment puts it on. The provision artifact-store it was found by before is
// retired; no module serves it.
const StoreSeat = "container-image-registry"
// storeHolders are the modules holding the store's seat: the holdings on record, and only where none is on record every
// module of the catalogue claiming it at the mesh's scope, as a seat holder's reach is read (replicatedHolders). Any name
// of the store counts (resolution.StoreSeats): until every counted engine states the new reading, the manifest every
// node reads is the holder's old one, which claims only mesh-artifact-store (ADR 0632 §2), and an engine that finds the
// store by the seat must find it there too.
func (c *composer) storeHolders() map[string]catalogue.Manifest {
isStore := func(claim string) bool {
hs, ok := catalogue.SeatNamed(claim)
return ok && slices.Contains(resolution.StoreSeats, hs.Name)
}
out := map[string]catalogue.Manifest{}
for _, h := range c.in.Holdings {
if isStore(h.Claim) && h.Scope == catalogue.ScopeMesh && h.Module != "" {
if m, known := c.in.Catalogue[h.Module]; known {
out[h.Module] = m
}
}
}
if len(providers) == 0 {
if len(out) > 0 {
return out
}
for name, m := range c.in.Catalogue {
for _, cl := range m.Claims {
if isStore(cl.Name) && cl.At() == catalogue.ScopeMesh {
out[name] = m
}
}
}
return out
}
// storeListen is the store holder's listen every machine pulls from: its one listen open to the mesh. A holder opening
// none, or more than one, says no address, by name: which of two ports is the store is no guess the composer makes.
func storeListen(m catalogue.Manifest) (catalogue.Listening, error) {
var open []catalogue.Listening
for _, l := range m.Listens {
if l.From == catalogue.FromMesh && l.At() == "tcp" {
open = append(open, l)
}
}
switch len(open) {
case 1:
return open[0], nil
case 0:
return catalogue.Listening{}, fmt.Errorf("%s holds %s and opens no tcp listen to the mesh: the artifact store has no "+
"address (novox/hq ADR 0632 §1)", m.Module, StoreSeat)
}
var said []string
for _, l := range open {
said = append(said, fmt.Sprintf("%s (%d)", l.Name, l.Port))
}
return catalogue.Listening{}, fmt.Errorf("%s holds %s and opens %s to the mesh: the artifact store's address is its one "+
"listen open to the mesh (novox/hq ADR 0632 §1)", m.Module, StoreSeat, strings.Join(said, ", "))
}
// artifactStoreOnNetwork is the machine on this network that holds the store's seat, and the port that machine put the
// holder's listen on (novox/hq ADR 0632 §1).
func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string, found bool, err error) {
holders := c.storeHolders()
if len(holders) == 0 {
return "", "", false, nil
}
// In a stated order, so two machines offering it would always answer the same one.
// In a stated order, so two machines holding it would always answer the same one.
machines := make([]string, 0, len(on))
for machine := range on {
machines = append(machines, machine)
@@ -335,17 +406,23 @@ func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string
return "", "", false, fmt.Errorf("reading what %s is assigned: %w", machine, err)
}
for _, a := range n.Assigned {
m, offers := providers[a]
if !offers {
m, holds := holders[a]
if !holds {
continue
}
serves, _, err := c.servedOnNode(machine, m, catalogue.ArtifactStoreProvision)
l, err := storeListen(m)
if err != nil {
return "", "", false, err
}
ports, _, err := c.portsGivenOn(machine, m)
if err != nil {
return "", "", false, fmt.Errorf("reading where %s puts the artifact store: %w", machine, err)
}
if p, ok := serves["port"]; ok {
return machine, fmt.Sprintf("%v", p), true, nil
at := l.Port
if given, ok := ports[l.Port]; ok {
at = given
}
return machine, fmt.Sprintf("%d", at), true, nil
}
}
return "", "", false, nil
+84 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/overlay"
)
// aWebModule publishes one port in the short form, which the mesh is free to move (MachineSide).
@@ -254,7 +254,8 @@ func TestTheNodeEnginesContractGatesWhatItIsSent(t *testing.T) {
func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *testing.T) {
in := aMesh(t)
in.Catalogue["artifacts"] = catalogue.Manifest{Module: "artifacts", Version: "1",
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}},
Listens: []catalogue.Listening{{Name: "registry", Port: 5100, From: catalogue.FromMesh}}}
anchor := nodeOf(t, &in, "anchor")
anchor.Assigned = append(anchor.Assigned, "artifacts")
if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "anchor.internal:5100" {
@@ -272,6 +273,87 @@ func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *test
if got := placed(t, in, "anchor").Rendering.Built; !got["mesh-sdk"] {
t.Errorf("built is %v", got)
}
// Its holder, from the holdings on record, beside the address (design 51, ENG-10): none on record names none, and a change
// of the holder is the rendering's.
if got := placed(t, in, "anchor").Rendering.SeatHolder; got != nil {
t.Errorf("a store with no holder on record names %v", got)
}
in.Holdings = []catalogue.Held{{Claim: "mesh-artifact-store", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "artifacts"}}
if got := placed(t, in, "laptop").Rendering.SeatHolder["mesh-artifact-store"]; got != "" {
t.Errorf("a machine that reaches no store names its holder %q", got)
}
if got := placed(t, in, "anchor").Rendering.SeatHolder["mesh-artifact-store"]; got != "artifacts" {
t.Errorf("the store's holder is %q", got)
}
in.Holdings[0].Module = "distribution"
if got := placed(t, in, "anchor").Rendering.SeatHolder["mesh-artifact-store"]; got != "distribution" {
t.Errorf("after the holder changed it is %q", got)
}
}
// The store's address is the image seat's holder's listen open to the mesh, on the port its node put it on, and every
// name of the store answers it (novox/hq ADR 0632 §1): a module serving the retired provision artifact-store is no store,
// and a holder opening two listens to the mesh says which is the store by no guess.
func TestTheStoreIsFoundByTheImageSeatsHolderAndItsListen(t *testing.T) {
in := aMesh(t)
in.Catalogue["old"] = catalogue.Manifest{Module: "old", Version: "1",
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
anchor := nodeOf(t, &in, "anchor")
anchor.Assigned = append(anchor.Assigned, "old")
if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "" {
t.Errorf("a module serving artifact-store is reached as the store at %q", got)
}
in.Catalogue["registry"] = catalogue.Manifest{Module: "registry", Version: "1",
Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}},
Listens: []catalogue.Listening{{Name: "local", Port: 5001, From: catalogue.FromMachine},
{Name: "registry", Port: 5000, From: catalogue.FromMesh}}}
anchor.Assigned = append(anchor.Assigned, "registry")
anchor.Ports = map[string]map[int]int{"registry": {5000: 5200}}
r := placed(t, in, "laptop").Rendering
if r.ArtifactStore != "anchor.internal:5200" {
t.Errorf("laptop reaches the store at %q, want the holder's listen open to the mesh on anchor's port", r.ArtifactStore)
}
for _, seat := range []string{"mesh-artifact-store", "container-image-registry", "oci-artifact-registry"} {
if r.SeatReach[seat] != "anchor.internal:5200" {
t.Errorf("${seat:%s:reach} is %q", seat, r.SeatReach[seat])
}
}
// main's distribution, the manifest every node reads while the indexer holds the new one (ADR 0632 §2): it claims
// only mesh-artifact-store and serves the retired provision; the engine finds the store by that claim and its listen.
old := in
old.Catalogue = map[string]catalogue.Manifest{}
for k, v := range in.Catalogue {
if k != "registry" {
old.Catalogue[k] = v
}
}
old.Catalogue["distribution"] = catalogue.Manifest{Module: "distribution", Version: "2",
Provides: []catalogue.Offer{{Name: catalogue.ArtifactStoreProvision, Scope: catalogue.ScopeMesh}},
Claims: []catalogue.Claim{{Name: "mesh-artifact-store", Scope: catalogue.ScopeMesh}},
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}},
Listens: []catalogue.Listening{{Name: "registry", Port: 5000, Protocol: "tcp", From: catalogue.FromMesh}}}
old.Nodes = append([]Node(nil), in.Nodes...)
for i := range old.Nodes {
if old.Nodes[i].Name == "anchor" {
var kept []string
for _, a := range old.Nodes[i].Assigned {
if a != "registry" && a != "old" {
kept = append(kept, a)
}
}
old.Nodes[i].Assigned = append(kept, "distribution")
old.Nodes[i].Ports = map[string]map[int]int{"distribution": {5000: 5400}}
}
}
if got := placed(t, old, "laptop").Rendering.ArtifactStore; got != "anchor.internal:5400" {
t.Errorf("main's distribution, claiming only mesh-artifact-store, is reached at %q, want anchor.internal:5400", got)
}
m := in.Catalogue["registry"]
m.Listens = append(m.Listens, catalogue.Listening{Name: "other", Port: 5300, From: catalogue.FromMesh})
in.Catalogue["registry"] = m
if _, err := Places(in, "laptop"); err == nil || !strings.Contains(err.Error(), "opens registry (5000), other (5300) to the mesh") {
t.Errorf("a holder opening two listens to the mesh: %v", err)
}
}
// A zone a module declares is placed where the mesh put it: settled from that node's settings, at the node's private address, on
+35 -8
View File
@@ -5,6 +5,7 @@ import (
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/registryforms"
)
// artifactsInto composes the artifact store's address into a resource's `image` and `source`.
@@ -32,25 +33,26 @@ func artifactsInto(resource map[string]any, module string, with mcat.Rendering)
if !ok {
continue
}
if _, kept := mcat.InArtifactStore(written); kept {
if _, kept := registryforms.Forms.InArtifactStore(written); kept {
if with.ArtifactStore == "" {
return fmt.Errorf(
"%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+
"artifact store on its network to fetch it from — nothing assigned offers "+
"%s, or the machine offering it is not on the private network",
module, resource["id"], written, mcat.ArtifactStoreProvision)
"artifact store on its network to fetch it from — nothing assigned holds the seat "+
"container-image-registry, or the machine holding it is not on the private network",
module, resource["id"], written)
}
resource[key] = mcat.Routed(written, with.ArtifactStore)
resource[key] = registryforms.Forms.Routed(written, with.ArtifactStore)
imageSeatInto(resource, key, written, with)
continue
}
if with.ArtifactStore == "" {
continue
}
recorded := mcat.Recorded(written)
recorded := registryforms.Forms.Recorded(written)
if recorded == written {
continue
}
path, _ := mcat.InArtifactStore(recorded)
path, _ := registryforms.Forms.InArtifactStore(recorded)
repository := path
if at := strings.IndexAny(path, "@"); at >= 0 {
repository = path[:at]
@@ -58,8 +60,33 @@ func artifactsInto(resource map[string]any, module string, with mcat.Rendering)
repository = path[:blobs]
}
if with.Built[repository] {
resource[key] = mcat.Routed(recorded, with.ArtifactStore)
resource[key] = registryforms.Forms.Routed(recorded, with.ArtifactStore)
imageSeatInto(resource, key, written, with)
}
}
return nil
}
// artifactSeat is the seat an image is routed through: the artifact store's (the registries' four seats follow as the
// references name them).
const artifactSeat = "mesh-artifact-store"
// imageSeatInto writes beside a container's routed image the seat the reference came through and that seat's holder (design 51
// *distribution's first delivery*, issue 1966 ENG-10), so the node-engine finds by the declaration whom a failed pull waits
// on, never by a module's name. Neither is part of what a container is: a declaration composed again for a new holder
// replaces no container. Nothing is written where the holder is not on record, or for a resource that is no container.
func imageSeatInto(resource map[string]any, key, written string, with mcat.Rendering) {
if key != "image" || resource["type"] != "container" {
return
}
// The seat a reference of the registry form names; the older forms came through the artifact store's.
seat := artifactSeat
if named, _, ok := registryforms.Forms.InRegistrySeat(written); ok {
seat = named
}
if with.SeatHolder[seat] == "" {
return
}
resource["image-seat"] = seat
resource["image-holder"] = with.SeatHolder[seat]
}
@@ -46,6 +46,45 @@ func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) {
t.Errorf("composing wrote the address into the catalogue's copy: %v", got)
}
// The seat the image came through and its holder, beside it (ENG-10); a holder not on record writes neither, a public
// image and an archive carry none, and a holder changing changes the declaration but not the container's spec.
if got := fileNamed(out, "gitea.server"); got["image-seat"] != nil || got["image-holder"] != nil {
t.Errorf("a holder not on record was written: %v %v", got["image-seat"], got["image-holder"])
}
held := mcat.Rendering{ArtifactStore: "anchor.internal:5101", SeatHolder: map[string]string{"mesh-artifact-store": "distribution"}}
held1, err := Declaration(r, held)
if err != nil {
t.Fatal(err)
}
if got := fileNamed(held1, "gitea.server"); got["image-seat"] != "mesh-artifact-store" || got["image-holder"] != "distribution" {
t.Errorf("the routed image names its seat and holder as %v %v", got["image-seat"], got["image-holder"])
}
for _, id := range []string{"gitea.config", "gitea.cache"} {
if got := fileNamed(held1, id); got["image-seat"] != nil || got["image-holder"] != nil || got["seat"] != nil {
t.Errorf("%s names a seat: %v", id, got)
}
}
// A reference in the form a registry seat answers is routed the same way, names the seat it came through (ENG-4), and its
// bundle is fetched as an archive from the store.
seatForm := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{{Module: "gitea", Version: "1", Resources: []map[string]any{
{"id": "server", "type": "container", "name": "mesh-gitea", "image": "container-image-registry/gitea/server@" + digest},
{"id": "config", "type": "archive", "path": "/etc/gitea", "source": "oci-artifact-registry/gitea/config@" + digest}}}}}
held.SeatHolder["container-image-registry"] = "distribution"
seated, err := Declaration(seatForm, held)
if err != nil {
t.Fatal(err)
}
if got := fileNamed(seated, "gitea.server"); got["image"] != "anchor.internal:5101/gitea/server@"+digest || got["image-seat"] != "container-image-registry" || got["image-holder"] != "distribution" {
t.Errorf("a seat-form image is %v, naming %v %v", got["image"], got["image-seat"], got["image-holder"])
}
if got := fileNamed(seated, "gitea.config")["source"]; got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest {
t.Errorf("a seat-form archive is fetched from %v", got)
}
held.SeatHolder = map[string]string{"mesh-artifact-store": "other-holder"}
if held2, err := Declaration(r, held); err != nil || fileNamed(held2, "gitea.server")["image-holder"] != "other-holder" {
t.Errorf("a new holder is not written: %v", err)
}
// And the store moves: the same record, another address, without a rebuild.
out, err = Declaration(r, mcat.Rendering{ArtifactStore: "laptop.internal:5000"})
if err != nil {
@@ -1,52 +0,0 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// controlRecordsDecision is the record that made the two seats.
const controlRecordsDecision = "novox/hq ADR 0565"
// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15).
func wantsVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " +
"and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " +
"rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " +
"with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.",
Input: schema(map[string]string{
"module": "one module",
"policy": "with module: roll, together, record, or default to take a person's choice back",
"why": "with policy: why, required and kept",
"compare": "\"true\": each node's own composition, by its digest, and the assigner's findings",
}, nil, "compare")},
{Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " +
"indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " +
"the step itself is the put on the operator's warrant.",
Input: schema(map[string]string{}, nil)},
}
}
// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them.
func handActsVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " +
"(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " +
"runtime proves, never said by the caller. Said as act-recorded.",
Input: schema(map[string]string{
"what": "what was done, in a line",
"why": "why it had to be done by hand",
"cause": "the cause in a word, or a condition's kind",
"condition": "the key of the condition it addressed, if any (optional)",
"verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own",
}, []string{"what", "why"})},
{Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " +
"cause of each, and the causes recorded twice within a fortnight (a healer is wanted).",
Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause",
"who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")},
{Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " +
"with which proofs and which answer read from the router's own record of that module's ask, never from the " +
"caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.",
Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})},
}
}
+1 -1
View File
@@ -1520,7 +1520,7 @@ func resolutionContributedOf(r mcat.Resolution, m mcat.Manifest, to string, with
}
// atAssignedPort is a contribution of m with its port where this machine publishes it for m: the number
// `${port:<software port>}` gives m itself and its declaration publishes (machinePort, port_into.go), the placement's
// `${port:<software port>}` gives m itself and its declaration publishes (machinePort, port_into.go), the assigner's
// assignment where it holds one and the software's own number where it holds none (a bare port not yet moved, a fixed
// mapping, the host's network). What its provider dials, on this machine over loopback and from another at m's address.
//
+2 -1
View File
@@ -1,12 +1,13 @@
package resolution
import (
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// deliveryRemovedInR6 is the row of a verb release 6 removed from the delivery seat.
func deliveryRemovedInR6(name string) mcat.Verb {
return mcat.Verb{Name: name, Description: "Removed in R6 (ADR 0539): refused by name, naming its replacement: " + mcat.RemovedDeliveryVerbs[name], Input: schema(nil, nil), Optional: true}
return mcat.Verb{Name: name, Description: meshdeliveryclient.RemovedPrefix + meshdeliveryclient.RemovedVerbs()[name], Input: schema(nil, nil), Optional: true}
}
// deliveryVerbs are the delivery seat's tools: what reads the merges and checks, and the acts of a person and healer H2
@@ -0,0 +1,30 @@
package resolution
import (
"testing"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
)
// Each verb release 6 removed from the delivery seat is a row refusing it by name with the replacement the seat's client
// gives (the list is the contract's, not the SDK's: novox/hq ADR 0634 §2), and the rows are exactly those verbs.
func TestTheDeliverySeatRefusesTheRemovedVerbsByNameWithTheClientsReplacement(t *testing.T) {
removed := meshdeliveryclient.RemovedVerbs()
rows := 0
for _, v := range deliveryVerbs() {
want, isRemoved := removed[v.Name]
if !isRemoved {
continue
}
rows++
if v.Description != meshdeliveryclient.RemovedPrefix+want || !v.Optional {
t.Errorf("%s = %q (optional %v), want %q", v.Name, v.Description, v.Optional, meshdeliveryclient.RemovedPrefix+want)
}
}
if rows != len(removed) {
t.Errorf("%d rows refuse a removed verb, the client names %d", rows, len(removed))
}
if len(removed) != 5 {
t.Errorf("the removed verbs are %v", removed)
}
}
@@ -1,11 +0,0 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// engineSeat is the seat's row in the closed set.
func engineSeat() mcat.Seat {
return mcat.Seat{Name: mcat.EngineSeat, Scope: mcat.ScopeNode, Serves: mcat.EngineVerbs(), Emits: append([]string(nil), mcat.EngineEvents...),
HeldByTheEngine: true, Decision: "novox/hq ADR 0563"}
}
@@ -15,22 +15,22 @@ func enrolmentVerbs() []mcat.Verb {
}
return []mcat.Verb{
{Name: mcat.EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " +
"refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " +
"refused at once, naming every finding, for a name that is not a node name or that a node record, an assignments " +
"key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " +
"reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " +
"warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " +
"warrant, adds the node to the assignments with its tunnel key, asks the vault for the token and says enrolment-issued. " +
"The token itself is read only through tokens.",
Input: schema(map[string]string{
"node": "the new machine's name in the mesh",
"why": "why it joins, shown to the operator with the ask",
"expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours",
// The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node
// enters placement with it and the hub peers it before redeem.
// enters the assignments with it and the hub peers it before redeem.
"overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it",
}, []string{"node", "why", "overlay_key"})},
{Name: mcat.EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol.<node> " +
"alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " +
"vault for the node's account and certificate, writes the node into placement, and answers the account sealed " +
"vault for the node's account and certificate, writes the node into the assignments, and answers the account sealed " +
"to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " +
"it finishes what is not done and answers the same; with other keys it is refused.",
Input: map[string]any{"type": "object", "additionalProperties": false,
-68
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"path"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
@@ -14,73 +13,6 @@ const filesAsAccount = "as the node's operator account, through sudo -n where it
const overrideWord = "a reason, to change a path a module owns anyway (optional)"
// filesVerbs is the contract every holder of node-files serves (novox/hq ADR 0394 §1): the reads of node-reads,
// with their descriptions restated for the operator's account, and the writes.
func filesVerbs() []mcat.Verb {
var verbs []mcat.Verb
for _, v := range readsVerbs() {
v.Description = strings.Replace(v.Description,
"Read as the holder's own account, which holds no secret: a component it may not reach says so.",
"", 1)
v.Description = strings.TrimSpace(v.Description) + " Read " + filesAsAccount + "."
verbs = append(verbs, v)
}
write := func(name, description string, in map[string]string, required []string, optional []string, replaces ...string) mcat.Verb {
in["reason"] = "why the file is changed, for the audit trail"
in["override"] = overrideWord
return mcat.Verb{Name: name, Description: description + " Refuses a path a module owns " +
"unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.",
Input: schema(in, append(required, "reason"), append(optional, "override")...), Replaces: replaces}
}
return append(verbs,
write("write", "Write one text file whole, replacing what it holds; the old content goes to the node's trash.",
map[string]string{"path": "an absolute path", "content": "the text to write",
"mode": "the file's mode, such as 0644 (optional)"},
[]string{"path", "content"}, []string{"mode"}, "tee", ">"),
write("copy", "Copy a file or directory to a path on this node. A target on another node (<node>:/path) is "+
"refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one "+
"kept in the node's trash for 7 days.",
map[string]string{"from": "an absolute path", "to": "an absolute path on this node"},
[]string{"from", "to"}, nil, "cp", "cp -a", "ssh <node> cp"),
write("move", "Move or rename a file or directory. A target on another node is refused: copying between nodes "+
"comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days.",
map[string]string{"from": "an absolute path", "to": "an absolute path"},
[]string{"from", "to"}, nil, "mv"),
write("delete", "Delete a file or directory into the node's trash, where restore can bring it back.",
map[string]string{"path": "an absolute path", "recursive": "\"true\": delete a directory with what it holds"},
[]string{"path"}, []string{"recursive"}, "rm", "rm -r"),
write("mkdir", "Make a directory.",
map[string]string{"path": "an absolute path", "parents": "\"true\": make the missing parents too"},
[]string{"path"}, []string{"parents"}, "mkdir", "mkdir -p"),
write("chmod", "Change the mode of a file or directory.",
map[string]string{"path": "an absolute path", "mode": "the mode, such as 0644 or u+x",
"recursive": "\"true\": also everything under a directory"},
[]string{"path", "mode"}, []string{"recursive"}, "chmod"),
write("chown", "Change the owner and group of a file or directory.",
map[string]string{"path": "an absolute path", "owner": "an account's name or number",
"group": "a group's name or number (optional)", "recursive": "\"true\": also everything under a directory"},
[]string{"path", "owner"}, []string{"group", "recursive"}, "chown", "chgrp"), mcat.Verb{Name: "upload", Description: "Send one file of this node into a bucket of the object store, over a short signed link " +
"and never over the bus; answers the key (the file's SHA-256 when none is given), the size and the SHA-256. " +
"At most 100 MiB. A place a secret is kept, a known credential file, or a link to one is refused by name, " +
"before anything is sent. Read " + filesAsAccount + ".",
Input: schema(map[string]string{"path": "an absolute path to a file", "bucket": "the bucket the file goes into",
"key": "the object's key (optional; the file's SHA-256 when not given)"}, []string{"path", "bucket"})}, mcat.
// download is a plain Verb, not through write(): write() types its optional arguments as true/false
// switches, which a hash and a reason are not (issue 1719).
Verb{Name: "download", Description: "Write one object of a bucket to a file of this node, over a short signed link and " +
"never over the bus; the old file goes to the node's trash. At most 100 MiB. The bytes must hash to sha256, or to " +
"the key when the key is a SHA-256; otherwise no file is written. Refuses a path a module owns " +
"unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.",
Input: schema(map[string]string{"bucket": "the bucket", "key": "the object's key", "path": "an absolute path",
"sha256": "the SHA-256 the bytes must have (optional; the key when that is a SHA-256)",
"reason": "why the file is changed, for the audit trail",
"override": overrideWord}, []string{"bucket", "key", "path", "reason"})}, mcat.Verb{Name: "trash", Description: "What the node's trash holds: each deleted or replaced item with its id, " +
"original path and when it went.",
Input: schema(map[string]string{}, nil), Replaces: []string{"ls ~/.local/share/Trash"}}, write("restore", "Put an item of the node's trash back where it was; refuses when something is there now.",
map[string]string{"id": "an id from trash"}, []string{"id"}, nil, "trash-restore"),
)
}
// ownedPaths is every path a module owns on this node, as the holder is told (novox/hq ADR 0394 §5): the
// paths every seat declares (ADR 0287) with the module holding the seat here, and the paths and files each
// module's manifest places (declaredPaths). A seat's path wins where both name one.
+6 -5
View File
@@ -42,12 +42,13 @@ func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) {
Resources: []map[string]any{{"id": "stub", "type": "file", "path": mcat.ResolverFile}}}
uplink := mcat.Manifest{Module: "networkmanager", Version: "1", Claims: []mcat.Claim{{Name: "node-uplink"}},
Resources: []map[string]any{{"id": "resolv", "type": "file", "path": mcat.ResolverFile}}}
compiled := seats
t.Cleanup(func() { seats = compiled })
compiled := mcat.Seats()
t.Cleanup(func() { mcat.UseSeats(compiled) })
for _, order := range []string{"compiled", "reversed"} {
if order == "reversed" {
seats = append([]mcat.Seat(nil), compiled...)
slices.Reverse(seats)
reversed := append([]mcat.Seat(nil), compiled...)
slices.Reverse(reversed)
mcat.UseSeats(reversed)
}
both := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{files, uplink, resolver}}
w, err := resolutionFilesWords(both, files, nil, mcat.Rendering{})
@@ -59,7 +60,7 @@ func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) {
t.Errorf("seat table %s: %s is owned by %q, want systemd-resolved (%s)", order, mcat.ResolverFile, o[mcat.ResolverFile], mcat.ResolverSeat)
}
}
seats = compiled
mcat.UseSeats(compiled)
// Issue 1126, C5 and seed-8: a module carried as it runs (compose.Carried) owns its manifest's paths (OwnsPaths), as
// the controller tells them, and not the paths its running resources are at (a directory the mesh placed).
carried := mcat.Manifest{Module: "de-spiegel", Version: "1", OwnsPaths: []string{"/var/lib/de-spiegel/server.env"},
@@ -56,7 +56,9 @@ func TestTheRuntimesModuleTrustsTheMeshsRegistry(t *testing.T) {
docker := catalogueManifest(t, "docker")
r := mcat.Resolution{Node: "workstation", Modules: []mcat.Manifest{docker}}
out, err := Declaration(r, mcat.Rendering{
SeatReach: map[string]string{"mesh-artifact-store": "anchor.internal:5100"},
// Every name of the store answers the one address, as the composer fills them (ADR 0632 §1).
SeatReach: map[string]string{"mesh-artifact-store": "anchor.internal:5100",
"container-image-registry": "anchor.internal:5100", "oci-artifact-registry": "anchor.internal:5100"},
})
if err != nil {
t.Fatal(err)
@@ -1,228 +0,0 @@
package resolution
import (
"fmt"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// graphicalSessionSeats are the eleven, in the order ADR 0208's table reads, each with the verbs
// research 026/05 starts it with. Three have none yet: the bar (which receives blocks instead,
// ADR 0255), the compositor and the secret
// service are roles a second holder competes for, and nothing has needed to ask them anything.
func graphicalSessionSeats() []mcat.Seat {
const decided = "novox/hq ADR 0208"
return []mcat.Seat{
{Name: mcat.LoginManagerSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
{Name: "sessions", Description: "The sessions the login manager offers on this machine, and which " +
"one the operator account starts by default.",
Input: schema(map[string]string{}, nil)},
}},
{Name: mcat.DisplayServerSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
{Name: "displays", Description: "The monitors connected now, each with its identity, its modes and " +
"where it is placed; and the layout profile in force, if one matches.",
Input: schema(map[string]string{}, nil)},
// Profiles are keyed by the monitors' identities and are the operator's data (ADR 0208 §6).
{Name: "layout", Description: "The monitor layout profiles, keyed by the connected monitors' " +
"identities: list them, save the current arrangement under a name, or apply one.",
Input: withEnum(schema(map[string]string{
"action": "list, save or apply",
"name": "the profile to save or apply (save and apply only)",
}, []string{"action"}), "action", "list", "save", "apply")},
}},
// It receives window-manager configuration lines from every other module (novox/hq ADR 0212):
// bindings, start-up commands, rules — placed by the session's holder, never written into
// its directory by the contributor.
{Name: mcat.DisplaySessionSeat, Scope: mcat.ScopeNode, Decision: decided,
Receives: []mcat.Receivable{{Kind: "config", Comment: "#"}}, Serves: []mcat.Verb{
{Name: "reload", Description: "Reload the session's configuration in place, keeping its windows.",
Input: schema(map[string]string{}, nil)},
{Name: "workspaces", Description: "The session's workspaces: each one's name, output, and whether " +
"it is visible or focused.",
Input: schema(map[string]string{}, nil)},
{Name: "windows", Description: "The session's windows: each one's title, class, workspace and " +
"whether it has focus; narrowed to one workspace when named.",
Input: schema(map[string]string{"workspace": "one workspace (optional)"}, nil)},
}},
{Name: mcat.TerminalEmulatorSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
{Name: "open", Description: "Open a terminal window in the operator's session, running a command " +
"or the login shell, in a directory or the account's home.",
Input: schema(map[string]string{
"command": "what to run in it (optional; the login shell when absent)",
"directory": "where it starts (optional; the account's home when absent)",
}, nil)},
}},
{Name: mcat.LauncherSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
{Name: "menu", Description: "Put a menu of choices in front of the operator and answer with the " +
"one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.",
Input: map[string]any{"type": "object", "required": []string{"choices"},
"properties": map[string]any{
"choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "the lines to choose between, in order"},
"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: mcat.NotifierSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
{Name: "send", Description: "Show the operator a notification.",
Input: withEnum(schema(map[string]string{
"title": "the notification's summary",
"body": "its text (optional)",
"urgency": "low, normal (the default) or critical",
}, []string{"title"}), "urgency", "low", "normal", "critical")},
{Name: "history", Description: "The notifications shown lately, newest first.",
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
}},
{Name: mcat.LockScreenSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
{Name: "lock", Description: "Lock the operator's session now.",
Input: schema(map[string]string{}, nil)},
}},
{Name: mcat.ClipboardSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
{Name: "history", Description: "What the clipboard held lately, newest first.",
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
{Name: "copy", Description: "Put text on the operator's clipboard.",
Input: schema(map[string]string{"text": "the text"}, []string{"text"})},
}},
// It receives blocks as data (novox/hq ADR 0255): what a block shows, said for any bar, which
// the holder renders in its own grammar. A module that knows something about the machine — the
// power module its battery — gives the block; the bar knows no machine's hardware.
{Name: mcat.BarSeat, Scope: mcat.ScopeNode, Decision: decided,
Receives: []mcat.Receivable{{Kind: mcat.BarKindBlock, Comment: "#", Offered: true, Shape: barBlockShape()}}},
{Name: mcat.CompositorSeat, Scope: mcat.ScopeNode, Decision: decided},
{Name: mcat.SecretServiceSeat, Scope: mcat.ScopeNode, Decision: decided},
}
}
// withEnum narrows one string property of a schema to the values it may take, so a caller is told
// the choices by the schema rather than by a refusal.
func withEnum(s map[string]any, property string, values ...string) map[string]any {
props := s["properties"].(map[string]any)
p := props[property].(map[string]any)
p["enum"] = values
return s
}
// barShows is what a contributed block may show, and the options each takes — the bar-neutral
// vocabulary every holder of node-bar renders. A new entry is a decision, and every holder's template
// renders it (the shape's examples) before it is accepted.
var barShows = map[string]map[string]barOption{
// The machine's battery: its charge, and how long it lasts or takes to fill.
"battery": {"device": {kind: "text"}},
// One line a command prints, run every interval seconds: what the bar cannot know itself.
"command": {"command": {kind: "text", required: true}, "interval": {kind: "seconds", required: true}},
// A value the contributor keeps on the bus (novox/hq ADR 0255): its state `<module>.<name>` and the
// key, this machine's name when absent. The value is an object whose `text` is what is shown. The
// holder is granted to read it and shows it however its bar shows a changing value; no file and
// no command passes between the two modules.
"state": {"state": {kind: "text", required: true}, "key": {kind: "text"}},
}
// barBlockReads is the state and key a block shows, if any; an empty key is the machine's own.
func barBlockReads(data map[string]any) []mcat.StateRead {
if data["shows"] != "state" {
return nil
}
options, _ := data["options"].(map[string]any)
s, ok := options["state"].(string)
if !ok || s == "" {
return nil
}
key, _ := options["key"].(string)
return []mcat.StateRead{{Read: s, Key: key}}
}
type barOption struct {
kind string // text, or seconds (a whole number from 1 to 86400)
required bool
}
// barBlockShape is the shape of a block (novox/hq ADR 0255): which bar, where on it, in what order,
// what it shows, and that kind's options.
//
// - bar: top or bottom;
// - place: resources (beside the processor, memory and disks) or status (beside the sound, before the
// clock) — the two places every holder keeps for contributed blocks;
// - order: 0–99 within the place, 50 when absent;
// - shows: battery or command; options: what that kind takes.
func barBlockShape() *mcat.Shape {
return &mcat.Shape{
Text: true,
Places: []mcat.PlaceField{{Field: "bar", Values: []string{"bottom", "top"}},
{Field: "place", Values: []string{"resources", "status"}}},
Order: "order",
Check: barBlockProblems,
Reads: barBlockReads,
Examples: []map[string]any{
{"bar": "bottom", "place": "status", "shows": "battery"},
{"bar": "bottom", "place": "status", "shows": "battery", "options": map[string]any{"device": "BAT0"}},
{"bar": "bottom", "place": "status", "shows": "command",
"options": map[string]any{"command": "echo \"a line\"", "interval": float64(5)}},
{"bar": "bottom", "place": "status", "shows": "state", "options": map[string]any{"state": "power.draw"}},
{"bar": "bottom", "place": "status", "shows": "state",
"options": map[string]any{"state": "power.draw", "key": "a-machine"}},
},
}
}
func barBlockProblems(data map[string]any) []string {
var problems []string
for field := range data {
if !oneOf([]string{"bar", "place", "order", "shows", "options"}, field) {
problems = append(problems, fmt.Sprintf("a block has no field %q; it has bar, place, order, shows and options", field))
}
}
shows, _ := data["shows"].(string)
takes, known := barShows[shows]
if !known {
return append(problems, fmt.Sprintf("a block shows %v; it shows one of %s", data["shows"],
strings.Join(sortedKeys(barShows), ", ")))
}
options := map[string]any{}
if raw, has := data["options"]; has {
o, ok := raw.(map[string]any)
if !ok {
return append(problems, "a block's options are an object")
}
options = o
}
for name, v := range options {
opt, ok := takes[name]
if !ok {
problems = append(problems, fmt.Sprintf("a %s block has no option %q; it takes %s", shows, name,
orNothing(sortedKeys(takes))))
continue
}
switch opt.kind {
case "text":
if t, isText := v.(string); !isText || strings.TrimSpace(t) == "" {
problems = append(problems, fmt.Sprintf("a %s block's %s is text", shows, name))
}
case "seconds":
if _, whole := wholeIn(v, 1, 86400); !whole {
problems = append(problems, fmt.Sprintf("a %s block's %s is a whole number of seconds from 1 to 86400", shows, name))
}
}
}
for _, name := range sortedKeys(takes) {
if _, has := options[name]; takes[name].required && !has {
problems = append(problems, fmt.Sprintf("a %s block needs the option %s", shows, name))
}
}
return problems
}
+5 -1
View File
@@ -161,7 +161,11 @@ func notHandedWhy(with mcat.Rendering, m mcat.Manifest, r map[string]any) (why s
if why := machineWhy(with); why != "" {
return why, true
}
return fmt.Sprintf("its build kept a template for %s and this composition filled it itself", id), true
// Every way a handed file is not handed is named above: the module moved with templates, a template for this file, no
// merge, a machine that takes it. Reached, the composition and Handing disagree, and that is a fault in this code, said as
// one so it is reported and not read as a reason about the module or the machine (issue 1966).
return fmt.Sprintf("a fault in the node-engine's composition, to be reported: %s is moved, its build kept a template for %s, "+
"and this machine takes handed files, yet the composition filled it itself", m.Module, id), true
}
// requiredByWords begins why a module only pulled in by another is on a machine (Resolution.Because): `required by
@@ -157,4 +157,12 @@ func TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded(t *testi
t.Fatalf("%s: the unmarked module is refused %v", name, before)
}
}
// And where nothing keeps it from being handed yet the composition filled the file itself, the reason says it is a fault
// in this code (the review of the vault's judgement, issue 1966), never a reason about the module or the machine.
whole := mcat.Manifest{Module: "bot", Moved: true, Handed: &mcat.Handed{Templates: map[string]string{"f": "templates/f"}}}
if why, handedFile := notHandedWhy(handingTo("store:5000"), whole, map[string]any{"id": "f"}); !handedFile ||
!strings.Contains(why, "a fault in the node-engine's composition") {
t.Errorf("the impossible branch says %q (handed file %v)", why, handedFile)
}
}
-104
View File
@@ -1,104 +0,0 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// healthDecision is the record that made the four seats.
const healthDecision = "novox/hq ADR 0564"
// conditionsVerbs are the condition keeper's verbs (ADR 0564 clauses 3, 9, 12).
func conditionsVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, then oldest, " +
"raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when " +
"the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition " +
"lately.",
Input: schema(map[string]string{
"key": "a condition's key: that one whole; with history, only its transitions",
"scope": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module",
"severity": "only urgent, or only warning",
"machine": "only those about this machine",
"history": "\"true\": every raising, change, silence and clearing lately, oldest first",
"days": "with history: how many days back (default 7, at most 90)",
}, nil, "history")},
{Name: "silence", Description: "Stop one condition's messages for a while, a hand act with why: it stays open and in " +
"status, and the silence is kept as its transition in history.",
Input: schema(map[string]string{
"key": "the condition's key",
"for": "how long: 30m, 4h, 2d; at most 7d",
"why": "why, required and recorded",
"cause": "the cause in a word (the condition's kind when absent)",
}, []string{"key", "for", "why"})},
{Name: "resync", Description: "Read every observer's findings again and raise and clear what differs from the open " +
"conditions: the recovery of a keeper that missed a change.",
Input: schema(map[string]string{}, nil)},
}
}
// watchdogsVerbs are the watchman's verbs (ADR 0564 clauses 4, 5, 12).
func watchdogsVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "signals", Description: "Every watchdog of the signals table the watchman keeps, each a key that expires unless " +
"its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its " +
"expiry wrote.",
Input: schema(map[string]string{"row": "one row, e.g. S1"}, nil)},
{Name: "stuck", Description: "Every want, build or step the watchman says stopped moving past its bound, with since " +
"when and the words of its finding.",
Input: schema(map[string]string{"machine": "only those on this machine"}, nil)},
{Name: "presence", Description: "Every machine and tool runner the watchman sees connected, with its last word, and " +
"those it says silent; a machine that said it sleeps is shown as sleeping, not silent.",
Input: schema(map[string]string{"machine": "one machine"}, nil)},
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose " +
"consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one " +
"whole. With deliver or drop, a hand act with why.",
Input: schema(map[string]string{
"id": "a dead letter's id, as the list gives it: that one whole, with its message",
"consumer": "a consumer's name, or <stream>.<consumer>: only what that one gave up on",
"limit": "how many to list, newest first (default 50); only when listing",
"deliver": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)",
"drop": "a dead letter's id: drop it for good (needs why)",
"why": "with deliver or drop: why, required and recorded",
"cause": "with deliver or drop: the cause in a word (dead-letter when absent)",
}, nil)},
{Name: "resync", Description: "Rebuild presence from the bus's connections, and the pulses and findings from the keys " +
"the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.",
Input: schema(map[string]string{}, nil)},
}
}
// selfCheckVerbs are the self-check's verbs (ADR 0564 clause 6).
func selfCheckVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "doctor", Description: "The self-check: the last run's verdict at once, each probe passed, failed, deferred " +
"(its facts not on the bus yet, named) or could not run, and how long ago. With run, a run now; with probe, one " +
"probe's verdict whole. Runs every five minutes on its own; each failure is a finding.",
Input: schema(map[string]string{
"run": "\"true\": run every probe now and answer the verdict",
"probe": "one probe's id (D6): its verdict alone, with every finding",
}, nil, "run")},
{Name: "probes", Description: "The registry: what each probe asserts, where it reads its facts, and the finding it writes.",
Input: schema(map[string]string{}, nil)},
}
}
// healersVerbs are the healers' verbs (ADR 0564 clauses 7, 8).
func healersVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "healers", Description: "The healers: each registered response to one kind of condition, its repair, its " +
"budget and what happens when it is spent; every act taken lately with its outcome, and the mesh-wide brake.",
Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)},
{Name: "heal", Description: "Take one healer's act for one open condition now, within its budget and the brake.",
Input: schema(map[string]string{
"key": "the condition's key",
"why": "why now, kept with the act",
}, []string{"key", "why"})},
{Name: "drill", Description: "Record a drill: something broken on purpose to see the mesh raise and clear it, with " +
"what it tests. A drill is never a repair: no healer is wanted for it however often it is run.",
Input: schema(map[string]string{
"what": "what was done on purpose, in a line",
"why": "what the drill tests",
"condition": "the key of the condition the drill is meant to raise, if any (optional)",
}, []string{"what", "why"})},
}
}
+15 -2
View File
@@ -16,12 +16,12 @@ func busSeatDelivering(t *testing.T, delivers string) {
func oldBroker() mcat.Manifest {
return mcat.Manifest{Module: "old-broker", Provides: []mcat.Offer{{Name: "mesh-bus", Scope: mcat.ScopeMesh}},
Claims: []mcat.Claim{{Name: "mesh-broker", Scope: mcat.ScopeMesh}}}
Claims: []mcat.Claim{{Name: "mesh-broker", Scope: mcat.ScopeMesh, Serves: brokerVerbs()}}}
}
func newBroker() mcat.Manifest {
return mcat.Manifest{Module: "new-broker", Provides: []mcat.Offer{{Name: "mesh-bus", Scope: mcat.ScopeMesh}},
Claims: []mcat.Claim{{Name: "mesh-broker", Scope: mcat.ScopeMesh}}}
Claims: []mcat.Claim{{Name: "mesh-broker", Scope: mcat.ScopeMesh, Serves: brokerVerbs()}}}
}
// Nothing on record: exactly the old rule. One claimant holds; two are refused.
@@ -108,3 +108,16 @@ func TestARefusalWithNothingOnRecordNamesTheHandover(t *testing.T) {
t.Fatalf("the refusal does not name the handover that records the holder: %s", problems[0])
}
}
// brokerVerbs are the verbs the seat mesh-broker promises its holder, as the contracts define them: a holder names every one
// (ADR 0132), so a fixture holder does (issue 1966 ENG-3).
func brokerVerbs() []string {
seat, _ := mcat.SeatNamed("mesh-broker")
var out []string
for _, v := range seat.Serves {
if !v.Optional {
out = append(out, v.Name)
}
}
return out
}
+4 -4
View File
@@ -54,7 +54,7 @@ func TestAModuleNotHandedHasAPlanForWhatAHelperAnswers(t *testing.T) {
t.Fatalf("the plan gives %+v (%v)", given, err)
}
// Its port is where the machine publishes it, the number ${port:9000} gives the module and its declaration publishes,
// never the software's own where the placement moved it (2026-10-31: route-proxy dialled gitea's 3000, published at
// never the software's own where the assigner moved it (2026-10-31: route-proxy dialled gitea's 3000, published at
// 20000): its provider dials what the plan says. Where a plan carries it, a port the module does not listen on is
// refused by module, node and port.
for _, p := range []struct {
@@ -65,10 +65,10 @@ func TestAModuleNotHandedHasAPlanForWhatAHelperAnswers(t *testing.T) {
port float64
refused string
}{
{"assigned by the placement (gitea 3000 at 20000)", map[string]map[int]int{"notify": {9000: 20009}}, nil, true, 20009, ""},
{"given by the node over the placement", map[string]map[int]int{"notify": {9000: 20009}},
{"assigned by the assigner (gitea 3000 at 20000)", map[string]map[int]int{"notify": {9000: 20009}}, nil, true, 20009, ""},
{"given by the node over the assigner", map[string]map[int]int{"notify": {9000: 20009}},
map[string]map[int]int{"notify": {9000: 9900}}, true, 9900, ""},
{"a bare published port the placement holds none for keeps its number (influxdb 8086)",
{"a bare published port the assigner holds none for keeps its number (influxdb 8086)",
map[string]map[int]int{"notify": {9000: 9000}}, nil, true, 9000, ""},
{"a mapping the module fixed (as mailu 7080:80)", map[string]map[int]int{"notify": {9000: 7080}}, nil, true, 7080, ""},
{"a port the module does not listen on", nil, nil, true, 0,
@@ -1,59 +0,0 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// placementVerbs are the seat's verbs: what reads all it keeps and does by hand all it does by itself (ADR 0567 §10).
// Optional while the holder that serves them is not yet on every node (design 33 §7): the seat is new in the same
// delivery as its holder.
func placementVerbs() []mcat.Verb {
changes := map[string]any{"type": "array", "minItems": 1, "description": "the changes, each an object with `op`: " +
"place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " +
"unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " +
"(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " +
"layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the " +
"proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), " +
"remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, " +
"on the same warrant)",
"items": map[string]any{"type": "object", "required": []string{"op", "node"}}}
return []mcat.Verb{
{Name: mcat.PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " +
"module is placed on, or every key, each with its revision, and how full the state is against its cap. With " +
"history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the " +
"proposal's changes from its placement-changed event.",
Input: schema(map[string]string{"node": "one node", "module": "one module: the nodes it is placed on",
"history": "\"true\": the changes in order, from the stream and the state's revisions",
// ADR 0571 clause 4: who holds which seat on which node, from the state seat-holders.
"seats": "\"true\": who holds each seat, and on which node (the state seat-holders); with node, that node's"},
nil, "history", "seats")},
{Name: mcat.PlacementVerbProposals, Optional: true, Description: "Every open proposal of placement: its changes, " +
"the revision of each key it was laid over, its candidates, every node's answer, the differences computed and " +
"answered, its verdict and its ask. Narrowed to one by id.",
Input: schema(map[string]string{"id": "one proposal's id"}, nil)},
{Name: mcat.PlacementVerbPropose, Optional: true, Description: "Propose a change of placement, with why: laid over " +
"the current keys and refused at once, naming every finding, when the result cannot be placed (two modules " +
"on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " +
"hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " +
"it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " +
"caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, " +
"is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).",
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"},
"properties": map[string]any{
"why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"},
"changes": changes,
// ADR 0587 §7: an add-node carries the warrant of the enrolment that issued the node's token, whose
// Approve is the proposal's; the holder writes it with no ask of its own.
"warrant": map[string]any{"type": "object", "description": "for an add-node alone: the enrolment's " +
"warrant {node, address, ask, warrant}, naming the change's node and address",
"required": []string{"node", "address", "ask", "warrant"}},
}}},
{Name: mcat.PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " +
"candidates are deleted and placement-withdrawn carries it whole. With why.",
Input: schema(map[string]string{"id": "the proposal's id", "why": "why, kept in the event"}, []string{"id", "why"})},
{Name: mcat.PlacementVerbAgain, Optional: true, Description: "Judge an open proposal now with the answers that " +
"stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. " +
"Nothing more: the recovery of a proposal whose judgement or ask was lost.",
Input: schema(map[string]string{"id": "the proposal's id"}, []string{"id"})},
}
}
+5 -5
View File
@@ -8,8 +8,8 @@ import (
"strings"
"testing"
provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/provisions"
"github.com/novox/mesh-host/internal/beside"
)
@@ -408,11 +408,11 @@ func TestRouteProxysProofsOfAComposition(t *testing.T) {
placed map[int]int
at int
}{
{"beside the proxy, moved by the placement (gitea)", "anchor", "gitea", 3000, []any{"3000"}, map[int]int{3000: 20000}, 20000},
{"on another machine, moved by the placement (grafana)", "home", "grafana", 3000, []any{"3000"}, map[int]int{3000: 20012}, 20012},
{"a bare port the placement holds none for (influxdb)", "home", "influxdb", 8086, []any{"8086"}, nil, 8086},
{"beside the proxy, moved by the assigner (gitea)", "anchor", "gitea", 3000, []any{"3000"}, map[int]int{3000: 20000}, 20000},
{"on another machine, moved by the assigner (grafana)", "home", "grafana", 3000, []any{"3000"}, map[int]int{3000: 20012}, 20012},
{"a bare port the assigner holds none for (influxdb)", "home", "influxdb", 8086, []any{"8086"}, nil, 8086},
{"a fixed mapping (mailu 7080:80)", "anchor", "mailu", 7080, []any{"25", "7080:80"}, nil, 7080},
{"a fixed mapping the placement names another port of (photos 9102:9000)", "anchor", "photos", 9102,
{"a fixed mapping the assigner names another port of (photos 9102:9000)", "anchor", "photos", 9102,
[]any{"9102:9000"}, map[int]int{9000: 20011}, 9102},
{"the host's network (home-assistant)", "anchor", "home-assistant", 8123, nil, nil, 8123},
} {
-58
View File
@@ -10,64 +10,6 @@ import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// readsVerbs is the contract every holder of node-reads serves (novox/hq ADR 0310 §2). The bounds are the
// build's to set and each verb's description to state (ADR 0310, Consequences); the holder states them in
// every answer it cuts.
func readsVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "stat", Description: "Each component of one path or several, from / down: its type, owner and group " +
"(name and number), mode, whether it is a link and to what, and whether it is a mount point and of which " +
"file system. A link is not followed unless follow is \"true\". Read as the holder's own account, which " +
"holds no secret: a component it may not reach says so. At most 200 paths in one call.",
Input: schema(map[string]string{
"paths": "one absolute path, or several separated by line breaks",
"follow": "\"true\": also say where the path leads once every link is followed",
}, []string{"paths"}, "follow"),
Replaces: []string{"stat", "ls -ld", "namei -l", "findmnt -T"}},
{Name: "readlink", Description: "What one link points to, as written, and where the path leads once every " +
"link is followed, and whether that exists.",
Input: schema(map[string]string{"path": "an absolute path"}, []string{"path"}),
Replaces: []string{"readlink", "readlink -f", "realpath"}},
{Name: "list", Description: "Every entry of one directory with its type, owner, group, mode, size and, for a " +
"link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept " +
"is refused by name.",
Input: schema(map[string]string{"path": "an absolute path to a directory"}, []string{"path"}),
Replaces: []string{"ls", "ls -la"}},
{Name: "read", Description: "One text file, or a part of it by line: at most 256 KiB in one answer " +
"(64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is " +
"kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its " +
"number said. /proc and /sys are read as far as the holder's account may.",
Input: schema(map[string]string{
"path": "an absolute path to a file",
"from": "the first line to answer, counted from 1 (optional)",
"lines": "how many lines to answer (optional)",
"bytes": "the most bytes to answer, up to 262144 (optional, 65536 unless given)",
}, []string{"path"}),
Replaces: []string{"cat", "head", "tail", "less"}},
{Name: "search", Description: "Find a fixed string (never a pattern) in one file or under one directory: " +
"every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), " +
"8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and " +
"binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a " +
"secret is withheld. The answer says which bound it met.",
Input: schema(map[string]string{
"text": "the fixed string to find",
"path": "an absolute path to a file or a directory",
"depth": "how many directories deep, up to 8 (optional)",
"matches": "the most matches to answer, up to 500 (optional)",
"case": "\"false\": match whatever the case (optional)",
}, []string{"text", "path"}, "case"),
Replaces: []string{"grep", "grep -r", "grep -F"}},
{Name: "account", Description: "One account or group by name or number, as this node's account files say: " +
"an account's number, group, home, shell and every group it is in; a group's number and members. With " +
"neither, every account and group. Never a password field.",
Input: schema(map[string]string{
"name": "an account's name or number (optional)",
"group": "a group's name or number (optional)",
}, nil),
Replaces: []string{"getent passwd", "getent group", "id", "groups"}},
}
}
// secretPlaces is every place on this node a secret is kept, as the placed manifests say (novox/hq ADR 0310
// §5): the directory of every module's own secret, every credential file a module is given and every grant
// directory a provider is handed its consumers' credentials in, the place a module keeps the operator-sealed
@@ -157,6 +157,18 @@ func TestADirectlyAssignedModuleTheMachineCannotHostIsReportedNotRefused(t *test
if !strings.Contains(WrongMachine("xorg", "seat", "server"), "wrong machine") {
t.Errorf("the report reads like a missing module")
}
// And a module whose calls only a tool runner stating them answers (ENG-2, issue 1966) is held off a machine whose record
// does not say them, naming the capability; on one that does, it is hosted.
withCalls := mcat.Node{Name: "server", Capabilities: map[string]bool{"container-runtime": true, "ledger-calls": true}}
ledgerOwner := shelf(mod("records", nil, nil, []string{"ledger-calls"}))
held, err := Resolve(ledgerOwner, []string{"records"}, server, mcat.World{})
if err != nil || len(held.Unhostable) != 1 || len(held.Unhostable[0].Missing) != 1 || held.Unhostable[0].Missing[0] != "ledger-calls" {
t.Errorf("a ledger owner on a machine whose runner states no ledger calls: %+v, %v", held.Unhostable, err)
}
if got, err := Resolve(ledgerOwner, []string{"records"}, withCalls, mcat.World{}); err != nil || len(got.Unhostable) != 0 || len(got.Modules) != 1 {
t.Errorf("a ledger owner on a machine whose runner states them was held: %v %v", got.Unhostable, err)
}
}
func TestAnUnhostableModuleSomethingRequiresRefusesTheNode(t *testing.T) {
+13 -1
View File
@@ -33,7 +33,19 @@ var ofSeat = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):([0-9]+)\}`)
var ofSeatReach = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):reach\}`)
// reachedSeats is every seat ${seat:…:reach} answers for.
var reachedSeats = map[string]bool{"mesh-artifact-store": true}
//
// Every name of the store is answered with one address (ADR 0605, issue 1966 ENG-3, ADR 0632 §1): the seat it was held
// through and the two registry seats its holder serves on the same port, the image seat's holder's listen open to the mesh.
var reachedSeats = map[string]bool{}
// StoreSeats are the seats the artifact store is reached through, each answered with the one address.
var StoreSeats = []string{"mesh-artifact-store", "container-image-registry", "oci-artifact-registry"}
func init() {
for _, s := range StoreSeats {
reachedSeats[s] = true
}
}
// seatInto replaces a resource's ${seat:…} placeholders with where this machine put each seat's
// holder — in a file's content, and in a value of a container's or a process's environment. The

Some files were not shown because too many files have changed in this diff Show More