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
mesh-tools e73aae3686 Merge pull request 'Node-engine: act on a backup answer whatever runs, say why a module stays held, and show the want and the hold in the composition verb (issue 1585)' (#208) from fix/1585-held-answer into main 2026-11-04 10:47:20 +00: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
jschoubben e2380404a1 Act on a backup answer whatever runs, say why a module stays held, and let the engine's read tool show its want and its hold (issue 1585)
mesh/delivery merged: source.main moved to novox/mesh-host@e73aae3686a6
mesh/check being carried to e2380404a1fa on e73aae3686a6 | from 998d8f737008
nats on novox, 2026-11-04 11:07: after mesh-host #207 the module stayed held
although its want carried "Back up again", and the engine said nothing. The
engine's composition verb showed it running what its want names (taken from
its held have at the start), so when the answer was in a revision of the want
read after the hold, settleOne returned on "it runs what the want names"
before it looked at the hold: the answer was swallowed, silently. The start
path #207 fixed was right but never met; no test ran the live shape.

- settleOne: the hold is looked at before what runs. An answer (again or
  skip) in a want revision after the one the hold took is acted on whatever
  runs; with no answer the hold is kept and said with both revisions.
- baseline: the hold kept at the start is said (it was the silent branch).
- composition verb: the want as the engine holds it now carries self, backup,
  words and its key's revision, beside the engine's attempt (held, answered
  revision, want and composition revisions, transient) and whether the module
  was looked at since the start, so which want the engine read is read, never
  guessed.

Quick fix: answer once more by hand (a new want revision), which the guard
still swallows while what runs equals the want, so none exists. Thorough fix,
chosen: the order of the guards and the read tool, proved by rows of the live
shape (a self module of policy record, its held have naming the want's own
composition, the answer before the start, and again and skip arriving while
held); the two later rows fail with the old guard order.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 11:36:14 +01:00
mesh-tools 998d8f7370 Merge pull request 'Node-engine: a stop never cuts a save off, an archive's unpack is recorded at once, and an answer the want carries is acted on at start (issue 1585)' (#207) from fix/archive-record-at-unpack into main 2026-11-04 10:04:25 +00:00
jschoubben 5dc3a8e17f Keep what an archive unpacked in the engine's own state directory, never beside the archive, and stop no slower than the host waits (issue 1585, review round 1)
mesh/delivery merged: source.main moved to novox/mesh-host@998d8f737008
mesh/check being carried to 5dc3a8e17f97 on 998d8f737008 | from fabfdf04f78a
The journal of an unpack sat at <target>.unpacked.json, in a directory
the module's account may write: a forged one would make the root engine
remove or adopt any path it named. It now lives under
<statedir>/unpacked/<sha256 of the target>.json, mode 0600, names its
target, and is refused whole when an entry is not filepath.IsLocal or a
parent is not above the target. It goes when the archive is taken off,
whether its state record still said what it unpacked or not.

The launcher's stop grace for a host on trial rises from 20 s to 75 s and
the unit's TimeoutStopSec is 120 s (openrc the same), past the host's own
60 s wait for the apply in flight, so neither cuts a save off again.

Tests folded as rows into existing ones (the repository is over its unit
budget): the journal's rows, a forged entry and parent refused, the
journal gone with a record that lost its Unpacked; the test that passed
on main dropped; the stop test waits on its own line, not a sleep, and no
longer consumes the save it waits for.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 10:52:42 +01:00
jschoubben 768f637bd0 Act at the engine's start on a backup answer the want already carries, so an answer written before a restart is never swallowed (issue 1585)
nats on novox, 2026-11-04: the operator answered "Back up again", the
engine restarted, and baseline() took the hold with AnsweredRev = the
want's current revision, so the answer in that very revision was never
acted on and compose refused "held for a failed backup".

Quick fix: answer once more (a new want revision). Thorough fix, chosen:
at baseline, a held module whose want carries again or skip is applied
(again backs it up first, skip goes on without one); a hold with no
answer stays held. The cost said in the code: an answer already acted on
before a stop, whose backup failed again, is tried once more per start.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 10:36:48 +01:00
jschoubben ee0f46b78f Stop between applies and save the node's state as one writer, so a stop never cuts a save off and loses its records (issue 1585)
On novox the node-engine's state had lost the records of nats's two
archives, which only a temporary save file of 2026-10-24 still held, and
about 260 such files (~80 MB) lay beside it. The cause: an apply runs
detached from the engine's stop (applyContext, WithoutCancel), and
runLink returned, and the process ended, without waiting for it; a save
cut between its write and its rename left its temporary file and the
state before it, and whatever the apply did was never recorded.

Quick fix: remove the leftover files by hand and restore the records.
Thorough fix, chosen: the engine ends between applies whichever way it
ends (a stop, standing aside, a move to another bus): it waits up to 60 s
for the apply in flight, then lets no save start (store.Quiesce). Save is
one writer per process, flushes the directory after its rename, and says
loudly each record the state saved before held that this save drops and
nothing forgot (Forget, Release mark what is dropped on purpose, and no
longer write into an array an earlier copy reads). The engine's start
removes leftover temporary saves under the apply lock and says how many.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 10:36:48 +01:00
jschoubben 84a14b08d5 Write down what an archive unpacked as soon as it is unpacked, so a lost record never makes the host's own files foreign (issue 1585)
Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 10:26:43 +01:00
mesh-tools fabfdf04f7 Merge pull request 'Node-engine: hand a moved module by its manifest, its build and the machine, never by the vault's reading of its plan (issue 1966)' (#206) from fix/handed-one-rule into main 2026-11-04 09:16:13 +00:00
jschoubben 966093d8d0 Hand a moved module by its manifest, its build and the machine, never by the vault's reading of its plan, so a node with no plan yet makes the plan that hands it (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-host@fabfdf04f78a
mesh/delivery-group group fix/handed-one-rule merged: every member is merged
mesh/check being carried to 966093d8d067 on fabfdf04f78a | from 4d47ff5ce743, mesh-catalog dd458cb155f5
The vault judged a module handed only when its assignment already carried a handing plan, and the node-engine followed
that word: a module with no plan was composed the old way, refused for a key only the provider's helper answers
(forge-account's ssh_alias), and so never got its plan. resolution.Handing is now the manifest, its build and the
machine alone; the vault's word is read only to say where the two disagree. A refusal of a file or an own secret the
provisioner's client should fill now says "handed module not handed" and why, never only the keys a binding lacks or a
secret to make.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 09:41:10 +01:00
mesh-tools 4d47ff5ce7 Merge pull request 'Node-engine: a node names its provisioner account even when no module's files are filled this pass (issue 774)' (#205) from fix/node-account-always into main 2026-11-04 08:39:53 +00:00
jschoubben 8e32583089 A node names its provisioner client's account whenever a client runs there, filled files or not (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-host@4d47ff5ce743
mesh/check being carried to 8e325830891c on 4d47ff5ce743 | from 6019c6d514a7
The declaration named the account only beside its filled files (node.go), so a node whose every filled-file module
was held this pass named none: on ace from 2026-11-04 01:29 its build modules and ssh-client were held for their
forge binding, and the node-engine refused every write of the files they keep, and of every other module's. The
account is the node's, named whenever the rendering has one. A row holds restic, the provisioned mesh's one
filled-file module, with the client placed: the node's own still names the account; it named none before.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 02:17:01 +01:00
mesh-tools 6019c6d514 Merge pull request 'Node-engine: a held provisioner client keeps its account; a module waiting for a yes has its new build read (issues 774, 1585)' (#204) from fix/frombus-inown-build into main 2026-11-04 01:05:49 +00:00
jschoubben 054754482c The running provisioner account is kept only for a client held this pass, and rows hold the guard (issue 774, review)
mesh/delivery merged: source.main moved to novox/mesh-host@6019c6d514a7
mesh/check being carried to 054754482cf4 on 6019c6d514a7 | from 83e135f10187
The review removed the guard and the suite still passed. The guard is now provisionerHeld, a placed client held as
its stand-in, rather than any placed client, so one placed and not held that composes no account keeps none stale.
Rows in the held client's table: the client taken out of the node's placement while what runs names the account, read
from the running own composition and from the client's running process, names no account; a placed client whose
process names no user names none. With the guard removed the first three fail; with it reduced to "placed", the last.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 01:53:29 +01:00
jschoubben f7129fdb41 node-engine.composition shows the node's own fields and what the last pass held (issue 774)
Reading ace's node composition gave four resources twice and no way to see that one revision named no provisioner
client's account. The verb now shows, for module node, the revision's provisioner account beside what runs names, its
adoption, how many identities, groups and libraries it names, and each module the composer's last pass held with why.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 01:50:21 +01:00
jschoubben f7abd05981 A node composition that drops the provisioner client's account what runs names is said (issue 774)
Applied, a node's own composition naming no account refuses every filled write and hands no module, which on ace
went unsaid from 01:29 to 01:33 but for the refusals themselves. The pass now says it once, naming the account what
runs names. A placed client keeps its account by the previous commit; this says the case that is left, a client no
longer composed here.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 01:48:12 +01:00
jschoubben 514cf23aef The provisioner client's account stays named while the client is held: a held client keeps the node's handing (issue 774)
provisionerAccount reads the user of the client's process from this pass's plan, and a client held as its stub (or
carried as it runs) composes no process: on ace, 2026-11-04 01:29 to 01:33, the node's own composition named no
account, the node-engine refused every filled write, and no module was handed (postgres's consumers, restic, power).
A node now carries the account its client runs as (Node.RunningProvisioner: its own composition's account as it runs,
else the user of the client's running process), and a placed client that composes none this pass keeps it. A row holds
the client in the provisioned mesh, both ways: the account stays and restic keeps its filled files; both were lost
before.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 01:47:19 +01:00
jschoubben 95b509e0d2 A module in InOwn wanted at a build of the new form has its build taken in: the reader and the composition fold by one rule (issue 1585)
frombus skipped reading the build of every module in Scope.InOwn, while compose.compositions folds such a module only
at no src2: fingerprint: a want at src2: (nats on novox, its have waiting for a yes) composed it from the indexer's
unresolved manifest and built() refused it, "no build has published" its server artifact. compose.Folds is now the one
rule; frombus takes the build in, or keeps the module at its wanted build, exactly where it says the module is not
folded. A row composes spiegel, in InOwn and wanted at src2:, from its build's upstream image; it read the indexer's
manifest before.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 01:31:27 +01:00
mesh-tools 83e135f101 Merge pull request 'L3 final: the node-engine reads core-credentials and tool-runner with no fallback, and states its manifest reads (ADR 0601 §4, ADR 0614 §4, issue 774)' (#203) from feat/l3-final into main 2026-11-04 00:23:56 +00:00
jschoubben 30b86ea602 An input carries the modules it holds as their stubs across the bus, and the sandbox names why the node-engine holds one (issue 774, group check 278aa5f0ccbe)
mesh/delivery-group group feat/l3-final merged: every member is merged
mesh/delivery merged: source.main moved to novox/mesh-host@83e135f10187
mesh/check checking 30b86ea602a8 on 83e135f10187 | novox/mesh-host#203 in group feat/l3-final
The stub's mark (Manifest.Stubbed) is no field of a manifest's JSON, so an input a node-engine sent the check's
sandbox lost it: node-tools, held by its manifest's verdict, read as a tool runner with no bundle and refused every
module of ace, g14 and shanks for "its build produced 0 bundle(s)". compose.Input now writes its held modules (catalogue
and each node's kept) beside its fields and marks them again on reading; an input from an engine before the mark is
read by its contract, every module Refused names held as its stub. The sandbox composer also says, in the module's
read, why the input's node-engine holds a module the case carries, so the check names the cause, not a rule about the
stub. One subtest round-trips an input holding a refused tool runner, in both shapes; it refused for 0 bundles before.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 00:45:35 +01:00
jschoubben 32a99144f6 The node record states the reader capabilities of the mesh-sdk the engine is built with (ADR 0614 §4, issue 774)
mesh/delivery closed: a newer head of the same pull request
mesh/delivery-group group feat/l3-final checking: 3 of 5 member(s) ready
mesh/check failed (2): unit: mesh-provisioner-client/cmd/mesh-provisioner-client.TestNatsANodeComingBackFil…; and 1 more | 32a99144f65e on 798d73eb11e9
recordOf writes catalogue.ManifestReads in NodeRecord.ManifestReads, once per build, so the assigner moves a want only
to an engine that reads its manifest. A row holds the engine's own manifest to the baseline's words, so its new build is
never held for a word only it reads.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-03 23:29:17 +01:00
jschoubben 3544d644ba Pin mesh-sdk 85ba0a9a70d8, the head of feat/l3-final: the contract states the reader capabilities a node-engine reads and the node record carries them (ADR 0614 §4, issue 774)
Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-03 23:27:52 +01:00
jschoubben a0ce37fb2c Pin mesh-sdk b7abe203a929, the group's head of feat/l3b-bus: it keeps, deprecated, the exported names the running parts still use
Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-03 18:50:12 +01:00
jschoubben a24a1d72ad Pin mesh-sdk cf5812101b0b, the group's final head: the module graph is no part of mesh-sdk (ADR 0603, ADR 0604, issue 1367)
mesh/delivery-group group feat/l3b-bus rejected: composed together: the checker's verdict at the members' heads (a1cb9beeb97a): unit: modules/mesh-provisioner.…
mesh/delivery closed: the pull request closed unmerged
mesh/check failed (4): unit: modules/mesh-provisioner.gofmt: not as gofmt writes them (run gofmt -w): modul…; and 3 more | a24a1d72ad45 on 798d73eb11e9
2026-11-03 17:02:14 +01:00
jschoubben 02c7f8e052 Pin mesh-sdk 65b7adc8bdd9; the seat table here takes the SDK's mesh-catalog entry with its four verbs (ADR 0604 section 1) 2026-11-03 16:25:46 +01:00
jschoubben 53b0e8d318 Genesis installs no catalog module: the catalogue step and the take of mesh-catalog go (ADR 0604 section 7, issue 774) 2026-11-03 16:17:27 +01:00
jschoubben c777fbae8f Pin mesh-sdk e7ce86ff62b5, the group's head of feat/l3b-bus (review round 2) 2026-11-03 14:12:50 +01:00
jschoubben 98719a1bd5 Pin mesh-sdk 825483043549, the group's head of feat/l3b-bus (review round 1) 2026-11-03 14:05:07 +01:00
jschoubben 72bcf20870 The bus module's integration test publishes the backup's end at once: the node agent hears it before it asks, so no sleep (review round 1, finding 5) 2026-11-03 14:02:01 +01:00
jschoubben b47668421f The witness judges the tool runner's process by its id, <its module>.runtime, which the composition gives only the module whose manifest says it is the tool runner, never by a name (ADR 0601 §4; review round 1, finding 4) 2026-11-03 14:02:01 +01:00
jschoubben 2239fb253d Rows for the composer's composition of today: the same inputs give the running digest, a changed input another, a build it cannot compose none (ADR 0601 §1; review round 1, finding 2) 2026-11-03 14:02:01 +01:00
jschoubben 7da4deb3f1 What a node keeps of a module as it runs, for a manifest that does not yet say it, is carried on the module's have by module, so the watchman raises its condition (ADR 0601 §4; review round 1, finding 3) 2026-11-03 14:02:01 +01:00
jschoubben b461b005e9 Pin mesh-sdk 09ec82cb3551, the group's head of feat/l3b-bus (ADR 0601) 2026-11-03 13:46:47 +01:00
jschoubben 75e3a26336 The integration test of the bus module's first want shows its node backing it up through the backup seat before that apply and before the one that replaces the server, as every module that keeps data (ADR 0601 §2) 2026-11-03 13:37:42 +01:00
jschoubben 5b455cecfe A have waiting for a yes says whether the move changes the build alone: the composer composes the build the module runs with today's settings and secrets and the agent compares it with what runs, so a release's Approve answers it and a changed setting is still asked (ADR 0601 §1) 2026-11-03 13:35:12 +01:00
jschoubben faa2005538 A node agent that meets a manifest which does not yet say it is the tool runner, or that its own secrets are the core's credentials, keeps what runs of it, its tool runner process and its sealed secrets alike, known by what runs and said; never composed without it (ADR 0601 §4) 2026-11-03 13:31:44 +01:00
jschoubben 6ea23b6731 The sandbox's composer marks a node it does not judge in the contract's field (Machine.NotJudged), neither composed nor refused, never by a prefix of its reason (ADR 0593 §9; the prepare's seams finding on "not judged") 2026-11-03 13:29:21 +01:00
jschoubben db7cd273f0 The node-engine finds the tool runner by what its manifest says (tool-runner), never by its name, pinned at mesh-sdk a973bfa (issue 774, the operator 2026-11-03) 2026-11-03 13:28:44 +01:00
jschoubben 12c80d7c2e The node-engine carries a module's own secrets sealed by the vault when its manifest says they are the core's credentials, pinned at mesh-sdk 4f141ff (issue 774, the operator 2026-11-03) 2026-11-03 13:28:44 +01:00
jschoubben 649ed21d55 The sandbox refuses no module the node holds composing without the change, a module blamed for a refusal among them (issue 774, review round 1) 2026-11-03 13:28:44 +01:00
jschoubben 23cbf9294a The node agent's applies run through a runner its options name, so one integration test proves the bus module's first want takes the bus server over from the node's own composition without moving it, and a build that changes it replaces it once (ADR 0599 §4, §6) 2026-11-03 13:28:44 +01:00
jschoubben 03b7fcd7bb The sandbox fails a node whose change refuses a module the node-engine did not hold, in that module's words, instead of passing it with the rest composed (issue 774, review of the sandbox input) 2026-11-03 13:28:44 +01:00
jschoubben 7b8f5e7805 The sandbox composes a node as its node-engine does, from an input that says how, and names a node it cannot judge from an older engine's input (issue 774, ADR 0593 §9) 2026-11-03 13:28:44 +01:00
217 changed files with 9868 additions and 5395 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)
+32 -7
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"
@@ -156,6 +156,17 @@ type options struct {
file string
// out is where what a command says goes. Stdout, and a buffer under test.
out io.Writer
// runner is what the node agent's applies run commands through (issue 774, ADR 0594): nil is the machine's own,
// apply.ExecRunner; a test gives one that records what an apply asked of the container runtime.
runner apply.Runner
}
// run is the runner the node agent's applies use: the options' own, else the machine's.
func (o options) run() apply.Runner {
if o.runner != nil {
return o.runner
}
return apply.ExecRunner
}
// parseArgs takes the subcommand first, then its flags.
@@ -808,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)
}
@@ -925,6 +936,11 @@ func runLink(ctx context.Context, opts options) error {
// which the launcher already reads as "run whatever is on disk now".
aside, standAside := context.WithCancel(ctx)
defer standAside()
// **And it ends between applies too** (novox/hq issue 1585): whichever way the link ends, the apply in flight ends and
// saves before the process does. Deferred after standAside, so it runs before it: nothing is cancelled under it.
defer func() { _ = stopBetweenApplies(say, stopBound) }()
// What an earlier stop cut off before its rename is removed, and said, before anything saves.
removeLeftoverSaves(opts.state, say)
stoodAside := false
membership := link.Membership{
Node: mine.Node,
@@ -1001,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) {
@@ -1048,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)
}
@@ -1388,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
@@ -1637,11 +1662,11 @@ func applyModuleHeld(ctx context.Context, opts options, declared *declaration.De
func(known store.State) (apply.Report, store.State, error) {
if scope == nil {
return apply.ApplyMindingWindows(ctx, built, declared, known, store.OriginDeclared,
apply.ExecRunner, announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)),
opts.run(), announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)),
apply.WindowsIn(filepath.Dir(opts.state)))
}
return apply.ApplyModule(ctx, built, declared, *scope, known, store.OriginDeclared,
apply.ExecRunner, announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)),
opts.run(), announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)),
apply.WindowsIn(filepath.Dir(opts.state)))
}, announceOr(say))
@@ -2008,7 +2033,7 @@ func adoptDeliveredMembership(identityPath string, mine *identity.Identity, say
return
}
say(fmt.Sprintf("moving to the %s bus at %s — restarting to dial it", next.Transport, next.Broker))
os.Exit(0)
exitBetweenApplies(say)
}
// profileAsReported is the profile as the mesh reads it — the same bytes enrolment sends, so a
+52
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -132,6 +133,57 @@ func TestAFlagAfterAPositionalIsRead(t *testing.T) {
// state, acts, and writes it back — so they must not run at the same time, or the last save loses
// what the other recorded.
func TestOnlyOneApplyRunsAtATime(t *testing.T) {
// Issue 1585: the engine's stop waits for the apply in flight to end and save, and then lets no save start; before,
// the process ended under it and a save cut before its rename left its temporary file and the state before it.
t.Run("the stop waits for the apply in flight and its save", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
applying.Lock()
var said []string
var mu sync.Mutex
waiting := make(chan struct{})
say := func(line string) {
mu.Lock()
said = append(said, line)
mu.Unlock()
if strings.Contains(line, "waiting up to") {
close(waiting)
}
}
stopped := make(chan func(), 1)
go func() { stopped <- stopBetweenApplies(say, time.Hour) }()
// Said before it blocks, and it cannot end before the apply lets go: so it has not ended now.
<-waiting
select {
case release := <-stopped:
release()
applying.Unlock()
t.Fatal("the stop did not wait for the apply in flight")
default:
}
if err := store.Save(path, store.State{Resources: []store.Applied{{ID: "nats.program-snapshot", Type: "archive"}}}); err != nil {
t.Fatal(err)
}
applying.Unlock()
release := <-stopped
saved := make(chan error, 1)
go func() { saved <- store.Save(path, store.State{}) }()
// A save after the stop blocks; nothing says it blocks, so this one wait is a bounded look.
select {
case <-saved:
release()
t.Fatal("a save started after the stop")
case <-time.After(50 * time.Millisecond):
}
release()
if err := <-saved; err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if len(said) != 2 || !strings.Contains(said[0], "waiting up to 1h0m0s") || !strings.Contains(said[1], "its state is saved") {
t.Errorf("said %q", said)
}
})
// A host is built for one system at link time, and a test binary has no link time: this asks
// the machine it runs on, and stands aside where the answer is no.
built, err := system.For("arch")
+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 })
}
+160 -56
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,
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 },
@@ -508,7 +511,13 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
// What this engine applied, kept on the machine and read at the start, so what runs is known before the node
// composes its own (issue 774).
Applied: appliedStoreFor(opts.state),
Inactive: inactiveIn(&liveness.Exec{Run: apply.ExecRunner, System: builtFor}, heldBy(apply.WindowsIn(filepath.Dir(opts.state)))),
Inactive: inactiveIn(&liveness.Exec{Run: liveness.Runner(opts.run()), System: builtFor}, heldBy(apply.WindowsIn(filepath.Dir(opts.state)))),
Held: func() map[string]string {
if c := composerNow.Load(); c != nil {
return c.LastHeld()
}
return nil
},
Revisions: func(ctx context.Context, key string) ([]agent.Update, error) {
got, err := session.KVHistory(ctx, composedBucket, key)
if err != nil {
@@ -538,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).
@@ -564,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()
@@ -580,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)
}
@@ -590,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
@@ -603,7 +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 {
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
@@ -613,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.certify = 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 = composer, true, composer.Policy
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)
@@ -671,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()
@@ -692,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 == "" {
@@ -719,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())
@@ -733,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)
@@ -760,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]
@@ -768,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
@@ -806,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
@@ -855,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)
}
}
@@ -892,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 {
@@ -955,17 +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)
held func(module string) (delivery.Seals, []string, error)
kept func(kept map[string][]string)
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
@@ -981,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) {
@@ -1011,6 +1109,12 @@ func composerEnv(session *link.Session, node string, say func(string), hooks com
Hear: session.Hear,
// And a moved module's templates archive, in the artifact store, as every archive is fetched (issue 1126).
Fetch: apply.Fetch,
Running: hooks.running, Unseal: hooks.unseal, Refused: hooks.refused, Held: hooks.held,
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
}
+551 -104
View File
@@ -6,21 +6,40 @@ import (
"context"
"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/delivery"
"git.novox.be/novox/mesh-sdk/go/declaration"
"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"
)
// The node agent whole, against a real bus with JetStream started in the test: the wants bucket, the node's composition
@@ -49,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
@@ -114,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)
@@ -159,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)
}
@@ -180,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
}
@@ -202,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)
@@ -232,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
@@ -249,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):
@@ -262,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++
}
}
@@ -273,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)
}
@@ -292,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)
}
@@ -308,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
@@ -386,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"}`)
@@ -432,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)
}
@@ -451,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 {
@@ -475,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".
@@ -483,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 {
@@ -507,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"))
@@ -556,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)
}
@@ -565,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)
}
@@ -593,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()
@@ -613,9 +642,427 @@ 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)
}
return key
}
// recordingRuntime is a container runtime that keeps its containers in memory and records every command an apply asks
// of it (issue 774): what the bus server's container met is read from it, never from a real runtime.
type recordingRuntime struct {
mu sync.Mutex
containers map[string]string // name: the spec label it was made with
asked []string
}
func (r *recordingRuntime) run(_ context.Context, name string, args ...string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.asked = append(r.asked, name+" "+strings.Join(args, " "))
if name != "docker" || len(args) == 0 {
return "", nil
}
switch args[0] {
case "info":
return "27.0\n", nil
case "container":
if len(args) > 1 && args[1] == "inspect" {
spec, ok := r.containers[args[len(args)-1]]
if !ok {
return "", errors.New("no such container")
}
return "true\t" + spec + "\n", nil
}
case "image":
if len(args) > 1 && args[1] == "inspect" {
return "2026-01-01T00:00:00Z\n", nil
}
case "rm":
delete(r.containers, args[len(args)-1])
case "rename":
if spec, ok := r.containers[args[1]]; ok {
delete(r.containers, args[1])
r.containers[args[2]] = spec
}
case "run", "create":
var made, spec string
for i, a := range args {
if a == "--name" && i+1 < len(args) {
made = args[i+1]
}
if a == "--label" && i+1 < len(args) && strings.HasPrefix(args[i+1], "mesh-host.spec=") {
spec = strings.TrimPrefix(args[i+1], "mesh-host.spec=")
}
}
r.containers[made] = spec
return "made\n", nil
}
return "", nil
}
// moved is each command that removed, made, stopped, renamed or restarted the container named.
func (r *recordingRuntime) moved(container string) []string {
r.mu.Lock()
defer r.mu.Unlock()
var out []string
for _, a := range r.asked {
for _, verb := range []string{"rm ", "run ", "create ", "stop ", "restart ", "kill ", "rename ", "start "} {
for _, prefix := range []string{"docker ", "docker container "} {
if strings.HasPrefix(a, prefix+verb) && strings.Contains(a, container) {
out = append(out, a)
}
}
}
}
return out
}
// ADR 0599 §6 and §4 (issue 774): the bus server the node's own composition made while the bus module was folded is taken
// over by the bus module's first want, the node agent started whole against a real bus: it composes the bus module, writes
// its changed file and records the server as the bus module's, and asks the container runtime to remove, make, stop,
// rename or restart nothing, since the build does not change the server. A later want whose build changes the server
// replaces it once, as any module's, once the want carries the yes (§2: the assigner gives it from the release's Approve).
// ADR 0601 §2: the bus module keeps data (its streams), so before each of those applies the node agent asks its node's
// backup seat to back it up, as for every module that keeps data, and applies only once the backup says it is done.
func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *testing.T) {
b := newItBed(t)
runtime := &recordingRuntime{containers: map[string]string{}}
b.opts.runner = runtime.run
const server = "mesh-broker-it"
conf := filepath.Join(b.dir, "nats.conf")
image := "registry.example/nats@sha256:" + strings.Repeat("a", 64)
// What the node's own composition made of the folded bus: the server and its file, recorded as the node's own.
built, err := system.For(builtFor)
if err != nil {
t.Fatal(err)
}
folded, err := declaration.Parse([]byte(`{"declaration":1,"resources":[` +
`{"id":"nats.server-conf","type":"file","path":"` + conf + `","mode":"0644","content":"listen: old\n"},` +
`{"id":"nats.server","type":"container","name":"` + server + `","image":"` + image + `"}]}`))
if err != nil {
t.Fatal(err)
}
_, known, err := apply.ApplyModule(context.Background(), built, folded, apply.Scope{Module: store.NodeModule}, store.State{},
store.OriginDeclared, runtime.run, nil, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
if err := store.Save(b.opts.state, known); err != nil {
t.Fatal(err)
}
if moved := runtime.moved(server); len(moved) != 1 {
t.Fatalf("the folded server was not made once: %v", moved)
}
made := runtime.containers[server]
runtime.mu.Lock()
runtime.asked = nil
runtime.mu.Unlock()
kvOf := func(bucket string) nats.KeyValue {
kv, err := b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: bucket, History: 5})
if err != nil {
t.Fatal(err)
}
return kv
}
assignment, modules, manifests := kvOf(buckets.Assignments), kvOf(buckets.Modules), kvOf(buckets.Manifests)
kvOf(selfcompose.ResolvedBucket)
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) {
man := `{"module":"nats","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],` +
`"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 := 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, _ := 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)
}
if _, err := modules.Put("nats", modv); err != nil {
t.Fatal(err)
}
}
rec, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: itNode,
Capabilities: []string{"service-manager", "package-manager", "container-runtime"}}})
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, _ := 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 meshwants.WantsByWantNodeModuleValue) {
raw, err := bucketstest.Want(w)
if err != nil {
t.Fatal(err)
}
key := meshwants.WantsByWantNodeModuleKey(itNode, "nats")
if _, err := b.wants.Put(key, raw); err != nil {
t.Fatal(err)
}
}
// The node's backup seat: its holder says the bus module has something to back up, starts a backup on now, and says
// when it ended on its event; each ask of now notes what the bus's file held then, so the order is seen.
var backups []string
var backupsMu sync.Mutex
if _, err := b.conn.Subscribe(link.SeatToolSubject("node-backup", "backed-up", itNode), func(m *nats.Msg) {
_ = m.Respond([]byte(`{"result":[{"module":"nats","runs":1}]}`))
}); err != nil {
t.Fatal(err)
}
if _, err := b.conn.Subscribe(link.SeatToolSubject("node-backup", "now", itNode), func(m *nats.Msg) {
got, _ := os.ReadFile(conf)
backupsMu.Lock()
backups = append(backups, string(got))
backupsMu.Unlock()
// The node agent hears the backup's end before it asks (backupNode), so the event follows the answer at once.
_ = m.Respond([]byte(`{"result":{"started":true}}`))
_ = b.conn.Publish(backedUpEvent, backupEvent(itNode, "nats", true, time.Now(), ""))
}); err != nil {
t.Fatal(err)
}
backedUp := func() []string {
backupsMu.Lock()
defer backupsMu.Unlock()
return append([]string(nil), backups...)
}
first := fpIt("e")
publish(first, "")
busWant(meshwants.WantsByWantNodeModuleValue{Fp: first, Self: true})
stop := b.start()
defer stop()
for key, value := range places {
if _, err := assignment.Put(key, value); err != nil {
t.Fatal(err)
}
}
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 == 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)
}
if got := backedUp(); len(got) != 1 || got[0] != "listen: old\n" {
t.Fatalf("the bus module's first apply was not backed up first: the backups saw %q", got)
}
state, err := store.Load(b.opts.state)
if err != nil {
t.Fatal(err)
}
if rec, ok := state.Find("nats.server"); !ok || rec.Module != "nats" {
t.Errorf("the server is recorded %+v, not as the bus module's", rec)
}
// A build that changes the server: replaced once, as any module's, with the yes the assigner writes from the release's
// Approve for a move the node judged to recreate it (ADR 0599 §2, §4).
next := fpIt("f")
publish(next, `,"env":{"CHANGED":"1"}`)
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 == nec.PhaseWaitingYes && h.Waits != "" {
busWant(meshwants.WantsByWantNodeModuleValue{Fp: next, Self: true, Yes: h.Waits})
}
return h.Fp == next && h.Phase == nec.PhaseRunning
})
var runs int
for _, a := range runtime.moved(server) {
if strings.HasPrefix(a, "docker run ") && strings.Contains(a, " --name "+server+" ") {
runs++
}
}
if got := backedUp(); len(got) != 2 || got[1] != "listen: new\n" {
t.Errorf("the replacing apply of the bus module was not backed up first: the backups saw %q", got)
}
if runs != 1 || runtime.containers[server] == made {
t.Errorf("the build that changes the server made it %d times, its spec still the folded one %v: %v", runs,
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)
}
}
+72
View File
@@ -0,0 +1,72 @@
package main
import (
"fmt"
"os"
"strings"
"time"
"github.com/novox/mesh-host/internal/store"
)
// stopBound is how long a stopping engine waits for the apply in flight to end and save what it did: under the service
// manager's own stop bound (90 s by default, then every process of the unit is killed), so the wait ends with a line in
// the journal rather than with a kill.
const stopBound = 60 * time.Second
// stopBetweenApplies is how this engine ends, whichever way (a stop of the service, standing aside for a successor, a
// move to another bus): it waits, up to bound, for the apply in flight to end, then lets no save start and waits for
// the one writing (novox/hq issue 1585). An apply runs detached from the engine's own stop (applyContext), so without
// this the process ended under it: what it unpacked was never recorded, and a save cut between its write and its
// rename left a temporary file and the state before it. A stopping engine never gives back what it takes (the process
// ends); the release it returns is for a test, and gives back the apply only when this took it.
func stopBetweenApplies(say func(string), bound time.Duration) (release func()) {
took := true
if !applying.TryLock() {
say(fmt.Sprintf("stopping: waiting up to %s for the apply in flight to end and save what it did", bound))
got := make(chan struct{})
go func() {
applying.Lock()
close(got)
}()
select {
case <-got:
say("stopping: the apply in flight ended and its state is saved")
case <-time.After(bound):
took = false
say(fmt.Sprintf("stopping while an apply still runs after %s: what it did since its last save is not "+
"recorded, and the next start applies its module again", bound))
}
}
saves := store.Quiesce()
return func() {
saves()
if took {
applying.Unlock()
}
}
}
// removeLeftoverSaves removes, at the engine's start, the temporary files of saves of the node's state that never
// reached their rename, and says how many (issue 1585: about 260 on novox, ~80 MB).
func removeLeftoverSaves(statePath string, say func(string)) {
removed, err := store.RemoveLeftovers(statePath)
if len(removed) > 0 {
shown := removed
if len(shown) > 5 {
shown = append(append([]string(nil), shown[:5]...), "…")
}
say(fmt.Sprintf("removed %d leftover save(s) of this node's state, cut off before their rename by an earlier "+
"stop: %s", len(removed), strings.Join(shown, ", ")))
}
if err != nil {
say(err.Error())
}
}
// exitBetweenApplies ends the process cleanly once no apply runs and no save writes: an exit that is not runLink's
// return (a move to another bus). A variable, so a test sees it called rather than the test process ending.
var exitBetweenApplies = func(say func(string)) {
_ = stopBetweenApplies(say, stopBound)
os.Exit(0)
}
+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.20261102191058-f02f142af128
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.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/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=
+416 -114
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+80 -12
View File
@@ -4,9 +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/delivery"
"git.novox.be/novox/mesh-sdk/go/declaration"
"github.com/novox/mesh-host/internal/judge"
)
@@ -20,6 +22,13 @@ type CompositionView struct {
Module string `json:"module"`
// Want is the module's want as the agent holds it; nil when it holds none.
Want *WantView `json:"want,omitempty"`
// Attempt is what the agent holds of the module's last apply that did not end running: failed, failed for a moment
// of the bus, or held for a failed backup, with the want's revision that hold took as answered; nil when none
// (issue 1585: why a module stays held was not readable from the engine).
Attempt *AttemptView `json:"attempt,omitempty"`
// Started says the agent has looked at the module once since it started (its baseline): what runs was taken from its
// have, or it was applied.
Started bool `json:"started"`
// Have is what the module's have key says now; nil when it says nothing.
Have *HaveView `json:"have,omitempty"`
// Composition is the revision read: the latest of the want's build, or the one whose digest was asked.
@@ -32,14 +41,56 @@ type CompositionView struct {
Judged JudgedView `json:"judged"`
// Diff is every resource that differs from what runs, when asked.
Diff []judge.ResourceChange `json:"diff,omitempty"`
// Own is, of the node's own composition (module node), its own fields beside what runs names (issue 774: ace's
// own composition dropped the provisioner client's account, and nothing read could show it).
Own *OwnView `json:"own,omitempty"`
}
// WantView is a want as the verb shows it.
// 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
// what runs names it. Empty: the declaration names none, and every filled write is refused.
Provisioner string `json:"provisioner_account"`
RunsProvisioner string `json:"runs_provisioner_account,omitempty"`
// Adoption is the revision's adoption: the node adopted, and which of its modules are taken; nil for a converged node.
Adoption *declaration.Adoption `json:"adoption,omitempty"`
// Identities, Groups and Libraries count what the revision names of each.
Identities int `json:"identities,omitempty"`
Groups int `json:"groups,omitempty"`
Libraries int `json:"libraries,omitempty"`
// Held is each module the node's composer held in its last pass, with why.
Held map[string]string `json:"held,omitempty"`
}
// WantView is a want as the verb shows it: as the agent holds it, at the revision of its key it last read.
type WantView struct {
FP string `json:"fp"`
Composed string `json:"composed,omitempty"`
Yes string `json:"yes,omitempty"`
Removed bool `json:"removed,omitempty"`
// Self says the node composes the module itself (ADR 0547 §2).
Self bool `json:"self,omitempty"`
// Backup is the operator's answer to a failed backup the want carries, again or skip (ADR 0539 §16); empty when none.
Backup string `json:"backup,omitempty"`
Words string `json:"words,omitempty"`
// Revision is the revision of the want's key the agent read last.
Revision uint64 `json:"revision"`
}
// AttemptView is the agent's record of a module's last apply that did not end running.
type AttemptView struct {
FP string `json:"fp"`
Composed string `json:"composed,omitempty"`
// WantRevision and CompositionRevision are the revisions of the want's and the composition's keys it was tried at.
WantRevision uint64 `json:"want_revision"`
CompositionRevision uint64 `json:"composition_revision"`
// Held says it is held for a failed backup; AnsweredRevision is the want's revision the hold took, so an answer
// (again or skip) in a later revision releases it.
Held bool `json:"held,omitempty"`
AnsweredRevision uint64 `json:"answered_revision,omitempty"`
// Transient says it failed because the bus did not answer for a moment, and is tried again by the clock.
Transient bool `json:"transient,omitempty"`
}
// HaveView is a have as the verb shows it.
@@ -96,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}
}
@@ -105,11 +156,17 @@ 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
}
if att, ok := a.attempt[module]; ok {
view.Attempt = &AttemptView{FP: att.FP, Composed: att.Composed, WantRevision: att.WantRev, CompositionRevision: att.CompRev,
Held: att.Held, AnsweredRevision: att.AnsweredRev, Transient: att.Transient}
}
view.Started = a.checked[module]
latest, haveLatest := a.compositionFor(module, fp)
r, ran := a.running[module]
from, known := a.runningComposition(module)
@@ -162,20 +219,31 @@ 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
}
if module == NodeModule {
o := read.C.Own
view.Own = &OwnView{Provisioner: o.Provisioner, Adoption: o.Adoption, Identities: len(o.Identities), Groups: len(o.Groups),
Libraries: len(o.Libraries)}
if running != nil {
view.Own.RunsProvisioner = running.Own.Provisioner
}
if a.env.Held != nil {
view.Own.Held = a.env.Held()
}
}
j := judge.JudgeMove(running, read.C)
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
}
}
@@ -188,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
@@ -209,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
+107 -10
View File
@@ -610,7 +610,7 @@ func applyScoped(
err = fmt.Errorf("%w: %s", errRemovalWaits, said)
}
default:
action, detail, err = remove(ctx, sys, orphan, run, made)
action, detail, err = remove(ctx, sys, orphan, run, made, journalDir(windows))
}
if errors.Is(err, errNoRemoval) && store.IsFormer(orphan.ID) {
// **A former target of a kind the host cannot remove is left in place and forgotten,
@@ -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
@@ -1572,7 +1579,7 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru
case *declaration.User:
return applyUser(ctx, sys, res, run, previous, in.groups, in.handed)
case *declaration.Archive:
return applyArchive(ctx, res, previous, in.away)
return applyArchive(ctx, res, previous, in.away, journalDir(in.windows))
case *declaration.Process:
return applyProcess(ctx, res, run, changed, previous, in.away)
case *declaration.Action:
@@ -2483,7 +2490,7 @@ func reflectOnly(ctx context.Context, sys system.System, r *declaration.Service,
//
// made is the units whose unit file this host wrote where there was none (meshMadeUnits).
func remove(ctx context.Context, sys system.System, a store.Applied, run Runner,
made map[string]bool) (string, string, error) {
made map[string]bool, journals string) (string, string, error) {
switch declaration.Type(a.Type) {
case declaration.TypeDirectory, declaration.TypeFile, declaration.TypeArchive:
// Removal below a home follows no link an account put there either (novox/hq ADR 0266).
@@ -2609,7 +2616,7 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner,
case declaration.TypeArchive:
// Exactly what it unpacked, and the directories the host made for it once they are empty
// (novox/hq issue 162).
return removeArchive(a)
return removeArchive(a, journals)
default:
return "", "", fmt.Errorf("%w: a %q", errNoRemoval, a.Type)
@@ -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 }
+11 -2
View File
@@ -35,9 +35,14 @@ import (
// enough for a desktop theme and small enough to notice.
const maxArchive = 512 << 20
func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied, away *storeAway) (Outcome, error) {
func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied, away *storeAway, journals string) (Outcome, error) {
out := begin(r)
out.Action = "unchanged"
recorded := previous.Wrote != ""
previous, err := withJournal(journals, previous, r.Path)
if err != nil {
return out, err
}
body, err := fetchMinding(ctx, r.Source, away)
if err != nil {
@@ -78,8 +83,12 @@ func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Ap
return out, err
}
out.unpacked = &unpacked
// At once, before the rest of the apply can fail or the engine stop (novox/hq issue 1585).
if err := keepJournal(journals, r.Path, got, &unpacked); err != nil {
return out, fmt.Errorf("%s was unpacked, and what it unpacked could not be written down: %w", r.Path, err)
}
out.Action = "updated"
if previous.Wrote == "" {
if !recorded {
out.Action = "created"
}
out.Detail = fmt.Sprintf("%d file(s)", written)
+133
View File
@@ -0,0 +1,133 @@
package apply
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/novox/mesh-host/internal/store"
)
// What an archive unpacked is written down the moment it is unpacked (novox/hq issue 1585).
//
// The node's state is saved at the end of a module's apply, so an engine that stopped, was replaced or lost its
// state between the unpack and that save held files on the machine it had no record of. The next apply then found
// its own files and refused them as ones "the mesh did not put there" (issue 162), and a module could not move to
// its new build until a person removed them. So, at once and atomically, the host keeps the digest it unpacked and
// the files and directories it placed; an apply that holds no record of the archive reads this instead of treating
// everything there as somebody's. The record in the state stays the authority: the journal is read only when there
// is none.
//
// **In the node-engine's own state directory, readable and writable by root alone, never beside the archive.** The
// archive's directory may be writable by the module's account (a home, a data directory): a journal there could be
// forged, and a root engine would then remove, or take as its own, any path the forger named. And an entry is read
// only when it names a path inside the archive's directory (filepath.IsLocal), and a parent only when it is above it.
// JournalDirName is the directory under the node's state directory that keeps the journals.
const JournalDirName = "unpacked"
type unpackJournal struct {
Target string `json:"target"`
Wrote string `json:"wrote"`
Unpacked *store.Unpacked `json:"unpacked"`
}
// journalDir is where the journals of this apply are kept: under the state directory every applier on the machine
// shares (the one its windows are kept in). Empty for a caller with no state directory (a test), which keeps none.
func journalDir(w *Windows) string {
if w == nil || w.dir == "" {
return ""
}
return filepath.Join(filepath.Dir(w.dir), JournalDirName)
}
// journalPath names the journal of the archive at target: by the digest of its path, so no path names a file
// outside the journals' directory.
func journalPath(dir, target string) string {
sum := sha256.Sum256([]byte(filepath.Clean(target)))
return filepath.Join(dir, hex.EncodeToString(sum[:])+".json")
}
// keepJournal writes down what an archive unpacked at path.
func keepJournal(dir, path, wrote string, u *store.Unpacked) error {
if dir == "" {
return nil
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
raw, err := json.Marshal(unpackJournal{Target: filepath.Clean(path), Wrote: wrote, Unpacked: u})
if err != nil {
return err
}
return writeAtomicallyByPath(journalPath(dir, path), raw, 0o600)
}
// journalOf is what the journal says of path: false when there is none, and an error when there is one that cannot
// be read or names a path outside the archive's directory, which is never believed.
func journalOf(dir, path string) (unpackJournal, bool, error) {
if dir == "" {
return unpackJournal{}, false, nil
}
raw, err := os.ReadFile(journalPath(dir, path))
if err != nil {
return unpackJournal{}, false, nil
}
var j unpackJournal
if err := json.Unmarshal(raw, &j); err != nil {
return unpackJournal{}, false, fmt.Errorf("the journal of what was unpacked at %s is unreadable: %w", path, err)
}
if j.Wrote == "" || j.Unpacked == nil {
return unpackJournal{}, false, fmt.Errorf("the journal of what was unpacked at %s names no digest or files", path)
}
if err := checkJournal(j, filepath.Clean(path)); err != nil {
return unpackJournal{}, false, err
}
return j, true, nil
}
// checkJournal refuses a journal of another target, an entry that is not a path inside the archive's directory, and
// a parent that is not above it.
func checkJournal(j unpackJournal, target string) error {
if j.Target != target {
return fmt.Errorf("the journal kept for %s names %q", target, j.Target)
}
for _, rel := range append(append([]string(nil), j.Unpacked.Files...), j.Unpacked.Dirs...) {
if !filepath.IsLocal(filepath.FromSlash(rel)) {
return fmt.Errorf("the journal of what was unpacked at %s names %q, which is not inside it: not believed", target, rel)
}
}
for _, p := range j.Unpacked.Parents {
if p = filepath.Clean(p); !filepath.IsAbs(p) || p == target || !strings.HasPrefix(target, p+string(filepath.Separator)) {
return fmt.Errorf("the journal of what was unpacked at %s names the parent %q, which is not above it: not believed", target, p)
}
}
return nil
}
// withJournal is previous, or, when the state holds no record of the archive, what the journal says the host
// unpacked at path. A journal not believed is an error: the apply stops and says so rather than guess.
func withJournal(dir string, previous store.Applied, path string) (store.Applied, error) {
if previous.Wrote != "" {
return previous, nil
}
j, ok, err := journalOf(dir, path)
if err != nil || !ok {
return previous, err
}
previous.Wrote, previous.Target, previous.Unpacked = j.Wrote, path, j.Unpacked
return previous, nil
}
// dropJournal removes the journal of the archive at target, once the archive is taken off; whether the state's
// record of it still said what it unpacked or not.
func dropJournal(dir, target string) {
if dir == "" || target == "" {
return
}
_ = os.Remove(journalPath(dir, target))
}
+93
View File
@@ -2,6 +2,7 @@ package apply
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -152,6 +153,98 @@ func TestAnArchiveDoesNotWriteOverAFileItFound(t *testing.T) {
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Fatalf("%d entries beside the directory", len(entries))
}
// Issue 1585: what the host unpacked is written down at once, in its own state directory, so an archive whose
// record in the state was lost is still the host's: the next version replaces its files, drops the ones it no
// longer has and leaves a file somebody added. A file the host never unpacked still refuses it, and so does a
// journal naming a path outside the archive's directory. The journal goes with the archive, record lost or not.
for _, row := range []struct {
name string
journaled bool // the first version was unpacked by the host
foreign string
forged func(j *unpackJournal)
refused string
}{
{"unpacked by the host, record lost", true, "", nil, ""},
{"unpacked by the host, somebody's file beside", true, "mine.txt", nil, ""},
{"never unpacked by the host", false, "", nil, "did not put there"},
{"a journal naming a path outside", true, "", func(j *unpackJournal) {
j.Unpacked.Files = append(j.Unpacked.Files, "../../etc/passwd")
}, "not inside it"},
{"a journal naming a parent not above", true, "", func(j *unpackJournal) {
j.Unpacked.Parents = []string{"/etc"}
}, "not above it"},
} {
t.Run(row.name, func(t *testing.T) {
dir, state := t.TempDir(), t.TempDir()
windows, journals := WindowsIn(state), filepath.Join(state, JournalDirName)
applyIt := func(decl string, known store.State) (store.State, error) {
_, after, err := ApplyMindingWindows(context.Background(), archHost(t), declare(t, decl), known,
store.OriginDeclared, noServices, nil, nil, nil, windows)
return after, err
}
target := filepath.Join(dir, "tools")
first, _ := archiveDecl(t, "m.tools", target, map[string]string{"bin": "one", "old": "o"}, "")
if row.journaled {
if _, err := applyIt(first, store.State{}); err != nil {
t.Fatal(err)
}
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Fatalf("%d entries beside the archive: the journal is the state directory's", len(entries))
}
info, err := os.Stat(journalPath(journals, target))
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("no journal of root's alone after the unpack: %v %v", info, err)
}
} else {
os.MkdirAll(target, 0o755)
os.WriteFile(filepath.Join(target, "bin"), []byte("one"), 0o644)
}
if row.forged != nil {
j, _, err := journalOf(journals, target)
if err != nil {
t.Fatal(err)
}
row.forged(&j)
raw, _ := json.Marshal(j)
os.WriteFile(journalPath(journals, target), raw, 0o600)
}
if row.foreign != "" {
os.WriteFile(filepath.Join(target, row.foreign), []byte("x"), 0o644)
}
second, _ := archiveDecl(t, "m.tools", target, map[string]string{"bin": "two"}, "")
// The state is empty: the record of the first unpack was lost.
after, err := applyIt(second, store.State{})
if row.refused != "" {
if err == nil || !strings.Contains(err.Error(), row.refused) {
t.Fatalf("refused? %v", err)
}
return
}
if err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(filepath.Join(target, "bin")); string(got) != "two" {
t.Fatalf("bin is %q", got)
}
if exists(filepath.Join(target, "old")) {
t.Fatal("a file the new version no longer has is still there")
}
if row.foreign != "" && !exists(filepath.Join(target, row.foreign)) {
t.Fatal("somebody's file went")
}
// Taken off, with a record that no longer says what it unpacked: the journal goes too.
for i := range after.Resources {
after.Resources[i].Unpacked = nil
}
if _, err := applyIt(`{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`, after); err != nil {
t.Fatal(err)
}
if exists(journalPath(journals, target)) {
t.Fatal("the journal outlived the archive")
}
})
}
}
func TestADirectoryTheHostMadeGoesOnlyWhenEmpty(t *testing.T) {
+10 -1
View File
@@ -364,7 +364,16 @@ func mergeInto(fresh, path, owner string, files, dirs []string, o ours) (store.U
// module's tools from its bundle — sees the whole tree or none of it, never half, and a file it has
// open stays readable until it closes it. The runtime is told the module went by its own membership,
// not by the files disappearing.
func removeArchive(a store.Applied) (string, string, error) {
func removeArchive(a store.Applied, journals string) (string, string, error) {
action, detail, err := removeArchiveFiles(a)
// And its journal (issue 1585), once the record goes: whether the record said what it unpacked or not.
if err == nil {
dropJournal(journals, a.Target)
}
return action, detail, err
}
func removeArchiveFiles(a store.Applied) (string, string, error) {
if store.IsFormer(a.ID) {
// The version before is what a rollback starts (ADR 0141) and what a reader may still have
// open; the launcher retires the host's own versions, not the apply (issue 194).
+1 -1
View File
@@ -159,7 +159,7 @@ func TestRemovalThroughALinkUnderAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
_, _, err := remove(context.Background(), archHost(t), store.Applied{ID: "m.f", Type: "file",
Target: filepath.Join(home, ".claude", "shadow")}, nil, nil)
Target: filepath.Join(home, ".claude", "shadow")}, nil, nil, "")
mustBeLinkRefusal(t, err)
if _, err := os.Stat(filepath.Join(outside, "shadow")); err != nil {
t.Fatal("removed through the link")
+1 -1
View File
@@ -536,7 +536,7 @@ func witnessOf(r *declaration.Process) string {
if r.Witness != "" {
return r.Witness
}
return witness.Default(r.Name)
return witness.Default(r.ID, r.Name)
}
// digestWritten is the bundle digest a process's record says was placed: the first half of what it
+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 -1
View File
@@ -185,7 +185,7 @@ func (r Rewritten) declares(id string) bool {
// container runtime's configuration whole — and neither is anything the operator installs later.
var genesisTakes = map[string]bool{
RegistryModule: true, ControlPlaneModule: true, BuilderModule: true,
"postgres": true, BusModule: true, "mesh-vault": true, "mesh-catalog": true,
"postgres": true, BusModule: true, "mesh-vault": true,
}
// takeIfAdopted takes one of genesis's own modules on an adopted node, once it is assigned and
+2 -10
View File
@@ -64,7 +64,6 @@ const (
StepStore Step = "store"
StepBroker Step = "broker"
StepVault Step = "vault"
StepCatalogue Step = "catalogue"
StepNetwork Step = "network"
StepFilter Step = "filter"
StepExtras Step = "extras"
@@ -89,7 +88,7 @@ var Steps = []Step{
// Phase two. The twelve above make a mesh that RUNS; these make one that WORKS — able to
// build, to say what it holds, on its network, filtering. They used to be things somebody
// typed afterwards, which is how they went missing without anything complaining.
StepBase, StepStore, StepBroker, StepVault, StepCatalogue, StepNetwork, StepFilter, StepExtras, StepExport,
StepBase, StepStore, StepBroker, StepVault, StepNetwork, StepFilter, StepExtras, StepExport,
}
// Error is a failure, named by the step it happened in.
@@ -184,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
@@ -750,12 +749,6 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
return result, failed(StepVault, err)
}
// ---- 15. catalogue --------------------------------------------------------------------
say("catalogue — the module graph: what is held, what a change reaches, what to rebuild")
if err := InstallFromCatalogue(ctx, o, permanentControl, "mesh-catalog", say); err != nil {
return result, failed(StepCatalogue, err)
}
// ---- 16. network ----------------------------------------------------------------------
say("network — the private network, and this machine's name on it")
if err := PlaceOnTheNetwork(ctx, o, permanentControl, rewritten.BrokerAddress, say); err != nil {
@@ -822,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)
}
}
}
+12 -2
View File
@@ -13,14 +13,24 @@ import (
// stopped genesis at step 17: "this mesh holds no copy of it. Build mesh-tools-go first".
// genesisModules is every catalogue module genesis installs on its own, read from the steps that
// install them: the registry, the build agent, the store, the bus, the vault, the catalogue and the
// install them: the registry, the build agent, the store, the bus, the vault and the
// filter (extras are the operator's choice and come after the base too, but are not genesis's own).
var genesisModules = []string{RegistryModule, BuilderModule, "postgres", BusModule, "mesh-vault", "mesh-catalog", "nftables"}
var genesisModules = []string{RegistryModule, BuilderModule, "postgres", BusModule, "mesh-vault", "nftables"}
// Every genesis module's bundles are compiled in a toolchain the base step builds, read from the real
// catalogue's manifests: a module that gains a language genesis does not build fails here, not at a
// genesis.
func TestTheBaseStepBuildsEveryToolchainAGenesisModuleIsCompiledIn(t *testing.T) {
// ADR 0604 section 7: the catalog is the indexer's seat now, so genesis has no catalogue step and installs or takes no
// mesh-catalog module.
for _, step := range Steps {
if step == "catalogue" {
t.Fatal("genesis still has a catalogue step")
}
}
if genesisTakes["mesh-catalog"] {
t.Fatal("an adopted genesis still takes mesh-catalog")
}
built := map[string]bool{}
for _, tc := range genesisToolchains {
built[tc.Language] = true
+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
}
+33 -21
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,29 +44,38 @@ 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)
}
func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) {
// Folds says whether a module the node runs inside its own composition (inOwn, Node.InOwn) is folded there at the
// fingerprint fp it is composed at: only at no fingerprint of the new form. At one (src2:) it has a composition of its
// own, composed from its build, so every reader of a build (frombus) takes the build in exactly where this says it is
// 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, meshcatalogclient.SourcePrefix2)
}
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
@@ -84,7 +96,7 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
// The private network's module is built by nobody: no fingerprint of the new form ever names it.
inOwn[overlay.Name] = true
folds := func(module string) bool {
return module != "" && inOwn[module] && !strings.HasPrefix(fps[module], delivery.SourcePrefix2)
return module != "" && Folds(inOwn[module], fps[module])
}
if !found {
return nil, nil, nil, fmt.Errorf("compose: the mesh has no node %q", node)
@@ -93,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 {
@@ -255,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
}
@@ -287,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
}
@@ -315,8 +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 {
runtimeID := catalogue.RuntimeModule + "." + resolution.RuntimeProcessID()
func ToolsRunning(running []engcomp.Composed) map[string][]catalogue.Bundle {
type resource struct {
ID string `json:"id"`
Module string `json:"module"`
@@ -339,12 +350,13 @@ func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
owner, _ = declaration.ModuleGuess(r.ID)
}
switch {
case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule:
case r.Type == "process" && owner != "" && r.ID == owner+"."+resolution.RuntimeProcessID():
// The tool runner's process, by its id alone: `<its module>.runtime` (resolutionRuntimeProcess).
env = r.Env
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})
}
}
}
@@ -467,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)
}
@@ -512,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"`
+25 -38
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 {
@@ -104,12 +105,13 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
// indexer's commit has no bundles (shanks's runtime lost the folded systemd-resolved, novox's the folded nats), and a
// held module's new build's bundles are not delivered.
dg := func(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) }
runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1",
runtime := catalogue.Manifest{Module: "node-tools", Version: "1", ToolRunner: true,
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/node-tools/broker"}},
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,48 +122,48 @@ 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: catalogue.RuntimeModule, 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
}, map[string]string{"lamp": fpFor('1')}, false},
} {
tools := aMesh(t)
tools.Catalogue[catalogue.RuntimeModule], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp
tools.Catalogue["node-tools"], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp
anchor := nodeOf(t, &tools, "anchor")
anchor.Assigned = append(anchor.Assigned, "artifacts", catalogue.RuntimeModule, "lamp")
tools.BusMinted = []string{"anchor." + catalogue.RuntimeModule}
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)})
row.fps[overlay.Name], row.fps["artifacts"], row.fps[catalogue.RuntimeModule] = fpFor('a'), fpFor('5'), fpFor('e')
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 {
t.Fatalf("%s: %v", row.name, err)
@@ -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, catalogue.FromBus, catalogue.FromData, catalogue.FromDefault} {
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)
}
}
+3 -3
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 {
@@ -386,13 +386,13 @@ type CoreBoxWanted struct {
Name string
}
// coreModulesOf is every module of the plan on the core's list whose own secrets this machine's declaration carries, with their
// coreModulesOf is every module of the plan whose manifest says its own secrets are the core's credentials, whose own secrets this machine's declaration carries, with their
// plain own secret names, sorted: one handed to the provisioner has its secrets from the vault through the client, and one issued
// outside the mesh is never the vault's to seal here.
func coreModulesOf(modules []catalogue.Manifest, handed func(catalogue.Manifest) bool) map[string][]string {
out := map[string][]string{}
for _, m := range modules {
if !resolution.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) {
if !resolution.CarriesVaultSealed(m) || (handed != nil && handed(m)) {
continue
}
var names []string
+3 -3
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",
@@ -295,7 +295,7 @@ func aValidKey(t *testing.T, name string) string { return keyOf(t, name) }
func TestACoreBoxTheInputHoldsNoneOfIsReturnedToKeepAndNothingIsWritten(t *testing.T) {
mk := func() Input {
in := aMesh(t)
in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1",
in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", CoreCredentials: true,
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}}
laptop := nodeOf(t, &in, "laptop")
laptop.Assigned = append(laptop.Assigned, "nats")
+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.
+41 -38
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]
@@ -404,11 +406,11 @@ func firstReason(err error, failed map[string]string) error {
return fmt.Errorf("%s; its stand-in is then refused: %w", strings.Join(named, "; "), err)
}
// blames says the composition with m's stub (again) makes m the culprit of err: it composes, or fails otherwise. The
// blames says the composition with m's stub (again) makes m the culprit of err, m the tool runner (runner) or not: it composes, or fails otherwise. The
// runtime's stub is never a runtime (no build is taken in, so it carries no bundle) and always fails otherwise: it is
// the culprit only where its stub composes (issue 774, 2026-10-30 19:30: node-tools held for any error naming it).
func blames(m string, again, err error) bool {
if m == catalogue.RuntimeModule {
func blames(runner bool, again, err error) bool {
if runner {
// Or where its stub fails only for the modules of their own account it would run (issue 774): they are held
// after it, each for its own words, and the node composes the rest.
var none *resolution.NoRuntimeToRunError
@@ -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
@@ -470,7 +472,8 @@ func culprit(in compose.Input, node string, fps map[string]string, mode compose.
}
changes := func(m, leave string) bool {
_, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode)
return blames(m, again, err)
man, known := manifestOn(in, node, m)
return blames(known && catalogue.IsToolRunner(man), again, err)
}
for _, m := range order {
if _, done := failed[m]; done {
@@ -541,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 {
+116 -86
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.
@@ -191,6 +197,28 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
!strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") {
t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held)
}
// Issue 1585 (nats on novox, met twice): wanted at a build of the new form, a module in InOwn is no longer folded
// (compose.Folds), so its build is taken in and it composes with its image, never from the indexer's manifest as
// "not built for this mesh".
{
bb := mk("lemurs", overlay.Name, "spiegel")
bp := bb.assignments["n1"]
bp.Decided.Endpoint = "n1.example:51820"
bb.assignments["n1"] = bp
sp := bb.modules["spiegel"]
sp.Fp2 = fp("3")
bb.modules["spiegel"] = sp
image := "registry.example:5100/spiegel/site@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})
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,
read.ComposedHow, raw)
}
}
// (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter
// opens the machine port its running container publishes (de-spiegel's 20005 on novox, not its software's 8080; a
@@ -206,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
@@ -287,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"}`),
@@ -344,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)
@@ -363,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)
}
}
@@ -403,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 {
@@ -428,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")
}
@@ -444,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)
}
@@ -485,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)
}
@@ -528,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)
}
@@ -563,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)
}
}
@@ -584,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
}
+255 -127
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
@@ -98,9 +107,10 @@ type SignersReader interface {
// HandedReader is a Reader that also says, of the composing node's modules, which the vault judged handed to the
// provisioner when it answered OwnSecrets (the vault's `own`, its `handed`: catalogue.AssignmentHands of the plan in
// each module's assignment). The node's composition then hands exactly those (compose.Node.Handed, issue 774), so the
// vault and the node never judge one module two ways. Nil, or a Reader without it (a vault older than the field),
// composes by the manifest and the machine, as before.
// each module's assignment). Never the rule: the node hands by the manifest, its build and the machine
// (resolution.Handing, issue 1966), and the plans it composes are what the vault reads next; the vault's word is said
// where the two disagree (resolution.VaultDisagrees). Nil, or a Reader without it (a vault older than the field), says
// nothing.
type HandedReader interface {
Handed(ctx context.Context, node string) map[string]bool
}
@@ -127,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).
@@ -140,20 +150,23 @@ 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
// runner, the core's credentials; ADR 0601 §4), so the node says it. Nil: said nowhere.
Kept func(module, words string)
}
// InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs
@@ -161,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
@@ -190,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.
@@ -201,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)
@@ -229,16 +242,26 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
if err != nil {
return compose.Input{}, err
}
return b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted)
in, err := b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted)
if err == nil {
runningProvisioner(&in, s.Node, s.Running)
// What runs is kept while a manifest does not yet say what shared code no longer infers (ADR 0601 §4).
for _, k := range keepWhatRuns(&in, s.Node, s.Running) {
if s.Kept != nil {
s.Kept(k.Module, k.Words)
}
}
}
return in, err
}
// 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
@@ -268,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 {
@@ -300,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,
@@ -324,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) {
@@ -334,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) {
@@ -365,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
@@ -378,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)
@@ -403,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{}
}
@@ -452,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).
@@ -469,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)
@@ -500,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).
@@ -540,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).
@@ -571,6 +594,58 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s
return out, skipped, held, err
}
// ComposingInput is the input the scope's node composes itself from at the builds fps, as Compose reads it (the same read,
// the refusals it holds), with how Compose composes from it (compose.Input.ComposedHow): what a node-engine sends a
// check's sandbox, so the sandbox composes the node through ComposeSent, the path Compose takes (issue 774). Read no
// credential: the sandbox composes no pair value.
func ComposingInput(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (compose.Input, error) {
held := map[string]string{}
in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, fps)
if err != nil {
return compose.Input{}, err
}
c := &compose.ComposedHow{Node: s.Node, Builds: maps.Clone(fps), Held: held}
for _, run := range s.Running {
if _, isHeld := held[run.Module]; isHeld && run.Module != "" {
c.Running = append(c.Running, run)
}
}
in.ComposedHow = c
return in, nil
}
// 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]engcomp.Composed, bool, error) {
c := in.ComposedHow
if c == nil || c.Node != node {
return nil, false, nil
}
held := maps.Clone(c.Held)
if held == nil {
held = map[string]string{}
}
out, _, _, err := composeEach(in, node, c.Builds, false, c.Running, held, nil)
if err != nil {
return nil, true, err
}
// A module the sandbox held that the node-engine held neither in the read nor composing without the change is the change
// refusing it: the node does not compose, said in that module's words, never passed with the rest composed (issue 774,
// review of the sandbox input).
var refused []string
for _, m := range sortedKeys(held) {
_, was := c.Held[m]
if _, before := c.HeldBefore[m]; !was && !before {
refused = append(refused, m+": "+held[m])
}
}
if len(refused) > 0 {
return nil, true, fmt.Errorf("on %s the change refuses %s", node, strings.Join(refused, "; "))
}
return out, true, nil
}
// Plans is the provisioning plan of each module handed to the provisioner on the scope's node, composed from the bus
// alone (novox/hq ADR 0570 clause 2): the controller's handedPlans over the same input (mesh-control
// cmd/mesh-controller/compose_sdk.go), its rendering in the Foreseeing mode with the mesh's generators. A plan names
@@ -697,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 {
@@ -705,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)
@@ -723,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
@@ -761,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)
}
@@ -811,8 +886,8 @@ 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 &&
keptAt[pm.Module] == "" && !slices.Contains(s.InOwn, pm.Module) {
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{}
}
@@ -827,9 +902,10 @@ 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] != "" || slices.Contains(s.InOwn, pm.Module) {
// A module it runs inside its own composition is composed there as before: no build of the new form is what
// it runs (Scope.InOwn).
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).
continue
}
if _, refused := in.Refused[pm.Module]; refused {
@@ -838,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
@@ -877,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
@@ -919,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 {
@@ -939,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
@@ -967,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)
@@ -989,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
}
}
@@ -1001,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})
@@ -1024,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 != "" {
@@ -1047,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 {
@@ -1069,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)
}
}
}
@@ -1086,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() {
@@ -1105,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"`
@@ -1166,3 +1259,38 @@ func withoutHeldData(in compose.Input, node string) compose.Input {
}
return out
}
// 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 []engcomp.Composed) {
account := ""
for _, c := range running {
if c.Module == "" && c.Own.Provisioner != "" {
account = c.Own.Provisioner
}
}
for _, c := range running {
if account != "" {
break
}
man, ok := in.Catalogue[c.Module]
if c.Module == "" || !ok || !man.ClaimsSeat(compose.NodeProvisionerSeat) {
continue
}
for _, raw := range c.Resources {
var r struct {
Type string `json:"type"`
User string `json:"user"`
}
if json.Unmarshal(raw, &r) == nil && r.Type == "process" && r.User != "" && r.User != "root" {
account = r.User
break
}
}
}
for i := range in.Nodes {
if in.Nodes[i].Name == node {
in.Nodes[i].RunningProvisioner = account
}
}
}
+175 -132
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) {
@@ -148,40 +159,41 @@ func TestInput(t *testing.T) {
t.Run("a held module's first reason leads", aHeldModulesFirstReasonLeads)
t.Run("a held module of its own account never refuses the node", aHeldModuleOfItsOwnAccountNeverRefusesTheNode)
t.Run("another node not resolved is named", otherNodeNotResolvedIsNamed)
t.Run("what runs is kept while a manifest does not say it", whatRunsIsKeptWhileAManifestDoesNotSayIt)
ctx := context.Background()
xterm, err := os.ReadFile("testdata/catalogue-835bf22b-xterm.module.json")
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"}},
@@ -277,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)
}
@@ -323,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),
@@ -344,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)
}
@@ -355,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)
@@ -373,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 {
@@ -415,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
@@ -457,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)
}
}
@@ -548,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)
@@ -576,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)
@@ -601,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()}
}
@@ -656,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)
}
@@ -759,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"}}
}
@@ -786,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"},
} {
@@ -828,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 {
@@ -867,3 +879,34 @@ func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates {
}
return out
}
// ADR 0601 §4: the new node agent meets the tool runner's older manifest (no tool-runner) while the node runs its runtime
// process, and the provisioner's older manifest (no core-credentials) while its own secret runs as the vault's box: each is
// 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 := []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"}`)}},
}
input := func(runnerSays bool) compose.Input {
return compose.Input{Nodes: []compose.Node{{Name: "a", Assigned: []string{"app", "prov-y", "runner-x"}}},
Catalogue: map[string]catalogue.Manifest{"runner-x": {Module: "runner-x", ToolRunner: runnerSays}, "prov-y": {Module: "prov-y"},
"app": {Module: "app"}}}
}
in := input(false)
said := keepWhatRuns(&in, "a", running)
if !in.Catalogue["runner-x"].ToolRunner || !in.Catalogue["prov-y"].CoreCredentials || in.Catalogue["app"].ToolRunner ||
in.Catalogue["app"].CoreCredentials || len(said) != 2 || said[0].Module != "prov-y" || said[1].Module != "runner-x" {
t.Errorf("what runs was not kept: %+v, said %q", in.Catalogue, said)
}
in = input(true)
if said := keepWhatRuns(&in, "a", running[:1]); len(said) != 0 {
t.Errorf("a manifest that says it is the tool runner was marked again: %q", said)
}
in = input(false)
if said := keepWhatRuns(&in, "a", nil); len(said) != 0 || in.Catalogue["runner-x"].ToolRunner {
t.Errorf("nothing running marked %q", said)
}
}
@@ -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
}
@@ -2,13 +2,22 @@ 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"
"testing"
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"
)
@@ -61,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})
@@ -103,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)) {
@@ -157,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 })
@@ -204,6 +213,92 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
if fetched-archives != 1 {
t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives)
}
// Issue 774 (ace, 2026-11-04 01:29): the provisioner client held as its stub (its build not done, so the node keeps
// running what it runs) keeps the account the node's own composition names, and restic stays handed to it with its
// plan; without it every filled write was refused and no module was handed.
if b, err = Provisioned(); err != nil {
t.Fatal(err)
}
fps, ownKey := FPs(), ""
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 []engcomp.Composed
for _, c := range before {
running = append(running, c)
}
for _, row := range []struct {
own, placed bool // the account read from the running own composition, else the client's running process; the client placed
userless bool // the client placed and not held, its process naming no user: it composes no account
// filledHeld holds every module with a filled file (restic, its build not done) and not the client: the node still
// names the account (issue 774, ace 2026-11-04: its build modules and ssh-client held, no filled file, no account)
filledHeld bool
}{{true, true, false, false}, {false, true, false, false}, {true, false, false, false}, {false, false, false, false},
{true, true, true, false}, {true, true, false, true}} {
own := row.own
runs := running
if !own { // what runs names no account of its own: the client's running process says it
runs = nil
for _, c := range running {
if c.Module == "" {
c.Own.Provisioner = ""
}
runs = append(runs, c)
}
}
bb, err := Provisioned()
if err != nil {
t.Fatal(err)
}
want := account
if row.userless {
// placed and not held, composing no account of its own: no stale account is kept from what runs
mod := bb.Module["mesh-provisioner-client"]
var m map[string]any
if err := json.Unmarshal(bb.Manifests[mod.Manifest], &m); err != nil {
t.Fatal(err)
}
for _, r := range m["resources"].([]any) {
delete(r.(map[string]any), "user")
}
raw, _ := json.Marshal(m)
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"]] = &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"]] = &builds.BuilderBuild{State: builds.BuildBuilding}
} else {
// 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)
if _, isHeld := held["mesh-provisioner-client"]; err != nil || isHeld != (row.placed && !row.userless && !row.filledHeld) {
t.Fatalf("%+v: held %v: %v", row, held, err)
}
if got := after[ownKey].Own.Provisioner; got != want {
t.Errorf("%+v: the node's own names %q, want %q", row, got, want)
}
if _, resticHeld := held["restic"]; row.filledHeld && !resticHeld {
t.Errorf("%+v: restic held %v", row, resticHeld)
}
if row.placed && !row.userless && !row.filledHeld && !reflect.DeepEqual(after[resticKey].Filled, before[resticKey].Filled) {
t.Errorf("%+v: restic's filled files %v, want %v", row, after[resticKey].Filled, before[resticKey].Filled)
}
}
}
// statesOf is the bus b as a module's read states hold it.
@@ -219,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())) }
+40 -37
View File
@@ -5,30 +5,33 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"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
}
@@ -49,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
}
@@ -100,12 +103,12 @@ func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) {
{"lamp", nil, true},
{"lamp", errors.New("other"), true},
{"lamp", errors.New("lamp refuses"), false},
{catalogue.RuntimeModule, nil, true},
{catalogue.RuntimeModule, errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false},
{catalogue.RuntimeModule, fmt.Errorf("compose: %w", &resolution.NoRuntimeToRunError{Held: true}), true},
{catalogue.RuntimeModule, &resolution.NoRuntimeToRunError{}, false},
{"node-tools", nil, true},
{"node-tools", errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false},
{"node-tools", fmt.Errorf("compose: %w", &resolution.NoRuntimeToRunError{Held: true}), true},
{"node-tools", &resolution.NoRuntimeToRunError{}, false},
} {
if got := blames(row.module, row.again, err); got != row.blamed {
if got := blames(row.module == "node-tools", row.again, err); got != row.blamed {
t.Errorf("%s with %v: blamed %v, want %v", row.module, row.again, got, row.blamed)
}
}
@@ -117,13 +120,13 @@ func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) {
// another build than the trunk's is composed from the build its want names.
func aRunningModuleWaitsForItsBuild(t *testing.T) {
dg := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
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: catalogue.RuntimeModule, 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") + `"}`)}}}
@@ -142,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)
@@ -157,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)
}
@@ -207,7 +210,7 @@ func aHeldModulesFirstReasonLeads(t *testing.T) {
// secrets: the node composes every other module, and the held one keeps the words it was first held for.
func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) {
dg := "sha256:" + strings.Repeat("1", 64)
tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
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"}]}}`
own := func(name string) string {
return `{"module":"` + name + `","version":"1","runs-as":"` + name + `","secrets-owner":"` + name + `","tools":["status"],` +
@@ -216,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
@@ -252,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)
@@ -268,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 {
@@ -285,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]
+106
View File
@@ -0,0 +1,106 @@
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"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/resolution"
)
// **What runs is kept while a manifest does not yet say what shared code no longer infers** (novox/hq ADR 0601 §4). mesh-sdk
// knows the tool runner and the modules whose own secrets are the core's credentials only by what their manifests say
// (`tool-runner`, `core-credentials`), no longer by their names. A module placed on the composing node whose manifest (an
// older build's) does not say so yet, while what the node runs of it shows it (the tool runner's process, `<module>.runtime`;
// an own secret carried as the vault's box, the file's `vault-sealed`), is composed as what runs shows: its tool runner
// process and its sealed secrets alike are kept, never composed away, and the node says so until the manifest says it.
// Known by what runs, never by a module's name.
// 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 []engcomp.Composed) []KeptAsRuns {
placed := assignedOn(*in, node)
if len(placed) == 0 || len(running) == 0 {
return nil
}
runner, sealed := "", map[string]bool{}
for _, run := range running {
for _, raw := range run.Resources {
var r struct {
ID string `json:"id"`
Type string `json:"type"`
}
if json.Unmarshal(raw, &r) != nil || r.ID == "" {
continue
}
owner := run.Module
if owner == "" {
owner, _ = declaration.ModuleGuess(r.ID)
}
if owner == "" || !slices.Contains(placed, owner) {
continue
}
if r.Type == "process" && r.ID == owner+"."+resolution.RuntimeProcessID() {
runner = owner
}
if r.Type == "file" {
var fields map[string]json.RawMessage
if json.Unmarshal(raw, &fields) == nil {
if _, ok := fields[catalogue.VaultSealedField]; ok {
sealed[owner] = true
}
}
}
}
}
var said []KeptAsRuns
saysRunner := false
for _, m := range placed {
if man, ok := manifestOn(*in, node, m); ok && catalogue.IsToolRunner(man) {
saysRunner = true
}
}
mark := func(module string, set func(*catalogue.Manifest), words string) {
k := KeptAsRuns{Module: module, Words: words}
for i := range in.Nodes {
if in.Nodes[i].Name != node {
continue
}
if man, ok := in.Nodes[i].Kept[module]; ok {
set(&man)
in.Nodes[i].Kept[module] = man
said = append(said, k)
return
}
}
if man, ok := in.Catalogue[module]; ok {
set(&man)
in.Catalogue[module] = man
said = append(said, k)
}
}
if runner != "" && !saysRunner {
mark(runner, func(m *catalogue.Manifest) { m.ToolRunner = true }, fmt.Sprintf("%s runs the tool runner's process here and its "+
"manifest does not say it is the tool runner (tool-runner): it is composed as the tool runner it runs as, until its manifest says so", runner))
}
for _, m := range slices.Sorted(maps.Keys(sealed)) {
if man, ok := manifestOn(*in, node, m); ok && !catalogue.CarriesCoreCredentials(man) {
mark(m, func(m *catalogue.Manifest) { m.CoreCredentials = true }, fmt.Sprintf("%s carries its own secrets sealed by the vault here "+
"and its manifest does not say they are the core's credentials (core-credentials): they stay sealed, until its manifest says so", m))
}
}
sort.SliceStable(said, func(i, j int) bool { return said[i].Module < said[j].Module })
return said
}
// KeptAsRuns is one module composed as what runs shows, and why, in words (ADR 0601 §4): its have carries the words.
type KeptAsRuns struct {
Module, Words string
}
@@ -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
}
+7 -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.
@@ -66,6 +66,7 @@ func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newe
sc.into.Err = err
continue
}
keepWhatRuns(&in, sc.scope.Node, sc.scope.Running) // what runs is kept, as Compose keeps it (ADR 0601 §4)
sc.into.Plans, sc.into.Err = plansHeldOf(in, s.Node, held)
mark("plans at " + sc.name)
}
@@ -74,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
@@ -100,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.
+34 -5
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
@@ -73,6 +73,13 @@ type Input struct {
// issue 1126, C6). Used only while OperatorExport is nil.
KeptRuns map[string]map[string]map[string]any
// ComposedHow is how the node-engine that read this input composes its node from it (frombus.Compose): the builds
// it composes each module at, the modules it holds and what runs of each held one. A node-engine answers it with the
// input it sends a check's sandbox (composer.SentInput), so the sandbox composes the node through the same path, a
// module folded in the node's own composition kept as it runs rather than resolved from its manifest (issue 774, the
// check of the bus want: novox's nats). Nil from every other reader and from a node-engine before it.
ComposedHow *ComposedHow
// Issued is the credentials made for the consumers of each provider (inv.SecretsFrom): one row for each pair credential
// that exists, by the provider node it is for. The grants a provider must create are read from these, so a credential not
// made yet has no grant until the send that makes it. An assigner fills it from the facts snapshot's Grants of each
@@ -152,8 +159,9 @@ type Node struct {
// Assigned is the modules assigned to it, in the order the controller reads them (inv.Assigned).
Assigned []string
// Handed is, by module, whether the vault judged it handed to the provisioner when it answered this node's own
// secrets (catalogue.AssignmentHands of the plan in its assignment): the node's own composition hands exactly those
// (catalogue.Rendering.Assigned, issue 774). Nil where the vault said nothing of it, as for the controller.
// secrets (catalogue.AssignmentHands of the plan in its assignment). Never the rule (resolution.Handing, issue 1966):
// read only to say where the vault and this composition disagree (resolution.VaultDisagrees). Nil where the vault said
// nothing of it, as for the controller.
Handed map[string]bool
// Capabilities are the ones present on it (inv.ProfileOf).
Capabilities map[string]bool
@@ -216,7 +224,12 @@ 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
// (provisionerAccount; issue 774: ace, 2026-11-04 01:29, every filled write refused and postgres's consumers unhanded).
RunningProvisioner string
// Held is the modules refused alone on this node, each composed as what runs of it (Carried, in Kept; its Stub when
// nothing of it runs) so the rest is composed (novox/hq issue 1126): none of a held module's own composition is in what
// Compositions answers, so the node keeps what it last applied of it; one whose resources the node's own runs
@@ -231,7 +244,23 @@ type Node struct {
RunsTools map[string][]catalogue.Bundle
}
// Place is where a node is, as its placement says (inv.Overlay).
// ComposedHow is how a node-engine composes its node from an Input (Input.ComposedHow).
type ComposedHow struct {
// Node is the node composed.
Node string
// Builds is the build each module is composed at, by module: a module the node runs folded in its own composition
// at a build of the old form has none, and is composed there as it runs (Node.InOwn, Node.RunsOwn).
Builds map[string]string
// 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 []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 assignment says (inv.Overlay).
type Place struct {
Key string
Endpoint string
+97
View File
@@ -0,0 +1,97 @@
package compose
import (
"encoding/json"
"sort"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// What an input says of its held modules on the wire (issue 774, the group check of feat/l3-final 278aa5f0ccbe). A held
// module stands in the input as its stub (Stub), and the stub's mark (catalogue.Manifest.Stubbed) is no field of a
// manifest's JSON: an input a node-engine sent the check's sandbox lost it, so the sandbox read node-tools, held by its
// manifest's verdict, as a tool runner with no bundle and refused every module of ace, g14 and shanks for "its build
// produced 0 bundle(s)" instead of naming the verdict. So the input carries the mark itself, by module, for its catalogue
// and for each node's kept manifests, and a reader marks each again. An input from a node-engine before the mark (no
// Held) is read by its contract: a module Refused names stands in Catalogue as its stub.
// heldMarks are the modules an input holds as their stubs: those of its catalogue, and those each node keeps, by node.
type heldMarks struct {
Catalogue []string `json:",omitempty"`
Kept map[string][]string `json:",omitempty"`
}
// inputFields is Input without its JSON methods.
type inputFields Input
// inputWire is an input as it crosses the bus: its fields, and the modules it holds as their stubs.
type inputWire struct {
inputFields
Held *heldMarks `json:",omitempty"`
}
// MarshalJSON writes the input with the modules it holds as their stubs named beside it (heldMarks).
func (in Input) MarshalJSON() ([]byte, error) {
w := inputWire{inputFields: inputFields(in)}
marks := heldMarks{Catalogue: stubbedOf(in.Catalogue)}
for _, n := range in.Nodes {
if kept := stubbedOf(n.Kept); len(kept) > 0 {
if marks.Kept == nil {
marks.Kept = map[string][]string{}
}
marks.Kept[n.Name] = kept
}
}
if len(marks.Catalogue) > 0 || len(marks.Kept) > 0 {
w.Held = &marks
}
return json.Marshal(w)
}
// UnmarshalJSON reads an input and marks again each module it holds as its stub: those it names held, and, by the
// input's contract, every module Refused names that its catalogue holds.
func (in *Input) UnmarshalJSON(raw []byte) error {
var w inputWire
if err := json.Unmarshal(raw, &w); err != nil {
return err
}
*in = Input(w.inputFields)
held := map[string]bool{}
for m := range in.Refused {
held[m] = true
}
if w.Held != nil {
for _, m := range w.Held.Catalogue {
held[m] = true
}
}
for m := range held {
if man, ok := in.Catalogue[m]; ok {
man.Stubbed = true
in.Catalogue[m] = man
}
}
if w.Held != nil {
for i := range in.Nodes {
for _, m := range w.Held.Kept[in.Nodes[i].Name] {
if man, ok := in.Nodes[i].Kept[m]; ok {
man.Stubbed = true
in.Nodes[i].Kept[m] = man
}
}
}
}
return nil
}
// stubbedOf is each module of manifests held as its stub, sorted.
func stubbedOf(manifests map[string]catalogue.Manifest) []string {
var out []string
for name, m := range manifests {
if m.Stubbed {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
+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) {
+6 -3
View File
@@ -115,6 +115,11 @@ func (d Declared) Body() ([]byte, error) {
}
if len(d.Filled) > 0 {
envelope["filled_files"] = d.Filled
}
// The provisioner client's account is the node's, named whenever a client runs here, filled files or not: a node
// whose every filled-file module is held this pass names none of its files, and still keeps them, so the client's
// writes of them must be accepted (issue 774, ace 2026-11-04: its build modules and ssh-client held, no account).
if d.Provisioner != "" {
envelope["provisioner_account"] = d.Provisioner
}
// An empty declaration is deliberate: the node owns nothing the mesh put there (novox/hq issue 127), and the host refuses an
@@ -202,9 +207,7 @@ func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) {
Filled: composed.Filled, LocalNetwork: resolution.LocalNetworkPorts(rules, with.LocalNetworks),
CoreBoxesWanted: rendered.CoreBoxesWanted, Said: rendered.Said,
Sequence: node.Sequence, Epoch: node.Epoch, Generation: node.Generation}
if len(composed.Filled) > 0 {
out.Provisioner = with.Provisioner
}
out.Provisioner = with.Provisioner
// A handed module only pulled in by another keeps what requires it, for its value of its own in the assignments mirror (issue 765).
for module := range composed.Plans {
if by := resolution.RequiredBy(plan, module); by != "" {
+2 -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"
)
func resourceIDs(d Declared) []string {
@@ -168,7 +168,7 @@ func TestWhatASendWouldMakeIsNamedAsForeseenAndNeverInvented(t *testing.T) {
in.Issued[0].ForProvider = ""
in.Catalogue["assistant"] = anAssistant()
in.Catalogue["web"] = aWebModule()
in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1",
in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", CoreCredentials: true,
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}}
anchor := nodeOf(t, &in, "anchor")
anchor.Assigned = append(anchor.Assigned, "assistant", "web", "nats")
+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
+113 -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
@@ -74,6 +75,12 @@ func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) {
// not sent the field they would refuse.
r.Hands = takesHanded(node)
r.Provisioner = provisionerAccount(plan)
if r.Provisioner == "" && provisionerHeld(plan) {
// The provisioner's client is placed here and held this pass (its stub, or carried as it runs, composes no process
// of its own): it keeps running as it runs, so the account it runs as stays named, and every module stays handed
// to it. A client not held that composes no account, or none placed, names none: no stale account is kept.
r.Provisioner = node.RunningProvisioner
}
r.Assigned = node.Handed
r.ReadsHealth = node.Stated && node.Contract >= ReadinessContract
r.JudgesRoot = node.Stated && node.Contract >= RootContract
@@ -161,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
@@ -190,6 +211,21 @@ func provisionerAccount(plan catalogue.Resolution) string {
return ""
}
// provisionerHeld says the plan places a module claiming the provisioner client's seat on the machine that this pass
// holds (its stand-in: Stub, or Carried as it runs), which composes none of the client's process.
func provisionerHeld(plan catalogue.Resolution) bool {
for _, m := range plan.Modules {
if m.Stubbed && m.ClaimsSeat(NodeProvisionerSeat) {
return true
}
}
return false
}
// RunsTheProvisionerClient says a module runs its machine's provisioner client (runsTheProvisionerClient), for a reader
// of what runs.
func RunsTheProvisionerClient(m catalogue.Manifest) bool { return runsTheProvisionerClient(m) }
// runsTheProvisionerClient says a module runs its machine's provisioner client: it claims the seat and declares the own
// secret that account is delivered in.
func runsTheProvisionerClient(m catalogue.Manifest) bool {
@@ -291,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)
@@ -314,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 {
@@ -102,7 +102,13 @@ func plainly(value any) string {
// **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR
// 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses
// that already; this is the same rule where the file is made, for a manifest registered before it.
func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error {
//
// **A key the binding does not say is refused with why the node-engine fills the file** (issue 1966): filledHere
// (notHandedWhy). What a provider's helper answers reaches only a handed module's files, which the provisioner's client
// fills; a file the client should fill (handedFile) refused here is a handed module not handed, and says so, never only
// the keys it lacks.
func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool,
handedFile bool, filledHere string) error {
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
@@ -135,10 +141,16 @@ func boundInto(resource map[string]any, known map[string]map[string]string, modu
return token
}
value, said := values[key]
if !said && handedFile {
refused = fmt.Errorf(
"handed module not handed: %s asks its %s binding for %q, which only the provisioner's client fills, "+
"and %s (issue 1966)", module, provision, key, filledHere)
return token
}
if !said {
refused = fmt.Errorf(
"%s asks its %s binding for %q, and what answers it says %s",
module, provision, key, orNothing(namesOfKeys(values)))
"%s asks its %s binding for %q, and what answers it says %s (%s)",
module, provision, key, orNothing(namesOfKeys(values)), filledHere)
return token
}
return value
@@ -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"})},
}
}
@@ -2,14 +2,14 @@ package resolution
import (
"encoding/json"
"slices"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// CarriesVaultSealed says whether module's own secrets are the core's credentials.
func CarriesVaultSealed(module string) bool {
return slices.Contains(mcat.CoreCredentialModules, module)
// CarriesVaultSealed says whether a module's own secrets are the core's credentials, as its manifest says
// (core-credentials): no list of modules is kept here.
func CarriesVaultSealed(m mcat.Manifest) bool {
return mcat.CarriesCoreCredentials(m)
}
// vaultSealedOf is the vault's box for one of module's own secrets, if the rendering was given one.
@@ -1,24 +1,24 @@
package resolution
import (
"reflect"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The core's own credentials are what a module's manifest says (core-credentials, issue 774): no module is on a list.
func TestTheCoresOwnCredentialsAreTheBusTheProvisionerAndItsClient(t *testing.T) {
if want := []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}; !reflect.DeepEqual(mcat.CoreCredentialModules, want) {
t.Fatalf("the core's modules are %v, want %v (mesh-vault's CoreModules is the same list)", mcat.CoreCredentialModules, want)
}
for _, m := range []string{"nats", "mesh-provisioner", "mesh-provisioner-client"} {
if !CarriesVaultSealed(m) {
t.Errorf("%s is not on the list", m)
for raw, core := range map[string]bool{
`{"module":"nats","version":"1","core-credentials":true}`: true,
`{"module":"nats","version":"1"}`: false,
`{"module":"postgres","version":"1"}`: false,
} {
m, err := mcat.Decode([]byte(raw))
if err != nil {
t.Fatal(err)
}
}
for _, m := range []string{"postgres", "mesh-vault", "mesh-controller", ""} {
if CarriesVaultSealed(m) {
t.Errorf("%s is on the list", m)
if CarriesVaultSealed(m) != core {
t.Errorf("%s: carries the core's credentials %v", raw, !core)
}
}
}
+10 -4
View File
@@ -415,6 +415,11 @@ func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string]
// family's member (ADR 0283), so the module reads its absence as not given.
continue
}
if why := VaultDisagrees(with, m); sealed == "" && why != "" {
// The vault left it out of what it answered this node, its plan handing it: never a secret to make.
return nil, fmt.Errorf("handed module not handed: %s needs its own secret %q, which the vault answers "+
"its provisioner's client alone, %s (issue 1966)", m.Module, name, why)
}
if sealed == "" {
// Declared and not made. Refused rather than skipped: a module whose own
// credential is silently absent starts, fails to authenticate, and the reason is
@@ -912,7 +917,8 @@ func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string]
// **Substituted here, not on the machine.** A bound value is not secret — the mesh
// holds it in the clear — so there is nothing for the host to be the only witness of,
// and sending it already filled in means the host learns no new field.
if err := boundInto(copied, known, m.Module, absent); err != nil {
filledHere, handedFile := notHandedWhy(with, m, unsettled)
if err := boundInto(copied, known, m.Module, absent, handedFile, filledHere); err != nil {
return nil, err
}
// And what the module could not have written: the machine it turned out to be
@@ -1046,7 +1052,7 @@ func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string]
return nil, err
}
if process != nil { // none for a held runtime's stand-in: what runs of it is carried
owner[fmt.Sprint(process["id"])] = mcat.RuntimeModule
owner[fmt.Sprint(process["id"])] = fmt.Sprint(process["name"]) // the tool runner's module (resolutionRuntimeProcess)
out = append(out, process)
}
// And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8).
@@ -1308,7 +1314,7 @@ func resolutionOwnSecretOwner(r mcat.Resolution, m mcat.Manifest, name string) s
if o := m.OwnSecrets[name].Owner; o != "" {
return o
}
if r.Account != "" && (m.Module == mcat.RuntimeModule || ToolsBusSecret(m, name)) {
if r.Account != "" && (mcat.IsToolRunner(m) || ToolsBusSecret(m, name)) {
return r.Account
}
return m.SecretsOwner
@@ -1514,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"}
}

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