From e419d0182fdf9e6d86c066927a5aa3ba35141543 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 23 Oct 2026 15:55:15 +0200 Subject: [PATCH] Delete the builder, the build asks, the build queue and the merge gate: the builder's keys are the only way a module is built (issues 1851, 1299, 1318; C2b-C5, C10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controller asks no build of anybody (ADR 0501 §5, ADR 0525 clause 6, ADR 0503 §3). Every module of a walk is taken from the builder's done key; a key not done yet waits, named in the plan, and never fails it. - C2b/C2c: internal/builder and cmd/mesh-controller's asks go (askABuild, onlyTheBuildersOwn, buildOneAsked, takeIn from an outcome, buildAgentsOwn, the builder's say and its fallback, the daemon hearing an outcome). The server follows the retired seats' built subjects only to let go of them, because the installer's carried user list names them. - C2d: replay, queue, cancel, clear, kill, pause, resume, delivery-check, check-here and merge-gate are refused by name. - C3: the check queues (CHECKS_READY, CHECKS_OTHER) and their workers, the checks-running and plans-building buckets and their grants, the build queue and its hold (plans_building.go, queue.go, link/holds.go, builds_nats.go, checks_nats.go). - C4: merge_gate*.go, the pull request's repo-check path, the Checked event and its layers; a pull request asks the controller nothing but to tell mesh-config what its head declares. - C5: the builds bucket is written only by key_takein. - C10 (help and refusals): the terminal's help lists only what is served. Integration tests of the plan's tiers, sends, stops and retries now run on a stand-in builder with a done key for every module (walk_keys_scaffold_integration_test.go); the tests of the asks, the queue, the merge gate and the replays are deleted. --- .../addresses_integration_test.go | 4 - cmd/mesh-controller/addresses_test.go | 59 - .../ask_bus_look_integration_test.go | 131 - cmd/mesh-controller/ask_bus_moment_test.go | 151 -- cmd/mesh-controller/ask_bus_wait.go | 35 - .../asker_retry_integration_test.go | 2 +- cmd/mesh-controller/away_integration_test.go | 20 +- cmd/mesh-controller/batches_test.go | 2 +- cmd/mesh-controller/build.go | 656 ----- cmd/mesh-controller/build_seat_test.go | 43 - cmd/mesh-controller/build_source.go | 42 - .../build_source_integration_test.go | 21 - cmd/mesh-controller/build_test.go | 11 - .../builder_on_the_bus_test.go | 30 - .../builder_says_default_test.go | 9 - cmd/mesh-controller/builder_says_test.go | 243 -- cmd/mesh-controller/builders_own_test.go | 112 - cmd/mesh-controller/chain_integration_test.go | 21 +- .../chain_no_table_integration_test.go | 10 +- cmd/mesh-controller/changeplan.go | 29 - cmd/mesh-controller/changeplan_test.go | 3 - cmd/mesh-controller/check_builds.go | 63 - .../check_builds_integration_test.go | 91 - cmd/mesh-controller/check_builds_test.go | 134 - cmd/mesh-controller/check_here.go | 203 -- cmd/mesh-controller/check_pulls_test.go | 44 - cmd/mesh-controller/checks.go | 576 +---- .../checks_integration_test.go | 56 - .../checks_stop_integration_test.go | 109 - cmd/mesh-controller/checks_test.go | 188 +- cmd/mesh-controller/composed_verdict_test.go | 61 - cmd/mesh-controller/delivery.go | 63 +- .../delivery_check_answer_test.go | 85 - cmd/mesh-controller/delivery_conditions.go | 2 +- cmd/mesh-controller/delivery_test.go | 2 - cmd/mesh-controller/doctor.go | 2 +- .../dry_run_integration_test.go | 20 - cmd/mesh-controller/dry_run_test.go | 29 - cmd/mesh-controller/durations.go | 20 - cmd/mesh-controller/forge_address_test.go | 4 +- cmd/mesh-controller/gate_integration_test.go | 8 +- cmd/mesh-controller/grants_step_test.go | 34 - cmd/mesh-controller/group_check_test.go | 12 - cmd/mesh-controller/issue529_test.go | 8 +- .../legacy_takein_integration_test.go | 317 +++ .../loop_own_consumers_test.go | 27 +- cmd/mesh-controller/main.go | 136 +- cmd/mesh-controller/members_file_test.go | 16 +- cmd/mesh-controller/merge_gate.go | 1580 ------------ .../merge_gate_changed_test.go | 31 - .../merge_gate_handing_integration_test.go | 68 - .../merge_gate_handing_test.go | 33 - cmd/mesh-controller/merge_gate_held.go | 135 - cmd/mesh-controller/merge_gate_held_test.go | 85 - .../merge_gate_integration_test.go | 582 ----- cmd/mesh-controller/merge_gate_test.go | 167 -- .../new_module_approved_test.go | 4 +- cmd/mesh-controller/new_modules.go | 38 +- .../outcome_off_the_loop_test.go | 198 -- cmd/mesh-controller/pending.go | 45 - .../pending_integration_test.go | 29 - cmd/mesh-controller/plain_words.go | 2 +- cmd/mesh-controller/plan_clock_test.go | 10 +- cmd/mesh-controller/plan_keys.go | 78 +- cmd/mesh-controller/plan_keys_test.go | 15 - cmd/mesh-controller/plan_looks.go | 16 - cmd/mesh-controller/plan_retry.go | 119 +- .../plan_retry_stopped_test.go | 21 +- cmd/mesh-controller/planner_rules_test.go | 42 - cmd/mesh-controller/plans_building.go | 238 -- cmd/mesh-controller/plans_building_test.go | 176 -- cmd/mesh-controller/plans_follow.go | 6 +- cmd/mesh-controller/plans_follow_test.go | 2 +- cmd/mesh-controller/predefined.go | 65 - .../predefined_integration_test.go | 22 - cmd/mesh-controller/push.go | 5 - cmd/mesh-controller/queue.go | 673 ----- cmd/mesh-controller/queue_answer_test.go | 157 -- cmd/mesh-controller/queue_held_test.go | 77 - cmd/mesh-controller/queue_integration_test.go | 431 ---- cmd/mesh-controller/queue_test.go | 234 -- cmd/mesh-controller/readable.go | 2 +- cmd/mesh-controller/references.go | 47 - cmd/mesh-controller/release_plan.go | 433 +--- cmd/mesh-controller/replay867_test.go | 330 --- cmd/mesh-controller/repository_words.go | 22 + cmd/mesh-controller/restarted_test.go | 56 - cmd/mesh-controller/rollout_events_test.go | 28 +- cmd/mesh-controller/same_program.go | 2 +- cmd/mesh-controller/seatverbs.go | 65 +- cmd/mesh-controller/seatverbs_test.go | 33 +- cmd/mesh-controller/signals.go | 47 +- cmd/mesh-controller/signals_test.go | 10 - .../silent_mid_tier_integration_test.go | 20 +- cmd/mesh-controller/source.go | 78 - cmd/mesh-controller/source_test.go | 63 - .../states_announced_integration_test.go | 2 +- cmd/mesh-controller/status.go | 15 +- cmd/mesh-controller/switch_list.go | 2 +- cmd/mesh-controller/take_refused.go | 48 - cmd/mesh-controller/take_refused_test.go | 119 - .../template_integration_test.go | 148 -- cmd/mesh-controller/template_test.go | 14 - .../tier_send_integration_test.go | 7 +- cmd/mesh-controller/try.go | 7 - cmd/mesh-controller/try_integration_test.go | 30 - cmd/mesh-controller/usage_test.go | 3 +- .../walk_keys_scaffold_integration_test.go | 137 ++ .../walk_said_integration_test.go | 39 +- .../walk_wait_rebuild_754_test.go | 33 - cmd/mesh-controller/watchdogs.go | 61 +- go.mod | 1 - go.sum | 2 - internal/artifacts/scratch.go | 91 - internal/beside/beside.go | 2 +- internal/broker/announced.go | 4 +- internal/broker/announced_test.go | 2 +- internal/broker/checks.go | 127 - internal/broker/checks_running_mirror_test.go | 60 - internal/broker/checks_test.go | 76 - internal/broker/controller_buckets.go | 80 +- internal/broker/nats.go | 28 - internal/broker/no_build_queue_test.go | 34 + internal/broker/plans_building_live_test.go | 134 - internal/broker/plans_building_mirror_test.go | 30 - .../broker/stream_names_agreement_test.go | 26 +- internal/broker/streams.go | 6 +- internal/broker/streams_test.go | 3 - internal/broker/testdata/composed.conf | 2 +- internal/broker/writers.go | 21 - internal/broker/writers_test.go | 4 - internal/builder/archive_symlink_test.go | 149 -- internal/builder/binary_name_test.go | 44 - internal/builder/build_source_test.go | 227 -- internal/builder/builder.go | 2161 ----------------- internal/builder/builder_integration_test.go | 362 --- internal/builder/builder_test.go | 199 -- internal/builder/bundle_integration_test.go | 166 -- internal/builder/bundle_test.go | 80 - internal/builder/check.go | 2037 ---------------- internal/builder/check_base.go | 134 - internal/builder/check_base_test.go | 212 -- internal/builder/check_builds.go | 355 --- internal/builder/check_changed_test.go | 130 - .../check_credential_integration_test.go | 180 -- internal/builder/check_credential_test.go | 82 - internal/builder/check_held_test.go | 33 - internal/builder/check_integration_test.go | 429 ---- internal/builder/check_merge.go | 115 - internal/builder/check_merge_test.go | 264 -- internal/builder/check_pin_test.go | 45 - internal/builder/check_test.go | 227 -- internal/builder/clone_lookup.go | 114 - internal/builder/clone_lookup_test.go | 162 -- internal/builder/context_test.go | 26 - internal/builder/dependencies.go | 147 -- internal/builder/dependencies_test.go | 133 - internal/builder/disk_1834_test.go | 216 -- internal/builder/failures.go | 285 --- internal/builder/failures_integration_test.go | 152 -- internal/builder/failures_test.go | 46 - internal/builder/floor.go | 46 - internal/builder/go_toolchain_test.go | 71 - internal/builder/judge_build_test.go | 584 ----- internal/builder/kill.go | 170 -- internal/builder/kill_integration_test.go | 58 - internal/builder/kill_test.go | 166 -- internal/builder/launchers_test.go | 49 - internal/builder/lend.go | 178 -- internal/builder/lend_integration_test.go | 26 - internal/builder/lend_test.go | 123 - internal/builder/local.go | 61 - internal/builder/mirror.go | 449 ---- .../builder/mirror_shared_integration_test.go | 40 - internal/builder/mirror_shared_test.go | 345 --- internal/builder/mirror_test.go | 266 -- internal/builder/packages.go | 233 -- internal/builder/packages_integration_test.go | 135 - internal/builder/packages_test.go | 107 - internal/builder/predefined_test.go | 108 - internal/builder/redact.go | 191 -- internal/builder/registry.go | 216 -- internal/builder/registry_test.go | 283 --- .../registry_unbound_integration_test.go | 93 - internal/builder/registry_unbound_test.go | 38 - internal/builder/registry_wait.go | 153 -- internal/builder/registry_wait_test.go | 219 -- internal/builder/replay310_test.go | 54 - internal/builder/replay341_test.go | 141 -- internal/builder/replay363_test.go | 154 -- internal/builder/replay713_test.go | 127 - internal/builder/replay716_test.go | 160 -- .../builder/reproducible_integration_test.go | 91 - internal/builder/reproducible_test.go | 65 - internal/builder/reuse.go | 630 ----- internal/builder/reuse_integration_test.go | 336 --- internal/builder/reuse_test.go | 478 ---- internal/builder/same_program.go | 183 -- internal/builder/same_program_test.go | 126 - internal/builder/siblings_test.go | 337 --- internal/builder/source.go | 327 --- internal/builder/source_integration_test.go | 44 - internal/builder/source_sdk_test.go | 125 - internal/builder/source_test.go | 41 - internal/builder/standing_on_test.go | 225 -- internal/builder/system_stamp_test.go | 76 - internal/builder/template.go | 95 - internal/builder/template_integration_test.go | 103 - internal/builder/template_test.go | 89 - internal/builder/testdata/issue460/CAPTURED | 7 - .../testdata/issue460/a-build-error.txt | 5 - .../builder/testdata/issue460/a-data-race.txt | 31 - .../issue460/a-long-run-failing-early.txt | 609 ----- .../builder/testdata/issue460/a-panic.txt | 18 - .../issue460/a-race-run-of-every-package.txt | 660 ----- internal/builder/testdata/release.tar.xz | Bin 252 -> 0 bytes internal/builder/toolchain.go | 247 -- internal/builder/toolchain_test.go | 89 - internal/builder/upstream_file.go | 306 --- .../builder/upstream_file_integration_test.go | 180 -- internal/builder/upstream_file_test.go | 306 --- internal/builder/warm_go_cache_test.go | 80 - internal/builder/xz_framing.go | 290 --- .../builder/xz_framing_integration_test.go | 40 - internal/builder/xz_framing_test.go | 178 -- internal/builderkeys/builderkeys.go | 97 - internal/builderkeys/says_integration_test.go | 54 - internal/builderkeys/says_test.go | 24 - internal/link/admit_test.go | 28 - internal/link/advisories.go | 4 +- internal/link/build.go | 479 +--- internal/link/build_seat_test.go | 32 - internal/link/builds.go | 245 -- internal/link/builds_current_test.go | 36 - internal/link/builds_nats.go | 751 ------ internal/link/builds_nats_test.go | 505 ---- .../link/check_builds_integration_test.go | 67 - internal/link/check_builds_test.go | 29 - internal/link/checks_nats.go | 414 ---- internal/link/checks_nats_integration_test.go | 622 ----- internal/link/checks_nats_test.go | 161 -- internal/link/checks_running_started_test.go | 61 - internal/link/checks_wait_integration_test.go | 479 ---- internal/link/contracts.go | 5 +- internal/link/contracts_test.go | 2 +- internal/link/deadletters_ask_test.go | 33 + internal/link/events.go | 78 - internal/link/held_asks_test.go | 43 - internal/link/holds.go | 384 --- internal/link/holds_test.go | 307 --- internal/link/pull_buckets_test.go | 232 -- internal/link/queue.go | 775 ------ internal/link/queue_integration_test.go | 220 -- internal/link/queue_kind.go | 50 - internal/link/queue_test.go | 129 - internal/link/receive_nats.go | 8 +- internal/link/seatask.go | 142 ++ internal/link/serve.go | 68 +- internal/link/store_window_test.go | 37 - internal/link/stray_test.go | 54 - internal/link/try_test.go | 37 - internal/lint/removed_test.go | 52 + .../ownmanifest/foundation_manifests_test.go | 38 - internal/ownmanifest/genesis_registry_test.go | 77 - .../modules/build-agent/module.json.captured | 116 - 265 files changed, 981 insertions(+), 38329 deletions(-) delete mode 100644 cmd/mesh-controller/ask_bus_look_integration_test.go delete mode 100644 cmd/mesh-controller/ask_bus_moment_test.go delete mode 100644 cmd/mesh-controller/build_seat_test.go delete mode 100644 cmd/mesh-controller/builder_says_default_test.go delete mode 100644 cmd/mesh-controller/builder_says_test.go delete mode 100644 cmd/mesh-controller/builders_own_test.go delete mode 100644 cmd/mesh-controller/check_builds.go delete mode 100644 cmd/mesh-controller/check_builds_integration_test.go delete mode 100644 cmd/mesh-controller/check_builds_test.go delete mode 100644 cmd/mesh-controller/check_here.go delete mode 100644 cmd/mesh-controller/check_pulls_test.go delete mode 100644 cmd/mesh-controller/checks_integration_test.go delete mode 100644 cmd/mesh-controller/checks_stop_integration_test.go delete mode 100644 cmd/mesh-controller/composed_verdict_test.go delete mode 100644 cmd/mesh-controller/delivery_check_answer_test.go delete mode 100644 cmd/mesh-controller/dry_run_integration_test.go delete mode 100644 cmd/mesh-controller/dry_run_test.go delete mode 100644 cmd/mesh-controller/group_check_test.go create mode 100644 cmd/mesh-controller/legacy_takein_integration_test.go delete mode 100644 cmd/mesh-controller/merge_gate.go delete mode 100644 cmd/mesh-controller/merge_gate_changed_test.go delete mode 100644 cmd/mesh-controller/merge_gate_handing_integration_test.go delete mode 100644 cmd/mesh-controller/merge_gate_handing_test.go delete mode 100644 cmd/mesh-controller/merge_gate_held.go delete mode 100644 cmd/mesh-controller/merge_gate_held_test.go delete mode 100644 cmd/mesh-controller/merge_gate_integration_test.go delete mode 100644 cmd/mesh-controller/merge_gate_test.go delete mode 100644 cmd/mesh-controller/outcome_off_the_loop_test.go delete mode 100644 cmd/mesh-controller/plans_building.go delete mode 100644 cmd/mesh-controller/plans_building_test.go delete mode 100644 cmd/mesh-controller/queue.go delete mode 100644 cmd/mesh-controller/queue_answer_test.go delete mode 100644 cmd/mesh-controller/queue_held_test.go delete mode 100644 cmd/mesh-controller/queue_integration_test.go delete mode 100644 cmd/mesh-controller/queue_test.go delete mode 100644 cmd/mesh-controller/replay867_test.go create mode 100644 cmd/mesh-controller/repository_words.go delete mode 100644 cmd/mesh-controller/restarted_test.go delete mode 100644 cmd/mesh-controller/take_refused.go delete mode 100644 cmd/mesh-controller/take_refused_test.go delete mode 100644 cmd/mesh-controller/template_integration_test.go create mode 100644 cmd/mesh-controller/walk_keys_scaffold_integration_test.go delete mode 100644 internal/artifacts/scratch.go delete mode 100644 internal/broker/checks.go delete mode 100644 internal/broker/checks_running_mirror_test.go delete mode 100644 internal/broker/checks_test.go create mode 100644 internal/broker/no_build_queue_test.go delete mode 100644 internal/broker/plans_building_live_test.go delete mode 100644 internal/broker/plans_building_mirror_test.go delete mode 100644 internal/builder/archive_symlink_test.go delete mode 100644 internal/builder/binary_name_test.go delete mode 100644 internal/builder/build_source_test.go delete mode 100644 internal/builder/builder.go delete mode 100644 internal/builder/builder_integration_test.go delete mode 100644 internal/builder/builder_test.go delete mode 100644 internal/builder/bundle_integration_test.go delete mode 100644 internal/builder/bundle_test.go delete mode 100644 internal/builder/check.go delete mode 100644 internal/builder/check_base.go delete mode 100644 internal/builder/check_base_test.go delete mode 100644 internal/builder/check_builds.go delete mode 100644 internal/builder/check_changed_test.go delete mode 100644 internal/builder/check_credential_integration_test.go delete mode 100644 internal/builder/check_credential_test.go delete mode 100644 internal/builder/check_held_test.go delete mode 100644 internal/builder/check_integration_test.go delete mode 100644 internal/builder/check_merge.go delete mode 100644 internal/builder/check_merge_test.go delete mode 100644 internal/builder/check_pin_test.go delete mode 100644 internal/builder/check_test.go delete mode 100644 internal/builder/clone_lookup.go delete mode 100644 internal/builder/clone_lookup_test.go delete mode 100644 internal/builder/context_test.go delete mode 100644 internal/builder/dependencies.go delete mode 100644 internal/builder/dependencies_test.go delete mode 100644 internal/builder/disk_1834_test.go delete mode 100644 internal/builder/failures.go delete mode 100644 internal/builder/failures_integration_test.go delete mode 100644 internal/builder/failures_test.go delete mode 100644 internal/builder/floor.go delete mode 100644 internal/builder/go_toolchain_test.go delete mode 100644 internal/builder/judge_build_test.go delete mode 100644 internal/builder/kill.go delete mode 100644 internal/builder/kill_integration_test.go delete mode 100644 internal/builder/kill_test.go delete mode 100644 internal/builder/launchers_test.go delete mode 100644 internal/builder/lend.go delete mode 100644 internal/builder/lend_integration_test.go delete mode 100644 internal/builder/lend_test.go delete mode 100644 internal/builder/local.go delete mode 100644 internal/builder/mirror.go delete mode 100644 internal/builder/mirror_shared_integration_test.go delete mode 100644 internal/builder/mirror_shared_test.go delete mode 100644 internal/builder/mirror_test.go delete mode 100644 internal/builder/packages.go delete mode 100644 internal/builder/packages_integration_test.go delete mode 100644 internal/builder/packages_test.go delete mode 100644 internal/builder/predefined_test.go delete mode 100644 internal/builder/redact.go delete mode 100644 internal/builder/registry.go delete mode 100644 internal/builder/registry_test.go delete mode 100644 internal/builder/registry_unbound_integration_test.go delete mode 100644 internal/builder/registry_unbound_test.go delete mode 100644 internal/builder/registry_wait.go delete mode 100644 internal/builder/registry_wait_test.go delete mode 100644 internal/builder/replay310_test.go delete mode 100644 internal/builder/replay341_test.go delete mode 100644 internal/builder/replay363_test.go delete mode 100644 internal/builder/replay713_test.go delete mode 100644 internal/builder/replay716_test.go delete mode 100644 internal/builder/reproducible_integration_test.go delete mode 100644 internal/builder/reproducible_test.go delete mode 100644 internal/builder/reuse.go delete mode 100644 internal/builder/reuse_integration_test.go delete mode 100644 internal/builder/reuse_test.go delete mode 100644 internal/builder/same_program.go delete mode 100644 internal/builder/same_program_test.go delete mode 100644 internal/builder/siblings_test.go delete mode 100644 internal/builder/source.go delete mode 100644 internal/builder/source_integration_test.go delete mode 100644 internal/builder/source_sdk_test.go delete mode 100644 internal/builder/source_test.go delete mode 100644 internal/builder/standing_on_test.go delete mode 100644 internal/builder/system_stamp_test.go delete mode 100644 internal/builder/template.go delete mode 100644 internal/builder/template_integration_test.go delete mode 100644 internal/builder/template_test.go delete mode 100644 internal/builder/testdata/issue460/CAPTURED delete mode 100644 internal/builder/testdata/issue460/a-build-error.txt delete mode 100644 internal/builder/testdata/issue460/a-data-race.txt delete mode 100644 internal/builder/testdata/issue460/a-long-run-failing-early.txt delete mode 100644 internal/builder/testdata/issue460/a-panic.txt delete mode 100644 internal/builder/testdata/issue460/a-race-run-of-every-package.txt delete mode 100644 internal/builder/testdata/release.tar.xz delete mode 100644 internal/builder/toolchain.go delete mode 100644 internal/builder/toolchain_test.go delete mode 100644 internal/builder/upstream_file.go delete mode 100644 internal/builder/upstream_file_integration_test.go delete mode 100644 internal/builder/upstream_file_test.go delete mode 100644 internal/builder/warm_go_cache_test.go delete mode 100644 internal/builder/xz_framing.go delete mode 100644 internal/builder/xz_framing_integration_test.go delete mode 100644 internal/builder/xz_framing_test.go delete mode 100644 internal/builderkeys/says_integration_test.go delete mode 100644 internal/builderkeys/says_test.go delete mode 100644 internal/link/admit_test.go delete mode 100644 internal/link/build_seat_test.go delete mode 100644 internal/link/builds.go delete mode 100644 internal/link/builds_current_test.go delete mode 100644 internal/link/builds_nats.go delete mode 100644 internal/link/builds_nats_test.go delete mode 100644 internal/link/check_builds_integration_test.go delete mode 100644 internal/link/check_builds_test.go delete mode 100644 internal/link/checks_nats.go delete mode 100644 internal/link/checks_nats_integration_test.go delete mode 100644 internal/link/checks_nats_test.go delete mode 100644 internal/link/checks_running_started_test.go delete mode 100644 internal/link/checks_wait_integration_test.go delete mode 100644 internal/link/held_asks_test.go delete mode 100644 internal/link/holds.go delete mode 100644 internal/link/holds_test.go delete mode 100644 internal/link/pull_buckets_test.go delete mode 100644 internal/link/queue.go delete mode 100644 internal/link/queue_integration_test.go delete mode 100644 internal/link/queue_kind.go delete mode 100644 internal/link/queue_test.go create mode 100644 internal/link/seatask.go delete mode 100644 internal/link/stray_test.go delete mode 100644 internal/link/try_test.go delete mode 100644 internal/ownmanifest/foundation_manifests_test.go delete mode 100644 internal/ownmanifest/genesis_registry_test.go delete mode 100644 testdata/beside/mesh-catalog/modules/build-agent/module.json.captured diff --git a/cmd/mesh-controller/addresses_integration_test.go b/cmd/mesh-controller/addresses_integration_test.go index d8c507b2..363f05e1 100644 --- a/cmd/mesh-controller/addresses_integration_test.go +++ b/cmd/mesh-controller/addresses_integration_test.go @@ -221,8 +221,4 @@ func TestOnANodeWithNoNetworkTheStoreIsReachedByLoopback(t *testing.T) { if image != "127.0.0.1:5100/postgres/runtime@"+aDigest { t.Fatalf("on the store's own node, off any network, the image is fetched as %v", image) } - held := heldBy(ctx) - if got := held["postgres/runtime"]; got != "127.0.0.1:5100/postgres/runtime@"+aDigest { - t.Fatalf("a builder beside the store is handed the base %q", got) - } } diff --git a/cmd/mesh-controller/addresses_test.go b/cmd/mesh-controller/addresses_test.go index ec44be96..09ed3438 100644 --- a/cmd/mesh-controller/addresses_test.go +++ b/cmd/mesh-controller/addresses_test.go @@ -1,12 +1,9 @@ package main import ( - "encoding/json" "strings" - "testing" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/link" ) // An address is read from the node's settings where it is used, never recorded with a port @@ -14,62 +11,6 @@ import ( var aDigest = "sha256:" + strings.Repeat("e", 64) -// **A build is recorded by digest and path**, whatever address the builder pushed to — what it -// made and what it stood on both; an image the module runs from elsewhere is left where it says. -func TestABuildIsRecordedWithoutTheStoresAddress(t *testing.T) { - manifest, _ := json.Marshal(map[string]any{ - "module": "gitea", "version": "1", - "resources": []map[string]any{ - {"id": "server", "type": "container", "name": "mesh-gitea", - "image": "anchor.internal:5100/gitea/server@" + aDigest}, - {"id": "config", "type": "archive", "path": "/etc/gitea", "digest": aDigest, - "source": "http://anchor.internal:5100/v2/gitea/config/blobs/" + aDigest}, - {"id": "cache", "type": "container", "name": "mesh-gitea-cache", - "image": "valkey/valkey@" + aDigest}, - }, - }) - kept := buildFrom(link.BuildResult{ - ID: "b1", Repository: "https://forge.example/gitea.git", Commit: "abc", On: "laptop", - Manifest: manifest, - Made: []link.MadeArtifact{ - {Name: "server", Kind: "image", Reference: "anchor.internal:5100/gitea/server@" + aDigest}, - {Name: "config", Kind: "archive", Reference: "http://anchor.internal:5100/v2/gitea/config/blobs/" + aDigest}, - }, - Against: []string{"anchor.internal:5100/mesh-tools/runtime@" + aDigest}, - }) - if kept.Module != "gitea" { - t.Fatalf("the module was not read from the recorded manifest: %q", kept.Module) - } - if kept.Made[0].Reference != catalogue.ArtifactStoreScheme+"gitea/server@"+aDigest { - t.Errorf("the image is recorded as %q, address and all", kept.Made[0].Reference) - } - if kept.Made[1].Reference != catalogue.ArtifactStoreScheme+"gitea/config/blobs/"+aDigest { - t.Errorf("the archive is recorded as %q, address and all", kept.Made[1].Reference) - } - recorded, err := catalogue.ParseManifest(kept.Manifest) - if err != nil { - t.Fatal(err) - } - if got := recorded.Resources[0]["image"]; got != catalogue.ArtifactStoreScheme+"gitea/server@"+aDigest { - t.Errorf("the recorded manifest's image is %v", got) - } - if got := recorded.Resources[1]["source"]; got != catalogue.ArtifactStoreScheme+"gitea/config/blobs/"+aDigest { - t.Errorf("the recorded manifest's archive is %v", got) - } - if got := recorded.Resources[2]["image"]; got != "valkey/valkey@"+aDigest { - t.Errorf("an image the build did not make was rewritten: %v", got) - } - if strings.Contains(string(kept.Manifest), "anchor.internal:5100") { - t.Errorf("the recorded manifest still carries the store's address:\n%s", kept.Manifest) - } - // What the build stood on is an edge to another module's artifact, and it is recorded the way - // that artifact is: by path in the store, so the edge still names the same thing when the - // store answers at another address. - if kept.Against[0] != catalogue.ArtifactStoreScheme+"mesh-tools/runtime@"+aDigest { - t.Errorf("what the build stood on was recorded by address: %v", kept.Against) - } -} - // aStore is a module offering the artifact store on 5000, published the long way as the // distribution module does, so a node may be given another number for it. func aStore() catalogue.Manifest { diff --git a/cmd/mesh-controller/ask_bus_look_integration_test.go b/cmd/mesh-controller/ask_bus_look_integration_test.go deleted file mode 100644 index d4b1eee4..00000000 --- a/cmd/mesh-controller/ask_bus_look_integration_test.go +++ /dev/null @@ -1,131 +0,0 @@ -//go:build integration - -package main - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/inventory" -) - -// Issue 1334, the look: when the bus did not answer an ask of any module of a tier, no build is in flight and no event follows, -// so the look itself comes back (it fails, and the looker retries it), asks the modules again, and past the bound fails the plan. -func TestALookComesBackForATierTheBusRefusedWhole(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - inv := open.inventory - for _, name := range []string{"a", "b"} { - if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: name, Version: "1"}, - inventory.Source{Repository: "novox/" + name, Seat: "git", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil { - t.Fatal(err) - } - } - p := inventory.Plan{ID: "plan-bus", Repository: "novox/a", Branch: "main", Commit: "c1", Created: time.Now().UTC(), - State: inventory.PlanBuilding, Tiers: [][]string{{"a", "b"}}, Modules: map[string]*inventory.PlanModule{"a": {}, "b": {}}} - if err := inv.SavePlan(ctx, &p); err != nil { - t.Fatal(err) - } - bus := false - asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it - was := askABuild - askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) { - if !bus { - return "", fmt.Errorf("%w: nats: no responders available for request", inventory.ErrNoChain) - } - return "build-" + source.Repository, nil - } - t.Cleanup(func() { askABuild = was }) - look := func() bool { - t.Helper() - release, err := inv.HoldPlans(ctx, true) - if err != nil { - t.Fatal(err) - } - defer release() - return advanceHeld(ctx, open) - } - read := func() inventory.Plan { - t.Helper() - got, err := inv.PlanByID(ctx, "plan-bus") - if err != nil { - t.Fatal(err) - } - return got - } - - // Every module refused: the look says it failed, which has the looker come back; the plan is building and says why. - if !look() { - t.Fatal("a look that left the whole tier unasked for the bus did not fail, so nothing would come back to it") - } - if got := read(); got.State != inventory.PlanBuilding || !strings.Contains(got.Note, "bus did not answer") || - got.Modules["a"].State != "" || got.Modules["b"].State != "" { - t.Fatalf("the plan after a refused tier: %s %q %+v %+v", got.State, got.Note, got.Modules["a"], got.Modules["b"]) - } - // Again, still refused: the same fact is not saved again, and the look still comes back. - before := read().Revision - if !look() || read().Revision != before { - t.Fatalf("a second refused look failed to come back or saved the plan again (revision %d, was %d)", read().Revision, before) - } - - // The bus answers at the next look: both are asked, and nothing waits any more. - bus = true - if look() { - t.Fatal("a look that asked the whole tier failed") - } - if got := read(); got.Modules["a"].State != "asked" || got.Modules["b"].State != "asked" || got.Modules["a"].BusWaitFrom != nil { - t.Fatalf("the tier was not asked once the bus answered: %+v %+v", got.Modules["a"], got.Modules["b"]) - } -} - -func TestALookFailsTheTierTheBusRefusedPastTheBound(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - inv := open.inventory - if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "a", Version: "1"}, - inventory.Source{Repository: "novox/a", Seat: "git", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil { - t.Fatal(err) - } - long := time.Now().UTC().Add(-busWaitBound - time.Minute) - p := inventory.Plan{ID: "plan-bus", Repository: "novox/a", Branch: "main", Commit: "c1", Created: time.Now().UTC(), - State: inventory.PlanBuilding, Tiers: [][]string{{"a"}}, Modules: map[string]*inventory.PlanModule{"a": {BusWaitFrom: &long}}} - if err := inv.SavePlan(ctx, &p); err != nil { - t.Fatal(err) - } - asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it - was := askABuild - askABuild = func(context.Context, buildSource, string, string) (string, error) { - return "", fmt.Errorf("%w: nats: no responders available for request", inventory.ErrNoChain) - } - t.Cleanup(func() { askABuild = was }) - release, err := inv.HoldPlans(ctx, true) - if err != nil { - t.Fatal(err) - } - advanceHeld(ctx, open) - release() - got, err := inv.PlanByID(ctx, "plan-bus") - if err != nil { - t.Fatal(err) - } - if got.State != inventory.PlanFailed || !strings.Contains(got.Note, "the bus did not answer an ask for 10m0s (since ") { - t.Fatalf("a bus silent past its bound did not fail the plan in words: %s %q", got.State, got.Note) - } - - // And retried by hand: the module is named as waiting, not as asked again. - got, _ = inv.PlanByID(ctx, "plan-bus") - if got.State == inventory.PlanFailed { - got.Modules["a"].State, got.Modules["a"].Why = "failed", "x" - if err := inv.SavePlan(ctx, &got); err != nil { - t.Fatal(err) - } - said, err := retryPlan(ctx, open, "plan-bus") - if err != nil || !strings.Contains(said, "not asked yet: the bus did not answer") { - t.Fatalf("a retry left for the bus is said as %q %v", said, err) - } - } -} diff --git a/cmd/mesh-controller/ask_bus_moment_test.go b/cmd/mesh-controller/ask_bus_moment_test.go deleted file mode 100644 index 70163618..00000000 --- a/cmd/mesh-controller/ask_bus_moment_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "strings" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/inventory" -) - -// Issue 1334: a bus that does not answer for a moment (it reloads its user list, and JetStream's API has no responder for a few -// seconds) is not a failed build. The module is left unasked and the plan building, and the next look asks it again; only a -// module's own fault, or a bus silent for ten minutes, fails the plan. - -// asksOfEveryModule makes every module a test names the builder's own, the one module a walk still asks build-agent -// for (ADR 0453 §5): what a test of that ask's waits reads. -func asksOfEveryModule(t *testing.T) { - t.Helper() - was := buildersOwn - buildersOwn = func(string) bool { return true } - t.Cleanup(func() { buildersOwn = was }) -} - -func aTierOfThree() (*inventory.Plan, map[string]inventory.Entry) { - p := &inventory.Plan{ID: "plan-1", State: inventory.PlanBuilding, Tiers: [][]string{{"a", "b", "c"}}, - Modules: map[string]*inventory.PlanModule{}} - byName := map[string]inventory.Entry{} - for _, m := range []string{"a", "b", "c"} { - byName[m] = inventory.Entry{Source: inventory.Source{Repository: "novox/" + m, Seat: "git", Ref: "main"}} - } - return p, byName -} - -func TestABusThatDoesNotAnswerForAMomentDoesNotFailTheTier(t *testing.T) { - asksOfEveryModule(t) - p, byName := aTierOfThree() - bus := false - was := askABuild - askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) { - if source.Repository == "novox/b" && !bus { - return "", fmt.Errorf("%w: the bucket mesh-controller_builds is not on the bus: nats: no responders available for request", - inventory.ErrNoChain) - } - return "build-" + source.Repository, nil - } - t.Cleanup(func() { askABuild = was }) - - for _, m := range []string{"a", "b", "c"} { - askModule(t.Context(), p, m, byName) - } - if p.State != inventory.PlanBuilding { - t.Fatalf("a bus that did not answer for a moment failed the plan: %s, %q", p.State, p.Note) - } - if p.Modules["a"].State != "asked" || p.Modules["c"].State != "asked" { - t.Fatalf("the modules the bus did answer for were not asked: %+v %+v", p.Modules["a"], p.Modules["c"]) - } - if s := p.Modules["b"]; s.State != "" { - t.Fatalf("the module the bus did not answer for is %q, not left unasked", s.State) - } - if got := unaskedOf(*p); len(got) != 1 || got[0] != "b" { - t.Fatalf("the next look would ask %v, not b", got) - } - if !strings.Contains(p.Note, "bus did not answer") { - t.Fatalf("the plan says nothing of the wait: %q", p.Note) - } - - // The next look, the bus answering: b is asked and the plan goes on. - bus = true - for _, m := range unaskedOf(*p) { - askModule(t.Context(), p, m, byName) - } - if p.State != inventory.PlanBuilding || p.Modules["b"].State != "asked" || p.Modules["b"].Build == "" { - t.Fatalf("b was not asked once the bus answered: %s %+v", p.State, p.Modules["b"]) - } -} - -func TestABusSilentForTenMinutesFailsTheTierAsBefore(t *testing.T) { - asksOfEveryModule(t) - p, byName := aTierOfThree() - was := askABuild - askABuild = func(context.Context, buildSource, string, string) (string, error) { - return "", fmt.Errorf("%w: nats: no responders available for request", inventory.ErrNoChain) - } - t.Cleanup(func() { askABuild = was }) - askModule(t.Context(), p, "a", byName) - if p.State != inventory.PlanBuilding { - t.Fatalf("the first refusal failed the plan: %q", p.Note) - } - long := time.Now().Add(-busWaitBound - time.Minute) - p.Modules["a"].BusWaitFrom = &long - askModule(t.Context(), p, "a", byName) - if p.State != inventory.PlanFailed || p.Modules["a"].State != "failed" { - t.Fatalf("a bus silent past its bound did not fail the plan: %s %+v", p.State, p.Modules["a"]) - } - // Said in plain words: the bus, the wait, and since when. - want := "a could not be asked for: the bus did not answer an ask for 10m0s (since " + long.UTC().Format(time.RFC3339) + - "): this kind of record lives on the bus" - if !strings.HasPrefix(p.Note, want) || p.Modules["a"].Why != strings.TrimPrefix(want, "a could not be asked for: ")+p.Note[len(want):] { - t.Fatalf("the plan says %q, not words starting %q", p.Note, want) - } - // The wait has ended: a Retry made while the bus still reloads waits again, and does not fail at its first refusal. - if p.Modules["a"].BusWaitFrom != nil { - t.Fatalf("a failed module still holds the start of its wait: %v", p.Modules["a"].BusWaitFrom) - } - p.State = inventory.PlanBuilding - askModule(t.Context(), p, "a", byName) - if p.State != inventory.PlanBuilding || p.Modules["a"].State != "" || p.Modules["a"].BusWaitFrom == nil { - t.Fatalf("a retry made while the bus is silent did not wait afresh: %s %+v", p.State, p.Modules["a"]) - } -} - -func TestAPlanWithAModuleWaitingForTheBusIsOneTheLookComesBackTo(t *testing.T) { - p, _ := aTierOfThree() - if busWaiting(*p) { - t.Fatal("a plan whose modules are not asked yet waits for the bus") - } - now := time.Now() - p.Modules["b"] = &inventory.PlanModule{BusWaitFrom: &now} - p.Modules["a"] = &inventory.PlanModule{State: "asked"} - if got := busWaitingIn(*p); len(got) != 1 || got[0] != "b" { - t.Fatalf("the modules waiting for the bus are %v, not b", got) - } - p.State = inventory.PlanFailed - if busWaiting(*p) { - t.Fatal("a plan that is not open is waited for") - } -} - -func TestAModulesOwnFaultStillFailsThePlan(t *testing.T) { - asksOfEveryModule(t) - p, byName := aTierOfThree() - delete(byName, "a") - askModule(t.Context(), p, "a", byName) - if p.State != inventory.PlanFailed || !strings.Contains(p.Note, "no longer in the catalogue") { - t.Fatalf("a module gone from the catalogue did not fail the plan: %s %q", p.State, p.Note) - } - - p, byName = aTierOfThree() - was := askABuild - askABuild = func(context.Context, buildSource, string, string) (string, error) { - return "", errors.New("git.example/novox/a is no repository the catalogue builds a module from") - } - t.Cleanup(func() { askABuild = was }) - askModule(t.Context(), p, "a", byName) - if p.State != inventory.PlanFailed || p.Modules["a"].State != "failed" { - t.Fatalf("a module's own refusal did not fail the plan: %s %+v", p.State, p.Modules["a"]) - } -} diff --git a/cmd/mesh-controller/ask_bus_wait.go b/cmd/mesh-controller/ask_bus_wait.go index afdfe96e..4488e903 100644 --- a/cmd/mesh-controller/ask_bus_wait.go +++ b/cmd/mesh-controller/ask_bus_wait.go @@ -1,16 +1,12 @@ package main import ( - "errors" "fmt" "slices" "sort" "strings" "time" - "github.com/nats-io/nats.go" - "github.com/nats-io/nats.go/jetstream" - "github.com/novox/mesh-controller/internal/inventory" ) @@ -18,37 +14,6 @@ import ( // (issue 1334). const busWaitBound = 10 * time.Minute -// busDidNotAnswer says an ask failed because the bus's JetStream had no responder at that moment, as it has for a few seconds -// when the bus reloads its user list (novox/hq issue 353), and not because of the module: the same ask is made again. -func busDidNotAnswer(err error) bool { - if err == nil { - return false - } - if errors.Is(err, nats.ErrNoResponders) || errors.Is(err, nats.ErrJetStreamNotEnabled) || - errors.Is(err, jetstream.ErrJetStreamNotEnabled) || errors.Is(err, inventory.ErrNoChain) { - return true - } - // An error that crossed a boundary as text only. - text := err.Error() - return strings.Contains(text, "no responders available") || strings.Contains(text, "jetstream not enabled") -} - -// waitForTheBus leaves a module whose ask the bus did not answer unasked, the plan building, and says so in the plan's note: the -// next look asks the tier's unasked modules again. It says false, so the caller fails the plan as for any refusal, once the bus -// has been silent for busWaitBound. -func waitForTheBus(p *inventory.Plan, state *inventory.PlanModule, name string, err error, now time.Time) bool { - if state.BusWaitFrom == nil { - state.BusWaitFrom = &now - } - if now.Sub(*state.BusWaitFrom) > busWaitBound { - return false - } - state.State, state.Why = "", "" - p.Note = fmt.Sprintf("tier %d: %s not asked: the bus did not answer (%v); asked again at the next look, failing after %s", - p.Tier, name, firstLine(err.Error()), busWaitBound) - return true -} - // busWaiting says an open plan has a module of its current tier that the bus did not answer an ask of: the look must come back // for it, as nothing else will when no build of the tier is in flight (issue 1334). func busWaiting(p inventory.Plan) bool { diff --git a/cmd/mesh-controller/asker_retry_integration_test.go b/cmd/mesh-controller/asker_retry_integration_test.go index b3c6365b..5e323b58 100644 --- a/cmd/mesh-controller/asker_retry_integration_test.go +++ b/cmd/mesh-controller/asker_retry_integration_test.go @@ -33,7 +33,7 @@ func TestAWarrantForRetryRetriesAFailedPlanInTheStoreAndEndsItsCondition(t *test Note: "a failed to build in tier 0", Delivery: &inventory.PlanDelivery{}, Modules: map[string]*inventory.PlanModule{"a": {State: "failed", AskedAt: &before, Build: "build-1", - Why: link.KilledByHand}}, + Why: "killed by hand"}}, Times: &inventory.PlanTimes{Cut: &before, Started: &before, Ended: &ended, Class: inventory.ClassLeaf}} if err := open.inventory.SavePlan(ctx, &failed); err != nil { t.Fatal(err) diff --git a/cmd/mesh-controller/away_integration_test.go b/cmd/mesh-controller/away_integration_test.go index 1bca1e7c..c1d37e74 100644 --- a/cmd/mesh-controller/away_integration_test.go +++ b/cmd/mesh-controller/away_integration_test.go @@ -66,18 +66,8 @@ func TestAPlanGoesOnWithoutAMachineAwayWhenItsTierIsSent(t *testing.T) { // The build machine and the machines, as far as the plan meets them: an ask is counted, a send is recorded and kept. var mu sync.Mutex - var asks []string var sends [][]string - asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it - wasAsk := askABuild - askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) { - mu.Lock() - defer mu.Unlock() - id := link.NewBuildID(time.Now().Add(time.Duration(len(asks)) * time.Millisecond)) - asks = append(asks, source.Repository+"#"+id) - return id, nil - } - t.Cleanup(func() { askABuild = wasAsk }) + everyKeyDone(t) // the builds of the tiers: each module is taken from a done key as soon as the walk reaches it digests := 0 wasSend := sendRollout sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) { @@ -199,13 +189,7 @@ func TestAPlanGoesOnWithoutAMachineAwayWhenItsTierIsSent(t *testing.T) { if !strings.Contains(moved, "laptop was away: sent when it is heard again") || strings.Contains(moved, "anchor") { t.Errorf("1161/AC2: the plan says %q; want laptop named away and not anchor", moved) } - // The ask for tier 1's build is the next step, and records its build id on the module. - eventually(t, "tier 1 asked", func() bool { m := plan().Modules["b"]; return m != nil && m.Build != "" }) - b := plan().Modules["b"] - askedAt, _ := link.BuildAskedAt(b.Build) - record(b.Build, "b", "c2", askedAt) - register("b", "c2", askedAt) - planBuilt(ctx, open, "b", "c2", "", askedAt, b.Build) + // Tier 1's module is taken from its key, which the walk's look does by itself. eventually(t, "tier 1 sent to anchor alone", func() bool { mu.Lock() defer mu.Unlock() diff --git a/cmd/mesh-controller/batches_test.go b/cmd/mesh-controller/batches_test.go index af52b8a9..cbbdc11b 100644 --- a/cmd/mesh-controller/batches_test.go +++ b/cmd/mesh-controller/batches_test.go @@ -264,7 +264,7 @@ func TestAPlanLineNamesWhatItMoves(t *testing.T) { } return nil } - if got, want := planLineOn(p, now, pauseView{}, tierAtLeast, running), + if got, want := planLineOn(p, now, tierAtLeast, running), "mesh-catalog #175 a tap shows its outcome · messenger, telegram → novox · tier 1 of 1, building for 2m0s"; got != want { t.Fatalf("the line reads %q, want %q", got, want) } diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index c09a49e0..c6318a61 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -2,24 +2,15 @@ package main import ( "context" - "encoding/json" "errors" "flag" "fmt" "github.com/novox/mesh-controller/internal/conditions" "io" "os" - "slices" - "strings" - "time" - - "github.com/nats-io/nats.go" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/builder" "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" ) // asking a build machine for a module, and what came back. @@ -28,80 +19,14 @@ import ( // cheapest next step. That is how novox/hq ADR 0001 records `hal/sdk` reaching 34,636: // nothing in it was wrong, and no one edit was the one that should have been a new file. -// buildFrom turns what a builder said into what the mesh keeps. -// -// **By digest and path, never by where it was pushed** (novox/hq 04-ISSUES/102). The builder -// says `://@sha256:…`; the mesh records the artifact-store -// reference and composes the store's address back in where a reference is used. `against` — what -// the build stood on, the catalogue's edge — is recorded the same way, so an edge names a module's -// artifact and not the machine it was pulled from. -func buildFrom(result link.BuildResult) inventory.Build { - kept := inventory.Build{ - ID: result.ID, Repository: result.Repository, Ref: result.Ref, - Commit: result.Commit, On: result.On, Failed: result.Failed, - // **What the announcement carries, kept rather than discarded** (novox/hq 04-ISSUES/050). - // The catalogue turns the manifest into requires/provides edges and `against` into build - // edges, and it is not always listening when a build happens — on a fresh mesh it cannot - // be, for exactly the modules it needs most. Keeping them is what makes a replay able to - // rebuild the graph rather than a list of names. - Path: result.Path, - // What it was made from (novox/hq issue 280): two builds with one are one build. - SourceFingerprint: result.SourceFingerprint, - SourceInputs: result.SourceInputs, - // And its reuse key (novox/hq ADR 0350 §1), which the chain names (ADR 0408 §1). - ReuseKey: result.ReuseKey, - } - // When it was asked, which is what orders it against another build of the same module - // (novox/hq 04-ISSUES/219) — not when it was heard. - if asked, ok := link.BuildAskedAt(result.ID); ok { - kept.Asked = asked - } - for _, ref := range result.Against { - kept.Against = append(kept.Against, catalogue.Recorded(ref)) - } - for _, ref := range result.Mirrored { - kept.Mirrored = append(kept.Mirrored, catalogue.Recorded(ref)) - } - for _, r := range result.Read { - kept.Read = append(kept.Read, inventory.ReadRepository{Repository: r.Repository, Ref: r.Ref}) - } - // What it was made from, as files (novox/hq ADR 0267): the planner maps the next merge onto it. - for _, s := range result.Sources { - kept.Sources = append(kept.Sources, inventory.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths}) - } - var announced []inventory.Artifact - for _, made := range result.Made { - announced = append(announced, inventory.Artifact{ - Name: made.Name, Kind: made.Kind, Reference: made.Reference, - }) - kept.Made = append(kept.Made, inventory.Artifact{ - Name: made.Name, Kind: made.Kind, Reference: catalogue.Recorded(made.Reference), - }) - } - kept.Manifest = recordedManifest(result.Manifest, announced) - // The module name comes from the manifest, which only exists when the build got that far. - if len(kept.Manifest) > 0 { - if list, err := judgedWith(result); err == nil { - if m, err := catalogue.ParseManifestWith(kept.Manifest, list); err == nil { - kept.Module = m.Module - } - } - } - return kept -} - // buildsCommand says what has been built lately. func buildsCommand(ctx context.Context, args []string) error { set := flag.NewFlagSet("builds", flag.ContinueOnError) limit := set.Int("n", 20, "how many to show") - logOf := set.String("log", "", "a build's id: print what the build machine said, line by line") positionals, err := parseAround(set, args) if err != nil { return err } - if *logOf != "" { - return buildLog(ctx, *logOf) - } module := "" if len(positionals) == 1 { module = positionals[0] @@ -182,365 +107,6 @@ func printBuild(w io.Writer, b inventory.Build) { } } -// builderCommand issues a build machine its own broker credential. -// -// **A build machine is not a node**, and giving it a node's account would let it read another -// machine's declarations. This is narrower and different: read the build queue, write the -// exchange and an asker's reply queue, and nothing else. -// -// Issued rather than assumed, because until this the builder used whatever credential it was -// handed — which in practice meant the broker's own administrative one. A program documented as -// holding its own credential and given somebody else's is worse than one with no story at all. -func builderCommand(ctx context.Context, args []string) error { - set := flag.NewFlagSet("builder issue", flag.ContinueOnError) - // Which machine will use it. Given, the credential is delivered by the mesh rather than - // printed for somebody to carry — which is the difference between the builder being a module - // and being a program somebody configures. - forNode := set.String("node", "", - "the machine that will run it, so the mesh delivers the credential instead of printing it") - module := set.String("module", "builder", "the module on that machine that will read it") - positionals, err := parseAround(set, args) - if err != nil { - return err - } - if len(positionals) != 2 || positionals[0] != "issue" { - return errors.New("builder issue [--node ]") - } - name := positionals[1] - - // **The build machine's credential is a module's credential** (novox/hq ADR 0131, design 28 - // task 5.5): minted into the mesh's records and sealed to the machine as the builder module's - // broker secret, usable at the next push — the same act `module issue` performs, and the same - // account the composed user list carries. Nothing is created on a server; the bus reads the list. - open, err := openStores(ctx) - if err != nil { - return err - } - defer open.Close() - inv := open.inventory - shelf, err := inv.Catalogue(ctx) - if err != nil { - return err - } - m, known := shelf[*module] - if !known { - return fmt.Errorf("%s is not in the catalogue; `module add` it first", *module) - } - fmt.Printf("build machine %s: ", name) - node := *forNode - if node == "" { - entries, err := inv.Catalogued(ctx) - if err != nil { - return err - } - for _, e := range entries { - if e.Manifest.Module == *module && len(e.On) > 0 { - node = e.On[0] - } - } - } - if node == "" { - return fmt.Errorf("%s is assigned nowhere; `assign %s` first, or say --node", *module, *module) - } - address, err := broker.BusAddress() - if err != nil { - return err - } - return issueOnTheNewBus(ctx, inv, m, node, address) -} - -// recordAsked keeps a build request once it is asked (novox/hq issue 325), with who asked it, in a store -// opened for the purpose: the asks reach here from processes that hold none. -func recordAsked(ctx context.Context, r inventory.BuildRequest) { - open, err := openStores(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "build %s is asked and not kept as a build request: %v\n", r.ID, err) - return - } - defer open.Close() - recordBuildRequest(ctx, open.inventory, r) -} - -// markWaitFailed says what became of a kept build request whose waited ask failed: never handed over, so -// nothing runs; or handed over and no longer waited for, so asked with its outcome unknown — still read as in -// flight until its outcome or its bound (novox/hq issue 325). -func markWaitFailed(ctx context.Context, id string, why error) { - open, err := openStores(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "build %s could not be marked: %v\n", id, err) - return - } - defer open.Close() - mark := open.inventory.MarkOutcomeUnknown - if errors.Is(why, link.ErrNotHandedOver) { - mark = open.inventory.MarkNotAsked - } - if err := mark(ctx, id, why.Error()); err != nil { - fmt.Fprintf(os.Stderr, "build %s could not be marked: %v\n", id, err) - } -} - -// buildOneAsked is buildOne answering the id it asked with — what a plan keeps to match the outcome -// by (novox/hq ADR 0219) — and, for an ask not waited for, optionally a dry run: built and looked -// at, never taken in (issue 240), which is what `replay` asks unless told to register. -// -// asker is who asked, for the build request kept once the ask is made (novox/hq issue 325); empty for an -// asker that keeps the request itself, with its own name, once it knows the ask was made. -func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wait time.Duration, - dryRun bool, asker string) (string, error) { - if dryRun && wait != 0 { - return "", errors.New("a dry run waited for is `build --dry-run`") - } - // Before anything is asked of a builder: a source on a seat nobody holds is refused here, with - // the reason, rather than sent to a machine to fail at `git clone`. - repository, err := cloneFrom(ctx, source) - if err != nil { - return "", err - } - // Through a verb, only a repository the catalogue builds from (novox/hq ADR 0266). - if err := verbMayAsk(ctx, source, repository, path); err != nil { - return "", err - } - - ident, err := openIdentity(ctx) - if err != nil { - return "", err - } - defer ident.Close() - - server, err := connectLink(ctx, nil, nil, nil) - if err != nil { - return "", err - } - defer server.Close() - - // Correlated by something the control plane makes, not by the module's name: two builds of one - // module can be in flight, and the second answer is not the first one's. - request := link.BuildRequest{ - ID: link.NewBuildID(time.Now()), - Repository: repository, - Path: path, - Ref: ref, - Held: heldBy(ctx), - Seats: seatBases(ctx), - DryRun: dryRun, - } - fmt.Printf("asked for %s", source) - if source.Seat != "" { - fmt.Printf(" (%s)", repository) - } - // The id is how a person follows this build while it runs: `builds --log `. - fmt.Printf(" as %s", request.ID) - if path != "" { - fmt.Printf(" at %s", path) - } - if ref != "" { - fmt.Printf(" on %s", ref) - } - fmt.Println() - - seat := buildSeatHeld(ctx) - ask, err := askOverOn(seat) - if err != nil { - return "", err - } - defer ask.Close() - fmt.Printf(" of %s\n", seat) - // Kept once it is asked, so `assign` knows a module is coming while its build runs (novox/hq issue 325); - // never before, so an ask that failed never reads as a build in flight. A dry run registers nothing, and - // is not kept. - keep := !dryRun && asker != "" - // Asked at the terminal is what lets its outcome register a module from a repository the catalogue does - // not build it from (novox/hq ADR 0266); never through a verb. - asked := inventory.BuildRequest{ID: request.ID, Repository: source.Repository, Seat: source.Seat, Path: path, - Ref: ref, For: asker, AtTerminal: startedAtTheTerminal()} - - if wait == 0 { - // Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the - // controller takes it in — records the build, registers the module — whether or not anybody - // is still here. A tool call cannot hold a connection for the minutes a build takes; it - // follows the build by its id instead. - if err := ask.Ask(ctx, request); err != nil { - return "", err - } - if keep { - recordAsked(ctx, asked) - } - if dryRun { - fmt.Printf("asked as a dry run, not waited for: `builds --log %s` follows it as it runs; "+ - "its outcome is not taken in\n", request.ID) - return request.ID, nil - } - fmt.Printf("asked, not waited for: `builds --log %s` follows it as it runs, and `builds` "+ - "shows what came of it; the module is registered when the outcome comes\n", request.ID) - return request.ID, nil - } - - // Waited for: kept while it is waited for, since that can take minutes, and marked when the hand-over - // failed (not asked) or the wait did (asked, outcome unknown) — an outcome heard later is the last word. - if keep { - recordAsked(ctx, asked) - } - result, err := ask.Submit(ctx, request, wait) - if err != nil { - if keep { - markWaitFailed(ctx, request.ID, err) - } - return request.ID, err - } - - open, err := openStores(ctx) - if err != nil { - return request.ID, err - } - defer open.Close() - manifest, kept, err := takeIn(ctx, open.inventory, result) - // The assignments pending on this build, made or ended (novox/hq issue 325). - for _, line := range settlePendingOnBuild(ctx, open, result, manifest.Module, err) { - fmt.Printf(" %s\n", line) - } - if err != nil { - return request.ID, err - } - // Said as recorded: what each artifact is, not where this builder happened to push it. - for _, made := range kept.Made { - fmt.Printf(" %-12s %s %s\n", made.Name, made.Kind, made.Reference) - } - fmt.Printf("\n%s %s, built on %s from %s\n", - manifest.Module, manifest.Version, result.On, short(result.Commit)) - saysWhenThePolicyActs(ctx, open.inventory, manifest.Module) - fmt.Printf(" run `assign %s` to put it somewhere\n", manifest.Module) - return request.ID, nil -} - -// saysWhenThePolicyActs tells whoever built a module that its upgrade policy will send the -// result on at once (novox/hq issue 126, ADR 0163): a person choreographing a data move must -// know which module will not wait for them. -func saysWhenThePolicyActs(ctx context.Context, inv *inventory.Inventory, module string) { - if u, err := inv.UpgradeOf(ctx, module); err == nil && u.RollOut { - how := "one machine at a time" - if u.Together { - how = "every machine at once" - } - fmt.Printf(" %s rolls out on build: the machines running it are sent this now, %s — "+ - "`upgrade %s record` first if something must move before it does\n", module, how, module) - } -} - -// takeIn is what the mesh does with a build's outcome, whoever hears it: the waiting command and -// the daemon that follows the role's events both come here (novox/hq issue 176), so a build's -// result reaches the catalogue whether or not the asker was still listening. -// -// Kept before it is judged. A failed build that leaves no trace is indistinguishable from one -// nobody asked for, and the difference is the whole of whether somebody should be looking at -// something. Then parsed with the same parser a hand-written manifest goes through — a second path -// would be a second thing to disagree about what a manifest is — and registered with where it came -// from: **for a source on a seat, as the path and the seat, never the URL just cloned** (ADR 0111), -// which the request carried and the outcome echoes. A definition naming an installation is refused -// here, where it would enter the catalogue; the build stays recorded and the refusal says which. -// -// Idempotent: the same outcome taken in twice registers the same module twice, which is one row -// written with the same values. -func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResult) ( - catalogue.Manifest, inventory.Build, error) { - // The list of predefined keys as the store keeps it now: another controller process may have kept a newer one. - if err := loadPredefined(ctx, inv); err != nil { - return catalogue.Manifest{}, inventory.Build{}, fmt.Errorf("the list of predefined keys kept cannot be "+ - "read: %w", err) - } - kept := buildFrom(result) - if err := inv.RecordBuild(ctx, kept); err != nil { - return catalogue.Manifest{}, kept, err - } - if result.Failed != "" { - // The builder's own words. Wrapping them in something about the control plane would put - // two explanations between a person and a build log. - return catalogue.Manifest{}, kept, fmt.Errorf("%s could not build %s:\n%s", - result.On, result.Repository, result.Failed) - } - // Judged first against the list the build was judged against: the catalogue's at the commit built (novox/hq - // ADR 0348 §7), so a module naming a key its own commit adds is read; and again below against the list the - // mesh publishes, before it is registered. - list, err := judgedWith(result) - if err != nil { - return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On, - result.Repository, short(result.Commit), err) - } - manifest, err := catalogue.ParseManifestWith(kept.Manifest, list) - if err != nil { - return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s and what came back is not a manifest: %w", - result.On, result.Repository, err) - } - recorded := inventory.Source{ - Repository: result.Repository, Path: result.Path, Ref: result.Ref, - BuiltFrom: result.Commit, Head: result.Commit, - // What it stood on, so registration can judge a built manifest's base (to-be 38 WP2.4). - Against: kept.Against, - // When it was asked, so an older request heard later does not replace a newer one - // (novox/hq 04-ISSUES/219). - Asked: kept.Asked, - } - if result.Source != nil && result.Source.Seat != "" { - recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat - } - // **A build at a commit does not change the branch a module follows** (novox/hq 04-ISSUES/215): - // the commit is built and recorded as what it was built from, and the module keeps following - // what it followed before — the repository's default branch for one new to the catalogue. - if followedBranch(result.Ref) == "" && result.Ref != "" { - recorded.Ref = "" - if was, err := inv.SourceOf(ctx, manifest.Module); err == nil { - recorded.Ref = followedBranch(was.Ref) - } - } - trunk := result.Trunk - if was, err := inv.SourceOf(ctx, manifest.Module); err == nil && followedBranch(was.Ref) != "" { - trunk = followedBranch(was.Ref) - } - if trunk == "" { - trunk = "main" - } - j := takenBuild{kept: kept, manifest: manifest, recorded: recorded, ask: byBuild(result.ID), path: result.Path, - trunk: trunk, by: result.On, what: result.Repository, commit: result.Commit} - // The checks only an outcome of build-agent needs: a key is a trunk build by construction (ADR 0501 §2), in no - // scratch namespace, and carries no list of its own. - j.published = func() error { - // **Only a commit on the trunk is published** (novox/hq ADR 0238): a commit off its repository's - // default branch — a pull request's head, a feature branch built by hand, a `rebuild` or `replay - // --register` of one — is for checking, and is never a module's version; nothing could then send it. - // The branch the module already follows is its trunk — never the branch a build was asked at, or a - // build of a feature branch by name would make that branch its trunk. - follows := "" - if was, err := inv.SourceOf(ctx, manifest.Module); err == nil { - follows = followedBranch(was.Ref) - } - if err := publishable(result, follows); err != nil { - return err - } - // **Never a build in the scratch namespace** (novox/hq ADR 0239 §9, ADR 0350 §2): what a check builds is - // registered only as the walk's copy in the module's own namespace. - if scratch := inScratch(result.Made); scratch != "" { - return fmt.Errorf("%s is in the scratch namespace, which only a walk's copy leaves (ADR 0350)", scratch) - } - return nil - } - j.carried = func() error { - // **The newest trunk build's list of predefined keys is published** (novox/hq ADR 0348 §7): a build of the - // catalogue that may be registered keeps its list when its commit is newer than the one kept. - carried, err := carriedList(result) - if err != nil { - return err - } - if carried != nil { - if err := publishPredefined(ctx, inv, result, carried); err != nil { - fmt.Printf("%s: the list of predefined keys it carried is not published: %v\n", result.ID, err) - } - } - return nil - } - manifest, err = registerJudged(ctx, inv, j) - return manifest, kept, err -} - // takenBuild is a build the controller judges and registers, whatever told it of the build: build-agent's outcome (the // builder's own module's, until R4g, ADR 0453 §5) or the builder's done key (ADR 0501 §1). **One judging for both**: // the rules of who may register, the gate a build failed, the predefined keys and the rule that an unchanged build @@ -643,44 +209,6 @@ func registerJudged(ctx context.Context, inv *inventory.Inventory, j takenBuild) return manifest, nil } -// inScratch is the first artifact a build made into the scratch namespace; empty when none is. -func inScratch(made []link.MadeArtifact) string { - for _, m := range made { - if path, kept := catalogue.InArtifactStore(catalogue.Recorded(m.Reference)); kept && strings.HasPrefix(path, builder.ScratchPrefix) { - return m.Reference - } - } - return "" -} - -// errOffTheTrunk is a build of a commit off its repository's trunk, which is never published. -var errOffTheTrunk = errors.New("the commit is not on its repository's trunk: a commit off the trunk is checked, " + - "never published (ADR 0238) — merge it, and the merge builds it") - -// publishable says whether a build's outcome may become a module's version: its commit on the module's -// trunk, as the build seat read the forge at the build — the branch the module follows when its source -// names one, else its repository's default branch. A build seat that could not say — one older than the -// rule, building its own successor — is let through and said, so the rule can reach the mesh. -func publishable(result link.BuildResult, follows string) error { - if result.Check != nil || result.Checked != nil || result.DryRun { - return errors.New("a check or a dry run is never published") - } - if result.Trunk == "" { - fmt.Printf("%s: the build seat did not say whether %s is on its repository's trunk (it predates ADR 0238); "+ - "registered as before\n", result.ID, short(result.Commit)) - return nil - } - trunk := result.Trunk - if follows != "" { - trunk = follows - } - on := slices.Contains(result.Branches, trunk) || (trunk == result.Trunk && result.OnTrunk) - if !on { - return fmt.Errorf("%w (%s is not on %s)", errOffTheTrunk, short(result.Commit), trunk) - } - return nil -} - // answers is what the three questions came back with, read once. type answers struct { // notAnnounced are the owners some module reads the state of that have announced nothing yet (novox/hq ADR 0495, @@ -703,8 +231,6 @@ type answers struct { reported []inventory.Reported // plans is what the last merges produced and where each stands (novox/hq ADR 0162). plans []inventory.Plan - // paused is whether the build seat takes work, which a plan waiting on it says (ADR 0219). - paused pauseView // tierBounds is how long each repository's plan tier may take before it is late (novox/hq issue 296). tierBounds tierBounds // refused is why a machine cannot be worked out at all, by name. A different thing from every @@ -766,185 +292,3 @@ type answers struct { lastSent map[string]inventory.Send lastSentUnread string } - -// heldBy is every artifact this mesh has built, for a build that may need one as its base. -// -// **A failure here is not a failure to build.** A module that names no base does not need this at -// all, and one that does gets a refusal naming exactly what is missing — which is a better sentence -// than a build command refusing to start because a query did not run. So the store not opening is -// reported and the build goes ahead without it. -// -// Routed through the artifact store as the network reaches it now (novox/hq 04-ISSUES/102): a -// base is recorded by digest and path, and a build machine needs something it can pull. -// runningBy is, keyed "/", each artifact of the build the mesh has registered for its module, as a -// builder reaches the artifact store (novox/hq issue 721): what a check compares its builds with. Empty when it cannot -// be read, said, and then nothing is said unchanged. -func runningBy(ctx context.Context) map[string]string { - open, err := openStores(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "could not read what this mesh runs, so the check compares nothing: %v\n", err) - // empty-on-error: said above; a check given nothing to compare with says no module unchanged, and all move - return nil - } - defer open.Close() - running, err := open.inventory.RegisteredHeld(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "could not read what this mesh runs, so the check compares nothing: %v\n", err) - // empty-on-error: said above; a check given nothing to compare with says no module unchanged, and all move - return nil - } - address, err := whereABuilderReachesTheStore(ctx, open.inventory) - if err != nil || address == "" { - return running - } - routed := make(map[string]string, len(running)) - for key, reference := range running { - routed[key] = catalogue.Rerouted(reference, address) - } - return routed -} - -func heldBy(ctx context.Context) map[string]string { - open, err := openStores(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "could not read what this mesh has built, so a module naming a "+ - "base will be told that base is missing: %v\n", err) - // empty-on-error: said above; a build that names a base is refused by name for want of it - return nil - } - defer open.Close() - held, err := open.inventory.Held(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "could not read what this mesh has built: %v\n", err) - // empty-on-error: said above; a build that names a base is refused by name for want of it - return nil - } - address, err := whereABuilderReachesTheStore(ctx, open.inventory) - if err != nil { - fmt.Fprintf(os.Stderr, "could not find the artifact store on this mesh's network, so a "+ - "module naming a base will be handed a reference nothing can fetch: %v\n", err) - return held - } - if address == "" { - return held - } - routed := make(map[string]string, len(held)) - for repository, reference := range held { - routed[repository] = catalogue.Rerouted(reference, address) - } - return routed -} - -// askOver opens the way a build is asked for, on whichever bus the mesh is on. -// -// **One place chooses**, as everywhere else the bus change went (novox/hq ADR 0116 step 5). On the bus -// the mesh runs on today this needs the controller's own connection, so it is handed one; on the bus -// being built it dials, because a build request is a one-shot and holds nothing else. -func askOverOn(seat string) (link.Builders, error) { - // The serving controller asks on its own connection (novox/hq issue 327). - if serving := servingBus.Load(); serving != nil { - return link.BuildsOn(serving, seat), nil - } - address, err := broker.BusAddress() - if err != nil { - return nil, err - } - return link.BuildsOverNATSOn(address, seat) -} - -// buildSeatHeld is the build role to ask: the one some assigned module claims (novox/hq ADR 0190, -// the handover). Read from the catalogue at ask time, because the answer changes exactly once, the -// moment the first build-agent is assigned — and a controller that asked the new role before then -// would queue work nothing takes, while the outcome that registers build-agent itself has to come -// from the old builder. When the catalogue cannot be read the current role is asked, said aloud. -func buildSeatHeld(ctx context.Context) string { - open, err := openStores(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "could not read what is assigned, so the build is asked of %s: %v\n", - link.TheBuildMachine, err) - return link.TheBuildMachine - } - defer open.Close() - entries, err := open.inventory.Catalogued(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "could not read the catalogue, so the build is asked of %s: %v\n", - link.TheBuildMachine, err) - return link.TheBuildMachine - } - return buildSeatAmong(entries) -} - -// buildSeatAmong is the rule, over what the catalogue holds: the current build role when any -// assigned module claims it; else the retired role while an assigned module still claims that; else -// the current role, which is where every ask goes once the handover is done. -func buildSeatAmong(entries []inventory.Entry) string { - heldBefore := false - for _, e := range entries { - if len(e.On) == 0 { - continue - } - if e.Manifest.ClaimsSeat(link.TheBuildMachine) { - return link.TheBuildMachine - } - if e.Manifest.ClaimsSeat(link.TheBuildMachineBefore) { - heldBefore = true - } - } - if heldBefore { - return link.TheBuildMachineBefore - } - return link.TheBuildMachine -} - -// buildLog prints everything a build machine said about one build, read back from the bus. -// -// **From the stream, not from a record** (novox/hq ADR 0157). A build's lines are the role's own -// events under the build's id, retained with every other event; the mesh keeps no second copy. Read -// with a consumer of its own that is gone when this returns, so nothing accumulates in the server -// for the reading, and filtered by subject, so one build's lines are all that travel. -func buildLog(ctx context.Context, id string) error { - js, err := aBus() - if err != nil { - return fmt.Errorf("cannot reach the bus to read a build's log: %w", err) - } - defer js.Close() - - // Under whichever build role did it: a build asked of the retired role during the handover - // (ADR 0190) said its lines as that role's events, and a reader should not have to know which. - lines := link.BuildLogOf("*", id) - sub, err := js.Context().PullSubscribe(lines, "", - nats.BindStream(broker.EventsStream), nats.DeliverAll(), nats.AckNone()) - if err != nil { - return fmt.Errorf("cannot read %s from the bus: %w", lines, err) - } - defer func() { _ = sub.Unsubscribe() }() - - printed := 0 - for { - batch, err := sub.Fetch(200, nats.MaxWait(2*time.Second)) - if err != nil && !errors.Is(err, nats.ErrTimeout) && !errors.Is(err, context.DeadlineExceeded) { - return fmt.Errorf("reading a build's log: %w", err) - } - for _, msg := range batch { - var line link.BuildLine - if err := json.Unmarshal(msg.Data, &line); err != nil { - fmt.Printf(" ? %s\n", string(msg.Data)) - continue - } - at := line.At - if t, err := time.Parse(time.RFC3339Nano, line.At); err == nil { - at = t.Local().Format("15:04:05") - } - fmt.Printf("%s %4d [%s] %s\n", at, line.Seq, line.Step, line.Message) - printed++ - } - if len(batch) < 200 { - break - } - } - if printed == 0 { - fmt.Printf("nothing on the bus for build %s: no build by that id in the last week, or a build "+ - "machine older than this that said nothing while building\n", id) - } - return nil -} diff --git a/cmd/mesh-controller/build_seat_test.go b/cmd/mesh-controller/build_seat_test.go deleted file mode 100644 index 26e5942d..00000000 --- a/cmd/mesh-controller/build_seat_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -func claiming(module, seat string, on ...string) inventory.Entry { - return inventory.Entry{ - Manifest: catalogue.Manifest{Module: module, Claims: []catalogue.Claim{{Name: seat}}}, - On: on, - } -} - -// The controller asks the build role that has a holder (novox/hq ADR 0190 handover): the retired -// one while only the builder is assigned, the current one from the first build-agent on, and the -// current one when nothing holds either — where every ask goes once the handover is done. -func TestTheControllerAsksTheBuildRoleThatHasAHolder(t *testing.T) { - onlyTheBuilder := []inventory.Entry{ - claiming("builder", link.TheBuildMachineBefore, "anchor"), - claiming("build-agent", link.TheBuildMachine), // registered, assigned nowhere yet - } - if got := buildSeatAmong(onlyTheBuilder); got != link.TheBuildMachineBefore { - t.Errorf("with only the builder assigned, asked %q", got) - } - bothHeld := []inventory.Entry{ - claiming("builder", link.TheBuildMachineBefore, "anchor"), - claiming("build-agent", link.TheBuildMachine, "home-server"), - } - if got := buildSeatAmong(bothHeld); got != link.TheBuildMachine { - t.Errorf("with a build-agent assigned anywhere, asked %q", got) - } - neither := []inventory.Entry{claiming("builder", link.TheBuildMachineBefore)} - if got := buildSeatAmong(neither); got != link.TheBuildMachine { - t.Errorf("with no holder of either, asked %q, want the current role", got) - } - if got := buildSeatAmong(nil); got != link.TheBuildMachine { - t.Errorf("an empty catalogue asks %q", got) - } -} diff --git a/cmd/mesh-controller/build_source.go b/cmd/mesh-controller/build_source.go index 82f29a7b..5f253a20 100644 --- a/cmd/mesh-controller/build_source.go +++ b/cmd/mesh-controller/build_source.go @@ -188,19 +188,6 @@ func (f *sourceForms) same(aRepository, aSeat, bRepository, bSeat string) bool { return f.canonical(aRepository, aSeat) == f.canonical(bRepository, bSeat) } -// buildsFrom says the catalogue builds some module from this repository. -func (f *sourceForms) buildsFrom(entries []inventory.Entry, repository, seat string) bool { - for _, e := range entries { - if e.Provided || e.Source.Repository == "" { - continue - } - if f.same(e.Source.Repository, e.Source.Seat, repository, seat) { - return true - } - } - return false -} - // forgeFacts is what the mesh's forge says of a repository a module is registered from (novox/hq ADR 0266). type forgeFacts struct { // ID is the forge's own id of the repository: what tells it from one deleted and made again by its name. @@ -489,9 +476,6 @@ type registrationAsk struct { Terminal bool } -// byBuild is the warrant of a build's outcome: read from the request kept under its id. -func byBuild(id string) registrationAsk { return registrationAsk{Build: id} } - // String is the ask as the registration's words name it. func (a registrationAsk) String() string { switch { @@ -697,29 +681,3 @@ func sourceWords(repository, seat string) string { } return buildSource{Repository: repository, Seat: seat}.String() } - -// verbMayAsk refuses, for a call through a verb, a build of a repository the catalogue builds no module from -// (novox/hq ADR 0266): the build node would run what an agent wrote, and its outcome could never be -// registered anyway. url is the repository as it is cloned. At the terminal anything may be asked. -func verbMayAsk(ctx context.Context, source buildSource, url, path string) error { - if startedAtTheTerminal() { - return nil - } - open, err := openStores(ctx) - if err != nil { - return err - } - defer open.Close() - entries, err := open.inventory.Catalogued(ctx) - if err != nil { - return err - } - forms := newSourceForms(ctx, open.inventory) - if forms.buildsFrom(entries, source.Repository, source.Seat) || forms.buildsFrom(entries, url, "") { - return nil - } - return terminalRefusal("%s is no repository the catalogue builds a module from, and a build of any other is "+ - "asked at the controller's terminal only, never through a verb: a build node runs what the repository "+ - "says, and its outcome would register a module the next push sends — whoever may call a verb includes "+ - "agents (novox/hq ADR 0266). Nothing was asked", source) -} diff --git a/cmd/mesh-controller/build_source_integration_test.go b/cmd/mesh-controller/build_source_integration_test.go index eadf9ade..66645552 100644 --- a/cmd/mesh-controller/build_source_integration_test.go +++ b/cmd/mesh-controller/build_source_integration_test.go @@ -129,27 +129,6 @@ func TestADeliveryFromTheRegisteredRepositoryIsRegistered(t *testing.T) { } } -func TestAVerbAsksNoBuildOfARepositoryTheCatalogueDoesNotBuildFrom(t *testing.T) { - theCatalogue(t) - ctx := t.Context() - t.Setenv(verbVar, "build") - t.Setenv(link.CallerVar, "node-tools.anchor, through the mesh-controller seat") - err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "http://forge.internal:20000/agent/sudo.git", "") - var policy *heldAtTheTerminal - if !errors.As(err, &policy) { - t.Fatalf("a verb's build of an agent's repository was asked: %v", err) - } - if err := verbMayAsk(ctx, buildSource{Repository: "novox/mesh-catalog", Seat: "git"}, - "http://forge.internal:20000/novox/mesh-catalog.git", ""); err != nil { - t.Fatalf("a verb's build of the catalogue repository was refused: %v", err) - } - t.Setenv(verbVar, "") - t.Setenv(link.CallerVar, "") - if err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "", ""); err != nil { - t.Fatalf("the terminal was refused: %v", err) - } -} - func TestARollbackNeverPutsBackABuildFromAnotherRepository(t *testing.T) { open := theCatalogue(t) ctx := t.Context() diff --git a/cmd/mesh-controller/build_test.go b/cmd/mesh-controller/build_test.go index 434f961b..ab1bf2e7 100644 --- a/cmd/mesh-controller/build_test.go +++ b/cmd/mesh-controller/build_test.go @@ -2,12 +2,10 @@ package main import ( "bytes" - "reflect" "strings" "testing" "time" - sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) @@ -68,12 +66,3 @@ func TestBuildsSaysTheSourceFingerprint(t *testing.T) { t.Errorf("the other lines changed:\n%s\nwant\n%s", plain.String(), want) } } - -// 1215/AC4: what the builder said of the inputs is what the controller keeps of the build. -func TestTheControllerKeepsTheInputsABuilderSaid(t *testing.T) { - in := &sdkdelivery.SourceInputs{Module: "mailu", Tree: "t", Toolchains: map[string]string{"code": "ts sha256:aa 01"}} - kept := buildFrom(link.BuildResult{ID: "build-1", Repository: "r", Commit: "c", SourceFingerprint: "src1:x", SourceInputs: in}) - if !reflect.DeepEqual(kept.SourceInputs, in) { - t.Errorf("the controller keeps %+v, the builder said %+v", kept.SourceInputs, in) - } -} diff --git a/cmd/mesh-controller/builder_on_the_bus_test.go b/cmd/mesh-controller/builder_on_the_bus_test.go index b17ff06e..4ab796b6 100644 --- a/cmd/mesh-controller/builder_on_the_bus_test.go +++ b/cmd/mesh-controller/builder_on_the_bus_test.go @@ -4,7 +4,6 @@ package main import ( "context" - "sync" "testing" sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" @@ -106,34 +105,5 @@ func (b builderOnTheBus) built(t *testing.T, module, version, fp, manifest, stat } } -// noBuildAsked fails the test if anything asks build-agent for a build (ADR 0501 §6, ADR 0503 §6). -func noBuildAsked(t *testing.T) { - t.Helper() - was := askABuild - askABuild = func(_ context.Context, _ buildSource, path, _ string) (string, error) { - t.Errorf("a build was asked of build-agent, at %q", path) - return "", context.Canceled - } - t.Cleanup(func() { askABuild = was }) -} - -// asksWithPaths records every build a merge asks for, as repository, path and ref. The merge window's goroutine and -// the test's own may ask at once, so the record is written under a lock (issue 1543). -func asksWithPaths(t *testing.T) *[][3]string { - t.Helper() - asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): these tests are of what a walk does around it - var asked [][3]string - var mu sync.Mutex - was := askABuild - askABuild = func(_ context.Context, source buildSource, path, ref string) (string, error) { - mu.Lock() - defer mu.Unlock() - asked = append(asked, [3]string{source.Repository, path, ref}) - return "b-" + path, nil - } - t.Cleanup(func() { askABuild = was }) - return &asked -} - // theIndexedModule is the look at the indexer as the controller makes it, kept before any test replaces it. var theIndexedModule = indexedModule diff --git a/cmd/mesh-controller/builder_says_default_test.go b/cmd/mesh-controller/builder_says_default_test.go deleted file mode 100644 index 744970e7..00000000 --- a/cmd/mesh-controller/builder_says_default_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package main - -import "context" - -// A test's builder says it writes references, as R4e's does, unless the test says otherwise (issue 1886): no test reads -// the bus for it. -func init() { - builderSaysReferences = func(context.Context) (bool, error) { return true, nil } -} diff --git a/cmd/mesh-controller/builder_says_test.go b/cmd/mesh-controller/builder_says_test.go deleted file mode 100644 index c85bdc17..00000000 --- a/cmd/mesh-controller/builder_says_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package main - -import ( - "context" - "errors" - "strings" - "testing" - - sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" - - "github.com/novox/mesh-controller/internal/builderkeys" - "github.com/novox/mesh-controller/internal/inventory" -) - -// theBuilderSays sets what the running builder says for one test. -func theBuilderSays(t *testing.T, says bool, err error) { - t.Helper() - was := builderSaysReferences - builderSaysReferences = func(context.Context) (bool, error) { return says, err } - t.Cleanup(func() { builderSaysReferences = was }) -} - -// askingBuildAgent records the modules build-agent is asked for, by path. -func askingBuildAgent(t *testing.T) *[]string { - t.Helper() - var asked []string - was := askABuild - askABuild = func(_ context.Context, _ buildSource, path, _ string) (string, error) { - asked = append(asked, path) - return "build-9", nil - } - t.Cleanup(func() { askABuild = was }) - return &asked -} - -// 1886/AC2: while no running builder says it writes references, a walk asks build-agent for a module of its tier, as -// before R4e, and takes nothing from the builder's key. -func TestAWalkAsksBuildAgentWhileTheBuilderDoesNotSayItWritesReferences(t *testing.T) { - theBuilderSays(t, false, nil) - asked := askingBuildAgent(t) - p, byName := aWalkOfOne("sudo") - askModule(t.Context(), p, "sudo", byName) - s := p.Modules["sudo"] - if s.Key || s.Build != "build-9" || s.State != "asked" || len(*asked) != 1 { - t.Fatalf("the module is not asked of build-agent: %+v, asked %v", s, *asked) - } -} - -// 1886/AC3: once a running builder says it writes references, a walk takes the module from the builder's key (ADR -// 0498 §8) and asks build-agent for nothing. -func TestAWalkTakesTheKeyOnceTheBuilderSaysItWritesReferences(t *testing.T) { - theBuilderSays(t, true, nil) - asked := askingBuildAgent(t) - p, byName := aWalkOfOne("sudo") - askModule(t.Context(), p, "sudo", byName) - if s := p.Modules["sudo"]; !s.Key || s.Build != "" || len(*asked) != 0 { - t.Fatalf("the module is not taken from its key: %+v, asked %v", s, *asked) - } -} - -// 1886/AC2: a say that cannot be read leaves the module unasked and says why, so the next look asks it: neither path is -// guessed. -func TestASayNotReadLeavesTheModuleUnasked(t *testing.T) { - theBuilderSays(t, false, errors.New("the bus did not answer")) - asked := askingBuildAgent(t) - p, byName := aWalkOfOne("sudo") - askModule(t.Context(), p, "sudo", byName) - s := p.Modules["sudo"] - if s.State != "" || s.Key || len(*asked) != 0 || !strings.Contains(s.Why, "the bus did not answer") { - t.Fatalf("a say not read: %+v, asked %v", s, *asked) - } - if got := unaskedOf(*p); len(got) != 1 { - t.Fatalf("the module is not left for the next look to ask: %v", got) - } -} - -// 1886/AC2: the one function every ask goes through lets build-agent build any module while the builder does not say -// it writes references, and refuses it again once it does. -func TestBuildAgentIsAskedForAnyModuleWhileTheBuilderDoesNotSay(t *testing.T) { - was := askedModuleOf - t.Cleanup(func() { askedModuleOf = was }) - askedModuleOf = func(_ context.Context, _ buildSource, path string) (string, error) { - return strings.TrimPrefix(path, "modules/"), nil - } - theBuilderSays(t, false, nil) - if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/mesh-catalog", Seat: "git"}, "modules/sudo"); err != nil { - t.Fatalf("an ask while the builder does not say is refused: %v", err) - } - theBuilderSays(t, true, nil) - if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/mesh-catalog", Seat: "git"}, "modules/sudo"); !errors.Is(err, errNotTheBuildersOwn) { - t.Fatalf("an ask once the builder says is not refused: %v", err) - } -} - -// 1886/AC4: an open walk that waits on a key the running builder cannot reference (done without a reference, as R4e's -// own walk did in plan-1792707174627079192) is judged again at the next look: the module is handed back unasked, and -// that look's ask goes to build-agent. With the builder saying it writes references, the same walk keeps waiting. -func TestAnOpenWalkWaitingOnAnUnreferencedKeyIsAskedOfBuildAgent(t *testing.T) { - t.Cleanup(func() { - keyTakes.Lock() - keyTakes.running, keyTakes.waits, keyTakes.taken = map[string]bool{}, map[string]string{}, map[string]bool{} - keyTakes.Unlock() - }) - fp := "src2:" + strings.Repeat("e", 64) - unreferenced := builderkeys.Key{Module: "sudo", Fingerprint: fp, Found: true, Revision: 1, - Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildDone, Manifest: strings.Repeat("b", 64), - Artifacts: []sdkdelivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: "sha256:1"}}}} - walk := func() (*inventory.Plan, map[string]inventory.Entry, planKeysOf) { - theBuilderSays(t, true, nil) - p, byName := aWalkOfOne("sudo") - askModule(t.Context(), p, "sudo", byName) - keys := planKeysOf{values: map[string]sdkdelivery.Module{"sudo@c0ffee0123456789": {FP2: fp}}, - keys: map[string]builderkeys.Key{"sudo": unreferenced}} - return p, byName, keys - } - take := func(string, sdkdelivery.Module, string) { t.Fatal("an unreferenced key was taken in") } - records := recordsOf{builds: map[string]inventory.Build{}} - - // Said: it waits for the reference, as ADR 0498 §8 has it. - p, _, keys := walk() - if _, err := settleKeys(t.Context(), records, keys, p, p.Tiers[0], take); err != nil { - t.Fatal(err) - } - if s := p.Modules["sudo"]; s.State != "asked" || !s.Key || !strings.Contains(s.Why, "waits for the reference") { - t.Fatalf("with the builder saying, the walk does not wait for the reference: %+v", s) - } - - // Not said: handed back, and the next look asks build-agent. - p, byName, keys := walk() - theBuilderSays(t, false, nil) - asked := askingBuildAgent(t) - changed, err := settleKeys(t.Context(), records, keys, p, p.Tiers[0], take) - if err != nil || !changed { - t.Fatalf("the walk was not judged again: %v %v", changed, err) - } - s := p.Modules["sudo"] - if s.Key || s.State != "" || p.State != inventory.PlanBuilding || !strings.Contains(s.Why, "does not say") { - t.Fatalf("the module is not handed back to be asked of build-agent: %+v %s", s, p.State) - } - for _, m := range unaskedOf(*p) { - askModule(t.Context(), p, m, byName) - } - if s := p.Modules["sudo"]; s.Key || s.Build != "build-9" || len(*asked) != 1 { - t.Fatalf("the next look did not ask build-agent: %+v, asked %v", s, *asked) - } -} - -// 1886/AC4: a key the running builder failed while it does not say it writes references fails no walk: the module is -// asked of build-agent, whose build is the one delivered. -func TestAFailedKeyOfABuilderThatDoesNotSayFailsNoWalk(t *testing.T) { - theBuilderSays(t, true, nil) - p, byName := aWalkOfOne("sudo") - askModule(t.Context(), p, "sudo", byName) - _ = byName - fp := "src2:" + strings.Repeat("f", 64) - theBuilderSays(t, false, nil) - keys := planKeysOf{values: map[string]sdkdelivery.Module{"sudo@c0ffee0123456789": {FP2: fp}}, - keys: map[string]builderkeys.Key{"sudo": {Module: "sudo", Fingerprint: fp, Found: true, - Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildFailed, Error: "not stored: tools: no reference"}}}} - if _, err := settleKeys(t.Context(), recordsOf{}, keys, p, p.Tiers[0], func(string, sdkdelivery.Module, string) {}); err != nil { - t.Fatal(err) - } - if s := p.Modules["sudo"]; p.State != inventory.PlanBuilding || s.State != "" || s.Key { - t.Fatalf("a failed key of a builder that does not say failed the walk: %s %q %+v", p.State, p.Note, s) - } -} - -// 1886/AC6 (ADR 0522 §6): the parts that write references, the builder and the build modules, are build-agent's own until -// R4g: asked of build-agent and never taken from keys they write themselves, whatever the builder says. -func TestTheReferenceWritersAreAskedOfBuildAgentWhateverTheBuilderSays(t *testing.T) { - theBuilderSays(t, true, nil) - asked := askingBuildAgent(t) - for _, m := range []string{"mesh-build-go", "mesh-build-node", "mesh-build-image", "mesh-delivery-builder"} { - p, byName := aWalkOfOne(m) - askModule(t.Context(), p, m, byName) - if s := p.Modules[m]; s.Key || s.Build != "build-9" { - t.Errorf("%s is not asked of build-agent: %+v", m, s) - } - } - if len(*asked) != 4 { - t.Fatalf("build-agent was asked %v", *asked) - } -} - -// 1886 review round 1 (ADR 0522 §2): the guard of the one ask function judges by the say askModule read, never a second -// read: a second read that fails, or that says otherwise, fails no plan. -func TestTheAskGuardUsesTheSayTheWalkRead(t *testing.T) { - was := askedModuleOf - t.Cleanup(func() { askedModuleOf = was }) - askedModuleOf = func(_ context.Context, _ buildSource, path string) (string, error) { - return strings.TrimPrefix(path, "modules/"), nil - } - reads := 0 - wasSays := builderSaysReferences - t.Cleanup(func() { builderSaysReferences = wasSays }) - for _, second := range []struct { - name string - says bool - err error - }{{"a second read that fails", false, errors.New("the bus did not answer")}, {"a say that turned true", true, nil}} { - reads = 0 - builderSaysReferences = func(context.Context) (bool, error) { - reads++ - if reads == 1 { - return false, nil - } - return second.says, second.err - } - wasAsk := askABuild - askABuild = func(ctx context.Context, source buildSource, path, _ string) (string, error) { - if err := onlyTheBuildersOwn(ctx, source, path); err != nil { - return "", err - } - return "build-9", nil - } - p, byName := aWalkOfOne("sudo") - askModule(t.Context(), p, "sudo", byName) - askABuild = wasAsk - if s := p.Modules["sudo"]; p.State == inventory.PlanFailed || s.Build != "build-9" || s.Key { - t.Errorf("%s: the walk %s, the module %+v", second.name, p.State, s) - } - } -} - -// 1886 merge check hang: the look must judge an ask and a settle by one say. A settle that read the say from another -// source than the ask (the bus's bucket, against the walk's read) handed a module back, the next ask took it from its key -// again, and the look's loop never ended: walk_from_keys_integration_test hung. -func TestTheSettleJudgesByTheSameSayAsTheAsk(t *testing.T) { - theBuilderSays(t, true, nil) - p, _ := aWalkOfOne("sudo") - p.Modules["sudo"] = &inventory.PlanModule{} - askModule(t.Context(), p, "sudo", map[string]inventory.Entry{"sudo": {Source: inventory.Source{Repository: "novox/mesh-catalog", Path: "modules/sudo", Ref: "main"}}}) - fp := "src2:" + strings.Repeat("1", 64) - keys := planKeysOf{values: map[string]sdkdelivery.Module{"sudo@c0ffee0123456789": {FP2: fp}}, - keys: map[string]builderkeys.Key{"sudo": {Module: "sudo", Fingerprint: fp, Found: true, - Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildBuilding, By: "shanks"}}}} - if _, err := settleKeys(t.Context(), recordsOf{}, keys, p, p.Tiers[0], func(string, sdkdelivery.Module, string) {}); err != nil { - t.Fatal(err) - } - if s := p.Modules["sudo"]; !s.Key || s.State != "asked" { - t.Fatalf("the settle judged by another say than the ask's: %+v", s) - } -} diff --git a/cmd/mesh-controller/builders_own_test.go b/cmd/mesh-controller/builders_own_test.go deleted file mode 100644 index 55aabaa9..00000000 --- a/cmd/mesh-controller/builders_own_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package main - -import ( - "context" - "errors" - "strings" - "testing" -) - -// A12 (ADR 0453 §5, ADR 0498 §7; issue 1851): the one function every ask goes through refuses any module but the -// builder's own, naming the builder's key it is taken from instead; the builder's own module passes the refusal. -func TestBuildAgentIsAskedForTheBuildersOwnModuleAlone(t *testing.T) { - was := askedModuleOf - t.Cleanup(func() { askedModuleOf = was }) - askedModuleOf = func(_ context.Context, _ buildSource, path string) (string, error) { - return strings.TrimPrefix(path, "modules/"), nil - } - _, err := askABuild(t.Context(), buildSource{Repository: "novox/mesh-catalog", Seat: "git"}, "modules/sudo", "main") - if !errors.Is(err, errNotTheBuildersOwn) || !strings.Contains(err.Error(), "sudo is taken from the builder's done key") || - !strings.Contains(err.Error(), "mesh-delivery-builder_builds") { - t.Fatalf("an ask of another module: %v", err) - } - if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, - "modules/mesh-delivery-builder"); err != nil { - t.Fatalf("the builder's own module is refused: %v", err) - } - askedModuleOf = func(context.Context, buildSource, string) (string, error) { return "", errors.New("no store") } - if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/x"}, ""); !errors.Is(err, errNotTheBuildersOwn) { - t.Fatalf("an ask whose module cannot be read went on: %v", err) - } -} - -// A12: a walk of the builder's own module and one other asks build-agent for the first alone; the other is taken from -// its key. -func TestAWalkAsksBuildAgentForTheBuildersOwnModuleAlone(t *testing.T) { - var asked []string - was := askABuild - askABuild = func(_ context.Context, _ buildSource, path, _ string) (string, error) { - asked = append(asked, path) - return "build-1", nil - } - t.Cleanup(func() { askABuild = was }) - p, byName := aWalkOfOne("sudo") - p.Tiers = [][]string{{"mesh-delivery-builder", "sudo"}} - byName["mesh-delivery-builder"] = byName["sudo"] - e := byName["mesh-delivery-builder"] - e.Source.Repository, e.Source.Path = "novox/mesh-delivery", "modules/mesh-delivery-builder" - byName["mesh-delivery-builder"] = e - for _, m := range p.Tiers[0] { - askModule(t.Context(), p, m, byName) - } - if len(asked) != 1 || asked[0] != "modules/mesh-delivery-builder" { - t.Fatalf("build-agent was asked for %v, want the builder's own module alone", asked) - } - if s := p.Modules["mesh-delivery-builder"]; s.Key || s.Build != "build-1" { - t.Fatalf("the builder's own module is not asked: %+v", s) - } - if s := p.Modules["sudo"]; !s.Key || s.Build != "" { - t.Fatalf("the other module is not taken from its key: %+v", s) - } -} - -// ADR 0520 §1 (issue 1851): until R4g build-agent and mesh-tools, the toolchain image its builds stand on, are build-agent's -// own, as the builder's module is: the builder writes no key of them, so a walk asks build-agent for them and reads no key; -// mesh-tools-go (its key from mesh-build-image) and any other module of the same tier are still taken from their keys. -func TestAWalkAsksBuildAgentForItselfAndMeshToolsAndReadsNoKeyOfThem(t *testing.T) { - own := []string{"build-agent", "mesh-tools"} - keyed := []string{"mesh-tools-go", "sudo"} - wasModule := askedModuleOf - askedModuleOf = func(_ context.Context, _ buildSource, path string) (string, error) { - return strings.TrimPrefix(path, "modules/"), nil - } - t.Cleanup(func() { askedModuleOf = wasModule }) - for _, m := range own { - if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/x", Seat: "git"}, "modules/"+m); err != nil { - t.Fatalf("an ask of %s is refused: %v", m, err) - } - } - if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/x", Seat: "git"}, "modules/mesh-tools-go"); !errors.Is(err, errNotTheBuildersOwn) { - t.Fatalf("an ask of mesh-tools-go went on: %v", err) - } - var asked []string - was := askABuild - askABuild = func(_ context.Context, _ buildSource, path, _ string) (string, error) { - asked = append(asked, path) - return "build-" + strings.TrimPrefix(path, "modules/"), nil - } - t.Cleanup(func() { askABuild = was }) - p, byName := aWalkOfOne("sudo") - p.Tiers = [][]string{append(append([]string{}, own...), keyed...)} - for _, m := range append(append([]string{}, own...), keyed...) { - e := byName["sudo"] - e.Source.Path = "modules/" + m - byName[m] = e - } - for _, m := range p.Tiers[0] { - askModule(t.Context(), p, m, byName) - } - if strings.Join(asked, " ") != "modules/build-agent modules/mesh-tools" { - t.Fatalf("build-agent was asked for %v", asked) - } - for _, m := range own { - if s := p.Modules[m]; s.Key || s.Build != "build-"+m || s.State != "asked" { - t.Fatalf("%s is not asked of build-agent, or is read from a key: %+v", m, s) - } - } - for _, m := range keyed { - if s := p.Modules[m]; !s.Key || s.Build != "" { - t.Fatalf("%s is not taken from its key: %+v", m, s) - } - } -} diff --git a/cmd/mesh-controller/chain_integration_test.go b/cmd/mesh-controller/chain_integration_test.go index ea086c9c..57a72b45 100644 --- a/cmd/mesh-controller/chain_integration_test.go +++ b/cmd/mesh-controller/chain_integration_test.go @@ -14,7 +14,6 @@ import ( "github.com/novox/mesh-controller/internal/chain" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/lease" - "github.com/novox/mesh-controller/internal/link" ) // **Acceptance 10 of design 47: one read of an assignment names the commit, the build and the rollout that put it there, @@ -80,7 +79,7 @@ func TestOneReadOfAnAssignmentNamesItsCommitBuildAndRolloutAndOneReadOfTheRollou if err != nil { t.Fatal(err) } - for try := 0; try < 3 && (asking.Modules["notes"] == nil || asking.Modules["notes"].Build == ""); try++ { + for try := 0; try < 6 && (asking.Modules["notes"] == nil || asking.Modules["notes"].Build == "" || asking.Modules["notes"].State == "asked"); try++ { advancePlans(ctx, open) if asking, err = inv.PlanByID(ctx, rollout); err != nil { t.Fatal(err) @@ -90,13 +89,6 @@ func TestOneReadOfAnAssignmentNamesItsCommitBuildAndRolloutAndOneReadOfTheRollou if buildID == "" || len(*asked) != 1 || !strings.HasSuffix((*asked)[0], "#"+buildID) { t.Fatalf("the walk asked %v, and notes' build is %q", *asked, buildID) } - digest := "sha256:" + strings.Repeat("a", 64) - result := outcome(buildID, "modules/notes", "c-notes", "notes", "") - result.ReuseKey = "rk-notes" - result.Made = []link.MadeArtifact{{Name: "notes", Kind: "image", Reference: "artifact-store://notes/notes@" + digest}} - if err := (builds{inv: inv, open: open}).Built(ctx, result); err != nil { - t.Fatal(err) - } // The walk sends the tier to its first machine. for try := 0; try < 3 && len(sent) == 0; try++ { @@ -116,9 +108,9 @@ func TestOneReadOfAnAssignmentNamesItsCommitBuildAndRolloutAndOneReadOfTheRollou if wireguard, pulled := assignmentOf(on, "mesh-wireguard"); pulled && (wireguard.Build != "" || wireguard.Rollout != "") { t.Fatalf("a module this rollout did not send names a build or a rollout: %+v", wireguard) } - if a.Module != "notes" || a.Commit != "c-notes" || a.Build != buildID || a.Rollout != rollout || a.Artifact != digest || + if a.Module != "notes" || a.Commit != "c-notes" || a.Build != buildID || a.Rollout != rollout || a.Since.IsZero() || a.Assigned.IsZero() || !a.Stands() { - t.Fatalf("the assignment names %+v, not commit c-notes, build %s, artifact %s, rollout %s", a, buildID, digest, rollout) + t.Fatalf("the assignment names %+v, not commit c-notes, build %s, rollout %s", a, buildID, rollout) } // One read of the rollout. p, err := inv.PlanByID(ctx, rollout) @@ -130,16 +122,11 @@ func TestOneReadOfAnAssignmentNamesItsCommitBuildAndRolloutAndOneReadOfTheRollou p.Steps[0].Node != "anchor" || p.Steps[0].Sent == nil { t.Fatalf("the rollout lists changed %+v, builds %v, steps %+v; want %+v", p.Changed, p.Builds, p.Steps, wantChange) } - // And one read of the build names the same commit, with what it published and its reuse key. + // And one read of the build names the same commit. built, found, err := inv.BuildByID(ctx, buildID) if err != nil || !found || built.Commit != "c-notes" || built.Module != "notes" { t.Fatalf("the build: %+v %v %v", built, found, err) } - lately, err := inv.Builds(ctx, "notes", 1) - if err != nil || len(lately) != 1 || lately[0].ReuseKey != "rk-notes" || lately[0].Published == nil || - lately[0].Published.Artifact != digest { - t.Fatalf("what the build published: %+v %v", lately, err) - } // The verbs say the links. if lines := changedLines(p); len(lines) != 2 || !strings.Contains(lines[1], "anchor/notes") || !strings.Contains(lines[1], buildID) { diff --git a/cmd/mesh-controller/chain_no_table_integration_test.go b/cmd/mesh-controller/chain_no_table_integration_test.go index 20181348..aab6ca2d 100644 --- a/cmd/mesh-controller/chain_no_table_integration_test.go +++ b/cmd/mesh-controller/chain_no_table_integration_test.go @@ -6,8 +6,6 @@ import ( "strings" "testing" "time" - - "github.com/novox/mesh-controller/internal/link" ) // **Acceptance 12 of design 47: no table in the controller's store holds builds, rollouts or assignments** (novox/hq ADR @@ -34,7 +32,7 @@ func TestNoTableOfTheControllersStoreHoldsBuildsRolloutsOrAssignments(t *testing if err != nil { t.Fatal(err) } - for try := 0; try < 3 && (asking.Modules["notes"] == nil || asking.Modules["notes"].Build == ""); try++ { + for try := 0; try < 6 && (asking.Modules["notes"] == nil || asking.Modules["notes"].Build == "" || asking.Modules["notes"].State == "asked"); try++ { advancePlans(ctx, open) if asking, err = inv.PlanByID(ctx, rollout); err != nil { t.Fatal(err) @@ -44,12 +42,6 @@ func TestNoTableOfTheControllersStoreHoldsBuildsRolloutsOrAssignments(t *testing if buildID == "" || len(*asked) != 1 { t.Fatalf("the walk asked %v, and notes' build is %q", *asked, buildID) } - digest := "sha256:" + strings.Repeat("b", 64) - result := outcome(buildID, "modules/notes", "c-notes", "notes", "") - result.Made = []link.MadeArtifact{{Name: "notes", Kind: "image", Reference: "artifact-store://notes/notes@" + digest}} - if err := (builds{inv: inv, open: open}).Built(ctx, result); err != nil { - t.Fatal(err) - } // What the live path made is in the chain. if built, found, err := inv.BuildByID(ctx, buildID); err != nil || !found || built.Commit != "c-notes" { diff --git a/cmd/mesh-controller/changeplan.go b/cmd/mesh-controller/changeplan.go index f6d3461e..da60adf4 100644 --- a/cmd/mesh-controller/changeplan.go +++ b/cmd/mesh-controller/changeplan.go @@ -190,32 +190,3 @@ func summaryOf(p link.ChangePlan) string { } return strings.Join(parts, "; ") } - -// planText is a change plan as a person reads it, for the pull request's comment. -func planText(p link.ChangePlan) string { - var b strings.Builder - fmt.Fprintf(&b, "change plan of %s at %.8s into %s: %s\n", p.Repository, p.Head, p.Base, p.Summary) - for i, tier := range p.Tiers { - fmt.Fprintf(&b, " tier %d: %s\n", i, strings.Join(tier, ", ")) - } - for _, m := range p.Machines { - line := " " + m.Machine + ": " - if len(m.Receives) > 0 { - line += "receives " + strings.Join(m.Receives, ", ") - } - if len(m.Waits) > 0 { - if len(m.Receives) > 0 { - line += "; " - } - line += "waits for a person: " + strings.Join(m.Waits, ", ") - } - b.WriteString(line + "\n") - } - for _, s := range p.Steps { - b.WriteString(" - " + s + "\n") - } - if len(p.Unread) > 0 { - fmt.Fprintf(&b, " read by no module's build: %s\n", strings.Join(p.Unread, ", ")) - } - return b.String() -} diff --git a/cmd/mesh-controller/changeplan_test.go b/cmd/mesh-controller/changeplan_test.go index 8e537227..bb250865 100644 --- a/cmd/mesh-controller/changeplan_test.go +++ b/cmd/mesh-controller/changeplan_test.go @@ -72,7 +72,4 @@ func TestAChangePlanSaysWhatEachMachineReceives(t *testing.T) { if !strings.HasPrefix(p.Summary, "builds nothing") || len(p.Machines) != 0 || strings.Join(p.Unread, ",") != "merge-check.sh" { t.Errorf("a root file's plan reads %+v", p) } - if !strings.Contains(planText(p), "read by no module's build: merge-check.sh") { - t.Errorf("the plan's text does not say why nothing is built:\n%s", planText(p)) - } } diff --git a/cmd/mesh-controller/check_builds.go b/cmd/mesh-controller/check_builds.go deleted file mode 100644 index 51b1c0dd..00000000 --- a/cmd/mesh-controller/check_builds.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "os" - - "github.com/novox/mesh-controller/internal/builder" - "github.com/novox/mesh-controller/internal/inventory" -) - -// A pull request's check builds go when it closes (novox/hq ADR 0350 §5): the build seat keeps them in the artifact -// store's scratch namespace for the walk of its merge to take. **Let go of where the moment is heard**: the -// controller ends the walk answering a merge — after that walk took what it reuses — and the delivery's owner, which -// hears the forge say a pull request closed unmerged, asks the `forget-checks` verb; both let go through the store, -// as the controller's own collection does. The build seat's holders let go of anything a week old on their own, -// so a word lost here leaves a week's builds, never more. - -// checkBuildsIn is the scratch namespace as the controller reaches the artifact store. -func checkBuildsIn(ctx context.Context, inv *inventory.Inventory) (builder.Scratch, error) { - shelf, err := inv.Catalogue(ctx) - if err != nil { - return nil, err - } - address, err := artifactStoreAddress(ctx, inv, shelf, "") - if err != nil { - return nil, err - } - if address == "" { - return nil, errors.New("the artifact store is not on the mesh's network") - } - return builder.Registry{Address: address}, nil -} - -// forgetChecks lets go of a pull request's check builds and answers what it did. -var forgetChecks = func(ctx context.Context, inv *inventory.Inventory, pull builder.PullRef) (string, error) { - scratch, err := checkBuildsIn(ctx, inv) - if err != nil { - return "", err - } - n, err := scratch.Forget(ctx, pull) - if err != nil { - return "", fmt.Errorf("%s: %d check(s)' builds let go of, and not all: %w", pull, n, err) - } - return fmt.Sprintf("%s: %d check(s)' builds let go of", pull, n), nil -} - -// forgetChecksOfWalk lets go, on the save that ends a walk, of the check builds of every pull request whose merge -// it answers: the walk took what it reuses. Said, never fatal. -func forgetChecksOfWalk(ctx context.Context, inv *inventory.Inventory, p inventory.Plan) { - if p.WalkSaid != inventory.WalkEnded || p.Delivery == nil { - return - } - for _, pull := range pullsOfWalk(&p) { - said, err := forgetChecks(ctx, inv, builder.PullRef{Owner: pull.Owner, Repo: pull.Repo, Number: pull.Number}) - if err != nil { - fmt.Fprintf(os.Stderr, "%s: its check builds stay until the week's sweep: %v\n", p.ID, err) - continue - } - fmt.Printf("%s: %s\n", p.ID, said) - } -} diff --git a/cmd/mesh-controller/check_builds_integration_test.go b/cmd/mesh-controller/check_builds_integration_test.go deleted file mode 100644 index 1e21463c..00000000 --- a/cmd/mesh-controller/check_builds_integration_test.go +++ /dev/null @@ -1,91 +0,0 @@ -//go:build integration - -package main - -import ( - "encoding/json" - "reflect" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -func TestAWalkNamesThePullRequestsOfItsMergesOnce(t *testing.T) { - p := &inventory.Plan{Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{ - {Repository: "novox/shop", Number: 7}, {Repository: "http://forge.invalid/novox/web.git", Number: 3}, - {Repository: "novox/shop", Number: 7}, {Repository: "novox/shop"}}}} - if want := []link.PullRef{{Owner: "novox", Repo: "shop", Number: 7}, {Owner: "novox", Repo: "web", Number: 3}}; !reflect.DeepEqual(pullsOfWalk(p), want) { - t.Fatalf("the walk names %+v", pullsOfWalk(p)) - } -} - -func TestAReusedCheckBuildIsRegisteredByItsDigestAndGatedAsABuiltOne(t *testing.T) { - withTheGate(t) // the first-machine gate's own path (novox/hq ADR 0360 keeps it for earlier walks) - g := aGateMesh(t) - ctx := t.Context() - inv := g.open.inventory - gateEvery = 0 - - digest := "sha256:" + strings.Repeat("c", 64) - reused := func(id, reference string) link.BuildResult { - manifest := mustJSON(t, catalogue.Manifest{Module: "app", Version: "c3", Resources: []map[string]any{ - {"id": "svc", "type": "container", "name": "app", "image": reference}}}) - return link.BuildResult{ID: id, Repository: "novox/mesh-catalog", Path: "modules/app", Ref: "main", On: "anchor", - Commit: "c3", Manifest: manifest, Trunk: "main", OnTrunk: true, Branches: []string{"main"}, - Made: []link.MadeArtifact{{Name: "server", Kind: "image", Reference: reference}}, - Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}, - Reused: "reused from the check of novox/mesh-catalog#9 at head abcdefabcdef (merged into trunk 123456123456): server " + digest, - ReusedFrom: &link.ReusedFrom{Pull: link.PullRef{Owner: "novox", Repo: "mesh-catalog", Number: 9}, - Head: "abcdefabcdef", Trunk: "123456123456", Key: "r1:" + strings.Repeat("1", 64)}} - } - if _, _, err := takeIn(ctx, inv, reused(link.NewBuildID(time.Now()), "store.internal:5000/scratch/app/server@"+digest)); err == nil || - !strings.Contains(err.Error(), "scratch") { - t.Fatalf("a build in the scratch namespace was registered: %v", err) - } - id := link.NewBuildID(time.Now().Add(time.Second)) - result := reused(id, "store.internal:5000/app/server@"+digest) - if _, _, err := takeIn(ctx, inv, result); err != nil { - t.Fatal(err) - } - current, err := inv.CurrentBuilds(ctx) - if err != nil || current["app"].Commit != "c3" { - t.Fatalf("the reused build is not what app is registered at: %+v %v", current["app"], err) - } - shelf, err := inv.Catalogue(ctx) - if err != nil { - t.Fatal(err) - } - raw, _ := json.Marshal(shelf["app"].Resources) - if !strings.Contains(string(raw), "app/server@"+digest) { - t.Fatalf("app is registered as %s, not by the check's digest in its own namespace", raw) - } - - // The walk asked it: the walk keeps what its build did, and its gate judges the registered build. - asked := time.Now().UTC() - p := g.plan(t) - p.Commit = "c3" - p.Modules["app"] = &inventory.PlanModule{State: "asked", AskedAt: &asked, Build: id} - if err := inv.SavePlan(ctx, &p); err != nil { - t.Fatal(err) - } - planBuiltSaying(ctx, g.open, "app", "c3", "", asked, id, result.MadeWords()) - p = g.plan(t) - if m := p.Modules["app"]; m.State != "built" || !strings.HasPrefix(m.Made, "reused from the check of novox/mesh-catalog#9") { - t.Fatalf("the walk keeps %+v", m) - } - for i := 0; i < 6; i++ { - advancePlans(ctx, g.open) - } - p = g.plan(t) - gate := p.Modules["app"].Gate - if gate == nil || gate.To != "c3" || gate.Verdict != inventory.GatePassed { - t.Fatalf("the gate judged %+v, not the reused build", gate) - } - if sent, _, _ := inv.SentBuilds(ctx, "anchor"); sent["app"] != "c3" { - t.Fatalf("the first machine was sent %v, not the reused build", sent) - } -} diff --git a/cmd/mesh-controller/check_builds_test.go b/cmd/mesh-controller/check_builds_test.go deleted file mode 100644 index f0219db1..00000000 --- a/cmd/mesh-controller/check_builds_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package main - -import ( - "context" - "errors" - "reflect" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/builder" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// One build per change (novox/hq ADR 0350), as the controller asks it and takes it in. - -// The check of a change is asked to build what a merge of it would move, in the walk's order: a module of the -// repository checked from the merge, one standing on it from its own repository as the walk builds it, and one -// that reads the repository through a context not at all. A change that moves no module builds nothing. -func TestACheckIsAskedToBuildWhatTheMergeWouldMove(t *testing.T) { - entries := []inventory.Entry{ - {Manifest: catalogue.Manifest{Module: "app"}, Source: inventory.Source{Repository: "novox/shop", Seat: "git", Path: "modules/app"}}, - {Manifest: catalogue.Manifest{Module: "web"}, Source: inventory.Source{Repository: "novox/web", Seat: "git", Ref: "main"}}, - {Manifest: catalogue.Manifest{Module: "docs"}, Source: inventory.Source{Repository: "novox/docs", Seat: "git"}}, - } - p := link.PullUpdated{Owner: "novox", Repo: "shop", Number: 7, Base: "main", Commit: "abc"} - scope := checkScope{Reach: mergeReach{Packaging: entries[2:], Plan: inventory.Plan{Tiers: [][]string{{"app", "docs"}, {"web"}}}}} - clone := func(s inventory.Source) (string, error) { return "http://forge.invalid/" + s.Repository + ".git", nil } - - got := checkBuildsOf(p, scope, entries, clone) - want := []link.CheckBuild{ - {Module: "app", Path: "modules/app", Merged: true, Tier: 0}, - {Module: "docs", Tier: 0, Skip: "its build reads novox/shop through a context, which a check cannot hand the merge"}, - {Module: "web", Repository: "http://forge.invalid/novox/web.git", Ref: "main", Tier: 1}, - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("the check is asked to build\n%+v\nwant\n%+v", got, want) - } - if got := checkBuildsOf(p, checkScope{}, entries, clone); got != nil { - t.Fatalf("a change that moves no module asks for builds: %+v", got) - } -} - -// A walk's ask carries its leave to take a check's build, naming the pull requests of the merges it answers; a dry -// run never does, and neither does an ask that is not a walk's. - -// A walk ended lets go of its merges' check builds — only once it ended, after it took what it reuses. -func TestAWalkEndedLetsGoOfItsMergesCheckBuilds(t *testing.T) { - var forgot []builder.PullRef - was := forgetChecks - forgetChecks = func(_ context.Context, _ *inventory.Inventory, pull builder.PullRef) (string, error) { - forgot = append(forgot, pull) - return "", nil - } - t.Cleanup(func() { forgetChecks = was }) - p := inventory.Plan{ID: "plan-1", Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{ - {Repository: "novox/shop", Number: 7}}}} - - p.WalkSaid = inventory.WalkStarted - forgetChecksOfWalk(t.Context(), nil, p) - p.WalkSaid = "" - forgetChecksOfWalk(t.Context(), nil, p) - if len(forgot) != 0 { - t.Fatalf("a walk not ended let go of %v, which it may still take", forgot) - } - p.WalkSaid = inventory.WalkEnded - forgetChecksOfWalk(t.Context(), nil, p) - if want := []builder.PullRef{{Owner: "novox", Repo: "shop", Number: 7}}; !reflect.DeepEqual(forgot, want) { - t.Fatalf("the walk ended let go of %v", forgot) - } -} - -// A build in the scratch namespace is never registered: only the walk's copy in the module's own namespace is. -func TestABuildInTheScratchNamespaceIsNeverRegistered(t *testing.T) { - if got := inScratch([]link.MadeArtifact{{Name: "server", Reference: "store.internal:5000/app/server@sha256:aa"}}); got != "" { - t.Fatalf("a module's own build reads as a scratch build: %s", got) - } - scratch := "store.internal:5000/scratch/app/server@sha256:aa" - if got := inScratch([]link.MadeArtifact{{Name: "server", Reference: scratch}}); got != scratch { - t.Fatalf("a scratch build was not found: %q", got) - } - if got := inScratch([]link.MadeArtifact{{Name: "look", Reference: "http://store.internal:5000/v2/scratch/app/look/blobs/sha256:bb"}}); got == "" { - t.Fatal("a scratch archive was not found") - } -} - -// The walk says per module what it did: "reused from …" or "built: " — and a build seat from before says -// neither, which is said. -func TestAWalksBuildSaysWhetherItReusedOrBuilt(t *testing.T) { - if got := (link.BuildResult{Reused: "reused from the check of novox/shop#7"}).MadeWords(); got != "reused from the check of novox/shop#7" { - t.Fatalf("said %q", got) - } - if got := (link.BuildResult{BuiltWhy: "no check built it"}).MadeWords(); got != "built: no check built it" { - t.Fatalf("said %q", got) - } - if got := (link.BuildResult{}).MadeWords(); !strings.Contains(got, "predates ADR 0350") { - t.Fatalf("said %q", got) - } -} - -// A walk's module the check's build stood in for is registered by the digest the check built — the copy in the -// module's namespace — and the walk keeps "reused from …" for it; its first-node gate judges that registered -// build as it judges one the walk built. A scratch reference is refused. Needs the store (MESH_TEST_POSTGRES), and -// is named in testdata/store-tests. - -// A check is handed only the builds earlier checks of its own pull request at its own head made: another pull -// request's build, or an earlier head's, is never kept (novox/hq issue 703). None when they cannot be read. -func TestACheckIsHandedOnlyItsOwnHeadsBuilds(t *testing.T) { - was := checkedBuildsOf - t.Cleanup(func() { checkedBuildsOf = was }) - pull := link.PullRef{Owner: "novox", Repo: "shop", Number: 7} - mine := link.CheckedBuild{Pull: pull, Check: "build-1", Head: "bbbbbbbbbbbb", Built: link.CheckBuilt{Module: "app", Key: "r1:1"}} - earlier := link.CheckedBuild{Pull: pull, Check: "build-0", Head: "aaaaaaaaaaaa", Built: link.CheckBuilt{Module: "app", Key: "r1:1"}} - another := link.CheckedBuild{Pull: link.PullRef{Owner: "novox", Repo: "shop", Number: 8}, Check: "build-9", - Head: "bbbbbbbbbbbb", Built: link.CheckBuilt{Module: "app", Key: "r1:1"}} - var asked [][]link.PullRef - checkedBuildsOf = func(_ context.Context, pulls []link.PullRef) ([]link.CheckedBuild, error) { - asked = append(asked, pulls) - return []link.CheckedBuild{mine, earlier, another}, nil - } - p := link.PullUpdated{Owner: "novox", Repo: "shop", Number: 7, Commit: "bbbbbbbbbbbb0123456789"} - builds := []link.CheckBuild{{Module: "app"}} - if got := namedForTheCheck(t.Context(), p, builds); !reflect.DeepEqual(got, []link.CheckedBuild{mine}) || - !reflect.DeepEqual(asked, [][]link.PullRef{{pull}}) { - t.Fatalf("the check is handed %+v, read for %v", got, asked) - } - checkedBuildsOf = func(context.Context, []link.PullRef) ([]link.CheckedBuild, error) { - return nil, errors.New("no bus") - } - if got := namedForTheCheck(t.Context(), p, builds); got != nil { - t.Fatalf("verdicts that could not be read named %+v", got) - } -} diff --git a/cmd/mesh-controller/check_here.go b/cmd/mesh-controller/check_here.go deleted file mode 100644 index 51e7c786..00000000 --- a/cmd/mesh-controller/check_here.go +++ /dev/null @@ -1,203 +0,0 @@ -package main - -import ( - "context" - "errors" - "flag" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/builder" - "github.com/novox/mesh-controller/internal/link" -) - -// `check-here` is a pull request's merge check run on the machine at hand **exactly as the build seat -// runs it** (novox/hq issue 286): the same code (builder.Check), the same ask the controller would make of -// the seat — the gate's modules and judge by the planner's own answer over the facts snapshot, the -// repositories beside it at the refs the snapshot says the seat clones them at — in the toolchain image -// the mesh holds, as the user the build seat runs as, against a throwaway store and bus of the versions -// the mesh runs. -// -// **The build seat is the reference.** A merge-check.sh that passed on an agent's machine failed on the -// seat for three reasons that were each the agent's environment, never the change: a newer Go whose gofmt -// lays a file out differently, a sibling checkout at the agent's feature branch where the seat had the -// commit the mesh runs, and a different user. Run here, a check sees what the seat will. -// -// check-here [--tree ] [--base main] [--registry ] [--forge ] -// [--number ] [--user uid:gid] [--facts store|] [--keep] -// -// The checkout's HEAD is what is checked, and it must be committed: the seat checks a commit, never a -// working tree. -func checkHereCommand(ctx context.Context, args []string) error { - set := flag.NewFlagSet("check-here", flag.ContinueOnError) - tree := set.String("tree", ".", "the change's checkout; its HEAD is checked") - base := set.String("base", "main", "the branch the change would merge into") - registry := set.String("registry", os.Getenv("MESH_REGISTRY"), "the artifact store holding the facts and the toolchains") - forge := set.String("forge", "", "where the repositories beside it are cloned from, as /.git; "+ - "the checkout's origin without its own name when not given") - number := set.Int("number", 0, "the pull request's number, when there is one") - // The build seat's service runs as root, and its check containers run as the builder does. - user := set.String("user", "0:0", "the user the check's containers run as: the build seat's") - keep := set.Bool("keep", false, "keep the workspace afterwards") - factsFrom := set.String("facts", "store", "the facts snapshot: `store`, the one the artifact store holds — "+ - "what the seat reads — or a file") - if _, err := parseAround(set, args); err != nil { - return err - } - if *registry == "" { - return errors.New("check-here reads the facts and the toolchains from the artifact store: --registry or MESH_REGISTRY") - } - dir, err := filepath.Abs(*tree) - if err != nil { - return err - } - git := func(args ...string) (string, error) { - cmd := exec.CommandContext(ctx, "git", args...) - cmd.Dir = dir - out, err := cmd.Output() - return strings.TrimSpace(string(out)), err - } - if dirty, err := git("status", "--porcelain", "--untracked-files=no"); err != nil { - return fmt.Errorf("%s is not a checkout: %w", dir, err) - } else if dirty != "" { - return errors.New("the checkout has changes not committed: the build seat checks a commit, so commit first") - } - head, err := git("rev-parse", "HEAD") - if err != nil { - return err - } - origin, err := git("remote", "get-url", "origin") - if err != nil { - return fmt.Errorf("the checkout has no origin to say which repository it is: %w", err) - } - owner, repo, prefix := ownerRepoOf(origin) - if *forge == "" { - *forge = prefix - } - if _, err := git("fetch", "--quiet", "origin", *base); err != nil { - return fmt.Errorf("cannot fetch %s to say what the change touches: %w", *base, err) - } - // Without rename detection, so a file moved out of a build source is said under its old name as well: - // its going is a change to the build that held it (novox/hq ADR 0267). - changedText, err := git("diff", "--name-only", "--no-renames", "origin/"+*base+"...HEAD") - if err != nil { - return err - } - var paths, removed []string - for _, p := range strings.Split(changedText, "\n") { - if p = strings.TrimSpace(p); p == "" { - continue - } - paths = append(paths, p) - if _, err := os.Stat(filepath.Join(dir, p)); os.IsNotExist(err) { - removed = append(removed, p) - } - } - - _ = os.Setenv("MESH_REGISTRY", *registry) - f, err := readFacts(ctx, *factsFrom) - if err != nil { - return fmt.Errorf("the facts snapshot cannot be read: %w", err) - } - entries, read, edges, err := graphOfFacts(f) - if err != nil { - return err - } - p := link.PullUpdated{Owner: owner, Repo: repo, Number: *number, Base: *base, Commit: head, Paths: paths, - Removed: removed, ModuleDirs: moduleDirsIn(dir, paths), ModuleDirsSaid: true} - scope := pullScope(p, entries, read, edges) - _, scriptErr := os.Stat(filepath.Join(dir, builder.CheckScript)) - if !scope.gated() && !scope.Mesh { - fmt.Printf("%s: %s, and the repository is not the mesh's: the build seat runs nothing for it\n", - owner+"/"+repo, noModuleTouched) - return nil - } - if !scope.gated() && scriptErr != nil { - fmt.Printf("%s: %s; %s\n", owner+"/"+repo, noModuleTouched, noMergeCheck) - return nil - } - - toolchains := map[string]string{} - for language, reference := range f.Versions.Toolchains { - toolchains[language] = catalogue.Rerouted(reference, *registry) - } - if len(toolchains) == 0 { - return errors.New("the facts snapshot names no toolchain: it was taken by a controller from before " + - "issue 286, and the seat's toolchain cannot be known here") - } - beside := map[string]builder.Beside{} - for d, ref := range f.Beside { - from := d - if d == "mesh-controller-main" { - from = "mesh-controller" - } - beside[d] = builder.Beside{Repository: strings.TrimSuffix(*forge, "/") + "/" + from + ".git", Ref: ref} - } - id := fmt.Sprintf("check-here-%d", time.Now().UnixNano()) - spec := builder.CheckSpec{ID: id, Repository: dir, Ref: head, Owner: owner, Repo: repo, Number: *number, - Paths: paths, Beside: beside, Modules: scope.Modules, New: scope.New, Manifests: scope.Manifests, - Base: *base, Judge: scope.Judge, Toolchain: toolchains["go"], Toolchains: toolchains, User: *user} - - workspace, err := os.MkdirTemp("", "mesh-check-here-") - if err != nil { - return err - } - if !*keep { - defer removeWorkspace(spec.Toolchain, workspace, *user) - } - fmt.Fprintf(os.Stderr, "checking %s/%s at %.8s as the build seat would, against the facts of %s, in %s\n", - owner, repo, head, f.Taken.Format(time.RFC3339), workspace) - v, err := builder.Check(ctx, builder.Command, spec, workspace, *registry, builder.GitCredential{}, - func(step, message string) { - if step != "output" { - fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message) - } - }) - if err != nil { - return fmt.Errorf("the check could not run — on the seat an error, never a pass: %w", err) - } - fmt.Println(v.Report) - fmt.Println() - fmt.Printf("mesh/merge-gate: %s — %s\n", strings.ToUpper(v.Gate.Verdict), v.Gate.Summary) - if v.Repo != nil { - fmt.Printf("mesh/repo-check: %s — %s\n", strings.ToUpper(v.Repo.Verdict), v.Repo.Summary) - } - for _, l := range []*builder.Layer{v.Gate, v.Repo} { - if l != nil && l.Verdict != "pass" && l.Verdict != "warning" { - return errors.New("the build seat would not pass this change") - } - } - return nil -} - -// ownerRepoOf reads owner, repository and the owner's URL from a remote: ssh://git@host:222/novox/mesh-host.git -// → novox, mesh-host, ssh://git@host:222/novox. -func ownerRepoOf(remote string) (string, string, string) { - trimmed := strings.TrimSuffix(strings.TrimSuffix(remote, "/"), ".git") - cut := strings.LastIndexAny(trimmed, "/:") - if cut < 0 { - return "", trimmed, "" - } - repo, prefix := trimmed[cut+1:], trimmed[:cut] - owner := prefix - if at := strings.LastIndexAny(prefix, "/:"); at >= 0 { - owner = prefix[at+1:] - } - return owner, repo, prefix -} - -// removeWorkspace removes what the check left, written as the seat's user: by a container of that user -// when it is not this one. -func removeWorkspace(image, workspace, user string) { - if image != "" && user != fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) { - // Everything in it — the check's HOME is the workspace, so the toolchain's own files are there too. - _ = exec.Command("docker", "run", "--rm", "--user", user, "--volume", workspace+":/workspace", image, - "sh", "-c", "rm -rf /workspace/* /workspace/.[!.]*").Run() - } - _ = os.RemoveAll(workspace) -} diff --git a/cmd/mesh-controller/check_pulls_test.go b/cmd/mesh-controller/check_pulls_test.go deleted file mode 100644 index c3baa27d..00000000 --- a/cmd/mesh-controller/check_pulls_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package main - -import ( - "reflect" - "testing" - - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// 1212/AC1 and AC2: the check request names the pulls bucket of each module claiming the delivery seat, in the order -// of the claimants' names. -func TestACheckNamesThePullsBucketsOfTheSeatsClaimants(t *testing.T) { - for name, c := range map[string]struct { - entries []inventory.Entry - want []string - }{ - "only the gate": {[]inventory.Entry{claimingTheSeat("mesh-delivery-gate"), namedOnly("mesh-delivery")}, []string{"mesh-delivery-gate_pulls"}}, - "only today's": {[]inventory.Entry{claimingTheSeat("mesh-delivery"), namedOnly("gitea")}, []string{"mesh-delivery_pulls"}}, - "both, name order": {[]inventory.Entry{claimingTheSeat("mesh-delivery-gate"), claimingTheSeat("mesh-delivery")}, []string{"mesh-delivery_pulls", "mesh-delivery-gate_pulls"}}, - "none": {[]inventory.Entry{namedOnly("gitea")}, nil}, - } { - if got := pullsBucketsOf(c.entries); !reflect.DeepEqual(got, c.want) { - t.Errorf("%s: %v, want %v", name, got, c.want) - } - } -} - -// 1212/AC1 and AC2: the check a request carries names those buckets (the wiring, not only the helper). -func TestTheCheckRequestCarriesThePullsBuckets(t *testing.T) { - for name, c := range map[string]struct { - entries []inventory.Entry - want []string - }{ - "only the gate": {[]inventory.Entry{claimingTheSeat("mesh-delivery-gate")}, []string{"mesh-delivery-gate_pulls"}}, - "only today's": {[]inventory.Entry{claimingTheSeat("mesh-delivery")}, []string{"mesh-delivery_pulls"}}, - "both, name order": {[]inventory.Entry{claimingTheSeat("mesh-delivery-gate"), claimingTheSeat("mesh-delivery")}, []string{"mesh-delivery_pulls", "mesh-delivery-gate_pulls"}}, - } { - got := checkOf(link.PullUpdated{Owner: "novox", Repo: "a", Number: 7}, checkScope{}, c.entries, nil, nil, nil, nil) - if !reflect.DeepEqual(got.Pulls, c.want) || got.Owner != "novox" || got.Number != 7 { - t.Errorf("%s: %+v, want pulls %v", name, got, c.want) - } - } -} diff --git a/cmd/mesh-controller/checks.go b/cmd/mesh-controller/checks.go index bbd6edc3..f3d84c34 100644 --- a/cmd/mesh-controller/checks.go +++ b/cmd/mesh-controller/checks.go @@ -2,9 +2,6 @@ package main import ( "context" - "encoding/json" - "fmt" - sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" "io" "os" "path" @@ -13,46 +10,18 @@ import ( "slices" "sort" "strings" - "time" - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) -// A pull request's merge check (novox/hq to-be 45 §9, ADR 0237 as amended 2026-10-06): the forge -// announces every pull request's new head, the controller decides what is checked, asks the build seat to -// check it, and says the verdict as `checked`, which the forge's holder sets as the pull request's -// statuses. **Before merge, never after**: every check the mesh had ran after a merge, on a machine. -// -// **The mesh's module graph decides, not the repository.** The controller holds the graph — every module, -// the repository and directory it is built from — and maps the pull request's changed paths onto it by -// the planner's own answer (reachOfMerge, touchedBy — the one place a changed file is mapped onto modules, -// for the merge handler, the release planner, the merge gate and this check; issue 280): **a changed file -// touches exactly the modules whose build reads it** — a module's own directory (the whole repository for -// one built from its root), or a repository its recipe packages. A file no build reads — a script at the -// root, a README — touches no module. A directory the change adds a module.json in, which the graph does -// not hold yet (said by the head, issue 278), is a new module and is checked too. -// -// - touches a module: the build seat runs **the gate** — `mesh/merge-gate`, the touched manifests, every -// machine composed with the change, the replays — and the repository's own merge-check.sh beside it; -// - touches none, in a repository that is the mesh's (it sources a module on some branch, or shares the -// core's owner): the gate is a pass that says so — a fact, not a missing check — and the repository's -// own merge-check.sh runs as `mesh/repo-check`, a warning when it has none; -// - touches none, anywhere else: the gate is a pass that says so, and nothing more is said. -// -// What is checked is decided here and run there (internal/builder/check.go). - -// checkTimeout is how long one check may run on the build seat. Said here so the ask's watchdog (S6) -// and the builder agree on what late means. -const checkTimeout = 45 * time.Minute - -// noModuleTouched is the gate's word for a change that touches nothing of the graph. -const noModuleTouched = "the change touches no module of the mesh's graph" - -// noMergeCheck is the repository layer's word for a repository of the mesh with no merge-check.sh. -const noMergeCheck = "the repository declares no merge-check.sh: none of its own tests run before it merges" +// What a pull request reaches of the mesh's graph (novox/hq to-be 45 §9, issue 280). The controller no longer checks a +// pull request or says a verdict on it (R4g, ADR 0503 §3, ADR 0501 §5): the checker judges the heads, and mesh-delivery +// asks the operator's Approve on its verdict. What stays here is the mapping of a pull request's changed paths onto the +// mesh's module graph by the planner's own answer (reachOfMerge, touchedBy), which the delivery verbs (`delivery order`, +// the change plan) and the head's declared config (tellPullDeclares) read: **a changed file touches exactly the modules +// whose build reads it** — a module's own directory (the whole repository for one built from its root), or a repository +// its recipe packages — and a directory the change adds a module.json in is a new module. // coreModules are the modules whose repositories are the mesh's core, by the directory a check finds // each beside it — and whose owner is the mesh's own. @@ -76,8 +45,6 @@ type checkScope struct { // Mesh says the repository is the mesh's: modules are built from it on some branch, its owner is the // core's, or the change adds a module to it. Mesh bool - // Judge is who judges the gate (link.JudgeSelf, or the running controller). - Judge string // From is a module built from the repository, for how the mesh clones it; nil when none is. From *inventory.Entry // Reach is the planner's whole answer, which the change plan is made from. @@ -99,10 +66,6 @@ func pullScope(p link.PullUpdated, entries []inventory.Entry, read map[string][] Width: len(r.Plan.Modules), Tiers: len(r.Plan.Tiers), Reach: r} for _, e := range append(append([]inventory.Entry{}, r.Touched...), r.Deleted...) { s.Manifests = append(s.Manifests, path.Join(strings.Trim(e.Source.Path, "/"), moduleManifestFile)) - switch e.Manifest.Module { - case "mesh-controller": - s.Judge = link.JudgeSelf - } } for _, d := range r.Added { s.Manifests = append(s.Manifests, path.Join(d, moduleManifestFile)) @@ -138,35 +101,29 @@ func sourceOwner(repository string) string { return strings.ToLower(parts[len(parts)-2]) } -// PullUpdated decides a pull request's merge check, and asks for it when there is something to run. +// PullUpdated hears a pull request's new head. It asks no check and says no verdict (pullUpdated). func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error { - _, err := f.pullUpdated(ctx, p, os.Stdout) - return err + return f.pullUpdated(ctx, p, os.Stdout) } -// pullUpdated is PullUpdated saying what it did to w. It returns the id of the check it asked of the build seat, or -// "" when it said a verdict itself and asked nothing. A command that answers JSON on standard output (`delivery -// check`) gives it standard error, so its output stays the document alone (novox/hq issue 1311). -func (f following) pullUpdated(ctx context.Context, p link.PullUpdated, w io.Writer) (string, error) { +// pullUpdated is PullUpdated saying what it did to w. It asks nothing of anybody and says no verdict (R4g; ADR 0503 §3, §5, +// ADR 0527): the checker judges every repository whose trunk requires `mesh/check`, hq included. All it still does is tell +// mesh-config what the head declares (ADR 0502), whatever judges it. +func (f following) pullUpdated(ctx context.Context, p link.PullUpdated, w io.Writer) error { inv := f.open.inventory entries, err := inv.Catalogued(ctx) if err != nil { - return "", err + return err } read, err := readForPlanning(ctx, inv) if err != nil { - return "", err + return err } edges, err := inv.Dependencies(ctx) if err != nil { - return "", err + return err } scope := pullScope(p, entries, read, edges) - // **The merge gate's checks stop** (novox/hq ADR 0503 §3, §5; issue 1299): the checker judges every repository whose - // trunk requires `mesh/check`. A repository whose trunk requires `mesh/repo-check` (hq), whatever else it requires - // (ADR 0513 §4), has its own merge-check.sh asked of build-agent, and said with that layer alone; any other is asked - // nothing and said nothing. What the forge's protection requires is read, never listed (lead decision L11). - // Mesh-config is still told what the head declares (ADR 0502), whatever judges it. this := pullAtOf(p) if this.Truncated { this.Paths, this.Truncated = truncatedPaths(entries, p.Owner, p.Repo, scope.Modules), false @@ -174,310 +131,7 @@ func (f following) pullUpdated(ctx context.Context, p link.PullUpdated, w io.Wri if scope.gated() || holdsModules(entries, p.Owner, p.Repo) { tellPullDeclares(ctx, w, []pullAt{this}) } - required, err := requiredStatusesOf(ctx, p.Owner, p.Repo, p.Base) - switch { - case err != nil: - fmt.Fprintf(w, "%s/%s#%d (%.8s): what %s's protection requires cannot be read, so nothing is asked and nothing is "+ - "said: %v\n", p.Owner, p.Repo, p.Number, p.Commit, p.Base, err) - return "", nil - case !asksRepoCheck(required): - fmt.Fprintf(w, "%s/%s#%d (%.8s): %s does not require %s, and %s; nothing is asked\n", p.Owner, p.Repo, p.Number, - p.Commit, p.Base, sdkdelivery.StatusContextRepoCheck, checkerWords(required)) - return "", nil - } - return f.repoCheckAlone(ctx, w, p, scope, entries) -} - -// asksRepoCheck says what a trunk's protection has the controller ask: its repository's own merge-check.sh whenever the -// trunk requires `mesh/repo-check`, whatever else it requires (ADR 0513 §4: a trunk requiring both has the checker judge -// `mesh/check` and the repository check asked beside it), and nothing otherwise. -func asksRepoCheck(required []string) bool { - return slices.Contains(required, sdkdelivery.StatusContextRepoCheck) -} - -// checkerWords says who judges a trunk that requires no `mesh/repo-check`. -func checkerWords(required []string) string { - if slices.Contains(required, sdkdelivery.StatusContextCheck) { - return "the checker judges its " + sdkdelivery.StatusContextCheck - } - return "does not require " + sdkdelivery.StatusContextCheck + " either, so nothing is said" -} - -// repoCheckAlone asks build-agent for a repository's own merge-check.sh alone, for a pull request of a repository whose -// trunk requires `mesh/repo-check`, whether or not it also requires `mesh/check` (ADR 0503 §5, ADR 0513 §4): no gate, no module built, its verdict said with the -// repo-check layer alone. -func (f following) repoCheckAlone(ctx context.Context, w io.Writer, p link.PullUpdated, scope checkScope, - entries []inventory.Entry) (string, error) { - inv := f.open.inventory - plan := changePlanOf(p.Owner+"/"+p.Repo, p.Base, p.Commit, scope.Reach, entries, func(module string) (inventory.Upgrade, bool) { - u, err := inv.UpgradeOf(ctx, module) - return u, err == nil - }) - fmt.Fprint(w, planText(plan)) - direct := directVerdict(p, plan) - switch { - case !scope.Mesh: - direct.RepoCheck = &link.CheckLayer{Verdict: "warning", Summary: "the repository is not the mesh's: nothing run"} - fmt.Fprintf(w, "%s/%s#%d (%.8s): the repository is not the mesh's: said, nothing run\n", p.Owner, p.Repo, p.Number, - p.Commit) - sayChecked(ctx, w, direct) - return "", nil - case p.MergeCheckSaid && !p.MergeCheck: - direct.RepoCheck = &link.CheckLayer{Verdict: "warning", Summary: noMergeCheck} - fmt.Fprintf(w, "%s/%s#%d (%.8s): %s\n", p.Owner, p.Repo, p.Number, p.Commit, noMergeCheck) - sayChecked(ctx, w, direct) - return "", nil - } - request, err := checkRequestFor(ctx, f.open, p, checkScope{Mesh: true, From: scope.From}, entries) - if err != nil { - return "", err - } - request.Check.Plan = &plan - seat := buildSeatHeld(ctx) - ask, err := askOverOn(seat) - if err != nil { - return "", err - } - defer ask.Close() - if err := ask.Ask(ctx, request); err != nil { - return "", err - } - fmt.Fprintf(w, "%s/%s#%d (%.8s): asked %s to run its own merge-check.sh alone, as %s\n", p.Owner, p.Repo, p.Number, - p.Commit, seat, request.ID) - return request.ID, nil -} - -// requiredStatusesOf is what the forge's protection of a repository's branch requires before a merge, as the forge -// picks the rule it applies (the one named for the branch, else the first glob covering it). A variable so a test needs -// no forge. -var requiredStatusesOf = func(ctx context.Context, owner, repo, branch string) ([]string, error) { - var rules struct { - Rules []protectionRule `json:"rules"` - } - js, err := aBus() - if err != nil { - return nil, err - } - defer js.Close() - raw, _ := json.Marshal(map[string]any{"owner": owner, "repo": repo}) - answer, err := link.Ask(ctx, link.OverNATS{Conn: js.Conn()}, "gitea", "gitea_branch_protection_get", raw, 30*time.Second) - if err != nil { - return nil, err - } - if answer.Error != "" { - return nil, fmt.Errorf("gitea.gitea_branch_protection_get: %s", answer.Error) - } - if err := json.Unmarshal(answer.Result, &rules); err != nil { - return nil, err - } - r := appliedRule(rules.Rules, branch) - if r == nil { - // empty-on-error: not an error; no rule covers the branch, so it requires nothing, which the caller says - return nil, nil - } - return r.RequiredStatuses, nil -} - -// composedWords is what a group member's own verdict says of its gate layer (novox/hq ADR 0403 clause 1). -const composedWords = "judged by its group's composed check" - -// gateComposed is a gate layer's verdict when no gate was run for the member alone: never a pass. Mesh-delivery reads it -// as the composed check's to judge, and the forge's holder sets no mesh/merge-gate from it. -const gateComposed = "composed" - -// composedGate says a group member's own verdict has no gate of its own: its gate layer, and the verdict that is -// the gate's, read "composed" in place of a pass. -func composedGate(c *link.Checked) { - c.Verdict, c.Summary = gateComposed, composedWords - c.Gate = &link.CheckLayer{Verdict: gateComposed, Summary: composedWords} -} - -// directVerdict is the verdict of a change no build judges — it touches no module, and either the repository is not -// the mesh's or the head holds no merge-check.sh. **It names the trunk commit it holds for** (novox/hq ADR 0323 §3): -// the base branch's head the forge announced with the head, so the merge tool can weigh it against the trunk's head -// and does not refuse it for ever (a verdict naming no trunk is never the trunk's head's). Nothing was built to -// judge: the trunk commit is the one the forge said the change was announced against. -func directVerdict(p link.PullUpdated, plan link.ChangePlan) link.Checked { - trunk := p.BaseSHA - if len(trunk) > 12 { - trunk = trunk[:12] - } - if trunk != "" { - plan.Trunk = trunk - } - return link.Checked{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Commit: p.Commit, - ID: link.NewBuildID(time.Now()), Verdict: "pass", Summary: noModuleTouched, - Gate: &link.CheckLayer{Verdict: "pass", Summary: noModuleTouched}, Plan: &plan, Trunk: trunk} -} - -// checkRequestFor is the ask for one pull request's head: the repository as the mesh clones it, the head, -// and what is read beside it. -func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scope checkScope, - entries []inventory.Entry) (link.BuildRequest, error) { - shelf := map[string]catalogue.Manifest{} - for _, e := range entries { - shelf[e.Manifest.Module] = e.Manifest - } - world, err := restOfTheMesh(ctx, open, shelf, "") - if err != nil { - return link.BuildRequest{}, err - } - clone := func(s inventory.Source) (string, error) { - if s.Seat == "" { - return s.Repository, nil - } - return clonedFromSeat(world, s.Seat, s.Repository) - } - // As the mesh clones a module built from it; a repository no module is built from, from the forge. - source := inventory.Source{Seat: gitSeat, Repository: p.Owner + "/" + p.Repo} - if scope.From != nil { - source = scope.From.Source - } - repository, err := clone(source) - if err != nil { - return link.BuildRequest{}, err - } - current, err := open.inventory.CurrentBuilds(ctx) - if err != nil { - return link.BuildRequest{}, err - } - beside, err := besideOf(entries, func(module string) string { return current[module].Commit }, clone) - if err != nil { - return link.BuildRequest{}, err - } - builds := checkBuildsOf(p, scope, entries, clone) - return link.BuildRequest{ - ID: link.NewBuildID(time.Now()), - Repository: repository, - Ref: p.Commit, - Held: heldBy(ctx), - Seats: seatBases(ctx), - Source: sourceOnSeat(source), - Check: checkOf(p, scope, entries, beside, builds, namedForTheCheck(ctx, p, builds), runningBy(ctx)), - }, nil -} - -// checkOf is the check a request carries: the pull request, what the change touches, and the buckets of the pull -// requests' keys of the modules claiming the delivery seat (novox/hq ADR 0437 §3, issue 1212). -func checkOf(p link.PullUpdated, scope checkScope, entries []inventory.Entry, beside map[string]link.CheckedOut, - builds []link.CheckBuild, named []link.CheckedBuild, running map[string]string) *link.CheckRequest { - return &link.CheckRequest{Pulls: pullsBucketsOf(entries), Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base, - Trunk: p.BaseSHA, Ready: p.Ready, Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents, - New: scope.New, Manifests: scope.Manifests, Judge: scope.Judge, Builds: builds, Named: named, Running: running} -} - -// besideOf is what a check reads beside its own repository, by the directory it is found under: each core repository -// by the module the mesh builds from it, at besideRefs. running is the commit the mesh runs of a module. -func besideOf(entries []inventory.Entry, running func(module string) string, - clone func(inventory.Source) (string, error)) (map[string]link.CheckedOut, error) { - beside := map[string]link.CheckedOut{} - for _, e := range entries { - dir, core := coreModules[e.Manifest.Module] - if !core || e.Provided || e.Source.Repository == "" { - continue - } - url, err := clone(e.Source) - if err != nil { - return nil, err - } - refs := besideRefs(dir, running(e.Manifest.Module)) - beside[dir] = link.CheckedOut{Repository: url, Ref: refs[dir]} - if dir == "mesh-controller" { - // The controller's main stays its main: it judges only a check the running controller predates. - beside["mesh-controller-main"] = link.CheckedOut{Repository: url, Ref: refs["mesh-controller-main"]} - if e.Source.Seat != "" { - for _, sibling := range []string{"mesh-lab", "mesh-sdk"} { - if url, err := clone(inventory.Source{Seat: e.Source.Seat, Repository: siblingOf(e.Source.Repository, - sibling)}); err == nil { - beside[sibling] = link.CheckedOut{Repository: url, Ref: refs[sibling]} - } - } - } - } - } - return beside, nil -} - -// trunkOf is the branch a module's source follows: main when it records none, or records a commit. -func trunkOf(s inventory.Source) string { - if t := strings.TrimSpace(followedBranch(s.Ref)); t != "" { - return t - } - return "main" -} - -// namedForTheCheck are the builds earlier checks of the same pull request at the same head named, read once for the -// check (novox/hq issue 703): asked again because only the trunk moved, the check keeps what they built of a key -// still the same. Another pull request's build, or one of an earlier head, is never kept. None when they cannot be -// read, and the check builds, as before. -func namedForTheCheck(ctx context.Context, p link.PullUpdated, builds []link.CheckBuild) []link.CheckedBuild { - if len(builds) == 0 || p.Commit == "" { - return nil - } - pull := link.PullRef{Owner: p.Owner, Repo: p.Repo, Number: p.Number} - read, err := checkedBuildsOf(ctx, []link.PullRef{pull}) - if err != nil { - fmt.Printf(" the earlier checks' builds of %s/%s#%d could not be read, so the check builds: %v\n", - p.Owner, p.Repo, p.Number, err) - // empty-on-error: said above; a check handed no named build builds every module, as before issue 703 - return nil - } - var named []link.CheckedBuild - for _, b := range read { - if b.Pull == pull && link.SameHead(b.Head, p.Commit) { - named = append(named, b) - } - } - return named -} - -// checkBuildsOf are the modules a merge of the change would build, in the walk's order, as the check builds them -// for the walk to take (novox/hq ADR 0350 §1): a module built from the repository checked, at the branch it merges -// into, from the merge the check makes; any other — a module standing on one — from its own repository at the -// branch it follows, as the walk builds it. A module whose build reads the repository checked through a context, -// or that the merge deletes, cannot be built as the walk would: said, and nothing after it is built. A change that -// moves no module builds nothing. -func checkBuildsOf(p link.PullUpdated, scope checkScope, entries []inventory.Entry, - clone func(inventory.Source) (string, error)) []link.CheckBuild { - if len(scope.Reach.Plan.Tiers) == 0 { - return nil - } - m := link.SourceMoved{Owner: p.Owner, Repo: p.Repo, Base: p.Base, CloneURL: p.CloneURL} - byName := map[string]inventory.Entry{} - for _, e := range entries { - byName[e.Manifest.Module] = e - } - skip := map[string]string{} - for _, e := range scope.Reach.Packaging { - skip[e.Manifest.Module] = "its build reads " + p.Owner + "/" + p.Repo + " through a context, which a check " + - "cannot hand the merge" - } - for _, e := range scope.Reach.Deleted { - skip[e.Manifest.Module] = "the merge deletes it" - } - var out []link.CheckBuild - for tier, names := range scope.Reach.Plan.Tiers { - for _, name := range names { - e, known := byName[name] - b := link.CheckBuild{Module: name, Tier: tier, Skip: skip[name]} - switch { - case !known: - b.Skip = "the catalogue does not hold it" - case b.Skip != "": - case sourceIs(e.Source, m): - b.Merged, b.Path = true, e.Source.Path - default: - url, err := clone(e.Source) - if err != nil { - b.Skip = "its repository cannot be cloned: " + err.Error() - break - } - b.Repository, b.Path, b.Ref = url, e.Source.Path, followedBranch(e.Source.Ref) - } - out = append(out, b) - } - } - return out + return nil } // besideRefs is the ref each repository is cloned at beside a check, by the directory it is found under: @@ -533,201 +187,5 @@ func sdkPinned() string { return "main" } -// siblingOf is another repository of the same owner: novox/mesh-controller → novox/mesh-lab. -func siblingOf(repository, name string) string { - if cut := strings.LastIndex(repository, "/"); cut >= 0 { - return repository[:cut+1] + name - } - return name -} - -// sourceOnSeat is a source's seat form, nil for one on no seat. -func sourceOnSeat(s inventory.Source) *link.SourceOnSeat { - if s.Seat == "" { - return nil - } - return &link.SourceOnSeat{Seat: s.Seat, Repository: s.Repository} -} - // checkEvents is where the serving controller says a check's verdict; nil in a command. var checkEvents link.Bus - -// maxCheckReport is how much of a check's report travels in its verdict: enough for the failures and -// the machines, never a log. -const maxCheckReport = 60 << 10 - -// checked says a merge check's verdict as the controller's `checked`. Nothing is recorded or -// registered: a check builds nothing (issue 240's rule for a dry run, kept for a check). -func checked(ctx context.Context, result link.BuildResult) { - // **A check stopped by its key says nothing** (novox/hq ADR 0406 §4, issue 974, review 2): it stopped because the head or - // the trunk commit moved on, and the newer request will judge. Said as an error it would name the live head — red - // statuses on the forge, a delivery refused — and written to the key it would stand in the newer request's way. - if result.Failed == link.SupersededByKey { - fmt.Printf("%s: stopped, its pull request's key moved on; the newer check will judge\n", result.ID) - return - } - sayChecked(ctx, os.Stdout, checkedOf(result)) -} - -// checkedOf is what a check's outcome says: each layer, and an error — never a pass — for a check that -// could not run. -func checkedOf(result link.BuildResult) link.Checked { - c := link.Checked{ID: result.ID, On: result.On, Commit: result.Ref} - if result.Checked != nil { - c.Owner, c.Repo, c.Number = result.Checked.Owner, result.Checked.Repo, result.Checked.Number - } - switch { - case result.Check != nil: - c.Verdict, c.Summary, c.Report = result.Check.Verdict, result.Check.Summary, result.Check.Report - c.Gate, c.RepoCheck = result.Check.Gate, result.Check.RepoCheck - // What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and the commits of the - // repositories read beside it. - c.Trunk, c.Beside, c.Took = result.Check.Trunk, result.Check.Beside, result.Check.Took - c.Built = result.Check.Built - c.Validator = result.Check.Validator - c.Siblings, c.SiblingsUnknown, c.JudgeFrom = result.Check.Siblings, result.Check.SiblingsUnknown, result.Check.JudgeFrom - if c.Gate == nil { - // A build seat from before the layers: its verdict is the gate's. - c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary} - } - case result.Failed != "": - // The check could not run: an error, never read as a pass — on both layers it was asked for. - c.Verdict, c.Summary = "error", "the check could not run: "+firstLine(result.Failed) - default: - c.Verdict, c.Summary = "error", "the build seat answered the check with no verdict" - } - if c.Verdict == "" { - c.Verdict = "error" - } - if result.Check == nil { - c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary} - c.RepoCheck = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary} - } - // **A group member's own check has no gate of its own** (novox/hq ADR 0403 clause 1): what ran was the repository's - // check alone, and its gate layer is "composed", never a pass. A failure or an error stays what it was. - if result.Checked != nil && result.Checked.Composed && result.Check != nil && c.Gate != nil && - (c.Gate.Verdict == "pass" || c.Gate.Verdict == "warning") { - composedGate(&c) - } - if result.Checked != nil && c.Gate != nil && len(c.Gate.Modules) == 0 { - c.Gate.Modules = append(append([]string{}, result.Checked.Modules...), prefixedAll("new:", result.Checked.New)...) - } - if result.Checked != nil && c.Gate != nil && len(c.Gate.Dependents) == 0 { - c.Gate.Dependents = result.Checked.Dependents - } - if result.Checked != nil { - c.Plan = result.Checked.Plan - if c.Plan != nil && c.Trunk != "" { - // The kept delivery plan records the trunk commit with its other inputs (ADR 0323 §3). - plan := *c.Plan - plan.Trunk = c.Trunk - c.Plan = &plan - } - // A delivery group's composed check (novox/hq ADR 0239): every head it judged, this one first. - if g := result.Checked; g.Group != "" { - c.Group = g.Group - // Every member is named with its trunk commit (novox/hq issue 1106): the seat's word, else the one the forge - // announced with the head, so the forge's merge tool never meets a member whose verdict names none. - c.Members = append(c.Members, link.CheckedMember{Owner: g.Owner, Repo: g.Repo, Number: g.Number, - Commit: result.Ref, Trunk: firstTrunk(c.Trunk, g.Trunk)}) - for _, m := range g.Members { - c.Members = append(c.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo, Number: m.Number, - Commit: m.Ref, Trunk: firstTrunk(memberTrunk(result.Check, m), m.Trunk)}) - } - } - } - for _, l := range []*link.CheckLayer{c.Gate, c.RepoCheck} { - if l != nil && l.Verdict == "" { - l.Verdict = "error" - } - } - // **A check run again says so** (novox/hq issue 594). The summaries are the lines the pull request - // shows, clipped by the forge to a few words, so they get only a short marker after the verdict's own - // words; the report gets the whole sentence. Layers copied, so the build's own outcome is left as it came. - if mark := link.RunMarker(result.Delivery); mark != "" { - c.Summary += " " + mark - for _, l := range []**link.CheckLayer{&c.Gate, &c.RepoCheck} { - if *l != nil { - layer := **l - layer.Summary += " " + mark - *l = &layer - } - } - c.Report = link.Restarted(result.Delivery) + "\n\n" + c.Report - } - if len(c.Report) > maxCheckReport { - c.Report = "…" + c.Report[len(c.Report)-maxCheckReport:] - } - return c -} - -// firstTrunk is the first trunk commit named, as twelve characters; empty when none is. -func firstTrunk(named ...string) string { - for _, n := range named { - if n != "" { - return twelve(n) - } - } - return "" -} - -// memberTrunk is the trunk commit the build seat merged one of a group's heads into (novox/hq ADR 0323 §6), empty -// when it said none. -func memberTrunk(outcome *link.CheckOutcome, m link.GroupMember) string { - if outcome == nil { - return "" - } - for _, j := range outcome.Members { - if strings.EqualFold(j.Owner, m.Owner) && strings.EqualFold(j.Repo, m.Repo) && strings.HasPrefix(m.Ref, j.Commit) { - return j.Trunk - } - } - return "" -} - -func prefixedAll(prefix string, items []string) []string { - out := make([]string, 0, len(items)) - for _, i := range items { - out = append(out, prefix+i) - } - return out -} - -// sayChecked says a merge check's verdict on the bus, where the forge's holder hears it. -func sayChecked(ctx context.Context, w io.Writer, c link.Checked) { - // **No merge-gate layer from R4e, for any repository** (ADR 0503 §3, §5; issue 1299): the forge's holder writes - // `mesh/repo-check` from the repo-check layer alone, and the checker owes `mesh/check`. - c.Gate = nil - repo := "none" - if c.RepoCheck != nil { - repo = strings.ToUpper(c.RepoCheck.Verdict) + " — " + c.RepoCheck.Summary - } - judged := "" - if c.Trunk != "" { - judged = " merged into trunk " + c.Trunk - } - fmt.Fprintf(w, "%s: %s/%s#%d at %.8s%s checked on %s: repository %s\n", c.ID, c.Owner, c.Repo, c.Number, - c.Commit, judged, orSomewhere(c.On), repo) - if checkEvents == nil { - return - } - body, err := json.Marshal(c) - if err != nil { - return - } - stating, stop := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) - defer stop() - if err := checkEvents.PublishSeatEvent(stating, link.MeshControllerSeat, link.KeyChecked, body); err != nil { - fmt.Fprintf(w, "%s: the verdict could not be said, so the pull request is not told it: %v\n", c.ID, err) - } -} - -// pullsBucketsOf are the buckets of the pull requests' keys a check names for the build agent: one per module -// claiming the delivery seat, in the order of their names (novox/hq ADR 0437 §3, issue 1212). -func pullsBucketsOf(entries []inventory.Entry) []string { - var out []string - for _, m := range claimantsOf(entries, catalogue.DeliverySeat) { - out = append(out, broker.BucketName(m, "pulls")) - } - return out -} diff --git a/cmd/mesh-controller/checks_integration_test.go b/cmd/mesh-controller/checks_integration_test.go deleted file mode 100644 index 447c4e2e..00000000 --- a/cmd/mesh-controller/checks_integration_test.go +++ /dev/null @@ -1,56 +0,0 @@ -//go:build integration - -package main - -import ( - "encoding/json" - "testing" - "time" - - "github.com/nats-io/nats.go" - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/link" - "github.com/novox/mesh-controller/internal/testbus" -) - -func TestACheckStoppedByItsKeyIsAnnouncedAsNoVerdict(t *testing.T) { - js, err := broker.Dial(testbus.URL(t)) - if err != nil { - t.Fatal(err) - } - t.Cleanup(js.Close) - if err := broker.AssertMeshStreams(js); err != nil { - t.Fatal(err) - } - legacy, err := js.Conn().JetStream() - if err != nil { - t.Fatal(err) - } - was := checkEvents - checkEvents = link.OverNATS{Conn: js.Conn(), JS: legacy} - t.Cleanup(func() { checkEvents = was }) - heard := make(chan *nats.Msg, 4) - sub, err := js.Conn().ChanSubscribe(link.SeatEventSubject(link.MeshControllerSeat, link.KeyChecked), heard) - if err != nil { - t.Fatal(err) - } - defer func() { _ = sub.Unsubscribe() }() - asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog", Number: 3} - checked(t.Context(), link.BuildResult{ID: "stopped", Ref: "abc", Checked: asked, Failed: link.SupersededByKey}) - checked(t.Context(), link.BuildResult{ID: "broken", Ref: "abc", Checked: asked, Failed: "the facts snapshot cannot be read"}) - select { - case m := <-heard: - var c link.Checked - _ = json.Unmarshal(m.Data, &c) - if c.ID != "broken" || c.Verdict != "error" { - t.Fatalf("the first verdict said is %+v; want only the broken check's error", c) - } - case <-time.After(5 * time.Second): - t.Fatal("nothing was said") - } - select { - case m := <-heard: - t.Fatalf("a second verdict was said: %s", m.Data) - case <-time.After(300 * time.Millisecond): - } -} diff --git a/cmd/mesh-controller/checks_stop_integration_test.go b/cmd/mesh-controller/checks_stop_integration_test.go deleted file mode 100644 index d0496f3a..00000000 --- a/cmd/mesh-controller/checks_stop_integration_test.go +++ /dev/null @@ -1,109 +0,0 @@ -//go:build integration - -package main - -import ( - "context" - "encoding/json" - "errors" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// C1 (ADR 0503 §3, §5; issue 1299, lead decision L11), with the store and a real build queue: a pull request of a -// repository whose trunk requires mesh/check asks build-agent nothing and says nothing; one whose trunk requires -// mesh/repo-check alone (hq) has its own merge-check.sh asked, and every verdict said names no gate layer; a protection -// that cannot be read asks and says nothing. -func TestThePullRequestChecksAreAskedOnlyForARepositoryTheCheckerDoesNotJudge(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - js, _ := aBuildQueue(t) - servingBus.Store(js) - t.Cleanup(func() { servingBus.Store(nil) }) - said := &saidOnTheBus{} - checkEvents = said - t.Cleanup(func() { checkEvents = nil }) - if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-controller", Version: "1"}, - inventory.Source{Repository: "novox/mesh-controller", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil { - t.Fatal(err) - } - // hq is cloned as a module built from it is, from its URL: no forge is held in this test. - if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "hq-docs", Version: "1"}, - inventory.Source{Repository: "http://forge.invalid/novox/hq.git", Ref: "main", Path: "docs", BuiltFrom: "c0", - Head: "c0"}); err != nil { - t.Fatal(err) - } - required := map[string][]string{"novox/mesh-controller": {"mesh/check"}, "novox/hq": {"mesh/repo-check"}} - was := requiredStatusesOf - requiredStatusesOf = func(_ context.Context, owner, repo, _ string) ([]string, error) { - if r, ok := required[owner+"/"+repo]; ok { - return r, nil - } - return nil, errors.New("the forge did not answer") - } - t.Cleanup(func() { requiredStatusesOf = was }) - queued := func() int { - q, err := link.ReadQueue(ctx, js, link.TheBuildMachine) - if err != nil { - t.Fatal(err) - } - return len(q.Asks) - } - pull := func(repo string, number int, mergeCheck bool) link.PullUpdated { - p := link.PullUpdated{Owner: "novox", Repo: repo, Number: number, Base: "main", Commit: "abcdefabcdefabcdefabcdefabcdefabcdefabcd", - BaseSHA: "1111111111111111111111111111111111111111", Paths: []string{"main.go"}, ModuleDirsSaid: true} - p.MergeCheck, p.MergeCheckSaid = mergeCheck, true - return p - } - f := following{open: open, oneShot: true} - - if err := f.PullUpdated(ctx, pull("mesh-controller", 7, true)); err != nil { - t.Fatal(err) - } - if n := queued(); n != 0 || len(said.events) != 0 { - t.Fatalf("0503 §3: a repository the checker judges asked %d check(s) and said %v", n, said.events) - } - if err := f.PullUpdated(ctx, pull("unread", 3, true)); err != nil { - t.Fatal(err) - } - if n := queued(); n != 0 || len(said.events) != 0 { - t.Fatalf("L11: a protection that cannot be read asked %d check(s) and said %v", n, said.events) - } - if err := f.PullUpdated(ctx, pull("hq", 9, true)); err != nil { - t.Fatal(err) - } - q, err := link.ReadQueue(ctx, js, link.TheBuildMachine) - if err != nil { - t.Fatal(err) - } - if len(q.Asks) != 1 || q.Asks[0].Request.Check == nil || len(q.Asks[0].Request.Check.Modules) != 0 || - q.Asks[0].Request.Check.Repo != "hq" { - t.Fatalf("0503 §5: hq's repository check is not the one ask: %+v", q.Asks) - } - // A head with no merge-check.sh is said at once, with the repo-check layer alone. - if err := f.PullUpdated(ctx, pull("hq", 10, false)); err != nil { - t.Fatal(err) - } - if len(said.events) != 1 { - t.Fatalf("hq's head with no merge-check.sh was said %d time(s)", len(said.events)) - } - var c link.Checked - if err := json.Unmarshal(said.bodies[0], &c); err != nil { - t.Fatal(err) - } - if c.Gate != nil || c.RepoCheck == nil || c.RepoCheck.Verdict != "warning" { - t.Fatalf("0503 §5: the verdict said names a gate layer, or no repo-check layer: %+v", c) - } - // A trunk requiring both (hq, ADR 0513 §4): the checker judges mesh/check, and the repository check is still asked. - required["novox/hq"] = []string{"mesh/check", "mesh/repo-check"} - if err := f.PullUpdated(ctx, pull("hq", 11, true)); err != nil { - t.Fatal(err) - } - if q, err := link.ReadQueue(ctx, js, link.TheBuildMachine); err != nil || len(q.Asks) != 2 || - q.Asks[1].Request.Check == nil || q.Asks[1].Request.Check.Repo != "hq" { - t.Fatalf("0513 §4: a trunk requiring both had no repository check asked: %+v %v", q.Asks, err) - } -} diff --git a/cmd/mesh-controller/checks_test.go b/cmd/mesh-controller/checks_test.go index f804eb06..96d134cb 100644 --- a/cmd/mesh-controller/checks_test.go +++ b/cmd/mesh-controller/checks_test.go @@ -1,7 +1,6 @@ package main import ( - "errors" "strings" "testing" @@ -76,8 +75,8 @@ func TestThePullRequestIsMappedOntoTheModuleGraph(t *testing.T) { t.Errorf("%s: %s %q, wanted %q", c.what, what, got[i], want[i]) } } - if s.Mesh != c.mesh || s.Judge != c.judge { - t.Errorf("%s: the mesh's %v judged by %q, wanted %v by %q", c.what, s.Mesh, s.Judge, c.mesh, c.judge) + if s.Mesh != c.mesh { + t.Errorf("%s: the mesh's %v, wanted %v", c.what, s.Mesh, c.mesh) } if s.gated() != (c.modules != "" || c.new != "") { t.Errorf("%s: gated %v", c.what, s.gated()) @@ -95,28 +94,6 @@ func TestAPullRequestTouchesWhatPackagesItsRepository(t *testing.T) { } } -// Each layer is said; a check that could not run is an error on both, never a pass; a build seat from -// before the layers is read as the gate. -func TestAChecksLayersAreEachSaidAndAnErrorIsNeverAPass(t *testing.T) { - asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog", Number: 3, Modules: []string{"gitea"}} - c := checkedOf(link.BuildResult{ID: "b", Ref: "abc", Checked: asked, Failed: "the facts snapshot cannot be read"}) - if c.Verdict != "error" || c.Gate == nil || c.Gate.Verdict != "error" || c.RepoCheck == nil || c.RepoCheck.Verdict != "error" { - t.Fatalf("a check that could not run said %+v", c) - } - if strings.Join(c.Gate.Modules, ",") != "gitea" { - t.Errorf("the gate names %v", c.Gate.Modules) - } - c = checkedOf(link.BuildResult{ID: "b", Ref: "abc", Checked: asked, Check: &link.CheckOutcome{Verdict: "warning", Summary: "wide"}}) - if c.Gate == nil || c.Gate.Verdict != "warning" || c.RepoCheck != nil { - t.Fatalf("an outcome without layers said %+v", c) - } - c = checkedOf(link.BuildResult{ID: "b", Ref: "abc", Checked: asked, Check: &link.CheckOutcome{Verdict: "pass", - Gate: &link.CheckLayer{Verdict: "pass"}, RepoCheck: &link.CheckLayer{Verdict: "fail", Summary: "its merge-check.sh failed"}}}) - if c.RepoCheck.Verdict != "fail" || c.Gate.Verdict != "pass" { - t.Fatalf("the layers said %+v / %+v", c.Gate, c.RepoCheck) - } -} - // **The check asks the planner, never a mapping of its own** (novox/hq ADR 0238): what a pull request // reaches is reachOfMerge's answer — the same that plans a merge — so a file at the root touches no // module in both, and what stands on a touched module is named as its dependents in both. @@ -171,166 +148,5 @@ func TestAPullRequestReachesWhatAMergeOfItWouldPlan(t *testing.T) { } } -// **Only a commit on the trunk is published** (novox/hq ADR 0238): a build of a commit off its repository's -// default branch — a pull request's head, a branch built by hand, a rebuild or replay of one — is recorded -// and never registered, so nothing can send it; a check or a dry run never is either. -func TestABuildOffTheTrunkIsNeverPublished(t *testing.T) { - on := link.BuildResult{ID: "b", Commit: "abc", Trunk: "main", OnTrunk: true} - if err := publishable(on, ""); err != nil { - t.Errorf("a build on the trunk was refused: %v", err) - } - off := link.BuildResult{ID: "b", Commit: "abc", Trunk: "main"} - if err := publishable(off, ""); !errors.Is(err, errOffTheTrunk) || !strings.Contains(err.Error(), "main") { - t.Errorf("a build off the trunk was let through: %v", err) - } - for _, r := range []link.BuildResult{ - {ID: "c", Trunk: "main", OnTrunk: true, Check: &link.CheckOutcome{Verdict: "pass"}}, - {ID: "d", Trunk: "main", OnTrunk: true, Checked: &link.CheckRequest{}}, - {ID: "e", Trunk: "main", OnTrunk: true, DryRun: true}, - } { - if publishable(r, "") == nil { - t.Errorf("%s, a check or a dry run, was publishable", r.ID) - } - } - // A module that follows a branch other than the default: that branch is its trunk, and the default - // is not. - follows := link.BuildResult{ID: "g", Commit: "abc", Trunk: "master", Branches: []string{"nox-mesh"}} - if err := publishable(follows, "nox-mesh"); err != nil { - t.Errorf("a commit on the branch a module follows was refused: %v", err) - } - if err := publishable(link.BuildResult{ID: "h", Commit: "abc", Trunk: "master", OnTrunk: true, - Branches: []string{"master"}}, "nox-mesh"); err == nil { - t.Error("a commit on the default but not on the branch the module follows was published") - } - if err := publishable(link.BuildResult{ID: "i", Commit: "abc", Trunk: "main", Branches: []string{"feat/x"}}, ""); err == nil { - t.Error("a feature branch's commit was published") - } - // A build seat older than the rule says nothing: let through and said, so the rule can reach the mesh. - if err := publishable(link.BuildResult{ID: "f", Commit: "abc"}, ""); err != nil { - t.Errorf("a build seat that said nothing was refused: %v", err) - } -} - -// **A verdict names the trunk commit it judged** (novox/hq ADR 0323 §3, issue 341): the controller's `checked` -// carries the head and the trunk commit the build seat merged it into, the commits of the repositories read beside -// it, how long it took, and the kept delivery plan records the trunk commit with its other inputs. A group's -// composed check names, per member, the trunk commit that head was merged into (§6). -func TestAVerdictNamesTheTrunkCommitItJudged(t *testing.T) { - plan := &link.ChangePlan{Repository: "novox/mesh-host", Base: "main", Head: "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - Summary: "builds mesh-host"} - asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-host", Number: 66, Base: "main", Plan: plan} - c := checkedOf(link.BuildResult{ID: "b", Ref: "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Checked: asked, - Check: &link.CheckOutcome{Verdict: "pass", Took: "4m12s", Head: "dfb1415aaaaa", Trunk: "d28ca95bbbbb", - Gate: &link.CheckLayer{Verdict: "pass"}, RepoCheck: &link.CheckLayer{Verdict: "pass"}, - Beside: map[string]string{"mesh-catalog": "0123456789ab", "mesh-lab": "ba9876543210"}}}) - if c.Trunk != "d28ca95bbbbb" || c.Commit != "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { - t.Fatalf("the verdict names head %s and trunk %q", c.Commit, c.Trunk) - } - if c.Beside["mesh-catalog"] != "0123456789ab" || c.Beside["mesh-lab"] != "ba9876543210" { - t.Fatalf("the verdict names the repositories read beside it as %v", c.Beside) - } - if c.Took != "4m12s" { - t.Fatalf("the verdict says it took %q", c.Took) - } - if c.Plan == nil || c.Plan.Trunk != "d28ca95bbbbb" || c.Plan.Head != plan.Head { - t.Fatalf("the kept plan records %+v", c.Plan) - } - if plan.Trunk != "" { - t.Fatal("the plan the controller asked with was changed: the verdict's is a copy") - } - - group := &link.CheckRequest{Owner: "novox", Repo: "mesh-controller", Number: 7, Base: "main", Group: "feat/x", - Members: []link.GroupMember{{Owner: "novox", Repo: "mesh-host", Number: 8, Ref: "cccccccccccc0000", Base: "main"}}} - c = checkedOf(link.BuildResult{ID: "g", Ref: "aaaaaaaaaaaa0000", Checked: group, Check: &link.CheckOutcome{ - Verdict: "pass", Head: "aaaaaaaaaaaa", Trunk: "111111111111", Gate: &link.CheckLayer{Verdict: "pass"}, - Members: []link.CheckedMember{{Owner: "novox", Repo: "mesh-host", Commit: "cccccccccccc", Trunk: "222222222222"}}}}) - if len(c.Members) != 2 || c.Members[0].Trunk != "111111111111" || c.Members[1].Trunk != "222222222222" || - c.Members[1].Commit != "cccccccccccc0000" { - t.Fatalf("the composed check names its members %+v", c.Members) - } -} - -// **A group's composed verdict names every member's trunk commit** (novox/hq issue 1106): a member the build seat named no -// trunk for (an SDK member whose base the seat did not merge, or a seat from before the rule) is named by the trunk the forge -// announced with its head, and the primary the same, so the merge tool never reads "names no trunk commit" on a member -// whose trunk did not move. -func TestAGroupVerdictNamesEveryMembersTrunkTheSDKMemberToo(t *testing.T) { - group := &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog", Number: 7, Base: "main", Group: "feat/x", - Trunk: "dddddddddddd0000", - Members: []link.GroupMember{ - {Owner: "novox", Repo: "mesh-sdk", Number: 8, Ref: "cccccccccccc0000", Base: "main", Trunk: "eeeeeeeeeeee0000"}, - {Owner: "novox", Repo: "mesh-host", Number: 9, Ref: "bbbbbbbbbbbb0000", Base: "main", Trunk: "ffffffffffff0000"}}} - c := checkedOf(link.BuildResult{ID: "g", Ref: "aaaaaaaaaaaa0000", Checked: group, Check: &link.CheckOutcome{ - Verdict: "pass", Head: "aaaaaaaaaaaa", Gate: &link.CheckLayer{Verdict: "pass"}, - Members: []link.CheckedMember{{Owner: "novox", Repo: "mesh-host", Commit: "bbbbbbbbbbbb", Trunk: "222222222222"}}}}) - want := []string{"dddddddddddd", "eeeeeeeeeeee", "222222222222"} - if len(c.Members) != 3 { - t.Fatalf("the composed check names its members %+v", c.Members) - } - for i, m := range c.Members { - if m.Trunk != want[i] { - t.Fatalf("member %d (%s/%s) is named with trunk %q, want %q (the seat's word first, else the announced one): %+v", - i, m.Owner, m.Repo, m.Trunk, want[i], c.Members) - } - } -} - -// **The merge made for a check is never registered** (novox/hq ADR 0323 §2): a build of it — a commit on no branch -// of the forge — is refused, naming the trunk. The rule of ADR 0238 §1 stands unchanged; this holds it for the -// merge commit a check makes. -func TestABuildOfACheckMergeIsRefusedNamingTheTrunk(t *testing.T) { - merge := link.BuildResult{ID: "b", Commit: "4b5c6d7e8f90aabbccddeeff0011223344556677", Trunk: "main"} - err := publishable(merge, "") - if !errors.Is(err, errOffTheTrunk) || !strings.Contains(err.Error(), "not on main") { - t.Fatalf("a build of a check's merge was answered %v", err) - } -} - -// **A verdict no build judges names its trunk commit too** (novox/hq ADR 0323 §3): a change touching no module of a -// repository with no merge-check.sh (novox/mesh-tools-go) is answered by the controller itself. Naming no trunk, the -// merge tool refused it, asked again, got the same verdict, and refused for ever. -func TestADirectVerdictNamesTheTrunkTheForgeAnnounced(t *testing.T) { - p := link.PullUpdated{Owner: "novox", Repo: "mesh-tools-go", Number: 3, Base: "main", Commit: "1a2b3c4d5e6f7a8b9c0d", - BaseSHA: "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b"} - c := directVerdict(p, link.ChangePlan{Repository: "novox/mesh-tools-go", Base: "main", Head: p.Commit}) - if c.Trunk != "7a8b9c0d1e2f" || c.Plan == nil || c.Plan.Trunk != "7a8b9c0d1e2f" { - t.Fatalf("the direct verdict names trunk %q, its plan %+v", c.Trunk, c.Plan) - } - if c.Verdict != "pass" || c.Commit != p.Commit { - t.Fatalf("the direct verdict is %+v", c) - } - if c := directVerdict(link.PullUpdated{Owner: "novox", Repo: "x", Commit: "abc"}, link.ChangePlan{}); c.Trunk != "" { - t.Fatalf("a forge that said no base head gave the trunk %q", c.Trunk) - } -} - -// A head checked again through mesh-delivery (`delivery check --members`, a recheck) reaches the same direct verdict: -// it names the base head the forge last announced, kept on the member, or the merge tool would refuse it for ever. -func TestARecheckedHeadsDirectVerdictNamesItsTrunk(t *testing.T) { - m := orderMember{ID: "novox/mesh-tools-go@1a2b3c4d5e6f", Repository: "novox/mesh-tools-go", Base: "main", - Head: "1a2b3c4d5e6f7a8b9c0d", Number: 3, BaseSHA: "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b"} - if c := directVerdict(m.pull(), link.ChangePlan{}); c.Trunk != "7a8b9c0d1e2f" { - t.Fatalf("a rechecked head's direct verdict names trunk %q", c.Trunk) - } -} - // A check stopped by its pull request's key moving on says no verdict (issue 974, review 2): an error said for it would name the // live head, and the newer request is the one that judges. A check that failed for any other reason still says its error. - -// C2 (ADR 0513 §4, ADR 0503 §5; issue 1299): a trunk requiring mesh/repo-check has its repository check asked, whether or -// not it also requires mesh/check (hq requires both); a trunk requiring mesh/check alone, or neither, is asked nothing. -func TestARepositoryCheckIsAskedWheneverTheTrunkRequiresIt(t *testing.T) { - for _, c := range []struct { - required []string - asks bool - }{ - {[]string{"mesh/check", "mesh/repo-check"}, true}, - {[]string{"mesh/repo-check"}, true}, - {[]string{"mesh/check"}, false}, - {nil, false}, - } { - if got := asksRepoCheck(c.required); got != c.asks { - t.Fatalf("a trunk requiring %v asks its repository check: %v, want %v", c.required, got, c.asks) - } - } -} diff --git a/cmd/mesh-controller/composed_verdict_test.go b/cmd/mesh-controller/composed_verdict_test.go deleted file mode 100644 index 58e4ee36..00000000 --- a/cmd/mesh-controller/composed_verdict_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package main - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/novox/mesh-controller/internal/link" -) - -// A delivery group's gate is the composed check's, and its members' own checks run their repository's check alone -// (novox/hq ADR 0403 clauses 1 and 7). Pure: no store, no bus. - -// The heads of a group's members, by repository. -const ( - headHost = "1111111111111111111111111111111111111111" - headController = "2222222222222222222222222222222222222222" -) - -// A composed member's verdict never says its gate passed: the gate layer, and the verdict that is the gate's, read -// "composed"; a failure or an error stays what it was. -func TestAComposedMembersVerdictHasNoPassingGate(t *testing.T) { - asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-host", Number: 114, Composed: true} - pass := link.BuildResult{ID: "b", Ref: headHost, Checked: asked, Check: &link.CheckOutcome{Verdict: "pass", Summary: "no module touched", - Gate: &link.CheckLayer{Verdict: "pass", Summary: "no module touched"}, RepoCheck: &link.CheckLayer{Verdict: "pass", Summary: "ok"}}} - c := checkedOf(pass) - if c.Gate == nil || c.Gate.Verdict != "composed" || c.Verdict != "composed" || c.RepoCheck == nil || c.RepoCheck.Verdict != "pass" { - t.Fatalf("the member's verdict says %+v gate %+v", c, c.Gate) - } - asked.Composed = false - if c := checkedOf(pass); c.Gate.Verdict != "pass" || c.Verdict != "pass" { - t.Fatalf("a member of no composed group has its own gate: %+v", c.Gate) - } - asked.Composed = true - failing := pass - failing.Check = &link.CheckOutcome{Verdict: "fail", Summary: "conflict", Gate: &link.CheckLayer{Verdict: "fail", Summary: "conflict"}, - RepoCheck: &link.CheckLayer{Verdict: "fail", Summary: "conflict"}} - if c := checkedOf(failing); c.Gate.Verdict != "fail" || c.Verdict != "fail" { - t.Fatalf("a failure was read as composed: %+v", c.Gate) - } - broken := link.BuildResult{ID: "b", Ref: headHost, Checked: asked, Failed: "the facts snapshot cannot be read"} - if c := checkedOf(broken); c.Verdict != "error" || c.Gate.Verdict != "error" { - t.Fatalf("an error was read as composed: %+v", c) - } -} - -// The composed verdict on the bus carries every member, each with its trunk commit, for the forge's holder to set each -// member's mesh/merge-gate from it. -func TestTheComposedVerdictCarriesEveryMember(t *testing.T) { - asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-host", Number: 114, Group: "feat/x", - Members: []link.GroupMember{{Owner: "novox", Repo: "mesh-controller", Number: 346, Ref: headController}}} - c := checkedOf(link.BuildResult{ID: "g", Ref: headHost, Checked: asked, Check: &link.CheckOutcome{Verdict: "pass", Summary: "ok", - Trunk: "aaaaaaaaaaaa", Members: []link.CheckedMember{{Owner: "novox", Repo: "mesh-controller", Commit: headController[:12], Trunk: "bbbbbbbbbbbb"}}}}) - body, _ := json.Marshal(c) - for _, want := range []string{`"group":"feat/x"`, `"members":[{"owner":"novox","repo":"mesh-host","number":114,"commit":"` + headHost + `","trunk":"aaaaaaaaaaaa"}`, - `{"owner":"novox","repo":"mesh-controller","number":346,"commit":"` + headController + `","trunk":"bbbbbbbbbbbb"}`} { - if !strings.Contains(string(body), want) { - t.Fatalf("the composed verdict lacks %s:\n%s", want, body) - } - } -} diff --git a/cmd/mesh-controller/delivery.go b/cmd/mesh-controller/delivery.go index 44d6c0f0..cad30ba5 100644 --- a/cmd/mesh-controller/delivery.go +++ b/cmd/mesh-controller/delivery.go @@ -815,18 +815,21 @@ func deliveryPlanDocument(m orderMember, entries []inventory.Entry, read map[str // delivery walks [-n 50] [--plan ] func deliveryCommand(ctx context.Context, args []string) error { if len(args) == 0 { - return errors.New("delivery order|check|go|stop|walks") + return errors.New("delivery order|go|stop|walks") } sub, rest := args[0], args[1:] if sub == "plan" { // Removed (ADR 0450 §8; issue 1318): refused by name before any flag is read, naming its replacement. return removedByName("delivery-plan") } + if sub == "check" { + // Removed (ADR 0503 §3, ADR 0527; issue 1318): the checker judges every head, so the controller asks nothing. + return removedByName("delivery-check") + } set := flag.NewFlagSet("delivery "+sub, flag.ContinueOnError) membersJSON := set.String("members", "", "a group's members, as JSON") membersFile := set.String("members-file", "", "a file of a group's members, as JSON: the same as --members, for a list "+ "too long for an argument (novox/hq issue 1272)") - group := set.String("group", "", "a delivery group's id") ready := set.Bool("ready", false, "the head's pull request is marked ready: its check is queued before every other check "+ "(novox/hq ADR 0406 §3)") by := set.String("by", catalogue.DeliverySeat, "who says it") @@ -883,25 +886,6 @@ func deliveryCommand(ctx context.Context, args []string) error { return fmt.Errorf("what the members say of their manifests cannot be read: %w", err) } return answer(orderOf(members, reachOfMembers(members, entries, read, edges), edges, changes)) - case "check": - if *group == "" && len(members) == 1 { - // One head, checked again as the forge's announcement would have it (a recheck): the controller's - // own path for a pull request, run because the delivery's owner asked. - return recheckOneHead(members[0].ID, answer, func() (string, error) { - var asked string - err := sayingOnTheBus(ctx, inv, func() (err error) { - asked, err = (following{open: open, oneShot: true}).pullUpdated(ctx, members[0].pull(), os.Stderr) - return err - }) - return asked, err - }) - } - if *group == "" || len(members) < 2 { - return errors.New("delivery check --group --members (two heads or more), or --members ") - } - // **The group's composed check is the checker's** (novox/hq ADR 0503 §3; issue 1299, C2): nothing is asked of - // build-agent, and the answer says who judges it. - return answer(groupCheckAnswer(*group)) case "go": if len(positionals) != 1 { return errors.New("delivery go [--by ] [--why ]") @@ -1076,14 +1060,6 @@ func theGraph(ctx context.Context, inv *inventory.Inventory) ([]inventory.Entry, return entries, read, edges, nil } -// twelve is a commit as twelve characters. -func twelve(c string) string { - if len(c) > 12 { - return c[:12] - } - return c -} - // appendNew appends what is not there yet. func appendNew(to []string, items ...string) []string { for _, i := range items { @@ -1235,32 +1211,3 @@ func overNATS(conn *nats.Conn) (link.Bus, error) { } return link.OverNATS{Conn: conn, JS: js}, nil } - -// recheckAnswer is what `delivery check` answers for one head: the id of the check asked of the build seat (empty when -// the controller said the verdict itself and asked nothing), which mesh-delivery reads, and the head rechecked. -func recheckAnswer(asked, member string) map[string]any { - return map[string]any{"asked": asked, "rechecked": member} -} - -// recheckOneHead runs a one-head `delivery check` and answers it as data. Standard output is the answer's JSON alone, -// or the seat verb reads no answer from it (novox/hq issue 1311): while the check runs, whatever it prints to standard -// output (the controller's own path prints its prose there, as do the stores it reads) goes to standard error. -func recheckOneHead(member string, answer func(any) error, ask func() (string, error)) error { - real := os.Stdout - os.Stdout = os.Stderr - asked, err := func() (string, error) { - defer func() { os.Stdout = real }() - return ask() - }() - if err != nil { - return err - } - return answer(recheckAnswer(asked, member)) -} - -// groupCheckAnswer is what `delivery check` of a group answers from R4e: who judges it, and that nothing was asked -// (novox/hq ADR 0503 §3; issue 1299, C2). -func groupCheckAnswer(group string) map[string]any { - return map[string]any{"asked": "", "group": group, "judged-by": checkerModule, - "said": "the checker judges a delivery group's heads merged together (ADR 0503 §3): nothing was asked"} -} diff --git a/cmd/mesh-controller/delivery_check_answer_test.go b/cmd/mesh-controller/delivery_check_answer_test.go deleted file mode 100644 index c9609ea7..00000000 --- a/cmd/mesh-controller/delivery_check_answer_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "strings" - "testing" - - "github.com/novox/mesh-controller/internal/link" -) - -// `delivery check` with one head answers the id of the check it asked, as data (novox/hq issue 1311): the prose -// the check says goes to standard error, so standard output is the document alone and answerOf reads it. -func TestDeliveryCheckOfOneHeadAnswersTheRequestID(t *testing.T) { - argv := []string{"delivery", "check", "--members", "[]"} - doc, err := json.Marshal(recheckAnswer("check-42", "novox/x@abc")) - if err != nil { - t.Fatal(err) - } - // Seen failing first: the prose the command used to print on standard output before the document. - polluted := append([]byte("novox/x#7 (abc): asked the build seat as check-42\n"), doc...) - if got := answerOf(argv, polluted, "", true); got.Answer != nil { - t.Fatalf("prose before the document should leave no answer; got %v", got.Answer) - } - got := answerOf(argv, doc, "novox/x#7 (abc): asked the build seat as check-42\n", true) - m, ok := got.Answer.(map[string]any) - if !ok || m["asked"] != "check-42" || m["rechecked"] != "novox/x@abc" { - t.Fatalf("the answer is %v; want asked check-42 and rechecked novox/x@abc", got.Answer) - } - if !strings.Contains(got.Output, "asked the build seat") { - t.Fatalf("the prose is still said in the text: %q", got.Output) - } -} - -// The text of the other callers does not change: sayChecked writes the same line to the writer it is given, and -// the controller's own path still gives PullUpdated standard output. -func TestSayCheckedWritesTheSameLineToItsWriter(t *testing.T) { - var w bytes.Buffer - sayChecked(context.Background(), &w, link.Checked{ID: "c1", Owner: "novox", Repo: "x", Number: 7, Commit: "0123456789abcdef", - Verdict: "pass", Summary: "fine"}) - want := "c1: novox/x#7 at 01234567 checked on a machine that did not say which: repository none\n" - if w.String() != want { - t.Fatalf("sayChecked said %q; want %q", w.String(), want) - } -} - -// Whatever the check prints to standard output while it runs (checks.go namedForTheCheck, the inventory's pointer -// repair) must not reach the answer's standard output: it is exactly one JSON document (novox/hq issue 1311). -func TestOneHeadRecheckLeavesStandardOutputTheAnswerAlone(t *testing.T) { - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - e, ew, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - realOut, realErr := os.Stdout, os.Stderr - os.Stdout, os.Stderr = w, ew - runErr := recheckOneHead("novox/x@abc", func(v any) error { return json.NewEncoder(os.Stdout).Encode(v) }, - func() (string, error) { - fmt.Println(" the earlier checks' builds could not be read, so the check builds") - fmt.Fprintln(os.Stdout, "repaired a pointer") - return "check-42", nil - }) - os.Stdout, os.Stderr = realOut, realErr - w.Close() - ew.Close() - if runErr != nil { - t.Fatal(runErr) - } - var out, errOut bytes.Buffer - _, _ = out.ReadFrom(r) - _, _ = errOut.ReadFrom(e) - var doc map[string]any - if err := json.Unmarshal(out.Bytes(), &doc); err != nil || doc["asked"] != "check-42" || doc["rechecked"] != "novox/x@abc" { - t.Fatalf("standard output %q is not the answer alone (%v)", out.String(), err) - } - if !strings.Contains(errOut.String(), "repaired a pointer") { - t.Fatalf("the prose should be on standard error: %q", errOut.String()) - } -} diff --git a/cmd/mesh-controller/delivery_conditions.go b/cmd/mesh-controller/delivery_conditions.go index d0a13dc5..c5de9415 100644 --- a/cmd/mesh-controller/delivery_conditions.go +++ b/cmd/mesh-controller/delivery_conditions.go @@ -41,7 +41,7 @@ type stalledLine struct { // waiting for the owner's word and what holds it (novox/hq issues 362, 529). Walk string `json:"walk,omitempty"` // Waiting are the merge checks a line of the state `unanswered` waits on, as mesh-delivery says each: - // "mesh/merge-gate pending since ", "mesh/repo-check never set" (novox/hq issue 438). + // "mesh/check pending since ", "mesh/check never set" (novox/hq issue 438). Waiting []string `json:"waiting,omitempty"` // Checks are the same merge checks as data, which the controller words in the operator's own time (novox/hq // issue 443): a time inside a finished sentence cannot be said again in another zone. A mesh-delivery from before diff --git a/cmd/mesh-controller/delivery_test.go b/cmd/mesh-controller/delivery_test.go index 30478a90..05cfc7a3 100644 --- a/cmd/mesh-controller/delivery_test.go +++ b/cmd/mesh-controller/delivery_test.go @@ -136,8 +136,6 @@ func TestTheDeliveryVerbsComposeTheirCommands(t *testing.T) { {"delivery-walks", map[string]any{}, []string{"delivery", "walks"}}, {"delivery-walks", map[string]any{"plan": "plan-1"}, []string{"delivery", "walks", "--plan", "plan-1"}}, {"delivery-order", map[string]any{"members": "[]"}, []string{"delivery", "order", "--members", "[]"}}, - {"delivery-check", map[string]any{"group": "feat/x", "members": "[]"}, - []string{"delivery", "check", "--group", "feat/x", "--members", "[]"}}, {"plans", map[string]any{"go": "plan-1", "why": "down"}, []string{"plans", "go", "plan-1", "--why", "down"}}, } { got, err := argvFor(c.verb, c.args) diff --git a/cmd/mesh-controller/doctor.go b/cmd/mesh-controller/doctor.go index fc2855c2..475d33fb 100644 --- a/cmd/mesh-controller/doctor.go +++ b/cmd/mesh-controller/doctor.go @@ -669,7 +669,7 @@ func localDoctor(ctx context.Context) (*doctor, func(), error) { if err != nil { return nil, nil, err } - js, err := dialTheBus() + js, err := aBus() if err != nil { open.Close() return nil, nil, err diff --git a/cmd/mesh-controller/dry_run_integration_test.go b/cmd/mesh-controller/dry_run_integration_test.go deleted file mode 100644 index a8cab99e..00000000 --- a/cmd/mesh-controller/dry_run_integration_test.go +++ /dev/null @@ -1,20 +0,0 @@ -//go:build integration - -package main - -import ( - "encoding/json" - "testing" - - "github.com/novox/mesh-controller/internal/link" -) - -func TestADryRunsOutcomeIsTakenInByNothing(t *testing.T) { - err := builds{}.Built(t.Context(), link.BuildResult{ - ID: "build-1", Repository: "ssh://forge/app.git", Ref: "unreviewed", Module: "app", DryRun: true, - Manifest: json.RawMessage(`{"module":"app","version":"1"}`), - }) - if err != nil { - t.Fatalf("a dry run's outcome was not simply set aside: %v", err) - } -} diff --git a/cmd/mesh-controller/dry_run_test.go b/cmd/mesh-controller/dry_run_test.go deleted file mode 100644 index 53767a0a..00000000 --- a/cmd/mesh-controller/dry_run_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import ( - "encoding/json" - "testing" - - "github.com/novox/mesh-controller/internal/link" -) - -// A dry run's outcome is looked at, never taken in (novox/hq issue 240). The daemon here holds no -// store at all, so anything that tried to record or register would fail rather than pass quietly. - -// The mark survives the wire both ways: asked as a dry run, answered as one. -func TestTheDryRunMarkTravelsWithTheBuild(t *testing.T) { - raw, _ := json.Marshal(link.BuildRequest{ID: "build-1", Repository: "r", DryRun: true}) - var asked link.BuildRequest - if err := json.Unmarshal(raw, &asked); err != nil || !asked.DryRun { - t.Fatalf("the request lost its dry-run mark: %s", raw) - } - raw, _ = json.Marshal(link.BuildResult{ID: "build-1", DryRun: true}) - var answered link.BuildResult - if err := json.Unmarshal(raw, &answered); err != nil || !answered.DryRun { - t.Fatalf("the outcome lost its dry-run mark: %s", raw) - } - raw, _ = json.Marshal(link.BuildResult{ID: "build-2"}) - if string(raw) != `{"id":"build-2","repository":"","on":""}` { - t.Fatalf("an ordinary outcome carries a dry-run mark: %s", raw) - } -} diff --git a/cmd/mesh-controller/durations.go b/cmd/mesh-controller/durations.go index 36afadc7..b03db5ce 100644 --- a/cmd/mesh-controller/durations.go +++ b/cmd/mesh-controller/durations.go @@ -12,7 +12,6 @@ import ( "time" "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" ) // What the core's bounds are set from (novox/hq to-be 45 Phase 0). @@ -24,25 +23,6 @@ import ( // controller as it hears each — a send's first report, a machine's next word, a plan leaving a tier, a // build's outcome — and summarised here per machine, repository or module. -// recordBuildDuration measures one build from its ask to its outcome heard. -func recordBuildDuration(ctx context.Context, inv *inventory.Inventory, result link.BuildResult, asked time.Time) { - if asked.IsZero() || result.ID == "" { - return - } - subject := result.Module - if subject == "" { - subject = result.Repository - } - detail := "built" - if result.Failed != "" { - detail = "failed: " + firstLine(result.Failed) - } - if err := inv.RecordDuration(ctx, inventory.Duration{Kind: inventory.DurationBuild, Subject: subject, - Node: result.On, Ref: result.ID, Started: asked, Took: time.Since(asked), Detail: detail}); err != nil { - fmt.Fprintf(os.Stderr, "%s: how long it took could not be recorded: %v\n", result.ID, err) - } -} - // durationSummary is one subject's measurements of one kind. type durationSummary struct { Kind string `json:"kind"` diff --git a/cmd/mesh-controller/forge_address_test.go b/cmd/mesh-controller/forge_address_test.go index a793bb06..c267b525 100644 --- a/cmd/mesh-controller/forge_address_test.go +++ b/cmd/mesh-controller/forge_address_test.go @@ -64,7 +64,7 @@ func registerFrom(t *testing.T, forms *sourceForms) (int64, error) { mesh := &aMeshRegisteredFromTheURL{anEmptyMesh{asked: true, request: inventory.BuildRequest{ID: "build-1", Repository: aRepoOnTheForge, For: "build"}}, aRepoOnTheForge} return mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-view", inventory.Source{Repository: aRepoOnTheForge}, - byBuild("build-1"), "", "main") + registrationAsk{Build: "build-1"}, "", "main") } // 1277/AC2: an address unread twice, then read, is waited out and the forge is asked. @@ -112,7 +112,7 @@ func TestAKnownAddressAndAnotherHostIsStillNotOnTheForge(t *testing.T) { const elsewhere = "http://elsewhere.example/x/y.git" mesh := &aMeshRegisteredFromTheURL{anEmptyMesh{asked: true, request: inventory.BuildRequest{ID: "build-1", For: "build"}}, elsewhere} _, err := mayRegisterIn(t.Context(), mesh, sourceFormsRead(context.Background(), time.Now, read), "mesh-delivery-view", - inventory.Source{Repository: elsewhere}, byBuild("build-1"), "", "main") + inventory.Source{Repository: elsewhere}, registrationAsk{Build: "build-1"}, "", "main") if !errors.Is(err, errNotItsSource) || !strings.Contains(err.Error(), "is not on the mesh's forge") { t.Fatalf("1277/AC4: the refusal changed: %v", err) } diff --git a/cmd/mesh-controller/gate_integration_test.go b/cmd/mesh-controller/gate_integration_test.go index 31e5c56d..f0357293 100644 --- a/cmd/mesh-controller/gate_integration_test.go +++ b/cmd/mesh-controller/gate_integration_test.go @@ -444,8 +444,12 @@ func TestAMergeThatDeletesAModulePlansNothingToBuildForIt(t *testing.T) { if err := inv.SavePlan(ctx, &plan); err != nil { t.Fatal(err) } - planBuilt(ctx, open, "kept", "", "http://forge/novox/mesh-catalog.git has no module.json at modules/kept, so "+ - "there is nothing saying what it is: open …/module.json: no such file or directory", asked0, "build-k") + if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-k", Module: "kept", Repository: "novox/mesh-catalog", + At: time.Now().UTC(), Asked: asked0, Failed: "http://forge/novox/mesh-catalog.git has no module.json at modules/kept, so " + + "there is nothing saying what it is: open …/module.json: no such file or directory"}); err != nil { + t.Fatal(err) + } + advancePlans(ctx, open) p, _ := inv.PlanByID(ctx, "plan-deleted") if p.State == inventory.PlanFailed || p.Modules["kept"].State != planDeleted { t.Fatalf("a module deleted at its source failed the plan: %s %+v", p.State, p.Modules["kept"]) diff --git a/cmd/mesh-controller/grants_step_test.go b/cmd/mesh-controller/grants_step_test.go index c484d693..c8502758 100644 --- a/cmd/mesh-controller/grants_step_test.go +++ b/cmd/mesh-controller/grants_step_test.go @@ -9,9 +9,7 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" ) // The grants reach the bus before the gate (novox/hq issue 490). @@ -176,35 +174,3 @@ func TestTheGrantsStepKeepsOneMachinesBuilds(t *testing.T) { t.Error("the grants step keeps the builds of a machine it does not send") } } - -// The delivery plan says the step before the merge. -func TestAChangePlanSaysTheGrantsStep(t *testing.T) { - const catalogue_ = "http://forge.internal:20000/novox/mesh-catalog.git" - entry := func(name string, on ...string) inventory.Entry { - e := fromRepo(name, catalogue_, "modules/"+name) - e.Source.BuiltFrom = "old" - e.On = on - return e - } - nats := entry("nats", "novox") - nats.Manifest.BusUsers = "/etc/nats/users.conf" - nats.Manifest.Claims = []catalogue.Claim{{Name: catalogue.BrokerSeat, Scope: catalogue.ScopeMesh}} - restic := entry("restic", "ace", "novox", "shanks") - only := entry("bus-tools", "novox") - entries := []inventory.Entry{nats, restic, only} - rollsOut := func(string) (inventory.Upgrade, bool) { return inventory.Upgrade{RollOut: true}, true } - plan := func(paths ...string) link.ChangePlan { - m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "head", Paths: paths} - return changePlanOf("novox/mesh-catalog", "main", "head", reachOfMerge(m, entries, nil, nil), entries, rollsOut) - } - - text := planText(plan("modules/restic/module.json")) - if !strings.Contains(text, "grants step: if this changes what the bus's user list says (a new tool, subject or "+ - "user), novox is sent that list alone, every build there kept, before restic is judged on its first machine") { - t.Errorf("the change plan does not say the grants step:\n%s", text) - } - // A module only on the bus's machine goes there with the list in its own send: no step of its own. - if text := planText(plan("modules/bus-tools/module.json")); strings.Contains(text, "grants step") { - t.Errorf("a module on the bus's machine alone reads:\n%s", text) - } -} diff --git a/cmd/mesh-controller/group_check_test.go b/cmd/mesh-controller/group_check_test.go deleted file mode 100644 index 080f1ba1..00000000 --- a/cmd/mesh-controller/group_check_test.go +++ /dev/null @@ -1,12 +0,0 @@ -package main - -import "testing" - -// C2 (ADR 0503 §3; issue 1299): a delivery group's composed check is answered as the checker's, and nothing is asked -// of build-agent: the command answers before any ask is composed. -func TestTheGroupCheckNamesTheCheckerAndAsksNothing(t *testing.T) { - a := groupCheckAnswer("feat/x") - if a["judged-by"] != "mesh-delivery-checker" || a["asked"] != "" || a["group"] != "feat/x" { - t.Fatalf("the group check answered %v", a) - } -} diff --git a/cmd/mesh-controller/issue529_test.go b/cmd/mesh-controller/issue529_test.go index d8cc2fb1..444d33ab 100644 --- a/cmd/mesh-controller/issue529_test.go +++ b/cmd/mesh-controller/issue529_test.go @@ -31,7 +31,7 @@ func TestAWalkWaitingOnAHeldDeliveryNamesTheHold(t *testing.T) { "check did not pass for the heads that merged — a person decides: `mesh-delivery.release` (id " + "novox/mesh-sdk@d41d255f28ad, why) lets it go, `mesh-delivery.stop` ends it" waits := walkWaits{p.ID: {{ID: "novox/mesh-sdk@d41d255f28ad", State: "held", Walk: p.ID, Says: held}}} - line := planLineSaid(p, now, pauseView{}, tierAtLeast, nil, waits) + line := planLineSaid(p, now, tierAtLeast, nil, waits) for _, want := range []string{"held for a person", "composed check did not pass", "mesh-delivery.release"} { if !strings.Contains(line, want) { t.Fatalf("the line does not say %q: %q", want, line) @@ -41,7 +41,7 @@ func TestAWalkWaitingOnAHeldDeliveryNamesTheHold(t *testing.T) { t.Fatalf("a held walk's line points at a hand start: %q", line) } // Told nothing — an older mesh-delivery, or none answering — it reads as before. - if line := planLineSaid(p, now, pauseView{}, tierAtLeast, nil, nil); !strings.Contains(line, "starts it by hand") { + if line := planLineSaid(p, now, tierAtLeast, nil, nil); !strings.Contains(line, "starts it by hand") { t.Fatalf("told nothing, the line reads %q", line) } } @@ -51,7 +51,7 @@ func TestAWalkWaitingOnADeliveryNotAtItsTurnSaysSo(t *testing.T) { p := waitingWalk(now) waits := walkWaits{p.ID: {{ID: "novox/mesh-sdk@9747f3e1b1ac", State: "published", Walk: p.ID, Says: "not let go yet: novox/mesh-tools@3f0c1a6cec18 (published) carried by it not at its turn"}}} - line := planLineSaid(p, now, pauseView{}, tierAtLeast, nil, waits) + line := planLineSaid(p, now, tierAtLeast, nil, waits) if !strings.Contains(line, "not at its turn") || !strings.Contains(line, "novox/mesh-tools@3f0c1a6cec18") { t.Fatalf("the line reads %q", line) } @@ -69,7 +69,7 @@ func TestAWalkHeldByNothingSaysWhyAndTheHandStart(t *testing.T) { waits := walkWaits{p.ID: {{ID: "novox/mesh-media-catalog@cdcdcdcd0000", State: "unheld", Walk: p.ID, Says: "held by nothing: its merges' deliveries ended as nothing-to-move (novox/mesh-media-catalog@abababab0000), " + "yet the walk moves nzbgeek, nzbget — nothing gives it its word"}}} - line := planLineSaid(p, now, pauseView{}, tierAtLeast, nil, waits) + line := planLineSaid(p, now, tierAtLeast, nil, waits) if !strings.Contains(line, "ended as nothing-to-move") || !strings.Contains(line, "plans go "+p.ID) { t.Fatalf("the line reads %q", line) } diff --git a/cmd/mesh-controller/legacy_takein_integration_test.go b/cmd/mesh-controller/legacy_takein_integration_test.go new file mode 100644 index 00000000..21558826 --- /dev/null +++ b/cmd/mesh-controller/legacy_takein_integration_test.go @@ -0,0 +1,317 @@ +//go:build integration + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path" + "slices" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// What the tests below take a build in with (R4g): the controller itself registers a module only from the builder's done +// key now (takeInKey), but a great many of these tests only need a module registered with a build recorded, in the form +// the old outcome carried, so that is kept here and nowhere else. It is the controller's take-in of a build-agent outcome +// as it was, less the checks only an outcome needed. Nothing in the controller calls any of it. + +// buildFrom turns what a builder said into what the mesh keeps. +// +// **By digest and path, never by where it was pushed** (novox/hq 04-ISSUES/102). The builder +// says `://@sha256:…`; the mesh records the artifact-store +// reference and composes the store's address back in where a reference is used. `against` — what +// the build stood on, the catalogue's edge — is recorded the same way, so an edge names a module's +// artifact and not the machine it was pulled from. +func buildFrom(result link.BuildResult) inventory.Build { + kept := inventory.Build{ + ID: result.ID, Repository: result.Repository, Ref: result.Ref, + Commit: result.Commit, On: result.On, Failed: result.Failed, + // **What the announcement carries, kept rather than discarded** (novox/hq 04-ISSUES/050). + // The catalogue turns the manifest into requires/provides edges and `against` into build + // edges, and it is not always listening when a build happens — on a fresh mesh it cannot + // be, for exactly the modules it needs most. Keeping them is what makes a replay able to + // rebuild the graph rather than a list of names. + Path: result.Path, + // What it was made from (novox/hq issue 280): two builds with one are one build. + SourceFingerprint: result.SourceFingerprint, + SourceInputs: result.SourceInputs, + } + // When it was asked, which is what orders it against another build of the same module + // (novox/hq 04-ISSUES/219) — not when it was heard. + if asked, ok := link.BuildAskedAt(result.ID); ok { + kept.Asked = asked + } + for _, ref := range result.Against { + kept.Against = append(kept.Against, catalogue.Recorded(ref)) + } + for _, ref := range result.Mirrored { + kept.Mirrored = append(kept.Mirrored, catalogue.Recorded(ref)) + } + for _, r := range result.Read { + kept.Read = append(kept.Read, inventory.ReadRepository{Repository: r.Repository, Ref: r.Ref}) + } + // What it was made from, as files (novox/hq ADR 0267): the planner maps the next merge onto it. + for _, s := range result.Sources { + kept.Sources = append(kept.Sources, inventory.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths}) + } + var announced []inventory.Artifact + for _, made := range result.Made { + announced = append(announced, inventory.Artifact{ + Name: made.Name, Kind: made.Kind, Reference: made.Reference, + }) + kept.Made = append(kept.Made, inventory.Artifact{ + Name: made.Name, Kind: made.Kind, Reference: catalogue.Recorded(made.Reference), + }) + } + kept.Manifest = recordedManifest(result.Manifest, announced) + // The module name comes from the manifest, which only exists when the build got that far. + if len(kept.Manifest) > 0 { + if list, err := judgedWith(result); err == nil { + if m, err := catalogue.ParseManifestWith(kept.Manifest, list); err == nil { + kept.Module = m.Module + } + } + } + return kept +} + +// recordedManifest is a build's manifest with the store's address taken off every reference the +// build itself made. Other references — an image a module runs from a public registry — are what +// they were, which is why this rewrites only what `made` names rather than everything that looks +// like an address. +func recordedManifest(raw json.RawMessage, made []inventory.Artifact) json.RawMessage { + announced := map[string]bool{} + for _, a := range made { + announced[a.Reference] = true + } + return withReferences(raw, func(ref string) (string, bool) { + if !announced[ref] { + return ref, false + } + return catalogue.Recorded(ref), true + }) +} + +// takeIn is what the mesh does with a build's outcome, whoever hears it: the waiting command and +// the daemon that follows the role's events both come here (novox/hq issue 176), so a build's +// result reaches the catalogue whether or not the asker was still listening. +// +// Kept before it is judged. A failed build that leaves no trace is indistinguishable from one +// nobody asked for, and the difference is the whole of whether somebody should be looking at +// something. Then parsed with the same parser a hand-written manifest goes through — a second path +// would be a second thing to disagree about what a manifest is — and registered with where it came +// from: **for a source on a seat, as the path and the seat, never the URL just cloned** (ADR 0111), +// which the request carried and the outcome echoes. A definition naming an installation is refused +// here, where it would enter the catalogue; the build stays recorded and the refusal says which. +// +// Idempotent: the same outcome taken in twice registers the same module twice, which is one row +// written with the same values. +func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResult) ( + catalogue.Manifest, inventory.Build, error) { + // The list of predefined keys as the store keeps it now: another controller process may have kept a newer one. + if err := loadPredefined(ctx, inv); err != nil { + return catalogue.Manifest{}, inventory.Build{}, fmt.Errorf("the list of predefined keys kept cannot be "+ + "read: %w", err) + } + kept := buildFrom(result) + if err := inv.RecordBuild(ctx, kept); err != nil { + return catalogue.Manifest{}, kept, err + } + if result.Failed != "" { + // The builder's own words. Wrapping them in something about the control plane would put + // two explanations between a person and a build log. + return catalogue.Manifest{}, kept, fmt.Errorf("%s could not build %s:\n%s", + result.On, result.Repository, result.Failed) + } + // Judged first against the list the build was judged against: the catalogue's at the commit built (novox/hq + // ADR 0348 §7), so a module naming a key its own commit adds is read; and again below against the list the + // mesh publishes, before it is registered. + list, err := judgedWith(result) + if err != nil { + return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On, + result.Repository, short(result.Commit), err) + } + manifest, err := catalogue.ParseManifestWith(kept.Manifest, list) + if err != nil { + return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s and what came back is not a manifest: %w", + result.On, result.Repository, err) + } + recorded := inventory.Source{ + Repository: result.Repository, Path: result.Path, Ref: result.Ref, + BuiltFrom: result.Commit, Head: result.Commit, + // What it stood on, so registration can judge a built manifest's base (to-be 38 WP2.4). + Against: kept.Against, + // When it was asked, so an older request heard later does not replace a newer one + // (novox/hq 04-ISSUES/219). + Asked: kept.Asked, + } + if result.Source != nil && result.Source.Seat != "" { + recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat + } + // **A build at a commit does not change the branch a module follows** (novox/hq 04-ISSUES/215): + // the commit is built and recorded as what it was built from, and the module keeps following + // what it followed before — the repository's default branch for one new to the catalogue. + if followedBranch(result.Ref) == "" && result.Ref != "" { + recorded.Ref = "" + if was, err := inv.SourceOf(ctx, manifest.Module); err == nil { + recorded.Ref = followedBranch(was.Ref) + } + } + trunk := result.Trunk + if was, err := inv.SourceOf(ctx, manifest.Module); err == nil && followedBranch(was.Ref) != "" { + trunk = followedBranch(was.Ref) + } + if trunk == "" { + trunk = "main" + } + j := takenBuild{kept: kept, manifest: manifest, recorded: recorded, ask: registrationAsk{Build: result.ID}, path: result.Path, + trunk: trunk, by: result.On, what: result.Repository, commit: result.Commit} + // The checks only an outcome of build-agent needs: a key is a trunk build by construction (ADR 0501 §2), in no + // scratch namespace, and carries no list of its own. + j.published = func() error { + // **Only a commit on the trunk is published** (novox/hq ADR 0238): a commit off its repository's + // default branch — a pull request's head, a feature branch built by hand, a `rebuild` or `replay + // --register` of one — is for checking, and is never a module's version; nothing could then send it. + // The branch the module already follows is its trunk — never the branch a build was asked at, or a + // build of a feature branch by name would make that branch its trunk. + follows := "" + if was, err := inv.SourceOf(ctx, manifest.Module); err == nil { + follows = followedBranch(was.Ref) + } + if err := publishable(result, follows); err != nil { + return err + } + return nil + } + j.carried = func() error { + // **The newest trunk build's list of predefined keys is published** (novox/hq ADR 0348 §7): a build of the + // catalogue that may be registered keeps its list when its commit is newer than the one kept. + carried, err := carriedList(result) + if err != nil { + return err + } + if carried != nil { + if err := publishPredefined(ctx, inv, result, carried); err != nil { + fmt.Printf("%s: the list of predefined keys it carried is not published: %v\n", result.ID, err) + } + } + return nil + } + manifest, err = registerJudged(ctx, inv, j) + return manifest, kept, err +} + +// errOffTheTrunk is a build of a commit off its repository's trunk, which is never published. +var errOffTheTrunk = errors.New("the commit is not on its repository's trunk: a commit off the trunk is checked, " + + "never published (ADR 0238) — merge it, and the merge builds it") + +// publishable says whether a build's outcome may become a module's version: its commit on the module's +// trunk, as the build seat read the forge at the build — the branch the module follows when its source +// names one, else its repository's default branch. A build seat that could not say — one older than the +// rule, building its own successor — is let through and said, so the rule can reach the mesh. +func publishable(result link.BuildResult, follows string) error { + if result.DryRun { + return errors.New("a dry run is never published") + } + if result.Trunk == "" { + fmt.Printf("%s: the build seat did not say whether %s is on its repository's trunk (it predates ADR 0238); "+ + "registered as before\n", result.ID, short(result.Commit)) + return nil + } + trunk := result.Trunk + if follows != "" { + trunk = follows + } + on := slices.Contains(result.Branches, trunk) || (trunk == result.Trunk && result.OnTrunk) + if !on { + return fmt.Errorf("%w (%s is not on %s)", errOffTheTrunk, short(result.Commit), trunk) + } + return nil +} + +// fromTheCatalogue is whether a build is of the catalogue's repository, whose list alone the mesh publishes: by +// its name, in the URL form or the seat form. +func fromTheCatalogue(result link.BuildResult) bool { + for _, repository := range []string{result.Repository, sourceRepository(result)} { + name := strings.TrimSuffix(path.Base(strings.TrimRight(repository, "/")), ".git") + if name == catalogue.CatalogueRepository { + return true + } + } + return false +} + +func sourceRepository(result link.BuildResult) string { + if result.Source == nil { + return "" + } + return result.Source.Repository +} + +// carriedList is the list a build of the catalogue carried, read strictly; nil for a build of another +// repository, or one that carried none. +func carriedList(result link.BuildResult) (catalogue.PredefinedList, error) { + if result.Predefined == nil || !fromTheCatalogue(result) { + return nil, nil + } + list, err := catalogue.ParsePredefined(result.Predefined.Keys) + if err != nil { + return nil, fmt.Errorf("%s carried a list of predefined keys that is refused: %w", result.ID, err) + } + return list, nil +} + +// judgedWith is the list a build's manifest is first judged against at registration: the catalogue's own at the +// commit built, which its build judged it against, where it carried one; the list kept otherwise. +func judgedWith(result link.BuildResult) (catalogue.PredefinedList, error) { + list, err := carriedList(result) + if err != nil || list != nil { + return list, err + } + return catalogue.PublishedPredefined(), nil +} + +// publishPredefined keeps a trunk build's list when it is newer than the one kept, says so, and loads what the +// store keeps then — this build's, or a newer one another controller process kept. +func publishPredefined(ctx context.Context, inv *inventory.Inventory, result link.BuildResult, + list catalogue.PredefinedList) error { + kept, err := inv.KeepPredefined(ctx, inventory.PredefinedRecord{Keys: list, Repository: result.Repository, + Commit: result.Commit, Generation: result.Predefined.Generation, Build: result.ID}) + if err != nil { + return err + } + if kept { + fmt.Printf("%s: the list of predefined keys at %s (generation %d) is published: %s\n", result.ID, + short(result.Commit), result.Predefined.Generation, strings.Join(list.Keys(), ", ")) + } else { + fmt.Printf("%s: the list of predefined keys at %s (generation %d) is not newer than the one kept, which "+ + "stands\n", result.ID, short(result.Commit), result.Predefined.Generation) + } + return loadPredefined(ctx, inv) +} + +// builds is what the tests of a pending assignment feed an outcome to: the take-in above, then the tick that settles what +// waited for it (settlePending), as the daemon's hearing of an outcome did before R4g (it also moved the plans, which the +// walk now does from its keys). Nothing in the controller calls it. +type builds struct { + inv *inventory.Inventory + open *stores +} + +func (b builds) Built(ctx context.Context, result link.BuildResult) error { + _, _, err := takeIn(ctx, b.inv, result) + if b.open != nil { + settlePending(ctx, b.open, time.Now()) + } + if result.Failed != "" { + return nil // the build is recorded; whether it registered is what the tests read + } + return err +} diff --git a/cmd/mesh-controller/loop_own_consumers_test.go b/cmd/mesh-controller/loop_own_consumers_test.go index 6136501c..d8f5ae9f 100644 --- a/cmd/mesh-controller/loop_own_consumers_test.go +++ b/cmd/mesh-controller/loop_own_consumers_test.go @@ -34,16 +34,6 @@ func deafObservations(now time.Time, held func(broker.Consumer) (uint64, error)) return obs[0].Said, len(obs) } -// 1063/AC1: a check in flight on a builder is not the controller's event loop being deaf. -func TestDeafLookIgnoresCheckWorkers(t *testing.T) { - now := time.Now() - for _, w := range broker.CheckWorkers() { - if said, n := deafObservations(now, holdingOne(w.Stream+"/"+w.Name)); n != 0 { - t.Fatalf("%s holding one message raised controller-deaf: %s", w.Name, said) - } - } -} - // 1063/AC2: a message held by one of the controller's own consumers is still deaf, naming its stream. func TestDeafLookStillCountsOwnConsumers(t *testing.T) { now := time.Now() @@ -55,25 +45,16 @@ func TestDeafLookStillCountsOwnConsumers(t *testing.T) { } } -// 1063/AC3: the list is the controller's consumers and none of the check workers. +// 1063/AC3: the list is the controller's consumers: the mesh defines no other durable consumer the loop could read (the +// check workers went with the checks' queues, ADR 0525 clause 6). func TestDeafLookReadsExactlyTheControllersOwnConsumers(t *testing.T) { - workers := map[string]bool{} - for _, w := range broker.CheckWorkers() { - workers[w.Stream+"/"+w.Name] = true - } want := map[string]bool{} for _, c := range broker.MeshConsumers() { - if !workers[c.Stream+"/"+c.Name] { - want[c.Stream+"/"+c.Name] = true - } + want[c.Stream+"/"+c.Name] = true } got := map[string]bool{} for _, c := range loopConsumers() { - k := c.Stream + "/" + c.Name - if workers[k] { - t.Errorf("the deaf look reads the check worker %s", k) - } - got[k] = true + got[c.Stream+"/"+c.Name] = true } if len(got) != len(want) { t.Fatalf("deaf look reads %v, want %v", got, want) diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 0e8a8b95..37e6ceaf 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -8,7 +8,6 @@ package main import ( "context" - "errors" "flag" "fmt" "os" @@ -19,7 +18,6 @@ import ( "github.com/novox/mesh-controller/internal/identity" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/licences" - "github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/store" ) @@ -82,8 +80,6 @@ func run() error { case "build", "rebuild": // **The hand build goes with the asks** (novox/hq ADR 0501 §5): refused by name, naming the replacement. return removedByName(args[0]) - case "builder": - return builderCommand(ctx, args[1:]) case "board": return boardCommand(ctx, args[1:]) case "api": @@ -98,23 +94,10 @@ func run() error { return rehearseCommand(ctx, args[1:]) case "builds": return buildsCommand(ctx, args[1:]) - // The build queue, controlled by hand (novox/hq ADR 0219). - case "queue": - return queueCommand(ctx, args[1:]) - case "cancel": - return cancelCommand(ctx, args[1:]) - case "clear": - return clearCommand(ctx, args[1:]) - case "replay": - return replayCommand(ctx, args[1:]) - case "kill": - return killCommand(ctx, args[1:]) - case "forget-checks": - return removedByName("forget-checks") + case "forget-checks", "queue", "cancel", "clear", "kill", "replay", "pause", "resume": + return removedByName(args[0]) case "try": return tryCommand(ctx, args[1:]) - case "pause", "resume": - return pauseCommand(ctx, args[0], args[1:]) case "collection": return collectionCommand(ctx, args[1:]) // What the records say the registries may keep, asked on demand (novox/hq ADR 0251). @@ -157,12 +140,6 @@ func run() error { // The facts snapshot a merge check is fed (novox/hq to-be 45 §9). case "facts": return factsCommand(ctx, args[1:]) - // The merge gate: every machine of the snapshot composed with a change (novox/hq to-be 45 §9). - case "merge-gate": - return mergeGateCommand(ctx, args[1:]) - // A pull request's merge check run here exactly as the build seat runs it (novox/hq issue 286). - case "check-here": - return checkHereCommand(ctx, args[1:]) case "upgrade": return upgradeCommand(ctx, args[1:]) // The bus as a planned step (novox/hq to-be 45 §8, ADR 0236). @@ -317,13 +294,7 @@ var usageText = `mesh-controller — the control plane --reference = (repeatable) and --why, at artifacts the store holds, recorded as a build by hand (ADR 0526 §3) switch [--json] the switch's list: each module, the build it runs, and what the builder holds - builds [] what has been built lately, and what came of it - queue [--json] every ask in the build queue: waiting, in flight (where, how long), dead - cancel drop a waiting or dead ask; recorded failed, cancelled by hand - clear [--dead] cancel every waiting ask (and the dead ones); never one in flight - replay that build's commit again, a dry run: what a build of it makes - kill end a build where it runs; recorded failed, killed by hand - pause [] / resume [] the build seat's holder there, or every holder, takes nothing new / again + builds [] [-n N] what has been built lately: the builder's done keys, recorded as builds plans retry ask a failed plan's failed builds again, and carry the plan on plans stop|close --why end a plan by hand; recorded in the hand-act log hand-act record --why --cause [--condition ] @@ -340,8 +311,6 @@ var usageText = `mesh-controller — the control plane durations [--kind K] [--days N] [--json] apply, heartbeat, plan-tier, build and walk-phase durations collection [--json] kept archives held/unheld by a manifest, and what the sweep may let go - builder issue a broker account for a build machine, scoped to build work, - delivered as the builder module's broker secret (module add it first) licence add|list|use|key model access, under the name a person calls it licence manager the node that holds a refreshable licence's refresh token licence refresh mint a new access token and seal it to every holder @@ -392,87 +361,6 @@ func parseAround(set *flag.FlagSet, args []string) ([]string, error) { } } -// Built is the daemon hearing a build's outcome on the bus — its own asking, an announcement's, or -// a tool's that did not wait (novox/hq issue 176) — and taking it in: recorded, and the module -// registered, the same as the waiting command does. Said either way, so the daemon's log tells what -// became of a build nobody was watching. -func (b builds) Built(ctx context.Context, result link.BuildResult) error { - // **A merge check builds nothing** (novox/hq to-be 45 §9): its verdict is said, and nothing of it is - // recorded or registered. - if result.Check != nil || result.Checked != nil { - if isTry(result) { - // A branch tried before its pull request (novox/hq issue 686): its asker reads the outcome from the - // build's log. Never said as `checked`, so the forge's holder sets no status. - fmt.Printf("%s: a try of %s at %.8s, read with `try --id`: said to nobody, nothing taken in\n", result.ID, - result.Repository, result.Ref) - return nil - } - checked(ctx, result) - return nil - } - // **A dry run is looked at, never taken in** (novox/hq issue 240). On 2026-10-04 a dry run of an - // unmerged branch was heard here like any build, registered, and its definition reached a machine - // before anyone had reviewed it. - if result.DryRun { - fmt.Printf("%s: a dry run of %s on %s, not taken in\n", result.ID, result.Repository, result.Ref) - return nil - } - manifest, _, err := takeIn(ctx, b.inv, result) - // The assignments pending on this build, made or ended (novox/hq issue 325), said in the daemon's log - // after what became of the build. - if b.open != nil { - defer func() { - for _, line := range settlePendingOnBuild(ctx, b.open, result, manifest.Module, err) { - fmt.Printf("%s: %s\n", result.ID, line) - } - }() - } - // When it was asked, so a plan takes as its outcome only a build asked for it or after it - // (novox/hq 04-ISSUES/219). Zero when the id does not say. - asked, _ := link.BuildAskedAt(result.ID) - // And how long it took, asked to heard, which a build's bound will be set from (novox/hq to-be 45 - // Phase 0). Said if lost; never a reason not to take the build in. - recordBuildDuration(ctx, b.inv, result, asked) - switch { - case err != nil && result.Failed != "": - fmt.Printf("%s: %v\n", result.ID, err) - if result.Module != "" { - planBuilt(ctx, b.open, result.Module, result.Commit, result.Failed, asked, result.ID) - } else { - planFailedBuild(ctx, b.open, result) - } - return nil - case errors.Is(err, inventory.ErrSuperseded): - // Not a failure: the module is already at what a later request built. A plan that asked - // before that later request is answered by it; one that asked after it ignores this. - fmt.Printf("%s: %v\n", result.ID, err) - planBuilt(ctx, b.open, manifest.Module, result.Commit, "", asked, result.ID) - return nil - case err != nil: - fmt.Printf("%s: heard and recorded, and not registered: %v\n", result.ID, err) - takeRefused(ctx, b.inv, result, manifest.Module, err.Error(), refusedOutcome{ - built: func(module, why string) { - planBuilt(ctx, b.open, module, result.Commit, why, asked, result.ID) - }, - failedBuild: func(failed link.BuildResult) { planFailedBuild(ctx, b.open, failed) }, - }) - return nil - } - fmt.Printf("%s: %s %s registered, built on %s from %s\n", - result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit)) - // What the walk's build did, kept on the walk whose ask it answers (novox/hq ADR 0350 §4); a build seat from - // before it says nothing, and that is what is kept. - made := result.MadeWords() - if result.Reused != "" || result.BuiltWhy != "" { - fmt.Printf("%s: %s %s\n", result.ID, manifest.Module, made) - } - saysWhenThePolicyActs(ctx, b.inv, manifest.Module) - planBuiltSaying(ctx, b.open, manifest.Module, result.Commit, "", asked, result.ID, made) - // A module registered may be one a machine is now behind: `status` is composed again. - statusFrom.nudge() - return nil -} - // verbVar carries the seat verb a command runs for, from the serving controller to the process it starts. const verbVar = "MESH_VERB" @@ -497,10 +385,24 @@ func removedByName(command string) error { "catalogue does not build is registered at the controller's terminal with `register`", "rebuild": "the controller asks no build (ADR 0501 §5): a failed key is built again by the builder as a new " + "revision (ADR 0429 §18), and a walk's Retry waits for it", - "replay --register": "a build is registered only from the builder's done key of a trunk fingerprint (ADR 0501 " + - "§1, §2); `replay` without --register still shows what a build of a recorded commit makes", + "replay": "the controller asks no build, so there is nothing to replay (ADR 0501 §5, ADR 0525 clause 6); a branch is " + + "tried with `try`", + "queue": "the controller keeps no build queue (ADR 0525 clause 6): the builds and checks are asks on the build modules' " + + "streams, and the delivery view shows their backlog and the oldest ask's age", + "cancel": "the controller keeps no build queue (ADR 0525 clause 6): an ask is on a build module's stream, and the delivery " + + "view shows the backlog", + "clear": "the controller keeps no build queue (ADR 0525 clause 6): an ask is on a build module's stream, and the delivery " + + "view shows the backlog", + "kill": "the controller keeps no build queue and starts no build (ADR 0525 clause 6): a build runs on a build module, " + + "which ends it", + "pause": "the build seat is retired (ADR 0525 clause 7): a node's builds are paused with its build module's own pause, " + + "or a capacity of 0", + "resume": "the build seat is retired (ADR 0525 clause 7): a node's builds are resumed with its build module's own " + + "resume, or its capacity", "forget-checks": "build-agent makes no check build from R4e (ADR 0503 §3), so there is nothing to let go of", "delivery-plan": "a delivery's plan is read with `delivery-order` and the checker's verdict (ADR 0503 §3)", + "delivery-check": "the checker judges every head and every delivery group (ADR 0503 §3, ADR 0527): the controller asks " + + "nothing and says no verdict of a pull request", }[command] return &removedCommand{msg: fmt.Sprintf("`%s` is removed: %s", command, replacement)} } diff --git a/cmd/mesh-controller/members_file_test.go b/cmd/mesh-controller/members_file_test.go index 23dc27b0..9c5b0309 100644 --- a/cmd/mesh-controller/members_file_test.go +++ b/cmd/mesh-controller/members_file_test.go @@ -47,18 +47,6 @@ func readBackFromTheCommand(t *testing.T, argv []string, members string) { } } -func TestDeliveryCheckHandsALongMembersListInAFile(t *testing.T) { // 1272/AC1 - members := aLongGroup(t) - argv, err := argvFor("delivery-check", map[string]any{"group": "feat/x", "members": members}) - if err != nil { - t.Fatal(err) - } - if argv[0] != "delivery" || argv[1] != "check" || !slices.Contains(argv, "--group") { - t.Errorf("the command is %.100v", strings.Join(argv, " ")) - } - readBackFromTheCommand(t, argv, members) -} - func TestDeliveryOrderHandsALongMembersListInAFile(t *testing.T) { // 1272/AC2 members := aLongGroup(t) argv, err := argvFor("delivery-order", map[string]any{"members": members}) @@ -73,8 +61,8 @@ func TestDeliveryOrderHandsALongMembersListInAFile(t *testing.T) { // 1272/AC2 func TestAShortMembersListIsStillTheArgumentItWas(t *testing.T) { // 1272/AC3 members := `[{"id":"novox/a#1"},{"id":"novox/b#2"}]` - argv, err := argvFor("delivery-check", map[string]any{"group": "feat/x", "members": members, "ready": "true"}) - want := []string{"delivery", "check", "--group", "feat/x", "--members", members, "--ready"} + argv, err := argvFor("delivery-order", map[string]any{"members": members}) + want := []string{"delivery", "order", "--members", members} if err != nil || !slices.Equal(argv, want) { t.Errorf("a short list gives %v, %v, want %v", argv, err, want) } diff --git a/cmd/mesh-controller/merge_gate.go b/cmd/mesh-controller/merge_gate.go deleted file mode 100644 index 35343ec3..00000000 --- a/cmd/mesh-controller/merge_gate.go +++ /dev/null @@ -1,1580 +0,0 @@ -package main - -import ( - "context" - "crypto/ecdh" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "io/fs" - "os" - "path/filepath" - "slices" - "sort" - "strings" - "time" - - "github.com/jackc/pgx/v5" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - snapshot "git.novox.be/novox/mesh-sdk/go/facts" - "github.com/novox/mesh-controller/internal/artifacts" - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" - "github.com/novox/mesh-controller/internal/store" -) - -// The merge gate (novox/hq to-be 45 §9, ADR 0227 rule 9): **every machine of the mesh composed with the -// change, and the node-engine's own validator run over each, before the change merges.** -// -// A change is judged against the mesh as the facts snapshot says it runs, twice: once as it is (the -// base) and once with the change applied, each in a throwaway store raised from the snapshot through -// the controller's own code — the same registration, assignment, seats, pins and settings a running -// controller keeps, and the same composition a push makes. What the change breaks is what composes or -// validates in the base and not with the change, named by the machine's role and the module; what was -// already broken is said and does not fail the change. -// -// What it catches, by the incidents it was written from: -// - a manifest the node-engine refuses whole, found only by assigning it to a live machine (236); -// - an identity a real machine's name makes too long for a provision, which refused the provider's -// whole machine (263) — or, since ADR 0225, leaves the consumer out of its grants: either fails; -// - a manifest the controller the mesh runs cannot read (version skew): a catalogue change is judged -// by that controller, so a field only a newer one knows fails here, not at registration; -// - a module removed from its source while machines still run it (ADR 0236), and two definitions of -// one module name (239); -// - a setting the mesh holds that the changed definition cannot compose with (ADR 0163); -// - and, said rather than failed, how wide the rebuild of the merge would be (278). -// -// It runs where a build runs: the build seat, asked to check a pull request (checks.go), so no CI -// outside the mesh is needed; and by hand, with a snapshot and a throwaway store, by anyone. - -// mergeCheckInput is what one judging needs. -type mergeCheckInput struct { - facts snapshot.Facts - // repository is the change's owner/repository; tree its checkout, empty when the change is to code - // alone and every module stays as the mesh holds it. - repository, tree string - // changed are the paths the change touches, for the width of its rebuild. - changed []string - // group are the other heads of a delivery group composed with this one (novox/hq ADR 0239), each laid - // over the mesh after the one before: the group judged as one future state. - group []groupChange - // held are the approved config values held for the keys the pull requests introduce (novox/hq ADR 0502 clause 4), - // laid over the composition with the change only. - held []heldValue - // admin is a PostgreSQL the gate may create and drop databases in. - admin string - // beside is the directory holding the core clones beside the check (MESH_CHECK_BESIDE), for finding moves. - beside string - // say is where progress goes; the verdict is returned. - say io.Writer -} - -// groupChange is one other head of a delivery group: its repository, its checkout and what it changes. -type groupChange struct { - Repository string `json:"repository"` - Tree string `json:"tree"` - Changed []string `json:"changed,omitempty"` -} - -// readGroup reads the other heads of a group's composed check from the file the build seat wrote. -func readGroup(path string) ([]groupChange, error) { - if path == "" { - return nil, nil - } - raw, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var g []groupChange - if err := json.Unmarshal(raw, &g); err != nil { - return nil, fmt.Errorf("the group's heads are not readable: %w", err) - } - for _, c := range g { - if c.Repository == "" || c.Tree == "" { - return nil, errors.New("a head of the group names no repository or no checkout") - } - } - return g, nil -} - -// mergeVerdict is what the gate found. -type mergeVerdict struct { - Verdict string `json:"verdict"` // pass, warning or fail - Summary string `json:"summary"` - // Judge is the controller build that judged it, and Facts when the snapshot it judged against was taken. - Judge string `json:"judge"` - Facts time.Time `json:"facts"` - Failures []string `json:"failures,omitempty"` - // Errors are what kept the gate from judging: a machine the mesh composes that the gate could not - // raise as it is. Any one makes the verdict an error — never a pass (novox/hq issue 285). - Errors []string `json:"errors,omitempty"` - Warnings []string `json:"warnings,omitempty"` - Notes []string `json:"notes,omitempty"` - Machines []mergeMachine `json:"machines"` - Modules map[string]string `json:"modules,omitempty"` // changed module → what changed: added, changed, removed - Width *mergeWidthOf `json:"width,omitempty"` -} - -// mergeMachine is one machine, composed as it is and with the change. -type mergeMachine struct { - Name string `json:"name"` - Described string `json:"described"` - Live bool `json:"live-composes"` - Base mergeComposed `json:"base"` - Change mergeComposed `json:"change"` - Added []string `json:"added,omitempty"` - Removed []string `json:"removed,omitempty"` - Modules map[string]int `json:"-"` -} - -// mergeComposed is one machine's composition in one store. -type mergeComposed struct { - Composes bool `json:"composes"` - Problems []string `json:"problems,omitempty"` - Withheld []string `json:"withheld,omitempty"` - Unbound []string `json:"unbound,omitempty"` - Unplaced []string `json:"unplaced,omitempty"` - LeftOut map[string]string `json:"left-out,omitempty"` - Resources []string `json:"resources,omitempty"` -} - -// mergeWidthOf is how much a merge of the change would rebuild. -type mergeWidthOf struct { - Modules []string `json:"modules"` - Tiers int `json:"tiers"` - // Unread is the changed paths no module's build reads — in no module's directory — which rebuild - // nothing (issue 280): said, so a reader sees why a change to them moves no module. - Unread []string `json:"unread,omitempty"` -} - -// wideRebuild is how many modules a rebuild may take before the gate says so as a warning. -var wideRebuild = 12 - -// changedPaths are the paths --changed (comma-separated) and --changed-file (one per line) name together. -func changedPaths(list, file string) ([]string, error) { - out := splitList(list) - if file == "" { - return out, nil - } - raw, err := os.ReadFile(file) - if err != nil { - return nil, fmt.Errorf("--changed-file: %w", err) - } - for _, line := range strings.Split(string(raw), "\n") { - if line = strings.TrimSpace(line); line != "" { - out = append(out, line) - } - } - return out, nil -} - -// mergeGateCommand is `gate`: -// -// merge-gate --facts --store [--repository owner/repo --tree ] [--changed a,b] [--json] -// -// --facts may be a file or `store`, the snapshot the artifact store holds (MESH_REGISTRY names it). -// --store is a PostgreSQL the gate may create and drop databases in: a throwaway, never the mesh's. -func mergeGateCommand(ctx context.Context, args []string) error { - set := flag.NewFlagSet("merge-gate", flag.ContinueOnError) - factsFrom := set.String("facts", "", "the facts snapshot: a file, or `store` for the one the artifact store holds") - admin := set.String("store", os.Getenv("MESH_GATE_POSTGRES"), "a throwaway PostgreSQL the gate may create databases in") - repository := set.String("repository", "", "owner/repository of the change") - tree := set.String("tree", "", "the change's checkout: every module.json in it replaces the mesh's") - changed := set.String("changed", "", "the paths the change touches, comma-separated, for its rebuild's width") - changedFile := set.String("changed-file", "", "a file of the paths the change touches, one per line; the same as "+ - "--changed, for a list too long for an argument (novox/hq issue 1247)") - group := set.String("group", "", "a delivery group's other heads, as JSON [{repository, tree, changed}], composed "+ - "with this one as one future state (novox/hq ADR 0239)") - heldFile := set.String("held", "", "a file of the config values approved for keys the change's pull requests introduce, as JSON "+ - "[{pull, node, module, key, value, approved_by}], laid over the composition with the change (novox/hq ADR 0502)") - asJSON := set.Bool("json", false, "print the verdict as JSON") - if _, err := parseAround(set, args); err != nil { - return err - } - if *factsFrom == "" || *admin == "" { - return errors.New("merge-gate --facts --store [--repository owner/repo --tree ] " + - "[--changed paths] [--json]") - } - if (*tree == "") != (*repository == "") && *tree != "" { - return errors.New("--tree names the change's checkout; --repository says which repository it is") - } - f, err := readFacts(ctx, *factsFrom) - if err != nil { - return err - } - others, err := readGroup(*group) - if err != nil { - return err - } - held, err := readHeld(*heldFile) - if err != nil { - return err - } - out := io.Writer(os.Stdout) - stdout := os.Stdout - if *asJSON { - out = os.Stderr - // **The verdict is the only thing on standard output** when it is asked for as JSON: composition - // says what it finds as it goes (a bus user without a credential, a module left out) and prints it, - // and a line of that before the verdict makes the verdict unreadable — the build seat would read no - // verdict at all. What is said goes beside the report. - os.Stdout = os.Stderr - } - changedList, err := changedPaths(*changed, *changedFile) - if err != nil { - return err - } - v, err := judgeChange(ctx, mergeCheckInput{facts: f, repository: *repository, tree: *tree, changed: changedList, - group: others, held: held, admin: *admin, say: out, beside: os.Getenv("MESH_CHECK_BESIDE")}) - os.Stdout = stdout - if err != nil { - return err - } - if *asJSON { - body, err := json.MarshalIndent(v, "", " ") - if err != nil { - return err - } - fmt.Println(string(body)) - // And the report a person reads, beside it: what the pull request is told comes from here. - fmt.Fprint(os.Stderr, v.Report()) - } else { - fmt.Print(v.Report()) - } - switch v.Verdict { - case "fail": - return errMergeGateFailed - case "error": - return errMergeGateCouldNotJudge - } - return nil -} - -// errMergeGateCouldNotJudge is the gate's own error: the mesh as it is could not be raised, so nothing -// the change does to it can be seen. Never a pass (novox/hq issue 285). -var errMergeGateCouldNotJudge = errors.New("the merge gate could not judge the change") - -// errMergeGateFailed is the gate's own failure: the verdict says why, so the error says nothing more. -var errMergeGateFailed = errors.New("the change fails the merge gate") - -// readFacts reads a snapshot from a file, or from the artifact store. -func readFacts(ctx context.Context, from string) (snapshot.Facts, error) { - var body []byte - var err error - if from == "store" { - address := strings.TrimSpace(os.Getenv("MESH_REGISTRY")) - if address == "" { - return snapshot.Facts{}, errors.New("--facts store reads the artifact store MESH_REGISTRY names, and it names none") - } - body, _, err = (artifacts.Store{Address: address}).GetTagged(ctx, snapshot.Repository, snapshot.Tag) - } else { - body, err = os.ReadFile(from) - } - if err != nil { - return snapshot.Facts{}, err - } - return snapshot.Decode(body) -} - -// judgeChange is the gate: both compositions, and what the change did to each machine. -func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error) { - v := mergeVerdict{Judge: version, Facts: in.facts.Taken, Modules: map[string]string{}} - say := func(format string, args ...any) { - if in.say != nil { - fmt.Fprintf(in.say, format+"\n", args...) - } - } - base, err := shelfOfFacts(in.facts) - if err != nil { - return v, err - } - change := base - sources := sourcesOfFacts(in.facts) - // **What the change moves is the planner's answer** (reachOfMerge, novox/hq ADR 0238), asked of the - // snapshot as if the change were merged: the definitions composed with the change are those of the - // modules a merge of it would rebuild, and of the modules it adds — not every definition in the tree, - // which may differ from what the mesh holds for reasons that are not this change's (a module pinned - // at an older commit, a main the mesh has not built yet). - var reach *mergeReach - // The change, then each other head of its delivery group laid over it in turn (novox/hq ADR 0239): one - // future state of the mesh, judged as the group would leave it. - heads := append([]groupChange{{Repository: in.repository, Tree: in.tree, Changed: in.changed}}, in.group...) - // **A module one head removes and another defines is a move** (novox/hq ADR 0420 §6, issue 1025): neither - // the removal of a module machines run (ADR 0236) nor a second definition of one module name (issue 239). - // The change is judged with the module built from the repository that now defines it. - moves, err := movesOfChange(in, sources) - if err != nil { - return v, err - } - changeSources := sources - if len(moves) > 0 { - changeSources = make(map[string]inventory.Source, len(sources)) - for k, o := range sources { - changeSources[k] = o - } - for name, mv := range moves { - o := changeSources[name] - o.Repository, o.Path = mv.To, mv.Dir - changeSources[name] = o - } - } - for i, h := range heads { - var r *mergeReach - if h.Repository != "" && len(h.Changed) > 0 { - got, err := reachOfChange(in.facts, h.Repository, h.Changed, h.Tree) - if err != nil { - v.Notes = append(v.Notes, "what a merge of "+h.Repository+" would rebuild could not be worked out, so "+ - "every definition in its tree is judged: "+err.Error()) - } else { - r = &got - } - } - if i == 0 { - reach = r - } - if h.Tree == "" { - continue - } - var failures []string - scope := scopeOfReach(r, h.Repository) - if scope != nil { - for _, mv := range moves { - if mv.To == h.Repository { - scope[mv.Dir] = true - } - } - } - change, failures, err = shelfWithChange(change, changeSources, in.facts, h.Repository, h.Tree, v.Modules, scope) - if err != nil { - return v, err - } - v.Failures = append(v.Failures, failures...) - } - for name := range moves { - v.Modules[name] = "moved" - } - if len(in.group) > 0 { - say("a delivery group: %d head(s) composed together", len(heads)) - } - say("judging %d module(s) changed against %d machine(s), as the snapshot of %s says they run", - len(v.Modules), len(in.facts.Machines), in.facts.Taken.Format(time.RFC3339)) - - // What no single machine shows: the rules between manifests, identities on the mesh's own longest - // name, and the data each module keeps. Only what the change adds is the change's. - for _, rule := range []func(catalogue.Shelf) []string{ - catalogue.CatalogueProblems, - func(s catalogue.Shelf) []string { return catalogue.IdentityProblems(s, in.facts.Longest()) }, - catalogue.DataProblems, - } { - was := rule(catalogue.Shelf(base)) - for _, p := range rule(catalogue.Shelf(change)) { - if !slices.Contains(was, p) { - v.Failures = append(v.Failures, p) - } - } - } - - // Both compositions, each in a store of its own. - say("composing every machine as the mesh is") - before, _, notes, err := composeEveryMachine(ctx, in, base, sources, nil) - if err != nil { - return v, fmt.Errorf("the mesh as the snapshot says it is could not be raised: %w", err) - } - v.Notes = append(v.Notes, notes...) - say("composing every machine with the change") - var added []string - for module, how := range v.Modules { - if how == "added" { - added = append(added, module) - } - } - sort.Strings(added) - // **The held values are laid in this composition only** (novox/hq ADR 0502 clause 4): the mesh as it is does not - // declare their keys, so the base composition would refuse them. - withHeld := in - var heldNotes []string - withHeld.facts, heldNotes = layHeld(in.facts, in.held, change) - for _, n := range heldNotes { - say("%s", n) - } - v.Notes = append(v.Notes, heldNotes...) - after, tried, notes, err := composeEveryMachine(ctx, withHeld, change, changeSources, added) - if err != nil { - return v, fmt.Errorf("the mesh with the change could not be raised: %w", err) - } - for _, n := range notes { - if !slices.Contains(v.Notes, n) { - v.Failures = append(v.Failures, n) - } - } - - for _, m := range in.facts.Machines { - gm := mergeMachine{Name: m.Name, Described: m.Described(), Live: m.Declaration.Composes, - Base: before[m.Name], Change: after[m.Name]} - gm.Added, gm.Removed = difference(gm.Base.Resources, gm.Change.Resources) - v.Machines = append(v.Machines, gm) - switch { - case gm.Base.Composes && !gm.Change.Composes: - v.Failures = append(v.Failures, fmt.Sprintf("%s: nothing could be sent to it with this change — %s", - gm.Described, firstOr(newOnly(gm.Change.Problems, gm.Base.Problems), gm.Change.Problems))) - case !gm.Base.Composes && m.Declaration.Composes: - // **The mesh composes it and the gate could not raise it as it is** (novox/hq issue 285): a fact - // the snapshot does not carry, or one the gate does not raise. Then the change is judged against a - // machine that is not the mesh's — broken against broken, which passes whatever the change does — - // so the gate cannot judge, and says so: an error, never a pass. - v.Errors = append(v.Errors, fmt.Sprintf("%s composes on the mesh and not as the gate raised it from the "+ - "snapshot: %s", gm.Described, firstOr(gm.Base.Problems, nil))) - if added := newOnly(gm.Change.Problems, gm.Base.Problems); len(added) > 0 { - v.Failures = append(v.Failures, fmt.Sprintf("%s: the change adds — %s", gm.Described, added[0])) - } - case !gm.Base.Composes: - v.Notes = append(v.Notes, fmt.Sprintf("%s does not compose on the mesh today either: %s", - gm.Described, firstOr(m.Declaration.Problems, gm.Base.Problems))) - } - for _, w := range newOnly(gm.Change.Withheld, gm.Base.Withheld) { - v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a consumer out of its grants — %s", - gm.Described, w)) - } - for _, u := range newOnly(gm.Change.Unbound, gm.Base.Unbound) { - v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a credential bound elsewhere — %s", - gm.Described, u)) - } - // A contribution its holder's template renders nothing for (novox/hq ADR 0255): the machine - // would be sent without it, so a change that adds one is refused, naming it. - for _, u := range newOnly(gm.Change.Unplaced, gm.Base.Unplaced) { - v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a contribution unplaced — %s", - gm.Described, u)) - } - for module, why := range gm.Change.LeftOut { - if _, was := gm.Base.LeftOut[module]; !was { - v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves %s out of its declaration — %s", - gm.Described, module, why)) - } - } - } - - // A module nobody runs yet, tried on a machine that could run it. - for _, module := range added { - t := tried[module] - for _, r := range t.Refused { - v.Failures = append(v.Failures, fmt.Sprintf("%s, assigned to %s, would be refused by its node-engine — %s", - module, t.Machine, r)) - } - if t.Said != "" { - v.Notes = append(v.Notes, fmt.Sprintf("%s could not be tried on a machine: %s", module, t.Said)) - } - } - - if reach != nil { - w := widthOf(*reach) - v.Width = &w - if len(w.Unread) > 0 { - v.Notes = append(v.Notes, fmt.Sprintf("%s read by no module's build, and rebuild nothing (issue 280)", - readableList(w.Unread))) - } - if len(w.Modules) > wideRebuild { - v.Warnings = append(v.Warnings, fmt.Sprintf("a merge rebuilds %d module(s) in %d tier(s)", - len(w.Modules), w.Tiers)) - } - } - - sort.Strings(v.Failures) - v.Failures = slices.Compact(v.Failures) - sort.Strings(v.Errors) - switch { - case len(v.Failures) > 0: - v.Verdict = "fail" - v.Summary = fmt.Sprintf("%d problem(s) the change brings; the first: %s", len(v.Failures), v.Failures[0]) - case len(v.Errors) > 0: - v.Verdict = "error" - v.Summary = fmt.Sprintf("the mesh as it is could not be raised, so the change cannot be judged against it "+ - "(%d of %d machines compose on the mesh and not in the gate); the first: %s", len(v.Errors), - len(v.Machines), v.Errors[0]) - case len(v.Warnings) > 0: - v.Verdict = "warning" - v.Summary = v.Warnings[0] - default: - v.Verdict = "pass" - composing := 0 - for _, m := range v.Machines { - if m.Change.Composes { - composing++ - } - } - v.Summary = fmt.Sprintf("every machine composes with the change as it did without (%d of %d compose)", - composing, len(v.Machines)) - } - return v, nil -} - -// Report is the verdict as a person reads it, and as a pull request is told it. -func (v mergeVerdict) Report() string { - var b strings.Builder - fmt.Fprintf(&b, "merge gate: %s — %s\n", strings.ToUpper(v.Verdict), v.Summary) - fmt.Fprintf(&b, "judged by controller %s against the facts of %s\n", v.Judge, v.Facts.Format(time.RFC3339)) - list := func(title string, items []string) { - if len(items) == 0 { - return - } - fmt.Fprintf(&b, "\n%s:\n", title) - for _, i := range items { - fmt.Fprintf(&b, " - %s\n", i) - } - } - list("fails", v.Failures) - list("could not judge", v.Errors) - list("warns", v.Warnings) - if len(v.Modules) > 0 { - var changed []string - for m, how := range v.Modules { - changed = append(changed, m+" ("+how+")") - } - sort.Strings(changed) - list("modules the change moves", changed) - } - var machines []string - for _, m := range v.Machines { - line := m.Described + ": " - switch { - case m.Change.Composes: - line += "composes" - default: - line += "does not compose" - } - if len(m.Added) > 0 || len(m.Removed) > 0 { - line += fmt.Sprintf("; the change adds %d resource(s) and removes %d", len(m.Added), len(m.Removed)) - if len(m.Added)+len(m.Removed) <= 6 { - line += " (" + strings.Join(append(prefixed("+", m.Added), prefixed("-", m.Removed)...), ", ") + ")" - } - } - machines = append(machines, line) - } - list("machines", machines) - if v.Width != nil { - fmt.Fprintf(&b, "\na merge would rebuild %d module(s) in %d tier(s)", len(v.Width.Modules), v.Width.Tiers) - if len(v.Width.Modules) > 0 && len(v.Width.Modules) <= wideRebuild { - fmt.Fprintf(&b, ": %s", strings.Join(v.Width.Modules, ", ")) - } - fmt.Fprintln(&b) - } - list("notes", v.Notes) - return b.String() -} - -func prefixed(p string, items []string) []string { - out := make([]string, len(items)) - for i, s := range items { - out[i] = p + s - } - return out -} - -// shelfOfFacts is every module of the snapshot, as the mesh holds it — but the ones that came with the -// controller: those are this controller's own, provided when its store is raised. -func shelfOfFacts(f snapshot.Facts) (map[string]catalogue.Manifest, error) { - out := map[string]catalogue.Manifest{} - for _, m := range f.Modules { - if m.Provided { - continue - } - var manifest catalogue.Manifest - if err := json.Unmarshal(m.Manifest, &manifest); err != nil { - return nil, fmt.Errorf("the snapshot's %s is not a manifest: %w", m.Name, err) - } - out[m.Name] = manifest - } - return out, nil -} - -// sourcesOfFacts is where each module of the snapshot is built from. -func sourcesOfFacts(f snapshot.Facts) map[string]inventory.Source { - out := map[string]inventory.Source{} - for _, m := range f.Modules { - if !m.Provided { - out[m.Name] = inventory.Source{Repository: m.Repository, Path: m.Path, BuiltFrom: m.Commit} - } - } - return out -} - -// movesOfChange is the modules the change moves: with a delivery group, those the group's heads move; without -// one, those it moves with the core clones beside the check (MESH_CHECK_BESIDE, mesh-catalog and -// mesh-controller, the repository a module can move between), so a member judged alone sees the move its -// group's composed check sees (issue 1043). The clones beside are used to find moves only, never as a -// composition of what machines get. -func movesOfChange(in mergeCheckInput, sources map[string]inventory.Source) (map[string]moveOfModule, error) { - heads := append([]groupChange{{Repository: in.repository, Tree: in.tree, Changed: in.changed}}, in.group...) - if len(in.group) == 0 && in.beside != "" && in.repository != "" && in.tree != "" { - for _, name := range []string{"mesh-catalog", "mesh-controller"} { - dir := filepath.Join(in.beside, name) - if repoOf(in.repository) == name { - continue - } - if st, err := os.Stat(dir); err != nil || !st.IsDir() { - continue - } - heads = append(heads, groupChange{Repository: ownerOf(in.repository) + "/" + name, Tree: dir}) - } - } - return movesInGroup(heads, sources) -} - -// moveOfModule is a module a delivery group moves: the repository the mesh builds it from, the one whose -// head now defines it, and its directory there. -type moveOfModule struct{ From, To, Dir string } - -// movesInGroup is the modules one head of a group removes and another defines (novox/hq ADR 0420 §6): a name -// the mesh builds from a head's repository that the head's tree no longer defines, and some other head's tree -// defines. A name removed by a head with no other head defining it, or defined by a head the mesh builds from -// another repository with none removing it, is no move. -func movesInGroup(heads []groupChange, sources map[string]inventory.Source) (map[string]moveOfModule, error) { - if len(heads) < 2 { - return nil, nil - } - defined := make([]map[string]string, len(heads)) - for i, h := range heads { - if h.Tree == "" { - continue - } - d, err := namesDefinedIn(h.Tree) - if err != nil { - return nil, err - } - defined[i] = d - } - var moves map[string]moveOfModule - for name, s := range sources { - if s.Repository == "" { - continue - } - for i, h := range heads { - if h.Tree == "" || !sameRepository(s.Repository, link.SourceMoved{Owner: ownerOf(h.Repository), Repo: repoOf(h.Repository)}) { - continue - } - if _, still := defined[i][name]; still { - break - } - // Removed by head i: defined by another? - for j, o := range heads { - if dir, ok := defined[j][name]; ok && j != i && o.Repository != h.Repository { - if moves == nil { - moves = map[string]moveOfModule{} - } - moves[name] = moveOfModule{From: h.Repository, To: o.Repository, Dir: dir} - } - } - break - } - } - return moves, nil -} - -// namesDefinedIn is the module names a tree's module.json files define, each with its directory; a manifest -// that does not read is left to shelfWithChange to refuse. -func namesDefinedIn(tree string) (map[string]string, error) { - found := map[string]string{} - err := filepath.WalkDir(tree, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - if path != tree && (ignoredInTree[d.Name()] || strings.HasPrefix(d.Name(), ".")) { - return filepath.SkipDir - } - return nil - } - if d.Name() != "module.json" { - return nil - } - raw, err := os.ReadFile(path) - if err != nil { - return err - } - var m struct { - Module string `json:"module"` - } - if json.Unmarshal(raw, &m) != nil || m.Module == "" { - return nil - } - dir, err := filepath.Rel(tree, filepath.Dir(path)) - if err != nil { - return err - } - if dir == "." { - dir = "" - } - if _, twice := found[m.Module]; !twice { - found[m.Module] = dir - } - return nil - }) - return found, err -} - -// ignoredInTree are directories a module.json in is not a module of the repository. -var ignoredInTree = map[string]bool{".git": true, "vendor": true, "node_modules": true, "testdata": true, - "examples": true, "fixtures": true} - -// shelfWithChange is the base shelf with every module of the change's tree in place of the mesh's: each -// read by this controller's strict parser — the one the mesh runs, for a change to a catalogue — resolved -// with stand-in builds, and read again as registration reads a built one. A module the repository held -// and the tree no longer has is removed. Answers the shelf and what the tree itself fails. -// -// `scope` is the directories of the modules a merge of the change would rebuild or add, as the planner says -// (scopeOfReach); a definition elsewhere in the tree is left as the mesh holds it. Nil judges every one. -func shelfWithChange(base map[string]catalogue.Manifest, sources map[string]inventory.Source, f snapshot.Facts, - repository, tree string, moved map[string]string, scope map[string]bool) (map[string]catalogue.Manifest, []string, error) { - inScope := func(dir string) bool { return scope == nil || scope[dir] } - out := make(map[string]catalogue.Manifest, len(base)) - for k, m := range base { - out[k] = m - } - var failures []string - // The list of predefined keys at the tree's root (novox/hq ADR 0348 §7): what the build of this tree judges each - // manifest against and registration publishes, so what the gate judges each against too. A list that is - // wrong is refused by name, and every manifest is then judged against none. - predefined, err := catalogue.ReadPredefined(tree) - if err != nil { - failures = append(failures, fmt.Sprintf("%s: the controller judging this (%s) refuses it — %s", - catalogue.PredefinedFile, version, oneLine(err.Error()))) - predefined = nil - } - found := map[string]string{} // module → its directory in the tree - err = filepath.WalkDir(tree, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - if path != tree && (ignoredInTree[d.Name()] || strings.HasPrefix(d.Name(), ".")) { - return filepath.SkipDir - } - return nil - } - if d.Name() != "module.json" { - return nil - } - dir, err := filepath.Rel(tree, filepath.Dir(path)) - if err != nil { - return err - } - if dir == "." { - dir = "" - } - raw, err := os.ReadFile(path) - if err != nil { - return err - } - m, err := catalogue.ParseManifestWith(raw, predefined) - // The manifest as written, still naming its templates: what the stand-in build resolves, as a real build - // does, so a moved module is handed its templates (issue 876). - written := m - var texts map[string]string - if err == nil { - // Each template's text from the tree beside the manifest, as the build reads it back from the archive it - // packs (novox/hq ADR 0349 §2): so a change to a template alone is a change to its module. - m, err = catalogue.WithTemplatesFrom(m, filepath.Dir(path)) - } - if err == nil && written.Templated() { - texts, err = catalogue.ReadTemplates(written, filepath.Dir(path)) - } - if err != nil { - if inScope(dir) { - failures = append(failures, fmt.Sprintf("%s: the controller judging this (%s) refuses it — %s", - orRoot(dir), version, oneLine(err.Error()))) - } - return nil - } - if was, twice := found[m.Module]; twice { - if inScope(dir) || inScope(was) { - failures = append(failures, fmt.Sprintf("%s and %s both define %s: two definitions of one module", - orRoot(was), orRoot(dir), m.Module)) - } - return nil - } - found[m.Module] = dir - if !inScope(dir) { - return nil - } - if s, held := sources[m.Module]; held && s.Repository != "" && !sameRepository(s.Repository, - link.SourceMoved{Owner: ownerOf(repository), Repo: repoOf(repository)}) { - failures = append(failures, fmt.Sprintf("%s defines %s, which the mesh builds from %s: two definitions "+ - "of one module name (issue 239)", orRoot(dir), m.Module, s.Repository)) - return nil - } - // A module lives whole in one repository (novox/hq ADR 0420 §1, issue 1019): no artifact builds from another. - if m.Build != nil { - own := link.SourceMoved{Owner: ownerOf(repository), Repo: repoOf(repository)} - other := false - for _, a := range m.Build.Artifacts { - if a.Context != nil && a.Context.Repository != "" && !sameRepository(a.Context.Repository, own) { - failures = append(failures, fmt.Sprintf("%s builds artifact %s from %s: a module lives whole in "+ - "one repository (novox/hq ADR 0420 §1)", m.Module, a.Name, a.Context.Repository)) - other = true - } - } - if other { - return nil - } - } - resolved, err := standInBuild(written, texts) - if err == nil { - // As registration reads a built manifest: strictly, again (takeIn). - var raw []byte - if raw, err = json.Marshal(resolved); err == nil { - resolved, err = catalogue.ParseManifestWith(raw, predefined) - } - } - if err == nil { - err = namesNoInstallation(resolved) - } - if err != nil { - failures = append(failures, fmt.Sprintf("%s: %s is not registrable — %s", orRoot(dir), m.Module, - oneLine(err.Error()))) - return nil - } - if was, held := base[m.Module]; !held { - moved[m.Module] = "added" - } else if builtAlike(was) == builtAlike(resolved) { - // Unchanged but for what a build pins: the mesh's own build stands, so the machines that - // run it compose exactly as they do. - return nil - } else { - moved[m.Module] = "changed" - } - out[m.Module] = resolved - return nil - }) - if err != nil { - return nil, nil, err - } - // What the repository held and the tree no longer defines is removed — refused while a machine runs it. - running := map[string][]string{} - for _, mc := range f.Machines { - for _, a := range mc.Assigned { - running[a] = append(running[a], mc.Described()) - } - } - for name, s := range sources { - if s.Repository == "" || !sameRepository(s.Repository, link.SourceMoved{Owner: ownerOf(repository), Repo: repoOf(repository)}) { - continue - } - if _, still := found[name]; still { - continue - } - if !inScope(strings.Trim(s.Path, "/")) { - continue - } - delete(out, name) - moved[name] = "removed" - if on := running[name]; len(on) > 0 { - failures = append(failures, fmt.Sprintf("%s is removed from %s, and %s run(s) it: unassign it first (ADR 0236)", - name, repository, readableList(on))) - } - } - return out, failures, nil -} - -func orRoot(dir string) string { - if dir == "" { - return "the repository's root" - } - return dir -} - -func ownerOf(repository string) string { - owner, _, _ := strings.Cut(repository, "/") - return owner -} - -func repoOf(repository string) string { - _, repo, _ := strings.Cut(repository, "/") - return repo -} - -// builtAlike is a resolved manifest with what a build pins taken out — the digests and the references -// they are reached by, and the version a path is named for — so a definition the change leaves alone reads -// the same as the build of it the mesh holds. -func builtAlike(m catalogue.Manifest) string { - raw, err := json.Marshal(m) - if err != nil { - return "" - } - var doc any - if err := json.Unmarshal(raw, &doc); err != nil { - return "" - } - var walk func(any) any - walk = func(v any) any { - switch t := v.(type) { - case map[string]any: - for k, e := range t { - switch strings.ToLower(k) { - case "image", "source", "digest", "launchers": - t[k] = "pinned" - default: - t[k] = walk(e) - } - } - return t - case []any: - for i, e := range t { - t[i] = walk(e) - } - return t - case string: - if before, _, found := strings.Cut(t, "/versions/"); found { - return before + "/versions/pinned" - } - return t - } - return v - } - out, err := json.Marshal(walk(doc)) - if err != nil { - return "" - } - return string(out) -} - -// standInBuild resolves a manifest as a build would, with artifacts nobody built: a digest made from the -// module's and the artifact's names, in the store's own vocabulary. Composition and validation read the -// shape of a reference, never its bytes. -func standInBuild(m catalogue.Manifest, texts map[string]string) (catalogue.Manifest, error) { - if m.Build == nil { - return m.Resolve(nil) - } - // The templates archive carries each template's text by its path inside the archive, as a build reads it back. - archive, inside := catalogue.TemplatesArtifact(m), m.TemplatesDir()+"/" - read := map[string]string{} - for template, text := range texts { - read[strings.TrimPrefix(template, inside)] = text - } - var built []catalogue.Built - for _, a := range m.Build.Artifacts { - sum := sha256.Sum256([]byte("gate\x00" + m.Module + "\x00" + a.Name)) - digest := fmt.Sprintf("sha256:%x", sum) - b := catalogue.Built{Name: a.Name, Kind: a.Kind, Digest: digest} - if a.Name == archive && archive != "" { - b.Texts = read - } - switch a.Kind { - case catalogue.ArtifactImage, catalogue.ArtifactUpstream: - b.Reference = catalogue.ArtifactStoreScheme + m.Module + "/" + a.Name + "@" + digest - default: - b.Reference = catalogue.ArtifactStoreScheme + m.Module + "/" + a.Name + "/blobs/" + digest - } - built = append(built, b) - } - return m.Resolve(built) -} - -// composeEveryMachine raises a throwaway store from the snapshot with this shelf, composes every machine -// twice — the first time as a push does, making each credential, so the second has every consumer's -// grant to compose — and answers each machine's composition. Notes are what of the snapshot could not -// be raised: a refusal to register a module, keep a setting or a pin. -func composeEveryMachine(ctx context.Context, in mergeCheckInput, shelf map[string]catalogue.Manifest, - sources map[string]inventory.Source, trials []string) (map[string]mergeComposed, map[string]mergeTrial, []string, error) { - open, drop, err := throwawayStores(ctx, in.admin) - if err != nil { - return nil, nil, nil, err - } - defer drop() - notes, err := raiseFromFacts(ctx, open, in.facts, shelf, sources) - if err != nil { - return nil, nil, notes, err - } - gens, gensErr := generatorsOf(ctx, open) - out := map[string]mergeComposed{} - for pass := 0; pass < 2; pass++ { - for _, m := range in.facts.Machines { - if gensErr != nil { - out[m.Name] = mergeComposed{Problems: []string{"the private network cannot be computed: " + - oneLine(gensErr.Error())}} - continue - } - declared, problems, err := composeSendValidated(ctx, open, m.Name) - if pass == 0 { - continue - } - c := mergeComposed{} - if err != nil { - c.Problems = []string{oneLine(err.Error())} - out[m.Name] = c - continue - } - c.Composes = len(problems) == 0 - c.Problems = problems - c.Resources = resourceNames(declared.Resources) - c.LeftOut = declared.leftOutWhy - for _, o := range declared.withheld { - c.Withheld = append(c.Withheld, o.String()) - } - for _, u := range declared.unbound { - c.Unbound = append(c.Unbound, u.String()) - } - c.Unplaced = declared.unplaced - sort.Strings(c.Withheld) - sort.Strings(c.Unbound) - out[m.Name] = c - } - if ctx.Err() != nil { - return nil, nil, notes, ctx.Err() - } - } - tried := map[string]mergeTrial{} - if gensErr == nil { - for _, module := range trials { - tried[module] = tryAssigning(ctx, open, in.facts, shelf[module], gens) - } - } - return out, tried, notes, nil -} - -// mergeTrial is a module no machine runs yet, assigned for a moment to one that could run it. -type mergeTrial struct { - Machine string `json:"machine,omitempty"` - Refused []string `json:"refused,omitempty"` - Composes bool `json:"composes"` - Said string `json:"said,omitempty"` -} - -// tryAssigning composes a module nobody runs yet on the first machine that has what it needs, and says -// what the node-engine would refuse of it there (issue 236: the refusal came only from the first live -// machine it was assigned to). The assignment is taken back; nothing else in the store moves. -func tryAssigning(ctx context.Context, open *stores, f snapshot.Facts, m catalogue.Manifest, - gens map[string]catalogue.Generator) mergeTrial { - for _, machine := range f.Machines { - has := map[string]bool{} - for _, c := range machine.Capabilities { - has[c.Name] = c.Present - } - fits := true - for _, c := range m.Capabilities { - fits = fits && has[c] - } - if !fits { - continue - } - if _, err := open.inventory.Assign(ctx, machine.Name, m.Module); err != nil { - return mergeTrial{Machine: machine.Described(), Said: oneLine(err.Error())} - } - _, problems, err := composeSendValidated(ctx, open, machine.Name) - _ = open.inventory.Unassign(ctx, machine.Name, m.Module) - t := mergeTrial{Machine: machine.Described()} - if err != nil { - t.Said = oneLine(err.Error()) - return t - } - for _, p := range problems { - if strings.Contains(p, `"`+m.Module+".") || strings.Contains(p, m.Module+":") { - t.Refused = append(t.Refused, p) - } - } - t.Composes = len(problems) == 0 - return t - } - return mergeTrial{Said: "no machine of the mesh has what it needs: " + strings.Join(m.Capabilities, ", ")} -} - -// throwawayStores creates fresh databases for every context the controller keeps in the PostgreSQL at -// admin, brings each to this controller's schema, and answers them opened, with what drops them again. -func throwawayStores(ctx context.Context, admin string) (*stores, func(), error) { - suffix := fmt.Sprintf("%d", time.Now().UnixNano()%1_000_000_000) - conn, err := pgx.Connect(ctx, admin) - if err != nil { - return nil, nil, fmt.Errorf("cannot reach the throwaway PostgreSQL: %w", err) - } - defer conn.Close(ctx) - cut := strings.LastIndex(admin, "/") - if cut < 0 { - return nil, nil, fmt.Errorf("%q is not a PostgreSQL URL", admin) - } - query := "" - if q := strings.Index(admin[cut:], "?"); q >= 0 { - query = admin[cut+q:] - } - var names []string - // The stores are found by the environment, so what it said before is what it says after: a gate run - // inside a process with stores of its own — a test, a controller — leaves them as they were. - restore := map[string]*string{} - for _, c := range held { - if was, set := os.LookupEnv(store.Variable(c.name)); set { - restore[c.name] = &was - } else { - restore[c.name] = nil - } - } - drop := func() { - for name, was := range restore { - if was == nil { - _ = os.Unsetenv(store.Variable(name)) - } else { - _ = os.Setenv(store.Variable(name), *was) - } - } - c, err := pgx.Connect(context.Background(), admin) - if err != nil { - return - } - defer c.Close(context.Background()) - for _, n := range names { - _, _ = c.Exec(context.Background(), "drop database if exists "+n+" with (force)") - } - } - for _, c := range held { - name := "gate_" + c.name + "_" + suffix - if _, err := conn.Exec(ctx, "create database "+name); err != nil { - drop() - return nil, nil, fmt.Errorf("cannot create %s: %w", name, err) - } - names = append(names, name) - url := admin[:cut] + "/" + name + query - if err := os.Setenv(store.Variable(c.name), url); err != nil { - drop() - return nil, nil, err - } - migrations, err := c.migrations() - if err != nil { - drop() - return nil, nil, err - } - s, err := store.Open(ctx, c.name) - if err != nil { - drop() - return nil, nil, err - } - _, err = s.Migrate(ctx, migrations) - s.Close() - if err != nil { - drop() - return nil, nil, fmt.Errorf("%s does not migrate: %w", c.name, err) - } - } - open, err := openStores(ctx) - if err != nil { - drop() - return nil, nil, err - } - // The chain lives on the bus, and the gate has none: this mesh is raised for a moment, with a chain of its own in memory - // that no other reads (issue 1121). - open.inventory.UseThrowawayChain() - for _, m := range provided { - if err := open.inventory.Provide(ctx, m); err != nil { - open.Close() - drop() - return nil, nil, err - } - } - if _, err := open.inventory.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil { - open.Close() - drop() - return nil, nil, err - } - return open, func() { open.Close(); drop() }, nil -} - -// raiseFromFacts puts the snapshot's mesh into a fresh store through the controller's own records: -// every module of the shelf registered, every machine added where it is with what it reported, a key -// for each standing in for the one it holds, its assignments, the seats it holds, its pins and its -// settings, and a stand-in for every secret a person gave. -func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf map[string]catalogue.Manifest, - sources map[string]inventory.Source) ([]string, error) { - inv := open.inventory - var notes []string - names := make([]string, 0, len(shelf)) - for name := range shelf { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - s := sources[name] - if err := inv.RegisterModule(ctx, shelf[name], inventory.Source{Repository: s.Repository, Path: s.Path, - BuiltFrom: s.BuiltFrom}); err != nil { - notes = append(notes, fmt.Sprintf("%s is not registered: %s", name, oneLine(err.Error()))) - } - } - for i, m := range f.Machines { - node, err := inv.AddNodeAs(ctx, m.Name, m.Adopted) - if err != nil { - return notes, fmt.Errorf("%s: %w", m.Name, err) - } - if m.OnNetwork { - endpoint := "" - if m.Public { - endpoint = fmt.Sprintf("192.0.2.%d:51820", i+1) - } - if err := inv.SetPlace(ctx, m.Name, endpoint, m.Site, m.Hub, fmt.Sprintf("10.99.%d.%d", i/250, i%250+1)); err != nil { - return notes, fmt.Errorf("%s: %w", m.Name, err) - } - } - var caps []map[string]any - for _, c := range m.Capabilities { - caps = append(caps, map[string]any{"name": c.Name, "present": c.Present, "detail": c.Detail}) - } - if err := inv.RecordProfile(ctx, node.ID, map[string]any{"architecture": m.Architecture, - "kernel": m.Kernel, "capabilities": caps}); err != nil { - return notes, err - } - // What its node-engine takes, as the mesh composes it today (issue 969): without the statement the gate - // composes a moved module the old way on a machine the mesh hands it to. - if stated, ok := standInHealth(m, f.Taken); ok { - if _, err := inv.RecordHealth(ctx, stated); err != nil { - return notes, err - } - } - for _, record := range []func(context.Context, string, string) error{inv.RecordSealingKey, inv.RecordOverlayKey} { - key, err := standInKey() - if err != nil { - return notes, err - } - if err := record(ctx, node.ID, key); err != nil { - return notes, err - } - } - // The links it faces outside by, which a filter is written around (ADR 0140). A snapshot from a - // controller that did not carry them stands in one for a machine the mesh composes: composing, it - // had reported them. - outward := m.OutwardLinks - if len(outward) == 0 && m.Declaration.Composes { - outward = []string{"outside0"} - } - if len(outward) > 0 { - if err := inv.RecordOutwardLinks(ctx, node.ID, outward); err != nil { - return notes, err - } - } - if m.Account != "" { - if err := inv.SetAccount(ctx, m.Name, m.Account, m.AccountHome); err != nil { - return notes, err - } - } - if m.AgentAccount != "" { - if err := inv.SetAgentAccount(ctx, m.Name, m.AgentAccount, m.AgentAccountHome); err != nil { - return notes, err - } - } - if m.PublicDomain != "" { - if err := inv.SetPublicDomain(ctx, m.Name, m.PublicDomain); err != nil { - return notes, err - } - } - } - for _, m := range f.Machines { - for _, module := range m.Assigned { - if _, err := inv.Assign(ctx, m.Name, module); err != nil { - notes = append(notes, fmt.Sprintf("%s cannot be assigned to %s: %s", module, m.Described(), - oneLine(err.Error()))) - } - } - } - for _, s := range f.Seats { - for _, h := range s.Holders { - var err error - if s.Scope == catalogue.ScopeMesh && len(s.Holders) > 1 { - err = inv.AddSeatHolder(ctx, s.Name, s.Scope, h.Machine, h.Module) - } else { - err = inv.HoldSeat(ctx, s.Name, s.Scope, h.Machine, h.Module) - } - if err != nil { - notes = append(notes, fmt.Sprintf("%s's hold of %s is not kept: %s", h.Machine, s.Name, oneLine(err.Error()))) - } - } - } - for _, s := range f.Settings { - if err := inv.KeepSettings(ctx, "", s.Module, standInPaths(s.Values)); err != nil { - notes = append(notes, fmt.Sprintf("the mesh's settings of %s are not kept: %s", s.Module, oneLine(err.Error()))) - } - } - for _, m := range f.Machines { - for _, p := range m.Pins { - if err := inv.PinProvision(ctx, m.Name, p.Provision, p.Machine, p.Module); err != nil { - notes = append(notes, fmt.Sprintf("%s's pin of %s is not kept: %s", m.Described(), p.Provision, - oneLine(err.Error()))) - } - } - for _, s := range m.Settings { - if err := inv.KeepSettings(ctx, m.Name, s.Module, standInPaths(s.Values)); err != nil { - notes = append(notes, fmt.Sprintf("%s's settings of %s are not kept: %s", m.Described(), s.Module, - oneLine(err.Error()))) - } - } - for _, a := range m.Accepted { - standIn := fmt.Sprintf("gate-stand-in-%x", sha256.Sum256([]byte(m.Name+a.Module+a.Name+a.Provider+a.Local)))[:40] - if a.Provider == "" && a.Name == "broker" { - // **The bus credential is an account, not a given value** (novox/hq issue 203): composition asks - // whether one was issued for the module, so the raised store mints the account the mesh issued - // as well as holding the sealed value — otherwise every machine running a module on the bus - // fails to compose, with the change and without, and the gate compares broken to broken. - // A credential the mesh still holds for a module that no longer reads one composes nothing, - // so it is not raised. - if !readsBusCredential(shelf, a.Module) { - continue - } - if _, err := inv.MintBusPassword(ctx, inventory.BusUser{Kind: busKindOf(a.Module), Node: m.Name, - Module: a.Module, Username: broker.Principal{Kind: broker.KindModule, Node: m.Name, - Module: a.Module}.Username()}); err != nil { - notes = append(notes, fmt.Sprintf("%s's bus account for %s is not kept: %s", m.Described(), - a.Module, oneLine(err.Error()))) - continue - } - } - // And the provisioner client's account, the same way (novox/hq ADR 0349 clause 11, design 57 P3). - if a.Provider == "" && a.Name == broker.ProvisionerSecret { - if pm, held := shelf[a.Module]; !held || !runsTheProvisionerClient(pm) { - continue - } - if _, err := inv.MintBusPassword(ctx, inventory.BusUser{Kind: inventory.BusProvisioner, Node: m.Name, - Module: a.Module, Username: broker.Principal{Kind: broker.KindProvisioner, Node: m.Name, - Module: a.Module}.Username()}); err != nil { - notes = append(notes, fmt.Sprintf("%s's provisioner client account for %s is not kept: %s", - m.Described(), a.Module, oneLine(err.Error()))) - continue - } - } - var err error - if a.Provider == "" { - err = inv.AcceptSecretForModule(ctx, m.Name, a.Module, a.Name, standIn) - } else { - err = inv.AcceptSecretForPair(ctx, a.Name, m.Name, a.Module, a.Provider, a.Local, standIn) - } - if err != nil { - notes = append(notes, fmt.Sprintf("%s's given %s for %s is not kept: %s", m.Described(), a.Name, - a.Module, oneLine(err.Error()))) - } - } - } - return notes, nil -} - -// standInPaths is a settings layer with every value the snapshot withheld where a path stands — an -// access's or a place's — given a path of its own: a path that reads as a key is withheld by the scrub, -// and a bare "withheld" is no absolute path, which composition refuses (a machine the mesh composes -// would not compose as the gate raised it). Each stand-in is distinct, so two withheld places stay two. -func standInPaths(values map[string]any) map[string]any { - out := make(map[string]any, len(values)) - for k, v := range values { - out[k] = v - } - if accesses, ok := values["accesses"].(map[string]any); ok { - kept := make(map[string]any, len(accesses)) - for name, at := range accesses { - if withheldPath(at) { - at = "/" + snapshot.Withheld + "/access/" + name - } - kept[name] = at - } - out["accesses"] = kept - } - if places, ok := values["places"].(map[string]any); ok { - kept := make(map[string]any, len(places)) - for name, p := range places { - if place, ok := p.(map[string]any); ok && withheldPath(place["path"]) { - copied := make(map[string]any, len(place)) - for k, v := range place { - copied[k] = v - } - copied["path"] = "/" + snapshot.Withheld + "/place/" + name - p = copied - } - kept[name] = p - } - out["places"] = kept - } - return out -} - -// withheldPath is a value the scrub withheld where a path stood: bare, or keeping a path's shape. -func withheldPath(v any) bool { - return v == snapshot.Withheld || v == "/"+snapshot.Withheld -} - -// readsBusCredential is whether a module of the shelf, or one that came with the controller, declares the -// own secret its bus account is delivered as. -func readsBusCredential(shelf map[string]catalogue.Manifest, module string) bool { - if m, held := shelf[module]; held { - _, reads := m.OwnSecrets["broker"] - return reads - } - for _, m := range provided { - if m.Module == module { - _, reads := m.OwnSecrets["broker"] - return reads - } - } - return false -} - -// standInKey is a key a machine could have reported; its private half is never kept. -func standInKey() (string, error) { - k, err := ecdh.X25519().GenerateKey(rand.Reader) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()), nil -} - -// reachOfChange is what a merge of the change would move and build — **the planner's own answer** -// (reachOfMerge), asked of the modules, sources, reads and edges the snapshot carries, never a mapping of -// the gate's own: what a merge touches and what follows it are decided in one place, so the gate and the -// plan a merge makes cannot disagree (novox/hq ADR 0238). -func reachOfChange(f snapshot.Facts, repository string, paths []string, tree string) (mergeReach, error) { - owner, repo, found := strings.Cut(repository, "/") - if !found { - return mergeReach{}, fmt.Errorf("%q is not owner/repository", repository) - } - m := link.SourceMoved{Owner: owner, Repo: repo, Base: "main", Commit: "gate", Paths: paths} - // Which changed directories hold a module, read from the change's tree as the forge's announcer reads - // them at the head (issue 278): what the planner is told of a module the mesh does not hold yet. - if tree != "" { - m.ModuleDirs, m.ModuleDirsSaid = moduleDirsIn(tree, paths), true - for _, p := range paths { - if _, err := os.Stat(filepath.Join(tree, p)); os.IsNotExist(err) { - m.Removed = append(m.Removed, p) - } - } - } - entries, read, edges, err := graphOfFacts(f) - if err != nil { - return mergeReach{}, err - } - return reachOfMerge(m, entries, read, edges), nil -} - -// graphOfFacts is the module graph the snapshot carries, as the planner reads it from the store. -func graphOfFacts(f snapshot.Facts) ([]inventory.Entry, map[string][]inventory.ReadRepository, []inventory.Edge, error) { - var entries []inventory.Entry - read := map[string][]inventory.ReadRepository{} - for _, mod := range f.Modules { - var manifest catalogue.Manifest - if err := json.Unmarshal(mod.Manifest, &manifest); err != nil { - return nil, nil, nil, err - } - entries = append(entries, inventory.Entry{Manifest: manifest, Provided: mod.Provided, - Source: inventory.Source{Repository: mod.Repository, Path: mod.Path, BuiltFrom: mod.Commit}}) - for _, r := range mod.Reads { - entry := inventory.ReadRepository{Repository: r} - for _, s := range mod.Sources { - if !s.Own && s.Repository == r && len(s.Paths) > 0 { - entry.Paths = s.Paths - } - } - read[mod.Name] = append(read[mod.Name], entry) - } - for _, s := range mod.Sources { - if s.Own && len(s.Paths) > 0 { - read[mod.Name] = append(read[mod.Name], inventory.ReadRepository{Own: true, Paths: s.Paths}) - } - } - // A context naming the module's own repository is its own build source (novox/hq ADR 0420 §4). - read[mod.Name] = inventory.OwnRepositoryAsOwn(read[mod.Name], mod.Repository) - } - var edges []inventory.Edge - for _, e := range f.Edges { - edges = append(edges, inventory.Edge{From: e.From, To: e.To, Kind: e.Kind}) - } - return entries, read, edges, nil -} - -// widthOf is how wide the rebuild of a reach is. -func widthOf(r mergeReach) mergeWidthOf { - w := mergeWidthOf{Tiers: len(r.Plan.Tiers), Unread: r.Unread} - for name := range r.Plan.Modules { - w.Modules = append(w.Modules, name) - } - sort.Strings(w.Modules) - return w -} - -// scopeOfReach is the directories, in the change's repository, of the modules a reach rebuilds or adds; -// nil — every definition judged — when there is no reach. -func scopeOfReach(r *mergeReach, repository string) map[string]bool { - if r == nil { - return nil - } - scope := map[string]bool{} - same := link.SourceMoved{Owner: ownerOf(repository), Repo: repoOf(repository)} - for _, e := range append(append([]inventory.Entry{}, r.Touched...), r.Deleted...) { - if sameRepository(e.Source.Repository, same) { - scope[strings.Trim(e.Source.Path, "/")] = true - } - } - for _, d := range r.Added { - if d == "." { - d = "" - } - scope[d] = true - } - return scope -} - -// moduleDirsIn are the directories above the changed paths, never the root, that hold a module.json in -// the tree. -func moduleDirsIn(tree string, paths []string) []string { - seen := map[string]bool{} - var out []string - for _, p := range paths { - for dir := filepath.Dir(filepath.Clean(p)); dir != "." && dir != "/" && dir != ""; dir = filepath.Dir(dir) { - if seen[dir] { - continue - } - seen[dir] = true - if _, err := os.Stat(filepath.Join(tree, dir, "module.json")); err == nil { - out = append(out, filepath.ToSlash(dir)) - } - } - } - sort.Strings(out) - return out -} - -// difference is what b has that a has not, and what a has that b has not. -func difference(a, b []string) (added, removed []string) { - for _, x := range b { - if !slices.Contains(a, x) { - added = append(added, x) - } - } - for _, x := range a { - if !slices.Contains(b, x) { - removed = append(removed, x) - } - } - return added, removed -} - -// newOnly is what now has that was had not. -func newOnly(now, was []string) []string { - var out []string - for _, x := range now { - if !slices.Contains(was, x) { - out = append(out, x) - } - } - return out -} - -func firstOr(items, otherwise []string) string { - if len(items) > 0 { - return items[0] - } - if len(otherwise) > 0 { - return otherwise[0] - } - return "it says nothing more" -} - -// standInHealth is the statement the gate gives a machine of the snapshot so that it composes as the mesh does: -// only its contract, which says whether it takes handed files (issue 969). False for a snapshot that carries none. -func standInHealth(m snapshot.Machine, taken time.Time) (inventory.NodeHealth, bool) { - if m.Contract == 0 { - return inventory.NodeHealth{}, false - } - return inventory.NodeHealth{Node: m.Name, Contract: m.Contract, SaidAt: taken, HeardAt: taken}, true -} diff --git a/cmd/mesh-controller/merge_gate_changed_test.go b/cmd/mesh-controller/merge_gate_changed_test.go deleted file mode 100644 index 4772c0d8..00000000 --- a/cmd/mesh-controller/merge_gate_changed_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "reflect" - "strings" - "testing" -) - -func TestTheGateJudgesAFileOfPathsAsItDoesTheSameListOfThem(t *testing.T) { // 1247/AC3 - paths := []string{"modules/a/x.go", "modules/a/y.go", "modules/b/z.go", "README.md"} - file := filepath.Join(t.TempDir(), "changed.txt") - if err := os.WriteFile(file, []byte(strings.Join(paths, "\n")+"\n"), 0o644); err != nil { - t.Fatal(err) - } - fromFile, err := changedPaths("", file) - if err != nil { - t.Fatal(err) - } - fromList, err := changedPaths(strings.Join(paths, ","), "") - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(fromFile, fromList) || len(fromFile) != len(paths) { - t.Errorf("the file gives %v, the list %v", fromFile, fromList) - } - if _, err := changedPaths("", filepath.Join(t.TempDir(), "none")); err == nil { - t.Error("a file that is not there is read as no paths") - } -} diff --git a/cmd/mesh-controller/merge_gate_handing_integration_test.go b/cmd/mesh-controller/merge_gate_handing_integration_test.go deleted file mode 100644 index 58259195..00000000 --- a/cmd/mesh-controller/merge_gate_handing_integration_test.go +++ /dev/null @@ -1,68 +0,0 @@ -//go:build integration - -package main - -import ( - "os" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -func TestIssue969TheGateRaisesEachMachineAsItsEngineStatedItself(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - said := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) - for node, contract := range map[string]int{"anchor": link.HandedContract, "laptop": link.HandedContract - 1} { - if _, err := open.inventory.RecordHealth(ctx, inventory.NodeHealth{Node: node, Contract: contract, - SaidAt: said, HeardAt: said}); err != nil { - t.Fatal(err) - } - } - f, err := gatherFacts(ctx, open, "2.11.17") - if err != nil { - t.Fatal(err) - } - if len(f.Machines) != 2 { - t.Fatalf("the snapshot holds %d machines", len(f.Machines)) - } - want := map[string]bool{} - for _, m := range f.Machines { - if m.Contract == 0 { - t.Fatalf("%s: the snapshot carries no contract", m.Name) - } - want[m.Name] = m.Contract >= link.HandedContract - } - handing := 0 - for _, hands := range want { - if hands { - handing++ - } - } - if handing != 1 { - t.Fatalf("one machine takes handed files in the snapshot, got %v", want) - } - - raised, drop, err := throwawayStores(ctx, os.Getenv("MESH_TEST_POSTGRES")) - if err != nil { - t.Fatal(err) - } - defer drop() - if _, err := raiseFromFacts(ctx, raised, f, map[string]catalogue.Manifest{}, map[string]inventory.Source{}); err != nil { - t.Fatal(err) - } - for name, hands := range want { - in, err := readComposeInput(ctx, raised) - if err != nil { - t.Fatal(err) - } - node, _ := nodeOf(in.Input, name) - if got := node.Stated && node.Contract >= compose.HandedContract; got != hands { - t.Errorf("%s: the gate raises it as taking handed files = %v (%v), the mesh says %v", name, got, err, hands) - } - } -} diff --git a/cmd/mesh-controller/merge_gate_handing_test.go b/cmd/mesh-controller/merge_gate_handing_test.go deleted file mode 100644 index 73c5a9da..00000000 --- a/cmd/mesh-controller/merge_gate_handing_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import ( - "testing" - "time" - - snapshot "git.novox.be/novox/mesh-sdk/go/facts" - "github.com/novox/mesh-controller/internal/link" -) - -// Issue 969: the gate composes a machine as the mesh does, so a moved module is handed where the machine takes -// handed files and composed the old way where it does not. -func TestIssue969TheGateGivesAMachineItsContractSoItTakesHandedFiles(t *testing.T) { - taken := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) - - stated, ok := standInHealth(snapshot.Machine{Name: "f35", Contract: link.HandedContract}, taken) - if !ok || stated.Contract < link.HandedContract { - t.Fatalf("a machine whose engine takes handed files must be given that in the gate: %+v, %v", stated, ok) - } - old, ok := standInHealth(snapshot.Machine{Name: "e12", Contract: link.HandedContract - 1}, taken) - if !ok || old.Contract >= link.HandedContract { - t.Fatalf("a machine whose engine is older must not take handed files: %+v, %v", old, ok) - } - none, ok := standInHealth(snapshot.Machine{Name: "d01"}, taken) - if ok { - t.Fatalf("a snapshot that carries no contract states nothing: %+v, %v", none, ok) - } -} - -// Issue 969, through the store: a machine whose engine stated the handed contract is in the snapshot with it, the -// gate raises it as taking handed files, and one whose statement is below it is raised as not taking them. Run in -// the merge check with the other tests that need a store; the composition itself, handed here and refused there, is -// the catalogue's TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded. diff --git a/cmd/mesh-controller/merge_gate_held.go b/cmd/mesh-controller/merge_gate_held.go deleted file mode 100644 index 3974e5ab..00000000 --- a/cmd/mesh-controller/merge_gate_held.go +++ /dev/null @@ -1,135 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - snapshot "git.novox.be/novox/mesh-sdk/go/facts" -) - -// Config values held for the keys an open pull request introduces (novox/hq ADR 0502 clause 4, issue 1826): the gate -// lays each approved one over its module's values on its node in the composition with the change, and in that one -// only. In the mesh as it is the key is not declared, so the value would be refused there. - -// heldValue is one approved held value as the build agent writes it (--held): the value of Key for Module on Node, -// a machine's real name, which the snapshot holds only as a pseudonym. -type heldValue struct { - Pull string `json:"pull"` - Head string `json:"head,omitempty"` - Node string `json:"node"` - Module string `json:"module"` - Key string `json:"key"` - Value json.RawMessage `json:"value"` - Approved string `json:"approved,omitempty"` - By string `json:"by,omitempty"` - At string `json:"at,omitempty"` -} - -// readHeld reads the file --held names; none named, none held. -func readHeld(path string) ([]heldValue, error) { - if path == "" { - return nil, nil - } - raw, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var held []heldValue - if err := json.Unmarshal(raw, &held); err != nil { - return nil, fmt.Errorf("the held values are not readable: %w", err) - } - for _, h := range held { - if h.Node == "" || h.Module == "" || h.Key == "" || len(h.Value) == 0 { - return nil, fmt.Errorf("a held value of %s names no node, module, key or value", h.Pull) - } - } - return held, nil -} - -// layHeld is the snapshot with each held value laid as the value of its key in its node's layer of its module, and what -// it said of them. The snapshot given is not changed. **A held value for a machine the snapshot does not hold, or a -// module the change does not define, is said and not laid**, never dropped silently; one for a module not assigned to -// its machine is laid and said. The notes name machines as the snapshot does (roles and pseudonym), not by their names. -func layHeld(f snapshot.Facts, held []heldValue, shelf map[string]catalogue.Manifest) (snapshot.Facts, []string) { - if len(held) == 0 { - return f, nil - } - out := f - out.Machines = make([]snapshot.Machine, len(f.Machines)) - copy(out.Machines, f.Machines) - var notes, laid []string - pulls := map[string]bool{} - for _, h := range held { - at := -1 - pseudonym := snapshot.Pseudonym("machine", h.Node) - for i, m := range out.Machines { - if m.Name == pseudonym { - at = i - } - } - if at < 0 { - notes = append(notes, fmt.Sprintf("the value held for %s of %s (%s=%s) is not composed: the snapshot holds no machine %s", - h.Module, h.Pull, h.Key, h.Value, pseudonym)) - continue - } - if _, defined := shelf[h.Module]; !defined { - notes = append(notes, fmt.Sprintf("the value held for %s of %s (%s=%s) is not composed: the change defines no module %s", - h.Module, h.Pull, h.Key, h.Value, h.Module)) - continue - } - var value any - if err := json.Unmarshal(h.Value, &value); err != nil { - notes = append(notes, fmt.Sprintf("the value held for %s of %s (%s) is not composed: it is not a value: %v", - h.Module, h.Pull, h.Key, err)) - continue - } - m := &out.Machines[at] - layers := make([]snapshot.Settings, len(m.Settings)) - copy(layers, m.Settings) - found := false - for i := range layers { - if layers[i].Module == h.Module { - values := make(map[string]any, len(layers[i].Values)+1) - for k, v := range layers[i].Values { - values[k] = v - } - values[h.Key] = value - layers[i].Values = values - found = true - } - } - if !found { - layers = append(layers, snapshot.Settings{Module: h.Module, Values: map[string]any{h.Key: value}}) - sort.Slice(layers, func(a, b int) bool { return layers[a].Module < layers[b].Module }) - } - m.Settings = layers - assigned := false - for _, a := range m.Assigned { - assigned = assigned || a == h.Module - } - by := "" - if h.Approved != "" { - by = ", approved by " + h.Approved - } - line := fmt.Sprintf("%s on %s %s=%s%s", h.Module, m.Described(), h.Key, h.Value, by) - if !assigned { - line += " (not assigned there in the snapshot)" - } - laid = append(laid, line) - pulls[h.Pull] = true - } - if len(laid) > 0 { - var names []string - for p := range pulls { - names = append(names, p) - } - sort.Strings(names) - notes = append([]string{fmt.Sprintf("composed with %d value(s) held for %s: %s", len(laid), strings.Join(names, ", "), - strings.Join(laid, "; "))}, notes...) - } - return out, notes -} diff --git a/cmd/mesh-controller/merge_gate_held_test.go b/cmd/mesh-controller/merge_gate_held_test.go deleted file mode 100644 index 922a02d0..00000000 --- a/cmd/mesh-controller/merge_gate_held_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - snapshot "git.novox.be/novox/mesh-sdk/go/facts" -) - -func heldGateFacts() snapshot.Facts { - return snapshot.Facts{Machines: []snapshot.Machine{ - {Name: snapshot.Pseudonym("machine", "ace"), Roles: []string{"the build node"}, Assigned: []string{"n8n"}, - Settings: []snapshot.Settings{{Module: "n8n", Values: map[string]any{"port": 5678.0}}}}, - {Name: snapshot.Pseudonym("machine", "g14")}, - }} -} - -var heldShelf = map[string]catalogue.Manifest{"n8n": {Module: "n8n"}} - -// 1826/AC3: a held value is laid in its node's layer of its module, beside what the layer holds, and said. -func TestAHeldValueIsLaidOverItsNodesLayerAndSaid(t *testing.T) { - f := heldGateFacts() - got, notes := layHeld(f, []heldValue{{Pull: "novox/mesh-catalog#434", Node: "ace", Module: "n8n", Key: "media-gid", - Value: []byte("2000"), Approved: "the operator"}}, heldShelf) - layer := got.Machines[0].Settings[0].Values - if layer["media-gid"] != 2000.0 || layer["port"] != 5678.0 { - t.Fatalf("the layer holds %v", layer) - } - if _, has := f.Machines[0].Settings[0].Values["media-gid"]; has { - t.Fatal("the snapshot given was changed") - } - if len(notes) != 1 || !strings.Contains(notes[0], "composed with 1 value(s) held for novox/mesh-catalog#434") || - !strings.Contains(notes[0], "media-gid=2000") || !strings.Contains(notes[0], "approved by the operator") || - strings.Contains(notes[0], "ace") { - t.Fatalf("said %q", notes) - } -} - -// 1826/AC3: a machine with no layer of the module gets one. -func TestAHeldValueMakesTheLayerAMachineLacks(t *testing.T) { - got, _ := layHeld(heldGateFacts(), []heldValue{{Pull: "p", Node: "g14", Module: "n8n", Key: "k", Value: []byte(`"x"`)}}, heldShelf) - s := got.Machines[1].Settings - if len(s) != 1 || s[0].Module != "n8n" || s[0].Values["k"] != "x" { - t.Fatalf("layers %+v", s) - } -} - -// 1826/AC3: a held value for a machine or a module the snapshot or the change does not hold is said, never laid. -func TestAHeldValueForWhatIsNotHeldIsSaidNotLaid(t *testing.T) { - f := heldGateFacts() - got, notes := layHeld(f, []heldValue{ - {Pull: "p", Node: "nowhere", Module: "n8n", Key: "k", Value: []byte("1")}, - {Pull: "p", Node: "ace", Module: "ghost", Key: "k", Value: []byte("1")}, - }, heldShelf) - if len(notes) != 2 || !strings.Contains(notes[0], "holds no machine") || !strings.Contains(notes[1], "defines no module ghost") { - t.Fatalf("said %q", notes) - } - if _, has := got.Machines[0].Settings[0].Values["k"]; has { - t.Fatal("laid") - } -} - -// 1826/AC3: none held lays nothing and says nothing; the file is read and refuses a value without a node. -func TestNoHeldValuesLayNothingAndTheFileIsRead(t *testing.T) { - if got, notes := layHeld(heldGateFacts(), nil, heldShelf); len(notes) != 0 || len(got.Machines) != 2 { - t.Fatalf("%v %v", got, notes) - } - dir := t.TempDir() - good := filepath.Join(dir, "good.json") - os.WriteFile(good, []byte(`[{"pull":"p","node":"ace","module":"n8n","key":"k","value":1}]`), 0o644) - if h, err := readHeld(good); err != nil || len(h) != 1 || h[0].Key != "k" { - t.Fatalf("%v %v", h, err) - } - bad := filepath.Join(dir, "bad.json") - os.WriteFile(bad, []byte(`[{"pull":"p","module":"n8n","key":"k","value":1}]`), 0o644) - if _, err := readHeld(bad); err == nil { - t.Fatal("a value without a node was read") - } - if h, err := readHeld(""); h != nil || err != nil { - t.Fatalf("%v %v", h, err) - } -} diff --git a/cmd/mesh-controller/merge_gate_integration_test.go b/cmd/mesh-controller/merge_gate_integration_test.go deleted file mode 100644 index fca93fd6..00000000 --- a/cmd/mesh-controller/merge_gate_integration_test.go +++ /dev/null @@ -1,582 +0,0 @@ -//go:build integration - -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - snapshot "git.novox.be/novox/mesh-sdk/go/facts" - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/inventory" -) - -func catalogueMesh(t *testing.T) (snapshot.Facts, map[string]string) { - t.Helper() - open := aMesh(t) - ctx := t.Context() - manifests := map[string]string{ - "objects": `{"module":"objects","version":"1", - "provides":[{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}], - "receives":{"s3-bucket":"/var/lib/mesh/objects/mesh.json"}}`, - "resolver": `{"module":"resolver","version":"1", - "provides":[{"name":"wildcard-resolution","scope":"mesh","identity":false}]}`, - "networkmanager": `{"module":"networkmanager","version":"1","requires":["wildcard-resolution"]}`, - "album": `{"module":"album","version":"1","requires":["s3-bucket"]}`, - "lemurs": `{"module":"lemurs","version":"1","capabilities":["systemd"], - "resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`, - } - for name, raw := range manifests { - m, err := catalogue.ParseManifest([]byte(raw)) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog", - Path: "modules/" + name, BuiltFrom: "c0ffee"}); err != nil { - t.Fatal(err) - } - } - for _, a := range [][2]string{{"anchor", "objects"}, {"anchor", "resolver"}, {"laptop", "networkmanager"}, - {"laptop", "album"}, {"laptop", "lemurs"}} { - if _, err := assign(ctx, open, a[0], a[1]); err != nil { - t.Fatalf("assign %s %s: %v", a[0], a[1], err) - } - } - f, err := gatherFacts(ctx, open, "2.11.17") - if err != nil { - t.Fatal(err) - } - return f, manifests -} - -func gateJudged(t *testing.T, f snapshot.Facts, tree string, changed ...string) mergeVerdict { - t.Helper() - admin := os.Getenv("MESH_TEST_POSTGRES") - in := mergeCheckInput{facts: f, admin: admin, changed: changed} - if tree != "" { - in.repository, in.tree = "novox/mesh-catalog", tree - } - v, err := judgeChange(t.Context(), in) - if err != nil { - t.Fatal(err) - } - return v -} - -func TestTheMeshAsItIsPassesTheGate(t *testing.T) { - f, manifests := catalogueMesh(t) - for _, m := range f.Machines { - if !m.Declaration.Composes { - t.Fatalf("the mesh itself does not compose: %+v", m.Declaration) - } - } - v := gateJudged(t, f, aTree(t, manifests)) - if v.Verdict != "pass" { - t.Fatalf("an unchanged catalogue does not pass:\n%s", v.Report()) - } - for _, m := range v.Machines { - if !m.Base.Composes || !m.Change.Composes { - t.Errorf("%s does not compose in the gate's store as it does on the mesh: %+v / %+v", m.Described, m.Base, m.Change) - } - } -} - -func TestIssue263AnIdentityARealMachineNameOverflowsFailsThePullRequest(t *testing.T) { - f, manifests := catalogueMesh(t) - tree := aTree(t, withEdit(manifests, "networkmanager", - `{"module":"networkmanager","version":"1","requires":["wildcard-resolution","s3-bucket"]}`)) - v := gateJudged(t, f, tree) - if v.Verdict != "fail" { - t.Fatalf("the overflow passed the gate:\n%s", v.Report()) - } - report := v.Report() - if !strings.Contains(report, "networkmanager") || !strings.Contains(report, "s3-bucket") { - t.Errorf("the refusal does not name the module and the provision:\n%s", report) - } -} - -func TestIssue236AManifestTheNodeEngineRefusesFailsThePullRequest(t *testing.T) { - f, manifests := catalogueMesh(t) - broken := `{"module":"lemurs","version":"1","capabilities":["systemd"], - "resources":[{"id":"service","type":"service","unit":"lemurs.service","boot":"enabled"}]}` - v := gateJudged(t, f, aTree(t, withEdit(manifests, "lemurs", broken))) - if v.Verdict != "fail" || !strings.Contains(v.Report(), "lemurs") { - t.Fatalf("a service the node-engine refuses passed the gate:\n%s", v.Report()) - } - laptop := snapshot.Pseudonym("machine", "laptop") - if !strings.Contains(v.Report(), laptop) { - t.Errorf("the refusal does not name the machine it would be refused on:\n%s", v.Report()) - } - - // And as a new module, which no machine runs: tried on one that could. - newcomer := strings.ReplaceAll(broken, `"lemurs"`, `"greeter"`) - newcomer = strings.ReplaceAll(newcomer, "lemurs.service", "greeter.service") - v = gateJudged(t, f, aTree(t, withEdit(manifests, "greeter", newcomer))) - if v.Verdict != "fail" || !strings.Contains(v.Report(), "greeter, assigned to") { - t.Fatalf("a new module the node-engine would refuse passed the gate:\n%s", v.Report()) - } -} - -func TestAManifestTheRunningControllerCannotReadFailsThePullRequest(t *testing.T) { - f, manifests := catalogueMesh(t) - v := gateJudged(t, f, aTree(t, withEdit(manifests, "album", - `{"module":"album","version":"1","requires":["s3-bucket"],"a-field-of-a-newer-controller":true}`))) - if v.Verdict != "fail" || !strings.Contains(v.Report(), "refuses it") { - t.Fatalf("a manifest this controller cannot read passed:\n%s", v.Report()) - } -} - -func TestAModuleAMachineRunsRemovedFromItsSourceFails(t *testing.T) { - f, manifests := catalogueMesh(t) - v := gateJudged(t, f, aTree(t, withEdit(manifests, "album", ""))) - if v.Verdict != "fail" || !strings.Contains(v.Report(), "album is removed") { - t.Fatalf("removing a module a machine runs passed:\n%s", v.Report()) - } -} - -func TestIssue278AWideRebuildIsSaidBeforeTheMerge(t *testing.T) { - f, manifests := catalogueMesh(t) - was := wideRebuild - wideRebuild = 2 - t.Cleanup(func() { wideRebuild = was }) - for _, file := range []string{"modules/showcase/index.ts", "merge-check.sh", "README.md"} { - v := gateJudged(t, f, aTree(t, manifests), file) - if v.Width == nil || len(v.Width.Modules) != 0 || len(v.Width.Unread) != 1 || v.Verdict != "pass" { - t.Fatalf("%s, read by no build, reads %+v, %s", file, v.Width, v.Verdict) - } - if !strings.Contains(v.Report(), "read by no module's build") { - t.Errorf("why %s rebuilds nothing is not said:\n%s", file, v.Report()) - } - } - v := gateJudged(t, f, aTree(t, manifests), "modules/album/x.ts", "modules/objects/x.go", "modules/resolver/x.go") - if v.Width == nil || len(v.Width.Modules) < 3 || v.Verdict != "warning" { - t.Fatalf("a rebuild wider than the bound is not warned of: %+v, %s", v.Width, v.Verdict) - } - // With the reference module's definition in the tree, its directory is a module, held or not. - withShowcase := withEdit(manifests, "showcase", `{"module":"showcase","version":"1"}`) - v = gateJudged(t, f, aTree(t, withShowcase), "modules/showcase/index.ts") - if v.Width == nil || len(v.Width.Modules) != 0 || len(v.Width.Unread) != 0 { - t.Fatalf("a file of a module nobody holds reads %+v", v.Width) - } - v = gateJudged(t, f, aTree(t, manifests), "modules/album/module.json") - if v.Width == nil || strings.Join(v.Width.Modules, ",") != "album" || v.Verdict != "pass" { - t.Fatalf("a change to one module's directory reads %+v, %s", v.Width, v.Verdict) - } -} - -func TestADeliveryGroupsHeadsAreComposedTogether(t *testing.T) { - f, manifests := catalogueMeshWithAnApp(t) - needsTheApp := withEdit(manifests, "album", `{"module":"album","version":"1","requires":["s3-bucket","app-api"]}`) - catTree := aTree(t, needsTheApp) - alone := gateJudged(t, f, catTree, "modules/album/module.json") - if alone.Verdict != "fail" { - t.Fatalf("a requirement nothing provides passed alone:\n%s", alone.Report()) - } - app := t.TempDir() - if err := os.WriteFile(filepath.Join(app, "module.json"), []byte(`{"module":"app","version":"1", - "provides":[{"name":"app-api","scope":"mesh","identity":false}]}`), 0o644); err != nil { - t.Fatal(err) - } - in := mergeCheckInput{facts: f, admin: os.Getenv("MESH_TEST_POSTGRES"), repository: "novox/mesh-catalog", - tree: catTree, changed: []string{"modules/album/module.json"}, - group: []groupChange{{Repository: "novox/app", Tree: app, Changed: []string{"module.json"}}}} - together, err := judgeChange(t.Context(), in) - if err != nil { - t.Fatal(err) - } - if together.Verdict == "fail" { - t.Fatalf("the group composed together fails:\n%s", together.Report()) - } - if together.Modules["app"] != "changed" || together.Modules["album"] != "changed" { - t.Fatalf("the group's heads were not both laid over the mesh: %v", together.Modules) - } -} - -func catalogueMeshWithAnApp(t *testing.T) (snapshot.Facts, map[string]string) { - t.Helper() - open := aMesh(t) - ctx := t.Context() - manifests := map[string]string{ - "objects": `{"module":"objects","version":"1", - "provides":[{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}], - "receives":{"s3-bucket":"/var/lib/mesh/objects/mesh.json"}}`, - "album": `{"module":"album","version":"1","requires":["s3-bucket"]}`, - } - for name, raw := range manifests { - m, err := catalogue.ParseManifest([]byte(raw)) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog", - Path: "modules/" + name, BuiltFrom: "c0ffee"}); err != nil { - t.Fatal(err) - } - } - if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, - inventory.Source{Repository: "novox/app", BuiltFrom: "c0ffee"}); err != nil { - t.Fatal(err) - } - for _, a := range [][2]string{{"anchor", "objects"}, {"anchor", "app"}, {"laptop", "album"}} { - if _, err := assign(ctx, open, a[0], a[1]); err != nil { - t.Fatalf("assign %s %s: %v", a[0], a[1], err) - } - } - f, err := gatherFacts(ctx, open, "2.11.17") - if err != nil { - t.Fatal(err) - } - return f, manifests -} - -func busMesh(t *testing.T) snapshot.Facts { - t.Helper() - open := aMesh(t) - ctx := t.Context() - m, err := catalogue.ParseManifest([]byte(`{"module":"speaker","version":"1", - "own-secrets":{"broker":"/var/lib/speaker/broker"}}`)) - if err != nil { - t.Fatal(err) - } - if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog", - Path: "modules/speaker", BuiltFrom: "c0ffee"}); err != nil { - t.Fatal(err) - } - if _, err := assign(ctx, open, "laptop", "speaker"); err != nil { - t.Fatal(err) - } - user := broker.Principal{Kind: broker.KindModule, Node: "laptop", Module: "speaker"}.Username() - password, err := open.inventory.MintBusPassword(ctx, inventory.BusUser{Username: user, Kind: inventory.BusModule, - Node: "laptop", Module: "speaker"}) - if err != nil { - t.Fatal(err) - } - if err := open.inventory.AcceptSecretForModule(ctx, "laptop", "speaker", "broker", - `{"user":"`+user+`","password":"`+password+`"}`); err != nil { - t.Fatal(err) - } - f, err := gatherFacts(ctx, open, "2.11.17") - if err != nil { - t.Fatal(err) - } - for _, mc := range f.Machines { - if !mc.Declaration.Composes { - t.Fatalf("the mesh itself does not compose: %+v", mc.Declaration) - } - } - return f -} - -func TestIssue285AModuleOnTheBusComposesInTheGate(t *testing.T) { - f := busMesh(t) - v := gateJudged(t, f, "") - if v.Verdict != "pass" { - t.Fatalf("the mesh as it is does not pass:\n%s", v.Report()) - } - for _, m := range v.Machines { - if !m.Base.Composes { - t.Errorf("%s composes on the mesh and not in the gate: %v", m.Described, m.Base.Problems) - } - } - if !strings.Contains(v.Summary, "2 of 2 compose") { - t.Errorf("the summary does not say every machine composes: %s", v.Summary) - } -} - -func TestIssue285AMachineTheGateCannotRaiseIsAnErrorNeverAPass(t *testing.T) { - f := busMesh(t) - for i := range f.Machines { - // A fact the snapshot does not carry: the module's credential, gone from the laptop's. - var kept []snapshot.Accepted - for _, a := range f.Machines[i].Accepted { - if a.Name != "broker" { - kept = append(kept, a) - } - } - f.Machines[i].Accepted = kept - } - v := gateJudged(t, f, "") - if v.Verdict != "error" { - t.Fatalf("a gate whose mesh does not compose said %s:\n%s", v.Verdict, v.Report()) - } - if len(v.Errors) != 1 || !strings.Contains(v.Errors[0], "speaker") { - t.Errorf("the error does not name what could not be raised: %v", v.Errors) - } -} - -func TestAGroupsMoveOfAModuleIsNeitherTwoDefinitionsNorARemoval(t *testing.T) { - const catalogueRepo, controllerRepo = "novox/mesh-catalog", "novox/mesh-controller" - built := func(repo, path string) inventory.Source { - return inventory.Source{Repository: "http://forge.internal:20000/" + repo + ".git", Path: path, BuiltFrom: "c0ffee"} - } - sources := map[string]inventory.Source{"mover": built(catalogueRepo, "modules/mover")} - raw := `{"module":"mover","version":"1"}` - base := map[string]catalogue.Manifest{} - m, err := catalogue.ParseManifest([]byte(raw)) - if err != nil { - t.Fatal(err) - } - base["mover"] = m - facts := snapshot.Facts{Machines: []snapshot.Machine{{Name: "anchor", Assigned: []string{"mover"}}}} - - catalogueWithout := aTree(t, map[string]string{"other": `{"module":"other","version":"1"}`}) - controllerWith := func() string { - dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, "modules", "mover"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "modules", "mover", "module.json"), []byte(raw), 0o644); err != nil { - t.Fatal(err) - } - return dir - }() - removes := groupChange{Repository: catalogueRepo, Tree: catalogueWithout, Changed: []string{"modules/mover/module.json"}} - defines := groupChange{Repository: controllerRepo, Tree: controllerWith, Changed: []string{"modules/mover/module.json"}} - - lay := func(heads ...groupChange) (map[string]catalogue.Manifest, []string, map[string]string) { - t.Helper() - moves, err := movesInGroup(heads, sources) - if err != nil { - t.Fatal(err) - } - at := sources - if len(moves) > 0 { - at = map[string]inventory.Source{} - for k, v := range sources { - at[k] = v - } - for name, mv := range moves { - o := at[name] - o.Repository, o.Path = mv.To, mv.Dir - at[name] = o - } - } - shelf, said := base, map[string]string{} - var failures []string - for _, h := range heads { - var f []string - if shelf, f, err = shelfWithChange(shelf, at, facts, h.Repository, h.Tree, said, nil); err != nil { - t.Fatal(err) - } - failures = append(failures, f...) - } - for name := range moves { - said[name] = "moved" - } - return shelf, failures, said - } - for _, order := range [][]groupChange{{removes, defines}, {defines, removes}} { - shelf, failures, said := lay(order...) - if len(failures) > 0 { - t.Fatalf("a move of a module machines run, laid %s first, was refused: %v", order[0].Repository, failures) - } - if said["mover"] != "moved" { - t.Errorf("the move reads %v, not moved", said) - } - if _, held := shelf["mover"]; !held { - t.Errorf("the moved module is not on the shelf after laying %s first", order[0].Repository) - } - } - // Alone, a head is still refused: a second definition (issue 239), and a removal of what runs (ADR 0236). - if _, failures, _ := lay(defines); len(failures) != 1 || !strings.Contains(failures[0], "issue 239") { - t.Errorf("a definition of a module another repository builds, alone: %v", failures) - } - if _, failures, _ := lay(removes); len(failures) != 1 || !strings.Contains(failures[0], "ADR 0236") { - t.Errorf("the removal of a module machines run, alone: %v", failures) - } -} - -func TestADeliveryGroupMovingAModuleMachinesRunPassesTheGate(t *testing.T) { - f, manifests := catalogueMeshWithAnApp(t) - catTree := aTree(t, withEdit(manifests, "album", "")) - app := t.TempDir() - // The app module is built from this repository's root, and stays there: only album moves. - if err := os.WriteFile(filepath.Join(app, "module.json"), []byte(`{"module":"app","version":"1"}`), 0o644); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(app, "modules", "album"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(app, "modules", "album", "module.json"), []byte(manifests["album"]), 0o644); err != nil { - t.Fatal(err) - } - removes := groupChange{Repository: "novox/mesh-catalog", Tree: catTree, Changed: []string{"modules/album/module.json"}} - defines := groupChange{Repository: "novox/app", Tree: app, Changed: []string{"modules/album/module.json"}} - judge := func(first groupChange, rest ...groupChange) mergeVerdict { - v, err := judgeChange(t.Context(), mergeCheckInput{facts: f, admin: os.Getenv("MESH_TEST_POSTGRES"), - repository: first.Repository, tree: first.Tree, changed: first.Changed, group: rest}) - if err != nil { - t.Fatal(err) - } - return v - } - for _, v := range []mergeVerdict{judge(removes, defines), judge(defines, removes)} { - if v.Verdict == "fail" || v.Modules["album"] != "moved" { - t.Fatalf("a group moving a module machines run: %v\n%s", v.Modules, v.Report()) - } - } - if v := judge(defines); v.Verdict != "fail" { - t.Errorf("a definition of a module another repository builds passed alone:\n%s", v.Report()) - } - if v := judge(removes); v.Verdict != "fail" { - t.Errorf("the removal of a module machines run passed alone:\n%s", v.Report()) - } -} - -func TestTheGateRefusesAnArtifactContextNamingAnotherRepository(t *testing.T) { - withContext := func(repo string) string { - return `{"module":"proxy","version":"1","build":{"artifacts":[{"name":"server","kind":"image","from":"Dockerfile",` + - `"context":{"seat":"git","repository":"` + repo + `","ref":"main"}}]}}` - } - judge := func(repo string) []string { - t.Helper() - tree := aTree(t, map[string]string{"proxy": withContext(repo)}) - _, failures, err := shelfWithChange(map[string]catalogue.Manifest{}, map[string]inventory.Source{}, - snapshot.Facts{}, "novox/mesh-controller", tree, map[string]string{}, nil) - if err != nil { - t.Fatal(err) - } - return failures - } - const want = "proxy builds artifact server from novox/mesh-catalog: a module lives whole in one repository (novox/hq ADR 0420 §1)" - failures := judge("novox/mesh-catalog") - if len(failures) != 1 || !strings.Contains(failures[0], want) { - t.Errorf("a context naming another repository: %v, want it to say %q", failures, want) - } - for _, f := range judge("novox/mesh-controller") { - if strings.Contains(f, "ADR 0420 §1") { - t.Errorf("a context naming the module's own repository was refused: %s", f) - } - } -} - -func TestAMemberJudgedAloneSeesAMoveThroughTheClonesBeside(t *testing.T) { - const catalogueRepo, controllerRepo = "novox/mesh-catalog", "novox/mesh-controller" - raw := `{"module":"mover","version":"1"}` - sources := map[string]inventory.Source{"mover": {Repository: "http://forge.internal:20000/" + catalogueRepo + ".git", - Path: "modules/mover", BuiltFrom: "c0ffee"}} - // A tree is written into dir: defining mover, or holding only another module. - write := func(dir string, defines bool) string { - name, body := "other", `{"module":"other","version":"1"}` - if defines { - name, body = "mover", raw - } - if err := os.MkdirAll(filepath.Join(dir, "modules", name), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "modules", name, "module.json"), []byte(body), 0o644); err != nil { - t.Fatal(err) - } - return dir - } - defining := func() string { return write(t.TempDir(), true) } - without := func() string { return write(t.TempDir(), false) } - // beside lays out a MESH_CHECK_BESIDE directory holding the named repositories' trees, each defining mover or not. - beside := func(trees map[string]bool) string { - root := t.TempDir() - for name, defines := range trees { - write(filepath.Join(root, name), defines) - } - return root - } - moves := func(repository, tree, besideRoot string) map[string]moveOfModule { - t.Helper() - got, err := movesOfChange(mergeCheckInput{repository: repository, tree: tree, beside: besideRoot}, sources) - if err != nil { - t.Fatal(err) - } - return got - } - // The controller's member: defines mover; the catalogue beside has removed it. - if got := moves(controllerRepo, defining(), beside(map[string]bool{"mesh-catalog": false})); got["mover"].To != controllerRepo { - t.Errorf("the controller's change defining mover, the catalogue beside without it: moves %v", got) - } - // The sources the gate then judges with: built from the controller, the definition is not a second one (issue 239); - // unmoved, it still is. - judge := func(at map[string]inventory.Source) []string { - t.Helper() - _, failures, err := shelfWithChange(map[string]catalogue.Manifest{}, at, snapshot.Facts{}, controllerRepo, defining(), - map[string]string{}, nil) - if err != nil { - t.Fatal(err) - } - return failures - } - mv := moves(controllerRepo, defining(), beside(map[string]bool{"mesh-catalog": false}))["mover"] - moved := map[string]inventory.Source{"mover": {Repository: mv.To, Path: mv.Dir, BuiltFrom: "c0ffee"}} - if f := judge(moved); len(f) != 0 { - t.Errorf("a definition of a module the catalogue beside removed was refused: %v", f) - } - if f := judge(sources); len(f) != 1 || !strings.Contains(f[0], "issue 239") { - t.Errorf("with the catalogue beside at main, the definition: %v, want the issue 239 refusal", f) - } - if got := moves(controllerRepo, defining(), beside(map[string]bool{"mesh-catalog": true})); len(got) != 0 { - t.Errorf("the catalogue beside at main still defines mover, yet moves %v", got) - } - // The catalogue's member: removes mover; the controller beside defines it. - if got := moves(catalogueRepo, without(), beside(map[string]bool{"mesh-controller": true})); got["mover"].To != controllerRepo { - t.Errorf("the catalogue's change removing mover, the controller beside defining it: moves %v", got) - } - if got := moves(catalogueRepo, without(), beside(map[string]bool{"mesh-controller": false})); len(got) != 0 { - t.Errorf("the controller beside at main does not define mover, yet moves %v", got) - } - if got := moves(catalogueRepo, without(), ""); len(got) != 0 { - t.Errorf("nothing beside, yet moves %v", got) - } -} - -// 1826/AC3 (integration level, a throwaway store): a change whose container says ${config:media-gid} fails the gate for -// want of the value, passes it with the value the operator approved held for its pull request, and the mesh as it is -// composes the same either way. -func TestAChangeNeedingAHeldValueFailsWithoutItAndPassesWithIt(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - trunk := `{"module":"n8n","version":"1","resources":[{"id":"server","type":"container","name":"n8n", - "image":"n8nio/n8n@sha256:4846eb2f4b874ab04cde7fc1e249d2ddaec66e9aea64439beb2972cfea88e3c0"}]}` - m, err := catalogue.ParseManifest([]byte(trunk)) - if err != nil { - t.Fatal(err) - } - if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog", - Path: "modules/n8n", BuiltFrom: "c0ffee"}); err != nil { - t.Fatal(err) - } - if _, err := assign(ctx, open, "laptop", "n8n"); err != nil { - t.Fatal(err) - } - f, err := gatherFacts(ctx, open, "2.11.17") - if err != nil { - t.Fatal(err) - } - for i := range f.Machines { - f.Machines[i].Capabilities = append(f.Machines[i].Capabilities, snapshot.Capability{Name: "container-groups", Present: true}) - } - tree := aTree(t, map[string]string{"n8n": n8nWithAGroup}) - judge := func(held []heldValue) mergeVerdict { - v, err := judgeChange(t.Context(), mergeCheckInput{facts: f, admin: os.Getenv("MESH_TEST_POSTGRES"), - repository: "novox/mesh-catalog", tree: tree, changed: []string{"modules/n8n/module.json"}, held: held}) - if err != nil { - t.Fatal(err) - } - return v - } - without := judge(nil) - if without.Verdict != "fail" || !strings.Contains(without.Report(), "media-gid") { - t.Fatalf("a required value nothing sets passed, or failed for another reason:\n%s", without.Report()) - } - with := judge([]heldValue{{Pull: "novox/mesh-catalog#434", Node: "laptop", Module: "n8n", Key: "media-gid", - Value: []byte("2000"), Approved: "the operator"}}) - if with.Verdict == "fail" || with.Verdict == "error" { - t.Fatalf("the held value did not make it compose:\n%s", with.Report()) - } - if !strings.Contains(with.Report(), "composed with 1 value(s) held for novox/mesh-catalog#434") { - t.Fatalf("the value laid is not said:\n%s", with.Report()) - } - for i := range with.Machines { - if with.Machines[i].Base.Composes != without.Machines[i].Base.Composes { - t.Fatalf("the mesh as it is composes differently with a held value: %+v / %+v", with.Machines[i].Base, without.Machines[i].Base) - } - } -} diff --git a/cmd/mesh-controller/merge_gate_test.go b/cmd/mesh-controller/merge_gate_test.go deleted file mode 100644 index 7960ea21..00000000 --- a/cmd/mesh-controller/merge_gate_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// The merge gate (novox/hq to-be 45 §9): a change judged against every machine of the snapshot, by the -// incidents it is written from. Each case raises a mesh, takes its snapshot, and judges a pull request's -// tree against it in throwaway stores of its own. - -// catalogueMesh is two machines and a catalogue repository: a resolver and an object store on the -// anchor, and on the laptop a network manager requiring the resolver, an album requiring the object -// store, and a login manager. Every module is registered from novox/mesh-catalog, as the mesh's are. - -// aTree is a checkout of the catalogue repository holding these manifests. -func aTree(t *testing.T, manifests map[string]string) string { - t.Helper() - dir := t.TempDir() - for name, raw := range manifests { - at := filepath.Join(dir, "modules", name) - if err := os.MkdirAll(at, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(at, "module.json"), []byte(raw), 0o644); err != nil { - t.Fatal(err) - } - } - return dir -} - -func withEdit(manifests map[string]string, name, raw string) map[string]string { - out := map[string]string{} - for k, v := range manifests { - out[k] = v - } - if raw == "" { - delete(out, name) - } else { - out[name] = raw - } - return out -} - -// The mesh as it is passes: every machine composes in the gate's store as the controller composed it. - -// **Issue 263**: a change makes the network manager require the object store's provision; on a machine -// whose name is six characters its identity is 26 against a bound of 20. Refused in the pull request, -// naming the module, and not on the anchor after it merged. - -// **Issue 236**: a login manager's service that omits its state passed the catalogue check and was -// refused whole by the node-engine on the first machine. A change to a module a machine runs, and a -// module nobody runs yet, are both refused before merge, naming the machine and the module. - -// **Version skew**: a manifest the controller judging it cannot read — the one the mesh runs, for a -// catalogue change — fails here, not at registration after the merge. - -// A module a machine runs, removed from its source, fails until it is unassigned (ADR 0236). - -// **Issues 278 and 280**: a file in no held module's directory read as shared code, and a merge rebuilt the -// catalogue. A file is a module's only by being inside it — no build reads one outside — so it rebuilds -// nothing, and the gate says why; a merge that does rebuild widely is warned of. - -// **A delivery group is judged as one future state** (novox/hq ADR 0239): a catalogue change that needs a -// provision only another repository's change adds fails alone and passes composed with it; and a group -// whose other head breaks what the first needs fails, naming it. - -// catalogueMeshWithAnApp is catalogueMesh with an application built from a repository of its own, at its -// root, on the anchor. - -// busMesh is aMesh with a module on the bus on the laptop, its account issued as `module issue` issues -// one: minted, and its credential held as the module's own secret named broker. - -// **Issue 285**: every machine running a module on the bus failed to compose in the gate's store, with -// the change and without — the store held the module's credential and no account for it, which -// composition refuses (issue 203) — and the gate passed every change, "0 of 4 compose". The account the -// mesh issued is raised with its credential, so the machine composes in the gate as on the mesh. - -// **Issue 285**: a machine the mesh composes that the gate cannot raise as it is leaves the change judged -// against a machine that is not the mesh's — broken against broken, which passes whatever the change does. -// That is an error, never a pass. - -// A path the snapshot withheld is given a path of its own where an access or a place needs one: a bare -// "withheld" is no absolute path, and a machine whose setting composes on the mesh would not in the gate. -func TestAWithheldPathIsStoodInForByAPath(t *testing.T) { - got := standInPaths(map[string]any{ - "accesses": map[string]any{"races": "withheld", "films": "/media/films", "shows": "/withheld"}, - "places": map[string]any{"config": map[string]any{"path": "withheld", "owner": "1000:1000"}}, - "puid": 1000, - }) - accesses := got["accesses"].(map[string]any) - if accesses["races"] == accesses["shows"] || !strings.HasPrefix(accesses["races"].(string), "/") || - !strings.HasPrefix(accesses["shows"].(string), "/") || accesses["films"] != "/media/films" { - t.Errorf("accesses: %v", accesses) - } - place := got["places"].(map[string]any)["config"].(map[string]any) - if !strings.HasPrefix(place["path"].(string), "/") || place["owner"] != "1000:1000" || got["puid"] != 1000 { - t.Errorf("places: %v", got) - } -} - -// **Issue 286**: a check run by hand clones beside a change what the seat clones — one rule, read by the -// controller's ask and by the facts — and finds the repository it checks from its origin. -func TestACheckByHandClonesWhatTheSeatClones(t *testing.T) { - for dir, refs := range map[string]map[string]string{ - "mesh-catalog": {"mesh-catalog": "main"}, - "mesh-host": {"mesh-host": "c0ffee"}, - "mesh-controller": {"mesh-controller": "c0ffee", "mesh-controller-main": "main", "mesh-lab": "main", - "mesh-sdk": sdkPinnedByGoMod(t)}, - } { - got := besideRefs(dir, "c0ffee") - for d, ref := range refs { - if got[d] != ref { - t.Errorf("beside %s, %s is cloned at %q, not %q", dir, d, got[d], ref) - } - } - } - for owner, want := range map[string][3]string{ - "ssh://git@git.example:222/novox/mesh-host.git": {"novox", "mesh-host", "ssh://git@git.example:222/novox"}, - "git@git.example:novox/mesh-tools.git": {"novox", "mesh-tools", "git@git.example:novox"}, - "http://git.example/novox/hq": {"novox", "hq", "http://git.example/novox"}, - } { - o, r, p := ownerRepoOf(owner) - if [3]string{o, r, p} != want { - t.Errorf("%s reads as %s %s %s", owner, o, r, p) - } - } -} - -// sdkPinnedByGoMod is the SDK commit this tree's go.mod pins, read from the file rather than the build, so -// sdkPinned is held to what the repository says (novox/hq issue 449). -func sdkPinnedByGoMod(t *testing.T) string { - t.Helper() - raw, err := os.ReadFile(filepath.Join("..", "..", "go.mod")) - if err != nil { - t.Fatal(err) - } - for _, line := range strings.Split(string(raw), "\n") { - fields := strings.Fields(line) - if len(fields) >= 2 && fields[0] == sdkModule { - if m := pseudoCommit.FindStringSubmatch(fields[1]); m != nil { - return m[1] - } - return "go/" + fields[1] - } - } - t.Fatalf("go.mod requires no %s", sdkModule) - return "" -} - -// **A module one head of a group removes and another defines is a move** (novox/hq ADR 0420 §6, issue 1025): -// neither a second definition of one module name (issue 239) nor the removal of a module machines run -// (ADR 0236). Alone, each head is still refused. This lays the heads over the shelf as judgeChange does, -// which needs no database. - -// A group that moves a module machines run passes the gate whole, in either order of its heads, and says the -// module moved (novox/hq ADR 0420 §6, issue 1025); a head alone is still refused. Needs the test store. - -// **A module lives whole in one repository** (novox/hq ADR 0420 §1, issue 1019): an artifact whose build -// context names another repository is refused by the gate, and one naming its own passes it. - -// **A member of a delivery group judged alone sees a move** through the core clones beside its check -// (novox/hq ADR 0420 §6, issue 1043): the controller's own gate, with the catalogue beside having removed the -// module, is not refused for a second definition (issue 239); the catalogue's own gate, with the controller -// beside defining it, is not refused for a removal (ADR 0236). A clone beside at main finds no move. diff --git a/cmd/mesh-controller/new_module_approved_test.go b/cmd/mesh-controller/new_module_approved_test.go index 58ba371d..67725f52 100644 --- a/cmd/mesh-controller/new_module_approved_test.go +++ b/cmd/mesh-controller/new_module_approved_test.go @@ -116,7 +116,7 @@ func TestANewModuleIsRegisteredOnTheOperatorsApproveOfItsMergeAndRefusedOtherwis if c.built != nil { src = *c.built } - id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-view", src, byBuild("build-1"), "", "main") + id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-view", src, registrationAsk{Build: "build-1"}, "", "main") switch { case c.accepted && err != nil: t.Fatalf("%s: the Approved merge's new module was refused: %v", c.ac, err) @@ -157,7 +157,7 @@ func TestASecondNewModuleFromARepositoryIsJudgedAlongsideAndAsksNoWarrant(t *tes request: inventory.BuildRequest{ID: "build-2", Repository: "novox/mesh-delivery", Seat: "git", For: "build"}}} forms := &sourceForms{bases: map[string]string{}, base: func(string) string { return "http://forge.internal:20000" }} built := inventory.Source{Repository: "novox/mesh-delivery", Seat: "git"} - if id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-watchman", built, byBuild("build-2"), "", "main"); err != nil || id == 0 { + if id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-watchman", built, registrationAsk{Build: "build-2"}, "", "main"); err != nil || id == 0 { t.Fatalf("1146/AC5: a second module from a repository the catalogue builds from was refused (id %d): %v", id, err) } } diff --git a/cmd/mesh-controller/new_modules.go b/cmd/mesh-controller/new_modules.go index 3fe4b5b6..faee9615 100644 --- a/cmd/mesh-controller/new_modules.go +++ b/cmd/mesh-controller/new_modules.go @@ -11,7 +11,6 @@ import ( "time" sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" - "github.com/nats-io/nats.go/jetstream" "github.com/novox/mesh-controller/internal/builderkeys" "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/inventory" @@ -210,10 +209,7 @@ func watchNewModules(ctx context.Context, open *stores) { fmt.Printf("new modules: the builder's keys are not watched: %v\n", err) return } - says := watchingSays{js: keys.JS} - says.watch(ctx) if err := builderkeys.Watch(ctx, keys.JS, func(module string) { - says.watch(ctx) wakeNewModule(ctx, open, module) }); err != nil { closeKeys() @@ -249,8 +245,9 @@ func wakeNewModule(ctx context.Context, open *stores, module string) { // ends only once the work its mesh started has. var background sync.WaitGroup -// inBackground runs work beside the caller, counted in background. -func inBackground(work func()) { +// inBackground runs work beside the caller, counted in background. A variable so a test of what a walk does around a +// take-in may run it in the caller's turn and need not wait for it. +var inBackground = func(work func()) { background.Add(1) go func() { defer background.Done() @@ -278,32 +275,3 @@ func newModulesOf(ctx context.Context, repository string) ([]string, error) { sort.Strings(out) return out, nil } - -// watchingSays watches the builder's bucket of instances once it is there (issue 1886): a change of what the builder says -// asks the look, which judges every open walk again. A builder from before R4e makes no such bucket; a write of the -// builder's keys asks again whether it is there now, so nothing waits on a timer. -type watchingSays struct { - js jetstream.JetStream - mu *sync.Mutex - on *bool -} - -func (w *watchingSays) watch(ctx context.Context) { - if w.mu == nil { - w.mu, w.on = &sync.Mutex{}, new(bool) - } - w.mu.Lock() - defer w.mu.Unlock() - if *w.on { - return - } - on, err := builderkeys.WatchSays(ctx, w.js, askToLook) - if err != nil { - fmt.Printf("plans: what the builder says it writes is not watched now: %v\n", err) - return - } - if on { - *w.on = true - askToLook() // what it says now may differ from what the last look read - } -} diff --git a/cmd/mesh-controller/outcome_off_the_loop_test.go b/cmd/mesh-controller/outcome_off_the_loop_test.go deleted file mode 100644 index 6b52e126..00000000 --- a/cmd/mesh-controller/outcome_off_the_loop_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package main - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/inventory" -) - -// heldElsewhere is the plans held by another act for longer than any test runs: waiting for them ends only with the -// context, and a look that does not wait is told at once that they are busy. -func heldElsewhere(ctx context.Context, wait bool) (func(), error) { - if !wait { - return nil, inventory.ErrPlansBusy - } - <-ctx.Done() - return nil, inventory.ErrPlansBusy -} - -// 1708/AC1: given the plans held by another act, when the event loop takes a build's outcome, then it returns at -// once, without waiting the plans' two minutes (on 2026-10-21 at 14:20:45 nodered's outcome held the loop until -// 14:22:45, and the operator was told the controller stopped listening). -func TestAnOutcomeDoesNotWaitForThePlansAnotherActHolds(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) - defer cancel() - start := time.Now() - release, err := holdPlansForOutcome(ctx, heldElsewhere) - if took := time.Since(start); took > time.Second { - t.Fatalf("taking an outcome waited %s for plans another act holds; the event loop takes nothing meanwhile", took) - } - if err == nil || release != nil { - t.Fatalf("plans another act holds were said to be held: release %v, err %v", release != nil, err) - } -} - -// 1708/AC4: given the plans free, when the event loop takes a build's outcome, then it holds them and marks the plan -// from the outcome directly, as before. -func TestAnOutcomeHoldsThePlansWhenTheyAreFree(t *testing.T) { - let := false - release, err := holdPlansForOutcome(t.Context(), func(context.Context, bool) (func(), error) { - return func() { let = true }, nil - }) - if err != nil || release == nil { - t.Fatalf("free plans were not held: %v", err) - } - release() - if !let { - t.Fatal("the hold given was not the one taken") - } -} - -// withLooker installs a looker that is never run, and answers it, so a test can read whether a look was asked. -func withLooker(t *testing.T) *looker { - t.Helper() - l := newLooker(func(context.Context) bool { return false }) - lookerMu.Lock() - before := theLooker - theLooker = l - lookerMu.Unlock() - t.Cleanup(func() { - lookerMu.Lock() - theLooker = before - lookerMu.Unlock() - }) - return l -} - -// 1708/AC2: given an outcome left to the build records because the plans were held, when it is left, then the look -// at the plans is asked for, so the records settle it once the holder lets go rather than at the next event. -func TestAnOutcomeLeftToTheRecordsAsksForTheLook(t *testing.T) { - l := withLooker(t) - leaveToRecords("nodered", "build-1708-ac2", "", "", inventory.ErrPlansBusy) - select { - case <-l.signal: - default: - t.Fatal("an outcome left to the build records asked for no look: the plan waits for whatever event comes next") - } -} - -// 1708/AC3: given an outcome left to the records that said what the build did (ADR 0350 §4), when the look settles -// the plan from the build records by that build's id, then the module keeps those words, once. -func TestTheRecordsSettleAnOutcomeWithWhatTheBuildDid(t *testing.T) { - withLooker(t) - asked := time.Date(2026, 10, 21, 12, 15, 0, 0, time.UTC) - plan := func() inventory.Plan { - return inventory.Plan{ID: "plan-1708", Tiers: [][]string{{"nodered"}}, - Modules: map[string]*inventory.PlanModule{"nodered": {State: "asked", AskedAt: &asked, Build: "build-1708-ac3"}}} - } - byID := map[string]inventory.Build{"build-1708-ac3": {ID: "build-1708-ac3", Module: "nodered", Commit: "4efac366", - At: asked.Add(5 * time.Minute)}} - leaveToRecords("nodered", "build-1708-ac3", "reused from build-1708-check", "", inventory.ErrPlansBusy) - - p := plan() - if !settleFromRecords(&p, p.Tiers[0], nil, byID) || p.Modules["nodered"].State != "built" { - t.Fatalf("the records did not settle the outcome: %+v", p.Modules["nodered"]) - } - if got := p.Modules["nodered"].Made; got != "reused from build-1708-check" { - t.Fatalf("settled from the records, the module says %q of what its build did, not the outcome's words", got) - } - again := plan() - settleFromRecords(&again, again.Tiers[0], nil, byID) - if got := again.Modules["nodered"].Made; got != "" { - t.Fatalf("an outcome's words were kept for a second settle: %q", got) - } -} - -// 1708/AC5: given a serving controller (its look at the plans running), when an outcome was taken into the plans, -// then moving them (a release's sends, which may retry for minutes) is left to the look, off the event loop -// (2026-10-21 14:28:40 to 14:32:10: the loop sent okioki's release to g14 after mesh-delivery-builder's outcome). -func TestAnOutcomeLeavesMovingThePlansToTheLook(t *testing.T) { - l := withLooker(t) - moved := false - advanceAfterOutcome(func() { moved = true }) - if moved { - t.Fatal("the plans were moved on the event loop after an outcome, where a send that retries holds every message") - } - select { - case <-l.signal: - default: - t.Fatal("the look was not asked to move the plans after an outcome") - } -} - -// 1708/AC5: given no look running (a command, which has no event loop), when an outcome was taken, then the plans are -// moved at once, as before. -func TestAnOutcomeMovesThePlansWhereNoLookRuns(t *testing.T) { - lookerMu.Lock() - before := theLooker - theLooker = nil - lookerMu.Unlock() - t.Cleanup(func() { - lookerMu.Lock() - theLooker = before - lookerMu.Unlock() - }) - moved := false - advanceAfterOutcome(func() { moved = true }) - if !moved { - t.Fatal("with no look running, an outcome left the plans unmoved") - } -} - -// 1708/AC6: given a registration the mesh refused (its build recorded as worked, the outcome failed), left to the -// records because the plans were held, when the look settles the plan from the records, then the module and the plan -// are failed, never built: settled as built, the tier would be sent the version from before (reviewer-1708@shanks). -func TestARefusedRegistrationLeftToTheRecordsFailsThePlan(t *testing.T) { - withLooker(t) - asked := time.Date(2026, 10, 21, 12, 15, 0, 0, time.UTC) - p := inventory.Plan{ID: "plan-1708-ac6", State: inventory.PlanBuilding, Tiers: [][]string{{"nodered"}}, - Modules: map[string]*inventory.PlanModule{"nodered": {State: "asked", AskedAt: &asked, Build: "build-1708-ac6"}}} - byID := map[string]inventory.Build{"build-1708-ac6": {ID: "build-1708-ac6", Module: "nodered", Commit: "4efac366", - At: asked.Add(5 * time.Minute)}} - leaveToRecords("nodered", "build-1708-ac6", "", "the mesh does not register it: a port is taken", inventory.ErrPlansBusy) - - overlayRefusals(nil, byID, refusalsKeptFor([]string{"build-1708-ac6"})) - settleFromRecords(&p, p.Tiers[0], nil, byID) - if s := p.Modules["nodered"]; s.State != "failed" || p.State != inventory.PlanFailed { - t.Fatalf("a refused registration left to the records settled as %q, the plan %q", s.State, p.State) - } - if !strings.Contains(p.Modules["nodered"].Why, "a port is taken") { - t.Fatalf("the refusal's words were lost: %q", p.Modules["nodered"].Why) - } -} - -// 1708/AC6: a refusal kept in the store (a controller replaced before the settle) fails the record as one kept in -// the process does. -func TestARefusalFromTheStoreFailsTheRecord(t *testing.T) { - recorded := map[string][]inventory.Build{"nodered": {{ID: "b-store", Module: "nodered"}, {ID: "b-other", Module: "nodered"}}} - byID := map[string]inventory.Build{"b-store": {ID: "b-store", Module: "nodered"}} - overlayRefusals(recorded, byID, map[string]string{"b-store": "not registered"}) - if recorded["nodered"][0].Worked() || byID["b-store"].Worked() { - t.Fatal("a build whose registration was refused still reads as worked") - } - if !recorded["nodered"][1].Worked() { - t.Fatal("a build nobody refused was failed") - } -} - -// 1708/AC7: given the records settle a module as deleted at its source, or fail the plan, when the look settles, then -// it does what taking the outcome directly does: the deleted module is named to be forgotten, and the plan's failure -// is said (reviewer-1708@shanks). -func TestSettlingFromTheRecordsDoesWhatTakingTheOutcomeDoes(t *testing.T) { - p := inventory.Plan{State: inventory.PlanFailed, Modules: map[string]*inventory.PlanModule{ - "gone": {State: planDeleted}, "x": {State: "failed"}, "y": {State: "built"}}} - before := map[string]string{"gone": "asked", "x": "asked", "y": "built"} - deleted, failed := settledEffects(before, &p, inventory.PlanBuilding) - if len(deleted) != 1 || deleted[0] != "gone" { - t.Fatalf("the module the records found deleted is not forgotten: %v", deleted) - } - if !failed { - t.Fatal("the plan the records failed is not said as failed") - } - if d, f := settledEffects(map[string]string{"gone": planDeleted}, &p, inventory.PlanFailed); len(d) != 0 || f { - t.Fatalf("what was deleted or failed before the settle is acted on again: %v %v", d, f) - } -} diff --git a/cmd/mesh-controller/pending.go b/cmd/mesh-controller/pending.go index 0a48d957..b2c63231 100644 --- a/cmd/mesh-controller/pending.go +++ b/cmd/mesh-controller/pending.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os" "sort" "strings" "time" @@ -14,7 +13,6 @@ import ( "github.com/novox/mesh-controller/internal/builderkeys" "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" ) // A module not registered yet, and the assignment that waits for it (novox/hq issue 325, ADR 0261). @@ -74,15 +72,6 @@ type assignOptions struct { Build bool } -// recordBuildRequest keeps a build request so `assign` can find it, once it is asked. Said, never fatal: the -// build was asked. -func recordBuildRequest(ctx context.Context, inv *inventory.Inventory, r inventory.BuildRequest) { - if err := inv.RecordBuildRequest(ctx, r); err != nil { - fmt.Fprintf(os.Stderr, "build %s was asked, and could not be kept as a build request, so `assign` "+ - "will not know it is coming: %v\n", r.ID, err) - } -} - // The cases of a module the catalogue does not hold (novox/hq issue 325). const ( unknownModule = iota @@ -474,40 +463,6 @@ func expirePending(ctx context.Context, inv *inventory.Inventory, p inventory.Pe return note } -// settlePendingOnBuild is a build's outcome reaching the assignments waiting for it, wherever the outcome is -// taken in (novox/hq issue 325): a module registered makes every assignment pending on it; a build of a -// pending assignment that failed, or was not registered, ends it with why. Returns what was said. -func settlePendingOnBuild(ctx context.Context, open *stores, result link.BuildResult, module string, takeErr error) []string { - inv := open.inventory - waiting, err := inv.Pending(ctx, time.Time{}) - if err != nil { - return []string{fmt.Sprintf("the pending assignments could not be read after build %s: %v", result.ID, err)} - } - registered := module != "" && (takeErr == nil || errors.Is(takeErr, inventory.ErrSuperseded)) - var said []string - for _, p := range waiting { - switch { - case registered && p.Module == module: - if line := applyPending(ctx, open, p, result.ID); line != "" { - said = append(said, line) - } - case !registered && p.Build == result.ID: - why := firstLine(result.Failed) - if why == "" && takeErr != nil { - why = firstLine(takeErr.Error()) - } - what := fmt.Sprintf("build %s of %s failed: %s", result.ID, orRoot(p.Path), why) - if result.Failed == "" { - what = fmt.Sprintf("build %s of %s was recorded and not registered: %s", result.ID, orRoot(p.Path), why) - } - if line := expirePending(ctx, inv, p, what); line != "" { - said = append(said, line) - } - } - } - return said -} - // settlingPending is the controller's tick over the pending assignments (ADR 0261), every settleEvery until // the context ends. What it does is printed to the controller's log, kept in each row's note (which `status` // lists for a day), and raised as a condition for an assignment that was not made. diff --git a/cmd/mesh-controller/pending_integration_test.go b/cmd/mesh-controller/pending_integration_test.go index 3e1b4780..1f2b246a 100644 --- a/cmd/mesh-controller/pending_integration_test.go +++ b/cmd/mesh-controller/pending_integration_test.go @@ -5,7 +5,6 @@ package main import ( "encoding/json" "errors" - "fmt" "slices" "strings" "testing" @@ -567,34 +566,6 @@ func TestAStaleClaimIsSettledFromWhatTheMeshHolds(t *testing.T) { } } -func TestABuildNoLongerWaitedForIsStillInFlight(t *testing.T) { - open := aCatalogueMesh(t) - ctx := t.Context() - inv := open.inventory - for _, id := range []string{"build-timeout", "build-nothandedover"} { - dir := "modules/" + strings.TrimPrefix(id, "build-") - if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: id, Repository: "novox/mesh-catalog", - Seat: "git", Path: dir, Ref: "main", For: "build"}); err != nil { - t.Fatal(err) - } - } - markWaitFailed(ctx, "build-timeout", errors.New("no build machine answered within 10m0s")) - markWaitFailed(ctx, "build-nothandedover", fmt.Errorf("%w: cannot submit a build: nats: timeout", link.ErrNotHandedOver)) - - said, err := assign(ctx, open, "laptop", "timeout") - if err != nil || !strings.Contains(said, "being built") || !strings.Contains(said, "kept as pending") { - t.Fatalf("a build no longer waited for: %q %v", said, err) - } - if _, err := assign(ctx, open, "laptop", "nothandedover"); err == nil || !strings.Contains(err.Error(), "was not handed over") { - t.Fatalf("a build never handed over: %v", err) - } - settlePending(ctx, open, time.Now().Add(buildRequestBound+time.Minute)) - rows, _ := inv.PendingFor(ctx, "laptop", "timeout") - if len(rows) != 1 || rows[0].State != inventory.PendingExpired || !strings.Contains(rows[0].Note, "its asker stopped waiting") { - t.Fatalf("past the bound: %+v", rows) - } -} - // A10 (ADR 0501 §6; issue 1851): an assignment of a module the indexer holds and the catalogue does not yet waits for // the builder's done key of it, asking nothing, and is made when that key registers the module. func TestAnAssignmentWaitsForTheKeyAndAsksNothing(t *testing.T) { diff --git a/cmd/mesh-controller/plain_words.go b/cmd/mesh-controller/plain_words.go index 1a954534..184e5d69 100644 --- a/cmd/mesh-controller/plain_words.go +++ b/cmd/mesh-controller/plain_words.go @@ -1084,7 +1084,7 @@ func unansweredWords(l stalledLine, o conditions.Observation) (headline, explana } // uncheckedWaitWords are the merge checks an unanswered line waits on, as the operator reads them: from the checks -// given as data, each time in the controller's local zone, as every other time in a message — "mesh/merge-gate +// given as data, each time in the controller's local zone, as every other time in a message — "mesh/check // pending since 02:11" — never the UTC time inside mesh-delivery's finished words, which cannot be said again in // another zone (novox/hq issue 443). A line from a mesh-delivery that gives no such data is said by its words. func uncheckedWaitWords(l stalledLine) []string { diff --git a/cmd/mesh-controller/plan_clock_test.go b/cmd/mesh-controller/plan_clock_test.go index 36073456..7de204d1 100644 --- a/cmd/mesh-controller/plan_clock_test.go +++ b/cmd/mesh-controller/plan_clock_test.go @@ -22,11 +22,11 @@ func TestAPlansLineCountsFromItsTierNotFromItsLastSave(t *testing.T) { if !strings.Contains(line, "building for 40m0s") || !strings.Contains(line, "LATE") { t.Fatalf("a plan in its tier for 40m, saved 3s ago, reads %q", line) } - st := planStatuses([]inventory.Plan{p}, now, pauseView{}, nil)[0] + st := planStatuses([]inventory.Plan{p}, now, nil)[0] if !st.Late || !st.Since.Equal(p.TierEntered) { t.Errorf("status --json says %+v", st) } - if _, late := openPlans([]inventory.Plan{p}, now, pauseView{}, nil); late != 1 { + if _, late := openPlans([]inventory.Plan{p}, now, nil); late != 1 { t.Errorf("status counted %d late", late) } @@ -42,7 +42,7 @@ func TestAPlansLineCountsFromItsTierNotFromItsLastSave(t *testing.T) { q := p q.TierEntered = now.Add(-in) bound := bounds.of(q.Repository) - late := strings.Contains(planLineWith(q, now, pauseView{}, bound), "LATE") + late := strings.Contains(planLineWith(q, now, bound), "LATE") stalled := len(watchPlans(&signalFacts{now: now, plans: []planFacts{{id: q.ID, repository: q.Repository, commit: q.Commit, tiers: 1, entered: inTierSince(q), bound: bound}}})) == 1 if late != stalled || late != (in > bound) { @@ -65,7 +65,7 @@ func TestAPlansLineCountsFromItsTierNotFromItsLastSave(t *testing.T) { if line := planLine(waiting, now); !strings.Contains(line, "for 40m0s") || strings.Contains(line, "LATE") { t.Errorf("a walk waiting 40m reads %q", line) } - if st := planStatuses([]inventory.Plan{waiting}, now, pauseView{}, nil)[0]; st.Late { + if st := planStatuses([]inventory.Plan{waiting}, now, nil)[0]; st.Late { t.Errorf("a waiting walk is called late: %+v", st) } let := now.Add(-5 * time.Minute) @@ -99,7 +99,7 @@ func TestAPlanLineSaysTheRefusalOnceItsTierIsBuilt(t *testing.T) { if !strings.Contains(line, "a build waits there for its gate: novox") || strings.Contains(line, "building for") { t.Errorf("920/AC1: a plan with its tier built and a refusal reads %q", line) } - st := planStatuses([]inventory.Plan{p}, now, pauseView{}, nil) + st := planStatuses([]inventory.Plan{p}, now, nil) if len(st) != 1 || !st[0].Late { t.Errorf("920/AC1: status: %+v", st) } diff --git a/cmd/mesh-controller/plan_keys.go b/cmd/mesh-controller/plan_keys.go index 1bdb1bcc..f867ad6b 100644 --- a/cmd/mesh-controller/plan_keys.go +++ b/cmd/mesh-controller/plan_keys.go @@ -9,7 +9,6 @@ import ( sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" - "github.com/novox/mesh-controller/internal/builder" "github.com/novox/mesh-controller/internal/builderkeys" "github.com/novox/mesh-controller/internal/inventory" ) @@ -17,64 +16,19 @@ import ( // A walk takes its modules from the builder's done keys (novox/hq ADR 0503 §6, ADR 0501 §1, ADR 0498 §8; issue 1851, A6 // and A7). // -// From R4e a walk asks build-agent for no module but the builder's own (ADR 0453 §5). A module of its tier is marked +// A walk asks no build of anybody (R4g; ADR 0453 §5). A module of its tier is marked // taken from a key (PlanModule.Key) and settled by the look: its fingerprint is read from the indexer's trunk value // written for the commit the walk carries of its repository; the builder's key of that fingerprint, done with every // artifact referenced, is taken in (recorded and registered, off the plans' hold) and the module is built; a key not done // yet, or waiting for a reference, holds the tier and says so; a failed key fails the walk in the builder's own words. // **No timer**: the builder's and the indexer's writes wake the look (builderkeys.Watch, watchNewModules). -// isBuildersOwn says a module is build-agent's own: asked of build-agent on a merge and taken from its build record, never -// from the builder's key, until R4g (ADR 0453 §5, ADR 0498 §7, ADR 0520 §1). -func isBuildersOwn(module string) bool { return buildersOwn(module) } - -// buildAgentsOwn are those modules, named (ADR 0520 §1): the builder's; build-agent itself, whose image's context is another -// repository's; and mesh-tools, the toolchain image build-agent's builds stand on. The builder writes no key of any of them, -// and a walk waiting for one would hold its tier for ever. mesh-tools-go is not among them: it builds from a Dockerfile in -// its own directory on a pinned base, and has its key from mesh-build-image. -// -// **And the parts that write references** (ADR 0522 §6, issue 1886): the build modules push and answer each reference, -// and the builder writes it into the key, so none of them is ever taken from a key they write themselves. Until R4g -// build-agent is the way they are delivered whatever the builder says. -var buildAgentsOwn = map[string]bool{sdkdelivery.BuilderModule: true, builder.AgentModule: true, "mesh-tools": true, - "mesh-build-go": true, "mesh-build-node": true, "mesh-build-image": true} - -// buildersOwn is isBuildersOwn's rule: a variable only so a test of the asks left (their bus waits, their plan key) may -// name modules of its own as build-agent's. -var buildersOwn = func(module string) bool { return buildAgentsOwn[module] } - // planKeyReads is what a walk reads of the indexer and the builder (builderkeys.Reader, or a test's). type planKeyReads interface { ModuleAt(ctx context.Context, module, commit string) (sdkdelivery.Module, error) Build(ctx context.Context, module, fp string) (builderkeys.Key, error) } -// builderSayKey carries, on an ask's context, the builder's say the walk read before asking (withBuilderSay). -type builderSayKey struct{} - -// withBuilderSay hands the say a walk read to the ask's guard, so the guard never reads it a second time. -func withBuilderSay(ctx context.Context, says bool) context.Context { - return context.WithValue(ctx, builderSayKey{}, says) -} - -// builderSayOf is the say a walk handed the ask, and whether it handed one. -func builderSayOf(ctx context.Context) (says, handed bool) { - says, handed = ctx.Value(builderSayKey{}).(bool) - return says, handed -} - -// builderSaysReferences is whether a running builder instance says, in its own bucket of instances, that the keys it -// writes name references (issue 1886). Until one does, a walk asks build-agent for every module, as before R4e: the -// keys the running builder writes could never be delivered from. A variable so a test needs no bus. -var builderSaysReferences = func(ctx context.Context) (bool, error) { - keys, closeKeys, err := openBuilderKeys() - if err != nil { - return false, err - } - defer closeKeys() - return keys.SaysReferences(ctx) -} - // openPlanKeys opens what a walk reads of the indexer and the builder. A variable so a test needs no bus. var openPlanKeys = func() (planKeyReads, func(), error) { r, closeKeys, err := openBuilderKeys() @@ -112,10 +66,13 @@ var keyTakes = struct { // the buckets and the records; a done key not recorded yet is taken in off the plans' hold, and its end asks the look. func settleFromKeys(ctx context.Context, open *stores, keys planKeyReads, p *inventory.Plan, tier []string) (bool, error) { return settleKeys(ctx, open.inventory, keys, p, tier, func(module string, trunk sdkdelivery.Module, id string) { - inBackground(func() { takeKeyOffTheHold(context.WithoutCancel(ctx), open, module, trunk, id) }) + inBackground(func() { takeKeyIn(context.WithoutCancel(ctx), open, module, trunk, id) }) }) } +// takeKeyIn is how a look takes a done key in; a variable so a test of what a walk does around it needs no builder. +var takeKeyIn = takeKeyOffTheHold + // keyRecords is what settling from keys reads of the records (*inventory.Inventory, or a test's). type keyRecords interface { BuildByID(ctx context.Context, id string) (inventory.Build, bool, error) @@ -133,20 +90,6 @@ func settleKeys(ctx context.Context, inv keyRecords, keys planKeyReads, p *inven changed = true } } - // One say for the ask and the settle (builderSaysReferences): two sources that differ hand a module back and take it - // from its key again at every turn of the look, which then never ends. - says, err := builderSaysReferences(ctx) - if err != nil { - return changed, fmt.Errorf("whether the builder writes references cannot be read: %w", err) - } - // **A walk never waits on a key the running builder cannot reference** (issue 1886): while no builder says it writes - // references, a module that waits on its key, or whose key failed, is handed back unasked, and the look asks it of - // build-agent. A done key with every reference is still taken. - toAgent := func(s *inventory.PlanModule, why string) { - s.State, s.Key, s.AskedAt, s.KeyAt, s.Build, s.Fingerprint = "", false, nil, "", "", "" - s.Why = "the builder does not say it writes references, so build-agent is asked (" + why + ")" - changed = true - } for _, m := range tier { s := p.Modules[m] if s == nil || !s.Key || s.State != "asked" { @@ -154,10 +97,6 @@ func settleKeys(ctx context.Context, inv keyRecords, keys planKeyReads, p *inven } trunk, err := keys.ModuleAt(ctx, m, s.KeyAt) if errors.Is(err, builderkeys.ErrNoValue) { - if !says { - toAgent(s, "the indexer holds no value of it") - continue - } set(s, fmt.Sprintf("waits for the indexer's value of %s at %s", m, orNow(s.KeyAt))) continue } @@ -169,10 +108,6 @@ func settleKeys(ctx context.Context, inv keyRecords, keys planKeyReads, p *inven if why == "" { why = "the indexer wrote no fingerprint of the new form" } - if !says { - toAgent(s, why) - continue - } set(s, fmt.Sprintf("waits: %s at %s has no fingerprint to take a build by: %s", m, orNow(s.KeyAt), why)) continue } @@ -184,9 +119,6 @@ func settleKeys(ctx context.Context, inv keyRecords, keys planKeyReads, p *inven return changed, err } switch { - case !says && (k.Failed() || !k.Done() || len(k.Unreferenced()) > 0): - toAgent(s, k.Says()) - continue case k.Failed(): // **A failed key fails the walk in the builder's own words** (`digests differ:`, `not stored:`, `not pinned:`, // `version taken:`), and a rebuild of it is a new revision the next walk takes. diff --git a/cmd/mesh-controller/plan_keys_test.go b/cmd/mesh-controller/plan_keys_test.go index 29190f32..3fe15089 100644 --- a/cmd/mesh-controller/plan_keys_test.go +++ b/cmd/mesh-controller/plan_keys_test.go @@ -73,12 +73,6 @@ func aWalkOfOne(module string) (*inventory.Plan, map[string]inventory.Entry) { // A6 (ADR 0503 §6, ADR 0498 check §7): a walk whose module has a done key goes asked to built with no build asked: the // ask stub is never called, the key is taken in once, and `plans` says it was taken from the builder's key. func TestAWalkTakesADoneKeyAndAsksNoBuild(t *testing.T) { - was := askABuild - askABuild = func(context.Context, buildSource, string, string) (string, error) { - t.Fatal("a walk asked build-agent for a module that is not the builder's own") - return "", nil - } - t.Cleanup(func() { askABuild = was }) p, byName := aWalkOfOne("sudo") askModule(t.Context(), p, "sudo", byName) s := p.Modules["sudo"] @@ -118,20 +112,11 @@ func TestAWalkTakesADoneKeyAndAsksNoBuild(t *testing.T) { if s.State != "built" || s.Build != id || !strings.Contains(s.Made, "taken from the builder's key") { t.Fatalf("the module is not built from its key: %+v", s) } - if takesOutcome(*p, &inventory.PlanModule{Key: true, State: "asked", AskedAt: s.AskedAt}, time.Now(), "build-1") { - t.Fatal("a build-agent outcome answers a module taken from a key") - } } // A7: a key building waits and says so, then moves on when done, with no timer and no ask; a failed key fails the walk // in the builder's words; a value the indexer has not written yet waits, named. func TestAWalkWaitsForAKeyAndFailsWithItsWords(t *testing.T) { - asksOfNone := askABuild - askABuild = func(context.Context, buildSource, string, string) (string, error) { - t.Fatal("a walk asked a build") - return "", nil - } - t.Cleanup(func() { askABuild = asksOfNone }) t.Cleanup(func() { keyTakes.Lock() keyTakes.running, keyTakes.waits, keyTakes.taken = map[string]bool{}, map[string]string{}, map[string]bool{} diff --git a/cmd/mesh-controller/plan_looks.go b/cmd/mesh-controller/plan_looks.go index 855d6e02..d8d84206 100644 --- a/cmd/mesh-controller/plan_looks.go +++ b/cmd/mesh-controller/plan_looks.go @@ -136,18 +136,6 @@ func askToLook() { } } -// askedToLook asks the serving controller's look at the plans, and says whether there was one to ask. -func askedToLook() bool { - lookerMu.RLock() - l := theLooker - lookerMu.RUnlock() - if l == nil { - return false - } - l.ask() - return true -} - // startPlanLooks starts the look at the open plans, and asks for the first: the controller took the lease, and a // plan left by a replaced controller resumes from there (ADR 0406 §6). It replaces the rollout's 30 second ticker. // It answers a channel that is closed once the look has returned after the context ended, for a test that restores what @@ -206,10 +194,6 @@ func servingPlanSaved(ctx context.Context, bus link.Bus, open *stores) func(*inv return func(_ *inventory.Inventory, p inventory.Plan) { sayPlanMoved(ctx, bus, open.inventory, p) planKept(p) - keepBuildingKey(ctx, p) - if p.WalkSaid == inventory.WalkEnded { - go forgetChecksOfWalk(context.WithoutCancel(ctx), open.inventory, p) - } } } diff --git a/cmd/mesh-controller/plan_retry.go b/cmd/mesh-controller/plan_retry.go index 7e846f77..f7bf2a41 100644 --- a/cmd/mesh-controller/plan_retry.go +++ b/cmd/mesh-controller/plan_retry.go @@ -3,14 +3,12 @@ package main import ( "context" "fmt" - "os" "slices" "sort" "strings" "time" "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" ) // A plan follows what is done to the build queue (novox/hq ADR 0219). @@ -26,106 +24,6 @@ import ( // beside it — beside it, the plan would either ask it again or stay failed on an outcome the // rebuild has already replaced. -// pauseView is whether the build seat takes work: the holders that said they are paused, and -// whether that is every holder. -type pauseView struct { - Nodes []string - All bool -} - -// pausedWaiting is what a plan waits on when the build seat is paused under it, or false: a plan -// building, whose current tier has an ask outstanding, while every holder of the seat is paused. -func pausedWaiting(p inventory.Plan, pause pauseView, now time.Time) (string, bool) { - if p.State != inventory.PlanBuilding || !pause.All || len(pause.Nodes) == 0 || p.Tier >= len(p.Tiers) { - return "", false - } - var asked *time.Time - for _, m := range p.Tiers[p.Tier] { - if s := p.Modules[m]; s != nil && s.State == "asked" && s.AskedAt != nil { - if asked == nil || s.AskedAt.Before(*asked) { - asked = s.AskedAt - } - } - } - if asked == nil { - return "", false - } - waited := now.Sub(*asked) - said := fmt.Sprintf("waiting: the build seat is paused on %s (asked %s ago)", - strings.Join(pause.Nodes, ", "), waited.Round(time.Second)) - // Not late — a pause is a decision — but a pause forgotten is a plan that never moves, so one held - // longer than a day is named. - if waited > pausedTooLong { - said += " — PAUSED OVER A DAY: `resume` takes builds again" - } - return said, true -} - -// pausedTooLong is how long a plan may wait on a paused build seat before it says the pause is long. -const pausedTooLong = 24 * time.Hour - -// awaitsABuild is whether any open plan has an ask outstanding in its current tier — the only case -// where the seat being paused changes what a plan says. -func awaitsABuild(plans []inventory.Plan) bool { - for _, p := range plans { - if p.State != inventory.PlanBuilding || p.Tier >= len(p.Tiers) { - continue - } - for _, m := range p.Tiers[p.Tier] { - if s := p.Modules[m]; s != nil && s.State == "asked" { - return true - } - } - } - return false -} - -// buildSeatPause reads whether the build seat's holders take work, from what each last said on the -// bus (link.HolderState) — read only when a plan waits on a build, so a mesh with nothing building -// does not dial the bus to say so. Anything unreadable is said and read as not paused: a plan then -// reads as late, which is what it said before this existed. -func buildSeatPause(ctx context.Context, inv *inventory.Inventory, plans []inventory.Plan) pauseView { - if !awaitsABuild(plans) { - return pauseView{} - } - entries, err := inv.Catalogued(ctx) - if err != nil { - return pauseView{} - } - seat := buildSeatAmong(entries) - holders := holdersAmong(entries, seat) - if len(holders) == 0 { - return pauseView{} - } - // On the serving controller's own connection when this is it: a watchdog tick while a walk waits for a - // build dialled one every 30 seconds (novox/hq issue 327). - js, err := aBus() - if err != nil { - fmt.Fprintf(os.Stderr, "could not reach the bus to read whether the build seat is paused: %v\n", err) - return pauseView{} - } - defer js.Close() - said, err := link.PausedSaid(js, seat, holders) - if err != nil { - fmt.Fprintf(os.Stderr, "could not read whether the build seat is paused: %v\n", err) - return pauseView{} - } - return pauseOf(holders, said) -} - -// pauseOf is the view from what each holder said. -func pauseOf(holders []string, said map[string]link.HolderState) pauseView { - var v pauseView - for _, n := range holders { - if said[n].Paused { - v.Nodes = append(v.Nodes, n) - } - } - sort.Strings(v.Nodes) - v.All = len(holders) > 0 && len(v.Nodes) == len(holders) - return v -} - // failedIn is the modules of a plan's current tier that failed to build, sorted. func failedIn(p inventory.Plan) []string { if p.Tier >= len(p.Tiers) { @@ -684,20 +582,10 @@ func holdingABuild(p inventory.Plan) []string { return out } -// resumeStopped sets a walk stopped by hand building again at the tier it stopped at, its stop cleared and its asks kept but -// build-agent's own (issue 1886): the next look carries it on as any walk — a build done waits for its reference, and one done on the +// resumeStopped sets a walk stopped by hand building again at the tier it stopped at, its stop cleared and its asks kept +// (issue 1886): the next look carries it on as any walk — a build done waits for its reference, and one done on the // builder's key is handed on (novox/hq ADR 0522 §3). Its note keeps who stopped it and that a person resumed it. func resumeStopped(p *inventory.Plan) string { - // **Build-agent's own are asked of build-agent** (novox/hq ADR 0522 §6): one marked taken from a key it writes itself is - // set unasked, and the look asks the rest of a partly asked tier (unaskedOf), of build-agent, whatever the builder says. - var again []string - for _, m := range p.Tiers[p.Tier] { - if s := p.Modules[m]; s != nil && s.State == "asked" && s.Key && isBuildersOwn(m) { - s.State, s.Key, s.AskedAt, s.Build, s.Why = "", false, nil, "", "" - again = append(again, m) - } - } - sort.Strings(again) asked, unsent, unasked := waitingIn(*p) later := 0 for i := p.Tier + 1; i < len(p.Tiers); i++ { @@ -706,9 +594,6 @@ func resumeStopped(p *inventory.Plan) string { by, why := p.Delivery.Stopped, p.Delivery.StoppedWhy waits := fmt.Sprintf("%d asked, %d built and not sent, %d not yet asked in it; %d not yet asked in the tiers after", asked, unsent, unasked, later) - if len(again) > 0 { - waits += "; " + strings.Join(again, ", ") + " set unasked, for the look to ask of build-agent (its own, ADR 0522 §6)" - } p.State = inventory.PlanBuilding goesOnAgain(p) p.Delivery.Kept = nil diff --git a/cmd/mesh-controller/plan_retry_stopped_test.go b/cmd/mesh-controller/plan_retry_stopped_test.go index d8618f85..a4552dfd 100644 --- a/cmd/mesh-controller/plan_retry_stopped_test.go +++ b/cmd/mesh-controller/plan_retry_stopped_test.go @@ -1,7 +1,6 @@ package main import ( - "slices" "strings" "testing" "time" @@ -27,7 +26,7 @@ func TestRetryResumesAWalkStoppedByHand(t *testing.T) { p.Modules[m] = &inventory.PlanModule{State: "built", BuiltAt: &sent, SentAt: &sent} } var tier1 []string - for _, m := range []string{"build-agent", "mesh-tools"} { + for _, m := range []string{"mesh-tools-go", "mesh-tools"} { tier1 = append(tier1, m) p.Modules[m] = &inventory.PlanModule{State: "built", AskedAt: &sent, BuiltAt: &sent, Build: "build-" + m} } @@ -58,22 +57,12 @@ func TestRetryResumesAWalkStoppedByHand(t *testing.T) { } for _, m := range onKeys { s := p.Modules[m] - if isBuildersOwn(m) { - // Build-agent's own are asked of build-agent whatever the builder says (ADR 0522 §6): set unasked, for the look. - if s.State != "" || s.Key || s.Build != "" || s.AskedAt != nil { - t.Errorf("%s, build-agent's own, was left on the builder's key: %+v", m, s) - } - if !slices.Contains(unaskedOf(p), m) { - t.Errorf("%s is not among what the look asks next: %v", m, unaskedOf(p)) - } - continue - } if s.State != "asked" || !s.Key || s.Build != "build-"+m { t.Errorf("%s's ask was not kept for the look to hand on: %+v", m, s) } } - for _, w := range []string{"plan-r4e", "tier 2 of 3", "5 asked", "2 built and not sent", "3 not yet asked in it", - "3 not yet asked in the tiers after", "mesh-build-go, mesh-build-image, mesh-build-node set unasked"} { + for _, w := range []string{"plan-r4e", "tier 2 of 3", "8 asked", "2 built and not sent", "0 not yet asked in it", + "3 not yet asked in the tiers after"} { if !strings.Contains(said, w) { t.Errorf("the answer %q does not say %q", said, w) } @@ -109,8 +98,8 @@ func TestRetryResumesAWalkStoppedByHand(t *testing.T) { } // A newer plan holding a build of its tier not yet sent refuses it: resuming would send the older build over the newer. newerBuilt := inventory.Plan{ID: "plan-agent", Repository: "novox/mesh-controller", Branch: "main", Commit: "d00dfeed", - Created: at, State: inventory.PlanDone, Tiers: [][]string{{"build-agent"}}, - Modules: map[string]*inventory.PlanModule{"build-agent": {State: "built"}}} + Created: at, State: inventory.PlanDone, Tiers: [][]string{{"mesh-tools"}}, + Modules: map[string]*inventory.PlanModule{"mesh-tools": {State: "built"}}} if err := retryRefusal(stopped, []inventory.Plan{stopped, newerBuilt}, ownPathOf(nil)); err == nil || !strings.Contains(err.Error(), "older build") { t.Errorf("a stopped walk was resumed over a newer plan of a build it has not sent: %v", err) diff --git a/cmd/mesh-controller/planner_rules_test.go b/cmd/mesh-controller/planner_rules_test.go index a002ee5e..4e14a594 100644 --- a/cmd/mesh-controller/planner_rules_test.go +++ b/cmd/mesh-controller/planner_rules_test.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "fmt" "math/rand/v2" "sort" @@ -9,7 +8,6 @@ import ( "testing" "time" - snapshot "git.novox.be/novox/mesh-sdk/go/facts" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) @@ -589,46 +587,6 @@ func describe(entries []inventory.Entry) []string { return out } -// **The merge gate reads the build sources the snapshot carries** (novox/hq ADR 0267): the gate's plan of a -// change is the merge handler's, so a snapshot taken by a controller that records build sources narrows the -// gate's plan as it narrows the merge's; one without them reads every module as before. -func TestTheGatePlansFromTheBuildSourcesTheSnapshotCarries(t *testing.T) { - manifest := func(name string) json.RawMessage { return json.RawMessage(`{"module":"` + name + `","version":"1"}`) } - facts := snapshot.Facts{Modules: []snapshot.Module{ - {Name: "mesh-controller", Repository: "novox/mesh-controller", Manifest: manifest("mesh-controller"), - Sources: []snapshot.BuildSource{{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/", "internal/broker/"}}}}, - {Name: "route-proxy", Repository: "novox/mesh-catalog", Path: "modules/route-proxy", Manifest: manifest("route-proxy"), - Reads: []string{"novox/mesh-controller"}, - Sources: []snapshot.BuildSource{{Repository: "novox/mesh-controller", Paths: []string{"modules/route-proxy/cmd/route-proxy/", "internal/broker/"}}, - {Own: true, Paths: []string{"modules/route-proxy/module.json"}}}}, - }} - for paths, want := range map[string]string{ - "cmd/mesh-controller/main.go": "mesh-controller", - "modules/route-proxy/cmd/route-proxy/main.go": "route-proxy", - "internal/broker/broker.go": "mesh-controller,route-proxy", - "README.md": "", - } { - r, err := reachOfChange(facts, "novox/mesh-controller", []string{paths}, "") - if err != nil { - t.Fatal(err) - } - if got := tiered(r.Plan.Tiers); got != want { - t.Errorf("%s: the gate planned %q, wanted %q", paths, got, want) - } - } - // A snapshot without build sources: as before. - for i := range facts.Modules { - facts.Modules[i].Sources = nil - } - r, err := reachOfChange(facts, "novox/mesh-controller", []string{"README.md"}, "") - if err != nil { - t.Fatal(err) - } - if got := tiered(r.Plan.Tiers); got != "mesh-controller,route-proxy" { - t.Errorf("a snapshot without build sources: the gate planned %q for a README", got) - } -} - // **A plan says why each module is in it** (novox/hq ADR 0267, issue 363): the files of its build source the // merge changed, or why it is read whole — never that it packages a repository as though that moved it. func TestAPlanSaysWhyEachModuleIsInIt(t *testing.T) { diff --git a/cmd/mesh-controller/plans_building.go b/cmd/mesh-controller/plans_building.go deleted file mode 100644 index 46ba06c3..00000000 --- a/cmd/mesh-controller/plans_building.go +++ /dev/null @@ -1,238 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sync" - "sync/atomic" - "time" - - "github.com/nats-io/nats.go" - "github.com/nats-io/nats.go/jetstream" - - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// The plans building (novox/hq ADR 0483 clause 2, issue 1687). -// -// **A key per delivery plan in its build phase, so that the build seat takes no new check while a delivery builds.** The phase -// runs from the ask of the plan's first build until every build of its last tier is built, or the plan fails, stops or waits -// on the operator. It is derived from the plan alone (buildPhaseOf), so a controller that restarts finds the same answer. The -// key is put when the first build is asked (sayBuilding), kept current at every save of the plan (keepBuildingKey) and by a -// look every link.PlansBuildingRefresh while this controller holds the lease (keepingPlansBuilding), and deleted at the phase's -// end; the bucket's age removes the key of a controller that stopped, so checks are held at most that long. -// -// The layout is documented in internal/link/holds.go. - -// buildPhaseOf says whether a plan is in its build phase: open, not waiting for the operator's word, with a build asked and a -// build of its tiers still to be built. A tier built and sent leaves the plan rolling while later tiers are still to be asked; -// it is in the phase still, which is what keeps a check from taking a place between two tiers. -func buildPhaseOf(p inventory.Plan) bool { - if !p.Open() || p.Waiting() || p.Release != nil { - return false - } - asked, unbuilt := false, false - for _, tier := range p.Tiers { - for _, m := range tier { - st := p.Modules[m] - if st != nil && st.State != "" { - asked = true - } - if st == nil || (st.State != "built" && st.State != planDeleted) { - unbuilt = true - } - } - } - return asked && unbuilt -} - -// buildingSince is when the plan's first build was asked, or fallback when no module says. -func buildingSince(p inventory.Plan, fallback time.Time) time.Time { - since := time.Time{} - for _, st := range p.Modules { - if st != nil && st.AskedAt != nil && (since.IsZero() || st.AskedAt.Before(since)) { - since = *st.AskedAt - } - } - if since.IsZero() { - return fallback - } - return since -} - -// buildingStore is the bucket of the plans building. -type buildingStore interface { - Put(ctx context.Context, plan string, value []byte) error - Delete(ctx context.Context, plan string) error - Keys(ctx context.Context) ([]string, error) -} - -// syncBuilding makes the bucket say what the plans say: a key for each plan in its build phase, put again, and none for any -// other plan, deleted. It returns the faults, each once. -func syncBuilding(ctx context.Context, store buildingStore, plans []inventory.Plan, now time.Time) []error { - var faults []error - live := map[string]bool{} - for _, p := range plans { - if !buildPhaseOf(p) { - continue - } - live[p.ID] = true - if err := putBuilding(ctx, store, p.ID, buildingSince(p, now), now); err != nil { - faults = append(faults, err) - } - } - keys, err := store.Keys(ctx) - if err != nil { - return append(faults, err) - } - for _, key := range keys { - if live[key] { - continue - } - if err := store.Delete(ctx, key); err != nil { - faults = append(faults, fmt.Errorf("the key of %s could not be taken away: %w", key, err)) - } - } - return faults -} - -func putBuilding(ctx context.Context, store buildingStore, plan string, since, now time.Time) error { - body, _ := json.Marshal(link.PlanBuilding{Plan: plan, Since: since.UTC(), Seen: now.UTC()}) - if err := store.Put(ctx, plan, body); err != nil { - return fmt.Errorf("the key of %s could not be put: %w", plan, err) - } - return nil -} - -// busBuilding is the store on the controller's bucket. -type busBuilding struct { - conn *nats.Conn - mu sync.Mutex - kv jetstream.KeyValue -} - -func (b *busBuilding) bucket(ctx context.Context) (jetstream.KeyValue, error) { - b.mu.Lock() - defer b.mu.Unlock() - if b.kv != nil { - return b.kv, nil - } - api, err := jetstream.New(b.conn) - if err != nil { - return nil, err - } - kv, err := api.KeyValue(ctx, broker.PlansBuildingBucket) - if err != nil { - return nil, fmt.Errorf("the plans building: %w", err) - } - b.kv = kv - return kv, nil -} - -func (b *busBuilding) Put(ctx context.Context, plan string, value []byte) error { - kv, err := b.bucket(ctx) - if err != nil { - return err - } - _, err = kv.Put(ctx, plan, value) - return err -} - -func (b *busBuilding) Delete(ctx context.Context, plan string) error { - kv, err := b.bucket(ctx) - if err != nil { - return err - } - if err := kv.Delete(ctx, plan); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) { - return err - } - return nil -} - -func (b *busBuilding) Keys(ctx context.Context) ([]string, error) { - kv, err := b.bucket(ctx) - if err != nil { - return nil, err - } - keys, err := kv.Keys(ctx) - if errors.Is(err, jetstream.ErrNoKeysFound) { - return nil, nil - } - return keys, err -} - -// buildingKeys is the store of the serving controller; nil in a process that is not it, which says nothing. -var buildingKeys atomic.Pointer[buildingStore] - -func setBuildingStore(s buildingStore) { - if s == nil { - buildingKeys.Store(nil) - return - } - buildingKeys.Store(&s) -} - -// sayBuilding puts a plan's key: its first build is being asked (or another of its builds). A key that could not be put is -// said; the look that follows puts it again. -func sayBuilding(ctx context.Context, plan string, since time.Time) { - s := buildingKeys.Load() - if s == nil { - return - } - if err := putBuilding(ctx, *s, plan, since, time.Now()); err != nil { - fmt.Printf("plans: %v; checks may take a place of %s until the next look\n", err, plan) - } -} - -// keepBuildingKey is told each plan this controller keeps: its key put again while it builds and taken away once it does not. -func keepBuildingKey(ctx context.Context, p inventory.Plan) { - s := buildingKeys.Load() - if s == nil { - return - } - if buildPhaseOf(p) { - sayBuilding(ctx, p.ID, buildingSince(p, time.Now())) - return - } - if err := (*s).Delete(ctx, p.ID); err != nil { - fmt.Printf("plans: the key of %s could not be taken away; it ages out: %v\n", p.ID, err) - } -} - -// keepingPlansBuilding looks at the plans every link.PlansBuildingRefresh for as long as this controller serves and holds the -// lease, so a key is put again before its age and the key of a plan that left the phase unseen is taken away. -func keepingPlansBuilding(ctx context.Context, inv *inventory.Inventory, conn *nats.Conn) { - store := &busBuilding{conn: conn} - setBuildingStore(store) - defer setBuildingStore(nil) - tick := time.NewTicker(broker.PlansBuildingRefresh) - defer tick.Stop() - failing := "" - for { - if epoch, err := theLease.epoch(ctx); err == nil && epoch != 0 { - why := "" - if plans, err := inv.OpenPlans(ctx); err != nil { - why = fmt.Sprintf("the plans cannot be read: %v", err) - } else if faults := syncBuilding(ctx, store, plans, time.Now()); len(faults) > 0 { - why = fmt.Sprint(faults) - } - if why != failing { - if why != "" { - fmt.Printf("plans: the keys of the plans building cannot be kept: %s\n", why) - } else { - fmt.Printf("plans: the keys of the plans building are kept again\n") - } - failing = why - } - } - select { - case <-ctx.Done(): - return - case <-tick.C: - } - } -} diff --git a/cmd/mesh-controller/plans_building_test.go b/cmd/mesh-controller/plans_building_test.go deleted file mode 100644 index 94d7d3bb..00000000 --- a/cmd/mesh-controller/plans_building_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "sort" - "sync" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// Issue 1687 (ADR 0483 clause 2): a delivery plan's key lives from the ask of its first build until every build of its last -// tier is built, or the plan fails, stops or waits on the operator. - -type fakeBuildingStore struct { - mu sync.Mutex - keys map[string]link.PlanBuilding - puts map[string]int -} - -func newFakeBuildingStore() *fakeBuildingStore { - return &fakeBuildingStore{keys: map[string]link.PlanBuilding{}, puts: map[string]int{}} -} - -func (f *fakeBuildingStore) Put(_ context.Context, plan string, value []byte) error { - f.mu.Lock() - defer f.mu.Unlock() - var v link.PlanBuilding - if err := json.Unmarshal(value, &v); err != nil { - return err - } - f.keys[plan] = v - f.puts[plan]++ - return nil -} - -func (f *fakeBuildingStore) Delete(_ context.Context, plan string) error { - f.mu.Lock() - defer f.mu.Unlock() - delete(f.keys, plan) - return nil -} - -func (f *fakeBuildingStore) Keys(context.Context) ([]string, error) { - f.mu.Lock() - defer f.mu.Unlock() - var out []string - for k := range f.keys { - out = append(out, k) - } - sort.Strings(out) - return out, nil -} - -func aPlan(id, state string, tiers [][]string, states map[string]string) inventory.Plan { - p := inventory.Plan{ID: id, State: state, Tiers: tiers, Modules: map[string]*inventory.PlanModule{}} - now := time.Now().Add(-time.Minute) - for m, st := range states { - pm := &inventory.PlanModule{State: st} - if st != "" { - pm.AskedAt = &now - } - p.Modules[m] = pm - } - return p -} - -func TestAPlanIsInItsBuildPhaseFromTheFirstAskToTheLastBuild(t *testing.T) { - tiers := [][]string{{"a", "b"}, {"c"}} - cases := []struct { - name string - plan inventory.Plan - want bool - }{ - {"no build asked yet", aPlan("p", inventory.PlanBuilding, tiers, nil), false}, - {"the first tier asked", aPlan("p", inventory.PlanBuilding, tiers, map[string]string{"a": "asked", "b": "asked"}), true}, - // Between two tiers: the first built and sent (rolling), the second not asked yet. - {"between two tiers", aPlan("p", inventory.PlanRolling, tiers, map[string]string{"a": "built", "b": "built"}), true}, - {"the last tier asked", aPlan("p", inventory.PlanBuilding, tiers, map[string]string{"a": "built", "b": "built", "c": "asked"}), true}, - {"every build built", aPlan("p", inventory.PlanRolling, tiers, map[string]string{"a": "built", "b": "built", "c": "built"}), false}, - // A module the plan deletes is done as far as its tiers go (release_plan.go), so it holds no key through the rollout. - {"a deleted module is done", aPlan("p", inventory.PlanRolling, tiers, map[string]string{"a": "built", "b": "built", "c": planDeleted}), false}, - {"failed", aPlan("p", inventory.PlanFailed, tiers, map[string]string{"a": "failed", "b": "asked"}), false}, - {"superseded", aPlan("p", inventory.PlanSuperseded, tiers, map[string]string{"a": "asked"}), false}, - {"a batch whose window is open", aPlan("p", inventory.PlanAssembling, tiers, map[string]string{"a": "asked"}), false}, - } - for _, c := range cases { - if got := buildPhaseOf(c.plan); got != c.want { - t.Errorf("%s: in the build phase = %v, want %v", c.name, got, c.want) - } - } - waiting := aPlan("p", inventory.PlanBuilding, tiers, map[string]string{"a": "asked"}) - waiting.Delivery = &inventory.PlanDelivery{Awaits: "the operator's word"} - if buildPhaseOf(waiting) { - t.Error("a plan that waits on the operator is in its build phase") - } -} - -// The key's life: put, refreshed, deleted at the phase's end; a plan that left the phase unseen loses its key at the next look. -func TestAPlanKeyIsPutRefreshedAndDeletedAtThePhasesEnd(t *testing.T) { - ctx := context.Background() - store := newFakeBuildingStore() - tiers := [][]string{{"a"}, {"b"}} - building := aPlan("plan-1", inventory.PlanBuilding, tiers, map[string]string{"a": "asked"}) - - if faults := syncBuilding(ctx, store, []inventory.Plan{building}, time.Now()); len(faults) > 0 { - t.Fatal(faults) - } - if _, ok := store.keys["plan-1"]; !ok { - t.Fatal("no key while the first build is asked") - } - first := store.keys["plan-1"] - // Refreshed: put again, the phase's start kept. - time.Sleep(2 * time.Millisecond) - syncBuilding(ctx, store, []inventory.Plan{building}, time.Now()) - if store.puts["plan-1"] != 2 || !store.keys["plan-1"].Seen.After(first.Seen) || !store.keys["plan-1"].Since.Equal(first.Since) { - t.Errorf("the key was not refreshed with its start kept: %d puts, %+v then %+v", store.puts["plan-1"], first, store.keys["plan-1"]) - } - // Between the tiers: still there. - between := aPlan("plan-1", inventory.PlanRolling, tiers, map[string]string{"a": "built"}) - syncBuilding(ctx, store, []inventory.Plan{between}, time.Now()) - if _, ok := store.keys["plan-1"]; !ok { - t.Error("the key was gone between two tiers") - } - // A key of another plan that is no longer in the phase. - store.keys["plan-0"] = link.PlanBuilding{Plan: "plan-0"} - done := aPlan("plan-1", inventory.PlanRolling, tiers, map[string]string{"a": "built", "b": "built"}) - syncBuilding(ctx, store, []inventory.Plan{done}, time.Now()) - if len(store.keys) != 0 { - t.Errorf("keys remain after the last build was built: %v", store.keys) - } -} - -// keepBuildingKey, told of each save, puts the key while the plan builds and deletes it when the plan fails. -func TestASavedPlanPutsOrDeletesItsKey(t *testing.T) { - ctx := context.Background() - store := newFakeBuildingStore() - var s buildingStore = store - buildingKeys.Store(&s) - defer buildingKeys.Store(nil) - - p := aPlan("plan-7", inventory.PlanBuilding, [][]string{{"a"}}, map[string]string{"a": "asked"}) - keepBuildingKey(ctx, p) - if _, ok := store.keys["plan-7"]; !ok { - t.Fatal("the key was not put when the plan was saved building") - } - p.State = inventory.PlanFailed - keepBuildingKey(ctx, p) - if _, ok := store.keys["plan-7"]; ok { - t.Fatal("the key stayed after the plan failed") - } -} - -// The plan's key is there before its first build is asked, so a check cannot take a place between the ask and the plan's save. -func TestThePlansKeyIsPutBeforeItsFirstBuildIsAsked(t *testing.T) { - asksOfEveryModule(t) - store := newFakeBuildingStore() - var s buildingStore = store - buildingKeys.Store(&s) - defer buildingKeys.Store(nil) - p, byName := aTierOfThree() - keyThere := false - was := askABuild - askABuild = func(context.Context, buildSource, string, string) (string, error) { - _, keyThere = store.keys["plan-1"] - return "build-x", nil - } - t.Cleanup(func() { askABuild = was }) - askModule(t.Context(), p, "a", byName) - if !keyThere { - t.Fatal("the plan's key was not there when its first build was asked") - } -} diff --git a/cmd/mesh-controller/plans_follow.go b/cmd/mesh-controller/plans_follow.go index cae2f5ba..3edf71d9 100644 --- a/cmd/mesh-controller/plans_follow.go +++ b/cmd/mesh-controller/plans_follow.go @@ -149,11 +149,11 @@ func planWaitCommand(ctx context.Context, inv *inventory.Inventory, id, untilAsk } // Said as `plans ` says its first line, and as `status` says the plan. now, one := time.Now(), []inventory.Plan{p} - bounds, pause := readTierBounds(ctx, inv, now), buildSeatPause(ctx, inv, one) - line := planLineSaid(p, now, pause, bounds.of(p.Repository), func(module string) []string { + bounds := readTierBounds(ctx, inv, now) + line := planLineSaid(p, now, bounds.of(p.Repository), func(module string) []string { on, _ := inv.Running(ctx, module) return on }, waitsOf(ctx, inv, one)) + heldSaid(p, heldNow(ctx, inv)) - doc := planWaitedDoc(p, planStatuses(one, now, pause, bounds)[0], until, came, now.Sub(began), line) + doc := planWaitedDoc(p, planStatuses(one, now, bounds)[0], until, came, now.Sub(began), line) return json.NewEncoder(os.Stdout).Encode(doc) } diff --git a/cmd/mesh-controller/plans_follow_test.go b/cmd/mesh-controller/plans_follow_test.go index 0bd63bdc..33b19bc8 100644 --- a/cmd/mesh-controller/plans_follow_test.go +++ b/cmd/mesh-controller/plans_follow_test.go @@ -94,7 +94,7 @@ func TestAWaitIsRefusedWhatItCannotDo(t *testing.T) { // the plan and what ended the wait, and its output is the line a person reads — never JSON inside a string. func TestAWaitForAPlanAnswersAsData(t *testing.T) { p := followedPlan(7, inventory.PlanRolling, 1) - st := planStatuses([]inventory.Plan{p}, time.Now(), pauseView{}, tierBounds{})[0] + st := planStatuses([]inventory.Plan{p}, time.Now(), tierBounds{})[0] doc := planWaitedDoc(p, st, "tier", "tier", 3*time.Second, "rolling, tier 2 of 2") stdout, _ := json.Marshal(doc) a := answerOf([]string{"plans", "wait", "plan-1"}, stdout, "", true) diff --git a/cmd/mesh-controller/predefined.go b/cmd/mesh-controller/predefined.go index 87dbf140..946dadf6 100644 --- a/cmd/mesh-controller/predefined.go +++ b/cmd/mesh-controller/predefined.go @@ -2,13 +2,9 @@ package main import ( "context" - "fmt" - "path" - "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" ) // The list of predefined keys the mesh publishes (novox/hq ADR 0348 §7, design 57 package K2, issue 661). @@ -20,48 +16,6 @@ import ( // against the list kept (catalogue.UsePredefined). mesh-config publishes it on the bus from there (design 57 // package K3); the controller keeps no config value, only the keys. -// fromTheCatalogue is whether a build is of the catalogue's repository, whose list alone the mesh publishes: by -// its name, in the URL form or the seat form. -func fromTheCatalogue(result link.BuildResult) bool { - for _, repository := range []string{result.Repository, sourceRepository(result)} { - name := strings.TrimSuffix(path.Base(strings.TrimRight(repository, "/")), ".git") - if name == catalogue.CatalogueRepository { - return true - } - } - return false -} - -func sourceRepository(result link.BuildResult) string { - if result.Source == nil { - return "" - } - return result.Source.Repository -} - -// carriedList is the list a build of the catalogue carried, read strictly; nil for a build of another -// repository, or one that carried none. -func carriedList(result link.BuildResult) (catalogue.PredefinedList, error) { - if result.Predefined == nil || !fromTheCatalogue(result) { - return nil, nil - } - list, err := catalogue.ParsePredefined(result.Predefined.Keys) - if err != nil { - return nil, fmt.Errorf("%s carried a list of predefined keys that is refused: %w", result.ID, err) - } - return list, nil -} - -// judgedWith is the list a build's manifest is first judged against at registration: the catalogue's own at the -// commit built, which its build judged it against, where it carried one; the list kept otherwise. -func judgedWith(result link.BuildResult) (catalogue.PredefinedList, error) { - list, err := carriedList(result) - if err != nil || list != nil { - return list, err - } - return catalogue.PublishedPredefined(), nil -} - // loadPredefined makes the list kept in the store the one this controller judges manifests against. A store // keeping none leaves the list it has. func loadPredefined(ctx context.Context, inv *inventory.Inventory) error { @@ -72,22 +26,3 @@ func loadPredefined(ctx context.Context, inv *inventory.Inventory) error { catalogue.UsePredefined(kept.Keys) return nil } - -// publishPredefined keeps a trunk build's list when it is newer than the one kept, says so, and loads what the -// store keeps then — this build's, or a newer one another controller process kept. -func publishPredefined(ctx context.Context, inv *inventory.Inventory, result link.BuildResult, - list catalogue.PredefinedList) error { - kept, err := inv.KeepPredefined(ctx, inventory.PredefinedRecord{Keys: list, Repository: result.Repository, - Commit: result.Commit, Generation: result.Predefined.Generation, Build: result.ID}) - if err != nil { - return err - } - if kept { - fmt.Printf("%s: the list of predefined keys at %s (generation %d) is published: %s\n", result.ID, - short(result.Commit), result.Predefined.Generation, strings.Join(list.Keys(), ", ")) - } else { - fmt.Printf("%s: the list of predefined keys at %s (generation %d) is not newer than the one kept, which "+ - "stands\n", result.ID, short(result.Commit), result.Predefined.Generation) - } - return loadPredefined(ctx, inv) -} diff --git a/cmd/mesh-controller/predefined_integration_test.go b/cmd/mesh-controller/predefined_integration_test.go index f999e3dc..9a7c1dd9 100644 --- a/cmd/mesh-controller/predefined_integration_test.go +++ b/cmd/mesh-controller/predefined_integration_test.go @@ -10,28 +10,6 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" ) -func TestTheMergeGateJudgesEachManifestAgainstTheListOfTheTree(t *testing.T) { - withoutAPublishedList(t) - f, manifests := templatedMesh(t) - - v := gateJudged(t, f, writeTheList(t, aTree(t, withEdit(manifests, "notifier", - notifierFallingBackTo("font-size", "number"))), theFirstKeys), "modules/notifier/module.json") - if v.Verdict == "fail" || v.Modules["notifier"] != "changed" { - t.Fatalf("a module naming font-size on the tree's list failed the gate (%v):\n%s", v.Modules, v.Report()) - } - for _, c := range []struct{ manifest, list, says string }{ - {notifierFallingBackTo("font-scale", "number"), theFirstKeys, `"font-scale", which is not on the list`}, - {notifierFallingBackTo("font-size", "string"), theFirstKeys, "which is a number"}, - {inlineNotifier, `{"font-size": {"type": "number", "description": "points", "unit": "pt"}}`, `unknown field "unit"`}, - } { - v := gateJudged(t, f, writeTheList(t, aTree(t, withEdit(manifests, "notifier", c.manifest)), c.list), - "modules/notifier/module.json", catalogue.PredefinedFile) - if v.Verdict != "fail" || !strings.Contains(v.Report(), c.says) { - t.Errorf("wanted the gate to fail saying %q:\n%s", c.says, v.Report()) - } - } -} - func TestRegistrationPublishesTheNewestTrunkListAndJudgesEveryModuleAgainstIt(t *testing.T) { withoutAPublishedList(t) open := aMesh(t) diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index 41d88d4f..e900ba0d 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -183,9 +183,6 @@ func serve(ctx context.Context) (err error) { // somebody deleted, a mesh raised from a restored backup, or a bus whose data directory was // replaced all have records and no objects — and a node whose consumer is missing hears nothing // while everything else about it looks correct. - // And build results nobody was waiting for. A build triggered any other way than `build` - // would otherwise be reported into the void, which is the same as not reporting it. - server.Records(builds{inv, open}) // Open plans move on events (novox/hq ADR 0406 §5, issue 974): a tier is asked on the `built` and the send of the // tier before it, a walk ends on its machines' reports, a silent machine is a key that expired, and a plan left // by a replaced controller resumes from the first look, which this asks for. No timer looks every so often. @@ -286,8 +283,6 @@ func serve(ctx context.Context) (err error) { go mirroringRecords(ctx, open, inv, bus.Conn, bus) // And the bucket of the files that travel by digest, judged against what each machine's latest declaration names (ADR 0486 §3). go sweepingDeclarationFiles(ctx, inv, bus.Conn) - // And the key of each plan in its build phase, which holds the build seat's checks while a delivery builds (ADR 0483 clause 2). - go keepingPlansBuilding(ctx, inv, bus.Conn) // And the modules the catalogue does not hold yet, registered from the builder's first done key of each, woken by // the builder's and the indexer's writes (novox/hq ADR 0501 §6, issue 1851): nothing asks a first build. watchNewModules(ctx, open) diff --git a/cmd/mesh-controller/queue.go b/cmd/mesh-controller/queue.go deleted file mode 100644 index a11ade45..00000000 --- a/cmd/mesh-controller/queue.go +++ /dev/null @@ -1,673 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "os" - "regexp" - "sort" - "strings" - "time" - - "github.com/nats-io/nats.go" - - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// The build queue, controlled by hand (novox/hq ADR 0219). -// -// Seen whole — what waits, what runs where and for how long, what was handed out as often as it -// may be and never settled — and changed through the controller: an ask cancelled, the queue -// cleared, a module rebuilt, a build replayed. What one machine is running is that machine's -// holder's to end or pause, and the controller asks it (`kill`, `pause`, `resume`). -// -// **Everything that drops an ask leaves a failed outcome for it**, taken in exactly as a build that -// failed is (takeIn, then the plan): a plan waiting on an ask a person removed fails, saying so, -// rather than waiting for ever on an answer nobody will give. - -// holderAsks is how long a holder's verb is waited for; killAnswer how long its kill is — longer -// than the holder's worst case (its two passes removing containers and its wait between, 50s). -const ( - holderAsks = 15 * time.Second - killAnswer = 75 * time.Second -) - -// dialTheBus is the controller's connection, for a command that reads or changes the queue: the serving -// controller's own, lent, when this process is it (novox/hq issue 327). -func dialTheBus() (*broker.JetStream, error) { - return aBus() -} - -// queueCommand prints every ask in the build seat's work queue. -func queueCommand(ctx context.Context, args []string) error { - return listQueue(ctx, args, os.Stdout) -} - -// listQueue is `queue`: the build queue, as a person reads it or as JSON. -func listQueue(ctx context.Context, args []string, w io.Writer) error { - set := flag.NewFlagSet("queue", flag.ContinueOnError) - usageTo(set, w) - asJSON := set.Bool("json", false, "the queue as JSON") - if _, err := parseAround(set, args); err != nil { - return err - } - js, err := dialTheBus() - if err != nil { - return err - } - defer js.Close() - seat := buildSeatHeld(ctx) - q, err := link.ReadQueue(ctx, js, seat) - if err != nil { - return err - } - now := time.Now() - if *asJSON { - body, err := json.MarshalIndent(queueAnswerOf(q, now), "", " ") - if err != nil { - return err - } - fmt.Fprintln(w, string(body)) - return nil - } - fmt.Fprint(w, queueText(q, now)) - return nil -} - -// queueAnswer is the queue as data (novox/hq issue 455): what a caller reads to say how far back an ask -// waits — "waiting in the build queue behind 2" — which the table alone could not tell it. Text is the -// table, written from the same read: the verb answers it as its output and the rest as its answer, so the -// two never disagree. -type queueAnswer struct { - Seat string `json:"seat"` - Waiting int `json:"waiting"` - InFlight int `json:"in-flight"` - Dead int `json:"dead"` - MaxDeliver int `json:"max-deliver"` - Asks []queuedAnswer `json:"asks"` - // HeldFor are the delivery plans building, while which the waiting checks are held (ADR 0483 clause 5); empty when none. - HeldFor []string `json:"held-for,omitempty"` - Text string `json:"text"` -} - -// queuedAnswer is one ask as data. Never what the request carries beyond its source — not `held`, every -// artifact the mesh has built, nor a check's checkouts beside it. -type queuedAnswer struct { - ID string `json:"id"` - State string `json:"state"` - // Kind is "delivery" for a build a walk waits for, "check" for a pull request's check and any ask - // whose kind cannot be told (link.KindOf, novox/hq issue 558). - Kind string `json:"kind"` - Repository string `json:"repository"` - Path string `json:"path,omitempty"` - Ref string `json:"ref,omitempty"` - // Commit is the ref when it names a commit; empty when the ask follows a branch or the default. - Commit string `json:"commit,omitempty"` - // Machine is the one building an ask in flight; WasOn the one that started it and handed it back. - Machine string `json:"machine,omitempty"` - WasOn string `json:"was-on,omitempty"` - AskedAt time.Time `json:"asked-at,omitzero"` - // Since is when the ask came to its state: its start on the machine for one in flight that a - // machine said it started, otherwise when it was asked. - Since time.Time `json:"since,omitzero"` - // Position is its place among the waiting, 1 being handed out next; 0 for an ask not waiting. - Position int `json:"position,omitempty"` - Seq uint64 `json:"seq"` - // Delivery is the how-manyth time an ask in flight was handed out; more than 1 is a restart - // (novox/hq issue 594). - Delivery int `json:"delivery,omitempty"` - // LetThrough is a check that waited past 30 minutes and is let through while a delivery builds: the next one, or the one - // that runs (ADR 0483 clause 5). - LetThrough bool `json:"let-through,omitempty"` -} - -// aCommitRef is a ref that names a commit by its full hash (SHA-1 or SHA-256). -var aCommitRef = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`) - -// queueAnswerOf is the queue as data, in the table's order: in flight, the waiting from the next one to -// be handed out, then the dead. A pure function of one read (novox/hq issue 455). -func queueAnswerOf(q link.Queue, now time.Time) queueAnswer { - waiting, running, dead := q.Of(link.AskWaiting), q.Of(link.AskInFlight), q.Of(link.AskDead) - out := queueAnswer{Seat: q.Seat, Waiting: len(waiting), InFlight: len(running), Dead: len(dead), - MaxDeliver: q.MaxDeliver, Asks: []queuedAnswer{}, HeldFor: q.Hold.Plans, Text: queueText(q, now)} - letNext, letRunning := q.LetThroughOf(now) - one := func(a link.QueuedAsk, position int) queuedAnswer { - kind := link.KindOf(a.Request) - since := a.AskedAt - if a.State == link.AskInFlight && a.On != "" && !a.Started.IsZero() { - since = a.Started - } - commit := "" - if aCommitRef.MatchString(a.Ref) { - commit = a.Ref - } - return queuedAnswer{ID: a.ID, State: a.State, Kind: kind, Repository: link.WithoutPassword(a.Repository), Path: a.Path, - Ref: a.Ref, Commit: commit, Machine: a.On, WasOn: a.Was, AskedAt: a.AskedAt, Since: since, - Position: position, Seq: a.Seq, Delivery: a.Delivery, LetThrough: a.ID != "" && (a.ID == letNext || a.ID == letRunning)} - } - for _, a := range running { - out.Asks = append(out.Asks, one(a, 0)) - } - for i, a := range waiting { - out.Asks = append(out.Asks, one(a, i+1)) - } - for _, a := range dead { - out.Asks = append(out.Asks, one(a, 0)) - } - return out -} - -// queueText is the queue as a person reads it: a summary line, then each kind in queue order. -// Never what an ask carries as `held` — that is every artifact the mesh has built. -func queueText(q link.Queue, now time.Time) string { - var b strings.Builder - waiting, running, dead := q.Of(link.AskWaiting), q.Of(link.AskInFlight), q.Of(link.AskDead) - fmt.Fprintf(&b, "%s: %d waiting, %d in flight, %d dead\n", q.Seat, len(waiting), len(running), len(dead)) - ago := func(t time.Time) string { - if t.IsZero() { - return "asked at an unknown time" - } - return "asked " + now.Sub(t).Round(time.Second).String() + " ago" - } - what := func(a link.QueuedAsk) string { - s := link.WithoutPassword(a.Repository) - if a.Path != "" { - s += " at " + a.Path - } - if a.Ref != "" { - s += " on " + a.Ref - } - return s - } - letNext, letRunning := q.LetThroughOf(now) - const passed = "waited past 30 minutes, let through" - if q.HoldFault != "" { - fmt.Fprintf(&b, "why checks wait cannot be read, so none is said held: %s\n", q.HoldFault) - } - if len(running) > 0 { - fmt.Fprintln(&b, "\nin flight:") - for _, a := range running { - where := "taken, not yet said where" - switch { - case a.On != "": - where = fmt.Sprintf("on %s for %s", a.On, now.Sub(a.Started).Round(time.Second)) - // Started again after an earlier taker left it (novox/hq issue 594). - if a.Delivery > 1 { - where += fmt.Sprintf(", handed out %d times", a.Delivery) - } - case a.Was != "": - where = fmt.Sprintf("handed back by %s, to be handed out again", a.Was) - } - note := "" - if a.ID != "" && a.ID == letRunning { - note = " — " + passed - } - fmt.Fprintf(&b, " %-26s %-8s %s — %s, %s (seq %d)%s\n", a.ID, link.KindOf(a.Request), what(a), where, - ago(a.AskedAt), a.Seq, note) - } - } - if len(waiting) > 0 { - fmt.Fprintln(&b, "\nwaiting, in the order they are handed out:") - // **Why the checks wait** (ADR 0483 clause 5): above them, the plans they are held for. - if len(q.Hold.Plans) > 0 && hasCheck(waiting) { - fmt.Fprintf(&b, " checks are held while delivery %s builds; the oldest check that waited 30 minutes goes first, one at a time\n", - strings.Join(q.Hold.Plans, ", ")) - } - for _, a := range waiting { - note := "" - if a.ID != "" && a.ID == letNext { - note = " — " + passed + " next" - } - fmt.Fprintf(&b, " %-26s %-8s %s — %s (seq %d)%s\n", a.ID, link.KindOf(a.Request), what(a), ago(a.AskedAt), a.Seq, note) - } - } - if len(dead) > 0 { - fmt.Fprintf(&b, "\ndead — handed out as often as the worker allows (%d) and never settled, still held:\n", q.MaxDeliver) - for _, a := range dead { - fmt.Fprintf(&b, " %-26s %-8s %s — %s (seq %d)\n", a.ID, link.KindOf(a.Request), what(a), ago(a.AskedAt), a.Seq) - } - } - switch { - case len(q.Asks) == 0: - fmt.Fprintln(&b, "nothing is asked of it") - default: - fmt.Fprintln(&b, "\n`cancel ` drops a waiting or dead ask, `clear` every waiting one, `kill ` ends one in flight") - } - return b.String() -} - -// hasCheck says a check is among the asks. -func hasCheck(asks []link.QueuedAsk) bool { - for _, a := range asks { - if a.Request.Check != nil { - return true - } - } - return false -} - -// cancelCommand drops one waiting or dead ask. -func cancelCommand(ctx context.Context, args []string) error { - if len(args) != 1 { - return errors.New("cancel ") - } - js, err := dialTheBus() - if err != nil { - return err - } - defer js.Close() - open, err := openStores(ctx) - if err != nil { - return err - } - defer open.Close() - seat := buildSeatHeld(ctx) - q, err := link.ReadQueue(ctx, js, seat) - if err != nil { - return err - } - ask, found := q.Find(args[0]) - if !found { - return fmt.Errorf("%s is not in the %s queue: it was built, cancelled, or never asked — `builds` says "+ - "what came of it", args[0], seat) - } - said, err := cancelAsk(ctx, js, open, seat, ask) - if err != nil { - return err - } - fmt.Println(said) - return nil -} - -// cancelAsk drops one ask from the queue and records it failed, cancelled by hand. -// -// **In this order, and each step for a reason** (novox/hq ADR 0219): -// 1. an ask a machine says it is building is refused: deleting its message ends nothing a machine -// is running — that is `kill`, on the machine running it; -// 2. its id goes into the seat's cancelled set, so a holder that fetches it from now on ends it; -// 3. for a waiting ask, the worker is read again: one handed out since the queue was read was -// taken in the moment of cancelling, and the cancel is withdrawn and refused — the holder either -// read the mark first and ends it as cancelled, or is building it; -// 4. for an ask the worker counts handed out that no machine is building — taken and not yet said, -// handed back after a restart, or past its deliveries while nobody pulls — the holder's own look -// at the set is waited out, and a start heard since withdraws and refuses the cancel: a holder -// that took it before the mark is building it, and only `kill` ends that; -// 5. the message is deleted by its sequence; -// 6. the failed outcome is taken in as any failed build's is, and the plan that asked fails. -func cancelAsk(ctx context.Context, js *broker.JetStream, open *stores, seat string, ask link.QueuedAsk) (string, error) { - if ask.State == link.AskInFlight && ask.On != "" { - return "", fmt.Errorf("%s is in flight on %s: cancelling drops an ask nobody is building. `kill %s` "+ - "ends the build where it runs", ask.ID, ask.On, ask.ID) - } - if err := link.MarkCancelled(ctx, js, seat, ask.ID); err != nil { - return "", err - } - withdraw := func() { - if err := link.UnmarkCancelled(ctx, js, seat, ask.ID); err != nil { - fmt.Fprintf(os.Stderr, "could not withdraw the cancel of %s: %v — a holder taking it ends it as cancelled\n", ask.ID, err) - } - } - // The worker of the queue it waits in: the seat's own, or one of the two check queues' (novox/hq ADR 0406 §3). - worker := link.WorkerOf(seat, ask) - switch ask.State { - case link.AskWaiting: - if taken, err := takenSince(js, worker, ask.Seq); err != nil { - withdraw() - return "", err - } else if taken { - withdraw() - return "", fmt.Errorf("%s was taken by a holder as it was cancelled, so the cancel is withdrawn. If the "+ - "holder read it first it ends the ask as %s and its outcome says so; otherwise it is building — "+ - "`queue` says which, and `kill %s` ends it", ask.ID, link.CancelledByHand, ask.ID) - } - case link.AskInFlight: - if started, err := startedSince(ctx, js, seat, ask); err != nil { - withdraw() - return "", err - } else if started != "" { - withdraw() - return "", fmt.Errorf("%s has started on %s since it was read, so the cancel is withdrawn: `kill %s` "+ - "ends it there", ask.ID, started, ask.ID) - } - } - removed, err := link.DeleteAsk(ctx, js, seat, ask) - if err != nil { - return "", fmt.Errorf("%s is marked cancelled and could not be deleted from the queue (seq %d): %w — a "+ - "holder taking it ends it as cancelled", ask.ID, ask.Seq, err) - } - // A check ended because its pull request has a newer head records nothing (novox/hq issue 726): open is nil. - if open != nil { - recordCancelled(ctx, open, ask) - } - return cancelSaid(ask, seat, removed), nil -} - -// cancelSaid is what a cancel says: deleted from the queue only when it was. -func cancelSaid(ask link.QueuedAsk, seat string, removed uint64) string { - where := fmt.Sprintf("deleted from the %s queue", seat) - if removed == 0 { - where = fmt.Sprintf("no longer in the %s queue to delete, and marked cancelled, so a holder taking it "+ - "ends it", seat) - } - return fmt.Sprintf("cancelled %s (%s, %s): %s and recorded failed, %s — a plan "+ - "that asked for it fails with that", ask.ID, ask.Repository, ask.State, where, link.CancelledByHand) -} - -// holderLooks is how long a cancel waits for a holder that took the ask before the mark to say it -// started: longer than a holder's look at the cancelled set (3s) and its start that follows. -var holderLooks = 5 * time.Second - -// startedSince waits out a holder's look at the cancelled set and says the machine that started the -// ask since it was read, or nothing. A start it ended as cancelled comes with its outcome and is not -// a start of a build. -func startedSince(ctx context.Context, js *broker.JetStream, seat string, ask link.QueuedAsk) (string, error) { - select { - case <-ctx.Done(): - return "", ctx.Err() - case <-time.After(holderLooks): - } - since := time.Now().Add(-7 * 24 * time.Hour) - if !ask.AskedAt.IsZero() { - since = ask.AskedAt.Add(-time.Minute) - } - heard, err := link.ReadBuildEvents(ctx, js, seat, since) - if err != nil { - return "", err - } - s, ok := heard.Started[ask.ID] - if !ok || heard.Outcomes[ask.ID] { - return "", nil - } - at, _ := time.Parse(time.RFC3339Nano, s.At) - if ask.Started.IsZero() || at.After(ask.Started) { - return s.On, nil - } - return "", nil -} - -// takenSince is whether the worker has handed out the ask at this sequence. -func takenSince(js *broker.JetStream, worker broker.Consumer, seq uint64) (bool, error) { - info, err := js.Context().ConsumerInfo(worker.Stream, worker.Name) - if errors.Is(err, nats.ErrConsumerNotFound) { - return false, nil - } - if err != nil { - return false, fmt.Errorf("cannot read the worker of the queue again: %w", err) - } - return info.Delivered.Stream >= seq, nil -} - -// recordCancelled takes the failed outcome in the way the daemon takes in any build's: recorded, -// then the plan that asked for it — by the id it asked with, or by repository and path. -func recordCancelled(ctx context.Context, open *stores, ask link.QueuedAsk) { - r := ask.Request - result := link.BuildResult{ID: ask.ID, Repository: ask.Repository, Path: ask.Path, Ref: ask.Ref, - Source: r.Source, DryRun: r.DryRun, Failed: link.CancelledByHand} - _ = builds{open.inventory, open}.Built(ctx, result) -} - -// clearCommand cancels every waiting ask, and with --dead every dead one too. -func clearCommand(ctx context.Context, args []string) error { - set := flag.NewFlagSet("clear", flag.ContinueOnError) - dead := set.Bool("dead", false, "the dead asks too") - if _, err := parseAround(set, args); err != nil { - return err - } - js, err := dialTheBus() - if err != nil { - return err - } - defer js.Close() - open, err := openStores(ctx) - if err != nil { - return err - } - defer open.Close() - seat := buildSeatHeld(ctx) - said, err := clearQueue(ctx, js, open, seat, *dead) - fmt.Print(said) - return err -} - -// clearQueue cancels what clear names, each as `cancel` would, and never anything in flight. -func clearQueue(ctx context.Context, js *broker.JetStream, open *stores, seat string, dead bool) (string, error) { - q, err := link.ReadQueue(ctx, js, seat) - if err != nil { - return "", err - } - var b strings.Builder - cancelled, refused := 0, 0 - for _, a := range q.Asks { - if a.State == link.AskInFlight || (a.State == link.AskDead && !dead) { - continue - } - said, err := cancelAsk(ctx, js, open, seat, a) - if err != nil { - refused++ - fmt.Fprintf(&b, " %s: %v\n", a.ID, err) - continue - } - cancelled++ - fmt.Fprintf(&b, " %s\n", said) - } - what := "waiting" - if dead { - what = "waiting and dead" - } - fmt.Fprintf(&b, "%d %s ask(s) cancelled", cancelled, what) - if refused > 0 { - fmt.Fprintf(&b, ", %d could not be", refused) - } - fmt.Fprintln(&b) - if n := len(q.Of(link.AskInFlight)); n > 0 { - fmt.Fprintf(&b, "%d in flight, left running: `kill ` ends one where it runs\n", n) - } - if n := len(q.Of(link.AskDead)); n > 0 && !dead { - fmt.Fprintf(&b, "%d dead, left: `clear --dead` cancels them too\n", n) - } - if refused > 0 { - return b.String(), fmt.Errorf("%d ask(s) could not be cancelled", refused) - } - return b.String(), nil -} - -// sourceOfBuild is where a recorded build's repository is asked from: the catalogued module's own -// source when the build is of one — on its seat, so the outcome registers it as the mesh records it -// (ADR 0111) — else the repository as it was cloned. -func sourceOfBuild(b inventory.Build, entries []inventory.Entry) (buildSource, string) { - for _, e := range entries { - if (b.Module != "" && e.Manifest.Module == b.Module) || - (b.Module == "" && repositoryMatches(e.Source.Repository, b.Repository) && e.Source.Path == b.Path) { - return buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}, e.Manifest.Module - } - } - return buildSource{Repository: b.Repository}, b.Module -} - -// replayCommand asks a recorded build's repository and path again at the commit it built. -func replayCommand(ctx context.Context, args []string) error { - set := flag.NewFlagSet("replay", flag.ContinueOnError) - register := set.Bool("register", false, "removed (novox/hq ADR 0501 §5): refused by name") - positionals, err := parseAround(set, args) - if err != nil { - return err - } - // **Registering a replay goes with the asks** (novox/hq ADR 0429 §18, ADR 0501 §5): refused by name. - if *register { - return removedByName("replay --register") - } - if len(positionals) != 1 { - return errors.New("replay ") - } - open, err := openStores(ctx) - if err != nil { - return err - } - defer open.Close() - inv := open.inventory - b, found, err := inv.BuildByID(ctx, positionals[0]) - if err != nil { - return err - } - if !found { - return fmt.Errorf("no build %s is recorded", positionals[0]) - } - entries, err := inv.Catalogued(ctx) - if err != nil { - return err - } - source, module := sourceOfBuild(b, entries) - if b.Commit == "" { - return fmt.Errorf("%s recorded no commit — it failed before it knew what it was building, so there is "+ - "nothing to replay", b.ID) - } - id, err := buildOneAsked(ctx, source, b.Path, b.Commit, 0, true, "") - if err != nil { - return err - } - fmt.Println(replaySaid(b, module, id, false)) - return nil -} - -// replaySaid is what a replay prints once asked: what it builds, and what becomes of the outcome. -func replaySaid(b inventory.Build, module, id string, register bool) string { - what := b.Repository - if module != "" { - what = module - } - said := fmt.Sprintf("replaying %s (%s) at %s as %s", b.ID, what, short(b.Commit), id) - if !register { - return said + "\n a dry run: the outcome is looked at and not taken in — nothing is recorded or " + - "registered, and nothing is sent. `builds --log " + id + "` follows it; `--register` registers it" - } - return said + "\n registered when it is built, as the module's current version — newer than every build " + - "asked before now — and rolled out as its policy says. `builds --log " + id + "` follows it" -} - -// killCommand finds the machine running a build and asks its holder to end it. -func killCommand(ctx context.Context, args []string) error { - if len(args) != 1 { - return errors.New("kill ") - } - id := args[0] - js, err := dialTheBus() - if err != nil { - return err - } - defer js.Close() - seat := buildSeatHeld(ctx) - since := time.Now().Add(-7 * 24 * time.Hour) - if at, ok := link.BuildAskedAt(id); ok { - since = at.Add(-time.Minute) - } - heard, err := link.ReadBuildEvents(ctx, js, seat, since) - if err != nil { - return err - } - started, ok := heard.Started[id] - if !ok { - return fmt.Errorf("no machine has said it started %s: if it waits in the queue, `cancel %s` drops it", id, id) - } - if heard.Outcomes[id] { - return fmt.Errorf("%s has already ended on %s — `builds` says how", id, started.On) - } - answer, err := link.AskSeatTool(ctx, js.Conn(), seat, "kill", started.On, map[string]string{"id": id}, killAnswer) - if err != nil { - return err - } - return printHolderAnswer(started.On, answer) -} - -// pauseCommand asks one machine's holder, or every holder's, to pause or resume. -func pauseCommand(ctx context.Context, verb string, args []string) error { - if len(args) > 1 { - return fmt.Errorf("%s [node]", verb) - } - js, err := dialTheBus() - if err != nil { - return err - } - defer js.Close() - seat := buildSeatHeld(ctx) - nodes := args - if len(nodes) == 0 { - if nodes, err = buildSeatHolders(ctx, seat); err != nil { - return err - } - if len(nodes) == 0 { - return fmt.Errorf("nothing holds %s, so there is nothing to %s", seat, verb) - } - } - failed := 0 - for _, node := range nodes { - answer, err := link.AskSeatTool(ctx, js.Conn(), seat, verb, node, map[string]string{}, holderAsks) - if err != nil { - fmt.Printf("%s: %v\n", node, err) - failed++ - continue - } - if err := printHolderAnswer(node, answer); err != nil { - failed++ - } - } - if failed > 0 { - return fmt.Errorf("%d of %d machine(s) did not %s", failed, len(nodes), verb) - } - return nil -} - -// printHolderAnswer prints what a holder said, or its refusal as the command's failure. -func printHolderAnswer(node string, answer link.Answer) error { - if answer.Error != "" { - fmt.Printf("%s: %s\n", node, answer.Error) - return errors.New(answer.Error) - } - var said struct { - Said string `json:"said"` - } - if json.Unmarshal(answer.Result, &said) == nil && said.Said != "" { - fmt.Printf("%s: %s\n", node, said.Said) - return nil - } - fmt.Printf("%s: %s\n", node, string(answer.Result)) - return nil -} - -// buildSeatHolders is every machine an assigned module holding the build seat runs on. -func buildSeatHolders(ctx context.Context, seat string) ([]string, error) { - open, err := openStores(ctx) - if err != nil { - return nil, err - } - defer open.Close() - entries, err := open.inventory.Catalogued(ctx) - if err != nil { - return nil, err - } - return holdersAmong(entries, seat), nil -} - -// holdersAmong is the machines of every catalogued module claiming the seat, sorted, once each. -func holdersAmong(entries []inventory.Entry, seat string) []string { - seen := map[string]bool{} - var out []string - for _, e := range entries { - if !e.Manifest.ClaimsSeat(seat) { - continue - } - for _, n := range e.On { - if !seen[n] { - seen[n] = true - out = append(out, n) - } - } - } - sort.Strings(out) - return out -} diff --git a/cmd/mesh-controller/queue_answer_test.go b/cmd/mesh-controller/queue_answer_test.go deleted file mode 100644 index 6f8cf6c9..00000000 --- a/cmd/mesh-controller/queue_answer_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package main - -import ( - "encoding/json" - "reflect" - "strings" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/link" -) - -// The queue answers as data as well as text (novox/hq issue 455): each ask's repository, commit, kind, -// machine, since and position among the waiting, read from the same queue the table is written from. - -const aCommit = "0123456789abcdef0123456789abcdef01234567" - -func aQueueOfEachKind(now time.Time) link.Queue { - asked := now.Add(-5 * time.Minute) - started := now.Add(-2 * time.Minute) - return link.Queue{Seat: link.TheBuildMachine, Delivered: 2, AckPending: 1, MaxDeliver: 5, Asks: []link.QueuedAsk{ - {Seq: 1, ID: "build-dead", Repository: "ssh://forge/novox/x", AskedAt: asked, State: link.AskDead}, - {Seq: 2, ID: "build-running", Repository: "ssh://forge/novox/mesh-host", Ref: aCommit, AskedAt: asked, - State: link.AskInFlight, On: "ace", Started: started, - Request: link.BuildRequest{ID: "build-running", Check: &link.CheckRequest{Owner: "novox", Repo: "mesh-host"}}}, - {Seq: 3, ID: "build-first", Repository: "ssh://forge/novox/mesh-catalog", Ref: "main", AskedAt: asked, - State: link.AskWaiting, Request: link.BuildRequest{ID: "build-first", Repository: "ssh://forge/novox/mesh-catalog"}}, - {Seq: 4, ID: "build-second", Repository: "ssh://forge/novox/mesh-catalog", Path: "modules/gitea", Ref: aCommit, - AskedAt: now.Add(-time.Minute), State: link.AskWaiting, - Request: link.BuildRequest{ID: "build-second", Check: &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog"}}}, - }} -} - -func TestTheQueueVerbAnswersEachAskAsData(t *testing.T) { - now := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC) - q := aQueueOfEachKind(now) - got := queueAnswerOf(q, now) - if got.Waiting != 2 || got.InFlight != 1 || got.Dead != 1 || got.Seat != link.TheBuildMachine { - t.Fatalf("counts: %+v", got) - } - if got.Text != queueText(q, now) { - t.Fatalf("the data's text is not the table read from the same queue:\n%s", got.Text) - } - byID := map[string]queuedAnswer{} - for _, a := range got.Asks { - byID[a.ID] = a - } - running := byID["build-running"] - if running.Kind != "check" || running.Machine != "ace" || running.Commit != aCommit || running.Position != 0 || - !running.Since.Equal(now.Add(-2*time.Minute)) || running.State != link.AskInFlight { - t.Errorf("in flight: %+v", running) - } - first, second := byID["build-first"], byID["build-second"] - if first.Position != 1 || first.Kind != "delivery" || first.Commit != "" || first.Ref != "main" || first.Machine != "" || - !first.Since.Equal(now.Add(-5*time.Minute)) { - t.Errorf("first waiting: %+v", first) - } - if second.Position != 2 || second.Kind != "check" || second.Commit != aCommit || second.Path != "modules/gitea" || - second.Repository != "ssh://forge/novox/mesh-catalog" { - t.Errorf("second waiting: %+v", second) - } - if dead := byID["build-dead"]; dead.Position != 0 || dead.State != link.AskDead { - t.Errorf("dead: %+v", dead) - } - // And the table says each ask's kind too (novox/hq issue 558). - for _, line := range []string{"build-running check ", "build-first delivery ", - "build-second check ", "build-dead check "} { - if !strings.Contains(got.Text, line) { - t.Errorf("the table does not say %q:\n%s", line, got.Text) - } - } - // In the table's order: in flight, then the waiting by position, then the dead. - var order []string - for _, a := range got.Asks { - order = append(order, a.ID) - } - if want := []string{"build-running", "build-first", "build-second", "build-dead"}; !reflect.DeepEqual(order, want) { - t.Errorf("order %v, want %v", order, want) - } -} - -// The verb asks its command for JSON, and its answer carries the data while its output stays the table a -// person reads, so a text caller reads what it always did. -func TestTheQueueVerbKeepsTheTableAndCarriesTheData(t *testing.T) { - argv, err := argvFor("queue", nil) - if err != nil || !reflect.DeepEqual(argv, []string{"queue", "--json"}) { - t.Fatalf("queue's command line: %v %v", argv, err) - } - now := time.Now() - q := aQueueOfEachKind(now) - body, err := json.Marshal(queueAnswerOf(q, now)) - if err != nil { - t.Fatal(err) - } - answer := answerOf(argv, body, "", true) - if answer.Output != queueText(q, now) { - t.Fatalf("the output is not the table:\n%s", answer.Output) - } - data, ok := answer.Answer.(map[string]any) - if !ok { - t.Fatalf("no data: %#v", answer.Answer) - } - if _, doubled := data["text"]; doubled { - t.Error("the table is said twice: once as the output and again inside the data") - } - asks, _ := data["asks"].([]any) - if len(asks) != 4 || data["waiting"] != float64(2) { - t.Fatalf("data: %v", data) - } - second := asks[2].(map[string]any) - if second["position"] != float64(2) || second["kind"] != "check" || second["commit"] != aCommit { - t.Errorf("second waiting: %v", second) - } - for _, a := range asks { - if _, held := a.(map[string]any)["held"]; held { - t.Error("an ask carries what the mesh holds") - } - } - // A failure is said as text, as before. - failed := answerOf(argv, nil, "mesh-controller: the bus cannot be reached\n", false) - if failed.Answer != nil || !strings.Contains(failed.Output, "cannot be reached") { - t.Errorf("a failure: %+v", failed) - } -} - -// A repository named with a password never shows it, in the table or the data. -func TestTheQueueNeverShowsARepositorysPassword(t *testing.T) { - if got := link.WithoutPassword("https://agent:s3cret@forge.example/novox/x.git"); strings.Contains(got, "s3cret") || - !strings.Contains(got, "agent@forge.example/novox/x.git") { - t.Errorf("redacted: %s", got) - } - now := time.Now() - q := link.Queue{Seat: link.TheBuildMachine, Asks: []link.QueuedAsk{{Seq: 1, ID: "build-1", - Repository: "https://agent:s3cret@forge.example/novox/x.git", State: link.AskWaiting}}} - body, _ := json.Marshal(queueAnswerOf(q, now)) - if strings.Contains(string(body), "s3cret") { - t.Errorf("the queue said a password: %s", body) - } - for _, kept := range []string{"ssh://git@forge:222/novox/x.git", "novox/x", "git@forge:novox/x.git", ""} { - if got := link.WithoutPassword(kept); got != kept { - t.Errorf("%q became %q", kept, got) - } - } -} - -// A cancel says it deleted the ask only when it did: moved into order and gone since, it says so (novox/hq -// issue 558). -func TestACancelSaysDeletedOnlyWhenItDeleted(t *testing.T) { - ask := link.QueuedAsk{ID: "build-1", Repository: "novox/x", State: link.AskWaiting, Seq: 4} - if said := cancelSaid(ask, link.TheBuildMachine, 9); !strings.Contains(said, "deleted from the") { - t.Errorf("removed, it says: %s", said) - } - if said := cancelSaid(ask, link.TheBuildMachine, 0); strings.Contains(said, "deleted from") || - !strings.Contains(said, "no longer in the") { - t.Errorf("gone already, it says: %s", said) - } -} diff --git a/cmd/mesh-controller/queue_held_test.go b/cmd/mesh-controller/queue_held_test.go deleted file mode 100644 index 2561492e..00000000 --- a/cmd/mesh-controller/queue_held_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package main - -import ( - "strings" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/link" -) - -// Issue 1688 / 1652 AC5 (ADR 0483 clause 5): the queue names, above the waiting checks, the plan they are held for, and says of a -// check let through that it waited past 30 minutes. - -func aHeldQueue(now time.Time) link.Queue { - check := func(id string, ago time.Duration, n int) link.QueuedAsk { - return link.QueuedAsk{Seq: uint64(n), ID: id, Repository: "ssh://forge/novox/r", AskedAt: now.Add(-ago), State: link.AskWaiting, - Request: link.BuildRequest{ID: id, Check: &link.CheckRequest{Owner: "novox", Repo: "r", Number: n}}} - } - return link.Queue{Seat: link.TheBuildMachine, Asks: []link.QueuedAsk{ - check("build-young", 5*time.Minute, 1), - check("build-old", 45*time.Minute, 2), - check("build-older", 50*time.Minute, 3), - }, Hold: link.CheckHold{Plans: []string{"plan-42"}}} -} - -func TestTheQueueNamesThePlanTheChecksAreHeldFor(t *testing.T) { - now := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC) - text := queueText(aHeldQueue(now), now) - heldAt := strings.Index(text, "held while delivery plan-42 builds") - firstAt := strings.Index(text, "build-young") - if heldAt < 0 || firstAt < 0 || heldAt > firstAt { - t.Fatalf("the plan the checks are held for is not named above the waiting checks:\n%s", text) - } - // Nothing building: nothing held. - q := aHeldQueue(now) - q.Hold = link.CheckHold{} - if strings.Contains(queueText(q, now), "held while") { - t.Fatal("checks are said held with no delivery building") - } -} - -func TestACheckLetThroughIsSaidToHaveWaitedPastThirtyMinutes(t *testing.T) { - now := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC) - q := aHeldQueue(now) - text := queueText(q, now) - var saidOf []string - for _, l := range strings.Split(text, "\n") { - if strings.Contains(l, "waited past 30 minutes, let through") { - saidOf = append(saidOf, strings.Fields(l)[0]) - } - } - if len(saidOf) != 1 || saidOf[0] != "build-older" { - t.Fatalf("the check let through next (the oldest) is not the one said: %v\n%s", saidOf, text) - } - answer := queueAnswerOf(q, now) - if len(answer.HeldFor) != 1 || answer.HeldFor[0] != "plan-42" { - t.Errorf("held-for = %v", answer.HeldFor) - } - for _, a := range answer.Asks { - if a.LetThrough != (a.ID == "build-older") { - t.Errorf("%s let-through = %v", a.ID, a.LetThrough) - } - } - // A check in flight under the let-through key is said too. - q.Asks = append(q.Asks, link.QueuedAsk{Seq: 9, ID: "build-running", State: link.AskInFlight, On: "ace", AskedAt: now.Add(-time.Hour), - Started: now.Add(-time.Minute), Request: link.BuildRequest{ID: "build-running", Check: &link.CheckRequest{Owner: "novox", Repo: "r", Number: 9}}}) - q.Hold.LetThrough = &link.LetThrough{Builder: "ace"} - if !strings.Contains(queueText(q, now), "build-running") || !strings.Contains(queueText(q, now), "waited past 30 minutes, let through\n") { - t.Errorf("the check in flight let through is not said:\n%s", queueText(q, now)) - } - // Younger than the bound: none is said. - young := aHeldQueue(now) - young.Asks = young.Asks[:1] - if strings.Contains(queueText(young, now), "waited past") { - t.Error("a check of 5 minutes is said to have waited past 30") - } -} diff --git a/cmd/mesh-controller/queue_integration_test.go b/cmd/mesh-controller/queue_integration_test.go deleted file mode 100644 index 5bb334f2..00000000 --- a/cmd/mesh-controller/queue_integration_test.go +++ /dev/null @@ -1,431 +0,0 @@ -//go:build integration - -package main - -import ( - "context" - "encoding/json" - "fmt" - "reflect" - "strings" - "testing" - "time" - - "github.com/nats-io/nats.go" - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" - "github.com/novox/mesh-controller/internal/testbus" -) - -func TestAFailedPlanIsRetriedAndGoesOnThroughItsLaterTiers(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - asked := asksRecorded(t) - twoTiers(t, open) - before := time.Now().UTC().Add(-time.Hour) - failed := inventory.Plan{ID: "plan-retry", Repository: "novox/a", Branch: "main", Commit: "c0ffee", - Created: before, State: inventory.PlanFailed, Tier: 0, Tiers: [][]string{{"a"}, {"b"}}, - Note: "a failed to build in tier 0", - Modules: map[string]*inventory.PlanModule{"a": {State: "failed", AskedAt: &before, Build: "build-1", - Why: link.KilledByHand}}} - if err := open.inventory.SavePlan(ctx, &failed); err != nil { - t.Fatal(err) - } - - said, err := retryPlan(ctx, open, failed.ID) - if err != nil { - t.Fatal(err) - } - p, err := open.inventory.PlanByID(ctx, failed.ID) - if err != nil { - t.Fatal(err) - } - a := p.Modules["a"] - if p.State != inventory.PlanBuilding || a.State != "asked" || a.Build == "" || a.Build == "build-1" || a.Why != "" { - t.Fatalf("after retry the plan is %s and a is %+v", p.State, a) - } - if !strings.Contains(said, a.Build) { - t.Errorf("retry does not say the new id: %q", said) - } - if len(*asked) != 1 { - t.Fatalf("asked %v", *asked) - } - - // The killed build's own late outcome is not this ask's; the new one's is, and tier 1 follows. - planBuilt(ctx, open, "a", "c0ffee", link.KilledByHand, before, "build-1") - if p, _ = open.inventory.PlanByID(ctx, failed.ID); p.State != inventory.PlanBuilding { - t.Fatalf("the old ask's outcome failed the retried plan: %s %q", p.State, p.Note) - } - asking, _ := link.BuildAskedAt(a.Build) - planBuilt(ctx, open, "a", "c0ffee", "", asking, a.Build) - p, err = open.inventory.PlanByID(ctx, failed.ID) - if err != nil { - t.Fatal(err) - } - if p.Tier != 1 || p.Modules["b"] == nil || p.Modules["b"].State != "asked" || p.Modules["b"].Build == "" { - t.Fatalf("the retried plan did not go on to tier 1: tier %d, %s, b %+v", p.Tier, p.State, p.Modules["b"]) - } - if len(*asked) != 2 { - t.Fatalf("asked %v", *asked) - } -} - -func aBuildQueue(t *testing.T) (*broker.JetStream, func(id, repository string) link.BuildRequest) { - t.Helper() - url := testbus.URL(t) - t.Setenv(broker.NATSVar, url) - js, err := broker.Dial(url) - if err != nil { - t.Fatal(err) - } - t.Cleanup(js.Close) - seat := broker.DeclaredSeat{Name: link.TheBuildMachine, Accepts: []string{"build"}, - Emits: []string{"started", "built", "log.*", "paused.*"}} - if err := broker.AssertMeshStreams(js); err != nil { - t.Fatal(err) - } - _ = js.Context().DeleteStream("SEAT_NODE_BUILD_AGENT") - if err := broker.RaiseSeats(js, []broker.DeclaredSeat{seat}, map[string]broker.Holder{ - link.TheBuildMachine: {Node: "anchor", Module: "build-agent"}}); err != nil { - t.Fatal(err) - } - if err := broker.RaiseCancelledSets(js, []broker.DeclaredSeat{seat}); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - _ = js.Context().DeleteStream("SEAT_NODE_BUILD_AGENT") - _ = js.Context().DeleteKeyValue(broker.CancelledSetName(link.TheBuildMachine)) - _ = js.Context().PurgeStream(broker.EventsStream) - }) - ask := func(id, repository string) link.BuildRequest { - r := link.BuildRequest{ID: id, Repository: repository, Held: map[string]string{"x/y": "secret-ish"}} - body, _ := json.Marshal(r) - if _, err := js.Context().Publish(link.BuildWork(), body); err != nil { - t.Fatal(err) - } - return r - } - return js, ask -} - -func TestCancelDeletesTheAskAndFailsThePlanThatAskedIt(t *testing.T) { - js, ask := aBuildQueue(t) - open := aMesh(t) - ctx := t.Context() - twoTiers(t, open) - id := link.NewBuildID(time.Now()) - ask(id, "https://forge.example/novox/a.git") - asked, _ := link.BuildAskedAt(id) - plan := inventory.Plan{ID: "plan-cancel", Repository: "novox/a", Commit: "c0ffee", Created: asked, - State: inventory.PlanBuilding, Tiers: [][]string{{"a"}, {"b"}}, - Modules: map[string]*inventory.PlanModule{"a": {State: "asked", AskedAt: &asked, Build: id}}} - if err := open.inventory.SavePlan(ctx, &plan); err != nil { - t.Fatal(err) - } - - q, err := link.ReadQueue(ctx, js, link.TheBuildMachine) - if err != nil { - t.Fatal(err) - } - if len(q.Asks) != 1 || q.Asks[0].State != link.AskWaiting || q.Asks[0].ID != id { - t.Fatalf("the queue reads %+v", q) - } - if text := queueText(q, time.Now()); !strings.Contains(text, "1 waiting, 0 in flight, 0 dead") || - strings.Contains(text, "secret-ish") { - t.Errorf("the queue says:\n%s", text) - } - - if err := cancelCommand(ctx, []string{id}); err != nil { - t.Fatal(err) - } - if q, _ = link.ReadQueue(ctx, js, link.TheBuildMachine); len(q.Asks) != 0 { - t.Fatalf("the ask is still queued: %+v", q.Asks) - } - if cancelled, err := link.IsCancelled(js.Conn(), link.TheBuildMachine, id); err != nil || !cancelled { - t.Errorf("the cancelled set does not hold it: %v %v", cancelled, err) - } - b, found, err := open.inventory.BuildByID(ctx, id) - if err != nil || !found || b.Failed != link.CancelledByHand { - t.Fatalf("the cancel is recorded as %+v (%v %v)", b, found, err) - } - p, err := open.inventory.PlanByID(ctx, plan.ID) - if err != nil { - t.Fatal(err) - } - if p.State != inventory.PlanFailed || p.Modules["a"].State != "failed" || p.Modules["a"].Why != link.CancelledByHand { - t.Fatalf("the plan that asked is %s, a %+v", p.State, p.Modules["a"]) - } - if err := cancelCommand(ctx, []string{id}); err == nil { - t.Error("an ask cancelled already was cancelled again") - } -} - -func TestClearCancelsTheWaitingAndTheDeadOnlyWhenTold(t *testing.T) { - js, ask := aBuildQueue(t) - open := aMesh(t) - ctx := t.Context() - start := time.Now() - dead := link.NewBuildID(start) - ask(dead, "https://forge.example/novox/dead.git") - deadOne(t, js) - var waiting []string - for i := 1; i <= 2; i++ { - id := link.NewBuildID(start.Add(time.Duration(i) * time.Millisecond)) - waiting = append(waiting, id) - ask(id, fmt.Sprintf("https://forge.example/novox/w%d.git", i)) - } - - q, err := link.ReadQueue(ctx, js, link.TheBuildMachine) - if err != nil { - t.Fatal(err) - } - if len(q.Of(link.AskDead)) != 1 || len(q.Of(link.AskWaiting)) != 2 || q.MaxDeliver != 5 { - t.Fatalf("the queue reads %+v", q) - } - said, err := clearQueue(ctx, js, open, link.TheBuildMachine, false) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(said, "2 waiting ask(s) cancelled") || !strings.Contains(said, "1 dead, left") { - t.Errorf("clear said:\n%s", said) - } - q, _ = link.ReadQueue(ctx, js, link.TheBuildMachine) - if len(q.Asks) != 1 || q.Asks[0].ID != dead || q.Asks[0].State != link.AskDead { - t.Fatalf("after clear the queue is %+v", q.Asks) - } - if _, err := clearQueue(ctx, js, open, link.TheBuildMachine, true); err != nil { - t.Fatal(err) - } - if q, _ = link.ReadQueue(ctx, js, link.TheBuildMachine); len(q.Asks) != 0 { - t.Fatalf("clear --dead left %+v", q.Asks) - } - for _, id := range append(waiting, dead) { - if b, found, _ := open.inventory.BuildByID(ctx, id); !found || b.Failed != link.CancelledByHand { - t.Errorf("%s is recorded as %+v", id, b) - } - } -} - -func TestCancelRefusesAnAskInFlight(t *testing.T) { - js, ask := aBuildQueue(t) - open := aMesh(t) - ctx := t.Context() - id := link.NewBuildID(time.Now()) - ask(id, "https://forge.example/novox/a.git") - worker, _ := broker.HolderConsumerFor("", "", broker.DeclaredSeat{Name: link.TheBuildMachine, Accepts: []string{"build"}}) - sub, err := js.Context().PullSubscribe(worker.Filters[0], worker.Name, nats.Bind(worker.Stream, worker.Name), nats.ManualAck()) - if err != nil { - t.Fatal(err) - } - defer func() { _ = sub.Unsubscribe() }() - if _, err := sub.Fetch(1, nats.MaxWait(3*time.Second)); err != nil { - t.Fatal(err) - } - started, _ := json.Marshal(link.BuildStart{ID: id, On: "ace", At: time.Now().UTC().Format(time.RFC3339Nano)}) - if _, err := js.Context().Publish(link.BuildStarted(), started); err != nil { - t.Fatal(err) - } - q, err := link.ReadQueue(ctx, js, link.TheBuildMachine) - if err != nil { - t.Fatal(err) - } - a, _ := q.Find(id) - if a.State != link.AskInFlight || a.On != "ace" { - t.Fatalf("the taken ask reads %+v", a) - } - _, err = cancelAsk(ctx, js, open, link.TheBuildMachine, a) - if err == nil || !strings.Contains(err.Error(), "kill "+id) { - t.Fatalf("an ask in flight was cancelled: %v", err) - } - if _, found, _ := open.inventory.BuildByID(ctx, id); found { - t.Error("a refused cancel recorded an outcome") - } -} - -func TestKillAndPauseAskTheHolderOnTheMachine(t *testing.T) { - js, _ := aBuildQueue(t) - ctx := t.Context() - asked := map[string]string{} - handlers := map[string]link.ToolHandler{ - "kill": func(_ context.Context, raw json.RawMessage) (any, error) { - var args struct{ ID string } - _ = json.Unmarshal(raw, &args) - asked["kill"] = args.ID - if args.ID != "build-running" { - return nil, fmt.Errorf("ace is not building %s", args.ID) - } - return map[string]any{"said": "killed " + args.ID}, nil - }, - "pause": func(context.Context, json.RawMessage) (any, error) { - asked["pause"] = "ace" - return map[string]any{"said": "ace is paused"}, nil - }, - } - stop, err := link.OverNATS{Conn: js.Conn()}.ServeNodeSeatTools(link.TheBuildMachine, "ace", handlers, nil) - if err != nil { - t.Fatal(err) - } - defer stop() - for _, id := range []string{"build-running", "build-other"} { - started, _ := json.Marshal(link.BuildStart{ID: id, On: "ace", At: time.Now().UTC().Format(time.RFC3339Nano)}) - if _, err := js.Context().Publish(link.BuildStarted(), started); err != nil { - t.Fatal(err) - } - } - if err := killCommand(ctx, []string{"build-running"}); err != nil { - t.Fatal(err) - } - if asked["kill"] != "build-running" { - t.Fatalf("the holder was asked %v", asked) - } - if err := killCommand(ctx, []string{"build-other"}); err == nil { - t.Error("the holder's refusal was not the command's") - } - if err := killCommand(ctx, []string{"build-never"}); err == nil || !strings.Contains(err.Error(), "cancel build-never") { - t.Errorf("a build nobody started: %v", err) - } - if err := pauseCommand(ctx, "pause", []string{"ace"}); err != nil || asked["pause"] != "ace" { - t.Fatalf("pause: %v %v", err, asked) - } - if err := pauseCommand(ctx, "resume", []string{"g14"}); err == nil { - t.Error("a machine nothing answers on was resumed") - } -} - -func TestAPlanStoppedAtItsFirstMachineIsRetried(t *testing.T) { - withTheGate(t) // the first-machine gate's own path (novox/hq ADR 0360 keeps it for earlier walks) - open := aMesh(t) - ctx := t.Context() - asked := asksRecorded(t) - twoTiers(t, open) - var sentTo [][]string - was := sendRollout - sendRollout = func(_ context.Context, _ *stores, names []string) ([]string, error) { - sentTo = append(sentTo, names) - return names, nil - } - t.Cleanup(func() { sendRollout = was }) - - // a records: a person's choice, since the default rolls out (novox/hq ADR 0236). - if err := open.inventory.SetUpgradeOf(ctx, "a", inventory.Upgrade{Why: "test"}); err != nil { - t.Fatal(err) - } - long := time.Now().UTC().Add(-2 * time.Hour) - stopped := inventory.Plan{ID: "plan-rollout", Repository: "novox/a", Branch: "main", Commit: "c0ffee", - Created: long, State: inventory.PlanFailed, Tiers: [][]string{{"a"}, {"b"}}, - Note: "a stopped at its first machine in tier 0: laptop refused what it was sent", - // Asked by the plan, as every module a plan holds built is (novox/hq issue 754). - Modules: map[string]*inventory.PlanModule{"a": {State: "built", AskedAt: &long, Build: "build-a", BuiltAt: &long, - Commit: "c0ffee", First: []string{"laptop"}, FirstAt: &long, Why: "laptop refused what it was sent"}}} - if err := open.inventory.SavePlan(ctx, &stopped); err != nil { - t.Fatal(err) - } - - // A newer plan holding a refuses it: sending the older build would put it back. - newer := inventory.Plan{ID: "plan-newer", Repository: "novox/other", Commit: "d00d", Created: long.Add(time.Hour), - State: inventory.PlanDone, Tiers: [][]string{{"a"}}, Modules: map[string]*inventory.PlanModule{}} - if err := open.inventory.SavePlan(ctx, &newer); err != nil { - t.Fatal(err) - } - if _, err := retryPlan(ctx, open, stopped.ID); err == nil || !strings.Contains(err.Error(), "plan-newer") { - t.Fatalf("retried under a newer plan: %v", err) - } - newer.State = inventory.PlanSuperseded - if err := open.inventory.SavePlan(ctx, &newer); err != nil { - t.Fatal(err) - } - - said, err := retryPlan(ctx, open, stopped.ID) - if err != nil { - t.Fatal(err) - } - if len(sentTo) != 1 || !reflect.DeepEqual(sentTo[0], []string{"laptop"}) || !strings.Contains(said, "laptop") { - t.Fatalf("sent %v; said %q", sentTo, said) - } - p, err := open.inventory.PlanByID(ctx, stopped.ID) - if err != nil { - t.Fatal(err) - } - a := p.Modules["a"] - if p.State != inventory.PlanRolling || a.FirstAt == nil || !a.FirstAt.After(long) || a.Why != "" { - t.Fatalf("after retry the plan is %s, a %+v", p.State, a) - } - // And it goes on: a records (its policy sends nothing more), so the next tier is asked. - advancePlans(ctx, open) - if p, _ = open.inventory.PlanByID(ctx, stopped.ID); p.Tier != 1 || p.Modules["b"] == nil || p.Modules["b"].State != "asked" { - t.Fatalf("the retried plan did not go on: tier %d %s %+v", p.Tier, p.State, p.Modules["b"]) - } - if len(*asked) != 1 { - t.Fatalf("asked %v", *asked) - } -} - -func TestAPlanIsAnsweredOnlyByTheBuildItAskedFor(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - asked := time.Now().UTC().Add(-time.Minute) - plan := inventory.Plan{ID: "plan-own", Repository: "novox/a", Commit: "c0ffee", Created: asked, - State: inventory.PlanBuilding, Tiers: [][]string{{"a"}}, - Modules: map[string]*inventory.PlanModule{"a": {State: "asked", AskedAt: &asked, Build: "build-own"}}} - if err := open.inventory.SavePlan(ctx, &plan); err != nil { - t.Fatal(err) - } - planBuilt(ctx, open, "a", "0ldc0mm1t", "", time.Now().UTC(), "build-replay") - p, _ := open.inventory.PlanByID(ctx, plan.ID) - if p.Modules["a"].State != "asked" { - t.Fatalf("a replay asked after the plan settled it: %+v", p.Modules["a"]) - } - // From the records too: a later build of the module recorded is not the plan's. - recorded := map[string][]inventory.Build{"a": {{ID: "build-replay", Commit: "0ldc0mm1t", Asked: time.Now(), At: time.Now()}}} - if settleFromRecords(&p, p.Tiers[0], recorded, nil) { - t.Fatalf("the records settled it with another build: %+v", p.Modules["a"]) - } - planBuilt(ctx, open, "a", "c0ffee", "", asked, "build-own") - if p, _ = open.inventory.PlanByID(ctx, plan.ID); p.Modules["a"].State != "built" || p.Modules["a"].Commit != "c0ffee" { - t.Fatalf("its own build did not settle it: %+v", p.Modules["a"]) - } -} - -func TestCancelOfAnAskNobodySaidIsWithdrawnWhenItStarts(t *testing.T) { - js, ask := aBuildQueue(t) - open := aMesh(t) - ctx := t.Context() - was := holderLooks - holderLooks = 300 * time.Millisecond - t.Cleanup(func() { holderLooks = was }) - id := link.NewBuildID(time.Now()) - ask(id, "https://forge.example/novox/a.git") - worker, _ := broker.HolderConsumerFor("", "", broker.DeclaredSeat{Name: link.TheBuildMachine, Accepts: []string{"build"}}) - sub, err := js.Context().PullSubscribe(worker.Filters[0], worker.Name, nats.Bind(worker.Stream, worker.Name), nats.ManualAck()) - if err != nil { - t.Fatal(err) - } - defer func() { _ = sub.Unsubscribe() }() - if _, err := sub.Fetch(1, nats.MaxWait(3*time.Second)); err != nil { - t.Fatal(err) - } - q, err := link.ReadQueue(ctx, js, link.TheBuildMachine) - if err != nil { - t.Fatal(err) - } - a, _ := q.Find(id) - if a.State != link.AskInFlight || a.On != "" { - t.Fatalf("the taken ask reads %+v", a) - } - // The holder that took it says it started, while the cancel waits. - go func() { - time.Sleep(100 * time.Millisecond) - started, _ := json.Marshal(link.BuildStart{ID: id, On: "ace", At: time.Now().UTC().Format(time.RFC3339Nano)}) - _, _ = js.Context().Publish(link.BuildStarted(), started) - }() - if _, err := cancelAsk(ctx, js, open, link.TheBuildMachine, a); err == nil || !strings.Contains(err.Error(), "started on ace") { - t.Fatalf("a build that started was cancelled: %v", err) - } - if cancelled, _ := link.IsCancelled(js.Conn(), link.TheBuildMachine, id); cancelled { - t.Error("the withdrawn cancel is still marked") - } - if _, found, _ := open.inventory.BuildByID(ctx, id); found { - t.Error("a withdrawn cancel recorded an outcome") - } -} diff --git a/cmd/mesh-controller/queue_test.go b/cmd/mesh-controller/queue_test.go deleted file mode 100644 index c5a4e281..00000000 --- a/cmd/mesh-controller/queue_test.go +++ /dev/null @@ -1,234 +0,0 @@ -package main - -import ( - "context" - "reflect" - "strings" - "testing" - "time" - - "github.com/nats-io/nats.go" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// The build queue, controlled by hand (novox/hq ADR 0219), and the plans that follow it. -// -// make postgres PG_PORT=55566 PG_CONTAINER=bq-pg -// MESH_TEST_POSTGRES='postgres://postgres:check@127.0.0.1:55566/postgres?sslmode=disable' \ -// go test ./cmd/mesh-controller/ -run 'Queue|Cancel|Clear|Retry|Rebuild|Replay|Paused' (each test on a bus of its own) - -// asksRecorded makes every ask a plan makes return the next id in a row, and says which were asked. -func asksRecorded(t *testing.T) *[]string { - t.Helper() - asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): these tests are of what a walk does around it - var asked []string - was := askABuild - askABuild = func(_ context.Context, source buildSource, path, ref string) (string, error) { - id := link.NewBuildID(time.Now().Add(time.Duration(len(asked)) * time.Millisecond)) - asked = append(asked, source.Repository+"#"+id) - return id, nil - } - t.Cleanup(func() { askABuild = was }) - return &asked -} - -// twoTiers registers two modules, b standing on a, each with a source a plan asks. -func twoTiers(t *testing.T, open *stores) { - t.Helper() - for _, name := range []string{"a", "b"} { - if err := open.inventory.RegisterModule(t.Context(), catalogue.Manifest{Module: name, Version: "1"}, - inventory.Source{Repository: "novox/" + name, Seat: "git", Ref: "main", BuiltFrom: "c0ffee", Head: "c0ffee"}); err != nil { - t.Fatal(err) - } - } -} - -// A failed plan is retried: its failed module asked again under a new id, the plan building at that -// tier, and when that build comes in the plan goes on and asks its next tier. - -// What retry refuses, and says why. -func TestRetryRefusesWhatItCannotResume(t *testing.T) { - at := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) - plan := func(id, state string, created time.Time) inventory.Plan { - return inventory.Plan{ID: id, Repository: "novox/mesh-catalog", Branch: "main", Commit: id + "c0ffee", - Created: created, State: state, Tiers: [][]string{{"a"}}, - Modules: map[string]*inventory.PlanModule{"a": {State: "failed"}}} - } - failed := plan("plan-1", inventory.PlanFailed, at) - for _, c := range []struct { - p inventory.Plan - others []inventory.Plan - says string - }{ - {plan("plan-d", inventory.PlanDone, at), nil, "is done"}, - {plan("plan-s", inventory.PlanSuperseded, at), nil, "was"}, - {plan("plan-o", inventory.PlanBuilding, at), nil, "still building"}, - {failed, []inventory.Plan{plan("plan-2", inventory.PlanRolling, at.Add(time.Hour))}, "plan-2 supersedes it"}, - } { - err := retryRefusal(c.p, c.others, ownPathOf(nil)) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v, wanted it to say %q", c.p.ID, err, c.says) - } - } - stopped := failed - stopped.Modules = map[string]*inventory.PlanModule{"a": {State: "built"}} - stopped.Note = "a stopped at its first machine" - if err := retryRefusal(stopped, nil, ownPathOf(nil)); err == nil || !strings.Contains(err.Error(), "nothing in tier 0") { - t.Errorf("a plan with nothing failed to build was retried: %v", err) - } - // A later failed plan does not supersede it; a later done one passes it (novox/hq ADR 0451). - if err := retryRefusal(failed, []inventory.Plan{plan("plan-4", inventory.PlanFailed, at.Add(time.Hour))}, ownPathOf(nil)); err != nil { - t.Errorf("refused for a plan that does not supersede it: %v", err) - } - if err := retryRefusal(failed, []inventory.Plan{plan("plan-5", inventory.PlanDone, at.Add(time.Hour))}, ownPathOf(nil)); err == nil || - !strings.Contains(err.Error(), "plan-5") { - t.Errorf("a plan a later done plan passed was retried: %v", err) - } - // Another branch's open walk, or an older one, does not supersede it, and is one walk open: one at a time - // (novox/hq ADR 0276). - other := plan("plan-3", inventory.PlanBuilding, at.Add(time.Hour)) - other.Branch = "release" - for _, open := range []inventory.Plan{other, plan("plan-0", inventory.PlanBuilding, at.Add(-time.Hour))} { - if err := retryRefusal(failed, []inventory.Plan{open}, ownPathOf(nil)); err == nil || !strings.Contains(err.Error(), "one walk at a time") { - t.Errorf("retried beside the open walk %s: %v", open.ID, err) - } - } -} - -// `rebuild` of a module a failed plan holds joins that plan; one held by nothing runs alone. - -// A plan waiting on builds of a seat whose every holder is paused says so and is not late; a seat -// paused on some holders only is not a reason the plan is waiting. -func TestAPlanWaitingOnAPausedSeatSaysSoAndIsNotLate(t *testing.T) { - now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) - asked := now.Add(-12 * time.Minute) - p := inventory.Plan{ID: "plan-p", Repository: "novox/a", Commit: "c0ffee", State: inventory.PlanBuilding, - Updated: now.Add(-2 * time.Hour), Tiers: [][]string{{"a"}}, - Modules: map[string]*inventory.PlanModule{"a": {State: "asked", AskedAt: &asked}}} - - all := pauseOf([]string{"g14", "ace"}, map[string]link.HolderState{"ace": {Paused: true}, "g14": {Paused: true}}) - line := planLineWith(p, now, all, tierAtLeast) - if !strings.Contains(line, "waiting: the build seat is paused on ace, g14 (asked 12m0s ago)") || strings.Contains(line, "LATE") { - t.Errorf("a plan on a paused seat reads %q", line) - } - if st := planStatuses([]inventory.Plan{p}, now, all, nil)[0]; st.Late || !strings.Contains(st.Waiting, "paused on ace, g14") { - t.Errorf("status --json says %+v", st) - } - if _, late := openPlans([]inventory.Plan{p}, now, all, nil); late != 0 { - t.Errorf("a plan waiting on a paused seat counted late") - } - - longAgo := now.Add(-25 * time.Hour) - forgotten := p - forgotten.Modules = map[string]*inventory.PlanModule{"a": {State: "asked", AskedAt: &longAgo}} - if line := planLineWith(forgotten, now, all, tierAtLeast); !strings.Contains(line, "PAUSED OVER A DAY") || strings.Contains(line, "LATE") { - t.Errorf("a plan paused over a day reads %q", line) - } - - some := pauseOf([]string{"g14", "ace"}, map[string]link.HolderState{"ace": {Paused: true}}) - if some.All || !reflect.DeepEqual(some.Nodes, []string{"ace"}) { - t.Fatalf("%+v", some) - } - if line := planLineWith(p, now, some, tierAtLeast); !strings.Contains(line, "LATE") { - t.Errorf("a seat paused on one holder of two made the plan not late: %q", line) - } - if st := planStatuses([]inventory.Plan{p}, now, some, nil)[0]; !st.Late { - t.Errorf("status --json: %+v", st) - } - // A plan rolling out, or with nothing asked, is not waiting on the seat. - rolling := p - rolling.State = inventory.PlanRolling - if _, paused := pausedWaiting(rolling, all, now); paused { - t.Error("a rolling plan reads as waiting on the build seat") - } -} - -// The queue's verbs are the controller seat's, each to the command it names. -func TestTheQueueVerbsRunTheirCommands(t *testing.T) { - for _, c := range []struct { - verb string - args map[string]any - want []string - }{ - {"queue", nil, []string{"queue", "--json"}}, - {"cancel", map[string]any{"id": "build-1"}, []string{"cancel", "build-1"}}, - {"clear", nil, []string{"clear"}}, - {"clear", map[string]any{"dead": "true"}, []string{"clear", "--dead"}}, - {"replay", map[string]any{"id": "build-1"}, []string{"replay", "build-1"}}, - {"kill", map[string]any{"id": "build-1"}, []string{"kill", "build-1"}}, - {"pause", nil, []string{"pause"}}, - {"resume", map[string]any{"node": "ace"}, []string{"resume", "ace"}}, - {"plans", map[string]any{"retry": "plan-1"}, []string{"plans", "retry", "plan-1"}}, - } { - got, err := argvFor(c.verb, c.args) - if err != nil || !reflect.DeepEqual(got, c.want) { - t.Errorf("%s %v: %v %v, want %v", c.verb, c.args, got, err, c.want) - } - } - if _, err := argvFor("cancel", nil); err == nil { - t.Error("cancel without an id was taken") - } - declared := map[string]bool{} - for _, v := range catalogue.ControllerVerbs { - declared[v.Name] = true - } - for _, v := range []string{"queue", "cancel", "clear", "rebuild", "replay", "kill", "pause", "resume"} { - if !declared[v] { - t.Errorf("%s is not a verb of the controller seat", v) - } - } -} - -// --- against a real bus ----------------------------------------------------------------------- - -// aBuildQueue is the build seat's queue, worker and cancelled set on a real server, the controller -// pointed at it, and a function that asks the seat one build. - -// deadOne takes the oldest ask from the worker and hands it back as often as the worker allows. -func deadOne(t *testing.T, js *broker.JetStream) { - t.Helper() - worker, _ := broker.HolderConsumerFor("", "", broker.DeclaredSeat{Name: link.TheBuildMachine, Accepts: []string{"build"}}) - sub, err := js.Context().PullSubscribe(worker.Filters[0], worker.Name, nats.Bind(worker.Stream, worker.Name), nats.ManualAck()) - if err != nil { - t.Fatal(err) - } - defer func() { _ = sub.Unsubscribe() }() - for i := 0; i < worker.MaxDeliver; i++ { - msgs, err := sub.Fetch(1, nats.MaxWait(3*time.Second)) - if err != nil { - t.Fatalf("delivery %d: %v", i+1, err) - } - _ = msgs[0].Nak() - } - // One more pull, as a holder always has one waiting: the server finds the ask past its deliveries - // then, and stops counting it pending. - if msgs, _ := sub.Fetch(1, nats.MaxWait(time.Second)); len(msgs) > 0 { - t.Fatalf("an ask past its deliveries was delivered again") - } -} - -// cancel drops a waiting ask from the bus and records it failed, cancelled by hand — and the plan -// that asked for it, matched by the id, fails with it; an ask the plan's records name no module for -// is still found. - -// clear cancels every waiting ask and leaves the dead ones unless told, and never one in flight. - -// An ask in flight is not cancelled: kill ends it where it runs. - -// kill finds the machine from the build's start and asks that machine's holder; pause asks the -// machine named. Each prints what the holder answered, and a refusal is the command's failure. - -// A plan that stopped at its first machine is retried: the module sent to that machine again, the -// send recorded as the first anew, and the plan goes on — unless a newer plan holds the module. - -// A plan module asked under an id is settled by that id's outcome alone: a replay or a rebuild beside -// the plan, asked later, never answers it (novox/hq ADR 0219). - -// rebuild of a build made at a commit asks what the module follows now, never the commit. - -// An ask the worker counts out that no machine said it started is cancelled only if no start comes -// while a holder looks: one that does withdraws the cancel, and kill is what ends it. diff --git a/cmd/mesh-controller/readable.go b/cmd/mesh-controller/readable.go index 30a32f0d..3d037c68 100644 --- a/cmd/mesh-controller/readable.go +++ b/cmd/mesh-controller/readable.go @@ -252,7 +252,7 @@ func statusAsJSON(asked answers) ([]byte, error) { out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{}, Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{}, Reported: []machineReported{}, Unresolved: []machineUnresolved{}, - Network: asked.network, Adopted: adoptedNodes(nodes), Plans: planStatuses(asked.plans, time.Now(), asked.paused, asked.tierBounds), + Network: asked.network, Adopted: adoptedNodes(nodes), Plans: planStatuses(asked.plans, time.Now(), asked.tierBounds), NotAnnounced: asked.notAnnounced, NotAnnouncedWhy: asked.notAnnouncedWhy} // In a stated order, so two readings of an unchanged mesh are the same document. untakenNodes := make([]string, 0, len(asked.untaken)) diff --git a/cmd/mesh-controller/references.go b/cmd/mesh-controller/references.go index c313d7e2..0486964b 100644 --- a/cmd/mesh-controller/references.go +++ b/cmd/mesh-controller/references.go @@ -18,23 +18,6 @@ import ( // given. Nothing recorded carries a port, so moving the store is a settings change and not a // rebuild of everything the mesh has ever built. -// recordedManifest is a build's manifest with the store's address taken off every reference the -// build itself made. Other references — an image a module runs from a public registry — are what -// they were, which is why this rewrites only what `made` names rather than everything that looks -// like an address. -func recordedManifest(raw json.RawMessage, made []inventory.Artifact) json.RawMessage { - announced := map[string]bool{} - for _, a := range made { - announced[a.Reference] = true - } - return withReferences(raw, func(ref string) (string, bool) { - if !announced[ref] { - return ref, false - } - return catalogue.Recorded(ref), true - }) -} - // routedManifest is a recorded manifest with the store's address, as this network reaches it now, // composed into every reference the build made — recorded either way, before or after references // were kept without their address. @@ -118,33 +101,3 @@ func whereTheStoreIs(ctx context.Context, inv *inventory.Inventory, forNode stri } return artifactStoreAddress(ctx, inv, shelf, forNode) } - -// whereABuilderReachesTheStore is the store's address for the machine that builds: the network's -// when there is one, else loopback on the store's own node — when that node also holds a module -// requiring the store, which is what a builder is (genesis: one node holds both). -func whereABuilderReachesTheStore(ctx context.Context, inv *inventory.Inventory) (string, error) { - shelf, err := inv.Catalogue(ctx) - if err != nil { - return "", err - } - holder, _, found, err := artifactStoreHolder(ctx, inv) - if err != nil || !found { - return "", err - } - assigned, err := inv.Assigned(ctx, holder) - if err != nil { - return "", err - } - besideIt := false - for _, a := range assigned { - for _, r := range shelf[a].Requires { - if r == catalogue.ArtifactStoreProvision { - besideIt = true - } - } - } - if !besideIt { - holder = "" - } - return artifactStoreAddress(ctx, inv, shelf, holder) -} diff --git a/cmd/mesh-controller/release_plan.go b/cmd/mesh-controller/release_plan.go index 54336966..fa254d87 100644 --- a/cmd/mesh-controller/release_plan.go +++ b/cmd/mesh-controller/release_plan.go @@ -6,7 +6,6 @@ import ( "errors" "flag" "fmt" - sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" "os" "slices" "sort" @@ -17,7 +16,6 @@ import ( "github.com/nats-io/nats.go" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/builder" "github.com/novox/mesh-controller/internal/chain" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" @@ -381,147 +379,6 @@ func askedInTier(p inventory.Plan) bool { return false } -// takesOutcome says whether a build's outcome is the plan's for a module of its current tier (novox/hq ADR 0219, -// issues 219 and 754). **A walk that waits for its delivery's word asks nothing, and so is answered by nothing** -// (ADR 0239): a rebuild that lands in the wait is somebody else's build. **Nor is a module the plan never asked -// answered**: taken, the plan held a module built that it never asked, and waited for the rest of the tier for -// ever. Asked under an id, the plan takes that id's outcome and no other; asked before ids were kept, a build -// asked at or after the plan's ask. -func takesOutcome(p inventory.Plan, state *inventory.PlanModule, asked time.Time, id string) bool { - if p.Waiting() || state == nil || (state.Build == "" && state.AskedAt == nil) { - return false - } - // **A module taken from the builder's key is answered by its key alone** (ADR 0501 §1): a build-agent outcome of it - // is somebody else's build. - if state.Key { - return false - } - if state.Build != "" { - return state.Build == id - } - return !askedBefore(asked, state.AskedAt) -} - -// askABuild is how a plan asks for one build, not waited for, and learns the id it asked under. A -// variable so a test of what a plan does around an ask needs no build machine. -// -// Set in init, not where it is declared: an assignment pending on a build asks for one too (novox/hq issue -// 325), and a build's outcome makes pending assignments, so the two refer to each other. -var askABuild func(ctx context.Context, source buildSource, path, ref string) (string, error) - -func init() { - askABuild = func(ctx context.Context, source buildSource, path, ref string) (string, error) { - // **Only the builder's own module is asked of build-agent** (novox/hq ADR 0453 §5, ADR 0498 §7; issue 1851, - // A12): refused here, in the one function every ask goes through, whoever calls it. - if err := onlyTheBuildersOwn(ctx, source, path); err != nil { - return "", err - } - // Kept by each asker with its own name once the ask is made (novox/hq issue 325). - return buildOneAsked(ctx, source, path, ref, 0, false, "") - } -} - -// errNotTheBuildersOwn says an ask of build-agent was refused: from R4e build-agent builds the builder's own module -// alone, and every other is taken from the builder's done key. -var errNotTheBuildersOwn = errors.New("build-agent is asked for the builder's own module alone (ADR 0453 §5)") - -// askedModuleOf is the module the catalogue builds from a source and path, empty when it builds none from it. A variable -// so a test needs no store. -var askedModuleOf = func(ctx context.Context, source buildSource, path string) (string, error) { - open, err := openStores(ctx) - if err != nil { - return "", err - } - defer open.Close() - entries, err := open.inventory.Catalogued(ctx) - if err != nil { - return "", err - } - forms := newSourceForms(ctx, open.inventory) - for _, e := range entries { - if forms.same(e.Source.Repository, e.Source.Seat, source.Repository, source.Seat) && - strings.Trim(e.Source.Path, "/") == strings.Trim(path, "/") { - return e.Manifest.Module, nil - } - } - return "", nil -} - -// onlyTheBuildersOwn refuses an ask of any module but the builder's own, naming the key it is taken from instead. -func onlyTheBuildersOwn(ctx context.Context, source buildSource, path string) error { - module, err := askedModuleOf(ctx, source, path) - if err != nil { - return fmt.Errorf("%w: which module %s at %q is cannot be read, so nothing is asked: %v", errNotTheBuildersOwn, - source, orRoot(path), err) - } - if isBuildersOwn(module) { - return nil - } - // **While no running builder says it writes references, build-agent builds every module** (issue 1886): judged by the - // say the walk read when it has one, read here only for an ask that carries none. - says, read := builderSayOf(ctx) - if !read { - var err error - if says, err = builderSaysReferences(ctx); err != nil { - says = true // not known: refused below, as before the say existed - } - } - if !says { - return nil - } - if module == "" { - module = fmt.Sprintf("%s at %q", source, orRoot(path)) - } - return fmt.Errorf("%w: %s is taken from the builder's done key of its fingerprint in %s (ADR 0501), and no build "+ - "of it is asked", errNotTheBuildersOwn, module, sdkdelivery.BuilderBuildsBucket) -} - -// pullsOfWalk are the pull requests of the merges a walk answers, each once: what its end lets go of (forgetChecksOfWalk) -// and what a check of one was (same_program.go). -func pullsOfWalk(p *inventory.Plan) []link.PullRef { - if p.Delivery == nil { - return nil - } - var out []link.PullRef - seen := map[link.PullRef]bool{} - for _, m := range p.Delivery.Merges { - pull, ok := pullOf(m) - if !ok { - continue - } - if !seen[pull] { - seen[pull] = true - out = append(out, pull) - } - } - return out -} - -// checkedBuildsOf are the builds the build seat's checks of those pull requests said they made for their merges, -// within the week check builds are kept (novox/hq ADR 0350 §2): read from the seat's own outcomes on the bus. A -// variable so a test needs no bus. -var checkedBuildsOf = func(ctx context.Context, pulls []link.PullRef) ([]link.CheckedBuild, error) { - if len(pulls) == 0 { - return nil, nil - } - js, err := aBus() - if err != nil { - return nil, err - } - defer js.Close() - return link.ReadCheckBuilds(ctx, js, buildSeatHeld(ctx), pulls, time.Now().Add(-builder.CheckBuildsKept)) -} - -// pullOf is a merge's pull request: owner and repository, the last two parts of however the repository is spelled -// (owner/repo, a path on the git seat, a URL), and its number. False when the merge names none. -func pullOf(m inventory.PlanMerge) (link.PullRef, bool) { - parts := strings.Split(strings.Trim(strings.TrimSuffix(m.Repository, ".git"), "/"), "/") - if len(parts) < 2 || m.Number <= 0 || parts[len(parts)-2] == "" || parts[len(parts)-1] == "" { - return link.PullRef{}, false - } - return link.PullRef{Owner: parts[len(parts)-2], Repo: parts[len(parts)-1], Number: m.Number}, true -} - // askModule asks the build machine for one module of a plan and marks it asked, with the id it was // asked under (novox/hq ADR 0219) — or failed, with the plan, when it could not be asked. func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[string]inventory.Entry) { @@ -539,108 +396,9 @@ func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[s p.Note = name + " is no longer in the catalogue" return } - // **Every module but the builder's own is taken from the builder's done key, asking no build** (novox/hq ADR 0503 - // §6, ADR 0501, ADR 0453 §5): the look settles it (settleFromKeys). - // **Only while a running builder says it writes references** (issue 1886): until then the keys it writes could never - // be delivered from, and the module is asked of build-agent as before R4e. A say not read leaves it unasked, said, - // for the next look. - if !isBuildersOwn(name) { - says, err := builderSaysReferences(ctx) - if err != nil { - state.Why = "waits: whether the builder writes references cannot be read: " + firstLine(err.Error()) - return - } - if says { - sayBuilding(ctx, p.ID, now) - takeFromKey(p, state, e, now) - return - } - // The ask's guard judges by this say, never by a second read that could fail or differ (review of issue 1886). - ctx = withBuilderSay(ctx, says) - } - source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat} - fmt.Printf(" tier %d: ", p.Tier) - // The branch it follows, never a commit a build once named (novox/hq 04-ISSUES/215): the branch contains every - // commit a batch's walk carries — but for a merge walked alone after a failed walk (novox/hq ADR 0276 - // decision 3), built on its own commit to find which merge brought the failure. - ref := followedBranch(e.Source.Ref) - carried := p.CommitOn(e.Source.Repository, ref) - if p.Delivery != nil && p.Delivery.Alone && carried != "" { - ref = carried - } - // **The plan's key is there before its first build is** (ADR 0483 clause 2): a check taken between the ask and the plan's save - // would hold a place for its whole run. - sayBuilding(ctx, p.ID, now) - id, err := askABuild(ctx, source, e.Source.Path, ref) - if err != nil { - // **A bus that does not answer for a moment is not a failed build** (issue 1334): the module stays unasked and the - // tier's next look asks it again. A fault of the module's own fails the plan at once, as does a bus silent too long. - reason := err.Error() - if busDidNotAnswer(err) { - if waitForTheBus(p, state, name, err, now) { - return - } - reason = fmt.Sprintf("the bus did not answer an ask for %s (since %s): %s", busWaitBound, - state.BusWaitFrom.UTC().Format(time.RFC3339), firstLine(err.Error())) - } - // The wait has ended: a Retry or a rebuild waits for the bus afresh. - state.BusWaitFrom = nil - state.State = "failed" - state.Why = reason - p.State = inventory.PlanFailed - p.Note = fmt.Sprintf("%s could not be asked for: %s", name, reason) - return - } - state.BusWaitFrom = nil - // The commit of the module's own repository the walk carries (novox/hq ADR 0276): a batch's walk carries - // one per repository. - commit := carried - if commit == "" { - commit = p.Commit - } - recordAsked(ctx, inventory.BuildRequest{ID: id, Repository: e.Source.Repository, Seat: e.Source.Seat, - Path: e.Source.Path, Ref: followedBranch(e.Source.Ref), Commit: commit, For: "plan"}) - state.State = "asked" - state.AskedAt = &now - state.Build = id - state.Why, state.Commit, state.BuiltAt, state.Made = "", "", nil, "" -} - -// planBuilt marks a module built (or failed) in every open plan whose current tier holds it, and -// advances what that completes. Called from the daemon's take-in of every outcome. -// -// **Only a build asked at or after the plan's ask is its outcome** (novox/hq 04-ISSUES/219). Two -// plans a few minutes apart both ask for a module; the earlier plan's build, finishing late, is not -// the later plan's answer — it stood on the bases from before the later plan's merge, and taking it -// would send machines, and the next tier, what the later merge replaced. asked is zero when the -// build's request time is not known, and such an outcome is taken as before. -func planBuilt(ctx context.Context, open *stores, module, commit, failed string, asked time.Time, id string) { - planBuiltSaying(ctx, open, module, commit, failed, asked, id, "") -} - -// holdPlansForOutcome is how an outcome taken on the event loop holds the plans: **without waiting for them** -// (novox/hq issue 1708). The loop takes one message at a time, and the plans' holder may keep them for minutes while -// it sends machines their declarations; an outcome that waited its turn (up to inventory.HoldWaitFor, two minutes) -// held every report, build and merge behind it for as long, and the watchdog said the controller stopped listening -// (2026-10-21 14:20:45 to 14:22:45). Busy, the outcome is left to the build records, where it already is, and the -// look settles the plan from them (leaveToRecords). -func holdPlansForOutcome(ctx context.Context, hold func(context.Context, bool) (func(), error)) (func(), error) { - return hold(ctx, false) -} - -// leaveToRecords says an outcome was not taken into the plans, which settle it from the build records (novox/hq issue -// 214). When another act holds the plans it asks for the look, which waits for them off the event loop and settles -// the plan from the records once they are let go (issue 1708); what the build did is kept for that settle by the -// build's id, so the walk's module says it as it would have (ADR 0350 §4). -func leaveToRecords(module, id, made, failed string, err error) { - fmt.Printf("plans: %s's outcome is left to the build records: %v\n", module, err) - if !errors.Is(err, inventory.ErrPlansBusy) { - return - } - keepMadeFor(id, made, time.Now()) - // A refused outcome (failed, its build recorded as worked) is kept as refused, or the records settle it built. - keepRefusalFor(id, failed, time.Now()) - askToLook() + // **Every module is taken from the builder's done key, asking no build** (novox/hq ADR 0503 §6, ADR 0501, ADR 0453 §5; + // issue 1851): the look settles it (settleFromKeys). A key not done yet waits, named in the plan, and never fails it. + takeFromKey(p, state, e, now) } // refusalsKeptFor is the refusals kept in this process for these builds (keepRefusalFor), by id. @@ -693,22 +451,6 @@ type madeKept struct { // madeLeftFor is how long words nobody settled are kept: a plan that ended or moved on never asks for them. const madeLeftFor = time.Hour -// keepMadeFor keeps what a build left to the records did for the settle that takes it, forgetting words older than -// madeLeftFor. -func keepMadeFor(id, made string, now time.Time) { - if id == "" || made == "" { - return - } - madeLeftMu.Lock() - defer madeLeftMu.Unlock() - for k, v := range madeLeft { - if now.Sub(v.at) > madeLeftFor { - delete(madeLeft, k) - } - } - madeLeft[id] = madeKept{made: made, at: now} -} - // takeMadeOf is what the build of this id did, if its outcome was left to the records with words; taken once. func takeMadeOf(id string) string { madeLeftMu.Lock() @@ -721,97 +463,6 @@ func takeMadeOf(id string) string { return k.made } -// planBuiltSaying is planBuilt keeping what the build did — "reused from " or "built: " (novox/hq -// ADR 0350 §4) — on the module of the walk it answers. -func planBuiltSaying(ctx context.Context, open *stores, module, commit, failed string, asked time.Time, id, made string) { - inv := open.inventory - // One controller works the plans at a time (novox/hq issue 213). Not taken, the outcome is still in the build - // records, which the holder settles the plan from (issue 214). - release, err := holdPlansForOutcome(ctx, inv.HoldPlans) - if err != nil { - if failed != "" && id != "" && errors.Is(err, inventory.ErrPlansBusy) { - // In the store too, so a controller replaced before the look settles still fails the plan (issue 1708). - if err := inv.RecordRefused(ctx, id, module, failed); err != nil { - fmt.Printf("plans: %s's refusal could not be kept in the store, only in this controller: %v\n", module, err) - } - } - leaveToRecords(module, id, made, failed, err) - return - } - defer release() - plans, err := inv.OpenPlans(ctx) - if err != nil { - fmt.Printf("plans: cannot read them: %v\n", err) - return - } - now := time.Now().UTC() - for i := range plans { - p := &plans[i] - if p.Tier >= len(p.Tiers) { - continue - } - inTier := false - for _, m := range p.Tiers[p.Tier] { - if m == module { - inTier = true - } - } - if !inTier { - continue - } - // **The plan's own ask is its outcome, by id** (novox/hq ADR 0219); another build of the module - // is, as before, when it was asked at or after the plan's ask (issue 219). - // **A module asked under an id is answered by that id's outcome and no other** (novox/hq ADR - // 0219): a replay, a rebuild beside the plan, or an older ask finishing late is somebody else's - // build, made from other source, and settling the plan with it would send that. A plan from - // before ids were kept is matched as it was: by when the build was asked (issue 219). A walk - // waiting for its word, or a module it never asked, takes no outcome at all (issue 754). - state := p.Modules[module] - if !takesOutcome(*p, state, asked, id) { - continue - } - if failed != "" && deletedAtSource(failed) { - // Deleted at its source by the merge, not broken (novox/hq ADR 0236): the plan goes on. - state.State, state.Why = planDeleted, "deleted at its source: "+firstLine(failed) - forgetDeleted(ctx, inv, module, p) - } else if failed != "" { - state.State = "failed" - state.Why = failed - p.State = inventory.PlanFailed - p.Note = fmt.Sprintf("%s failed to build in tier %d", module, p.Tier) - sayUnsent(p, func(m string) bool { - u, err := inv.UpgradeOf(ctx, m) - return err == nil && u.RollOut - }) - } else { - state.State = "built" - state.BuiltAt = &now - state.Commit = commit - state.Made = made - } - if err := inv.SavePlan(ctx, p); err != nil { - fmt.Printf("%s: cannot keep the plan: %v\n", p.ID, err) - continue - } - if p.State == inventory.PlanFailed { - fmt.Printf("%s: %s; the tiers after it are not asked\n", p.ID, p.Note) - } - } - advanceAfterOutcome(func() { advanceHeld(ctx, open) }) -} - -// advanceAfterOutcome moves the plans after an outcome was taken into them: **in the serving controller, by asking -// the look** (novox/hq issue 1708). The outcome is taken on the event loop, and moving the plans may send machines -// their declarations, a release's send retried for minutes among them; done here it held every report, build and -// merge behind it (2026-10-21 14:28:40 to 14:32:10). The look runs off the loop, holding the plans itself. Where no -// look runs (a command, which has no event loop) the plans are moved at once, as before. -func advanceAfterOutcome(advance func()) { - if askedToLook() { - return - } - advance() -} - // startedBeside is the id of a started walk open beside this one that it is related to — a merge's walk past // its wait, not the backlog's — with why, or empty: a walk waits only for a started walk it shares something with // (novox/hq ADR 0276 decision 4, ADR 0407 decision 4, issue 973). @@ -2001,7 +1652,7 @@ func sayUnsent(p *inventory.Plan, rollsOut func(string) bool) { // planLine is one plan as `status` says it, given the least bound of a tier. func planLine(p inventory.Plan, now time.Time) string { - return planLineWith(p, now, pauseView{}, tierAtLeast) + return planLineWith(p, now, tierAtLeast) } // inTierSince is when a plan began waiting on what it waits on now, which `plans`, `status` and the @@ -2025,23 +1676,22 @@ func inTierSince(p inventory.Plan) time.Time { return since } -// planLineWith is planLine knowing whether the build seat is paused (novox/hq ADR 0219): a plan -// waiting on builds nobody will take until a person resumes the seat says so, and is not late. bound is +// planLineWith is planLine with bound, the plan's tier bound: bound is // the plan's tier bound, the one its stalled condition is raised at (tierBounds): LATE is that condition // said on the line (novox/hq issue 296). The machines running what it moves are not named: planLineOn. -func planLineWith(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration) string { - return planLineOn(p, now, pause, bound, nil) +func planLineWith(p inventory.Plan, now time.Time, bound time.Duration) string { + return planLineOn(p, now, bound, nil) } // planLineOn is planLineWith naming the plan by what it moves and where (planHeadline), given what runs each // module; nil names no machine. -func planLineOn(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration, running func(string) []string) string { - return planLineSaid(p, now, pause, bound, running, nil) +func planLineOn(p inventory.Plan, now time.Time, bound time.Duration, running func(string) []string) string { + return planLineSaid(p, now, bound, running, nil) } // planLineSaid is planLineOn saying, of a walk waiting for its delivery's word, what the delivery's owner says // holds it (novox/hq issue 529); nil waits says it as before. -func planLineSaid(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration, running func(string) []string, +func planLineSaid(p inventory.Plan, now time.Time, bound time.Duration, running func(string) []string, waits walkWaits) string { name := planHeadline(p, running) where := fmt.Sprintf("tier %d of %d", min(p.Tier+1, len(p.Tiers)), len(p.Tiers)) @@ -2068,9 +1718,6 @@ func planLineSaid(p inventory.Plan, now time.Time, pause pauseView, bound time.D if waitingOnBackups(p) { return fmt.Sprintf("%s · %s, %s, for %s", name, where, p.Note, since) } - if waiting, paused := pausedWaiting(p, pause, now); paused { - return fmt.Sprintf("%s · %s, %s", name, where, waiting) - } late := "" if since > bound { late = " — LATE" @@ -2141,51 +1788,6 @@ func planHeadline(p inventory.Plan, running func(string) []string) string { return out } -// planFailedBuild marks the module a failed build was for when the result names no module: by the -// repository and path the plan's modules were asked at. -// -// **By the id first** (novox/hq ADR 0219): a plan keeps the id it asked each module under, so an -// outcome that never learnt its module's name — cancelled, killed, failed at the clone — is matched -// to the module it was asked for exactly. Repository and path remain for a plan from before ids -// were kept. -func planFailedBuild(ctx context.Context, open *stores, result link.BuildResult) { - asked, _ := link.BuildAskedAt(result.ID) - if plans, err := open.inventory.OpenPlans(ctx); err == nil { - if module := moduleAskedAs(plans, result.ID); module != "" { - planBuilt(ctx, open, module, result.Commit, result.Failed, asked, result.ID) - return - } - } - entries, err := open.inventory.Catalogued(ctx) - if err != nil { - return - } - for _, e := range entries { - if repositoryMatches(e.Source.Repository, result.Repository) && e.Source.Path == result.Path { - planBuilt(ctx, open, e.Manifest.Module, result.Commit, result.Failed, asked, result.ID) - return - } - } -} - -// moduleAskedAs is the module an open plan's current tier asked for under this id, or nothing. -func moduleAskedAs(plans []inventory.Plan, id string) string { - if id == "" { - return "" - } - for _, p := range plans { - if p.Tier >= len(p.Tiers) { - continue - } - for _, m := range p.Tiers[p.Tier] { - if s := p.Modules[m]; s != nil && s.Build == id { - return m - } - } - } - return "" -} - func repositoryMatches(a, b string) bool { trim := func(s string) string { return strings.ToLower(strings.TrimSuffix(s, ".git")) } return trim(a) == trim(b) || strings.HasSuffix(trim(a), "/"+trim(b)) || strings.HasSuffix(trim(b), "/"+trim(a)) @@ -2204,7 +1806,7 @@ type planStatus struct { Late bool `json:"late"` } -func planStatuses(plans []inventory.Plan, now time.Time, pause pauseView, bounds tierBounds) []planStatus { +func planStatuses(plans []inventory.Plan, now time.Time, bounds tierBounds) []planStatus { out := make([]planStatus, 0, len(plans)) for _, p := range plans { ps := planStatus{ID: p.ID, Repository: p.Repository, Commit: p.Commit, State: p.State, @@ -2217,10 +1819,6 @@ func planStatuses(plans []inventory.Plan, now time.Time, pause pauseView, bounds } // A walk waiting for its delivery's word is no tier late (ADR 0239): S16 is its watchdog. ps.Late = !p.Waiting() && now.Sub(ps.Since) > bounds.of(p.Repository) - // Paused is a person's decision, not lateness (novox/hq ADR 0219). - if waiting, paused := pausedWaiting(p, pause, now); paused { - ps.Waiting, ps.Late = waiting, false - } } out = append(out, ps) } @@ -2228,13 +1826,13 @@ func planStatuses(plans []inventory.Plan, now time.Time, pause pauseView, bounds } // openPlans is the open plans among the recent ones, and how many have waited past the bound. -func openPlans(plans []inventory.Plan, now time.Time, pause pauseView, bounds tierBounds) ([]inventory.Plan, int) { +func openPlans(plans []inventory.Plan, now time.Time, bounds tierBounds) ([]inventory.Plan, int) { var open []inventory.Plan late := 0 for _, p := range plans { if p.Open() { open = append(open, p) - if _, paused := pausedWaiting(p, pause, now); paused || p.Waiting() { + if p.Waiting() { continue } if now.Sub(inTierSince(p)) > bounds.of(p.Repository) { @@ -2293,7 +1891,7 @@ func plansCommand(ctx context.Context, args []string) error { return err } bounds := readTierBounds(ctx, inv, now) - fmt.Printf("%s — %s\n", p.ID, planLineSaid(p, now, buildSeatPause(ctx, inv, []inventory.Plan{p}), + fmt.Printf("%s — %s\n", p.ID, planLineSaid(p, now, bounds.of(p.Repository), func(module string) []string { on, _ := inv.Running(ctx, module) return on @@ -2485,7 +2083,6 @@ func plansCommand(ctx context.Context, args []string) error { fmt.Printf("%-28s %s\n", "", line) } } - pause := buildSeatPause(ctx, inv, plans) bounds := readTierBounds(ctx, inv, now) running := func(module string) []string { on, _ := inv.Running(ctx, module) @@ -2497,7 +2094,7 @@ func plansCommand(ctx context.Context, args []string) error { if p.Batch() { continue } - fmt.Printf("%-28s %s%s\n", p.ID, planLineSaid(p, now, pause, bounds.of(p.Repository), running, waits), heldSaid(p, held)) + fmt.Printf("%-28s %s%s\n", p.ID, planLineSaid(p, now, bounds.of(p.Repository), running, waits), heldSaid(p, held)) } return nil } diff --git a/cmd/mesh-controller/replay867_test.go b/cmd/mesh-controller/replay867_test.go deleted file mode 100644 index 0ed17dbc..00000000 --- a/cmd/mesh-controller/replay867_test.go +++ /dev/null @@ -1,330 +0,0 @@ -//go:build integration - -package main - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "net" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/nats-io/nats.go" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - meshfacts "git.novox.be/novox/mesh-sdk/go/facts" - "github.com/novox/mesh-controller/internal/builder" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// TestReplay867 is the replay of novox/hq issue 867, written with only what the controller and the build seat had -// before the fix, so mesh-lab's prover can lay it over the commit before. A mesh-host change (#114) and a -// mesh-controller change (#346) of one delivery group: the node-engine change's own check reads the controller's lock -// beside it, which only the controller change brings. It was read beside the other's repository at the commit the mesh -// runs, which holds neither change, so the node-engine change could not pass its own merge check. (The controller -// change once needed the node-engine's validator word too; the validator is mesh-sdk's now, issue 1391.) -// -// It runs the whole path: the forge announces each pull request (following.PullUpdated), the controller asks -// mesh-delivery which pull requests the group holds, the ask is queued for the build seat, and the build seat's check -// (builder.Check, with a container runtime that runs each container's command on this machine) judges it. - -// TestReplay867Recheck is the same pair re-checked through mesh-delivery (`delivery check` with one member): the forge's -// announcement is not repeated, so the pull request reaches the controller without its head branch name. - -func replay867(t *testing.T, recheck bool) { - if _, err := exec.LookPath("go"); err != nil { - t.Skip("no Go toolchain on this machine to build a judge with") - } - // The check's floor of free space (novox/hq issue 1834) reads the disk the test runs on, which is none of this - // replay's business: a build node under the floor failed every mesh-controller check here (issue 1834, note 12). - wasFree := builder.FreeBytes - builder.FreeBytes = func(string) (uint64, error) { return 500 << 30, nil } - t.Cleanup(func() { builder.FreeBytes = wasFree }) - r := theGroupOfIssue867(t) - for _, c := range []struct { - what, repo string - number int - head string - module string - }{{"the node-engine change", "mesh-host", 114, r.hostHead, "mesh-host"}, - {"the controller change", "mesh-controller", 346, r.controllerHead, "mesh-controller"}} { - v := r.checked(t, c.repo, c.number, c.head, recheck) - if v.Gate == nil || v.Gate.Verdict != "pass" || v.Repo == nil || v.Repo.Verdict != "pass" { - t.Errorf("%s (%s#%d) did not pass its own merge check beside its group: gate %+v, repository %+v\n%s", - c.what, c.repo, c.number, v.Gate, v.Repo, v.Report) - } - } -} - -// theIssue867 is the two repositories as the trunk holds them and each pull request's head. -type theIssue867 struct { - open *stores - hostHead, controllerHead string - js interface{ Conn() *nats.Conn } - judgeCache string - registry string - hostRepo, controllerRepo string - groups string // what mesh-delivery's groups answers -} - -func r867Git(t *testing.T, dir string, args ...string) string { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.org", - "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.org") - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v: %v\n%s", args, err, out) - } - return strings.TrimSpace(string(out)) -} - -func r867Commit(t *testing.T, dir string, files map[string]string) string { - t.Helper() - for name, body := range files { - if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o755); err != nil { - t.Fatal(err) - } - } - r867Git(t, dir, "add", "-A") - r867Git(t, dir, "commit", "--quiet", "-m", "x") - return r867Git(t, dir, "rev-parse", "HEAD") -} - -// r867Controller is a controller whose judge says who it is and the validator word it was built with. -func r867Controller(who, extra string) map[string]string { - return map[string]string{ - "go.mod": "module example.org/judge\n\ngo 1.22\n\nrequire git.novox.be/novox/mesh-sdk/go v0.0.0\n", - "vendor/modules.txt": "# git.novox.be/novox/mesh-sdk/go v0.0.0\n## explicit; go 1.22\ngit.novox.be/novox/mesh-sdk/go/validate\n", - "vendor/git.novox.be/novox/mesh-sdk/go/validate/v.go": "package validate\n\nconst Word = \"the vendored validator\"\n", - "cmd/mesh-controller/main.go": `package main - -import ( - "fmt" - "os" - - "git.novox.be/novox/mesh-sdk/go/validate" -) - -func main() { - if len(os.Args) == 2 && os.Args[1] == "merge-gate" { - fmt.Println("usage: merge-gate --facts --store ") - os.Exit(2) - } - fmt.Printf("{\"verdict\":\"pass\",\"summary\":\"%s with %s\"}\n", "` + who + `", validate.Word` + extra + `) -} -`, - } -} - -func theGroupOfIssue867(t *testing.T) *theIssue867 { return theGroupOfIssue867Into(t, "main") } - -// theGroupOfIssue867Into is the group with both pull requests into base: main, or a release branch both repositories -// hold, cut from their trunks. -func theGroupOfIssue867Into(t *testing.T, base string) *theIssue867 { - t.Helper() - open := aMesh(t) - r := &theIssue867{open: open, judgeCache: t.TempDir()} - root := filepath.Join(t.TempDir(), "novox") - controller, host := filepath.Join(root, "mesh-controller"), filepath.Join(root, "mesh-host") - r.hostRepo, r.controllerRepo = host, controller - for _, d := range []string{controller, host} { - if err := os.MkdirAll(d, 0o755); err != nil { - t.Fatal(err) - } - r867Git(t, d, "init", "--quiet", "-b", "main") - } - // The trunks. - files := r867Controller("the controller", "") - files["lock.txt"] = "foundation: old\n" - files["module.json"] = `{"module":"mesh-controller"}` - files[builder.CheckScript] = "true\n" - r867Commit(t, controller, files) - r867Commit(t, host, map[string]string{"validate/v.go": "package validate\n\nconst Word = \"the trunk's validator\"\n", - "internal/declaration/d.go": "package declaration\n", "module.json": `{"module":"mesh-host"}`, - builder.CheckScript: "set -e\ngrep -q 'foundation: new' \"$MESH_CHECK_BESIDE/mesh-controller/lock.txt\"\n"}) - if base != "main" { - for _, d := range []string{controller, host} { - r867Git(t, d, "branch", base, "main") - } - } - // The two pull requests of one group, feat/867. - r867Git(t, controller, "checkout", "--quiet", "-b", "feat/867", base) - change := r867Controller("the controller", "") - change["lock.txt"] = "foundation: new\n" - r.controllerHead = r867Commit(t, controller, change) - r867Git(t, controller, "checkout", "--quiet", "main") - r867Git(t, host, "checkout", "--quiet", "-b", "feat/867", base) - r.hostHead = r867Commit(t, host, map[string]string{"notes.txt": "the node-engine change\n"}) - r867Git(t, host, "checkout", "--quiet", "main") - - followed := func(string) string { return "main" } - for _, m := range []struct{ module, dir string }{{"mesh-controller", controller}, {"mesh-host", host}} { - // The mesh runs each at its trunk's head, the commit a check reads it at beside another repository's change. - running := r867Git(t, m.dir, "rev-parse", "main") - if err := open.inventory.RegisterModule(t.Context(), catalogue.Manifest{Module: m.module, Version: "1"}, - inventory.Source{Repository: m.dir, Ref: followed(m.module), BuiltFrom: running, Head: running}); err != nil { - t.Fatal(err) - } - } - if base != "main" { - // A module built from the controller's repository follows the release branch, as the mesh runs it there: a pull - // request into the release branch moves it, so the composed check's gate runs. - running := r867Git(t, controller, "rev-parse", "main") - if err := open.inventory.RegisterModule(t.Context(), catalogue.Manifest{Module: "zz-agent", Version: "1"}, - inventory.Source{Repository: controller, Ref: base, BuiltFrom: running, Head: running}); err != nil { - t.Fatal(err) - } - } - holdTheDeliverySeat(t, open) - - // mesh-delivery says the group: two open pull requests into main, each with its head. - was := askDeliveryOwner - t.Cleanup(func() { askDeliveryOwner = was }) - askDeliveryOwner = func(_ context.Context, _ *nats.Conn, verb string, _ map[string]any) (json.RawMessage, error) { - if verb != "groups" { - t.Fatalf("asked the owner %s", verb) - } - return json.RawMessage(r.groups), nil - } - r.groups = (`[{"id":"feat/867","members":["` + root + `/mesh-host@` + r.hostHead[:12] + ` (checked)","` + - root + `/mesh-controller@` + r.controllerHead[:12] + ` (checked)"],"member_details":[` + - `{"id":"novox/mesh-host@` + r.hostHead[:12] + `","repository":"novox/mesh-host","state":"checked","number":114,"head":"` + - r.hostHead + `","base":"` + base + `","open":true},` + - `{"id":"novox/mesh-controller@` + r.controllerHead[:12] + `","repository":"novox/mesh-controller","state":"checked","number":346,"head":"` + - r.controllerHead + `","base":"` + base + `","open":true}]}]`) - r.registry = r867Registry(t) - r867Runtime(t) - return r -} - -// checked is a pull request announced by the forge, asked of the build seat by the controller, and checked there. -func (r *theIssue867) checked(t *testing.T, repo string, number int, head string, recheck bool) builderVerdict { - t.Helper() - js, ask := aBuildQueue(t) - _ = ask - servingBus.Store(js) - t.Cleanup(func() { servingBus.Store(nil) }) - p := link.PullUpdated{Owner: "novox", Repo: repo, Number: number, Base: "main", Head: "feat/867", Commit: head, - Paths: []string{"lock.txt", "notes.txt", "cmd/mesh-controller/main.go"}, ModuleDirsSaid: true, - MergeCheck: true, MergeCheckSaid: true} - if recheck { - // As `delivery check --members ` builds it from mesh-delivery's member: no head branch name. - p = orderMember{ID: "novox/" + repo + "@" + head[:12], Repository: "novox/" + repo, Base: "main", Head: head, - Number: number, Paths: p.Paths, ModuleDirsSaid: true}.pull() - p.MergeCheck, p.MergeCheckSaid = true, true - } - if err := (following{open: r.open, oneShot: true}).PullUpdated(t.Context(), p); err != nil { - t.Fatal(err) - } - q, err := link.ReadQueue(t.Context(), js, link.TheBuildMachine) - if err != nil { - t.Fatal(err) - } - if len(q.Asks) == 0 { - t.Fatalf("%s#%d: nothing was asked of the build seat", repo, number) - } - request := q.Asks[len(q.Asks)-1].Request - // What the build seat makes of the ask: the fields the two share, by name. - var spec builder.CheckSpec - body, _ := json.Marshal(request.Check) - if err := json.Unmarshal(body, &spec); err != nil { - t.Fatal(err) - } - spec.ID, spec.Repository, spec.Ref = request.ID, request.Repository, request.Ref - spec.Toolchain, spec.JudgeCache = "golang", r.judgeCache - v, err := builder.Check(t.Context(), builder.Command, spec, t.TempDir(), r.registry, builder.GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - return v -} - -type builderVerdict = builder.CheckVerdict - -func r867Registry(t *testing.T) string { - t.Helper() - body, err := json.Marshal(meshfacts.Facts{Format: meshfacts.Format, Taken: time.Now().UTC(), - Versions: meshfacts.Versions{Bus: "2.11.17", Store: "17.11"}, Machines: []meshfacts.Machine{{Name: "abcdef", Length: 6}}}) - if err != nil { - t.Fatal(err) - } - sum := sha256.Sum256(body) - digest := "sha256:" + hex.EncodeToString(sum[:]) - manifest, _ := json.Marshal(map[string]any{"schemaVersion": 2, "layers": []map[string]any{ - {"mediaType": meshfacts.MediaType, "digest": digest, "size": len(body)}}}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - switch { - case strings.Contains(req.URL.Path, "/manifests/"): - w.Write(manifest) - case strings.HasSuffix(req.URL.Path, "/blobs/"+digest): - w.Write(body) - default: - http.NotFound(w, req) - } - })) - t.Cleanup(srv.Close) - return strings.TrimPrefix(srv.URL, "http://") -} - -// r867Runtime puts a container runtime on PATH that runs every container's command on this machine, in the directory -// and with the environment the check gives it, and a store and a bus that answer. -func r867Runtime(t *testing.T) { - t.Helper() - bus, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { bus.Close() }) - go func() { - for { - c, err := bus.Accept() - if err != nil { - return - } - c.Close() - } - }() - dir := t.TempDir() - script := `#!/bin/sh -cmd="$1"; shift -case "$cmd" in -ps|rm|exec) exit 0 ;; -port) echo "` + bus.Addr().String() + `"; exit 0 ;; -run) - workdir=""; detached="" - while [ $# -gt 0 ]; do - case "$1" in - -d) detached=1; shift ;; - --rm) shift ;; - --workdir) workdir="$2"; shift 2 ;; - --env) export "$2"; shift 2 ;; - --network|--volume|--user|--label|--name|-p|-e) shift 2 ;; - *) break ;; - esac - done - if [ -n "$detached" ]; then echo a-container; exit 0; fi - shift - cd "$workdir" && exec "$@" ;; -esac -exit 1 -` - if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv("GOTOOLCHAIN", "local") -} diff --git a/cmd/mesh-controller/repository_words.go b/cmd/mesh-controller/repository_words.go new file mode 100644 index 00000000..c520ecb9 --- /dev/null +++ b/cmd/mesh-controller/repository_words.go @@ -0,0 +1,22 @@ +package main + +import "strings" + +// orRoot names a module's directory for a sentence: the repository's root when it is empty. +func orRoot(dir string) string { + if dir == "" { + return "the repository's root" + } + return dir +} + +// ownerOf and repoOf are the two halves of an owner/repository name. +func ownerOf(repository string) string { + owner, _, _ := strings.Cut(repository, "/") + return owner +} + +func repoOf(repository string) string { + _, repo, _ := strings.Cut(repository, "/") + return repo +} diff --git a/cmd/mesh-controller/restarted_test.go b/cmd/mesh-controller/restarted_test.go deleted file mode 100644 index 380ffa37..00000000 --- a/cmd/mesh-controller/restarted_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "strings" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/link" -) - -// **A check that ran again says so on the pull request** (novox/hq issue 594): a build agent replaced -// mid-check leaves the ask to be handed out again, and the verdict that finally comes is from a second -// run. Each layer's summary — what the forge shows as the check's line — names the restart. -func TestARestartedCheckSaysSoOnEveryLayer(t *testing.T) { - asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-controller", Number: 279} - gate := &link.CheckLayer{Verdict: "pass", Summary: "every node composes"} - repo := &link.CheckLayer{Verdict: "pass", Summary: "merge-check.sh passed"} - c := checkedOf(link.BuildResult{ID: "b", Ref: "abc", On: "shanks", Checked: asked, Delivery: 2, - Check: &link.CheckOutcome{Verdict: "pass", Summary: "every node composes", Gate: gate, RepoCheck: repo}}) - for name, said := range map[string]string{"summary": c.Summary, "gate": c.Gate.Summary, - "repository check": c.RepoCheck.Summary} { - // The verdict's own words first, then the marker: the forge clips a status to a few words. - if !strings.HasSuffix(said, " (run 2)") || strings.HasPrefix(said, "(run") { - t.Errorf("the %s says %q: a check run twice reads as run once, or its marker pushes its words out", name, said) - } - } - if !strings.HasPrefix(c.Report, "restarted: handed out 2 times") { - t.Errorf("the report does not open with the restart: %q", c.Report) - } - if gate.Summary != "every node composes" { - t.Errorf("the build's own outcome was changed under it: %q", gate.Summary) - } - - // Taken once, nothing is added. - once := checkedOf(link.BuildResult{ID: "b", Ref: "abc", Checked: asked, Delivery: 1, - Check: &link.CheckOutcome{Verdict: "pass", Summary: "every node composes"}}) - if strings.Contains(once.Summary, "(run") || strings.Contains(once.Report, "restarted") { - t.Errorf("a check run once says %q", once.Summary) - } -} - -// And the queue says it of the ask in flight. -func TestTheQueueSaysAnAskInFlightRestarted(t *testing.T) { - now := time.Date(2026, 10, 13, 0, 16, 0, 0, time.UTC) - q := link.Queue{Seat: link.TheBuildMachine, Delivered: 1, AckPending: 1, MaxDeliver: 5, Asks: []link.QueuedAsk{ - {Seq: 1, ID: "build-279", Repository: "ssh://forge/novox/mesh-controller", AskedAt: now.Add(-7 * time.Minute), - State: link.AskInFlight, On: "shanks", Started: now.Add(-time.Minute), Delivery: 2}, - }} - text := queueText(q, now) - if !strings.Contains(text, "handed out 2 times") { - t.Fatalf("the queue does not say the ask was handed out again:\n%s", text) - } - if a := queueAnswerOf(q, now).Asks[0]; a.Delivery != 2 { - t.Errorf("the queue's data says delivery %d, want 2", a.Delivery) - } -} diff --git a/cmd/mesh-controller/rollout_events_test.go b/cmd/mesh-controller/rollout_events_test.go index 0c072b33..c86e12a4 100644 --- a/cmd/mesh-controller/rollout_events_test.go +++ b/cmd/mesh-controller/rollout_events_test.go @@ -67,18 +67,8 @@ func TestARolloutAdvancesOnEventsAndAReplacedControllerResumesIt(t *testing.T) { // The build machine and the machines, as far as the plan meets them: an ask is counted, a send is recorded and kept. var mu sync.Mutex - var asks []string var sends [][]string - asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it - wasAsk := askABuild - askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) { - mu.Lock() - defer mu.Unlock() - id := link.NewBuildID(time.Now().Add(time.Duration(len(asks)) * time.Millisecond)) - asks = append(asks, source.Repository+"#"+id) - return id, nil - } - t.Cleanup(func() { askABuild = wasAsk }) + asked := asksRecorded(t) // each module taken from a done key as the walk reaches it, said as repository#build id digests := 0 wasSend := sendRollout sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) { @@ -194,7 +184,7 @@ func TestARolloutAdvancesOnEventsAndAReplacedControllerResumesIt(t *testing.T) { eventually(t, "tier 0 sent to both machines and tier 1 asked, on the save alone", func() bool { mu.Lock() defer mu.Unlock() - return len(sends) == 1 && len(asks) == 1 && plan().Tier == 1 + return len(sends) == 1 && len(*asked) == 1 && plan().Tier == 1 }) eventually(t, "a key on the bus for each machine tier 0 was sent to", func() bool { return len(heldKeys()) == 2 }) @@ -216,15 +206,7 @@ func TestARolloutAdvancesOnEventsAndAReplacedControllerResumesIt(t *testing.T) { return !anchor }) - // tier 1's build comes in, as the build machine's outcome is taken in. - b := plan().Modules["b"] - if b == nil || b.Build == "" { - t.Fatalf("tier 1 was not asked: %+v", b) - } - askedAt, _ := link.BuildAskedAt(b.Build) - record(b.Build, "b", "c2", askedAt) - register("b", "c2", askedAt) - planBuilt(ctx, open, "b", "c2", "", askedAt, b.Build) + // tier 1's module is taken from its key by the new controller's look. eventually(t, "tier 1 sent to both machines", func() bool { mu.Lock() defer mu.Unlock() @@ -246,8 +228,8 @@ func TestARolloutAdvancesOnEventsAndAReplacedControllerResumesIt(t *testing.T) { // No tier asked twice and none skipped: one ask for tier 1 (tier 0 was built when the plan was kept), each tier sent // once to both machines, and said as sent once for each. mu.Lock() - if len(asks) != 1 || !strings.HasPrefix(asks[0], "novox/b#") { - t.Errorf("asks = %v; want tier 1's module asked once", asks) + if len(*asked) != 1 || !strings.HasPrefix((*asked)[0], "novox/mesh-catalog#key-") { + t.Errorf("taken from keys = %v; want tier 1's module taken once", *asked) } if len(sends) != 2 || strings.Join(sends[0], ",") != "anchor,laptop" || strings.Join(sends[1], ",") != "anchor,laptop" { t.Errorf("sends = %v; want both machines sent each tier once", sends) diff --git a/cmd/mesh-controller/same_program.go b/cmd/mesh-controller/same_program.go index 00892b95..b044536e 100644 --- a/cmd/mesh-controller/same_program.go +++ b/cmd/mesh-controller/same_program.go @@ -79,7 +79,7 @@ func leaveSameAsRunning(ctx context.Context, inv *inventory.Inventory, plan *inv now, held := map[string]string{}, map[string]string{} for m := range plan.Modules { e, known := byName[m] - if !known || isBuildersOwn(m) { + if !known { continue } v, err := keys.ModuleAt(ctx, m, plan.CommitOn(e.Source.Repository, followedBranch(e.Source.Ref))) diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 737c89ec..a4bf2f30 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -58,11 +58,6 @@ func argvFor(verb string, args map[string]any) ([]string, error) { if removedVerbs[verb] { return nil, removedByName(verb) } - if verb == "replay" { - if v, given := args["register"]; given && v != false && v != "false" { - return nil, removedByName("replay --register") - } - } a, err := readArguments(verb, args) if err != nil { return nil, err @@ -99,7 +94,8 @@ type verbArguments struct { // removedVerbs are the controller seat's verbs R4e removed, kept in its table with no input so a caller is answered // by name (ADR 0501 §5, ADR 0503 §3, ADR 0450 §8). -var removedVerbs = map[string]bool{"build": true, "rebuild": true, "forget-checks": true, "delivery-plan": true} +var removedVerbs = map[string]bool{"build": true, "rebuild": true, "forget-checks": true, "delivery-plan": true, + "delivery-check": true, "queue": true, "cancel": true, "clear": true, "kill": true, "replay": true, "pause": true, "resume": true} // controllerVerb is this binary's own definition of a verb: what it runs is what it declares, so the // arguments are checked against the table compiled beside argvFor, not a row a newer or older build @@ -341,8 +337,9 @@ func (a *verbArguments) commandLine() ([]string, error) { case "seats": return []string{"seats", "--json"}, nil case "builds": - if id := str("log"); id != "" { - return []string{"builds", "--log", id}, nil + if str("log") != "" { + return nil, errors.New("a build's log is not read here any more: the build seat is retired (ADR 0525 clause 7), " + + "and a build runs on a build module, whose step key names where it stands (ADR 0501)") } argv := []string{"builds"} if n := str("limit"); n != "" { @@ -427,23 +424,6 @@ func (a *verbArguments) commandLine() ([]string, error) { return nil, err } return append([]string{"delivery", "order"}, members...), nil - case "delivery-check": - if err := need("members"); err != nil { - return nil, err - } - members, err := membersFlags(str("members")) - if err != nil { - return nil, err - } - argv := append([]string{"delivery", "check"}, members...) - if g := str("group"); g != "" { - argv = append([]string{"delivery", "check", "--group", g}, members...) - } - // Marked ready (novox/hq ADR 0406 §3): the check is queued before every other check. - if r := strings.ToLower(str("ready")); r != "" && r != "false" && r != "no" && r != "0" { - argv = append(argv, "--ready") - } - return argv, nil case "deliver": if err := need("plan"); err != nil { return nil, err @@ -484,10 +464,6 @@ func (a *verbArguments) commandLine() ([]string, error) { argv = append(argv, "-n", n) } return argv, nil - // The build queue (novox/hq ADR 0219). - case "queue": - // As data, the table its output (novox/hq issue 455). - return []string{"queue", "--json"}, nil case "try": // A branch tried before its pull request (novox/hq issue 686): asked, or read by the id it answered. if id := str("id"); id != "" { @@ -510,27 +486,6 @@ func (a *verbArguments) commandLine() ([]string, error) { argv = append(argv, "--store-only") } return argv, nil - case "cancel", "kill": - if err := need("id"); err != nil { - return nil, err - } - return []string{verb, str("id")}, nil - case "clear": - if on("dead") { - return []string{"clear", "--dead"}, nil - } - return []string{"clear"}, nil - case "replay": - if err := need("id"); err != nil { - return nil, err - } - // A dry run alone (ADR 0429 §18): register is refused by name before this (argvFor). - return []string{"replay", str("id")}, nil - case "pause", "resume": - if n := str("node"); n != "" { - return []string{verb, n}, nil - } - return []string{verb}, nil case "plan": if err := need("node"); err != nil { return nil, err @@ -1079,8 +1034,6 @@ var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true, "co "move-config": true, // The delivery's owner's verbs answer JSON where they read (plan, order, check, walks) — novox/hq ADR 0239. "delivery": true, - // The build queue, each ask with its position among the waiting (novox/hq issue 455). - "queue": true, // A branch tried before its pull request (novox/hq issue 686). "try": true, // A plan followed (novox/hq issue 229): `plans wait` answers JSON; its other forms say text, and so answer no data. @@ -1089,7 +1042,7 @@ var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true, "co // tableBesideVerbs are the JSON verbs whose document carries the table a person reads as its `text`: the // verb answers that table as its output, as it did before it answered data, and the rest as `answer` — // both from the one read, so they never disagree (novox/hq issue 455). -var tableBesideVerbs = map[string]bool{"queue": true, "plans": true} +var tableBesideVerbs = map[string]bool{"plans": true} // overviewVerbs are the JSON verbs whose answer is said once, as data, and not again as the text the // command printed: the overviews, which grow with the mesh (novox/hq issue 314). Every reader of theirs @@ -1254,10 +1207,6 @@ func readHere(ctx context.Context, argv []string) (verbAnswer, bool) { if handActConn != nil || servingBus.Load() != nil { read = listHandActs } - case "queue": - if servingBus.Load() != nil { - read = listQueue - } } if read == nil { return verbAnswer{}, false @@ -1692,7 +1641,7 @@ func subIn(rest []string, subs ...string) bool { var commandReadForms = map[string]func(rest []string) bool{ "status": flagsOnly, "version": flagsOnly, "help": flagsOnly, "seats": flagsOnly, "healers": flagsOnly, "hand-acts": flagsOnly, "durations": flagsOnly, "collection": flagsOnly, "images": flagsOnly, - "artifacts": flagsOnly, "data": flagsOnly, "builds": flagsOnly, "queue": flagsOnly, + "artifacts": flagsOnly, "data": flagsOnly, "builds": flagsOnly, // The switch's list reads the builder's keys and the builds held, and moves nothing (issue 1851, A13). "switch": flagsOnly, // `plan ` previews a node's declaration; it sends nothing. diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index b41492dc..37193269 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -10,15 +10,11 @@ import ( "testing" ) -// `builds` given a build's id reads that build's log from the bus rather than listing builds -// (novox/hq ADR 0157). -func TestBuildsWithAnIdReadsThatBuildsLog(t *testing.T) { - argv, err := argvFor("builds", map[string]any{"log": "build-17"}) - if err != nil { - t.Fatal(err) - } - if strings.Join(argv, " ") != "builds --log build-17" { - t.Fatalf("builds with a log id became %q", strings.Join(argv, " ")) +// `builds` given a build's id no longer reads a log from the bus: the build seat is retired (ADR 0525 clause 7). +func TestBuildsWithAnIdIsRefusedForTheRetiredSeat(t *testing.T) { + if _, err := argvFor("builds", map[string]any{"log": "build-17"}); err == nil || + !strings.Contains(err.Error(), "build seat is retired") { + t.Fatalf("builds with a log id: %v", err) } } @@ -125,7 +121,13 @@ func TestTheRemovedVerbsAreRefusedByName(t *testing.T) { {"build", map[string]any{"repository": "novox/x", "path": "modules/x"}, "`build` is removed"}, {"build", map[string]any{}, "`build` is removed"}, {"rebuild", map[string]any{"what": "gitea"}, "`rebuild` is removed"}, - {"replay", map[string]any{"id": "build-1", "register": "true", "older": "true"}, "`replay --register` is removed"}, + {"replay", map[string]any{"id": "build-1"}, "`replay` is removed"}, + {"queue", map[string]any{}, "`queue` is removed"}, + {"cancel", map[string]any{"id": "build-1"}, "`cancel` is removed"}, + {"clear", map[string]any{"dead": "true"}, "`clear` is removed"}, + {"kill", map[string]any{"id": "build-1"}, "`kill` is removed"}, + {"pause", map[string]any{"node": "ace"}, "`pause` is removed"}, + {"resume", map[string]any{}, "`resume` is removed"}, // 1318: delivery-plan and forget-checks go too. {"delivery-plan", map[string]any{"repository": "novox/a", "paths": "x"}, "`delivery-plan` is removed"}, {"forget-checks", map[string]any{"owner": "novox", "repo": "a", "number": "7"}, "`forget-checks` is removed"}, @@ -135,10 +137,7 @@ func TestTheRemovedVerbsAreRefusedByName(t *testing.T) { t.Errorf("%s %v: %v %v, want refused saying %q", c.verb, c.args, argv, err, c.said) } } - if argv, err := argvFor("replay", map[string]any{"id": "build-1"}); err != nil || strings.Join(argv, " ") != "replay build-1" { - t.Errorf("replay as a dry run: %v %v", argv, err) - } - for _, removed := range []string{"build", "rebuild", "forget-checks", "delivery-plan"} { + for _, removed := range []string{"build", "rebuild", "forget-checks", "delivery-plan", "queue", "replay"} { err := removedByName(removed) if err == nil || !strings.Contains(err.Error(), "`"+removed+"` is removed") || !strings.Contains(err.Error(), "ADR 05") { t.Errorf("%s: %v", removed, err) @@ -148,10 +147,6 @@ func TestTheRemovedVerbsAreRefusedByName(t *testing.T) { !strings.Contains(err.Error(), "`delivery-plan` is removed") { t.Errorf("delivery plan: %v", err) } - err := replayCommand(context.Background(), []string{"build-1", "--register"}) - if err == nil || !strings.Contains(err.Error(), "`replay --register` is removed") { - t.Errorf("replay --register: %v", err) - } } // What `tools` answers is the seats' records, with each verb's schema. @@ -232,7 +227,7 @@ func TestTheCommandVerbOnlyReads(t *testing.T) { } for _, line := range []string{ "status --json", "version", "seats --json", "healers", "hand-acts --days 3", "durations", "collection", - "images", "artifacts --collected", "data --machine a", "builds --log b", "queue", "plan ace --diff", + "images", "artifacts --collected", "data --machine a", "builds", "plan ace --diff", "plans", "plans plan-1", "doctor", "doctor probes", "doctor signals", "conditions", "conditions list", "conditions show c", "conditions history", "node list", "node show ace", "module list", "settings show m", "settings preferences", "retire list", "cleanup list", "delivery plan --repository r", diff --git a/cmd/mesh-controller/signals.go b/cmd/mesh-controller/signals.go index 76e65509..7ee34b89 100644 --- a/cmd/mesh-controller/signals.go +++ b/cmd/mesh-controller/signals.go @@ -62,10 +62,6 @@ const ( tierAtLeast = planWaitBound // loopDeafAfter is how long the event loop may take nothing while its consumers hold some (S4). loopDeafAfter = 2 * time.Minute - // askAtLeast is the least a build ask is given, and askDefault the bound while nothing is - // measured (S6): the build seat declares no timeout of its own. - askAtLeast = 20 * time.Minute - askDefault = time.Hour // callDefault is the bound of a verb that declares none (S7); push's and build's are longer. callDefault = 10 * time.Minute // advisoryQuiet is how long the bus must be quiet about a thing before its advisory clears (S9). @@ -114,9 +110,8 @@ var signalsTable = []signalRow{ return newestOf(f.machines, func(m machineFacts) time.Time { return m.reportedAt }) }}, {Row: "S3", Signal: "plan tier progress", Emitter: "controller's plan", Trigger: "each tier entered", - Bound: "max(30 min, 3 × the p90 of that repository's measured tiers), provisional; not while the " + - "build seat is paused under it", - Kind: "stalled", Severity: conditions.Warning, Phase: 1, + Bound: "max(30 min, 3 × the p90 of that repository's measured tiers), provisional", + Kind: "stalled", Severity: conditions.Warning, Phase: 1, needs: func(f *signalFacts) error { return f.plansErr }, watch: watchPlans, newest: func(f *signalFacts) time.Time { return newestOf(f.plans, func(p planFacts) time.Time { return p.entered }) @@ -126,12 +121,6 @@ var signalsTable = []signalRow{ Kind: "controller-deaf", Severity: conditions.Urgent, Phase: 1, needs: func(f *signalFacts) error { return f.loopErr }, watch: watchLoop, newest: func(f *signalFacts) time.Time { return f.loop.took }}, - {Row: "S6", Signal: "a build asked → its outcome", Emitter: "build seat", Trigger: "each ask", - Bound: "max(20 min, 3 × the p90 of measured builds), 1 h while nothing is measured, provisional — the " + - "build seat declares no timeout; and an ask the queue gave up on (dead) at once", - Kind: "ask-lost", Severity: conditions.Warning, Phase: 1, - needs: func(f *signalFacts) error { return f.asksErr }, watch: watchAsks, - newest: func(f *signalFacts) time.Time { return newestOf(f.asks, func(a askFacts) time.Time { return a.since }) }}, {Row: "S7", Signal: "a call running → finished", Emitter: "controller", Trigger: "each call", Bound: "the verb's bound: push, rotate and command 30 min, assign and unassign 15 min, doctor 3 min, " + "any other 10 min", @@ -407,9 +396,9 @@ func watchReports(f *signalFacts) []conditions.Observation { func watchPlans(f *signalFacts) []conditions.Observation { var out []conditions.Observation for _, p := range f.plans { - // Under a paused build seat, or held only by the bus's planned step (S17, novox/hq issue 336): a wait + // Held only by the bus's planned step (S17, novox/hq issue 336): a wait // for a person, said as itself, not a walk running late. - if p.paused || p.bus { + if p.bus { continue } in := f.now.Sub(p.entered) @@ -521,32 +510,6 @@ func watchLoop(f *signalFacts) []conditions.Observation { Said: fmt.Sprintf("%d pending (%s), last taken %s", f.loop.pending, f.loop.where, since.UTC().Format(time.RFC3339))}} } -func watchAsks(f *signalFacts) []conditions.Observation { - var out []conditions.Observation - for _, a := range f.asks { - var said string - switch { - case a.state == link.AskDead: - said = fmt.Sprintf("the %s queue handed it out as often as it may and it was never settled", a.seat) - case a.state == link.AskInFlight && f.now.Sub(a.since) > a.bound: - said = fmt.Sprintf("in flight on %s for %s (bound %s)", orSomewhere(a.on), ago(f.now.Sub(a.since)), ago(a.bound)) - default: - continue - } - out = append(out, conditions.Observation{Scope: conditions.ScopeBuild, ID: a.id, Kind: "ask-lost", - Token: "lost", Machine: a.on, Severity: conditions.Warning, - Summary: fmt.Sprintf("the build %s of %s has no outcome: %s", a.id, a.what, said), Said: said}) - } - return out -} - -func orSomewhere(node string) string { - if node == "" { - return "a machine that did not say which" - } - return node -} - func watchCalls(f *signalFacts) []conditions.Observation { var out []conditions.Observation for _, c := range f.calls { @@ -738,8 +701,6 @@ func consumerWho(stream, consumer string) string { switch { case consumer == broker.ControllerName || consumer == broker.MergesConsumerName: return "the controller" - case broker.IsCheckStream(stream): - return "the holder of " + broker.TheBuildSeat case strings.HasPrefix(stream, "SEAT_") && strings.HasSuffix(consumer, "_worker"): seat := strings.ToLower(strings.ReplaceAll(strings.TrimSuffix(strings.TrimPrefix(consumer, "SEAT_"), "_worker"), "_", "-")) return "the holder of " + seat diff --git a/cmd/mesh-controller/signals_test.go b/cmd/mesh-controller/signals_test.go index 9bf41d09..bbb0f09a 100644 --- a/cmd/mesh-controller/signals_test.go +++ b/cmd/mesh-controller/signals_test.go @@ -71,16 +71,6 @@ var suppressions = map[string]suppression{ inside: func(f *signalFacts) { f.loop.took = f.now.Add(-119 * time.Second) }, past: func(f *signalFacts) { f.loop.took = f.now.Add(-121 * time.Second) }, }, - "S6": { - inside: func(f *signalFacts) { - f.asks = []askFacts{{id: "build-1", seat: "node-build-agent", what: "novox/app", state: link.AskInFlight, - on: "anchor", since: f.now.Add(-59 * time.Minute), bound: time.Hour}} - }, - past: func(f *signalFacts) { - f.asks = []askFacts{{id: "build-1", seat: "node-build-agent", what: "novox/app", state: link.AskInFlight, - on: "anchor", since: f.now.Add(-61 * time.Minute), bound: time.Hour}} - }, - }, "S7": { inside: func(f *signalFacts) { f.calls = []link.Call{{ID: "call-1", Seat: "mesh-controller", Verb: "push", Started: f.now.Add(-29 * time.Minute)}} diff --git a/cmd/mesh-controller/silent_mid_tier_integration_test.go b/cmd/mesh-controller/silent_mid_tier_integration_test.go index dac3e72b..2593d8b1 100644 --- a/cmd/mesh-controller/silent_mid_tier_integration_test.go +++ b/cmd/mesh-controller/silent_mid_tier_integration_test.go @@ -63,18 +63,8 @@ func TestAPlanPassesATierWhoseMachineNeverReportsAfterTheBoundAndSendsTheNextToT // The build machine and the machines, as far as the plan meets them: an ask is counted, a send is recorded and kept. var mu sync.Mutex - var asks []string var sends [][]string - asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it - wasAsk := askABuild - askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) { - mu.Lock() - defer mu.Unlock() - id := link.NewBuildID(time.Now().Add(time.Duration(len(asks)) * time.Millisecond)) - asks = append(asks, source.Repository+"#"+id) - return id, nil - } - t.Cleanup(func() { askABuild = wasAsk }) + asksRecorded(t) // each module is taken from a done key as the walk reaches it digests := 0 wasSend := sendRollout sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) { @@ -200,13 +190,7 @@ func TestAPlanPassesATierWhoseMachineNeverReportsAfterTheBoundAndSendsTheNextToT if !strings.Contains(moved, "laptop") || strings.Contains(moved, "anchor") { t.Errorf("1160/AC5: the plan says %q; want laptop named silent and not anchor", moved) } - // The ask for tier 1's build is the next step, and records its build id on the module. - eventually(t, "tier 1 asked", func() bool { m := plan().Modules["b"]; return m != nil && m.Build != "" }) - b := plan().Modules["b"] - askedAt, _ := link.BuildAskedAt(b.Build) - record(b.Build, "b", "c2", askedAt) - register("b", "c2", askedAt) - planBuilt(ctx, open, "b", "c2", "", askedAt, b.Build) + // Tier 1's module is taken from its key, which the walk's look does by itself. eventually(t, "tier 1 sent to anchor alone", func() bool { mu.Lock() defer mu.Unlock() diff --git a/cmd/mesh-controller/source.go b/cmd/mesh-controller/source.go index b54a6678..29dfb760 100644 --- a/cmd/mesh-controller/source.go +++ b/cmd/mesh-controller/source.go @@ -1,9 +1,7 @@ package main import ( - "context" "fmt" - "os" "strconv" "strings" @@ -50,47 +48,6 @@ func onASeat(repository string) error { return nil } -// cloneFrom is the URL a build machine clones for a source. -// -// A URL is itself. A path on a seat is composed from the seat's holder as the mesh sees it now — -// the same view planning takes of every machine, so the forge a build clones from is the forge the -// mesh says holds the seat. -func cloneFrom(ctx context.Context, source buildSource) (string, error) { - if source.Seat == "" { - return source.Repository, nil - } - open, err := openStores(ctx) - if err != nil { - return "", err - } - defer open.Close() - shelf, err := open.inventory.Catalogue(ctx) - if err != nil { - return "", err - } - world, err := restOfTheMesh(ctx, open, shelf, "") - if err != nil { - return "", err - } - return clonedFromSeat(world, source.Seat, source.Repository) -} - -// clonedFromSeat composes the clone URL for a repository on a seat's holder. -// -// **Refused, never defaulted, at every step that has no answer.** Nobody holding the seat is a mesh -// without a forge of its own: it builds from external repositories and must say so rather than fail -// to clone. A holder off the private network cannot be reached by any build machine. A holder that -// serves no scheme or port has nothing to compose from — a default port here would be the forge's -// address guessed, which is the thing this exists to stop. -func clonedFromSeat(world catalogue.World, seatName, repository string) (string, error) { - base, err := seatBase(world, seatName) - if err != nil { - return "", err - } - path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git") - return fmt.Sprintf("%s/%s.git", base, path), nil -} - // seatBase is `scheme://host:port` of a seat's holder as the mesh reaches it, for cloning. func seatBase(world catalogue.World, seatName string) (string, error) { seat, known := catalogue.SeatNamed(seatName) @@ -132,41 +89,6 @@ func seatBase(world catalogue.World, seatName string) (string, error) { return fmt.Sprintf("%s://%s:%s", scheme, provider.At, port), nil } -// seatBases is the clone base of every seat a recipe's context may name, for a build request -// (novox/hq ADR 0155). A seat nobody holds is left out rather than refused here: the build may not -// name it at all, and if it does the builder refuses with the seat's name. -// -// **What cannot be read is said, not passed over as no seats** (novox/hq to-be 45 Phase 2, the -// empty-on-error lint): the build still goes ahead — one that names no seat needs none — and one that -// does is refused naming it, but the reason is the store, and that is said here where it is known. -func seatBases(ctx context.Context) map[string]string { - unread := func(what string, err error) map[string]string { - fmt.Fprintf(os.Stderr, "could not read %s, so a build naming a seat's clone base will be told that "+ - "seat is not held: %v\n", what, err) - return nil - } - open, err := openStores(ctx) - if err != nil { - return unread("the mesh's store", err) - } - defer open.Close() - shelf, err := open.inventory.Catalogue(ctx) - if err != nil { - return unread("the catalogue", err) - } - world, err := restOfTheMesh(ctx, open, shelf, "") - if err != nil { - return unread("who holds which seat", err) - } - bases := map[string]string{} - for _, seatName := range []string{gitSeat} { - if base, err := seatBase(world, seatName); err == nil { - bases[seatName] = base - } - } - return bases -} - // servedPort is a served port as text, however the manifest and the node's settings carried it. func servedPort(v any) string { switch p := v.(type) { diff --git a/cmd/mesh-controller/source_test.go b/cmd/mesh-controller/source_test.go index ab9c8ccd..eab53021 100644 --- a/cmd/mesh-controller/source_test.go +++ b/cmd/mesh-controller/source_test.go @@ -1,7 +1,6 @@ package main import ( - "strings" "testing" "git.novox.be/novox/mesh-sdk/go/catalogue" @@ -22,68 +21,6 @@ func forgeHolding(port any) catalogue.World { } } -func TestARepositoryOnTheSeatIsClonedFromItsHolder(t *testing.T) { - got, err := clonedFromSeat(forgeHolding(float64(3000)), "git", "novox/mesh-catalog") - if err != nil { - t.Fatal(err) - } - if got != "http://anchor.internal:3000/novox/mesh-catalog.git" { - t.Fatalf("cloned from %s", got) - } -} - -func TestAMovedForgeIsFollowedWithoutRewritingAnything(t *testing.T) { - // The whole point: the node gave the forge another port, and the same recorded path clones - // from the new one. Nothing recorded contained the old one to be wrong. - got, err := clonedFromSeat(forgeHolding(float64(3100)), "git", "novox/mesh-catalog") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(got, ":3100/") { - t.Fatalf("the moved port was not followed: %s", got) - } -} - -func TestWithNobodyHoldingTheSeatASelfHostedBuildIsRefusedAndSaysWhy(t *testing.T) { - _, err := clonedFromSeat(catalogue.World{}, "git", "novox/mesh-catalog") - if err == nil { - t.Fatal("a repository was cloned from a forge the mesh does not have") - } - for _, want := range []string{"nobody holds the git seat", "without --self"} { - if !strings.Contains(err.Error(), want) { - t.Fatalf("the refusal does not say %q: %v", want, err) - } - } -} - -func TestAnExternalRepositoryIsClonedExactlyAsGiven(t *testing.T) { - // Unaffected by the seat, held or not: GitHub and GitLab are the ordinary cases. - given := "https://github.com/someone/something.git" - got, err := cloneFrom(t.Context(), buildSource{Repository: given}) - if err != nil { - t.Fatal(err) - } - if got != given { - t.Fatalf("an external repository became %s", got) - } -} - -func TestAHolderOffThePrivateNetworkIsRefused(t *testing.T) { - world := forgeHolding(float64(3000)) - world.Offered["git"][1].At = "" - if _, err := clonedFromSeat(world, "git", "novox/mesh-catalog"); err == nil || - !strings.Contains(err.Error(), "private network") { - t.Fatalf("a forge nothing can reach was cloned from: %v", err) - } -} - -func TestAHolderServingNoPortIsRefusedRatherThanGuessed(t *testing.T) { - // A default port would be the forge's address guessed, which is what this exists to stop. - if _, err := clonedFromSeat(forgeHolding(nil), "git", "novox/mesh-catalog"); err == nil { - t.Fatal("a port was guessed for a forge that serves none") - } -} - func TestAnAddressGivenAsAPathOnTheForgeIsRefused(t *testing.T) { for _, bad := range []string{ "https://github.com/someone/something.git", diff --git a/cmd/mesh-controller/states_announced_integration_test.go b/cmd/mesh-controller/states_announced_integration_test.go index 0e64ca7f..32d9beab 100644 --- a/cmd/mesh-controller/states_announced_integration_test.go +++ b/cmd/mesh-controller/states_announced_integration_test.go @@ -77,7 +77,7 @@ func TestTheStatesAnnouncedGrantTheReads(t *testing.T) { } becomes(t, 10*time.Second, "both words kept", func() bool { a, err := readAnnouncements(ctx) - return err == nil && len(a) == 2 && a["owner"].Readable("open") && a[broker.ControllerSeat].Readable(broker.PlansBuilding) + return err == nil && len(a) == 2 && a["owner"].Readable("open") && a[broker.ControllerSeat].Readable(broker.ChainBuilds) }) records, err := inv.BusRecords(ctx) diff --git a/cmd/mesh-controller/status.go b/cmd/mesh-controller/status.go index 93191908..32c9ac25 100644 --- a/cmd/mesh-controller/status.go +++ b/cmd/mesh-controller/status.go @@ -152,14 +152,14 @@ func printStatus(asked answers) error { printPending(asked.pending, asked.pendingUnread) - if open, late := openPlans(asked.plans, time.Now(), asked.paused, asked.tierBounds); len(open) > 0 { + if open, late := openPlans(asked.plans, time.Now(), asked.tierBounds); len(open) > 0 { fmt.Printf("%d plan(s) open", len(open)) if late > 0 { fmt.Printf(", %d in a tier past its bound", late) } fmt.Println(":") for _, p := range open { - fmt.Printf(" %s\n", planLineWith(p, time.Now(), asked.paused, asked.tierBounds.of(p.Repository))) + fmt.Printf(" %s\n", planLineWith(p, time.Now(), asked.tierBounds.of(p.Repository))) } fmt.Println() } @@ -393,16 +393,6 @@ func firstLine(s string) string { return strings.TrimSpace(s) } -// builds keeps what a builder said, for the serving control plane. -// -// A type of its own rather than a method on the enrolment, because they are unrelated things -// arriving on one queue and an implementation of one should not have to say anything about the -// other. -type builds struct { - inv *inventory.Inventory - open *stores -} - // theThreeQuestions reads what anything answering "is the mesh alright" needs. // // **One reading, for every way of saying it** (novox/hq 03-DESIGN/01-to-be/11-a-board.md). There @@ -511,7 +501,6 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) { return answers{}, err } // Whether the build seat takes work, for a plan waiting on it (novox/hq ADR 0219). - out.paused = buildSeatPause(ctx, inv, out.plans) // And how long a tier may take, the bound a plan is late at (novox/hq issue 296). out.tierBounds = readTierBounds(ctx, inv, time.Now()) // And how many repairs were done by hand this week (novox/hq to-be 45 §7) — where there is a bus diff --git a/cmd/mesh-controller/switch_list.go b/cmd/mesh-controller/switch_list.go index b3ac0ac5..a34a277e 100644 --- a/cmd/mesh-controller/switch_list.go +++ b/cmd/mesh-controller/switch_list.go @@ -160,7 +160,7 @@ func switchList(ctx context.Context, inv *inventory.Inventory, keys switchReads) var out []switchLine for _, e := range entries { m := e.Manifest.Module - if e.Provided || e.Source.Repository == "" || isBuildersOwn(m) { + if e.Provided || e.Source.Repository == "" { continue } trunk, err := keys.ModuleAt(ctx, m, "") diff --git a/cmd/mesh-controller/take_refused.go b/cmd/mesh-controller/take_refused.go deleted file mode 100644 index 2d31edab..00000000 --- a/cmd/mesh-controller/take_refused.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "context" - "fmt" - "time" - - "github.com/novox/mesh-controller/internal/link" -) - -// refusalKeeper keeps a refused registration in the store (*inventory.Inventory, or a test's). -type refusalKeeper interface { - RecordRefused(ctx context.Context, id, module, why string) error -} - -// refusedOutcome is how a refused build reaches the plans: built takes it in by its module (planBuilt), failedBuild -// by the id the plan asked it under (planFailedBuild). -type refusedOutcome struct { - built func(module, why string) - failedBuild func(result link.BuildResult) -} - -// takeRefused takes in a build heard and recorded, and not registered (novox/hq issue 1713). -// -// **The refusal is kept by the build's id, whatever the plans' state**: in the store (RecordRefused) and in this -// process (keepRefusalFor). Taking it into the plans keeps it only when a plan is open or the plans are held, and a -// tier's first failure fails its plan, which no outcome reaches after that: the build record says the builder's words, -// which worked, and a Retry settled from it sent the module's old version and reported the plan done (on 2026-10-23 -// 12:01, plan-1792748906937927069 sent mesh-build-go, mesh-build-node, mosquitto and ssh-client so). -// -// **A build whose module is not known fails the plan that asked it by its id** (planFailedBuild, moduleAskedAs), as -// a failed build that names no module does: a manifest that could not be read is no reason for the plan not to hear. -func takeRefused(ctx context.Context, keeper refusalKeeper, result link.BuildResult, module, why string, - plans refusedOutcome) { - if result.ID != "" { - if err := keeper.RecordRefused(ctx, result.ID, module, why); err != nil { - fmt.Printf("%s: the refusal could not be kept in the store, only in this controller: %v\n", result.ID, err) - } - keepRefusalFor(result.ID, why, time.Now()) - } - if module != "" { - plans.built(module, why) - return - } - failed := result - failed.Failed = why - plans.failedBuild(failed) -} diff --git a/cmd/mesh-controller/take_refused_test.go b/cmd/mesh-controller/take_refused_test.go deleted file mode 100644 index 8e7cc6b0..00000000 --- a/cmd/mesh-controller/take_refused_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package main - -import ( - "context" - "reflect" - "strings" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// keptRefusals is a store that keeps refusals, by id. -type keptRefusals map[string]string - -func (k keptRefusals) RecordRefused(_ context.Context, id, _, why string) error { - if _, ok := k[id]; !ok { - k[id] = why - } - return nil -} - -// failedPlanOf is a plan already failed on gitea, its tier also asking module under id: open() does not count it, so -// no outcome taken in reaches it (as plan-1792748906937927069 on 2026-10-23 11:51). -func failedPlanOf(module, id string, asked time.Time) inventory.Plan { - return inventory.Plan{ID: "plan-1713", State: inventory.PlanFailed, Tier: 0, - Tiers: [][]string{{"gitea", module}}, Note: "gitea failed to build in tier 0", - Modules: map[string]*inventory.PlanModule{ - "gitea": {State: "failed", AskedAt: &asked, Build: "build-1713-gitea", Why: "not registered"}, - module: {State: "asked", AskedAt: &asked, Build: id}, - }} -} - -// retried is what a Retry of the plan asks again, its records read as recordsOfAsked reads them: the build record of -// id (which says the builder's words, worked) with every refusal kept for it in the store and in this process. -func retried(p *inventory.Plan, store keptRefusals, id, module string, at time.Time) []string { - byID := map[string]inventory.Build{id: {ID: id, Module: module, Commit: "6c0024e4", At: at}} - refused := map[string]string{} - for k, why := range store { - refused[k] = why - } - for k, why := range refusalsKeptFor([]string{id}) { - refused[k] = why - } - overlayRefusals(nil, byID, refused) - return toRetry(p, nil, byID) -} - -// 1713/AC1: given a build of a module heard and recorded and not registered while its plan is failed and the plans are -// not held, when the controller takes it in, then the refusal is kept by id, and a Retry asks the module again instead -// of settling it built and sending its old version (plan-1792748906937927069 sent mosquitto's on 2026-10-23 12:01). -func TestARefusalWhileThePlanIsFailedIsKeptAndRetried(t *testing.T) { - asked := time.Date(2026, 10, 23, 11, 51, 0, 0, time.UTC) - id := "build-1713-ac1-mosquitto" - p := failedPlanOf("mosquitto", id, asked) - store := keptRefusals{} - var took []string - takeRefused(t.Context(), store, link.BuildResult{ID: id, Commit: "6c0024e4"}, "mosquitto", - "the trunk requires no status", refusedOutcome{ - built: func(module, why string) { took = append(took, module+": "+why) }, - failedBuild: func(link.BuildResult) { t.Fatal("a refusal with its module was taken by id") }, - }) - - if !reflect.DeepEqual(took, []string{"mosquitto: the trunk requires no status"}) { - t.Fatalf("the plans were told %v", took) - } - if store[id] == "" { - t.Fatal("the refusal was not kept in the store") - } - if got := retried(&p, keptRefusals{}, id, "mosquitto", asked.Add(time.Minute)); !reflect.DeepEqual(got, - []string{"gitea", "mosquitto"}) { - t.Fatalf("a Retry, the refusal kept only in this controller, asks %v; mosquitto was settled %q", - got, p.Modules["mosquitto"].State) - } -} - -// 1713/AC2: given a build that came back without a readable manifest and was not registered, when the controller takes -// it in, then the asking plan is failed by the build's id and the refusal is kept by id, so a Retry asks it again. -func TestARefusalWithoutAModuleFailsItsPlanByIDAndIsRetried(t *testing.T) { - asked := time.Date(2026, 10, 23, 11, 51, 0, 0, time.UTC) - id := "build-1713-ac2-ssh-client" - p := failedPlanOf("ssh-client", id, asked) - store := keptRefusals{} - var failed []link.BuildResult - takeRefused(t.Context(), store, link.BuildResult{ID: id, Commit: "6c0024e4"}, "", - "what came back is not a manifest", refusedOutcome{ - built: func(module, _ string) { t.Fatalf("a refusal without a module was taken as %q", module) }, - failedBuild: func(r link.BuildResult) { failed = append(failed, r) }, - }) - - if len(failed) != 1 || failed[0].ID != id || !strings.Contains(failed[0].Failed, "not a manifest") { - t.Fatalf("the asking plan was not failed by the build's id: %+v", failed) - } - if store[id] == "" { - t.Fatal("the refusal was not kept in the store") - } - if got := retried(&p, store, id, "", asked.Add(time.Minute)); !reflect.DeepEqual(got, - []string{"gitea", "ssh-client"}) { - t.Fatalf("a Retry asks %v; ssh-client was settled %q", got, p.Modules["ssh-client"].State) - } -} - -// 1713/AC3: given a refused build kept by id, when the records settle a building plan's tier, then the module and the -// plan are failed, never built: the plan never reports done for it. -func TestAPlanNeverSettlesARefusedBuildBuilt(t *testing.T) { - asked := time.Date(2026, 10, 23, 11, 51, 0, 0, time.UTC) - id := "build-1713-ac3-mesh-build-go" - takeRefused(t.Context(), keptRefusals{}, link.BuildResult{ID: id}, "mesh-build-go", "not registered", - refusedOutcome{built: func(string, string) {}, failedBuild: func(link.BuildResult) {}}) - p := inventory.Plan{ID: "plan-1713-ac3", State: inventory.PlanBuilding, Tiers: [][]string{{"mesh-build-go"}}, - Modules: map[string]*inventory.PlanModule{"mesh-build-go": {State: "asked", AskedAt: &asked, Build: id}}} - byID := map[string]inventory.Build{id: {ID: id, Module: "mesh-build-go", At: asked.Add(time.Minute)}} - overlayRefusals(nil, byID, refusalsKeptFor([]string{id})) - settleFromRecords(&p, p.Tiers[0], nil, byID) - if s := p.Modules["mesh-build-go"]; s.State != "failed" || p.State != inventory.PlanFailed { - t.Fatalf("a refused build settled as %q, the plan %q", s.State, p.State) - } -} diff --git a/cmd/mesh-controller/template_integration_test.go b/cmd/mesh-controller/template_integration_test.go deleted file mode 100644 index 8b847819..00000000 --- a/cmd/mesh-controller/template_integration_test.go +++ /dev/null @@ -1,148 +0,0 @@ -//go:build integration - -package main - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - snapshot "git.novox.be/novox/mesh-sdk/go/facts" - "github.com/novox/mesh-controller/internal/inventory" -) - -func templatedMesh(t *testing.T) (snapshot.Facts, map[string]string) { - t.Helper() - open := aMesh(t) - ctx := t.Context() - manifests := map[string]string{ - "lemurs": `{"module":"lemurs","version":"1","capabilities":["systemd"], - "resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`, - "notifier": inlineNotifier, - } - for name, raw := range manifests { - m, err := catalogue.ParseManifest([]byte(raw)) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog", - Path: "modules/" + name, BuiltFrom: "c0ffee"}); err != nil { - t.Fatal(err) - } - if _, err := assign(ctx, open, "laptop", name); err != nil { - t.Fatalf("assign %s: %v", name, err) - } - } - f, err := gatherFacts(ctx, open, "2.11.17") - if err != nil { - t.Fatal(err) - } - return f, manifests -} - -func TestTheMergeGateJudgesAChangeToATemplateAlone(t *testing.T) { - f, manifests := templatedMesh(t) - - // The module moved to its template, the same text: nothing changes on any machine. - v := gateJudged(t, f, aTemplatedTree(t, manifests, "volume = 3\n", true), "modules/notifier/module.json", - notifierTemplate) - if v.Verdict == "fail" || v.Modules["notifier"] != "" { - t.Fatalf("the module moved to its template with the same text reads as a change (%v):\n%s", v.Modules, v.Report()) - } - - // The template alone changed: its module changed, and composes. - v = gateJudged(t, f, aTemplatedTree(t, manifests, "volume = 4\n", true), notifierTemplate) - if v.Verdict == "fail" || v.Modules["notifier"] != "changed" { - t.Fatalf("a change to the template alone is not a change to its module (%v):\n%s", v.Modules, v.Report()) - } - if v.Width == nil || strings.Join(v.Width.Modules, ",") != "notifier" { - t.Fatalf("a change to the template rebuilds %+v, not its module", v.Width) - } - - // The template alone changed to text the controller refuses: the pull request fails, naming the module. - v = gateJudged(t, f, aTemplatedTree(t, manifests, "store = ${dir:nowhere}\n", true), notifierTemplate) - if v.Verdict != "fail" || !strings.Contains(v.Report(), "notifier") { - t.Fatalf("a template the controller refuses passed the gate:\n%s", v.Report()) - } - - // A symlinked files/ fails: nothing is read from outside the tree. - tree := aTree(t, withEdit(manifests, "notifier", "")) - elsewhere := t.TempDir() - if err := os.WriteFile(filepath.Join(elsewhere, "notifier.conf"), []byte("volume = 3\n"), 0o644); err != nil { - t.Fatal(err) - } - aTemplatedModule(t, filepath.Join(tree, "modules", "notifier"), templatedNotifier, nil) - if err := os.Symlink(elsewhere, filepath.Join(tree, "modules", "notifier", "files")); err != nil { - t.Fatal(err) - } - v = gateJudged(t, f, tree, notifierTemplate) - if v.Verdict != "fail" || !strings.Contains(v.Report(), "files/ is a symlink") { - t.Fatalf("a symlinked files/ passed the gate:\n%s", v.Report()) - } - - // And the template removed from the tree fails, naming it. - v = gateJudged(t, f, aTemplatedTree(t, manifests, "", false), notifierTemplate) - if v.Verdict != "fail" || !strings.Contains(v.Report(), "files/notifier.conf") { - t.Fatalf("a missing template passed the gate:\n%s", v.Report()) - } -} - -func TestTheMergeGateResolvesATemplatedModuleToItsInlineTwin(t *testing.T) { - dir := t.TempDir() - path := aTemplatedModule(t, dir, templatedNotifier, map[string]string{"files/notifier.conf": "volume = 3\n"}) - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - m, err := catalogue.ParseManifest(raw) - if err != nil { - t.Fatal(err) - } - texts, err := catalogue.ReadTemplates(m, dir) - if err != nil { - t.Fatal(err) - } - resolved, err := standInBuild(m, texts) - if err != nil { - t.Fatal(err) - } - inline, err := catalogue.ParseManifest([]byte(inlineNotifier)) - if err != nil { - t.Fatal(err) - } - if builtAlike(resolved) != builtAlike(inline) { - got, _ := json.Marshal(resolved) - t.Fatalf("the templated module resolves to %s, not its inline twin", got) - } -} - -func TestReplay876(t *testing.T) { - moved := strings.Replace(templatedNotifier, `"version":"1",`, `"version":"1","moved":true,`, 1) - judge := func(manifest string, files map[string]string) (map[string]catalogue.Manifest, []string) { - tree := t.TempDir() - aTemplatedModule(t, filepath.Join(tree, "modules", "notifier"), manifest, files) - out, failures, err := shelfWithChange(map[string]catalogue.Manifest{}, nil, snapshot.Facts{}, - "novox/mesh-catalog", tree, map[string]string{}, nil) - if err != nil { - t.Fatal(err) - } - return out, failures - } - out, failures := judge(moved, map[string]string{"files/notifier.conf": "volume = 3\n"}) - if len(failures) > 0 { - t.Fatalf("a moved module naming its templates was refused: %v", failures) - } - if h := out["notifier"].Handed; h == nil || h.Templates["conf"] != "notifier.conf" { - t.Fatalf("the gate did not hand the moved module its templates: %+v", h) - } - - // A moved module naming no template is still refused. - none := strings.Replace(strings.Replace(moved, `"template":"files/notifier.conf"`, - `"content":"volume = 3\n"`, 1), `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1) - if _, failures := judge(none, nil); len(failures) != 1 || !strings.Contains(failures[0], "names no template") { - t.Fatalf("a moved module naming no template was not refused: %v", failures) - } -} diff --git a/cmd/mesh-controller/template_test.go b/cmd/mesh-controller/template_test.go index 2ece11f5..d3972043 100644 --- a/cmd/mesh-controller/template_test.go +++ b/cmd/mesh-controller/template_test.go @@ -79,20 +79,6 @@ func TestModuleCheckReadsTemplatesFromTheTreeBesideTheManifest(t *testing.T) { // templatedMesh is catalogueMesh with the notifier on the laptop, registered as built: its text inline. -// aTemplatedTree is the catalogue's checkout with the notifier's text in files/. -func aTemplatedTree(t *testing.T, manifests map[string]string, text string, has bool) string { - t.Helper() - tree := aTree(t, withEdit(manifests, "notifier", "")) - files := map[string]string{} - if has { - files["files/notifier.conf"] = text - } - aTemplatedModule(t, filepath.Join(tree, "modules", "notifier"), templatedNotifier, files) - return tree -} - -const notifierTemplate = "modules/notifier/files/notifier.conf" - // What the gate composes for the module in its template form is what the mesh composes for it inline. // A module whose files/ is unpacked onto the machine names its own templates directory (issue 731): `module check` diff --git a/cmd/mesh-controller/tier_send_integration_test.go b/cmd/mesh-controller/tier_send_integration_test.go index b933516a..5ab4a20f 100644 --- a/cmd/mesh-controller/tier_send_integration_test.go +++ b/cmd/mesh-controller/tier_send_integration_test.go @@ -302,10 +302,7 @@ func TestAModuleItsSendChangedNothingOfIsLeftAsItWasAndRetried(t *testing.T) { } } - asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it - was := askABuild - askABuild = func(context.Context, buildSource, string, string) (string, error) { return "build-3", nil } - t.Cleanup(func() { askABuild = was }) + everyKeyDone(t) // the retried module is taken from a done key g.health["anchor"] = healthGood said, err := retryPlan(ctx, g.open, "plan-gate") if err != nil { @@ -313,7 +310,7 @@ func TestAModuleItsSendChangedNothingOfIsLeftAsItWasAndRetried(t *testing.T) { } p = g.plan(t) s := p.Modules["app"] - if p.State != inventory.PlanBuilding || s.State != "asked" || s.Build != "build-3" || s.FirstAt != nil || s.Gate != nil { + if p.State != inventory.PlanBuilding || s.State != "asked" || !s.Key || s.FirstAt != nil || s.Gate != nil { t.Fatalf("retried as %q: the plan is %s, app %+v", said, p.State, s) } } diff --git a/cmd/mesh-controller/try.go b/cmd/mesh-controller/try.go index 7dcd4ed3..b7ed722a 100644 --- a/cmd/mesh-controller/try.go +++ b/cmd/mesh-controller/try.go @@ -63,13 +63,6 @@ type tryAnswer struct { Last string `json:"last,omitempty"` } -// isTry says a check's outcome is a try's: marked so, or — echoed by a build seat from before the mark — a -// check of no pull request and no group, which is nobody's status either. -func isTry(result link.BuildResult) bool { - c := result.Checked - return c != nil && (c.Try || (c.Number == 0 && c.Group == "")) -} - // checkerModule is the module whose tools judge a try (mesh-delivery-checker). const checkerModule = "mesh-delivery-checker" diff --git a/cmd/mesh-controller/try_integration_test.go b/cmd/mesh-controller/try_integration_test.go index 5885470c..f3da3b01 100644 --- a/cmd/mesh-controller/try_integration_test.go +++ b/cmd/mesh-controller/try_integration_test.go @@ -11,38 +11,8 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" ) -func TestATrysOutcomeSetsNoStatusAndRegistersNothing(t *testing.T) { - said := &saidOnTheBus{} - checkEvents = said - defer func() { checkEvents = nil }() - for name, result := range map[string]link.BuildResult{ - "a try": {ID: "build-1", Ref: "c0ffee", Checked: &link.CheckRequest{Owner: "novox", Repo: "a", Try: true}, - Check: &link.CheckOutcome{Verdict: "pass", RepoCheck: &link.CheckLayer{Verdict: "fail", Summary: "failed"}}}, - "a try that could not run": {ID: "build-2", Checked: &link.CheckRequest{Owner: "novox", Repo: "a", Try: true}, - Failed: "cannot clone"}, - // A build seat from before the mark echoes the ask without it: a check of no pull request and no - // group is still nobody's status. - "a try echoed by an older build seat": {ID: "build-3", Checked: &link.CheckRequest{Owner: "novox", Repo: "a"}, - Check: &link.CheckOutcome{Verdict: "pass"}}, - } { - if err := (builds{}).Built(context.Background(), result); err != nil { - t.Fatalf("%s: %v", name, err) - } - } - if len(said.events) != 0 { - t.Fatalf("a try's outcome was said for the forge: %v", said.events) - } - // A pull request's check is still said. - _ = (builds{}).Built(context.Background(), link.BuildResult{ID: "build-4", Checked: &link.CheckRequest{ - Owner: "novox", Repo: "a", Number: 7}, Check: &link.CheckOutcome{Verdict: "pass"}}) - if len(said.events) != 1 { - t.Fatalf("a pull request's check was said %d time(s)", len(said.events)) - } -} - // ADR 0501 §5 (issue 1851): a try is asked of the checker, the head merged onto the trunk, never of build-agent: // --store-only is the integration level, --tests passes on, the ask answers the checker's id. func TestATryIsAskedOfTheChecker(t *testing.T) { diff --git a/cmd/mesh-controller/usage_test.go b/cmd/mesh-controller/usage_test.go index 034ac0b2..d6239e10 100644 --- a/cmd/mesh-controller/usage_test.go +++ b/cmd/mesh-controller/usage_test.go @@ -9,7 +9,8 @@ import ( // C3 (issue 1851 A11a, A11b, A13; ADR 0501 §4, §5): the terminal's help lists no command it refuses as removed, and lists // the ones R4e added: `register` and `switch`. func TestTheHelpListsNoRemovedCommandAndTheNewOnes(t *testing.T) { - for _, removed := range []string{"build", "rebuild", "forget-checks", "delivery plan"} { + for _, removed := range []string{"build", "rebuild", "forget-checks", "delivery plan", "queue", "cancel", "clear", "replay", + "kill", "pause", "builder issue"} { if regexp.MustCompile(`(?m)^ ` + regexp.QuoteMeta(removed) + ` `).MatchString(usageText) { t.Errorf("the help still lists %q, which is refused as removed", removed) } diff --git a/cmd/mesh-controller/walk_keys_scaffold_integration_test.go b/cmd/mesh-controller/walk_keys_scaffold_integration_test.go new file mode 100644 index 00000000..d957e9c0 --- /dev/null +++ b/cmd/mesh-controller/walk_keys_scaffold_integration_test.go @@ -0,0 +1,137 @@ +//go:build integration + +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-controller/internal/builderkeys" + "github.com/novox/mesh-controller/internal/inventory" +) + +// A walk takes every module from the builder's done key and asks no build (R4g, ADR 0501, ADR 0503 §6). The tests of what +// a walk does around its builds (its tiers, sends, stops, retries, backups and waits for its delivery's word) need the +// builds to happen without a builder, so this stands one in: every module has a done key at once, and the take-in of a key +// records the build and registers the module as the real one does, in the caller's turn. The tests that are of the take-in +// itself (key_takein, walk_from_keys, first_builds) use a builder on a bus of their own (builder_on_the_bus_test.go). + +// keysAllDone is the indexer and the builder as a walk reads them when every module is built at every commit. +type keysAllDone struct { + mu sync.Mutex + paths [][3]string // repository, path and commit of each key taken in, in order + named []string // the same, as repository#id +} + +// fpOfModuleAt is the fingerprint a module has at a commit in these tests: one for each pair. +func fpOfModuleAt(module, commit string) string { + sum := sha256.Sum256([]byte(module + "@" + commit)) + return "src2:" + hex.EncodeToString(sum[:]) +} + +func (k *keysAllDone) ModuleAt(_ context.Context, module, commit string) (sdkdelivery.Module, error) { + return sdkdelivery.Module{FP2: fpOfModuleAt(module, commit), Commit: commit, Repository: "novox/mesh-catalog", + Path: "modules/" + module}, nil +} + +func (k *keysAllDone) Build(_ context.Context, module, fp string) (builderkeys.Key, error) { + digest := "sha256:" + fp[len("src2:"):] + return builderkeys.Key{Module: module, Fingerprint: fp, Found: true, Revision: 1, + Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildDone, By: "ace", Artifacts: []sdkdelivery.BuiltArtifact{{ + Name: "a", Kind: "archive", Digest: digest, Reference: "artifact-store://" + module + "/a/blobs/" + digest}}}}, nil +} + +// take is the take-in of a key as the walk's look starts it: the build recorded and the module registered at the commit the +// walk carries. +func (k *keysAllDone) take(ctx context.Context, open *stores, module string, trunk sdkdelivery.Module, id string) { + inv := open.inventory + defer func() { + keyTakes.Lock() + delete(keyTakes.running, id) + keyTakes.taken[id] = true + keyTakes.Unlock() + askToLook() + }() + m := catalogue.Manifest{Module: module, Version: "1"} + if shelf, err := inv.Catalogue(ctx); err == nil { + if held, ok := shelf[module]; ok { + m = held + } + } + src, err := inv.SourceOf(ctx, module) + if err != nil { + src = inventory.Source{Repository: trunk.Repository, Seat: "git", Ref: "main", Path: keyPath(trunk)} + } + src.BuiltFrom, src.Head = trunk.Commit, trunk.Commit + raw, _ := json.Marshal(m) + if err := inv.RecordBuild(ctx, inventory.Build{ID: id, Module: module, Repository: src.Repository, Commit: trunk.Commit, + Path: src.Path, On: "ace", At: time.Now().UTC(), Manifest: raw, SourceFingerprint: trunk.FP2}); err != nil { + keepKeyWait(id, "the build could not be recorded: "+err.Error()) + return + } + if err := inv.RegisterModule(ctx, m, src); err != nil { + keepKeyWait(id, "the module could not be registered: "+err.Error()) + return + } + k.mu.Lock() + defer k.mu.Unlock() + k.paths = append(k.paths, [3]string{src.Repository, src.Path, trunk.Commit}) + k.named = append(k.named, src.Repository+"#"+id) +} + +// everyKeyDone has the walks of a test take every module from a done key, and says which were taken. +func everyKeyDone(t *testing.T) *keysAllDone { + t.Helper() + k := &keysAllDone{} + reset := func() { + keyTakes.Lock() + keyTakes.running, keyTakes.waits, keyTakes.taken = map[string]bool{}, map[string]string{}, map[string]bool{} + keyTakes.Unlock() + } + reset() + wasKeys, wasTake, wasBackground := openPlanKeys, takeKeyIn, inBackground + openPlanKeys = func() (planKeyReads, func(), error) { return k, func() {}, nil } + takeKeyIn = k.take + inBackground = func(work func()) { work() } + t.Cleanup(func() { + openPlanKeys, takeKeyIn, inBackground = wasKeys, wasTake, wasBackground + reset() + }) + return k +} + +// asksRecorded has the walks of a test take every module from a done key, and says each as repository#build id. +func asksRecorded(t *testing.T) *[]string { + t.Helper() + return &everyKeyDone(t).named +} + +// asksWithPaths is asksRecorded saying each as repository, path and the commit the walk carried. +func asksWithPaths(t *testing.T) *[][3]string { + t.Helper() + return &everyKeyDone(t).paths +} + +// asksOfEveryModule once made every module the builder's own, the one ask left; a walk asks nothing now. +func asksOfEveryModule(t *testing.T) { t.Helper() } + +// noBuildAsked once failed a test that asked build-agent for a build; nothing can now, so it says nothing. +func noBuildAsked(t *testing.T) { t.Helper() } + +// twoTiers registers two modules, b standing on a, each with a source a plan asks. +func twoTiers(t *testing.T, open *stores) { + t.Helper() + for _, name := range []string{"a", "b"} { + if err := open.inventory.RegisterModule(t.Context(), catalogue.Manifest{Module: name, Version: "1"}, + inventory.Source{Repository: "novox/" + name, Seat: "git", Ref: "main", BuiltFrom: "c0ffee", Head: "c0ffee"}); err != nil { + t.Fatal(err) + } + } +} diff --git a/cmd/mesh-controller/walk_said_integration_test.go b/cmd/mesh-controller/walk_said_integration_test.go index f53ed71c..25aba361 100644 --- a/cmd/mesh-controller/walk_said_integration_test.go +++ b/cmd/mesh-controller/walk_said_integration_test.go @@ -138,7 +138,7 @@ func TestAWalkRetriedByAPersonIsSaidStartedAgain(t *testing.T) { Created: before, State: inventory.PlanFailed, Tier: 0, Tiers: [][]string{{"a"}, {"b"}}, Note: "a failed to build in tier 0", Modules: map[string]*inventory.PlanModule{"a": {State: "failed", AskedAt: &before, Build: "build-1", - Why: link.KilledByHand}}, + Why: "killed by hand"}}, Times: &inventory.PlanTimes{Cut: &before, Started: &before, Ended: &ended, Class: inventory.ClassLeaf}} if err := open.inventory.SavePlan(ctx, &failed); err != nil { t.Fatal(err) @@ -177,35 +177,6 @@ func asTyped(t *testing.T, args ...string) error { return err } -func TestAWalkEndedByCancelOrClearIsSaidEnded(t *testing.T) { - _, ask := aBuildQueue(t) - open := aMesh(t) - next := planMovedHeard(t) - twoTiers(t, open) - for _, verb := range [][]string{{"cancel"}, {"clear"}} { - id := link.NewBuildID(time.Now()) - ask(id, "https://forge.example/novox/a.git") - asked, _ := link.BuildAskedAt(id) - walk := aStartedWalkAsking(t, open, "plan-308-"+verb[0], id, asked) - args := verb - if verb[0] == "cancel" { - args = append(args, id) - } - if err := asTyped(t, args...); err != nil { - t.Fatal(err) - } - ended := next(inventory.WalkEnded) - if ended["walk"] != walk.ID || ended["outcome"] != inventory.WalkFailed { - t.Fatalf("%s: the end is %v", verb[0], ended) - } - // Ended, and so the next walk may be cut on the same repository. - p, _ := open.inventory.PlanByID(t.Context(), walk.ID) - if p.State != inventory.PlanFailed { - t.Fatalf("%s left the walk %s", verb[0], p.State) - } - } -} - func TestAWalkStoppedByHandAndRetriedEndsFailedWhenItFailsAgain(t *testing.T) { open := aMesh(t) ctx := t.Context() @@ -234,8 +205,12 @@ func TestAWalkStoppedByHandAndRetriedEndsFailedWhenItFailsAgain(t *testing.T) { if p.State != inventory.PlanBuilding || (p.Delivery != nil && p.Delivery.Stopped != "") { t.Fatalf("%s: the walk is %s and keeps its stop: %+v", again[0], p.State, p.Delivery) } - asking, _ := link.BuildAskedAt(p.Modules["a"].Build) - planBuilt(ctx, open, "a", "c0ffee", "it broke again", asking, p.Modules["a"].Build) + // The build asked fails again: its record says so, and the look settles the walk from it. + if err := open.inventory.RecordBuild(ctx, inventory.Build{ID: p.Modules["a"].Build, Module: "a", Commit: "c0ffee", + Repository: "novox/a", At: time.Now().UTC(), Asked: before, Failed: "it broke again"}); err != nil { + t.Fatal(err) + } + advancePlans(ctx, open) if p, _ = open.inventory.PlanByID(ctx, walk.ID); p.State != inventory.PlanFailed || p.Outcome() != inventory.WalkFailed { t.Fatalf("%s: failing again, the walk is %s and ends %s", again[0], p.State, p.Outcome()) } diff --git a/cmd/mesh-controller/walk_wait_rebuild_754_test.go b/cmd/mesh-controller/walk_wait_rebuild_754_test.go index ee9616b3..97b134cb 100644 --- a/cmd/mesh-controller/walk_wait_rebuild_754_test.go +++ b/cmd/mesh-controller/walk_wait_rebuild_754_test.go @@ -8,39 +8,6 @@ import ( "github.com/novox/mesh-controller/internal/inventory" ) -// novox/hq issue 754: a walk waiting for its delivery's word takes no build's outcome, nor does a walk take one for -// a module it never asked; asked under an id, it takes that id's alone. -func TestAWalkTakesOnlyTheOutcomesOfWhatItAsked(t *testing.T) { - asked := time.Date(2026, 10, 14, 23, 30, 0, 0, time.UTC) - before := asked.Add(-time.Minute) - waiting := inventory.Plan{Delivery: &inventory.PlanDelivery{Awaits: "mesh-delivery"}} - letGo := inventory.Plan{Delivery: &inventory.PlanDelivery{Awaits: "mesh-delivery", Go: &asked}} - own := &inventory.PlanModule{State: "asked", AskedAt: &asked, Build: "build-own"} - old := &inventory.PlanModule{State: "asked", AskedAt: &asked} - for _, c := range []struct { - what string - p inventory.Plan - state *inventory.PlanModule - at time.Time - id string - takes bool - }{ - {"a rebuild while the walk waits", waiting, nil, asked, "build-rebuild", false}, - {"its own id while it waits", waiting, own, asked, "build-own", false}, - {"a module it never asked", letGo, nil, asked, "build-rebuild", false}, - {"a module it never asked, state kept empty", letGo, &inventory.PlanModule{}, asked, "build-rebuild", false}, - {"another id", letGo, own, asked, "build-rebuild", false}, - {"its own id", letGo, own, asked, "build-own", true}, - {"a plan from before ids, asked after it", letGo, old, asked.Add(time.Second), "x", true}, - {"a plan from before ids, asked before it", letGo, old, before, "x", false}, - {"a plan with no delivery, its own id", inventory.Plan{}, own, asked, "build-own", true}, - } { - if got := takesOutcome(c.p, c.state, c.at, c.id); got != c.takes { - t.Errorf("%s: takes %v, want %v", c.what, got, c.takes) - } - } -} - // novox/hq issue 754: a tier partly asked asks the rest; a module held built that the plan never asked — the live // shape, a hand rebuild taken in during the wait, built after the plan was made — is asked again; one asked is kept. func TestATierPartlyAskedAsksTheRest(t *testing.T) { diff --git a/cmd/mesh-controller/watchdogs.go b/cmd/mesh-controller/watchdogs.go index c714678d..208c98da 100644 --- a/cmd/mesh-controller/watchdogs.go +++ b/cmd/mesh-controller/watchdogs.go @@ -79,9 +79,6 @@ type signalFacts struct { loop loopFacts loopErr error - asks []askFacts - asksErr error - calls []link.Call standings []conditions.Condition @@ -175,7 +172,6 @@ type planFacts struct { entered time.Time bound time.Duration waiting string - paused bool bus bool } @@ -195,12 +191,6 @@ type loopFacts struct { where string } -type askFacts struct { - id, seat, what, state, on string - since time.Time - bound time.Duration -} - type selfCheckFacts struct { last time.Time every time.Duration @@ -366,7 +356,6 @@ func (w *watchdogs) gather(ctx context.Context) *signalFacts { f.held, f.heldErr = gatherHeldMoves(ctx, inv) f.images, f.imagesErr = gatherMissingImagesHere(ctx, inv) f.loop, f.loopErr = w.gatherLoop() - f.asks, f.asksErr = w.gatherAsks(ctx, inv) if open, err := w.keeper.Open(ctx); err != nil { f.standingsErr = err } else { @@ -544,7 +533,6 @@ func gatherPlans(ctx context.Context, inv *inventory.Inventory, now time.Time, b if err != nil { return nil, nil, err } - pause := buildSeatPause(ctx, inv, plans) var out []planFacts var waits []waitFacts for _, p := range plans { @@ -565,11 +553,10 @@ func gatherPlans(ctx context.Context, inv *inventory.Inventory, now time.Time, b if waitingOnBackups(p) { continue } - _, paused := pausedWaiting(p, pause, now) bound := bounds.of(p.Repository) out = append(out, planFacts{id: p.ID, repository: p.Repository, commit: p.Commit, tier: p.Tier, tiers: len(p.Tiers), entered: inTierSince(p), bound: bound, - waiting: planLineWith(p, now, pause, bound), paused: paused, bus: byTheBus[p.ID]}) + waiting: planLineWith(p, now, bound), bus: byTheBus[p.ID]}) } return out, waits, nil } @@ -629,7 +616,7 @@ func p90(took []time.Duration) time.Duration { return sorted[int(0.9*float64(len(sorted)-1))] } -// loopConsumers are the controller's own consumers: the loop never takes from the build seat's check workers (issue 1063). +// loopConsumers are the controller's own consumers (issue 1063). func loopConsumers() []broker.Consumer { return broker.ControllerConsumers() } // gatherLoop is when the event loop last took a message and what its consumers hold. @@ -664,50 +651,6 @@ func readLoop(took time.Time, consumers []broker.Consumer, held func(broker.Cons return f, nil } -// gatherAsks is every ask in the build seat's queue that is in flight or dead, with its bound. -func (w *watchdogs) gatherAsks(ctx context.Context, inv *inventory.Inventory) ([]askFacts, error) { - if w.js == nil { - return nil, errors.New("this controller is not on the bus") - } - entries, err := inv.Catalogued(ctx) - if err != nil { - return nil, fmt.Errorf("the catalogue cannot be read: %w", err) - } - seat := buildSeatAmong(entries) - q, err := link.ReadQueue(ctx, w.js, seat) - if err != nil { - return nil, err - } - builds, err := inv.Durations(ctx, inventory.DurationBuild, time.Now().Add(-14*24*time.Hour)) - if err != nil { - return nil, fmt.Errorf("the measured builds cannot be read: %w", err) - } - var took []time.Duration - for _, d := range builds { - took = append(took, d.Took) - } - bound := askDefault - if len(took) > 0 { - bound = max(askAtLeast, 3*p90(took)) - } - var out []askFacts - for _, a := range q.Asks { - if a.State != link.AskInFlight && a.State != link.AskDead { - continue - } - since := a.Started - if since.IsZero() { - since = a.AskedAt - } - what := a.Repository - if a.Path != "" { - what += " " + a.Path - } - out = append(out, askFacts{id: a.ID, seat: seat, what: what, state: a.State, on: a.On, since: since, bound: bound}) - } - return out, nil -} - // lostConsumers is, of the consumers the bus said were deleted, each that is still missing and that // the mesh expects: a consumer removed because its module was unassigned is not lost. func (w *watchdogs) lostConsumers(ctx context.Context, heard []link.Advisory) (map[string]bool, error) { diff --git a/go.mod b/go.mod index 791ca25c..1ba1a88c 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 github.com/novox/mesh-host v0.0.0 - github.com/therootcompany/xz v1.0.1 golang.org/x/crypto v0.57.0 golang.org/x/net v0.58.0 golang.org/x/sys v0.48.0 diff --git a/go.sum b/go.sum index bea58f3a..cf2ba6cd 100644 --- a/go.sum +++ b/go.sum @@ -38,8 +38,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw= -github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= diff --git a/internal/artifacts/scratch.go b/internal/artifacts/scratch.go deleted file mode 100644 index cb20b972..00000000 --- a/internal/artifacts/scratch.go +++ /dev/null @@ -1,91 +0,0 @@ -package artifacts - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" -) - -// Listing and letting go of tagged documents: the check builds the scratch namespace keeps (novox/hq ADR 0350 -// §5). A check build is found by its tag, and the build seat's holder lets each go when its pull request closes -// or it is seven days old, so the store keeps a week of check builds and no more. - -// Tags is every tag repository holds, in the store's order. None for a repository the store does not hold. -func (s Store) Tags(ctx context.Context, repository string) ([]string, error) { - if s.Address == "" { - return nil, fmt.Errorf("this mesh has no artifact store on its network to list %s in", repository) - } - var tags []string - next := "http://" + s.Address + "/v2/" + repository + "/tags/list?n=1000" - for next != "" { - request, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil) - if err != nil { - return nil, err - } - response, err := s.client().Do(request) - if err != nil { - return nil, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err) - } - switch response.StatusCode { - case http.StatusOK: - case http.StatusNotFound: - response.Body.Close() - return tags, nil - default: - response.Body.Close() - return nil, fmt.Errorf("the artifact store answered %s for the tags of %s", response.Status, repository) - } - var page struct { - Tags []string `json:"tags"` - } - err = json.NewDecoder(io.LimitReader(response.Body, 8<<20)).Decode(&page) - link := response.Header.Get("Link") - response.Body.Close() - if err != nil { - return nil, fmt.Errorf("the tags of %s could not be read: %w", repository, err) - } - tags = append(tags, page.Tags...) - next = nextPage(link, s.Address) - } - return tags, nil -} - -// nextPage is the registry's `Link: ; rel="next"`, made whole; empty when there is no next page. -func nextPage(link, address string) string { - if !strings.Contains(link, `rel="next"`) { - return "" - } - start, end := strings.Index(link, "<"), strings.Index(link, ">") - if start < 0 || end <= start+1 { - return "" - } - target := link[start+1 : end] - if strings.HasPrefix(target, "/") { - return "http://" + address + target - } - return target -} - -// DeleteTagged lets go of the document tag names in repository: its manifest, by digest, so the next -// collection takes its layer. Gone when the tag names nothing. -func (s Store) DeleteTagged(ctx context.Context, repository, tag string) error { - if s.Address == "" { - return fmt.Errorf("this mesh has no artifact store on its network to let go of %s:%s in", repository, tag) - } - digest, err := s.manifestDigest(ctx, repository, tag) - if err != nil { - return err - } - if digest == "" { - return Gone - } - err = s.remove(ctx, s.url(repository, "manifests", digest), repository+":"+tag) - if errors.Is(err, Gone) { - return Gone - } - return err -} diff --git a/internal/beside/beside.go b/internal/beside/beside.go index b64f67cc..233f1ab6 100644 --- a/internal/beside/beside.go +++ b/internal/beside/beside.go @@ -8,7 +8,7 @@ // - In a merge check, the build seat clones each core repository beside the one checked (the catalogue // at its main, the node-engine at the commit the mesh runs) and says where in MESH_CHECK_BESIDE. A // test judges against those clones, so agreement with the other repository is checked where -// `mesh/repo-check` runs; a repository missing there fails the test, never skips it. Which clones a +// `mesh/check` runs; a repository missing there fails the test, never skips it. Which clones a // check gets is chosen from the inventory: mesh-catalog by the source of the `nats` module, mesh-host // by the source of `mesh-host`, and mesh-sdk as the controller's sibling at the commit the controller's // go.mod pins. In a mesh where either module has no source repository nothing is diff --git a/internal/broker/announced.go b/internal/broker/announced.go index 5f559b88..7379e0a9 100644 --- a/internal/broker/announced.go +++ b/internal/broker/announced.go @@ -17,9 +17,9 @@ import ( const StatesAnnouncedBucket = announce.KeptBucket // controllerReadable are the controller's states others may read: the record mirrors (the packages each node declares -// among them, which the pacman module reads, ADR 0507 §5), the chain's buckets, the checks running and the plans building. +// among them, which the pacman module reads, ADR 0507 §5) and the chain's buckets. var controllerReadable = []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts, - ChecksRunning, PlansBuilding, MirrorDeclaredPackages} + MirrorDeclaredPackages} // ControllerAnnouncement is what the controller announces it keeps (ADR 0495 clause 2): every bucket of its own by its local // name, the ones others may read marked readable. It announces itself, as any module does from its code. diff --git a/internal/broker/announced_test.go b/internal/broker/announced_test.go index 8b4c1167..a05830b8 100644 --- a/internal/broker/announced_test.go +++ b/internal/broker/announced_test.go @@ -38,7 +38,7 @@ func TestTheControllerAnnouncesWhatItKeeps(t *testing.T) { readable = append(readable, k.Name) } } - want := []string{ChainBuilds, ChecksRunning, MirrorDeclaredConfig, PlansBuilding, MirrorPredefined, ChainRollouts, + want := []string{ChainBuilds, MirrorDeclaredConfig, MirrorPredefined, ChainRollouts, MirrorAssignments, MirrorDeclaredPackages} slices.Sort(want) if !slices.Equal(readable, want) { diff --git a/internal/broker/checks.go b/internal/broker/checks.go deleted file mode 100644 index cb3b16ea..00000000 --- a/internal/broker/checks.go +++ /dev/null @@ -1,127 +0,0 @@ -package broker - -import "strings" - -// The pull requests' checks have work queues of their own (novox/hq ADR 0406 §3, issue 974). -// -// A delivery build goes on the build seat's own queue, `mesh.seat.node-build-agent.accept.build`. A check of a pull -// request marked ready goes on a second work-queue stream and every other check on a third. A builder takes from the -// first, then the second, then the third; the others are taken in the order asked. **Each check stream has one subject -// per pull request and keeps only the newest request** (a limit of one message a subject, the older discarded), so a -// newer request replaces one still waiting and a pull request has at most one waiting check: the stream's rule, not a -// pass of the controller that moves asks about. Nothing pauses the worker or deletes and republishes a request. - -const ( - // ChecksReadyStream holds the checks of pull requests marked ready; ChecksOtherStream every other check. - ChecksReadyStream = "CHECKS_READY" - ChecksOtherStream = "CHECKS_OTHER" - // ChecksReadyWorker and ChecksOtherWorker are the durable consumers the build seat's holders share on them, as - // they share the seat's worker on its own queue (novox/hq ADR 0190). - ChecksReadyWorker = "CHECKS_READY_worker" - ChecksOtherWorker = "CHECKS_OTHER_worker" - // TheBuildSeat is the role whose checks these streams queue. The retired build role's checks stay on its own queue. - TheBuildSeat = "node-build-agent" -) - -// PullsBucket is mesh-delivery's key per pull request: its head, the trunk commit its current check is asked against and -// its verdict (ADR 0406 §4). A running check watches its key. -var PullsBucket = BucketName("mesh-delivery", "pulls") - -// CheckSubjectPrefix is where a seat's checks of one kind are asked: `mesh.seat..check.ready.` or `.other.`. The -// `check` token keeps the subjects out of the seat's own queue, which takes `accept.>`. -func CheckSubjectPrefix(seat string, ready bool) string { - kind := "other" - if ready { - kind = "ready" - } - return "mesh.seat." + seat + ".check." + kind + "." -} - -// CheckStreams are the two check queues, in the order a builder takes from them. -func CheckStreams() []Stream { - return []Stream{ - { - Name: ChecksReadyStream, - Subjects: []string{CheckSubjectPrefix(TheBuildSeat, true) + ">"}, - Retention: RetentionWorkQueue, - MaxAge: 7 * 24 * 60 * 60, - MaxMsgsPerSubject: 1, - Direct: true, - Why: "the checks of pull requests marked ready, taken after a delivery build and before any other check " + - "(novox/hq ADR 0406 §3); one subject a pull request, the newest request kept, so a newer one replaces a " + - "request still waiting", - }, - { - Name: ChecksOtherStream, - Subjects: []string{CheckSubjectPrefix(TheBuildSeat, false) + ">"}, - Retention: RetentionWorkQueue, - MaxAge: 7 * 24 * 60 * 60, - MaxMsgsPerSubject: 1, - Direct: true, - Why: "every other check, taken last and in the order asked (novox/hq ADR 0406 §3); one subject a pull " + - "request, the newest request kept; one left behind for a head already judged is dropped when taken", - }, - } -} - -// CheckWorkers are the consumers the build seat's holders pull the check queues through: as the seat's own worker is, -// one for every holder, one ask at a time, acknowledged when the check is done. -func CheckWorkers() []Consumer { - worker := func(name, stream string, ready bool) Consumer { - return Consumer{ - Name: name, - Stream: stream, - Filters: []string{CheckSubjectPrefix(TheBuildSeat, ready) + ">"}, - AckWaitSeconds: 60, - MaxDeliver: 5, - Why: "every holder of " + TheBuildSeat + " pulls one check at a time from this worker and acknowledges after " + - "the check is done, so a crash mid-check redelivers rather than loses (novox/hq ADR 0406 §3)", - } - } - return []Consumer{ - worker(ChecksReadyWorker, ChecksReadyStream, true), - worker(ChecksOtherWorker, ChecksOtherStream, false), - } -} - -// holderCheckGrants are what a holder of the build seat is granted to take checks: ask about the two workers, pull from -// them and acknowledge, and nothing of the subjects themselves. -func holderCheckGrants() []string { - var pub []string - for _, w := range CheckWorkers() { - pub = append(pub, - "$JS.API.CONSUMER.INFO."+w.Stream+"."+w.Name, - "$JS.API.CONSUMER.MSG.NEXT."+w.Stream+"."+w.Name, - "$JS.ACK."+w.Stream+"."+w.Name+".>") - } - // And the bucket of the checks they are running: each writes the key of its own check, reads another's, and the bucket - // is a controller's own bucket the build seat's holders write by design (ADR 0406 §3). - pub = append(pub, - "$KV."+CheckRunningBucket+".>", - "$JS.API.STREAM.INFO.KV_"+CheckRunningBucket, - "$JS.API.DIRECT.GET.KV_"+CheckRunningBucket+".>") - // And, to hold checks while a delivery builds (ADR 0483, issues 1687 and 1688): the bucket of the plans building, **read and - // never written** (the controller's alone, so no `$KV.` subject of it is granted), and the oldest waiting ask of each check - // queue, read by a direct get that takes nothing from the queue and so counts against no delivery of the ask. - pub = append(pub, readGrants(PlansBuildingBucket, "")...) - for _, w := range CheckWorkers() { - pub = append(pub, "$JS.API.DIRECT.GET."+w.Stream, "$JS.API.DIRECT.GET."+w.Stream+".>") - } - return pub -} - -// IsCheckStream says a stream is one of the check queues. -func IsCheckStream(stream string) bool { - return stream == ChecksReadyStream || stream == ChecksOtherStream -} - -// isCheckAsk says a message of a check queue is an ask the controller itself makes. -func isCheckAsk(stream, subject string) bool { - switch stream { - case ChecksReadyStream: - return strings.HasPrefix(subject, CheckSubjectPrefix(TheBuildSeat, true)) - case ChecksOtherStream: - return strings.HasPrefix(subject, CheckSubjectPrefix(TheBuildSeat, false)) - } - return false -} diff --git a/internal/broker/checks_running_mirror_test.go b/internal/broker/checks_running_mirror_test.go deleted file mode 100644 index aec691ea..00000000 --- a/internal/broker/checks_running_mirror_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package broker - -import ( - "slices" - "testing" -) - -// 1155/AC1: a module whose manifest reads mesh-controller.checks-running may watch and get every key of the bucket and -// write none. -func TestAModuleReadingChecksRunningReadsAndWritesNone(t *testing.T) { // 1155/AC1 - if !slices.Contains(controllerReadableStates(), "checks-running") || BucketName(ControllerSeat, "checks-running") != CheckRunningBucket { - t.Fatalf("a module cannot name %s in `reads`: %v", CheckRunningBucket, controllerReadableStates()) - } - perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-watchman", PasswordHash: "x", - Reads: []string{"mesh-controller.checks-running"}}) - if err != nil { - t.Fatal(err) - } - stream := "KV_" + CheckRunningBucket - has(t, perms.Publish, "$JS.API.STREAM.INFO."+stream) - has(t, perms.Publish, "$JS.API.DIRECT.GET."+stream+".>") - has(t, perms.Publish, "$JS.API.CONSUMER.CREATE."+stream+".>") - for _, p := range perms.Publish { - if SubjectsOverlap(p, "$KV."+CheckRunningBucket+".any.key") { - t.Errorf("the reading module may write %s through %q", CheckRunningBucket, p) - } - } -} - -// 1155/AC3: the writers table names checks-running's writer as the build seat's holders, and the rule that a mirror is -// written by the controller alone names it as the exception, with its reason. -func TestChecksRunningIsAMirrorWrittenByTheBuildSeatsHolders(t *testing.T) { // 1155/AC3 - why := MirrorsWrittenByAnother["checks-running"] - if why == "" { - t.Fatal("the mirrors written by another do not name checks-running with a reason") - } - subject := "$KV." + CheckRunningBucket + ".novox.a.7" - var rows int - for _, row := range WritersTable { - for _, s := range row.Subjects { - if s != "$KV."+CheckRunningBucket+".>" { - continue - } - rows++ - holder := Principal{Kind: KindModule, Module: "builder", Node: "one", Holds: []Seat{{Name: TheBuildSeat}}} - other := Principal{Kind: KindModule, Module: "mesh-delivery-watchman", Node: "one"} - if row.Writes == nil || !row.Writes(holder, subject) || row.Writes(other, subject) { - t.Errorf("%q is not written by the build seat's holders alone", row.State) - } - } - } - if rows != 1 { - t.Fatalf("%d rows of the writers table write %s, want one", rows, CheckRunningBucket) - } - for m := range MirrorsWrittenByAnother { - if m != "checks-running" { - t.Errorf("the mirror %q is written by another and is not checks-running", m) - } - } -} diff --git a/internal/broker/checks_test.go b/internal/broker/checks_test.go deleted file mode 100644 index 9fd3e095..00000000 --- a/internal/broker/checks_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package broker - -import "testing" - -// The build agent's credential is the module principal `.build-agent` (Username, KindModule): its manifest's -// `reads: ["mesh-delivery.pulls"]` reaches that principal as a read and a watch of mesh-delivery's bucket of pull requests' -// keys — the direct get of a key, the consumer a watch makes, the stream's info — and as no write and no direct -// subscription of the bucket's subjects (novox/hq ADR 0406 §4, issue 974). -func TestTheBuildAgentReadsAndWatchesThePullRequestsKeys(t *testing.T) { - seat := Seat{Name: TheBuildSeat, Scope: "node", Accepts: []string{"build"}, Emits: []string{"built", "started"}} - p := Principal{Kind: KindModule, Node: "one", Module: "build-agent", Holds: []Seat{seat}, - Reads: []string{"mesh-delivery.pulls"}, PasswordHash: "x"} - if got := p.Username(); got != "one.build-agent" { - t.Fatalf("the build agent's credential is %q", got) - } - perms, err := PermissionsFor(p) - if err != nil { - t.Fatal(err) - } - for _, s := range []string{ - "$JS.API.STREAM.INFO.KV_mesh-delivery_pulls", - "$JS.API.DIRECT.GET.KV_mesh-delivery_pulls.>", - "$JS.API.CONSUMER.CREATE.KV_mesh-delivery_pulls.>", - "$JS.API.CONSUMER.DELETE.KV_mesh-delivery_pulls.>", - // And the two check workers it takes from. - "$JS.API.CONSUMER.MSG.NEXT.CHECKS_READY.CHECKS_READY_worker", - "$JS.API.CONSUMER.MSG.NEXT.CHECKS_OTHER.CHECKS_OTHER_worker", - "$JS.ACK.CHECKS_OTHER.CHECKS_OTHER_worker.>", - // And the bucket of the checks running, which it writes and reads (ADR 0406 §3). - "$KV." + CheckRunningBucket + ".>", - "$JS.API.DIRECT.GET.KV_" + CheckRunningBucket + ".>", - } { - has(t, perms.Publish, s) - } - hasNot(t, perms.Publish, "$KV."+PullsBucket+".>") - hasNot(t, perms.Publish, "mesh.seat.node-build-agent.check.>") - for _, s := range perms.Subscribe { - if s == "$KV."+PullsBucket+".>" { - t.Fatalf("the build agent subscribes the bucket's subjects directly: %v", perms.Subscribe) - } - } -} - -// Issue 1687 (ADR 0483 clause 2): a holder of the build seat reads the bucket of the plans building and writes none of it, and -// reads the oldest waiting ask of each check queue by a direct get, which takes nothing from the queue. -func TestTheBuildSeatsHoldersReadThePlansBuildingAndWriteNone(t *testing.T) { - seat := Seat{Name: TheBuildSeat, Scope: "node", Accepts: []string{"build"}, Emits: []string{"built", "started"}} - p := Principal{Kind: KindModule, Node: "one", Module: "build-agent", Holds: []Seat{seat}, PasswordHash: "x"} - perms, err := PermissionsFor(p) - if err != nil { - t.Fatal(err) - } - stream := "KV_" + PlansBuildingBucket - for _, s := range []string{ - "$JS.API.STREAM.INFO." + stream, - "$JS.API.DIRECT.GET." + stream + ".>", - "$JS.API.CONSUMER.CREATE." + stream + ".>", - "$JS.API.DIRECT.GET.CHECKS_READY", - "$JS.API.DIRECT.GET.CHECKS_OTHER", - } { - has(t, perms.Publish, s) - } - for _, s := range perms.Publish { - if SubjectsOverlap(s, "$KV."+PlansBuildingBucket+".plan-1") { - t.Errorf("a holder of the build seat may write %s through %q", PlansBuildingBucket, s) - } - } - // And the controller alone writes it, as the writers table says. - for _, row := range WritersTable { - for _, s := range row.Subjects { - if s == "$KV."+PlansBuildingBucket+".>" && row.Writes(p, "$KV."+PlansBuildingBucket+".plan-1") { - t.Errorf("%q lets the build seat's holder write the plans building", row.State) - } - } - } -} diff --git a/internal/broker/controller_buckets.go b/internal/broker/controller_buckets.go index 0462f01d..e584b88d 100644 --- a/internal/broker/controller_buckets.go +++ b/internal/broker/controller_buckets.go @@ -50,39 +50,12 @@ var ( // controller's watcher hearing the expiry ends the wait and names the machine silent. Written by the lease // holder alone. RolloutSilentBucket = BucketName(ControllerSeat, "rollout-silent") - // CheckRunningBucket holds the pull request checks the builders are running now (novox/hq ADR 0406 §3, issue 974): per - // pull request a key `..` (or `group.`) holding the head and trunk commit being checked, the - // builder and its build. A builder puts it when it starts a check and again on each heartbeat, and deletes it when the - // check ends; a key not renewed within the bucket's age is a builder that died, and is gone. A builder that takes a - // waiting request for a head and trunk commit a key names drops it. **Written by the build seat's holders, several by - // design** — each the key of the check it runs. - CheckRunningBucket = BucketName(ControllerSeat, ChecksRunning) // DeliveryMergesBucket holds the merges mesh-delivery announced it made, each with the ask whose Approve it merged // on (novox/hq ADR 0420 §7, issue 1025): per merge a key `///` holding the // announcement, kept DeliveryMergesKeptFor. What lets the build of a moved module register it from its new // repository: the controller reads the ask's warrant from the router and accepts only the operator's Approve of // those heads. Written by the controller alone, from the announcement, when it holds the lease. DeliveryMergesBucket = BucketName(ControllerSeat, "delivery-merges") - // PlansBuildingBucket holds one key per delivery plan in its build phase (novox/hq ADR 0483 clause 2, issue 1687): the key - // is `` (`plan-`), its value the plan, the time its build phase began and the time of the last refresh. - // The controller (lease holder) puts it when the plan's first build is asked, puts it again about every thirty seconds - // while the plan builds, and deletes it when the phase ends (every build of the last tier built, or the plan fails, stops - // or waits on the operator); the bucket's age, PlansBuildingKeptFor, is how long a key outlives a controller that - // stopped. **Read by the build seat's holders and the checker, written by the controller alone**: a holder takes no new - // check while any key is there, but for one that waited past ADR 0483's bound. A reader lists the keys and reads none of - // their values to decide that (`link.BuildingPlans`). - PlansBuildingBucket = BucketName(ControllerSeat, PlansBuilding) -) - -// PlansBuilding is the local name of the bucket of the plans building, as a reader names it in `reads` -// (`mesh-controller.plans-building`). -const PlansBuilding = "plans-building" - -// PlansBuildingKeptFor is the age of a plan-building key: the controller puts it again at PlansBuildingRefresh, so a -// controller that stopped leaves checks held at most this long (ADR 0483 clause 2). -const ( - PlansBuildingKeptFor = 2 * time.Minute - PlansBuildingRefresh = 30 * time.Second ) // DeliveryMergesKeptFor is how long a merge mesh-delivery announced is kept: a week, as the events are. @@ -158,15 +131,7 @@ func ChainBuckets() []string { return []string{BuildsBucket, RolloutsBucket, Ass // MirrorsWrittenByAnother are the controller's mirrors whose writer is not the controller, each with its reason: the rule // that a mirror is written by the controller alone (TestTheMirrorsListAndTheWritersTableAgree) has these exceptions and no // other. -var MirrorsWrittenByAnother = map[string]string{ - ChecksRunning: "the checks running are said by the builders themselves: every machine holding the build seat puts the " + - "key of the check it runs, at its start and at each heartbeat, and deletes it when the check ends, so the " + - "controller asserts the bucket and writes none of its keys (ADR 0406 §3); it is readable through `reads` so " + - "the watchman sees a check that stopped (ADR 0433 §6)", -} - -// ChecksRunning is the local name of the bucket of the checks running, as a reader names it in `reads`. -const ChecksRunning = "checks-running" +var MirrorsWrittenByAnother = map[string]string{} // The mirrors' caps (design 54 §5): one value per key, no age, 64 KiB a value, 64 MiB a bucket. const ( @@ -189,10 +154,6 @@ const RolloutSilentMarkerTTL = time.Minute // takes nothing live. const RolloutSilentKeptFor = time.Hour -// CheckRunningKeptFor is the age of a running check's key: a few heartbeats (ten seconds each) of a builder, so a builder that -// died is forgotten within two minutes and the check it ran is taken again then. -const CheckRunningKeptFor = 2 * time.Minute - // AskedKeptFor is how long an ask is kept after it was made: a month, as the router keeps its own. const AskedKeptFor = 30 * 24 * time.Hour @@ -222,16 +183,16 @@ func IsControllerBucket(bucket string) bool { bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket || bucket == AssignmentsBucket || bucket == DeclaredConfigBucket || bucket == PredefinedBucket || bucket == DeclaredPackagesBucket || - bucket == MergeWindowBucket || bucket == RolloutSilentBucket || bucket == CheckRunningBucket || + bucket == MergeWindowBucket || bucket == RolloutSilentBucket || bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket || - bucket == PlansBuildingBucket || bucket == delivery.HaveBucket || bucket == StatesAnnouncedBucket + bucket == delivery.HaveBucket || bucket == StatesAnnouncedBucket } // ControllerBuckets are the controller's own buckets, in the order they are asserted. func ControllerBuckets() []string { return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket, - AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, DeclaredPackagesBucket, MergeWindowBucket, RolloutSilentBucket, CheckRunningBucket, - DeliveryMergesBucket, PlansBuildingBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket, StatesAnnouncedBucket} + AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, DeclaredPackagesBucket, MergeWindowBucket, RolloutSilentBucket, + DeliveryMergesBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket, StatesAnnouncedBucket} } // ControllerBucketsAsserter is what raising the controller's buckets needs of a connection. @@ -359,37 +320,6 @@ func (j *JetStream) EnsureControllerBuckets() error { }); err != nil { return fmt.Errorf("asserting bucket %s: %w", RolloutSilentBucket, err) } - // **The checks running: a key that ages** (ADR 0406 §3, issue 974). One message a key, each renewal a new one, so the - // age of the bucket is how long a key outlives the builder that stopped renewing it. - if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: CheckRunningBucket, - Description: "the pull request checks the builders are running now (novox/hq ADR 0406): `` holds the " + - "head and trunk commit, the builder and its build; put by the builder when it starts and at each heartbeat, " + - "deleted when the check ends, gone within two minutes of a builder that died; written by the build seat's holders", - History: 1, - TTL: CheckRunningKeptFor, - MaxValueSize: 4 << 10, - MaxBytes: 8 << 20, - Storage: jetstream.FileStorage, - }); err != nil { - return fmt.Errorf("asserting bucket %s: %w", CheckRunningBucket, err) - } - // **The plans building: keys that age** (ADR 0483 clause 2, issue 1687). One message a key, each refresh a new one, so the - // age of the bucket is how long a key outlives a controller that stopped refreshing it. - if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: PlansBuildingBucket, - Description: "the delivery plans in their build phase (novox/hq ADR 0483): `` holds the plan and when its " + - "build phase began; put when the plan's first build is asked and about every thirty seconds, deleted when the " + - "phase ends, gone within two minutes of a controller that stopped; read by the build seat's holders, which " + - "take no new check while a key is there; written by the controller alone", - History: 1, - TTL: PlansBuildingKeptFor, - MaxValueSize: 4 << 10, - MaxBytes: 8 << 20, - Storage: jetstream.FileStorage, - }); err != nil { - return fmt.Errorf("asserting bucket %s: %w", PlansBuildingBucket, err) - } // **The states each module announced** (novox/hq ADR 0495 clauses 2 and 3, issue 1790): one key a module, its last word. if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ Bucket: StatesAnnouncedBucket, diff --git a/internal/broker/nats.go b/internal/broker/nats.go index a6980a5d..1d8e66b6 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -237,9 +237,6 @@ var seatsTheControllerAsks = []string{"node-build-agent", "mesh-build-machine"} // original — and no other can (novox/hq issue 334, ADR 0264's consequences: no grant over the seats' // queues). func TheControllersAsk(stream, subject string) bool { - if isCheckAsk(stream, subject) { - return true - } for _, seat := range seatsTheControllerAsks { if stream == seatStreamName(seat) && strings.HasPrefix(subject, "mesh.seat."+seat+".accept.") { return true @@ -424,22 +421,6 @@ var DegradableGrants = []DegradableGrant{ Reason: "refused: the controller closes a batch's window by a timer of its own for that batch, as the " + "window setting says (ADR 0407 decision 1, issue 973)", }, - { - Subject: "mesh.seat." + TheBuildSeat + ".check.>", - Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + - "first push, and a check asked before that is asked again by the next announcement of its head (ADR 0406 §3, " + - "issue 974)", - }, - { - Subject: "$KV." + CheckRunningBucket + ".>", - Reason: "refused: the controller asserts this bucket through the API and writes none of its keys — the build seat's " + - "holders do (ADR 0406 §3, issue 974)", - }, - { - Subject: "$KV." + PlansBuildingBucket + ".>", - Reason: "refused: the controller then says no plan builds, and no check is held while a delivery builds — a check run " + - "wrongly costs a delivery a place, a check held wrongly costs a pull request its turn (ADR 0483 clause 2, issue 1687)", - }, { Subject: "$KV." + DeclaredPackagesBucket + ".>", Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + @@ -661,11 +642,6 @@ func PermissionsFor(p Principal) (Permissions, error) { // the role, and whichever machine holding it is idle takes it. for _, seat := range seatsTheControllerAsks { pub = append(pub, "mesh.seat."+seat+".accept.>") - // **And its checks, on the two queues of their own** (novox/hq ADR 0406 §3, issue 974): one subject a pull - // request on each, under `check`; the retired role's checks stay on its accepts. - if seat == TheBuildSeat { - pub = append(pub, "mesh.seat."+seat+".check.>") - } // **And its holders' verbs, on every machine** (novox/hq ADR 0219): what a holder is // building, kill it, pause it, resume it. The queue is the controller's to show and to // change, and what one machine is doing with an ask it took only that machine can say. @@ -1013,10 +989,6 @@ func PermissionsFor(p Principal) (Permissions, error) { "$JS.API.CONSUMER.INFO."+stream+"."+worker, "$JS.API.CONSUMER.MSG.NEXT."+stream+"."+worker, "$JS.ACK."+stream+"."+worker+".>") - // And the checks' two workers, for the build role they are queued for (novox/hq ADR 0406 §3). - if s.Name == TheBuildSeat { - pub = append(pub, holderCheckGrants()...) - } // **And whether an ask it took was cancelled** (novox/hq ADR 0219): one key of the // seat's cancelled set, read directly by its id, so a holder that fetched an ask in the // moment the controller cancelled it ends it instead of building it. Read, never written: diff --git a/internal/broker/no_build_queue_test.go b/internal/broker/no_build_queue_test.go new file mode 100644 index 00000000..ddd39843 --- /dev/null +++ b/internal/broker/no_build_queue_test.go @@ -0,0 +1,34 @@ +package broker + +import ( + "slices" + "strings" + "testing" +) + +// R4g (issue 1299, ADR 0525 clause 6): the controller declares no work-queue stream for builds or checks and no +// plans-building or checks-running bucket. The streams and buckets a bus already holds are left until a cleanup; none +// is asserted. +func TestTheControllerDeclaresNoBuildQueueAndNoBuildHold(t *testing.T) { + for _, s := range MeshStreams() { + if strings.HasPrefix(s.Name, "CHECKS_") || strings.HasPrefix(s.Name, "SEAT_") { + t.Errorf("the mesh's own streams hold %s, a queue of builds or checks", s.Name) + } + if s.Retention == RetentionWorkQueue && s.Name != "CONTROL" && s.Name != DeadLetterNoticesStream { + t.Errorf("%s is a work queue the controller declares", s.Name) + } + } + for _, c := range MeshConsumers() { + if strings.HasSuffix(c.Name, "_worker") { + t.Errorf("%s is a worker of a build or check queue", c.Name) + } + } + for _, name := range ControllerBuckets() { + if strings.Contains(name, "plans-building") || strings.Contains(name, "checks-running") { + t.Errorf("the controller's buckets hold %s", name) + } + } + if slices.Contains(ControllerBuckets(), "") { + t.Error("an unnamed bucket") + } +} diff --git a/internal/broker/plans_building_live_test.go b/internal/broker/plans_building_live_test.go deleted file mode 100644 index adaad82d..00000000 --- a/internal/broker/plans_building_live_test.go +++ /dev/null @@ -1,134 +0,0 @@ -//go:build integration - -package broker - -import ( - "context" - "errors" - "os" - "path/filepath" - "testing" - "time" - - "github.com/nats-io/nats-server/v2/server" - "github.com/nats-io/nats.go" - "github.com/nats-io/nats.go/jetstream" - "golang.org/x/crypto/bcrypt" -) - -// **A holder of the build seat reads the plans building and the server refuses its write** (novox/hq ADR 0483 clause 2, issue -// 1687): on a real server whose users are composed as the controller composes them, the controller makes the bucket and puts a -// plan's key; the build agent's credential lists the keys and gets one, and its put of a key is refused with a permissions -// violation. It also reads the first waiting ask of a check queue by a direct get, which takes nothing from the queue. -func TestNatsTheBuildSeatsHoldersReadThePlansBuildingAndTheServerRefusesTheirWrite(t *testing.T) { // 1687 - hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost) - if err != nil { - t.Fatal(err) - } - seat := Seat{Name: TheBuildSeat, Scope: "node", Accepts: []string{"build"}, Emits: []string{"built", "started"}} - users, err := Users(Records{Nodes: []string{"alpha"}, Assigned: map[string][]Declared{ - "alpha": {{Module: RuntimeModule}, {Module: "build-agent", Holds: []Seat{seat}}}, - }}) - if err != nil { - t.Fatal(err) - } - for i := range users { - users[i].PasswordHash = string(hash) - } - accounts, err := ComposeAccounts(users) - if err != nil { - t.Fatal(err) - } - conf := filepath.Join(t.TempDir(), "bus.conf") - if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+ - `"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil { - t.Fatal(err) - } - opts, err := server.ProcessConfigFile(conf) - if err != nil { - t.Fatalf("the composed accounts do not parse: %v", err) - } - opts.NoLog, opts.NoSigs = true, true - s, err := server.NewServer(opts) - if err != nil { - t.Fatal(err) - } - go s.Start() - if !s.ReadyForConnections(10 * time.Second) { - t.Fatal("the bus did not come up") - } - t.Cleanup(s.Shutdown) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - connect := func(user string) (*nats.Conn, chan error) { - t.Helper() - refused := make(chan error, 8) - nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user), - nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refused <- err })) - if err != nil { - t.Fatalf("%s cannot connect: %v", user, err) - } - t.Cleanup(nc.Close) - return nc, refused - } - controller, _ := connect(ControllerName) - cj := OnConn(controller) - if err := AssertMeshStreams(cj); err != nil { - t.Fatalf("the controller cannot make the streams: %v", err) - } - if err := cj.EnsureControllerBuckets(); err != nil { - t.Fatalf("the controller cannot make its buckets: %v", err) - } - cjs, _ := jetstream.New(controller) - ckv, err := cjs.KeyValue(ctx, PlansBuildingBucket) - if err != nil { - t.Fatal(err) - } - if _, err := ckv.Put(ctx, "plan-1", []byte(`{"plan":"plan-1"}`)); err != nil { - t.Fatalf("the controller cannot put a plan's key: %v", err) - } - - holder, refused := connect("alpha.build-agent") - hjs, err := jetstream.New(holder) - if err != nil { - t.Fatal(err) - } - kv, err := hjs.KeyValue(ctx, PlansBuildingBucket) - if err != nil { - t.Fatalf("a holder of the build seat cannot bind %s: %v", PlansBuildingBucket, err) - } - keys, err := kv.Keys(ctx) - if err != nil || len(keys) != 1 || keys[0] != "plan-1" { - t.Fatalf("the holder lists %v (%v), want plan-1", keys, err) - } - if e, err := kv.Get(ctx, "plan-1"); err != nil || e == nil { - t.Fatalf("the holder cannot get the key: %v", err) - } - // Its write is refused by the server. - if err := holder.Publish("$KV."+PlansBuildingBucket+".plan-2", []byte(`{}`)); err != nil { - t.Fatal(err) - } - _ = holder.Flush() - select { - case err := <-refused: - if !errors.Is(err, nats.ErrPermissionViolation) { - t.Errorf("the server said %v, want a permissions violation", err) - } - case <-time.After(3 * time.Second): - t.Error("the server did not refuse the holder's put of a plan's key") - } - if _, err := ckv.Get(ctx, "plan-2"); !errors.Is(err, jetstream.ErrKeyNotFound) { - t.Errorf("the refused put left a key: %v", err) - } - - // And the direct get of a check queue's first waiting ask, which is granted. - reply, err := holder.RequestWithContext(ctx, "$JS.API.DIRECT.GET."+ChecksOtherStream, []byte(`{"seq":1,"next_by_subj":"`+ - CheckSubjectPrefix(TheBuildSeat, false)+`>"}`)) - if err != nil { - t.Fatalf("the holder's direct get of %s was not answered: %v", ChecksOtherStream, err) - } - if reply.Header.Get("Status") != "404" { - t.Errorf("an empty queue answered %q, want 404", reply.Header.Get("Status")) - } -} diff --git a/internal/broker/plans_building_mirror_test.go b/internal/broker/plans_building_mirror_test.go deleted file mode 100644 index 9b57f4d3..00000000 --- a/internal/broker/plans_building_mirror_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package broker - -import ( - "slices" - "testing" -) - -// Issue 1687 (ADR 0483 clause 2): a module whose manifest reads mesh-controller.plans-building may name it in `reads`, and -// reads the bucket and writes none; the controller writes it, so it is no mirror written by another. -func TestAModuleReadingPlansBuildingReadsAndWritesNone(t *testing.T) { - if !slices.Contains(controllerReadableStates(), "plans-building") || BucketName(ControllerSeat, PlansBuilding) != PlansBuildingBucket { - t.Fatalf("a module cannot name %s in `reads`: %v", PlansBuildingBucket, controllerReadableStates()) - } - if _, other := MirrorsWrittenByAnother[PlansBuilding]; other { - t.Error("the controller writes the plans building itself") - } - perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-checker", PasswordHash: "x", - Reads: []string{"mesh-controller.plans-building"}}) - if err != nil { - t.Fatal(err) - } - stream := "KV_" + PlansBuildingBucket - has(t, perms.Publish, "$JS.API.STREAM.INFO."+stream) - has(t, perms.Publish, "$JS.API.DIRECT.GET."+stream+".>") - for _, p := range perms.Publish { - if SubjectsOverlap(p, "$KV."+PlansBuildingBucket+".plan-1") { - t.Errorf("the reading module may write %s through %q", PlansBuildingBucket, p) - } - } -} diff --git a/internal/broker/stream_names_agreement_test.go b/internal/broker/stream_names_agreement_test.go index c57e0d67..9990f2d0 100644 --- a/internal/broker/stream_names_agreement_test.go +++ b/internal/broker/stream_names_agreement_test.go @@ -9,7 +9,12 @@ import ( // The catalogue's list of the mesh's own stream names, which a module's stream may not take, is the broker's // own list (novox/hq ADR 0363 §1): read from MeshStreams, so a stream added there cannot be forgotten there. +// +// **The names of streams the mesh no longer asserts stay reserved** (R4g, ADR 0525 clause 6): the pull requests' two check +// queues are left on the bus until a cleanup removes them, and no module may take their names meanwhile. So the catalogue +// reserves every name of the broker's own, and may reserve these retired ones beside them. func TestTheCataloguesReservedStreamNamesAreTheMeshsOwn(t *testing.T) { + retired := []string{"CHECKS_OTHER", "CHECKS_READY"} var own []string for _, s := range MeshStreams() { own = append(own, s.Name) @@ -17,7 +22,24 @@ func TestTheCataloguesReservedStreamNamesAreTheMeshsOwn(t *testing.T) { reserved := append([]string(nil), catalogue.MeshStreamNames...) slices.Sort(own) slices.Sort(reserved) - if !slices.Equal(own, reserved) { - t.Fatalf("the broker's own streams are %v, the catalogue reserves %v", own, reserved) + var extra []string + for _, name := range reserved { + if !slices.Contains(own, name) { + extra = append(extra, name) + } + } + var missing []string + for _, name := range own { + if !slices.Contains(reserved, name) { + missing = append(missing, name) + } + } + for _, name := range extra { + if !slices.Contains(retired, name) { + t.Errorf("the catalogue reserves %s, which the broker neither asserts nor names retired", name) + } + } + if len(missing) > 0 { + t.Fatalf("the broker's own streams %v are not reserved by the catalogue (it reserves %v)", missing, reserved) } } diff --git a/internal/broker/streams.go b/internal/broker/streams.go index c261f1a8..f694c83f 100644 --- a/internal/broker/streams.go +++ b/internal/broker/streams.go @@ -154,7 +154,7 @@ func OriginalOfAgain(subject string) (string, bool) { // every tool invocation in the mesh, and a tool call must never be persisted (design 25 §3 keeps // tools on core NATS, where a lost call is a timeout the caller already handles). func MeshStreams() []Stream { - return append([]Stream{ + return []Stream{ { Name: "CONTROL", // A build's outcome is no longer here: it is the build-machine seat's own event, so one @@ -228,7 +228,7 @@ func MeshStreams() []Stream { "failed with the reason (novox/hq ADR 0349 §4): what a walk's end and a gate wait for (ADR 0362, " + "ADR 0349 §15), read by subject; a week, as the events are", }, - }, CheckStreams()...) + } } // ProvAppliedStream keeps what each machine's provisioner client last said of each module's files, newest per @@ -495,7 +495,7 @@ const EventsStream = "EVENTS" // held through a store restart — the exact message the stream exists to protect — some minutes // before the controller had finished deciding about it. func MeshConsumers() []Consumer { - return append(ControllerConsumers(), CheckWorkers()...) + return ControllerConsumers() } // ControllerConsumers are the consumers the controller's own event loop takes from: MeshConsumers without the build diff --git a/internal/broker/streams_test.go b/internal/broker/streams_test.go index 68831f41..7a52d422 100644 --- a/internal/broker/streams_test.go +++ b/internal/broker/streams_test.go @@ -134,9 +134,6 @@ func TestEachStreamCarriesTheRetentionItsShapeNeeds(t *testing.T) { // What each machine's provisioner client last said of each module's files (design 57 P6): the newest // word per module, what a walk's end reads. "PROV_APPLIED": RetentionLastPerSubject, - // The checks of pull requests, in two work queues of their own (novox/hq ADR 0406 §3, issue 974). - "CHECKS_READY": RetentionWorkQueue, - "CHECKS_OTHER": RetentionWorkQueue, } got := map[string]Retention{} for _, s := range MeshStreams() { diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index dfc1d983..b24479d7 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -29,7 +29,7 @@ accounts { ] users = [ { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { - publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_checks-running.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_plans-building.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.check.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] } + publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] } subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] } allow_responses: { max: 1, ttl: "1m" } } } diff --git a/internal/broker/writers.go b/internal/broker/writers.go index 73dd8369..00643b3d 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -43,14 +43,6 @@ func ownMachine(p Principal, pattern string) bool { return p.Kind == KindNode && len(tokens) > 2 && tokens[2] == p.Node } -// holdsTheBuildSeat is a principal holding the build seat, or the controller, which asserts the bucket the holders write. -func holdsTheBuildSeat(p Principal, _ string) bool { - if p.Kind == KindController { - return true - } - return holdsSeatOf(p, "mesh.seat."+TheBuildSeat+".x") -} - // holdsSeatOf is a principal holding the seat a `mesh.seat..…` pattern names: its module's own // principal, or the node tools carrying a module that holds it (ADR 0175, the runtime is its modules). func holdsSeatOf(p Principal, pattern string) bool { @@ -184,19 +176,6 @@ var WritersTable = []WriterRow{ Subjects: []string{"mesh.seat.node-build-agent.event.built", "mesh.seat.mesh-build-machine.event.built"}, Writes: holdsSeatOf, Shared: "every machine holding the build seat answers the asks it took; each outcome names its ask"}, - {State: "a pull request's check asked", Writer: "controller", - KeptIn: "the work queues " + ChecksReadyStream + " and " + ChecksOtherStream + ", one subject a pull request, the newest kept", - Others: "the build seat's holders take it", - Subjects: []string{CheckSubjectPrefix(TheBuildSeat, true) + ">", CheckSubjectPrefix(TheBuildSeat, false) + ">"}, - Writes: isController}, - {State: "a pull request's check running", Writer: "the build seat's holders — several by design, each the key of the check it runs", - KeptIn: "key-value " + CheckRunningBucket + ", aged out when not renewed", Others: "the build seat's holders read it to drop a duplicate request", - Subjects: kvOf(CheckRunningBucket), Writes: holdsTheBuildSeat, - Shared: "every machine holding the build seat puts the key of the check it runs and deletes it when the check ends"}, - {State: "the plans-building mirror", Writer: "controller (lease holder), at the first build's ask and at each refresh", - KeptIn: "key-value " + PlansBuildingBucket + ", aged out when not refreshed", - Others: "the build seat's holders read it and take no new check while a key is there (ADR 0483)", - Subjects: kvOf(PlansBuildingBucket), Writes: isController}, // What a module keeps and who may read it (novox/hq ADR 0495 clauses 2 and 3, issue 1790): said by the module, kept by // the controller. {State: "a module's states announced", Writer: "the module itself, from its code, under its own name", diff --git a/internal/broker/writers_test.go b/internal/broker/writers_test.go index aed01b84..749c3569 100644 --- a/internal/broker/writers_test.go +++ b/internal/broker/writers_test.go @@ -27,10 +27,6 @@ var designRows = []string{ "the healers' acts and their brake", "stream definitions and bus permissions", "builds and their outcomes", - "a pull request's check asked", - "a pull request's check running", - // The plans in their build phase, a bucket the build seat's holders read (ADR 0483 clause 2, issue 1687). - "the plans-building mirror", // What each module keeps and who may read it, said by the module and kept by the controller (ADR 0495, issue 1790). "a module's states announced", "the states announced", diff --git a/internal/builder/archive_symlink_test.go b/internal/builder/archive_symlink_test.go deleted file mode 100644 index a6c2eae4..00000000 --- a/internal/builder/archive_symlink_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package builder - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// A link as a parent of an archive's `from` must not lead the build out of the module: the repository -// is somebody else's, and a build that followed it would pack, register and serve any directory the -// build account can read (issue 1609). -func TestAnArchiveWhoseFromGoesThroughALinkOutOfTheModuleIsRefused(t *testing.T) { - outside := t.TempDir() - if err := os.MkdirAll(filepath.Join(outside, "x"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(outside, "x", "secret"), []byte("s"), 0o644); err != nil { - t.Fatal(err) - } - tree := t.TempDir() - if err := os.Symlink(outside, filepath.Join(tree, "link")); err != nil { - t.Fatal(err) - } - r := &recorded{} - a := catalogue.Artifact{Name: "all", Kind: catalogue.ArtifactArchive, From: "link/x"} - _, err := one(context.Background(), nil, r, "m", tree, t.TempDir(), "c", "", a, nil, nil, "", Npmrc{}, nil, - nil, func(string, string, ...any) {}) - if err == nil { - t.Fatal("an archive was packed through a link out of the module") - } - realOutside, _ := filepath.EvalSymlinks(filepath.Join(outside, "x")) - realTree, _ := filepath.EvalSymlinks(tree) - for _, want := range []string{realOutside, realTree, "link/x"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal %q does not name %q", err, want) - } - } - if len(r.archives) != 0 { - t.Errorf("a refused archive left %d published", len(r.archives)) - } -} - -// A link that stays inside the module is still followed, and its archive keeps its digest. -func TestAnArchiveWhoseFromGoesThroughALinkInsideTheModuleIsPacked(t *testing.T) { - tree := t.TempDir() - if err := os.MkdirAll(filepath.Join(tree, "real", "x"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(tree, "real", "x", "f"), []byte("f"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.Symlink("real", filepath.Join(tree, "link")); err != nil { - t.Fatal(err) - } - r := &recorded{} - a := catalogue.Artifact{Name: "all", Kind: catalogue.ArtifactArchive, From: "link/x"} - if _, err := one(context.Background(), nil, r, "m", tree, t.TempDir(), "c", "", a, nil, nil, "", Npmrc{}, nil, - nil, func(string, string, ...any) {}); err != nil { - t.Fatal(err) - } -} - -// A module directory that is itself a link out of the fetched tree is refused, naming both places. -func TestAModuleDirectoryThatIsALinkOutOfTheRepositoryIsRefused(t *testing.T) { - outside := t.TempDir() - if err := os.WriteFile(filepath.Join(outside, ManifestName), []byte("{}"), 0o644); err != nil { - t.Fatal(err) - } - tree := t.TempDir() - if err := os.MkdirAll(filepath.Join(tree, "modules"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outside, filepath.Join(tree, "modules", "evil")); err != nil { - t.Fatal(err) - } - _, err := inside(tree, "modules/evil") - if err == nil { - t.Fatal("a module directory behind a link out of the repository was accepted") - } - realOutside, _ := filepath.EvalSymlinks(outside) - realTree, _ := filepath.EvalSymlinks(tree) - for _, want := range []string{realOutside, realTree, "modules/evil"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal %q does not name %q", err, want) - } - } - // A link that stays inside the repository, and a module directory not there, are left as they were. - if err := os.MkdirAll(filepath.Join(tree, "real"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.Symlink("real", filepath.Join(tree, "modules", "fine")); err != nil { - t.Fatal(err) - } - if _, err := inside(tree, "modules/fine"); err != nil { - t.Errorf("a link inside the repository was refused: %v", err) - } - if _, err := inside(tree, "modules/none"); err != nil { - t.Errorf("an absent module directory was refused here: %v", err) - } -} - -// A repository that commits `.mesh-build` as a link must not make a bundle's build write into, or pack, -// a directory outside the module (issue 1609): the compile, the bundling and the pack each refuse it. -func TestABundleWhoseOutputDirectoryIsALinkOutOfTheModuleIsRefused(t *testing.T) { - outside := t.TempDir() - if err := os.WriteFile(filepath.Join(outside, "secret"), []byte("s"), 0o644); err != nil { - t.Fatal(err) - } - tree := t.TempDir() - if err := os.Symlink(outside, filepath.Join(tree, ".mesh-build")); err != nil { - t.Fatal(err) - } - chain, err := ToolchainFor("go") - if err != nil { - t.Fatal(err) - } - a := catalogue.Artifact{Name: "b", Kind: catalogue.ArtifactBundle, Language: "go", From: "cmd/b"} - ran := false - run := func(context.Context, string, string, ...string) (string, error) { ran = true; return "", nil } - realOutside, _ := filepath.EvalSymlinks(outside) - realTree, _ := filepath.EvalSymlinks(tree) - - check := func(what string, err error) { - t.Helper() - if err == nil { - t.Fatalf("%s went through a link out of the module", what) - } - for _, want := range []string{realOutside, realTree} { - if !strings.Contains(err.Error(), want) { - t.Errorf("%s: the refusal %q does not name %q", what, err, want) - } - } - } - _, err = compile(context.Background(), run, tree, chain, "base", a) - check("compile", err) - _, err = bundled(context.Background(), run, tree, chain, "base", a, nil) - check("bundled", err) - check("the pack", outsideTheModule(tree, filepath.Join(tree, Out(a.Name)))) - if ran { - t.Error("a command ran for a refused bundle") - } - if entries, _ := os.ReadDir(outside); len(entries) != 1 { - t.Errorf("a refused bundle wrote into the directory outside: %d entries", len(entries)) - } -} diff --git a/internal/builder/binary_name_test.go b/internal/builder/binary_name_test.go deleted file mode 100644 index efefbe1f..00000000 --- a/internal/builder/binary_name_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package builder - -import ( - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// The name a machine runs a binary by is not always the name of the package that built it. The host's -// command is cmd/mesh-host and every machine runs it as nox-mesh-host — the path it is installed at, -// the name in its unit, and the name its launcher looks for inside a delivered version. -// -// A bundle carrying the package's name was delivered to a machine correctly, reported "created … 1 -// file(s)", and was invisible to the launcher (novox/hq 04-ISSUES/142). Found by reading the delivered -// directory rather than by trusting the line that said it worked. - -func TestACompiledArtifactNamesTheBinaryAMachineWillRun(t *testing.T) { - got := binaryName(catalogue.Artifact{ - Name: "host-arch", From: "cmd/mesh-host", Binary: "nox-mesh-host", - }) - if got != "nox-mesh-host" { - t.Fatalf("the binary is named %q, and the launcher looks for nox-mesh-host", got) - } -} - -func TestSayingNothingKeepsWhatTheCompilerWouldHaveChosen(t *testing.T) { - // go build names its output after the package, so an artifact that says nothing gets the same - // thing it got before this existed. - if got := binaryName(catalogue.Artifact{Name: "host-arch", From: "cmd/mesh-host"}); got != "mesh-host" { - t.Fatalf("an artifact naming no binary produced %q", got) - } - if got := binaryName(catalogue.Artifact{Name: "host-arch", From: "./cmd/agent/"}); got != "agent" { - t.Fatalf("a from with slashes produced %q", got) - } -} - -func TestABundleBuiltFromTheModuleRootFallsBackToItsArtifactName(t *testing.T) { - // A single-command repository names no package, and `go build -o ` would then write a file - // named after the module directory — which is not something the manifest states. The artifact's - // own name is what the manifest does state. - if got := binaryName(catalogue.Artifact{Name: "tool"}); got != "tool" { - t.Fatalf("a bundle built from the root produced %q", got) - } -} diff --git a/internal/builder/build_source_test.go b/internal/builder/build_source_test.go deleted file mode 100644 index 12bbbd4b..00000000 --- a/internal/builder/build_source_test.go +++ /dev/null @@ -1,227 +0,0 @@ -//go:build integration - -package builder - -import ( - "context" - "github.com/novox/mesh-controller/internal/gosource" - "os" - "path/filepath" - "slices" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// What a build says it was made from, as files (novox/hq ADR 0267), and what a build compiling a Go -// program is handed. - -// onTrunk answers the trunk's questions as a clone of a commit on main would, or off it. -type onTrunk struct { - *recorded - off bool - // behind is a commit on the trunk that is not its head: an older commit built by hand. - behind bool -} - -func (o onTrunk) run(ctx context.Context, dir, name string, args ...string) (string, error) { - if name == "git" && len(args) > 0 && args[0] == "symbolic-ref" { - return "origin/main\n", nil - } - if name == "git" && len(args) > 1 && args[0] == "rev-parse" && args[1] == "origin/main" && o.behind { - return "feedfacefeedfacefeedfacefeedfacefeedface\n", nil - } - if name == "git" && len(args) > 0 && args[0] == "merge-base" && o.off { - return "", os.ErrNotExist - } - return compiling{o.recorded}.run(ctx, dir, name, args...) -} - -// aSharedRepository is a repository holding two programs that share a package, as the controller's does. -func aSharedRepository(readme, shared string) map[string]string { - return map[string]string{ - "go.mod": "module example.com/ctl\n\ngo 1.22\n", - "go.sum": "", - "README.md": readme, - "cmd/ctl/main.go": "package main\n\nimport _ \"example.com/ctl/internal/shared\"\n\nfunc main() {}\n", - "proxy/main.go": "package main\n\nimport _ \"example.com/ctl/internal/shared\"\n\nfunc main() {}\n", - "internal/shared/s.go": "package shared\n\nconst S = " + shared + "\n", - "internal/only/o.go": "package only\n", - } -} - -const aProxy = `{"module":"route-proxy","version":"1", - "build":{"artifacts":[ - {"name":"server","kind":"image","from":"Dockerfile","compiles":"proxy", - "context":{"repository":"https://forge.invalid/ctl.git","ref":"main"}}, - {"name":"trust","kind":"upstream","from":"alpine@sha256:` + "3333333333333333333333333333333333333333333333333333333333333333" + `"}]}}` - -func buildTheProxy(t *testing.T, context_ map[string]string, off bool, behind ...bool) (Result, *recorded, string) { - t.Helper() - r := &recorded{ - contents: map[string]string{"modules/route-proxy/" + ManifestName: aProxy, "modules/route-proxy/Dockerfile": "FROM scratch\nCOPY . .\n", "modules/route-proxy/README.md": "x"}, - secondary: map[string]map[string]string{"https://forge.invalid/ctl.git": context_}, - } - workspace := t.TempDir() - got, err := Build(context.Background(), onTrunk{r, off, len(behind) > 0 && behind[0]}.run, r, - "https://forge.invalid/catalogue.git", "modules/route-proxy", "", workspace, nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - return got, r, workspace -} - -func TestAnImageCompilingGoIsHandedItsBuildSourceAndSaysIt(t *testing.T) { - got, r, workspace := buildTheProxy(t, aSharedRepository("one", "1"), false) - - // Built in the narrowed tree, which holds the program's closure and nothing else. - at := "" - for i, line := range r.ran { - if strings.HasPrefix(line, "docker build ") { - at = r.dirs[i] - } - } - if filepath.Base(at) != "narrow-server" { - t.Fatalf("docker build ran in %q, not the narrowed build source", at) - } - for file, want := range map[string]bool{"proxy/main.go": true, "internal/shared/s.go": true, "go.mod": true, - "cmd/ctl/main.go": false, "internal/only/o.go": false, "README.md": false} { - held := slices.Contains(r.builtFrom, file) - if held != want { - t.Errorf("%s handed to the recipe: %v, wanted %v", file, held, want) - } - } - if _, err := os.Stat(filepath.Join(workspace, "narrow-server")); err == nil { - t.Error("the narrowed build source was left in the workspace") - } - - // Said per repository: its own, the manifest and the recipe; the context's, the closure. - if len(got.Sources) != 2 { - t.Fatalf("sources %+v", got.Sources) - } - own, ctx := got.Sources[0], got.Sources[1] - if own.Repository != "" || !slices.Equal(own.Paths, []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}) { - t.Errorf("its own build source: %+v", own) - } - if ctx.Repository != "https://forge.invalid/ctl.git" || ctx.Ref != "main" { - t.Errorf("the context's build source names %q at %q", ctx.Repository, ctx.Ref) - } - for file, want := range map[string]bool{"proxy/main.go": true, "internal/shared/s.go": true, "go.mod": true, - "cmd/ctl/main.go": false, "README.md": false} { - if gosource.SourceHolds(ctx.Paths, file) != want { - t.Errorf("the context's build source holds %s: %v, wanted %v (%v)", file, !want, want, ctx.Paths) - } - } - - // **The fingerprint is over the build source** (rule 5): a change outside it is one build, inside it another. - readme, _, _ := buildTheProxy(t, aSharedRepository("two", "1"), false) - if readme.Source != got.Source { - t.Errorf("a README of the context changed the fingerprint: %s %s", got.Source, readme.Source) - } - shared, _, _ := buildTheProxy(t, aSharedRepository("one", "2"), false) - if shared.Source == got.Source { - t.Error("a change to the program's closure kept its fingerprint") - } -} - -// A build off the trunk, or of a trunk commit that is not its head, says no build source: the planner maps -// a merge onto the trunk head's, and an older commit's closure lacks what was imported since. -func TestABuildOffTheTrunksHeadSaysNoBuildSource(t *testing.T) { - got, _, _ := buildTheProxy(t, aSharedRepository("one", "1"), true) - if len(got.Sources) != 0 { - t.Fatalf("a build off the trunk said %+v", got.Sources) - } - got, _, _ = buildTheProxy(t, aSharedRepository("one", "1"), false, true) - if len(got.Sources) != 0 { - t.Fatalf("a build of an older trunk commit said %+v", got.Sources) - } -} - -// An archive of the module's whole directory holds every file of it. -func TestAnArchiveOfTheWholeDirectoryHoldsIt(t *testing.T) { - manifest := `{"module":"look","version":"1","build":{"artifacts":[{"name":"all","kind":"archive","from":"."}]}, - "resources":[{"id":"files","type":"archive","path":"/opt/look","artifact":"all"}]}` - r := &recorded{contents: map[string]string{"modules/look/" + ManifestName: manifest, "modules/look/a/b.css": "x"}} - got, err := Build(context.Background(), onTrunk{recorded: r}.run, r, - "https://forge.invalid/catalogue.git", "modules/look", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - if len(got.Sources) != 1 || !gosource.SourceHolds(got.Sources[0].Paths, "modules/look/a/b.css") || - gosource.SourceHolds(got.Sources[0].Paths, "modules/other/x") { - t.Fatalf("sources %+v", got.Sources) - } -} - -// A recipe reading past its build source fails, naming what it could not find — in the real docker build; -// here, the file is simply not in the tree it is handed, which is what makes that so. -func TestAnImageCompilingANonexistentPackageFails(t *testing.T) { - r := &recorded{ - contents: map[string]string{ManifestName: strings.Replace(aProxy, `"compiles":"proxy"`, `"compiles":"nowhere"`, 1), - "Dockerfile": "FROM scratch\n"}, - secondary: map[string]map[string]string{"https://forge.invalid/ctl.git": aSharedRepository("one", "1")}, - } - _, err := Build(context.Background(), onTrunk{recorded: r}.run, r, - "https://forge.invalid/catalogue.git", "", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) - if err == nil || !strings.Contains(err.Error(), "nowhere") { - t.Fatalf("a package that is not there built: %v", err) - } -} - -// A Go bundle of a module built from its repository's root says its import closure, and the manifest; -// an image that compiles nothing it was told of leaves the module's source whole, and says none. -func TestAGoBundleSaysItsClosureAndAnUntoldImageNothing(t *testing.T) { - files := aSharedRepository("one", "1") - manifest := `{"module":"ctl","version":"1","build":{"artifacts":[ - {"name":"controller","kind":"bundle","language":"go","system":"arch","from":"cmd/ctl","binary":"ctl"}]}, - "resources":[{"id":"controller","type":"process","name":"ctl","artifact":"controller","run":["./ctl"]}]}` - r := &recorded{contents: map[string]string{ManifestName: manifest}} - for k, v := range files { - r.contents[k] = v - } - held := map[string]string{"mesh-tools-go/build": "registry.invalid/mesh-tools-go/build@sha256:" + strings.Repeat("b", 64)} - got, err := Build(context.Background(), onTrunk{recorded: r}.run, r, - "https://forge.invalid/ctl.git", "", "", t.TempDir(), held, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - if len(got.Sources) != 1 || got.Sources[0].Repository != "" { - t.Fatalf("sources %+v", got.Sources) - } - for file, want := range map[string]bool{"cmd/ctl/main.go": true, "internal/shared/s.go": true, ManifestName: true, - "go.sum": true, "proxy/main.go": false, "README.md": false, "internal/only/o.go": false} { - if gosource.SourceHolds(got.Sources[0].Paths, file) != want { - t.Errorf("%s: held %v, wanted %v (%v)", file, !want, want, got.Sources[0].Paths) - } - } - - untold := `{"module":"ctl","version":"1","build":{"artifacts":[ - {"name":"server","kind":"image","from":"Dockerfile"}]}}` - r = &recorded{contents: map[string]string{ManifestName: untold, "Dockerfile": "FROM scratch\n"}} - got, err = Build(context.Background(), onTrunk{recorded: r}.run, r, - "https://forge.invalid/ctl.git", "", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - if len(got.Sources) != 0 { - t.Fatalf("an image compiling nothing it was told of said a build source: %+v", got.Sources) - } -} - -// 1215/AC1 (compiled here, run by the merge check; the unit level has 1215/AC1 over the same inputs): a build's -// result carries every input of its fingerprint, and the SDK function over them gives it again. -func TestABuildSaysTheInputsItsFingerprintWasComputedFrom(t *testing.T) { - got, _, _ := buildTheProxy(t, aSharedRepository("one", "1"), false) - if got.SourceInputs == nil { - t.Fatal("the result carries no source inputs") - } - in := *got.SourceInputs - if in.Module != "route-proxy" || in.Tree == "" || in.Contexts["server"] == "" { - t.Errorf("the inputs miss the module, its tree or the server's context: %+v", in) - } - fp, none := delivery.SourceFingerprint(in) - if fp == "" || none != "" || fp != got.Source { - t.Errorf("the inputs give %q (%q); the build recorded %q", fp, none, got.Source) - } -} diff --git a/internal/builder/builder.go b/internal/builder/builder.go deleted file mode 100644 index 137da548..00000000 --- a/internal/builder/builder.go +++ /dev/null @@ -1,2161 +0,0 @@ -package builder - -import ( - "archive/tar" - "compress/gzip" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "github.com/novox/mesh-controller/internal/gosource" - "io" - "io/fs" - "os" - "os/exec" - "path" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" - "github.com/novox/mesh-controller/internal/buildreads" -) - -// Turning a repository into artifacts the mesh can pin. -// -// **This runs on a node, not in the control plane.** Building needs a container runtime and a -// working tree, and the control plane deliberately cannot run commands on a machine — what it may -// send is bounded by the declaration language (novox/hq ADR 0005), and "run this build" is not in -// it. So the builder is something a node runs *as a module*, given work over the broker like -// anything else, and this package is what it does when it gets some. -// -// The alternative — the control plane holding a docker socket — would make it the one component -// that can do anything on a machine, which is the property the whole design is arranged to avoid. - -// Runner runs a command in a directory and returns what it said. Injected so the tests do not -// need docker and git, and so the failure of either is reported rather than assumed. -type Runner func(ctx context.Context, dir string, name string, args ...string) (string, error) - -// Publisher puts an artifact somewhere a machine can fetch it, and says how to refer to it. -type Publisher interface { - // PublishImage pushes a locally built image and returns a reference pinned by digest. - PublishImage(ctx context.Context, localTag, repository string) (string, error) - // PublishArchive stores bytes and returns where to fetch them from. - PublishArchive(ctx context.Context, repository string, body []byte, digest string) (string, error) -} - -// Result is everything one build produced. -type Result struct { - // Against is every pinned image this build was built on top of, read out of its own inputs. - // - // **Derived, not declared** (novox/hq ADR 0009): a declared list of dependencies drifts from - // what the code actually uses, and an artifact is out of date when anything it was built - // against moved. These are artifact references rather than module-versions, because that is - // what a build input names; resolving them to modules is the catalogue's work, since it is - // what knows which module-version published which artifact. - Against []string - - // Manifest is the module as the mesh should hold it: artifacts resolved to digests. - Manifest catalogue.Manifest - // Commit is what was built, so "is this current?" is answerable without building again. - Commit string - // Built is each artifact, for reporting. - Built []catalogue.Built - - // Read is every repository this build read source from besides the module's own — the second - // repository an artifact's recipe names (ArtifactContext). Reported because the manifest the - // mesh keeps carries no build section, so nothing else could say that a merge there is a - // change to this module (novox/hq 04-ISSUES/131). - Read []catalogue.ArtifactContext - - // Trunk is the repository's default branch as the forge holds it at the build, and OnTrunk whether - // the commit built is reachable from it (novox/hq ADR 0238): a commit off the trunk is checked, - // never published. Trunk empty is "could not be said". - Trunk string - OnTrunk bool - // Branches are every branch of the forge the commit is on: a module may follow a branch other than - // the default, and that branch is its trunk. - Branches []string - - // Mirrored is every base this build copied into the artifact store, as pinned (novox/hq ADR 0257): - // said whether the build worked or not, because the copy is in the store either way, and the - // records are what decide whether it stays. - Mirrored []string - - // Source is the build's source fingerprint (source.go): what it was made from — the module's tree, - // the contexts' trees, the bases and toolchains by digest — hashed. Empty where the source does not - // pin the build. Two builds with one fingerprint are one build, whatever digests they made - // (novox/hq issue 280). - Source string - - // SourceInputs are what Source was computed from (novox/hq ADR 0437 §5, issue 1215), set also where the source - // does not pin the build, so the reason is visible: with them delivery.SourceFingerprint gives Source again. - SourceInputs *delivery.SourceInputs - - // ReuseKey is the build's reuse key (novox/hq ADR 0350 §1): its source fingerprint's parts with the builder's - // version, and ReuseParts those parts; empty, with NoKey saying why, where the source does not pin the build. - ReuseKey string - ReuseParts map[string]string - NoKey string - // Reused is the check build a walk took instead of building (ADR 0350 §2), and Why, when it built, why it did - // not take one (§3). Both empty for a build not asked to reuse. - Reused *Reused - Why string - - // Sources are what this build was made from, as files (novox/hq ADR 0267 rule 1): per repository, the - // entries a changed file is tested against (SourceHolds). The module's own repository has an empty - // Repository. Said only for a build of a commit on the trunk, and only for a repository whose every - // artifact's build source is known — a Go program's import closure, an archive's directory, an image's - // recipe and the package it compiles; for any other, nothing is said and the whole of what the build - // sees stays its source, as before. - Sources []BuildSource - - // Predefined is the list of predefined keys at the root of the repository at the commit built (novox/hq ADR - // 0348 §7), which the manifest was judged against; nil for a repository that holds none. - Predefined *Predefined -} - -// Predefined is the list of predefined keys a build read: its text, canonical (catalogue.PredefinedList), and -// the generation of the commit built — how many commits it stands on, which orders two builds of one trunk. -type Predefined struct { - Keys []byte - Generation int -} - -// BuildSource is the build source a build read in one repository (novox/hq ADR 0267). -type BuildSource struct { - // Repository and Ref are a context's, as the manifest names it; empty for the module's own. - Repository string - Ref string - // Paths are the entries, relative to the repository's root (SourceHolds). - Paths []string -} - -// GitCredential is the forge credential a clone may present when the server asks for one. -// -// **Offered, never pushed.** It is written as a git credential-store file and named to git with -// `-c credential.helper=store`, so git itself decides when it applies: only on an authentication -// challenge, and only for the URL it was written for — scheme, host and port included. A public -// repository clones exactly as before, and a repository on any other host is never shown it. -type GitCredential struct { - // URL is the credential-store line — scheme://user:password@host[:port] — naming the one - // server this credential belongs to. Empty means the builder holds none and every clone is - // anonymous, as it always was. - URL string -} - -// Build clones a repository at a ref, reads its manifest, produces what it declares, publishes -// each, and returns the manifest the mesh should hold. -// -// **Nothing is published until everything is built.** A module whose image succeeded and whose -// archive failed would otherwise leave half of itself in the store under a digest the mesh never -// records — reachable, unreferenced, and indistinguishable from something in use. -func Build(ctx context.Context, run Runner, publish Publisher, - repository, path, ref, workspace string, held map[string]string, npmrc Npmrc, - forge GitCredential, log Log, seats ...map[string]string) (Result, error) { - // **What was copied is said whether the build worked or not** (novox/hq ADR 0257): a base is in - // the store from the moment it is copied, and a build that failed after copying it is the - // one record that it is there. - var mirrored []string - result, err := build(ctx, run, publish, repository, path, ref, workspace, held, npmrc, forge, log, - &mirrored, nil, seats...) - result.Mirrored = mirrored - return result, err -} - -// Reuse is what a walk's build may take instead of building (novox/hq ADR 0350 §2): the scratch namespace's check -// builds, and the pull requests the walk answers, whose checks name a differing key. -type Reuse struct { - Scratch Scratch - Pulls []PullRef - // Named are the builds the verdicts of the walk's own pull requests name (novox/hq ADR 0350 §2): a scratch build - // is taken only when one of them names its key and every digest. The record in the store alone is never enough. - Named []NamedBuild - // keyFirst is a check's build (§1): its key is taken before anything is built, and a module with none is not - // built at all — nothing could ever take it. - keyFirst bool - // pull and head are the check's own pull request and the head it judged (novox/hq issue 703): it keeps only a - // build an earlier check of the same pull request at the same head made. - pull PullRef - head string -} - -// errNoKey is a check's build of a module whose source does not pin it: not built (ADR 0350 §1). -var errNoKey = errors.New("it has no reuse key") - -// BuildReusing is Build for the walk of a trunk commit (novox/hq ADR 0350 §2): before building, it takes the reuse -// key of the commit's tree, and where a check build holds the same key it copies that build into the module's -// namespace by digest and answers with it, building nothing. Otherwise it builds, and Result.Why says why (§3). -// Only a commit on its trunk takes a check build: anything else builds, as Build does. -func BuildReusing(ctx context.Context, run Runner, publish Publisher, reuse Reuse, - repository, path, ref, workspace string, held map[string]string, npmrc Npmrc, - forge GitCredential, log Log, seats ...map[string]string) (Result, error) { - var mirrored []string - result, err := build(ctx, run, publish, repository, path, ref, workspace, held, npmrc, forge, log, - &mirrored, &reuse, seats...) - result.Mirrored = mirrored - return result, err -} - -func build(ctx context.Context, run Runner, publish Publisher, - repository, path, ref, workspace string, held map[string]string, npmrc Npmrc, - forge GitCredential, log Log, mirrored *[]string, reuse *Reuse, seats ...map[string]string) (Result, error) { - // The clone base of each seat a context may name (novox/hq ADR 0155); variadic so the callers - // that hand none — tests of everything but contexts — read as they did. - var seatBases map[string]string - if len(seats) > 0 { - seatBases = seats[0] - } - - say := logging(log) - say("clone", "%s%s at %s", repository, describePath(path), refOrHead(ref)) - - // Made rather than required. A builder that fails because the directory it was told to work - // in does not exist is a builder that needs a setup step nobody documented. - if err := os.MkdirAll(workspace, 0o755); err != nil { - return Result{}, err - } - // **A node with no room takes no build** (novox/hq issue 1834). - if err := RoomToWork("/", workspace); err != nil { - say("clone", "refused: %v", err) - return Result{}, err - } - // The credential is a file git reads, never an argument: a URL carrying a password in argv - // would be readable by anything that can list processes for as long as a clone runs. - credentials, forget, err := storeCredential(workspace, forge) - if err != nil { - return Result{}, err - } - defer forget() - tree := filepath.Join(workspace, "source") - if err := os.RemoveAll(tree); err != nil { - return Result{}, err - } - // Removed when the build ends, whatever happens: the tree holds the .npmrc a build may be handed, in the - // workspace a later check's container mounts as its HOME (novox/hq issue 462). - defer os.RemoveAll(tree) - // A fresh clone every time rather than a fetch into a tree that is already there. A build - // that reuses a working tree can succeed because of something a previous build left behind, - // and that is a build nobody can reproduce. - if err := cloneFromForge(ctx, run, workspace, repository, tree, - cloneWith(credentials, "clone", "--quiet", repository, tree), say); err != nil { - say("clone", "FAILED: %v", err) - return Result{}, fmt.Errorf("cannot clone %s: %w", repository, err) - } - if err := recordedWithoutUserinfo(ctx, run, tree, repository); err != nil { - return Result{}, err - } - say("clone", "done") - if ref != "" { - if _, err := run(ctx, tree, "git", "checkout", "--quiet", ref); err != nil { - return Result{}, fmt.Errorf("%s has no %s: %w", repository, ref, err) - } - } - commit, err := run(ctx, tree, "git", "rev-parse", "HEAD") - if err != nil { - return Result{}, err - } - commit = strings.TrimSpace(commit) - say("commit", "%s", short(commit)) - // **Whether the commit is on the trunk** (novox/hq ADR 0238): only a commit on the repository's own - // default branch is published. Read from the clone just made — the forge's own word on which branch - // is its default and what it holds now — and said with the outcome, so the controller refuses to - // register a build of a commit off it. - trunk, onTrunk := trunkOf(ctx, run, tree, commit) - branches := branchesHolding(ctx, run, tree, commit) - if trunk != "" { - say("trunk", "%s is %son %s; on %s", short(commit), map[bool]string{true: "", false: "NOT "}[onTrunk], trunk, - orNoBranch(branches)) - } - - // A module is a repository and a path within it (novox/hq ADR 0069). The ordinary case is an - // empty path, meaning the repository's root; a repository holding several modules names each - // by its own directory, which is what the catalogue is and what the system this replaces has - // always done. - within, err := inside(tree, path) - if err != nil { - return Result{}, err - } - - raw, err := os.ReadFile(filepath.Join(within, ManifestName)) - if err != nil { - return Result{}, fmt.Errorf( - "%s has no %s at %s, so there is nothing saying what it is: %w", - repository, ManifestName, describe(path), err) - } - // **The list of predefined keys at the commit built** (novox/hq ADR 0348 §7): predefined.json at the - // repository's root, read strictly; a list that is wrong fails the build, and the manifest is judged against - // the list of its own tree, never against one the mesh published from another commit. - predefined, err := catalogue.ReadPredefined(tree) - if err != nil { - say("predefined", "INVALID: %v", err) - return Result{}, err - } - manifest, err := catalogue.ParseManifestWith(raw, predefined) - if err != nil { - say("manifest", "INVALID: %v", err) - return Result{}, err - } - var listed *Predefined - if predefined != nil { - text, err := predefined.Canonical() - if err != nil { - return Result{}, err - } - listed = &Predefined{Keys: text, Generation: generationOf(ctx, run, tree, commit)} - say("predefined", "%d key(s) at %s, generation %d", len(predefined), short(commit), listed.Generation) - } - // What is judged only where a definition is written (novox/hq ADR 0326 §3, issue 574): a build is - // where a changed definition is first read, so it is refused here before anything is made of it. - // Judged with each template's text in its file, as written in the tree (novox/hq ADR 0349 §2): a template the - // module's directory lacks is refused here, before anything is built. - judged, err := catalogue.WithTemplatesFrom(manifest, within) - if err != nil { - say("manifest", "INVALID: %v", err) - return Result{}, err - } - if problems := catalogue.WrittenSettingsProblems(judged); len(problems) > 0 { - err := fmt.Errorf("%s", strings.Join(problems, "; ")) - say("manifest", "INVALID: %v", err) - return Result{}, err - } - say("manifest", "%s v%s — %d artifact(s)", manifest.Module, manifest.Version, artifactCount(manifest)) - - // What it is made from, for its source fingerprint: the module's own tree first. - src := newSourceInputs(manifest.Module) - src.prefix = strings.Trim(filepath.ToSlash(filepath.Clean(path)), "/") - if src.prefix == "." { - src.prefix = "" - } - if src.tree, err = gitTree(ctx, run, tree, path); err != nil { - src.notPinned("its tree could not be named: " + err.Error()) - } - // A module falling back to a predefined key was judged against the list, so a change to the list is a change - // to it: the planner rebuilds it, and its build publishes the list (ADR 0348 §7). - if predefined != nil && manifest.NamesPredefined() { - src.rootHas(catalogue.PredefinedFile) - } - - // A build-time credential, written into the build context as .npmrc, but ONLY for a module that - // asks for it: a `package` artifact (which publishes), or an image whose Dockerfile COPYs .npmrc. - // Writing it into every context would put a per-run credential in `COPY . .` of modules that - // never resolve a mesh package — making their image non-deterministic (a needless rollout every - // build) and leaking the credential into a build stage. Absent entirely with no registry, which - // is the bootstrap case (novox/hq ADR 0076). - // **A build that needs the registry and has none is refused before anything is built or - // published** (novox/hq ADR 0336 §6) — before a base is copied into the store, and never built - // without the credential or against the public registry. - if !npmrc.Enabled() { - why, err := needsRegistry(manifest, within) - if err != nil { - return Result{}, err - } - if why != "" { - err := npmrc.unbound(manifest.Module, why) - say("packages", "REFUSED: %v", err) - return Result{}, err - } - } - var npmrcPath string - if npmrc.Enabled() && manifest.Build != nil && wantsPackages(manifest, within) { - content, err := npmrc.File() - if err != nil { - return Result{}, err - } - npmrcPath = filepath.Join(within, ".npmrc") - if err := os.WriteFile(npmrcPath, []byte(content), 0o600); err != nil { - return Result{}, fmt.Errorf("cannot write the package-registry credential for the build: %w", err) - } - // Only for as long as the build: a later check's container mounts this workspace (novox/hq issue 462). - defer os.Remove(npmrcPath) - say("packages", "resolving %s from the mesh's package registry, from %s", npmrc.Scope, fromOr(npmrc.From)) - src.notPinned("it resolves packages from the mesh's registry at build time") - } - - var built []catalogue.Built - // stoodOn is every base the build was handed, as resolved — the edges the catalogue derives. - var stoodOn []string - // reused is the check build a walk took, and builtWhy why it built instead (novox/hq ADR 0350). - var reused *Reused - builtWhy := "" - if manifest.Build != nil { - // What this module said it stands on, answered with what this mesh actually holds. Done - // before anything is built, so a missing base is refused in front of the person who can - // fix it rather than inside a build that stops on its own first line. - // An image published elsewhere that the build stands on is copied into the mesh's own - // registry first, like an upstream artifact (ADR 0096), and the recipe is handed the copy. - // Genesis has nowhere to copy to and pulls it into this machine's store instead. - mirror := func(ctx context.Context, from, repository, former string) (string, error) { - if m, can := publish.(BaseMirrorer); can { - say("bases", "copying %s into the mesh's registry as %s", from, repository) - reference, err := m.MirrorBase(ctx, from, repository, former) - if err == nil { - *mirrored = append(*mirrored, reference) - } - return reference, err - } - if m, can := publish.(Mirrorer); can { - say("bases", "copying %s into the mesh's registry as %s", from, repository) - reference, err := m.MirrorImage(ctx, from, repository) - if err == nil { - *mirrored = append(*mirrored, reference) - } - return reference, err - } - if _, err := run(ctx, tree, "docker", "pull", from); err != nil { - return "", fmt.Errorf("cannot fetch %s: %w", from, err) - } - return from, nil - } - args, bases, err := standingOn(ctx, manifest, held, mirror) - if err != nil { - say("bases", "UNMET: %v", err) - return Result{}, err - } - stoodOn = bases - src.bases = append(src.bases, bases...) - if len(args) > 0 { - say("bases", "%d resolved from what the mesh holds", len(args)/2) - } - artifacts := append([]catalogue.Artifact{}, manifest.Build.Artifacts...) - // Ordered, so two builds of one commit do the same work in the same sequence and their - // logs can be compared. - sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name }) - // **A walk takes the check's build of the same key** (novox/hq ADR 0350 §2): only a commit on its trunk, and - // only when asked — a check, a dry run, `build --ref` and `rebuild` never are. Its key is taken by the same - // steps a build takes, stopped before anything is built; what it takes is copied by digest. - if reuse != nil && reuse.keyFirst { - keyed, key, err := keyFor(ctx, run, publish, manifest, within, workspace, commit, credentials, artifacts, args, - held, npmrcPath, npmrc, seatBases, src, say) - if err == nil && key.Key == "" { - err = fmt.Errorf("%w: %s", errNoKey, key.Why) - } - if err != nil { - say("reuse", "not built for the merge: %v", err) - return Result{Commit: commit, NoKey: err.Error()}, err - } - // **A check keeps a build of the same key a verdict names** (novox/hq issue 703): asked again when only - // the trunk moved, it builds nothing the earlier check built of an unchanged source. - if kept := keptCheckBuild(ctx, *reuse, manifest.Module, key.Key, len(artifacts), say); kept != nil { - built, src, reused = kept.Made, keyed, kept - } - } else if reuse != nil && reuse.Scratch != nil { - taken, keyed, why := takeACheckBuild(ctx, run, publish, *reuse, manifest, within, workspace, commit, - credentials, artifacts, args, held, npmrcPath, npmrc, seatBases, src, onTrunk, say) - if taken != nil { - built = taken.Made - src = keyed - reused = taken - } else { - builtWhy = why - say("reuse", "built: %s", why) - } - } - for _, a := range artifacts { - if reused != nil { - break - } - say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a)) - made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, npmrc, seatBases, src, say) - if err != nil { - say("artifact", "%s FAILED: %v", a.Name, err) - return Result{}, err - } - say("artifact", "%s done — %s", a.Name, describeMade(made)) - built = append(built, made) - } - } - - // Each template's text, read back from the archive the build made or took (novox/hq ADR 0349 §2). - if built, err = withTemplateTexts(manifest, within, built); err != nil { - say("templates", "FAILED: %v", err) - return Result{}, err - } - resolved, err := manifest.Resolve(built) - if err != nil { - say("resolve", "FAILED: %v", err) - return Result{}, err - } - say("done", "%s at %s — %d artifact(s) pinned", manifest.Module, short(commit), len(built)) - fingerprint := src.fingerprint() - if fingerprint == "" { - say("source", "no source fingerprint: %s", orNoTree(src.unpinned)) - } - // **Only a build of the trunk's head says its build source** (novox/hq ADR 0267): the planner maps the - // next merge onto the build source of the newest build, and a branch's closure — or an older trunk - // commit's, built by hand — is not the trunk's. Nor does a build whose context was not its trunk's head. - var sources []BuildSource - if trunk != "" && onTrunk && src.contextsAtHead && atTrunkHead(ctx, run, tree, commit, trunk) { - sources = src.buildSources() - for _, s := range sources { - where := "its own repository" - if s.Repository != "" { - where = s.Repository - } - say("source", "%d path(s) of %s", len(s.Paths), where) - } - } - // The reuse key (novox/hq ADR 0350 §1): what a check keeps its build under, and what a walk takes one by. - key := keyOf(src, manifest, Version) - return Result{Manifest: resolved, Commit: commit, Built: built, - Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint, SourceInputs: resultInputs(src), - Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources, - ReuseKey: key.Key, ReuseParts: key.Parts, NoKey: key.Why, Reused: reused, Why: builtWhy, - Predefined: listed}, nil -} - -// generationOf is how many commits the commit built stands on, itself included: of two commits on one trunk the -// later has more, so the controller keeps the list of the newer one whatever order it hears them in (ADR 0348 -// §7). 0 when git could not say. -func generationOf(ctx context.Context, run Runner, tree, commit string) int { - out, err := run(ctx, tree, "git", "rev-list", "--count", commit) - if err != nil { - return 0 - } - n, err := strconv.Atoi(strings.TrimSpace(out)) - if err != nil || n < 0 { - return 0 - } - return n -} - -// takeACheckBuild is the walk's look for a check build of its key (novox/hq ADR 0350 §2, §3): the key taken by -// the steps a build takes, each artifact stopped before it is built; the build kept under it copied by digest into -// the module's namespace. Answers what it took and the source inputs its key was taken from, or why it builds. A -// copy that fails is said, and the walk builds (§3): it never fails the walk on its own. -func takeACheckBuild(ctx context.Context, run Runner, publish Publisher, reuse Reuse, manifest catalogue.Manifest, - within, workspace, commit, credentials string, artifacts []catalogue.Artifact, args []string, - held map[string]string, npmrcPath string, npmrc Npmrc, seats map[string]string, src *sourceInputs, onTrunk bool, - say func(step, format string, args ...any)) (*Reused, *sourceInputs, string) { - if !onTrunk { - return nil, nil, "the commit is not on its trunk, and only a trunk commit's walk takes a check's build" - } - keyed, key, err := keyFor(ctx, run, publish, manifest, within, workspace, commit, credentials, artifacts, args, - held, npmrcPath, npmrc, seats, src, say) - if err != nil { - return nil, nil, err.Error() - } - if key.Key == "" { - return nil, nil, "it has no reuse key: " + key.Why - } - found, ok, err := reuse.Scratch.Find(ctx, manifest.Module, key.Key) - if err != nil { - return nil, nil, "the scratch namespace could not be read: " + err.Error() - } - if !ok { - return nil, nil, notKeptWhy(ctx, reuse, manifest.Module, key) - } - // **Only a build a verdict names** (ADR 0350 §2): the store takes writes from anything on the network, so a - // record is believed only when a check verdict of the walk's own pull requests names its key and its digests. - if why := unnamed(reuse.Named, manifest.Module, key.Key, found); why != "" { - return nil, nil, why - } - taken := &Reused{From: found} - for _, made := range found.Made { - copied, err := reuse.Scratch.Take(ctx, manifest.Module, made) - if err != nil { - why := fmt.Sprintf("copying %s's %s from %s failed (%v)", manifest.Module, made.Name, found.Said(), err) - say("reuse", "%s; built instead", why) - return nil, nil, why - } - taken.Made = append(taken.Made, copied) - } - // Every artifact the manifest declares, or none: a check build missing one is not this build. - if len(taken.Made) != len(artifacts) { - return nil, nil, fmt.Sprintf("%s holds %d artifact(s) of %s, which declares %d", found.Said(), len(taken.Made), - manifest.Module, len(artifacts)) - } - say("reuse", "%s", taken.Words()) - return taken, keyed, "" -} - -// keptCheckBuild is the build a check keeps instead of building (novox/hq issue 703): the one kept under the -// module's key by an earlier check of the same pull request at the same head — only the trunk moved — when a verdict -// of the build seat names its key and every digest — the record alone is never trusted (ADR 0350 §2) — and it holds -// every artifact the module declares. Another pull request's build, or an earlier head's, is never kept. Nil, and -// the check builds, otherwise. -func keptCheckBuild(ctx context.Context, reuse Reuse, module, key string, artifacts int, - say func(step, format string, args ...any)) *Reused { - if reuse.Scratch == nil || reuse.head == "" { - return nil - } - var same []NamedBuild - for _, n := range reuse.Named { - if n.Pull == reuse.pull { - same = append(same, n) - } - } - if len(same) == 0 { - return nil - } - found, ok, err := reuse.Scratch.Find(ctx, module, key) - switch { - case err != nil: - say("reuse", "the scratch namespace could not be read, so the check builds: %v", err) - return nil - case !ok: - return nil - case found.Pull != reuse.pull || found.Head == "" || twelve(found.Head) != twelve(reuse.head): - say("reuse", "the build kept under %s is %s, not this pull request's at this head: built", twelve(key[3:]), - found.Said()) - return nil - case unnamed(same, module, key, found) != "": - say("reuse", "a build is kept under %s and no verdict names it with its digests: built", twelve(key[3:])) - return nil - case len(found.Made) != artifacts: - return nil - } - kept := &Reused{From: found, Made: found.Made} - say("reuse", "kept, not built: %s", strings.Replace(kept.Words(), "reused from", "the build of", 1)) - return kept -} - -// keyFor takes a build's reuse key by the steps a build takes, each artifact stopped before anything is built or -// published (ADR 0350 §2): its own copy of the source inputs, so the build's are untouched. -func keyFor(ctx context.Context, run Runner, publish Publisher, manifest catalogue.Manifest, - within, workspace, commit, credentials string, artifacts []catalogue.Artifact, args []string, - held map[string]string, npmrcPath string, npmrc Npmrc, seats map[string]string, src *sourceInputs, - say func(step, format string, args ...any)) (*sourceInputs, reuseKey, error) { - keyed := src.copy() - keyed.onlyKey = true - for _, a := range artifacts { - if _, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, - npmrcPath, npmrc, seats, keyed, say); err != nil { - return nil, reuseKey{}, fmt.Errorf("its reuse key could not be taken: %w", err) - } - } - keyed.onlyKey = false - return keyed, keyOf(keyed, manifest, Version), nil -} - -// notKeptWhy is why no check build has the key (§3): no check of the walk's pull requests built the module, the -// key differs — naming the part — or the build it made is gone. -func notKeptWhy(ctx context.Context, reuse Reuse, module string, key reuseKey) string { - if len(reuse.Pulls) == 0 { - return "no check built it with this key" - } - builds, err := reuse.Scratch.OfPulls(ctx, reuse.Pulls) - if err != nil { - return "no check build has this key, and which checks built it could not be read: " + err.Error() - } - for _, b := range builds { - if b.Module != module { - continue - } - if b.Key == key.Key { - return b.Said() + " built it with this key, and it was removed" - } - return "the key differs from " + b.Said() + "'s in " + strings.Join(differing(b.Parts, key.Parts), ", ") - } - var pulls []string - for _, p := range reuse.Pulls { - pulls = append(pulls, p.String()) - } - return "no check of " + strings.Join(pulls, ", ") + " built it (a module its check did not move, or a check " + - "that did not finish)" -} - -// branchesHolding is every branch of a fresh clone's origin the commit is on, without `origin/`. -func branchesHolding(ctx context.Context, run Runner, clone, commit string) []string { - out, err := run(ctx, clone, "git", "branch", "--remotes", "--format=%(refname:short)", "--contains", commit) - if err != nil { - // empty-on-error: no branch said is the commit on none, which refuses its registration — never a pass - return nil - } - var branches []string - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if b, ok := strings.CutPrefix(line, "origin/"); ok && b != "HEAD" && b != "" { - branches = append(branches, b) - } - } - sort.Strings(branches) - return branches -} - -func orNoBranch(branches []string) string { - if len(branches) == 0 { - return "no branch" - } - return strings.Join(branches, ", ") -} - -// trunkOf is a fresh clone's trunk — the branch the forge names its default, as `origin/HEAD` says — and -// whether a commit is reachable from it. Empty when the clone does not say, which is "not known", never -// "on it". -func trunkOf(ctx context.Context, run Runner, clone, commit string) (string, bool) { - head, err := run(ctx, clone, "git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD") - if err != nil { - return "", false - } - remote := strings.TrimSpace(head) - trunk := strings.TrimPrefix(remote, "origin/") - if trunk == "" || trunk == remote { - return "", false - } - _, err = run(ctx, clone, "git", "merge-base", "--is-ancestor", commit, remote) - return trunk, err == nil -} - -// atTrunkHead is whether a clone's commit is its trunk's head as the clone holds it. -func atTrunkHead(ctx context.Context, run Runner, clone, commit, trunk string) bool { - head, err := run(ctx, clone, "git", "rev-parse", "origin/"+trunk) - if err != nil { - return false - } - at, err := run(ctx, clone, "git", "rev-parse", commit) - if err != nil { - return false - } - return strings.TrimSpace(head) != "" && strings.TrimSpace(head) == strings.TrimSpace(at) -} - -// cleanEntry is a path of the module's directory as an entry: cleaned, relative, `.` for the directory. -func cleanEntry(p string) string { - c := strings.Trim(path.Clean("/"+filepath.ToSlash(p)), "/") - if c == "" { - return "." - } - return c -} - -// orNoTree is why a build has no source fingerprint, for its log. -func orNoTree(why string) string { - if why == "" { - return "its tree was not named" - } - return why -} - -// Log is where a build says what it is doing, step by step. Nil is silent — the tests pass none, -// and a build with nowhere to speak must still build. -type Log func(step, message string) - -func logging(log Log) func(step, format string, args ...any) { - if log == nil { - return func(string, string, ...any) {} - } - return func(step, format string, args ...any) { - log(step, fmt.Sprintf(format, args...)) - } -} - -// keepsImageLocally says the publisher leaves the image in the machine's runtime as the artifact itself. -func keepsImageLocally(publish Publisher) bool { - switch publish.(type) { - case Local, *Local: - return true - } - return false -} - -// PushedNames is implemented by a publisher that tags the image under other names to push it: the names it leaves in the -// machine's runtime for a repository, which go with the build's own tag (novox/hq issue 1834). -type PushedNames interface { - PushedNames(repository string) []string -} - -// releaseImage removes the names an image was given on the node, in one command: its layers go only with the last. It runs -// with a context of its own, the build's may be cancelled. A name never made (the build failed before it) is no fault; -// one that cannot be removed is said, and removal is said only when the image is gone. -func releaseImage(ctx context.Context, run Runner, names []string, say func(step, format string, args ...any)) { - removing, done := context.WithTimeout(context.WithoutCancel(ctx), time.Minute) - defer done() - if _, err := run(removing, "", "docker", append([]string{"rmi"}, names...)...); err != nil { - if !strings.Contains(err.Error(), "No such image") { - say("image", "the local image %s could not be removed, and goes with the week's sweep: %v", strings.Join(names, ", "), err) - } - return - } - say("image", "the local image %s is removed: the registry holds it", strings.Join(names, ", ")) -} - -// contextFrom clones an image artifact's own build context, when it names one apart from this -// module's own repository — a fresh tree, the same way the module's own is, keyed by artifact -// name so two artifacts of one module naming different contexts do not collide. -func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentials string, - from catalogue.ArtifactContext, seats map[string]string, say func(step, format string, args ...any)) (_ string, err error) { - url, err := contextURL(from, seats) - if err != nil { - return "", err - } - say("context", "cloning %s at %s for %s", url, refOrHead(from.Ref), artifact) - dir := filepath.Join(workspace, "context-"+artifact) - if err := os.RemoveAll(dir); err != nil { - return "", err - } - // A clone that failed half-way is no context to leave behind (novox/hq issue 1834). - defer func() { - if err != nil { - os.RemoveAll(dir) - } - }() - if err := cloneFromForge(ctx, run, workspace, url, dir, cloneWith(credentials, "clone", "--quiet", url, dir), say); err != nil { - return "", fmt.Errorf("cannot clone %s: %w", url, err) - } - if err := recordedWithoutUserinfo(ctx, run, dir, url); err != nil { - return "", err - } - if from.Ref != "" { - if _, err := run(ctx, dir, "git", "checkout", "--quiet", from.Ref); err != nil { - return "", fmt.Errorf("%s has no %s: %w", from.Repository, from.Ref, err) - } - } - say("context", "done") - return dir, nil -} - -// contextURL is what a context is cloned from: its URL, or — for a context on a seat — the seat's -// clone base the mesh sent with the request joined to the repository's path (novox/hq ADR 0155). -// Refused, never guessed, when the mesh sent no base for that seat: a builder that guessed a forge -// would be the literal this removes, one layer down. -func contextURL(from catalogue.ArtifactContext, seats map[string]string) (string, error) { - if from.Seat == "" { - return from.Repository, nil - } - base, told := seats[from.Seat] - if !told || base == "" { - return "", fmt.Errorf("the context is %s on the %s seat, and this build was told no clone "+ - "base for that seat — nothing holds it in this mesh, or the control plane predates the word", - from.Repository, from.Seat) - } - return strings.TrimRight(base, "/") + "/" + strings.TrimSuffix(strings.Trim(from.Repository, "/"), ".git") + ".git", nil -} - -// storeCredential writes the forge credential where git's credential store reads it, and the function that -// removes it again; "" and nothing to remove when the builder holds none. -// -// **Never inside the workspace** (novox/hq issue 462): a merge check's toolchain container mounts the -// workspace as its HOME, so a credential kept there — even one a build left behind — is readable by any pull -// request's merge-check.sh, and what it prints is kept on the bus. So it lives in a directory of its own -// outside the workspace, made private, and a credential an older builder left in the workspace is removed. -func storeCredential(workspace string, forge GitCredential) (string, func(), error) { - if err := os.Remove(filepath.Join(workspace, "git-credentials")); err != nil && !errors.Is(err, os.ErrNotExist) { - return "", nil, fmt.Errorf("a credential left in the workspace cannot be removed: %w", err) - } - if forge.URL == "" { - return "", func() {}, nil - } - dir, err := os.MkdirTemp("", "mesh-forge-credential-") - if err != nil { - return "", nil, err - } - forget := func() { os.RemoveAll(dir) } - if withinDir(workspace, dir) { - forget() - return "", nil, fmt.Errorf("the temporary directory %s is inside the workspace %s, which a check's "+ - "container mounts: the forge credential is not written where it could read it", dir, workspace) - } - path := filepath.Join(dir, "git-credentials") - if err := os.WriteFile(path, []byte(forge.URL+"\n"), 0o600); err != nil { - forget() - return "", nil, err - } - return path, forget, nil -} - -// recordedWithoutUserinfo makes a clone record the URL it came from without userinfo: git keeps it as given in -// the clone's .git/config, inside the workspace a check's container mounts (novox/hq issue 462). -func recordedWithoutUserinfo(ctx context.Context, run Runner, clone, repository string) error { - bare, carried := withoutUserinfo(repository) - if !carried { - return nil - } - if _, err := run(ctx, clone, "git", "remote", "set-url", "origin", bare); err != nil { - return fmt.Errorf("the clone of %s keeps its credential: %w", bare, err) - } - return nil -} - -// forgetLeftCredentials removes what an earlier build or an older builder left in the workspace that holds -// a credential: the forge's git-credentials, and every .npmrc a build wrote into a tree it cloned. Run -// before a check, whose container mounts the workspace as its HOME (novox/hq issue 462). The Go caches are -// not walked: no build writes a credential there, and they hold more files than everything else. -func forgetLeftCredentials(workspace string) error { - if err := os.Remove(filepath.Join(workspace, "git-credentials")); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - return filepath.WalkDir(workspace, func(p string, d fs.DirEntry, err error) error { - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return err - } - if d.IsDir() && p != workspace && (d.Name() == "go-cache" || d.Name() == "go-modules") && - filepath.Dir(p) == workspace { - return filepath.SkipDir - } - if !d.IsDir() && d.Name() == ".npmrc" { - if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("a package-registry credential left at %s cannot be removed: %w", p, err) - } - } - return nil - }) -} - -// withinDir is whether path is dir or under it, both made absolute and resolved. -func withinDir(dir, path string) bool { - d, err1 := filepath.Abs(dir) - p, err2 := filepath.Abs(path) - if err1 != nil || err2 != nil { - return true - } - if r, err := filepath.EvalSymlinks(d); err == nil { - d = r - } - if r, err := filepath.EvalSymlinks(p); err == nil { - p = r - } - rel, err := filepath.Rel(d, p) - return err != nil || rel == "." || filepath.IsLocal(rel) -} - -// cloneWith is a git invocation that may offer a stored credential. -// -// The first `-c credential.helper=` clears every helper the environment might carry, so exactly -// one place answers an authentication challenge: the file the builder wrote. Without a file, the -// invocation is exactly what it always was. -func cloneWith(credentials string, rest ...string) []string { - if credentials == "" { - return rest - } - return append([]string{ - "-c", "credential.helper=", - "-c", "credential.helper=store --file=" + credentials, - }, rest...) -} - -func describePath(path string) string { - if path == "" { - return "" - } - return " at " + path -} - -func refOrHead(ref string) string { - if ref == "" { - return "HEAD" - } - return ref -} - -func artifactCount(m catalogue.Manifest) int { - if m.Build == nil { - return 0 - } - return len(m.Build.Artifacts) -} - -func langSuffix(a catalogue.Artifact) string { - if a.Language != "" { - return ", " + a.Language - } - return "" -} - -func describeMade(made catalogue.Built) string { - if made.Digest != "" { - return made.Kind + " " + short(strings.TrimPrefix(made.Digest, "sha256:")) - } - return made.Kind + " " + made.Reference -} - -// inside resolves a module's path within a clone, and refuses one that leaves it. -// -// **A build reads only its own tree.** A path of `../../etc` would otherwise make a build read — -// and an archive artifact publish — whatever the build machine happens to hold, which is the one -// thing a machine that builds other people's repositories must not do. -func inside(tree, path string) (string, error) { - if path == "" { - return tree, nil - } - if filepath.IsAbs(path) { - return "", fmt.Errorf( - "a module's path is inside its repository, and %q is an absolute path", path) - } - within := filepath.Join(tree, path) - rel, err := filepath.Rel(tree, within) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return "", fmt.Errorf( - "%q leaves the repository, and a build reads only its own tree", path) - } - // A link in the fetched tree must not lead the module out of it either (issue 1609): compare where - // both really are. A module directory that does not exist is left to the read of its manifest. - realTree, err := filepath.EvalSymlinks(tree) - if err != nil { - return "", err - } - realWithin, err := filepath.EvalSymlinks(within) - if errors.Is(err, os.ErrNotExist) { - return within, nil - } - if err != nil { - return "", err - } - if rel, err := filepath.Rel(realTree, realWithin); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("%q leads out of the repository through a link: it resolves to %s, and the repository is %s", - path, realWithin, realTree) - } - return within, nil -} - -// describe says where a manifest was looked for, in words a person can act on. -func describe(path string) string { - if path == "" { - return "its root" - } - return path -} - -// pinnedImage matches an image reference pinned by digest, which is the only kind a build input is -// allowed to name — a tag is something somebody else can move under you. -var pinnedImage = regexp.MustCompile(`[A-Za-z0-9][A-Za-z0-9._/:-]*@sha256:[0-9a-f]{64}`) - -// against is what this module's image artifacts are built on top of: every base the mesh resolved -// and handed the recipe as a build argument (`build.on`), and any image a recipe pins by digest -// itself. Nothing is guessed: a reference that was neither resolved nor written down is not -// reported. -// -// **The resolved bases are the edges.** A recipe reads its base from an argument (`FROM -// ${RUNTIME_BASE}`), so the digest is never in the file, and a derivation that read files alone -// recorded no edge for any module on the mesh — which is why nothing knew what a changed base -// meant to rebuild (novox/hq 04-ISSUES/131). -func against(within string, manifest catalogue.Manifest, resolved []string) []string { - if manifest.Build == nil { - return nil - } - seen := map[string]bool{} - var out []string - for _, r := range resolved { - if r != "" && !seen[r] { - seen[r] = true - out = append(out, r) - } - } - for _, a := range manifest.Build.Artifacts { - if a.Kind != catalogue.ArtifactImage || a.From == "" { - continue - } - body, err := os.ReadFile(filepath.Join(within, a.From)) - if err != nil { - // Not fatal: the build itself already failed if this file was needed and missing, and - // reporting no edges is honest where inventing them would not be. - continue - } - for _, found := range pinnedImage.FindAllString(string(body), -1) { - if !seen[found] { - seen[found] = true - out = append(out, found) - } - } - } - sort.Strings(out) - return out -} - -// fromOr is where a registry was resolved from, as a build's log says it. -func fromOr(from string) string { - if from == "" { - return "what this build was handed" - } - return from -} - -// ManifestName is the one file a module repository must have. -// -// At the root, and named the same in every repository. A convention somebody can look for beats a -// setting somebody has to find. -const ManifestName = "module.json" - -// wantsPackages reports whether this module's build resolves anything from the mesh's package -// registry, so the credential is written into its context only then. A package artifact always -// does; an image does when its Dockerfile names .npmrc — the file it would COPY to authenticate. -func wantsPackages(manifest catalogue.Manifest, within string) bool { - for _, a := range manifest.Build.Artifacts { - switch a.Kind { - case catalogue.ArtifactPackage: - return true - case catalogue.ArtifactImage: - raw, err := os.ReadFile(filepath.Join(within, a.From)) - if err == nil && strings.Contains(string(raw), ".npmrc") { - return true - } - } - } - return false -} - -func one(ctx context.Context, run Runner, publish Publisher, - module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string, - held map[string]string, npmrc string, registry Npmrc, seats map[string]string, src *sourceInputs, - say func(step, format string, args ...any)) (catalogue.Built, error) { - - switch a.Kind { - case catalogue.ArtifactUpstream: - // Its key is the image it copies, read from the manifest (keyOf): nothing to take here. - if src.keyOnly() { - return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil - } - // Mirrored, not built: copied under a name of the mesh's own, so what a machine fetches is - // pinned by a digest this registry assigned rather than by a tag somebody else can move. - // - // **Between registries, never through this machine's image store** (novox/hq - // 04-ISSUES/046, ADR 0096). A published image is an index over several architectures; - // pulled, the store keeps the index and refuses to push one platform out of it, and - // every variant of pull-then-push failed the same way. A copy moves what is there. - if mirror, can := publish.(Mirrorer); can { - say("mirror", "copying %s into the mesh's registry", a.From) - reference, err := mirror.MirrorImage(ctx, a.From, module+"/"+a.Name) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: %w", module, err) - } - return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil - } - // Genesis has no registry to copy into: the image stays in this machine's store, named by - // its own id, as every artifact does before there is anywhere to publish. - say("mirror", "pulling %s", a.From) - if _, err := run(ctx, tree, "docker", "pull", a.From); err != nil { - return catalogue.Built{}, fmt.Errorf("%s: cannot fetch %s: %w", module, a.From, err) - } - reference, err := publish.PublishImage(ctx, a.From, module+"/"+a.Name) - if err != nil { - return catalogue.Built{}, err - } - return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil - - case catalogue.ArtifactImage: - // Tagged by commit rather than by version, because a version is what a person calls a - // release and a commit is what was actually built. The mesh pins the digest anyway; this - // is only so a person looking at the build node can tell what is there. - local := fmt.Sprintf("%s-%s:%s", module, a.Name, short(commit)) - // The bases this module named, resolved to what this mesh holds. A recipe reads them as - // build arguments, so a module says which module it stands on and never which copy. - // **A recipe fetches nothing the manifest did not declare** (novox/hq 04-ISSUES/064). A FROM - // or a COPY --from naming a registry image that is not a declared base is a build that - // reaches a public registry on its own — and works when that registry answers, which is - // sometimes. Refused here, in front of the person who can declare it, not inside a build - // that fails with "pull access denied" for a reason that is not the mesh's. - recipe, err := os.ReadFile(filepath.Join(tree, a.From)) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: cannot read the recipe %s: %w", module, a.From, err) - } - declared := map[string]bool{} - for i := 0; i+1 < len(args); i += 2 { - if args[i] == "--build-arg" { - declared[strings.SplitN(args[i+1], "=", 2)[0]] = true - } - } - bases, copies := undeclaredFetches(string(recipe), declared) - if len(copies) > 0 { - return catalogue.Built{}, fmt.Errorf( - "%s: the recipe %s copies out of %s, which the manifest does not declare. A build "+ - "reaching a public registry on its own works only when that registry answers; "+ - "declare it under build.on as {\"arg\": \"\", \"image\": \"@sha256:…\"} "+ - "and read it from that argument (novox/hq ADR 0097)", - module, a.From, strings.Join(copies, ", ")) - } - if len(bases) > 0 { - // Refused, since the mesh's own images declare theirs (ADR 0097): a base fetched on - // its own is a build that works when a public registry answers, which is sometimes. - return catalogue.Built{}, fmt.Errorf( - "%s: the recipe %s starts FROM %s, which the manifest does not declare. Declare "+ - "each under build.on as {\"arg\": \"\", \"image\": \"@sha256:…\"} "+ - "and start FROM ${} (novox/hq ADR 0097)", - module, a.From, strings.Join(bases, ", ")) - } - // The recipe is always read from this module's own tree, at this module's own commit — only - // the context docker build's final argument names can come from somewhere else, when the - // artifact says so. - recipePath := a.From - buildDir := tree - if a.Context != nil { - cloned, err := contextFrom(ctx, run, workspace, a.Name, credentials, *a.Context, seats, say) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: %s's context: %w", module, a.Name, err) - } - if t, err := gitTree(ctx, run, cloned, ""); err != nil { - src.notPinned(a.Name + "'s context could not be named: " + err.Error()) - } else if src != nil { - src.contexts[a.Name] = t - } - buildDir = cloned - // Let go of when the image is built, whatever became of it (novox/hq issue 1834). - defer os.RemoveAll(cloned) - if t, _ := trunkOf(ctx, run, cloned, "HEAD"); t == "" || !atTrunkHead(ctx, run, cloned, "HEAD", t) { - src.contextOffHead() - } - } - // docker build accepts -f outside the context it is given; the recipe stays exactly where it was - // read from and validated against, absolute so a context elsewhere does not change which file - // that is. - if buildDir != tree || a.Compiles != "" { - absRecipe, err := filepath.Abs(filepath.Join(tree, a.From)) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: %s's recipe: %w", module, a.Name, err) - } - recipePath = absRecipe - } - // **An image that compiles a Go program is handed its build source and nothing else** (novox/hq - // ADR 0267 rules 1 and 3): the program's import closure, read from the context it is built in, so a - // merge elsewhere in that repository is no change to it — and a recipe that copies a file outside - // it fails here, naming the file, rather than building from something no merge is mapped onto. - if a.Compiles != "" { - paths, err := gosource.GoBuildSource(buildDir, a.Compiles) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: %s compiles %s, whose build source cannot be read: %w", - module, a.Name, a.Compiles, err) - } - narrowed := filepath.Join(workspace, "narrow-"+a.Name) - sum, err := gosource.NarrowTree(buildDir, narrowed, paths) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: handing %s its build source: %w", module, a.Name, err) - } - say("image", "%s is handed its build source: %d path(s) of %s", a.Name, len(paths), a.Compiles) - if a.Context != nil { - src.contexts[a.Name] = sum - src.readIn(*a.Context, paths) - } else { - src.ownHas(paths...) - } - buildDir = narrowed - defer os.RemoveAll(narrowed) - } else if a.Context != nil { - src.readWhole(*a.Context) - } - if a.Compiles != "" || a.Context != nil { - src.ownHas(cleanEntry(a.From)) - } else { - src.ownWhole() - } - // Taken for a reuse key (novox/hq ADR 0350 §2): everything its fingerprint reads is read; nothing is built. - if src.keyOnly() { - return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil - } - invocation := append([]string{"build", "-f", recipePath, "-t", local}, args...) - if a.Target != "" { - invocation = append(invocation, "--target", a.Target) - } - if npmrc != "" { - // Host network for the build, so a RUN reaching the package registry finds it where the - // binding says it is — the machine's own loopback, where the registry answers. The - // credential itself is in the context as .npmrc, COPY'd by a stage that is not published; - // buildkit is not required, because this machine's docker may not carry buildx. - invocation = append(invocation, "--network", "host") - } - invocation = append(invocation, ".") - say("image", "docker build -f %s", recipePath) - // **The local tag goes with the build** (novox/hq issue 1834): an image published to a registry is held there, and - // a tag per module and commit left on the node was the 96 GB of a full disk. Only a node publishing nowhere (a - // mesh being raised) keeps it, since the local image is then the artifact. - if !keepsImageLocally(publish) { - names := []string{local} - if p, ok := publish.(PushedNames); ok { - names = append(names, p.PushedNames(module+"/"+a.Name)...) - } - defer releaseImage(ctx, run, names, say) - } - if _, err := run(ctx, buildDir, "docker", invocation...); err != nil { - return catalogue.Built{}, fmt.Errorf("%s: building %s failed: %w", module, a.Name, err) - } - say("image", "built, publishing") - // Its bases linked into its repository first, so the push needs none of their blobs from this - // machine's store, which may not hold them (novox/hq issue 694). Said and not fatal: a push - // from a store that holds them works without, and one that fails says which blob it lacked. - if lender, can := publish.(BaseLender); can { - if n, err := lender.LendBases(ctx, module+"/"+a.Name, buildArgValues(args)); err != nil { - say("image", "its bases could not all be linked into %s/%s before the push: %v", module, a.Name, err) - } else if n > 0 { - say("image", "%d blob(s) of its bases linked into %s/%s before the push", n, module, a.Name) - } - } - reference, err := publish.PublishImage(ctx, local, module+"/"+a.Name) - if err != nil { - return catalogue.Built{}, err - } - return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil - - case catalogue.ArtifactBundle: - // **The one recipe that both builds and packs.** Everything else either produces an image - // or packs what is already there; this compiles the module's own code first, in a - // toolchain the mesh chose from what the module said it was written in, and packs the - // result. - // - // The compiler runs in a container rather than on the build machine, for the reason every - // other build does: what a build needs installed is the toolchain's business, and a build - // machine that accumulated one toolchain per language would be a machine nobody could - // reproduce. - chain, err := ToolchainFor(a.Language) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: %s: %w", module, a.Name, err) - } - base, ok := held[chain.Base+"/"+chain.Artifact] - if !ok { - // Named, not pinned: the mesh answers with the copy it holds. Refused before anything - // is built, saying which module has to exist first, rather than failing inside a - // compile with a message about an image (novox/hq 04-ISSUES/044). - return catalogue.Built{}, fmt.Errorf( - "%s: %s is written in %s, which is compiled by %s's %q artifact, and this mesh "+ - "holds no copy of it. Build %s first", - module, a.Name, chain.Language, chain.Base, chain.Artifact, chain.Base) - } - if src != nil { - src.toolchains[a.Name] = toolchainOf(chain, base) - } - // A Go program's build source is its import closure (novox/hq ADR 0267 rule 1). Not read, it is the - // module's whole directory, as before — said, so the wider plan has a reason a person can find. - if chain.Language == "go" { - if paths, err := gosource.GoBuildSource(tree, a.From); err != nil { - say("bundle", "%s's build source is its whole directory: %v", a.Name, err) - src.ownWhole() - } else { - src.ownHas(paths...) - } - } else { - src.ownWhole() - } - if chain.Language == "typescript" { - if own, _ := ownDependencies(tree); len(own) > 0 { - src.notPinned(a.Name + " resolves packages of its own at build time") - } - } - if src.keyOnly() { - return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil - } - // The module's own packages first, where the compiler and the bundler resolve them from - // (dependencies.go); nothing at all for a module whose package.json names only the SDK. - if err := installOwn(ctx, run, tree, chain, base, registry, say); err != nil { - return catalogue.Built{}, fmt.Errorf("%s: %s: %w", module, a.Name, err) - } - say("bundle", "compiling %s in %s's toolchain", a.Language, chain.Base) - compiled, err := compile(ctx, run, tree, chain, base, a) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: compiling %s failed: %w", module, a.Name, err) - } - // **Every entrypoint the runtime may serve is executable** (novox/hq ADR 0193). The runtime - // knows no language; for one that runs through an interpreter the build writes the launcher. - launchers, err := writeLaunchers(compiled, chain, a) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: writing %s's launchers failed: %w", module, a.Name, err) - } - if chain.Bundler != "" { - say("bundle", "bundling each entrypoint into one file") - if compiled, err = bundled(ctx, run, tree, chain, base, a, launchers); err != nil { - return catalogue.Built{}, fmt.Errorf("%s: bundling %s failed: %w", module, a.Name, err) - } - } - say("bundle", "compiled, packing") - if err := outsideTheModule(tree, compiled); err != nil { - return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err) - } - body, err := pack(compiled) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err) - } - sum := sha256.Sum256(body) - digest := "sha256:" + hex.EncodeToString(sum[:]) - where, err := publish.PublishArchive(ctx, module+"/"+a.Name, body, digest) - if err != nil { - return catalogue.Built{}, err - } - return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest, Launchers: launchers}, nil - - case catalogue.ArtifactPackage: - // Built and published on a public base, to the mesh's package registry, by version - // (novox/hq ADR 0076). Not an image, not an archive: nothing on a machine references it, so - // there is no Publisher call — the container itself publishes, with the credential the - // build was handed. - say("package", "building and publishing %s (%s)", a.Name, a.Language) - src.ownWhole() - src.notPinned(a.Name + " is a package, built from what the registry holds when it is built") - if src.keyOnly() { - return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil - } - reference, err := publishPackage(ctx, run, module, tree, a, npmrc, say) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: publishing %s failed: %w", module, a.Name, err) - } - say("package", "published %s", reference) - return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil - - case catalogue.ArtifactArchive: - if src.keyOnly() { - if a.URL == "" { - src.ownHas(cleanEntry(a.From) + "/**") - } - return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil - } - var body []byte - var err error - if a.URL != "" { - // An upstream file pinned by URL and sha256 (novox/hq ADR 0316): its build source is the pin, which - // is in module.json, always part of it. - body, err = upstreamArchive(ctx, workspace, a, say) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: %s: %w", module, a.Name, err) - } - } else { - src.ownHas(cleanEntry(a.From) + "/**") - var root string - if root, err = archiveRoot(tree, a); err == nil { - body, err = pack(root) - } - } - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err) - } - sum := sha256.Sum256(body) - digest := "sha256:" + hex.EncodeToString(sum[:]) - where, err := publish.PublishArchive(ctx, module+"/"+a.Name, body, digest) - if err != nil { - return catalogue.Built{}, err - } - return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest}, nil - } - return catalogue.Built{}, fmt.Errorf("%s: %q is a %q, which is not something this builds", - module, a.Name, a.Kind) -} - -// archiveRoot is the directory an archive artifact is packed from, refused when a link in the fetched -// tree leads out of the module. The repository is somebody else's: pack follows a link in a parent -// component of `from`, so without this a committed link makes the build pack, register and serve any -// directory the build account can read (issue 1609). Both are compared where they really are. -func archiveRoot(tree string, a catalogue.Artifact) (string, error) { - root := filepath.Join(tree, a.From) - realTree, err := filepath.EvalSymlinks(tree) - if err != nil { - return "", err - } - realRoot, err := filepath.EvalSymlinks(root) - if err != nil { - return "", err - } - if rel, err := filepath.Rel(realTree, realRoot); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("the archive %s is taken from %q, which lies outside the module: it resolves to %s, and the module is %s", - a.Name, a.From, realRoot, realTree) - } - return root, nil -} - -// outsideTheModule refuses a path that, once every link in it is followed, is not inside the module's -// directory, naming where it really is and where the module is. For a path not there yet (an output -// directory about to be made) the deepest part that exists is the one followed. A repository commits -// its own links, so `.mesh-build` as a link would otherwise have a bundle's build write into, and pack, -// any directory the build account can read (issue 1609). -func outsideTheModule(tree, path string) error { - realTree, err := filepath.EvalSymlinks(tree) - if err != nil { - return err - } - real, rest := path, "" - for { - resolved, err := filepath.EvalSymlinks(real) - if err == nil { - real = filepath.Join(resolved, rest) - break - } - if !errors.Is(err, os.ErrNotExist) || filepath.Dir(real) == real { - return err - } - rest = filepath.Join(filepath.Base(real), rest) - real = filepath.Dir(real) - } - if rel, err := filepath.Rel(realTree, real); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return fmt.Errorf("%s leads out of the module through a link: it resolves to %s, and the module is %s", - path, real, realTree) - } - return nil -} - -// pack tars and gzips a directory. -// -// **Deterministically**: entries sorted, and no timestamps, uid, gid or original names carried -// through. Two builds of one commit must produce one digest, or nothing downstream can tell "this -// changed" from "this was built again" — and every rebuild would look like a change to every -// machine holding it. -func pack(root string) ([]byte, error) { - info, err := os.Stat(root) - if err != nil { - return nil, err - } - if !info.IsDir() { - return nil, fmt.Errorf("%s is not a directory", root) - } - - var paths []string - err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() || !info.Mode().IsRegular() { - // Only files. A symlink or a device in an archive is refused by the host that unpacks - // it, so putting one in would build something that cannot be applied. - if !info.IsDir() && !info.Mode().IsRegular() { - return fmt.Errorf("%s is neither a file nor a directory, and an archive carries "+ - "only those", path) - } - return nil - } - paths = append(paths, path) - return nil - }) - if err != nil { - return nil, err - } - // filepath.Walk is documented to walk in lexical order, so this is belt and braces rather - // than load-bearing — and no test distinguishes it, which is worth saying rather than - // implying otherwise. It stays because the cost is nothing and the failure it guards against - // is silent: an archive whose digest changes because the traversal did. - sort.Strings(paths) - - var out strings.Builder - zipped := gzip.NewWriter(&stringWriter{&out}) - writer := tar.NewWriter(zipped) - for _, path := range paths { - body, err := os.ReadFile(path) - if err != nil { - return nil, err - } - relative, err := filepath.Rel(root, path) - if err != nil { - return nil, err - } - info, err := os.Stat(path) - if err != nil { - return nil, err - } - mode := int64(info.Mode().Perm()) - if err := writer.WriteHeader(&tar.Header{ - Name: filepath.ToSlash(relative), Mode: mode, Size: int64(len(body)), - Typeflag: tar.TypeReg, - // Everything else left at its zero value on purpose — see the note above. - }); err != nil { - return nil, err - } - if _, err := writer.Write(body); err != nil { - return nil, err - } - } - if err := writer.Close(); err != nil { - return nil, err - } - if err := zipped.Close(); err != nil { - return nil, err - } - return []byte(out.String()), nil -} - -type stringWriter struct{ to *strings.Builder } - -func (w *stringWriter) Write(p []byte) (int, error) { return w.to.Write(p) } - -func short(commit string) string { - if len(commit) > 8 { - return commit[:8] - } - return commit -} - -// Said is where the lines Command speaks go, beside the build's own Log: what runs, how long it -// took, and that it failed. Nil prints them to stderr, as a build machine with nobody listening -// should. The machine sets it per build so every line reaches the bus too (novox/hq ADR 0157) — -// the step is "run", and the message is the line as it has always been printed. -var Said Log - -func tell(step, format string, args ...any) { - message := fmt.Sprintf(format, args...) - if Said == nil { - fmt.Fprintf(os.Stderr, " %s\n", message) - return - } - Said(step, message) -} - -// Command is a Runner that actually runs things. -func Command(ctx context.Context, dir, name string, args ...string) (string, error) { - // **Every command is echoed before it runs**, with where. On a build that hangs, the last line - // is exactly the command it is inside — which is the difference between "the builder did - // nothing" and "git clone is waiting on a network that will not answer". Silent on success is - // what made an empty workspace unreadable. - started := timeNow() - tell("run", "$ (%s) %s %s", short(filepath.Base(dir)), name, strings.Join(args, " ")) - cmd := exec.CommandContext(ctx, name, args...) - cmd.Dir = dir - // **Ended whole when the build is** (novox/hq ADR 0219): its own process group, killed as one - // when the context ends, so a build killed by hand leaves no `git` or `docker` child running. - inItsOwnGroup(cmd) - out, err := cmd.CombinedOutput() - if err != nil { - tell("run", "! %s %s failed after %s", name, args[0], since(started)) - // The command's own output is part of what a reader needs — the compiler's error, the - // clone's refusal — and a line per output line keeps it readable on the bus. - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { - if line != "" { - tell("output", "%s", line) - } - } - return string(out), fmt.Errorf("%s %s: %w\n%s", - name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) - } - tell("run", "✓ %s %s (%s)", name, firstArg(args), since(started)) - return string(out), nil -} - -func firstArg(args []string) string { - if len(args) == 0 { - return "" - } - return args[0] -} - -var _ io.Writer = (*stringWriter)(nil) - -// standingOn turns the bases a module named into build arguments for what this mesh holds. -// -// **Refused rather than defaulted** (novox/hq issue 044). A module naming a base the mesh has not -// built cannot be built here yet, and the useful sentence names which module is missing — not the -// one a container runtime produces when a recipe's first line refers to an image nobody has. -// -// The order is fixed so two builds of one commit invoke the same command. Returned alongside the -// arguments is every reference they resolved to, which is what the build stood on. -func standingOn(ctx context.Context, manifest catalogue.Manifest, held map[string]string, - mirror func(ctx context.Context, from, repository, former string) (string, error)) ([]string, []string, error) { - if manifest.Build == nil || len(manifest.Build.On) == 0 { - return nil, nil, nil - } - on := append([]catalogue.BuildsOn{}, manifest.Build.On...) - sort.Slice(on, func(i, j int) bool { return on[i].Arg < on[j].Arg }) - - var args, resolved []string - for _, base := range on { - if base.Image != "" { - // A vendor's image, declared (novox/hq 04-ISSUES/064, ADR 0097). Pinned, because a tag - // is what somebody else can move; copied into the mesh's registry, because a build - // that reaches a public registry on its own is a build that works sometimes. - if base.Arg == "" || base.Module != "" || base.Artifact != "" { - return nil, nil, fmt.Errorf( - "%s stands on the image %s, and a base is either a module's artifact or an "+ - "image — never both — read from one build argument", manifest.Module, base.Image) - } - if !strings.Contains(base.Image, "@sha256:") { - return nil, nil, fmt.Errorf( - "%s stands on the image %q, which is not pinned by digest. A tag is what "+ - "somebody else can move; name it as @sha256:…", manifest.Module, base.Image) - } - // **One copy per upstream image, whichever modules stand on it** (novox/hq ADR 0257). Its - // repository is named for the image, not the module; the module's own repository of - // before is offered as a source, so the move to one copy asks upstream for nothing. - repository, err := MirrorRepository(base.Image) - if err != nil { - return nil, nil, fmt.Errorf("%s stands on %s: %w", manifest.Module, base.Image, err) - } - reference, err := mirror(ctx, base.Image, repository, FormerMirrorRepository(manifest.Module, base.Arg)) - if err != nil { - return nil, nil, fmt.Errorf("%s stands on %s: %w", manifest.Module, base.Image, err) - } - args = append(args, "--build-arg", base.Arg+"="+reference) - resolved = append(resolved, reference) - continue - } - if base.Arg == "" || base.Module == "" || base.Artifact == "" { - return nil, nil, fmt.Errorf( - "%s says its build stands on something, and does not say all of what: a base "+ - "needs the module, the artifact, and the build argument the recipe reads it "+ - "from", manifest.Module) - } - key := base.Module + "/" + base.Artifact - reference, has := held[key] - if !has { - return nil, nil, fmt.Errorf( - "%s builds on %s, and this mesh has not built it. Build %s first — every module "+ - "in this toolchain stands on it, so it is the thing to have before anything "+ - "else", manifest.Module, key, base.Module) - } - args = append(args, "--build-arg", base.Arg+"="+reference) - resolved = append(resolved, reference) - } - return args, resolved, nil -} - -// buildArgValues is what each --build-arg of a build was handed: the bases, as this mesh holds them. -func buildArgValues(args []string) []string { - var values []string - for i := 0; i+1 < len(args); i += 2 { - if args[i] == "--build-arg" { - if _, value, has := strings.Cut(args[i+1], "="); has { - values = append(values, value) - } - } - } - return values -} - -// compile runs a module's own code through its toolchain, and says where the result is. -// -// **In the module's own directory, under the path the toolchain expects.** A module is compiled -// where its dependencies resolve upward into the base's own library directory, so what it is -// compiled against is exactly what it will run against — the reason every hand-written Dockerfile -// had to choose a working directory carefully, and the reason none of them has to now. -// publishPackage builds a `package` artifact on a public base image and publishes it to the mesh's -// package registry by version. The credential arrives as an .npmrc file the build was handed -// (novox/hq ADR 0076); it is mounted read-only into the container rather than baked, because a -// package build produces no image to leak it into. The reference returned is name@version, read from -// the module's own package.json — the same two fields npm publishes under. -func publishPackage(ctx context.Context, run Runner, module, dir string, a catalogue.Artifact, - npmrc string, say func(step, format string, args ...any)) (string, error) { - - recipe, ok := packageRecipes[a.Language] - if !ok { - return "", fmt.Errorf( - "a package written in %q cannot be built: no public toolchain is known for it", a.Language) - } - if npmrc == "" { - // A package with nowhere to be published is not built. Said here rather than failing inside - // npm publish with a message about a registry that is simply absent. - return "", fmt.Errorf( - "%s is a package and this build was given no package registry to publish it to", a.Name) - } - - raw, err := os.ReadFile(filepath.Join(dir, "package.json")) - if err != nil { - return "", fmt.Errorf("a package is published by name and version, and %s has no package.json: %w", module, err) - } - var pkg struct { - Name string `json:"name"` - Version string `json:"version"` - } - if err := json.Unmarshal(raw, &pkg); err != nil { - return "", fmt.Errorf("%s's package.json is not readable: %w", module, err) - } - if pkg.Name == "" || pkg.Version == "" { - return "", fmt.Errorf("%s's package.json names no %s to publish under", - module, either(pkg.Name == "", "name", "version")) - } - - const within = "/app/module" - invocation := []string{ - "run", "--rm", - // Host network, so the publish reaches the registry at the address the binding names. - "--network", "host", - "--volume", dir + ":" + within, - // Read-only, so a build cannot alter the credential, and at /root where npm reads it. - "--volume", npmrc + ":/root/.npmrc:ro", - "--workdir", within, - recipe.Base, - "sh", "-c", recipe.Script, - } - if _, err := run(ctx, dir, "docker", invocation...); err != nil { - return "", err - } - return pkg.Name + "@" + pkg.Version, nil -} - -// either names whichever of two fields is the missing one, for a message that says which. -func either(first bool, a, b string) string { - if first { - return a - } - return b -} - -func compile(ctx context.Context, run Runner, tree string, chain Toolchain, - base string, a catalogue.Artifact) (string, error) { - - // Where inside the toolchain the module's source is mounted, and where its output lands. Fixed - // rather than configurable: a module that could move this would be describing its own build. - const within = "/app/modules/module" - - // **Its own output directory, because a module may be several languages at once.** One module - // is one piece of software and can still carry a daemon in one language, tools in another and - // a package in a third (ADR 0040). Compiling them all into one place would have them overwrite - // each other and then be packed together, so each bundle compiles and packs alone. - out := Out(a.Name) - if err := outsideTheModule(tree, filepath.Join(tree, out)); err != nil { - return "", err - } - - // **The output directory exists before the compiler is told about it.** `tsc --outDir` makes - // one; `go build -o` writes a file into a directory and does not create it, failing with a - // message about a path rather than about a build. Made here for every toolchain, because which - // compilers happen to be forgiving is not a thing a reader should have to know - // (novox/hq 04-ISSUES/142). - if err := os.MkdirAll(filepath.Join(tree, out), 0o755); err != nil { - return "", fmt.Errorf("making the output directory for %s: %w", a.Name, err) - } - - invocation := []string{ - "run", "--rm", - "--volume", tree + ":" + within, - "--workdir", within, - base, - } - invocation = append(invocation, chain.Compile...) - // **One `-ldflags`, composed here.** A repeated flag is not a merged one: the Go command takes - // the last and drops the first, so passing the toolchain's flags and then the system stamp as a - // second `-ldflags` produced a binary that knew its system and had lost `-s -w` — half again the - // size, with its debug info (novox/hq 04-ISSUES/161). - // - // What it was built for is the one thing taken from the artifact, and ADR 0142 says why: the - // target is a property of the artifact rather than of the recipe. A host with no system refuses - // every declaration before it applies anything. - linker := append([]string(nil), chain.LinkerFlags...) - if chain.SystemStamp != "" && strings.TrimSpace(a.System) != "" { - linker = append(linker, "-X", chain.SystemStamp+"="+strings.TrimSpace(a.System)) - } - if len(linker) > 0 { - invocation = append(invocation, "-ldflags", strings.Join(linker, " ")) - } - if chain.OutputFlag != "" { - // A compiler pointed at a package is told the file to write, not the directory: the name a - // machine runs it by is not always the name of the package that built it. The host's command - // is `cmd/mesh-host` and every machine runs it as `nox-mesh-host` — so a bundle carrying the - // package's name lands correctly, reports success, and is invisible to whatever looks for it - // (novox/hq 04-ISSUES/142). - target := out - if chain.Unit == UnitPackage { - target = filepath.Join(out, binaryName(a)) - } - invocation = append(invocation, chain.OutputFlag, target) - } - // What to compile. Named by the module rather than discovered, so adding a file does not - // silently change what a build produces. - switch { - case chain.Unit == UnitPackage: - // One directory, compiled whole: the thing the artifact is built `from`. Relative, because - // the compiler runs with the module's own root as its working directory and a package path - // that looked absolute would name one inside the toolchain image. - invocation = append(invocation, "./"+strings.Trim(a.From, "./")) - case len(a.Entrypoints) > 0: - invocation = append(invocation, sourcesFor(a.Entrypoints, out, chain.SourceExt)...) - } - if _, err := run(ctx, tree, "docker", invocation...); err != nil { - return "", err - } - if chain.Dependencies != "" && chain.Bundler == "" { - // **What the bundle runs with, from the image it was compiled in** (Toolchain.Dependencies). - // A second run in the same image rather than a shell wrapped around the compiler: the - // compile line stays a plain command a reader can run by hand, and the copy is one more - // plain command beside it. Refused by name when the image carries no such directory — an - // older toolchain image — because a bundle packed without its dependencies starts nowhere - // and says so three layers away from here. - copying := []string{ - "run", "--rm", - "--volume", tree + ":" + within, - "--workdir", within, - base, - "sh", "-c", - `test -d "$1" || { echo "the toolchain image carries no $1: it predates the mesh shipping a bundle's dependencies, rebuild $2 first" >&2; exit 1; }; cp -a "$1/." "$3/"`, - "dependencies", chain.Dependencies, chain.Base, out, - } - if _, err := run(ctx, tree, "docker", copying...); err != nil { - return "", fmt.Errorf("copying the %s dependencies a bundle runs with: %w", chain.Language, err) - } - } - return filepath.Join(tree, out), nil -} - -// sourcesFor turns compiled entrypoints back into what to compile. -// -// A module names what a tool host should LOAD — compiled paths under the bundle's root — because -// that is the thing anything else needs to know. What to compile is the same list with the -// language's own extension, which is the toolchain's business rather than the module's. -func sourcesFor(entrypoints []string, out, ext string) []string { - sources := make([]string, 0, len(entrypoints)) - for _, e := range entrypoints { - // An entrypoint is named as it will be FOUND — a path inside the unpacked bundle — so the - // source is the same path with the output directory taken off the front and the language's - // own extension on the end. **The extension is the toolchain's**, where it used to be the - // literal `.ts`: one language's file extension written into the code that serves every - // language is a wall the next one hits (novox/hq 04-ISSUES/142). - at := strings.TrimPrefix(strings.TrimPrefix(e, out), "/") - sources = append(sources, strings.TrimSuffix(at, filepath.Ext(at))+ext) - } - return sources -} - -func timeNow() time.Time { return time.Now() } -func since(t time.Time) string { return time.Since(t).Round(time.Millisecond).String() } - -// undeclaredFetches is every image a recipe reaches for that is neither a declared build argument -// nor one of its own stages nor `scratch`, in two lists: the bases it starts `FROM`, and the images -// it `COPY --from`s out of — a vendor's tool, the case novox/hq 04-ISSUES/064 is about. -func undeclaredFetches(recipe string, declared map[string]bool) (bases, copies []string) { - stages := map[string]bool{} - seen := map[string]bool{} - var out *[]string - note := func(ref string) { - ref = strings.TrimSpace(ref) - switch { - case ref == "" || ref == "scratch" || stages[strings.ToLower(ref)]: - return - case strings.HasPrefix(ref, "$"): - name := strings.Trim(strings.TrimPrefix(ref, "$"), "{}") - if cut := strings.IndexAny(name, ":-"); cut >= 0 { - name = name[:cut] - } - if !declared[name] { - if !seen[ref] { - seen[ref] = true - *out = append(*out, ref+" (a build argument the manifest does not declare)") - } - } - return - } - // A stage referenced by number (COPY --from=0) is its own recipe's. - if _, err := strconv.Atoi(ref); err == nil { - return - } - if !seen[ref] { - seen[ref] = true - *out = append(*out, ref) - } - } - for _, line := range instructions(recipe) { - fields := strings.Fields(line) - switch strings.ToUpper(fields[0]) { - case "FROM": - // FROM [--platform=…] [AS ] - out = &bases - var ref string - for i := 1; i < len(fields); i++ { - if strings.HasPrefix(fields[i], "--") { - continue - } - ref = fields[i] - if i+2 < len(fields) && strings.EqualFold(fields[i+1], "AS") { - stages[strings.ToLower(fields[i+2])] = true - } - break - } - note(ref) - case "COPY", "ADD": - out = &copies - for _, f := range fields[1:] { - if strings.HasPrefix(f, "--from=") { - note(strings.TrimPrefix(f, "--from=")) - } - } - case "RUN": - // RUN --mount=type=bind,from=,… reaches for an image exactly as COPY --from does. - out = &copies - for _, f := range fields[1:] { - if !strings.HasPrefix(f, "--mount=") { - continue - } - for _, opt := range strings.Split(strings.TrimPrefix(f, "--mount="), ",") { - if from, found := strings.CutPrefix(opt, "from="); found { - note(from) - } - } - } - } - } - return bases, copies -} - -// instructions is a recipe as its instructions, one per line: continuations joined, comments and -// blank lines dropped, and heredoc bodies (`COPY <= 0 { - // `< 0 { - heredoc = strings.Trim(strings.TrimPrefix(word[0], "-"), `'"`) - } - } - flush() - } - flush() - return out -} - -// readBy is every repository other than the module's own that this build's recipes read source from: the rule -// build-agent and the controller share (internal/buildreads). -func readBy(manifest catalogue.Manifest) []catalogue.ArtifactContext { return buildreads.Of(manifest) } - -// binaryName is what a compiled bundle's executable is called: what the artifact says, or the name of -// the package it is built from, which is what a compiler would have chosen anyway. -func binaryName(a catalogue.Artifact) string { - if name := catalogue.BinaryOf(a); name != "" { - return name - } - if name := strings.TrimSpace(a.Binary); name != "" { - return name - } - if from := strings.Trim(a.From, "./"); from != "" { - return filepath.Base(from) - } - return a.Name -} - -// launcherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is -// started as index.serve.mjs (novox/hq ADR 0193). An ES module by its own extension, whatever the -// bundle's package.json says. -const launcherSuffix = ".serve.mjs" - -// writeLaunchers writes, beside every entrypoint of a TypeScript bundle, an executable that -// imports the entrypoint and serves what it registered over MCP on stdio — through the bundle's -// own copy of the SDK, so registering and serving meet in one registry (novox/hq ADR 0193). Its -// answer is each entrypoint's launcher, by entrypoint, relative to the bundle's root; nothing for -// a language whose build is already executable. -func writeLaunchers(root string, chain Toolchain, a catalogue.Artifact) (map[string]string, error) { - if chain.Language != "typescript" { - return nil, nil - } - out := map[string]string{} - for _, entry := range a.Entrypoints { - if !strings.HasSuffix(entry, ".js") { - continue - } - launcher := strings.TrimSuffix(entry, ".js") + launcherSuffix - body := "#!/usr/bin/env node\n" + - "// Written by the mesh's builder (novox/hq ADR 0193): serve what " + entry + " registers,\n" + - "// over MCP on stdio, as the module the node's runtime names in MESH_SERVED_MODULE.\n" + - "import { serveRegisteredOverStdio } from \"@novox/mesh-sdk/stdio\";\n" + - "await import(\"./" + filepath.Base(entry) + "\");\n" + - "await serveRegisteredOverStdio();\n" - path := filepath.Join(root, filepath.FromSlash(launcher)) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return nil, err - } - if err := os.WriteFile(path, []byte(body), 0o755); err != nil { - return nil, err - } - // WriteFile honours the umask; the mode a machine unpacks is the one packed, so it is set. - if err := os.Chmod(path, 0o755); err != nil { - return nil, err - } - out[entry] = launcher - } - return out, nil -} - -// bundledSuffix is where a bundle's one-file output is written, beside what the compiler wrote. -const bundledSuffix = ".bundled" - -// bundled makes every entrypoint and every launcher of a compiled bundle ONE file, in the toolchain -// image's bundler, and answers the directory to pack (novox/hq ADR 0193). -// -// **What a launched bundle runs is what it imports, and nothing else.** Every served bundle is its -// own process, so it carries its own copy of the SDK and its own dependencies inlined — the -// toolchain's whole node_modules no longer travels in every bundle. An entrypoint a process runs by -// name (`node daemon/index.js`) is bundled in place under its own name; a launcher keeps its name -// and its first line, and stays executable. A package the bundler cannot inline is named by the -// artifact (`external`), kept as an import, and only then is the toolchain's runtime directory -// copied beside the files. CommonJS inlined into an ES module still finds `require`. -func bundled(ctx context.Context, run Runner, tree string, chain Toolchain, base string, - a catalogue.Artifact, launchers map[string]string) (string, error) { - const within = "/app/modules/module" - out, final := Out(a.Name), Out(a.Name)+bundledSuffix - if err := outsideTheModule(tree, filepath.Join(tree, final)); err != nil { - return "", err - } - if err := os.RemoveAll(filepath.Join(tree, final)); err != nil { - return "", err - } - if err := os.MkdirAll(filepath.Join(tree, final), 0o755); err != nil { - return "", err - } - common := []string{"--bundle", "--platform=node", "--format=esm", "--target=node22", - "--outbase=" + out, "--outdir=" + final, "--log-level=warning", - "--banner:js=import { createRequire as __meshRequire } from 'node:module'; const require = __meshRequire(import.meta.url);"} - for _, x := range a.External { - common = append(common, "--external:"+x) - } - var plain []string - for _, e := range a.Entrypoints { - if strings.HasSuffix(e, ".js") { - plain = append(plain, out+"/"+e) - } - } - var launch []string - for _, l := range sortedValues(launchers) { - launch = append(launch, out+"/"+l) - } - // Refused by name in an image that predates the bundler, as the dependencies copy is: a bundle - // packed without it would carry nothing it imports. Run as itself: npm installs esbuild's native - // binary in place of its script, which `node` cannot run. - guard := `test -x "$0" || { echo "the toolchain image carries no bundler at $0: it predates one-file bundles, rebuild mesh-tools first" >&2; exit 1; }; exec "$0" "$@"` - step := func(entries []string, extra ...string) error { - if len(entries) == 0 { - return nil - } - invocation := []string{"run", "--rm", "--volume", tree + ":" + within, "--workdir", within, base, - "sh", "-c", guard, chain.Bundler} - invocation = append(invocation, entries...) - invocation = append(invocation, common...) - invocation = append(invocation, extra...) - _, err := run(ctx, tree, "docker", invocation...) - return err - } - if err := step(plain); err != nil { - return "", err - } - if err := step(launch, "--out-extension:.js=.mjs"); err != nil { - return "", err - } - // Plain `.js` output is an ES module; said once, as the runtime directory used to say it. - if err := os.WriteFile(filepath.Join(tree, final, "package.json"), []byte(`{"type":"module","private":true}`+"\n"), 0o644); err != nil { - return "", err - } - for _, l := range launchers { - path := filepath.Join(tree, final, filepath.FromSlash(l)) - if _, err := os.Stat(path); err == nil { - if err := os.Chmod(path, 0o755); err != nil { - return "", err - } - } - } - if len(a.External) > 0 && chain.Dependencies != "" { - copying := []string{"run", "--rm", "--volume", tree + ":" + within, "--workdir", within, base, - "sh", "-c", `cp -a "$0/node_modules" "$1/"`, chain.Dependencies, final} - if _, err := run(ctx, tree, "docker", copying...); err != nil { - return "", fmt.Errorf("copying the packages %s keeps external: %w", a.Name, err) - } - } - return filepath.Join(tree, final), nil -} - -func sortedValues(m map[string]string) []string { - out := make([]string, 0, len(m)) - for _, v := range m { - out = append(out, v) - } - sort.Strings(out) - return out -} diff --git a/internal/builder/builder_integration_test.go b/internal/builder/builder_integration_test.go deleted file mode 100644 index 31447aec..00000000 --- a/internal/builder/builder_integration_test.go +++ /dev/null @@ -1,362 +0,0 @@ -//go:build integration - -package builder - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -func TestABuildProducesAManifestThePinsAreIn(t *testing.T) { - r, workspace := aRepository(t, withBoth, map[string]string{ - "Dockerfile": "FROM scratch", "files/theme.conf": "dark", - }) - got, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - if got.Commit != "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff" { - t.Fatalf("the commit was not recorded: %q", got.Commit) - } - if got.Manifest.Resources[0]["image"] == nil { - t.Fatalf("the image was not pinned: %v", got.Manifest.Resources[0]) - } - digest, _ := got.Manifest.Resources[1]["digest"].(string) - if !strings.HasPrefix(digest, "sha256:") { - t.Fatalf("the archive was not pinned: %v", got.Manifest.Resources[1]) - } -} - -func TestTwoBuildsOfOneCommitProduceOneDigest(t *testing.T) { - // Or nothing downstream can tell "this changed" from "this was built again", and every - // rebuild looks like a change to every machine holding it. - var digests []string - for i := 0; i < 2; i++ { - r, workspace := aRepository(t, withBoth, map[string]string{ - "Dockerfile": "FROM scratch", "files/a.conf": "one", "files/b.conf": "two", - }) - // A year apart, so a packer carrying timestamps cannot accidentally agree. - r.stamped = time.Date(2020+i, time.March, 3, 4, 5, 6, 0, time.UTC) - got, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - for _, b := range got.Built { - if b.Kind == catalogue.ArtifactArchive { - digests = append(digests, b.Digest) - } - } - } - if digests[0] != digests[1] { - t.Fatalf("two builds of one commit produced %s and %s", digests[0], digests[1]) - } -} - -func TestNothingIsPublishedUntilEverythingIsBuilt(t *testing.T) { - // Half a module in the store under a digest the mesh never records is reachable, - // unreferenced, and indistinguishable from something in use. - r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch"}) - // `files` is missing, so packing the archive fails — after the image would have been pushed. - _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) - if err == nil { - t.Fatal("a build with a missing input succeeded") - } - if len(r.archives) != 0 { - t.Fatalf("an archive was published by a failed build: %v", r.archives) - } -} - -func TestARepositoryWithNoManifestSaysSo(t *testing.T) { - workspace := t.TempDir() - r := &recorded{contents: map[string]string{"README.md": "nothing to see"}} - _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) - if err == nil { - t.Fatal("a repository with nothing saying what it is was built") - } - if !strings.Contains(err.Error(), ManifestName) { - t.Fatalf("the failure does not name what is missing: %v", err) - } -} - -func TestAModuleThatBuildsNothingStillProducesAManifest(t *testing.T) { - // Most of what a person installs is configuration. - r, workspace := aRepository(t, `{"module":"shell","version":"1","resources":[ - {"id":"rc","type":"file","path":"/etc/zsh/zshrc","content":"setopt"}]}`, nil) - got, err := Build(context.Background(), r.run, r, "https://forge.invalid/shell.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - if len(got.Built) != 0 { - t.Fatalf("something was built: %v", got.Built) - } - if got.Manifest.Module != "shell" || len(got.Manifest.Resources) != 1 { - t.Fatalf("got %+v", got.Manifest) - } - for _, line := range r.ran { - if strings.HasPrefix(line, "docker") { - t.Fatalf("docker was run for a module that builds nothing: %q", line) - } - } -} - -func TestTheTreeIsFreshEveryTime(t *testing.T) { - // A build that reuses a working tree can succeed because of something a previous build left - // behind, and that is a build nobody can reproduce. - r, workspace := aRepository(t, withBoth, map[string]string{ - "Dockerfile": "FROM scratch", "files/a": "b", - }) - leftover := filepath.Join(workspace, "source", "files", "from-last-time") - if err := os.MkdirAll(filepath.Dir(leftover), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(leftover, []byte("stale"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(leftover); err == nil { - t.Fatal("a previous build's file survived into this one") - } -} - -func TestABuildThatCannotPushFails(t *testing.T) { - r, workspace := aRepository(t, withBoth, map[string]string{ - "Dockerfile": "FROM scratch", "files/a": "b", - }) - r.failPush = true - if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil); err == nil { - t.Fatal("a build that could publish nothing reported success") - } -} - -func TestAnUpstreamImageIsMirroredRatherThanBuilt(t *testing.T) { - // A module usually runs software it did not write. Naming the upstream reference directly - // would need every machine to reach a public registry, and would pin to a tag somebody else - // can move. - const mirrors = `{"module":"postgres","version":"1", - "build":{"artifacts":[{"name":"store","kind":"upstream","from":"postgres:17-alpine"}]}, - "resources":[{"id":"db","type":"container","name":"mesh-postgres","artifact":"store"}]}` - - r, workspace := aRepository(t, mirrors, nil) - got, err := Build(context.Background(), r.run, r, "https://forge.invalid/postgres.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - // Pulled, not built. - var pulled, built bool - for _, line := range r.ran { - if strings.HasPrefix(line, "docker pull postgres:17-alpine") { - pulled = true - } - if strings.HasPrefix(line, "docker build") { - built = true - } - } - if !pulled { - t.Fatalf("the upstream image was not fetched: %v", r.ran) - } - if built { - t.Fatalf("something was built for an image that is mirrored: %v", r.ran) - } - // And the resource names what this registry serves, pinned by the digest it assigned. - image, _ := got.Manifest.Resources[0]["image"].(string) - if !strings.Contains(image, "@sha256:") { - t.Fatalf("the mirrored image is not pinned by digest: %q", image) - } - if strings.Contains(image, "17-alpine") { - t.Fatalf("the resource still names the upstream tag: %q", image) - } -} - -func TestAModuleIsBuiltFromItsPathWithinTheRepository(t *testing.T) { - r := &recorded{contents: map[string]string{ - "README.md": "this repository holds several modules", - "modules/shell/" + ManifestName: withBoth, - "modules/shell/Dockerfile": "FROM scratch", - "modules/shell/files/theme.conf": "dark", - // A second module beside it, so what is built is chosen by the path rather than by - // happening to be the only manifest in the clone. - "modules/other/" + ManifestName: `{"module":"other","version":"1"}`, - }} - got, err := Build(context.Background(), r.run, r, - "https://forge.invalid/catalogue.git", "modules/shell", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - if got.Manifest.Module != "meshboard" { - t.Fatalf("built %q, which is not the module at the path asked for", got.Manifest.Module) - } - if got.Manifest.Resources[0]["image"] == nil { - t.Fatalf("the image was not pinned: %v", got.Manifest.Resources[0]) - } -} - -func TestAPathThatLeavesTheRepositoryIsRefused(t *testing.T) { - for _, escaping := range []string{"../../etc", "/etc"} { - r := &recorded{contents: map[string]string{ManifestName: withBoth}} - _, err := Build(context.Background(), r.run, r, - "https://forge.invalid/x.git", escaping, "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) - if err == nil { - t.Fatalf("%q was accepted as a module's path", escaping) - } - if !strings.Contains(err.Error(), "leaves the repository") && - !strings.Contains(err.Error(), "absolute path") { - t.Fatalf("the refusal of %q does not say why: %v", escaping, err) - } - } -} - -func TestAnArtifactWithItsOwnContextIsBuiltFromThere(t *testing.T) { - const withContext = `{"module":"route-proxy","version":"1", - "build":{"artifacts":[ - {"name":"server","kind":"image","from":"Dockerfile", - "context":{"repository":"https://forge.invalid/source.git","ref":"main"}}]}}` - r := &recorded{ - contents: map[string]string{ - ManifestName: withContext, - // The recipe lives with the packaging, not the source — read from here regardless of - // where the build context comes from. FROM scratch declares no base, so what is under - // test — where the context comes from — is not entangled with ADR 0097's own checks. - "Dockerfile": "FROM scratch\nCOPY go.mod ./\n", - }, - secondary: map[string]map[string]string{ - // go.mod exists only in the second repository. A build context taken from the wrong - // place would never find it, which a real docker build would refuse on — the fake - // does not read files, so what is checked below is that the build was even pointed - // at the right place, not that COPY would have succeeded. - "https://forge.invalid/source.git": {"go.mod": "module route-proxy\n"}, - }, - } - _, err := Build(context.Background(), r.run, r, - "https://forge.invalid/catalogue.git", "", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - - var clonedSource bool - for _, line := range r.ran { - if strings.HasPrefix(line, "git clone") && strings.Contains(line, "https://forge.invalid/source.git") { - clonedSource = true - } - } - if !clonedSource { - t.Fatalf("the artifact's own context was never cloned: %v", r.ran) - } - - buildIndex := -1 - for i, line := range r.ran { - if strings.HasPrefix(line, "docker build ") { - buildIndex = i - } - } - if buildIndex == -1 { - t.Fatal("no docker build was run") - } - build := r.ran[buildIndex] - buildDir := r.dirs[buildIndex] - - if !strings.Contains(buildDir, "context-server") { - t.Errorf("docker build ran from %q, not the artifact's own cloned context", buildDir) - } - recipe := strings.SplitN(strings.SplitN(build, "-f ", 2)[1], " ", 2)[0] - if !filepath.IsAbs(recipe) { - t.Errorf("the recipe %q is not an absolute path, so it is read relative to whatever "+ - "directory the build context moved to rather than where it actually is", recipe) - } - if !strings.HasSuffix(recipe, string(filepath.Separator)+"Dockerfile") { - t.Errorf("the recipe is not the module's own Dockerfile: %q", recipe) - } - if !strings.HasSuffix(build, " .") { - t.Errorf("the build was not given a context: %s", build) - } -} - -func TestABuildOffersTheForgesCredentialThroughGitsOwnStore(t *testing.T) { - r, workspace := aRepository(t, withBoth, map[string]string{ - "Dockerfile": "FROM scratch", "files/theme.conf": "dark", - }) - _, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", - workspace, nil, Npmrc{}, - GitCredential{URL: "http://mesh_novox_builder:sw0rdfi5h@forge.invalid:20000"}, nil) - if err != nil { - t.Fatal(err) - } - clone := r.ran[0] - stored := storeNamedIn(t, clone) - for _, line := range r.ran { - if strings.Contains(line, "sw0rdfi5h") { - t.Fatalf("the secret is in a command line, readable by anything that can list processes: %s", line) - } - } - // Outside the workspace a check's container mounts, and gone once the build is (novox/hq issue 462); what - // it held while git read it is checked with the check's clones (TestACheckContainerSeesNoForgeCredential). - if withinDir(workspace, stored) { - t.Fatalf("the credential store %s is inside the workspace %s", stored, workspace) - } - if _, err := os.Stat(stored); !os.IsNotExist(err) { - t.Fatalf("the credential store outlives the build: %v", err) - } - if _, err := os.Stat(filepath.Join(workspace, "git-credentials")); !os.IsNotExist(err) { - t.Fatal("a credential file is left in the workspace") - } -} - -func TestABuildWithNoCredentialClonesExactlyAsBefore(t *testing.T) { - r, workspace := aRepository(t, withBoth, map[string]string{ - "Dockerfile": "FROM scratch", "files/theme.conf": "dark", - }) - _, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", - workspace, nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(r.ran[0], "git clone --quiet ") { - t.Fatalf("a credential-less clone grew flags: %s", r.ran[0]) - } - if _, err := os.Stat(filepath.Join(workspace, "git-credentials")); !os.IsNotExist(err) { - t.Fatal("a credential file was written with no credential to put in it") - } -} - -func TestAContextCloneCarriesTheSameCredentialStore(t *testing.T) { - const withContext = `{"module":"route-proxy","version":"1", - "build":{"artifacts":[ - {"name":"server","kind":"image","from":"Dockerfile", - "context":{"repository":"https://forge.invalid/source.git","ref":"main"}}]}}` - r := &recorded{ - contents: map[string]string{ - ManifestName: withContext, - "Dockerfile": "FROM scratch\nCOPY go.mod ./\n", - }, - secondary: map[string]map[string]string{ - "https://forge.invalid/source.git": {"go.mod": "module route-proxy\n"}, - }, - } - workspace := t.TempDir() - _, err := Build(context.Background(), r.run, r, - "https://forge.invalid/catalogue.git", "", "", workspace, nil, Npmrc{}, - GitCredential{URL: "https://builder:s3cret@forge.invalid"}, nil) - if err != nil { - t.Fatal(err) - } - stored := storeNamedIn(t, r.ran[0]) - var contextClone string - for _, line := range r.ran { - if strings.Contains(line, "clone") && strings.Contains(line, "source.git") { - contextClone = line - } - } - if contextClone == "" { - t.Fatalf("the context was never cloned: %v", r.ran) - } - if !strings.Contains(contextClone, "credential.helper=store --file="+stored) { - t.Fatalf("the context clone does not name the credential store: %s", contextClone) - } -} diff --git a/internal/builder/builder_test.go b/internal/builder/builder_test.go deleted file mode 100644 index 0151585d..00000000 --- a/internal/builder/builder_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package builder - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// A repository becoming artifacts the mesh can pin. -// -// git and docker are injected rather than run, because what is under test is the ORDER and the -// refusals — that nothing is published until everything is built, that a build reads only its own -// tree, that two builds of one commit produce one digest. Running docker here would test docker. - -type recorded struct { - ran []string - // dirs is the directory each entry in ran was run from, same index — so a test can ask not - // only what ran but where. - dirs []string - images map[string]string - archives map[string]string - failPush bool - // contents is what a clone of this repository lands, so the fake clone can restore the tree - // Build deliberately removes first. - contents map[string]string - // secondary is what a clone of a repository OTHER than the one under test lands, keyed by - // that repository's URL — an artifact's own build context, cloned apart from the module. - secondary map[string]map[string]string - // stamped is the modification time the clone gives every file. Set differently between two - // builds of one commit, because otherwise both land in the same second and a packer that - // carried timestamps would still produce one digest — which is a test that passes for a - // reason that has nothing to do with what it claims. - stamped time.Time - // tree is what git names as the tree of the module's directory; empty answers as for a commit. - tree string - // builtFrom is the files (slash-separated, relative) of the folder the last `docker build` ran in, read as it ran: - // the build removes that folder when it ends (novox/hq issue 1834). - builtFrom []string -} - -func (r *recorded) run(_ context.Context, dir, name string, args ...string) (string, error) { - line := name + " " + strings.Join(args, " ") - r.ran = append(r.ran, line) - r.dirs = append(r.dirs, dir) - // A clone may carry `-c` configuration in front of the verb — the credential store — so the - // verb is found rather than assumed first. - isClone := false - for _, a := range args { - if a == "clone" { - isClone = true - break - } - } - if name == "docker" && len(args) > 0 && args[0] == "build" { - r.builtFrom = nil - _ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error { - if err == nil && !d.IsDir() { - rel, _ := filepath.Rel(dir, p) - r.builtFrom = append(r.builtFrom, filepath.ToSlash(rel)) - } - return nil - }) - } - switch { - case name == "git" && isClone: - repository := args[len(args)-2] - tree := args[len(args)-1] - if err := os.MkdirAll(tree, 0o755); err != nil { - return "", err - } - lands := r.contents - if by, is := r.secondary[repository]; is { - lands = by - } - for path, body := range lands { - full := filepath.Join(tree, path) - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { - return "", err - } - if err := os.WriteFile(full, []byte(body), 0o644); err != nil { - return "", err - } - if !r.stamped.IsZero() { - if err := os.Chtimes(full, r.stamped, r.stamped); err != nil { - return "", err - } - } - } - return "", nil - case name == "git" && len(args) > 1 && args[0] == "rev-parse" && strings.HasPrefix(args[1], "HEAD:") && r.tree != "": - return r.tree + "\n", nil - case name == "git" && len(args) > 0 && args[0] == "rev-parse": - return "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff\n", nil - case name == "git" && len(args) > 1 && args[0] == "rev-list" && args[1] == "--count": - return "42\n", nil - } - return "", nil -} - -func (r *recorded) PublishImage(_ context.Context, localTag, repository string) (string, error) { - if r.failPush { - return "", os.ErrPermission - } - if r.images == nil { - r.images = map[string]string{} - } - r.images[repository] = localTag - return "registry.invalid/" + repository + "@sha256:" + strings.Repeat("a", 64), nil -} - -func (r *recorded) PublishArchive(_ context.Context, repository string, body []byte, digest string) (string, error) { - if r.failPush { - return "", os.ErrPermission - } - if r.archives == nil { - r.archives = map[string]string{} - } - r.archives[repository] = digest - _ = body - return "https://store.invalid/" + repository, nil -} - -// aRepository is a workspace whose clone lands a manifest and some files. -func aRepository(t *testing.T, manifest string, files map[string]string) (*recorded, string) { - t.Helper() - contents := map[string]string{ManifestName: manifest} - for name, body := range files { - contents[name] = body - } - return &recorded{contents: contents}, t.TempDir() -} - -const withBoth = `{"module":"meshboard","version":"1", - "build":{"artifacts":[ - {"name":"server","kind":"image","from":"Dockerfile"}, - {"name":"look","kind":"archive","from":"files"}]}, - "resources":[ - {"id":"svc","type":"container","name":"meshboard","artifact":"server"}, - {"id":"theme","type":"archive","path":"/opt/meshboard","artifact":"look"}]}` - -func TestAnUpstreamImageWithNoTagIsRefused(t *testing.T) { - // What gets mirrored would be whatever `latest` means today, and a module pinned to that is - // not pinned. - _, err := catalogue.ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"x","kind":"upstream","from":"postgres"}]}}`)) - if err == nil { - t.Fatal("an untagged upstream reference was accepted") - } - if !strings.Contains(err.Error(), "no tag or digest") { - t.Fatalf("unhelpful refusal: %v", err) - } -} - -func TestAnUpstreamReferenceIsNotAPathInTheRepository(t *testing.T) { - // The rule that a build reads only its own repository must not refuse every reference with a - // registry host in it. - if _, err := catalogue.ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"x","kind":"upstream","from":"registry.example/library/postgres:17"}]}}`)); err != nil { - t.Fatalf("a perfectly ordinary upstream reference was refused: %v", err) - } -} - -// **A module is a repository and a path within it** (novox/hq ADR 0069). The catalogue holds its -// modules one to a directory and the system this replaces has always built one that way, so a -// builder that could only read a repository's root could build none of what exists. - -// A build reads only its own tree. A path climbing out of the clone would otherwise let a build -// read — and an archive artifact publish — whatever the build machine happens to hold, which is -// the one thing a machine that builds other people's repositories must not do. - -// **Packaging and source are allowed to live apart** — a module that ships only the recipe for -// source that lives in a second repository (the reference route-proxy, packaged in the catalogue -// but built from mesh-controller's own repository) names where that source actually is, rather -// than vendoring a second copy the two could drift from. - -// The forge credential is offered through git's own credential store — a file, never argv — and -// git decides when it applies. What is checked: the clone names the store, the secret never -// appears in a command line, and the file holds exactly the URL at 0600. - -// storeNamedIn is the credential store a git command line offers. -func storeNamedIn(t *testing.T, line string) string { - t.Helper() - _, after, ok := strings.Cut(line, "credential.helper=store --file=") - if !ok { - t.Fatalf("the clone does not name the credential store: %s", line) - } - return strings.Fields(after)[0] -} - -// Without a credential, a clone is exactly the invocation it always was, and no credential file -// appears — the builder a mesh of public repositories runs is unchanged. - -// An artifact's own context is cloned with the same offer: a private module whose context is a -// second private repository on the same forge builds, and the secret still never reaches argv. diff --git a/internal/builder/bundle_integration_test.go b/internal/builder/bundle_integration_test.go deleted file mode 100644 index 80a69e81..00000000 --- a/internal/builder/bundle_integration_test.go +++ /dev/null @@ -1,166 +0,0 @@ -//go:build integration - -package builder - -import ( - "context" - "strings" - "testing" -) - -func TestABundleIsCompiledAndPackedWithNoDockerfile(t *testing.T) { - r, workspace := aRepository(t, aBundle, map[string]string{"index.ts": "console.log(1)"}) - held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)} - - got, err := Build(context.Background(), compiling{r}.run, r, - "https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatalf("a module with a language and no Dockerfile did not build: %v", err) - } - - // Compiled in the toolchain the mesh chose, not in one the module named. - var compiled string - for _, line := range r.ran { - if strings.HasPrefix(line, "docker run") { - compiled = line - } - } - if compiled == "" { - t.Fatalf("nothing was compiled:\n%s", strings.Join(r.ran, "\n")) - } - if !strings.Contains(compiled, "mesh-tools/build@sha256:") { - t.Fatalf("the compile did not run in the mesh's own toolchain: %s", compiled) - } - if strings.Contains(strings.Join(r.ran, "\n"), "docker build") { - t.Fatalf("a bundle invoked a Dockerfile build, which is the thing it exists to avoid:\n%s", - strings.Join(r.ran, "\n")) - } - - // And pinned by a digest of what came out, like any other artifact. - digest, _ := got.Manifest.Resources[0]["digest"].(string) - if !strings.HasPrefix(digest, "sha256:") { - t.Fatalf("the bundle was not pinned: %v", got.Manifest.Resources[0]) - } - - // **One file per entrypoint and launcher, in the toolchain's bundler** (novox/hq ADR 0193). A - // second run in the same toolchain image bundles each into the artifact's bundled output, the SDK - // inlined, refusing by name in an image that predates the bundler; and the toolchain's - // node_modules is no longer copied into a bundle that keeps nothing external. - var bundling []string - for _, line := range r.ran { - if strings.HasPrefix(line, "docker run") && strings.Contains(line, "esbuild") { - bundling = append(bundling, line) - } - } - if len(bundling) != 2 { - t.Fatalf("want one bundling run for the entrypoints and one for the launchers:\n%s", strings.Join(r.ran, "\n")) - } - for _, want := range []string{"mesh-tools/build@sha256:", "predates one-file bundles", "--bundle", "--format=esm", - "--platform=node", "--outdir=" + Out("code") + ".bundled", Out("code") + "/index.js"} { - if !strings.Contains(bundling[0], want) { - t.Errorf("the entrypoints' bundling lacks %q: %s", want, bundling[0]) - } - } - if !strings.Contains(bundling[1], Out("code")+"/index.serve.mjs") || !strings.Contains(bundling[1], "--out-extension:.js=.mjs") { - t.Errorf("the launcher is not bundled under its own name: %s", bundling[1]) - } - if strings.Contains(strings.Join(r.ran, "\n"), "/app/runtime") { - t.Errorf("the toolchain's node_modules was copied into a bundle that keeps nothing external:\n%s", strings.Join(r.ran, "\n")) - } - if strings.Index(strings.Join(r.ran, "\n"), "--outDir") > strings.Index(strings.Join(r.ran, "\n"), "esbuild") { - t.Fatal("the bundler ran before the compile wrote its output") - } -} - -func TestABundleKeepingAPackageExternalCarriesTheToolchainsModules(t *testing.T) { - manifest := strings.Replace(aBundle, `"entrypoints":["index.js"]`, `"entrypoints":["index.js"],"external":["sharp"]`, 1) - r, workspace := aRepository(t, manifest, map[string]string{"index.ts": "console.log(1)"}) - held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)} - if _, err := Build(context.Background(), compiling{r}.run, r, - "https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, GitCredential{}, nil); err != nil { - t.Fatal(err) - } - all := strings.Join(r.ran, "\n") - if !strings.Contains(all, "--external:sharp") || !strings.Contains(all, "/app/runtime") { - t.Errorf("an external package was not kept as an import with the toolchain's modules beside it:\n%s", all) - } -} - -func TestABundleWhoseToolchainIsNotHeldIsRefusedFirst(t *testing.T) { - r, workspace := aRepository(t, aBundle, map[string]string{"index.ts": "console.log(1)"}) - - _, err := Build(context.Background(), compiling{r}.run, r, - "https://forge.invalid/greeter.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) - if err == nil { - t.Fatal("a bundle was built with no toolchain to compile it in") - } - if !strings.Contains(err.Error(), "mesh-tools") { - t.Fatalf("the refusal does not name what has to be built first: %v", err) - } - for _, line := range r.ran { - if strings.HasPrefix(line, "docker run") { - t.Fatalf("a compile was attempted before the refusal: %s", line) - } - } -} - -func TestABundleInAnUnknownLanguageIsRefused(t *testing.T) { - manifest := strings.Replace(aBundle, `"language":"typescript"`, `"language":"cobol"`, 1) - r, workspace := aRepository(t, manifest, map[string]string{"index.ts": "x"}) - - _, err := Build(context.Background(), compiling{r}.run, r, - "https://forge.invalid/greeter.git", "", "", workspace, - map[string]string{"mesh-tools/build": "registry.invalid/x@sha256:" + strings.Repeat("c", 64)}, Npmrc{}, GitCredential{}, nil) - if err == nil { - t.Fatal("a language nothing can compile was accepted") - } - if !strings.Contains(err.Error(), "typescript") { - t.Fatalf("the refusal does not say what would have worked: %v", err) - } -} - -func TestTwoBundlesInOneModuleArePackedSeparately(t *testing.T) { - const two = `{"module":"greeter","version":"1", - "build":{"artifacts":[ - {"name":"daemon","kind":"bundle","language":"typescript","entrypoints":["index.js"]}, - {"name":"tools","kind":"bundle","language":"typescript","entrypoints":["index.js"]}]}, - "resources":[ - {"id":"a","type":"archive","path":"/opt/greeter/daemon","artifact":"daemon"}, - {"id":"b","type":"archive","path":"/opt/greeter/tools","artifact":"tools"}]}` - - r, workspace := aRepository(t, two, map[string]string{"index.ts": "console.log(1)"}) - held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)} - - got, err := Build(context.Background(), compiling{r}.run, r, - "https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatalf("a module with two bundles did not build: %v", err) - } - - // Compiled into two different places. Only the compile lines: the copy of each bundle's - // dependencies names the same directory again, deliberately. - var outputs []string - for _, line := range r.ran { - if !strings.Contains(line, "--outDir") { - continue - } - for _, part := range strings.Fields(line) { - if strings.HasPrefix(part, ".mesh-build/") { - outputs = append(outputs, part) - } - } - } - if len(outputs) != 2 || outputs[0] == outputs[1] { - t.Fatalf("two bundles did not get their own output directories: %v", outputs) - } - - // And published as two artifacts, each with its own digest. - if len(r.archives) != 2 { - t.Fatalf("expected two archives published, got %v", r.archives) - } - first, _ := got.Manifest.Resources[0]["digest"].(string) - second, _ := got.Manifest.Resources[1]["digest"].(string) - if first == "" || second == "" { - t.Fatalf("a bundle was not pinned: %v", got.Manifest.Resources) - } -} diff --git a/internal/builder/bundle_test.go b/internal/builder/bundle_test.go deleted file mode 100644 index ccabe300..00000000 --- a/internal/builder/bundle_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package builder - -import ( - "context" - "os" - "path/filepath" - "testing" -) - -const aBundle = `{"module":"greeter","version":"1", - "build":{"artifacts":[ - {"name":"code","kind":"bundle","language":"typescript","entrypoints":["index.js"]}]}, - "resources":[ - {"id":"files","type":"archive","path":"/opt/greeter","artifact":"code"}]}` - -// compiling is a runner that behaves like a toolchain: when asked to compile, it leaves output -// where the toolchain says output lands. Without this the pack step has nothing to pack, and the -// test would be asserting on a failure rather than on a build. -type compiling struct{ *recorded } - -func (c compiling) run(ctx context.Context, dir, name string, args ...string) (string, error) { - out, err := c.recorded.run(ctx, dir, name, args...) - if name != "docker" || len(args) == 0 || args[0] != "run" { - return out, err - } - // **Writes where it was TOLD to**, rather than to a fixed directory. A fake that always wrote - // to one place would pass whether or not the builder gave each artifact its own — which is the - // thing being tested. - where := "" - for i, a := range args { - if a == "--outDir" && i+1 < len(args) { - where = args[i+1] - } - } - if where == "" { - return out, err - } - made := filepath.Join(dir, where) - if err := os.MkdirAll(made, 0o755); err != nil { - return "", err - } - if err := os.WriteFile(filepath.Join(made, "index.js"), []byte("console.log(1)"), 0o644); err != nil { - return "", err - } - return out, err -} - -// **A module says what it is written in, and needs no Dockerfile.** This is the whole point of the -// bundle recipe: the same module previously needed a hand-written recipe repeating an incantation -// that is easy to get wrong in ways that fail somewhere else. - -// A bundle naming packages it keeps external is bundled with them as imports, and carries the -// toolchain's node_modules for them — the one case it still does. - -// A language whose bundle carries its own dependencies copies nothing in: a Go binary is static. -func TestOnlyALanguageWithARuntimeDirectoryCopiesDependenciesIn(t *testing.T) { - ts, _ := ToolchainFor("typescript") - if ts.Dependencies != "/app/runtime" { - t.Fatalf("typescript bundles run with %q", ts.Dependencies) - } - for _, language := range []string{"go", "python"} { - chain, _ := ToolchainFor(language) - if chain.Dependencies != "" { - t.Fatalf("%s copies %q into every bundle, and its bundles carry their own", language, chain.Dependencies) - } - } -} - -// **Refused before anything is built, naming what to build first.** A base the mesh has not built -// is not a compile that fails on its first line — it is a question somebody can answer, and saying -// it early is the difference between a fixable message and one about a missing image. - -// A language the mesh does not build is refused the same way, and names what it can build. - -// **One module, two bundles, and neither packs the other.** -// -// The case that matters for real modules: a module is one piece of software and may still carry a -// daemon in one language and tools in another (ADR 0040). An earlier version of this compiled -// every bundle into the toolchain's single output directory, so two of them would overwrite each -// other and then be packed together — one artifact containing both, twice. diff --git a/internal/builder/check.go b/internal/builder/check.go deleted file mode 100644 index b9add5d2..00000000 --- a/internal/builder/check.go +++ /dev/null @@ -1,2037 +0,0 @@ -package builder - -import ( - "bufio" - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "os" - "os/exec" - "path/filepath" - "regexp" - "sort" - "strings" - "sync" - "time" - - "git.novox.be/novox/mesh-sdk/go/facts" - "github.com/novox/mesh-controller/internal/artifacts" -) - -// A pull request's merge check, run on the build seat (novox/hq to-be 45 §9, ADR 0237). -// -// **The build machine already has what a check needs**: the repositories, a container runtime, the -// artifact store where the controller keeps the facts snapshot, and a Go toolchain. So a check is one -// more kind of work on the build seat's queue rather than a CI the mesh would have to run beside itself. -// -// **Two layers, each its own status on the pull request** (ADR 0237 as amended, 2026-10-06): -// -// - **the gate** (`mesh/merge-gate`) runs when the change touches a module of the mesh's graph — the -// controller, which holds the graph, says which (Modules) and which directories it adds a module in -// (New). The touched manifests through `module check`; every machine of the facts snapshot composed -// with the change and validated by the node-engine's own validator; then mesh-lab's replays. The -// judge is the controller the mesh runs — or, for a change to the controller, the change's own -// controller. A change to the node-engine is judged as any module is: the validator is mesh-sdk's, at -// the controller's pin. The graph decides whether this runs, never the repository. -// - **the repository's own check** (`mesh/repo-check`): its merge-check.sh, its unit tests and code -// quality, run when present in the toolchain it declares (`# mesh-check-toolchain: go|typescript` -// among its first lines; go when it declares none). A repository that reaches the build seat with -// none is said as a warning: it is the mesh's, and nothing of its own is tested before it merges. -// **A repository in two languages declares the other part too** (`# mesh-check-also: -//