From 9a12f2851f0b04223a24b09ca167e2ce310f79db Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:24:05 +0100 Subject: [PATCH 01/14] The node-engine owns the composition it alone runs (H1, H2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0593 §1 and §7: compose, its bus reader and runtime reader, takein, validate, the overlay generator, the composer's run and mesh-compose, and the judgement of a move leave mesh-sdk for the node-engine's internal packages, so no other module composes and a change to them rebuilds only the node-engine. The shapes others read (the composer's request and verdict, the overlay's names, compositions and answers) stay in mesh-sdk. The node's grant is now a golden file of what the grants part composes, since the composer of the bus's user list leaves mesh-sdk too. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- cmd/mesh-compose/main.go | 154 +++ cmd/mesh-compose/main_test.go | 366 ++++++ cmd/mesh-compose/reads-outside | 1 + .../modules/adwaita/module.json.captured | 176 +++ .../artifact-store-tools/module.json.captured | 65 + .../asus-zephyrus-g14/module.json.captured | 229 ++++ .../modules/audit-logger/module.json.captured | 69 + .../modules/avahi/module.json.captured | 43 + .../modules/baserow/module.json.captured | 158 +++ .../modules/betterbird/module.json.captured | 253 ++++ .../modules/blueman/module.json.captured | 37 + .../modules/bluetooth/module.json.captured | 53 + .../modules/brother/module.json.captured | 280 ++++ .../modules/ca-trust/module.json.captured | 66 + .../modules/claude-code/module.json.captured | 247 ++++ .../module.json.captured | 184 +++ .../modules/clipmenu/module.json.captured | 135 ++ .../modules/confluence/module.json.captured | 76 ++ .../modules/cups/module.json.captured | 58 + .../modules/dbus/module.json.captured | 67 + .../modules/desk-channel/module.json.captured | 106 ++ .../modules/dev-tools/module.json.captured | 38 + .../modules/disk-load/module.json.captured | 26 + .../modules/distribution/module.json.captured | 96 ++ .../modules/dmenu/module.json.captured | 33 + .../modules/dnsmasq/module.json.captured | 162 +++ .../docker-compose/module.json.captured | 40 + .../modules/docker/module.json.captured | 179 +++ .../modules/dunst/module.json.captured | 123 ++ .../modules/fail2ban/module.json.captured | 195 +++ .../modules/feh/module.json.captured | 75 ++ .../modules/flatpak/module.json.captured | 42 + .../modules/fonts/module.json.captured | 70 + .../modules/forticlient/module.json.captured | 57 + .../modules/gitea/module.json.captured | 547 ++++++++ .../modules/gitlab/module.json.captured | 65 + .../gnome-keyring/module.json.captured | 82 ++ .../modules/go/module.json.captured | 20 + .../modules/grafana/module.json.captured | 225 ++++ .../modules/hello-web/module.json.captured | 82 ++ .../home-assistant/module.json.captured | 209 +++ .../modules/hostname/module.json.captured | 61 + .../modules/i3/module.json.captured | 124 ++ .../i3status-rust/module.json.captured | 126 ++ .../modules/icecast/module.json.captured | 152 +++ .../modules/influxdb/module.json.captured | 198 +++ .../modules/invoicing/module.json.captured | 128 ++ .../modules/jira/module.json.captured | 75 ++ .../modules/kernel/module.json.captured | 78 ++ .../modules/keycloak/module.json.captured | 245 ++++ .../modules/lab/module.json.captured | 121 ++ .../modules/lemurs/module.json.captured | 84 ++ .../modules/letta/module.json.captured | 156 +++ .../local-model-consumer/module.json.captured | 42 + .../modules/localization/module.json.captured | 61 + .../modules/logrotate/module.json.captured | 58 + .../modules/mailu/module.json.captured | 772 +++++++++++ .../modules/marrytts/module.json.captured | 37 + .../modules/matrix/module.json.captured | 257 ++++ .../memory-pressure/module.json.captured | 130 ++ .../mesh-build-go/module.json.captured | 148 +++ .../mesh-build-node/module.json.captured | 169 +++ .../modules/mesh-catalog/module.json.captured | 98 ++ .../modules/mesh-cli/module.json.captured | 37 + .../modules/mesh-config/module.json.captured | 74 ++ .../mesh-delivery-gate/module.json.captured | 110 ++ .../modules/mesh-files/module.json.captured | 57 + .../modules/mesh-issues/module.json.captured | 329 +++++ .../mesh-knowledge/module.json.captured | 218 +++ .../mesh-mail-notifier/module.json.captured | 68 + .../mesh-mail-reader/module.json.captured | 141 ++ .../mesh-mail-web/module.json.captured | 131 ++ .../modules/mesh-mail/module.json.captured | 317 +++++ .../module.json.captured | 60 + .../mesh-provisioner/module.json.captured | 56 + .../modules/mesh-reads/module.json.captured | 55 + .../modules/mesh-vault/module.json.captured | 173 +++ .../modules/mesh-watcher/module.json.captured | 71 + .../modules/messenger/module.json.captured | 196 +++ .../modules/minio/module.json.captured | 188 +++ .../modules/model-usage/module.json.captured | 107 ++ .../modules/mongodb/module.json.captured | 170 +++ .../modules/mosquitto/module.json.captured | 194 +++ .../modules/mounts/module.json.captured | 160 +++ .../modules/mssql/module.json.captured | 174 +++ .../modules/n8n/module.json.captured | 173 +++ .../modules/nats/module.json.captured | 171 +++ .../modules/netcheck/module.json.captured | 57 + .../networkmanager/module.json.captured | 71 + .../nextcloud-client/module.json.captured | 38 + .../modules/nextcloud/module.json.captured | 168 +++ .../modules/nfs-server/module.json.captured | 191 +++ .../modules/nftables/module.json.captured | 91 ++ .../modules/nm-applet/module.json.captured | 37 + .../modules/node-env/module.json.captured | 55 + .../modules/nodejs/module.json.captured | 25 + .../modules/nodered/module.json.captured | 297 +++++ .../modules/ollama/module.json.captured | 62 + .../modules/only-office/module.json.captured | 173 +++ .../openai-consumer/module.json.captured | 61 + .../modules/openrazer/module.json.captured | 52 + .../modules/pacman/module.json.captured | 136 ++ .../modules/photos-eef/module.json.captured | 56 + .../modules/photos-filip/module.json.captured | 56 + .../modules/picom/module.json.captured | 90 ++ .../polychromatic/module.json.captured | 40 + .../modules/postgres/module.json.captured | 191 +++ .../modules/power/module.json.captured | 408 ++++++ .../powerlevel10k/module.json.captured | 49 + .../modules/records/module.json.captured | 103 ++ .../modules/redis/module.json.captured | 154 +++ .../modules/restic/module.json.captured | 196 +++ .../modules/rofi/module.json.captured | 124 ++ .../route-adapter/module.json.captured | 134 ++ .../modules/screen-lock/module.json.captured | 78 ++ .../modules/searxng/module.json.captured | 229 ++++ .../modules/sensors/module.json.captured | 69 + .../modules/showcase/module.json.captured | 207 +++ .../modules/slack/module.json.captured | 41 + .../modules/snapd/module.json.captured | 31 + .../modules/ssh-client/module.json.captured | 112 ++ .../modules/sshd/module.json.captured | 54 + .../modules/step-ca/module.json.captured | 106 ++ .../modules/sudo/module.json.captured | 47 + .../modules/supabase/module.json.captured | 659 ++++++++++ .../systemd-networkd/module.json.captured | 65 + .../systemd-resolved/module.json.captured | 110 ++ .../modules/systemd/module.json.captured | 63 + .../modules/telegram/module.json.captured | 79 ++ .../modules/time-sync/module.json.captured | 81 ++ .../modules/triggerhappy/module.json.captured | 81 ++ .../modules/umami/module.json.captured | 142 ++ .../modules/unifi/module.json.captured | 203 +++ .../modules/website/module.json.captured | 60 + .../modules/xclip/module.json.captured | 35 + .../modules/xdg/module.json.captured | 97 ++ .../modules/xorg/module.json.captured | 134 ++ .../modules/xterm/module.json.captured | 67 + .../modules/zfs/module.json.captured | 235 ++++ .../zsh-autosuggestions/module.json.captured | 30 + .../module.json.captured | 30 + .../modules/zsh/module.json.captured | 160 +++ cmd/mesh-host/nodeagent.go | 2 +- cmd/mesh-host/nodeagent_integration_test.go | 37 +- cmd/mesh-host/nodeclare_test.go | 5 + internal/agent/agent.go | 9 +- internal/agent/composition.go | 7 +- internal/compose/composed.go | 559 ++++++++ internal/compose/composed_test.go | 200 +++ internal/compose/credentials.go | 498 +++++++ internal/compose/credentials_needed.go | 69 + internal/compose/credentials_test.go | 549 ++++++++ internal/compose/fixture_test.go | 90 ++ internal/compose/frombus/answer.go | 582 ++++++++ internal/compose/frombus/answer_rows_test.go | 595 +++++++++ internal/compose/frombus/export.go | 112 ++ internal/compose/frombus/frombus.go | 1167 +++++++++++++++++ internal/compose/frombus/frombus_test.go | 869 ++++++++++++ .../frombus/frombustest/frombustest.go | 183 +++ .../frombus/frombustest/frombustest_test.go | 272 ++++ ...5aa5e6-mesh-provisioner-client.module.json | 60 + .../backups.conf | 2 + .../catalogue-c05aa5e6-restic.files/data.conf | 2 + .../retention.conf | 6 + .../catalogue-c05aa5e6-restic.module.json | 199 +++ internal/compose/frombus/held_rows_test.go | 298 +++++ internal/compose/frombus/states.go | 204 +++ .../catalogue-835bf22b-xterm.module.json | 236 ++++ ...5aa5e6-mesh-provisioner-client.module.json | 60 + .../backups.conf | 2 + .../catalogue-c05aa5e6-restic.files/data.conf | 2 + .../retention.conf | 6 + .../catalogue-c05aa5e6-restic.module.json | 199 +++ .../testdata/de-spiegel-e25e1beb.module.json | 129 ++ .../testdata/f1-circuits-c0ea2c94.module.json | 82 ++ internal/compose/frombus/twice.go | 147 +++ internal/compose/fromruntime/fromruntime.go | 101 ++ internal/compose/input.go | 365 ++++++ internal/compose/network.go | 307 +++++ internal/compose/network_test.go | 306 +++++ internal/compose/node.go | 351 +++++ internal/compose/node_test.go | 424 ++++++ internal/compose/one_machine_alone_test.go | 167 +++ internal/compose/render.go | 150 +++ internal/compose/rendering.go | 543 ++++++++ internal/compose/rendering_test.go | 329 +++++ internal/compose/resolve.go | 347 +++++ internal/compose/resolve_test.go | 388 ++++++ internal/compose/rest.go | 255 ++++ internal/compose/rest_test.go | 231 ++++ internal/compose/seats_test.go | 22 + internal/composer/composer.go | 236 ++++ internal/composer/composer_test.go | 100 ++ .../catalogue-835bf22b-xterm.module.json | 236 ++++ internal/judge/aliases.go | 24 + internal/judge/answer.go | 48 + internal/judge/answer_test.go | 25 + internal/judge/judge.go | 365 ++++++ internal/judge/judge_test.go | 96 ++ internal/nodegrant/nodegrant.go | 48 + internal/nodegrant/testdata/node.json | 129 ++ internal/overlay/declaration.go | 187 +++ internal/overlay/declaration_test.go | 311 +++++ internal/overlay/generator.go | 151 +++ internal/overlay/generator_test.go | 30 + internal/overlay/graph.go | 231 ++++ internal/overlay/graph_test.go | 342 +++++ internal/overlay/names.go | 30 + internal/overlay/opens_test.go | 113 ++ internal/selfcompose/data.go | 4 +- internal/selfcompose/data_test.go | 2 +- internal/selfcompose/selfcompose.go | 4 +- internal/selfcompose/selfcompose_test.go | 18 +- internal/selfcompose/shadow.go | 5 +- internal/takein/takein.go | 134 ++ internal/validate/sdk_test.go | 31 + internal/validate/validate.go | 42 + internal/validate/validate_test.go | 50 + 218 files changed, 33022 insertions(+), 54 deletions(-) create mode 100644 cmd/mesh-compose/main.go create mode 100644 cmd/mesh-compose/main_test.go create mode 100644 cmd/mesh-compose/reads-outside create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/adwaita/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/artifact-store-tools/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/audit-logger/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/avahi/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/baserow/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/betterbird/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/blueman/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/bluetooth/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/brother/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/ca-trust/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/claude-code/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/claude-licence-manager/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/clipmenu/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/confluence/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/cups/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/dbus/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/desk-channel/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/dev-tools/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/disk-load/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/distribution/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/dmenu/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/dnsmasq/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/docker-compose/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/docker/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/dunst/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/fail2ban/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/feh/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/flatpak/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/fonts/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/forticlient/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/gitea/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/gitlab/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/gnome-keyring/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/go/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/grafana/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/hello-web/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/home-assistant/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/hostname/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/i3/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/i3status-rust/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/icecast/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/influxdb/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/invoicing/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/jira/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/kernel/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/keycloak/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/lab/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/lemurs/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/letta/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/local-model-consumer/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/localization/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/logrotate/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mailu/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/marrytts/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/matrix/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/memory-pressure/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-build-go/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-build-node/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-catalog/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-cli/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-config/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-delivery-gate/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-files/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-issues/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-knowledge/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-mail-notifier/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-mail-reader/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-mail-web/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-mail/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-provisioner-client/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-provisioner/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-reads/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-vault/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-watcher/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/messenger/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/minio/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/model-usage/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mongodb/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mosquitto/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mounts/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/mssql/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/n8n/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/nats/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/netcheck/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/networkmanager/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/nextcloud-client/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/nextcloud/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/nfs-server/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/nftables/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/nm-applet/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/node-env/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/nodejs/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/nodered/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/ollama/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/only-office/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/openai-consumer/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/openrazer/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/pacman/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/photos-eef/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/photos-filip/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/picom/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/polychromatic/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/postgres/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/power/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/powerlevel10k/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/records/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/redis/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/restic/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/rofi/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/route-adapter/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/screen-lock/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/searxng/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/sensors/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/showcase/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/slack/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/snapd/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/ssh-client/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/sshd/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/step-ca/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/sudo/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/supabase/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/systemd-networkd/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/systemd-resolved/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/systemd/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/telegram/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/time-sync/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/triggerhappy/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/umami/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/unifi/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/website/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/xclip/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/xdg/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/xorg/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/xterm/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/zfs/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/zsh-autosuggestions/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/zsh-syntax-highlighting/module.json.captured create mode 100644 cmd/mesh-compose/testdata/mesh-catalog/modules/zsh/module.json.captured create mode 100644 internal/compose/composed.go create mode 100644 internal/compose/composed_test.go create mode 100644 internal/compose/credentials.go create mode 100644 internal/compose/credentials_needed.go create mode 100644 internal/compose/credentials_test.go create mode 100644 internal/compose/fixture_test.go create mode 100644 internal/compose/frombus/answer.go create mode 100644 internal/compose/frombus/answer_rows_test.go create mode 100644 internal/compose/frombus/export.go create mode 100644 internal/compose/frombus/frombus.go create mode 100644 internal/compose/frombus/frombus_test.go create mode 100644 internal/compose/frombus/frombustest/frombustest.go create mode 100644 internal/compose/frombus/frombustest/frombustest_test.go create mode 100644 internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json create mode 100644 internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf create mode 100644 internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf create mode 100644 internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf create mode 100644 internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json create mode 100644 internal/compose/frombus/held_rows_test.go create mode 100644 internal/compose/frombus/states.go create mode 100644 internal/compose/frombus/testdata/catalogue-835bf22b-xterm.module.json create mode 100644 internal/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json create mode 100644 internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf create mode 100644 internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf create mode 100644 internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf create mode 100644 internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json create mode 100644 internal/compose/frombus/testdata/de-spiegel-e25e1beb.module.json create mode 100644 internal/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json create mode 100644 internal/compose/frombus/twice.go create mode 100644 internal/compose/fromruntime/fromruntime.go create mode 100644 internal/compose/input.go create mode 100644 internal/compose/network.go create mode 100644 internal/compose/network_test.go create mode 100644 internal/compose/node.go create mode 100644 internal/compose/node_test.go create mode 100644 internal/compose/one_machine_alone_test.go create mode 100644 internal/compose/render.go create mode 100644 internal/compose/rendering.go create mode 100644 internal/compose/rendering_test.go create mode 100644 internal/compose/resolve.go create mode 100644 internal/compose/resolve_test.go create mode 100644 internal/compose/rest.go create mode 100644 internal/compose/rest_test.go create mode 100644 internal/compose/seats_test.go create mode 100644 internal/composer/composer.go create mode 100644 internal/composer/composer_test.go create mode 100644 internal/composer/testdata/catalogue-835bf22b-xterm.module.json create mode 100644 internal/judge/aliases.go create mode 100644 internal/judge/answer.go create mode 100644 internal/judge/answer_test.go create mode 100644 internal/judge/judge.go create mode 100644 internal/judge/judge_test.go create mode 100644 internal/nodegrant/nodegrant.go create mode 100644 internal/nodegrant/testdata/node.json create mode 100644 internal/overlay/declaration.go create mode 100644 internal/overlay/declaration_test.go create mode 100644 internal/overlay/generator.go create mode 100644 internal/overlay/generator_test.go create mode 100644 internal/overlay/graph.go create mode 100644 internal/overlay/graph_test.go create mode 100644 internal/overlay/names.go create mode 100644 internal/overlay/opens_test.go create mode 100644 internal/takein/takein.go create mode 100644 internal/validate/sdk_test.go create mode 100644 internal/validate/validate.go create mode 100644 internal/validate/validate_test.go diff --git a/cmd/mesh-compose/main.go b/cmd/mesh-compose/main.go new file mode 100644 index 0000000..22a5753 --- /dev/null +++ b/cmd/mesh-compose/main.go @@ -0,0 +1,154 @@ +// mesh-compose is the node-engine's composer command (novox/hq ADR 0482 clause 4, ADR 0593 §7, issue 1621): it reads a +// composer.Request on standard input, reads every manifest, composes the machines named in each case and writes one +// composer.Verdict on standard output. Built at a commit of the node-engine's repository, it answers what that +// commit's composition answers, so a group that changes the node-engine's composition is judged by its own next code. +// +// mesh-compose < request.json > verdict.json +// mesh-compose -request request.json > verdict.json +// mesh-compose -catalogue [-node ]... [-depth resolve|compose] [-declarations] [< request.json] +// +// -catalogue reads /modules/*/module.json and a module.json at 's root as the first case's manifests (a +// checkout or `git archive` of the catalogue repository at a ref: the command fetches nothing). -node adds a machine to +// compose; -declarations answers each machine's declared paths with their owners and modes. +// +// It opens no network connection, bus, store or file beyond its standard input, the -request file and the -catalogue +// directory, and reads +// no environment. Exit codes: 0 the verdict is written (a machine that does not compose is a verdict, not an error); +// 1 the request cannot be read or run, said on standard error; 2 the flags are wrong. +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/novox/mesh-host/internal/composer" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} + +type nodes []string + +func (n *nodes) String() string { return strings.Join(*n, ",") } +func (n *nodes) Set(v string) error { *n = append(*n, v); return nil } + +// run is the command: its arguments, standard input, output and error; it answers the exit code. +func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("mesh-compose", flag.ContinueOnError) + fs.SetOutput(stderr) + catalogueDir := fs.String("catalogue", "", "a catalogue checkout: /modules/*/module.json are the first case's manifests") + var machines nodes + fs.Var(&machines, "node", "a machine to compose (repeatable)") + depth := fs.String("depth", "", "resolve or compose (default: the request's, else compose)") + declarations := fs.Bool("declarations", false, "answer each composed machine's declared paths, owners and modes") + requestFile := fs.String("request", "", "read the request from this file instead of standard input (a run step that hands no standard input)") + if err := fs.Parse(args); err != nil { + return 2 + } + if fs.NArg() > 0 { + fmt.Fprintf(stderr, "mesh-compose: unexpected arguments %q\n", fs.Args()) + return 2 + } + var raw []byte + var err error + if *requestFile != "" { + raw, err = os.ReadFile(*requestFile) + } else { + raw, err = io.ReadAll(stdin) + } + if err != nil { + fmt.Fprintln(stderr, "mesh-compose: the request cannot be read:", err) + return 1 + } + var req composer.Request + if strings.TrimSpace(string(raw)) == "" { + if *catalogueDir == "" { + fmt.Fprintln(stderr, "mesh-compose: no request on standard input and no -catalogue") + return 1 + } + req = composer.Request{V: composer.Version} + } else if req, err = composer.Decode(raw); err != nil { + fmt.Fprintln(stderr, "mesh-compose:", err) + return 1 + } + if *catalogueDir != "" { + read, err := readCatalogue(*catalogueDir) + if err != nil { + fmt.Fprintln(stderr, "mesh-compose:", err) + return 1 + } + if len(req.Cases) == 0 { + req.Cases = []composer.Case{{Name: "catalogue", Manifests: map[string]composer.Manifest{}}} + } + if req.Cases[0].Manifests == nil { + req.Cases[0].Manifests = map[string]composer.Manifest{} + } + for name, m := range read { + if _, given := req.Cases[0].Manifests[name]; !given { + req.Cases[0].Manifests[name] = m + } + } + // No list of predefined keys is read: the composer judges no manifest (novox/hq ADR 0578 §5). + } + req.Machines = append(req.Machines, machines...) + if *depth != "" { + req.Depth = *depth + } + if *declarations { + req.Declarations = true + } + v, err := composer.Run(req) + if err != nil { + fmt.Fprintln(stderr, "mesh-compose:", err) + return 1 + } + enc := json.NewEncoder(stdout) + if err := enc.Encode(v); err != nil { + fmt.Fprintln(stderr, "mesh-compose: the verdict cannot be written:", err) + return 1 + } + return 0 +} + +// readCatalogue reads every module.json of a catalogue checkout by its module's name: the root's and each of +// modules/*/. A manifest whose name cannot be read is kept under its directory's name, so the reader's refusal is said. +func readCatalogue(dir string) (map[string]composer.Manifest, error) { + paths, err := filepath.Glob(filepath.Join(dir, "modules", "*", "module.json")) + if err != nil { + return nil, err + } + if _, err := os.Stat(filepath.Join(dir, "module.json")); err == nil { + paths = append(paths, filepath.Join(dir, "module.json")) + } + if len(paths) == 0 { + return nil, fmt.Errorf("the catalogue %s holds no module.json", dir) + } + sort.Strings(paths) + out := map[string]composer.Manifest{} + for _, p := range paths { + raw, err := os.ReadFile(p) + if err != nil { + return nil, err + } + var probe struct { + Module string `json:"module"` + } + name := filepath.Base(filepath.Dir(p)) + if json.Unmarshal(raw, &probe) == nil && probe.Module != "" { + name = probe.Module + } + if _, twice := out[name]; twice { + return nil, errors.New("the catalogue " + dir + " defines " + name + " twice") + } + out[name] = composer.Manifest{Raw: raw} + } + return out, nil +} diff --git a/cmd/mesh-compose/main_test.go b/cmd/mesh-compose/main_test.go new file mode 100644 index 0000000..dbb62af --- /dev/null +++ b/cmd/mesh-compose/main_test.go @@ -0,0 +1,366 @@ +package main + +import ( + "bytes" + "encoding/json" + "go/build" + "io/fs" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "sort" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/composer" +) + +// captured is every manifest of the catalogue, raw, by module name: the clone beside a merge check (MESH_CHECK_BESIDE), +// or the copy captured in testdata/mesh-catalog from mesh-sdk's testdata/beside (mesh-catalog b5dbfbb7, its +// module.json files alone), each kept as module.json.captured so this repository holds no module of the catalogue. +func captured(t *testing.T) map[string]composer.Manifest { + t.Helper() + dir := "" + if root := os.Getenv("MESH_CHECK_BESIDE"); root != "" { + dir = filepath.Join(root, "mesh-catalog") + } else { + dir = t.TempDir() + from, _ := filepath.Abs("testdata/mesh-catalog") + err := filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel, _ := filepath.Rel(from, path) + into := filepath.Join(dir, strings.TrimSuffix(rel, ".captured")) + raw, err := os.ReadFile(path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(into), 0o755); err != nil { + return err + } + return os.WriteFile(into, raw, 0o644) + }) + if err != nil { + t.Fatal(err) + } + } + read, err := readCatalogue(dir) + if err != nil { + t.Fatal(err) + } + if len(read) < 100 { + t.Fatalf("the captured catalogue holds %d manifests, fewer than the catalogue's", len(read)) + } + return read +} + +// meshOf is a mesh of three machines over the captured catalogue, each assigned real modules, the input the assigner +// composes with (nodes and their assignments, holdings from the claims): anchor runs the bus and the store, laptop a +// desktop's modules, ace a module whose requirement is provided elsewhere. +func meshOf() compose.Input { + return compose.Input{Nodes: []compose.Node{ + {Name: "anchor", Assigned: []string{"nats", "postgres"}}, + {Name: "laptop", Assigned: []string{"sudo", "zsh"}}, + {Name: "ace", Assigned: []string{"mesh-reads", "nats"}}, + }} +} + +func request(t *testing.T, depth string) composer.Request { + t.Helper() + r := composer.Request{V: composer.Version, Depth: depth, HoldingsFromClaims: true, + Machines: []string{"anchor", "laptop", "ace"}, + Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}} + return withInput(t, r, meshOf()) +} + +// withInput is r with in as its input, as a caller puts it on the wire: the request's shape carries the input as JSON. +func withInput(t *testing.T, r composer.Request, in compose.Input) composer.Request { + t.Helper() + raw, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &r.Input); err != nil { + t.Fatal(err) + } + return r +} + +func runJSON(t *testing.T, args []string, in []byte) (int, composer.Verdict, string) { + t.Helper() + var out, errOut bytes.Buffer + code := run(args, bytes.NewReader(in), &out, &errOut) + var v composer.Verdict + if code == 0 { + if err := json.Unmarshal(out.Bytes(), &v); err != nil { + t.Fatalf("the verdict is not JSON: %v\n%s", err, out.String()) + } + } + return code, v, errOut.String() +} + +func encode(t *testing.T, r composer.Request) []byte { + t.Helper() + raw, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + return raw +} + +// 1621/AC1: the command reads the plain input and the manifests on standard input, validates every manifest, composes +// the machines named and writes one verdict: per machine composes or the reason, per manifest accepted or the field +// refused. +func TestTheCommandWritesOneVerdictPerManifestAndMachine(t *testing.T) { + r := request(t, composer.DepthResolve) + r.Cases[0].Manifests["later"] = composer.Manifest{Raw: json.RawMessage(`{"module":"later","version":"1","shares":["x"]}`)} + r.Cases[0].Manifests["refused"] = composer.Manifest{Raw: json.RawMessage(`{"module":"refused","version":"1"}`)} + r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"refused": {State: delivery.VerdictRefused, Problems: []string{"it is wrong"}}} + code, v, stderr := runJSON(t, nil, encode(t, r)) + if code != 0 { + t.Fatalf("exit %d: %s", code, stderr) + } + if len(v.Cases) != 1 || v.Cases[0].Name != "trunk" || v.SDK == "" { + t.Fatalf("verdict = %+v", v) + } + c := v.Cases[0] + // ADR 0578 §5, §6: the composer judges nothing; it sets an unknown field aside and names it, and holds a manifest + // its verdict refuses, named in the indexer's words. + if got := c.Manifests["later"]; !got.Accepted || !strings.Contains(got.SetAside, `"shares"`) { + t.Errorf("the manifest with an unknown field reads %+v, want accepted naming the field set aside", got) + } + if got := c.Manifests["refused"]; got.Accepted || !strings.Contains(got.Refused, "refused the manifest of refused: it is wrong") { + t.Errorf("the refused manifest reads %+v, want refused in the verdict's words", got) + } + if got := c.Manifests["nats"]; !got.Accepted { + t.Errorf("nats reads %+v, want accepted", got) + } + if len(c.Machines) != 3 { + t.Fatalf("machines = %v, want the three named", c.Machines) + } + for name, m := range c.Machines { + if !m.Composes && m.Reason == "" { + t.Errorf("%s does not compose and says no reason", name) + } + } +} + +// 1621/AC3: over the captured catalogue and a mesh of three machines, the command's verdict equals, machine for +// machine and manifest for manifest, what the linked reader, resolution and composition answer, at both depths. +func TestTheCommandAnswersWhatTheLinkedCompositionAnswers(t *testing.T) { + for _, depth := range []string{composer.DepthResolve, composer.DepthCompose} { + r := request(t, depth) + code, v, stderr := runJSON(t, nil, encode(t, r)) + if code != 0 { + t.Fatalf("%s: exit %d: %s", depth, code, stderr) + } + in := meshOf() + in.Catalogue = map[string]catalogue.Manifest{} + for name, m := range r.Cases[0].Manifests { + // A request without a list of predefined keys reads a fallback as the indexer does (Request.Predefined). + parsed, err := catalogue.Decode(m.Raw) + got := v.Cases[0].Manifests[name] + if (err == nil) != got.Accepted || (err != nil && err.Error() != got.Refused) { + t.Errorf("%s: the manifest of %s: linked %v, command %+v", depth, name, err, got) + } + if err == nil { + in.Catalogue[name] = parsed + } + } + in.Holdings = composer.HoldingsOf(in.Catalogue, in.Nodes) + composed := 0 + for _, node := range r.Machines { + got := v.Cases[0].Machines[node] + var want composer.Machine + if depth == composer.DepthResolve { + if _, err := compose.Resolve(in, node); err != nil { + want = composer.Machine{Reason: err.Error()} + } else { + want = composer.Machine{Composes: true} + } + } else { + d, problems, err := compose.Compose(in, node) + switch { + case err != nil: + want = composer.Machine{Reason: err.Error()} + default: + want = composer.Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()} + if len(problems) > 0 { + want.Reason = "the node-engine's validator refuses the declaration" + } + } + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: %s: command %+v, linked %+v", depth, node, got, want) + } + if got.Composes { + composed++ + } else { + t.Logf("%s: %s does not compose: %s %v", depth, node, got.Reason, got.Problems) + } + } + t.Logf("%s: %d of %d machines compose", depth, composed, len(r.Machines)) + } +} + +// 1621 note 6: a person composes a catalogue checkout (repository@ref checked out or archived: the command fetches +// nothing) for a node and reads its declaration: each path with its kind, owner and mode. +func TestACatalogueCheckoutIsComposedForANode(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "modules", "demo"), 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"module": "demo", "version": "1", "resources": [{"id": "state", "type": "directory", ` + + `"path": "/var/lib/demo", "mode": "0750", "owner": "root"}]}` + if err := os.WriteFile(filepath.Join(dir, "modules", "demo", "module.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + in := encode(t, withInput(t, composer.Request{V: composer.Version}, compose.Input{Nodes: []compose.Node{{Name: "laptop", Assigned: []string{"demo"}}}})) + code, v, stderr := runJSON(t, []string{"-catalogue", dir, "-node", "laptop", "-declarations"}, in) + if code != 0 { + t.Fatalf("exit %d: %s", code, stderr) + } + m := v.Cases[0].Machines["laptop"] + want := []composer.Path{{Path: "/var/lib/demo", Kind: "directory", Owner: "root", Mode: "0750", Module: ""}} + if !m.Composes || len(m.Declared) != 1 || m.Declared[0].Path != want[0].Path || m.Declared[0].Mode != "0750" || + m.Declared[0].Owner != "root" || m.Declared[0].Kind != "directory" { + t.Fatalf("laptop = %+v, want it composed declaring %+v", m, want) + } +} + +// A run step hands the command no standard input: the request is read from the file -request names, and a file that is +// not there is a request it cannot read. +func TestTheRequestIsReadFromAFile(t *testing.T) { + file := filepath.Join(t.TempDir(), "request.json") + r := composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "only", Manifests: map[string]composer.Manifest{ + "demo": {Raw: json.RawMessage(`{"module":"demo","version":"1"}`)}}}}} + if err := os.WriteFile(file, encode(t, r), 0o600); err != nil { + t.Fatal(err) + } + code, v, stderr := runJSON(t, []string{"-request", file}, nil) + if code != 0 || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted { + t.Fatalf("exit %d (%s), verdict %+v", code, stderr, v) + } + if code, _, stderr := runJSON(t, []string{"-request", file + ".gone"}, nil); code != 1 || !strings.Contains(stderr, "cannot be read") { + t.Errorf("a request file that is not there: exit %d, %q", code, stderr) + } +} + +// The exit codes: 1 for a request that cannot be read or run, 2 for wrong flags, each said on standard error. +func TestTheExitCodesSayWhatFailed(t *testing.T) { + unfilled := encode(t, composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "c", Manifests: map[string]composer.Manifest{"x": {Digest: "abc"}}}}}) + for _, c := range []struct { + name string + args []string + in string + code int + says string + }{ + {"not JSON", nil, "{", 1, "not JSON"}, + {"another version", nil, `{"v":2}`, 1, "version 2"}, + {"a digest left unfilled", nil, string(unfilled), 1, "digest"}, + {"an unknown depth", nil, `{"v":1,"depth":"deep"}`, 1, "depth"}, + {"nothing to read", nil, "", 1, "no request"}, + {"a wrong flag", []string{"-nope"}, "", 2, "nope"}, + {"an argument", []string{"extra"}, "", 2, "unexpected"}, + } { + var out, errOut bytes.Buffer + if got := run(c.args, strings.NewReader(c.in), &out, &errOut); got != c.code || !strings.Contains(errOut.String(), c.says) { + t.Errorf("%s: exit %d saying %q, want %d saying %q", c.name, got, errOut.String(), c.code, c.says) + } + } +} + +// ownPackages is every package of this repository the command imports, directly or not, with the packages each +// imports: what this repository adds to the composition; mesh-sdk's packages are judged in mesh-sdk. +func ownPackages(t *testing.T) map[string][]string { + t.Helper() + _, file, _, _ := runtime.Caller(0) + root := filepath.Join(filepath.Dir(file), "..", "..") + const module = "github.com/novox/mesh-host" + out := map[string][]string{} + var walk func(path string) + walk = func(path string) { + if _, done := out[path]; done { + return + } + rel := strings.TrimPrefix(strings.TrimPrefix(path, module), "/") + pkg, err := build.Default.ImportDir(filepath.Join(root, rel), 0) + if err != nil { + t.Fatalf("%s: %v", path, err) + } + out[path] = pkg.Imports + for _, imp := range pkg.Imports { + if strings.HasPrefix(imp, module+"/") { + walk(imp) + } + } + } + walk(composer.Package) + return out +} + +// 1621/AC2 and AC4: the command imports, through every package of this repository it reaches, only the standard +// library, mesh-sdk and this repository's own packages, and none that reaches a bus, a store, a network service or +// another process. +func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) { + forbidden := []string{"net/http", "net/rpc", "net/smtp", "os/exec", "database/sql", "plugin", "syscall"} + own := ownPackages(t) + if len(own) < 4 { + t.Fatalf("the command reaches %d packages of this repository; the walk found too few to judge", len(own)) + } + for path, imports := range own { + for _, imp := range imports { + std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".") + if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") { + t.Errorf("%s imports %s, which is neither the standard library, mesh-sdk nor this repository", path, imp) + } + for _, f := range forbidden { + if imp == f { + t.Errorf("%s imports %s", path, imp) + } + } + } + } +} + +// 1621/AC2: run with an empty network namespace, the command still writes its verdict: it needs no network. The test +// runs its own binary again under `unshare --net` as an unprivileged user's namespace; where the kernel or the sandbox +// allows no new namespace (a build module's run step is already one with no network), it says so and skips. +func TestTheCommandRunsWithNoNetwork(t *testing.T) { + if os.Getenv("MESH_COMPOSE_HELPER") == "1" { + os.Exit(run(nil, os.Stdin, os.Stdout, os.Stderr)) + } + unshare, err := exec.LookPath("unshare") + if err != nil { + t.Skip("no unshare on this machine, so no empty network namespace can be made here") + } + if out, err := exec.Command(unshare, "--net", "--map-root-user", "true").CombinedOutput(); err != nil { + t.Skipf("this machine makes no unprivileged network namespace (%v: %s); a run step's own has no network", err, out) + } + r := request(t, composer.DepthResolve) + cmd := exec.Command(unshare, "--net", "--map-root-user", os.Args[0], "-test.run=^TestTheCommandRunsWithNoNetwork$") + cmd.Env = []string{"MESH_COMPOSE_HELPER=1"} + cmd.Stdin = bytes.NewReader(encode(t, r)) + var out, errOut bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errOut + if err := cmd.Run(); err != nil { + t.Fatalf("the command in an empty network namespace: %v: %s", err, errOut.String()) + } + var v composer.Verdict + if err := json.Unmarshal(out.Bytes(), &v); err != nil || len(v.Cases) != 1 || len(v.Cases[0].Machines) != 3 { + t.Fatalf("no verdict from the command with no network: %v\n%s", err, out.String()) + } + names := make([]string, 0, len(v.Cases[0].Machines)) + for n := range v.Cases[0].Machines { + names = append(names, n) + } + sort.Strings(names) + t.Logf("composed with no network: %v", names) +} diff --git a/cmd/mesh-compose/reads-outside b/cmd/mesh-compose/reads-outside new file mode 100644 index 0000000..f6b2d87 --- /dev/null +++ b/cmd/mesh-compose/reads-outside @@ -0,0 +1 @@ +These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935). diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/adwaita/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/adwaita/module.json.captured new file mode 100644 index 0000000..bf81c83 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/adwaita/module.json.captured @@ -0,0 +1,176 @@ +{ + "module": "adwaita", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "tools": [ + "adwaita_appearance", + "adwaita_cursor", + "adwaita_icons", + "adwaita_portal_check" + ], + "items": [ + { + "collection": "environment", + "name": "gtk-theme", + "attributes": { + "kind": "variable", + "name": "GTK_THEME" + }, + "template": "files/env-gtk-theme" + }, + { + "collection": "environment", + "name": "gtk2-rc-files", + "attributes": { + "kind": "variable", + "name": "GTK2_RC_FILES" + }, + "template": "files/env-gtk2-rc-files" + }, + { + "collection": "environment", + "name": "qt-qpa-platformtheme", + "attributes": { + "kind": "variable", + "name": "QT_QPA_PLATFORMTHEME" + }, + "template": "files/env-qt-qpa-platformtheme" + }, + { + "collection": "environment", + "name": "qt-style-override", + "attributes": { + "kind": "variable", + "name": "QT_STYLE_OVERRIDE" + }, + "template": "files/env-qt-style-override" + }, + { + "collection": "environment", + "name": "qt-select", + "attributes": { + "kind": "variable", + "name": "QT_SELECT" + }, + "template": "files/env-qt-select" + }, + { + "collection": "environment", + "name": "xcursor-theme", + "attributes": { + "kind": "variable", + "name": "XCURSOR_THEME" + }, + "template": "files/env-xcursor-theme" + }, + { + "collection": "environment", + "name": "xcursor-size", + "attributes": { + "kind": "variable", + "name": "XCURSOR_SIZE" + }, + "template": "files/env-xcursor-size" + }, + { + "collection": "shell:xresources:normal", + "name": "xresources-normal", + "template": "files/xresources-normal" + }, + { + "collection": "shell:xinitrc:normal", + "name": "xinitrc-normal", + "template": "files/xinitrc-normal" + } + ], + "resources": [ + { + "id": "package-gnome-themes-extra", + "type": "package", + "package": "gnome-themes-extra" + }, + { + "id": "package-adwaita-icon-theme", + "type": "package", + "package": "adwaita-icon-theme" + }, + { + "id": "package-adwaita-cursors", + "type": "package", + "package": "adwaita-cursors" + }, + { + "id": "package-qt6ct", + "type": "package", + "package": "qt6ct" + }, + { + "id": "package-xdg-desktop-portal-gtk", + "type": "package", + "package": "xdg-desktop-portal-gtk" + }, + { + "id": "gtk3", + "type": "file", + "path": "${machine:account-home}/.config/gtk-3.0/settings.ini", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/gtk-settings.ini" + }, + { + "id": "gtk4", + "type": "file", + "path": "${machine:account-home}/.config/gtk-4.0/settings.ini", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/gtk-settings.ini" + }, + { + "id": "qt6ct", + "type": "file", + "path": "${machine:account-home}/.config/qt6ct/qt6ct.conf", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/qt6ct.conf" + }, + { + "id": "portals", + "type": "file", + "path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/portals.conf" + }, + { + "id": "cursor", + "type": "file", + "path": "${machine:account-home}/.icons/default/index.theme", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/cursor-index.theme" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/adwaita-tools", + "binary": "adwaita-tools", + "loads": [ + "adwaita-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/artifact-store-tools/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/artifact-store-tools/module.json.captured new file mode 100644 index 0000000..208329c --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/artifact-store-tools/module.json.captured @@ -0,0 +1,65 @@ +{ + "module": "artifact-store-tools", + "version": "1", + "invokes": [ + "seat:mesh-controller.artifacts", + "seat:mesh-controller.collect", + "mesh-catalog.catalog_modules", + "mesh-catalog.catalog_module" + ], + "tools": [ + "artifact_store_repositories", + "artifact_store_usage", + "artifact_store_references", + "artifact_store_collect" + ], + "config": { + "older-store-names": { + "type": "string", + "description": "The names the artifact store had before, as host names or host:port separated by spaces, or none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323).", + "pattern": "none|[A-Za-z0-9.:-]+( [A-Za-z0-9.:-]+)*", + "default": "none" + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "store-names", + "type": "file", + "path": "${dir:state}/store-names", + "mode": "0600", + "trusted": false, + "template": "files/store-names" + } + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/artifact-store-tools", + "binary": "artifact-store-tools", + "loads": [ + "artifact-store-tools" + ], + "env": { + "MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry", + "MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured new file mode 100644 index 0000000..d1ee8d3 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured @@ -0,0 +1,229 @@ +{ + "module": "asus-zephyrus-g14", + "version": "1", + "moved": true, + "templates": "templates", + "capabilities": [ + "package-manager", + "service-manager" + ], + "requires": [ + "x11-display" + ], + "emits": [ + "profile.switched" + ], + "tools": [ + "zephyrus_brightness", + "zephyrus_battery", + "zephyrus_charge_limit", + "zephyrus_gpu_mode", + "zephyrus_profile", + "zephyrus_thermals", + "zephyrus_power_draw", + "zephyrus_profile_policy", + "zephyrus_fan_curves", + "zephyrus_keys", + "zephyrus_check" + ], + "resources": [ + { + "id": "asusctl", + "type": "package", + "package": "asusctl" + }, + { + "id": "playerctl", + "type": "package", + "package": "playerctl" + }, + { + "id": "asusd", + "type": "service", + "unit": "asusd.service", + "state": "running" + }, + { + "id": "supergfxd", + "type": "service", + "unit": "supergfxd.service", + "state": "running", + "boot": "enabled" + }, + { + "id": "scripts", + "type": "archive", + "path": "/usr/local/lib/asus-zephyrus-g14", + "artifact": "scripts", + "unknown": "program" + }, + { + "id": "nvidia-options", + "type": "file", + "path": "/etc/modprobe.d/g14-nvidia-power.conf", + "mode": "0644", + "template": "templates/modprobe.d/g14-nvidia-power.conf" + }, + { + "id": "video-options", + "type": "file", + "path": "/etc/modprobe.d/video-brightness-switch.conf", + "mode": "0644", + "template": "templates/modprobe.d/video-brightness-switch.conf" + }, + { + "id": "suspend-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/systemd-suspend.service.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "nvidia-on-suspend", + "type": "file", + "path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf", + "mode": "0644", + "template": "templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf" + }, + { + "id": "hibernate-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/systemd-hibernate.service.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "nvidia-on-hibernate", + "type": "file", + "path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf", + "mode": "0644", + "template": "templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf" + }, + { + "id": "suspend-then-hibernate-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "nvidia-on-suspend-then-hibernate", + "type": "file", + "path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf", + "mode": "0644", + "template": "templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf" + }, + { + "id": "powerd-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/nvidia-powerd.service.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "powerd-opt-in", + "type": "file", + "path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf", + "mode": "0644", + "template": "templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf" + }, + { + "id": "backlight-rule", + "type": "file", + "path": "/etc/udev/rules.d/90-backlight.rules", + "mode": "0644", + "template": "templates/udev/rules.d/90-backlight.rules" + }, + { + "id": "udev", + "type": "service", + "unit": "systemd-udevd.service", + "reload-on": [ + "backlight-rule" + ] + }, + { + "id": "upower-package", + "type": "package", + "package": "upower" + }, + { + "id": "upower-drop-ins", + "type": "directory", + "path": "/etc/UPower/UPower.conf.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "low-battery", + "type": "file", + "path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf", + "mode": "0644", + "template": "templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf" + }, + { + "id": "upower", + "type": "service", + "unit": "upower.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "low-battery" + ] + }, + { + "id": "xorg-drop-ins", + "type": "directory", + "path": "/etc/X11/xorg.conf.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "touchpad", + "type": "file", + "path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf", + "mode": "0644", + "template": "templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf" + } + ], + "items": [ + { + "collection": "shell:after-wake:normal", + "template": "templates/after-wake" + }, + { + "collection": "contribution:node-hotkeys:trigger", + "template": "templates/hotkeys-triggers" + }, + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "templates/display-session" + } + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/zephyrus", + "binary": "zephyrus", + "loads": [ + "zephyrus" + ] + }, + { + "name": "scripts", + "kind": "archive", + "from": "files" + }, + { + "name": "templates", + "kind": "archive", + "from": "templates" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/audit-logger/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/audit-logger/module.json.captured new file mode 100644 index 0000000..122b94e --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/audit-logger/module.json.captured @@ -0,0 +1,69 @@ +{ + "module": "audit-logger", + "version": "1", + "slug": "audit", + "consumes": [ + "**" + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js" + ], + "loads": [ + "index.js", + "tools/index.js" + ], + "env": { + "AUDIT_LOG": "${dir:trail}/audit.log", + "AUDIT_SPOOL": "${dir:spool}" + } + } + ] + }, + "data": { + "own": [ + { + "id": "trail", + "path": "${dir:trail}", + "class": "valuable", + "why": "the audit trail, written nowhere else" + }, + { + "id": "spool", + "path": "${dir:spool}", + "class": "valuable", + "why": "events the trail could not take yet; after the bus gives one up, the only copy" + } + ] + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "trail", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "spool", + "type": "directory", + "mode": "0700", + "unknown": "program" + } + ], + "capabilities": [ + "container-runtime" + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/avahi/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/avahi/module.json.captured new file mode 100644 index 0000000..de99c7c --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/avahi/module.json.captured @@ -0,0 +1,43 @@ +{ + "module": "avahi", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "avahi_status", + "avahi_browse", + "avahi_resolve", + "avahi_services" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "avahi" + }, + { + "id": "daemon", + "type": "service", + "unit": "avahi-daemon.service", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/avahi-tools", + "binary": "avahi-tools", + "loads": [ + "avahi-tools" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/baserow/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/baserow/module.json.captured new file mode 100644 index 0000000..8119621 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/baserow/module.json.captured @@ -0,0 +1,158 @@ +{ + "module": "baserow", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "postgres-database", + "route" + ], + "contributes": { + "postgres-database": { + "name": "baserow" + }, + "route": { + "label": "baserow", + "endpoint": "web" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "own-secrets": { + "admin": "${dir:state}/admin.secret" + }, + "listens": [ + { + "name": "web", + "port": 80, + "protocol": "tcp", + "from": "mesh", + "why": "the Baserow web UI and REST API, served by the image's own Caddy; a public name is the route's" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "why": "uploaded files and media; the tables are in the database" + } + ] + }, + "config": { + "host": { + "type": "string", + "description": "The Host header the module's tools send to Baserow, a host name; the file's default is the name the module's route was given.", + "trusted": true, + "default": "${bound:route:name}" + }, + "email": { + "type": "string", + "description": "The email address of the Baserow account the module's tools sign in as; unset, the code falls back to its environment.", + "trusted": true, + "default": "" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach Baserow at, as a URL; unset, the code falls back to its environment, else this machine's Baserow on port 80.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0755", + "owner": "9999:9999", + "unknown": "program" + }, + { + "id": "server-env", + "type": "file", + "path": "${dir:state}/server.env", + "mode": "0600", + "template": "files/server.env" + }, + { + "id": "net", + "type": "network", + "name": "baserow" + }, + { + "id": "server", + "type": "container", + "name": "baserow", + "image": "baserow/baserow@sha256:263ea6c4b72c9eccabcd975ffe9fdebf23913a293a514bec6a3897a5e0a5a080", + "health": { + "kind": "runtime" + }, + "network": "baserow", + "env-file": [ + "${dir:state}/server.env" + ], + "ports": [ + "80" + ], + "volumes": [ + "${dir:data}:/baserow/data", + "${dir:state}/database.secret:/run/secrets/database:ro" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": ["host", "email", "url"], + "protected": ["password"] + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_BASEROW_URL": "http://127.0.0.1:${port:80}", + "MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/betterbird/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/betterbird/module.json.captured new file mode 100644 index 0000000..891f883 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/betterbird/module.json.captured @@ -0,0 +1,253 @@ +{ + "module": "betterbird", + "version": "1", + "upgrade": { + "policy": "record", + "why": "a new build may remove the community package betterbird-bin and replaces the installation in /opt/betterbird, which a running Betterbird has open: the switch on each node and every upgrade wait for the operator, who quits Betterbird first and then pushes (novox/hq ADR 0316 §4, §5)" + }, + "requires": [ + "x11-display" + ], + "invokes": [ + "seat:mesh-controller.settings", + "seat:mesh-controller.nodes", + "seat:mesh-controller.node", + "betterbird.betterbird_derive" + ], + "tools": [ + "betterbird_status", + "betterbird_prefs", + "betterbird_config", + "betterbird_config_apply", + "betterbird_user_js", + "betterbird_log", + "betterbird_restart", + "betterbird_check", + "betterbird_accounts", + "betterbird_identities", + "betterbird_calendars", + "betterbird_address_books", + "betterbird_filters", + "betterbird_tags", + "betterbird_derive", + "betterbird_drift", + "betterbird_reminder_test" + ], + "config": { + "prefs": { + "type": "object", + "description": "Betterbird's preferences the operator sets, by their own names (mailnews.*, calendar.* and the like), laid into prefs.json; an account's, a server's and a tag's are refused, as accounts, the four kinds and tags own them (novox/hq ADR 0329 §6).", + "trusted": true, + "default": {} + }, + "accounts": { + "type": "object", + "description": "The mail accounts Betterbird is set up with, each by a name of the operator's: its address, its sender's name and its incoming and outgoing servers; no password, token or cookie (novox/hq ADR 0329 §2).", + "trusted": true, + "default": {} + }, + "identities": { + "type": "object", + "description": "The identities beside an account's own, each by a name of the operator's: the address and name it sends as, its signature as a list of lines, and the profile's key to adopt on a node.", + "trusted": true, + "default": {} + }, + "calendars": { + "type": "object", + "description": "The calendars Betterbird shows, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.", + "trusted": true, + "default": {} + }, + "address-books": { + "type": "object", + "description": "The address books Betterbird uses, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.", + "trusted": true, + "default": {} + }, + "filters": { + "type": "object", + "description": "The message filters of each account, by account: what each matches and what it does with the mail.", + "trusted": true, + "default": {} + }, + "tags": { + "type": "object", + "description": "The tags Betterbird offers, each by its own key: its name, colour and order; trusted since issue 751, so a change to it is the operator's to approve.", + "trusted": true, + "default": {} + } + }, + "resources": [ + { + "id": "configuration-dir", + "type": "directory", + "path": "${machine:account-home}/.config/betterbird", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out", + "knows": ["accounts-record.json", "configuration-record.json", "kept/"] + }, + { + "id": "prefs", + "type": "file", + "path": "${machine:account-home}/.config/betterbird/prefs.json", + "owner": "${machine:account}", + "mode": "0644", + "merge": "json", + "takes-config": [ + "prefs" + ], + "template": "files/prefs.json", + "trusted": true + }, + { + "id": "accounts", + "type": "file", + "path": "${machine:account-home}/.config/betterbird/accounts.json", + "owner": "${machine:account}", + "mode": "0600", + "merge": "json", + "takes-config": [ + "accounts" + ], + "template": "files/accounts.json", + "trusted": true + }, + { + "id": "configuration", + "type": "file", + "path": "${machine:account-home}/.config/betterbird/configuration.json", + "owner": "${machine:account}", + "mode": "0600", + "merge": "json", + "takes-config": [ + "identities", + "calendars", + "address-books", + "filters" + ], + "template": "files/configuration.json", + "trusted": true + }, + { + "id": "tags", + "type": "file", + "path": "${machine:account-home}/.config/betterbird/tags.json", + "owner": "${machine:account}", + "mode": "0644", + "merge": "json", + "takes-config": [ + "tags" + ], + "template": "files/tags.json", + "trusted": true + }, + { + "id": "community-package", + "type": "package", + "package": "betterbird-bin", + "absent": true + }, + { + "id": "installation", + "type": "archive", + "path": "/opt/betterbird", + "artifact": "archive", + "unknown": "program" + }, + { + "id": "owl", + "type": "archive", + "path": "/opt/betterbird/owl", + "artifact": "owl", + "unknown": "program" + }, + { + "id": "policies", + "type": "archive", + "path": "/opt/betterbird/distribution", + "artifact": "distribution", + "unknown": "program" + }, + { + "id": "spelling", + "type": "archive", + "path": "/opt/betterbird/defaults/pref", + "artifact": "defaults", + "unknown": "program" + }, + { + "id": "preparation", + "type": "archive", + "path": "/usr/local/lib/mesh-betterbird", + "artifact": "tools", + "unknown": "program" + }, + { + "id": "start", + "type": "file", + "path": "/usr/local/bin/betterbird", + "mode": "0755", + "template": "files/betterbird" + }, + { + "id": "launcher", + "type": "file", + "path": "/usr/local/share/applications/eu.betterbird.Betterbird.desktop", + "mode": "0644", + "template": "files/eu.betterbird.Betterbird.desktop" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/betterbird-tools", + "binary": "betterbird-tools", + "loads": [ + "betterbird-tools" + ] + }, + { + "name": "archive", + "kind": "archive", + "url": "https://www.betterbird.eu/downloads/LinuxArchive/betterbird-153.4.0esr-bb10.en-US.linux-x86_64.tar.xz", + "sha256": "38dd24890a66b4cf9a45bc24e15192e00c26b929e1bb8df7974c16a4539129e2", + "version": "153.4.0esr-bb10", + "within": "betterbird", + "names-on-purpose": { + "www.betterbird.eu": "Betterbird's project publishes its Linux release there; the build seat fetches it once, by its pinned sha256 (novox/hq ADR 0316 §2)" + } + }, + { + "name": "owl", + "kind": "archive", + "url": "https://www.beonex.com/owl/owl.xpi", + "sha256": "a54cc3d52113b34f97942ee19190947dee23561e2217f18db3d80ac023991221", + "version": "1.5.6.1", + "file": "owl.xpi", + "names-on-purpose": { + "www.beonex.com": "Owl's vendor publishes its xpi there; the build seat fetches it once, by its pinned sha256 (novox/hq ADR 0316 §6)" + } + }, + { + "name": "distribution", + "kind": "archive", + "from": "install/distribution" + }, + { + "name": "defaults", + "kind": "archive", + "from": "install/defaults-pref" + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/blueman/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/blueman/module.json.captured new file mode 100644 index 0000000..73a7640 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/blueman/module.json.captured @@ -0,0 +1,37 @@ +{ + "module": "blueman", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "tools": [ + "blueman_status", + "blueman_restart", + "blueman_check" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "blueman" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/blueman-tools", + "binary": "blueman-tools", + "loads": [ + "blueman-tools" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/bluetooth/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/bluetooth/module.json.captured new file mode 100644 index 0000000..dfaabf1 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/bluetooth/module.json.captured @@ -0,0 +1,53 @@ +{ + "module": "bluetooth", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "bluetooth_controller", + "bluetooth_power", + "bluetooth_devices", + "bluetooth_scan", + "bluetooth_connect", + "bluetooth_disconnect", + "bluetooth_trust", + "bluetooth_pair", + "bluetooth_remove" + ], + "resources": [ + { + "id": "stack", + "type": "package", + "package": "bluez" + }, + { + "id": "utilities", + "type": "package", + "package": "bluez-utils" + }, + { + "id": "daemon", + "type": "service", + "unit": "bluetooth.service", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/bluetooth-tools", + "binary": "bluetooth-tools", + "loads": [ + "bluetooth-tools" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/brother/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/brother/module.json.captured new file mode 100644 index 0000000..57559d5 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/brother/module.json.captured @@ -0,0 +1,280 @@ +{ + "module": "brother", + "version": "1", + "requires": [ + "s3-bucket" + ], + "binds": { + "s3-bucket": "${dir:state}/store-binding.json" + }, + "secrets": { + "s3-bucket": "${dir:state}/store.secret" + }, + "config": { + "scanner": { + "type": "string", + "description": "The network scanner's eSCL address, such as http://192.0.2.10/eSCL: the scanner's own address and the path its eSCL service answers on; the operator's, with no default.", + "pattern": "^https?://.+", + "trusted": true, + "required": true + }, + "scan-folder": { + "type": "string", + "description": "The absolute path of the folder the scans are filed in as PDF files; the module makes it when it files the first scan, and never touches a file in it that it did not write. The operator's, with no default.", + "pattern": "^/.+$", + "trusted": true, + "required": true + }, + "printer": { + "type": "string", + "description": "The printer's host, such as 192.0.2.10: where its admin page (HTTPS) and its IPP service answer. The device is the scanner's own; the operator's, with no default.", + "pattern": "^[A-Za-z0-9.:-]+$", + "trusted": true, + "required": true + }, + "printer-certificate": { + "type": "string", + "description": "The SHA-256 fingerprint of the certificate the printer's admin page serves, as 32 upper-case hex pairs joined by colons; several, separated by commas, when the printer serves more than one (it serves an ECDSA and an RSA certificate for one name). The module trusts those certificates alone and refuses any other; the operator's, with no default.", + "pattern": "^([0-9A-F]{2}:){31}[0-9A-F]{2}(, *([0-9A-F]{2}:){31}[0-9A-F]{2})*$", + "trusted": true, + "required": true + }, + "resolution": { + "type": "number", + "description": "The resolution asked of the scanner, in dpi: 100, 200, 300 or 600. A scanner may give another; the scan is filed at the resolution it gave.", + "enum": [ + 100, + 200, + 300, + 600 + ], + "trusted": false, + "default": 300 + }, + "color-mode": { + "type": "string", + "description": "Whether the scan is in colour or in grays: color or gray.", + "enum": [ + "color", + "gray" + ], + "trusted": false, + "default": "color" + }, + "crop": { + "type": "boolean", + "description": "Whether the document is cut out of the platen's white lid; false files the whole platen. A feeder scan is never cropped.", + "trusted": false, + "default": true + } + }, + "own-secrets": { + "admin-password": { + "path": "${dir:state}/admin-password.secret", + "issued-by": "outside", + "prompt": "the admin password of the Brother printer", + "owner": "brother-device" + }, + "broker-device": { + "path": "${dir:state}/broker-device.secret", + "owner": "brother-device" + } + }, + "streams": [ + { + "name": "device-asks", + "events": [ + "device.asked" + ], + "max-age-days": 1, + "max-mib": 8, + "retention": "work-queue", + "max-deliveries": 3, + "duplicates": 24 + } + ], + "state": [ + { + "name": "device-answers", + "ttl-seconds": 600 + } + ], + "seats": [ + { + "name": "scanner", + "scope": "mesh", + "serves": [ + "scan", + "status", + "list", + "document" + ] + }, + { + "name": "printer", + "scope": "mesh", + "serves": [ + "status", + "settings", + "print", + "upload" + ] + } + ], + "claims": [ + { + "name": "scanner", + "scope": "mesh", + "serves": [ + "scan", + "status", + "list", + "document" + ] + }, + { + "name": "printer", + "scope": "mesh", + "serves": [ + "status", + "settings", + "print", + "upload" + ] + } + ], + "emits": [ + "scan.added" + ], + "tools": [ + "scanner.scan", + "scanner.status", + "scanner.list", + "scanner.document", + "printer.status", + "printer.settings", + "printer.print", + "printer.upload", + "brother_health" + ], + "resources": [ + { + "id": "account", + "type": "user", + "name": "brother-device", + "shell": "/usr/bin/nologin" + }, + { + "id": "state", + "type": "directory", + "mode": "0711", + "owner": "${machine:account}", + "place": ".", + "unknown": "program" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "owner": "${machine:account}", + "mode": "0644", + "takes-config": [ + "scanner", + "scan-folder", + "printer", + "printer-certificate", + "resolution", + "color-mode", + "crop" + ], + "merge": "json", + "template": "files/settings.json", + "trusted": true + }, + { + "id": "store", + "type": "file", + "path": "${dir:state}/store.json", + "owner": "brother-device", + "mode": "0600", + "template": "files/store.json" + }, + { + "id": "device", + "type": "process", + "name": "brother-device", + "artifact": "tools", + "run": [ + "./brother", + "device" + ], + "user": "brother-device", + "env": { + "BROTHER_SETTINGS": "${dir:state}/settings.json", + "BROTHER_ADMIN_PASSWORD": "${dir:state}/admin-password.secret", + "BROTHER_BROKER": "${dir:state}/broker-device.secret", + "BROTHER_STORE": "${dir:state}/store.json" + }, + "restart-on": [ + "settings", + "store" + ], + "bus": { + "queues": [ + "device-asks" + ], + "state": [ + "device-answers" + ], + "emits": [ + "scan.added" + ] + }, + "health": { + "kind": "unit" + } + }, + { + "id": "watch", + "type": "process", + "name": "brother-watch", + "artifact": "tools", + "run": [ + "./brother", + "watch" + ], + "user": "brother-device", + "health": { + "kind": "tool", + "tool": "brother_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/brother", + "binary": "brother", + "loads": [ + "brother" + ], + "env": { + "BROTHER_SETTINGS": "${dir:state}/settings.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/ca-trust/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/ca-trust/module.json.captured new file mode 100644 index 0000000..b3c9dc9 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/ca-trust/module.json.captured @@ -0,0 +1,66 @@ +{ + "module": "ca-trust", + "version": "1", + "slug": "catrust", + "capabilities": [ + "service-manager" + ], + "requires": [ + "internal-acme-ca" + ], + "seats": [ + { + "name": "the-mesh-trust-anchor", + "scope": "node" + } + ], + "claims": [ + { + "name": "the-mesh-trust-anchor", + "scope": "node" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "anchor", + "type": "file", + "path": "${dir:state}/anchor", + "mode": "0755", + "template": "files/anchor" + }, + { + "id": "unit", + "type": "file", + "path": "/etc/systemd/system/mesh-ca-trust.service", + "mode": "0644", + "template": "files/mesh-ca-trust.service" + }, + { + "id": "trust", + "type": "service", + "unit": "mesh-ca-trust.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "anchor", + "unit" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/claude-code/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/claude-code/module.json.captured new file mode 100644 index 0000000..b869d01 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/claude-code/module.json.captured @@ -0,0 +1,247 @@ +{ + "module": "claude-code", + "version": "1", + "moved": true, + "slug": "agent", + "capabilities": [ + "package-manager" + ], + "requires": [ + "mcp-endpoint", + { + "name": "gitea-read", + "optional": true + } + ], + "binds": { + "mcp-endpoint": "${dir:state}/mcp-endpoint.json" + }, + "secrets": { + "gitea-read": "${dir:state}/gitea-read.secret" + }, + "uses": [ + "operator-channel" + ], + "state": [ + "servers", + "holdings", + "config", + "proposals", + "questions", + "memory" + ], + "reads": [ + "claude-licence-manager.bindings" + ], + "tools": [ + "claude_code_status", + "claude_code_render", + "claude_code_guard", + "claude_code_pull", + "claude_code_grant", + "claude_code_add_api_key", + "claude_code_registrations", + "claude_code_memory", + "claude_code_mcp_list", + "claude_code_mcp_register", + "claude_code_mcp_unregister", + "claude_code_skill_list", + "claude_code_skill_register", + "claude_code_skill_unregister", + "claude_code_agent_list", + "claude_code_agent_register", + "claude_code_agent_unregister", + "claude_code_command_list", + "claude_code_command_register", + "claude_code_command_unregister", + "claude_code_hook_list", + "claude_code_hook_register", + "claude_code_hook_unregister", + "claude_code_output_style_list", + "claude_code_output_style_register", + "claude_code_output_style_unregister", + "claude_code_instructions_list", + "claude_code_instructions_register", + "claude_code_instructions_unregister", + "claude_code_instructions_propose", + "claude_code_proposals", + "claude_code_ask", + "claude_code_answer", + "claude_code_settings_get", + "claude_code_settings_set", + "claude_code_settings_clear", + "claude_code_permission_add", + "claude_code_permission_remove", + "claude_code_config_list", + "claude_code_config_show", + "claude_code_config_status", + "claude_code_config_import", + "claude_code_home_show", + "claude_code_home_remove", + "claude_code_home_removed", + "claude_code_home_restore", + "claude_code_judge", + "claude_code_judged" + ], + "data": { + "own": [ + { + "id": "agent-home", + "path": "${dir:agent-home}", + "class": "valuable", + "why": "the operator's agent's own files: its memory, history and projects" + } + ] + }, + "config": { + "role": { + "type": "string", + "description": "What this machine is for, in a sentence, written into the instructions every Claude Code session on it reads; empty says nothing.", + "trusted": true, + "default": "" + }, + "mcp_servers": { + "type": "object", + "description": "The tool servers every session on this machine is given beside the mesh's own, by name, each as Claude Code's managed MCP configuration takes it; empty adds none.", + "trusted": true, + "default": {} + }, + "managed_settings": { + "type": "object", + "description": "Keys of Claude Code's managed settings every session on this machine obeys, such as spinnerTipsEnabled, laid over the mesh's own; empty adds none. Falls back to agent-managed-settings, the mesh's or this node's.", + "trusted": true, + "predefined": "agent-managed-settings", + "default": {} + }, + "instructions": { + "type": "object", + "description": "The instructions every session on this machine reads, by name, each a text as one line or a list of its lines; empty adds none.", + "trusted": true, + "default": {} + }, + "output_styles": { + "type": "object", + "description": "The output styles offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.", + "trusted": true, + "default": {} + }, + "skills": { + "type": "object", + "description": "The skills offered to every session on this machine, by name, each its files by path, a text as one line or a list of its lines; a skill without its SKILL.md is left out; empty adds none.", + "trusted": true, + "default": {} + }, + "commands": { + "type": "object", + "description": "The slash commands offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.", + "trusted": true, + "default": {} + }, + "agents": { + "type": "object", + "description": "The agents offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.", + "trusted": true, + "default": {} + } + }, + "resources": [ + { + "id": "package", + "type": "package", + "package": "claude-code" + }, + { + "id": "managed", + "type": "directory", + "path": "/etc/claude-code", + "mode": "0755", + "unknown": "program" + }, + { + "id": "start", + "type": "file", + "path": "/usr/local/bin/claude-agent", + "mode": "0755", + "template": "files/claude-agent" + }, + { + "id": "agent-account-name", + "type": "file", + "path": "/etc/claude-code/agent-account", + "mode": "0644", + "template": "files/agent-account" + }, + { + "id": "agent-account", + "type": "user", + "name": "${machine:agent-account}", + "home": "${machine:agent-home}", + "root": "${machine:agent-root}" + }, + { + "id": "agent-home", + "type": "directory", + "path": "${machine:agent-home}/.claude", + "mode": "0700", + "owner": "${machine:agent-account}", + "unknown": "module" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "${machine:account}", + "place": ".", + "unknown": "program" + }, + { + "id": "facts", + "type": "file", + "path": "${dir:state}/facts.json", + "mode": "0600", + "owner": "${machine:account}", + "template": "files/facts.json" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "owner": "${machine:account}", + "takes-config": ["role", "mcp_servers", "managed_settings", "instructions", "output_styles", "skills", "commands", "agents"], + "merge": "json", + "template": "files/settings.json" + } + ], + "items": [ + { + "collection": "shell:zsh:normal", + "template": "files/zsh-normal" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/claude-code", + "binary": "claude-code", + "loads": [ + "claude-code" + ], + "env": { + "MESH_CLAUDE_CODE_STATE": "${dir:state}", + "MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json", + "MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/claude-licence-manager/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/claude-licence-manager/module.json.captured new file mode 100644 index 0000000..a55da31 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/claude-licence-manager/module.json.captured @@ -0,0 +1,184 @@ +{ + "module": "claude-licence-manager", + "version": "1", + "slug": "licmgr", + "requires": [ + "postgres-database", + "secret" + ], + "contributes": { + "postgres-database": { + "name": "claude_licences", + "readable": [ + "audit", + "binding" + ] + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret", + "secret": { + "grant-key": "${dir:state}/grant.key" + } + }, + "seats": [ + { + "name": "anthropic-licence-manager", + "scope": "mesh", + "serves": [ + "licences", + "bindings", + "bind", + "switch", + "release", + "refresh", + "usage", + "adopt", + "current", + "public-key" + ] + } + ], + "claims": [ + { + "name": "anthropic-licence-manager", + "scope": "mesh", + "serves": [ + "licences", + "bindings", + "bind", + "switch", + "release", + "refresh", + "usage", + "adopt", + "current", + "public-key" + ] + } + ], + "emits": [ + "licence.adopted", + "licence.refused", + "licence.bound", + "licence.switched", + "licence.released", + "licence.rotated", + "licence.failing", + "usage.read" + ], + "state": [ + "bindings" + ], + "reads": [ + "claude-code.holdings" + ], + "config": { + "cadence_minutes": { + "type": "number", + "description": "How old a licence's grant may grow before it is rotated, in minutes; 0 keeps 240.", + "minimum": 0, + "trusted": true, + "default": 240 + }, + "floor_minutes": { + "type": "number", + "description": "How little time a grant may have left before it is refreshed in any case, in minutes; 0 keeps 60.", + "minimum": 0, + "trusted": true, + "default": 60 + }, + "failures_to_notify": { + "type": "number", + "description": "How many refreshes of a licence fail in a row before the operator is told, a count; 0 keeps 3.", + "minimum": 0, + "trusted": true, + "default": 3 + }, + "cooldown_hours": { + "type": "number", + "description": "How long after telling the operator about a licence nothing more is said about it, in hours; 0 keeps 24.", + "minimum": 0, + "trusted": true, + "default": 24 + }, + "refresh_warn_days": { + "type": "number", + "description": "How long before a licence's refresh token itself expires the operator is warned, in days; 0 keeps 3.", + "minimum": 0, + "trusted": true, + "default": 3 + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "database-url", + "type": "file", + "path": "${dir:state}/database.url", + "mode": "0600", + "template": "files/database.url" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "takes-config": ["cadence_minutes", "floor_minutes", "failures_to_notify", "cooldown_hours", "refresh_warn_days"], + "merge": "json", + "template": "files/settings.json" + }, + { + "id": "prepare", + "type": "process", + "name": "claude-licence-manager-prepare", + "artifact": "code", + "run": [ + "./claude-licence-manager", + "prepare" + ], + "run-once": true, + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/claude-licence-manager", + "binary": "claude-licence-manager", + "loads": [ + "claude-licence-manager" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_LICENCE_STATE": "${dir:state}", + "MESH_LICENCE_KEY_FILE": "${dir:state}/grant.key", + "MESH_LICENCE_SETTINGS": "${dir:state}/settings.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/clipmenu/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/clipmenu/module.json.captured new file mode 100644 index 0000000..30ec9c0 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/clipmenu/module.json.captured @@ -0,0 +1,135 @@ +{ + "module": "clipmenu", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-clipboard", + "scope": "node", + "serves": [ + "history", + "copy" + ] + } + ], + "tools": [ + "clipmenu_paste", + "clipmenu_clear", + "clipmenu_delete" + ], + "items": [ + { + "collection": "environment", + "name": "cm-selections", + "attributes": { + "kind": "variable", + "name": "CM_SELECTIONS" + }, + "template": "files/env-cm-selections" + }, + { + "collection": "environment", + "name": "cm-max-clips", + "attributes": { + "kind": "variable", + "name": "CM_MAX_CLIPS" + }, + "template": "files/env-cm-max-clips" + }, + { + "collection": "environment", + "name": "cm-histlength", + "attributes": { + "kind": "variable", + "name": "CM_HISTLENGTH" + }, + "template": "files/env-cm-histlength" + }, + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/50-clipmenu.conf" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "clipmenu" + }, + { + "id": "greenclip", + "type": "package", + "package": "rofi-greenclip", + "absent": true + }, + { + "id": "text-only", + "type": "file", + "path": "/usr/local/lib/mesh-clipmenu/xsel", + "mode": "0755", + "template": "files/bin/xsel" + }, + { + "id": "daemon-dropins", + "type": "directory", + "path": "${machine:account-home}/.config/systemd/user/clipmenud.service.d", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "daemon-dropin", + "type": "file", + "path": "${machine:account-home}/.config/systemd/user/clipmenud.service.d/mesh.conf", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/systemd/clipmenud-mesh.conf" + }, + { + "id": "launcher", + "type": "file", + "path": "/usr/local/lib/mesh-clipmenu/clipmenud-session", + "mode": "0755", + "template": "files/bin/clipmenud-session" + }, + { + "id": "daemon", + "type": "service", + "unit": "clipmenud.service", + "scope": "user", + "user": "${machine:account}", + "state": "running", + "boot": "enabled", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/clipmenu-tools", + "binary": "clipmenu-tools", + "loads": [ + "clipmenu-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/confluence/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/confluence/module.json.captured new file mode 100644 index 0000000..caa0d1b --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/confluence/module.json.captured @@ -0,0 +1,76 @@ +{ + "module": "confluence", + "version": "1", + "slug": "confl", + "own-secrets": { + "token": "${dir:state}/token" + }, + "config": { + "atlassian-url": { + "type": "string", + "description": "The address of the operator's Atlassian site the confluence tools call, as a URL such as https://example.atlassian.net, laid into the file as ATLASSIAN_URL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + }, + "atlassian-email": { + "type": "string", + "description": "The email address of the Atlassian account the confluence tools sign in as with its API token, laid into the file as ATLASSIAN_EMAIL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "config", + "type": "file", + "path": "${dir:state}/config.json", + "merge": "json", + "template": "files/config.json", + "mode": "0600", + "takes-config": [ + { + "key": "atlassian-url", + "as": "ATLASSIAN_URL" + }, + { + "key": "atlassian-email", + "as": "ATLASSIAN_EMAIL" + } + ] + } + ], + "capabilities": [ + "container-runtime" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_CONFLUENCE_TOKEN_FILE": "${dir:state}/token", + "MESH_CONFLUENCE_CONFIG_FILE": "${dir:state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/cups/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/cups/module.json.captured new file mode 100644 index 0000000..01a4822 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/cups/module.json.captured @@ -0,0 +1,58 @@ +{ + "module": "cups", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "cups_printers", + "cups_queue", + "cups_cancel", + "cups_print", + "cups_default", + "cups_resume", + "cups_drivers" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "cups" + }, + { + "id": "driverless", + "type": "package", + "package": "cups-filters" + }, + { + "id": "socket", + "type": "service", + "unit": "cups.socket", + "state": "running", + "boot": "enabled" + }, + { + "id": "scheduler", + "type": "service", + "unit": "cups.service", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/cups-tools", + "binary": "cups-tools", + "loads": [ + "cups-tools" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/dbus/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/dbus/module.json.captured new file mode 100644 index 0000000..c0e28b3 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/dbus/module.json.captured @@ -0,0 +1,67 @@ +{ + "module": "dbus", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "node-message-bus", + "scope": "node" + } + ], + "emits": [ + "bus.stalled", + "bus.recovered", + "bus.restarted", + "service.appeared", + "service.left", + "policy.denied" + ], + "tools": [ + "dbus_names", + "dbus_introspect", + "dbus_monitor", + "dbus_check", + "dbus_health" + ], + "resources": [ + { + "id": "dbus", + "type": "package", + "package": "dbus" + }, + { + "id": "broker", + "type": "package", + "package": "dbus-broker" + }, + { + "id": "broker-units", + "type": "package", + "package": "dbus-broker-units" + }, + { + "id": "system-bus", + "type": "service", + "unit": "dbus-broker.service", + "state": "running" + } + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/dbus-tools", + "binary": "dbus-tools", + "loads": [ + "dbus-tools" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/desk-channel/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/desk-channel/module.json.captured new file mode 100644 index 0000000..115ce62 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/desk-channel/module.json.captured @@ -0,0 +1,106 @@ +{ + "module": "desk-channel", + "version": "1", + "slug": "desk", + "runs-as": "desk-channel", + "claims": [ + { + "name": "channel", + "scope": "mesh", + "kind": "desktop", + "capabilities": [ + "deliver", + "silent", + "loud", + "edit", + "private", + "choice", + "exact-render" + ] + }, + { + "name": "intake", + "scope": "mesh", + "kind": "desktop" + } + ], + "consumes": [ + "dunst.action-chosen", + "dunst.started", + "dunst.paused" + ], + "invokes": [ + "seat:node-notifier.send" + ], + "state": [ + { + "name": "shown", + "ttl-seconds": 2592000 + } + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "desk-channel", + "tools": [ + "desk_channel_status" + ], + "config": { + "desktop-machines": { + "type": "list", + "description": "The machines, by node name, whose desktop shows the operator's questions and notifications, in the order they are tried; empty shows them on none.", + "trusted": true, + "default": [] + } + }, + "resources": [ + { + "id": "account", + "type": "user", + "name": "desk-channel", + "shell": "/usr/bin/nologin", + "home": "/var/lib/desk-channel" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "desk-channel", + "place": ".", + "unknown": "program" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "owner": "desk-channel", + "takes-config": ["desktop-machines"], + "merge": "json", + "template": "files/settings.json" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/desk-channel", + "binary": "desk-channel", + "loads": [ + "desk-channel" + ], + "env": { + "MESH_DESK_SETTINGS": "${dir:state}/settings.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/dev-tools/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/dev-tools/module.json.captured new file mode 100644 index 0000000..094f833 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/dev-tools/module.json.captured @@ -0,0 +1,38 @@ +{ + "module": "dev-tools", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "go-toolchain", + "node-toolchain" + ], + "resources": [ + { + "id": "c-compiler", + "type": "package", + "package": "gcc" + }, + { + "id": "make", + "type": "package", + "package": "make" + }, + { + "id": "python", + "type": "package", + "package": "python" + }, + { + "id": "git", + "type": "package", + "package": "git" + }, + { + "id": "jq", + "type": "package", + "package": "jq" + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/disk-load/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/disk-load/module.json.captured new file mode 100644 index 0000000..e50253e --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/disk-load/module.json.captured @@ -0,0 +1,26 @@ +{ + "module": "disk-load", + "version": "1", + "tools": [ + "disk_load", + "disk_top", + "disk_filesystems", + "disk_devices", + "cpu_top" + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/disk-load", + "binary": "disk-load", + "loads": [ + "disk-load" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/distribution/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/distribution/module.json.captured new file mode 100644 index 0000000..eb9f7c2 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/distribution/module.json.captured @@ -0,0 +1,96 @@ +{ + "module": "distribution", + "version": "1", + "provides": [ + { + "name": "artifact-store", + "scope": "mesh" + } + ], + "claims": [ + { + "name": "mesh-artifact-store", + "scope": "mesh" + } + ], + "capabilities": [ + "container-runtime" + ], + "own-secrets": { + "broker": "/var/lib/mesh/registry/broker" + }, + "serves": { + "artifact-store": { + "port": 5000 + } + }, + "listens": [ + { + "name": "registry", + "port": 5000, + "protocol": "tcp", + "from": "mesh", + "why": "every machine pulls images and artifacts from here" + } + ], + "data": { + "own": [ + { + "id": "registry", + "path": "${dir:registry-data}", + "class": "rebuildable", + "backup": "none", + "measure": "shallow", + "why": "the artifact store: every image is built again from its source, and too large to copy every night (ADR 0214 left it out)" + } + ] + }, + "resources": [ + { + "id": "state", + "type": "directory", + "path": "/var/lib/mesh/registry", + "mode": "0700", + "unknown": "program" + }, + { + "id": "registry-data", + "type": "directory", + "path": "/var/lib/mesh-registry", + "mode": "0700", + "unknown": "program" + }, + { + "id": "store", + "type": "container", + "name": "mesh-registry", + "image": "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373", + "ports": [ + "5000:5000" + ], + "volumes": [ + "/var/lib/mesh-registry:/var/lib/registry" + ], + "env": { + "REGISTRY_STORAGE_DELETE_ENABLED": "true" + } + }, + { + "id": "collect", + "type": "container", + "name": "mesh-registry-collect", + "image": "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373", + "volumes": [ + "/var/lib/mesh-registry:/var/lib/registry" + ], + "args": [ + "garbage-collect", + "/etc/docker/registry/config.yml" + ], + "schedule": "30 3 * * *", + "while-stopped": [ + "store" + ] + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/dmenu/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/dmenu/module.json.captured new file mode 100644 index 0000000..aeeb3ce --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/dmenu/module.json.captured @@ -0,0 +1,33 @@ +{ + "module": "dmenu", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "dmenu_menu", + "dmenu_session" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "dmenu" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/dmenu-tools", + "binary": "dmenu-tools", + "loads": [ + "dmenu-tools" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/dnsmasq/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/dnsmasq/module.json.captured new file mode 100644 index 0000000..58cc0da --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/dnsmasq/module.json.captured @@ -0,0 +1,162 @@ +{ + "module": "dnsmasq", + "version": "1", + "upgrade": { + "policy": "record", + "why": "the mesh's resolver: a build that breaks it can stop a machine resolving the bus, and then neither the gate's rollback nor a push reaches it (hq ADR 0236, issue 260)" + }, + "provides": [ + { + "name": "wildcard-resolution", + "scope": "mesh", + "identity": false + } + ], + "requires": [ + "mesh-addressing" + ], + "emits": [ + "name.added", + "name.removed", + "restarted", + "restart-judged" + ], + "own-secrets": { + "broker": "${dir:mesh-state}/broker" + }, + "claims": [ + { + "name": "mesh-dns-resolver", + "scope": "mesh" + } + ], + "tools": [ + "dnsmasq_health", + "dnsmasq_names", + "dnsmasq_resolve" + ], + "listens": [ + { + "name": "dns-udp", + "port": 53, + "protocol": "udp", + "from": "mesh", + "why": "one of the mesh's resolvers (ADR 0194, 0223): every node's and every container's resolver, beside any other holder of the seat — the mesh's own names answered here, a module's zone forwarded to it, the rest forwarded upstream", + "fixed": true + }, + { + "name": "dns-tcp", + "port": 53, + "protocol": "tcp", + "from": "mesh", + "why": "the same names over tcp, which a resolver answers on as well and is asked for whenever an answer will not fit in a datagram. Declared because the daemon serves it: a declaration that covers one of the two protocols its own service listens on leaves the other closed while everything reports success", + "fixed": true + } + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "package", + "type": "package", + "package": "dnsmasq" + }, + { + "id": "config", + "type": "file", + "path": "/etc/dnsmasq.conf", + "mode": "0644", + "template": "files/dnsmasq.conf" + }, + { + "id": "drop-ins", + "type": "directory", + "path": "/etc/systemd/system/dnsmasq.service.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "wait-for-address", + "type": "file", + "path": "/etc/systemd/system/dnsmasq.service.d/mesh.conf", + "mode": "0644", + "template": "files/mesh.conf" + }, + { + "id": "service", + "type": "service", + "unit": "dnsmasq.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "config", + "wait-for-address", + "dnsmasq.fact-node-zones", + "dnsmasq.fact-zones" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "watch-dir", + "type": "directory", + "path": "/var/lib/dnsmasq-watch", + "mode": "0755", + "unknown": "program" + }, + { + "id": "watch", + "type": "process", + "name": "dnsmasq-watch", + "artifact": "tools", + "run": [ + "./dnsmasq-tools", + "watch" + ], + "health": { + "kind": "tool", + "tool": "dnsmasq_health", + "interval": "20s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + } + } + ], + "facts": { + "node-zones": { + "path": "/etc/mesh-resolver/nodes.conf", + "template": "# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n# joins or leaves, and an edit would survive until then and vanish.\n\nlocal=/{{.Suffix}}/\n# A machine's own name is a host record as well as a wildcard: asked for an IPv6 address, the\n# resolver then says the name exists and has none, where the wildcard alone said there is no such\n# name — and a resolver that reads that as final (musl, so every Alpine container) failed to find\n# the machine at all (novox/hq issue 262).\n{{range .Machines}}address=/{{.FQDN}}/{{.Address}}\nhost-record={{.FQDN}},{{.Address}}\n{{end}}" + }, + "zones": { + "path": "/etc/mesh-resolver/zones.conf", + "template": "# Generated by the mesh. Do not edit — this file is replaced whenever a module declares a zone or\n# its machine moves (novox/hq ADR 0199). One forwarding line per zone, to the module answering it.\n\n{{range .Zones}}server=/{{.Zone}}/{{.Address}}#{{.Port}}\n{{end}}" + } + }, + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/dnsmasq-tools", + "binary": "dnsmasq-tools", + "loads": [ + "dnsmasq-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/docker-compose/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/docker-compose/module.json.captured new file mode 100644 index 0000000..2f576b4 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/docker-compose/module.json.captured @@ -0,0 +1,40 @@ +{ + "module": "docker-compose", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "docker_compose_projects", + "docker_compose_ps", + "docker_compose_logs", + "docker_compose_config", + "docker_compose_up", + "docker_compose_down", + "docker_compose_restart", + "docker_compose_pull", + "docker_compose_job" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "docker-compose" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/docker-compose-tools", + "binary": "docker-compose-tools", + "loads": [ + "docker-compose-tools" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/docker/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/docker/module.json.captured new file mode 100644 index 0000000..a3a75e0 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/docker/module.json.captured @@ -0,0 +1,179 @@ +{ + "module": "docker", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager", + "privileged" + ], + "invokes": [ + "seat:mesh-controller.images" + ], + "claims": [ + { + "name": "node-container-runtime", + "scope": "node" + } + ], + "tools": [ + "docker_list", + "docker_inspect", + "docker_resolv_conf", + "docker_logs", + "docker_secrets_in_logs", + "docker_secrets_in_events", + "docker_stats", + "docker_start", + "docker_stop", + "docker_restart", + "docker_top", + "docker_images", + "docker_prune", + "docker_prune_images", + "docker_disk_usage", + "docker_networks", + "docker_volumes", + "docker_events", + "docker_daemon_config", + "docker_unlabelled", + "docker_problems", + "docker_ports" + ], + "replaces": { + "docker_resolv_conf": [ + "docker exec cat /etc/resolv.conf" + ] + }, + "config": { + "log-max-size": { + "type": "string", + "description": "The size each container's log is cut at, a whole number from 1 with k, m or g after it; the runtime keeps log-max-file of them, so 100m and 10 hold about 1 GB per container (novox/hq issue 452). The runtime reads it only at its next start, which the mesh never forces, and a container created before that keeps the options it was created with until it is created again.", + "pattern": "[1-9][0-9]*[kmg]", + "trusted": true, + "default": "100m" + }, + "log-max-file": { + "type": "number", + "description": "How many of a container's log files of log-max-size the runtime keeps, a count from 1 to 99, the oldest dropped first; taken at the runtime's next start and by containers created after it, like log-max-size (novox/hq issue 452).", + "minimum": 1, + "maximum": 99, + "trusted": true, + "default": 10 + } + }, + "resources": [ + { + "id": "package", + "type": "package", + "package": "docker" + }, + { + "id": "buildx", + "type": "package", + "package": "docker-buildx" + }, + { + "id": "socket", + "type": "service", + "unit": "docker.socket", + "state": "running", + "boot": "enabled" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "log-rotation", + "type": "file", + "path": "${dir:state}/log-rotation.conf", + "mode": "0600", + "trusted": true, + "template": "files/log-rotation.conf" + }, + { + "id": "log-rotation-check", + "type": "process", + "name": "docker-log-rotation-check", + "artifact": "tools", + "run": [ + "./docker-tools", + "check-log-rotation" + ], + "run-once": true, + "env": { + "DOCKER_LOG_ROTATION": "${dir:state}/log-rotation.conf" + }, + "restart-on": [ + "log-rotation" + ] + }, + { + "id": "daemon", + "type": "file", + "path": "/etc/docker/daemon.json", + "mode": "0644", + "into": "json", + "trusted": true, + "template": "files/daemon.json" + }, + { + "id": "runtime", + "type": "service", + "unit": "docker.service", + "state": "running", + "boot": "enabled", + "reload-on": [ + "daemon" + ] + }, + { + "id": "prune-service", + "type": "file", + "path": "/etc/systemd/system/docker-prune.service", + "mode": "0644", + "template": "files/docker-prune.service" + }, + { + "id": "prune-timer", + "type": "file", + "path": "/etc/systemd/system/docker-prune.timer", + "mode": "0644", + "template": "files/docker-prune.timer" + }, + { + "id": "prune", + "type": "service", + "unit": "docker-prune.timer", + "state": "running", + "boot": "enabled", + "restart-on": [ + "prune-service", + "prune-timer" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/docker-tools", + "binary": "docker-tools", + "loads": [ + "docker-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/dunst/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/dunst/module.json.captured new file mode 100644 index 0000000..0c6983b --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/dunst/module.json.captured @@ -0,0 +1,123 @@ +{ + "module": "dunst", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "claims": [ + { + "name": "node-notifier", + "scope": "node", + "serves": [ + "send", + "history" + ] + } + ], + "tools": [ + "dunst_pause", + "dunst_resume", + "dunst_close_all", + "dunst_rules", + "dunst_count", + "dunst_reload" + ], + "config": { + "font-size": { + "type": "number", + "description": "The size of a notification's text, in points of Inter: 10 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.", + "minimum": 6, + "maximum": 72, + "predefined": "font-size", + "default": 10 + }, + "width": { + "type": "number", + "description": "The width of a notification, in pixels: 250 fits a title of about forty characters at 10 points, and a larger font needs a wider notification.", + "minimum": 50, + "maximum": 4000, + "default": 250 + } + }, + "resources": [ + { + "id": "package", + "type": "package", + "package": "dunst" + }, + { + "id": "client", + "type": "package", + "package": "libnotify" + }, + { + "id": "configuration-dir", + "type": "directory", + "path": "${machine:account-home}/.config/dunst", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "dropins", + "type": "directory", + "path": "${machine:account-home}/.config/dunst/dunstrc.d", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out", + "knows": ["50-slack.conf"] + }, + { + "id": "configuration", + "trusted": false, + "type": "file", + "path": "${machine:account-home}/.config/dunst/dunstrc", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/dunstrc" + }, + { + "id": "notifier", + "type": "service", + "unit": "dunst.service", + "scope": "user", + "user": "${machine:account}", + "reload-on": [ + "configuration" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/dunst-tools", + "binary": "dunst-tools", + "loads": [ + "dunst-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "emits": [ + "action-chosen", + "started", + "paused" + ], + "items": [ + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/50-dunst.conf" + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/fail2ban/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/fail2ban/module.json.captured new file mode 100644 index 0000000..446395f --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/fail2ban/module.json.captured @@ -0,0 +1,195 @@ +{ + "module": "fail2ban", + "version": "1", + "capabilities": [ + "firewall" + ], + "claims": [ + { + "name": "node-intrusion-prevention", + "scope": "node", + "serves": [ + "status", + "banned", + "ban", + "unban" + ] + } + ], + "tools": [ + "fail2ban_settings", + "fail2ban_health" + ], + "jailing": { + "into": "/etc/fail2ban/jail.d/mesh.conf", + "filter-into": "/etc/fail2ban/filter.d" + }, + "config": { + "own-addresses": { + "type": "list", + "description": "The addresses and networks outside the mesh that are the operator's own, each an IP address or a CIDR network: every jail ignores them beside the private ranges, the ban verb refuses them, and a failure a jail counted from one is said as a warning; empty is none.", + "trusted": true, + "default": [] + } + }, + "resources": [ + { + "id": "package", + "type": "package", + "package": "fail2ban" + }, + { + "id": "state-dir", + "type": "directory", + "path": "/var/lib/mesh-fail2ban", + "mode": "0755", + "unknown": "program" + }, + { + "id": "own-addresses-list", + "type": "file", + "path": "/var/lib/mesh-fail2ban/own-addresses", + "mode": "0644", + "create-once": true, + "template": "files/own-addresses" + }, + { + "id": "jail-d", + "type": "directory", + "path": "/etc/fail2ban/jail.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "action-d", + "type": "directory", + "path": "/etc/fail2ban/action.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "filter-d", + "type": "directory", + "path": "/etc/fail2ban/filter.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "run-dir", + "type": "directory", + "path": "/var/run/fail2ban", + "mode": "0755", + "unknown": "program" + }, + { + "id": "jail-local", + "type": "file", + "path": "/etc/fail2ban/jail.local", + "mode": "0644", + "template": "files/jail.local" + }, + { + "id": "jail-sshd", + "type": "file", + "path": "/etc/fail2ban/jail.d/sshd.conf", + "mode": "0644", + "template": "files/sshd.conf" + }, + { + "id": "own-addresses", + "type": "file", + "path": "/etc/fail2ban/mesh-own-addresses.json", + "mode": "0644", + "merge": "json", + "takes-config": [ + "own-addresses" + ], + "trusted": true, + "template": "files/mesh-own-addresses.json" + }, + { + "id": "watch", + "type": "process", + "name": "fail2ban-watch", + "artifact": "tools", + "run": [ + "./fail2ban-tools", + "watch" + ], + "health": { + "kind": "tool", + "tool": "fail2ban_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "90s" + } + }, + { + "id": "log", + "type": "file", + "path": "/var/log/fail2ban.log", + "mode": "0640", + "create-once": true, + "template": "files/own-addresses" + }, + { + "id": "jail-recidive", + "type": "file", + "path": "/etc/fail2ban/jail.d/recidive.conf", + "mode": "0644", + "template": "files/recidive.conf" + }, + { + "id": "action-dualchain", + "type": "file", + "path": "/etc/fail2ban/action.d/iptables-allports-dualchain.conf", + "mode": "0644", + "template": "files/iptables-allports-dualchain.conf" + }, + { + "id": "logrotate", + "type": "file", + "path": "/etc/logrotate.d/fail2ban", + "mode": "0644", + "template": "files/fail2ban" + }, + { + "id": "run", + "type": "service", + "unit": "fail2ban.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "jail-local", + "jail-sshd", + "jail-recidive", + "action-dualchain", + "composed-jails" + ], + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/fail2ban-tools", + "binary": "fail2ban-tools", + "loads": [ + "fail2ban-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/feh/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/feh/module.json.captured new file mode 100644 index 0000000..d6e4407 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/feh/module.json.captured @@ -0,0 +1,75 @@ +{ + "module": "feh", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "tools": [ + "feh_set", + "feh_current" + ], + "items": [ + { + "collection": "shell:xinitrc:normal", + "name": "xinitrc-normal", + "template": "files/xinitrc-normal" + }, + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/50-feh.conf" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "feh" + }, + { + "id": "wallpapers", + "type": "archive", + "path": "${machine:account-home}/.local/share/feh/wallpapers", + "owner": "${machine:account}", + "artifact": "wallpapers", + "unknown": "move-out" + }, + { + "id": "fehbg", + "type": "file", + "path": "${machine:account-home}/.fehbg", + "owner": "${machine:account}", + "mode": "0755", + "template": "files/fehbg" + } + ], + "build": { + "artifacts": [ + { + "name": "wallpapers", + "kind": "archive", + "from": "wallpaper" + }, + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/feh-tools", + "binary": "feh-tools", + "loads": [ + "feh-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/flatpak/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/flatpak/module.json.captured new file mode 100644 index 0000000..76d4721 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/flatpak/module.json.captured @@ -0,0 +1,42 @@ +{ + "module": "flatpak", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "flatpak_list", + "flatpak_runtimes", + "flatpak_remotes", + "flatpak_updates", + "flatpak_unused", + "flatpak_disk_usage", + "flatpak_install", + "flatpak_remove", + "flatpak_update", + "flatpak_remove_unused", + "flatpak_job" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "flatpak" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/flatpak-tools", + "binary": "flatpak-tools", + "loads": [ + "flatpak-tools" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/fonts/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/fonts/module.json.captured new file mode 100644 index 0000000..b2958da --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/fonts/module.json.captured @@ -0,0 +1,70 @@ +{ + "module": "fonts", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "fonts_families", + "fonts_match", + "fonts_glyph", + "fonts_sources", + "fonts_config", + "fonts_cache_rebuild" + ], + "resources": [ + { + "id": "monospace", + "type": "package", + "package": "ttf-jetbrains-mono-nerd" + }, + { + "id": "interface", + "type": "package", + "package": "inter-font" + }, + { + "id": "symbols", + "type": "package", + "package": "ttf-nerd-fonts-symbols" + }, + { + "id": "noto", + "type": "package", + "package": "noto-fonts" + }, + { + "id": "emoji", + "type": "package", + "package": "noto-fonts-emoji" + }, + { + "id": "defaults", + "type": "file", + "path": "${machine:account-home}/.config/fontconfig/conf.d/50-mesh-fonts.conf", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/50-mesh-fonts.conf" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/fonts-tools", + "binary": "fonts-tools", + "loads": [ + "fonts-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/forticlient/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/forticlient/module.json.captured new file mode 100644 index 0000000..631d3fa --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/forticlient/module.json.captured @@ -0,0 +1,57 @@ +{ + "module": "forticlient", + "version": "1", + "upgrade": { + "policy": "record", + "why": "its adapter routes the company's domains on the machine a person works on: a build that breaks it cuts the person off from work names until a person pushes the next (hq ADR 0236, ADR 0247)" + }, + "capabilities": [ + "service-manager" + ], + "requires": [ + "x11-display", + "split-dns" + ], + "tools": [ + "forticlient_status", + "forticlient_restart", + "forticlient_check" + ], + "resources": [ + { + "id": "scheduler", + "type": "service", + "unit": "forticlient.service", + "state": "running", + "boot": "enabled" + }, + { + "id": "split-dns", + "type": "process", + "name": "forticlient-split-dns", + "artifact": "tools", + "run": [ + "./forticlient-tools", + "split-dns" + ], + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/forticlient-tools", + "binary": "forticlient-tools", + "loads": [ + "forticlient-tools" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/gitea/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/gitea/module.json.captured new file mode 100644 index 0000000..118224c --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/gitea/module.json.captured @@ -0,0 +1,547 @@ +{ + "module": "gitea", + "version": "1", + "moved": true, + "requires": [ + "postgres-database", + "route", + "secret" + ], + "contributes": { + "postgres-database": { + "name": "gitea" + }, + "route": { + "web": { + "label": "git", + "endpoint": "web" + }, + "internal-api-refused": { + "label": "git", + "path": "/api/internal", + "deny": true, + "priority": 100000 + } + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret", + "secret": { + "internal-token": "${dir:state}/internal-token.secret", + "admin": "${dir:state}/admin.secret" + } + }, + "capabilities": [ + "container-runtime" + ], + "emits": [ + "repo.created", + "issue.opened", + "pull.merged", + "pull.updated", + "pull.closed", + "forge.push", + "forge.create", + "forge.delete", + "forge.fork", + "forge.status", + "forge.pull-request.opened", + "forge.pull-request.reopened", + "forge.pull-request.synchronized", + "forge.pull-request.edited", + "forge.pull-request.closed", + "forge.pull-request.merged", + "forge.pull-request.labeled", + "forge.pull-request.unlabeled", + "forge.pull-request.assigned", + "forge.pull-request.unassigned", + "forge.pull-request.reviewed", + "forge.pull-request.review-requested", + "forge.pull-request.commented", + "forge.pull-request.changed", + "forge.issue-comment.created", + "forge.issue-comment.edited", + "forge.issue-comment.deleted", + "forge.issues.opened", + "forge.issues.reopened", + "forge.issues.closed", + "forge.issues.edited", + "forge.issues.labeled", + "forge.issues.unlabeled", + "forge.issues.assigned", + "forge.issues.unassigned", + "forge.issues.changed", + "forge.release.published", + "forge.release.updated", + "forge.release.deleted", + "forge.release.changed", + "forge.repository.created", + "forge.repository.deleted", + "forge.repository.changed", + "forge.wiki.changed", + "forge.other" + ], + "consumes": [ + "mesh-controller.checked", + "node-build-agent.started" + ], + "listens": [ + { + "name": "web", + "port": 3000, + "protocol": "tcp", + "from": "mesh", + "why": "the forge, over http" + }, + { + "name": "ssh", + "port": 22, + "protocol": "tcp", + "from": "mesh", + "why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention — not 22, which the machine's own daemon holds and a module does not take" + }, + { + "name": "forge-events", + "port": 3047, + "protocol": "tcp", + "from": "machine", + "why": "the forge's system webhook, every event signed with a secret the module keeps, from the forge's container on the docker bridge to the module's receiver at the bridge's gateway 172.17.0.1 (novox/hq issue 729)" + } + ], + "serves": { + "npm-package-registry": { + "scheme": "http", + "port": 3000, + "npm-path": "/api/packages/novox/npm/", + "public-root": "https://${bound:route:name-web}/api/packages/novox/npm/" + }, + "git": { + "scheme": "http", + "port": 3000 + } + }, + "receives": { + "npm-package-registry": "${dir:grants}/npm.json" + }, + "grants": { + "npm-package-registry": "${dir:grants}" + }, + "claims": [ + { + "name": "npm-package-registry", + "scope": "mesh" + }, + { + "name": "git", + "scope": "mesh" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "why": "every repository, its issues and attachments, and the package registry" + } + ], + "consumers": { + "npm-package-registry": { + "class": "rebuildable", + "in": "data", + "why": "a consumer's packages are published again from its source" + } + } + }, + "config": { + "url": { + "type": "string", + "description": "The address the module's tools reach the forge at, as a URL; unset, the code falls back to its environment, else this machine's gitea on its port.", + "trusted": true, + "default": "" + }, + "agent-read-teams": { + "type": "object", + "description": "The read teams of the agents' forge accounts (ADR 0365 clause 3): each team by name, {\"\": {\"repos\": [\"/\", ...], \"members\": [\"\", ...]}}. The forge gets a team agent-read- that reads only, with exactly those repositories and the accounts agent-; a team taken out of the value is emptied. Nothing else on the forge is touched; an agent account is in no team until it is listed here.", + "trusted": true, + "default": {} + }, + "repositories": { + "type": "object", + "description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push and the tools' merge requiring mesh/repo-check and mesh/merge-gate, and only then moved into the organisation; one that exists only has main's rule put right, its statuses kept. A repository taken out of the value is never touched.", + "trusted": true, + "default": {} + } + }, + "facts": { + "forge-host": { + "path": "/etc/gitea/forge-host", + "template": "{{range .Machines}}{{if eq .Name $.Node}}{{.FQDN}}{{end}}{{end}}\n" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "runtime-state", + "type": "directory", + "path": "${dir:mesh-state}/state", + "mode": "0700", + "unknown": "program" + }, + { + "id": "forge-patches", + "type": "directory", + "path": "/var/lib/mesh/forge-patches", + "mode": "0733", + "owner": "${machine:account}", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "server-env", + "type": "file", + "path": "${dir:state}/server.env", + "mode": "0600", + "template": "files/server.env" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "1000:1000", + "unknown": "program" + }, + { + "id": "server", + "type": "container", + "health": { + "kind": "http", + "endpoint": "web", + "path": "/api/healthz", + "status": 200, + "needs": "postgres-database" + }, + "name": "gitea", + "image": "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef", + "env": { + "DB_TYPE": "postgres", + "USER_UID": "1000", + "USER_GID": "1000" + }, + "env-file": [ + "${dir:state}/server.env" + ], + "ports": [ + "3000", + "222:22" + ], + "volumes": [ + "${dir:data}:/data" + ], + "secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it", + "logging": "journald" + }, + { + "id": "admin-bootstrap", + "type": "container", + "name": "mesh-gitea-admin", + "image": "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef", + "run-once": true, + "env": { + "USER_UID": "1000", + "USER_GID": "1000", + "MESH_GITEA_ADMIN_USER": "mesh-admin" + }, + "env-file": [ + "${dir:state}/server.env" + ], + "volumes": [ + "${dir:data}:/data", + "${dir:state}/admin.secret:/run/secrets/admin:ro" + ], + "args": [ + "/bin/sh", + "-c", + "su-exec git gitea admin user create --admin --username \"$MESH_GITEA_ADMIN_USER\" --email mesh-admin@localhost --password \"$(cat /run/secrets/admin)\" --must-change-password=false || true" + ], + "secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it" + }, + { + "id": "origins", + "type": "file", + "path": "${dir:mesh-state}/origins", + "mode": "0644", + "template": "files/origins" + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": [ + "url" + ] + }, + { + "id": "agent-teams-file", + "type": "file", + "path": "${dir:mesh-state}/agent-teams.json", + "mode": "0600", + "template": "files/agent-teams.json", + "merge": "json", + "takes-config": [ + "agent-read-teams" + ] + }, + { + "id": "repositories-file", + "type": "file", + "path": "${dir:mesh-state}/repositories.json", + "mode": "0600", + "template": "files/repositories.json" + }, + { + "id": "consumers", + "type": "file", + "path": "${dir:state}/consumers.json", + "mode": "0600", + "template": "files/consumers.json" + }, + { + "id": "agent-teams", + "type": "process", + "name": "gitea-agent-teams", + "artifact": "agent-teams", + "run": [ + "./agent-teams" + ], + "run-once": true, + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_AGENT_TEAMS_FILE": "${dir:mesh-state}/agent-teams.json" + }, + "restart-on": [ + "agent-teams-file" + ] + }, + { + "id": "repositories", + "type": "process", + "name": "gitea-repositories", + "artifact": "repositories", + "run": [ + "./repositories", + "sync" + ], + "run-once": true, + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_GITEA_TOOLS_USER": "mesh-tools", + "MESH_REPOSITORIES_FILE": "${dir:mesh-state}/repositories.json", + "MESH_REPOSITORIES_OUTCOME": "${dir:runtime-state}/repositories-outcome.json" + }, + "restart-on": [ + "repositories-file" + ] + } + ], + "provides": [ + { + "name": "npm-package-registry", + "scope": "mesh", + "identity": { + "max": 40, + "in": "a Gitea user name" + } + }, + { + "name": "git", + "scope": "mesh" + }, + { + "name": "gitea-read", + "scope": "mesh", + "credential": { + "issued-by": "software", + "tool": "gitea_agent_token" + } + }, + { + "name": "forge-account", + "scope": "mesh" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_GITEA_TOOLS_USER": "mesh-tools", + "MESH_GITEA_STATE_DIR": "${dir:runtime-state}", + "MESH_RECEIVES": "${dir:grants}/npm.json", + "MESH_GITEA_HOOK_URL": "http://172.17.0.1:3047", + "MESH_GITEA_HOOK_PORT": "3047", + "MESH_GITEA_HOOK_HOST": "172.17.0.1" + } + }, + { + "name": "npm-registry", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/npm-registry", + "binary": "npm-registry", + "loads": [ + "npm-registry" + ], + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_NPM_OWNER": "novox" + } + }, + { + "name": "forge", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/forge", + "binary": "forge", + "loads": [ + "forge" + ], + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_TOOLS_USER": "mesh-tools", + "MESH_GITEA_TOKEN_FILE": "${dir:runtime-state}/token", + "MESH_GITEA_PATCH_DIR": "${dir:forge-patches}" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + }, + { + "name": "agent-token", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/agent-token", + "binary": "agent-token", + "loads": [ + "agent-token" + ], + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_GITEA_ORIGINS_FILE": "${dir:mesh-state}/origins" + } + }, + { + "name": "agent-teams", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/agent-teams", + "binary": "agent-teams", + "loads": [ + "agent-teams" + ] + }, + { + "name": "repositories", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/repositories", + "binary": "repositories", + "loads": [ + "repositories" + ] + }, + { + "name": "forge-account-provider", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/forge-account-provider", + "binary": "forge-account-provider", + "loads": [ + "forge-account-provider" + ], + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_GITEA_ORIGINS_FILE": "${dir:mesh-state}/origins", + "MESH_GITEA_SSH_PORT": "222", + "MESH_FORGE_HOST_FILE": "/etc/gitea/forge-host" + } + } + ] + }, + "jails": [ + { + "name": "gitea", + "failregex": "^.*Failed authentication attempt for .* from (?::\\d+)?\\s*$\n ^.*Invalid user .* from port \\d+\\s*$\n ^.*User \\S+ from not allowed because .*$", + "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d" + } + ], + "state": [ + "bound", + "announced" + ], + "reads": [ + "mesh-controller.assignments", + "ssh-client.keys", + "mesh-build-go.keys", + "mesh-build-node.keys" + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/gitlab/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/gitlab/module.json.captured new file mode 100644 index 0000000..3cb33fb --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/gitlab/module.json.captured @@ -0,0 +1,65 @@ +{ + "module": "gitlab", + "version": "1", + "own-secrets": { + "token": "${dir:state}/token" + }, + "config": { + "gitlab-url": { + "type": "string", + "description": "The address of the GitLab instance the gitlab tools call, as a URL such as https://gitlab.com, laid into the file as GITLAB_URL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "config", + "type": "file", + "path": "${dir:state}/config.json", + "merge": "json", + "template": "files/config.json", + "mode": "0600", + "takes-config": [ + { + "key": "gitlab-url", + "as": "GITLAB_URL" + } + ] + } + ], + "capabilities": [ + "container-runtime" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_GITLAB_TOKEN_FILE": "${dir:state}/token", + "MESH_GITLAB_CONFIG_FILE": "${dir:state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/gnome-keyring/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/gnome-keyring/module.json.captured new file mode 100644 index 0000000..9f75b9f --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/gnome-keyring/module.json.captured @@ -0,0 +1,82 @@ +{ + "module": "gnome-keyring", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "claims": [ + { + "name": "node-secret-service", + "scope": "node" + } + ], + "tools": [ + "gnome_keyring_unlocked", + "gnome_keyring_lock", + "gnome_keyring_collections", + "gnome_keyring_ssh_keys" + ], + "items": [ + { + "collection": "shell:xinitrc:first", + "name": "xinitrc-first", + "template": "files/xinitrc-first" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "gnome-keyring" + }, + { + "id": "library", + "type": "package", + "package": "libsecret" + }, + { + "id": "manager", + "type": "package", + "package": "seahorse" + }, + { + "id": "pam-login", + "type": "file", + "path": "/etc/pam.d/login", + "mode": "0644", + "into": "block", + "at": "end", + "template": "files/pam/login" + }, + { + "id": "pam-passwd", + "type": "file", + "path": "/etc/pam.d/passwd", + "mode": "0644", + "into": "block", + "at": "end", + "template": "files/pam/passwd" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/gnome-keyring-tools", + "binary": "gnome-keyring-tools", + "loads": [ + "gnome-keyring-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/go/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/go/module.json.captured new file mode 100644 index 0000000..48a6bf5 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/go/module.json.captured @@ -0,0 +1,20 @@ +{ + "module": "go", + "version": "1", + "capabilities": [ + "package-manager" + ], + "provides": [ + { + "name": "go-toolchain", + "reach": "machine" + } + ], + "resources": [ + { + "id": "go", + "type": "package", + "package": "go" + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/grafana/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/grafana/module.json.captured new file mode 100644 index 0000000..1cbf995 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/grafana/module.json.captured @@ -0,0 +1,225 @@ +{ + "module": "grafana", + "version": "1", + "emits": [ + "alert.firing" + ], + "own-secrets": { + "admin": "${dir:mesh-state}/admin" + }, + "capabilities": [ + "container-runtime" + ], + "listens": [ + { + "name": "web", + "port": 3000, + "protocol": "tcp", + "from": "mesh", + "why": "the dashboards. Also 3000 inside, like the forge - which is the mesh's port assignment earning its keep" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "why": "dashboards, users and alert rules" + } + ] + }, + "config": { + "user": { + "type": "string", + "description": "The Grafana account the module's tools sign in as with the vault's password; the file's default is admin.", + "trusted": true, + "default": "admin" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach Grafana at, as a URL; unset, the code falls back to its environment, else this machine's Grafana on its port.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "472:472", + "unknown": "program" + }, + { + "id": "admin-secret", + "type": "file", + "path": "${dir:state}/admin.secret", + "mode": "0400", + "owner": "472:472", + "template": "files/admin.secret" + }, + { + "id": "oidc-secret", + "type": "file", + "path": "${dir:state}/oidc-client.secret", + "mode": "0400", + "owner": "472:472", + "template": "files/oidc-client.secret" + }, + { + "id": "oidc-env", + "type": "file", + "path": "${dir:state}/oidc.env", + "mode": "0644", + "template": "files/oidc.env" + }, + { + "id": "influxdb-datasources", + "type": "directory", + "path": "${dir:state}/datasources", + "mode": "0755", + "owner": "472:472", + "unknown": "program" + }, + { + "id": "influxdb-credential", + "type": "directory", + "path": "${dir:state}/influxdb", + "mode": "0700", + "owner": "472:472", + "unknown": "program" + }, + { + "id": "influxdb-secret", + "type": "file", + "path": "${dir:influxdb-credential}/influxdb-api", + "mode": "0400", + "owner": "472:472", + "template": "files/influxdb-api", + "only-when-bound": "influxdb-api" + }, + { + "id": "influxdb-datasource", + "type": "file", + "path": "${dir:influxdb-datasources}/mesh-influxdb.yaml", + "mode": "0644", + "template": "files/mesh-influxdb.yaml", + "only-when-bound": "influxdb-api" + }, + { + "id": "server", + "type": "container", + "name": "grafana", + "image": "grafana/grafana@sha256:ac461fb352abc50da10a51c7d02462e9c05488f11f53f14b3ad79a8145f638a0", + "health": { + "kind": "http", + "endpoint": "web", + "path": "/" + }, + "ports": [ + "3000" + ], + "volumes": [ + "${dir:data}:/var/lib/grafana", + "${dir:state}/admin.secret:/run/secrets/admin:ro", + "${dir:state}/oidc-client.secret:/run/secrets/oidc-client:ro", + "${dir:influxdb-credential}:/run/secrets/influxdb:ro", + "${dir:influxdb-datasources}:/etc/grafana/provisioning/datasources:ro" + ], + "env": { + "GF_SECURITY_ADMIN_PASSWORD__FILE": "/run/secrets/admin" + }, + "env-file": [ + "${dir:state}/oidc.env" + ], + "restart-on": [ + "oidc-env", + "oidc-secret", + "influxdb-datasource", + "influxdb-secret" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": ["user", "url"], + "protected": ["password", "token"] + } + ], + "requires": [ + "route", + "oidc-client", + { + "name": "influxdb-api", + "optional": true + } + ], + "contributes": { + "route": { + "label": "grafana", + "endpoint": "web" + }, + "oidc-client": { + "label": "grafana", + "endpoint": "web", + "callback": "/login/generic_oauth" + }, + "influxdb-api": { + "access": "read" + } + }, + "binds": { + "route": "${dir:state}/route.json", + "oidc-client": "${dir:state}/oidc.json", + "influxdb-api": "${dir:state}/influxdb.json" + }, + "secrets": { + "oidc-client": "${dir:mesh-state}/oidc-client", + "influxdb-api": "${dir:mesh-state}/influxdb-api" + }, + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js" + ], + "loads": [ + "index.js", + "tools/index.js" + ], + "env": { + "MESH_GRAFANA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GRAFANA_CONFIG_FILE": "${dir:mesh-state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/hello-web/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/hello-web/module.json.captured new file mode 100644 index 0000000..005578c --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/hello-web/module.json.captured @@ -0,0 +1,82 @@ +{ + "module": "hello-web", + "slug": "hello", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "hello", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "listens": [ + { + "name": "web", + "port": 8080, + "protocol": "tcp", + "from": "mesh", + "why": "the demo web page; only the route-proxy reaches it, and the public name is a route grant" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "page", + "type": "file", + "path": "${dir:state}/index.html", + "mode": "0644", + "template": "files/index.html" + }, + { + "id": "net", + "type": "network", + "name": "hello-web" + }, + { + "id": "server", + "type": "container", + "name": "hello-web", + "network": "hello-web", + "ports": [ + "8080" + ], + "volumes": [ + "${dir:state}/index.html:/www/index.html:ro" + ], + "args": [ + "sh", + "-c", + "while true; do { printf 'HTTP/1.1 200 OK\\r\\nContent-Type: text/plain\\r\\nConnection: close\\r\\n\\r\\n'; cat /www/index.html; } | nc -l -p 8080; done" + ], + "artifact": "server" + } + ], + "build": { + "artifacts": [ + { + "name": "server", + "kind": "upstream", + "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/home-assistant/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/home-assistant/module.json.captured new file mode 100644 index 0000000..9c4f99d --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/home-assistant/module.json.captured @@ -0,0 +1,209 @@ +{ + "module": "home-assistant", + "version": "1", + "slug": "hass", + "capabilities": [ + "container-runtime" + ], + "emits": [ + "state.changed" + ], + "own-secrets": { + "token": "${dir:mesh-state}/token" + }, + "listens": [ + { + "name": "web", + "port": 8123, + "protocol": "tcp", + "from": "mesh", + "why": "the dashboard, the API and the companion apps" + }, + { + "name": "sonos-events", + "port": 1400, + "protocol": "tcp", + "from": "mesh", + "why": "the Sonos integration's event callback: speakers push their state changes here" + }, + { + "name": "webrtc", + "port": 18555, + "protocol": "tcp", + "from": "mesh", + "why": "the bundled go2rtc's WebRTC port, which camera streams to a browser use" + } + ], + "data": { + "own": [ + { + "id": "config", + "path": "${dir:config}", + "class": "valuable", + "active": "1d", + "why": "the house's configuration, automations and history; written all the time" + } + ] + }, + "config": { + "url": { + "type": "string", + "description": "The address the module's tools reach Home Assistant at, as a URL; unset, the code falls back to its environment, else this machine's Home Assistant on its port.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "config", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "written", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "server", + "type": "container", + "name": "home-assistant", + "image": "ghcr.io/home-assistant/home-assistant@sha256:d8922685169707fd91e8b9729902d975f06157d005e422874d201e0261dda196", + "network": "host", + "env": { + "TZ": "Etc/UTC" + }, + "volumes": [ + "${dir:config}:/config" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": ["url"] + }, + { + "id": "provisions-env", + "type": "file", + "path": "${dir:state}/provisions.env", + "mode": "0600", + "template": "files/provisions.env" + }, + { + "id": "provisions", + "type": "process", + "name": "home-assistant-provisions", + "artifact": "code", + "run": [ + "node", + "provisions/index.js" + ], + "run-once": true, + "env-file": [ + "${dir:state}/provisions.env" + ], + "restart-on": [ + "provisions-env", + "bound-mqtt-topic", + "secret-mqtt-topic", + "bound-sonarr-api", + "secret-sonarr-api", + "bound-radarr-api", + "secret-radarr-api", + "bound-lidarr-api", + "secret-lidarr-api" + ] + } + ], + "requires": [ + { + "name": "lidarr-api", + "optional": true + }, + { + "name": "mqtt-topic", + "optional": true + }, + { + "name": "radarr-api", + "optional": true + }, + "route", + { + "name": "sonarr-api", + "optional": true + } + ], + "contributes": { + "mqtt-topic": { + "topics": [ + "#" + ] + }, + "route": { + "label": "home-assistant", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json", + "mqtt-topic": "${dir:state}/mqtt-topic.json", + "sonarr-api": "${dir:state}/sonarr-api.json", + "radarr-api": "${dir:state}/radarr-api.json", + "lidarr-api": "${dir:state}/lidarr-api.json" + }, + "secrets": { + "mqtt-topic": "${dir:state}/mqtt-topic.secret", + "sonarr-api": "${dir:state}/sonarr-api.secret", + "radarr-api": "${dir:state}/radarr-api.secret", + "lidarr-api": "${dir:state}/lidarr-api.secret" + }, + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisions/index.js" + ], + "loads": [ + "index.js", + "tools/index.js" + ], + "env": { + "MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}", + "MESH_HOMEASSISTANT_TOKEN_FILE": "${dir:mesh-state}/token", + "MESH_HOMEASSISTANT_CONFIG_FILE": "${dir:mesh-state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/hostname/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/hostname/module.json.captured new file mode 100644 index 0000000..e84e7be --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/hostname/module.json.captured @@ -0,0 +1,61 @@ +{ + "module": "hostname", + "version": "1", + "claims": [ + { + "name": "node-hostname", + "scope": "node", + "serves": [ + "entries", + "add", + "remove" + ] + } + ], + "config": { + "hostname": { + "type": "string", + "description": "The machine's host name, written to /etc/hostname: the operator's name for this machine, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + } + }, + "resources": [ + { + "id": "own", + "type": "file", + "path": "/etc/hosts", + "mode": "0644", + "into": "block", + "at": "start", + "template": "files/hosts" + }, + { + "id": "name", + "type": "file", + "path": "/etc/hostname", + "mode": "0644", + "template": "files/hostname" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/hostname-tools", + "binary": "hostname-tools", + "loads": [ + "hostname-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/i3/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/i3/module.json.captured new file mode 100644 index 0000000..1ec9242 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/i3/module.json.captured @@ -0,0 +1,124 @@ +{ + "module": "i3", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-display-session", + "scope": "node", + "serves": [ + "reload", + "workspaces", + "windows" + ] + } + ], + "tools": [ + "i3_focus", + "i3_move", + "i3_layout_save", + "i3_layout_restore", + "i3_exec", + "i3_kill", + "i3_bindings", + "i3_config_check", + "i3_marks", + "i3_scratchpad" + ], + "items": [ + { + "collection": "environment", + "name": "xdg-current-desktop", + "attributes": { + "kind": "variable", + "name": "XDG_CURRENT_DESKTOP" + }, + "template": "files/env-xdg-current-desktop" + }, + { + "collection": "environment", + "name": "xdg-session-desktop", + "attributes": { + "kind": "variable", + "name": "XDG_SESSION_DESKTOP" + }, + "template": "files/env-xdg-session-desktop" + }, + { + "collection": "shell:xinitrc:last", + "name": "xinitrc-last", + "template": "files/xinitrc-last" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "i3-wm" + }, + { + "id": "package-dex", + "type": "package", + "package": "dex" + }, + { + "id": "config-dir", + "type": "directory", + "path": "${machine:account-home}/.config/i3", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out", + "knows": ["scripts/"] + }, + { + "id": "drop-ins", + "type": "directory", + "path": "${machine:account-home}/.config/i3/config.d", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out", + "knows": ["10-asus.conf", "20-g14.conf"] + }, + { + "id": "config", + "type": "file", + "path": "${machine:account-home}/.config/i3/config", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/config" + }, + { + "id": "session", + "type": "file", + "path": "/etc/lemurs/wms/i3", + "mode": "0755", + "template": "files/wms/i3" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/i3-tools", + "binary": "i3-tools", + "loads": [ + "i3-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/i3status-rust/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/i3status-rust/module.json.captured new file mode 100644 index 0000000..1e07556 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/i3status-rust/module.json.captured @@ -0,0 +1,126 @@ +{ + "module": "i3status-rust", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-bar", + "scope": "node" + } + ], + "tools": [ + "i3status_rust_reload", + "i3status_rust_blocks", + "i3status_rust_block_run", + "i3status_rust_themes" + ], + "items": [ + { + "collection": "shell:xinitrc:normal", + "name": "xinitrc-normal", + "template": "files/xinitrc-normal" + }, + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/60-i3status-rust.conf" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "i3status-rust" + }, + { + "id": "configuration-dir", + "type": "directory", + "path": "${machine:account-home}/.config/i3status-rust", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "icons-dir", + "type": "directory", + "path": "${machine:account-home}/.config/i3status-rust/icons", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "top-bar", + "type": "file", + "path": "${machine:account-home}/.config/i3status-rust/top-bar.toml", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/top-bar.toml" + }, + { + "id": "bottom-bar", + "type": "file", + "path": "${machine:account-home}/.config/i3status-rust/bottom-bar.toml", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/bottom-bar.toml" + }, + { + "id": "icons", + "type": "file", + "path": "${machine:account-home}/.config/i3status-rust/icons/custom-icons.toml", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/icons/custom-icons.toml" + }, + { + "id": "updates", + "type": "file", + "path": "${machine:account-home}/.local/bin/i3status-updates", + "owner": "${machine:account}", + "mode": "0755", + "template": "files/bin/i3status-updates" + }, + { + "id": "watchdog", + "type": "file", + "path": "${machine:account-home}/.local/bin/i3bar-watchdog", + "owner": "${machine:account}", + "mode": "0755", + "template": "files/bin/i3bar-watchdog" + }, + { + "id": "value", + "type": "file", + "path": "${machine:account-home}/.local/bin/i3status-value", + "owner": "${machine:account}", + "mode": "0755", + "template": "files/bin/i3status-value" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/i3status-rust-tools", + "binary": "i3status-rust-tools", + "loads": [ + "i3status-rust-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/icecast/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/icecast/module.json.captured new file mode 100644 index 0000000..6f9ab54 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/icecast/module.json.captured @@ -0,0 +1,152 @@ +{ + "module": "icecast", + "version": "1", + "requires": [ + "route", + "secret" + ], + "contributes": { + "route": { + "label": "icecast", + "endpoint": "stream" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "secrets": { + "secret": { + "source": "${dir:state}/source.secret", + "admin": "${dir:state}/admin.secret", + "relay": "${dir:state}/relay.secret" + } + }, + "capabilities": [ + "container-runtime" + ], + "emits": [ + "stream.started", + "stream.stopped" + ], + "listens": [ + { + "name": "stream", + "port": 8000, + "protocol": "tcp", + "from": "mesh", + "why": "streams in from sources (HTTP PUT) and out to listeners, plus the status and admin pages; a public name is its route" + } + ], + "data": { + "own": [ + { + "id": "logs", + "path": "${dir:logs}", + "class": "cache", + "why": "the streaming server's logs" + } + ] + }, + "config": { + "url": { + "type": "string", + "description": "The address the module's tools reach Icecast at, as a URL; unset, the code falls back to its environment, else this machine's Icecast on its port.", + "trusted": true, + "default": "" + }, + "user": { + "type": "string", + "description": "The Icecast admin account the module's tools sign in as; unset, the code falls back to its environment, else admin.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "logs", + "type": "directory", + "mode": "0700", + "owner": "100:101", + "unknown": "program" + }, + { + "id": "server-conf", + "type": "file", + "path": "${dir:state}/icecast.xml", + "mode": "0600", + "template": "files/icecast.xml" + }, + { + "id": "net", + "type": "network", + "name": "icecast" + }, + { + "id": "server", + "type": "container", + "name": "icecast", + "image": "infiniteproject/icecast@sha256:cd506cf3dfe31ce05fd37d7e672dbd1213e7255cc93d28ecf5a3b547af4e162c", + "network": "icecast", + "ports": [ + "8000" + ], + "volumes": [ + "${dir:state}/icecast.xml:/etc/icecast.xml:ro", + "${dir:logs}:/var/log/icecast" + ], + "restart-on": [ + "server-conf" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": ["url", "user"] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js" + ], + "loads": [ + "index.js", + "tools/index.js" + ], + "env": { + "MESH_ICECAST_URL": "http://127.0.0.1:${port:8000}", + "MESH_ICECAST_CONFIG_FILE": "${dir:mesh-state}/config.json", + "MESH_ICECAST_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/influxdb/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/influxdb/module.json.captured new file mode 100644 index 0000000..53389a5 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/influxdb/module.json.captured @@ -0,0 +1,198 @@ +{ + "module": "influxdb", + "version": "1", + "provides": [ + { + "name": "influxdb-api", + "scope": "mesh", + "identity": { + "in": "an InfluxDB v1 authorization" + } + } + ], + "capabilities": [ + "container-runtime" + ], + "own-secrets": { + "admin": "${dir:state}/admin.secret", + "admin-token": "${dir:state}/admin-token.secret" + }, + "listens": [ + { + "name": "api", + "port": 8086, + "protocol": "tcp", + "from": "mesh", + "why": "queries, writes and the web UI, over http; consumers granted influxdb-api sign in with the mesh's credential, and a name is a route grant" + } + ], + "config": { + "org": { + "type": "string", + "description": "The organisation influxdb-api names to its consumers and the module's tools write to; mesh is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11).", + "trusted": true, + "default": "mesh" + }, + "bucket": { + "type": "string", + "description": "The bucket influxdb-api names to its consumers and the module's tools write to; default is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11).", + "trusted": true, + "default": "default" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach influxdb at, as a URL; empty is this machine's influxdb on its port.", + "trusted": true, + "default": "" + } + }, + "serves": { + "influxdb-api": { + "scheme": "http", + "port": 8086, + "org": "${config:org}", + "bucket": "${config:bucket}" + } + }, + "receives": { + "influxdb-api": "${dir:grants}/mesh.json" + }, + "grants": { + "influxdb-api": "${dir:grants}" + }, + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "why": "every consumer's time series" + }, + { + "id": "config", + "path": "${dir:config}", + "class": "valuable", + "why": "the server's own configuration and its operator token, made at its first start" + } + ], + "consumers": { + "influxdb-api": { + "class": "valuable", + "in": "data", + "why": "a consumer's measurements are the only copy" + } + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "1000:1000", + "unknown": "program" + }, + { + "id": "config", + "type": "directory", + "mode": "0700", + "owner": "1000:1000", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "server-env", + "type": "file", + "path": "${dir:state}/server.env", + "mode": "0600", + "template": "files/server.env" + }, + { + "id": "server", + "type": "container", + "name": "influxdb", + "image": "influxdb@sha256:f75e48af0598e8aec7986e991a848d19a119101a7d563a2e5db1dfaac9c45daa", + "env-file": [ + "${dir:state}/server.env" + ], + "ports": [ + "8086" + ], + "volumes": [ + "${dir:data}:/var/lib/influxdb2", + "${dir:config}:/etc/influxdb2", + "${dir:state}/admin.secret:/run/secrets/admin:ro", + "${dir:state}/admin-token.secret:/run/secrets/admin-token:ro" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": [ + "bucket", + "org", + "url" + ], + "trusted": true + } + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "influxdb", + "endpoint": "api" + } + }, + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}", + "MESH_INFLUXDB_CONFIG_FILE": "${dir:mesh-state}/config.json", + "MESH_INFLUXDB_TOKEN_FILE": "${dir:state}/admin-token.secret", + "MESH_RECEIVES": "${dir:grants}/mesh.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/invoicing/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/invoicing/module.json.captured new file mode 100644 index 0000000..29670a3 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/invoicing/module.json.captured @@ -0,0 +1,128 @@ +{ + "module": "invoicing", + "version": "1", + "slug": "invoice", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "mongodb-database", + "s3-bucket", + "route" + ], + "contributes": { + "mongodb-database": { + "name": "invoicing" + }, + "route": { + "site": { + "label": "invoicing", + "endpoint": "web" + }, + "api": { + "label": "invoicing-api", + "endpoint": "api" + } + } + }, + "binds": { + "mongodb-database": "${dir:state}/database.json", + "s3-bucket": "${dir:state}/store.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "mongodb-database": "${dir:state}/database.secret", + "s3-bucket": "${dir:state}/store.secret" + }, + "listens": [ + { + "name": "web", + "port": 80, + "protocol": "tcp", + "from": "mesh", + "why": "the invoicing web frontend; a public name is a route grant later" + }, + { + "name": "api", + "port": 9000, + "protocol": "tcp", + "from": "mesh", + "why": "the invoicing REST API the frontend and integrations call" + } + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "api-env", + "type": "file", + "path": "${dir:state}/api.env", + "mode": "0600", + "template": "files/api.env" + }, + { + "id": "net", + "type": "network", + "name": "invoicing" + }, + { + "id": "app", + "type": "container", + "name": "invoicing-app", + "image": "registry-api.novox.be/novox/invoicing-app@sha256:1e6ed40822f07169b24867cbfe8fc1ab3ef6a15ad642f3b3a2882a8e15c3dbec", + "network": "invoicing", + "env": { + "UID": "2201", + "GID": "2201" + }, + "ports": [ + "80" + ], + "names-on-purpose": { + "registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)" + } + }, + { + "id": "api", + "type": "container", + "name": "invoicing-api", + "image": "registry-api.novox.be/novox/invoicing-api@sha256:efa6fba1fa9ba78849e94e958d33793a76654df0468e3012da9c02c54c265354", + "network": "invoicing", + "env": { + "UID": "2201", + "GID": "2201" + }, + "env-file": [ + "${dir:state}/api.env" + ], + "ports": [ + "9000" + ], + "secrets-in-environment": "the application's own code reads MONGO_URL and MINIO_SECRET from the environment (invoicing-app server/src/config.js); converting is that repository's change", + "names-on-purpose": { + "registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)" + } + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/jira/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/jira/module.json.captured new file mode 100644 index 0000000..9f98dd8 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/jira/module.json.captured @@ -0,0 +1,75 @@ +{ + "module": "jira", + "version": "1", + "own-secrets": { + "token": "${dir:state}/token" + }, + "config": { + "atlassian-url": { + "type": "string", + "description": "The address of the operator's Atlassian site the jira tools call, as a URL such as https://example.atlassian.net, laid into the file as ATLASSIAN_URL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + }, + "atlassian-email": { + "type": "string", + "description": "The email address of the Atlassian account the jira tools sign in as with its API token, laid into the file as ATLASSIAN_EMAIL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "config", + "type": "file", + "path": "${dir:state}/config.json", + "merge": "json", + "template": "files/config.json", + "mode": "0600", + "takes-config": [ + { + "key": "atlassian-url", + "as": "ATLASSIAN_URL" + }, + { + "key": "atlassian-email", + "as": "ATLASSIAN_EMAIL" + } + ] + } + ], + "capabilities": [ + "container-runtime" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_JIRA_TOKEN_FILE": "${dir:state}/token", + "MESH_JIRA_CONFIG_FILE": "${dir:state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/kernel/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/kernel/module.json.captured new file mode 100644 index 0000000..67d3215 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/kernel/module.json.captured @@ -0,0 +1,78 @@ +{ + "module": "kernel", + "version": "1", + "capabilities": [ + "service-manager" + ], + "config": { + "transparent-huge-pages": { + "type": "string", + "description": "madvise is the value the mesh chose for every node (novox/hq ADR 0381), not the always the distribution's kernel boots with; a machine that needs another value is given it as its own setting; madvise gives huge pages only to programs that ask for them, which stops a small write to a mapped file (the journal) dirtying and writing back a whole 2 MiB page on a machine whose disks are slow (novox/hq issue 746)", + "trusted": true, + "default": "madvise" + } + }, + "tools": [ + "kernel_huge_pages" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "huge-pages", + "type": "file", + "path": "${dir:state}/huge-pages", + "mode": "0755", + "trusted": true, + "template": "files/huge-pages.sh" + }, + { + "id": "unit", + "type": "file", + "path": "/etc/systemd/system/mesh-kernel-huge-pages.service", + "mode": "0644", + "template": "files/mesh-kernel-huge-pages.service" + }, + { + "id": "apply", + "type": "service", + "unit": "mesh-kernel-huge-pages.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "unit" + ], + "reload-on": [ + "huge-pages" + ], + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/kernel-tools", + "binary": "kernel-tools", + "loads": [ + "kernel-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/keycloak/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/keycloak/module.json.captured new file mode 100644 index 0000000..847d5f4 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/keycloak/module.json.captured @@ -0,0 +1,245 @@ +{ + "module": "keycloak", + "version": "1", + "moved": true, + "upgrade": { + "policy": "record", + "why": "every person's sign-in to every site goes through it, and its new version migrates its database on start: a person takes each build, after a backup (hq ADR 0236)" + }, + "provides": [ + { + "name": "oidc-client", + "scope": "mesh", + "identity": { + "max": 255, + "in": "a Keycloak client id" + } + } + ], + "requires": [ + "postgres-database", + "route" + ], + "contributes": { + "postgres-database": { + "name": "keycloak" + }, + "route": { + "label": "keycloak", + "endpoint": "web" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "capabilities": [ + "container-runtime" + ], + "emits": [ + "user.created", + "user.deleted", + "password.reset", + "client.created", + "client.retired", + "group.created", + "role.created", + "admin.repaired", + "admin.unrepaired" + ], + "listens": [ + { + "name": "web", + "port": 8080, + "protocol": "tcp", + "from": "mesh", + "why": "anything the mesh runs that authenticates a person" + } + ], + "serves": { + "oidc-client": { + "authorization-path": "/protocol/openid-connect/auth", + "token-path": "/protocol/openid-connect/token", + "userinfo-path": "/protocol/openid-connect/userinfo", + "issuer": "${config:issuer}" + } + }, + "receives": { + "oidc-client": "${dir:grants}/mesh.json" + }, + "grants": { + "oidc-client": "${dir:grants}" + }, + "own-secrets": { + "admin": "${dir:state}/admin.secret" + }, + "data": { + "consumers": { + "oidc-client": { + "class": "rebuildable", + "in": "postgres-database", + "why": "a consumer's client is made again from its declaration, with a new secret" + } + } + }, + "config": { + "url": { + "type": "string", + "description": "The address the module's provider reaches Keycloak's admin API at, as a URL; empty is this machine's Keycloak on its port.", + "trusted": true, + "default": "" + }, + "user": { + "type": "string", + "description": "The Keycloak administrator the module's provider signs in as; empty is admin.", + "trusted": true, + "default": "" + }, + "realm": { + "type": "string", + "description": "The realm the module's provider makes its consumers' clients in; empty is master.", + "trusted": true, + "default": "" + }, + "issuer": { + "type": "string", + "description": "The issuer Keycloak's tokens name and oidc consumers are told, as the realm's URL, such as https://keycloak.example.org/realms/Example; the operator's, with no default (novox/hq ADR 0112, ADR 0155).", + "trusted": true, + "required": true + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "consumers", + "type": "file", + "path": "${dir:state}/consumers.json", + "mode": "0600", + "template": "files/consumers.json" + }, + { + "id": "admin-env", + "type": "file", + "path": "${dir:state}/admin.env", + "mode": "0600", + "template": "files/admin.env" + }, + { + "id": "database-env", + "type": "file", + "path": "${dir:state}/database.env", + "mode": "0600", + "template": "files/database.env" + }, + { + "id": "net", + "type": "network", + "name": "keycloak" + }, + { + "id": "hostname", + "type": "file", + "path": "${dir:state}/hostname.env", + "mode": "0644", + "template": "files/hostname.env" + }, + { + "id": "server", + "type": "container", + "name": "keycloak", + "image": "quay.io/keycloak/keycloak@sha256:ecd43971114b0c764f8a3288dddab73f98cb473daccc4feaffe4dc14adeaf866", + "network": "keycloak", + "args": [ + "start-dev" + ], + "env": { + "KC_DB": "postgres", + "KC_HTTP_ENABLED": "true", + "KC_HEALTH_ENABLED": "true", + "KC_PROXY_HEADERS": "xforwarded" + }, + "env-file": [ + "${dir:state}/admin.env", + "${dir:state}/database.env", + "${dir:state}/hostname.env" + ], + "ports": [ + "8080" + ], + "secrets-in-environment": "KC_DB_PASSWORD is convertible through a generated keycloak.conf (db-password=); KEYCLOAK_ADMIN_PASSWORD is env-only before Keycloak 26; not yet converted", + "restart-on": [ + "hostname" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": [ + "url", + "user", + "realm", + "issuer" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/keycloak-provider", + "binary": "keycloak-provider", + "loads": [ + "keycloak-provider" + ], + "env": { + "MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}", + "MESH_KEYCLOAK_CONFIG_FILE": "${dir:mesh-state}/config.json", + "MESH_KEYCLOAK_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_RECEIVES": "${dir:grants}/mesh.json", + "MESH_KEYCLOAK_CONTAINER": "keycloak", + "MESH_CONSUMERS_FILE": "${dir:state}/consumers.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "state": [ + "bound" + ], + "reads": [ + "mesh-controller.assignments" + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/lab/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/lab/module.json.captured new file mode 100644 index 0000000..ef82b87 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/lab/module.json.captured @@ -0,0 +1,121 @@ +{ + "module": "lab", + "version": "1", + "capabilities": [ + "container-runtime", + "virtualisation" + ], + "requires": [ + "npm-package-registry", + "go-toolchain", + "node-toolchain" + ], + "contributes": { + "npm-package-registry": { + "access": "read" + } + }, + "binds": { + "npm-package-registry": "${dir:state}/package-registry.json" + }, + "secrets": { + "npm-package-registry": "${dir:state}/package-registry.secret" + }, + "data": { + "own": [ + { + "id": "work", + "path": "${dir:work}", + "class": "cache", + "why": "the lab's runs, each thrown away" + } + ] + }, + "config": { + "forge": { + "type": "string", + "description": "The forge the lab's machines take their packages from, as its address: the operator's, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "work", + "type": "directory", + "path": "/var/lib/mesh-lab-runs", + "mode": "0700", + "unknown": "program" + }, + { + "id": "runtime-env", + "type": "file", + "path": "${dir:state}/lab.env", + "mode": "0600", + "template": "files/lab.env" + }, + { + "id": "git", + "type": "package", + "package": "git" + }, + { + "id": "make", + "type": "package", + "package": "make" + }, + { + "id": "python", + "type": "package", + "package": "python" + }, + { + "id": "file", + "type": "package", + "package": "file" + }, + { + "id": "iproute2", + "type": "package", + "package": "iproute2" + }, + { + "id": "incus", + "type": "package", + "package": "incus" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_LAB_WORK": "${dir:work}", + "MESH_LAB_ENV_FILE": "${dir:state}/lab.env", + "MESH_LAB_FORGE_BINDING": "${dir:state}/package-registry.json", + "MESH_LAB_FORGE_SECRET": "${dir:state}/package-registry.secret" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/lemurs/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/lemurs/module.json.captured new file mode 100644 index 0000000..702a959 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/lemurs/module.json.captured @@ -0,0 +1,84 @@ +{ + "module": "lemurs", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager", + "seat" + ], + "claims": [ + { + "name": "node-login-manager", + "scope": "node", + "serves": [ + "sessions" + ] + } + ], + "tools": [ + "lemurs_default_session", + "lemurs_logins" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "lemurs" + }, + { + "id": "xserver", + "type": "file", + "path": "/etc/lemurs/xserver", + "mode": "0755", + "template": "files/xserver" + }, + { + "id": "config", + "type": "file", + "path": "/etc/lemurs/config.toml", + "mode": "0644", + "template": "files/config.toml" + }, + { + "id": "xsessions", + "type": "directory", + "path": "/etc/lemurs/xsessions", + "mode": "0755", + "unknown": "program" + }, + { + "id": "wayland-sessions", + "type": "directory", + "path": "/etc/lemurs/wayland-sessions", + "mode": "0755", + "unknown": "program" + }, + { + "id": "service", + "type": "service", + "unit": "lemurs.service", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/lemurs-tools", + "binary": "lemurs-tools", + "loads": [ + "lemurs-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/letta/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/letta/module.json.captured new file mode 100644 index 0000000..de7baf6 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/letta/module.json.captured @@ -0,0 +1,156 @@ +{ + "module": "letta", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "postgres-database", + "route" + ], + "contributes": { + "postgres-database": { + "name": "letta", + "extensions": [ + "vector" + ] + }, + "route": { + "label": "letta", + "endpoint": "web" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "own-secrets": { + "server-password": { + "path": "${dir:state}/server-password.secret", + "taken": "at-start" + }, + "openai-api-key": { + "path": "${dir:state}/openai-api-key.secret", + "taken": "at-start", + "issued-by": "outside" + } + }, + "listens": [ + { + "name": "web", + "port": 8283, + "protocol": "tcp", + "from": "mesh", + "why": "the Letta agent server REST API and web UI, password-protected (--secure); a public name is the route's" + } + ], + "config": { + "url": { + "type": "string", + "description": "The address the module's tools reach Letta at, as a URL; unset, the code falls back to its environment, else this machine's Letta on port 8283.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "server-env", + "type": "file", + "path": "${dir:state}/server.env", + "mode": "0600", + "template": "files/server.env" + }, + { + "id": "pgpass", + "type": "file", + "path": "${dir:state}/pgpass", + "mode": "0600", + "template": "files/pgpass" + }, + { + "id": "start", + "type": "file", + "path": "${dir:state}/start.sh", + "mode": "0644", + "template": "files/start.sh" + }, + { + "id": "net", + "type": "network", + "name": "letta" + }, + { + "id": "server", + "type": "container", + "name": "letta", + "image": "letta/letta@sha256:bfd1e49ce45b9a208c941e832c1d1d194017ff210a3784b0ca6c323aed767a29", + "network": "letta", + "env-file": [ + "${dir:state}/server.env" + ], + "ports": [ + "8283" + ], + "volumes": [ + "${dir:state}/pgpass:/run/secrets/pgpass:ro", + "${dir:state}/start.sh:/run/letta/start.sh:ro" + ], + "args": [ + "sh", + "/run/letta/start.sh" + ], + "secrets-in-environment": "letta 0.6.x reads its settings from the environment only (pydantic settings, no secrets_dir or _FILE twin): LETTA_SERVER_PASSWORD and OPENAI_API_KEY have no file source. The database password is not here: LETTA_PG_URI, which letta prints at start, names no password, and libpq reads it from the mounted pgpass file (PGPASSFILE)" + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": ["url"], + "protected": ["password"] + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_LETTA_URL": "http://127.0.0.1:${port:8283}", + "MESH_LETTA_CONFIG_FILE": "${dir:mesh-state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/local-model-consumer/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/local-model-consumer/module.json.captured new file mode 100644 index 0000000..7300209 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/local-model-consumer/module.json.captured @@ -0,0 +1,42 @@ +{ + "module": "local-model-consumer", + "version": "1", + "slug": "local", + "requires": [ + "model-access" + ], + "binds": { + "model-access": "${dir:state}/model.json" + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "config", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "openai-env", + "type": "file", + "path": "${dir:config}/openai.env", + "mode": "0600", + "template": "files/openai.env" + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/localization/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/localization/module.json.captured new file mode 100644 index 0000000..0974ed3 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/localization/module.json.captured @@ -0,0 +1,61 @@ +{ + "module": "localization", + "version": "1", + "capabilities": [ + "service-manager" + ], + "tools": [ + "localization_get", + "localization_time_zone", + "localization_locales", + "localization_keymaps" + ], + "resources": [ + { + "id": "locale", + "type": "file", + "path": "/etc/locale.conf", + "mode": "0644", + "template": "files/locale.conf" + }, + { + "id": "keymap", + "type": "file", + "path": "/etc/vconsole.conf", + "mode": "0644", + "template": "files/vconsole.conf" + }, + { + "id": "time-zone", + "type": "process", + "name": "localization-time-zone", + "artifact": "tools", + "run": [ + "./localization-tools", + "set-time-zone", + "Europe/Brussels" + ], + "run-once": true + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/localization-tools", + "binary": "localization-tools", + "loads": [ + "localization-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/logrotate/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/logrotate/module.json.captured new file mode 100644 index 0000000..8318451 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/logrotate/module.json.captured @@ -0,0 +1,58 @@ +{ + "module": "logrotate", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "logrotate_status", + "logrotate_configs", + "logrotate_check", + "logrotate_big_logs", + "logrotate_force", + "logrotate_journal_usage", + "logrotate_journal_vacuum" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "logrotate" + }, + { + "id": "config", + "type": "file", + "path": "/etc/logrotate.conf", + "mode": "0644", + "template": "files/logrotate.conf" + }, + { + "id": "timer", + "type": "service", + "unit": "logrotate.timer", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/logrotate-tools", + "binary": "logrotate-tools", + "loads": [ + "logrotate-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mailu/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mailu/module.json.captured new file mode 100644 index 0000000..568be14 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mailu/module.json.captured @@ -0,0 +1,772 @@ +{ + "module": "mailu", + "version": "1", + "upgrade": { + "policy": "record", + "why": "people's mail: any change to how its containers are declared recreates them together in one send, and the mail is down for everyone until they are up again — a person chooses the moment (hq ADR 0242, issue 295)" + }, + "unhealthy": { + "severity": "urgent", + "why": "while mailu is broken, mail is refused or stuck for everyone with a mailbox here (hq ADR 0355, issue 687)" + }, + "capabilities": [ + "container-runtime" + ], + "requires": [ + "postgres-database", + "route", + "secret" + ], + "contributes": { + "postgres-database": { + "name": "mailu" + }, + "route": { + "web": { + "label": "mail", + "endpoint": "web-tls", + "scheme": "https", + "insecure": true + }, + "acme": { + "label": "mail", + "path": "/.well-known/acme-challenge", + "endpoint": "web", + "priority": 100 + }, + "autoconfig": { + "label": "autoconfig", + "endpoint": "autoconfig" + }, + "autodiscover": { + "label": "autodiscover", + "endpoint": "autoconfig" + }, + "automx": { + "label": "automx", + "endpoint": "autoconfig" + } + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret", + "secret": { + "secret-key": "${dir:state}/secret-key.secret", + "admin": "${dir:state}/admin.secret", + "api-token": "${dir:state}/api-token.secret" + } + }, + "tools": [ + "mailu_list_users", + "mailu_create_user", + "mailu_change_password", + "mailu_delete_user", + "mailu_list_aliases", + "mailu_create_alias", + "mailu_delete_alias", + "mailu_list_domains", + "mailu_read_mail", + "mailu_search_mail", + "mailu_health" + ], + "emits": [ + "user.created", + "user.deleted", + "alias.created", + "alias.deleted" + ], + "listens": [ + { + "name": "smtp", + "port": 25, + "protocol": "tcp", + "from": "anywhere", + "why": "mail from other mail servers", + "fixed": true + }, + { + "name": "pop3", + "port": 110, + "protocol": "tcp", + "from": "anywhere", + "why": "POP3, kept at parity with the predecessor; pruning legacy protocols is its own deliberate change", + "fixed": true + }, + { + "name": "imap", + "port": 143, + "protocol": "tcp", + "from": "anywhere", + "why": "IMAP with STARTTLS, kept at parity", + "fixed": true + }, + { + "name": "smtps", + "port": 465, + "protocol": "tcp", + "from": "anywhere", + "why": "submission over TLS", + "fixed": true + }, + { + "name": "submission", + "port": 587, + "protocol": "tcp", + "from": "anywhere", + "why": "submission; also what the smtp provision serves consumers", + "fixed": true + }, + { + "name": "imaps", + "port": 993, + "protocol": "tcp", + "from": "anywhere", + "why": "IMAP over TLS", + "fixed": true + }, + { + "name": "pop3s", + "port": 995, + "protocol": "tcp", + "from": "anywhere", + "why": "POP3 over TLS, kept at parity", + "fixed": true + }, + { + "name": "web", + "port": 7080, + "protocol": "tcp", + "from": "mesh", + "why": "the web front over http; only the ACME HTTP-01 passthrough is routed here — everything else 301s to https and would loop a proxy" + }, + { + "name": "web-tls", + "port": 7443, + "protocol": "tcp", + "from": "mesh", + "why": "the web front over its own TLS (admin, webmail, API); its public name is a route grant reaching it here" + }, + { + "name": "autoconfig", + "port": 4243, + "protocol": "tcp", + "from": "mesh", + "why": "automx: mail client autoconfiguration; the autoconfig, autodiscover and automx names are route grants reaching it here" + }, + { + "name": "admin-api", + "port": 8080, + "protocol": "tcp", + "from": "machine", + "why": "the admin API, which this module's own code reaches on loopback from the node's runtime now that it runs outside the mailu network" + } + ], + "data": { + "own": [ + { + "id": "mail", + "path": "${dir:data-mail}", + "class": "valuable", + "why": "every mailbox" + }, + { + "id": "dkim", + "path": "${dir:data-dkim}", + "class": "valuable", + "why": "the domain's signing keys; a new one means a new DNS record" + }, + { + "id": "data", + "path": "${dir:data-data}", + "class": "valuable", + "why": "the server's own data" + }, + { + "id": "dav", + "path": "${dir:data-dav}", + "class": "valuable", + "why": "calendars and contacts" + }, + { + "id": "webmail", + "path": "${dir:data-webmail}", + "class": "valuable", + "why": "the webmail's own data: its users' settings and address books" + }, + { + "id": "queue", + "path": "${dir:data-mailqueue}", + "class": "valuable", + "why": "mail accepted and not yet delivered, kept nowhere else" + }, + { + "id": "filter", + "path": "${dir:data-filter}", + "class": "rebuildable", + "why": "the spam filter's learned statistics; it learns again" + }, + { + "id": "certs", + "path": "${dir:data-certs}", + "class": "rebuildable", + "why": "certificates, issued again" + }, + { + "id": "automx", + "path": "${dir:data-automx}", + "class": "rebuildable", + "why": "client autoconfiguration, written again" + }, + { + "id": "fetchmail", + "path": "${dir:data-fetchmail}", + "class": "rebuildable", + "why": "which remote messages were fetched; lost, some are fetched twice" + }, + { + "id": "clamav", + "path": "${dir:data-clamav}", + "class": "cache", + "why": "virus signatures, downloaded again" + }, + { + "id": "redis", + "path": "${dir:data-redis}", + "class": "cache", + "why": "the filter's working set" + } + ], + "consumers": { + "smtp": { + "class": "valuable", + "in": "mail", + "why": "a consumer's mailbox holds the only copy of its mail" + } + } + }, + "config": { + "domain": { + "type": "string", + "description": "The mail domain Mailu serves, such as example.org: its first mail domain, the postmaster's and the one consumers send as; the operator's, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + }, + "proxy-address": { + "type": "string", + "description": "The address of the reverse proxy in front of Mailu, whose X-Real-IP header Mailu believes; the operator's, with no default.", + "trusted": true, + "required": true + }, + "sitename": { + "type": "string", + "description": "The name Mailu's web pages show for the mail service; the operator's, with no default.", + "trusted": true, + "required": true + }, + "website": { + "type": "string", + "description": "The address of the website Mailu's web pages link to, as a URL; the operator's, with no default.", + "trusted": true, + "required": true + }, + "url": { + "type": "string", + "description": "The address the module's tools reach Mailu's API at, as a URL; empty leaves them to MESH_MAILU_URL.", + "trusted": true, + "default": "" + }, + "container": { + "type": "string", + "description": "The name of the container holding Mailu's mailboxes, which the module's tools read mail in; empty is mailu-imap.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-automx", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "config-env", + "type": "file", + "path": "${dir:state}/mailu.env", + "mode": "0644", + "template": "files/mailu.env" + }, + { + "id": "secret-env", + "type": "file", + "path": "${dir:state}/secret.env", + "mode": "0600", + "template": "files/secret.env" + }, + { + "id": "database-env", + "type": "file", + "path": "${dir:state}/database.env", + "mode": "0600", + "template": "files/database.env" + }, + { + "id": "admin-env", + "type": "file", + "path": "${dir:state}/admin.env", + "mode": "0600", + "template": "files/admin.env" + }, + { + "id": "data-certs", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-data", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-dkim", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-mail", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-mailqueue", + "type": "directory", + "mode": "0755", + "unknown": "program" + }, + { + "id": "data-filter", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-clamav", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-redis", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-webmail", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-dav", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-fetchmail", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-overrides-nginx", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-overrides-dovecot", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-overrides-postfix", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-overrides-rspamd", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-overrides-roundcube", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "mailu" + }, + { + "id": "resolver", + "type": "container", + "name": "mailu-resolver", + "image": "ghcr.io/mailu/unbound@sha256:2c037bc5c988b5c0ab7e1b6e6ffa762e14ba1e45c96281ac87f8b9be8eee8b6e", + "health": { + "kind": "runtime" + }, + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env", + "${dir:state}/secret.env" + ], + "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", + "ip": "192.168.203.254" + }, + { + "id": "redis", + "type": "container", + "name": "mailu-redis", + "image": "redis@sha256:4bed291aa5efb9f0d77b76ff7d4ab71eee410962965d052552db1fb80576431d", + "network": "mailu", + "volumes": [ + "${dir:data-redis}:/data" + ] + }, + { + "id": "admin", + "type": "container", + "name": "mailu-admin", + "image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a", + "health": { + "kind": "runtime" + }, + "network": "mailu", + "ports": [ + "8080" + ], + "env-file": [ + "${dir:state}/mailu.env", + "${dir:state}/secret.env", + "${dir:state}/database.env", + "${dir:state}/admin.env" + ], + "volumes": [ + "${dir:data-data}:/data", + "${dir:data-dkim}:/dkim" + ], + "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified" + }, + { + "id": "imap", + "type": "container", + "name": "mailu-imap", + "image": "ghcr.io/mailu/dovecot@sha256:7f0ed5db996fbdc00adc5c5e38a08492e04f7eb4a9fbd66a03aa9a28ddf23993", + "health": { + "kind": "runtime" + }, + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env" + ], + "volumes": [ + "${dir:data-mail}:/mail", + "${dir:data-overrides-dovecot}:/overrides:ro" + ], + "dns": [ + "192.168.203.254" + ] + }, + { + "id": "smtp", + "type": "container", + "name": "mailu-smtp", + "image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e", + "health": { + "kind": "tool", + "tool": "mailu_health", + "interval": "60s", + "timeout": "30s", + "looks": 3, + "grace": "120s" + }, + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env" + ], + "volumes": [ + "${dir:data-mailqueue}:/queue", + "${dir:data-overrides-postfix}:/overrides:ro" + ], + "dns": [ + "192.168.203.254" + ] + }, + { + "id": "antispam", + "type": "container", + "name": "mailu-antispam", + "image": "ghcr.io/mailu/rspamd@sha256:ff3666d8a61f17d309c5c6f6bcf4d40470b82299ca706ac650301175bb1a079d", + "health": { + "kind": "runtime" + }, + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env" + ], + "volumes": [ + "${dir:data-filter}:/var/lib/rspamd", + "${dir:data-overrides-rspamd}:/etc/rspamd/override.d:ro" + ], + "dns": [ + "192.168.203.254" + ] + }, + { + "id": "antivirus", + "type": "container", + "name": "mailu-antivirus", + "image": "clamav/clamav-debian@sha256:b12ef8fefddbba7d88de59bea8a32622f365339154adf02d38fd089112e6745a", + "network": "mailu", + "volumes": [ + "${dir:data-clamav}:/var/lib/clamav" + ], + "dns": [ + "192.168.203.254" + ] + }, + { + "id": "webmail", + "type": "container", + "name": "mailu-webmail", + "image": "ghcr.io/mailu/webmail@sha256:bdbee44cdb05a4658f0e3b62cc448de55ca8f8aea172279fda594826144c04f6", + "health": { + "kind": "runtime" + }, + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env", + "${dir:state}/secret.env" + ], + "volumes": [ + "${dir:data-webmail}:/data", + "${dir:data-overrides-roundcube}:/overrides:ro" + ], + "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", + "dns": [ + "192.168.203.254" + ] + }, + { + "id": "webdav", + "type": "container", + "name": "mailu-webdav", + "image": "ghcr.io/mailu/radicale@sha256:690ed6edf189dfef100a5a8b37c195ebf5d9241ac5f23f2f44b8b7b75726e3de", + "health": { + "kind": "runtime" + }, + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env", + "${dir:state}/secret.env" + ], + "volumes": [ + "${dir:data-dav}:/data" + ], + "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", + "dns": [ + "192.168.203.254" + ] + }, + { + "id": "fetchmail", + "type": "container", + "name": "mailu-fetchmail", + "image": "ghcr.io/mailu/fetchmail@sha256:f881c8412d3bbe73d638469b48321558d6403a9d45bfa043c1e52c752103d42d", + "health": { + "kind": "runtime" + }, + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env", + "${dir:state}/secret.env" + ], + "volumes": [ + "${dir:data-fetchmail}:/data" + ], + "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", + "dns": [ + "192.168.203.254" + ] + }, + { + "id": "front", + "type": "container", + "name": "mailu-front", + "image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d", + "health": { + "kind": "exec", + "command": "curl -m3 -skfLo /dev/null http://127.0.0.1:10204/health && kill -0 $(cat /run/dovecot/master.pid) && python3 -c \"import smtplib; s = smtplib.SMTP('127.0.0.1', 25, timeout=5); s.ehlo('health'); s.quit()\" && openssl x509 -checkend 1209600 -noout -in /certs/letsencrypt/live/mailu/nginx-chain.pem", + "interval": "60s", + "timeout": "15s", + "looks": 3, + "grace": "120s" + }, + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env" + ], + "ports": [ + "25", + "110", + "143", + "465", + "587", + "993", + "995", + "7080:80", + "7443:443" + ], + "volumes": [ + "${dir:data-certs}:/certs", + "${dir:data-overrides-nginx}:/overrides:ro" + ], + "dns": [ + "192.168.203.254" + ], + "logging": "journald" + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "takes-config": ["domain", "proxy-address", "sitename", "website", "url", "container"], + "merge": "json" + }, + { + "id": "automx", + "type": "container", + "name": "mailu-automx", + "artifact": "automx", + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env" + ], + "ports": [ + "4243" + ], + "volumes": [ + "${dir:data-automx}:/data" + ] + } + ], + "build": { + "on": [ + { + "arg": "PYTHON_BASE", + "image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228" + } + ], + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1", + "MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret", + "MESH_MAILU_IMAP_CONTAINER": "mailu-imap", + "MESH_MAILU_SMTP_CONTAINER": "mailu-smtp", + "MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json", + "MESH_RECEIVES": "${dir:grants}/mesh.json" + } + }, + { + "name": "automx", + "kind": "image", + "from": "automx/Dockerfile" + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "provides": [ + { + "name": "smtp", + "scope": "mesh", + "identity": { + "max": 64, + "in": "a mailbox's local part" + } + } + ], + "serves": { + "smtp": { + "port": 587, + "domain": "${config:domain}" + } + }, + "receives": { + "smtp": "${dir:grants}/mesh.json" + }, + "grants": { + "smtp": "${dir:grants}" + }, + "jails": [ + { + "name": "mailu-front", + "failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=(?:,|$)\n ^.*client login failed: \"AUTH not supported\" while in http auth state, client: , server: \\S+:25,", + "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d" + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/marrytts/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/marrytts/module.json.captured new file mode 100644 index 0000000..ed242aa --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/marrytts/module.json.captured @@ -0,0 +1,37 @@ +{ + "module": "marrytts", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "listens": [ + { + "name": "api", + "port": 59125, + "protocol": "tcp", + "from": "mesh", + "why": "the MaryTTS text-to-speech HTTP API and web interface" + } + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "server", + "type": "container", + "name": "marrytts", + "image": "synesthesiam/marytts@sha256:45970ecb3e21a2981c66c60563a70cf00be8e95c02565e7d74b3a73dcec7db2c", + "env": { + "TZ": "Europe/Brussels" + }, + "ports": [ + "59125" + ] + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/matrix/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/matrix/module.json.captured new file mode 100644 index 0000000..170719c --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/matrix/module.json.captured @@ -0,0 +1,257 @@ +{ + "module": "matrix", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "listens": [ + { + "name": "client", + "port": 6167, + "protocol": "tcp", + "from": "mesh", + "why": "Conduit's client-server and federation APIs over plain HTTP. Both arrive through the route on 443: Conduit answers /.well-known/matrix/server with :443, so other homeservers federate through the proxy and nothing needs the traditional 8448" + }, + { + "name": "web", + "port": 80, + "protocol": "tcp", + "from": "mesh", + "why": "Element Web, the static browser client, served by the image's nginx; reached through its route" + } + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "homeserver": { + "label": "matrix", + "endpoint": "client" + }, + "element": { + "label": "element", + "endpoint": "web" + } + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "data": { + "own": [ + { + "id": "db", + "path": "${dir:db}", + "class": "valuable", + "why": "every room, message and account" + } + ] + }, + "config": { + "default_server_name": { + "type": "string", + "description": "Element's default_server_name setting of config.json: the homeserver Element signs in to by default, by name; the file's default is the module's own route.", + "trusted": true, + "default": "${bound:route:name-homeserver}" + }, + "default_server_config": { + "type": "object", + "description": "Element's default_server_config setting of config.json: the homeserver and identity server Element uses by default, by URL.", + "trusted": true, + "default": { + "m.homeserver": { + "base_url": "https://${bound:route:name-homeserver}" + }, + "m.identity_server": { + "base_url": "https://vector.im" + } + } + }, + "brand": { + "type": "string", + "description": "Element's brand setting of config.json: the name Element shows itself as.", + "trusted": true, + "default": "Element" + }, + "integrations_ui_url": { + "type": "string", + "description": "Element's integrations_ui_url setting of config.json: the integration manager's page, a URL.", + "trusted": true, + "default": "https://scalar.vector.im/" + }, + "integrations_rest_url": { + "type": "string", + "description": "Element's integrations_rest_url setting of config.json: the integration manager's API, a URL.", + "trusted": true, + "default": "https://scalar.vector.im/api" + }, + "integrations_widgets_urls": { + "type": "list", + "description": "Element's integrations_widgets_urls setting of config.json: the URLs whose widgets Element trusts as the integration manager's.", + "trusted": true, + "default": [ + "https://scalar.vector.im/_matrix/integrations/v1", + "https://scalar.vector.im/api", + "https://scalar-staging.vector.im/_matrix/integrations/v1", + "https://scalar-staging.vector.im/api", + "https://scalar-staging.riot.im/scalar/api" + ] + }, + "bug_report_endpoint_url": { + "type": "string", + "description": "Element's bug_report_endpoint_url setting of config.json: where Element sends a bug report, a URL; its default here is empty, and the file's own value (Element's, naming outside servers) stays in the file and is never replaced unless this is set.", + "trusted": true, + "default": "" + }, + "uisi_autorageshake_app": { + "type": "string", + "description": "Element's uisi_autorageshake_app setting of config.json: the app name Element files an automatic report of an undecryptable message under.", + "trusted": true, + "default": "element-auto-uisi" + }, + "show_labs_settings": { + "type": "boolean", + "description": "Element's show_labs_settings setting of config.json: whether Element shows its Labs settings.", + "trusted": true, + "default": true + }, + "room_directory": { + "type": "object", + "description": "Element's room_directory setting of config.json: the servers whose room directories Element offers; its default here is empty, and the file's own value (Element's, naming outside servers) stays in the file and is never replaced unless this is set.", + "trusted": true, + "default": {} + }, + "enable_presence_by_hs_url": { + "type": "object", + "description": "Element's enable_presence_by_hs_url setting of config.json: whether Element shows presence, per homeserver URL.", + "trusted": true, + "default": { + "https://matrix.org": false, + "https://matrix-client.matrix.org": false + } + }, + "terms_and_conditions_links": { + "type": "list", + "description": "Element's terms_and_conditions_links setting of config.json: the links Element shows as its terms, each a URL and its text; its default here is empty, and the file's own value (Element's, naming outside servers) stays in the file and is never replaced unless this is set.", + "trusted": true, + "default": [] + }, + "features": { + "type": "object", + "description": "Element's features setting of config.json: Element's feature switches, each on or off.", + "trusted": true, + "default": { + "feature_video_rooms": true, + "feature_rust_crypto": true + } + }, + "element_call": { + "type": "object", + "description": "Element's element_call setting of config.json: where Element starts a call with Element Call, a URL.", + "trusted": true, + "default": { + "url": "https://call.element.dev" + } + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "db", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "conduit-conf", + "type": "file", + "path": "${dir:state}/conduit.toml", + "mode": "0644", + "template": "files/conduit.toml", + "names-on-purpose": { + "matrix.org": "the federation's public key server, trusted by default; the world's, not this mesh's" + } + }, + { + "id": "element-conf", + "type": "file", + "path": "${dir:state}/element.json", + "mode": "0644", + "merge": "json", + "takes-config": ["default_server_name", "default_server_config", "brand", "integrations_ui_url", "integrations_rest_url", "integrations_widgets_urls", "bug_report_endpoint_url", "uisi_autorageshake_app", "show_labs_settings", "room_directory", "enable_presence_by_hs_url", "terms_and_conditions_links", "features", "element_call"], + "template": "files/element.json", + "names-on-purpose": { + "matrix.org": "the public room directory and the federation's largest homeserver; the world's", + "matrix-client.matrix.org": "the same homeserver's client endpoint; the world's", + "vector.im": "Element's public identity server; the world's", + "scalar.vector.im": "Element's public integration manager; the world's", + "scalar-staging.vector.im": "Element's staging integration manager, named by the upstream default config; the world's", + "scalar-staging.riot.im": "the same, under its former name; the world's", + "element.io": "Element's bug reports, privacy and cookie pages; the world's", + "gitter.im": "a public room directory; the world's", + "libera.chat": "a public room directory; the world's", + "call.element.dev": "Element Call's public instance; the world's" + } + }, + { + "id": "net", + "type": "network", + "name": "matrix" + }, + { + "id": "homeserver", + "type": "container", + "name": "matrix", + "image": "matrixconduit/matrix-conduit@sha256:b0d24248e94f944ca49f90f10c429e3d65f4472bdde25661ecea9840134fb133", + "network": "matrix", + "env": { + "CONDUIT_CONFIG": "/etc/conduit/conduit.toml" + }, + "ports": [ + "6167" + ], + "volumes": [ + "${dir:db}:/var/lib/matrix-conduit", + "${dir:state}/conduit.toml:/etc/conduit/conduit.toml:ro" + ], + "restart-on": [ + "conduit-conf" + ] + }, + { + "id": "element", + "type": "container", + "name": "element-web", + "image": "vectorim/element-web@sha256:a8f415462ab8d2600a592ba1b92bea51efe5a4d10eb738aab9bed769f7099613", + "health": { + "kind": "runtime" + }, + "network": "matrix", + "ports": [ + "80" + ], + "volumes": [ + "${dir:state}/element.json:/app/config.json:ro" + ], + "restart-on": [ + "element-conf" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/memory-pressure/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/memory-pressure/module.json.captured new file mode 100644 index 0000000..1af96e3 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/memory-pressure/module.json.captured @@ -0,0 +1,130 @@ +{ + "module": "memory-pressure", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "emits": [ + "pressure.high", + "pressure.cleared" + ], + "tools": [ + "memory_status", + "memory_top", + "memory_oom_history", + "memory_zram", + "memory_oomd", + "memory_guard" + ], + "resources": [ + { + "id": "zram-generator", + "type": "package", + "package": "zram-generator" + }, + { + "id": "zram", + "type": "file", + "path": "/etc/systemd/zram-generator.conf", + "mode": "0644", + "template": "files/zram-generator.conf" + }, + { + "id": "sysctl-drop-ins", + "type": "directory", + "path": "/etc/sysctl.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "swap-tunables", + "type": "file", + "path": "/etc/sysctl.d/90-memory-pressure.conf", + "mode": "0644", + "template": "files/sysctl.conf" + }, + { + "id": "sysctl", + "type": "service", + "unit": "systemd-sysctl.service", + "restart-on": [ + "swap-tunables" + ] + }, + { + "id": "oomd-drop-ins", + "type": "directory", + "path": "/etc/systemd/oomd.conf.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "oomd-limits", + "type": "file", + "path": "/etc/systemd/oomd.conf.d/memory-pressure.conf", + "mode": "0644", + "template": "files/oomd.conf" + }, + { + "id": "root-slice-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/-.slice.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "root-slice", + "type": "file", + "path": "/etc/systemd/system/-.slice.d/10-oomd.conf", + "mode": "0644", + "template": "files/root-slice.conf" + }, + { + "id": "user-manager-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/user@.service.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "user-manager", + "type": "file", + "path": "/etc/systemd/system/user@.service.d/10-oomd.conf", + "mode": "0644", + "template": "files/user-manager.conf" + }, + { + "id": "oomd", + "type": "service", + "unit": "systemd-oomd.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "oomd-limits", + "root-slice", + "user-manager" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/memory-pressure", + "binary": "memory-pressure", + "loads": [ + "memory-pressure" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-build-go/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-build-go/module.json.captured new file mode 100644 index 0000000..9f0fd7f --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-build-go/module.json.captured @@ -0,0 +1,148 @@ +{ + "module": "mesh-build-go", + "version": "1", + "moved": true, + "runs-as": "mesh-build-go", + "capabilities": [ + "package-manager", + "user-namespaces" + ], + "state": [ + "toolchains", + "keys", + "steps" + ], + "tools": [ + "build_go_fetch", + "build_go_test", + "build_go_digest", + "build_go_build", + "build_go_step", + "build_go_steps", + "build_go_toolchains", + "build_go_status", + "build_go_health" + ], + "streams": [ + { + "name": "toolchains-go", + "events": ["toolchain.**"] + } + ], + "requires": [ + "forge-account", + "go-toolchain" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-build-go", + "runtime-health": { + "kind": "tool", + "tool": "build_go_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + }, + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-build-go", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-build-go" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-build-go", + "place": ".", + "unknown": "program" + }, + { + "id": "work", + "type": "directory", + "path": "/var/lib/mesh-build-go-work", + "mode": "0700", + "owner": "mesh-build-go", + "unknown": "program" + }, + { + "id": "cache", + "type": "directory", + "path": "/var/lib/mesh-build-go-cache", + "mode": "0700", + "owner": "mesh-build-go", + "unknown": "program" + }, + { + "id": "ssh-dir", + "type": "directory", + "path": "${dir:state}/.ssh", + "mode": "0700", + "owner": "mesh-build-go", + "unknown": "program" + }, + { + "id": "ssh-config", + "type": "file", + "path": "${dir:ssh-dir}/config", + "owner": "mesh-build-go", + "mode": "0600", + "template": "files/forge" + }, + { + "id": "known-hosts", + "type": "file", + "path": "${dir:ssh-dir}/known_hosts", + "owner": "mesh-build-go", + "mode": "0644", + "template": "files/known_hosts" + }, + { + "id": "git-config", + "type": "file", + "path": "${dir:state}/.config/git/config", + "owner": "mesh-build-go", + "mode": "0644", + "template": "files/gitconfig" + }, + { + "id": "git", + "type": "package", + "package": "git" + }, + { + "id": "openssh", + "type": "package", + "package": "openssh" + }, + { + "id": "postgresql", + "type": "package", + "package": "postgresql" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-build-go", + "binary": "mesh-build-go", + "loads": [ + "mesh-build-go" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-build-node/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-build-node/module.json.captured new file mode 100644 index 0000000..b92212a --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-build-node/module.json.captured @@ -0,0 +1,169 @@ +{ + "module": "mesh-build-node", + "version": "1", + "moved": true, + "runs-as": "mesh-build-node", + "capabilities": [ + "package-manager", + "user-namespaces" + ], + "state": [ + "toolchains", + "keys", + "steps" + ], + "tools": [ + "build_node_fetch", + "build_node_test", + "build_node_build", + "build_node_step", + "build_node_steps", + "build_node_toolchains", + "build_node_status", + "build_node_health" + ], + "streams": [ + { + "name": "toolchains-node", + "events": [ + "toolchain.**" + ] + } + ], + "requires": [ + "forge-account", + "npm-package-registry", + "node-toolchain" + ], + "contributes": { + "npm-package-registry": { + "access": "read" + } + }, + "binds": { + "npm-package-registry": "${dir:state}/package-registry.json" + }, + "secrets": { + "npm-package-registry": "${dir:state}/package-registry.secret" + }, + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-build-node", + "runtime-health": { + "kind": "tool", + "tool": "build_node_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + }, + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-build-node", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-build-node" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-build-node", + "place": ".", + "unknown": "program" + }, + { + "id": "work", + "type": "directory", + "path": "/var/lib/mesh-build-node-work", + "mode": "0700", + "owner": "mesh-build-node", + "unknown": "program" + }, + { + "id": "cache", + "type": "directory", + "path": "/var/lib/mesh-build-node-cache", + "mode": "0700", + "owner": "mesh-build-node", + "unknown": "program" + }, + { + "id": "ssh-dir", + "type": "directory", + "path": "${dir:state}/.ssh", + "mode": "0700", + "owner": "mesh-build-node", + "unknown": "program" + }, + { + "id": "ssh-config", + "type": "file", + "path": "${dir:ssh-dir}/config", + "owner": "mesh-build-node", + "mode": "0600", + "template": "files/forge" + }, + { + "id": "known-hosts", + "type": "file", + "path": "${dir:ssh-dir}/known_hosts", + "owner": "mesh-build-node", + "mode": "0644", + "template": "files/known_hosts" + }, + { + "id": "git-config", + "type": "file", + "path": "${dir:state}/.config/git/config", + "owner": "mesh-build-node", + "mode": "0644", + "template": "files/gitconfig" + }, + { + "id": "toolchain-dir", + "type": "archive", + "path": "${dir:state}/toolchain", + "owner": "mesh-build-node", + "artifact": "toolchain", + "unknown": "program" + }, + { + "id": "git", + "type": "package", + "package": "git" + }, + { + "id": "openssh", + "type": "package", + "package": "openssh" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-build-node", + "binary": "mesh-build-node", + "loads": [ + "mesh-build-node" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + }, + { + "name": "toolchain", + "kind": "archive", + "from": "toolchain" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-catalog/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-catalog/module.json.captured new file mode 100644 index 0000000..8d8c659 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-catalog/module.json.captured @@ -0,0 +1,98 @@ +{ + "module": "mesh-catalog", + "slug": "catalog", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "claims": [ + { + "name": "mesh-catalog", + "scope": "mesh" + } + ], + "requires": [ + "postgres-database" + ], + "contributes": { + "postgres-database": { + "name": "mesh_catalog" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "consumes": [ + "node-build-agent.built", + "mesh-build-machine.built", + "mesh-controller.built-before" + ], + "emits": [ + "registered", + "upgraded", + "rebuild-needed", + "catching-up" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "database-url", + "type": "file", + "path": "${dir:state}/database.url", + "mode": "0600", + "template": "files/database.url" + }, + { + "id": "prepare", + "type": "process", + "name": "mesh-catalog-prepare", + "artifact": "code", + "run": [ + "node", + "prepare/index.js" + ], + "run-once": true, + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "prepare/index.js" + ], + "loads": [ + "index.js", + "tools/index.js" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-cli/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-cli/module.json.captured new file mode 100644 index 0000000..681a3e5 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-cli/module.json.captured @@ -0,0 +1,37 @@ +{ + "module": "mesh-cli", + "version": "1", + "moved": true, + "resources": [ + { + "id": "program", + "type": "archive", + "artifact": "program", + "path": "/usr/local/bin", + "unknown": "program" + } + ], + "items": [ + { + "collection": "shell:zsh:normal", + "template": "files/zsh-normal" + } + ], + "build": { + "artifacts": [ + { + "name": "program", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-cli", + "binary": "mesh-cli" + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-config/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-config/module.json.captured new file mode 100644 index 0000000..e03e891 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-config/module.json.captured @@ -0,0 +1,74 @@ +{ + "module": "mesh-config", + "version": "1", + "runs-as": "mesh-config", + "claims": [ + { + "name": "mesh-config", + "scope": "mesh", + "serves": [ + "config" + ] + } + ], + "emits": [ + "config-changed" + ], + "uses": [ + "operator-channel" + ], + "invokes": [ + "seat:mesh-controller.hand-act", + "seat:mesh-controller.warranted" + ], + "reads": [ + "mesh-controller.assignments", + "mesh-controller.declared-config", + "mesh-controller.predefined" + ], + "state": [ + "values", + "resolved", + "predefined", + "proposals" + ], + "logs": [ + "history" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-config", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-config", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-config" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-config", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-config", + "binary": "mesh-config", + "loads": [ + "mesh-config" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-delivery-gate/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-delivery-gate/module.json.captured new file mode 100644 index 0000000..ec83852 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-delivery-gate/module.json.captured @@ -0,0 +1,110 @@ +{ + "module": "mesh-delivery-gate", + "version": "1", + "slug": "gate", + "claims": [ + { + "name": "mesh-delivery", + "scope": "mesh", + "serves": [ + "deliveries", + "times", + "show", + "groups", + "what-if", + "checks", + "table", + "stalled", + "recheck", + "release", + "stop", + "close", + "retire-history" + ] + } + ], + "consumes": [ + "gitea.pull.updated", + "gitea.pull.merged", + "gitea.pull.closed", + "gitea.forge.status", + "gitea.forge.pull-request.edited", + "gitea.forge.pull-request.closed", + "mesh-controller.checked", + "mesh-controller.plan-moved", + "mesh-delivery-forge-bridge.forge.**" + ], + "emits": [ + "transition", + "group", + "merged" + ], + "invokes": [ + "seat:mesh-controller.deliver", + "seat:mesh-controller.delivery-stop", + "seat:mesh-controller.delivery-backups-again", + "seat:mesh-controller.delivery-walks", + "seat:mesh-controller.delivery-check", + "seat:mesh-controller.forget-checks", + "seat:mesh-controller.plans", + "gitea.gitea_commit_statuses", + "gitea.gitea_contains", + "gitea.gitea_open_pulls" + ], + "state": [ + "deliveries", + "groups", + "pulls", + { + "name": "asks", + "history": 5 + }, + { + "name": "merges", + "history": 10 + }, + { + "name": "owed", + "history": 5 + }, + { + "name": "source", + "history": 64 + } + ], + "reads": [ + "mesh-delivery.deliveries", + "mesh-delivery.groups", + "mesh-delivery.pulls", + "mesh-delivery-teller.answers", + "mesh-controller.assignments", + "node-engine.have" + ], + "tools": [ + "delivery_status" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-gate", + "binary": "mesh-delivery-gate", + "loads": [ + "mesh-delivery-gate" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-files/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-files/module.json.captured new file mode 100644 index 0000000..cc1df16 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-files/module.json.captured @@ -0,0 +1,57 @@ +{ + "module": "mesh-files", + "version": "1", + "claims": [ + { + "name": "node-files", + "scope": "node", + "serves": [ + "stat", + "readlink", + "list", + "read", + "search", + "account", + "write", + "copy", + "move", + "delete", + "mkdir", + "chmod", + "chown", + "trash", + "restore" + ] + } + ], + "invokes": [ + "seat:operator-channel.tell" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-files", + "binary": "mesh-files", + "loads": [ + "mesh-files" + ], + "env": { + "MESH_FILES_STATE": "${dir:state}" + } + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-issues/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-issues/module.json.captured new file mode 100644 index 0000000..dcfc71c --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-issues/module.json.captured @@ -0,0 +1,329 @@ +{ + "module": "mesh-issues", + "version": "1", + "slug": "issues", + "seats": [ + { + "name": "issue-tracker", + "scope": "mesh", + "serves": [ + "open", + "get", + "list", + "move", + "note", + "link", + "import", + "groom", + "set", + "tracking", + "reserve", + "fix-times" + ] + } + ], + "claims": [ + { + "name": "issue-tracker", + "scope": "mesh", + "serves": [ + "open", + "get", + "list", + "move", + "note", + "link", + "import", + "groom", + "set", + "tracking", + "reserve", + "fix-times" + ] + } + ], + "emits": [ + "opened", + "moved", + "noted", + "linked", + "imported", + "set", + "triaged", + "ready", + "ready-to-verify", + "reserved" + ], + "consumes": [ + "mesh-issues.opened", + "mesh-issues.moved", + "mesh-delivery-gate.transition" + ], + "invokes": [ + "claude-code.claude_code_judge", + "seat:mesh-delivery.checks", + "seat:operator-channel.notify", + "gitea.gitea_pull_reviews", + "records.records_status", + "records.records_list", + "records.records_read", + "mesh-knowledge.knowledge_highest" + ], + "logs": [ + "changes" + ], + "state": [ + { + "name": "issues", + "history": 2 + }, + "counters", + "grooming", + "reservations" + ], + "config": { + "first-number": { + "type": "number", + "description": "The number the first issue the tracker numbers gets, read once when its counter starts and never again; the import reads it as the boundary between the folders hq numbered and the tracker's issues.", + "minimum": 1, + "trusted": true, + "default": 365 + }, + "owners": { + "type": "list", + "description": "The registered places an issue's located-in may name: a repository, or a repository and a folder in it separated by a space.", + "trusted": true, + "default": [ + "hq", + "mesh-catalog", + "mesh-catalog modules/adwaita", + "mesh-catalog modules/artifact-store-tools", + "mesh-catalog modules/asus-zephyrus-g14", + "mesh-catalog modules/audit-logger", + "mesh-catalog modules/avahi", + "mesh-catalog modules/baserow", + "mesh-catalog modules/betterbird", + "mesh-catalog modules/blueman", + "mesh-catalog modules/bluetooth", + "mesh-catalog modules/ca-trust", + "mesh-catalog modules/claude-code", + "mesh-catalog modules/claude-licence-manager", + "mesh-catalog modules/clipmenu", + "mesh-catalog modules/confluence", + "mesh-catalog modules/cups", + "mesh-catalog modules/dbus", + "mesh-catalog modules/desk-channel", + "mesh-catalog modules/disk-load", + "mesh-catalog modules/distribution", + "mesh-catalog modules/dmenu", + "mesh-catalog modules/dnsmasq", + "mesh-catalog modules/docker", + "mesh-catalog modules/docker-compose", + "mesh-catalog modules/dunst", + "mesh-catalog modules/fail2ban", + "mesh-catalog modules/feh", + "mesh-catalog modules/flatpak", + "mesh-catalog modules/fonts", + "mesh-catalog modules/forticlient", + "mesh-catalog modules/gitea", + "mesh-catalog modules/gitlab", + "mesh-catalog modules/gnome-keyring", + "mesh-catalog modules/grafana", + "mesh-catalog modules/hello-web", + "mesh-catalog modules/home-assistant", + "mesh-catalog modules/hostname", + "mesh-catalog modules/i3", + "mesh-catalog modules/i3status-rust", + "mesh-catalog modules/icecast", + "mesh-catalog modules/influxdb", + "mesh-catalog modules/invoicing", + "mesh-catalog modules/jira", + "mesh-catalog modules/keycloak", + "mesh-catalog modules/lab", + "mesh-catalog modules/lemurs", + "mesh-catalog modules/letta", + "mesh-catalog modules/local-model-consumer", + "mesh-catalog modules/localization", + "mesh-catalog modules/logrotate", + "mesh-catalog modules/mailu", + "mesh-catalog modules/marrytts", + "mesh-catalog modules/matrix", + "mesh-catalog modules/memory-pressure", + "mesh-catalog modules/mesh-catalog", + "mesh-catalog modules/mesh-cli", + "mesh-catalog modules/mesh-delivery", + "mesh-catalog modules/mesh-delivery-gate", + "mesh-catalog modules/mesh-issues", + "mesh-catalog modules/mesh-vault", + "mesh-catalog modules/mesh-watcher", + "mesh-catalog modules/messenger", + "mesh-catalog modules/minio", + "mesh-catalog modules/model-usage", + "mesh-catalog modules/mongodb", + "mesh-catalog modules/mosquitto", + "mesh-catalog modules/mounts", + "mesh-catalog modules/mssql", + "mesh-catalog modules/n8n", + "mesh-catalog modules/nats", + "mesh-catalog modules/netcheck", + "mesh-catalog modules/networkmanager", + "mesh-catalog modules/nextcloud", + "mesh-catalog modules/nextcloud-client", + "mesh-catalog modules/nfs-server", + "mesh-catalog modules/nftables", + "mesh-catalog modules/nm-applet", + "mesh-catalog modules/node-env", + "mesh-catalog modules/nodered", + "mesh-catalog modules/ollama", + "mesh-catalog modules/only-office", + "mesh-catalog modules/openai-consumer", + "mesh-catalog modules/openrazer", + "mesh-catalog modules/pacman", + "mesh-catalog modules/photos-eef", + "mesh-catalog modules/photos-filip", + "mesh-catalog modules/picom", + "mesh-catalog modules/polychromatic", + "mesh-catalog modules/postgres", + "mesh-catalog modules/power", + "mesh-catalog modules/powerlevel10k", + "mesh-catalog modules/records", + "mesh-catalog modules/redis", + "mesh-catalog modules/restic", + "mesh-catalog modules/rofi", + "mesh-catalog modules/route-adapter", + "mesh-catalog modules/screen-lock", + "mesh-catalog modules/searxng", + "mesh-catalog modules/sensors", + "mesh-catalog modules/showcase", + "mesh-catalog modules/slack", + "mesh-catalog modules/snapd", + "mesh-catalog modules/ssh-client", + "mesh-catalog modules/sshd", + "mesh-catalog modules/step-ca", + "mesh-catalog modules/sudo", + "mesh-catalog modules/supabase", + "mesh-catalog modules/systemd", + "mesh-catalog modules/systemd-networkd", + "mesh-catalog modules/systemd-resolved", + "mesh-catalog modules/telegram", + "mesh-catalog modules/time-sync", + "mesh-catalog modules/triggerhappy", + "mesh-catalog modules/umami", + "mesh-catalog modules/unifi", + "mesh-catalog modules/website", + "mesh-catalog modules/xclip", + "mesh-catalog modules/xdg", + "mesh-catalog modules/xorg", + "mesh-catalog modules/xterm", + "mesh-catalog modules/zfs", + "mesh-catalog modules/zsh", + "mesh-catalog modules/zsh-autosuggestions", + "mesh-catalog modules/zsh-syntax-highlighting", + "mesh-controller", + "mesh-controller modules/build-agent", + "mesh-controller modules/route-proxy", + "mesh-delivery", + "mesh-host", + "mesh-lab", + "mesh-media-catalog", + "mesh-sdk", + "mesh-tools" + ] + }, + "duplicate-check": { + "type": "string", + "description": "Whether a new issue is checked against the open ones for a duplicate: off stops it, anything else is on (novox/hq ADR 0278 §7).", + "trusted": true, + "default": "on" + }, + "judge-model": { + "type": "string", + "description": "The model the duplicate check asks, by the agent's name for it, such as sonnet; empty is the tracker's own default.", + "trusted": true, + "default": "sonnet" + }, + "forge-owner": { + "type": "string", + "description": "The owner on the forge of the repositories an issue's fixed-by names, for asking after a pull request's delivery (novox/hq ADR 0279 §3); empty asks no forge.", + "trusted": true, + "default": "novox" + }, + "stuck-after": { + "type": "string", + "description": "How long a fix may be merged and not delivered before it is said stuck, as a duration such as 2h (novox/hq ADR 0281); empty is the tracker's own default.", + "pattern": "|[0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))*", + "trusted": true, + "default": "2h" + }, + "worker-stale-after": { + "type": "string", + "description": "How long an issue's worker stays current with nothing done on it, as a duration such as 24h (novox/hq ADR 0284 §4); empty is the tracker's own default.", + "pattern": "|[0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))*", + "trusted": true, + "default": "24h" + }, + "delivered-not-resolved-after": { + "type": "string", + "description": "How long an issue may stay in fixing once every fix it names is delivered before the operator is warned, as a duration such as 1h (novox/hq issue 483); empty is the tracker's own default.", + "pattern": "|[0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))*", + "trusted": true, + "default": "1h" + }, + "decision-floor": { + "type": "number", + "description": "The highest decision number already taken somewhere the tracker cannot read, such as an open branch of hq; the decision sequence's first number is one above it, read once when the counter starts (novox/hq issue 424).", + "minimum": 0, + "trusted": true, + "default": 293 + }, + "research-floor": { + "type": "number", + "description": "The highest research number already taken somewhere the tracker cannot read; the research sequence's first number is one above it, read once when the counter starts (novox/hq issue 424).", + "minimum": 0, + "trusted": true, + "default": 45 + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "settings", + "trusted": true, + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "takes-config": ["first-number", "owners", "duplicate-check", "judge-model", "forge-owner", "stuck-after", "worker-stale-after", "delivered-not-resolved-after", "decision-floor", "research-floor"], + "merge": "json", + "template": "files/settings.json" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-issues", + "binary": "mesh-issues", + "loads": [ + "mesh-issues" + ], + "env": { + "MESH_ISSUES_SETTINGS": "${dir:state}/settings.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-knowledge/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-knowledge/module.json.captured new file mode 100644 index 0000000..92b15f2 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-knowledge/module.json.captured @@ -0,0 +1,218 @@ +{ + "module": "mesh-knowledge", + "version": "1", + "slug": "knowledge", + "requires": [ + "postgres-database" + ], + "contributes": { + "postgres-database": { + "name": "knowledge" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "seats": [ + { + "name": "knowledge-base", + "scope": "mesh", + "serves": [ + "ask", + "get", + "list", + "findings", + "holds", + "links", + "history", + "reserve", + "propose", + "import", + "return", + "withdraw" + ] + } + ], + "claims": [ + { + "name": "knowledge-base", + "scope": "mesh", + "serves": [ + "ask", + "get", + "list", + "findings", + "holds", + "links", + "history", + "reserve", + "propose", + "import", + "return", + "withdraw" + ] + } + ], + "tools": [ + "knowledge_health", + "knowledge_highest" + ], + "emits": [ + "reserved", + "proposed", + "reviewed", + "accepted", + "changed", + "imported", + "drafted", + "confirmed", + "returned" + ], + "uses": [ + "operator-channel" + ], + "invokes": [ + "seat:issue-tracker.reserve", + "claude-code.claude_code_judge", + "claude-code.claude_code_judged", + "records.records_status", + "records.records_list", + "records.records_read", + "seat:operator-channel.notify" + ], + "config": { + "judge-model": { + "type": "string", + "description": "The model the knowledge base's drafting and confirming judgements ask, by the agent's name for it, such as sonnet; empty is sonnet.", + "default": "sonnet" + }, + "drafting": { + "type": "string", + "description": "Whether the knowledge base drafts and confirms clauses itself: off stops it, anything else is on.", + "default": "on" + }, + "reserved-for": { + "type": "string", + "description": "How long a reserved number waits for its proposal before it is withdrawn, as a duration such as 168h; empty is the knowledge base's own default.", + "pattern": "|[0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))*", + "default": "168h" + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "database-url", + "type": "file", + "path": "${dir:state}/database.url", + "mode": "0600", + "template": "files/database.url" + }, + { + "id": "settings", + "trusted": false, + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "takes-config": ["judge-model", "drafting", "reserved-for"], + "merge": "json", + "template": "files/settings.json" + }, + { + "id": "prepare", + "type": "process", + "name": "mesh-knowledge-prepare", + "artifact": "tools", + "run": [ + "./mesh-knowledge", + "prepare" + ], + "run-once": true, + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ] + }, + { + "id": "keep", + "type": "process", + "name": "mesh-knowledge-keep", + "artifact": "tools", + "run": [ + "./mesh-knowledge", + "keep" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "watch", + "type": "process", + "name": "mesh-knowledge-watch", + "artifact": "tools", + "run": [ + "./mesh-knowledge", + "watch" + ], + "user": "${machine:account}", + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_KNOWLEDGE_STATUS": "${dir:state}/health.json" + }, + "restart-on": [ + "database-url" + ], + "health": { + "kind": "tool", + "tool": "knowledge_health", + "needs": "postgres-database", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "180s" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-knowledge", + "binary": "mesh-knowledge", + "loads": [ + "mesh-knowledge" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_KNOWLEDGE_SETTINGS": "${dir:state}/settings.json", + "MESH_KNOWLEDGE_STATUS": "${dir:state}/health.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-mail-notifier/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-mail-notifier/module.json.captured new file mode 100644 index 0000000..9afba46 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-mail-notifier/module.json.captured @@ -0,0 +1,68 @@ +{ + "module": "mesh-mail-notifier", + "version": "1", + "runs-as": "mesh-mail-notifier", + "claims": [ + { + "name": "mail-notifier", + "scope": "node", + "serves": ["status"] + } + ], + "consumes": [ + "mesh-mail.mail.*.message.added" + ], + "invokes": [ + "seat:node-notifier.send", + "seat:mail-store.accounts" + ], + "emits": [ + "shown" + ], + "state": [ + { + "name": "shown", + "ttl-seconds": 2592000 + } + ], + "tools": [ + "mail-notifier.status", + "mail_notifier_health" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-mail-notifier", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-mail-notifier", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-mail-notifier" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-mail-notifier", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-mail-notifier", + "binary": "mesh-mail-notifier", + "loads": [ + "mesh-mail-notifier" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-mail-reader/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-mail-reader/module.json.captured new file mode 100644 index 0000000..b83e8bb --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-mail-reader/module.json.captured @@ -0,0 +1,141 @@ +{ + "module": "mesh-mail-reader", + "version": "1", + "runs-as": "mesh-mail-reader", + "claims": [ + { + "name": "mail-reading", + "scope": "mesh", + "serves": ["entries", "groups", "correct", "lessons", "undo", "status"] + } + ], + "consumes": [ + "mesh-mail.mail.*.message.added" + ], + "invokes": [ + "seat:mail-store.read", + "seat:mail-store.accounts", + "claude-code.claude_code_judge", + "claude-code.claude_code_judged", + "messenger.messenger_check", + "seat:operator-channel.tell" + ], + "requires": ["postgres-database"], + "contributes": { + "postgres-database": { + "name": "mail-reader" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "config": { + "callers": { + "type": "list", + "description": "Who may call one of mail-reading's six verbs (entries, groups, correct, lessons, undo, status): a module's name (on every machine), ., or person. (novox/hq ADR 0459 §2). mesh-mail-web alone by default.", + "trusted": true, + "default": ["mesh-mail-web"] + } + }, + "tools": [ + "mail-reading.entries", + "mail-reading.groups", + "mail-reading.correct", + "mail-reading.lessons", + "mail-reading.undo", + "mail-reading.status", + "mail_reader_health" + ], + "runtime-health": { + "kind": "tool", + "tool": "mail_reader_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + }, + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-mail-reader", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-mail-reader", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-mail-reader" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-mail-reader", + "place": ".", + "unknown": "program" + }, + { + "id": "callers", + "trusted": true, + "type": "file", + "path": "${dir:state}/callers.json", + "mode": "0600", + "owner": "mesh-mail-reader", + "merge": "json", + "template": "files/callers.json", + "takes-config": ["callers"] + }, + { + "id": "database-url", + "type": "file", + "path": "${dir:state}/database.url", + "mode": "0600", + "owner": "mesh-mail-reader", + "template": "files/database-url" + }, + { + "id": "prepare", + "type": "process", + "name": "mesh-mail-reader-prepare", + "artifact": "tools", + "run": [ + "./mesh-mail-reader", + "prepare" + ], + "run-once": true, + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-mail-reader", + "binary": "mesh-mail-reader", + "loads": [ + "mesh-mail-reader" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_MAIL_READER_CALLERS": "${dir:state}/callers.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-mail-web/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-mail-web/module.json.captured new file mode 100644 index 0000000..c6c0d3d --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-mail-web/module.json.captured @@ -0,0 +1,131 @@ +{ + "module": "mesh-mail-web", + "version": "1", + "slug": "mail-web", + "runs-as": "mesh-mail-web", + "claims": [ + { + "name": "mail-webclient", + "scope": "mesh", + "serves": ["open", "status"] + } + ], + "invokes": [ + "seat:mail-store.accounts", + "seat:mail-store.search", + "seat:mail-store.read", + "seat:mail-store.send", + "seat:mail-store.send-status", + "seat:mail-reading.entries", + "seat:mail-reading.groups", + "seat:mail-reading.status", + "seat:mail-reading.correct", + "seat:mail-reading.lessons", + "seat:mail-reading.undo" + ], + "reads": ["mesh-mail.accounts"], + "requires": ["route", "oidc-client"], + "contributes": { + "route": { + "label": "mail", + "endpoint": "web" + }, + "oidc-client": { + "label": "mail", + "endpoint": "web", + "callback": "/signed-in" + } + }, + "binds": { + "route": "${dir:state}/route.json", + "oidc-client": "${dir:state}/oidc-bound.json" + }, + "secrets": { + "oidc-client": "${dir:state}/oidc-bound.secret" + }, + "listens": [ + { + "name": "web", + "port": 8099, + "protocol": "tcp", + "from": "mesh", + "why": "the mail web client's pages, over http; only the route proxy reaches it, and the operator's own browser through the internal name (novox/hq ADR 0440 §2) — its public name, if any, is a route grant" + } + ], + "config": { + "operators": { + "type": "list", + "description": "The Keycloak identities (the ID token's preferred_username) let to sign in; nobody by default (novox/hq ADR 0440 §3).", + "trusted": true, + "default": [] + } + }, + "tools": [ + "mail-webclient.open", + "mail-webclient.status", + "mail_web_health" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-mail-web", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-mail-web", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-mail-web" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-mail-web", + "place": ".", + "unknown": "program" + }, + { + "id": "operators", + "trusted": true, + "type": "file", + "path": "${dir:state}/operators.json", + "mode": "0600", + "owner": "mesh-mail-web", + "merge": "json", + "template": "files/operators.json", + "takes-config": ["operators"] + }, + { + "id": "oidc-config", + "type": "file", + "path": "${dir:state}/oidc.json", + "mode": "0600", + "owner": "mesh-mail-web", + "template": "files/oidc.json" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-mail-web", + "binary": "mesh-mail-web", + "loads": ["mesh-mail-web"], + "env": { + "MESH_MAIL_WEB_OPERATORS": "${dir:state}/operators.json", + "MESH_MAIL_WEB_OIDC": "${dir:state}/oidc.json", + "MESH_MAIL_WEB_LISTEN": "${port:8099}" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-mail/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-mail/module.json.captured new file mode 100644 index 0000000..735fbc5 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-mail/module.json.captured @@ -0,0 +1,317 @@ +{ + "module": "mesh-mail", + "version": "1", + "slug": "mail", + "seats": [ + { + "name": "mail-store", + "scope": "mesh", + "serves": ["accounts", "search", "read", "send", "send-status"] + }, + { + "name": "mail-webclient", + "scope": "mesh", + "serves": ["open", "status"] + }, + { + "name": "mail-notifier", + "scope": "node", + "needs": ["node-notifier"], + "serves": ["status"] + }, + { + "name": "mail-reading", + "scope": "mesh", + "serves": ["entries", "groups", "correct", "lessons", "undo", "status"] + } + ], + "claims": [ + { + "name": "mail-store", + "scope": "mesh", + "serves": ["accounts", "search", "read", "send", "send-status"] + } + ], + "requires": [ + "postgres-database", + "s3-bucket" + ], + "contributes": { + "postgres-database": { + "name": "mail" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "s3-bucket": "${dir:state}/store-binding.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret", + "s3-bucket": "${dir:state}/store.secret" + }, + "own-secrets": { + "mail-credential-*": { + "path": "${dir:state}/mail-credential-*.secret", + "issued-by": "outside", + "prompt": "the password of the mail account {member}", + "owner": "mesh-mail-send" + }, + "broker-send": { + "path": "${dir:state}/broker-send.secret", + "owner": "mesh-mail-send" + } + }, + "config": { + "accounts": { + "type": "object", + "description": "The mail accounts, each by a lower-case name that names it on the bus and its secret: its kind (imap), address, incoming and outgoing servers (host, port, security tls or starttls), and optionally the folders watched and the user it signs in as; no password, which is the own secret mail-credential- (novox/hq ADR 0359 §5).", + "trusted": true, + "default": {} + }, + "grants": { + "type": "object", + "description": "Per account, who may read its mail and who may send from it: a module's name (on every machine), ., or person.. Nobody by default; it restricts the stream MAIL and the tools mail_search, mail_read and mail_send alike (novox/hq ADR 0359 §4).", + "trusted": true, + "default": {} + } + }, + "tools": [ + "mail_accounts", + "mail_search", + "mail_read", + "mail_health", + "mail_send", + "mail_send_status", + "mail-store.accounts", + "mail-store.search", + "mail-store.read", + "mail-store.send", + "mail-store.send-status" + ], + "uses": [ + "operator-channel" + ], + "state": [ + "sends", + "accounts", + "mails" + ], + "emits": [ + "mail.send.sent", + "mail.send.failed" + ], + "streams": [ + { + "name": "mail", + "events": [ + "mail.*.message.added", + "mail.*.message.changed", + "mail.*.message.removed" + ], + "max-age-days": 7, + "grant": { + "config": "grants", + "events": "mail.{key}.**" + } + }, + { + "name": "mail-sends", + "events": [ + "mail.send.approved" + ], + "max-age-days": 1, + "max-mib": 64, + "retention": "work-queue", + "max-deliveries": 5, + "duplicates": 24 + } + ], + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-mail-send" + }, + { + "id": "state", + "type": "directory", + "mode": "0711", + "place": ".", + "unknown": "program" + }, + { + "id": "database-url", + "type": "file", + "path": "${dir:state}/database.url", + "mode": "0600", + "template": "files/database-url" + }, + { + "id": "store", + "type": "file", + "path": "${dir:state}/store.json", + "mode": "0600", + "template": "files/store.json" + }, + { + "id": "accounts", + "trusted": true, + "type": "file", + "path": "${dir:state}/accounts.json", + "mode": "0644", + "merge": "json", + "template": "files/accounts.json", + "takes-config": [ + "accounts" + ] + }, + { + "id": "grants", + "trusted": true, + "type": "file", + "path": "${dir:state}/grants.json", + "mode": "0600", + "merge": "json", + "template": "files/grants.json", + "takes-config": [ + "grants" + ] + }, + { + "id": "prepare", + "type": "process", + "name": "mesh-mail-prepare", + "artifact": "tools", + "run": [ + "./mesh-mail", + "prepare" + ], + "run-once": true, + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ] + }, + { + "id": "keep", + "type": "process", + "name": "mesh-mail-keep", + "artifact": "tools", + "run": [ + "./mesh-mail", + "keep" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json", + "MESH_MAIL_SECRETS": "${dir:state}", + "MESH_MAIL_STORE": "${dir:state}/store.json" + }, + "restart-on": [ + "database-url", + "store", + "accounts" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "watch", + "type": "process", + "name": "mesh-mail-watch", + "artifact": "tools", + "run": [ + "./mesh-mail", + "watch" + ], + "user": "${machine:account}", + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json", + "MESH_MAIL_SECRETS": "${dir:state}", + "MESH_MAIL_STATUS": "${dir:state}/health.json" + }, + "restart-on": [ + "database-url", + "accounts" + ], + "health": { + "kind": "tool", + "tool": "mail_health", + "needs": "postgres-database", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "180s" + } + }, + { + "id": "send", + "type": "process", + "name": "mesh-mail-send", + "artifact": "tools", + "run": [ + "./mesh-mail", + "send" + ], + "user": "mesh-mail-send", + "env": { + "MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json", + "MESH_MAIL_SECRETS": "${dir:state}" + }, + "restart-on": [ + "accounts" + ], + "bus": { + "emits": [ + "mail.send.sent", + "mail.send.failed" + ], + "queues": [ + "mail-sends" + ], + "state": [ + "sends" + ], + "asks": true, + "invokes": [ + "seat:operator-channel.tell" + ] + }, + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-mail", + "binary": "mesh-mail", + "loads": [ + "mesh-mail" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json", + "MESH_MAIL_GRANTS": "${dir:state}/grants.json", + "MESH_MAIL_SECRETS": "${dir:state}", + "MESH_MAIL_STORE": "${dir:state}/store.json", + "MESH_MAIL_STATUS": "${dir:state}/health.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-provisioner-client/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-provisioner-client/module.json.captured new file mode 100644 index 0000000..095128a --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-provisioner-client/module.json.captured @@ -0,0 +1,60 @@ +{ + "module": "mesh-provisioner-client", + "version": "1", + "claims": [ + { + "name": "node-provisioner", + "scope": "node" + } + ], + "own-secrets": { + "provisioner-broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-provisioner-client", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-provisioner-client", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-provisioner-client" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-provisioner-client", + "place": ".", + "unknown": "program" + }, + { + "id": "client", + "type": "process", + "name": "mesh-provisioner-client", + "artifact": "client", + "run": [ + "./mesh-provisioner-client" + ], + "env": { + "MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker", + "MESH_PROVISIONER_STATE": "${dir:state}" + }, + "user": "mesh-provisioner-client", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "client", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-provisioner-client", + "binary": "mesh-provisioner-client" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-provisioner/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-provisioner/module.json.captured new file mode 100644 index 0000000..650425b --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-provisioner/module.json.captured @@ -0,0 +1,56 @@ +{ + "module": "mesh-provisioner", + "version": "1", + "runs-as": "mesh-provisioner", + "claims": [ + { + "name": "mesh-provisioner", + "scope": "mesh", + "serves": [ + "provisioning" + ] + } + ], + "state": [ + "registry", + "needs", + "applied", + "collect" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-provisioner", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-provisioner", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-provisioner" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-provisioner", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-provisioner", + "binary": "mesh-provisioner", + "loads": [ + "mesh-provisioner" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-reads/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-reads/module.json.captured new file mode 100644 index 0000000..1806275 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-reads/module.json.captured @@ -0,0 +1,55 @@ +{ + "module": "mesh-reads", + "version": "1", + "runs-as": "mesh-reads", + "claims": [ + { + "name": "node-reads", + "scope": "node", + "serves": [ + "stat", + "readlink", + "list", + "read", + "search", + "account" + ] + } + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-reads", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-reads", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-reads" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-reads", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-reads", + "binary": "mesh-reads", + "loads": [ + "mesh-reads" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-vault/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-vault/module.json.captured new file mode 100644 index 0000000..44944c9 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-vault/module.json.captured @@ -0,0 +1,173 @@ +{ + "module": "mesh-vault", + "version": "1", + "runs-as": "mesh-vault", + "provides": [ + { + "name": "secret", + "scope": "mesh", + "identity": { + "in": "a vault entry" + } + } + ], + "emits": [ + "provisioned", + "rotated", + "secret-changed" + ], + "reads": [ + "mesh-controller.assignments" + ], + "receives": { + "secret": "${dir:grants}/mesh.json" + }, + "grants": { + "secret": "${dir:grants}" + }, + "keeps": "/var/lib/mesh-vault/root", + "data": { + "own": [ + { + "id": "state", + "path": "${dir:state}", + "class": "valuable", + "why": "the vault's own keys" + }, + { + "id": "ledger", + "path": "${dir:ledger}", + "class": "valuable", + "why": "every secret the vault keeps" + }, + { + "id": "root", + "path": "${dir:root}", + "class": "valuable", + "why": "the vault's root material" + }, + { + "id": "store", + "path": "${dir:store}", + "class": "valuable", + "why": "every secret the vault holds, sealed to its own key and to the operator key" + } + ], + "consumers": { + "secret": { + "class": "valuable", + "in": "ledger", + "why": "a secret given to a consumer is kept nowhere else in the clear" + } + } + }, + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-vault", + "runtime-health": { + "kind": "tool", + "tool": "vault_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + }, + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-vault", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-vault" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-vault", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "owner": "mesh-vault", + "unknown": "program" + }, + { + "id": "ledger", + "type": "directory", + "mode": "0700", + "owner": "mesh-vault", + "unknown": "program" + }, + { + "id": "root", + "type": "directory", + "mode": "0700", + "owner": "mesh-vault", + "unknown": "program" + }, + { + "id": "store", + "type": "directory", + "mode": "0700", + "owner": "mesh-vault", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-vault", + "binary": "mesh-vault", + "loads": [ + "mesh-vault" + ], + "env": { + "MESH_RECEIVES": "${dir:grants}/mesh.json", + "MESH_VAULT_LEDGER": "${dir:ledger}", + "MESH_VAULT_ROOT": "${dir:root}", + "MESH_VAULT_STORE": "${dir:store}", + "MESH_VAULT_STATE": "${dir:state}" + } + } + ] + }, + "tools": [ + "secret_export", + "secret_holders", + "secret_verify", + "secret_handed", + "secret_asks", + "secret_vault_export", + "provisioner_retirement", + "provisioner_retire_approve", + "provisioner_retire_reject", + "provisioner_delete", + "vault_status", + "vault_health" + ], + "claims": [ + { + "name": "mesh-vault", + "scope": "mesh", + "serves": [ + "get", + "accept", + "secret-ask", + "issued", + "rotate", + "move-in", + "node-key", + "move-asks" + ] + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-watcher/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-watcher/module.json.captured new file mode 100644 index 0000000..e9543a5 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mesh-watcher/module.json.captured @@ -0,0 +1,71 @@ +{ + "module": "mesh-watcher", + "version": "1", + "slug": "watch", + "consumes": [ + "mesh-controller.doctor-heartbeat" + ], + "invokes": [ + "mesh-watcher.watcher_ping", + "seat:mesh-controller.seats" + ], + "own-secrets": { + "telegram-token": "${dir:state}/telegram-token" + }, + "tools": [ + "watcher_status", + "watcher_last_heard", + "watcher_test", + "watcher_ping" + ], + "config": { + "telegram-chat-id": { + "type": "string", + "description": "The Telegram chat the watcher reports to: the chat's id, a number, or @name of a channel; empty reports to none.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "takes-config": ["telegram-chat-id"], + "merge": "json", + "template": "files/settings.json" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-watcher", + "binary": "mesh-watcher", + "loads": [ + "mesh-watcher" + ], + "env": { + "MESH_WATCHER_SETTINGS": "${dir:state}/settings.json", + "MESH_WATCHER_TELEGRAM_TOKEN_FILE": "${dir:state}/telegram-token" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/messenger/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/messenger/module.json.captured new file mode 100644 index 0000000..6045195 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/messenger/module.json.captured @@ -0,0 +1,196 @@ +{ + "module": "messenger", + "version": "1", + "slug": "msgr", + "runs-as": "messenger", + "seats": [ + { + "name": "operator-channel", + "scope": "mesh", + "serves": [ + "open", + "history", + "notify", + "tell", + "asks" + ], + "accepts": [ + "ask", + "cancel", + "condition" + ], + "emits": [ + "decided" + ], + "by-caller": [ + "ask", + "cancel", + "condition", + "decided" + ], + "records": [ + "asks" + ] + }, + { + "name": "channel", + "scope": "mesh", + "kinded": true, + "accepts": [ + "show", + "edit", + "send" + ] + }, + { + "name": "intake", + "scope": "mesh", + "kinded": true, + "emits": [ + "choice", + "link", + "failed", + "standing" + ], + "proofs": [ + "code" + ] + } + ], + "claims": [ + { + "name": "operator-channel", + "scope": "mesh", + "serves": [ + "open", + "history", + "notify", + "tell", + "asks" + ] + } + ], + "uses": [ + "channel" + ], + "consumes": [ + "mesh-controller.condition-raised", + "mesh-controller.condition-changed", + "mesh-controller.condition-cleared", + "mesh-controller.plan-moved", + "intake.choice", + "intake.link", + "intake.failed", + "intake.standing" + ], + "emits": [ + "refused" + ], + "invokes": [ + "seat:mesh-controller.conditions", + "seat:mesh-controller.root-free", + "seat:issue-tracker.tracking" + ], + "state": [ + "open", + "sent", + { + "name": "asks", + "ttl-seconds": 2592000 + }, + "identities", + "walks" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "messenger", + "tools": [ + "messenger_status", + "messenger_recent", + "messenger_test", + "messenger_preview", + "messenger_check", + "messenger_identities", + "messenger_unlink" + ], + "config": { + "time-zone": { + "type": "string", + "description": "The time zone every time a message says is said in, an IANA name such as Europe/Brussels; empty is this machine's own zone.", + "trusted": true, + "default": "" + }, + "desktop-machines": { + "type": "list", + "description": "The machines whose desktop showed the operator's messages, by node name. Nothing in this module reads it since the desk channel took the desktop over; it is taken so the file stays as a layer that still sets it makes it, until that layer drops it.", + "trusted": true, + "default": [] + }, + "only-what-needs-me": { + "type": "list", + "description": "The kinds of channel set to say only what needs the operator: a walk that delivered with nothing held and nothing open is not told there (ADR 0308 §4); empty is none.", + "trusted": true, + "default": [] + }, + "messages-per-hour": { + "type": "number", + "description": "messages per channel per hour before the rest are held as one; 0 turns the pause off. The tell verb has its own cap of 10 and is not counted here.", + "minimum": 0, + "maximum": 1000, + "trusted": false, + "default": 100 + } + }, + "resources": [ + { + "id": "account", + "type": "user", + "name": "messenger", + "shell": "/usr/bin/nologin", + "home": "/var/lib/messenger" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "owner": "messenger", + "unknown": "program" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "takes-config": ["time-zone", "desktop-machines", "only-what-needs-me", "messages-per-hour"], + "merge": "json", + "template": "files/settings.json", + "owner": "messenger" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/messenger", + "binary": "messenger", + "loads": [ + "messenger" + ], + "env": { + "MESH_MESSENGER_SETTINGS": "${dir:state}/settings.json", + "MESH_MESSENGER_STATE": "${dir:state}" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/minio/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/minio/module.json.captured new file mode 100644 index 0000000..85b8253 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/minio/module.json.captured @@ -0,0 +1,188 @@ +{ + "module": "minio", + "version": "1", + "upgrade": { + "policy": "record", + "why": "holds the photos themselves (irreplaceable, kept by photos) for its consumers: a person takes each build, after a backup (hq ADR 0236)" + }, + "provides": [ + { + "name": "s3-bucket", + "scope": "mesh", + "identity": { + "max": 20, + "in": "an S3 access key" + } + } + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "api": { + "label": "files-api", + "endpoint": "s3" + }, + "console": { + "label": "files", + "endpoint": "console" + } + } + }, + "capabilities": [ + "container-runtime" + ], + "emits": [ + "bucket.created", + "bucket.removed" + ], + "listens": [ + { + "name": "s3", + "port": 9000, + "protocol": "tcp", + "from": "mesh", + "why": "the S3 endpoint" + }, + { + "name": "console", + "port": 9001, + "protocol": "tcp", + "from": "mesh", + "why": "the admin console" + } + ], + "serves": { + "s3-bucket": { + "scheme": "http", + "region": "eu-west", + "port": 9000, + "bucket": "${consumer:as:dns}" + } + }, + "receives": { + "s3-bucket": "${dir:grants}/mesh.json" + }, + "grants": { + "s3-bucket": "${dir:grants}" + }, + "own-secrets": { + "root": "${dir:state}/root.secret" + }, + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "why": "every consumer's bucket and its objects" + } + ], + "consumers": { + "s3-bucket": { + "class": "valuable", + "in": "data", + "why": "a consumer's objects are the only copy of what it stored; a consumer that keeps something irreplaceable here says so (kept-by)" + } + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "root-env", + "type": "file", + "path": "${dir:state}/root.env", + "mode": "0600", + "template": "files/root.env" + }, + { + "id": "data", + "type": "directory", + "path": "/var/lib/minio-store", + "mode": "0700", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "minio-net" + }, + { + "id": "server", + "type": "container", + "name": "minio", + "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", + "network": "minio-net", + "args": [ + "server", + "/data", + "--console-address", + ":9001" + ], + "env-file": [ + "${dir:state}/root.env" + ], + "ports": [ + "9000", + "9001" + ], + "volumes": [ + "/var/lib/minio-store:/data", + "${dir:state}/root.secret:/run/secrets/root:ro" + ], + "env": { + "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", + "MINIO_REGION": "eu-west" + } + }, + { + "id": "client", + "type": "package", + "package": "minio-client" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_MINIO_ENDPOINT": "http://127.0.0.1:${port:9000}", + "MESH_MINIO_ROOT_USER": "meshroot", + "MESH_MINIO_ROOT_PASSWORD_FILE": "${dir:state}/root.secret", + "MESH_MINIO_REGION": "eu-west", + "MESH_MINIO_MC_BIN": "mcli", + "MESH_MINIO_MC_CONFIG": "${dir:state}/mc", + "MESH_RECEIVES": "${dir:grants}/mesh.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/model-usage/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/model-usage/module.json.captured new file mode 100644 index 0000000..50743a3 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/model-usage/module.json.captured @@ -0,0 +1,107 @@ +{ + "module": "model-usage", + "version": "1", + "slug": "usage", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "postgres-database" + ], + "contributes": { + "postgres-database": { + "name": "model_usage" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "consumes": [ + "*.usage.*" + ], + "data": { + "own": [ + { + "id": "state", + "path": "${dir:state}", + "class": "cache", + "why": "the mesh's own files for it; its records are in its database" + }, + { + "id": "spool", + "path": "${dir:spool}", + "class": "valuable", + "why": "usage events the store could not take yet; after the bus gives one up, the only copy" + } + ] + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "database-url", + "type": "file", + "path": "${dir:state}/database.url", + "mode": "0600", + "template": "files/database.url" + }, + { + "id": "spool", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "prepare", + "type": "process", + "name": "model-usage-prepare", + "artifact": "code", + "run": [ + "node", + "prepare/index.js" + ], + "run-once": true, + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "prepare/index.js" + ], + "loads": [ + "index.js", + "tools/index.js" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "USAGE_SPOOL": "${dir:spool}" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mongodb/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mongodb/module.json.captured new file mode 100644 index 0000000..cbb1268 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mongodb/module.json.captured @@ -0,0 +1,170 @@ +{ + "module": "mongodb", + "version": "1", + "upgrade": { + "policy": "record", + "why": "a provider whose restart drops every consumer on its machine, and which holds the photos' albums (irreplaceable, kept by photos): a person takes each build, after a backup (hq ADR 0236)" + }, + "provides": [ + { + "name": "mongodb-database", + "scope": "mesh", + "identity": { + "max": 63, + "in": "a MongoDB database name" + } + } + ], + "capabilities": [ + "container-runtime" + ], + "emits": [ + "database.provisioned", + "database.deprovisioned" + ], + "consumes": [ + "mongodb.database.provisioned", + "mongodb.database.deprovisioned" + ], + "listens": [ + { + "name": "database", + "port": 27017, + "protocol": "tcp", + "from": "mesh", + "why": "modules on any machine that were granted a database" + } + ], + "serves": { + "mongodb-database": { + "port": 27017 + } + }, + "receives": { + "mongodb-database": "${dir:grants}/mesh.json" + }, + "grants": { + "mongodb-database": "${dir:grants}" + }, + "own-secrets": { + "root": "${dir:state}/root.secret" + }, + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "backup": { + "dump": "docker exec mongodb-server sh -c 'printf \"password: %s\\n\" \"$(cat /run/secrets/root)\" > /tmp/.backup.yaml && mongodump --quiet --config /tmp/.backup.yaml --username root --authenticationDatabase admin --archive; s=$?; rm -f /tmp/.backup.yaml; exit $s' > ${dir:dumps}/all.archive.partial && mv ${dir:dumps}/all.archive.partial ${dir:dumps}/all.archive", + "into": "dumps" + }, + "why": "every consumer's database; copied by the dump, not as live files" + }, + { + "id": "dumps", + "path": "${dir:dumps}", + "class": "rebuildable", + "why": "last night's dump, made again every night" + } + ], + "consumers": { + "mongodb-database": { + "class": "valuable", + "in": "data", + "why": "a consumer's documents are the only copy of what it wrote; a consumer that keeps something irreplaceable here says so (kept-by)" + } + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "dumps", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "mongodb" + }, + { + "id": "server-root", + "type": "file", + "path": "${dir:state}/server-root.secret", + "mode": "0400", + "owner": "999:999", + "template": "files/server-root.secret" + }, + { + "id": "server", + "type": "container", + "name": "mongodb-server", + "image": "mongo@sha256:e3fa459b4f4b72f3257c67a23c145e250b8b5700f033860392c68539b998bbe3", + "health": { + "kind": "tcp", + "endpoint": "database" + }, + "network": "mongodb", + "env": { + "MONGO_INITDB_ROOT_USERNAME": "root", + "MONGO_INITDB_ROOT_PASSWORD_FILE": "/run/secrets/root" + }, + "ports": [ + "27017" + ], + "volumes": [ + "${dir:data}:/data/db", + "${dir:state}/server-root.secret:/run/secrets/root:ro" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_PROVISION_MONGODB": "mongodb://root@127.0.0.1:${port:27017}/admin?authSource=admin", + "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/root.secret", + "MESH_RECEIVES": "${dir:grants}/mesh.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mosquitto/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mosquitto/module.json.captured new file mode 100644 index 0000000..21058e0 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mosquitto/module.json.captured @@ -0,0 +1,194 @@ +{ + "module": "mosquitto", + "version": "1", + "slug": "mosq", + "provides": [ + { + "name": "mqtt-topic", + "scope": "mesh", + "identity": { + "in": "a Mosquitto client and topic prefix" + } + } + ], + "capabilities": [ + "container-runtime" + ], + "emits": [ + "topic.provisioned", + "topic.deprovisioned" + ], + "consumes": [ + "mosquitto.topic.provisioned", + "mosquitto.topic.deprovisioned" + ], + "serves": { + "mqtt-topic": { + "scheme": "mqtt", + "port": 1883 + } + }, + "receives": { + "mqtt-topic": "${dir:grants}/mesh.json" + }, + "grants": { + "mqtt-topic": "${dir:grants}" + }, + "own-secrets": { + "admin": { + "path": "${dir:mesh-state}/admin", + "taken": "at-start" + } + }, + "listens": [ + { + "name": "mqtt", + "port": 1883, + "protocol": "tcp", + "from": "mesh", + "why": "modules on any machine that were granted a topic namespace" + }, + { + "name": "mqtt-websockets", + "port": 8081, + "protocol": "tcp", + "from": "mesh", + "why": "the same broker over MQTT-on-WebSockets, for browser clients" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "rebuildable", + "why": "retained messages and sessions; devices publish them again" + } + ], + "consumers": { + "mqtt-topic": { + "class": "rebuildable", + "in": "data", + "why": "retained messages are published again by the devices" + } + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "1883:1883", + "unknown": "program" + }, + { + "id": "server-conf", + "type": "file", + "path": "${dir:state}/mosquitto.conf", + "mode": "0600", + "owner": "1883:1883", + "template": "files/mosquitto.conf" + }, + { + "id": "net", + "type": "network", + "name": "mosquitto" + }, + { + "id": "bootstrap-env", + "type": "file", + "path": "${dir:state}/bootstrap.env", + "mode": "0600", + "template": "files/bootstrap.env" + }, + { + "id": "bootstrap", + "type": "process", + "name": "mosquitto-bootstrap", + "artifact": "code", + "run": [ + "node", + "bootstrap/index.js" + ], + "run-once": true, + "env-file": [ + "${dir:state}/bootstrap.env" + ], + "restart-on": [ + "bootstrap-env", + "needs-admin" + ] + }, + { + "id": "server", + "type": "container", + "name": "mosquitto", + "image": "eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408", + "health": { + "kind": "tcp", + "endpoint": "mqtt" + }, + "network": "mosquitto", + "ports": [ + "1883", + "8081" + ], + "volumes": [ + "${dir:data}:/mosquitto/data", + "${dir:state}/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js", + "bootstrap/index.js" + ], + "loads": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_RECEIVES": "${dir:grants}/mesh.json", + "MESH_PROVISION_MQTT": "127.0.0.1:${port:1883}", + "MESH_PROVISION_ADMIN_USER": "mesh-admin", + "MESH_PROVISION_PASSWORD_FILE": "${dir:mesh-state}/admin", + "MESH_MQTT_CTRL_CONTAINER": "mosquitto" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mounts/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mounts/module.json.captured new file mode 100644 index 0000000..488ebb9 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mounts/module.json.captured @@ -0,0 +1,160 @@ +{ + "module": "mounts", + "version": "1", + "requires": [ + "nfs-share" + ], + "reads": [ + "nfs-server.exports" + ], + "invokes": [ + "seat:mesh-controller.data" + ], + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "node-mounts", + "scope": "node", + "serves": [ + "list", + "test", + "mount", + "unmount", + "adopt" + ] + } + ], + "tools": [ + "mounts_list", + "mounts_exports", + "mounts_health" + ], + "state": [ + { + "name": "shares", + "per-machine": true + } + ], + "config": { + "shares": { + "type": "string", + "description": "The shares of another machine this machine mounts: space-separated name=mountpoint, each optionally :ro, or none (novox/hq ADR 0263 rule 5).", + "trusted": true, + "default": "none" + }, + "sources": { + "type": "string", + "description": "The occasional sources this machine mounts: space-separated name=smb://[@]/@ or name=disk:@, each optionally :ro, or none (novox/hq ADR 0263 rule 6); an SMB source's username is in its entry, its password the secret smb-password- (novox/hq ADR 0283).", + "trusted": true, + "default": "none" + } + }, + "own-secrets": { + "smb-password-*": { + "path": "${dir:state}/smb-password-*.secret", + "issued-by": "outside" + } + }, + "resources": [ + { + "id": "nfs-utils", + "type": "package", + "package": "nfs-utils" + }, + { + "id": "cifs-utils", + "type": "package", + "package": "cifs-utils" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "config-dir", + "type": "directory", + "path": "/etc/mesh-mounts", + "mode": "0755", + "unknown": "program" + }, + { + "id": "config", + "type": "file", + "path": "/etc/mesh-mounts/mounts.conf", + "mode": "0644", + "template": "files/mounts.conf" + }, + { + "id": "bus-dir-parent", + "type": "directory", + "path": "/var/lib/mesh-mounts", + "mode": "0755", + "unknown": "program" + }, + { + "id": "bus-dir", + "type": "directory", + "path": "/var/lib/mesh-mounts/from-bus", + "mode": "0755", + "owner": "${machine:account}", + "unknown": "program" + }, + { + "id": "apply", + "type": "process", + "name": "mesh-mounts-apply", + "artifact": "tools-go", + "run": [ + "./mounts", + "apply" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "watch", + "type": "process", + "name": "mesh-mounts-watch", + "artifact": "tools-go", + "run": [ + "./mounts", + "watch" + ], + "health": { + "kind": "tool", + "tool": "mounts_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "90s" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mounts", + "binary": "mounts", + "loads": [ + "mounts" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/mssql/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/mssql/module.json.captured new file mode 100644 index 0000000..ed4e448 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/mssql/module.json.captured @@ -0,0 +1,174 @@ +{ + "module": "mssql", + "version": "1", + "upgrade": { + "policy": "record", + "why": "a provider whose restart drops every consumer on its machine, and whose new version may upgrade its databases in place: a person takes each build, after a backup (hq ADR 0236)" + }, + "provides": [ + { + "name": "mssql-database", + "scope": "mesh", + "identity": { + "max": 128, + "in": "a SQL Server login and database name" + } + } + ], + "capabilities": [ + "container-runtime" + ], + "emits": [ + "database.provisioned", + "database.deprovisioned" + ], + "consumes": [ + "mssql.database.provisioned", + "mssql.database.deprovisioned" + ], + "listens": [ + { + "name": "database", + "port": 1433, + "protocol": "tcp", + "from": "mesh", + "why": "modules on any machine that were granted a database, and the people who were given its address; the machine port is the assignment's to pin" + } + ], + "serves": { + "mssql-database": {} + }, + "receives": { + "mssql-database": "${dir:grants}/mesh.json" + }, + "grants": { + "mssql-database": "${dir:grants}" + }, + "own-secrets": { + "sa": "${dir:state}/sa.secret", + "reader": "${dir:state}/reader.secret" + }, + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "backup": { + "dump": "{ cat ${dir:state}/sa.secret; echo; cat ${dir:state}/backup.sql; } | docker exec -i mssql sh -c 'read -r p; SQLCMDPASSWORD=\"$p\" exec /opt/mssql-tools18/bin/sqlcmd -C -b -S localhost -U sa -i /dev/stdin'", + "into": "dumps" + }, + "why": "every consumer's database; copied by the dump, not as live files" + }, + { + "id": "dumps", + "path": "${dir:dumps}", + "class": "rebuildable", + "why": "last night's dump, made again every night" + } + ], + "consumers": { + "mssql-database": { + "class": "valuable", + "in": "data", + "why": "a consumer's rows are the only copy of what it wrote" + } + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "sa-env", + "type": "file", + "path": "${dir:state}/sa.env", + "mode": "0600", + "template": "files/sa.env" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "10001:0", + "unknown": "program" + }, + { + "id": "dumps", + "type": "directory", + "path": "${dir:data}/backup", + "mode": "0700", + "owner": "10001:0", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "mssql" + }, + { + "id": "server", + "type": "container", + "name": "mssql", + "image": "mcr.microsoft.com/mssql/server@sha256:4402d880dd4c34bfa7d8705e56a86cd6c88da80a1f6bbbe741f999e76264a090", + "network": "mssql", + "env-file": [ + "${dir:state}/sa.env" + ], + "ports": [ + "1433" + ], + "volumes": [ + "${dir:data}:/var/opt/mssql" + ], + "secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint" + }, + { + "id": "backup-sql", + "type": "file", + "path": "${dir:state}/backup.sql", + "mode": "0600", + "template": "files/backup.sql" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_PROVISION_MSSQL": "mssql://sa@127.0.0.1:${port:1433}/master", + "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/sa.secret", + "MESH_RECEIVES": "${dir:grants}/mesh.json", + "MESH_MSSQL_READER_PASSWORD_FILE": "${dir:state}/reader.secret" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/n8n/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/n8n/module.json.captured new file mode 100644 index 0000000..975247f --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/n8n/module.json.captured @@ -0,0 +1,173 @@ +{ + "module": "n8n", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "postgres-database", + "route" + ], + "contributes": { + "postgres-database": { + "name": "n8n" + }, + "route": { + "label": "n8n", + "endpoint": "web" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "accesses": [ + { + "id": "media", + "mode": "read-write" + } + ], + "listens": [ + { + "name": "web", + "port": 5678, + "protocol": "tcp", + "from": "mesh", + "why": "the n8n editor, its REST API and the webhook endpoints workflows are triggered through; a public name is the route's" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "why": "the instance's encryption key, settings and binary data; workflows are in the database" + }, + { + "id": "cache", + "path": "${dir:cache}", + "class": "cache", + "why": "scratch space" + } + ] + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "1000:1000", + "unknown": "program" + }, + { + "id": "cache", + "type": "directory", + "mode": "0700", + "owner": "999:999", + "unknown": "program" + }, + { + "id": "database-secret", + "type": "file", + "path": "${dir:state}/n8n-database.secret", + "mode": "0400", + "owner": "1000:1000", + "template": "files/n8n-database.secret" + }, + { + "id": "server-env", + "type": "file", + "path": "${dir:state}/server.env", + "mode": "0600", + "template": "files/server.env" + }, + { + "id": "net", + "type": "network", + "name": "n8n" + }, + { + "id": "server", + "type": "container", + "health": { + "kind": "http", + "endpoint": "web", + "path": "/healthz/readiness", + "status": 200, + "needs": "postgres-database" + }, + "name": "n8n", + "artifact": "server", + "network": "n8n", + "env-file": [ + "${dir:state}/server.env" + ], + "ports": [ + "5678" + ], + "volumes": [ + "${dir:data}:/home/node/.n8n", + "${dir:state}/n8n-database.secret:/run/secrets/database:ro", + "${access:media}:/media-library" + ] + }, + { + "id": "cache-server", + "type": "container", + "name": "n8n-redis", + "image": "redis@sha256:8a1efc5f479551822b47424ccae982026b633f28818eab0387348120a61e10e2", + "network": "n8n", + "args": [ + "redis-server", + "--appendonly", + "yes" + ], + "volumes": [ + "${dir:cache}:/data" + ] + }, + { + "id": "browser", + "type": "container", + "name": "n8n-selenium", + "image": "selenium/standalone-chrome@sha256:9ae1c78e9b2ca9fe4b22e57873b5ee34aeb8e814e3122293eef4c3abe4c5f448", + "network": "n8n", + "env": { + "SE_ENABLE_TRACING": "false", + "SE_NODE_MAX_SESSIONS": "5", + "SE_NODE_OVERRIDE_MAX_SESSIONS": "true" + } + } + ], + "build": { + "on": [ + { + "arg": "N8N_BASE", + "image": "n8nio/n8n@sha256:4846eb2f4b874ab04cde7fc1e249d2ddaec66e9aea64439beb2972cfea88e3c0" + } + ], + "artifacts": [ + { + "name": "server", + "kind": "image", + "from": "Dockerfile" + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/nats/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/nats/module.json.captured new file mode 100644 index 0000000..3ad66bc --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/nats/module.json.captured @@ -0,0 +1,171 @@ +{ + "module": "nats", + "version": "1", + "upgrade": { + "policy": "record", + "why": "the bus: replaced only as a planned step a person starts (`bus upgrade`), its streams snapshotted first and checked after (hq ADR 0236); the controller holds this whatever is said here" + }, + "provides": [ + { + "name": "mesh-bus", + "scope": "mesh" + } + ], + "claims": [ + { + "name": "mesh-broker", + "scope": "mesh" + } + ], + "bus-users": "/var/lib/nats-module/conf/accounts.conf", + "own-secrets": { + "broker": "${dir:mesh-state}/broker" + }, + "capabilities": [ + "container-runtime" + ], + "emits": [], + "consumes": [], + "listens": [ + { + "name": "bus", + "port": 4222, + "protocol": "tcp", + "from": "mesh", + "why": "the mesh bus — every link the mesh has, over TLS, reached across the overlay" + } + ], + "tools": [ + "nats_server", + "nats_connections", + "nats_closed_connections", + "nats_subscriptions", + "nats_streams", + "nats_backlog", + "nats_buckets", + "nats_users", + "nats_user_can" + ], + "guards": [ + 8222 + ], + "data": { + "own": [ + { + "id": "jetstream", + "path": "${dir:jetstream-data}", + "class": "valuable", + "active": "1d", + "backup": { + "dump": "docker exec -i mesh-broker-nats mesh-nats-snapshot snapshot < ${dir:mesh-state}/broker > ${dir:snapshots}/bus.tar.partial && mv ${dir:snapshots}/bus.tar.partial ${dir:snapshots}/bus.tar || { rm -f ${dir:snapshots}/bus.tar.partial; exit 1; }", + "into": "snapshots" + }, + "why": "the bus's streams and key-value buckets: the hand-act log, conditions, every module's state; written all the time, so copied by the server's own snapshot of each stream (novox/hq ADR 0235), never as live files" + }, + { + "id": "snapshots", + "path": "${dir:snapshots}", + "class": "rebuildable", + "why": "last night's snapshot of every stream with its manifest, made again every night" + } + ] + }, + "resources": [ + { + "id": "jetstream-data", + "type": "directory", + "path": "/var/lib/mesh-broker-nats", + "mode": "0700", + "unknown": "program" + }, + { + "id": "conf-dir", + "type": "directory", + "path": "/var/lib/nats-module/conf", + "mode": "0700", + "unknown": "program" + }, + { + "id": "snapshots", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "server-conf", + "type": "file", + "path": "/var/lib/nats-module/conf/nats.conf", + "template": "files/nats.conf", + "mode": "0644" + }, + { + "id": "server", + "type": "container", + "name": "mesh-broker-nats", + "ports": [ + "4222:4222", + "127.0.0.1:8222:8222" + ], + "volumes": [ + "/var/lib/mesh-broker-nats:/data", + "/var/lib/nats-module/conf:/etc/nats:ro", + "${access:tls}:/tls:ro" + ], + "artifact": "server" + } + ], + "accesses": [ + { + "id": "tls", + "path": "/var/lib/mesh-broker-tls", + "mode": "read" + } + ], + "build": { + "on": [ + { + "arg": "NATS_BASE", + "image": "nats@sha256:e4bf19f15fd3218814a4e3c9e0064e1334bd8aa20d5984b9f1a0afd084f8cc00" + }, + { + "arg": "GO_BASE", + "image": "golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c" + } + ], + "artifacts": [ + { + "name": "server", + "kind": "image", + "from": "Dockerfile" + }, + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/nats-tools", + "binary": "nats-tools", + "loads": [ + "nats-tools" + ], + "env": { + "MESH_NATS_MONITOR": "http://127.0.0.1:8222", + "MESH_NATS_CONTAINER": "mesh-broker-nats", + "MESH_NATS_USERS_FILE": "/etc/nats/accounts.conf" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/netcheck/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/netcheck/module.json.captured new file mode 100644 index 0000000..ec01003 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/netcheck/module.json.captured @@ -0,0 +1,57 @@ +{ + "module": "netcheck", + "version": "1", + "emits": [ + "tunnel.endpoint-changed", + "tunnel.handshake-stale", + "tunnel.handshake-fresh" + ], + "tools": [ + "netcheck_tunnel", + "netcheck_tcp", + "netcheck_dns", + "netcheck_http", + "netcheck_listening" + ], + "replaces": { + "netcheck_listening": [ + "ss -lunt", + "ss -ltn", + "netstat -lnt" + ], + "netcheck_dns": [ + "dig", + "nslookup" + ], + "netcheck_tunnel": [ + "wg show", + "wg show mesh0 dump" + ] + }, + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/netcheck", + "binary": "netcheck", + "loads": [ + "netcheck" + ] + }, + { + "name": "tools-typescript", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/networkmanager/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/networkmanager/module.json.captured new file mode 100644 index 0000000..68ace34 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/networkmanager/module.json.captured @@ -0,0 +1,71 @@ +{ + "module": "networkmanager", + "version": "1", + "upgrade": { + "policy": "record", + "why": "the machine's network: a build that breaks it can cut the machine off from the bus, and then neither the gate's rollback nor a push reaches it (hq ADR 0236)" + }, + "slug": "nm", + "requires": [ + "wildcard-resolution" + ], + "capabilities": [ + "package-manager", + "service-manager", + "uplink-networkmanager" + ], + "claims": [ + { + "name": "node-uplink", + "scope": "node" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/uplink-tools", + "binary": "uplink-tools", + "loads": [ + "uplink-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "facts": { + "resolvers": { + "path": "/etc/resolv.conf", + "template": "# Managed by the mesh, and written by the module holding this machine's uplink:\n# the program that manages the machine's network would otherwise rewrite this\n# file on every change of network, so its holder is the one that writes it\n# (novox/hq ADR 0117, ADR 0223). Replaced on every push; edit nothing here.\n#\n# Every resolver of the mesh, by address, and nothing else (novox/hq ADR 0223) \u2014\n# this machine's own first when it holds one, then the others by name. Each\n# answers the mesh's names from the same roster and forwards every other name, so\n# whichever answers first gives the one answer. There is no public resolver here:\n# a C library that asks every listed server at once and takes the first reply \u2014\n# musl, so every Alpine container \u2014 took a public resolver's \"no such name\" for\n# a mesh name and failed. A machine that reaches none of these has no names until\n# it does. Containers copy these lines from their machine.\n{{range index .Holders \"mesh-dns-resolver\"}}nameserver {{.Address}}\n{{end}}options timeout:1 attempts:2 edns0\n" + } + }, + "resources": [ + { + "id": "package", + "type": "package", + "package": "networkmanager" + }, + { + "id": "config", + "type": "file", + "path": "/etc/NetworkManager/conf.d/50-mesh.conf", + "mode": "0644", + "template": "files/50-mesh.conf" + }, + { + "id": "service", + "type": "service", + "unit": "NetworkManager.service", + "reload-on": [ + "config" + ] + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/nextcloud-client/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/nextcloud-client/module.json.captured new file mode 100644 index 0000000..af71acb --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/nextcloud-client/module.json.captured @@ -0,0 +1,38 @@ +{ + "module": "nextcloud-client", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "tools": [ + "nextcloud_status", + "nextcloud_log", + "nextcloud_restart", + "nextcloud_check" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "nextcloud-client" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/nextcloud-client-tools", + "binary": "nextcloud-client-tools", + "loads": [ + "nextcloud-client-tools" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/nextcloud/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/nextcloud/module.json.captured new file mode 100644 index 0000000..2854a16 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/nextcloud/module.json.captured @@ -0,0 +1,168 @@ +{ + "module": "nextcloud", + "version": "1", + "slug": "ncloud", + "requires": [ + "postgres-database", + "s3-bucket", + "route" + ], + "contributes": { + "postgres-database": { + "name": "nextcloud" + }, + "route": { + "label": "drive", + "endpoint": "web" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "s3-bucket": "${dir:state}/store.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret", + "s3-bucket": "${dir:state}/store.secret" + }, + "emits": [ + "user.created", + "share.created" + ], + "own-secrets": { + "admin": "${dir:state}/admin.secret" + }, + "capabilities": [ + "container-runtime" + ], + "listens": [ + { + "name": "web", + "port": 80, + "protocol": "tcp", + "from": "mesh", + "why": "files and sync, over http; a public name is a route grant later" + } + ], + "data": { + "own": [ + { + "id": "html", + "path": "${dir:html}", + "class": "valuable", + "why": "the instance's configuration, its secret and installed apps; the files are in the object store" + } + ] + }, + "config": { + "container": { + "type": "string", + "description": "The name of the container the module's tools run Nextcloud's occ in; empty is nextcloud.", + "trusted": true, + "default": "" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach Nextcloud's sharing API at, as a URL; empty is this machine's Nextcloud on its port.", + "trusted": true, + "default": "" + }, + "user": { + "type": "string", + "description": "The Nextcloud account the module's tools act as; empty is admin.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "server-env", + "type": "file", + "path": "${dir:state}/server.env", + "mode": "0600", + "template": "files/server.env" + }, + { + "id": "html", + "type": "directory", + "mode": "0750", + "owner": "33:33", + "unknown": "program" + }, + { + "id": "server", + "type": "container", + "health": { + "kind": "http", + "endpoint": "web", + "path": "/status.php", + "status": 200, + "body": "\"installed\":true", + "needs": "postgres-database" + }, + "name": "nextcloud", + "image": "nextcloud@sha256:fb966733647ea03f0446b0c22eac9733c8eb616d37b960caca9d4c3010e14a08", + "env-file": [ + "${dir:state}/server.env" + ], + "ports": [ + "80" + ], + "volumes": [ + "${dir:html}:/var/www/html" + ], + "secrets-in-environment": "the image honours POSTGRES_PASSWORD_FILE and NEXTCLOUD_ADMIN_PASSWORD_FILE (entrypoint file_env); OBJECTSTORE_S3_SECRET has none and needs a generated config fragment; convertible, awaiting a bed" + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "takes-config": ["container", "url", "user"], + "merge": "json" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js" + ], + "loads": [ + "index.js", + "tools/index.js" + ], + "env": { + "MESH_NEXTCLOUD_URL": "http://127.0.0.1:${port:80}", + "MESH_NEXTCLOUD_CONFIG_FILE": "${dir:mesh-state}/config.json", + "MESH_NEXTCLOUD_ADMIN_USER": "mesh-admin", + "MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/nfs-server/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/nfs-server/module.json.captured new file mode 100644 index 0000000..f9994d1 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/nfs-server/module.json.captured @@ -0,0 +1,191 @@ +{ + "module": "nfs-server", + "version": "1", + "upgrade": { + "policy": "record", + "why": "the folders other machines mount: a build that breaks the exports leaves every client's mount hanging or refused, and the gate on this one machine does not see the clients (hq ADR 0236, ADR 0263)" + }, + "capabilities": [ + "package-manager", + "service-manager" + ], + "provides": [ + { + "name": "nfs-share", + "scope": "mesh", + "identity": false + } + ], + "data": { + "consumers": { + "nfs-share": { + "class": "none", + "why": "the shared folders are the operator's data (hq ADR 0051): what a client writes lands in them, and they are protected where the operator declares them, never by this module, which keeps nothing of a consumer's" + } + } + }, + "claims": [ + { + "name": "node-nfs-server", + "scope": "node", + "serves": [ + "exports", + "clients", + "test", + "reload", + "adopt" + ] + } + ], + "state": [ + { + "name": "exports", + "ttl-seconds": 120, + "per-machine": true + } + ], + "reads": [ + "mounts.shares" + ], + "invokes": [ + "seat:mesh-controller.seats", + "seat:mesh-controller.data" + ], + "config": { + "shares": { + "type": "string", + "description": "The folders this machine exports, as space-separated name=path; the operator's, with no default (novox/hq ADR 0263).", + "trusted": true, + "required": true + }, + "grants": { + "type": "string", + "description": "Which nodes may mount which share, and how: share=node:rw|ro[,node:rw|ro], entries separated by spaces, or none to export to no node whatever a node wants (novox/hq ADR 0263).", + "trusted": true, + "default": "none" + } + }, + "facts": { + "machines": { + "path": "/etc/nfs-server/machines", + "template": "# Written by the mesh (module nfs-server, novox/hq ADR 0263): every machine of the mesh and its private\n# address, from which the module takes a node's address. Replaced on every push.\n{{range .Machines}}{{.Name}} {{.Address}}\n{{end}}" + } + }, + "tools": [ + "nfs_health" + ], + "listens": [ + { + "name": "nfs", + "port": 2049, + "protocol": "tcp", + "from": "mesh", + "fixed": true, + "why": "the shares, to the mesh's machines only (hq ADR 0263): NFS version 4 alone, which needs no other port, and never the home network, where a device that is not a node could claim any user id" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "nfs-utils" + }, + { + "id": "nfs-conf", + "type": "file", + "path": "/etc/nfs.conf.d/50-mesh.conf", + "mode": "0644", + "template": "files/50-mesh.conf" + }, + { + "id": "run-dir", + "type": "directory", + "path": "/run/nfs-server", + "mode": "0755", + "unknown": "program" + }, + { + "id": "server", + "type": "service", + "unit": "nfs-server.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "nfs-conf" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "wants-dir-parent", + "type": "directory", + "path": "/var/lib/nfs-server", + "mode": "0755", + "unknown": "program" + }, + { + "id": "wants-dir", + "type": "directory", + "path": "/var/lib/nfs-server/from-bus", + "mode": "0755", + "owner": "${machine:account}", + "unknown": "program" + }, + { + "id": "exports", + "type": "process", + "name": "nfs-server-exports", + "artifact": "tools", + "run": [ + "./nfs-server", + "exports" + ], + "restart-on": [ + "config" + ], + "health": { + "kind": "tool", + "tool": "nfs_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "90s" + } + }, + { + "id": "config-dir", + "type": "directory", + "path": "/etc/nfs-server", + "mode": "0755", + "unknown": "program" + }, + { + "id": "config", + "type": "file", + "path": "/etc/nfs-server/shares.conf", + "mode": "0644", + "template": "files/shares.conf" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/nfs-server", + "binary": "nfs-server", + "loads": [ + "nfs-server" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/nftables/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/nftables/module.json.captured new file mode 100644 index 0000000..b7b0c83 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/nftables/module.json.captured @@ -0,0 +1,91 @@ +{ + "module": "nftables", + "version": "1", + "upgrade": { + "policy": "record", + "why": "the machine's packet filter: a build that breaks it can cut the machine off from the bus, and then neither the gate's rollback nor a push reaches it (hq ADR 0236)" + }, + "capabilities": [ + "firewall" + ], + "claims": [ + { + "name": "node-packet-filter", + "scope": "node", + "serves": [ + "rules", + "reload", + "remove" + ] + } + ], + "filtering": { + "into": "/etc/nftables.conf" + }, + "resources": [ + { + "id": "package", + "type": "package", + "package": "nftables" + }, + { + "id": "legacy-tools", + "type": "package", + "package": "iptables" + }, + { + "id": "unit", + "type": "file", + "path": "/etc/systemd/system/mesh-filter.service", + "template": "files/mesh-filter.service", + "mode": "0644" + }, + { + "id": "stock-unit-stop", + "type": "file", + "path": "/etc/systemd/system/nftables.service.d/mesh.conf", + "template": "files/mesh.conf", + "mode": "0644" + }, + { + "id": "load", + "type": "service", + "unit": "mesh-filter.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "unit", + "stock-unit-stop" + ], + "reload-on": [ + "filtering" + ] + }, + { + "id": "front-end", + "type": "package", + "package": "ufw", + "absent": true + } + ], + "tools": [ + "firewall_rules" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/nm-applet/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/nm-applet/module.json.captured new file mode 100644 index 0000000..1c6764e --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/nm-applet/module.json.captured @@ -0,0 +1,37 @@ +{ + "module": "nm-applet", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "tools": [ + "nm_applet_status", + "nm_applet_restart", + "nm_applet_check" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "network-manager-applet" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/nm-applet-tools", + "binary": "nm-applet-tools", + "loads": [ + "nm-applet-tools" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/node-env/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/node-env/module.json.captured new file mode 100644 index 0000000..85fd7e5 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/node-env/module.json.captured @@ -0,0 +1,55 @@ +{ + "module": "node-env", + "version": "1", + "moved": true, + "claims": [ + { + "name": "node-environment", + "scope": "node" + } + ], + "resources": [ + { + "id": "mesh-config-dir", + "type": "directory", + "path": "${machine:account-home}/.config/mesh", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out", + "knows": ["forge.credentials"] + }, + { + "id": "environment-d", + "type": "directory", + "path": "${machine:account-home}/.config/environment.d", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "posix", + "type": "file", + "path": "${machine:account-home}/.config/mesh/environment.sh", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/environment.sh" + }, + { + "id": "systemd", + "type": "file", + "path": "${machine:account-home}/.config/environment.d/50-mesh.conf", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/50-mesh.conf" + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/nodejs/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/nodejs/module.json.captured new file mode 100644 index 0000000..c357344 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/nodejs/module.json.captured @@ -0,0 +1,25 @@ +{ + "module": "nodejs", + "version": "1", + "capabilities": [ + "package-manager" + ], + "provides": [ + { + "name": "node-toolchain", + "reach": "machine" + } + ], + "resources": [ + { + "id": "nodejs", + "type": "package", + "package": "nodejs" + }, + { + "id": "npm", + "type": "package", + "package": "npm" + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/nodered/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/nodered/module.json.captured new file mode 100644 index 0000000..f8068dd --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/nodered/module.json.captured @@ -0,0 +1,297 @@ +{ + "module": "nodered", + "version": "1", + "emits": [ + "flows.deployed" + ], + "own-secrets": { + "admin": { + "path": "${dir:mesh-state}/admin", + "taken": "at-start" + }, + "api-token": { + "path": "${dir:mesh-state}/api-token", + "taken": "at-start" + } + }, + "capabilities": [ + "container-runtime" + ], + "listens": [ + { + "name": "web", + "port": 1880, + "protocol": "tcp", + "from": "mesh", + "why": "the flow editor and the endpoints flows expose" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "why": "flows and their credentials" + } + ] + }, + "config": { + "flow-file": { + "type": "string", + "description": "Node-RED's flowFile setting: the file in the user directory the flows are kept in.", + "trusted": true, + "default": "flows.json" + }, + "flow-file-pretty": { + "type": "boolean", + "description": "Node-RED's flowFilePretty setting: whether the flows file is written indented.", + "trusted": true, + "default": true + }, + "diagnostics": { + "type": "object", + "description": "Node-RED's diagnostics setting: whether its diagnostics report is on, and shown in the editor.", + "trusted": true, + "default": { + "enabled": true, + "ui": true + } + }, + "runtime-state": { + "type": "object", + "description": "Node-RED's runtimeState setting: whether flows may be started and stopped from the editor.", + "trusted": true, + "default": { + "enabled": false, + "ui": false + } + }, + "logging": { + "type": "object", + "description": "Node-RED's logging setting: the console log's level, and whether metrics and audit events are logged.", + "trusted": true, + "default": { + "console": { + "level": "info", + "metrics": false, + "audit": false + } + } + }, + "export-global-context-keys": { + "type": "boolean", + "description": "Node-RED's exportGlobalContextKeys setting: whether a function node may list the global context's keys.", + "trusted": true, + "default": false + }, + "external-modules": { + "type": "object", + "description": "Node-RED's externalModules setting: which modules the editor may install, and from where.", + "trusted": true, + "default": {} + }, + "editor-theme": { + "type": "object", + "description": "Node-RED's editorTheme setting: the editor's look and features, projects among them.", + "trusted": true, + "default": { + "projects": { + "enabled": false + } + } + }, + "function-external-modules": { + "type": "boolean", + "description": "Node-RED's functionExternalModules setting: whether a function node may load modules of its own.", + "trusted": true, + "default": true + }, + "debug-max-length": { + "type": "number", + "description": "Node-RED's debugMaxLength setting: the most characters a debug message shows, a count.", + "trusted": true, + "default": 1000 + }, + "mqtt-reconnect-time": { + "type": "number", + "description": "Node-RED's mqttReconnectTime setting: how long an MQTT node waits before reconnecting, in milliseconds.", + "trusted": true, + "default": 15000 + }, + "serial-reconnect-time": { + "type": "number", + "description": "Node-RED's serialReconnectTime setting: how long a serial node waits before reconnecting, in milliseconds.", + "trusted": true, + "default": 15000 + }, + "mqtt": { + "type": "object", + "description": "The broker nodes the module's MQTT step keeps pointed at the mesh's broker, as {\"brokers\": [their ids]}; empty, none.", + "trusted": true, + "default": {} + }, + "time-zone": { + "type": "string", + "description": "The time zone the flows schedule and format in, an IANA name such as Europe/Brussels; unset, the container's UTC.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "1000:1000", + "unknown": "program" + }, + { + "id": "written", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "settings-code", + "type": "file", + "path": "${dir:state}/settings.js", + "mode": "0600", + "owner": "1000:1000", + "template": "files/settings.js" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "owner": "1000:1000", + "merge": "json", + "takes-config": [{"key": "flow-file", "as": "flowFile"}, {"key": "flow-file-pretty", "as": "flowFilePretty"}, "diagnostics", {"key": "runtime-state", "as": "runtimeState"}, "logging", {"key": "export-global-context-keys", "as": "exportGlobalContextKeys"}, {"key": "external-modules", "as": "externalModules"}, {"key": "editor-theme", "as": "editorTheme"}, {"key": "function-external-modules", "as": "functionExternalModules"}, {"key": "debug-max-length", "as": "debugMaxLength"}, {"key": "mqtt-reconnect-time", "as": "mqttReconnectTime"}, {"key": "serial-reconnect-time", "as": "serialReconnectTime"}, "mqtt", {"key": "time-zone", "as": "timeZone"}], + "template": "files/settings.json" + }, + { + "id": "server", + "type": "container", + "name": "nodered", + "image": "nodered/node-red@sha256:a649dd711d55490151a2c39a8e48ad0c44325488fbc0e66315f2d2e19e5e1ace", + "health": { + "kind": "runtime" + }, + "env": { + "TZ": "Etc/UTC" + }, + "ports": [ + "1880" + ], + "args": [ + "--settings", + "/data/settings.js" + ], + "volumes": [ + "${dir:data}:/data", + "${dir:state}/settings.js:/data/settings.js:ro", + "${dir:state}/settings.json:/data/settings.json:ro" + ], + "restart-on": [ + "settings-code", + "settings" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json" + }, + { + "id": "mqtt-env", + "type": "file", + "path": "${dir:state}/mqtt.env", + "mode": "0600", + "template": "files/mqtt.env" + }, + { + "id": "mqtt", + "type": "process", + "name": "nodered-mqtt", + "artifact": "code", + "run": [ + "node", + "mqtt/index.js" + ], + "run-once": true, + "env-file": [ + "${dir:state}/mqtt.env" + ], + "restart-on": [ + "mqtt-env", + "bound-mqtt-topic", + "secret-mqtt-topic", + "settings" + ] + } + ], + "requires": [ + "mqtt-topic", + "route" + ], + "contributes": { + "mqtt-topic": { + "topics": [ + "#" + ] + }, + "route": { + "label": "nodered", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json", + "mqtt-topic": "${dir:state}/mqtt-topic.json" + }, + "secrets": { + "mqtt-topic": "${dir:state}/mqtt-topic.secret" + }, + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js", + "mqtt/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_NODERED_URL": "http://127.0.0.1:${port:1880}", + "MESH_NODERED_CONFIG_FILE": "${dir:mesh-state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/ollama/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/ollama/module.json.captured new file mode 100644 index 0000000..2d5fb48 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/ollama/module.json.captured @@ -0,0 +1,62 @@ +{ + "module": "ollama", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "provides": [ + { + "name": "model-access", + "scope": "node" + } + ], + "listens": [ + { + "name": "api", + "port": 11434, + "protocol": "tcp", + "from": "machine", + "why": "consumers on this machine reaching the local model server's OpenAI-compatible API" + } + ], + "serves": { + "model-access": { + "port": 11434, + "model": "llama3.2" + } + }, + "data": { + "own": [ + { + "id": "models", + "path": "${dir:state}", + "class": "rebuildable", + "backup": "none", + "measure": "shallow", + "why": "models, downloaded again, and too large to copy every night" + } + ] + }, + "resources": [ + { + "id": "state", + "type": "directory", + "path": "/services/ollama", + "mode": "0700", + "unknown": "program" + }, + { + "id": "server", + "type": "container", + "name": "ollama", + "image": "ollama/ollama@sha256:32931b46719f673c05fdbaa81ccb26da18ea4a1c57590a754874ab28ba269eb2", + "network": "host", + "env": { + "OLLAMA_HOST": "0.0.0.0:11434" + }, + "volumes": [ + "/services/ollama:/root/.ollama" + ] + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/only-office/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/only-office/module.json.captured new file mode 100644 index 0000000..4af651f --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/only-office/module.json.captured @@ -0,0 +1,173 @@ +{ + "module": "only-office", + "version": "1", + "slug": "office", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "office", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "own-secrets": { + "jwt": "${dir:state}/jwt.secret" + }, + "listens": [ + { + "name": "web", + "port": 9070, + "protocol": "tcp", + "from": "mesh", + "why": "the document server over http; its public name is a route grant, and route-proxy reaches it on this published port" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "rebuildable", + "why": "documents being edited; the documents themselves are the file store's" + }, + { + "id": "lib", + "path": "${dir:lib}", + "class": "rebuildable", + "why": "the document server's working files" + }, + { + "id": "db", + "path": "${dir:db}", + "class": "rebuildable", + "why": "the document server's own database of editing sessions" + }, + { + "id": "fonts", + "path": "${dir:fonts}", + "class": "rebuildable", + "why": "fonts, installed again" + }, + { + "id": "logs", + "path": "${dir:logs}", + "class": "cache", + "why": "logs" + }, + { + "id": "rabbitmq", + "path": "${dir:rabbitmq}", + "class": "cache", + "why": "its queue's working set" + }, + { + "id": "redis", + "path": "${dir:redis}", + "class": "cache", + "why": "its cache" + } + ] + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "server-env", + "type": "file", + "path": "${dir:state}/server.env", + "mode": "0600", + "template": "files/server.env" + }, + { + "id": "logs", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "lib", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "db", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "rabbitmq", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "redis", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "fonts", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "only-office" + }, + { + "id": "server", + "type": "container", + "name": "only-office", + "image": "onlyoffice/documentserver@sha256:3ab6ebc7c605e5a32b7ae3ff19daed4925090245acc8100ce2230bd766c88212", + "network": "only-office", + "env-file": [ + "${dir:state}/server.env" + ], + "ports": [ + "9070:80" + ], + "volumes": [ + "${dir:logs}:/var/log/onlyoffice", + "${dir:data}:/var/www/onlyoffice/Data", + "${dir:lib}:/var/lib/onlyoffice", + "${dir:db}:/var/lib/postgresql", + "${dir:rabbitmq}:/var/lib/rabbitmq", + "${dir:redis}:/var/lib/redis", + "${dir:fonts}:/usr/share/fonts/truetype/custom" + ], + "secrets-in-environment": "run-document-server.sh regenerates JWT_SECRET from the environment on every start and overwrites local.json; not convertible" + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/openai-consumer/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/openai-consumer/module.json.captured new file mode 100644 index 0000000..2e98414 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/openai-consumer/module.json.captured @@ -0,0 +1,61 @@ +{ + "module": "openai-consumer", + "version": "1", + "slug": "openai", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "model-access" + ], + "binds": { + "model-access": "${dir:state}/model.json" + }, + "secrets": { + "model-access": "${dir:state}/api-key" + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "config", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "apply", + "type": "process", + "name": "openai-consumer-apply", + "artifact": "code", + "run": [ + "node", + "apply/index.js" + ], + "schedule": "*/5 * * * *", + "env": { + "MESH_MODEL_ACCESS_SECRET_FILE": "${dir:state}/api-key", + "MESH_MODEL_ACCESS_BIND_FILE": "${dir:state}/model.json", + "MESH_OPENAI_ENV_FILE": "${dir:state}/config/openai.env", + "MESH_OPENAI_CREDENTIALS_FILE": "${dir:state}/config/auth.json" + } + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "apply/index.js" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/openrazer/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/openrazer/module.json.captured new file mode 100644 index 0000000..faa34f8 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/openrazer/module.json.captured @@ -0,0 +1,52 @@ +{ + "module": "openrazer", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "openrazer_status", + "openrazer_restart", + "openrazer_check" + ], + "resources": [ + { + "id": "driver", + "type": "package", + "package": "openrazer-driver-dkms" + }, + { + "id": "daemon", + "type": "package", + "package": "openrazer-daemon" + }, + { + "id": "library", + "type": "package", + "package": "python-openrazer" + }, + { + "id": "account", + "type": "user", + "name": "${machine:account}", + "groups": [ + "openrazer" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/openrazer-tools", + "binary": "openrazer-tools", + "loads": [ + "openrazer-tools" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/pacman/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/pacman/module.json.captured new file mode 100644 index 0000000..ca1a81a --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/pacman/module.json.captured @@ -0,0 +1,136 @@ +{ + "module": "pacman", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "node-package-manager", + "scope": "node" + } + ], + "tools": [ + "pacman_search", + "pacman_info", + "pacman_installed", + "pacman_owns", + "pacman_files", + "pacman_updates", + "pacman_upgrade", + "pacman_upgrade_health", + "pacman_orphans", + "pacman_remove_orphans", + "pacman_cache", + "pacman_history", + "pacman_mirrors", + "pacman_foreign", + "pacman_news", + "pacman_config" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "pacman" + }, + { + "id": "config", + "type": "file", + "path": "/etc/pacman.conf", + "mode": "0644", + "template": "files/pacman.conf" + }, + { + "id": "contrib", + "type": "package", + "package": "pacman-contrib" + }, + { + "id": "reflector", + "type": "package", + "package": "reflector" + }, + { + "id": "mirrors", + "type": "file", + "path": "/etc/xdg/reflector/reflector.conf", + "mode": "0644", + "template": "files/reflector.conf" + }, + { + "id": "mirror-refresh", + "type": "service", + "unit": "reflector.timer", + "state": "running", + "boot": "enabled", + "health": { + "kind": "unit" + } + }, + { + "id": "cache-cleaning", + "type": "service", + "unit": "paccache.timer", + "state": "running", + "boot": "enabled", + "health": { + "kind": "unit" + } + }, + { + "id": "upgrade-service", + "type": "file", + "path": "/etc/systemd/system/mesh-pacman-weekly-upgrade.service", + "mode": "0644", + "template": "files/mesh-pacman-weekly-upgrade.service" + }, + { + "id": "upgrade-timer", + "type": "file", + "path": "/etc/systemd/system/mesh-pacman-weekly-upgrade.timer", + "mode": "0644", + "template": "files/mesh-pacman-weekly-upgrade.timer" + }, + { + "id": "weekly-upgrade", + "type": "service", + "unit": "mesh-pacman-weekly-upgrade.timer", + "state": "running", + "boot": "enabled", + "restart-on": [ + "upgrade-service", + "upgrade-timer" + ], + "health": { + "kind": "tool", + "tool": "pacman_upgrade_health", + "interval": "60s", + "timeout": "20s", + "looks": 2, + "grace": "60s" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/pacman-tools", + "binary": "pacman-tools", + "loads": [ + "pacman-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/photos-eef/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/photos-eef/module.json.captured new file mode 100644 index 0000000..101bdc2 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/photos-eef/module.json.captured @@ -0,0 +1,56 @@ +{ + "module": "photos-eef", + "version": "1", + "slug": "eef", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "eef", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "listens": [ + { + "name": "web", + "port": 4012, + "protocol": "tcp", + "from": "mesh", + "why": "the eef photos client site over http; its public name is a route grant, and route-proxy reaches it on this published port" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "photos-eef" + }, + { + "id": "client", + "type": "container", + "name": "photos-eef", + "image": "registry-api.novox.be/novox/photos-client@sha256:f87d63ee7bfb44c9f9748b99be6ba6dc0daf955a1d463699e9e7e3009693c0ab", + "network": "photos-eef", + "ports": [ + "4012:80" + ], + "names-on-purpose": { + "registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)" + } + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/photos-filip/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/photos-filip/module.json.captured new file mode 100644 index 0000000..9c1c83f --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/photos-filip/module.json.captured @@ -0,0 +1,56 @@ +{ + "module": "photos-filip", + "version": "1", + "slug": "filip", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "filip", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "listens": [ + { + "name": "web", + "port": 4013, + "protocol": "tcp", + "from": "mesh", + "why": "the filip photos client site over http; its public name is a route grant, and route-proxy reaches it on this published port" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "photos-filip" + }, + { + "id": "client", + "type": "container", + "name": "photos-filip", + "image": "registry-api.novox.be/novox/photos-client@sha256:f87d63ee7bfb44c9f9748b99be6ba6dc0daf955a1d463699e9e7e3009693c0ab", + "network": "photos-filip", + "ports": [ + "4013:80" + ], + "names-on-purpose": { + "registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)" + } + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/picom/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/picom/module.json.captured new file mode 100644 index 0000000..cfdc397 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/picom/module.json.captured @@ -0,0 +1,90 @@ +{ + "module": "picom", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-compositor", + "scope": "node" + } + ], + "config": { + "backend": { + "type": "string", + "description": "How picom draws: glx with the graphics card, which has served every machine so far, or xrender on the CPU where glx leaves a window unpainted or stale on the machine's driver (novox/hq research 037, issue 345); xr_glx_hybrid and egl are picom's other backends.", + "enum": [ + "glx", + "xrender", + "xr_glx_hybrid", + "egl" + ], + "trusted": true, + "default": "glx" + }, + "use-damage": { + "type": "boolean", + "description": "Whether picom repaints only what changed (true, picom's own default) or the whole screen each frame (false), which costs work and cures a window left stale or unpainted on some drivers (novox/hq issue 345).", + "trusted": true, + "default": true + } + }, + "tools": [ + "picom_restart", + "picom_rules", + "picom_window_opacity", + "picom_toggle" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "picom" + }, + { + "id": "window-properties", + "type": "package", + "package": "xorg-xprop" + }, + { + "id": "configuration-dir", + "type": "directory", + "path": "${machine:account-home}/.config/picom", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "configuration", + "type": "file", + "path": "${machine:account-home}/.config/picom/picom.conf", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/picom.conf" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/picom-tools", + "binary": "picom-tools", + "loads": [ + "picom-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/polychromatic/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/polychromatic/module.json.captured new file mode 100644 index 0000000..4d6ba4b --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/polychromatic/module.json.captured @@ -0,0 +1,40 @@ +{ + "module": "polychromatic", + "version": "1", + "moved": true, + "requires": [ + "x11-display" + ], + "tools": [ + "polychromatic_status", + "polychromatic_restart", + "polychromatic_check" + ], + "items": [ + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/display-session" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/polychromatic-tools", + "binary": "polychromatic-tools", + "loads": [ + "polychromatic-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/postgres/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/postgres/module.json.captured new file mode 100644 index 0000000..919df42 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/postgres/module.json.captured @@ -0,0 +1,191 @@ +{ + "module": "postgres", + "version": "1", + "moved": true, + "upgrade": { + "policy": "record", + "why": "a provider whose restart drops every consumer on its machine, and whose new major version changes its data's format: a person takes each build, after a backup (hq ADR 0236)" + }, + "provides": [ + { + "name": "postgres-database", + "scope": "mesh", + "identity": { + "max": 63, + "in": "a PostgreSQL role and database name" + } + } + ], + "claims": [ + { + "name": "mesh-store", + "scope": "mesh", + "serves": [ + "databases", + "query" + ] + } + ], + "capabilities": [ + "container-runtime" + ], + "emits": [ + "database.provisioned", + "database.deprovisioned" + ], + "consumes": [ + "postgres.database.provisioned", + "postgres.database.deprovisioned" + ], + "listens": [ + { + "name": "database", + "port": 5432, + "protocol": "tcp", + "from": "mesh", + "why": "modules on any machine that were granted a database" + } + ], + "guards": [ + 5432 + ], + "serves": { + "postgres-database": { + "port": 5432 + } + }, + "receives": { + "postgres-database": "${dir:grants}/mesh.json" + }, + "grants": { + "postgres-database": "${dir:grants}" + }, + "own-secrets": { + "superuser": "${dir:state}/superuser.secret", + "reader": "${dir:state}/reader.secret" + }, + "data": { + "own": [ + { + "id": "store", + "path": "${dir:store-data}", + "class": "valuable", + "backup": { + "dump": "docker exec -u postgres postgres sh -c 'cd /var/lib/postgresql/data/dumps && for db in $(psql -Atc \"select datname from pg_database where oid >= 16384 order by 1\"); do pg_dump -Fc -f \"$db.dump.partial\" \"$db\" && mv \"$db.dump.partial\" \"$db.dump\" || exit 1; done'", + "into": "dumps" + }, + "why": "every consumer's database; copied by the dump below, since a running store's files are not a consistent copy" + }, + { + "id": "dumps", + "path": "${dir:dumps}", + "class": "rebuildable", + "why": "last night's dump of the store, made again every night" + } + ], + "consumers": { + "postgres-database": { + "class": "valuable", + "in": "store", + "why": "a consumer's rows are the only copy of what it wrote" + } + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "consumers", + "type": "file", + "path": "${dir:state}/consumers.json", + "mode": "0600", + "template": "files/consumers.json" + }, + { + "id": "store-data", + "type": "directory", + "path": "/var/lib/mesh-store", + "mode": "0700", + "owner": "999:70", + "unknown": "program" + }, + { + "id": "dumps", + "type": "directory", + "path": "${dir:store-data}/dumps", + "mode": "0700", + "owner": "999:70", + "unknown": "program" + }, + { + "id": "server", + "type": "container", + "name": "postgres", + "image": "pgvector/pgvector@sha256:cf134a767f474095eeba57e0117be8e568e011a63f33fbf252f14c9b760f8e6f", + "health": { + "kind": "tcp", + "endpoint": "database" + }, + "env": { + "POSTGRES_PASSWORD_FILE": "/run/secrets/superuser", + "PGDATA": "/var/lib/postgresql/data/pgdata" + }, + "ports": [ + "5432:5432" + ], + "volumes": [ + "/var/lib/mesh-store:/var/lib/postgresql/data", + "${dir:state}/superuser.secret:/run/secrets/superuser:ro" + ] + }, + { + "id": "client", + "type": "package", + "package": "postgresql-libs" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/postgres-provider", + "binary": "postgres-provider", + "loads": [ + "postgres-provider" + ], + "env": { + "MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port:5432}/postgres?sslmode=disable", + "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/superuser.secret", + "MESH_RECEIVES": "${dir:grants}/mesh.json", + "MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:state}/reader.secret", + "MESH_CONSUMERS_FILE": "${dir:state}/consumers.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "state": [ + "bound" + ], + "reads": [ + "mesh-controller.assignments" + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/power/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/power/module.json.captured new file mode 100644 index 0000000..a110d41 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/power/module.json.captured @@ -0,0 +1,408 @@ +{ + "module": "power", + "version": "1", + "moved": true, + "templates": "templates", + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "node-power", + "scope": "node" + } + ], + "emits": [ + "booted", + "sleeping", + "woke", + "shutting-down", + "on-mains", + "on-battery", + "battery-low" + ], + "state": [ + { + "name": "draw", + "ttl-seconds": 30, + "per-machine": true + } + ], + "tools": [ + "power_state", + "power_hooks", + "power_history", + "power_run", + "power_check" + ], + "config": { + "handle-power-key": { + "type": "string", + "description": "What logind does when the power key is pressed: one of logind's actions, such as suspend, ignore or poweroff (logind.conf(5)); the operator's choice for this machine, with no default.", + "enum": [ + "ignore", + "poweroff", + "reboot", + "halt", + "kexec", + "suspend", + "hibernate", + "hybrid-sleep", + "suspend-then-hibernate", + "lock", + "factory-reset", + "sleep" + ], + "trusted": true, + "required": true + }, + "handle-lid-switch": { + "type": "string", + "description": "What logind does when the lid is closed on battery: one of logind's actions, such as suspend, ignore or poweroff (logind.conf(5)); the operator's choice for this machine, with no default.", + "enum": [ + "ignore", + "poweroff", + "reboot", + "halt", + "kexec", + "suspend", + "hibernate", + "hybrid-sleep", + "suspend-then-hibernate", + "lock", + "factory-reset", + "sleep" + ], + "trusted": true, + "required": true + }, + "handle-lid-switch-external-power": { + "type": "string", + "description": "What logind does when the lid is closed on external power: one of logind's actions, such as suspend, ignore or poweroff (logind.conf(5)); the operator's choice for this machine, with no default.", + "enum": [ + "ignore", + "poweroff", + "reboot", + "halt", + "kexec", + "suspend", + "hibernate", + "hybrid-sleep", + "suspend-then-hibernate", + "lock", + "factory-reset", + "sleep" + ], + "trusted": true, + "required": true + }, + "handle-lid-switch-docked": { + "type": "string", + "description": "What logind does when the lid is closed while docked or with another screen attached: one of logind's actions, such as suspend, ignore or poweroff (logind.conf(5)); the operator's choice for this machine, with no default.", + "enum": [ + "ignore", + "poweroff", + "reboot", + "halt", + "kexec", + "suspend", + "hibernate", + "hybrid-sleep", + "suspend-then-hibernate", + "lock", + "factory-reset", + "sleep" + ], + "trusted": true, + "required": true + } + }, + "resources": [ + { + "id": "polkit", + "type": "package", + "package": "polkit" + }, + { + "id": "scripts", + "type": "archive", + "path": "/usr/local/lib/mesh-power", + "artifact": "scripts", + "unknown": "program" + }, + { + "id": "config-dir", + "type": "directory", + "path": "/etc/mesh-power", + "mode": "0755", + "unknown": "program" + }, + { + "id": "moments-dir", + "type": "directory", + "path": "/etc/mesh-power/moments", + "mode": "0755", + "unknown": "program" + }, + { + "id": "moment-after-boot", + "type": "file", + "path": "/etc/mesh-power/moments/after-boot", + "mode": "0644", + "template": "templates/mesh-power/moments/after-boot" + }, + { + "id": "moment-before-sleep", + "type": "file", + "path": "/etc/mesh-power/moments/before-sleep", + "mode": "0644", + "template": "templates/mesh-power/moments/before-sleep" + }, + { + "id": "moment-after-wake", + "type": "file", + "path": "/etc/mesh-power/moments/after-wake", + "mode": "0644", + "template": "templates/mesh-power/moments/after-wake" + }, + { + "id": "moment-before-shutdown", + "type": "file", + "path": "/etc/mesh-power/moments/before-shutdown", + "mode": "0644", + "template": "templates/mesh-power/moments/before-shutdown" + }, + { + "id": "moment-on-mains", + "type": "file", + "path": "/etc/mesh-power/moments/on-mains", + "mode": "0644", + "template": "templates/mesh-power/moments/on-mains" + }, + { + "id": "moment-on-battery", + "type": "file", + "path": "/etc/mesh-power/moments/on-battery", + "mode": "0644", + "template": "templates/mesh-power/moments/on-battery" + }, + { + "id": "logind-drop-ins", + "type": "directory", + "path": "/etc/systemd/logind.conf.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "logind-power", + "type": "file", + "path": "/etc/systemd/logind.conf.d/power.conf", + "mode": "0644", + "template": "templates/systemd/logind.conf.d/power.conf" + }, + { + "id": "logind", + "type": "service", + "unit": "systemd-logind.service", + "reload-on": [ + "logind-power" + ] + }, + { + "id": "after-boot-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-power-after-boot.service", + "mode": "0644", + "template": "templates/systemd/system/mesh-power-after-boot.service" + }, + { + "id": "before-shutdown-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-power-before-shutdown.service", + "mode": "0644", + "template": "templates/systemd/system/mesh-power-before-shutdown.service" + }, + { + "id": "before-sleep-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-power-before-sleep.service", + "mode": "0644", + "template": "templates/systemd/system/mesh-power-before-sleep.service" + }, + { + "id": "after-wake-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-power-after-wake.service", + "mode": "0644", + "template": "templates/systemd/system/mesh-power-after-wake.service" + }, + { + "id": "supply-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-power-supply.service", + "mode": "0644", + "template": "templates/systemd/system/mesh-power-supply.service" + }, + { + "id": "sleep-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/sleep.target.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "sleep-wants", + "type": "file", + "path": "/etc/systemd/system/sleep.target.d/mesh-power.conf", + "mode": "0644", + "template": "templates/systemd/system/sleep.target.d/mesh-power.conf" + }, + { + "id": "suspend-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/suspend.target.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "suspend-wants", + "type": "file", + "path": "/etc/systemd/system/suspend.target.d/mesh-power.conf", + "mode": "0644", + "template": "templates/systemd/system/suspend.target.d/mesh-power.conf" + }, + { + "id": "hibernate-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/hibernate.target.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "hibernate-wants", + "type": "file", + "path": "/etc/systemd/system/hibernate.target.d/mesh-power.conf", + "mode": "0644", + "template": "templates/systemd/system/hibernate.target.d/mesh-power.conf" + }, + { + "id": "hybrid-sleep-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/hybrid-sleep.target.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "hybrid-sleep-wants", + "type": "file", + "path": "/etc/systemd/system/hybrid-sleep.target.d/mesh-power.conf", + "mode": "0644", + "template": "templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf" + }, + { + "id": "suspend-then-hibernate-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/suspend-then-hibernate.target.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "suspend-then-hibernate-wants", + "type": "file", + "path": "/etc/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf", + "mode": "0644", + "template": "templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf" + }, + { + "id": "udev-rule", + "type": "file", + "path": "/etc/udev/rules.d/90-mesh-power.rules", + "mode": "0644", + "template": "templates/udev/rules.d/90-mesh-power.rules" + }, + { + "id": "after-boot", + "type": "service", + "unit": "mesh-power-after-boot.service", + "state": "running", + "boot": "enabled" + }, + { + "id": "before-shutdown", + "type": "service", + "unit": "mesh-power-before-shutdown.service", + "state": "running", + "boot": "enabled" + }, + { + "id": "draw-dir", + "type": "directory", + "path": "/run/mesh-power", + "mode": "0755", + "unknown": "program" + }, + { + "id": "draw", + "type": "process", + "name": "mesh-power-draw", + "artifact": "tools-go", + "run": [ + "./power", + "draw" + ], + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/power", + "binary": "power", + "loads": [ + "power" + ] + }, + { + "name": "scripts", + "kind": "archive", + "from": "files" + }, + { + "name": "templates", + "kind": "archive", + "from": "templates" + } + ] + }, + "items": [ + { + "collection": "contribution:node-bar:block", + "name": "battery", + "if-capability": "battery", + "attributes": { + "bar": "bottom", + "place": "status", + "order": "40" + }, + "template": "templates/bar-battery" + }, + { + "collection": "contribution:node-bar:block", + "name": "power-draw", + "if-capability": "power-meter", + "attributes": { + "bar": "bottom", + "place": "status", + "order": "45" + }, + "template": "templates/bar-power-draw" + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/powerlevel10k/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/powerlevel10k/module.json.captured new file mode 100644 index 0000000..da1bd4d --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/powerlevel10k/module.json.captured @@ -0,0 +1,49 @@ +{ + "module": "powerlevel10k", + "version": "1", + "moved": true, + "resources": [ + { + "id": "theme", + "type": "archive", + "path": "${machine:account-home}/.local/share/powerlevel10k", + "owner": "${machine:account}", + "artifact": "theme", + "unknown": "move-out", + "knows": ["powerlevel9k.zsh-theme.zwc", "powerlevel10k.zsh-theme.zwc", "internal/p10k.zsh.zwc", "internal/icons.zsh.zwc", "internal/configure.zsh.zwc", "internal/worker.zsh.zwc", "internal/parser.zsh.zwc", "gitstatus/gitstatus.plugin.zsh.zwc", "gitstatus/install.zwc"] + }, + { + "id": "configuration", + "type": "archive", + "path": "${machine:account-home}/.config/powerlevel10k", + "owner": "${machine:account}", + "artifact": "configuration", + "unknown": "move-out" + } + ], + "items": [ + { + "collection": "shell:zsh:normal", + "template": "files/zsh-normal" + } + ], + "build": { + "artifacts": [ + { + "name": "theme", + "kind": "archive", + "from": "theme" + }, + { + "name": "configuration", + "kind": "archive", + "from": "config" + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/records/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/records/module.json.captured new file mode 100644 index 0000000..db6f7e5 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/records/module.json.captured @@ -0,0 +1,103 @@ +{ + "module": "records", + "version": "1", + "slug": "records", + "requires": [ + "git" + ], + "binds": { + "git": "${dir:mesh-state}/git.json" + }, + "consumes": [ + "gitea.pull.merged" + ], + "tools": [ + "records_search", + "records_read", + "records_list", + "records_decisions", + "records_status", + "records_sync" + ], + "data": { + "own": [ + { + "id": "checkout", + "path": "${dir:checkout}", + "class": "rebuildable", + "why": "a clone of the record, cloned again" + } + ] + }, + "config": { + "repository": { + "type": "string", + "description": "The repository the records module keeps a checkout of and serves the record from, as owner/name on the mesh's forge; the operator's, with no default (novox/hq ADR 0112).", + "pattern": "[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", + "trusted": true, + "required": true + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "checkout", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "takes-config": ["repository"], + "merge": "json" + }, + { + "id": "origin", + "type": "file", + "path": "${dir:mesh-state}/origin", + "mode": "0600", + "template": "files/origin" + }, + { + "id": "git", + "type": "package", + "package": "git" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/records", + "binary": "records", + "loads": [ + "records" + ], + "env": { + "MESH_RECORDS_CONFIG_FILE": "${dir:mesh-state}/config.json", + "MESH_RECORDS_ORIGIN_FILE": "${dir:mesh-state}/origin", + "MESH_RECORDS_DIR": "${dir:checkout}" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/redis/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/redis/module.json.captured new file mode 100644 index 0000000..58e1106 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/redis/module.json.captured @@ -0,0 +1,154 @@ +{ + "module": "redis", + "version": "1", + "provides": [ + { + "name": "redis-cache", + "scope": "mesh", + "identity": { + "in": "a Redis ACL user and key prefix" + } + } + ], + "requires": [ + "secret" + ], + "capabilities": [ + "container-runtime" + ], + "emits": [ + "cache.provisioned", + "cache.deprovisioned" + ], + "consumes": [ + "redis.cache.provisioned", + "redis.cache.deprovisioned" + ], + "serves": { + "redis-cache": { + "port": 6379 + } + }, + "receives": { + "redis-cache": "${dir:grants}/mesh.json" + }, + "grants": { + "redis-cache": "${dir:grants}" + }, + "secrets": { + "secret": "${dir:state}/default.secret" + }, + "listens": [ + { + "name": "cache", + "port": 6379, + "protocol": "tcp", + "from": "mesh", + "why": "modules on any machine that were granted a cache" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "cache", + "why": "the cache's own snapshot" + } + ], + "consumers": { + "redis-cache": { + "class": "cache", + "why": "a cache: nothing in this mesh requires it, and a consumer that kept data in it would be using a cache as a store" + } + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "999:1000", + "unknown": "program" + }, + { + "id": "server-conf", + "type": "file", + "path": "${dir:state}/redis.conf", + "mode": "0600", + "template": "files/redis.conf", + "owner": "999:1000" + }, + { + "id": "net", + "type": "network", + "name": "redis" + }, + { + "id": "server", + "type": "container", + "name": "redis", + "image": "redis@sha256:520775a41a63e77e06c73e35d2fd9cc15921a609516818796b4ecbb813078bc7", + "health": { + "kind": "tcp", + "endpoint": "cache" + }, + "network": "redis", + "ports": [ + "6379" + ], + "volumes": [ + "${dir:data}:/data", + "${dir:state}/redis.conf:/etc/redis/redis.conf:ro" + ], + "args": [ + "/etc/redis/redis.conf" + ], + "restart-on": [ + "server-conf" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_RECEIVES": "${dir:grants}/mesh.json", + "MESH_PROVISION_REDIS": "127.0.0.1:${port:6379}", + "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/default.secret" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/restic/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/restic/module.json.captured new file mode 100644 index 0000000..18ee811 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/restic/module.json.captured @@ -0,0 +1,196 @@ +{ + "module": "restic", + "version": "1", + "moved": true, + "claims": [ + { + "name": "node-backup", + "scope": "node", + "serves": [ + "backed-up", + "now", + "restore" + ] + } + ], + "tools": [ + "backup_health", + "backup_delete_retired", + "backup_deleted" + ], + "own-secrets": { + "repository": "${dir:state}/repository.secret" + }, + "data": { + "own": [ + { + "id": "repository", + "path": "${dir:repository}", + "class": "rebuildable", + "backup": "none", + "why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does" + }, + { + "id": "state", + "path": "${dir:state}", + "class": "cache", + "why": "what the last nights and measurements were; the next night writes it again" + } + ] + }, + "config": { + "keep-daily": { + "type": "number", + "description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 14 + }, + "keep-weekly": { + "type": "number", + "description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 8 + }, + "keep-monthly": { + "type": "number", + "description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 6 + }, + "keep-hand-taken-days": { + "type": "number", + "description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 7 + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "repository", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "declared", + "type": "file", + "path": "${dir:state}/backups.conf", + "mode": "0600", + "template": "files/backups.conf" + }, + { + "id": "data-declared", + "type": "file", + "path": "${dir:state}/data.conf", + "mode": "0600", + "template": "files/data.conf" + }, + { + "id": "retention", + "type": "file", + "path": "${dir:state}/retention.conf", + "mode": "0644", + "trusted": true, + "template": "files/retention.conf" + }, + { + "id": "tool", + "type": "package", + "package": "restic" + }, + { + "id": "sqlite", + "type": "package", + "package": "sqlite" + }, + { + "id": "night", + "type": "process", + "name": "restic-night", + "artifact": "tools", + "run": [ + "./restic-backups", + "night" + ], + "user": "${machine:account}", + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + }, + "health": { + "kind": "tool", + "tool": "backup_health", + "interval": "60s", + "timeout": "20s", + "looks": 2, + "grace": "60s" + } + }, + { + "id": "measure", + "type": "process", + "name": "restic-measure", + "artifact": "tools", + "run": [ + "./restic-backups", + "measure" + ], + "user": "${machine:account}", + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + }, + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/restic-backups", + "binary": "restic-backups", + "loads": [ + "restic-backups" + ], + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/rofi/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/rofi/module.json.captured new file mode 100644 index 0000000..fbd061d --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/rofi/module.json.captured @@ -0,0 +1,124 @@ +{ + "module": "rofi", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-launcher", + "scope": "node", + "serves": [ + "menu", + "secret" + ] + } + ], + "tools": [ + "rofi_applications", + "rofi_themes", + "rofi_run" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "rofi" + }, + { + "id": "configuration-dir", + "type": "directory", + "path": "${machine:account-home}/.config/rofi", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "themes-dir", + "type": "directory", + "path": "${machine:account-home}/.config/rofi/themes", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "configuration", + "type": "file", + "path": "${machine:account-home}/.config/rofi/config.rasi", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/config.rasi" + }, + { + "id": "theme", + "type": "file", + "path": "${machine:account-home}/.config/rofi/themes/mesh.rasi", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/themes/mesh.rasi" + }, + { + "id": "theme-powermenu", + "type": "file", + "path": "${machine:account-home}/.config/rofi/themes/mesh-powermenu.rasi", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/themes/mesh-powermenu.rasi" + }, + { + "id": "dmenu", + "type": "file", + "path": "${machine:account-home}/.local/bin/dmenu", + "owner": "${machine:account}", + "mode": "0755", + "template": "files/bin/dmenu" + }, + { + "id": "launch", + "type": "file", + "path": "${machine:account-home}/.local/bin/rofi-launch", + "owner": "${machine:account}", + "mode": "0755", + "template": "files/bin/rofi-launch" + }, + { + "id": "powermenu", + "type": "file", + "path": "${machine:account-home}/.local/bin/rofi-powermenu", + "owner": "${machine:account}", + "mode": "0755", + "template": "files/bin/rofi-powermenu" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/rofi-tools", + "binary": "rofi-tools", + "loads": [ + "rofi-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "items": [ + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/50-rofi.conf" + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/route-adapter/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/route-adapter/module.json.captured new file mode 100644 index 0000000..5234c78 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/route-adapter/module.json.captured @@ -0,0 +1,134 @@ +{ + "module": "route-adapter", + "version": "1", + "moved": true, + "slug": "radapt", + "capabilities": [ + "container-runtime" + ], + "provides": [ + { + "name": "route", + "scope": "mesh", + "identity": false + } + ], + "serves": { + "route": {} + }, + "receives": { + "route": "${dir:routes-dir}/mesh.json" + }, + "accesses": [ + { + "id": "dynamic", + "path": "/services/traefik/dynamic", + "mode": "read-write" + } + ], + "config": { + "dynamic": { + "type": "string", + "description": "The directory the predecessor proxy's file provider reads, on this machine, as an absolute path.", + "pattern": "/.*", + "trusted": true, + "default": "/services/traefik/dynamic" + }, + "entrypoint": { + "type": "string", + "description": "The predecessor proxy's entry point it serves public HTTPS on, by its own name.", + "trusted": true, + "default": "websecure" + }, + "certificate-resolver": { + "type": "string", + "description": "The predecessor proxy's certificate resolver, by its own name.", + "trusted": true, + "default": "le" + }, + "machine": { + "type": "string", + "description": "How the predecessor's proxy reaches this machine: a host name or an address.", + "trusted": true, + "default": "host.docker.internal" + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "routes-dir", + "type": "directory", + "path": "/var/lib/route-adapter/routes", + "mode": "0700", + "unknown": "program" + }, + { + "id": "config", + "type": "file", + "path": "${dir:state}/config.json", + "takes-config": ["dynamic", "entrypoint", "certificate-resolver", "machine"], + "merge": "json", + "mode": "0644", + "template": "files/config.json" + }, + { + "id": "adapt", + "type": "process", + "name": "route-adapter", + "artifact": "code", + "run": [ + "node", + "index.js" + ], + "run-once": true, + "env": { + "MESH_RECEIVES": "${dir:routes-dir}/mesh.json", + "MESH_ROUTE_ADAPTER_CONFIG": "${dir:state}/config.json" + }, + "restart-on": [ + "received-route", + "config" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js" + ] + }, + { + "name": "provider", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/route-provider", + "binary": "route-provider", + "loads": [ + "route-provider" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "state": [ + "bound" + ], + "reads": [ + "mesh-controller.assignments" + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/screen-lock/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/screen-lock/module.json.captured new file mode 100644 index 0000000..1791c79 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/screen-lock/module.json.captured @@ -0,0 +1,78 @@ +{ + "module": "screen-lock", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-lock-screen", + "scope": "node", + "serves": [ + "lock" + ] + } + ], + "tools": [ + "screen_lock_idle", + "screen_lock_inhibit", + "screen_lock_locked" + ], + "items": [ + { + "collection": "shell:xinitrc:normal", + "name": "xinitrc-normal", + "template": "files/xinitrc-normal" + } + ], + "resources": [ + { + "id": "screensaver", + "type": "package", + "package": "xscreensaver", + "absent": true + }, + { + "id": "watcher", + "type": "package", + "package": "xss-lock" + }, + { + "id": "locker", + "type": "package", + "package": "i3lock" + }, + { + "id": "wrapper", + "type": "file", + "path": "${machine:account-home}/.local/bin/screen-lock", + "owner": "${machine:account}", + "mode": "0755", + "template": "files/bin/screen-lock" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/screen-lock-tools", + "binary": "screen-lock-tools", + "loads": [ + "screen-lock-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/searxng/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/searxng/module.json.captured new file mode 100644 index 0000000..cee18e4 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/searxng/module.json.captured @@ -0,0 +1,229 @@ +{ + "module": "searxng", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "own-secrets": { + "secret": { + "path": "${dir:mesh-state}/secret", + "taken": "at-start" + } + }, + "listens": [ + { + "name": "web", + "port": 8080, + "protocol": "tcp", + "from": "mesh", + "why": "the search pages" + } + ], + "data": { + "own": [ + { + "id": "valkey", + "path": "${dir:valkey-data}", + "class": "cache", + "why": "the search cache" + } + ] + }, + "config": { + "general": { + "type": "object", + "description": "SearXNG's general section of settings.yml, laid over the file's own and SearXNG's defaults: the instance's name, contact and debug switches.", + "trusted": true, + "default": {} + }, + "brand": { + "type": "object", + "description": "SearXNG's brand section of settings.yml, laid over the file's own and SearXNG's defaults: the links the pages show for the instance's source, issues and documentation.", + "trusted": true, + "default": {} + }, + "search": { + "type": "object", + "description": "SearXNG's search section of settings.yml, laid over the file's own and SearXNG's defaults: safe search, autocomplete, the result formats offered and how long an engine is suspended after an error.", + "trusted": true, + "default": { + "formats": [ + "html", + "json" + ] + } + }, + "server": { + "type": "object", + "description": "SearXNG's server section of settings.yml, laid over the file's own and SearXNG's defaults: the public base URL, the request method, the limiter and the image proxy; its secret_key is the vault's.", + "trusted": true, + "default": { + "secret_key": "${secret:secret}", + "base_url": false, + "limiter": false, + "image_proxy": false, + "public_instance": false + } + }, + "ui": { + "type": "object", + "description": "SearXNG's ui section of settings.yml, laid over the file's own and SearXNG's defaults: the theme, the default locale and how results are shown.", + "trusted": true, + "default": {} + }, + "outgoing": { + "type": "object", + "description": "SearXNG's outgoing section of settings.yml, laid over the file's own and SearXNG's defaults: how SearXNG reaches the engines: timeouts, connection pool and proxies.", + "trusted": true, + "default": {} + }, + "engines": { + "type": "list", + "description": "SearXNG's engines section of settings.yml, laid over the file's own and SearXNG's defaults: the engines to add or change, each by its name.", + "trusted": true, + "default": [] + }, + "plugins": { + "type": "object", + "description": "SearXNG's plugins section of settings.yml, laid over the file's own and SearXNG's defaults: the plugins to switch on or off, by their id.", + "trusted": true, + "default": {} + }, + "categories_as_tabs": { + "type": "object", + "description": "SearXNG's categories_as_tabs section of settings.yml, laid over the file's own and SearXNG's defaults: the categories shown as tabs above the results.", + "trusted": true, + "default": {} + }, + "preferences": { + "type": "object", + "description": "SearXNG's preferences section of settings.yml, laid over the file's own and SearXNG's defaults: the preferences a user cannot change (lock).", + "trusted": true, + "default": {} + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "valkey-data", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "searxng" + }, + { + "id": "cache", + "type": "container", + "name": "valkey", + "image": "valkey/valkey@sha256:b21fd94099dcd4bc6b2b9230daef69b6558b887ad4a2a1afe56ff6e745a88cdb", + "network": "searxng", + "args": [ + "valkey-server", + "--save", + "30", + "1", + "--loglevel", + "warning" + ], + "volumes": [ + "${dir:valkey-data}:/data" + ] + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.yml", + "mode": "0600", + "merge": "json", + "takes-config": [ + "general", + "brand", + "search", + "server", + "ui", + "outgoing", + "engines", + "plugins", + "categories_as_tabs", + "preferences" + ], + "template": "files/settings.yml" + }, + { + "id": "server", + "type": "container", + "name": "searxng", + "image": "searxng/searxng@sha256:cd8812607ab73730a0b1a0dc4990223fe1b9e383f6f35947114d0bef7f8bb441", + "network": "searxng", + "ports": [ + "8080" + ], + "volumes": [ + "${dir:state}/settings.yml:/etc/searxng/settings.yml:ro" + ], + "restart-on": [ + "settings" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json" + } + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "searxng", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_SEARXNG_URL": "http://127.0.0.1:${port:8080}", + "MESH_SEARXNG_CONFIG_FILE": "${dir:mesh-state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/sensors/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/sensors/module.json.captured new file mode 100644 index 0000000..3df6783 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/sensors/module.json.captured @@ -0,0 +1,69 @@ +{ + "module": "sensors", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "sensors_summary", + "sensors_temperatures", + "sensors_fans", + "sensors_drives", + "sensors_faults", + "sensors_health" + ], + "resources": [ + { + "id": "smartmontools", + "type": "package", + "package": "smartmontools" + }, + { + "id": "drives", + "type": "process", + "name": "sensors-drives", + "artifact": "tools", + "run": [ + "./sensors", + "drives" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "watch", + "type": "process", + "name": "sensors-watch", + "artifact": "tools", + "run": [ + "./sensors", + "watch" + ], + "health": { + "kind": "tool", + "tool": "sensors_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/sensors", + "binary": "sensors", + "loads": [ + "sensors" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/showcase/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/showcase/module.json.captured new file mode 100644 index 0000000..e657e2a --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/showcase/module.json.captured @@ -0,0 +1,207 @@ +{ + "module": "showcase", + "version": "1", + "templates": "templates", + "slug": "show", + "capabilities": [ + "container-runtime" + ], + "provides": [ + { + "name": "greeting", + "scope": "mesh" + } + ], + "serves": { + "greeting": { + "path": "/greeting" + } + }, + "requires": [ + "postgres-database" + ], + "binds": { + "postgres-database": "${dir:state}/database.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "own-secrets": { + "broker": "${dir:mesh-state}/broker" + }, + "claims": [ + { + "name": "the-showcase", + "scope": "node" + } + ], + "emits": [ + "greeted", + "acknowledged" + ], + "consumes": [ + "showcase.greeted" + ], + "listens": [ + { + "name": "web", + "port": 8080, + "protocol": "tcp", + "from": "mesh", + "why": "the port the daemon itself listens on. The mesh assigns the machine-side number and tells consumers that one (ADR 0038)" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js", + "daemon/index.js", + "step/index.js", + "report/index.js" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + }, + { + "name": "helper", + "kind": "upstream", + "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" + }, + { + "name": "templates", + "kind": "archive", + "from": "templates" + } + ] + }, + "resources": [ + { + "id": "account", + "type": "user", + "name": "showcase", + "shell": "/usr/bin/nologin", + "home": "/var/lib/showcase" + }, + { + "id": "logs", + "type": "access", + "path": "/var/log", + "mode": "0755" + }, + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0755", + "place": ".", + "unknown": "program" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/showcase.env", + "mode": "0600", + "template": "templates/showcase.env" + }, + { + "id": "packed", + "type": "archive", + "path": "/opt/showcase", + "artifact": "files", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "showcase" + }, + { + "id": "tooling", + "type": "package", + "package": "jq" + }, + { + "id": "migrate", + "type": "process", + "name": "showcase-migrate", + "artifact": "code", + "run": [ + "node", + "step/index.js" + ], + "run-once": true, + "env-file": [ + "${dir:state}/showcase.env" + ] + }, + { + "id": "server", + "type": "process", + "name": "showcase", + "artifact": "code", + "run": [ + "node", + "daemon/index.js" + ], + "user": "showcase", + "env-file": [ + "${dir:state}/showcase.env" + ], + "restart-on": [ + "settings" + ] + }, + { + "id": "reporting", + "type": "process", + "name": "showcase-report", + "artifact": "code", + "run": [ + "node", + "report/index.js" + ], + "schedule": "0 3 * * *", + "env-file": [ + "${dir:state}/showcase.env" + ] + }, + { + "id": "tools", + "type": "container", + "name": "the-showcase", + "artifact": "helper", + "network": "showcase", + "volumes": [ + "${dir:mesh-state}/broker:/run/secrets/broker:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker" + }, + "args": [ + "sleep", + "infinity" + ] + } + ], + "seats": [ + { + "name": "the-showcase", + "scope": "node" + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/slack/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/slack/module.json.captured new file mode 100644 index 0000000..9feeedb --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/slack/module.json.captured @@ -0,0 +1,41 @@ +{ + "module": "slack", + "version": "1", + "moved": true, + "requires": [ + "x11-display" + ], + "tools": [ + "slack_status", + "slack_log", + "slack_restart", + "slack_check" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/slack-tools", + "binary": "slack-tools", + "loads": [ + "slack-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "items": [ + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/display-session" + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/snapd/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/snapd/module.json.captured new file mode 100644 index 0000000..93ad730 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/snapd/module.json.captured @@ -0,0 +1,31 @@ +{ + "module": "snapd", + "version": "1", + "tools": [ + "snapd_status", + "snapd_list", + "snapd_info", + "snapd_updates", + "snapd_disk_usage", + "snapd_services", + "snapd_changes", + "snapd_install", + "snapd_remove", + "snapd_refresh" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/snapd-tools", + "binary": "snapd-tools", + "loads": [ + "snapd-tools" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/ssh-client/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/ssh-client/module.json.captured new file mode 100644 index 0000000..a696544 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/ssh-client/module.json.captured @@ -0,0 +1,112 @@ +{ + "module": "ssh-client", + "version": "1", + "moved": true, + "tools": [ + "ssh_client_hosts", + "ssh_client_resolve", + "ssh_client_check", + "ssh_client_keys", + "ssh_client_authorized", + "ssh_client_revoke", + "ssh_client_known_host", + "ssh_client_test" + ], + "requires": [ + "forge-account" + ], + "state": [ + "keys" + ], + "data": { + "own": [ + { + "id": "ssh", + "path": "${dir:ssh-dir}", + "class": "valuable", + "why": "the operator's keys and known hosts" + } + ] + }, + "resources": [ + { + "id": "ssh-dir", + "type": "directory", + "path": "${machine:account-home}/.ssh", + "owner": "${machine:account}", + "mode": "0700", + "unknown": "module" + }, + { + "id": "config-d", + "type": "directory", + "path": "${machine:account-home}/.ssh/config.d", + "owner": "${machine:account}", + "mode": "0700", + "unknown": "module" + }, + { + "id": "config", + "type": "file", + "path": "${machine:account-home}/.ssh/config", + "owner": "${machine:account}", + "mode": "0600", + "into": "block", + "at": "start", + "template": "files/config" + }, + { + "id": "known-hosts", + "type": "file", + "path": "${machine:account-home}/.ssh/known_hosts", + "owner": "${machine:account}", + "mode": "0644", + "into": "block", + "template": "files/known_hosts" + }, + { + "id": "forge-host", + "type": "file", + "path": "${machine:account-home}/.ssh/config.d/10-forge", + "owner": "${machine:account}", + "mode": "0600", + "template": "files/forge" + }, + { + "id": "git-config", + "type": "file", + "path": "${machine:account-home}/.config/git/config", + "owner": "${machine:account}", + "mode": "0644", + "into": "block", + "template": "files/gitconfig" + } + ], + "facts": { + "mesh-hosts": { + "path": ".ssh/config.d/00-mesh", + "home": true, + "template": "# Generated by the mesh. Do not edit — module ssh-client writes this file whenever a machine\n# joins, leaves or is renamed. ~/.ssh/config includes it first, so these hosts win over every\n# later line; a host of your own goes below the mesh's region in ~/.ssh/config.\n{{range .Machines}}{{if ne .Name $.Node}}\nHost {{.Name}} {{.FQDN}}\n HostName {{.FQDN}}\n{{if .Account}} User {{.Account}}\n{{end}}{{end}}{{end}}" + } + }, + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/ssh-client-tools", + "binary": "ssh-client-tools", + "loads": [ + "ssh-client-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/sshd/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/sshd/module.json.captured new file mode 100644 index 0000000..066e058 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/sshd/module.json.captured @@ -0,0 +1,54 @@ +{ + "module": "sshd", + "version": "1", + "upgrade": { + "policy": "record", + "why": "the operator's way into the machine when the mesh cannot reach it: a build that breaks it is found only when that way is needed, which no gate sees (hq ADR 0236)" + }, + "capabilities": [ + "package-manager", + "service-manager" + ], + "listens": [ + { + "name": "ssh", + "port": 22, + "protocol": "tcp", + "from": "anywhere", + "why": "the operator's own door. From anywhere because the machines that need it are exactly the ones not on the mesh yet \u2014 and locking the operator out is the one failure a firewall must never arrange" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "openssh" + }, + { + "id": "config", + "type": "file", + "path": "/etc/ssh/sshd_config.d/10-mesh.conf", + "mode": "0644", + "template": "files/10-mesh.conf" + }, + { + "id": "run", + "type": "service", + "unit": "sshd.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "config" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/step-ca/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/step-ca/module.json.captured new file mode 100644 index 0000000..b11d2b9 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/step-ca/module.json.captured @@ -0,0 +1,106 @@ +{ + "module": "step-ca", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "provides": [ + { + "name": "internal-acme-ca", + "scope": "mesh" + } + ], + "serves": { + "internal-acme-ca": { + "path": "/acme/acme/directory", + "roots": "/roots.pem" + } + }, + "listens": [ + { + "name": "acme", + "port": 9000, + "protocol": "tcp", + "from": "mesh", + "why": "the ACME directory and step-ca API; a proxy on any node reaches it here over the mesh to obtain certificates, and its single listen is what the consumer is told the port is" + } + ], + "own-secrets": { + "password": "${dir:mesh-state}/password" + }, + "data": { + "own": [ + { + "id": "home", + "path": "${dir:home}", + "class": "valuable", + "why": "the mesh's certificate authority: its keys and its database of what it issued" + } + ] + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "home", + "type": "directory", + "mode": "0700", + "owner": "1000:1000", + "place": ".", + "unknown": "program" + }, + { + "id": "config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0644", + "template": "files/config.json", + "takes-config": [], + "merge": "json" + }, + { + "id": "init-env", + "type": "file", + "path": "${dir:mesh-state}/init.env", + "mode": "0600", + "template": "files/init.env" + }, + { + "id": "server", + "type": "container", + "name": "step-ca", + "image": "smallstep/step-ca@sha256:a2b17872915c193259b75a5474c398326f41bd199f0842093e52cf4182bc8270", + "health": { + "kind": "runtime" + }, + "network": "host", + "env-file": [ + "${dir:mesh-state}/init.env" + ], + "env": { + "DOCKER_STEPCA_INIT_NAME": "Mesh Internal CA", + "DOCKER_STEPCA_INIT_ACME": "true", + "DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT": "false" + }, + "volumes": [ + "${dir:home}:/home/step", + "${dir:mesh-state}:/run/mesh:ro" + ], + "secrets-in-environment": "the entrypoint honours DOCKER_STEPCA_INIT_PASSWORD_FILE; convertible, awaiting a bed that proves it" + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/sudo/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/sudo/module.json.captured new file mode 100644 index 0000000..364ce5f --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/sudo/module.json.captured @@ -0,0 +1,47 @@ +{ + "module": "sudo", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "sudo_rules", + "sudo_check", + "sudo_escalation", + "sudo_drop_ins" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "sudo" + }, + { + "id": "operator", + "type": "file", + "path": "/etc/sudoers.d/10-mesh-operator", + "mode": "0440", + "template": "files/10-mesh-operator" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/sudo-tools", + "binary": "sudo-tools", + "loads": [ + "sudo-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/supabase/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/supabase/module.json.captured new file mode 100644 index 0000000..14ddbcd --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/supabase/module.json.captured @@ -0,0 +1,659 @@ +{ + "module": "supabase", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "listens": [ + { + "name": "api", + "port": 8000, + "protocol": "tcp", + "from": "mesh", + "why": "the Kong gateway: the REST, auth, storage, realtime, functions and GraphQL APIs under their /\u2026/v1 paths, and Studio at / behind the dashboard password. The one surface people and apps use, reached through the route" + }, + { + "name": "pooler-session", + "port": 5432, + "protocol": "tcp", + "from": "mesh", + "why": "Supavisor's session-mode Postgres port, for clients that connect to the database directly as ." + }, + { + "name": "pooler-transaction", + "port": 6543, + "protocol": "tcp", + "from": "mesh", + "why": "Supavisor's transaction-mode Postgres port" + } + ], + "requires": [ + "route" + ], + "own-secrets": { + "postgres": "${dir:state}/postgres.secret", + "jwt": "${dir:state}/jwt.secret", + "anon-key": "${dir:state}/anon-key.secret", + "service-role-key": "${dir:state}/service-role-key.secret", + "dashboard-user": "${dir:state}/dashboard-user.secret", + "dashboard": "${dir:state}/dashboard.secret", + "logflare": { + "path": "${dir:state}/logflare.secret", + "taken": "at-start" + }, + "pooler-vault": "${dir:state}/pooler-vault.secret", + "key-base-a": "${dir:state}/key-base-a.secret", + "key-base-b": "${dir:state}/key-base-b.secret", + "openai": { + "path": "${dir:state}/openai.secret", + "issued-by": "outside" + } + }, + "contributes": { + "route": { + "label": "supabase", + "endpoint": "api" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "data": { + "own": [ + { + "id": "db", + "path": "${dir:db-data}", + "class": "valuable", + "backup": { + "dump": "docker exec supabase-db pg_dumpall -h 127.0.0.1 -U supabase_admin > ${dir:dumps}/all.sql.partial && mv ${dir:dumps}/all.sql.partial ${dir:dumps}/all.sql", + "into": "dumps" + }, + "why": "every table; copied by pg_dumpall inside the database's container (local connections are trusted there), since a running store's files are not a consistent copy" + }, + { + "id": "dumps", + "path": "${dir:dumps}", + "class": "rebuildable", + "why": "last night's dump of the database, made again every night" + }, + { + "id": "storage", + "path": "${dir:storage}", + "class": "valuable", + "why": "uploaded objects" + }, + { + "id": "functions", + "path": "${dir:functions}", + "class": "valuable", + "why": "the edge functions' code" + }, + { + "id": "db-config", + "path": "${dir:db-config}", + "class": "rebuildable", + "why": "the database's configuration, seeded again" + } + ] + }, + "config": { + "pooler": { + "type": "object", + "description": "The connection pooler's settings, laid over the file's: its tenant, pool size and the most client connections it takes.", + "trusted": true, + "default": { + "tenant": "default", + "pool-size": 20, + "max-client-connections": 100 + } + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "db-init", + "type": "directory", + "path": "${dir:state}/db-init", + "mode": "0755", + "unknown": "program" + }, + { + "id": "functions-main-dir", + "type": "directory", + "path": "${dir:state}/functions-main", + "mode": "0755", + "unknown": "program" + }, + { + "id": "db-data", + "type": "directory", + "mode": "0700", + "owner": "105:106", + "unknown": "program" + }, + { + "id": "dumps", + "type": "directory", + "path": "${dir:state}/dumps", + "mode": "0700", + "unknown": "program" + }, + { + "id": "db-config", + "type": "directory", + "mode": "0755", + "owner": "105:106", + "unknown": "program" + }, + { + "id": "storage", + "type": "directory", + "mode": "0755", + "unknown": "program" + }, + { + "id": "functions", + "type": "directory", + "mode": "0755", + "unknown": "program" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0644", + "merge": "json", + "takes-config": ["pooler"], + "template": "files/settings.json" + }, + { + "id": "kong-conf", + "type": "file", + "path": "${dir:state}/kong.yml", + "mode": "0600", + "owner": "100:65533", + "template": "files/kong.yml" + }, + { + "id": "auth-conf", + "type": "file", + "path": "${dir:state}/gotrue.env", + "mode": "0600", + "owner": "1000:1000", + "template": "files/gotrue.env" + }, + { + "id": "rest-conf", + "type": "file", + "path": "${dir:state}/postgrest.conf", + "mode": "0600", + "owner": "1000:1000", + "template": "files/postgrest.conf" + }, + { + "id": "vector-conf", + "type": "file", + "path": "${dir:state}/vector.yml", + "mode": "0600", + "template": "files/vector.yml" + }, + { + "id": "pooler-conf", + "type": "file", + "path": "${dir:state}/pooler.exs", + "mode": "0644", + "template": "files/pooler.exs" + }, + { + "id": "db-init-roles", + "type": "file", + "path": "${dir:state}/db-init/roles.sql", + "mode": "0600", + "owner": "105:106", + "template": "files/db-init/roles.sql" + }, + { + "id": "db-init-jwt", + "type": "file", + "path": "${dir:state}/db-init/jwt.sql", + "mode": "0600", + "owner": "105:106", + "template": "files/db-init/jwt.sql" + }, + { + "id": "db-init-realtime", + "type": "file", + "path": "${dir:state}/db-init/realtime.sql", + "mode": "0600", + "owner": "105:106", + "template": "files/db-init/realtime.sql" + }, + { + "id": "db-init-supabase", + "type": "file", + "path": "${dir:state}/db-init/supabase.sql", + "mode": "0600", + "owner": "105:106", + "template": "files/db-init/supabase.sql" + }, + { + "id": "db-init-logs", + "type": "file", + "path": "${dir:state}/db-init/logs.sql", + "mode": "0600", + "owner": "105:106", + "template": "files/db-init/logs.sql" + }, + { + "id": "db-init-pooler", + "type": "file", + "path": "${dir:state}/db-init/pooler.sql", + "mode": "0600", + "owner": "105:106", + "template": "files/db-init/pooler.sql" + }, + { + "id": "db-init-webhooks", + "type": "file", + "path": "${dir:state}/db-init/webhooks.sql", + "mode": "0600", + "owner": "105:106", + "template": "files/db-init/webhooks.sql" + }, + { + "id": "functions-main", + "type": "file", + "path": "${dir:state}/functions-main/index.ts", + "mode": "0644", + "template": "files/functions-main/index.ts" + }, + { + "id": "realtime-env", + "type": "file", + "path": "${dir:state}/realtime-env.env", + "mode": "0600", + "template": "files/realtime-env.env" + }, + { + "id": "storage-env", + "type": "file", + "path": "${dir:state}/storage-env.env", + "mode": "0600", + "template": "files/storage-env.env" + }, + { + "id": "meta-env", + "type": "file", + "path": "${dir:state}/meta-env.env", + "mode": "0600", + "template": "files/meta-env.env" + }, + { + "id": "functions-env", + "type": "file", + "path": "${dir:state}/functions-env.env", + "mode": "0600", + "template": "files/functions-env.env" + }, + { + "id": "analytics-env", + "type": "file", + "path": "${dir:state}/analytics-env.env", + "mode": "0600", + "template": "files/analytics-env.env" + }, + { + "id": "analytics-start", + "type": "file", + "path": "${dir:state}/analytics-start.sh", + "mode": "0644", + "template": "files/analytics-start.sh" + }, + { + "id": "analytics-reconcile", + "type": "file", + "path": "${dir:state}/analytics-reconcile.exs", + "mode": "0644", + "template": "files/analytics-reconcile.exs" + }, + { + "id": "studio-env", + "type": "file", + "path": "${dir:state}/studio-env.env", + "mode": "0600", + "template": "files/studio-env.env" + }, + { + "id": "pooler-env", + "type": "file", + "path": "${dir:state}/pooler-env.env", + "mode": "0600", + "template": "files/pooler-env.env" + }, + { + "id": "net", + "type": "network", + "name": "supabase" + }, + { + "id": "vector", + "type": "container", + "name": "supabase-vector", + "image": "timberio/vector@sha256:4bc04aca94a44f04b427a490f346e7397ef7ce61fe589d718f744f7d92cb5c80", + "network": "supabase", + "args": [ + "--config", + "/etc/vector/vector.yml" + ], + "volumes": [ + "${dir:state}/vector.yml:/etc/vector/vector.yml:ro", + "/var/run/docker.sock:/var/run/docker.sock:ro" + ], + "restart-on": [ + "vector-conf" + ] + }, + { + "id": "db-config-seed", + "type": "container", + "name": "supabase-db-config-seed", + "image": "supabase/postgres@sha256:ee29d0aaff03d0e12c7aa63437cba25f24246872f0ed3f82a9fd13184d780777", + "network": "supabase", + "run-once": true, + "args": [ + "sh", + "-c", + "cp -rn /etc/postgresql-custom/. /seed/ && chown -R postgres:postgres /seed" + ], + "volumes": [ + "${dir:db-config}:/seed" + ] + }, + { + "id": "db", + "type": "container", + "name": "supabase-db", + "image": "supabase/postgres@sha256:ee29d0aaff03d0e12c7aa63437cba25f24246872f0ed3f82a9fd13184d780777", + "health": { + "kind": "runtime" + }, + "network": "supabase", + "args": [ + "postgres", + "-c", + "config_file=/etc/postgresql/postgresql.conf", + "-c", + "log_min_messages=fatal" + ], + "env": { + "POSTGRES_HOST": "/var/run/postgresql", + "PGPORT": "5432", + "POSTGRES_PORT": "5432", + "PGDATABASE": "postgres", + "POSTGRES_DB": "postgres", + "POSTGRES_PASSWORD_FILE": "/run/secrets/postgres" + }, + "volumes": [ + "${dir:state}/postgres.secret:/run/secrets/postgres:ro", + "${dir:state}/db-init/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:ro", + "${dir:state}/db-init/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:ro", + "${dir:state}/db-init/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:ro", + "${dir:state}/db-init/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:ro", + "${dir:state}/db-init/supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:ro", + "${dir:state}/db-init/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:ro", + "${dir:state}/db-init/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:ro", + "${dir:db-data}:/var/lib/postgresql/data", + "${dir:db-config}:/etc/postgresql-custom" + ] + }, + { + "id": "db-ready", + "type": "container", + "name": "supabase-db-ready", + "image": "supabase/postgres@sha256:ee29d0aaff03d0e12c7aa63437cba25f24246872f0ed3f82a9fd13184d780777", + "network": "supabase", + "run-once": true, + "args": [ + "sh", + "-c", + "for i in $(seq 150); do pg_isready -h supabase-db -p 5432 -U postgres && exit 0; sleep 2; done; echo 'the database did not come up' >&2; exit 1" + ] + }, + { + "id": "analytics", + "type": "container", + "name": "supabase-analytics", + "image": "supabase/logflare@sha256:e693c787ffe1ae17b6e4e920a3cdd212416d3e1f97e1bd7cb5b67de0abbb0264", + "network": "supabase", + "env-file": [ + "${dir:state}/analytics-env.env" + ], + "volumes": [ + "${dir:state}/analytics-start.sh:/run/logflare/start.sh:ro", + "${dir:state}/vector.yml:/run/logflare/vector.yml:ro", + "${dir:state}/analytics-reconcile.exs:/run/logflare/reconcile.exs:ro" + ], + "args": [ + "sh", + "/run/logflare/start.sh" + ], + "restart-on": [ + "analytics-env", + "analytics-start", + "analytics-reconcile", + "vector-conf" + ], + "secrets-in-environment": "Logflare reads its database password and API key from the environment only. The API key is never in a URL the mesh writes: logflare prints a failed request's URL, so vector sends the key in the x-api-key header, and the start script refuses to start logflare while vector.yml says otherwise" + }, + { + "id": "analytics-ready", + "type": "container", + "name": "supabase-analytics-ready", + "image": "supabase/logflare@sha256:e693c787ffe1ae17b6e4e920a3cdd212416d3e1f97e1bd7cb5b67de0abbb0264", + "network": "supabase", + "run-once": true, + "args": [ + "sh", + "-c", + "for i in $(seq 150); do curl -fsS -o /dev/null http://supabase-analytics:4000/health && exit 0; sleep 2; done; echo 'analytics did not come up' >&2; exit 1" + ] + }, + { + "id": "kong", + "type": "container", + "name": "supabase-kong", + "image": "kong@sha256:1b53405d8680a09d6f44494b7990bf7da2ea43f84a258c59717d4539abf09f6d", + "health": { + "kind": "runtime" + }, + "network": "supabase", + "ports": [ + "8000" + ], + "env": { + "KONG_DATABASE": "off", + "KONG_DECLARATIVE_CONFIG": "/home/kong/kong.yml", + "KONG_DNS_ORDER": "LAST,A,CNAME", + "KONG_PLUGINS": "request-transformer,cors,key-auth,acl,basic-auth", + "KONG_NGINX_PROXY_PROXY_BUFFER_SIZE": "160k", + "KONG_NGINX_PROXY_PROXY_BUFFERS": "64 160k" + }, + "volumes": [ + "${dir:state}/kong.yml:/home/kong/kong.yml:ro" + ], + "restart-on": [ + "kong-conf" + ] + }, + { + "id": "auth", + "type": "container", + "name": "supabase-auth", + "image": "supabase/gotrue@sha256:f8b871f46f3f9c5306af00d2fb856fb5479db730bb6d4fd2757b2da8abe953f9", + "network": "supabase", + "args": [ + "auth", + "-c", + "/etc/gotrue/gotrue.env" + ], + "volumes": [ + "${dir:state}/gotrue.env:/etc/gotrue/gotrue.env:ro" + ], + "restart-on": [ + "auth-conf" + ] + }, + { + "id": "rest", + "type": "container", + "name": "supabase-rest", + "image": "postgrest/postgrest@sha256:2cf1efd2c9c2e7606610c113cc73e936d8ce9ba089271cb9cbf11aa564bc30c7", + "network": "supabase", + "args": [ + "postgrest", + "/etc/postgrest/postgrest.conf" + ], + "volumes": [ + "${dir:state}/postgrest.conf:/etc/postgrest/postgrest.conf:ro" + ], + "restart-on": [ + "rest-conf" + ] + }, + { + "id": "realtime", + "type": "container", + "name": "realtime-dev.supabase-realtime", + "image": "supabase/realtime@sha256:104fe2e4e09a7c81eebe0c39e3a9b9dca4684f55470ca01cb3c080d1d56453a1", + "network": "supabase", + "env-file": [ + "${dir:state}/realtime-env.env" + ], + "secrets-in-environment": "Supabase Realtime (Elixir release) reads DB_PASSWORD, API_JWT_SECRET and SECRET_KEY_BASE from its environment only; no file or _FILE form" + }, + { + "id": "imgproxy", + "type": "container", + "name": "supabase-imgproxy", + "image": "darthsim/imgproxy@sha256:0facd355d50f3be665ebe674486f2b2e9cdaebd3f74404acd9b7fece2f661435", + "network": "supabase", + "env": { + "IMGPROXY_BIND": ":5001", + "IMGPROXY_LOCAL_FILESYSTEM_ROOT": "/", + "IMGPROXY_USE_ETAG": "true", + "IMGPROXY_ENABLE_WEBP_DETECTION": "true" + }, + "volumes": [ + "${dir:storage}:/var/lib/storage" + ] + }, + { + "id": "storage-api", + "type": "container", + "name": "supabase-storage", + "image": "supabase/storage-api@sha256:1e85dad48e8b3e85890a555e5114dc7ee48c2e8be4cfd97dd4e3564b4f104fcd", + "network": "supabase", + "volumes": [ + "${dir:storage}:/var/lib/storage" + ], + "env-file": [ + "${dir:state}/storage-env.env" + ], + "secrets-in-environment": "Supabase Storage reads its keys and DATABASE_URL from the environment only" + }, + { + "id": "meta", + "type": "container", + "name": "supabase-meta", + "image": "supabase/postgres-meta@sha256:d0a96973e9f1b6303f7500421a459af3d7273b3f9f1db38610899f9f573da7c7", + "health": { + "kind": "runtime" + }, + "network": "supabase", + "env-file": [ + "${dir:state}/meta-env.env" + ], + "secrets-in-environment": "postgres-meta reads PG_META_DB_PASSWORD from the environment only" + }, + { + "id": "edge-functions", + "type": "container", + "name": "supabase-edge-functions", + "image": "supabase/edge-runtime@sha256:6f7ac3b363f6b278ff9235672a81504ad07709a07f62c50cd4fffb6708842ff1", + "network": "supabase", + "args": [ + "start", + "--main-service", + "/home/deno/main" + ], + "volumes": [ + "${dir:functions}:/home/deno/functions", + "${dir:state}/functions-main/index.ts:/home/deno/main/index.ts:ro" + ], + "env-file": [ + "${dir:state}/functions-env.env" + ], + "secrets-in-environment": "the edge runtime's main service hands its own environment (JWT secret, service key, database URL) to every function worker by design; there is no other channel" + }, + { + "id": "studio", + "type": "container", + "name": "supabase-studio", + "image": "supabase/studio@sha256:ebf492ba82db1fc9497f56e4cdebf5cddb464b61f6227e21c8f5f3693f9dbbc5", + "health": { + "kind": "runtime" + }, + "network": "supabase", + "env-file": [ + "${dir:state}/studio-env.env" + ], + "restart-on": [ + "studio-env" + ], + "secrets-in-environment": "Studio (Next.js) reads its keys, the database password and the OpenAI key from the environment only" + }, + { + "id": "pooler", + "type": "container", + "name": "supabase-pooler", + "image": "supabase/supavisor@sha256:2f24ab556380db7aaf402d2ec5db09d1974739e685781a91a77ab5ae0dadb3a5", + "network": "supabase", + "ports": [ + "5432", + "6543" + ], + "args": [ + "/bin/sh", + "-c", + "/app/bin/migrate && /app/bin/supavisor eval \"$(cat /etc/pooler/pooler.exs)\" && /app/bin/server" + ], + "volumes": [ + "${dir:state}/pooler.exs:/etc/pooler/pooler.exs:ro", + "${dir:state}/settings.json:/etc/pooler/settings.json:ro" + ], + "restart-on": [ + "pooler-conf", + "settings" + ], + "env-file": [ + "${dir:state}/pooler-env.env" + ], + "secrets-in-environment": "Supavisor reads DATABASE_URL, SECRET_KEY_BASE, VAULT_ENC_KEY and the JWT secret from the environment only" + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/systemd-networkd/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/systemd-networkd/module.json.captured new file mode 100644 index 0000000..0c4c109 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/systemd-networkd/module.json.captured @@ -0,0 +1,65 @@ +{ + "module": "systemd-networkd", + "version": "1", + "upgrade": { + "policy": "record", + "why": "the machine's network: a build that breaks it can cut the machine off from the bus, and then neither the gate's rollback nor a push reaches it (hq ADR 0236)" + }, + "slug": "networkd", + "requires": [ + "wildcard-resolution" + ], + "capabilities": [ + "service-manager", + "uplink-systemd-networkd" + ], + "claims": [ + { + "name": "node-uplink", + "scope": "node" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/uplink-tools", + "binary": "uplink-tools", + "loads": [ + "uplink-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "facts": { + "resolvers": { + "path": "/etc/resolv.conf", + "template": "# Managed by the mesh, and written by the module holding this machine's uplink:\n# the program that manages the machine's network would otherwise rewrite this\n# file on every change of network, so its holder is the one that writes it\n# (novox/hq ADR 0117, ADR 0223). Replaced on every push; edit nothing here.\n#\n# Every resolver of the mesh, by address, and nothing else (novox/hq ADR 0223) —\n# this machine's own first when it holds one, then the others by name. Each\n# answers the mesh's names from the same roster and forwards every other name, so\n# whichever answers first gives the one answer. There is no public resolver here:\n# a C library that asks every listed server at once and takes the first reply —\n# musl, so every Alpine container — took a public resolver's \"no such name\" for\n# a mesh name and failed. A machine that reaches none of these has no names until\n# it does. Containers copy these lines from their machine.\n{{range index .Holders \"mesh-dns-resolver\"}}nameserver {{.Address}}\n{{end}}options timeout:1 attempts:2 edns0\n" + } + }, + "resources": [ + { + "id": "config", + "type": "file", + "path": "/etc/systemd/network/00-mesh0.network", + "mode": "0644", + "template": "files/00-mesh0.network" + }, + { + "id": "service", + "type": "service", + "unit": "systemd-networkd.service", + "reload-on": [ + "config" + ] + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/systemd-resolved/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/systemd-resolved/module.json.captured new file mode 100644 index 0000000..b850afc --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/systemd-resolved/module.json.captured @@ -0,0 +1,110 @@ +{ + "module": "systemd-resolved", + "version": "1", + "upgrade": { + "policy": "record", + "why": "the machine's names: a build that breaks this resolver stops every name resolving on a machine a person works on, the bus's included, and then neither the gate's rollback nor a push reaches it (hq ADR 0236, ADR 0247)" + }, + "provides": [ + { + "name": "split-dns", + "reach": "machine" + } + ], + "requires": [ + "wildcard-resolution" + ], + "capabilities": [ + "service-manager" + ], + "claims": [ + { + "name": "node-resolver", + "scope": "node", + "serves": [ + "routes", + "route", + "unroute" + ] + } + ], + "listens": [ + { + "name": "dns-udp", + "port": 53, + "protocol": "udp", + "from": "machine", + "fixed": true, + "why": "this machine's own resolver (ADR 0247), on loopback and on its private address for its own containers; never another machine's, so a VPN's domains routed here are asked by nothing beyond this machine" + }, + { + "name": "dns-tcp", + "port": 53, + "protocol": "tcp", + "from": "machine", + "fixed": true, + "why": "the same names over tcp, which a resolver answers on for an answer too large for a datagram" + } + ], + "facts": { + "kept": { + "path": "/etc/node-resolver/resolv.conf", + "template": "# Managed by the mesh, and written by the module holding this machine's own resolver\n# (module systemd-resolved, novox/hq ADR 0247). On every other machine the module holding\n# the uplink writes this file, listing the mesh's resolvers (ADR 0223); here something\n# requires names routed by domain - a VPN client's domains to its own servers - so the file\n# names this machine's own resolver alone, which sends those domains over the VPN's link and\n# every other name to the mesh's resolvers. One server listed, so one answer per name.\n#\n# Its address on the private network, not loopback: a container copies this file, and\n# this address is one it can reach. Another program writing this file is put back by the\n# module's guard, which keeps what it wrote for whoever handles it on this machine.\n# Replaced on every push; edit nothing here.\n{{$own := \"\"}}{{range .Machines}}{{if eq .Name $.Node}}{{$own = .Address}}{{end}}{{end}}nameserver {{if $own}}{{$own}}{{else}}127.0.0.53{{end}}\noptions timeout:1 attempts:2 edns0\n" + }, + "resolved": { + "path": "/etc/systemd/resolved.conf.d/50-mesh.conf", + "template": "# Managed by the mesh (module systemd-resolved, novox/hq ADR 0247). Replaced on every\n# push; a drop-in of the operator's that sorts after this one overrides it, and is theirs.\n#\n# The mesh's resolvers, every one of them (ADR 0223): they answer every name no link's own\n# domains route elsewhere. ~. makes them the default route for names, and a link's servers\n# answer only the domains routed to them (the module's verb `route`).\n[Resolve]\nDNS={{range index .Holders \"mesh-dns-resolver\"}}{{.Address}} {{end}}\nDomains=~.\n# No compiled-in public fallback: a public resolver beside the mesh's is what ADR 0223\n# removed, because one of them said \"no such name\" for a mesh name and was believed.\nFallbackDNS=\n# The stub on loopback for this machine, and on its private address for its containers,\n# which cannot reach loopback. The packet filter admits this machine's own guests and\n# nobody else: another machine never asks this resolver (ADR 0247).\nDNSStubListener=yes\n{{$own := \"\"}}{{range .Machines}}{{if eq .Name $.Node}}{{$own = .Address}}{{end}}{{end}}DNSStubListenerExtra={{$own}}\n# No cache: nothing on this machine keeps a copy of a mesh name or of a \"no such name\",\n# as before this resolver - each question is asked again (ADR 0223's objection to a\n# local forwarder was a copy disagreeing with the truth).\nCache=no\n# What this machine's own programs read from /etc/hosts they read themselves; containers\n# asking here get what the mesh's resolvers say, as before.\nReadEtcHosts=no\n# Names are the mesh's resolvers' and a routed link's to answer, never the local network's\n# guesses; validation stays the upstreams' (the mesh's resolvers pass the bit through).\nLLMNR=no\nMulticastDNS=no\nDNSSEC=no\nDNSOverTLS=no\n" + }, + "resolvers": { + "path": "/etc/resolv.conf", + "template": "# Managed by the mesh, and written by the module holding this machine's own resolver\n# (module systemd-resolved, novox/hq ADR 0247). On every other machine the module holding\n# the uplink writes this file, listing the mesh's resolvers (ADR 0223); here something\n# requires names routed by domain - a VPN client's domains to its own servers - so the file\n# names this machine's own resolver alone, which sends those domains over the VPN's link and\n# every other name to the mesh's resolvers. One server listed, so one answer per name.\n#\n# Its address on the private network, not loopback: a container copies this file, and\n# this address is one it can reach. Another program writing this file is put back by the\n# module's guard, which keeps what it wrote for whoever handles it on this machine.\n# Replaced on every push; edit nothing here.\n{{$own := \"\"}}{{range .Machines}}{{if eq .Name $.Node}}{{$own = .Address}}{{end}}{{end}}nameserver {{if $own}}{{$own}}{{else}}127.0.0.53{{end}}\noptions timeout:1 attempts:2 edns0\n" + }, + "suffix": { + "path": "/etc/node-resolver/suffix", + "template": "{{.Suffix}}\n" + } + }, + "resources": [ + { + "id": "service", + "type": "service", + "unit": "systemd-resolved.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "systemd-resolved.fact-resolved" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "guard", + "type": "process", + "name": "systemd-resolved-guard", + "artifact": "tools", + "run": [ + "./resolver-tools", + "guard" + ], + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/resolver-tools", + "binary": "resolver-tools", + "loads": [ + "resolver-tools" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/systemd/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/systemd/module.json.captured new file mode 100644 index 0000000..984ccf3 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/systemd/module.json.captured @@ -0,0 +1,63 @@ +{ + "module": "systemd", + "version": "1", + "capabilities": [ + "service-manager", + "package-manager" + ], + "claims": [ + { + "name": "node-service-manager", + "scope": "node", + "serves": [ + "units", + "status", + "start", + "stop", + "restart", + "enable", + "disable", + "journal", + "failed", + "reset-failed", + "wanted-by", + "unlink-dangling" + ] + } + ], + "emits": [ + "link.removal" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/systemd-tools", + "binary": "systemd-tools", + "loads": [ + "systemd-tools" + ], + "env": { + "MESH_SYSTEMD_STATE": "${dir:state}" + } + } + ] + }, + "resources": [ + { + "id": "package", + "type": "package", + "package": "systemd" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/telegram/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/telegram/module.json.captured new file mode 100644 index 0000000..95ae6f1 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/telegram/module.json.captured @@ -0,0 +1,79 @@ +{ + "module": "telegram", + "version": "1", + "slug": "tgrm", + "runs-as": "telegram", + "claims": [ + { + "name": "channel", + "scope": "mesh", + "kind": "telegram", + "capabilities": [ + "deliver", + "reaches-away", + "silent", + "edit", + "max-length:4096", + "choice", + "verified-sender", + "exact-render", + "code-factor" + ] + }, + { + "name": "intake", + "scope": "mesh", + "kind": "telegram" + } + ], + "state": [ + "offset", + { + "name": "messages", + "ttl-seconds": 2592000 + } + ], + "own-secrets": { + "broker": "${dir:state}/broker", + "telegram-token": "${dir:state}/telegram-token" + }, + "secrets-owner": "telegram", + "tools": [ + "telegram_status" + ], + "resources": [ + { + "id": "account", + "type": "user", + "name": "telegram", + "shell": "/usr/bin/nologin", + "home": "/var/lib/telegram" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "telegram", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/telegram", + "binary": "telegram", + "loads": [ + "telegram" + ], + "env": { + "MESH_TELEGRAM_TOKEN_FILE": "${dir:state}/telegram-token" + } + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/time-sync/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/time-sync/module.json.captured new file mode 100644 index 0000000..c3d9b0c --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/time-sync/module.json.captured @@ -0,0 +1,81 @@ +{ + "module": "time-sync", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "time_sync_status", + "time_sync_servers", + "time_sync_sync_now" + ], + "resources": [ + { + "id": "servers", + "type": "file", + "path": "/etc/systemd/timesyncd.conf.d/50-mesh.conf", + "mode": "0644", + "template": "files/50-mesh.conf", + "names-on-purpose": { + "0.europe.pool.ntp.org": "the public NTP pool's European zone: the world's time service, the same for every installation; nearest to every machine of this mesh", + "1.europe.pool.ntp.org": "the public NTP pool's European zone: the world's time service, the same for every installation; nearest to every machine of this mesh", + "2.europe.pool.ntp.org": "the public NTP pool's European zone: the world's time service, the same for every installation; nearest to every machine of this mesh", + "3.europe.pool.ntp.org": "the public NTP pool's European zone: the world's time service, the same for every installation; nearest to every machine of this mesh", + "0.arch.pool.ntp.org": "the distribution's own NTP pool, timesyncd's compiled-in fallback, stated so the drop-in says the whole of what timesyncd uses", + "1.arch.pool.ntp.org": "the distribution's own NTP pool, timesyncd's compiled-in fallback, stated so the drop-in says the whole of what timesyncd uses", + "2.arch.pool.ntp.org": "the distribution's own NTP pool, timesyncd's compiled-in fallback, stated so the drop-in says the whole of what timesyncd uses", + "3.arch.pool.ntp.org": "the distribution's own NTP pool, timesyncd's compiled-in fallback, stated so the drop-in says the whole of what timesyncd uses" + } + }, + { + "id": "retire-ntpd", + "type": "process", + "name": "time-sync-retire-ntpd", + "artifact": "tools", + "run": [ + "./time-sync-tools", + "retire", + "ntpd.service", + "ntpdate.service" + ], + "run-once": true + }, + { + "id": "daemon", + "type": "service", + "unit": "systemd-timesyncd.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "servers" + ] + }, + { + "id": "ntp", + "type": "package", + "package": "ntp", + "absent": true + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/time-sync-tools", + "binary": "time-sync-tools", + "loads": [ + "time-sync-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/triggerhappy/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/triggerhappy/module.json.captured new file mode 100644 index 0000000..18faaf4 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/triggerhappy/module.json.captured @@ -0,0 +1,81 @@ +{ + "module": "triggerhappy", + "version": "1", + "moved": true, + "capabilities": [ + "service-manager" + ], + "claims": [ + { + "name": "node-hotkeys", + "scope": "node" + } + ], + "tools": [ + "triggerhappy_keys", + "triggerhappy_devices", + "triggerhappy_dump", + "triggerhappy_check" + ], + "resources": [ + { + "id": "drop-ins", + "type": "directory", + "path": "/etc/systemd/system/triggerhappy.service.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "as-account", + "type": "file", + "path": "/etc/systemd/system/triggerhappy.service.d/mesh.conf", + "mode": "0644", + "template": "files/service.conf" + }, + { + "id": "triggers-dir", + "type": "directory", + "path": "/etc/triggerhappy/triggers.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "triggers", + "type": "file", + "path": "/etc/triggerhappy/triggers.d/mesh.conf", + "mode": "0644", + "template": "files/triggers.conf" + }, + { + "id": "service", + "type": "service", + "unit": "triggerhappy.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "as-account", + "triggers" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/triggerhappy-tools", + "binary": "triggerhappy-tools", + "loads": [ + "triggerhappy-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/umami/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/umami/module.json.captured new file mode 100644 index 0000000..22ead41 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/umami/module.json.captured @@ -0,0 +1,142 @@ +{ + "module": "umami", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "postgres-database", + "route", + "secret" + ], + "contributes": { + "postgres-database": { + "name": "umami" + }, + "route": { + "label": "umami", + "endpoint": "web" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret", + "secret": { + "app-secret": "${dir:state}/app.secret", + "admin": "${dir:state}/admin.secret" + } + }, + "provides": [ + { + "name": "analytics", + "scope": "mesh" + } + ], + "serves": { + "analytics": {} + }, + "receives": { + "analytics": "${dir:grants}/mesh.json" + }, + "grants": { + "analytics": "${dir:grants}" + }, + "listens": [ + { + "name": "web", + "port": 3000, + "protocol": "tcp", + "from": "mesh", + "why": "one port serves two surfaces — the dashboard and the collection endpoint that the browsers of every tracked site POST to. Both are reached through the proxy, by name, so the port is how the proxy reaches this module and nothing else (novox/hq ADR 0045). It said \"anywhere\" and gave the reason that the collection endpoint must be public, which is true of the name and not of the port: opened, the machine-side port served the dashboard over plain HTTP to the internet, bypassing every rule the proxy applies by path" + } + ], + "data": { + "consumers": { + "analytics": { + "class": "valuable", + "in": "postgres-database", + "why": "a site's page views are recorded nowhere else" + } + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "server-env", + "type": "file", + "path": "${dir:state}/server.env", + "mode": "0600", + "template": "files/server.env" + }, + { + "id": "net", + "type": "network", + "name": "umami" + }, + { + "id": "server", + "type": "container", + "health": { + "kind": "http", + "endpoint": "web", + "path": "/api/share/mesh-health", + "status": 404, + "body": "\"code\":\"not-found\"", + "timeout": "10s", + "needs": "postgres-database" + }, + "name": "umami", + "image": "ghcr.io/umami-software/umami@sha256:85909afc45bdcda1917394594a087421fdbb05610fded0fa9f6fb861abb2f367", + "network": "umami", + "env-file": [ + "${dir:state}/server.env" + ], + "ports": [ + "3000" + ], + "secrets-in-environment": "a Next.js/Prisma application: DATABASE_URL and APP_SECRET are read from the environment only; not convertible" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_RECEIVES": "${dir:grants}/mesh.json", + "MESH_UMAMI_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_PROVISION_UMAMI_URL": "http://127.0.0.1:${port:3000}" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/unifi/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/unifi/module.json.captured new file mode 100644 index 0000000..2ee5cf4 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/unifi/module.json.captured @@ -0,0 +1,203 @@ +{ + "module": "unifi", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "listens": [ + { + "name": "web", + "port": 8443, + "protocol": "tcp", + "from": "mesh", + "why": "the controller web UI and API, over its own self-signed tls; named through the proxy as an https route" + }, + { + "name": "inform", + "port": 8080, + "protocol": "tcp", + "from": "mesh", + "why": "device inform, how APs and switches check in and are adopted; the controller tells devices this number, so the machine must publish it on the same one" + }, + { + "name": "stun", + "port": 3478, + "protocol": "udp", + "from": "mesh", + "why": "STUN for managed devices; the controller tells devices this number, so the machine must publish it on the same one" + }, + { + "name": "discovery", + "port": 10001, + "protocol": "udp", + "from": "mesh", + "why": "device discovery broadcasts from unadopted devices and the UniFi apps" + }, + { + "name": "discovery-l2", + "port": 1900, + "protocol": "udp", + "from": "mesh", + "why": "make-controller-discoverable-on-L2 (SSDP); the software listens on 1900, which machines commonly have taken by another SSDP speaker" + }, + { + "name": "portal-tls", + "port": 8843, + "protocol": "tcp", + "from": "mesh", + "why": "the guest captive portal over https" + }, + { + "name": "portal", + "port": 8880, + "protocol": "tcp", + "from": "mesh", + "why": "the guest captive portal over http" + }, + { + "name": "speedtest", + "port": 6789, + "protocol": "tcp", + "from": "mesh", + "why": "mobile-app speed-test throughput measurement" + }, + { + "name": "syslog", + "port": 5514, + "protocol": "udp", + "from": "mesh", + "why": "remote syslog from managed devices" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "why": "the network's configuration and every adopted device" + } + ] + }, + "config": { + "site": { + "type": "string", + "description": "The UniFi site the module's tools act on, by its id; the file's default is default.", + "trusted": true, + "default": "default" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach the UniFi controller at, as a URL; unset, the code falls back to its environment, else this machine's controller on its HTTPS port.", + "trusted": true, + "default": "" + }, + "username": { + "type": "string", + "description": "The UniFi account the module's tools sign in as with the vault's password; unset, the code falls back to its environment.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "1000:1000", + "unknown": "program" + }, + { + "id": "server", + "type": "container", + "name": "unifi-controller", + "image": "lscr.io/linuxserver/unifi-controller@sha256:0ae315a3a45635e443899e30e86bd507c2c48922cb27f4bc7241777885f4650e", + "ports": [ + "8443", + "8080", + "3478/udp", + "10001/udp", + "1900/udp", + "8843", + "8880", + "6789", + "5514/udp" + ], + "env": { + "PUID": "1000", + "PGID": "1000", + "TZ": "Etc/UTC", + "MEM_LIMIT": "1024", + "MEM_STARTUP": "1024" + }, + "volumes": [ + "${dir:data}:/config" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": ["site", "url", "username"], + "protected": ["password"] + } + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "unifi", + "endpoint": "web", + "scheme": "https", + "insecure": true + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "own-secrets": { + "controller": "${dir:mesh-state}/controller" + }, + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_UNIFI_URL": "https://127.0.0.1:${port:8443}", + "MESH_UNIFI_CONFIG_FILE": "${dir:mesh-state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/website/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/website/module.json.captured new file mode 100644 index 0000000..481cfc6 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/website/module.json.captured @@ -0,0 +1,60 @@ +{ + "module": "website", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "@", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "listens": [ + { + "name": "web", + "port": 8080, + "protocol": "tcp", + "from": "mesh", + "why": "the public website over http; its public name is a route grant, and route-proxy reaches it on this published port" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "website" + }, + { + "id": "server", + "type": "container", + "name": "website", + "image": "registry-api.novox.be/novox/www@sha256:aa7ed20a293e1d7444c5c5d59b6d8bdb382bad159559a22e006810e379cae69c", + "health": { + "kind": "http", + "endpoint": "web", + "path": "/" + }, + "network": "website", + "ports": [ + "8080" + ], + "names-on-purpose": { + "registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)" + } + } + ] +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/xclip/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/xclip/module.json.captured new file mode 100644 index 0000000..bf06f6f --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/xclip/module.json.captured @@ -0,0 +1,35 @@ +{ + "module": "xclip", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "xclip_copy", + "xclip_paste", + "xclip_targets", + "xclip_session" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "xclip" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/xclip-tools", + "binary": "xclip-tools", + "loads": [ + "xclip-tools" + ] + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/xdg/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/xdg/module.json.captured new file mode 100644 index 0000000..c3d191a --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/xdg/module.json.captured @@ -0,0 +1,97 @@ +{ + "module": "xdg", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "xdg_user_dirs", + "xdg_default", + "xdg_open_handlers", + "xdg_autostart", + "xdg_check" + ], + "config": { + "desktop-dir": { + "type": "string", + "description": "The desktop folder, as user-dirs.dirs writes it: \"$HOME/\", or \"$HOME/\" when the folder is not wanted; it differs between the workstations, so each says its own.", + "trusted": true, + "required": true + }, + "publicshare-dir": { + "type": "string", + "description": "The public share folder, as user-dirs.dirs writes it: \"$HOME/\", or \"$HOME/\" when the folder is not wanted; it differs between the workstations, so each says its own.", + "trusted": true, + "required": true + }, + "videos-dir": { + "type": "string", + "description": "The videos folder, as user-dirs.dirs writes it: \"$HOME/\", or \"$HOME/\" when the folder is not wanted; it differs between the workstations, so each says its own.", + "trusted": true, + "required": true + } + }, + "resources": [ + { + "id": "package-xdg-user-dirs", + "type": "package", + "package": "xdg-user-dirs" + }, + { + "id": "package-xdg-utils", + "type": "package", + "package": "xdg-utils" + }, + { + "id": "user-dirs", + "type": "file", + "path": "${machine:account-home}/.config/user-dirs.dirs", + "owner": "${machine:account}", + "mode": "0600", + "template": "files/user-dirs.dirs" + }, + { + "id": "user-dirs-locale", + "type": "file", + "path": "${machine:account-home}/.config/user-dirs.locale", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/user-dirs.locale" + }, + { + "id": "user-dirs-update", + "type": "file", + "path": "${machine:account-home}/.config/user-dirs.conf", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/user-dirs.conf" + }, + { + "id": "default-applications", + "type": "file", + "path": "/etc/xdg/mimeapps.list", + "mode": "0644", + "template": "files/mimeapps.list" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/xdg-tools", + "binary": "xdg-tools", + "loads": [ + "xdg-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/xorg/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/xorg/module.json.captured new file mode 100644 index 0000000..7b0b6b1 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/xorg/module.json.captured @@ -0,0 +1,134 @@ +{ + "module": "xorg", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager", + "seat" + ], + "provides": [ + { + "name": "x11-display", + "reach": "machine" + } + ], + "claims": [ + { + "name": "node-display-server", + "scope": "node", + "serves": [ + "displays", + "layout" + ] + } + ], + "tools": [ + "xorg_set_mode", + "xorg_primary", + "xorg_dpi", + "xorg_input_devices", + "xorg_input_set", + "xorg_keyboard", + "xorg_screenshot", + "xorg_windows", + "xorg_x_log" + ], + "resources": [ + { + "id": "package-xorg-server", + "type": "package", + "package": "xorg-server" + }, + { + "id": "package-xorg-xinit", + "type": "package", + "package": "xorg-xinit" + }, + { + "id": "package-xorg-xrandr", + "type": "package", + "package": "xorg-xrandr" + }, + { + "id": "package-xorg-xset", + "type": "package", + "package": "xorg-xset" + }, + { + "id": "package-xorg-xrdb", + "type": "package", + "package": "xorg-xrdb" + }, + { + "id": "package-xorg-xinput", + "type": "package", + "package": "xorg-xinput" + }, + { + "id": "package-xorg-setxkbmap", + "type": "package", + "package": "xorg-setxkbmap" + }, + { + "id": "package-xorg-xwd", + "type": "package", + "package": "xorg-xwd" + }, + { + "id": "package-xorg-xwininfo", + "type": "package", + "package": "xorg-xwininfo" + }, + { + "id": "package-autorandr", + "type": "package", + "package": "autorandr" + }, + { + "id": "config", + "type": "directory", + "path": "${machine:account-home}/.config/xorg", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "xresources", + "type": "file", + "path": "${machine:account-home}/.config/xorg/xresources", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/xresources" + }, + { + "id": "xinitrc", + "type": "file", + "path": "${machine:account-home}/.xinitrc", + "owner": "${machine:account}", + "mode": "0755", + "into": "block", + "at": "start", + "template": "files/xinitrc" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/xorg", + "binary": "xorg", + "loads": [ + "xorg" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/xterm/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/xterm/module.json.captured new file mode 100644 index 0000000..e6a565a --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/xterm/module.json.captured @@ -0,0 +1,67 @@ +{ + "module": "xterm", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-terminal-emulator", + "scope": "node", + "serves": [ + "open" + ] + } + ], + "tools": [ + "xterm_font", + "xterm_colours" + ], + "items": [ + { + "collection": "environment", + "name": "terminal", + "attributes": { + "kind": "variable", + "name": "TERMINAL" + }, + "template": "files/env-terminal" + }, + { + "collection": "shell:xresources:normal", + "name": "xresources-normal", + "template": "files/xresources-normal" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "xterm" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/xterm-tools", + "binary": "xterm-tools", + "loads": [ + "xterm-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/zfs/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/zfs/module.json.captured new file mode 100644 index 0000000..c109320 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/zfs/module.json.captured @@ -0,0 +1,235 @@ +{ + "module": "zfs", + "version": "1", + "capabilities": [ + "service-manager" + ], + "emits": [ + "snapshot.taken", + "scrub.requested" + ], + "config": { + "scrub-pool": { + "type": "string", + "description": "The name of the ZFS pool the module scrubs; the operator's, with no default.", + "trusted": true, + "required": true + }, + "scrub-cadence": { + "type": "string", + "description": "How often the pool named by scrub-pool is scrubbed: weekly, or monthly on the first scrub-day of each month. Weekly is what the distribution's timer did on the home server before the module, so taking it over moves nothing until the operator chooses.", + "enum": [ + "weekly", + "monthly" + ], + "trusted": true, + "default": "weekly" + }, + "scrub-day": { + "type": "string", + "description": "The weekday the scrub starts, Mon to Sun; for a monthly scrub, the first such day of the month. Checked by the scheduled run, which refuses any other value and runs nothing; Monday is when the distribution's weekly timer fired.", + "enum": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ], + "trusted": true, + "default": "Mon" + }, + "scrub-time": { + "type": "string", + "description": "The time the scrub starts, HH:MM from 00:00 to 23:59 in the machine's time zone; the scheduled run checks it every quarter of an hour, so the scrub starts within 15 minutes of it. Midnight is when the distribution's weekly timer fired.", + "pattern": "([01][0-9]|2[0-3]):[0-5][0-9]", + "trusted": true, + "default": "00:00" + } + }, + "tools": [ + "zfs_datasets", + "zfs_snapshots", + "zfs_snapshot", + "zfs_pools", + "zfs_space", + "zfs_pool_layout", + "zfs_scrub", + "zfs_scrub_start", + "zfs_scrub_pause", + "zfs_scrub_stop", + "zfs_io", + "zfs_arc", + "zfs_events", + "zfs_properties", + "zfs_health" + ], + "replaces": { + "zfs_datasets": [ + "zfs list" + ], + "zfs_snapshots": [ + "zfs list -t snapshot" + ], + "zfs_snapshot": [ + "zfs snapshot" + ], + "zfs_pools": [ + "zpool list", + "zpool status -x" + ], + "zfs_space": [ + "zfs list -o space", + "zpool list -v" + ], + "zfs_pool_layout": [ + "zpool status", + "zpool status -v" + ], + "zfs_scrub": [ + "zpool history -i" + ], + "zfs_io": [ + "zpool iostat", + "zpool iostat -v" + ], + "zfs_arc": [ + "arcstat", + "arc_summary", + "cat /proc/spl/kstat/zfs/arcstats" + ], + "zfs_events": [ + "zpool events", + "zpool events -v" + ], + "zfs_properties": [ + "zfs get all", + "zfs get" + ], + "zfs_scrub_start": [ + "zpool scrub" + ], + "zfs_scrub_pause": [ + "zpool scrub -p" + ], + "zfs_scrub_stop": [ + "zpool scrub -s" + ] + }, + "resources": [ + { + "id": "watch", + "type": "process", + "name": "zfs-watch", + "artifact": "tools", + "run": [ + "./zfs-tools", + "watch" + ], + "user": "${machine:account}", + "health": { + "kind": "tool", + "tool": "zfs_health", + "interval": "60s", + "timeout": "20s", + "looks": 2, + "grace": "60s" + } + }, + { + "id": "state-dir", + "type": "directory", + "path": "/var/lib/zfs-tools", + "mode": "0755", + "owner": "${machine:account}", + "unknown": "program" + }, + { + "id": "cursor-dir", + "type": "directory", + "path": "/run/zfs-events", + "mode": "0700", + "unknown": "program" + }, + { + "id": "events", + "type": "process", + "name": "zfs-events", + "artifact": "tools", + "run": [ + "./zfs-tools", + "events" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "config-dir", + "type": "directory", + "path": "/etc/zfs-tools", + "mode": "0755", + "unknown": "program" + }, + { + "id": "scrub-config", + "type": "file", + "path": "/etc/zfs-tools/scrub.conf", + "mode": "0644", + "template": "files/scrub.conf" + }, + { + "id": "scrub-state-dir", + "type": "directory", + "path": "/var/lib/zfs-scrub", + "mode": "0700", + "unknown": "program" + }, + { + "id": "scrub-run", + "type": "process", + "name": "zfs-mesh-scrub", + "artifact": "tools", + "run": [ + "./zfs-tools", + "scrub-run" + ], + "schedule": "*/15 * * * *" + }, + { + "id": "distribution-weekly", + "type": "service", + "unit": "zfs-scrub-weekly@${config:scrub-pool}.timer", + "state": "stopped", + "boot": "disabled" + }, + { + "id": "distribution-monthly", + "type": "service", + "unit": "zfs-scrub-monthly@${config:scrub-pool}.timer", + "state": "stopped", + "boot": "disabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/zfs-tools", + "binary": "zfs-tools", + "loads": [ + "zfs-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/zsh-autosuggestions/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/zsh-autosuggestions/module.json.captured new file mode 100644 index 0000000..e883921 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/zsh-autosuggestions/module.json.captured @@ -0,0 +1,30 @@ +{ + "module": "zsh-autosuggestions", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "zsh-autosuggestions" + } + ], + "items": [ + { + "collection": "shell:zsh:normal", + "template": "files/zsh-normal" + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/zsh-syntax-highlighting/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/zsh-syntax-highlighting/module.json.captured new file mode 100644 index 0000000..b9c81a9 --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/zsh-syntax-highlighting/module.json.captured @@ -0,0 +1,30 @@ +{ + "module": "zsh-syntax-highlighting", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "zsh-syntax-highlighting" + } + ], + "items": [ + { + "collection": "shell:zsh:last", + "template": "files/zsh-last" + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-compose/testdata/mesh-catalog/modules/zsh/module.json.captured b/cmd/mesh-compose/testdata/mesh-catalog/modules/zsh/module.json.captured new file mode 100644 index 0000000..da125af --- /dev/null +++ b/cmd/mesh-compose/testdata/mesh-catalog/modules/zsh/module.json.captured @@ -0,0 +1,160 @@ +{ + "module": "zsh", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "claims": [ + { + "name": "node-login-shell", + "scope": "node", + "serves": [ + "execute" + ] + } + ], + "tools": [ + "zsh_config" + ], + "config": { + "execute": { + "type": "string", + "description": "Whether this machine offers execute, which runs any command as the operator account and so can become root without a person: serve, or withhold on a machine that does not offer it (novox/hq ADR 0268), such as the control-node until a call to it needs a person's approval.", + "enum": [ + "serve", + "withhold" + ], + "trusted": true, + "default": "serve" + } + }, + "items": [ + { + "collection": "environment", + "name": "editor", + "attributes": { + "kind": "variable", + "name": "EDITOR" + }, + "template": "files/env-editor" + }, + { + "collection": "environment", + "name": "visual", + "attributes": { + "kind": "variable", + "name": "VISUAL" + }, + "template": "files/env-visual" + }, + { + "collection": "environment", + "name": "xdg-config-home", + "attributes": { + "kind": "variable", + "name": "XDG_CONFIG_HOME" + }, + "template": "files/env-xdg-config-home" + }, + { + "collection": "environment", + "name": "path-local-bin", + "attributes": { + "kind": "path", + "at": "start", + "order": "10" + }, + "template": "files/env-path-local-bin" + }, + { + "collection": "environment", + "name": "path-scripts", + "attributes": { + "kind": "path", + "at": "start", + "order": "20" + }, + "template": "files/env-path-scripts" + }, + { + "collection": "environment", + "name": "path-scripts-bin", + "attributes": { + "kind": "path", + "at": "start", + "order": "30" + }, + "template": "files/env-path-scripts-bin" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "zsh" + }, + { + "id": "env", + "type": "file", + "path": "${machine:account-home}/.zshenv", + "owner": "${machine:account}", + "mode": "0644", + "into": "block", + "at": "start", + "template": "files/zshenv" + }, + { + "id": "rc", + "type": "file", + "path": "${machine:account-home}/.zshrc", + "owner": "${machine:account}", + "mode": "0644", + "into": "block", + "at": "start", + "template": "files/zshrc" + }, + { + "id": "login", + "type": "user", + "name": "${machine:account}", + "shell": "/usr/bin/zsh" + }, + { + "id": "state", + "type": "directory", + "place": ".", + "owner": "root", + "mode": "0755", + "unknown": "program" + }, + { + "id": "tools-conf", + "type": "file", + "path": "${dir:state}/tools.conf", + "owner": "root", + "mode": "0644", + "template": "files/tools.conf" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "env": { + "ZSH_TOOLS_CONF": "${dir:state}/tools.conf" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index c9a140a..31ebb60 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -14,7 +14,7 @@ import ( "sync/atomic" "time" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" + "github.com/novox/mesh-host/internal/compose/frombus" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/overlay" diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index a00bef5..17eab9b 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -19,6 +19,7 @@ import ( "github.com/nats-io/nats.go" "github.com/novox/mesh-host/internal/agent" "github.com/novox/mesh-host/internal/link" + "github.com/novox/mesh-host/internal/nodegrant" "github.com/novox/mesh-host/internal/selfcompose" ) @@ -32,7 +33,7 @@ type itBed struct { t *testing.T conn *nats.Conn js nats.JetStreamContext - // nodeConn and nodeJS are the node's own, under its real grant (buscompose.PermissionsFor, KindNode): the agent runs + // nodeConn and nodeJS are the node's own, under its real grant (the grants part's, nodegrant.For): the agent runs // on them, so a watch or a read the grant does not allow fails here as it fails on the bus (issue 1126, 2026-10-27 // 09:21: a watch of every have key stopped every node agent). conn is the test's, unrestricted, for setting up. nodeConn *nats.Conn @@ -51,13 +52,9 @@ type itBed struct { } // aServer is an embedded bus with JetStream and two users: the test's, unrestricted, and the node's, granted exactly what -// buscompose.PermissionsFor gives a node (KindNode); it answers a connection of each. -func aServer(t *testing.T, principal buscompose.Principal, refused func(error)) (admin, node *nats.Conn) { +// the grants part gives a node (nodegrant.For); it answers a connection of each. +func aServer(t *testing.T, perms nodegrant.Permissions, refused func(error)) (admin, node *nats.Conn) { t.Helper() - perms, err := buscompose.PermissionsFor(principal) - if err != nil { - t.Fatal(err) - } granted := &server.Permissions{Publish: &server.SubjectPermission{Allow: perms.Publish, Deny: perms.PublishDeny}, Subscribe: &server.SubjectPermission{Allow: perms.Subscribe}} if perms.AllowResponses { @@ -88,11 +85,11 @@ func aServer(t *testing.T, principal buscompose.Principal, refused func(error)) func newItBed(t *testing.T) *itBed { t.Helper() - return newItBedAs(t, buscompose.Principal{Kind: buscompose.KindNode, Node: itNode}) + return newItBedAs(t, nodegrant.For(itNode)) } -// newItBedAs is newItBed with the node's connection under the grant of principal, a node's (KindNode). -func newItBedAs(t *testing.T, principal buscompose.Principal) *itBed { +// newItBedAs is newItBed with the node's connection under the grant perms, a node's. +func newItBedAs(t *testing.T, perms nodegrant.Permissions) *itBed { t.Helper() // Not skipped off Arch: a package manager that answers is put on PATH, so the apply of files runs on any build node. bin := t.TempDir() @@ -105,7 +102,7 @@ func newItBedAs(t *testing.T, principal buscompose.Principal) *itBed { t.Cleanup(func() { builtFor = was }) b := &itBed{t: t, dir: t.TempDir()} - b.conn, b.nodeConn = aServer(t, principal, func(err error) { + b.conn, b.nodeConn = aServer(t, perms, func(err error) { b.mu.Lock() b.refusals = append(b.refusals, err.Error()) b.mu.Unlock() @@ -233,24 +230,10 @@ func within(t *testing.T, d time.Duration, what string, ok func() bool) { // in the node's own key, and nothing is applied for it (ADR 0559 §4, subtask 10 of the milestone). // // And (issue 1126, ADR 0560) it reads, through its own session under the grant the grants part composes for it -// (buscompose.Users, so it carries ServedReads), the build of a placed module that declares one and the one config value +// (nodegrant.For with the served key, as the grants part grants it), the build of a placed module that declares one and the one config value // a provider on another node serves from (a mail domain); under a grant without that served key the bus refuses the // read and the node composes nothing. func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) { - users, err := buscompose.Users(buscompose.Records{Nodes: []string{itNode, "it2"}, - Assigned: map[string][]buscompose.Declared{"it2": {{Module: "mailer", Served: []string{"domain"}}}}}) - if err != nil { - t.Fatal(err) - } - var principal buscompose.Principal - for _, u := range users { - if u.Kind == buscompose.KindNode && u.Node == itNode { - principal = u - } - } - if len(principal.ServedReads) != 1 { - t.Fatalf("the grants part's principal of %s reads %v of the served keys", itNode, principal.ServedReads) - } t.Run("without the served key the bus refuses the read", func(t *testing.T) { b := newItBed(t) stop, _ := h8(t, b, false) @@ -268,7 +251,7 @@ func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) { t.Errorf("composed and applied under a grant without the served key: %q", b.file("web")) } }) - b := newItBedAs(t, principal) + b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain")) stop, st := h8(t, b, true) defer stop() placement, resolved, candidates := st.placement, st.resolved, st.candidates diff --git a/cmd/mesh-host/nodeclare_test.go b/cmd/mesh-host/nodeclare_test.go index 430fec3..f295ca5 100644 --- a/cmd/mesh-host/nodeclare_test.go +++ b/cmd/mesh-host/nodeclare_test.go @@ -22,6 +22,11 @@ func TestNoPackageHearsOrOrdersAWholeDeclaration(t *testing.T) { if err != nil { return err } + // The composition moved in from mesh-sdk (novox/hq ADR 0593 §7) has a node's input field of the same name, + // ReadsModules, which says the node reads a module per resource; it is no reconciling of a declaration. + if d.IsDir() && path == filepath.Join(root, "internal", "compose") { + return filepath.SkipDir + } if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "nodeclare_test.go") { return nil } diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 5d50426..9674f23 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -26,6 +26,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/judge" ) // Update is one change of a key in a watched bucket. Caught marks the end of the keys that were there when the watch @@ -1646,14 +1647,14 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp prior = true } } - var j delivery.Judged + var j judge.Judged switch { case known: - j = delivery.JudgeMove(&from, comp.C) + j = judge.JudgeMove(&from, comp.C) case prior: - j = delivery.Judged{Judgement: delivery.JudgedCannot, Words: "what " + module + " runs on this node cannot be read"} + j = judge.Judged{Judgement: delivery.JudgedCannot, Words: "what " + module + " runs on this node cannot be read"} default: - j = delivery.JudgeMove(nil, comp.C) + j = judge.JudgeMove(nil, comp.C) } switch j.Judgement { case delivery.JudgedRecreates, delivery.JudgedMovesData, delivery.JudgedCannot: diff --git a/internal/agent/composition.go b/internal/agent/composition.go index 71ea0e7..1e5c70d 100644 --- a/internal/agent/composition.go +++ b/internal/agent/composition.go @@ -8,6 +8,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/judge" ) // The node-engine seat's read verb composition (issue 774): one module's composition beside what runs of it, so why a @@ -31,7 +32,7 @@ type CompositionView struct { // Judged is the move from what runs to the composition read, as the agent judges it before an apply. Judged JudgedView `json:"judged"` // Diff is every resource that differs from what runs, when asked. - Diff []delivery.ResourceChange `json:"diff,omitempty"` + Diff []judge.ResourceChange `json:"diff,omitempty"` } // WantView is a want as the verb shows it. @@ -171,7 +172,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo } view.Runs = rv } - j := delivery.JudgeMove(running, read.C) + j := judge.JudgeMove(running, read.C) view.Judged = JudgedView{Judgement: string(j.Judgement), Words: j.Words} if a.env.SelfComposed && a.env.Policy != nil && module != NodeModule && (!w.Present || w.Want.Yes != read.Dig) { switch j.Judgement { @@ -180,7 +181,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo } } if diff { - view.Diff = delivery.ChangedResources(running, read.C) + view.Diff = judge.ChangedResources(running, read.C) } return view, nil } diff --git a/internal/compose/composed.go b/internal/compose/composed.go new file mode 100644 index 0000000..d7a4661 --- /dev/null +++ b/internal/compose/composed.go @@ -0,0 +1,559 @@ +package compose + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/declaration" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/overlay" +) + +// Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as +// the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own +// (`.node`), by key (delivery.ComposedKey). Each resource says its module, whatever the node reported it reads. +// A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module +// gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the +// private network's always), which fps names at no fingerprint of the new form: its resources go into the node's own, +// their module taken off and their ids still naming it, and its filled paths with them, as the controller's composed +// writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are not +// signed and not yet by digest: the writer does both (delivery.SignComposed, Composed.ByDigest). +func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) { + out, skipped, _, err = compositions(in, node, fps, mode, false) + return out, skipped, err +} + +// ErrStoodIn is a composition that holds a stand-in for a value the input does not carry (an own secret, a grant's +// sealed copy, a vault box, the certificate, the bus's user list) or a port it does not give: what a send would make, +// never what may be applied. +var ErrStoodIn = errors.New("compose: the composition stands in for what its input does not carry") + +// BusUsersStandIn is what a composition says it stood in for when its input carries no bus user list. +const BusUsersStandIn = "the bus's user list" + +// CompositionsApplied is Compositions for a node that applies what it composed itself (novox/hq ADR 0547 §2, issue +// 1126): a declaration that stands in for anything (Declared.StoodIn, Foreseen, ForeseenPorts) is refused with +// ErrStoodIn, naming each, so a stand-in is never applied as if it were the value; but for the bus's user list +// (BusUsersStandIn), which the bus node holds out of every apply of a composition it made itself. +func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]delivery.Composed, []string, error) { + out, skipped, _, err := compositions(in, node, fps, Reading, true) + return out, skipped, err +} + +// CompositionsShadow is CompositionsApplied for a composition that is compared and never applied (a node's answer to a +// placement candidate, novox/hq ADR 0559 §4): composed in the Foreseeing mode, so an own secret never issued (a module +// the candidate adds) is a named stand-in rather than an error, and what it stands in for is answered (stood, sorted, +// the bus's user list left out) rather than refused, so the caller judges each stand-in: a value not yet issued for a +// module the candidate adds, or one missing for a module the node runs. A module whose every value is held composes +// as CompositionsApplied composes it: the modes differ only in an own secret never issued. +func CompositionsShadow(in Input, node string, fps map[string]string) (out map[string]delivery.Composed, skipped, stood []string, err error) { + return compositions(in, node, fps, Foreseeing, false) +} + +// CompositionsIn is the node's compositions in mode with what they stand in for answered (stood, sorted, the bus's user +// list left out) rather than refused: CompositionsShadow in Foreseeing, and in Reading what CompositionsApplied refuses +// before it is refused, so a caller that applies refuses each stand-in to the module it is for alone (issue 1126). +func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped, stood []string, err error) { + return compositions(in, node, fps, mode, false) +} + +func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) { + reading := in + reading.Nodes = append([]Node(nil), in.Nodes...) + found := false + inOwn := map[string]bool{} + held := map[string]bool{} + for i := range reading.Nodes { + if reading.Nodes[i].Name == node { + reading.Nodes[i].ReadsModules = true + found = true + for m, ok := range reading.Nodes[i].InOwn { + inOwn[m] = ok + } + for m, ok := range reading.Nodes[i].Held { + held[m] = ok + } + } + } + // The private network's module is built by nobody: no fingerprint of the new form ever names it. + inOwn[overlay.Name] = true + folds := func(module string) bool { + return module != "" && inOwn[module] && !strings.HasPrefix(fps[module], delivery.SourcePrefix2) + } + if !found { + return nil, nil, nil, fmt.Errorf("compose: the mesh has no node %q", node) + } + // A folded module built by a build of the old form (Node.RunsOwn) is left to the node: composed from what runs of it + // (Carried), so what it provides and requires still shapes the rest and what the others read of it (its published + // ports, the paths it owns) is what runs, and its resources are then the ones the node runs. + carried := map[string]bool{} + var runsOwn *delivery.Composed + for i := range reading.Nodes { + n := &reading.Nodes[i] + if n.Name != node || n.RunsOwn == nil { + continue + } + runsOwn = n.RunsOwn + candidates := map[string]bool{} + for m := range inOwn { + candidates[m] = true + } + for m := range held { + candidates[m] = true + } + for m := range candidates { + // A held module is carried as it runs wherever the node's own runs it, folded or leaving the fold for a + // composition of its own (its want new, its names still unset): its stub is never what the node's own applies, + // and what the node's own applies never drops what runs of it (issue 1126, review of the apply; issue 1945). + heldHere := held[m] && RunsAnyOf(runsOwn, m) + if !folds(m) && !heldHere { + continue + } + man, ok := n.Kept[m] + if !ok { + man, ok = reading.Catalogue[m] + } + if !ok || (!NamesBuild(man) && !held[m]) { + continue + } + carried[m] = true + kept := make(map[string]catalogue.Manifest, len(n.Kept)+1) + for k, v := range n.Kept { + kept[k] = v + } + kept[m] = Carried(man, m, runsOwn) + n.Kept = kept + } + } + // A module composed as what runs of it contributes to the runtime what the runtime serves of it as it runs + // (Node.RunsTools); one of its own account is served by a runtime of its own, from its manifest as before. + for i := range reading.Nodes { + n := &reading.Nodes[i] + if n.Name != node || n.RunsTools == nil { + continue + } + kept := make(map[string]catalogue.Manifest, len(n.Kept)) + for m, man := range n.Kept { + if man.Stubbed && man.RunsAs == "" { + man.Bundles = runningBundles(n.RunsTools[m], reading.Catalogue[m]) + } + kept[m] = man + } + n.Kept = kept + } + isCarried := func(module, id string) bool { + if module == "" { + module, _ = declaration.ModuleGuess(id) + } + return carried[module] + } + d, problems, err := ComposeIn(reading, node, mode) + if err != nil { + return nil, nil, nil, err + } + if len(problems) > 0 { + return nil, nil, nil, fmt.Errorf("compose: %s's declaration is refused by the node-engine's validator: %v", node, problems) + } + for _, x := range d.StoodIn { + // The bus's user list is the bus node's own to follow, written alone and never from a composition the node + // made itself (ADR 0547 §7, §8): its stand-in is held out of the apply, not applied. + if x != BusUsersStandIn { + stood = append(stood, x) + } + } + stood = append(stood, d.Foreseen...) + for _, p := range d.ForeseenPorts { + stood = append(stood, "port "+p) + } + sort.Strings(stood) + stood = slices.Compact(stood) // an own secret is both stood in for and foreseen + if applied && len(stood) > 0 { + return nil, nil, nil, fmt.Errorf("%w on %s: %s", ErrStoodIn, node, strings.Join(stood, ", ")) + } + body, err := d.Body() + if err != nil { + return nil, nil, nil, err + } + var envelope struct { + Resources []json.RawMessage `json:"resources"` + Adoption *declaration.Adoption `json:"adoption"` + Filled []string `json:"filled_files"` + Provisioner string `json:"provisioner_account"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, nil, nil, err + } + var kept []json.RawMessage + keptPaths := map[string]bool{} + foldedPaths := map[string]bool{} + left := map[string]bool{} + for _, raw := range envelope.Resources { + var head struct { + ID string `json:"id"` + Module string `json:"module"` + Type string `json:"type"` + Path string `json:"path"` + } + if err := json.Unmarshal(raw, &head); err != nil { + return nil, nil, nil, err + } + if isCarried(head.Module, head.ID) { + continue // what the stub composed of it: the node's own carries what runs of it instead + } + if folds(head.Module) { + // Into the node's own, in its place among the node's resources, its module taken off (mesh-controller's + // compositionsOf): the node's own composition is then the controller's, byte for byte. + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return nil, nil, nil, err + } + delete(fields, "module") + if raw, err = json.Marshal(fields); err != nil { + return nil, nil, nil, err + } + if head.Type == string(declaration.TypeFile) { + foldedPaths[head.Path] = true + } + kept = append(kept, raw) + continue + } + if _, ok := fps[head.Module]; head.Module != "" && !ok { + left[head.Module] = true + continue + } + if head.Type == string(declaration.TypeFile) { + keptPaths[head.Path] = true + } + kept = append(kept, raw) + } + // What runs of each carried module, in the node's own composition, each after the resource it runs after. + if len(carried) > 0 { + var err error + if kept, foldedPaths, err = carry(kept, foldedPaths, runsOwn, isCarried); err != nil { + return nil, nil, nil, err + } + for _, p := range runsOwn.Filled { + if foldedPaths[p] && !slices.Contains(envelope.Filled, p) { + envelope.Filled = append(envelope.Filled, p) + } + } + } + // A filled file of a module's own composition goes with that module (SplitComposed); one of a folded module goes with + // the node's own, after them, in the declaration's order; one of a module left out is dropped. + var filled, ownFilled []string + for _, p := range envelope.Filled { + switch { + case foldedPaths[p]: + ownFilled = append(ownFilled, p) + case keptPaths[p]: + filled = append(filled, p) + } + } + out, err = delivery.SplitComposed(node, kept, fps, filled, + delivery.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner}) + if err != nil { + return nil, nil, nil, err + } + if len(ownFilled) > 0 { + own, err := delivery.ComposedNodeKey(node) + if err != nil { + return nil, nil, nil, err + } + c := out[own] + c.Filled = append(c.Filled, ownFilled...) + out[own] = c + } + for key, c := range out { + if held[c.Module] { + delete(out, key) // its stub's: the node keeps what it last applied of it (Node.Held) + continue + } + if m, ok := in.Catalogue[c.Module]; ok && c.Module != "" { + c.Data = len(m.DataItems()) > 0 + out[key] = c + } + } + for m := range left { + skipped = append(skipped, m) + } + sort.Strings(skipped) + return out, skipped, stood, nil +} + +// RunsAnyOf says runs (a composition as it runs) holds a resource of module, by its module or the module its id names. +func RunsAnyOf(runs *delivery.Composed, module string) bool { + if runs == nil { + return false + } + for _, raw := range runs.Resources { + var head struct { + ID string `json:"id"` + Module string `json:"module"` + } + if json.Unmarshal(raw, &head) != nil { + continue + } + owner := head.Module + if owner == "" { + owner, _ = declaration.ModuleGuess(head.ID) + } + if owner == module { + return true + } + } + return false +} + +// ToolsRunning is, by module, the tools bundles that run on the node (Node.RunsTools): each bundle archive a module's +// resource unpacks at its BundlePath, with what the runtime's own process loads of it and the words it hands it +// (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads +// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build +// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process. +func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle { + runtimeID := catalogue.RuntimeModule + "." + catalogue.RuntimeProcessID() + type resource struct { + ID string `json:"id"` + Module string `json:"module"` + Type string `json:"type"` + Path string `json:"path"` + Source string `json:"source"` + Digest string `json:"digest"` + Env map[string]string `json:"env"` + } + var env map[string]string + out := map[string][]catalogue.Bundle{} + for _, c := range running { + for _, raw := range c.Resources { + var r resource + if json.Unmarshal(raw, &r) != nil { + continue + } + owner := r.Module + if owner == "" { + owner, _ = declaration.ModuleGuess(r.ID) + } + switch { + case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule: + env = r.Env + case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+catalogue.BundleID("")): + name := strings.TrimPrefix(r.ID, owner+"."+catalogue.BundleID("")) + if r.Path == catalogue.BundlePath(owner, name) { + out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest}) + } + } + } + } + if env == nil { + return nil + } + var words map[string]map[string]string + _ = json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &words) + for _, entry := range strings.Split(env[catalogue.RuntimeToolModules], ",") { + module, path, ok := strings.Cut(entry, "=") + if !ok || !strings.HasPrefix(path, catalogue.BundleRoot+"/"+module+"/") { + continue + } + name, load, ok := strings.Cut(strings.TrimPrefix(path, catalogue.BundleRoot+"/"+module+"/"), "/") + bundles := out[module] + i := slices.IndexFunc(bundles, func(b catalogue.Bundle) bool { return b.Name == name }) + if !ok || i < 0 { + continue // no archive of it runs: nothing to load + } + var own map[string]string + for w, v := range words[module] { + if w == catalogue.FilesOwnedPaths || w == catalogue.ReadsSecretPlaces { + continue // the mesh's to say, composed again + } + if own == nil { + own = map[string]string{} + } + own[w] = v + } + bundles[i].Loads = append(bundles[i].Loads, load) + bundles[i].Env = own + } + for _, bundles := range out { + sort.Slice(bundles, func(i, j int) bool { return bundles[i].Name < bundles[j].Name }) + } + return out +} + +// runningBundles is the bundles of a module composed as what runs of it (Node.RunsTools): each that runs, with what the +// runtime loads of it and its words; one the runtime loads nothing of takes its loads and words from the module's build +// (man: its built bundles, else its build's artifacts, catalogue.BundleLoads), so a drop already applied is undone. +func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue.Bundle { + var out []catalogue.Bundle + for _, b := range runs { + if len(b.Loads) == 0 { + for _, built := range man.Bundles { + if built.Name == b.Name { + b.Loads, b.Env, b.Launchers, b.Binary = built.Loads, built.Env, built.Launchers, built.Binary + } + } + if man.Build != nil { + run := catalogue.RunByAResource(man) + for _, a := range man.Build.Artifacts { + if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name { + b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a) + } + } + } + } + out = append(out, b) + } + return out +} + +// NamesBuild says a manifest names what a build makes: its build, or a resource naming an artifact. A node cannot +// compose such a module folded into its own composition: the build of the old form it runs is held by the controller +// alone (issue 1126). +func NamesBuild(m catalogue.Manifest) bool { + if m.Build != nil { + return true + } + for _, r := range m.Resources { + if _, ok := r["artifact"]; ok { + return true + } + } + return false +} + +// Stub is m with nothing to build and no resource but its directories: what it provides, requires, serves and grants +// still shape the rest of the composition, the places its own secrets, data and grants name (${dir:…}) still resolve, +// and nothing else of its own is composed (issue 1126: nats's ${dir:mesh-state} on novox). A module refused in a +// node's answer is composed so; a folded one the node runs is Carried. +func Stub(m catalogue.Manifest) catalogue.Manifest { + // The paths it owns stay its manifest's, as the controller tells the holder of node-files them (issue 1126, seed-8). + if m.OwnsPaths == nil { + m.OwnsPaths = catalogue.DeclaredPaths(m) + } + m.Build = nil + // What it hands the provisioner is left out as it runs, not refused for want of a plan (catalogue.Manifest.Stubbed). + m.Stubbed = true + // Nor is a template its build kept for its own files handed: a stub carries none of them (issue 1126: novox's node + // refused "postgres: its build kept a template for consumers, which it does not declare" with postgres held). Its + // items stay, and the templates they render through: a held module's contributions to other modules' collections + // (seat contributions, shell blocks, seat data) still reach their collectors. + if m.Handed != nil { + h := *m.Handed + h.Templates = nil + m.Handed = &h + } + var dirs []map[string]any + for _, r := range m.Resources { + if r["type"] == "directory" { + dirs = append(dirs, r) + } + } + m.Resources = dirs + return m +} + +// Carried is m, folded into its node's own composition at a build of the old form, as the node runs it (issue 1126, +// C5): nothing to build; its containers and directories those the node's own composition (runs) carries of it, their +// module's word taken off their ids, with the manifest's directories it does not run. So what other modules read of it +// is what runs: the machine ports its containers publish (the filter's rules, the routes reaching it: MachineSide). The +// paths it owns, which the holder of node-files is told, are its manifest's, as for its Stub: the controller composes +// them from the manifest, not from every path a resource of it runs at (issue 1126, seed-8: f1-circuits, snake and +// txt-game on ace, whose manifests leave their directories to the mesh). Its other resources are not taken, since the +// composition makes some of them again from its manifest (its secrets' files) and one id twice is refused; none of them +// is composed into what is applied either way (compositions leaves a carried module's out, and carries the ones that +// run). What it provides, requires, listens on and grants is its manifest's. A module whose resources runs holds none +// of is its Stub, as before. +func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catalogue.Manifest { + if runs == nil { + return Stub(m) + } + var taken []map[string]any + runsAny := false + ids := map[string]bool{} + for _, raw := range runs.Resources { + var r map[string]any + if json.Unmarshal(raw, &r) != nil { + continue + } + id, _ := r["id"].(string) + owner, _ := r["module"].(string) + if owner == "" { + owner, _ = declaration.ModuleGuess(id) + } + if owner != module { + continue + } + runsAny = true + if typ := r["type"]; typ != "container" && typ != "directory" { + continue + } + r["id"] = strings.TrimPrefix(id, module+".") + delete(r, "module") + ids[r["id"].(string)] = true + taken = append(taken, r) + } + if !runsAny { + return Stub(m) + } + stub := Stub(m) + for _, r := range stub.Resources { + if id, _ := r["id"].(string); !ids[id] { + taken = append(taken, r) + } + } + stub.Resources = taken + return stub +} + +// carry puts into the node's resources (kept, in the declaration's order) the resources of the node's own composition as +// it runs (runs) that belong to a carried module, each after the resource it runs after, or first when that one is +// not among them; a file among them is a folded path. +func carry(kept []json.RawMessage, foldedPaths map[string]bool, runs *delivery.Composed, carried func(module, id string) bool) ([]json.RawMessage, map[string]bool, error) { + idOf := func(raw json.RawMessage) (string, string, string, string, error) { + var head struct { + ID string `json:"id"` + Module string `json:"module"` + Type string `json:"type"` + Path string `json:"path"` + } + err := json.Unmarshal(raw, &head) + return head.ID, head.Module, head.Type, head.Path, err + } + present := map[string]bool{} + for _, raw := range kept { + if id, module, _, _, err := idOf(raw); err == nil && module == "" { + present[id] = true + } + } + // By the id of the resource of the node's own it runs after, "" for first: the last one before it that is composed. + after := map[string][]json.RawMessage{} + anchor := "" + for _, raw := range runs.Resources { + id, module, typ, path, err := idOf(raw) + if err != nil { + return nil, nil, fmt.Errorf("compose: a resource the node runs in its own composition: %w", err) + } + if !carried(module, id) { + if module == "" && present[id] { + anchor = id + } + continue + } + after[anchor] = append(after[anchor], raw) + if typ == string(declaration.TypeFile) { + foldedPaths[path] = true + } + } + out := make([]json.RawMessage, 0, len(kept)+len(runs.Resources)) + out = append(out, after[""]...) + for _, raw := range kept { + out = append(out, raw) + if id, module, _, _, err := idOf(raw); err == nil && module == "" { + out = append(out, after[id]...) + } + } + return out, foldedPaths, nil +} diff --git a/internal/compose/composed_test.go b/internal/compose/composed_test.go new file mode 100644 index 0000000..c5373f4 --- /dev/null +++ b/internal/compose/composed_test.go @@ -0,0 +1,200 @@ +package compose + +import ( + "encoding/json" + "sort" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/overlay" +) + +func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) } + +// 1943/AC5: a node's compositions are its declaration as Compose gives it, split by the module each resource belongs +// to: every resource once, each in its module's composition at the fingerprint given, the module's ownership said on +// it; a module given no fingerprint is named as skipped and composed nowhere. +func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { + in := aSmallMesh(t) + d, _, err := Compose(in, "laptop") + if err != nil { + t.Fatal(err) + } + var want []string + for _, r := range d.Resources { + want = append(want, r["type"].(string)+":"+r["id"].(string)) + } + sort.Strings(want) + + fps := map[string]string{"mesh-wireguard": fpFor('a'), "files": fpFor('b')} + out, skipped, err := Compositions(in, "laptop", fps, Foreseeing) + if err != nil || len(skipped) != 0 { + t.Fatalf("Compositions = %v, skipped %v", err, skipped) + } + var got []string + for key, c := range out { + node, module, fp, isNode, err := delivery.ParseComposedKey(key) + if err != nil || node != "laptop" || module != c.Module || fp != c.FP || (isNode != (module == "")) { + t.Errorf("key %s holds %s at %s (%v)", key, c.Module, c.FP, err) + } + if !isNode && fp != fps[module] { + t.Errorf("%s is composed at %s, given %s", module, fp, fps[module]) + } + if _, err := delivery.EncodeComposed(c); err != nil { + t.Errorf("%s: %v", key, err) + } + for _, raw := range c.Resources { + var r struct{ ID, Type, Module string } + _ = json.Unmarshal(raw, &r) + if r.Module != module { + t.Errorf("%s holds %s of %q", key, r.ID, r.Module) + } + got = append(got, r.Type+":"+r.ID) + } + } + sort.Strings(got) + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("the compositions hold %v, the declaration %v", got, want) + } + + one, skipped, err := Compositions(in, "laptop", map[string]string{"mesh-wireguard": fpFor('a')}, Foreseeing) + if err != nil || strings.Join(skipped, ",") != "files" { + t.Fatalf("one module: %v, skipped %v", err, skipped) + } + for _, c := range one { + if c.Module == "files" { + t.Errorf("a module given no fingerprint was composed") + } + } + + // Issue 1126: a module the node runs inside its own composition (Node.InOwn), and the private network's always, + // given no fingerprint of the new form, is folded into the node's own as the controller's composed writer folds it: + // every resource in its place, its module taken off, its id still naming it, nothing skipped. + folding := in + folding.Nodes = append([]Node(nil), in.Nodes...) + for i := range folding.Nodes { + if folding.Nodes[i].Name == "laptop" { + folding.Nodes[i].InOwn = map[string]bool{"files": true} + } + } + all, skipped, err := Compositions(folding, "laptop", nil, Foreseeing) + nodeKey, _ := delivery.ComposedNodeKey("laptop") + if err != nil || len(skipped) != 0 || len(all) != 1 || len(all[nodeKey].Resources) != len(want) { + t.Fatalf("folded: %v, skipped %v, %d compositions, %d of %d resources in the node's own", err, skipped, len(all), len(all[nodeKey].Resources), len(want)) + } + for i, raw := range all[nodeKey].Resources { + var r map[string]any + _ = json.Unmarshal(raw, &r) + if _, says := r["module"]; says || r["id"] != d.Resources[i]["id"] { + t.Errorf("folded resource %d is %v, the declaration's %v", i, r["id"], d.Resources[i]["id"]) + } + } + if _, err := delivery.EncodeComposed(all[nodeKey]); err != nil { + t.Errorf("the node's own with the folded modules: %v", err) + } + if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil { + t.Errorf("a node the mesh does not have was composed") + } + + // Issue 1126, 2026-10-29: a module composed as what runs of it (folded at a build of the old form, or held) still + // contributes to the runtime what the runtime serves of it as it runs (Node.RunsTools): its manifest at the + // indexer's commit has no bundles (shanks's runtime lost the folded systemd-resolved, novox's the folded nats), and a + // held module's new build's bundles are not delivered. + dg := func(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) } + runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1", + OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/node-tools/broker"}}, + Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'), + Digest: dg('a'), Language: "go", Binary: "node-tools"}}} + store := catalogue.Manifest{Module: "artifacts", Version: "1", + Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}} + lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"}, + Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}}, + Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle, + Language: "go", System: "arch", From: "cmd/lamp-tools", Env: map[string]string{"LAMP_CONF": "/var/lib/lamp/conf"}}}}} + newLamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle, + Reference: catalogue.ArtifactStoreScheme + "lamp/tools/blobs/" + dg('c'), Digest: dg('c')}}) + if err != nil { + t.Fatal(err) + } + served := "lamp=" + catalogue.BundlePath("lamp", "tools") + "/lamp-tools" + runtimeRuns := func(dropped bool) delivery.Composed { + env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"` + if dropped { + env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29) + } + return delivery.Composed{Node: "anchor", Module: catalogue.RuntimeModule, Resources: []json.RawMessage{json.RawMessage( + `{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}} + } + lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') + + `","digest":"` + dg('b') + `","path":"` + catalogue.BundlePath("lamp", "tools") + `"}` + for _, row := range []struct { + name string + set func(n *Node) delivery.Composed // and what runs of lamp + fps map[string]string + dropped bool + }{ + {"folded at a build of the old form", func(n *Node) delivery.Composed { + n.InOwn = map[string]bool{"lamp": true} + n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}} + return *n.RunsOwn + }, map[string]string{}, false}, + {"folded, the runtime already dropped it", func(n *Node) delivery.Composed { + n.InOwn = map[string]bool{"lamp": true} + n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}} + return *n.RunsOwn + }, map[string]string{}, true}, + {"held, its new build not delivered", func(n *Node) delivery.Composed { + n.Held = map[string]bool{"lamp": true} + lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage( + strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}} + n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)} + return lr + }, map[string]string{"lamp": fpFor('1')}, false}, + } { + tools := aMesh(t) + tools.Catalogue[catalogue.RuntimeModule], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp + anchor := nodeOf(t, &tools, "anchor") + anchor.Assigned = append(anchor.Assigned, "artifacts", catalogue.RuntimeModule, "lamp") + tools.BusMinted = []string{"anchor." + catalogue.RuntimeModule} + anchor.SealingKey = keyOf(t, "anchor") + anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)}) + row.fps[overlay.Name], row.fps["artifacts"], row.fps[catalogue.RuntimeModule] = fpFor('a'), fpFor('5'), fpFor('e') + out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing) + if err != nil { + t.Fatalf("%s: %v", row.name, err) + } + var env map[string]any + for _, c := range out { + for _, raw := range c.Resources { + var r struct { + ID string `json:"id"` + Env map[string]any `json:"env"` + } + if json.Unmarshal(raw, &r) == nil && r.ID == "node-tools.runtime" { + env = r.Env + } + } + } + if env[catalogue.RuntimeToolModules] != served || env[catalogue.RuntimeToolEnv] != `{"lamp":{"LAMP_CONF":"/var/lib/lamp/conf"}}` { + t.Errorf("%s: the runtime serves %v with %v, want what runs of lamp, %s", row.name, + env[catalogue.RuntimeToolModules], env[catalogue.RuntimeToolEnv], served) + } + } +} + +// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words. +func TestTheComposedPoliciesAreTheCatalogues(t *testing.T) { + for _, p := range []string{catalogue.PolicyRoll, catalogue.PolicyTogether, catalogue.PolicyRecord} { + for _, from := range []string{catalogue.FromPerson, catalogue.FromModule, catalogue.FromBus, catalogue.FromData, catalogue.FromDefault} { + v := delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("a", 64), Judgement: delivery.JudgedNeither, Policy: p, PolicyFrom: from} + if _, err := delivery.EncodeComposedDigest(v); err != nil { + t.Errorf("%s from %s: %v", p, from, err) + } + } + } + if delivery.PolicyRecord != catalogue.PolicyRecord { + t.Errorf("delivery.PolicyRecord = %q, the catalogue's %q", delivery.PolicyRecord, catalogue.PolicyRecord) + } +} diff --git a/internal/compose/credentials.go b/internal/compose/credentials.go new file mode 100644 index 0000000..480c988 --- /dev/null +++ b/internal/compose/credentials.go @@ -0,0 +1,498 @@ +package compose + +import ( + "crypto/ecdh" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// Mode says what a composition does about what a send would make. Both modes read what the Input holds and make nothing; they +// differ as the controller's Reading and Foreseeing modes do. +type Mode int + +const ( + // Reading composes what is held and leaves out an own secret the Input holds none for: that machine cannot be running it. + Reading Mode = iota + // Foreseeing composes an own secret the next send would make with a stand-in and names it foreseen, while one the send would + // refuse (a bus credential nobody issued, a given secret under an old key) is refused here, in the same words. + Foreseeing + // Planning composes for a provisioning plan alone (novox/hq ADR 0570 clause 2): every own secret the Input holds none + // for stands in, named foreseen, and nothing is refused for a value, since a plan names secrets and never carries one. + // The seat mesh-provisioner's holder composes so, reading no secret; a rendering in this mode is never applied. + Planning +) + +// ForeseenCertificate and ForeseenBusUsers stand for what the Input does not carry, in a composition asked ahead of the send. +// Never sent. +const ( + ForeseenCertificate = "foreseen: issued by the next send" + ForeseenBusUsers = "foreseen: composed from the bus records by the next send" +) + +// grantsFor is every credential this node must create, because something elsewhere uses it (plan.go grantsFor): the mirror of +// what a consumer is given. A consumer whose identity overflows the provision's bound is left out of the grants and returned +// beside them (novox/hq ADR 0225); one whose resolution binds it to another provider is withdrawn and returned beside them +// (issue 274). Every other is granted. +func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overflow, []catalogue.Unbound, []string, error) { + issued, err := c.rowsFor(node) + if err != nil { + return nil, nil, nil, nil, err + } + onNetwork, err := c.whereEveryoneIs() + if err != nil { + return nil, nil, nil, nil, err + } + out := make([]catalogue.Grant, 0, len(issued)) + var withheld []catalogue.Overflow + var unbound []catalogue.Unbound + var foreseen []string + for _, s := range issued { + resolved, err := c.planFor(s.Consumer) + switch { + case unresolvable(err): + // Their set does not resolve. Skipped rather than fatal: this node is not the place to report another machine's problem. + continue + case err != nil: + // The mesh could not be asked what they wanted, which is not the same as their wanting nothing. + return nil, nil, nil, nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Provision, err) + } + plan := resolved.Plan + values, asks, err := plan.ContributionsFrom(s.Provision, s.ConsumerModule, resolved.Settings) + if err != nil { + return nil, nil, nil, nil, err + } + // A port in there is the consumer's software port until this: the assignment that moved it is that machine's. + consumer, err := c.in.node(s.Consumer) + if err != nil { + return nil, nil, nil, nil, err + } + values = catalogue.AtPublishedPort(values, s.ConsumerModule, consumer.Ports[s.ConsumerModule]) + from := s.ConsumerModule + if !asks { + // That module no longer wants this: left empty, which is how a login is withdrawn rather than kept working for ever. + from = "" + } + if bound := plan.BindsFrom(s.Provision, s.ConsumerModule, s.Local); from != "" && !slices.Contains(bound, node) { + // It still asks, and not of this node: withdrawn exactly as a credential nobody asks for, and said. + from = "" + unbound = append(unbound, catalogue.Unbound{Provision: s.Provision, Provider: node, + Consumer: s.Consumer, Module: s.ConsumerModule, Local: s.Local, BoundTo: bound}) + } + // The consumer's identity slug, from its own manifest, carried on the grant so the provider derives the same login the + // consumer does (novox/hq ADR 0049). + slug := "" + for _, mm := range plan.Modules { + if mm.Module == s.ConsumerModule { + slug = mm.Slug + break + } + } + if from != "" { + bound := boundOfGrant(plan, s, node) + source := catalogue.IdentitySource(slug, s.ConsumerModule) + if catalogue.CheckIdentityWithin(s.Consumer, source, bound) != nil { + withheld = append(withheld, catalogue.Overflow{Provision: s.Provision, Provider: node, + Consumer: s.Consumer, Module: s.ConsumerModule, + Identity: catalogue.ConsumerIdentity(s.Consumer, source), Bound: bound}) + continue + } + } + sealed := s.ForProvider + switch { + case credentiallessRow(plan, s, node): + // Its offer presents no credential (form none): the grant carries the consumer and nothing sealed, and + // nothing stands in for it, so neither end waits for a value nobody makes (issue 1126). + sealed = "" + case sealed == "": + sealed = catalogue.ForeseenSealed + foreseen = append(foreseen, fmt.Sprintf("the grant of %s for %s on %s", s.Provision, s.ConsumerModule, s.Consumer)) + } + out = append(out, catalogue.Grant{ + Provision: s.Provision, Consumer: s.Consumer, At: onNetwork[s.Consumer], + From: from, Values: values, Slug: slug, Sealed: sealed, Local: s.Local}) + } + return out, withheld, unbound, foreseen, nil +} + +// credentialless is whether a need's provider offers it with no credential (catalogue.CredentialFormNone): the vault +// mints nothing for it, so nothing is asked of the vault, sealed into a grant or a file, or stood in for (issue 1126). +func credentialless(n catalogue.Needed) bool { return n.CredentialForm == catalogue.CredentialFormNone } + +// credentiallessRow is whether the consumer's resolution answers row's need from provider with an offer of form none. +func credentiallessRow(consumer catalogue.Resolution, row Issued, provider string) bool { + return slices.ContainsFunc(consumer.Needs, func(n catalogue.Needed) bool { + return n.Name == row.Provision && n.For == row.ConsumerModule && n.Local == row.Local && n.From == provider && + credentialless(n) + }) +} + +// rowsFor is every pair credential this node's declaration must carry a grant for, in the order the controller reads them +// (SecretsFrom: by provision, consumer, consumer module and local name): the ones the Input says were made, and the ones the same send +// would make before it composes this node -- every need of a node that resolves (this one too), whose provider is this node, that is not +// answered by a record and not a credential only a person gives and nobody gave. Those have no sealed copy (ForProvider empty), so the +// grant is composed with a stand-in and named foreseen (plan.go: planFor makes the secret in every mode before grantsFor reads it). +func (c *composer) rowsFor(node string) ([]Issued, error) { + var rows []Issued + for _, row := range c.in.Issued { + if row.Provider == node { + rows = append(rows, row) + } + } + for _, other := range c.in.sorted() { + resolved, err := c.planFor(other.Name) + switch { + case unresolvable(err): + continue + case err != nil: + return nil, fmt.Errorf("what %s asked of %s cannot be read: %w", other.Name, node, err) + } + shelf := c.shelfFor(other) + for _, n := range resolved.Plan.Needs { + if n.From != node || n.ByRecord { + continue + } + cred, held := c.credential(n, other.Name) + if _, outside := issuedOutsideBy(shelf, n); cred.State == CredentialNotGiven || (outside && !(held && cred.State == CredentialHeld)) { + continue + } + if slices.ContainsFunc(rows, func(r Issued) bool { + return r.Provision == n.Name && r.Consumer == other.Name && r.ConsumerModule == n.For && r.Local == n.Local + }) { + continue + } + rows = append(rows, Issued{Provision: n.Name, Consumer: other.Name, ConsumerModule: n.For, Local: n.Local, Provider: node}) + } + } + sort.SliceStable(rows, func(i, j int) bool { + a, b := rows[i], rows[j] + switch { + case a.Provision != b.Provision: + return a.Provision < b.Provision + case a.Consumer != b.Consumer: + return a.Consumer < b.Consumer + case a.ConsumerModule != b.ConsumerModule: + return a.ConsumerModule < b.ConsumerModule + } + return a.Local < b.Local + }) + return rows, nil +} + +// boundOfGrant is the identity bound the consumer's own resolution states for the requirement this grant answers. A requirement +// not found there is held to the tightest bound the mesh knows rather than to none. +func boundOfGrant(consumer catalogue.Resolution, s Issued, provider string) catalogue.IdentityBound { + for _, n := range consumer.Needs { + if n.Name == s.Provision && n.For == s.ConsumerModule && n.From == provider { + return n.Identity + } + } + return catalogue.DefaultIdentityBound +} + +// ownSecretRow is the row for one own secret of a module on a node. +func (c *composer) ownSecretRow(node, module, name string) (OwnSecretRow, bool) { + for _, r := range c.in.OwnSecrets { + if r.Node == node && r.Module == module && r.Name == name { + return r, true + } + } + return OwnSecretRow{}, false +} + +// givenMember is one own secret a person gave on a machine and whether it is sealed to the key the machine holds now. +type givenMember struct { + Name string + Sealed string + Current bool +} + +// givenMembers is every member of a module's secret family given on a machine, by name (inv.GivenMembers): the mesh never +// makes a member, so only a value a person gave is one; a machine with no sealing key holds none. +func (c *composer) givenMembers(node Node, module, family string) []givenMember { + if node.SealingKey == "" { + return nil + } + prefix := catalogue.FamilyPrefix(family) + var out []givenMember + for _, r := range c.in.OwnSecrets { + if r.Node == node.Name && r.Module == module && r.Origin == "accepted" && strings.HasPrefix(r.Name, prefix) { + out = append(out, givenMember{Name: r.Name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// givenOwnSecret is an own secret issued outside the mesh as a person gave it on a machine (inv.GivenOwnSecret): not given when +// the value held was made by the mesh or the machine has no sealing key. +func (c *composer) givenOwnSecret(node Node, module, name string) (givenMember, bool) { + if node.SealingKey == "" { + return givenMember{}, false + } + r, ok := c.ownSecretRow(node.Name, module, name) + if !ok || r.Origin != "accepted" { + return givenMember{}, false + } + return givenMember{Name: name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey}, true +} + +// moduleSecretIfIssued is the read half of the controller's SecretForModule (inv.ModuleSecretIfIssued): the secret when one is +// held and sealed to the key the machine holds now, and otherwise not held. A key it was sealed to that the machine no longer +// has is reported as absent rather than as an error. +func (c *composer) moduleSecretIfIssued(node Node, module, name string) (sealed string, held bool) { + if node.SealingKey == "" { + return "", false + } + r, ok := c.ownSecretRow(node.Name, module, name) + if !ok || r.SealedTo != node.SealingKey { + return "", false + } + return r.Sealed, true +} + +// wouldMakeSecretForModule says what the send would do about an own secret without doing it: nil when it would make one (or one +// is held), and otherwise the very refusal it would meet (inv.WouldMakeSecretForModule). +func (c *composer) wouldMakeSecretForModule(node Node, module, name string) error { + if node.SealingKey == "" { + return fmt.Errorf("%s needs a secret and %s has no sealing key, so nothing can be sealed to it", module, node.Name) + } + r, ok := c.ownSecretRow(node.Name, module, name) + switch { + case !ok: + return nil + case r.SealedTo != node.SealingKey && r.Origin == "accepted": + return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+ + "since generated a new sealing key. The mesh cannot make another; issue it again", module, node.Name, name, node.Name) + } + return nil +} + +// givenUnderAnOldKey is the refusal for a value a person gave sealed to a key the machine no longer holds. +func givenUnderAnOldKey(module, node, name string) error { + return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+ + "since generated a new sealing key. The mesh cannot make another; give it again", module, node, name, node) +} + +// ownSecretsFor is each module's own secrets as this node composes them (plan.go renderingFor): the needed ones sealed to the +// node, and, in the Foreseeing mode, the ones the next send would make named foreseen. A module handed to the provisioner has its +// secrets from the vault through the client, so none of them is composed here. +func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing catalogue.Rendering, mode Mode) ( + needed map[string]map[string]string, foreseen map[string]map[string]bool, err error) { + needed = map[string]map[string]string{} + foreseen = map[string]map[string]bool{} + mark := func(module, name string) { + if foreseen[module] == nil { + foreseen[module] = map[string]bool{} + } + foreseen[module][name] = true + } + for _, m := range plan.Modules { + if handing.Handing(m) { + continue + } + // A secret family is never made (novox/hq ADR 0283): each member a person gave on this machine is placed, and one not + // given is nothing -- the module says it waits for it. + for _, family := range m.OwnSecrets.Families() { + for _, g := range c.givenMembers(node, m.Module, family) { + if _, fam, ok := m.OwnSecrets.Lookup(g.Name); !ok || fam != family { + continue // a longer family's member, or a name no longer of this family + } + if !g.Current { + continue + } + if needed[m.Module] == nil { + needed[m.Module] = map[string]string{} + } + needed[m.Module][g.Name] = g.Sealed + if g.Sealed == "" { + // Given, and its value is not in the Input: placed with a stand-in, named. + needed[m.Module][g.Name] = catalogue.ForeseenSealed + mark(m.Module, g.Name) + } + } + } + names := make([]string, 0) + for name := range m.OwnSecrets.Plain() { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + own := m.OwnSecrets.Plain()[name] + // An own secret issued outside the mesh is never made (novox/hq issue 401, ADR 0228; ADR 0283 for a family): only a + // value a person gave is placed. One not given is nothing, and a value the mesh made for it before is not one either. + if own.IssuedBy == catalogue.IssuedOutside { + g, given := c.givenOwnSecret(node, m.Module, name) + if !given || !g.Current { + continue + } + if g.Sealed == "" { + mark(m.Module, name) + continue + } + if needed[m.Module] == nil { + needed[m.Module] = map[string]string{} + } + needed[m.Module][name] = g.Sealed + continue + } + sealed, held := c.moduleSecretIfIssued(node, m.Module, name) + if !held && mode == Planning { + mark(m.Module, name) + continue + } + if !held && mode == Foreseeing { + // Asked ahead of the send: what the send would do about it, by the send's own rules. Made by it -- a stand-in, + // named; refused by it -- refused here, the same words (novox/hq issue 275). A bus account not yet issued is + // foreseen, never refused: the vault mints it from the grants part's wanted list once the placement is written + // on the operator's Approve (ADR 0561 §1, ADR 0567 §5), so a composition asked ahead of that cannot hold it + // (issue 774, 2026-10-30: every proposal placing mesh-conditions, mesh-self-check or mesh-healers refused). + if err := c.wouldMakeSecretForModule(node, m.Module, name); err != nil { + return nil, nil, err + } + mark(m.Module, name) + continue + } + if !held { + // Never issued, so this machine cannot be running it: left out rather than invented. + continue + } + if sealed == "" { + // Held, and its value is not in the Input: composed with a stand-in, named. + mark(m.Module, name) + continue + } + if needed[m.Module] == nil { + needed[m.Module] = map[string]string{} + } + needed[m.Module][name] = sealed + } + } + return needed, foreseen, nil +} + +// CoreBoxWanted is a core credential whose box the Input does not hold for a machine: the next send asks the vault for it and +// keeps the answer. It is returned for the caller to ask and keep; this package asks and keeps nothing. +type CoreBoxWanted struct { + Node string + Module string + Name string +} + +// coreModulesOf is every module of the plan on the core's list whose own secrets this machine's declaration carries, with their +// plain own secret names, sorted: one handed to the provisioner has its secrets from the vault through the client, and one issued +// outside the mesh is never the vault's to seal here. +func coreModulesOf(modules []catalogue.Manifest, handed func(catalogue.Manifest) bool) map[string][]string { + out := map[string][]string{} + for _, m := range modules { + if !catalogue.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) { + continue + } + var names []string + for name, own := range m.OwnSecrets.Plain() { + if own.IssuedBy != catalogue.IssuedOutside { + names = append(names, name) + } + } + if len(names) > 0 { + sort.Strings(names) + out[m.Module] = names + } + } + return out +} + +// sealingKeyValid says a machine's sealing key, recorded raw in base64, is a raw X25519 key. +func sealingKeyValid(raw string) error { + b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw)) + if err != nil || len(b) != 32 { + return errors.New("the machine's sealing key is not a raw X25519 key") + } + _, err = ecdh.X25519().NewPublicKey(b) + return err +} + +// coreBoxesFor decides each core credential of a machine in a composition that sends nothing and asks no vault (core_box.go +// coreBoxesOf, in the modes that are not a send): the kept box while it is sealed to the key the machine holds now, and otherwise a +// stand-in that the next send replaces by asking the vault -- returned in wanted for the caller to ask and keep. A machine with no +// sealing key, or one whose key is not a key, leaves its module out, said. +func (c *composer) coreBoxesFor(node Node, modules []catalogue.Manifest, handing catalogue.Rendering) ( + sealed map[string]map[string]json.RawMessage, notPlaced map[string]string, wanted []CoreBoxWanted) { + core := coreModulesOf(modules, func(m catalogue.Manifest) bool { return handing.Handing(m) }) + sealed, notPlaced = map[string]map[string]json.RawMessage{}, map[string]string{} + mods := make([]string, 0, len(core)) + for m := range core { + mods = append(mods, m) + } + sort.Strings(mods) + for _, module := range mods { + if node.SealingKey == "" { + notPlaced[module] = fmt.Sprintf("%s has no sealing key, so the vault can seal %s's credentials to "+ + "nothing there (novox/hq ADR 0349 §16)", node.Name, module) + continue + } + if err := sealingKeyValid(node.SealingKey); err != nil { + notPlaced[module] = fmt.Sprintf("%s's credentials cannot be asked of the vault: %v", module, err) + continue + } + boxes := map[string]json.RawMessage{} + for _, name := range core[module] { + var kept *CoreBox + for i := range c.in.KeptCoreBoxes { + k := &c.in.KeptCoreBoxes[i] + if k.Node == node.Name && k.Module == module && k.Name == name { + kept = k + } + } + if kept != nil && kept.SealedTo == node.SealingKey && len(kept.Box) > 0 { + boxes[name] = kept.Box + continue + } + boxes[name] = catalogue.ForeseenVaultBox + wanted = append(wanted, CoreBoxWanted{Node: node.Name, Module: module, Name: name}) + } + sealed[module] = boxes + } + return sealed, notPlaced, wanted +} + +// certificateFor is what the mesh certifies about one machine's internal name (plan.go certificateFor): refused for a machine that +// never told the mesh what key it serves with, and for one not on the private network, which has no name to be certified for. +// The certificate and the authority are public values the Input may carry; without them stand-ins are composed, and named. +func (c *composer) certificateFor(node Node) (certificate, authority string, foreseen bool, err error) { + if node.ServingKey == "" { + return "", "", false, fmt.Errorf( + "%s wants a certificate and has never told the mesh what key it serves with; it "+ + "joins again to report one", node.Name) + } + where, err := c.whereEveryoneIs() + if err != nil { + return "", "", false, err + } + if where[node.Name] == "" { + return "", "", false, fmt.Errorf( + "%s wants a certificate and is not on the private network, so it has no name inside "+ + "the mesh to be certified for", node.Name) + } + certificate, authority = node.Certificate, c.in.Authority + if certificate == "" || authority == "" { + return ForeseenCertificate, ForeseenCertificate, true, nil + } + return certificate, authority, false, nil +} + +// holdsTheBus says whether a node's set includes the module that asks for the bus's user list. +func holdsTheBus(modules []catalogue.Manifest) bool { + for _, m := range modules { + if m.BusUsers != "" && m.ClaimsSeat("mesh-broker") { + return true + } + } + return false +} diff --git a/internal/compose/credentials_needed.go b/internal/compose/credentials_needed.go new file mode 100644 index 0000000..ec36c71 --- /dev/null +++ b/internal/compose/credentials_needed.go @@ -0,0 +1,69 @@ +package compose + +import ( + "fmt" + "sort" +) + +// CredentialKey names one pair credential by the ends it joins: the provision, the consumer's node and module, the +// provider's node, and the local name where a module keeps several (Credential's and Issued's key). +type CredentialKey struct { + Provision string `json:"provision"` + Consumer string `json:"consumer"` + ConsumerModule string `json:"consumer_module"` + Provider string `json:"provider"` + Local string `json:"local,omitempty"` +} + +// CredentialsNeeded is every pair credential node's composition reads (novox/hq ADR 0547 §2, ADR 0550 §5, issue 1126): +// as a consumer, each need of its resolution a provider's credential answers (a need answered by a record is a licence's, +// not a pair's); as a provider, each credential it must carry a grant for (rowsFor). A node that composes its own asks the +// vault for exactly these, sealed to its key, and composes with them; one it does not get is stood in for, which the +// node's own apply refuses (CompositionsApplied). A need whose provider offers it with no credential (form none) is +// in neither list: the vault mints nothing for it (issue 1126). +func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []CredentialKey, err error) { + c := newComposer(in) + resolved, err := c.resolution(node) + if err != nil { + return nil, nil, err + } + for _, n := range resolved.Needs { + if n.ByRecord || n.From == "" || credentialless(n) { + continue + } + asConsumer = append(asConsumer, CredentialKey{Provision: n.Name, Consumer: node, ConsumerModule: n.For, + Provider: n.From, Local: n.Local}) + } + rows, err := c.rowsFor(node) + if err != nil { + return nil, nil, fmt.Errorf("compose: the credentials %s provides: %w", node, err) + } + for _, r := range rows { + // A grant whose offer presents no credential (form none) has none to ask for (issue 1126). + if consumer, err := c.planFor(r.Consumer); err == nil && credentiallessRow(consumer.Plan, r, node) { + continue + } + asProvider = append(asProvider, CredentialKey{Provision: r.Provision, Consumer: r.Consumer, + ConsumerModule: r.ConsumerModule, Provider: node, Local: r.Local}) + } + sortKeys(asConsumer) + sortKeys(asProvider) + return asConsumer, asProvider, nil +} + +func sortKeys(ks []CredentialKey) { + sort.Slice(ks, func(i, j int) bool { + a, b := ks[i], ks[j] + switch { + case a.Provision != b.Provision: + return a.Provision < b.Provision + case a.Provider != b.Provider: + return a.Provider < b.Provider + case a.Consumer != b.Consumer: + return a.Consumer < b.Consumer + case a.ConsumerModule != b.ConsumerModule: + return a.ConsumerModule < b.ConsumerModule + } + return a.Local < b.Local + }) +} diff --git a/internal/compose/credentials_test.go b/internal/compose/credentials_test.go new file mode 100644 index 0000000..dde374e --- /dev/null +++ b/internal/compose/credentials_test.go @@ -0,0 +1,549 @@ +package compose + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/overlay" +) + +// secretValue is a value the Input carries that must never appear in anything the composition says. +const secretValue = "SECRET-VALUE-THAT-MUST-NOT-LEAK" + +func rendered(t *testing.T, in Input, node string, mode Mode) Rendered { + t.Helper() + got, err := Render(in, node, mode) + if err != nil { + t.Fatalf("%v", err) + } + return got +} + +// anAssistant is mesh-controller's outside_not_given_test.go anAssistant (cmd/mesh-controller at 6c2a8d62): a module with a secret +// the mesh makes and one only an outside party issues. +func anAssistant() catalogue.Manifest { + return catalogue.Manifest{Module: "assistant", Version: "1", + OwnSecrets: catalogue.OwnSecrets{ + "password": {Path: "/var/lib/mesh/assistant/password", Taken: catalogue.TakenAtStart}, + "vendor-key": {Path: "/var/lib/mesh/assistant/vendor-key", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside}, + }, + Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/mesh/assistant", "mode": "0700"}}} +} + +func withAssistant(t *testing.T) Input { + t.Helper() + in := aMesh(t) + in.Catalogue["assistant"] = anAssistant() + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "assistant") + laptop.SealingKey = keyOf(t, "laptop") + return in +} + +// Own secrets (plan.go renderingFor, novox/hq issue 275 and 401): one held and sealed to the machine's key is carried; one not held is +// foreseen in the Foreseeing mode and left out in the Reading mode, and nothing is made. +func TestAnOwnSecretHeldIsCarriedAndOneNotHeldIsForeseenOrLeftOut(t *testing.T) { + mk := func() Input { + in := withAssistant(t) + in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", + SealedTo: nodeOf(t, &in, "laptop").SealingKey, Sealed: secretValue}} + return in + } + in := mk() + got := rendered(t, in, "laptop", Foreseeing) + if got.Rendering.Needed["assistant"]["password"] != secretValue || len(got.Rendering.Foreseen) != 0 { + t.Errorf("a held secret: needed %v foreseen %v", got.Rendering.Needed, got.Rendering.Foreseen) + } + // Not held: foreseen with a stand-in (Foreseeing), left out (Reading). + in.OwnSecrets = nil + got = rendered(t, in, "laptop", Foreseeing) + if !got.Rendering.Foreseen["assistant"]["password"] || got.Rendering.Needed["assistant"]["password"] != "" || + len(got.Foreseen) != 1 || got.Foreseen[0] != "assistant/password" { + t.Errorf("a secret the send makes: foreseen %v / %v needed %v", got.Rendering.Foreseen, got.Foreseen, got.Rendering.Needed) + } + got = rendered(t, in, "laptop", Reading) + if len(got.Rendering.Needed["assistant"]) != 0 || len(got.Foreseen) != 0 || got.Rendering.Foreseen != nil { + t.Errorf("Reading composed a secret that was never issued: %v %v", got.Rendering.Needed, got.Foreseen) + } + // Held but sealed to a key the machine no longer has is not held. + in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: "an-old-key", Sealed: secretValue}} + if got := rendered(t, in, "laptop", Foreseeing); !got.Rendering.Foreseen["assistant"]["password"] { + t.Errorf("a secret sealed to an old key is held: %v", got.Rendering.Needed) + } + // Held with no value in the Input: a stand-in, named. + in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: nodeOf(t, &in, "laptop").SealingKey}} + if got := rendered(t, in, "laptop", Reading); !got.Rendering.Foreseen["assistant"]["password"] { + t.Errorf("a held secret with no value is not named: %v", got.Rendering.Foreseen) + } + unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) }) + + // 1126 (ADR 0547 §2): a node applying what it composed itself never applies a stand-in: a held secret with no value in the + // Input is refused, naming it; held with its value, it composes. + fps := map[string]string{"assistant": "src2:" + strings.Repeat("a", 64), overlay.Name: "src2:" + strings.Repeat("b", 64)} + if _, _, err := CompositionsApplied(in, "laptop", fps); !errors.Is(err, ErrStoodIn) || !strings.Contains(err.Error(), "assistant/password") { + t.Errorf("a stand-in was not refused for a node's own apply: %v", err) + } + if out, _, err := CompositionsApplied(mk(), "laptop", fps); err != nil || len(out) == 0 { + t.Errorf("a held secret with its value is refused for a node's own apply: %v", err) + } +} + +// An own secret issued outside the mesh that nobody gave is never made (novox/hq issue 401, ADR 0228): nothing is composed or +// foreseen for it; a value the mesh made for it is not one a person gave; a value given is placed once it is sealed to the key held now. +func TestAnOutsideSecretIsPlacedOnlyOnceAPersonGaveIt(t *testing.T) { + in := withAssistant(t) + key := nodeOf(t, &in, "laptop").SealingKey + for _, mode := range []Mode{Reading, Foreseeing} { + got := rendered(t, in, "laptop", mode) + if _, there := got.Rendering.Needed["assistant"]["vendor-key"]; there || got.Rendering.Foreseen["assistant"]["vendor-key"] { + t.Fatalf("%v: a secret nobody gave is composed: %v %v", mode, got.Rendering.Needed, got.Rendering.Foreseen) + } + } + in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "vendor-key", Origin: "made", SealedTo: key, Sealed: secretValue}} + if _, there := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; there { + t.Error("a value the mesh made for a secret issued outside was placed") + } + in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "vendor-key", Origin: "accepted", SealedTo: key, Sealed: "the vendor's key, sealed"}} + if got := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; got != "the vendor's key, sealed" { + t.Errorf("a value given is placed as %q", got) + } + in.OwnSecrets[0].SealedTo = "an-old-key" + if _, there := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; there { + t.Error("a value given under an old key was placed") + } +} + +// 1364/AC3 (the send's refusals, novox/hq issue 275): in the Foreseeing mode a given secret under an old key is refused in the +// controller's words. No secret value is in an error. A bus account not yet issued is foreseen, issued or not (issue 774: the vault +// mints it once the placement is written on the Approve, ADR 0561 §1, ADR 0567 §5; mesh-self-check's proposal on ace was refused +// "has no bus credential: nothing was issued for ace.mesh-self-check"). +func TestTheSendsRefusalsAreRefusedInTheSendsWords(t *testing.T) { + broker := catalogue.Manifest{Module: "mesh-self-check", Version: "1", RunsAs: "mesh-self-check", + OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/mesh-self-check/broker", Taken: catalogue.TakenAtStart}}} + in := withAssistant(t) + in.Catalogue["mesh-self-check"] = broker + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "mesh-self-check") + for _, minted := range [][]string{nil, {"laptop.mesh-self-check"}} { + in.BusMinted = minted + got, err := Render(in, "laptop", Foreseeing) + if err != nil || !got.Rendering.Foreseen["mesh-self-check"]["broker"] { + t.Fatalf("minted %v: the bus account is not foreseen: %v", minted, err) + } + // Reading refuses nothing for it: the machine is not running it. + if _, err := Render(in, "laptop", Reading); err != nil { + t.Fatalf("Reading refused a secret the machine is not running: %v", err) + } + } + // A given secret under an old key. + in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "accepted", SealedTo: "an-old-key", Sealed: secretValue}, + {Node: "laptop", Module: "mesh-self-check", Name: "broker", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}} + _, err := Render(in, "laptop", Foreseeing) + wantOld := "assistant on laptop holds \"password\", which was given to the mesh rather than made by it, and laptop has since generated a new " + + "sealing key. The mesh cannot make another; issue it again" + if err == nil || err.Error() != wantOld { + t.Fatalf("got %v, want %q", err, wantOld) + } + if strings.Contains(err.Error(), secretValue) { + t.Error("a secret value is in an error") + } + // A machine with no sealing key cannot be sealed to. + laptop.SealingKey = "" + in.OwnSecrets = nil + if _, err := Render(in, "laptop", Foreseeing); err == nil || !strings.Contains(err.Error(), "laptop has no sealing key, so nothing can be sealed to it") { + t.Fatalf("no sealing key: %v", err) + } +} + +// aGrantMesh is the objects provider on anchor and its consumer files on laptop, with the pair credential made. +func aGrantMesh(t *testing.T) Input { + t.Helper() + in := theObjectsMesh(t, objects()) + in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}} + return in +} + +// A credential made for a consumer is a grant on its provider's machine, carrying the consumer's values and the sealed copy; without +// the sealed copy a stand-in is composed and named (novox/hq plan.go grantsFor). +func TestAGrantIsCreatedForEachCredentialMadeForAConsumer(t *testing.T) { + in := aGrantMesh(t) + // 1126 (ADR 0550 §5): the credentials each node's composition reads, by their ends: laptop's files consumes anchor's + // s3-bucket, and anchor grants it; the node composing its own asks the vault for exactly these. + pair := CredentialKey{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor"} + if consumer, provider, err := CredentialsNeeded(in, "laptop"); err != nil || len(provider) != 0 || len(consumer) != 1 || consumer[0] != pair { + t.Errorf("laptop needs %v as consumer and %v as provider (%v), want %v as consumer", consumer, provider, err, pair) + } + if consumer, provider, err := CredentialsNeeded(in, "anchor"); err != nil || len(consumer) != 0 || len(provider) != 1 || provider[0] != pair { + t.Errorf("anchor needs %v as consumer and %v as provider (%v), want %v as provider", consumer, provider, err, pair) + } + got := rendered(t, in, "anchor", Foreseeing) + if len(got.Rendering.Grants) != 1 { + t.Fatalf("grants are %+v", got.Rendering.Grants) + } + g := got.Rendering.Grants[0] + if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.At != "laptop.internal" || g.Sealed != "sealed-to-anchor" { + t.Errorf("the grant is %+v", g) + } + if len(got.Foreseen) != 0 || len(got.Withheld) != 0 || len(got.Unbound) != 0 { + t.Errorf("foreseen %v withheld %v unbound %v", got.Foreseen, got.Withheld, got.Unbound) + } + in.Issued[0].ForProvider = "" + got = rendered(t, in, "anchor", Foreseeing) + if got.Rendering.Grants[0].Sealed != catalogue.ForeseenSealed || len(got.Foreseen) != 1 { + t.Errorf("a grant with no sealed copy: %+v foreseen %v", got.Rendering.Grants, got.Foreseen) + } + // A credential made for a consumer on another provider is not this node's grant. + if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 { + t.Error("the consumer is granted its own credential") + } + // A consumer whose set does not resolve is skipped, not fatal. + nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "files") + in.Catalogue["files"] = catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"}, + Claims: []catalogue.Claim{{Name: "x", Scope: catalogue.ScopeNode}}} + in.Catalogue["twin"] = catalogue.Manifest{Module: "twin", Version: "1", Claims: []catalogue.Claim{{Name: "x", Scope: catalogue.ScopeNode}}} + nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "twin") + if got, err := Render(in, "anchor", Foreseeing); err != nil || len(got.Rendering.Grants) != 0 { + t.Errorf("a consumer that does not resolve: %v %+v", err, got.Rendering.Grants) + } +} + +// 1364/AC2: a consumer the grants leave out because its identity overflows the provision's bound is returned beside them, naming the +// consumer and the provision (novox/hq ADR 0225), and is not granted. +func TestAConsumerWhoseIdentityOverflowsIsLeftOutOfTheGrantsAndNamed(t *testing.T) { + in := aGrantMesh(t) + in.Nodes[1].Name = "a-machine-with-a-very-long-name" + in.Issued[0].Consumer = "a-machine-with-a-very-long-name" + got := rendered(t, in, "anchor", Foreseeing) + if len(got.Rendering.Grants) != 0 || len(got.Withheld) != 1 { + t.Fatalf("grants %+v withheld %+v", got.Rendering.Grants, got.Withheld) + } + w := got.Withheld[0] + if w.Consumer != "a-machine-with-a-very-long-name" || w.Provision != "s3-bucket" || w.Provider != "anchor" || w.Module != "files" || + !strings.Contains(w.String(), "a-machine-with-a-very-long-name") || !strings.Contains(w.String(), "s3-bucket") { + t.Errorf("the consumer left out is %+v / %s", w, w.String()) + } + if len(got.Rendering.Withheld) != 1 { + t.Errorf("the Rendering does not carry it: %+v", got.Rendering.Withheld) + } +} + +// 1364/AC3: a credential on record for a consumer that its own resolution binds to another provider is withdrawn, named with where it is +// bound (novox/hq issue 274): the grant stays, with no consumer module asking. +func TestACredentialBoundToAnotherProviderIsWithdrawnAndSaidWhereItIsBound(t *testing.T) { + in := aGrantMesh(t) + second := objects() + second.Module = "objects2" + in.Catalogue["objects2"] = second + in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Site: "there", Address: "10.77.0.3"}, + Assigned: []string{overlay.Name, "objects2"}, Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}}) + nodeOf(t, &in, "laptop").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "ace", Module: "objects2"}} + got := rendered(t, in, "anchor", Foreseeing) + if len(got.Unbound) != 1 { + t.Fatalf("unbound %+v grants %+v", got.Unbound, got.Rendering.Grants) + } + u := got.Unbound[0] + if u.Provision != "s3-bucket" || u.Provider != "anchor" || u.Consumer != "laptop" || u.Module != "files" || + len(u.BoundTo) != 1 || u.BoundTo[0] != "ace" || !strings.Contains(u.String(), "ace") { + t.Errorf("unbound is %+v / %s", u, u.String()) + } + if len(got.Rendering.Grants) != 1 || got.Rendering.Grants[0].From != "" { + t.Errorf("the withdrawn credential is still asked for: %+v", got.Rendering.Grants) + } +} + +func aCertificateMesh(t *testing.T) Input { + t.Helper() + in := aMesh(t) + in.Catalogue["secure"] = catalogue.Manifest{Module: "secure", Version: "1", Certificate: &catalogue.Certificate{Into: "/etc/secure/tls.pem", Authority: "/etc/secure/ca.pem"}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "secure") + return in +} + +// 1364/AC4: with no certificate and authority in the Input a stand-in is composed and named; given them, the composition carries them as +// given. A machine that never reported a serving key, and one not on the private network, are refused in the controller's words. +func TestACertificateIsCarriedAsGivenOrStoodInForAndNamed(t *testing.T) { + in := aCertificateMesh(t) + laptop := nodeOf(t, &in, "laptop") + if _, err := Render(in, "laptop", Foreseeing); err == nil || err.Error() != "laptop wants a certificate and has never told the mesh what key it serves with; it joins again to report one" { + t.Fatalf("no serving key: %v", err) + } + laptop.ServingKey = "serving-key" + got := rendered(t, in, "laptop", Foreseeing) + if got.Rendering.Certificate != ForeseenCertificate || got.Rendering.Authority != ForeseenCertificate || len(got.Foreseen) != 1 { + t.Errorf("no certificate in the Input: %q %q foreseen %v", got.Rendering.Certificate, got.Rendering.Authority, got.Foreseen) + } + laptop.Certificate, in.Authority = "the-certificate", "the-authority" + got = rendered(t, in, "laptop", Foreseeing) + if got.Rendering.Certificate != "the-certificate" || got.Rendering.Authority != "the-authority" || len(got.Foreseen) != 0 { + t.Errorf("a certificate given: %q %q foreseen %v", got.Rendering.Certificate, got.Rendering.Authority, got.Foreseen) + } + laptop.Place.Address = "" // placed nowhere: no name inside the mesh + if _, err := Render(in, "laptop", Foreseeing); err == nil || err.Error() != "laptop wants a certificate and is not on the private network, so it has no name inside the mesh to be certified for" { + t.Fatalf("off the network: %v", err) + } +} + +func aValidKey(t *testing.T, name string) string { return keyOf(t, name) } + +// 1364/AC5 and AC6: a core credential the node needs and the Input holds no box for is composed with a stand-in and returned as one +// to keep; a kept box sealed to the key held now is carried; a node-engine below the vault-box contract is sent none (and nothing is +// asked of it); a machine with no sealing key has its core module left out, said. +func TestACoreBoxTheInputHoldsNoneOfIsReturnedToKeepAndNothingIsWritten(t *testing.T) { + mk := func() Input { + in := aMesh(t) + in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", + OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "nats") + laptop.SealingKey = aValidKey(t, "laptop") + laptop.Stated, laptop.Contract = true, VaultBoxContract + in.BusMinted = []string{"laptop.nats"} + return in + } + in := mk() + got := rendered(t, in, "laptop", Foreseeing) + box := got.Rendering.VaultSealed["nats"]["broker"] + if string(box) != string(catalogue.ForeseenVaultBox) || len(got.CoreBoxesWanted) != 1 || + got.CoreBoxesWanted[0] != (CoreBoxWanted{Node: "laptop", Module: "nats", Name: "broker"}) { + t.Errorf("no box held: %s wanted %v", box, got.CoreBoxesWanted) + } + kept := json.RawMessage(`{"v":1,"eph":"e","iv":"i","tag":"t","ct":"c"}`) + in.KeptCoreBoxes = []CoreBox{{Node: "laptop", Module: "nats", Name: "broker", Generation: 3, SealedTo: nodeOf(t, &in, "laptop").SealingKey, Box: kept}} + got = rendered(t, in, "laptop", Foreseeing) + if string(got.Rendering.VaultSealed["nats"]["broker"]) != string(kept) || len(got.CoreBoxesWanted) != 0 { + t.Errorf("a kept box: %s wanted %v", got.Rendering.VaultSealed["nats"]["broker"], got.CoreBoxesWanted) + } + in.KeptCoreBoxes[0].SealedTo = "an-old-key" + if got = rendered(t, in, "laptop", Foreseeing); len(got.CoreBoxesWanted) != 1 { + t.Errorf("a box sealed to an old key is kept: %v", got.CoreBoxesWanted) + } + // Below the vault-box contract: none, and the controller's own seal as before. + nodeOf(t, &in, "laptop").Contract = VaultBoxContract - 1 + if got = rendered(t, in, "laptop", Foreseeing); got.Rendering.VaultSealed != nil || len(got.CoreBoxesWanted) != 0 { + t.Errorf("an engine below the contract is sent boxes: %v %v", got.Rendering.VaultSealed, got.CoreBoxesWanted) + } + // No sealing key: the module is left out, said. + nodeOf(t, &in, "laptop").Contract, nodeOf(t, &in, "laptop").SealingKey = VaultBoxContract, "" + got = rendered(t, in, "laptop", Reading) + if why := got.Rendering.NotPlaced["nats"]; !strings.Contains(why, "laptop has no sealing key, so the vault can seal nats's credentials to nothing there") { + t.Errorf("not placed: %v", got.Rendering.NotPlaced) + } + unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) }) +} + +// 1364/AC1: the bus's user list is carried for the machine that runs the bus and for no other; without it a stand-in is composed and +// named; the users left out for want of a credential are said; each machine's membership is carried. (The list is composed by the +// controller from the bus records, which stay the controller's: see the commit message.) +func TestTheBusUserListIsCarriedForTheMachineThatRunsTheBus(t *testing.T) { + in := aMesh(t) + in.Catalogue["bus"] = catalogue.Manifest{Module: "bus", Version: "1", BusUsers: "/etc/bus/accounts.conf", + Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}} + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "bus") + in.BusUsers = "accounts { anchor.bus: hash }" + in.BusMissing = []string{"laptop.files", "laptop.web"} + in.BusMemberships = map[string]string{"laptop": "sealed-membership"} + got := rendered(t, in, "anchor", Foreseeing) + if got.Rendering.BusUsers != in.BusUsers || len(got.Foreseen) != 0 { + t.Errorf("the list is %q foreseen %v", got.Rendering.BusUsers, got.Foreseen) + } + if len(got.Said) != 1 || got.Said[0] != "the bus's user list leaves out 2 user(s) the mesh has minted no credential for: laptop.files, laptop.web. Each is a user that cannot connect until one is issued" { + t.Errorf("said %v", got.Said) + } + if other := rendered(t, in, "laptop", Foreseeing); other.Rendering.BusUsers != "" || other.Rendering.BusMembership != "sealed-membership" { + t.Errorf("laptop: list %q membership %q", other.Rendering.BusUsers, other.Rendering.BusMembership) + } + in.BusUsers = "" + got = rendered(t, in, "anchor", Foreseeing) + if got.Rendering.BusUsers != ForeseenBusUsers || len(got.Foreseen) != 1 { + t.Errorf("no list in the Input: %q foreseen %v", got.Rendering.BusUsers, got.Foreseen) + } +} + +// 1364/AC7: nothing the composition says carries a secret value the Input holds. +func TestNoSecretValueOfTheInputIsInWhatTheCompositionSays(t *testing.T) { + in := aGrantMesh(t) + in.Issued[0].ForProvider = secretValue + laptop := nodeOf(t, &in, "laptop") + in.Catalogue["assistant"] = anAssistant() + laptop.Assigned = append(laptop.Assigned, "assistant") + laptop.SealingKey = keyOf(t, "laptop") + in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}} + in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialHeld, Sealed: secretValue}} + for _, node := range []string{"anchor", "laptop"} { + got := rendered(t, in, node, Foreseeing) + for _, said := range append(append([]string{}, got.Foreseen...), got.Said...) { + if strings.Contains(said, secretValue) { + t.Errorf("%s: %q carries a secret value", node, said) + } + } + } +} + +// A consumer no credential was made for yet is granted with a stand-in and named foreseen, for every form whose credential +// the vault mints or takes in; an offer of form none has no credential (issue 1126, 2026-11-02: mesh-cli on ace held for +// "the grant of mcp-endpoint", whose provider node-tools offers form none): its grant carries the consumer and no +// credential, nothing stands in on either side, and neither end asks the vault for one. +func TestAConsumerNoCredentialWasMadeForYetIsGrantedWithAStandInUnlessItsOfferHasNone(t *testing.T) { + for _, tc := range []struct { + form string + cred *catalogue.OfferCredential + }{ + {"undeclared", nil}, + {catalogue.CredentialFormPair, &catalogue.OfferCredential{Form: catalogue.CredentialFormPair}}, + {catalogue.CredentialFormShared, &catalogue.OfferCredential{Own: "access-key"}}, + {catalogue.CredentialFormNone, &catalogue.OfferCredential{Form: catalogue.CredentialFormNone}}, + } { + in := aGrantMesh(t) + in.Issued = nil + provider := in.Catalogue["objects"] + provider.Provides[0].Credential = tc.cred + if tc.cred != nil && tc.cred.Own != "" { + provider.OwnSecrets = catalogue.OwnSecrets{"access-key": {Path: "/var/lib/mesh/objects/access-key"}} + } + in.Catalogue["objects"] = provider + none := tc.form == catalogue.CredentialFormNone + got := rendered(t, in, "anchor", Foreseeing) + if len(got.Rendering.Grants) != 1 { + t.Fatalf("%s: grants are %+v", tc.form, got.Rendering.Grants) + } + g, wantSealed := got.Rendering.Grants[0], catalogue.ForeseenSealed + if none { + wantSealed = "" + } + if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.Sealed != wantSealed { + t.Errorf("%s: the grant is %+v", tc.form, g) + } + if has(got.Foreseen, "the grant of s3-bucket for files on laptop") == none { + t.Errorf("%s: foreseen %v", tc.form, got.Foreseen) + } + consumer, asProvider, err := CredentialsNeeded(in, "laptop") + _, anchorProvides, err2 := CredentialsNeeded(in, "anchor") + if err != nil || err2 != nil || len(asProvider) != 0 || (len(consumer) == 0) != none || (len(anchorProvides) == 0) != none { + t.Errorf("%s: laptop asks %v, anchor provides %v (%v %v)", tc.form, consumer, anchorProvides, err, err2) + } + resolved, err := Resolve(in, "laptop") + if err != nil { + t.Fatalf("%s: %v", tc.form, err) + } + if n := theFilesNeed(t, resolved); (n.Sealed == "") != none || (len(resolved.Foreseen) == 0) != none { + t.Errorf("%s: the consumer's credential is %q, foreseen %v", tc.form, n.Sealed, resolved.Foreseen) + } + if lap := rendered(t, in, "laptop", Foreseeing); none && len(lap.Foreseen) != 0 { + t.Errorf("%s: the consumer stands in for %v", tc.form, lap.Foreseen) + } + // Only the provider's own: the consumer is granted nothing, and nothing in the Input changed. + if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 { + t.Errorf("%s: the consumer is granted its own credential", tc.form) + } + if len(in.Issued) != 0 { + t.Errorf("%s: a row was added to the Input", tc.form) + } + // A row made long ago for an offer of form none (the controller's) carries no credential either. + in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}} + if g := rendered(t, in, "anchor", Foreseeing).Rendering.Grants; none && (len(g) != 1 || g[0].Sealed != "") { + t.Errorf("%s: an old row's grant is %+v", tc.form, g) + } + } + // A credential only a person gives and nobody has is no grant. + in := aGrantMesh(t) + in.Issued = nil + in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven}} + if got := rendered(t, in, "anchor", Foreseeing); len(got.Rendering.Grants) != 0 { + t.Errorf("a credential nobody gave is granted: %+v", got.Rendering.Grants) + } +} + +// And a consumer whose identity overflows the provision's bound is judged before any credential is made for it (issue 263). +func TestAnOverflowingConsumerNoCredentialWasMadeForYetIsWithheld(t *testing.T) { + in := aGrantMesh(t) + in.Issued = nil + in.Catalogue["resolver"] = storeManifests()[1] + in.Catalogue["networkmanager"] = catalogue.Manifest{Module: "networkmanager", Version: "1", Requires: []string{"wildcard-resolution", "s3-bucket"}} + nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "networkmanager") + nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, "resolver") + in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}} + got := rendered(t, in, "anchor", Foreseeing) + if len(got.Withheld) != 1 || got.Withheld[0].Module != "networkmanager" { + t.Fatalf("withheld %+v", got.Withheld) + } +} + +// The grants come in the order the controller reads them, whatever order the Input gives its rows. +func TestGrantsComeInTheOrderOfProvisionConsumerModuleAndLocal(t *testing.T) { + in := aGrantMesh(t) + in.Issued = []Issued{ + {Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Local: "b", Provider: "anchor", ForProvider: "x"}, + {Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Local: "a", Provider: "anchor", ForProvider: "x"}, + } + rows, err := newComposer(in).rowsFor("anchor") + // The credential the same send would make (no local name) comes first, then the two the Input gives, a before b. + if err != nil || len(rows) != 3 || rows[0].Local != "" || rows[1].Local != "a" || rows[2].Local != "b" { + t.Errorf("rows are %+v %v", rows, err) + } +} + +// Round 1, finding 4: a member of a secret family a person gave, whose sealed value the Input does not carry, is placed with a +// stand-in and named foreseen, not dropped as if nobody gave it. +func TestAGivenFamilyMemberWithoutItsValueIsAStandInAndNamedForeseen(t *testing.T) { + in := aMesh(t) + in.Catalogue["mailer"] = catalogue.Manifest{Module: "mailer", Version: "1", + OwnSecrets: catalogue.OwnSecrets{"credential-*": {Path: "/var/lib/mailer/credential-*.secret", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside}}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "mailer") + laptop.SealingKey = keyOf(t, "laptop") + in.OwnSecrets = []OwnSecretRow{ + {Node: "laptop", Module: "mailer", Name: "credential-a", Origin: "accepted", SealedTo: laptop.SealingKey}, + {Node: "laptop", Module: "mailer", Name: "credential-b", Origin: "accepted", SealedTo: laptop.SealingKey, Sealed: "b-sealed"}, + } + got := rendered(t, in, "laptop", Foreseeing) + needed := got.Rendering.Needed["mailer"] + if needed["credential-a"] != catalogue.ForeseenSealed || needed["credential-b"] != "b-sealed" { + t.Errorf("needed %v", needed) + } + if !has(got.Foreseen, "mailer/credential-a") || has(got.Foreseen, "mailer/credential-b") { + t.Errorf("foreseen %v", got.Foreseen) + } +} + +// Round 1, finding 5: the suffix internal names end in is a field of the Input, and the environment is never read. +func TestTheInternalSuffixIsTheInputsAndNeverTheEnvironments(t *testing.T) { + t.Setenv(overlay.SuffixVar, "from-the-environment") + in := aGrantMesh(t) + got := rendered(t, in, "anchor", Foreseeing) + if got.Rendering.Suffix != "internal" || got.Rendering.Grants[0].At != "laptop.internal" { + t.Errorf("by default: suffix %q, grant at %q", got.Rendering.Suffix, got.Rendering.Grants[0].At) + } + in.InternalSuffix = ".lan" + got = rendered(t, in, "anchor", Foreseeing) + if got.Rendering.Suffix != "lan" || got.Rendering.Grants[0].At != "laptop.lan" { + t.Errorf("given: suffix %q, grant at %q", got.Rendering.Suffix, got.Rendering.Grants[0].At) + } + where, err := newComposer(in).whereEveryoneIs() + if err != nil || where["anchor"] != "anchor.lan" { + t.Errorf("where everyone is: %v %v", where, err) + } + if names := newComposer(in).namesInTheMesh(); names["anchor.lan"] == "" || names["anchor.internal"] != "" { + t.Errorf("names: %v", names) + } +} + +// Round 2: a need answered on the same machine is a grant too (catalogue resolve.go gives it From: the node's own name). +func TestAConsumerOnTheProvidersOwnNodeIsGrantedWithAStandInAndNamedForeseen(t *testing.T) { + in := aGrantMesh(t) + in.Issued = nil + laptop, anchor := nodeOf(t, &in, "laptop"), nodeOf(t, &in, "anchor") + laptop.Assigned = []string{overlay.Name} + anchor.Assigned = append(anchor.Assigned, "files") + got := rendered(t, in, "anchor", Foreseeing) + if len(got.Rendering.Grants) != 1 || got.Rendering.Grants[0].Consumer != "anchor" || got.Rendering.Grants[0].Sealed != catalogue.ForeseenSealed { + t.Fatalf("grants are %+v", got.Rendering.Grants) + } + if !has(got.Foreseen, "the grant of s3-bucket for files on anchor") { + t.Errorf("foreseen %v", got.Foreseen) + } +} diff --git a/internal/compose/fixture_test.go b/internal/compose/fixture_test.go new file mode 100644 index 0000000..1766852 --- /dev/null +++ b/internal/compose/fixture_test.go @@ -0,0 +1,90 @@ +package compose + +import ( + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "encoding/json" + "reflect" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/overlay" +) + +// aPublicKey is a key a machine could have reported, made as mesh-controller's mesh_for_test.go aPublicKey makes it. +func aPublicKey(t *testing.T) string { + t.Helper() + k, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()) +} + +// keys are made once per run, so two Inputs made the same way are equal. +var keys = map[string]string{} + +func keyOf(t *testing.T, name string) string { + t.Helper() + if keys[name] == "" { + keys[name] = aPublicKey(t) + } + return keys[name] +} + +// theOverlayModule is the module the mesh provides for itself, as mesh-controller's providedModules makes it +// (cmd/mesh-controller/modules.go at 6c2a8d62). +func theOverlayModule(t *testing.T) catalogue.Manifest { + t.Helper() + var m catalogue.Manifest + b, err := json.Marshal(overlay.Manifest()) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + return m +} + +// aMesh is the Input of mesh-controller's aMesh (cmd/mesh-controller/mesh_for_test.go at 6c2a8d62): two placed +// machines, `anchor` the hub, both reporting container-runtime, wireguard and systemd, both assigned what puts them on the +// private network, each at .example:51820 on the site `here`, anchor at 10.77.0.1 and laptop at 10.77.0.2. +func aMesh(t *testing.T) Input { + t.Helper() + in := Input{Catalogue: map[string]catalogue.Manifest{overlay.Name: theOverlayModule(t)}} + for i, name := range []string{"anchor", "laptop"} { + in.Nodes = append(in.Nodes, Node{ + Name: name, + Place: Place{Key: keyOf(t, name), Endpoint: name + ".example:51820", Site: "here", + Hub: name == "anchor", Address: []string{"10.77.0.1", "10.77.0.2"}[i]}, + Assigned: []string{overlay.Name}, + Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}, + }) + } + return in +} + +// nodeOf is the named node of an Input, for a test to change. +func nodeOf(t *testing.T, in *Input, name string) *Node { + t.Helper() + for i := range in.Nodes { + if in.Nodes[i].Name == name { + return &in.Nodes[i] + } + } + t.Fatalf("no node %s", name) + return nil +} + +// unchanged runs call on one Input and compares it with another made the same way: the composition changes nothing in what +// it is given (deep equality, nothing shared). +func unchanged(t *testing.T, mk func() Input, call func(Input)) { + t.Helper() + used, same := mk(), mk() + call(used) + if !reflect.DeepEqual(used, same) { + t.Fatalf("the call changed its Input") + } +} diff --git a/internal/compose/frombus/answer.go b/internal/compose/frombus/answer.go new file mode 100644 index 0000000..f5a5e9e --- /dev/null +++ b/internal/compose/frombus/answer.go @@ -0,0 +1,582 @@ +package frombus + +import ( + "context" + "errors" + "fmt" + "maps" + "regexp" + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" +) + +// Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with +// s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as +// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes +// nothing; the proposal and the revision are the candidate's, said back in the answer. +// +// The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the +// node does not run, the build the trunk holds; a placed module with none is added, with no build of it known. What the +// composition stands in for is judged: a value of a module the proposal adds (on this node or another: its credential, own +// secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other +// stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own). +func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, proposal string, revision uint64) delivery.CandidateAnswer { + a := delivery.CandidateAnswer{Proposal: proposal, Revision: revision} + refuse := func(err error) delivery.CandidateAnswer { + a.Refused = err.Error() + a.Verdict = delivery.CandidateRefused + return a + } + trunk, err := r.Placements(ctx) + if err != nil { + return refuse(fmt.Errorf("frombus: placement: %w", err)) + } + if s.Running == nil { + s.Running = running + } + // Every module it cannot compose is named, not the first only (issue 1126): a build it cannot read or take in, and + // below, a module the composition refuses, each composed as a stub so the rest is composed and compared. + failed := map[string]string{} + in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() }, nil) + if err != nil { + return refuse(err) + } + asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node) + if err != nil { + return refuse(err) + } + // What the node runs, by module. A consumer's credential is asked only for a module it runs: the vault refuses the + // whole ask for a consuming module not assigned to the node (mesh-vault's own), and a module the candidate adds is + // assigned only after the merge (ADR 0549 §5), so its credential is left missing, a stand-in answered as not yet + // issued under Added (review of L2b, round 1, finding 1). + runs := map[string]bool{} + for _, c := range running { + if c.Module != "" { + runs[c.Module] = true + } + } + var consumed []compose.CredentialKey + for _, k := range asConsumer { + if runs[k.ConsumerModule] { + consumed = append(consumed, k) + } + } + asConsumer = consumed + if len(asConsumer)+len(asProvider) > 0 { + if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil { + return refuse(fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err)) + } + } + composed, skipped, stood, err := composeEach(in, s.Node, fps, false, s.Running, failed, ranOf(ctx, r, mesh, s.Running, fps)) + if err != nil { + // Not one module's: the node's own (a value served elsewhere, the mesh's network), said once as the node's. + if len(failed) == 0 { + return refuse(err) + } + failed[delivery.HaveNodeWord] = err.Error() + composed, skipped, stood = nil, nil, nil + } + // The node's own composition is judged only where the node runs one: one that runs none applies it only once a want + // names it (the node agent's own composer hands it on that want alone), so a candidate does not add it. + runsOwn := false + for _, c := range running { + runsOwn = runsOwn || c.Module == "" + } + if !runsOwn { + for key, c := range composed { + if c.Module == "" { + delete(composed, key) + } + } + } + if _, whole := failed[delivery.HaveNodeWord]; whole { + // Nothing composed to compare: every refusal is said, and nothing else. + for _, m := range sortedKeys(failed) { + a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]}) + } + heldAsToday(&a, s.HeldToday) + a.Verdict = delivery.VerdictOf(a) + return a + } + // A module the composition folds inside the node's own is judged there: the agent also says it runs by module, from + // the declaration it kept before the switch (agent.Everything), which is no removal (issue 1126, seed #531). + if len(s.InOwn) > 0 { + var judged []delivery.Composed + for _, c := range running { + if c.Module == "" || !slices.Contains(s.InOwn, c.Module) { + judged = append(judged, c) + } + } + running = judged + } + cmp := delivery.CompareComposedWith(composed, running, s.SameValue) + // The answer rides in the node's have key: its shown bodies are bounded, the rest named only. + budget := MaxShown + for i := range cmp.Differs { + kept := cmp.Differs[i].Bodies[:0] + for _, b := range cmp.Differs[i].Bodies { + if n := len(b.Composed) + len(b.Running); n <= budget { + budget -= n + kept = append(kept, b) + } else { + kept = append(kept, delivery.ResourceBodies{ID: b.ID, Why: b.Why}) + a.Note = "some differing resources are named without their bodies: the answer holds at most " + + fmt.Sprint(MaxShown) + " bytes of them" + } + } + cmp.Differs[i].Bodies = kept + } + // A refused module was composed as a stub: what it composed is no difference; its refusal is said. + notFailed := func(m string) bool { _, ok := failed[m]; return !ok } + a.Same = slices.DeleteFunc(cmp.Same, func(m string) bool { return !notFailed(m) }) + a.Removed = slices.DeleteFunc(cmp.Removed, func(m string) bool { return !notFailed(m) }) + a.Differs = slices.DeleteFunc(cmp.Differs, func(d delivery.ModuleDiffers) bool { return !notFailed(d.Module) }) + cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) }) + skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) }) + + // What the proposal adds, by node and module: placed by a candidate and not by the trunk on that node. + added := map[string]bool{} + for node, p := range s.Placements { + if p == nil { + continue + } + was := map[string]bool{} + for _, m := range trunk[node].Modules { + was[m.Module] = true + } + for _, m := range p.Modules { + if !was[m.Module] { + added[node+"/"+m.Module] = true + } + } + } + unissued := map[string][]string{} + refusals := map[string][]string{} + for _, x := range stood { + node, module := standsInFor(x, s.Node) + switch { + case node == s.Node && runs[module]: + // A value missing for a module the node runs is a refusal, whatever the trunk places (the seed's trunk is + // empty, and every module counts as the group's). + refusals[module] = append(refusals[module], x) + case module != "" && added[node+"/"+module]: + if node == s.Node { + unissued[module] = append(unissued[module], x) + } // another node's addition: the module here that carries it differs, which the assigner judges + case module == "" || node != s.Node: + // The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's. + refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x) + default: + refusals[module] = append(refusals[module], x) + } + } + addedHere := map[string]delivery.ModuleAdded{} + for _, m := range cmp.Added { + addedHere[m.Module] = m + } + for _, m := range skipped { // placed, and no build given to compose it at + addedHere[m] = delivery.ModuleAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}} + } + for m, xs := range unissued { + got := addedHere[m] + got.Module = m + got.Unissued = append(got.Unissued, xs...) + addedHere[m] = got + } + for _, m := range sortedKeys(addedHere) { + a.Added = append(a.Added, addedHere[m]) + } + for _, m := range sortedKeys(refusals) { + if _, isAdded := addedHere[m]; isAdded { + continue // a module the candidate adds here: what it lacks is said under Added + } + if _, isFailed := failed[m]; isFailed { + continue // said below, as why it could not be composed + } + a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")}) + } + for _, m := range sortedKeys(failed) { + a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]}) + } + sort.Slice(a.Refusals, func(i, j int) bool { return a.Refusals[i].Module < a.Refusals[j].Module }) + heldAsToday(&a, s.HeldToday) + a.Verdict = delivery.VerdictOf(a) + return a +} + +// heldAsToday moves into Held each refusal of a module the node keeps today as it runs (Scope.HeldToday, with how: +// held, failed, running a build of the old form): the candidate keeps it as the node does, which is no change (issue +// 1126: a seed reproduces today, and today keeps them so). Each says how it stands today (ModuleRefused.Today). No +// module is held behind another (issue 1960): a module that reads from a held one composes with what it last offered. +func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) { + if len(today) == 0 { + return + } + kept := a.Refusals[:0] + for _, r := range a.Refusals { + if w, keep := today[r.Module]; keep { + r.Today = w + a.Held = append(a.Held, r) + } else { + kept = append(kept, r) + } + } + a.Refusals = kept + if len(a.Refusals) == 0 { + a.Refusals = nil + } +} + +// maxCulprits bounds the modules one refusal is tried on, each a whole composition. +const maxCulprits = 8 + +// MaxShown bounds the bytes of shown resource bodies in one answer (delivery.ModuleDiffers.Bodies). +const MaxShown = 256 << 10 + +var ( + grantStandIn = regexp.MustCompile(`^the grant of \S+ for (\S+) on (\S+)$`) + // grantOfStandIn is the provision a grant's stand-in is of. + grantOfStandIn = regexp.MustCompile(`^the grant of (\S+) for \S+ on \S+$`) + needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `) +) + +// standsInFor is the node and module a stand-in of compose's words is for: an own secret `module/name`, a port +// `port module:port`, a grant `the grant of for on `, a need ` on needs ...`; +// the module empty when the words name none (the certificate, the authority). +func standsInFor(x, node string) (string, string) { + if m := grantStandIn.FindStringSubmatch(x); m != nil { + return m[2], m[1] + } + if m := needsStandIn.FindStringSubmatch(x); m != nil { + return m[2], m[1] + } + if rest, ok := strings.CutPrefix(x, "port "); ok { + if module, _, ok := strings.Cut(rest, ":"); ok { + return node, module + } + } + if module, _, ok := strings.Cut(x, "/"); ok && !strings.Contains(x, " ") { + return node, module + } + return node, "" +} + +// composeEach is the one rule, for the node that applies and for its answer to a candidate alike, by which a module's +// own refusal refuses that module and never the node (issue 1126: seed-8 fixed it in the answer alone, and the +// applying path still refused all of novox for de-spiegel's unset route names). The node is composed; a refusal is laid +// on the module whose stub (compose.Stub) changes it (culprit), that module is held (compose.Node.Held) and said in +// failed with the refusal's words, and the rest is composed again, until it composes or the refusal is no single +// module's, which is answered as the node's (err). Applied composes as the node applies (compose.Reading) and holds the +// module placed here each stand-in is for; one for no module placed here (the node's own, a value this node holds for a +// module elsewhere) refuses the node (compose.ErrStoodIn). Not applied, it composes in compose.Foreseeing and answers +// every stand-in for the caller to judge (Answer). +// +// **A held module is what runs of it** (review of the apply, round 1): composed from what running holds of it +// (compose.Carried of its own composition there; the node's own carries it wherever it runs there), so what the others +// read of it (the machine ports its containers publish, the routes reaching it) is what runs, and none of its own +// composition is in out: the node keeps what it last applied. +// +// **A hold never spreads** (issue 1126, issue 1960: de-spiegel's hold for an unset value held mailu, which held +// route-proxy, nftables and postgres on novox). No module is held because a module it reads from is held: a consumer, +// a collector, the provider of what the held one contributes to and the filter where it listens each compose with what +// the held module last offered. That offer (what it contributes, contributes many, listens on) is its manifest at the +// build it runs where one is known (ran: a composition of its own running at another build than it is composed at), +// else the manifest it is composed from. Only the part of the offer that cannot compose is left out, by provision +// (culprit): de-spiegel's routes, its names unset, leave route-proxy without them while mailu keeps its smtp account, +// and the hold says so. +func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []delivery.Composed, failed map[string]string, + ran func(module string) (catalogue.Manifest, bool)) (out map[string]delivery.Composed, skipped, stood []string, err error) { + mode := compose.Foreseeing + if applied { + mode = compose.Reading + } + runs := map[string]*delivery.Composed{} + for i := range running { + if m := running[i].Module; m != "" { + runs[m] = &running[i] + } + } + src := map[string]catalogue.Manifest{} // each held module's manifest before it was held, offering what it last offered + left := map[string]map[string]bool{} // by held module, what of its offer is left out (leaveOut) + hold := func(m, why, leave string) { + if _, done := failed[m]; !done { + failed[m] = why // a held module tried again keeps the words it was first held for + } + if _, ok := src[m]; !ok { + man, ok := manifestOn(in, node, m) + if !ok { + return + } + if ran != nil { + if was, ok := ran(m); ok { + man.Contributes, man.ContributesMany, man.Listens = was.Contributes, was.ContributesMany, was.Listens + } + } + src[m] = man + } + if leave != "" { + if left[m] == nil { + left[m] = map[string]bool{} + } + left[m][leave] = true + if leave != leaveAll && leave != leaveOwn { + failed[m] += "; composed without what it contributes to " + leave + ", which does not compose" + } + } + in = heldAs(in, node, m, src[m], left[m], runs[m]) + } + for _, m := range sortedKeys(failed) { + hold(m, failed[m], "") + } + for { + out, skipped, stood, err = compose.CompositionsIn(in, node, fps, mode) + if err != nil { + module, leave := culprit(in, node, fps, mode, failed, err) + if module == "" { + return nil, nil, nil, firstReason(err, failed) + } + hold(module, err.Error(), leave) + continue + } + again := false + if applied && len(stood) > 0 { + placed := map[string]bool{} + for _, m := range assignedOn(in, node) { + placed[m] = true + } + of := map[string][]string{} + var nodes []string + for _, x := range stood { + if n, m := standsInFor(x, node); n == node && placed[m] { + of[m] = append(of[m], x) + } else { + nodes = append(nodes, x) + } + } + for _, m := range sortedKeys(of) { + if _, done := failed[m]; !done { + hold(m, "stands in for "+strings.Join(of[m], ", "), "") + again = true + continue + } + // Already held, and still a stand-in in another module's composition: a grant not yet issued for what + // it contributes (a module held for its own secret keeps its contributions, leaveOwn). Its contribution + // to that provision is left out, so the provider never applies a stand-in (issue 774, review round 1). + for _, x := range of[m] { + if g := grantOfStandIn.FindStringSubmatch(x); g != nil && !left[m][g[1]] { + hold(m, failed[m], g[1]) + again = true + } + } + } + if !again && len(nodes) > 0 { + return nil, nil, nil, fmt.Errorf("%w on %s: %s", compose.ErrStoodIn, node, strings.Join(nodes, ", ")) + } + } + if !again { + break + } + } + skipped = slices.DeleteFunc(skipped, func(m string) bool { _, held := failed[m]; return held }) + return out, skipped, stood, nil +} + +// firstReason is err, a refusal no culprit is found for, led by the first reason of each held module it names: a held +// module's stand-in refused later never replaces why it was held (issue 774, 2026-10-30: mesh-self-check on ace held for +// "has no bus credential", then refused as "runs as its own account and declares no own secret broker", its bare stub's +// words, which were all the proposal's refusal said). +func firstReason(err error, failed map[string]string) error { + var named []string + for _, m := range sortedKeys(failed) { + if strings.Contains(err.Error(), m) { + named = append(named, m+" is held: "+failed[m]) + } + } + if len(named) == 0 { + return err + } + return fmt.Errorf("%s; its stand-in is then refused: %w", strings.Join(named, "; "), err) +} + +// blames says the composition with m's stub (again) makes m the culprit of err: it composes, or fails otherwise. The +// runtime's stub is never a runtime (no build is taken in, so it carries no bundle) and always fails otherwise: it is +// the culprit only where its stub composes (issue 774, 2026-10-30 19:30: node-tools held for any error naming it). +func blames(m string, again, err error) bool { + if m == catalogue.RuntimeModule { + // Or where its stub fails only for the modules of their own account it would run (issue 774): they are held + // after it, each for its own words, and the node composes the rest. + var none *catalogue.NoRuntimeToRunError + return again == nil || errors.As(again, &none) && none.Held + } + return again == nil || again.Error() != err.Error() +} + +// leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); leaveOwn its own secrets +// alone, its offer kept; any other word leaves out what it contributes to that provision alone. +// +// leaveOwn is tried before leaveAll: a module held for an own secret never made (nextcloud's admin, issue 774, +// 2026-10-30) keeps what it contributes, so its providers' received files keep it and their helpers never retire its +// login while it is held. +const ( + leaveAll = "*" + leaveOwn = "own-secrets" +) + +// assignedOn is the modules placed on node, in placement order. +func assignedOn(in compose.Input, node string) []string { + for _, n := range in.Nodes { + if n.Name == node { + return n.Assigned + } + } + return nil +} + +// manifestOn is the manifest node composes module from: its Kept, else the catalogue's. +func manifestOn(in compose.Input, node, module string) (catalogue.Manifest, bool) { + for _, n := range in.Nodes { + if n.Name == node { + if man, ok := n.Kept[module]; ok { + return man, true + } + } + } + man, ok := in.Catalogue[module] + return man, ok +} + +// culprit is the module placed on node whose composition the error is of, and what of its offer to leave out: the +// first, in placement order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave +// ""); else the first whose stub without what it contributes to one provision does (leave that provision: de-spiegel's +// routes, its names unset, and not its smtp account, issue 1960); else the first whose bare stub does (leaveAll: its +// stub contributing nothing, its own secret never issued, issue 1126 seed-8). Empty when none does (the error is not +// one module's). Modules already refused are passed over for their stub. +func culprit(in compose.Input, node string, fps map[string]string, mode compose.Mode, failed map[string]string, err error) (string, string) { + assigned := assignedOn(in, node) + // Only the modules the error names: the words of a module's refusal name it. Each try is a whole composition, so a + // refusal naming none is no module's, answered at once rather than after a composition per module (issue 1126: ace's + // answer took 7m46s). + var order []string + for _, m := range assigned { + if strings.Contains(err.Error(), m) && len(order) < maxCulprits { + order = append(order, m) + } + } + changes := func(m, leave string) bool { + _, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode) + return blames(m, again, err) + } + for _, m := range order { + if _, done := failed[m]; done { + continue + } + if changes(m, "") { + return m, "" + } + } + for _, m := range order { + man, ok := manifestOn(in, node, m) + if !ok { + continue + } + // One already held is tried again too: held for its build, its stub may still refuse (its own secret never + // issued, issue 1126). A provision named in the error is tried first, each at one more composition. + var to []string + for p := range man.Contributes { + to = append(to, p) + } + for p := range man.ContributesMany { + if _, both := man.Contributes[p]; !both { + to = append(to, p) + } + } + sort.SliceStable(to, func(i, j int) bool { + return strings.Contains(err.Error(), " "+to[i]) && !strings.Contains(err.Error(), " "+to[j]) + }) + for _, p := range to { + if changes(m, p) { + return m, p + } + } + if isBare(man) { + continue + } + if len(man.OwnSecrets) > 0 && changes(m, leaveOwn) { + return m, leaveOwn + } + if changes(m, leaveAll) { + return m, leaveAll + } + } + return "", "" +} + +// isBare says a stub contributes nothing and declares no own secret (stubbed, leaveAll). +func isBare(m catalogue.Manifest) bool { + return len(m.Contributes) == 0 && len(m.ContributesMany) == 0 && len(m.OwnSecrets) == 0 +} + +// stubbed is in with module held on node as its stub alone (heldAs, nothing of it running), from the manifest node +// composes it from, leave left out of its offer: a culprit's try. +func stubbed(in compose.Input, node, module, leave string) compose.Input { + man, ok := manifestOn(in, node, module) + if !ok { + return in + } + var left map[string]bool + if leave != "" { + left = map[string]bool{leave: true} + } + return heldAs(in, node, module, man, left, nil) +} + +// heldAs is in with module held on node (compose.Node.Held): composed from man as what runs of it (compose.Carried of +// runs; its stub, compose.Stub, when runs is nil or holds nothing of it), as node's Kept; with what left names out of +// its offer: a provision's contributions, with leaveOwn its own secrets alone, or with leaveAll everything it contributes +// and its own secrets, none of a held module's own composition being applied, so what it has not been issued (restic's +// repository on a node it never ran on) is no refusal of the rest. +func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *delivery.Composed) compose.Input { + out := in + out.Nodes = append([]compose.Node(nil), in.Nodes...) + for i := range out.Nodes { + if out.Nodes[i].Name != node { + continue + } + if left[leaveAll] { + man.Contributes, man.ContributesMany, man.OwnSecrets = nil, nil, nil + } else if len(left) > 0 { + if left[leaveOwn] { + man.OwnSecrets = nil + } + contributes := map[string]map[string]any{} + for p, v := range man.Contributes { + if !left[p] { + contributes[p] = v + } + } + many := maps.Clone(man.ContributesMany) + for p := range left { + delete(many, p) + } + man.Contributes, man.ContributesMany = contributes, many + } + kept := make(map[string]catalogue.Manifest, len(out.Nodes[i].Kept)+1) + for k, v := range out.Nodes[i].Kept { + kept[k] = v + } + kept[module] = compose.Carried(man, module, runs) + out.Nodes[i].Kept = kept + held := make(map[string]bool, len(out.Nodes[i].Held)+1) + for k, v := range out.Nodes[i].Held { + held[k] = v + } + held[module] = true + out.Nodes[i].Held = held + } + return out +} diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go new file mode 100644 index 0000000..a6c9c1c --- /dev/null +++ b/internal/compose/frombus/answer_rows_test.go @@ -0,0 +1,595 @@ +package frombus + +import ( + "context" + "encoding/json" + "fmt" + "os" + "reflect" + "slices" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/overlay" +) + +// Issue 1126 (the seed's shadow, round 2): a node's answer names every module it cannot compose, not the first only; +// and a module folded into its own composition that names a build of the old form (de-spiegel on novox, nats, photos, +// systemd-resolved: their haves name a build-... id and no fingerprint) is left to the node, carried as it runs. +func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { + ctx := context.Background() + image := "alpine@sha256:" + strings.Repeat("a", 64) + manifest := func(module string) []byte { + // Its data names its directory, as nats's names ${dir:mesh-state} (issue 1126: novox's own composition refused + // "nats says ${dir:mesh-state}, and nats declares no directory mesh-state" when its stub held no directory). + return []byte(`{"module":"` + module + `","version":"1","capabilities":["container-runtime"],` + + `"own-secrets":{"token":"${dir:state}/token"},` + + `"resources":[{"id":"state","type":"directory","path":"/var/lib/` + module + `","mode":"0700"},` + + `{"id":"server","type":"container","name":"` + module + `","artifact":"site"}],` + + `"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`) + } + lemurs := []byte(`{"module":"lemurs","version":"1","resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`) + fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) } + mk := func(placed ...string) *bus { + p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} + for _, m := range placed { + p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) + } + b := &bus{placements: map[string]delivery.Placement{"n1": p}, + records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}}, + modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}}, + manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs}, + builds: map[string]*delivery.BuilderBuild{}} + for _, m := range []string{"web", "api", "spiegel"} { + b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "token", Origin: "made", SealedTo: "sk", Sealed: `"box"`}) + } + for _, m := range []string{"web", "api", "spiegel"} { + raw := manifest(m) + b.manifests[delivery.ManifestDigest(raw)] = raw + b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + } + // web and api are built, their keys done and waiting for the reference of their image; spiegel has no build of + // the new form at all. + for i, m := range []string{"web", "api"} { + mod := b.modules[m] + mod.FP2 = fp(string(rune('1' + i))) + b.modules[m] = mod + b.builds[m+" "+mod.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, + Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}} + } + return b + } + given := func(b *bus) map[string]*delivery.Placement { + p := b.placements["n1"] + return map[string]*delivery.Placement{"n1": &p} + } + + // Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named. + b := mk("lemurs", "web", "api", "spiegel") + a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b)}, + map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) + var refused []string + for _, r := range a.Refusals { + refused = append(refused, r.Module) + } + if a.Verdict != delivery.CandidateRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) { + t.Errorf("every refusal: %+v, want api, spiegel and web refused, each named", a) + } + for _, r := range a.Refusals { + want := map[string]string{"api": "waits for the reference of site", "web": "waits for the reference of site", + "spiegel": "spiegel has not been built for this mesh"}[r.Module] + if !strings.Contains(r.Why, want) { + t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want) + } + } + if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil { + t.Errorf("the answer is not one a have holds: %v (%s)", err, raw) + } + if lemursOK := slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }); !lemursOK { + t.Errorf("lemurs, which composes, is not answered beside the refusals: %+v", a) + } + // Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every + // refused module held today the verdict is no refusal, and each is named under Held. + today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}}, + map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) + if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" { + t.Errorf("held as today: %+v", today) + } + // A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused. + part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held"}}, + map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) + if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" { + t.Errorf("held as today in part: %+v", part) + } + // No module is held behind one kept today (issue 1960): mailu's own refusal stays a refusal beside de-spiegel's hold. + if a := (delivery.CandidateAnswer{Refusals: []delivery.ModuleRefused{{Module: "de-spiegel", Why: "a value is unset"}, + {Module: "mailu", Why: "no build"}}}); true { + heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"}) + if len(a.Refusals) != 1 || a.Refusals[0].Module != "mailu" || len(a.Held) != 1 { + t.Errorf("held as today spreads: %+v", a) + } + } + + // The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as + // they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself. + b = mk("lemurs", overlay.Name, "spiegel") + p := b.placements["n1"] + p.Decided.Endpoint = "n1.example:51820" + b.placements["n1"] = p + fps := map[string]string{"lemurs": fp("e")} + stub := Scope{Node: "n1"} + in, err := Input(ctx, b, Mesh{}, stub) + if err != nil { + t.Fatal(err) + } + in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])} + base, _, err := compose.Compositions(in, "n1", fps, compose.Reading) + if err != nil { + t.Fatal(err) + } + // A refusal naming no module placed here is no module's: no compose is spent stubbing each in turn (issue 1126: ace's + // answer took 7m46s, one compose per module), though here a stub would compose. + if m, _ := culprit(in, "n1", fps, compose.Foreseeing, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" { + t.Errorf("a refusal naming no placed module was laid on %s", m) + } + ownKey, _ := delivery.ComposedNodeKey("n1") + own := base[ownKey] + carriedRes := []json.RawMessage{ + json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`), + json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)} + // They run after the node's first own resource. + own.Resources = append(append(append([]json.RawMessage{}, own.Resources[:1]...), carriedRes...), own.Resources[1:]...) + var running []delivery.Composed + for k, c := range base { + if k == ownKey { + c = own + } + running = append(running, c) + } + scope := Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running} + got, _, held, err := Compose(ctx, b, Mesh{}, scope, fps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } + if err != nil { + t.Fatalf("the node does not compose itself with spiegel folded at a build of the old form: %v", err) + } + gotOwn, _ := json.Marshal(got[ownKey].Resources) + wantOwn, _ := json.Marshal(own.Resources) + if string(gotOwn) != string(wantOwn) { + t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn) + } + a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3) + if a.Verdict != delivery.CandidateSame { + t.Errorf("the fold's answer: %+v, want the same", a) + } + // (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a + // module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer). + keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes}) + if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 { + t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a) + } + // (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed + // the same, not 'in what runs, not composed' (seed #531, novox). + membership := json.RawMessage(`{"id":"bus-membership","type":"file","path":"` + catalogue.BusMembershipPath + `","sealed":"{\"box\":\"m\"}","mode":"0600"}`) + withMembership := make([]delivery.Composed, 0, len(running)) + for _, c := range running { + if c.Module == "" { + c.Resources = append(append([]json.RawMessage{}, c.Resources...), membership) + } + withMembership = append(withMembership, c) + } + if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != delivery.CandidateSame { + t.Errorf("the node's own with its bus membership is answered %+v, want the same", a) + } + // Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it. + if _, _, held, err := Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, fps); err != nil || + !strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") { + t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held) + } + + // (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter + // opens the machine port its running container publishes (de-spiegel's 20005 on novox, not its software's 8080; a + // stub had no container, so snake's 80 on ace was opened beside the route proxy's). And C6: the operator export's file + // a module that keeps it runs is carried as it runs (mesh-vault.kept on novox), since the node holds no export. + listening := []byte(`{"module":"spiegel","version":"1","capabilities":["container-runtime"],` + + `"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"token":"${dir:state}/token"},` + + `"resources":[{"id":"state","type":"directory","path":"/var/lib/spiegel","mode":"0700"},` + + `{"id":"server","type":"container","name":"spiegel","artifact":"site","ports":["8080"]}],` + + `"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`) + nft := []byte(`{"module":"nft","version":"1","capabilities":["systemd"],"filtering":{"into":"/etc/nftables.conf"}}`) + vault := []byte(`{"module":"vault","version":"1","capabilities":["systemd"],"keeps":"/var/lib/vault/root",` + + `"resources":[{"id":"service","type":"service","unit":"vault.service","state":"running","boot":"enabled"}]}`) + b = mk("lemurs", overlay.Name, "spiegel", "nft", "vault") + for m, raw := range map[string][]byte{"spiegel": listening, "nft": nft, "vault": vault} { + b.manifests[delivery.ManifestDigest(raw)] = raw + b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + } + p = b.placements["n1"] + p.Decided.Endpoint = "n1.example:51820" + for i := range p.Modules { + if p.Modules[i].Module == "spiegel" { + p.Modules[i].Ports = map[string]int{"8080": 20005} + } + } + b.placements["n1"] = p + rec := b.records["n1"] + rec.OutwardLinks = []string{"eth0"} + b.records["n1"] = rec + fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "vault": fp("9")} + if in, err = Input(ctx, b, Mesh{}, stub); err != nil { + t.Fatal(err) + } + in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])} + if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil { + t.Fatal(err) + } + keptFile := json.RawMessage(`{"digest":"sha256:` + strings.Repeat("d", 64) + `","id":"vault.kept","mode":"0600","module":"vault","path":"/var/lib/vault/root/export.json","size":143604,"type":"file"}`) + running = nil + for _, c := range base { + switch c.Module { + case "": + c.Resources = append(append([]json.RawMessage{}, c.Resources...), + json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`), + json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:`+strings.Repeat("c", 64)+`","ports":["10.77.0.1:20005:8080"]}`), + json.RawMessage(`{"id":"spiegel.server-env","type":"file","path":"/var/lib/spiegel/server.env","mode":"0600","content":"A=1\n"}`)) + case "vault": + c.Resources = append(append([]json.RawMessage{}, c.Resources...), keptFile) + } + running = append(running, c) + } + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}, fps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } + if err != nil { + t.Fatalf("the node does not compose itself with spiegel carried: %v", err) + } + var rules string + var keptComposed json.RawMessage + for _, c := range got { + for _, raw := range c.Resources { + var r struct { + ID string `json:"id"` + Content string `json:"content"` + } + _ = json.Unmarshal(raw, &r) + switch r.ID { + case "nft." + catalogue.FilteringID(): + rules = r.Content + case "vault." + catalogue.KeptID(): + keptComposed = raw + } + } + } + if !strings.Contains(rules, "# spiegel — the site") || !strings.Contains(rules, "dport 20005 accept") || strings.Contains(rules, "dport 8080") { + t.Errorf("the filter does not open the machine port spiegel runs at:\n%s", rules) + } + // The paths a carried module owns are its manifest's, as the controller composes them, never what runs of it (issue + // 1126, seed-8 #542: on ace, the folded f1-circuits, snake and txt-game, whose manifests leave their directories and + // files to ${dir:…}, owned their /var/lib paths on the node and none on the controller, shifting node-tools' + // MESH_OWNED_PATHS). f1-circuits' manifest at the indexer's commit (c0ea2c94) is captured; what runs of it is + // composed from that manifest by the controller's rule (the directory at /var/lib/f1-circuits, its file, its container). + for _, c := range running { + if c.Module == "" && !reflect.DeepEqual(compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths, []string{"/var/lib/spiegel"}) { + t.Errorf("spiegel carried owns %v, want its manifest's /var/lib/spiegel alone", compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths) + } + } + f1raw, err := os.ReadFile("testdata/f1-circuits-c0ea2c94.module.json") + if err != nil { + t.Fatal(err) + } + f1, err := catalogue.Decode(f1raw) + if err != nil { + t.Fatal(err) + } + f1runs := delivery.Composed{Node: "ace", Resources: []json.RawMessage{ + json.RawMessage(`{"id":"f1-circuits.state","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits","mode":"0700"}`), + json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`), + json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`), + json.RawMessage(`{"id":"f1-circuits.server","module":"f1-circuits","type":"container","name":"f1-circuits","image":"registry.example/f1@sha256:` + strings.Repeat("c", 64) + `"}`)}} + if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) { + t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want) + } + // Issue 1126: a held module hands no template its build kept for a file it no longer carries (novox's node: + // "postgres: its build kept a template for consumers"), but keeps its items and their templates, so what it + // contributes to other modules' collections still reaches them. + for _, row := range []struct { + name string + handed *catalogue.Handed + items []catalogue.Item + }{ + {"templates and items", &catalogue.Handed{Templates: map[string]string{"consumers": "files/consumers.json"}, + Items: map[string]string{"routes": "files/routes.tmpl"}}, []catalogue.Item{{Collection: "routes", Content: "x"}}}, + {"items alone", nil, []catalogue.Item{{Collection: "routes", Content: "x"}}}, + } { + kept := f1 + kept.Handed, kept.Items = row.handed, row.items + st := compose.Stub(kept) + if st.Handed != nil && st.Handed.Templates != nil { + t.Errorf("%s: a stub hands templates %v", row.name, st.Handed.Templates) + } + if !reflect.DeepEqual(st.Items, row.items) { + t.Errorf("%s: a stub collects %+v, want its items %+v", row.name, st.Items, row.items) + } + if row.handed != nil && (st.Handed == nil || !reflect.DeepEqual(st.Handed.Items, row.handed.Items) || row.handed.Templates == nil) { + t.Errorf("%s: a stub hands item templates %+v, want %v and its manifest untouched", row.name, st.Handed, row.handed.Items) + } + } + var k1, k2 any + _ = json.Unmarshal(keptComposed, &k1) + _ = json.Unmarshal(keptFile, &k2) + if !reflect.DeepEqual(k1, k2) { + t.Errorf("the operator export is composed as %s, want the file that runs %s", keptComposed, keptFile) + } + + // (D) Issue 1126, seed-8 (mesh-catalog #542): a module's own refusal refuses that module, never the node. novox runs + // de-spiegel folded, and its manifest at the indexer's commit (e25e1beb, captured) asks four required config values + // for its route names that nothing sets. novox answered the whole candidate refused ("de-spiegel contributing + // praktijk to route says ${setting:praktijk-name} ...": its stub still contributed the route). Only de-spiegel is + // refused, in the config form's words; the rest is judged. Issue 1960, novox's chain on 2026-10-29: the hold spread + // to mailu (its smtp provider) and proxy (its route provider), and from them on. Neither is held now: proxy composes + // without de-spiegel's routes, which do not compose, and mailu with its smtp account, which does. + spiegel, err := os.ReadFile("testdata/de-spiegel-e25e1beb.module.json") + if err != nil { + t.Fatal(err) + } + proxy := []byte(`{"module":"proxy","version":"1","capabilities":["systemd"],"provides":[{"name":"route","scope":"mesh"}],` + + `"resources":[{"id":"service","type":"service","unit":"proxy.service","state":"running","boot":"enabled"}]}`) + mailu := []byte(`{"module":"mailu","version":"1","capabilities":["systemd"],"provides":[{"name":"smtp","scope":"mesh"}],` + + `"resources":[{"id":"service","type":"service","unit":"mailu.service","state":"running","boot":"enabled"}]}`) + b = mk("lemurs", overlay.Name, "mailu", "proxy", "de-spiegel") + for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy, "mailu": mailu} { + b.manifests[delivery.ManifestDigest(raw)] = raw + b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + } + p = b.placements["n1"] + p.Decided.Endpoint = "n1.example:51820" + b.placements["n1"] = p + fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f"), "mailu": fp("d")} + a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3) + refused = nil + for _, r := range a.Refusals { + refused = append(refused, r.Module) + if r.Module == "de-spiegel" && (!strings.Contains(r.Why, "${config:praktijk-name}") || strings.Contains(r.Why, "${setting:")) { + t.Errorf("de-spiegel is refused in other words than its config form's: %s", r.Why) + } + } + if a.Refused != "" || !reflect.DeepEqual(refused, []string{"de-spiegel"}) { + t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node, mailu and proxy not", a) + } + for _, m := range []string{"lemurs", "mailu", "proxy"} { + if !slices.ContainsFunc(a.Added, func(x delivery.ModuleAdded) bool { return x.Module == m }) { + t.Errorf("%s, which composes, is not judged beside de-spiegel's refusal: %+v", m, a) + } + } + + // (E) Issue 1126, the applying path (review of the seed): the composition novox applies follows the same rule. It + // composed with no culprit, so de-spiegel's unset route names refused the whole node and every module it runs was + // marked not composed. Now de-spiegel is held, its refusal said in its config form's words, with proxy, which + // receives its routes; lemurs composes, and the node's own composition carries de-spiegel as it runs, so applying it + // removes nothing of it. + spiegelRuns := []json.RawMessage{ + json.RawMessage(`{"id":"de-spiegel.state","type":"directory","path":"/var/lib/de-spiegel","mode":"0755"}`), + json.RawMessage(`{"id":"de-spiegel.server-env","type":"file","path":"/var/lib/de-spiegel/server.env","mode":"0600","content":"A=1\n"}`), + json.RawMessage(`{"id":"de-spiegel.server","type":"container","name":"de-spiegel","image":"registry.example/de-spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)} + if in, err = Input(ctx, b, Mesh{}, stub); err != nil { + t.Fatal(err) + } + in.Nodes[0].Kept = map[string]catalogue.Manifest{"de-spiegel": compose.Stub(in.Catalogue["de-spiegel"])} + in.Nodes[0].Kept["de-spiegel"] = func(m catalogue.Manifest) catalogue.Manifest { + m.Contributes, m.ContributesMany = nil, nil + return m + }(in.Nodes[0].Kept["de-spiegel"]) + if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil { + t.Fatal(err) + } + running = nil + for _, c := range base { + if c.Module == "" { + c.Resources = append(append([]json.RawMessage{}, c.Resources...), spiegelRuns...) + } + running = append(running, c) + } + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"de-spiegel"}, Running: running}, fps) + if err != nil { + t.Fatalf("de-spiegel's own refusal refuses the node's whole composition: %v", err) + } + if len(held) != 1 || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") || !strings.Contains(held["de-spiegel"], "without what it contributes to route") { + t.Errorf("held %v, want de-spiegel alone, in its config form's words, composed without its routes", held) + } + for _, m := range []string{"lemurs", "mailu", "proxy"} { + if key, _ := delivery.ComposedKey("n1", m, fps[m]); got[key].Module != m { + t.Errorf("%s is not composed beside de-spiegel's hold: %v", m, sortedKeys(got)) + } + } + carries := func(c delivery.Composed, what string) { + t.Helper() + ownRes, _ := json.Marshal(c.Resources) + for _, raw := range spiegelRuns { + var r struct{ ID string } + _ = json.Unmarshal(raw, &r) + if !strings.Contains(string(ownRes), `"id":"`+r.ID+`"`) { + t.Errorf("%s: the node's own composition drops %s, which de-spiegel runs: %s", what, r.ID, ownRes) + } + } + } + carries(got[ownKey], "folded") + // (F) Leaving the fold (review of the apply, round 1, finding 1): de-spiegel's want names a build of the new form, + // so it is no longer folded, while its names are still unset and the node's own still runs it. Held, it is carried + // in the node's own as it runs until a composition of its own is handed: else the node's own apply removes its + // container, files and units (issue 1945 by a new path). + fps["de-spiegel"] = fp("7") + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: running}, fps) + if err != nil || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") { + t.Fatalf("de-spiegel leaving the fold: %v, held %v", err, held) + } + spiegelKey, _ := delivery.ComposedKey("n1", "de-spiegel", fps["de-spiegel"]) + if _, ok := got[spiegelKey]; ok { + t.Errorf("held de-spiegel's own composition is handed") + } + carries(got[ownKey], "leaving the fold") + + // (G) A held module that runs a composition of its own (review of the apply, round 1, finding 2): what the others + // compose of it is what runs, never its stub's. site listens on 8080, placed at the machine's 20005, and its own + // secret was never issued here: held, the filter must not open 8080 and drop the 20005 that runs. The filter, which + // reads what site listens on, composes with it (issue 1960: a hold never spreads). + site := []byte(`{"module":"site","version":"1","capabilities":["container-runtime"],` + + `"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"key":"${dir:state}/key"},` + + `"resources":[{"id":"state","type":"directory","path":"/var/lib/site","mode":"0700"},` + + `{"id":"server","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}]}`) + b = mk("lemurs", overlay.Name, "site", "nft") + for m, raw := range map[string][]byte{"site": site, "nft": nft} { + b.manifests[delivery.ManifestDigest(raw)] = raw + b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + } + p = b.placements["n1"] + p.Decided.Endpoint = "n1.example:51820" + for i := range p.Modules { + if p.Modules[i].Module == "site" { + p.Modules[i].Ports = map[string]int{"8080": 20005} + } + } + b.placements["n1"] = p + rec = b.records["n1"] + rec.OutwardLinks = []string{"eth0"} + b.records["n1"] = rec + fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "site": fp("8")} + siteRuns := delivery.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{ + json.RawMessage(`{"id":"site.state","module":"site","type":"directory","path":"/var/lib/site","mode":"0700"}`), + json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["10.77.0.1:20005:8080"]}`)}} + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps) + nftKey, _ := delivery.ComposedKey("n1", "nft", fps["nft"]) + if err != nil || held["site"] == "" || held["nft"] != "" || got[nftKey].Module != "nft" { + t.Fatalf("site, its own secret never issued: %v, held %v; want site held alone, and the filter composed", err, held) + } + if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 20005") { + t.Errorf("the filter drops held site's opening where it runs (20005): %s", rules) + } + for key, c := range got { + for _, raw := range c.Resources { + if strings.Contains(string(raw), "dport 8080") || (c.Module == "nft" && !strings.Contains(string(raw), "dport 20005") && strings.Contains(string(raw), "# site")) { + t.Errorf("%s composes held site from its stub, not from what runs: %s", key, raw) + } + } + } + // (G2) Issue 1126, the spreading hold: a held module is no dependence of its neighbours. site, held, provides page, and + // app requires and binds it: app composes against what runs of site and is not held (novox's seed held every postgres + // consumer behind de-spiegel's hold, "what it composes of …, held, is not what runs of them"). + provider := []byte(strings.Replace(string(site), `"listens":`, `"provides":[{"name":"page","scope":"node"}],"serves":{"page":{"port":8080}},"listens":`, 1)) + app := []byte(`{"module":"app","version":"1","capabilities":["systemd"],"requires":["page"],"binds":{"page":"/etc/app/page.json"},` + + `"resources":[{"id":"service","type":"service","unit":"app.service","state":"running","boot":"enabled"}]}`) + b2 := mk("lemurs", overlay.Name, "site", "nft", "app") + for m, raw := range map[string][]byte{"site": provider, "nft": nft, "app": app} { + b2.manifests[delivery.ManifestDigest(raw)] = raw + b2.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + } + p2 := b2.placements["n1"] + p2.Decided.Endpoint = "n1.example:51820" + for i := range p2.Modules { + if p2.Modules[i].Module == "site" { + p2.Modules[i].Ports = map[string]int{"8080": 20005} + } + } + b2.placements["n1"] = p2 + b2.records["n1"] = rec + b2.held = true + fps["app"] = fp("9") + got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps) + appKey, _ := delivery.ComposedKey("n1", "app", fps["app"]) + if _, composed := got[appKey]; err != nil || held["site"] == "" || held["app"] != "" || !composed { + t.Errorf("a consumer of held site: %v, held %v; want site held, and app composed against what runs of it", err, held) + } + if bound, _ := json.Marshal(got[appKey].Resources); !strings.Contains(string(bound), "20005") { + t.Errorf("app's binding does not reach held site where it runs (20005): %s", bound) + } + delete(fps, "app") + // A held module's offer is left out by provision, never whole when one part composes (issue 1960): de-spiegel's + // routes, its names unset, leave the proxy without them, and its smtp account stays with mailu. + withProxy := compose.Input{Catalogue: map[string]catalogue.Manifest{}, + Nodes: []compose.Node{{Name: "n1", Assigned: []string{"feeder"}}}} + feeder := catalogue.Manifest{Module: "feeder", Contributes: map[string]map[string]any{"route": {"name": "x"}, "smtp": {"account": "feeder"}}, + OwnSecrets: catalogue.OwnSecrets{"key": {Path: "/k"}}} + for _, row := range []struct { + left map[string]bool + route, smtp, own bool + }{{nil, true, true, true}, {map[string]bool{"route": true}, false, true, true}, {map[string]bool{leaveOwn: true}, true, true, false}, {map[string]bool{leaveAll: true}, false, false, false}} { + k := heldAs(withProxy, "n1", "feeder", feeder, row.left, nil).Nodes[0].Kept["feeder"] + if _, r := k.Contributes["route"]; r != row.route || (k.Contributes["smtp"] != nil) != row.smtp || (len(k.OwnSecrets) > 0) != row.own { + t.Errorf("held leaving out %v offers %+v, own secrets %v", row.left, k.Contributes, k.OwnSecrets) + } + } + // (H) Review of the apply, round 2: a held module is composed from its new manifest, so what the others read of its + // manifest (its listens, what it contributes) may not be what runs. site's new manifest moves it to 9090 while the + // build that runs (7) listens on 8080. The filter composes with what site last offered, its manifest at the build + // that runs (issue 1960: a hold never spreads), so it opens 8080 and nothing applied opens 9090. + moved := []byte(strings.Replace(string(site), `"port":8080`, `"port":9090`, 1)) + b.manifests[delivery.ManifestDigest(moved)] = moved + b.modules["site"] = delivery.Module{Manifest: delivery.ManifestDigest(moved)} + for i := range p.Modules { + p.Modules[i].Ports = nil + } + b.placements["n1"] = p + siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`) + siteRuns.FP = fp("7") + b.manifests[delivery.ManifestDigest(site)] = site + b.builds["site "+fp("7")] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: delivery.ManifestDigest(site)} + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps) + if err != nil || held["site"] == "" || held["nft"] != "" { + t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held alone", err, held) + } + if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 8080") { + t.Errorf("the filter drops what held site last offered (8080): %s", rules) + } + for key, c := range got { + for _, raw := range c.Resources { + if strings.Contains(string(raw), "dport 9090") { + t.Errorf("%s opens 9090, which held site's new manifest names and nothing runs: %s", key, raw) + } + } + } +} + +// A module another node cannot run in this composition offers nothing in it: the refusal names it and why +// (issue 1126, probe #532: ace and g14 answered "nothing in this mesh provides artifact-store … assign distribution to a +// node" while novox runs it). And a refusal no module's stub changes is answered at once, not after a compose per module +// (ace took 7m46s). +func otherNodeNotResolvedIsNamed(t *testing.T) { + ctx := context.Background() + prov := []byte(`{"module":"prov","version":"1","capabilities":["container-runtime"],"provides":[{"name":"thing","scope":"mesh"}]}`) + cons := []byte(`{"module":"cons","version":"1","requires":["thing"]}`) + // n2 runs prov, which needs a container runtime n2's record does not say it has: n2's set does not resolve here. + b := &bus{placements: map[string]delivery.Placement{ + "n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}}, + "n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}}, + records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}}, + modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}}, + manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}} + given := map[string]*delivery.Placement{} + for n, p := range b.placements { + p := p + given[n] = &p + } + a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1) + if a.Verdict != delivery.CandidateRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") { + t.Errorf("a refusal for want of n2's provider = %+v", a) + } +} + +// A node that reaches no artifact store says what its composition holds of one (issue 1126: ace and g14 answered +// "reaches no artifact store", and nothing said why). +func noStoreReachedSaysWhy(t *testing.T) { + in := compose.Input{Catalogue: map[string]catalogue.Manifest{ + "distribution": {Module: "distribution", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}}, + Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita"}}, {Name: "novox", Assigned: []string{"distribution"}}}} + if got := storeSought(in); got != "the composition holds g14, novox; artifact-store is served by distribution on novox, none of it on the private network with a port" { + t.Errorf("said %q", got) + } + in.Nodes = in.Nodes[:1] + if got := storeSought(in); got != "the composition holds g14; no module placed there serves artifact-store" { + t.Errorf("said %q", got) + } +} diff --git a/internal/compose/frombus/export.go b/internal/compose/frombus/export.go new file mode 100644 index 0000000..8726c0f --- /dev/null +++ b/internal/compose/frombus/export.go @@ -0,0 +1,112 @@ +package frombus + +import ( + "fmt" + "sort" + "strconv" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" +) + +// Export is one node's placement as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567 +// §7): the inverse of Input for what a person decides, so the controller's one read-only verb `placement-export` +// answers, from its own store by its own code, exactly what the seat's adopt proposes as the seed. Its answer must stay +// stable for the same store, since adopt checks its digest again at the warrant. +// What the machine observes (its record) is not in it. +func Export(in compose.Input, node string) (delivery.Placement, error) { + var n *compose.Node + for i := range in.Nodes { + if in.Nodes[i].Name == node { + n = &in.Nodes[i] + } + } + if n == nil { + return delivery.Placement{}, fmt.Errorf("frombus: the input has no node %q", node) + } + // At the version a placement key is read at, with a list of modules even when none is placed: the export is the + // seed's value, and the reader refuses a value of version 0 or without its modules. + p := delivery.Placement{V: delivery.Version, Node: node, Modules: []delivery.PlacedModule{}, Decided: delivery.NodeDecided{ + Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address, + PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount, + AgentHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}} + // The tunnel found at the node's adoption: the one its private network took over, or, on the hub, the mesh's adopted + // one whether or not the hub took it; and the names a person gave the hub's carried peers. + if t, ok := in.Tunnels[node]; ok { + rt := recordTunnelOf(t.Tunnel) + p.Decided.Tunnel = &rt + } else if in.Adopted != nil && in.Adopted.Hub == node { + rt := recordTunnelOf(in.Adopted.Tunnel) + p.Decided.Tunnel = &rt + } + if n.Place.Hub { + for _, c := range in.CarriedPeers { + if c.Named != "" { + if p.Decided.PeerNames == nil { + p.Decided.PeerNames = map[string]string{} + } + p.Decided.PeerNames[c.PublicKey] = c.Named + } + } + } + for _, module := range n.Assigned { + m := delivery.PlacedModule{Module: module} + for port, at := range n.Ports[module] { + if m.Ports == nil { + m.Ports = map[string]int{} + } + m.Ports[strconv.Itoa(port)] = at + } + for provision, to := range n.Bound[module] { + if m.Bindings == nil { + m.Bindings = map[string]delivery.Provider{} + } + m.Bindings[provision] = delivery.Provider{Node: to.Node, Module: to.Module} + } + // A module in the settings form keeps its config values in its layers, not in mesh-config: they are exported with + // the placement. A module in the config form has mesh-config's values laid into its layer, which are not. + settingsForm := !in.Catalogue[module].DeclaresConfig() + for _, layer := range n.Settings[module] { + if layer.From == catalogue.MeshWideLayer { + m.HasMesh, m.MeshWide = true, layer.Placement + if settingsForm && len(layer.Values) > 0 { + m.MeshValues = layer.Values + } + } else { + m.HasLayer, m.Layer = true, layer.Placement + if settingsForm && len(layer.Values) > 0 { + m.Values = layer.Values + } + } + } + p.Modules = append(p.Modules, m) + } + for provision, to := range n.Pins { + if p.Pins == nil { + p.Pins = map[string]delivery.Provider{} + } + p.Pins[provision] = delivery.Provider{Node: to.Node, Module: to.Module} + } + for _, h := range in.Holdings { + if h.Node == node { + p.Holds = append(p.Holds, delivery.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind}) + } + } + sort.SliceStable(p.Holds, func(i, j int) bool { + if p.Holds[i].Claim != p.Holds[j].Claim { + return p.Holds[i].Claim < p.Holds[j].Claim + } + return p.Holds[i].Module < p.Holds[j].Module + }) + return p, nil +} + +func recordTunnelOf(t compose.Tunnel) delivery.RecordTunnel { + out := delivery.RecordTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU, + Address: t.Address, Range: t.Range, PublicKey: t.PublicKey} + for _, peer := range t.Peers { + out.Peers = append(out.Peers, delivery.RecordTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address}) + } + return out +} diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go new file mode 100644 index 0000000..c5795c2 --- /dev/null +++ b/internal/compose/frombus/frombus.go @@ -0,0 +1,1167 @@ +// Package frombus fills mesh-sdk's composition input from what is on the bus, so a node agent, the composition check +// and the grants part compose with one composer over the same inputs (novox/hq ADR 0547 §2, ADR 0549 §3; issue 1126). +// +// **One composer, three callers.** compose.Compositions is the composer; Input is how each caller fills its +// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the seat mesh-placement's holder judging a +// proposal for every node (ADR 0567 §4), and the grants part (ADR 0547 §5). Each reads the bus with its own credential +// through a Reader, so this package holds no bus client, no store and no timer: what it is given is all it reads. +// +// **What it reads, and from whom.** +// - which modules run on each node, with their pins, holdings, host ports, bindings and settings placement: the +// seat mesh-placement's state `placement`, one key per node (ADR 0567 §1); +// - what a person decided of each node (its endpoint, site, hub, address on the private network, public domain and +// accounts): the same placement key, Placement.Decided; +// - what each node observes of itself (its tunnel key, capabilities, keys, certificate, links and networks): the +// node's own have key, `.node` (HaveNode.Record); the tunnel found at a node's adoption and the names of the hub's peers are decided, +// in its placement, and whether the hub took the tunnel over and who enrolled under a peer's key are read from both; +// - every module's manifest: the indexer's `modules.` and its manifests by digest; for each module placed on +// the composing node, its built manifest instead: the builder's done key at the trunk's FP2, or at the fingerprint +// the node's want names where it runs another build (Scope.KeptAt), resolved by the controller's own take-in rule +// (takein.Manifest), a moved module's templates archive read in the artifact store (issue 1126); +// - each module's config values on a node: mesh-config's resolved state, the composing node's slice alone unless the +// caller composes every node (ADR 0547 §2, the prepare step's L4); of another node, only the keys its modules' +// served values fill (catalogue.ServedSettings), which every consumer is told (issue 1126). +// +// **Own secrets and pair credentials** come from the vault sealed to the composing node's key (OwnSecrets, Credentials), +// for that node alone: as a consumer and as a provider, exactly the keys compose.CredentialsNeeded names. **Not yet +// read** (each a later subtask of issue 1126): licences (none is placed today) and core boxes. Without one +// the composition stands in for it, and Compose holds the module it stands in for (or refuses the node, for a stand-in +// of no module placed there; compose.ErrStoodIn): a node never applies a stand-in. No node is joining before ADR 0547's third milestone (ADR 0550 §6), so Joining is +// always empty. +package frombus + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "net/netip" + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/declaration" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/overlay" + "github.com/novox/mesh-host/internal/takein" +) + +// Reader is the bus as a caller reads it with its own credential. Each method reads one part's state; an error is a +// read that failed, never an absent key (an absent key is an empty answer). +type Reader interface { + // Placements is every node's placement, by node (delivery.PlacementBucket). + Placements(ctx context.Context) (map[string]delivery.Placement, error) + // Records is every node's record, by node: HaveNode.Record of each node's own have key, `.node` of + // delivery.HaveBucket (ADR 0555 §3). + Records(ctx context.Context) (map[string]delivery.NodeRecord, error) + // Modules is every module the indexer holds, by module (delivery.ModulesBucket). + Modules(ctx context.Context) (map[string]delivery.Module, error) + // Manifest is the raw manifest of a digest (delivery.ManifestsBucket). + Manifest(ctx context.Context, digest string) ([]byte, error) + // Values is one module's config values on one node as mesh-config resolved them (mesh-config's state `resolved`, + // keys `..`), decoded; nil when it has none. + Values(ctx context.Context, node, module string) (map[string]any, error) + // OwnSecrets is the own secrets of the composing node's modules, each sealed to that node's key, as the vault answers + // the node's engine (the vault's verb `own`, ADR 0550 §5); asked only for the node composing itself. + OwnSecrets(ctx context.Context, node string) ([]compose.OwnSecretRow, error) + // Credentials is, for the composing node, each pair credential it reads as a consumer, held and sealed to its key, + // and each it grants as a provider, with the copy sealed to it (the vault's `own` with the keys, ADR 0550 §5). A key + // the vault holds no value for is left out; the composition then stands in for it, and Compose refuses it. + Credentials(ctx context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) + // Build is a module's build at a fingerprint, the builder's key (delivery.BuilderBuildsBucket, BuilderBuildKey); nil + // when the builder holds none. The composing node composes each of its modules from its build (issue 1126). + Build(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) + // Archive is the archive at url, a kept reference routed to the artifact store as the composing node reaches it + // (catalogue.Routed), checked against digest: a moved module's templates archive, read back for its texts as the + // controller's take-in reads it (takein.Built). + Archive(ctx context.Context, url, digest string) ([]byte, error) + // Served is, of one module's config values on another node than the composing one (mesh-config's state `resolved`, + // keys `..`), the keys named, decoded: the keys its served values and zone name fill + // (catalogue.ServedSettings), which every consumer is told and so every node may read (issue 1126). Nil when none + // is set. + Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) +} + +// SignersReader is a Reader that also reads the vault's public authority from its state `signers` (delivery.SignersBucket, +// key delivery.SignersAuthorityKey; ADR 0550 §2). A Reader that has it composes the mesh's authority as a bundle, the +// controller's and the vault's; one that has not, or that answers none, composes the controller's alone, as before. +type SignersReader interface { + // VaultAuthority is the vault's authority certificate as PEM, checked (delivery.DecodeSignerAuthority); empty when the + // vault has published none. A read that fails is said by the reader and answered empty: a node that may not read the + // state yet composes as before rather than not at all. + VaultAuthority(ctx context.Context) string +} + +// HandedReader is a Reader that also says, of the composing node's modules, which the vault judged handed to the +// provisioner when it answered OwnSecrets (the vault's `own`, its `handed`: catalogue.AssignmentHands of the plan in +// each module's assignment). The node's composition then hands exactly those (compose.Node.Handed, issue 774), so the +// vault and the node never judge one module two ways. Nil, or a Reader without it (a vault older than the field), +// composes by the manifest and the machine, as before. +type HandedReader interface { + Handed(ctx context.Context, node string) map[string]bool +} + +// Mesh is what is true of the whole mesh and is no part's state on the bus yet: the private network's range and the +// internal names' suffix as genesis was told them, and the mesh's authority certificate (public). Empty fields take +// compose's defaults. +type Mesh struct { + OverlayCIDR string + InternalSuffix string + Authority string + // Predefined is the list of predefined keys mesh-config published. No longer read: a reader decodes a manifest and + // takes the indexer's verdict on it, and judges nothing (novox/hq ADR 0578 §3); kept so a caller that sets it builds. + Predefined catalogue.PredefinedList +} + +// Scope says who composes. Node is the node composing; only its config values are read unless Every is set, as the +// composition check sets it to compose every node (of another node's modules, only the values their served values fill +// are read, Reader.Served). KeptAt is, by module, the fingerprint the composing node's want names where it differs from +// the indexer's trunk: that node composes the module from its build at that fingerprint, as it runs it. +// +// **Composed from its build** (issue 1126): each module placed on the composing node, and each in KeptAt, composes from +// its built manifest (takein.Manifest over the builder's done key at the trunk's FP2 or the kept fingerprint), as the +// controller composes from the manifest its take-in resolved: so a moved module is handed to the provisioner, a resource +// naming an artifact names what was built, and a templated file has its text. A placed module the builder holds no done +// build of at the trunk's FP2 composes from the indexer's manifest, as before. Every leaves the catalogue as the indexer +// read it: the check of every node judges placement, and reads no archive. +// +// Placements, when set, stands in for the placement of the nodes it names: a proposal's candidates +// (delivery.PlacementCandidate), so a node composes in shadow with a proposal laid over and compares the result with +// what it runs (ADR 0567 §3); a nil value removes the node's placement. The rest is read as always. +// +// InOwn is the modules the composing node runs inside its own composition (InOwn of what it runs), composed there again +// (compose.Node.InOwn) rather than cut out as modules no build is given for (issue 1126). +// +// Running is what the composing node runs; of it, its own composition (`.node`) is given to the composition +// (compose.Node.RunsOwn), so a module folded there that names a build of the old form is left to the node, carried +// as it runs (issue 1126). Nil composes every folded module from its manifest. +type Scope struct { + Node string + Every bool + KeptAt map[string]string + Placements map[string]*delivery.Placement + InOwn []string + Running []delivery.Composed + // HeldToday is, in an answer, the modules the node keeps today as they run ("node" for its own composition), each with + // how ("held", "failed", "runs a build of the old form"): a candidate that cannot compose one, or holds one only + // behind one of them, keeps it as today, which is said under CandidateAnswer.Held and is no refusal (issue 1126). + HeldToday map[string]string + // SameValue, in an answer, says whether two sealed values open to the same value (the node opens them with its own + // key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them + // as differing. + SameValue func(a, b string) (same, known bool) +} + +// InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs +// (`.node` in running) carries, by the module their ids name (declaration.ModuleGuess), sorted: the modules the +// controller's composed writer folded there because no fingerprint of the new form names them (mesh-wireguard, a module +// run at a build of the old form; issue 1126). A node composing itself passes it as Scope.InOwn, so its own +// composition keeps them as it runs them until a want names a build of the new form for one. +func InOwn(running []delivery.Composed, p delivery.Placement) []string { + placed := map[string]bool{} + for _, m := range p.Modules { + placed[m.Module] = true + } + in := map[string]bool{} + for _, c := range running { + if c.Module != "" { + continue + } + for _, raw := range c.Resources { + var head struct { + ID string `json:"id"` + Module string `json:"module"` + } + if json.Unmarshal(raw, &head) != nil || head.Module != "" { + continue + } + if m, ok := declaration.ModuleGuess(head.ID); ok && placed[m] { + in[m] = true + } + } + } + return sortedKeys(in) +} + +// Input is the composition input for s, read through r. +// +// **A refused manifest never ends the read** (novox/hq ADR 0578 §3): each manifest is decoded leniently +// (catalogue.Decode) and the indexer's verdict on it taken from its modules value (delivery.TakeManifestVerdict, a missing +// one accepted); a module whose manifest the indexer's judge refused, or whose bytes do not decode, stands in the +// catalogue as its stub (unusable), so what it provides and requires still shapes the rest, and is named with why in +// the input's Refused; a field set aside is named in its SetAside. Every other module of the read goes on. +func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, error) { + return input(ctx, r, mesh, s, nil, nil) +} + +// taken is a module's manifest as a reader takes it (novox/hq ADR 0578 §3): decoded leniently, with the indexer's verdict +// from its modules value. A refused one, or bytes that do not decode, stands as its stub (unusable) and why says so; a +// manifest not read past a field it set aside holds its name alone and is held the same. +func taken(name string, raw []byte, m delivery.Module) (man catalogue.Manifest, why string) { + if v := m.VerdictOf(name); v != nil { + if refused, _ := delivery.TakeManifestVerdict(v); refused { + return unusable(name, raw), v.Why() + } + } + man, err := catalogue.Decode(raw) + switch { + case err != nil: + return unusable(name, raw), fmt.Sprintf("the manifest of %s does not decode: %v", name, err) + case man.ReadNoFurther(): + return unusable(name, raw), fmt.Sprintf("the manifest of %s could not be read past a field this reader does not "+ + "know: %s", name, man.SetAside()) + } + return man, "" +} + +// input is Input; with refuse set, a module placed on the composing node whose build cannot be read or taken in, or whose +// manifest the indexer refused, is also handed to refuse and composed as a stub (compose.Stub, kept for that node alone), +// so a node's answer names every module it cannot compose (issue 1126, issue 774); a module placed elsewhere whose +// manifest is refused is read for its offer alone (unusable). A manifest the bus does not answer for still ends the +// read: that is the bus's, not the module's. +func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) { + b, err := readBase(ctx, r, mesh, s) + b.refuseOwn(refuse) + if err != nil { + return compose.Input{}, err + } + return b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted) +} + +// base is the part of a node's input that the fingerprints it composes its modules at leave alone (issue 1954): read once +// for its plans at the wanted builds and at the newest, whose builds alone differ (PlansHeldTwice). +type base struct { + in compose.Input + placements map[string]delivery.Placement + records map[string]delivery.NodeRecord + modules map[string]delivery.Module + // ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order + // read: every scope holds it. + ownRefused [][2]string +} + +// refuseOwn hands refuse each module placed on the composing node whose manifest is refused. +func (b base) refuseOwn(refuse func(module string, err error)) { + if refuse == nil { + return + } + for _, r := range b.ownRefused { + refuse(r[0], errors.New(r[1])) + } +} + +// cloned is the input with its own catalogue, nodes and kept manifests, what finishing it sets: two scopes finished over +// one base never see each other's builds. +func (b base) cloned() compose.Input { + in := b.in + in.Catalogue = maps.Clone(b.in.Catalogue) + in.Nodes = slices.Clone(b.in.Nodes) + for i := range in.Nodes { + in.Nodes[i].Kept = maps.Clone(in.Nodes[i].Kept) + } + in.KeptCoreBoxes = slices.Clone(b.in.KeptCoreBoxes) + in.OwnSecrets = slices.Clone(b.in.OwnSecrets) + return in +} + +// readBase is input up to the builds: placement, records, the indexer's modules and manifests, each node's values. +func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) { + var b base + placements, err := r.Placements(ctx) + if err != nil { + return b, fmt.Errorf("frombus: placement: %w", err) + } + if len(s.Placements) > 0 { + given := make(map[string]delivery.Placement, len(placements)+len(s.Placements)) + for node, p := range placements { + given[node] = p + } + for node, p := range s.Placements { + if p == nil { + delete(given, node) + continue + } + if p.Node != node { + return b, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node) + } + given[node] = *p + } + placements = given + } + records, err := r.Records(ctx) + if err != nil { + return b, fmt.Errorf("frombus: node records: %w", err) + } + modules, err := r.Modules(ctx) + if err != nil { + return b, fmt.Errorf("frombus: the indexer's modules: %w", err) + } + if _, ok := placements[s.Node]; !ok && !s.Every { + return b, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node) + } + + in := compose.Input{OverlayCIDR: mesh.OverlayCIDR, InternalSuffix: mesh.InternalSuffix, Authority: mesh.Authority, + Catalogue: map[string]catalogue.Manifest{}} + if in.Authority == "" { + // The mesh's own certificate, public, as the composing node holds it; for the check of every node, as the first + // node holding one does. + if rec, ok := records[s.Node]; ok && rec.Authority != "" { + in.Authority = rec.Authority + } else { + for _, node := range sortedKeys(records) { + if a := records[node].Authority; a != "" { + in.Authority = a + break + } + } + } + } + // And the vault's authority beside it, once the vault publishes one (ADR 0550 §2): a certificate either signed checks + // against the bundle. The controller's stays while a certificate it issued is in a node's record. + if sr, ok := r.(SignersReader); ok { + if vault := sr.VaultAuthority(ctx); vault != "" { + in.Authority = delivery.AuthorityBundle(in.Authority, vault) + } + } + named := func(field *map[string]string, module, why string) { + if *field == nil { + *field = map[string]string{} + } + (*field)[module] = why + } + ownPlaced := map[string]bool{} + for _, pm := range placements[s.Node].Modules { + ownPlaced[pm.Module] = true + } + for _, name := range sortedKeys(modules) { + m := modules[name] + if m.Manifest == "" { + continue // a registered module the indexer has not read: nothing to compose it from + } + raw, err := r.Manifest(ctx, m.Manifest) + if err != nil { + // Not read is no refusal of the module: a bus that does not answer is waited out by the caller. + return b, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err) + } + // One module's manifest the indexer refused refuses that module alone, by the rule a build that cannot be read + // follows (issue 774: power's bar item refused every node's composition, 2026-10-30; ADR 0578 §3: one refused + // manifest silenced disk-load for 45 hours). It stands in as its stub, so what it provides and requires still + // shapes the rest; it is held on the composing node where it is placed there, and elsewhere only its offer is read. + man, why := taken(name, raw, m) + if why != "" { + named(&in.Refused, name, why) + if ownPlaced[name] { + b.ownRefused = append(b.ownRefused, [2]string{name, why}) + } + } else if aside := man.SetAside(); aside != "" { + named(&in.SetAside, name, aside) + } + in.Catalogue[name] = man + } + + // The module the mesh provides itself (the private network) has no repository the indexer reads: its manifest comes + // with mesh-sdk and always wins, as the controller's providedModules and the grants part have it. + for _, name := range delivery.ProvidedModules() { + raw, err := json.Marshal(overlay.Manifest()) + if err != nil { + return b, err + } + var private catalogue.Manifest + if err := json.Unmarshal(raw, &private); err != nil { + return b, fmt.Errorf("frombus: the manifest mesh-sdk provides for %s: %w", name, err) + } + in.Catalogue[name] = private + } + + var missing []string + for _, node := range nodesOf(placements, records) { + rec, hasRecord := records[node] + p := placements[node] + if !hasRecord { + if len(p.Modules) > 0 { + missing = append(missing, node) + } + continue + } + n := nodeOf(rec, p) + withValues := s.Every || node == s.Node + for _, m := range p.Modules { + var values map[string]any + if withValues { + if values, err = r.Values(ctx, node, m.Module); err != nil { + return b, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err) + } + } else if keys := catalogue.ServedSettings(in.Catalogue[m.Module]); len(keys) > 0 { + // What a provider elsewhere serves from its settings is told to every consumer: the composing node reads + // those keys of it, and no other (issue 1126: mailu's smtp domain, a consumer's on shanks). + if values, err = r.Served(ctx, node, m.Module, keys); err != nil { + return b, fmt.Errorf("frombus: what %s serves on %s (%s): %w", m.Module, node, + strings.Join(keys, ", "), err) + } + } + if layers := m.Layers(node, values); layers != nil { + if n.Settings == nil { + n.Settings = map[string][]catalogue.Layer{} + } + n.Settings[m.Module] = layers + } + } + if node == s.Node { + // What its runtime serves as it runs: what a module composed as what runs of it contributes there (issue 1126). + n.RunsTools = compose.ToolsRunning(s.Running) + for _, module := range s.InOwn { + if n.InOwn == nil { + n.InOwn = map[string]bool{} + } + n.InOwn[module] = true + } + for i := range s.Running { + // The operator export as the vault runs it: the controller composes it from its store, which the node + // does not read; until the export is on the bus, the node carries the file it runs (issue 1126, C6: + // mesh-vault.kept on novox, "in what runs, and not composed"). + if m := s.Running[i].Module; m != "" { + if kept := keptOf(s.Running[i]); kept != nil { + if in.KeptRuns == nil { + in.KeptRuns = map[string]map[string]map[string]any{} + } + if in.KeptRuns[s.Node] == nil { + in.KeptRuns[s.Node] = map[string]map[string]any{} + } + in.KeptRuns[s.Node][m] = kept + } + continue + } + if s.Running[i].Module == "" { + own := s.Running[i] + n.RunsOwn = &own + // Its bus membership as it runs it: the controller composes it from its store, which the node does + // not read; until the grants part hands memberships sealed to the node, the node's own composition + // carries the one it runs (issue 1126, seed #531 on novox: "bus-membership in what runs, not + // composed", which applied would have taken the node's bus credential away). + if sealed := membershipOf(own); sealed != "" { + if in.BusMemberships == nil { + in.BusMemberships = map[string]string{} + } + in.BusMemberships[s.Node] = sealed + } + } + } + } + in.Nodes = append(in.Nodes, n) + in.Holdings = append(in.Holdings, p.Held()...) + // A tunnel found at adoption is the node's private network's when the node's tunnel key is the tunnel's (the + // controller's inv.Tunnels); the hub's found tunnel is the mesh's adopted one whether or not the hub took it over + // (inv.AdoptedTunnel), and its peers are carried once it did (inv.CarriedPeers). + if t := p.Decided.Tunnel; t != nil { + if rec.TunnelKey == t.PublicKey { + if in.Tunnels == nil { + in.Tunnels = map[string]compose.FoundTunnel{} + } + in.Tunnels[node] = compose.FoundTunnel{Tunnel: tunnelOf(*t), NodeAdopted: p.Decided.Adopted} + } + if p.Decided.Hub { + in.Adopted = &compose.AdoptedTunnel{Tunnel: tunnelOf(*t), Hub: node, Adopted: rec.TunnelKey == t.PublicKey} + } + } + } + if in.Adopted != nil && in.Adopted.Adopted { + in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, placements[in.Adopted.Hub].Decided.PeerNames, records) + } + if len(missing) > 0 { + return b, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet", + strings.Join(missing, ", ")) + } + b.in, b.placements, b.records, b.modules = in, placements, records, modules + return b, nil +} + +// finish is input from its base on: the composing node's modules from their builds (build reads one), its own secrets. +// in is the base's input, or a copy of it (base.cloned) where one base is finished more than once. +func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error), + mesh Mesh, s Scope, in compose.Input, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) { + var err error + parse := parser(mesh) + read := func(module, digest string) (catalogue.Manifest, error) { + raw, err := r.Manifest(ctx, digest) + if err != nil { + return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err) + } + man, err := parse(raw) + if err != nil { + return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s) does not decode: %w", module, digest, err) + } + return man, nil + } + if !s.Every { + if _, ok := b.records[s.Node]; !ok { + return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node) + } + if err := fromBuilds(ctx, r, build, &in, s, b.placements[s.Node], b.modules, read, parse, refuse, wanted); err != nil { + return compose.Input{}, err + } + // Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5). + // Asked only when a module placed on it declares one: a node none of whose modules has a secret composes + // whether or not the vault answers. + if declaresOwnSecrets(in, s.Node) { + if in.OwnSecrets, err = r.OwnSecrets(ctx, s.Node); err != nil { + return compose.Input{}, fmt.Errorf("frombus: the own secrets of %s's modules: %w", s.Node, err) + } + // And which of its modules the vault left out of that answer as handed: the node hands exactly those. + if hr, ok := r.(HandedReader); ok { + if handed := hr.Handed(ctx, s.Node); handed != nil { + for i := range in.Nodes { + if in.Nodes[i].Name == s.Node { + in.Nodes[i].Handed = handed + } + } + } + } + } + // The core's credentials travel as the vault's box sealed to the machine (ADR 0349 §16): the same values, which + // the composition reads for a core module as its kept boxes. + for _, o := range in.OwnSecrets { + if json.Valid([]byte(o.Sealed)) { + in.KeptCoreBoxes = append(in.KeptCoreBoxes, compose.CoreBox{Node: o.Node, Module: o.Module, Name: o.Name, + SealedTo: o.SealedTo, Box: json.RawMessage(o.Sealed)}) + } + } + } + return in, nil +} + +// Compose is the node's compositions over the bus (ADR 0547 §2): input for s, then composed at the fingerprints fps +// gives each module in the Reading mode, as the controller composes them, by the one rule its answer to a candidate +// follows too (composeEach, issue 1126): a module refused alone (its build unread or not taken in, its composition +// refused, a stand-in for a value of its own the input does not carry, compose.ErrStoodIn) is held, named in held with +// why, and none of its own composition is in out, so the node keeps what it last applied of it and applies the rest; a +// node never applies a stand-in. Only a refusal that is no single module's (the node's own resources, a value served +// elsewhere, a stand-in for the node's own) refuses the node, err. +func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]delivery.Composed, skipped []string, held map[string]string, err error) { + held = map[string]string{} + // A node refused whole says what it held on the way, since the refusal can be of a held module's stub (issue 774, + // 2026-10-30 19:30: node-tools held, then "its build produced 0 bundle(s)" with its own refusal unsaid). + defer func() { + if err != nil && len(held) > 0 { + var said []string + for _, m := range sortedKeys(held) { + said = append(said, m+": "+held[m]) + } + err = fmt.Errorf("%w (held before: %s)", err, strings.Join(said, "; ")) + } + }() + in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, fps) + if err != nil { + return nil, nil, held, err + } + // The pair credentials its composition reads, asked of the vault sealed to it (ADR 0550 §5). + asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node) + if err != nil { + return nil, nil, held, err + } + if len(asConsumer)+len(asProvider) > 0 { + if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil { + return nil, nil, held, fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err) + } + } + out, skipped, _, err = composeEach(in, s.Node, fps, true, s.Running, held, ranOf(ctx, r, mesh, s.Running, fps)) + return out, skipped, held, err +} + +// Plans is the provisioning plan of each module handed to the provisioner on the scope's node, composed from the bus +// alone (novox/hq ADR 0570 clause 2): the controller's handedPlans over the same input (mesh-control +// cmd/mesh-controller/compose_sdk.go), its rendering in the Foreseeing mode with the mesh's generators. A plan names +// templates, files, own secrets by name, items, collections and provisions, never a value, so no value is read for it: +// the seat mesh-provisioner's holder composes every node's plans with a Reader that reads no secret. +func Plans(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string]catalogue.Plan, error) { + in, err := Input(ctx, r, mesh, s) + if err != nil { + return nil, err + } + return PlansOf(in, s.Node) +} + +// PlansHeld is Plans composed module by module (issue 774: one bad module must not drop its node): a module placed on +// the scope's node whose build cannot be read or taken in, or whose plan refuses the node's, is held, by the one rule +// Compose follows (composeEach, culprit): composed as its stub so what it provides and requires still shapes the rest, +// named in held with why, and none of its plan in plans. Only a refusal that is no single module's refuses the node, +// err. The seat mesh-provisioner's holder composes so: a node with one bad manifest still has every other module's plan. +func PlansHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (plans map[string]catalogue.Plan, held map[string]string, err error) { + held = map[string]string{} + in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, nil) + if err != nil { + return nil, held, err + } + plans, err = plansHeldOf(in, s.Node, held) + return plans, held, err +} + +// plansHeldOf is the plans of node over in, every module in held held, and each module a refusal is of held too until +// the plans compose (culprit's rule over the Planning mode: its stub first, then its bare stub). +func plansHeldOf(in compose.Input, node string, held map[string]string) (map[string]catalogue.Plan, error) { + return heldOf(in, node, held, func(in compose.Input) (map[string]catalogue.Plan, error) { return plansIn(in, node, compose.Planning) }) +} + +// heldOf composes compose1 over in with every module in held held, and each module a refusal is of held too until it +// composes (culprit's rule: its stub first, then its bare stub); what compose1 answers of a held module is left out. +// Plans and data items are composed so (PlansHeld, DataItemsHeld): one module never ends the read (novox/hq ADR 0578 §3). +func heldOf[V any](in compose.Input, node string, held map[string]string, compose1 func(compose.Input) (map[string]V, error)) (map[string]V, error) { + for _, m := range sortedKeys(held) { + in = stubbed(in, node, m, "") + } + // Each try holds one module more or leaves out all of one held module's offer: bounded by twice the modules placed. + for tries := 0; tries <= 2*len(assignedOn(in, node))+1; tries++ { + plans, err := compose1(in) + if err == nil { + for m := range held { + delete(plans, m) + } + return plans, nil + } + var order []string + for _, m := range assignedOn(in, node) { + if strings.Contains(err.Error(), m) && len(order) < maxCulprits { + order = append(order, m) + } + } + module, leave := "", "" + find: + for _, l := range []string{"", leaveAll} { + for _, m := range order { + if _, done := held[m]; done && l == "" { + continue + } + if _, again := compose1(stubbed(in, node, m, l)); again == nil || again.Error() != err.Error() { + module, leave = m, l + break find + } + } + } + if module == "" { + return nil, err + } + if _, done := held[module]; !done { + held[module] = err.Error() + } + in = stubbed(in, node, module, leave) + } + return nil, fmt.Errorf("frombus: what %s composes does not compose with %d module(s) held: %s", node, len(held), + strings.Join(sortedKeys(held), ", ")) +} + +// PlansOf is Plans over an input already filled, rendered in the Planning mode (compose.Planning): every own secret the +// input holds none for stands in, so no value and no record of one is needed. +func PlansOf(in compose.Input, node string) (map[string]catalogue.Plan, error) { + return plansIn(in, node, compose.Planning) +} + +// plansIn is the controller's handedPlans in a mode: Foreseeing is the controller's own. +func plansIn(in compose.Input, node string, mode compose.Mode) (map[string]catalogue.Plan, error) { + // One composition for the three, so each node of the mesh is resolved once (issue 1954). + resolved, rendered, gens, err := compose.Composing(in, node, mode) + if err != nil { + return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err) + } + with := rendered.Rendering + with.Generators = gens + composed, err := resolved.Plan.Compose(with) + if err != nil { + return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err) + } + return composed.Plans, nil +} + +// unusable is what stands in for a module's manifest the mesh refuses: its stub (compose.Stub) +// read from its JSON unjudged, so what it provides, requires and serves still shapes the rest, and without its items, +// since nothing judged what they would put into another module's collection. Bytes that are no manifest of the module +// stand in as a stub that offers nothing. +func unusable(module string, raw []byte) catalogue.Manifest { + var m catalogue.Manifest + if json.Unmarshal(raw, &m) != nil || m.Module != module { + m = catalogue.Manifest{Module: module} + } + m.Items = nil + return compose.Stub(m) +} + +// parser reads a manifest as every reader does (novox/hq ADR 0578 §3): decoded leniently, judged by nothing here. +func parser(Mesh) func([]byte) (catalogue.Manifest, error) { + return catalogue.Decode +} + +// ranOf is what a module last offered the others, read when it is held (composeEach, issue 1960): its manifest at the +// build its own composition runs at (running), where that is another build than the one it is composed at (fps) and +// the builder holds it done. False where nothing is known of it (it runs no composition of its own, it runs the build +// it is composed at, or its build is unread): the manifest it is composed from is its offer then. Only what it says to +// others is taken of it, which needs no take-in of its artifacts. +func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed, fps map[string]string) func(string) (catalogue.Manifest, bool) { + parse := parser(mesh) + return func(module string) (catalogue.Manifest, bool) { + for _, c := range running { + if c.Module != module || c.FP == "" || c.FP == fps[module] { + continue + } + b, err := r.Build(ctx, module, c.FP) + if err != nil || b == nil || b.State != delivery.BuildDone || b.Manifest == "" { + return catalogue.Manifest{}, false + } + raw, err := r.Manifest(ctx, b.Manifest) + if err != nil { + return catalogue.Manifest{}, false + } + man, err := parse(raw) + return man, err == nil + } + return catalogue.Manifest{}, false + } +} + +// fromBuilds puts in the built manifest of each module placed on the composing node, at the trunk's FP2, and of each +// module it keeps at another build (Scope.KeptAt, into its node's Kept), read from the builder's done key and resolved +// by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it +// (issue 1126). +func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error), + in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module, + read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error), + refuse func(module string, err error), wanted map[string]string) error { + // stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides + // and requires still shapes the rest. + stub := func(module string, err error) { + refuse(module, err) + for i := range in.Nodes { + if in.Nodes[i].Name != s.Node { + continue + } + man, ok := in.Nodes[i].Kept[module] + if !ok { + man, ok = in.Catalogue[module] + } + if !ok { + return + } + if in.Nodes[i].Kept == nil { + in.Nodes[i].Kept = map[string]catalogue.Manifest{} + } + in.Nodes[i].Kept[module] = compose.Stub(man) + } + } + address, addressErr := "", error(nil) + addressed := false + archive := func(ctx context.Context, reference, digest string) ([]byte, error) { + if !addressed { + address, addressErr = compose.ArtifactStoreAddress(*in, s.Node) + addressed = true + } + switch { + case addressErr != nil: + return nil, addressErr + case address == "": + return nil, fmt.Errorf("%s reaches no artifact store (%s)", s.Node, storeSought(*in)) + } + return r.Archive(ctx, catalogue.Routed(reference, address), digest) + } + built := func(module, fp string, b *delivery.BuilderBuild) (catalogue.Manifest, error) { + if b.Manifest == "" { + return catalogue.Manifest{}, fmt.Errorf("frombus: the build of %s at %s names no manifest", module, fp) + } + // A take-in kept by the reader is read back as below, never shared: each composition holds its own manifest. + keeper, keeps := r.(takenInKeeper) + if keeps && !addressed { + address, addressErr = compose.ArtifactStoreAddress(*in, s.Node) + addressed = true + } + // Kept by the store's address too: a node that reaches no store takes in as before, and fails where it did. + key, keyed := takenInKey(module, fp, address, *b) + keyed = keyed && addressErr == nil && address != "" + if keeps && keyed { + if raw, ok := keeper.takenIn(key); ok { + resolved, err := parse(raw) + if err != nil { + return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err) + } + return resolved, nil + } + } + src, err := read(module, b.Manifest) + if err != nil { + return catalogue.Manifest{}, err + } + resolved, err := takein.Manifest(ctx, src, *b, archive) + if err != nil { + return catalogue.Manifest{}, fmt.Errorf("frombus: %s at %s: %w", module, fp, err) + } + // Read back as the mesh holds a built manifest (the controller keeps the resolved manifest's bytes and reads them + // as any manifest): a template's text is then in the manifest's own words, its ${config:…} in the settings form. + raw, err := json.Marshal(resolved) + if err != nil { + return catalogue.Manifest{}, err + } + if resolved, err = parse(raw); err != nil { + return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err) + } + if keeps && keyed { + keeper.keepTakenIn(key, raw) + } + return resolved, nil + } + // A module wanted at another build than the trunk's is composed from the build its want names (issue 774), as one + // the scope keeps at another build is. + keptAt := maps.Clone(s.KeptAt) + for _, pm := range p.Modules { + if m, ok := modules[pm.Module]; ok && m.FP2 != "" && wanted[pm.Module] != "" && wanted[pm.Module] != m.FP2 && + keptAt[pm.Module] == "" && !slices.Contains(s.InOwn, pm.Module) { + if keptAt == nil { + keptAt = map[string]string{} + } + keptAt[pm.Module] = wanted[pm.Module] + } + } + runs := map[string]bool{} + for _, c := range s.Running { + if c.Module != "" { + runs[c.Module] = true + } + } + for _, pm := range p.Modules { + m, ok := modules[pm.Module] + if !ok || m.FP2 == "" || keptAt[pm.Module] != "" || slices.Contains(s.InOwn, pm.Module) { + // A module it runs inside its own composition is composed there as before: no build of the new form is what + // it runs (Scope.InOwn). + continue + } + if _, refused := in.Refused[pm.Module]; refused { + continue // held as its stub: no build of a refused manifest is taken in (ADR 0578 §3) + } + if man, ok := in.Catalogue[pm.Module]; !ok || (man.Build == nil && !man.Templated()) { + continue // nothing is built of it: its manifest is what it composes from + } + b, err := build(ctx, pm.Module, m.FP2) + if err != nil { + err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.FP2, err) + if refuse == nil { + return err + } + stub(pm.Module, err) + continue + } + if b == nil || b.State != delivery.BuildDone { + if refuse != nil && runs[pm.Module] { + // A module that runs is never composed from the indexer's manifest, which names no build's artifacts (no + // runtime's bundle): it is held, keeping what runs, until its build lands (issue 774, 2026-10-30 19:30: + // node-tools on ace, g14 and shanks, "its build produced 0 bundle(s)", and every module refused). + stub(pm.Module, fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build yet; it "+ + "keeps running what it runs", pm.Module, m.FP2)) + } + continue // nothing of it runs: composed from the indexer's manifest, as before + } + resolved, err := built(pm.Module, m.FP2, b) + if err != nil { + if refuse == nil { + return err + } + stub(pm.Module, err) + continue + } + in.Catalogue[pm.Module] = resolved + } + for i := range in.Nodes { + if in.Nodes[i].Name != s.Node { + continue + } + for _, module := range sortedKeys(keptAt) { + fp := keptAt[module] + b, err := build(ctx, module, fp) + switch { + case err != nil: + err = fmt.Errorf("frombus: the build of %s at %s: %w", module, fp, err) + case b == nil || b.State != delivery.BuildDone: + err = fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build", module, fp) + } + var resolved catalogue.Manifest + if err == nil { + resolved, err = built(module, fp, b) + } + if err != nil { + if refuse == nil { + return err + } + stub(module, err) + continue + } + if in.Nodes[i].Kept == nil { + in.Nodes[i].Kept = map[string]catalogue.Manifest{} + } + in.Nodes[i].Kept[module] = resolved + } + } + return nil +} + +// declaresOwnSecrets says a module assigned to node declares an own secret, in the manifest the node composes it from. +func declaresOwnSecrets(in compose.Input, node string) bool { + for _, n := range in.Nodes { + if n.Name != node { + continue + } + for _, module := range n.Assigned { + m, ok := n.Kept[module] + if !ok { + m = in.Catalogue[module] + } + if len(m.OwnSecrets) > 0 { + return true + } + } + } + return false +} + +// nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person +// decided of it, and where its modules run). +func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node { + n := compose.Node{ + Name: rec.Node, + Place: compose.Place{Key: rec.TunnelKey}, + SealingKey: rec.SealingKey, + OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid, + ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0, + } + placeNode(&n, p) + if len(rec.Capabilities) > 0 { + n.Capabilities = make(map[string]bool, len(rec.Capabilities)) + for _, c := range rec.Capabilities { + n.Capabilities[c] = true + } + } + return n +} + +// placeNode sets what a person decided of n and where its modules run, from its placement; what the machine observes +// (its keys, links, networks, certificate) and its settings layers are left as they are. +func placeNode(n *compose.Node, p delivery.Placement) { + d := p.Decided + n.Place = compose.Place{Key: n.Place.Key, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address} + n.Assigned, n.PublicDomain = p.Assigned(), d.PublicDomain + n.Account, n.AccountHome, n.AgentAccount, n.AgentAccountHome = d.Account, d.AccountHome, d.AgentAccount, d.AgentHome + n.Adopted, n.Taken = d.Adopted, d.Taken + n.Pins, n.Bound, n.Ports = p.ChosenPins(), p.Bound(), p.HostPorts() +} + +// Placed is in with the placements given standing in for those of the nodes they name (issue 1126): each such node's +// decided parts, modules, pins, bindings, host ports, settings placement and holdings are the given placement's, its +// config values those in already carries for it (the layer of the node's own name), and what the machine observes is +// kept. A nil placement leaves the node in the mesh with nothing placed on it. A node in does not hold is refused: it has +// no record to compose it from. The found tunnel and the hub's peers are read from in as they are, not from the given +// placement. It is how a check composes a change's own placement over an input already filled (composer.Case's +// Placements). +func Placed(in compose.Input, placements map[string]*delivery.Placement) (compose.Input, error) { + if len(placements) == 0 { + return in, nil + } + out := in + out.Nodes = append([]compose.Node(nil), in.Nodes...) + index := map[string]int{} + for i, n := range out.Nodes { + index[n.Name] = i + } + for _, node := range sortedKeys(placements) { + p := placements[node] + i, ok := index[node] + if !ok { + return compose.Input{}, fmt.Errorf("frombus: a placement is given for %s, which the input holds no node for", node) + } + if p == nil { + p = &delivery.Placement{Node: node} + } + if p.Node != node { + return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node) + } + n := out.Nodes[i] + placeNode(&n, *p) + settings := map[string][]catalogue.Layer{} + for _, m := range p.Modules { + var values map[string]any + for _, l := range n.Settings[m.Module] { + if l.From == node { + values = l.Values + } + } + if layers := m.Layers(node, values); layers != nil { + settings[m.Module] = layers + } + } + n.Settings = nil + if len(settings) > 0 { + n.Settings = settings + } + out.Nodes[i] = n + } + out.Holdings = nil + for _, h := range in.Holdings { + if _, given := placements[h.Node]; !given { + out.Holdings = append(out.Holdings, h) + } + } + for _, node := range sortedKeys(placements) { + if p := placements[node]; p != nil { + out.Holdings = append(out.Holdings, p.Held()...) + } + } + return out, nil +} + +func tunnelOf(t delivery.RecordTunnel) compose.Tunnel { + out := compose.Tunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU, + Address: t.Address, Range: t.Range, PublicKey: t.PublicKey} + for _, p := range t.Peers { + out.Peers = append(out.Peers, compose.TunnelPeer{PublicKey: p.PublicKey, Address: p.Address}) + } + return out +} + +// carriedPeers is every peer of the hub's adopted tunnel, by address, each with the node that enrolled under its key +// where one has (a node whose tunnel key is the peer's) and the name a person gave it (inv.CarriedPeers). +func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]delivery.NodeRecord) []compose.CarriedPeer { + byKey := map[string]string{} + for node, rec := range records { + if rec.TunnelKey != "" { + byKey[rec.TunnelKey] = node + } + } + var out []compose.CarriedPeer + for _, p := range t.Peers { + address, _, _ := strings.Cut(p.Address, "/") + out = append(out, compose.CarriedPeer{PublicKey: p.PublicKey, Address: address, EnrolledAs: byKey[p.PublicKey], Named: names[p.PublicKey]}) + } + sort.SliceStable(out, func(i, j int) bool { + a, errA := netip.ParseAddr(out[i].Address) + b, errB := netip.ParseAddr(out[j].Address) + if errA != nil || errB != nil { + return out[i].Address < out[j].Address + } + return a.Less(b) + }) + return out +} + +// nodesOf is every node named by a placement or a record, by name: the order the controller reads them in. +func nodesOf(placements map[string]delivery.Placement, records map[string]delivery.NodeRecord) []string { + seen := map[string]bool{} + for n := range placements { + seen[n] = true + } + for n := range records { + seen[n] = true + } + return sortedKeys(seen) +} + +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// storeSought says, for a node that reaches no artifact store, what the composition holds of one: the nodes it holds and +// where each module serving the artifact store is placed, so the refusal names why (issue 1126). +func storeSought(in compose.Input) string { + var nodes, where []string + for _, n := range in.Nodes { + nodes = append(nodes, n.Name) + for _, a := range n.Assigned { + if m, ok := in.Catalogue[a]; ok { + if _, serves := m.Serves[catalogue.ArtifactStoreProvision]; serves { + where = append(where, a+" on "+n.Name) + } + } + } + } + sort.Strings(nodes) + sort.Strings(where) + said := "the composition holds " + strings.Join(nodes, ", ") + if len(where) == 0 { + return said + "; no module placed there serves " + catalogue.ArtifactStoreProvision + } + return said + "; " + catalogue.ArtifactStoreProvision + " is served by " + strings.Join(where, ", ") + + ", none of it on the private network with a port" +} + +// keptOf is the operator export's file (catalogue.KeptID) of a module's composition as it runs, or nil. +func keptOf(c delivery.Composed) map[string]any { + for _, raw := range c.Resources { + var r map[string]any + if json.Unmarshal(raw, &r) == nil && r["id"] == c.Module+"."+catalogue.KeptID() { + return r + } + } + return nil +} + +// membershipOf is the sealed value of the bus membership resource (catalogue.BusMembershipID) of a node's own +// composition, or "". +func membershipOf(own delivery.Composed) string { + for _, raw := range own.Resources { + var r struct { + ID string `json:"id"` + Sealed string `json:"sealed"` + } + if json.Unmarshal(raw, &r) == nil && r.ID == catalogue.BusMembershipID() { + return r.Sealed + } + } + return "" +} + +// DataItems is every data item the modules placed on scope's node declare, placed there, composed from the bus as Plans +// composes (novox/hq ADR 0571 clause 3): disk-load measures these. A module whose manifest the indexer refused is held +// and its items left out; DataItemsHeld names it, and a reader that must say what it leaves out reads that. +func DataItems(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string][]catalogue.PlacedDataItem, []string, error) { + items, unplaced, _, err := DataItemsHeld(ctx, r, mesh, s) + return items, unplaced, err +} + +// DataItemsHeld is DataItems and, by module, why each module placed on scope's node is held: its manifest refused by +// the indexer's judge or not decoded (compose.Input.Refused). One refused manifest never ends the read (novox/hq ADR +// 0578 §3: disk-load measured nothing for 45 hours when one did). +func DataItemsHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (items map[string][]catalogue.PlacedDataItem, unplaced []string, held map[string]string, err error) { + held = map[string]string{} + in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, nil) + if err != nil { + return nil, nil, held, err + } + // Held module by module, as PlansHeld holds them: a refused stub or one module's composition refusal (an item's + // template) holds that module and never ends the read. + items, err = heldOf(in, s.Node, held, func(in compose.Input) (map[string][]catalogue.PlacedDataItem, error) { + var err error + items, unplaced, err = compose.DataItems(withoutHeldData(in, s.Node), s.Node) + return items, err + }) + return items, unplaced, held, err +} + +// withoutHeldData is in with no data item of a module held on node: none of a held module's items is measured, so its +// data never refuses the rest (its stub keeps its data for the backups the plans compose). +func withoutHeldData(in compose.Input, node string) compose.Input { + out := in + out.Nodes = append([]compose.Node(nil), in.Nodes...) + for i := range out.Nodes { + n := &out.Nodes[i] + if n.Name != node || len(n.Held) == 0 { + continue + } + kept := maps.Clone(n.Kept) + for m := range n.Held { + if man, ok := kept[m]; ok { + man.Data = nil + kept[m] = man + } + } + n.Kept = kept + } + return out +} diff --git a/internal/compose/frombus/frombus_test.go b/internal/compose/frombus/frombus_test.go new file mode 100644 index 0000000..cd566f5 --- /dev/null +++ b/internal/compose/frombus/frombus_test.go @@ -0,0 +1,869 @@ +package frombus + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "reflect" + "slices" + "sort" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/overlay" +) + +// bus is a Reader over values held in memory, counting whose config values were read. +type bus struct { + vaultAuthority string + handed map[string]bool + placements map[string]delivery.Placement + records map[string]delivery.NodeRecord + modules map[string]delivery.Module + manifests map[string][]byte + values map[string]map[string]any // by node.module + valuesRead []string + own []compose.OwnSecretRow + ownErr error + // asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming + // module its placement does not put on the node. + asked []compose.CredentialKey + // builds are the builder's keys, by " "; archives the artifact store's blobs, by digest, and fetched + // every URL asked; served another node's values by ".", and servedRead every ask with its keys. + builds map[string]*delivery.BuilderBuild + archives map[string][]byte + fetched []string + served map[string]map[string]any + servedRead []string + buildsRead []string + // held answers every credential asked as held, sealed to the consumer. + held bool +} + +func (b *bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) { + b.buildsRead = append(b.buildsRead, module) + return b.builds[module+" "+fp], nil +} + +func (b *bus) Archive(_ context.Context, url, digest string) ([]byte, error) { + b.fetched = append(b.fetched, url) + body, ok := b.archives[digest] + if !ok { + return nil, errors.New("the store holds no such blob") + } + return body, nil +} + +func (b *bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) { + b.servedRead = append(b.servedRead, node+"."+module+":"+strings.Join(keys, ",")) + out := map[string]any{} + for _, k := range keys { + if v, ok := b.served[node+"."+module][k]; ok { + out[k] = v + } + } + return out, nil +} + +func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) { + return b.placements, nil +} +func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority } +func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed } +func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil } +func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil } +func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) { + raw, ok := b.manifests[digest] + if !ok { + return nil, errors.New("no such manifest") + } + return raw, nil +} +func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { + return b.own, b.ownErr +} + +func (b *bus) Credentials(_ context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) { + b.asked = append(b.asked, asConsumer...) + for _, k := range asConsumer { + placed := false + for _, m := range b.placements[node].Modules { + placed = placed || m.Module == k.ConsumerModule + } + if !placed { + return nil, nil, fmt.Errorf("%s is not on %s: neither assigned there nor required by a module there", k.ConsumerModule, node) + } + } + var out []compose.Credential + var issued []compose.Issued + for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) { + if b.held { + out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule, + Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`}) + issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule, + Provider: k.Provider, Local: k.Local, ForProvider: `"box"`}) + } + } + return out, issued, nil +} + +func (b *bus) Values(_ context.Context, node, module string) (map[string]any, error) { + b.valuesRead = append(b.valuesRead, node+"."+module) + return b.values[node+"."+module], nil +} + +func publicKey(t *testing.T) string { + t.Helper() + k, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()) +} + +// Issue 1126 (S3, S4, S5): the bus's placement, node records, manifests and config values fill the same compose.Input the +// controller's store fills for compose's own two-machine mesh (compose's aMesh: anchor the hub, laptop), and that Input +// composes; a node reads only its own config values; what cannot be composed is refused, naming it. +func TestInput(t *testing.T) { + // Issue 1126, the seed's shadow: every refusal named, an old-form module left to the node, and no store reached + // saying why (answer_rows_test.go). + t.Run("every refusal, and an old build left to the node", answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode) + t.Run("no store reached says why", noStoreReachedSaysWhy) + // Issue 774: a node refused whole says what it held on the way. + t.Run("a node refused whole says what it held", aNodeRefusedWholeSaysWhatItHeld) + t.Run("a refused manifest never ends the read", aRefusedManifestNeverEndsTheRead) + t.Run("the runtime is blamed only where its stub composes", theRuntimeIsBlamedOnlyWhereItsStubComposes) + t.Run("a running module waits for its build", aRunningModuleWaitsForItsBuild) + t.Run("a held module's first reason leads", aHeldModulesFirstReasonLeads) + t.Run("a held module of its own account never refuses the node", aHeldModuleOfItsOwnAccountNeverRefusesTheNode) + t.Run("another node not resolved is named", otherNodeNotResolvedIsNamed) + ctx := context.Background() + xterm, err := os.ReadFile("testdata/catalogue-835bf22b-xterm.module.json") + if err != nil { + t.Fatal(err) + } + digest := delivery.ManifestDigest(xterm) + // A module in the settings form (plex and the media modules on one node, 2026-10-26): its config values are in its + // layers, not mesh-config, so the placement carries them (MeshValues, Values) and mesh-config's are not read for it. + media := []byte(`{"module":"media","version":"1","settings":{"lines":{"kind":"preference","default":0,"why":"kept"},` + + `"name":{"kind":"preference","default":"x","why":"shown"}},"own-secrets":{"token":"/srv/media/token"},"resources":[{"id":"conf","type":"file",` + + `"path":"/srv/media/conf","mode":"0644","content":"uid=${setting:lines} name=${setting:name}\n"}]}`) + mediaDigest := delivery.ManifestDigest(media) + keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)} + caps := []string{"container-runtime", "systemd", "wireguard"} + tunnel := &delivery.RecordTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24", + Peers: []delivery.RecordTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}} + mk := func() *bus { + return &bus{ + placements: map[string]delivery.Placement{ + "anchor": {Node: "anchor", Decided: delivery.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1", + Tunnel: tunnel, PeerNames: map[string]string{"peer": "phone"}}, + Modules: []delivery.PlacedModule{{Module: overlay.Name}}, + Holds: []delivery.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}}, + "laptop": {Node: "laptop", Decided: delivery.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"}, + Modules: []delivery.PlacedModule{{Module: overlay.Name}, + {Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}}, + {Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true, + Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}}, + }, + records: map[string]delivery.NodeRecord{ + "anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2}, + "laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"}, + }, + modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}}, + manifests: map[string][]byte{digest: xterm, mediaDigest: media}, + values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"}, + "laptop.media": {"lines": "not mesh-config's"}}, + own: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, + } + } + + // What the controller's store gives for the same mesh, as compose's aMesh and SettingsFor build it. + var private catalogue.Manifest + raw, _ := json.Marshal(overlay.Manifest()) + if err := json.Unmarshal(raw, &private); err != nil { + t.Fatal(err) + } + parsed, err := catalogue.Decode(xterm) + if err != nil { + t.Fatal(err) + } + parsedMedia, err := catalogue.Decode(media) + if err != nil { + t.Fatal(err) + } + capsSet := map[string]bool{"container-runtime": true, "systemd": true, "wireguard": true} + tun := compose.Tunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24", + Peers: []compose.TunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}} + want := compose.Input{ + Catalogue: map[string]catalogue.Manifest{overlay.Name: private, "xterm": parsed, "media": parsedMedia}, + Nodes: []compose.Node{ + {Name: "anchor", Place: compose.Place{Key: keys["anchor"], Endpoint: "anchor.example:51820", Site: "here", Hub: true, + Address: "10.77.0.1"}, Assigned: []string{overlay.Name}, Capabilities: capsSet, Contract: 2, Stated: true}, + {Name: "laptop", Place: compose.Place{Key: keys["laptop"], Endpoint: "laptop.example:51820", Site: "here", + Address: "10.77.0.2"}, Assigned: []string{overlay.Name, "xterm", "media"}, Capabilities: capsSet, + Ports: map[string]map[int]int{"xterm": {80: 8080}}, + Settings: map[string][]catalogue.Layer{"xterm": {{From: "laptop", Values: map[string]any{"font": "mono"}}}, + "media": {{From: catalogue.MeshWideLayer, Values: map[string]any{"name": "mesh"}}, + {From: "laptop", Values: map[string]any{"lines": float64(1000)}, Placement: map[string]any{"places": map[string]any{"data": "/srv/data"}}}}}}, + }, + Holdings: []catalogue.Held{{Claim: "mesh-private-network", Scope: "mesh", Node: "anchor", Module: overlay.Name}}, + OwnSecrets: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, + // The same value as a core credential's box, for a core module (ADR 0349 §16). + KeptCoreBoxes: []compose.CoreBox{{Node: "laptop", Module: "media", Name: "token", SealedTo: "sk", Box: json.RawMessage("{}")}}, + Tunnels: map[string]compose.FoundTunnel{"anchor": {Tunnel: tun}}, + Adopted: &compose.AdoptedTunnel{Tunnel: tun, Hub: "anchor", Adopted: true}, + // The controller's order is by address; laptop enrolled under its peer's key, and a person named the other. + Authority: "the mesh's certificate", + CarriedPeers: []compose.CarriedPeer{{PublicKey: keys["laptop"], Address: "10.77.0.2", EnrolledAs: "laptop"}, + {PublicKey: "peer", Address: "10.77.0.9", Named: "phone"}}, + } + + b := mk() + got, err := Input(ctx, b, Mesh{}, Scope{Node: "laptop"}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + gj, _ := json.MarshalIndent(got, "", " ") + wj, _ := json.MarshalIndent(want, "", " ") + t.Fatalf("Input from the bus differs from the store's:\n got %s\nwant %s", gj, wj) + } + // L3 s8 (ADR 0550 §2): with the vault's authority published, the mesh's authority is the bundle of the controller's + // and the vault's; with none, the controller's alone, as above. + signing := mk() + signing.vaultAuthority = "the vault's certificate" + if bundled, err := Input(ctx, signing, Mesh{}, Scope{Node: "laptop"}); err != nil || + bundled.Authority != "the mesh's certificate\nthe vault's certificate\n" { + t.Errorf("the authority with the vault's published: %q, %v", bundled.Authority, err) + } + // Issue 774: the modules the vault judged handed when it answered own are the node's to hand, and no other node's. + judged := mk() + judged.handed = map[string]bool{"media": true, "xterm": false} + if in, err := Input(ctx, judged, Mesh{}, Scope{Node: "laptop"}); err != nil || + !reflect.DeepEqual(in.Nodes[1].Handed, judged.handed) || in.Nodes[0].Handed != nil { + t.Errorf("the vault's handed verdicts: %+v, %v", in.Nodes, err) + } + if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) { + t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead) + } + + // The same composer over both: the compositions from the bus are the store's, byte for byte. + fps := map[string]string{overlay.Name: "src2:" + strings.Repeat("a", 64), "xterm": "src2:" + strings.Repeat("b", 64), + "media": "src2:" + strings.Repeat("c", 64)} + fromBus, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } + if err != nil { + t.Fatal(err) + } + fromStore, _, err := compose.Compositions(want, "laptop", fps, compose.Reading) + if err != nil { + t.Fatal(err) + } + if len(fromBus) == 0 || !reflect.DeepEqual(fromBus, fromStore) { + t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore)) + } + + // Issue 1126: a candidate placement equal to the node's composes what it runs, and CompareComposed says so; one + // thing changed in the group's candidate of another node (the hub's endpoint) names exactly the module it moves + // here. Placed puts the same placement over the store's input unchanged. + running := make([]delivery.Composed, 0, len(fromBus)) + for _, c := range fromBus { + running = append(running, c) + } + laptop := mk().placements["laptop"] + asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } + if err != nil { + t.Fatal(err) + } + if c := delivery.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) { + t.Errorf("the candidate equal to the placement: %+v", c) + } + moved := mk().placements["anchor"] + moved.Decided.Endpoint = "anchor.example:51821" + changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } + if err != nil { + t.Fatal(err) + } + if c := delivery.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" { + t.Errorf("the hub's endpoint changed: %+v, want %s alone, naming its config", c, overlay.Name) + } + if c := delivery.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) { + t.Errorf("nothing runs: %+v", c) + } + + // ADR 0559 §4, the node's answer: the same; one module differing; a module the group adds, whose own secret is not + // yet issued (no refusal); a value missing for a module the node runs (a refusal); a module the candidate removes. + keeper := []byte(`{"module":"keeper","version":"1","own-secrets":{"password":"/var/lib/mesh/keeper/password"},` + + `"resources":[{"id":"state","type":"directory","path":"/var/lib/mesh/keeper","mode":"0700"}]}`) + lemurs := []byte(`{"module":"lemurs","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`) + // A provider and a consumer of a pair credential on the one node (compose's objects and files). + objects, _ := json.Marshal(catalogue.Manifest{Module: "objects", Version: "1", + Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh, Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}}, + Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}}) + files, _ := json.Marshal(catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"}, + Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", "mode": "0600", "content": "{}", "merge": "json"}}}) + one := func(placed ...string) *bus { + p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} + for _, m := range placed { + p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) + } + return &bus{placements: map[string]delivery.Placement{"n1": p}, + records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}}, + modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)}, + "objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}}, + manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs, + delivery.ManifestDigest(objects): objects, delivery.ManifestDigest(files): files}, + own: []compose.OwnSecretRow{{Node: "n1", Module: "keeper", Name: "password", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}} + } + oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64), + "objects": "src2:" + strings.Repeat("f", 64), "files": "src2:" + strings.Repeat("0", 64)} + runs, _, held, err := Compose(ctx, one("keeper", "lemurs"), Mesh{}, Scope{Node: "n1"}, oneFps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } + if err != nil { + t.Fatal(err) + } + var runsNow []delivery.Composed + for _, c := range runs { + runsNow = append(runsNow, c) + } + objectsRun, _, held, err := Compose(ctx, one("objects"), Mesh{}, Scope{Node: "n1"}, oneFps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } + if err != nil { + t.Fatal(err) + } + var consumerRuns []delivery.Composed + for _, c := range objectsRun { + consumerRuns = append(consumerRuns, c) + } + withoutOwn := func(cs []delivery.Composed) []delivery.Composed { + var out []delivery.Composed + for _, c := range cs { + if c.Module != "" { + out = append(out, c) + } + } + return out + } + // What the controller runs on n1 for a module no want names (lemurs, at a build of the old form) and the private + // network's (built by nobody): its composed writer (mesh-controller compositionsOf) folds their resources into the + // node's own, each in its place, its module taken off; the rest split by module. + tunnelled := func() *bus { + b := one("keeper", "lemurs", overlay.Name) + p := b.placements["n1"] + p.Decided.Endpoint = "n1.example:51820" + b.placements["n1"] = p + return b + } + tunnelledAs := func() map[string]*delivery.Placement { + p := tunnelled().placements["n1"] + return map[string]*delivery.Placement{"n1": &p} + } + folded := func() []delivery.Composed { + b := tunnelled() + in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"}) + if err != nil { + t.Fatal(err) + } + reading := in + reading.Nodes = append([]compose.Node(nil), in.Nodes...) + reading.Nodes[0].ReadsModules = true + d, _, err := compose.ComposeIn(reading, "n1", compose.Reading) + if err != nil { + t.Fatal(err) + } + body, err := d.Body() + if err != nil { + t.Fatal(err) + } + var envelope struct { + Resources []map[string]json.RawMessage `json:"resources"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + t.Fatal(err) + } + var resources []json.RawMessage + for _, r := range envelope.Resources { + var module string + _ = json.Unmarshal(r["module"], &module) + if module == "lemurs" || module == overlay.Name { + delete(r, "module") + } + raw, _ := json.Marshal(r) + resources = append(resources, raw) + } + split, err := delivery.SplitComposed("n1", resources, map[string]string{"keeper": oneFps["keeper"]}, nil, delivery.NodeOwn{}) + if err != nil { + t.Fatal(err) + } + var out []delivery.Composed + for _, c := range split { + out = append(out, c) + } + return out + }() + if got := InOwn(folded, tunnelled().placements["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) { + t.Errorf("InOwn of the controller's composition = %v, want lemurs and %s", got, overlay.Name) + } + placedAs := func(modules ...string) map[string]*delivery.Placement { + p := one(modules...).placements["n1"] + return map[string]*delivery.Placement{"n1": &p} + } + for name, c := range map[string]struct { + b *bus + given map[string]*delivery.Placement + running []delivery.Composed + verdict string + check func(delivery.CandidateAnswer) bool + }{ + "the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame, + func(a delivery.CandidateAnswer) bool { + return len(a.Same) == 3 && a.Proposal == "p-h1" && a.Revision == 7 + }}, + "the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame, + func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }}, + "one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers, + func(a delivery.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }}, + "one added, its secret not yet issued": {func() *bus { b := one("lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"), + nil, delivery.CandidateDiffers, func(a delivery.CandidateAnswer) bool { + for _, m := range a.Added { + if m.Module == "keeper" { + return reflect.DeepEqual(m.Unissued, []string{"keeper/password"}) && len(a.Refusals) == 0 + } + } + return false + }}, + "a value missing for a module that runs": {func() *bus { b := one("keeper", "lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"), + runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool { + return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && strings.Contains(a.Refusals[0].Why, "keeper/password") + }}, + // Review of L2b, round 1, finding 1: a consumer the candidate adds has no credential yet, and the vault refuses an + // ask for a module not on the node; it is not asked, and the module is added, its credential not yet issued. + "a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, delivery.CandidateDiffers, + func(a delivery.CandidateAnswer) bool { + return len(a.Added) == 1 && a.Added[0].Module == "files" && len(a.Added[0].Unissued) > 0 && len(a.Refusals) == 0 + }}, + // And with no trunk at all (the seed), a stand-in of a module the node runs is a refusal, not an addition. + "the seed, a value missing for a module that runs": {func() *bus { b := one(); b.own = nil; return b }(), placedAs("keeper", "lemurs"), + runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool { + return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0 + }}, + "cannot compose": {one("keeper"), map[string]*delivery.Placement{"n1": {Node: "n2"}}, runsNow, delivery.CandidateRefused, + func(a delivery.CandidateAnswer) bool { return a.Refused != "" }}, + // Issue 1126 (the seed's review): the private network's module and one at a build of the old form, which no want + // names and the controller folds into the node's own, are folded there again: the node's own is the same, and + // neither is added. + "mesh-wireguard and an old-form module, folded": {tunnelled(), tunnelledAs(), + folded, delivery.CandidateSame, func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }}, + } { + fps := oneFps + if name == "mesh-wireguard and an old-form module, folded" { + fps = map[string]string{"keeper": oneFps["keeper"]} // the node's wants: no build of lemurs or the private network + } + a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7) + if a.Verdict != c.verdict || !c.check(a) { + t.Errorf("%s: %+v", name, a) + } + if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil { + t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw) + } + } + if got, err := Placed(want, map[string]*delivery.Placement{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) { + t.Errorf("Placed with the node's own placement changes the input: %v", err) + } + if got, err := Placed(want, map[string]*delivery.Placement{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] { + t.Errorf("Placed with no placement: %+v, %v", got.Nodes[1], err) + } + if _, err := Placed(want, map[string]*delivery.Placement{"stranger": {Node: "stranger"}}); err == nil { + t.Error("Placed a node the input holds no node for") + } + if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": {Node: "anchor"}}}); err == nil { + t.Error("a given placement naming another node was taken") + } + + // ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the + // placement the bus holds, and read back it is the store's input again. + for node, placed := range mk().placements { + got, err := Export(want, node) + if err != nil { + t.Fatal(err) + } + if gj, _ := delivery.EncodePlacement(got); !delivery.SamePlacement(got, placed) { + pj, _ := delivery.EncodePlacement(placed) + t.Errorf("%s exported as %s, want %s", node, gj, pj) + } + } + if _, err := Export(want, "stranger"); err == nil { + t.Error("a node the input lacks was exported") + } + + b = mk() + if every, err := Input(ctx, b, Mesh{}, Scope{Every: true}); err != nil || every.OwnSecrets != nil { + t.Fatalf("composing every node: %v, own secrets %v (the vault answers a node about itself alone)", err, every.OwnSecrets) + } + if !reflect.DeepEqual(b.valuesRead, []string{"anchor." + overlay.Name, "laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) { + // Every node's placed modules, and nothing placed nowhere: anchor places no xterm. + t.Errorf("the check read %v", b.valuesRead) + } + + // A node none of whose modules declares an own secret composes without asking the vault (the review of L2: a node + // with no secret composed nothing while the vault did not answer); one whose module does, refuses without it. + quiet := mk() + quiet.ownErr = errors.New("no responders") + if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "anchor"}); err != nil { + t.Errorf("anchor, with no own secret, needed the vault: %v", err) + } + if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "laptop"}); err == nil { + t.Error("laptop's media, which declares an own secret, composed without the vault") + } + + // A module the node's want keeps at another build than the trunk's composes from that build's manifest, resolved with + // what it made (issue 1126), on the composing node alone. + kept := mk() + oldXterm := []byte(`{"module":"xterm","version":"1","resources":[{"id":"tools","type":"process","name":"xterm-tools",` + + `"artifact":"tools","run":["./xterm-tools"]}],"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/xterm-tools","binary":"xterm-tools"}]}}`) + kept.manifests["old"] = oldXterm + oldFP := "src2:" + strings.Repeat("9", 64) + toolsRef := "artifact-store://xterm/tools/blobs/sha256:" + strings.Repeat("1", 64) + kept.builds = map[string]*delivery.BuilderBuild{"xterm " + oldFP: {State: delivery.BuildDone, Manifest: "old", + Artifacts: []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}} + parsedOld, err := catalogue.Decode(oldXterm) + if err != nil { + t.Fatal(err) + } + keptBuilt, err := parsedOld.Resolve([]catalogue.Built{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}) + if err != nil { + t.Fatal(err) + } + in, err := Input(ctx, kept, Mesh{}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": oldFP}}) + if err != nil || !reflect.DeepEqual(in.Nodes[1].Kept, map[string]catalogue.Manifest{"xterm": keptBuilt}) || in.Nodes[0].Kept != nil { + t.Errorf("the want's build is kept on the composing node alone: %v, %v", in.Nodes, err) + } + + // Issue 1126, the seed's refusals. restic and the provisioner's client as the catalogue holds them (mesh-catalog + // c05aa5e6), restic moved with an own secret the vault leaves out (it hands it to the client, mesh-vault ownnode.go + // handed): composed from the indexer's manifest restic is not handed and stands in for that secret, as novox's, + // ace's, g14's and shanks's answers refused; composed from its build, it is handed, its templates archive read + // where the node reaches the artifact store, and the composition is the one the controller's take-in gives. + resticRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.module.json") + if err != nil { + t.Fatal(err) + } + clientRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json") + if err != nil { + t.Fatal(err) + } + store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` + + `"serves":{"artifact-store":{"port":5000}}}`) + texts := map[string]string{} + var packed bytes.Buffer + zw := gzip.NewWriter(&packed) + tw := tar.NewWriter(zw) + for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} { + text, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.files/" + f) + if err != nil { + t.Fatal(err) + } + texts[f] = string(text) + if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(text); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + archiveDigest := delivery.ManifestDigest(packed.Bytes()) + digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) } + movedFps := map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64), + "store": "src2:" + strings.Repeat("5", 64)} + resticArtifacts := []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")}, + {Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archiveDigest, Digest: archiveDigest}} + clientArtifacts := []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}} + movedMesh := func(built bool) *bus { + b := one() + p := b.placements["n1"] + p.Modules = []delivery.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}} + p.Decided.Account, p.Decided.AccountHome = "operator", "/home/operator" + b.placements["n1"] = p + rec := b.records["n1"] + rec.Contract, rec.Capabilities = compose.HandedContract, []string{"systemd", "package-manager"} + b.records["n1"] = rec + b.modules = map[string]delivery.Module{} + b.manifests = map[string][]byte{} + for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store} { + b.modules[module] = delivery.Module{FP2: movedFps[module], Manifest: delivery.ManifestDigest(raw)} + b.manifests[delivery.ManifestDigest(raw)] = raw + } + // The vault's own answer: the client's own secret; restic's is the client's to ask, so own leaves it out. + b.own = []compose.OwnSecretRow{{Node: "n1", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} + if built { + b.builds = map[string]*delivery.BuilderBuild{ + "restic " + movedFps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: resticArtifacts}, + "mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw), Artifacts: clientArtifacts}, + "store " + movedFps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)}, + } + b.archives = map[string][]byte{archiveDigest: packed.Bytes()} + } + return b + } + if out, _, held, err := Compose(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], `restic needs a secret called "repository"`) { + t.Errorf("restic composed from the indexer's manifest: %v %v, want its own secret refusing restic alone", err, held) + } else { + for key, c := range out { + if c.Module == "restic" { + // Its stub's composition, applied, would remove what restic runs: a held module keeps its last one. + t.Errorf("held restic is composed as %s, want none of its own composition handed", key) + } + } + } + builtBus := movedMesh(true) + fromBuild, _, held, err := Compose(ctx, builtBus, Mesh{}, Scope{Node: "n1"}, movedFps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } + if err != nil { + t.Fatalf("restic composed from its build: %v", err) + } + if !reflect.DeepEqual(builtBus.fetched, []string{"http://127.0.0.1:5000/v2/restic/files/blobs/" + archiveDigest}) { + t.Errorf("the templates archive was read at %v", builtBus.fetched) + } + resticKey, _ := delivery.ComposedKey("n1", "restic", movedFps["restic"]) + if c := fromBuild[resticKey]; len(c.Filled) != 4 { // its three templated files and its own secret + t.Errorf("restic is not handed to the provisioner: its filled files are %v", c.Filled) + } + // A done key an artifact of which names no reference is waited for, in the controller's words (ADR 0498 §8). + unreferenced := movedMesh(true) + unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]} + if _, _, held, err := Compose(ctx, unreferenced, Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], "the build of restic is done and waits for the reference of tools") { + t.Errorf("a done key with an unreferenced artifact: %v %v, want restic held alone", err, held) + } + // A module the node runs inside its own composition (the fold) composes from the indexer's manifest though a done + // build of it is at the trunk's FP2: its build is not read. + folding := movedMesh(true) + foldIn, err := Input(ctx, folding, Mesh{}, Scope{Node: "n1", InOwn: []string{"mesh-provisioner-client"}}) + if err != nil { + t.Fatal(err) + } + clientParsed, err := catalogue.Decode(clientRaw) + if err != nil { + t.Fatal(err) + } + if slices.Contains(folding.buildsRead, "mesh-provisioner-client") || !reflect.DeepEqual(foldIn.Catalogue["mesh-provisioner-client"], clientParsed) { + t.Errorf("a folded module was taken from its build: builds read %v", folding.buildsRead) + } + // The controller's: its take-in resolves each manifest with the key's artifacts and the archive's texts, and composes + // from those over the same input. + storeIn, err := Input(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}) + if err != nil { + t.Fatal(err) + } + for module, built := range map[string][]catalogue.Built{ + "restic": {{Name: "tools", Kind: "bundle", Reference: resticArtifacts[0].Reference, Digest: digestOf("6")}, + {Name: "files", Kind: "archive", Reference: resticArtifacts[1].Reference, Digest: archiveDigest, Texts: texts}}, + "mesh-provisioner-client": {{Name: "client", Kind: "bundle", Reference: clientArtifacts[0].Reference, Digest: digestOf("7")}}, + } { + resolved, err := storeIn.Catalogue[module].Resolve(built) + if err != nil { + t.Fatal(err) + } + // Kept as its bytes and read again, as the controller's store holds a build's manifest. + raw, _ := json.Marshal(resolved) + if storeIn.Catalogue[module], err = catalogue.Decode(raw); err != nil { + t.Fatal(err) + } + } + storeIn.OwnSecrets = movedMesh(true).own + fromStoreMoved, _, err := compose.Compositions(storeIn, "n1", movedFps, compose.Reading) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(fromBuild, fromStoreMoved) { + gj, _ := json.MarshalIndent(fromBuild, "", " ") + wj, _ := json.MarshalIndent(fromStoreMoved, "", " ") + t.Errorf("composed from the build differs from the controller's take-in:\n got %s\nwant %s", gj, wj) + } + // ADR 0570 clause 2 (issue 1954 L6-P1): the seat mesh-provisioner's plans, composed from the bus alone and with no + // secret read (the Planning mode), are the controller's handedPlans over its own input, which holds its secrets + // (compose_sdk.go: Resolve, Render in Foreseeing, Generators, Plan.Compose), and restic, handed, has one. + noSecrets := movedMesh(true) + noSecrets.own = nil + seat, err := Plans(ctx, noSecrets, Mesh{}, Scope{Node: "n1"}) + if err != nil { + t.Fatalf("the plans from the bus: %v", err) + } + controllers, err := plansIn(storeIn, "n1", compose.Foreseeing) + if err != nil { + t.Fatal(err) + } + if _, handed := seat["restic"]; !handed || !reflect.DeepEqual(seat, controllers) { + gj, _ := json.MarshalIndent(seat, "", " ") + wj, _ := json.MarshalIndent(controllers, "", " ") + t.Errorf("the seat's plans differ from the controller's:\n got %s\nwant %s", gj, wj) + } + // ADR 0571 clause 3: the data items of n1's modules, placed, from the bus alone: what disk-load measures. + items, unplaced, err := DataItems(ctx, noSecrets, Mesh{}, Scope{Node: "n1"}) + if err != nil || len(unplaced) > 0 || len(items) == 0 { + t.Errorf("the data items from the bus are %v, unplaced %v: %v", items, unplaced, err) + } + for module, its := range items { + for _, it := range its { + if it.Module != module || !strings.HasPrefix(it.Path, "/") || it.Measure == "" { + t.Errorf("%s's item %+v is not placed", module, it) + } + } + } + // And the same through StatesReader, over the fake's states as a module's runtime gives them (one reader for + // mesh-provisioner and disk-load, the lead 2026-10-29). + viaStates, _, err := DataItems(ctx, StatesReader(statesOf(t, ctx, noSecrets), func(_ context.Context, url string) ([]byte, error) { + for _, body := range noSecrets.archives { + return body, nil + } + return nil, errors.New("no archive") + }), Mesh{}, Scope{Node: "n1"}) + if err != nil || !reflect.DeepEqual(viaStates, items) { + t.Errorf("through the states the data items are %v, %v; through the reader %v", viaStates, err, items) + } + + // And what a provider on another node serves from its settings (mailu's smtp domain, issue 1126: shanks refused + // '${setting:domain} ... nothing sets "domain"'): read for that provider's served keys alone, through Served. + mailer := []byte(`{"module":"mailer","version":"1","config":{"domain":{"type":"string","description":"the mail domain","required":true}},` + + `"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`) + withMailer := func(set bool) *bus { + b := mk() + p := b.placements["anchor"] + p.Modules = append(p.Modules, delivery.PlacedModule{Module: "mailer"}) + b.placements["anchor"] = p + b.modules["mailer"] = delivery.Module{Manifest: delivery.ManifestDigest(mailer)} + b.manifests[delivery.ManifestDigest(mailer)] = mailer + if set { + b.served = map[string]map[string]any{"anchor.mailer": {"domain": "example.org"}} + } + return b + } + if _, _, _, err := Compose(ctx, withMailer(false), Mesh{}, Scope{Node: "laptop"}, fps); err == nil || !strings.Contains(err.Error(), `nothing sets "domain"`) { + t.Errorf("a provider's served setting nobody sets: %v", err) + } + served := withMailer(true) + if _, _, held, err := Compose(ctx, served, Mesh{}, Scope{Node: "laptop"}, fps); err != nil || len(held) > 0 { + t.Errorf("laptop with what anchor's mailer serves: %v %v", err, held) + } + if !reflect.DeepEqual(served.servedRead, []string{"anchor.mailer:domain"}) || slices.Contains(served.valuesRead, "anchor.mailer") { + t.Errorf("laptop read %v of other nodes (and %v of config values): the served keys of anchor's mailer alone", served.servedRead, served.valuesRead) + } + + for name, c := range map[string]struct { + change func(*bus) + scope Scope + says string + }{ + "no record": {func(b *bus) { delete(b.records, "laptop") }, Scope{Node: "anchor"}, "laptop, whose agent has written no record"}, + "no placement": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no placement"}, + "a manifest unread": {func(b *bus) { delete(b.manifests, digest) }, Scope{Node: "laptop"}, "the manifest of xterm"}, + "a kept one unbuilt": {func(b *bus) {}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": "src2:gone"}}, "holds no done build"}, + } { + b := mk() + c.change(b) + if _, err := Input(ctx, b, Mesh{}, c.scope); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v, want it to say %q", name, err, c.says) + } + } +} + +// mapStates are read states by name, key to value, as a module's runtime gives them. +type mapStates map[string]map[string][]byte + +func (m mapStates) Get(state, key string) ([]byte, bool, error) { + v, ok := m[state][key] + return v, ok, nil +} + +func (m mapStates) Keys(state string) ([]string, error) { + var out []string + for k := range m[state] { + out = append(out, k) + } + sort.Strings(out) + return out, nil +} + +// statesOf is b laid out as the states StatesReader reads. +func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates { + t.Helper() + out := mapStates{} + put := func(state, key string, raw []byte, err error) { + if err != nil { + t.Fatal(err) + } + if out[state] == nil { + out[state] = map[string][]byte{} + } + out[state][key] = raw + } + placements, _ := b.Placements(ctx) + for _, p := range placements { + values, err := delivery.PlacementValues(p) + for k, raw := range values { + put(PlacementRead, k, raw, err) + } + } + records, _ := b.Records(ctx) + for node, r := range records { + k, _ := delivery.HaveNodeKey(node) + rec := r + raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec}) + put(HaveRead, k, raw, err) + } + modules, _ := b.Modules(ctx) + for name, m := range modules { + raw, err := delivery.EncodeModule(m) + put(ModulesRead, name, raw, err) + var mf struct{ Module string } + _ = json.Unmarshal(b.manifests[m.Manifest], &mf) + k, _ := delivery.ManifestsKey(m.Manifest) + raw, err = delivery.EncodeManifest(delivery.Manifest{Module: mf.Module, JSON: string(b.manifests[m.Manifest])}) + put(ManifestsRead, k, raw, err) + } + for key, build := range b.builds { + module, fp, _ := strings.Cut(key, " ") + k, _ := delivery.BuilderBuildKey(module, fp) + raw, err := delivery.EncodeBuilderBuild(*build) + put(BuildsRead, k, raw, err) + } + for nm, values := range b.values { + for k, v := range values { + value, _ := json.Marshal(v) + raw, err := json.Marshal(map[string]any{"value": json.RawMessage(value), "source": catalogue.SourceModule}) + put(ResolvedRead, nm+"."+k, raw, err) + } + } + return out +} diff --git a/internal/compose/frombus/frombustest/frombustest.go b/internal/compose/frombus/frombustest/frombustest.go new file mode 100644 index 0000000..a627e30 --- /dev/null +++ b/internal/compose/frombus/frombustest/frombustest.go @@ -0,0 +1,183 @@ +// Package frombustest is a bus in memory for frombus's Reader, and a mesh on it whose composition hands a module to the +// provisioner with a provision to make, so a module that composes plans from the bus (mesh-provisioner, novox/hq ADR +// 0570 clause 2) tests against a real plan rather than one written by hand. The manifests are the catalogue's (mesh-catalog +// c05aa5e6: restic and the provisioner's client) as frombus's own tests read them, restic given one requirement more. +package frombustest + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "embed" + "encoding/json" + "errors" + "strings" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/frombus" +) + +//go:embed testdata +var testdata embed.FS + +// Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set. +type Bus struct { + Placement map[string]delivery.Placement + Record map[string]delivery.NodeRecord + Module map[string]delivery.Module + Manifests map[string][]byte + Value map[string]map[string]any // by node.module + Own []compose.OwnSecretRow + Builds map[string]*delivery.BuilderBuild // by " " + Archives map[string][]byte // by digest + Serves map[string]map[string]any // another node's values, by node.module + Held bool +} + +var _ frombus.Reader = (*Bus)(nil) + +func (b *Bus) Placements(context.Context) (map[string]delivery.Placement, error) { + return b.Placement, nil +} +func (b *Bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.Record, nil } +func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil } +func (b *Bus) VaultAuthority(context.Context) string { return "" } + +func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) { + raw, ok := b.Manifests[digest] + if !ok { + return nil, errors.New("no such manifest") + } + return raw, nil +} + +func (b *Bus) Values(_ context.Context, node, module string) (map[string]any, error) { + return b.Value[node+"."+module], nil +} + +func (b *Bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { return b.Own, nil } + +func (b *Bus) Credentials(_ context.Context, _ string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) { + var out []compose.Credential + var issued []compose.Issued + for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) { + if b.Held { + out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule, + Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`}) + issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule, + Provider: k.Provider, Local: k.Local, ForProvider: `"box"`}) + } + } + return out, issued, nil +} + +func (b *Bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) { + return b.Builds[module+" "+fp], nil +} + +func (b *Bus) Archive(_ context.Context, _, digest string) ([]byte, error) { + body, ok := b.Archives[digest] + if !ok { + return nil, errors.New("the store holds no such blob") + } + return body, nil +} + +func (b *Bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) { + out := map[string]any{} + for _, k := range keys { + if v, ok := b.Serves[node+"."+module][k]; ok { + out[k] = v + } + } + return out, nil +} + +// Node is the one node of the mesh Provisioned composes. +const Node = "n1" + +// Provisioned is one node, n1, that runs the artifact store, a postgres providing postgres-database, the provisioner's +// client and restic: restic built and handed to the provisioner, and requiring postgres-database. Its plans (frombus.Plans +// with Scope{Node: Node}) hand restic, whose plan names that provision with postgres on n1 as its provider. +func Provisioned() (*Bus, error) { + read := func(name string) ([]byte, error) { return testdata.ReadFile("testdata/" + name) } + resticRaw, err := read("catalogue-c05aa5e6-restic.module.json") + if err != nil { + return nil, err + } + var restic map[string]any + if err := json.Unmarshal(resticRaw, &restic); err != nil { + return nil, err + } + restic["requires"] = []string{"postgres-database"} + if resticRaw, err = json.Marshal(restic); err != nil { + return nil, err + } + clientRaw, err := read("catalogue-c05aa5e6-mesh-provisioner-client.module.json") + if err != nil { + return nil, err + } + store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` + + `"serves":{"artifact-store":{"port":5000}}}`) + postgres := []byte(`{"module":"postgres","version":"1","provides":[{"name":"postgres-database","scope":"mesh"}],` + + `"serves":{"postgres-database":{"port":5432}}}`) + + var packed bytes.Buffer + zw := gzip.NewWriter(&packed) + tw := tar.NewWriter(zw) + for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} { + text, err := read("catalogue-c05aa5e6-restic.files/" + f) + if err != nil { + return nil, err + } + if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil { + return nil, err + } + if _, err := tw.Write(text); err != nil { + return nil, err + } + } + if err := tw.Close(); err != nil { + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + archive := delivery.ManifestDigest(packed.Bytes()) + digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) } + fps := FPs() + b := &Bus{ + Placement: map[string]delivery.Placement{Node: {Node: Node, + Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"}, + Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}}, + Record: map[string]delivery.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=", + SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}}, + Module: map[string]delivery.Module{}, Manifests: map[string][]byte{}, + Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}, + Builds: map[string]*delivery.BuilderBuild{ + "restic " + fps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: []delivery.BuiltArtifact{ + {Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")}, + {Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archive, Digest: archive}}}, + "mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw), + Artifacts: []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", + Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}}, + "store " + fps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)}, + "postgres " + fps["postgres"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(postgres)}, + }, + Archives: map[string][]byte{archive: packed.Bytes()}, + Held: true, + } + for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store, "postgres": postgres} { + b.Module[module] = delivery.Module{FP2: fps[module], Manifest: delivery.ManifestDigest(raw)} + b.Manifests[delivery.ManifestDigest(raw)] = raw + } + return b, nil +} + +// FPs are the fingerprints Provisioned's modules are built at. +func FPs() map[string]string { + return map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64), + "store": "src2:" + strings.Repeat("5", 64), "postgres": "src2:" + strings.Repeat("8", 64)} +} diff --git a/internal/compose/frombus/frombustest/frombustest_test.go b/internal/compose/frombus/frombustest/frombustest_test.go new file mode 100644 index 0000000..e6a0311 --- /dev/null +++ b/internal/compose/frombus/frombustest/frombustest_test.go @@ -0,0 +1,272 @@ +package frombustest + +import ( + "context" + "reflect" + "strings" + "testing" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose/frombus" + "github.com/novox/mesh-host/internal/compose/fromruntime" +) + +// ADR 0570 clause 2 (issue 1954; a3's H5): the in-memory mesh composes a plan from the bus that hands restic to the +// provisioner and names its postgres-database, provided by postgres on n1. And one bad module must not drop its node +// (issue 774): with a module placed beside it whose built manifest cannot be used, or whose plan refuses, PlansHeld +// still hands restic's plan and names the bad one with why, where Plans refuses the whole node. A manifest the indexer +// holds that the mesh refuses (power's bar item with attributes its bar places by none of, mesh-catalog ca8d6f33, +// 2026-10-30) holds that module alone on its node, in the plans and in the node's composition (Compose), and on a node +// it is not placed on holds nothing. +func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { + fp := "src2:" + strings.Repeat("9", 64) + for _, row := range []struct { + name string + bad string // the indexer's manifest of a module "backup" placed on n1; empty places none + // built is the manifest backup's done build names; empty gives it no build + built string + why string // words backup's hold says + // elsewhere leaves backup in the indexer's modules and places it on no node: nothing is held. + elsewhere bool + // refused is the indexer's verdict on bad, a problem its judge found (novox/hq ADR 0578 §1): Plans then holds + // backup too, and never ends the read. + refused string + // dataHeld says the data items' read holds backup too: a refusal it meets (a refused manifest, an unusable build, + // an item composition refuses), not one only a plan meets. + dataHeld bool + // dataOnly checks the data items' read alone: the other reads blame restic, whose templates collect the item. + dataOnly bool + }{ + {name: "every module composes"}, + {name: "a built manifest that cannot be used", + bad: `{"module":"backup","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, + built: `{"module":"backup","version":"1","requires":"amqp"}`, why: "backup", dataHeld: true}, + {name: "a plan that refuses", + bad: `{"module":"backup","version":"1","resources":[{"id":"conf","type":"file","path":"/etc/backup.conf",` + + `"mode":"0600","content":"key=${secret:nope}"}]}`, why: "${secret:nope}"}, + {name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`, + refused: `"shows" is none of them`, dataHeld: true}, + // ADR 0578 §3: an accepted manifest whose data item its composition refuses is held by the culprit rule, in every read. + {name: "a data item its composition refuses", + bad: `{"module":"backup","version":"1","data":{"own":[{"id":"d","path":"${dir:nope}","class":"cache"}]}}`, + why: "nope", dataHeld: true, dataOnly: true}, + {name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true, + refused: `"shows" is none of them`}, + } { + b, err := Provisioned() + if err != nil { + t.Fatal(err) + } + if row.bad != "" { + if !row.elsewhere { + p := b.Placement[Node] + p.Modules = append(p.Modules, delivery.PlacedModule{Module: "backup"}) + b.Placement[Node] = p + } + digest := delivery.ManifestDigest([]byte(row.bad)) + b.Manifests[digest] = []byte(row.bad) + b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest} + if row.refused != "" { + b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest, Verdict: delivery.VerdictRefused, + Problems: []string{row.refused}} + } + if row.built != "" { + builtDigest := delivery.ManifestDigest([]byte(row.built)) + b.Manifests[builtDigest] = []byte(row.built) + b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest, + Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}} + } + if row.dataOnly { + items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) + if why, ok := iheld["backup"]; err != nil || !ok || !strings.Contains(why, row.why) || len(items["restic"]) == 0 { + t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err) + } + continue + } + plans, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) + switch { + case row.refused != "" && (err != nil || !planned(plans, "restic") || planned(plans, "backup")): + t.Errorf("%s: Plans did not hold the refused manifest alone: %v %v", row.name, plans, err) + case row.refused == "" && err == nil && !row.elsewhere: + t.Errorf("%s: Plans composed the node; the row does not refuse it", row.name) + } + } + if row.why != "" || row.bad == "" { + // The node's own composition holds by the same rule: every other module composed, the bad one named. + fps := FPs() + if row.bad != "" { + fps["backup"] = fp + } + out, _, held, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps) + if err != nil { + t.Fatalf("%s: the node's composition: %v", row.name, err) + } + if key, _ := delivery.ComposedKey(Node, "restic", fps["restic"]); out[key].Module != "restic" { + t.Errorf("%s: restic is not composed (held %v)", row.name, held) + } + if why, ok := held["backup"]; (row.bad == "" && len(held) > 0) || (row.bad != "" && (!ok || !strings.Contains(why, row.why) || len(held) != 1)) { + t.Errorf("%s: the node's composition held %v, want backup alone, saying %q", row.name, held, row.why) + } + } + plans, held, err := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) + if err != nil { + t.Fatalf("%s: %v", row.name, err) + } + restic, handed := plans["restic"] + if !handed || len(restic.Provisions) != 1 || restic.Provisions[0].Provision != "postgres-database" || + restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" { + t.Fatalf("%s: the plans are %+v (held %v)", row.name, plans, held) + } + // Issue 1954: the plans at the wanted builds and at the newest, over one read, are what PlansHeld answers for each, + // and where the time went is said by phase. + keptAt := FPs() + at, newest, took := frombus.PlansHeldTwice(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt}) + wantAt, heldAt, errAt := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt}) + if !reflect.DeepEqual(at, frombus.HeldPlans{Plans: wantAt, Held: heldAt, Err: errAt}) || + !reflect.DeepEqual(newest, frombus.HeldPlans{Plans: plans, Held: held}) { + t.Errorf("%s: twice composed %+v and %+v, want %v %v %v and %v %v", row.name, at, newest, wantAt, heldAt, errAt, plans, held) + } + if said := frombus.PhasesSaid(took); !strings.HasPrefix(said, "read ") || !strings.Contains(said, "plans at newest ") { + t.Errorf("%s: the phases are said as %q", row.name, said) + } + if row.bad == "" || row.elsewhere { + if len(held) > 0 { + t.Errorf("%s: held %v", row.name, held) + } + continue + } + if why, ok := held["backup"]; !ok || !strings.Contains(why, row.why) || len(held) != 1 { + t.Errorf("%s: held %v, want backup alone, saying %q", row.name, held, row.why) + } + if _, planned := plans["backup"]; planned { + t.Errorf("%s: the held module has a plan", row.name) + } + // The data items are read by the same rule (ADR 0578 §3: disk-load's read): the bad module held, the read whole. + items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) + // A refusal only a plan meets (a file's secret) holds nothing here: data items render no such file. + mustHold := row.dataHeld + if why, ok := iheld["backup"]; err != nil || ok != mustHold || mustHold && !strings.Contains(why, row.why) || items["backup"] != nil { + t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err) + } + } + + // ADR 0580 clause 1: the watched reader reads a state it composes from out of the copy, a build by key each time it is + // asked, and a manifest once per digest. + byKey := map[string]int{} + ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error { + byKey[state+" watched"]++ + return handle(stdio.StateChange{Key: "placement." + Node, Op: stdio.OpPut, Value: []byte(`{}`), Current: true}) + }, func(state, key string) ([]byte, bool, error) { byKey[state]++; return []byte(key), true, nil }) + w := fromruntime.Watched{WatchedStates: ws} + for range 2 { + _, _, _ = w.Get(frombus.BuildsRead, "restic."+fp) + _, _, _ = w.Get(frombus.ManifestsRead, "sha256-d") + if _, found, _ := w.Get(frombus.PlacementRead, "placement."+Node); !found { + t.Error("the watched reader does not answer the placement from its copy") + } + } + if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.PlacementRead] != 0 || + byKey[frombus.PlacementRead+" watched"] != 1 { + t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the placement watched once", byKey) + } + fetched := 0 + keep := fromruntime.KeptFetch(func(context.Context, string) ([]byte, error) { fetched++; return []byte("tar"), nil }) + for range 2 { + if body, err := keep(context.Background(), "http://store/restic/files/blobs/sha256:a"); err != nil || string(body) != "tar" { + t.Fatalf("the kept fetch answered %q, %v", body, err) + } + } + if fetched != 1 { + t.Errorf("a templates archive was fetched %d times, want once", fetched) + } + + // Issue 1954: the states reader takes a build in once per its content: composed again, its archive is not read again + // and the plans are the same. + b, err := Provisioned() + if err != nil { + t.Fatal(err) + } + reader, archives := frombus.StatesReader(statesOf(t, b), func(_ context.Context, url string) ([]byte, error) { + fetched++ + _, digest, _ := strings.Cut(url, "/blobs/") + return b.Archives[digest], nil + }), fetched + var first map[string]catalogue.Plan + for i := range 2 { + plans, held, err := frombus.PlansHeld(context.Background(), reader, frombus.Mesh{}, frombus.Scope{Node: Node}) + if err != nil || len(held) > 0 || (i == 1 && !reflect.DeepEqual(plans, first)) || !planned(plans, "restic") { + t.Fatalf("composed %v (held %v): %v", plans, held, err) + } + first = plans + } + if fetched-archives != 1 { + t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives) + } +} + +// statesOf is the bus b as a module's read states hold it. +func statesOf(t *testing.T, b *Bus) frombus.States { + t.Helper() + s := states{} + put := func(state, key string, raw []byte, err error) { + if err != nil { + t.Fatal(err) + } + if s[state] == nil { + s[state] = map[string][]byte{} + } + s[state][key] = raw + } + for node, p := range b.Placement { + raw, err := delivery.EncodePlacement(p) + put(frombus.PlacementRead, "placement."+node, raw, err) + } + for node, rec := range b.Record { + raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec}) + put(frombus.HaveRead, node+".node", raw, err) + } + for module, m := range b.Module { + key, _ := delivery.ModulesKey(module) + raw, err := delivery.EncodeModule(m) + put(frombus.ModulesRead, key, raw, err) + } + for digest, man := range b.Manifests { + key, _ := delivery.ManifestsKey(digest) + raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: string(man)}) + put(frombus.ManifestsRead, key, raw, err) + } + for at, build := range b.Builds { + module, fp, _ := strings.Cut(at, " ") + key, _ := delivery.BuilderBuildKey(module, fp) + raw, err := delivery.EncodeBuilderBuild(*build) + put(frombus.BuildsRead, key, raw, err) + } + return s +} + +// states are read states in memory. +type states map[string]map[string][]byte + +func (s states) Get(state, key string) ([]byte, bool, error) { + v, ok := s[state][key] + return v, ok, nil +} + +func (s states) Keys(state string) ([]string, error) { + var keys []string + for k := range s[state] { + keys = append(keys, k) + } + return keys, nil +} + +func planned[P any](plans map[string]P, module string) bool { _, ok := plans[module]; return ok } + +// unusableItem is a manifest the mesh refuses as the node-engine did power's at mesh-catalog ca8d6f33: a bar block item +// with attributes its bar places by none of. +const unusableItem = `{"module":"backup","version":"1","moved":true,` + + `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` + + `"items":[{"collection":"contribution:node-bar:block",` + + `"attributes":{"bar":"bottom","place":"status","shows":"state","state":"power.draw"},"template":"files/bar"}]}` diff --git a/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json new file mode 100644 index 0000000..095128a --- /dev/null +++ b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json @@ -0,0 +1,60 @@ +{ + "module": "mesh-provisioner-client", + "version": "1", + "claims": [ + { + "name": "node-provisioner", + "scope": "node" + } + ], + "own-secrets": { + "provisioner-broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-provisioner-client", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-provisioner-client", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-provisioner-client" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-provisioner-client", + "place": ".", + "unknown": "program" + }, + { + "id": "client", + "type": "process", + "name": "mesh-provisioner-client", + "artifact": "client", + "run": [ + "./mesh-provisioner-client" + ], + "env": { + "MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker", + "MESH_PROVISIONER_STATE": "${dir:state}" + }, + "user": "mesh-provisioner-client", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "client", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-provisioner-client", + "binary": "mesh-provisioner-client" + } + ] + } +} diff --git a/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf new file mode 100644 index 0000000..2f23f90 --- /dev/null +++ b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf @@ -0,0 +1,2 @@ +# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit. +${backup:lines} \ No newline at end of file diff --git a/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf new file mode 100644 index 0000000..f19e849 --- /dev/null +++ b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf @@ -0,0 +1,2 @@ +# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit. +${backup:data} \ No newline at end of file diff --git a/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf new file mode 100644 index 0000000..cd7954c --- /dev/null +++ b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf @@ -0,0 +1,6 @@ +# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily, +# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file. +daily=${config:keep-daily} +weekly=${config:keep-weekly} +monthly=${config:keep-monthly} +hand=${config:keep-hand-taken-days} diff --git a/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json new file mode 100644 index 0000000..ea5ec8b --- /dev/null +++ b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json @@ -0,0 +1,199 @@ +{ + "module": "restic", + "version": "1", + "moved": true, + "claims": [ + { + "name": "node-backup", + "scope": "node", + "serves": [ + "backed-up", + "now", + "restore" + ] + } + ], + "emits": [ + "backed-up" + ], + "tools": [ + "backup_health", + "backup_delete_retired", + "backup_deleted" + ], + "own-secrets": { + "repository": "${dir:state}/repository.secret" + }, + "data": { + "own": [ + { + "id": "repository", + "path": "${dir:repository}", + "class": "rebuildable", + "backup": "none", + "why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does" + }, + { + "id": "state", + "path": "${dir:state}", + "class": "cache", + "why": "what the last nights and measurements were; the next night writes it again" + } + ] + }, + "config": { + "keep-daily": { + "type": "number", + "description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 14 + }, + "keep-weekly": { + "type": "number", + "description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 8 + }, + "keep-monthly": { + "type": "number", + "description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 6 + }, + "keep-hand-taken-days": { + "type": "number", + "description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 7 + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "repository", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "declared", + "type": "file", + "path": "${dir:state}/backups.conf", + "mode": "0600", + "template": "files/backups.conf" + }, + { + "id": "data-declared", + "type": "file", + "path": "${dir:state}/data.conf", + "mode": "0600", + "template": "files/data.conf" + }, + { + "id": "retention", + "type": "file", + "path": "${dir:state}/retention.conf", + "mode": "0644", + "trusted": true, + "template": "files/retention.conf" + }, + { + "id": "tool", + "type": "package", + "package": "restic" + }, + { + "id": "sqlite", + "type": "package", + "package": "sqlite" + }, + { + "id": "night", + "type": "process", + "name": "restic-night", + "artifact": "tools", + "run": [ + "./restic-backups", + "night" + ], + "user": "${machine:account}", + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + }, + "health": { + "kind": "tool", + "tool": "backup_health", + "interval": "60s", + "timeout": "20s", + "looks": 2, + "grace": "60s" + } + }, + { + "id": "measure", + "type": "process", + "name": "restic-measure", + "artifact": "tools", + "run": [ + "./restic-backups", + "measure" + ], + "user": "${machine:account}", + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + }, + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/restic-backups", + "binary": "restic-backups", + "loads": [ + "restic-backups" + ], + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/internal/compose/frombus/held_rows_test.go b/internal/compose/frombus/held_rows_test.go new file mode 100644 index 0000000..7bde353 --- /dev/null +++ b/internal/compose/frombus/held_rows_test.go @@ -0,0 +1,298 @@ +package frombus + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" +) + +// aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given. +func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) { + t.Helper() + p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} + b := &bus{records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}}, + modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}} + fps := map[string]string{} + for i, raw := range manifests { + module := strings.SplitN(strings.TrimPrefix(raw, `{"module":"`), `"`, 2)[0] + p.Modules = append(p.Modules, delivery.PlacedModule{Module: module}) + b.modules[module] = delivery.Module{Manifest: delivery.ManifestDigest([]byte(raw))} + b.manifests[delivery.ManifestDigest([]byte(raw))] = []byte(raw) + fps[module] = "src2:" + strings.Repeat(string(rune('1'+i)), 64) + } + b.placements = map[string]delivery.Placement{"n1": p} + return b, fps +} + +// Issue 774, 2026-10-30 19:30: a node refused whole after holding a module says what it held and why, since the last +// refusal can be of the held module's stub (node-tools held, then "its build produced 0 bundle(s)", its own why unsaid). +func aNodeRefusedWholeSaysWhatItHeld(t *testing.T) { + refused := `{"module":"power","version":"1","items":[{"collection":"contribution:node-bar:block","attributes":{"shows":"state"},"template":"files/a"}]}` + needs := `{"module":"lamp","version":"1","requires":["nothing-provides-this"]}` + b, fps := aNode(t, refused, needs) + refuse(b, "power", `"shows" is none of them`) + _, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps) + if err == nil || !strings.Contains(err.Error(), "nothing provides") || !strings.Contains(err.Error(), `held before: power: `) || + !strings.Contains(err.Error(), `"shows" is none of them`) { + t.Errorf("the node refused whole with %v (held %v), want lamp's refusal and power held before, with its why", err, held) + } +} + +// refuse writes the indexer's refusal of module's manifest into its modules value (novox/hq ADR 0578 §1). +func refuse(b *bus, module string, problems ...string) { + m := b.modules[module] + m.Verdict, m.Problems = delivery.VerdictRefused, problems + b.modules[module] = m +} + +// ADR 0578 §3: the bus reader never ends a whole read on a refused manifest. Input, Plans and DataItems compose the rest +// and hold the refused module, named with the indexer's words in Refused; a field the decoder does not know is set aside +// and named, and its module composed. +func aRefusedManifestNeverEndsTheRead(t *testing.T) { + ok := `{"module":"lamp","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/lamp","mode":"0700"}],` + + `"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}` + bad := `{"module":"power","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/power","mode":"0700"}],` + + `"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}` + later := `{"module":"later","version":"1","a-field-from-later":1}` + b, _ := aNode(t, ok, bad, later) + refuse(b, "power", "removes a stream it may not") + ctx := context.Background() + in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"}) + if err != nil { + t.Fatalf("one refused manifest ended the read: %v", err) + } + if why := in.Refused["power"]; !strings.Contains(why, "refused the manifest of power: removes a stream it may not") || + len(in.Refused) != 1 { + t.Fatalf("refused: %v", in.Refused) + } + if aside := in.SetAside["later"]; !strings.Contains(aside, "a-field-from-later") { + t.Fatalf("set aside: %v", in.SetAside) + } + if !in.Catalogue["power"].Stubbed || in.Catalogue["lamp"].Stubbed || in.Catalogue["later"].Module != "later" { + t.Fatalf("the catalogue: %+v", in.Catalogue) + } + if _, err := Plans(ctx, b, Mesh{}, Scope{Node: "n1"}); err != nil { + t.Fatalf("plans: %v", err) + } + items, _, held, err := DataItemsHeld(ctx, b, Mesh{}, Scope{Node: "n1"}) + if err != nil || len(items["lamp"]) != 1 || items["power"] != nil || !strings.Contains(held["power"], "removes a stream") { + t.Fatalf("data items %v held %v: %v", items, held, err) + } +} + +// Issue 774: the culprit rule takes a module whose stub composes or fails otherwise; the runtime only where its stub +// composes, since its stub always fails otherwise. +func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) { + err := errors.New("lamp refuses") + for _, row := range []struct { + module string + again error + blamed bool + }{ + {"lamp", nil, true}, + {"lamp", errors.New("other"), true}, + {"lamp", errors.New("lamp refuses"), false}, + {catalogue.RuntimeModule, nil, true}, + {catalogue.RuntimeModule, errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false}, + {catalogue.RuntimeModule, fmt.Errorf("compose: %w", &catalogue.NoRuntimeToRunError{Held: true}), true}, + {catalogue.RuntimeModule, &catalogue.NoRuntimeToRunError{}, false}, + } { + if got := blames(row.module, row.again, err); got != row.blamed { + t.Errorf("%s with %v: blamed %v, want %v", row.module, row.again, got, row.blamed) + } + } +} + +// Issue 774, 2026-10-30 19:30: the trunk's node-tools moved to a build not yet done, and ace, g14 and shanks composed +// it from the indexer's manifest, which names no bundle: "its build produced 0 bundle(s)", every module refused. A +// module that runs is now held until its build lands, the runtime composed as what runs of it; and a module wanted at +// another build than the trunk's is composed from the build its want names. +func aRunningModuleWaitsForItsBuild(t *testing.T) { + dg := func(c string) string { return "sha256:" + strings.Repeat(c, 64) } + tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` + + `"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}` + lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}` + store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` + + `"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}` + old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64) + running := []delivery.Composed{{Node: "n1", Module: catalogue.RuntimeModule, FP: old, Resources: []json.RawMessage{ + json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`), + json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` + + dg("1") + `","digest":"` + dg("1") + `","path":"` + catalogue.BundlePath("node-tools", "runtime") + `"}`)}}} + for _, row := range []struct { + name string + want string // the build node-tools' want names + done bool // the trunk's build is done + held string // words node-tools' hold says; empty: composed + digest string // the runtime bundle's digest composed + }{ + {name: "the trunk's build not done yet", want: next, held: "no done build yet"}, + {name: "wanted at the build that runs, the trunk's not done", want: old, digest: dg("1")}, + {name: "the trunk's build done", want: next, done: true, digest: dg("2")}, + } { + b, fps := aNode(t, store, tools, lamp) + b.held = true // every grant held, so a contribution's credential stands in for nothing + b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} + mod := b.modules["node-tools"] + mod.FP2 = next + b.modules["node-tools"] = mod + b.builds["node-tools "+old] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, + Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}} + b.builds["node-tools "+next] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest} + if row.done { + b.builds["node-tools "+next] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, + Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("2"), Digest: dg("2")}}} + } + fps["node-tools"] = row.want + out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1", Running: running}, fps) + if err != nil { + t.Errorf("%s: the node is refused: %v", row.name, err) + continue + } + if lampKey, _ := delivery.ComposedKey("n1", "lamp", fps["lamp"]); out[lampKey].Module != "lamp" { + t.Errorf("%s: lamp is not composed (held %v)", row.name, held) + } + if why := held["node-tools"]; (row.held == "") != (why == "") || !strings.Contains(why, row.held) { + t.Errorf("%s: node-tools held %q, want %q", row.name, why, row.held) + } + if row.digest != "" { + key, _ := delivery.ComposedKey("n1", "node-tools", row.want) + if c := out[key]; !strings.Contains(fmt.Sprint(rawStrings(c.Resources)), row.digest) { + t.Errorf("%s: node-tools is composed as %v, want its bundle %s", row.name, rawStrings(c.Resources), row.digest) + } + } + } +} + +func rawStrings(rs []json.RawMessage) []string { + var out []string + for _, r := range rs { + out = append(out, string(r)) + } + return out +} + +// Issue 774: a refusal of a held module's stand-in leads with why the module was held, its first reason, as mesh-self-check's +// on ace should have; a refusal naming no held module is as it was. +func aHeldModulesFirstReasonLeads(t *testing.T) { + held := map[string]string{"mesh-self-check": "mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check"} + for _, row := range []struct { + err, want string + }{ + {"mesh-self-check runs as its own account and declares no own secret broker", + "mesh-self-check is held: mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check; " + + "its stand-in is then refused: mesh-self-check runs as its own account and declares no own secret broker"}, + {"ace is the hub and has no endpoint", "ace is the hub and has no endpoint"}, + } { + err := errors.New(row.err) + got := firstReason(err, held) + if got.Error() != row.want || !errors.Is(got, err) { + t.Errorf("refused %q, want %q", got, row.want) + } + } +} + +// Issue 774, 2026-10-30 21:04: mesh-conditions and mesh-healers on novox and mesh-self-check on ace were placed before their +// bus accounts were issued. Held for "needs a secret called broker and none was made", each one's bare stub was then +// refused as "runs as its own account and declares no own secret broker", and that refusal failed every module of the +// node (79 on novox, 74 on ace). A held module's stand-in is never refused by a rule about its own runtime or its own +// secrets: the node composes every other module, and the held one keeps the words it was first held for. +func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) { + dg := "sha256:" + strings.Repeat("1", 64) + tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` + + `"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}` + own := func(name string) string { + return `{"module":"` + name + `","version":"1","runs-as":"` + name + `","secrets-owner":"` + name + `","tools":["status"],` + + `"own-secrets":{"broker":"/var/lib/` + name + `/broker"},"contributes":{"artifact-store":{"name":"` + name + `"}},` + + `"resources":[{"id":"account","type":"user","name":"` + name + `"}],` + + `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}` + } + lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}` + store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` + + `"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}` + for _, row := range []struct { + name string + issued []string // the own-account modules whose broker is issued + building bool // node-tools' build is not done, and nothing of it runs + held map[string]string // the modules held, each with words of the reason it was first held for + // ungranted gives the provider a grants file and no consumer's grant is issued yet (a newly placed module's + // usual state): a held module's contribution then stands in for its grant, and is left out (review round 1). + ungranted bool + }{ + {name: "none issued", held: map[string]string{"check": "broker", "heal": "broker"}}, + {name: "none issued, no grant issued", ungranted: true, held: map[string]string{"check": "broker", "heal": "broker"}}, + {name: "one issued", issued: []string{"heal"}, held: map[string]string{"check": "broker"}}, + {name: "both issued", issued: []string{"check", "heal"}}, + // The runtime held: its stand-in is not refused for its bundle; the modules it would run are held, not the node. + {name: "the runtime's build not done", issued: []string{"check", "heal"}, building: true, + held: map[string]string{"node-tools": "its build produced 0 bundle(s)", "check": "not here to run it from", "heal": "not here to run it from"}}, + } { + provider := store + if row.ungranted { + provider = strings.Replace(store, `"receives":`, `"grants":{"artifact-store":"/var/lib/artifacts/grants"},"receives":`, 1) + } + b, fps := aNode(t, provider, tools, own("check"), lamp, own("heal")) + // Every grant held, so the held modules' contributions are composed whole; the row ungranted issues none. + b.held = !row.ungranted + b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} + for _, m := range row.issued { + b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}) + } + for _, m := range []string{"node-tools", "check", "heal"} { + name := "tools" + if m == "node-tools" { + name = "runtime" + } + mod := b.modules[m] + mod.FP2 = fps[m] + b.modules[m] = mod + b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, + Artifacts: []delivery.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}} + if m == "node-tools" && row.building { + b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest} + } + } + out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps) + if err != nil { + t.Errorf("%s: the node is refused: %v", row.name, err) + continue + } + // A held module keeps what it contributes (issue 774, 2026-10-31: nextcloud held for its admin secret dropped out + // of postgres's received file, and postgres's helper would have locked its login): only its own secrets are left + // out of its stand-in, never its contributions, so the provider's received file names every consumer. + storeKey, _ := delivery.ComposedKey("n1", "artifacts", fps["artifacts"]) + // And never a stand-in for a grant not yet issued: the provider is applied, so a held module whose grant is + // not issued is composed without that contribution, never with "foreseen: made by the next send". + for key, c := range out { + if got, _ := json.Marshal(c.Resources); strings.Contains(string(got), "foreseen") { + t.Errorf("%s: %s applies a stand-in: %s", row.name, key, got) + } + } + for _, m := range []string{"check", "heal"} { + if row.ungranted { + break + } + if got, _ := json.Marshal(out[storeKey].Resources); !strings.Contains(string(got), `\"from\": \"`+m+`\"`) { + t.Errorf("%s: the provider's received file drops %s's contribution: %s", row.name, m, got) + } + } + for _, m := range []string{"node-tools", "check", "lamp", "heal"} { + key, _ := delivery.ComposedKey("n1", m, fps[m]) + _, composed := out[key] + why, isHeld := held[m] + want, wantHeld := row.held[m] + if isHeld != wantHeld || composed == wantHeld { + t.Errorf("%s: %s held %v (%q), composed %v; want held %v", row.name, m, isHeld, why, composed, wantHeld) + } else if isHeld && !strings.Contains(why, want) { + t.Errorf("%s: %s is held for %q, want its first reason, %q", row.name, m, why, want) + } + } + } +} diff --git a/internal/compose/frombus/states.go b/internal/compose/frombus/states.go new file mode 100644 index 0000000..fbf92b0 --- /dev/null +++ b/internal/compose/frombus/states.go @@ -0,0 +1,204 @@ +package frombus + +// **The bus read through a module's declared `reads`** (the lead, 2026-10-29: one reader in place of the copies in +// mesh-provisioner and disk-load): a Reader over the states a module reads, by a runtime's Get and Keys (a bundle's +// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: placement, each +// node's record in its own have key, the indexer's modules and manifests, the builder's builds and mesh-config's resolved +// values. No own secret and no credential is read (compose.Planning). Pure as the rest of this package: a bundle's +// runtime states and an HTTP fetch are compose/fromruntime's. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" +) + +// The states a module names in its manifest's `reads` to compose through StatesReader. +const ( + PlacementRead = delivery.PlacementRead + ModulesRead = delivery.IndexerModule + ".modules" + ManifestsRead = delivery.IndexerModule + ".manifests" + BuildsRead = delivery.BuilderModule + ".builds" + HaveRead = "node-engine.have" + ResolvedRead = "mesh-config.resolved" +) + +// StatesReads are those reads, in a manifest's order. +var StatesReads = []string{PlacementRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead} + +// States is a module's read states as its runtime gives them: a key's value, false when it has none. +type States interface { + Get(state, key string) (value []byte, found bool, err error) + Keys(state string) ([]string, error) +} + +// Fetch reads an archive at a URL (fromruntime.HTTPFetch over HTTP). +type Fetch func(ctx context.Context, url string) ([]byte, error) + +// StatesReader is the Reader over s; fetch reads a templates archive, nil for a holder that reads none. +// It keeps what each build's take-in made, by the build's content (issue 1954), so a holder composing again and again +// takes in only a build it has not seen. +func StatesReader(s States, fetch Fetch) Reader { + return statesReader{s: s, fetch: fetch, kept: &keptTakeIns{}} +} + +type statesReader struct { + s States + fetch Fetch + kept *keptTakeIns +} + +func (r statesReader) takenIn(key string) ([]byte, bool) { return r.kept.takenIn(key) } + +func (r statesReader) keepTakenIn(key string, raw []byte) { r.kept.keepTakenIn(key, raw) } + +func (r statesReader) each(read string, visit func(key string, value []byte)) error { + keys, err := r.s.Keys(read) + if err != nil { + return fmt.Errorf("%s: %w", read, err) + } + for _, k := range keys { + v, found, err := r.s.Get(read, k) + if err != nil { + return fmt.Errorf("%s %s: %w", read, k, err) + } + if found { + visit(k, v) + } + } + return nil +} + +func (r statesReader) Placements(context.Context) (map[string]delivery.Placement, error) { + keys := delivery.NewPlacementKeys() + err := r.each(PlacementRead, func(k string, v []byte) { + _, _ = keys.Put(k, v, 0, false) // a key that does not read places nothing, as before + }) + return keys.All(), err +} + +func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord, error) { + out := map[string]delivery.NodeRecord{} + err := r.each(HaveRead, func(k string, v []byte) { + if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode { + if h, err := delivery.DecodeHaveNode(v); err == nil && h.Record != nil { + out[node] = *h.Record + } + } + }) + return out, err +} + +func (r statesReader) Modules(context.Context) (map[string]delivery.Module, error) { + out := map[string]delivery.Module{} + err := r.each(ModulesRead, func(k string, v []byte) { + if module, err := delivery.ParseModulesKey(k); err == nil { + if m, err := delivery.DecodeModule(v); err == nil { + out[module] = m + } + } + }) + return out, err +} + +func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error) { + key, err := delivery.ManifestsKey(digest) + if err != nil { + return nil, err + } + v, found, err := r.s.Get(ManifestsRead, key) + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("the indexer holds no manifest %s", digest) + } + m, err := delivery.DecodeManifest(v) + if err != nil { + return nil, err + } + return []byte(m.JSON), nil +} + +func (r statesReader) Values(_ context.Context, node, module string) (map[string]any, error) { + var out map[string]any + prefix := node + "." + module + "." + err := r.each(ResolvedRead, func(k string, v []byte) { + name, ok := strings.CutPrefix(k, prefix) + if !ok || strings.Contains(name, ".") { + return + } + if value, laid := catalogue.LaidValue(v); laid { + if out == nil { + out = map[string]any{} + } + out[name] = value + } + }) + return out, err +} + +func (statesReader) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { + return nil, nil +} + +func (statesReader) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) { + return nil, nil, nil +} + +func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) { + key, err := delivery.BuilderBuildKey(module, fp) + if err != nil { + return nil, err + } + v, found, err := r.s.Get(BuildsRead, key) + if err != nil || !found { + return nil, err + } + b, err := delivery.DecodeBuilderBuild(v) + if err != nil { + return nil, err + } + return &b, nil +} + +// archiveWithin bounds one read of a templates archive. +const archiveWithin = 30 * time.Second + +func (r statesReader) Archive(ctx context.Context, url, digest string) ([]byte, error) { + if r.fetch == nil { + return nil, fmt.Errorf("this holder reads no archive (%s)", url) + } + fetching, cancel := context.WithTimeout(ctx, archiveWithin) + defer cancel() + body, err := r.fetch(fetching, url) + if err != nil { + return nil, err + } + sum := sha256.Sum256(body) + if got := hex.EncodeToString(sum[:]); got != strings.TrimPrefix(digest, "sha256:") { + return nil, fmt.Errorf("%s was recorded as %s and what arrived is %s", url, digest, got) + } + return body, nil +} + +func (r statesReader) Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) { + all, err := r.Values(ctx, node, module) + var out map[string]any + for _, k := range keys { + if v, ok := all[k]; ok { + if out == nil { + out = map[string]any{} + } + out[k] = v + } + } + return out, err +} diff --git a/internal/compose/frombus/testdata/catalogue-835bf22b-xterm.module.json b/internal/compose/frombus/testdata/catalogue-835bf22b-xterm.module.json new file mode 100644 index 0000000..b65be8e --- /dev/null +++ b/internal/compose/frombus/testdata/catalogue-835bf22b-xterm.module.json @@ -0,0 +1,236 @@ +{ + "module": "xterm", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-terminal-emulator", + "scope": "node", + "serves": [ + "open" + ] + } + ], + "tools": [ + "xterm_font", + "xterm_colours" + ], + "config": { + "face": { + "type": "string", + "description": "The font family xterm draws with, as fc-list names it: JetBrainsMono Nerd Font, because research 026/04 chose it for its glyphs the prompt uses. A family the machine does not have falls back to the nearest installed face (xterm_font says which).", + "pattern": "[A-Za-z0-9 ._-]{1,64}", + "default": "JetBrainsMono Nerd Font" + }, + "size": { + "type": "number", + "description": "The size of xterm's text, in points: 12 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.", + "minimum": 6, + "maximum": 72, + "predefined": "font-size", + "default": 12 + }, + "save-lines": { + "type": "number", + "description": "How many lines of scrollback xterm keeps per terminal: 10000 holds a long build log, and each line costs memory in every open terminal.", + "minimum": 0, + "maximum": 1000000, + "default": 10000 + }, + "term-name": { + "type": "string", + "description": "The TERM value xterm sets for the programs it runs: xterm-256color, because the palette has 256 colours and every program knows the name.", + "pattern": "[A-Za-z0-9._-]{1,64}", + "default": "xterm-256color" + }, + "render-font": { + "type": "boolean", + "description": "Whether xterm draws with fontconfig faces (true) or the old X core fonts (false): true, because the face is a fontconfig family and core fonts cannot show it.", + "default": true + }, + "bold-mode": { + "type": "boolean", + "description": "Whether xterm draws bold text by overstriking the normal face (true): false, because the face has a real bold style and overstriking blurs it.", + "default": false + }, + "always-bold-mode": { + "type": "boolean", + "description": "Whether xterm overstrikes bold even where the face has a bold style (true): false, for the same reason as bold-mode.", + "default": false + }, + "allow-bold-fonts": { + "type": "boolean", + "description": "Whether xterm may use the face's bold style for bold text (true): true, so bold text is drawn with the real bold weight.", + "default": true + }, + "background": { + "type": "string", + "description": "The terminal's background colour, as #rrggbb: black, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#000000" + }, + "foreground": { + "type": "string", + "description": "The terminal's text colour, as #rrggbb: a light grey, easier on the eyes than white on black.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#a8a8a8" + }, + "color0": { + "type": "string", + "description": "Palette entry 0, the normal black, as #rrggbb: #000000, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#000000" + }, + "color1": { + "type": "string", + "description": "Palette entry 1, the normal red, as #rrggbb: #9e1828, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#9e1828" + }, + "color2": { + "type": "string", + "description": "Palette entry 2, the normal green, as #rrggbb: #008800, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#008800" + }, + "color3": { + "type": "string", + "description": "Palette entry 3, the normal yellow, as #rrggbb: #d2bb4b, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#d2bb4b" + }, + "color4": { + "type": "string", + "description": "Palette entry 4, the normal blue, as #rrggbb: #414171, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#414171" + }, + "color5": { + "type": "string", + "description": "Palette entry 5, the normal magenta, as #rrggbb: #963c59, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#963c59" + }, + "color6": { + "type": "string", + "description": "Palette entry 6, the normal cyan, as #rrggbb: #418179, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#418179" + }, + "color7": { + "type": "string", + "description": "Palette entry 7, the normal white, as #rrggbb: #bebebe, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#bebebe" + }, + "color8": { + "type": "string", + "description": "Palette entry 8, the bright black, as #rrggbb: #666666, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#666666" + }, + "color9": { + "type": "string", + "description": "Palette entry 9, the bright red, as #rrggbb: #bc5766, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#bc5766" + }, + "color10": { + "type": "string", + "description": "Palette entry 10, the bright green, as #rrggbb: #61a171, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#61a171" + }, + "color11": { + "type": "string", + "description": "Palette entry 11, the bright yellow, as #rrggbb: #e7db52, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#e7db52" + }, + "color12": { + "type": "string", + "description": "Palette entry 12, the bright blue, as #rrggbb: #5085af, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#5085af" + }, + "color13": { + "type": "string", + "description": "Palette entry 13, the bright magenta, as #rrggbb: #a97a99, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#a97a99" + }, + "color14": { + "type": "string", + "description": "Palette entry 14, the bright cyan, as #rrggbb: #6ba4a4, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#6ba4a4" + }, + "color15": { + "type": "string", + "description": "Palette entry 15, the bright white, as #rrggbb: #ffffff, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#ffffff" + }, + "copy-binding": { + "type": "string", + "description": "The key combination that copies the selection to the clipboard, in X translation syntax: Ctrl Shift ~Alt C, so a plain Ctrl+C still interrupts the program. It holds no colon, backslash or line break, which would end the resource.", + "pattern": "[^:\\\\\\n]+", + "default": "Ctrl Shift ~Alt C" + }, + "paste-binding": { + "type": "string", + "description": "The key combination that pastes the clipboard, in X translation syntax: Ctrl Shift ~Alt V, the pair of copy-binding. It holds no colon, backslash or line break, which would end the resource.", + "pattern": "[^:\\\\\\n]+", + "default": "Ctrl Shift ~Alt V" + } + }, + "items": [ + { + "collection": "environment", + "name": "terminal", + "attributes": { + "kind": "variable", + "name": "TERMINAL" + }, + "template": "files/env-terminal" + }, + { + "collection": "shell:xresources:normal", + "name": "xresources-normal", + "template": "files/xresources-normal" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "xterm" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/xterm-tools", + "binary": "xterm-tools", + "loads": [ + "xterm-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/internal/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json b/internal/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json new file mode 100644 index 0000000..095128a --- /dev/null +++ b/internal/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json @@ -0,0 +1,60 @@ +{ + "module": "mesh-provisioner-client", + "version": "1", + "claims": [ + { + "name": "node-provisioner", + "scope": "node" + } + ], + "own-secrets": { + "provisioner-broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-provisioner-client", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-provisioner-client", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-provisioner-client" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-provisioner-client", + "place": ".", + "unknown": "program" + }, + { + "id": "client", + "type": "process", + "name": "mesh-provisioner-client", + "artifact": "client", + "run": [ + "./mesh-provisioner-client" + ], + "env": { + "MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker", + "MESH_PROVISIONER_STATE": "${dir:state}" + }, + "user": "mesh-provisioner-client", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "client", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-provisioner-client", + "binary": "mesh-provisioner-client" + } + ] + } +} diff --git a/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf new file mode 100644 index 0000000..2f23f90 --- /dev/null +++ b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf @@ -0,0 +1,2 @@ +# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit. +${backup:lines} \ No newline at end of file diff --git a/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf new file mode 100644 index 0000000..f19e849 --- /dev/null +++ b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf @@ -0,0 +1,2 @@ +# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit. +${backup:data} \ No newline at end of file diff --git a/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf new file mode 100644 index 0000000..cd7954c --- /dev/null +++ b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf @@ -0,0 +1,6 @@ +# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily, +# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file. +daily=${config:keep-daily} +weekly=${config:keep-weekly} +monthly=${config:keep-monthly} +hand=${config:keep-hand-taken-days} diff --git a/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json new file mode 100644 index 0000000..ea5ec8b --- /dev/null +++ b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json @@ -0,0 +1,199 @@ +{ + "module": "restic", + "version": "1", + "moved": true, + "claims": [ + { + "name": "node-backup", + "scope": "node", + "serves": [ + "backed-up", + "now", + "restore" + ] + } + ], + "emits": [ + "backed-up" + ], + "tools": [ + "backup_health", + "backup_delete_retired", + "backup_deleted" + ], + "own-secrets": { + "repository": "${dir:state}/repository.secret" + }, + "data": { + "own": [ + { + "id": "repository", + "path": "${dir:repository}", + "class": "rebuildable", + "backup": "none", + "why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does" + }, + { + "id": "state", + "path": "${dir:state}", + "class": "cache", + "why": "what the last nights and measurements were; the next night writes it again" + } + ] + }, + "config": { + "keep-daily": { + "type": "number", + "description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 14 + }, + "keep-weekly": { + "type": "number", + "description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 8 + }, + "keep-monthly": { + "type": "number", + "description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 6 + }, + "keep-hand-taken-days": { + "type": "number", + "description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 7 + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "repository", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "declared", + "type": "file", + "path": "${dir:state}/backups.conf", + "mode": "0600", + "template": "files/backups.conf" + }, + { + "id": "data-declared", + "type": "file", + "path": "${dir:state}/data.conf", + "mode": "0600", + "template": "files/data.conf" + }, + { + "id": "retention", + "type": "file", + "path": "${dir:state}/retention.conf", + "mode": "0644", + "trusted": true, + "template": "files/retention.conf" + }, + { + "id": "tool", + "type": "package", + "package": "restic" + }, + { + "id": "sqlite", + "type": "package", + "package": "sqlite" + }, + { + "id": "night", + "type": "process", + "name": "restic-night", + "artifact": "tools", + "run": [ + "./restic-backups", + "night" + ], + "user": "${machine:account}", + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + }, + "health": { + "kind": "tool", + "tool": "backup_health", + "interval": "60s", + "timeout": "20s", + "looks": 2, + "grace": "60s" + } + }, + { + "id": "measure", + "type": "process", + "name": "restic-measure", + "artifact": "tools", + "run": [ + "./restic-backups", + "measure" + ], + "user": "${machine:account}", + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + }, + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/restic-backups", + "binary": "restic-backups", + "loads": [ + "restic-backups" + ], + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/internal/compose/frombus/testdata/de-spiegel-e25e1beb.module.json b/internal/compose/frombus/testdata/de-spiegel-e25e1beb.module.json new file mode 100644 index 0000000..b4c4bd2 --- /dev/null +++ b/internal/compose/frombus/testdata/de-spiegel-e25e1beb.module.json @@ -0,0 +1,129 @@ +{ + "module": "de-spiegel", + "version": "1", + "slug": "spiegel", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "route", + "smtp" + ], + "contributes": { + "route": { + "site": { + "label": "de-spiegel", + "port": 35621 + }, + "praktijk": { + "name": "${config:praktijk-name}", + "port": 35621 + }, + "praktijk-www": { + "name": "${config:praktijk-www-name}", + "redirect": "https://${config:praktijk-name}" + }, + "psychologie": { + "name": "${config:psychologie-name}", + "port": 35621 + }, + "psychologie-www": { + "name": "${config:psychologie-www-name}", + "redirect": "https://${config:psychologie-name}" + } + }, + "smtp": { + "account": "de-spiegel" + } + }, + "binds": { + "route": "/var/lib/de-spiegel/route.json", + "smtp": "/var/lib/de-spiegel/smtp.json" + }, + "config": { + "praktijk-name": { + "type": "string", + "description": "The public name the practice's site is served on; the operator's, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + }, + "praktijk-www-name": { + "type": "string", + "description": "The public name that redirects to the practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + }, + "psychologie-name": { + "type": "string", + "description": "The public name the psychology practice's site is served on; the operator's, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + }, + "psychologie-www-name": { + "type": "string", + "description": "The public name that redirects to the psychology practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + } + }, + "listens": [ + { + "port": 35621, + "protocol": "tcp", + "from": "mesh", + "why": "the de-spiegel site and its /contact endpoint over http; the practice's three public names are route grants, and the proxy reaches every one of them here" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "path": "/var/lib/de-spiegel", + "mode": "0700" + }, + { + "id": "server-env", + "type": "file", + "path": "/var/lib/de-spiegel/server.env", + "mode": "0600", + "content": "SMTP_HOST=${bound:smtp:at}\nSMTP_PORT=${bound:smtp:port}\nSMTP_AUTH_USER=de-spiegel@${bound:smtp:domain}\nSMTP_AUTH_PASS=${secret:smtp}\n" + }, + { + "id": "net", + "type": "network", + "name": "de-spiegel" + }, + { + "id": "server", + "type": "container", + "name": "de-spiegel", + "artifact": "site", + "network": "de-spiegel", + "env-file": [ + "/var/lib/de-spiegel/server.env" + ], + "ports": [ + "35621" + ], + "secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (server/index.js); converting is this repository's change" + } + ], + "build": { + "on": [ + { + "arg": "NODE_BASE", + "image": "node@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9" + } + ], + "artifacts": [ + { + "name": "site", + "kind": "image", + "from": "Dockerfile" + } + ] + }, + "secrets": { + "smtp": "/var/lib/de-spiegel/smtp.secret" + } +} diff --git a/internal/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json b/internal/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json new file mode 100644 index 0000000..8f2b612 --- /dev/null +++ b/internal/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json @@ -0,0 +1,82 @@ +{ + "module": "f1-circuits", + "slug": "f1", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "f1-circuits", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "listens": [ + { + "name": "web", + "port": 8000, + "protocol": "tcp", + "from": "mesh", + "why": "the track-layout CDN, the race posters and the Kometa metadata over http; its public name is a route grant and the proxy reaches it here. Kometa fetches the metadata and every poster by that public name" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, + { + "id": "cache", + "type": "directory", + "mode": "0700" + }, + { + "id": "site-env", + "type": "file", + "path": "${dir:state}/site.env", + "mode": "0644", + "content": "F1_CIRCUITS_BASE_URL=https://${bound:route:name}\n" + }, + { + "id": "server", + "type": "container", + "name": "f1-circuits", + "artifact": "cdn", + "ports": [ + "8000" + ], + "env-file": [ + "${dir:state}/site.env" + ], + "volumes": [ + "${dir:cache}:/app/cache" + ], + "restart-on": [ + "site-env" + ] + } + ], + "build": { + "on": [ + { + "arg": "PYTHON_BASE", + "image": "python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df" + } + ], + "artifacts": [ + { + "name": "cdn", + "kind": "image", + "from": "service/Dockerfile" + } + ] + } +} diff --git a/internal/compose/frombus/twice.go b/internal/compose/frombus/twice.go new file mode 100644 index 0000000..cb133df --- /dev/null +++ b/internal/compose/frombus/twice.go @@ -0,0 +1,147 @@ +package frombus + +// **A node's plans at two sets of builds, over one read** (issue 1954; novox/hq ADR 0570 clause 2, ADR 0580 clause 2): +// the seat mesh-provisioner's holder composes each node at the builds its wants name and at the newest build of each +// placed module. Both are composed from one read of the bus (placement, records, the indexer's modules and manifests, +// values: base); only the builds the node's modules are composed from differ, each build read once for both, and a +// build taken in once per content by a reader that keeps it (StatesReader). Where the time went is said by phase. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sync" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// HeldPlans is one scope's plans, as PlansHeld answers them: err a refusal that is no single module's. +type HeldPlans struct { + Plans map[string]catalogue.Plan + Held map[string]string + Err error +} + +// Phase is one step of a composition and how long it took. +type Phase struct { + Name string `json:"phase"` + Took time.Duration `json:"took"` +} + +// PlansHeldTwice is PlansHeld at s and at s with no kept builds (each placed module at its newest build), over one +// read of the bus: what each answers is what PlansHeld answers for that scope. A read that fails is both scopes' err. +// took names each phase in order: the read, then each scope's builds and its plans. +func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newest HeldPlans, took []Phase) { + start := time.Now() + mark := func(name string) { + now := time.Now() + took = append(took, Phase{Name: name, Took: now.Sub(start)}) + start = now + } + b, err := readBase(ctx, r, mesh, s) + mark("read") + builds := onceBuilds(r) + newestScope := s + newestScope.KeptAt = nil + for _, sc := range []struct { + scope Scope + into *HeldPlans + name string + }{{s, &at, "wanted"}, {newestScope, &newest, "newest"}} { + held := map[string]string{} + sc.into.Held = held + refuse := func(module string, err error) { held[module] = err.Error() } + b.refuseOwn(refuse) + if err != nil { + sc.into.Err = err + continue + } + in, err := b.finish(ctx, r, builds, mesh, sc.scope, b.cloned(), refuse, nil) + mark("builds at " + sc.name) + if err != nil { + sc.into.Err = err + continue + } + sc.into.Plans, sc.into.Err = plansHeldOf(in, s.Node, held) + mark("plans at " + sc.name) + } + return at, newest, took +} + +// onceBuilds is r's Build, each module's build at a fingerprint read once: both scopes of one composition read the +// builds of the same moment. +func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) { + type answer struct { + b *delivery.BuilderBuild + err error + } + read := map[string]answer{} + return func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) { + key := module + " " + fp + if a, ok := read[key]; ok { + return a.b, a.err + } + b, err := r.Build(ctx, module, fp) + read[key] = answer{b, err} + return b, err + } +} + +// takenInKeeper is a Reader that keeps what a build's take-in made (StatesReader does): the resolved manifest's bytes, +// by the content it was made from, so a composition made again on every change takes in only a build it has not seen. +type takenInKeeper interface { + takenIn(key string) ([]byte, bool) + keepTakenIn(key string, raw []byte) +} + +// takenInKey is what a take-in is made from: the artifact store's address and the build's record (its manifest's digest, its artifacts by digest), read +// whole; an archive is checked against its digest on every read, so the same record takes in to the same bytes. +func takenInKey(module, fp, address string, b delivery.BuilderBuild) (string, bool) { + raw, err := json.Marshal(b) + if err != nil { + return "", false + } + sum := sha256.Sum256(append([]byte(module+" "+fp+" "+address+" "), raw...)) + return hex.EncodeToString(sum[:]), true +} + +// takenInAtMost bounds the take-ins a reader keeps; past it they are emptied, which costs only a take-in again. +const takenInAtMost = 512 + +// keptTakeIns is a reader's kept take-ins. +type keptTakeIns struct { + mu sync.Mutex + kept map[string][]byte +} + +func (k *keptTakeIns) takenIn(key string) ([]byte, bool) { + k.mu.Lock() + defer k.mu.Unlock() + raw, ok := k.kept[key] + return raw, ok +} + +func (k *keptTakeIns) keepTakenIn(key string, raw []byte) { + k.mu.Lock() + defer k.mu.Unlock() + if k.kept == nil || len(k.kept) >= takenInAtMost { + k.kept = map[string][]byte{} + } + k.kept[key] = raw +} + +// PhasesSaid says the phases in one line, each in milliseconds. +func PhasesSaid(took []Phase) string { + out := "" + for i, p := range took { + if i > 0 { + out += ", " + } + out += fmt.Sprintf("%s %d ms", p.Name, p.Took.Milliseconds()) + } + return out +} diff --git a/internal/compose/fromruntime/fromruntime.go b/internal/compose/fromruntime/fromruntime.go new file mode 100644 index 0000000..b9fab99 --- /dev/null +++ b/internal/compose/fromruntime/fromruntime.go @@ -0,0 +1,101 @@ +// Package fromruntime is compose/frombus's StatesReader over a bundle's runtime (the lead, 2026-10-29): the states a +// module's manifest names in `reads` (frombus.StatesReads), read through stdio.State, and a templates archive fetched +// over HTTP. Apart from frombus so that frombus stays pure for the mesh-compose command. +package fromruntime + +import ( + "context" + "fmt" + "io" + "net/http" + "sync" + + stdio "git.novox.be/novox/mesh-sdk/go" + "github.com/novox/mesh-host/internal/compose/frombus" +) + +// States are a bundle's read states through its runtime. +type States struct{} + +// Get is a key's value, false when it has none. +func (States) Get(state, key string) ([]byte, bool, error) { + e, err := stdio.State(state).Get(key) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil +} + +// Keys are a state's keys. +func (States) Keys(state string) ([]string, error) { return stdio.State(state).Keys() } + +// Reader is frombus's Reader over this bundle's read states key by key, fetching archives over HTTP. A holder that +// composes again and again reads through Watched instead (novox/hq ADR 0580). +func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) } + +// Watched is frombus's States over a watched copy (novox/hq ADR 0580 clause 1): the states a composition reads whole +// (placement, haves, modules, resolved values) from the copy, a build by key when asked, a manifest once per digest. +type Watched struct{ *stdio.WatchedStates } + +// Get is a key's value: a build read now, a manifest kept once read, any other state's from the copy. +func (w Watched) Get(state, key string) ([]byte, bool, error) { + switch state { + case frombus.BuildsRead: + return w.Direct(state, key) + case frombus.ManifestsRead: + return w.Once(state, key) + } + return w.WatchedStates.Get(state, key) +} + +// WatchedReader is frombus's Reader over w, fetching each templates archive over HTTP once (KeptFetch): a composition +// made again on every change reads it again otherwise. +func WatchedReader(w *stdio.WatchedStates) frombus.Reader { + return frombus.StatesReader(Watched{w}, KeptFetch(HTTPFetch)) +} + +// archivesAtMost bounds the archives KeptFetch keeps; past it they are emptied, which costs only a fetch again. +const archivesAtMost = 64 + +// KeptFetch is fetch kept once per URL: an archive's URL names its blob by digest, so its body never changes under it +// (frombus checks the digest on every read). A failed fetch is not kept. +func KeptFetch(fetch frombus.Fetch) frombus.Fetch { + var mu sync.Mutex + kept := map[string][]byte{} + return func(ctx context.Context, url string) ([]byte, error) { + mu.Lock() + body, ok := kept[url] + mu.Unlock() + if ok { + return body, nil + } + body, err := fetch(ctx, url) + if err != nil { + return nil, err + } + mu.Lock() + if len(kept) >= archivesAtMost { + clear(kept) + } + kept[url] = body + mu.Unlock() + return body, nil + } +} + +// HTTPFetch reads an archive over HTTP, at most 256 MiB. +func HTTPFetch(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s answered %s", url, resp.Status) + } + return io.ReadAll(io.LimitReader(resp.Body, 256<<20)) +} diff --git a/internal/compose/input.go b/internal/compose/input.go new file mode 100644 index 0000000..72aecf7 --- /dev/null +++ b/internal/compose/input.go @@ -0,0 +1,365 @@ +// Package compose is the controller's composition of a machine, as a function over a plain input (novox/hq ADR 0453 +// clause 3, ADR 0446 clause 4): what the rest of the mesh offers and holds, the private network's generator, one +// node's resolution and, in the issues after, its rendering and its declaration. +// +// **It never allocates, makes, writes, logs or raises.** The controller composes with a store behind it; this +// package is its Reading and Foreseeing modes over data. Everything the controller reads from its store is a field of +// Input, and everything a send would make or record (a port, a credential, a seat passed on, a node left out) is +// returned for the caller to act on, never done. Input is never changed by a call. No field holds a secret value +// the composition needs: sealed values are optional inputs, and without them the composition uses stand-ins named +// foreseen. The logic is the controller's, ported from mesh-controller cmd/mesh-controller at commit +// 6c2a8d62100cb7d491f8eb9a623e1eeea5ec0de1 (plan.go, network.go, holdings.go, seat_pass.go), in the same order, with the +// same refusals in the same words, so that a comparison node for node is a fair one. +// +// It imports the standard library and mesh-sdk's own packages only. +// It reads no environment variable: the range the mesh allocates from and the suffix of its internal names are fields of Input. +package compose + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Input is everything the composition reads. Each field is what one read of the controller's inventory answers, named +// beside it; the controller's adapter fills it from its stores and the assigner from the mesh's facts and buckets +// (ADR 0446 clause 2). +type Input struct { + // Catalogue is the shelf: every module's manifest by name (inv.Catalogue), as it is to be composed. A recorded + // module kept at the build a machine runs (keepRecorded) is that node's Kept, applied to its own resolution only (never to the rest of the mesh). + Catalogue map[string]catalogue.Manifest + // Nodes is every node the mesh has (inv.Nodes and inv.Overlays together, one row per node). + Nodes []Node + // Holdings is every seat whose holder is on record (inv.Holdings). + Holdings []catalogue.Held + // Licences is every licence the mesh holds, with the machines that use it (open.Licences); none for a mesh with no + // licence. + Licences []Licence + + // OverlayCIDR is the range the mesh allocates from when no tunnel was adopted, as genesis was told it (the + // controller's OverlayCIDRVar); empty means DefaultOverlayCIDR. The package reads no environment. + OverlayCIDR string + // InternalSuffix is what the mesh's internal names end in (the controller's MESH_INTERNAL_SUFFIX), with or without a leading + // dot; empty means overlay.DefaultSuffix. A fact of the whole mesh, so it is in the Input and never read from the environment. + InternalSuffix string + // Tunnels is, by node name, the found tunnel whose key is the node's overlay key (inv.Tunnels). + Tunnels map[string]FoundTunnel + // Adopted is the hub's found tunnel and the hub's name, whether or not the hub took it (inv.AdoptedTunnel); nil + // when no hub has one. + Adopted *AdoptedTunnel + // CarriedPeers is every peer of the adopted tunnel (inv.CarriedPeers). + CarriedPeers []CarriedPeer + // Joining is the nodes that hold a live enrolment token (inv.NodesWithALiveToken). + Joining []string + + // Credentials is how each pair credential a node takes from elsewhere stands, never its value (what inv.SecretFor, + // SharedSecretFor and GivenPairSecret would answer): held, to be made by the next send, only a person's to give, or + // refused. A need with no entry here is one the next send would make. + Credentials []Credential + + // Built is every repository the mesh has built (the keys of inv.Held): what tells a reference recorded with an address + // from an image a module runs straight from a public registry. + Built []string + // OperatorExport is every operator-sealed secret in the mesh, for a module that keeps them (inv.OperatorExport): sealed + // values the composition carries without opening. Nil when the mesh has no operator key. + OperatorExport *catalogue.KeptExport + // KeptRuns is, by node and module, the file of the export as that node runs it (its resource of a module's running + // composition): what a node composing its own carries where it holds no OperatorExport (catalogue.Rendering.KeptRuns, + // issue 1126, C6). Used only while OperatorExport is nil. + KeptRuns map[string]map[string]map[string]any + + // Issued is the credentials made for the consumers of each provider (inv.SecretsFrom): one row for each pair credential + // that exists, by the provider node it is for. The grants a provider must create are read from these, so a credential not + // made yet has no grant until the send that makes it. An assigner fills it from the facts snapshot's Grants of each + // machine (go/facts, from format 2), which carry who each credential is for and never the credential. + Issued []Issued + // OwnSecrets is the own secrets held on each machine (the module_secret rows): who made each, and the key it is sealed to. + // Their sealed values are optional. + OwnSecrets []OwnSecretRow + // KeptCoreBoxes is the vault's box last kept for each core credential on each machine (inv.KeptCoreBoxes). + KeptCoreBoxes []CoreBox + // BusUsers is the bus's user list as the controller composes it from the bus records and the hashes it holds (composeBusUsers), + // for the machine that runs the bus: names and hashes, never passwords. Empty when the Input does not carry it, and then + // the composition uses a stand-in, named foreseen. BusMissing are the users left out of it for want of a credential. + BusUsers string + BusMissing []string + // BusMinted are the bus users the mesh has minted a password hash for (inv.BusUserHash): `.` for a module and + // `provisioner.` for a machine's provisioner client. + BusMinted []string + // BusMemberships is, by node, the membership sealed to it for the bus the mesh is moving to (inv.BusMemberships). + BusMemberships map[string]string + // Authority is the mesh's own certificate (identity.EstablishAuthority), public. Optional. + Authority string + + // CurrentBuilds is, by module, the commit of the build the mesh holds for it (inv.CurrentBuilds, the commit it was built from); + // BuildIDs the build made from a commit of a module (inv.BuildOf), by module then commit. Read only to name the builds a + // declaration carries to a node-engine that reads them. + CurrentBuilds map[string]string + BuildIDs map[string]map[string]string + + // Refused is each module whose manifest the indexer's judge refused, or whose bytes do not decode, by module, with why + // (novox/hq ADR 0578 §3, §5): its verdict, carried in the plain input. Such a module stands in Catalogue as its stub + // (compose.Stub), so it is held: nothing of its own is composed, while what it provides and requires still shapes the + // rest. A module not named is accepted, a missing verdict included. + Refused map[string]string `json:",omitempty"` + // SetAside is each module whose manifest has a field the reader's decoder does not know, by module, with what was set + // aside (catalogue.Manifest.SetAside): the rest of it is composed, and the reader names it (ADR 0578 §6). + SetAside map[string]string `json:",omitempty"` +} + +// Issued is one pair credential made for a consumer of a provider (inv.Secret): ForProvider is its copy sealed to the +// provider, optional. +type Issued struct { + Provision string + Consumer string + ConsumerModule string + Local string + Provider string + ForProvider string +} + +// OwnSecretRow is one own secret held on a machine: Origin is `made` (the mesh generated it) or `accepted` (a person gave it), +// SealedTo the sealing key it is sealed to, and Sealed its value sealed to that key, optional. +type OwnSecretRow struct { + Node string + Module string + Name string + Origin string + SealedTo string + Sealed string +} + +// CoreBox is the vault's box for one core credential of one machine, as last kept (inv.CoreBox). +type CoreBox struct { + Node string + Module string + Name string + Generation int + SealedTo string + Box json.RawMessage +} + +// Node is one node of the mesh and what the composition reads about it. +type Node struct { + Name string + // Place is where it is on the private network (inv.Overlays). + Place Place + // Assigned is the modules assigned to it, in the order the controller reads them (inv.Assigned). + Assigned []string + // Handed is, by module, whether the vault judged it handed to the provisioner when it answered this node's own + // secrets (catalogue.AssignmentHands of the plan in its assignment): the node's own composition hands exactly those + // (catalogue.Rendering.Assigned, issue 774). Nil where the vault said nothing of it, as for the controller. + Handed map[string]bool + // Capabilities are the ones present on it (inv.ProfileOf). + Capabilities map[string]bool + // PublicDomain is the domain it composes its routed names under (inv.PublicDomainOf). + PublicDomain string + // Account, AccountHome, AgentAccount and AgentAccountHome are its operator and agent logins (inv.NodeByName). + Account, AccountHome, AgentAccount, AgentAccountHome string + // Adopted is whether it is adopted rather than converged. + Adopted bool + // Pins is the provider each provision was pinned to for it (inv.PinsFor). + Pins map[string]catalogue.Chosen + // Bound is where each of its consumers of a provision that keeps data was last sent (inv.BindingsFor), by consumer + // module and provision. An assigner fills it from the facts snapshot's Bindings of the machine (go/facts, from format 2). + Bound map[string]map[string]catalogue.Chosen + // Settings is its settings layers by module (inv.SettingsFor); a module with none is absent. + Settings map[string][]catalogue.Layer + // Ports is the machine port each module's software port was given on it (inv.PortsFor), by module then port. + Ports map[string]map[int]int + // SealingKey is the public key its secrets are sealed to (inv.SealingKeyOf); empty when it has none. A public key, so + // nothing secret. + SealingKey string + // Taken is the modules taken on it, for an adopted node (inv.Taken). + Taken []string + // OutwardLinks are the links it reported as facing outside it (inv.OutwardLinksOf). + OutwardLinks []string + // Networks are the networks it reported being attached to, as link and network (inv.MachineOf); NetworksSaid is whether + // its node-engine said any at all, which is not the same as saying none. + Networks []catalogue.AttachedNetwork + NetworksSaid bool + // ServingKey is the key the machine serves with, which a certificate is issued for (identity.ServingKeyOf); empty when it has + // never reported one. Certificate is the certificate the mesh issued for its internal name, public and optional. + ServingKey string + Certificate string + // Sequence, Epoch and Generation are what the declaration is sent under (inv.Sequence, SentEpoch, SentGeneration): the order + // against every other send to the node, the lease epoch and the assignment generation it was last sent. Zero is not sent. + Sequence int64 + Epoch uint64 + Generation int64 + // ReadsModules says its node-engine reads a module per resource and a build per module (inv.ReadsModules, issue 1145); only + // then are they sent. SentBuilds is the build of each module it was last sent, by module to commit, and SentKnown whether + // that is known at all (inv.SentBuilds); Runs is what each of its assignments runs (inv.AssignmentRuns), by module. + ReadsModules bool + SentBuilds map[string]string + SentKnown bool + Runs map[string]Run + // Kept is, by module, the manifest of a recorded or core module kept at the build this machine runs (plan.go keepRecorded: + // the caller reads it with recordedKept and ManifestAt), and KeptBuilds the commit of that build. They apply to this node's + // own resolution only, never to another node's, and a declaration carries the kept commit for the module. + Kept map[string]catalogue.Manifest + KeptBuilds map[string]string + // Contract is the contract of its node-engine's newest statement (inv.HealthOf); Stated is whether it ever stated one. + Contract int + Stated bool + // InOwn is the modules it runs inside its own composition (`.node`), not by a composition of their own, since + // no fingerprint of the new form names them where it runs them (novox/hq issue 1126): Compositions folds their + // resources into the node's own, as the controller's composed writer does, unless fps gives one of the new form. + // The private network's module (overlay.Name), which nobody builds, is folded whether or not it is named here. + InOwn map[string]bool + // RunsOwn is the node's own composition (`.node`) as it runs, when the node composes itself: a module folded + // there (InOwn) whose manifest names a build (NamesBuild) runs a build of the old form only the controller holds, + // so it is left to the node: Compositions composes it as a stub (Stub) and carries its resources as they run, each + // after the resource it runs after (issue 1126). Nil composes every folded module from its manifest, as before. + RunsOwn *delivery.Composed + // Held is the modules refused alone on this node, each composed as what runs of it (Carried, in Kept; its Stub when + // nothing of it runs) so the rest is composed (novox/hq issue 1126): none of a held module's own composition is in what + // Compositions answers, so the node keeps what it last applied of it; one whose resources the node's own runs + // (RunsOwn), folded or leaving the fold, is carried there as it runs, whatever its manifest names, so the node's own + // never drops it. + Held map[string]bool + // RunsTools is, by module, the tools bundles the node's runtime serves of it as it runs (ToolsRunning of what the + // node runs); nil when not known. A module composed as what runs of it (its Stub or Carried: held, or folded at a + // build of the old form) contributes these to the runtime, never its manifest's: a manifest no build resolved has no + // bundles, and a held module's new build's bundles are not delivered (issue 1126, 2026-10-29: shanks's runtime lost + // the folded systemd-resolved, novox's the folded nats). + RunsTools map[string][]catalogue.Bundle +} + +// Place is where a node is, as its placement says (inv.Overlay). +type Place struct { + Key string + Endpoint string + Site string + Hub bool + Address string +} + +// Reachable says whether the node can be dialled. +func (p Place) Reachable() bool { return strings.TrimSpace(p.Endpoint) != "" } + +// Tunnel is a tunnel found on a node (inv.Tunnel). +type Tunnel struct { + Interface string + Unit string + Config string + Port int + MTU int + // Address is the interface's own address with its prefix length, Range the network that prefix names. + Address string + Range string + PublicKey string + Peers []TunnelPeer +} + +// TunnelPeer is one peer of a found tunnel. +type TunnelPeer struct { + PublicKey string + Address string +} + +// FoundTunnel is a node's tunnel and whether the node is adopted (inv.FoundTunnel). +type FoundTunnel struct { + Tunnel + NodeAdopted bool +} + +// AdoptedTunnel is the hub's tunnel, the hub's name, and whether the hub's overlay key is the tunnel's, which is +// whether the private network took the tunnel over. +type AdoptedTunnel struct { + Tunnel Tunnel + Hub string + Adopted bool +} + +// CarriedPeer is a peer of the adopted tunnel (inv.CarriedPeer). +type CarriedPeer struct { + PublicKey string + Address string + EnrolledAs string + Named string +} + +// Licence is one licence the mesh holds: its name, what a consumer may know of it, and the machines and modules +// that use it. +type Licence struct { + Name string + Serves map[string]any + Holders []Holder + // Keys is, by holder, the licence's key sealed to that holder's machine (held.KeyFor), empty where the mesh holds none: + // the plaintext was discarded when it was supplied. Optional: nil means not given, and the composition uses a stand-in named + // foreseen; given (even empty), a holder with no entry is one the mesh holds no key for. + Keys map[Holder]string + // Manager is the holder of a refreshable-grant licence that manages it (held.ManagerOf); the zero Holder when none. + Manager Holder +} + +// Holder is a module on a node that uses a licence. +type Holder struct { + Node string + Module string +} + +// ErrNoSuchNode is what the controller's inventory answers for a node it does not have. +var ErrNoSuchNode = errors.New("no node of that name") + +// node is the node of that name, or the refusal the controller's inventory gives for one it does not have. +func (in Input) node(name string) (Node, error) { + for _, n := range in.Nodes { + if n.Name == name { + return n, nil + } + } + return Node{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name) +} + +// sorted is the nodes by name, the order the controller reads them in. +func (in Input) sorted() []Node { + out := append([]Node(nil), in.Nodes...) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// CredentialState is how one pair credential stands. +type CredentialState string + +const ( + // CredentialHeld is a credential held for both ends as they stand now. Sealed, when given, is the consumer's copy; a held + // credential with no value is composed with a stand-in, named foreseen. + CredentialHeld CredentialState = "held" + // CredentialWouldMake is a credential none is held for: the next send makes it, and the composition uses a stand-in, + // named foreseen. It is also what a need with no entry is. + CredentialWouldMake CredentialState = "would-make" + // CredentialNotGiven is a credential only a person gives (novox/hq ADR 0337 clause 4) that nobody has given, or that was given + // under a key one end no longer has: the consumer still resolves, without it. Why is the controller's words for it. + CredentialNotGiven CredentialState = "not-given" + // CredentialRefused is a credential the send would refuse to make, for the reason Why gives in the controller's words: a + // value a person gave sealed to a key the machine no longer has, a machine with no sealing key. + CredentialRefused CredentialState = "refused" +) + +// Credential is the state of one pair credential, by the ends it joins: the provision, the consumer's node and module, the +// provider's node, and the local name where a module keeps several (inv.Secret's key). +type Credential struct { + Provision string + Consumer string + ConsumerModule string + Provider string + Local string + State CredentialState + // Sealed is the consumer's copy as it is sealed to the consumer's key; optional, and only the controller's adapter passes it. + Sealed string + // Why is the words of a CredentialNotGiven or CredentialRefused. + Why string +} + +// Run is the build an assignment names: the commit it is of and the build made from it (chain.Ref). +type Run struct { + Commit string + Build string +} diff --git a/internal/compose/network.go b/internal/compose/network.go new file mode 100644 index 0000000..2438efa --- /dev/null +++ b/internal/compose/network.go @@ -0,0 +1,307 @@ +package compose + +import ( + "errors" + "fmt" + "slices" + "sort" + "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/overlay" +) + +// The private network: who is on it, where, and what they are called. Ported from mesh-controller +// cmd/mesh-controller/network.go at 6c2a8d62. + +// DefaultOverlayCIDR is the range the mesh allocates from when nothing says another. +const DefaultOverlayCIDR = "10.42.0.0/16" + +// composer is one composition over an Input: it reads Input, changes nothing in it, and keeps what the controller +// said on its standard error as lines to return. +type composer struct { + in Input + said []string + seen map[string]bool + // unhostable is, from the last look at the rest of the mesh, each module assigned to another node that this + // composition finds that node cannot run: what it would have offered is missing here (issue 1126, probe #532). + unhostable []string + + // What one composition looks at again and again, each worked out once over its Input, which it never changes (issue + // 1954: a node's plans resolved every machine of the mesh some two hundred times, 11.7 s of CPU for novox): who is + // on the private network, and the rest of the mesh as each node sees it. + onNetwork []Node + onNetworkSet bool + rest map[string]restOf + plans map[string]planOf +} + +// restOf is theRestOfTheMesh's answer for one excluded node, kept: what it found unhostable beside it. +type restOf struct { + world catalogue.World + passes Passes + left []LeftOut + unhostable []string + err error +} + +func newComposer(in Input) *composer { return &composer{in: in, seen: map[string]bool{}} } + +// say keeps a line the controller would have written to its log, once. +func (c *composer) say(format string, args ...any) { + line := fmt.Sprintf(format, args...) + if c.seen[line] { + return + } + c.seen[line] = true + c.said = append(c.said, line) +} + +// suffix is what the mesh's internal names end in: the Input's, and overlay.DefaultSuffix when it gives none. The package never calls +// overlay.Suffix or overlay.InternalName, which read the environment, so the assigner and the controller compose the same names +// from the same Input. +func (c *composer) suffix() string { + if v := strings.TrimPrefix(strings.TrimSpace(c.in.InternalSuffix), "."); v != "" { + return v + } + return overlay.DefaultSuffix +} + +// internalName is a node's name inside the mesh. +func (c *composer) internalName(node string) string { return node + "." + c.suffix() } + +// saidBeside is the lines already said and then every line the composer kept, once each, in the order they were found. +func (c *composer) saidBeside(lines []string) []string { + for _, l := range c.said { + if !slices.Contains(lines, l) { + lines = append(lines, l) + } + } + return lines +} + +// overlayRange is the range the mesh allocates node addresses from: the adopted tunnel's first (novox/hq ADR 0105), +// then the range genesis was told, then the default. +func (c *composer) overlayRange() string { + if a := c.in.Adopted; a != nil && a.Adopted { + return a.Tunnel.Range + } + if v := strings.TrimSpace(c.in.OverlayCIDR); v != "" { + return v + } + return DefaultOverlayCIDR +} + +// network builds the private network over the machines that resolved the module for it. A machine is on it because it +// was given the module, or because it is joining with a key and an address (novox/hq ADR 0169), or because its own set +// could not be resolved and it holds a place (a key and an address): that place is its last good one on the network, and +// it does not change because its modules failed, so every other machine keeps its path to it -- the hub above all -- and +// only its own declaration is refused (issue 1833). A machine refused with no place is left off. +func (c *composer) network(on map[string]bool, refused map[string]string) (*overlay.Generator, error) { + tunnels := c.in.Tunnels + carried := c.in.CarriedPeers + isJoining := map[string]bool{} + for _, name := range c.in.Joining { + isJoining[name] = true + } + var nodes []overlay.Node + for _, node := range c.in.sorted() { + p := node.Place + placed := p.Key != "" && p.Address != "" + _, wasRefused := refused[node.Name] + if !on[node.Name] && !((isJoining[node.Name] || wasRefused) && placed) { + continue + } + if !on[node.Name] && wasRefused && !isJoining[node.Name] { + c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it", + node.Name, p.Address) + } + n := overlay.Node{ + Name: node.Name, Key: p.Key, Endpoint: p.Endpoint, + Site: p.Site, Hub: p.Hub, Address: p.Address, + } + if t, takes := tunnels[node.Name]; takes && t.NodeAdopted { + // Only an adopted node is told to take the found unit over. A hub whose placement disagrees with the tunnel is + // refused, not composed. + if wrong := disagrees(p, t.Tunnel); wrong != "" { + return nil, fmt.Errorf("%s takes over the tunnel on %s and its placement disagrees with it: %s. "+ + "Re-place it — `overlay place %s --hub --endpoint :%d …` — and push again; "+ + "nothing was composed", node.Name, t.Interface, wrong, node.Name, t.Port) + } + n.TakesOver = &overlay.TakeOver{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU} + } + if p.Hub { + for _, cp := range carried { + n.Carried = append(n.Carried, overlay.Carried{Key: cp.PublicKey, Address: cp.Address}) + } + } + nodes = append(nodes, n) + } + if len(nodes) == 0 { + // Nobody was given it: an empty network is a legitimate mesh, not a broken one. + return overlay.Empty(), nil + } + g, err := overlay.From(nodes, c.overlayRange(), "") + if err != nil && len(refused) > 0 { + // The network is missing something, and some machines could not be resolved at all: almost always the same fact. + var who []string + for name, why := range refused { + who = append(who, fmt.Sprintf(" %s: %s", name, why)) + } + sort.Strings(who) + return nil, fmt.Errorf("%w\n\nand %d node(s) could not be resolved at all, which is "+ + "probably why:\n%s", err, len(refused), strings.Join(who, "\n")) + } + return g, err +} + +// Graph is the whole mesh's network, for showing it: the nodes on it and each one's peers. +func Graph(in Input) ([]overlay.Node, overlay.Graph, error) { + c := newComposer(in) + on, refused, err := c.whoResolves(overlay.Requirement) + if err != nil { + return nil, nil, err + } + g, err := c.network(on, refused) + if err != nil { + return nil, nil, err + } + return g.Nodes(), g.Graph(), nil +} + +// whoResolves is the machines whose resolution answers a requirement, and why the others did not: by what a module +// provides, not by its name, resolved rather than read from the assignments. +func (c *composer) whoResolves(requirement string) (map[string]bool, map[string]string, error) { + on := map[string]bool{} + // Why a node could not be resolved, kept rather than raised: one broken node must not stop the rest being described. + refused := map[string]string{} + for _, n := range c.in.sorted() { + resolved, err := c.planFor(n.Name) + switch { + case unresolvable(err): + refused[n.Name] = err.Error() + continue + case err != nil: + // Not a node that does not resolve: a question that went unanswered. + return nil, nil, fmt.Errorf("whether %s answers %q cannot be read: %w", n.Name, requirement, err) + } + for _, m := range resolved.Plan.Modules { + for _, offered := range m.Offers() { + if offered == requirement { + on[n.Name] = true + } + } + } + } + return on, refused, nil +} + +// Generators is what every node's declaration is composed with, over the whole mesh: the private network's generator +// (one generator now; the names and a resolver's zone file are facts a module asks for in its manifest). +func Generators(in Input) (map[string]catalogue.Generator, error) { + c := newComposer(in) + return c.generators() +} + +func (c *composer) generators() (map[string]catalogue.Generator, error) { + on, refused, err := c.whoResolves(overlay.Addressing) + if err != nil { + return nil, err + } + net, err := c.network(on, refused) + if err != nil { + return nil, err + } + return map[string]catalogue.Generator{overlay.Name: net}, nil +} + +// disagrees says how a node's placement differs from the tunnel it takes over — its address not the tunnel's, its +// endpoint not on the tunnel's port — or nothing when both agree. +func disagrees(p Place, t Tunnel) string { + var wrong []string + want := t.Address + if i := strings.Index(want, "/"); i >= 0 { + want = want[:i] + } + if p.Address != want { + wrong = append(wrong, fmt.Sprintf("its address is %s and the tunnel's is %s", orNothing(p.Address), want)) + } + if p.Reachable() && portOfEndpoint(p.Endpoint) != strconv.Itoa(t.Port) { + wrong = append(wrong, fmt.Sprintf("its endpoint %s is not on the tunnel's port %d", p.Endpoint, t.Port)) + } + return strings.Join(wrong, "; ") +} + +func orNothing(s string) string { + if s == "" { + return "unset" + } + return s +} + +// portOfEndpoint is the port in host:port, or empty. +func portOfEndpoint(endpoint string) string { + if i := strings.LastIndex(endpoint, ":"); i >= 0 { + return endpoint[i+1:] + } + return "" +} + +// whereEveryoneIs is each machine's name on the private network, for the ones on it. Resolved without consulting the +// rest of the mesh (it is called from that path), and refused without the catalogue. +func (c *composer) whereEveryoneIs() (map[string]string, error) { + if c.in.Catalogue == nil { + return nil, errors.New("asked where everyone is without the catalogue, which cannot be answered") + } + out := map[string]string{} + for _, p := range c.onTheNetwork() { + out[p.Name] = c.internalName(p.Name) + } + return out, nil +} + +// onTheNetwork is every placed machine that resolves the private network: has an address AND runs what puts it there +// (novox/hq issue 079). A machine that does not resolve keeps its place when it holds one (a key and an address), by the +// same rule as network(), so its name and its address stay where the tunnel to it does (issue 1833); one without is not +// counted, and said. +func (c *composer) onTheNetwork() []Node { + if !c.onNetworkSet { + c.onNetwork, c.onNetworkSet = c.onTheNetworkNow(), true + } + return slices.Clone(c.onNetwork) +} + +// onTheNetworkNow is onTheNetwork worked out: every placed machine's set resolved. +func (c *composer) onTheNetworkNow() []Node { + var out []Node + for _, n := range c.in.sorted() { + if n.Place.Address == "" { + continue + } + if len(n.Assigned) == 0 { + continue + } + got, err := catalogue.Resolve(c.in.Catalogue, n.Assigned, + catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities}, + catalogue.World{Unchecked: true, Holdings: c.in.Holdings}) + if err != nil { + if n.Place.Key != "" { + c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it", + n.Name, n.Place.Address) + out = append(out, n) + continue + } + c.say("%s is not counted as on the network: it does not resolve: %v", n.Name, err) + continue + } + for _, m := range got.Modules { + for _, offered := range m.Offers() { + if offered == overlay.Requirement { + out = append(out, n) + } + } + } + } + return out +} diff --git a/internal/compose/network_test.go b/internal/compose/network_test.go new file mode 100644 index 0000000..2f924f9 --- /dev/null +++ b/internal/compose/network_test.go @@ -0,0 +1,306 @@ +package compose + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/overlay" +) + +// onTheTunnel makes the hub of an Input what mesh-controller's network_test.go makes of anchor in +// TestTheOverlaysRangeIsTheAdoptedTunnels: adopted, enrolled with the found tunnel's key, and presenting the tunnel +// (Tunnel{wg0, wg-quick@wg0, port 51900, 192.0.2.1/24, range 192.0.2.0/24, one peer PEER-TWO}). Placed on it when +// replaced is true: its address the tunnel's, its endpoint on the tunnel's port, as `overlay place anchor --endpoint +// 198.51.100.10:51900 --site hosting --hub` leaves it. +func onTheTunnel(t *testing.T, in *Input, replaced bool) { + t.Helper() + const key = "THE-TUNNELS-KEY=========================" + tunnel := Tunnel{Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf", Port: 51900, + Address: "192.0.2.1/24", Range: "192.0.2.0/24", PublicKey: key, + Peers: []TunnelPeer{{PublicKey: "PEER-TWO", Address: "192.0.2.2"}}} + hub := nodeOf(t, in, "anchor") + hub.Adopted = true + hub.Place.Key = key + if replaced { + hub.Place.Address, hub.Place.Endpoint, hub.Place.Site = "192.0.2.1", "198.51.100.10:51900", "hosting" + } + in.Tunnels = map[string]FoundTunnel{"anchor": {Tunnel: tunnel, NodeAdopted: true}} + in.Adopted = &AdoptedTunnel{Tunnel: tunnel, Hub: "anchor", Adopted: true} + in.CarriedPeers = []CarriedPeer{{PublicKey: "PEER-TWO", Address: "192.0.2.2"}} +} + +// novox/hq ADR 0105 (mesh-controller network_test.go TestTheOverlaysRangeIsTheAdoptedTunnels): the range every address is +// composed from is the adopted tunnel's, read from the tunnel the hub holds, never stored anywhere else. The environment +// value of the controller is Input.OverlayCIDR here. +func TestTheOverlaysRangeIsTheAdoptedTunnels(t *testing.T) { + in := aMesh(t) + in.OverlayCIDR = "10.99.0.0/16" + if got := newComposer(in).overlayRange(); got != "10.99.0.0/16" { + t.Fatalf("without an adopted tunnel the range is not what genesis said: %q", got) + } + in.OverlayCIDR = "" + if got := newComposer(in).overlayRange(); got != DefaultOverlayCIDR { + t.Fatalf("with nothing said the range is %q, not the default", got) + } + in.OverlayCIDR = "10.99.0.0/16" + onTheTunnel(t, &in, true) + if got := newComposer(in).overlayRange(); got != "192.0.2.0/24" { + t.Fatalf("with an adopted tunnel the range is %q, not the tunnel's", got) + } + // A tunnel the hub found and did not take (its key is not the tunnel's) does not give its range. + in.Adopted.Adopted = false + if got := newComposer(in).overlayRange(); got != "10.99.0.0/16" { + t.Fatalf("a tunnel the hub did not take gave its range: %q", got) + } + in.Adopted.Adopted = true + + // And the hub's declaration carries the peer and the takeover. + nodes, computed, err := Graph(in) + if err != nil { + t.Fatal(err) + } + var hubNode overlay.Node + for _, n := range nodes { + if n.Name == "anchor" { + hubNode = n + } + } + if hubNode.TakesOver == nil || hubNode.TakesOver.Unit != "wg-quick@wg0" { + t.Errorf("the hub is not told to take over the found tunnel: %+v", hubNode) + } + carried := false + for _, p := range computed["anchor"] { + if p.Key == "PEER-TWO" && p.Allowed == "192.0.2.2/32" { + carried = true + } + } + if !carried { + t.Errorf("the hub's peer list does not carry the tunnel's peer: %+v", computed["anchor"]) + } +} + +// A takeover is composed only for a hub whose placement agrees with the tunnel (mesh-controller network_test.go +// TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel): an address or an endpoint port that differs would have the host stop +// the found interface and raise the mesh's where no peer is listening. +func TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel(t *testing.T) { + in := aMesh(t) + onTheTunnel(t, &in, false) + // aMesh placed anchor at 10.77.0.1 on :51820, the record of a hub placed before it took the tunnel over. + _, _, err := Graph(in) + if err == nil { + t.Fatal("a takeover was composed for a hub whose address and port are not the tunnel's") + } + for _, want := range []string{"10.77.0.1", "192.0.2.1", "51820", "51900", "overlay place anchor"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not say %q: %v", want, err) + } + } + // Re-placed on the tunnel, it composes. + onTheTunnel(t, &in, true) + if _, _, err := Graph(in); err != nil { + t.Fatalf("re-placed on the tunnel, the graph still refuses: %v", err) + } +} + +// 1360/AC2: the generator gives the addresses, peers and names of aMesh: anchor the hub at 10.77.0.1, laptop at 10.77.0.2 +// peering through it, both written for their machines. +func TestTheGeneratorGivesTheMeshItsAddressesAndPeers(t *testing.T) { + in := aMesh(t) + gens, err := Generators(in) + if err != nil { + t.Fatal(err) + } + g, ok := gens[overlay.Name].(*overlay.Generator) + if !ok { + t.Fatalf("the generator is %T", gens[overlay.Name]) + } + addr := map[string]string{} + for _, n := range g.Nodes() { + addr[n.Name] = n.Address + } + if addr["anchor"] != "10.77.0.1" || addr["laptop"] != "10.77.0.2" || len(addr) != 2 { + t.Fatalf("the addresses are %v", addr) + } + peers := g.Graph() + if len(peers["laptop"]) != 1 || peers["laptop"][0].Name != "anchor" || peers["laptop"][0].Endpoint != "anchor.example:51820" { + t.Errorf("laptop's peers are %+v, want the hub", peers["laptop"]) + } + if len(peers["anchor"]) != 1 || peers["anchor"][0].Name != "laptop" || peers["anchor"][0].Allowed != "10.77.0.2/32" { + t.Errorf("anchor's peers are %+v, want laptop", peers["anchor"]) + } + for _, node := range []string{"anchor", "laptop"} { + res, on, err := g.Resources(node) + if err != nil || !on || len(res) == 0 { + t.Errorf("%s is given no network: %v %v %d", node, on, err, len(res)) + } + } +} + +// 1360/AC3: a node not on the private network (it runs nothing that puts it there) gets no address and no network, and the +// one that stayed keeps its own. +func TestANodeNotOnThePrivateNetworkGetsNoAddressAndNoNetwork(t *testing.T) { + in := aMesh(t) + nodeOf(t, &in, "laptop").Assigned = nil // it keeps its place and its address, and stops running the network + gens, err := Generators(in) + if err != nil { + t.Fatal(err) + } + g := gens[overlay.Name].(*overlay.Generator) + if len(g.Nodes()) != 1 || g.Nodes()[0].Name != "anchor" { + t.Fatalf("the network is over %+v, want anchor alone", g.Nodes()) + } + if res, on, err := g.Resources("laptop"); on || err != nil || len(res) != 0 { + t.Errorf("a machine that left the network is given one: %v %v %v", res, on, err) + } + if _, in := g.Graph()["laptop"]; in { + t.Error("a machine that left the network has peers") + } +} + +// 1360/AC3: a node whose resolution is refused is said with today's words: the network missing its hub names the +// machine that could not be resolved, which is almost always why. The hub here holds no complete place (no key): a refused +// machine with one keeps it on the network (issue 1833), so only one without is missing from it. +func TestARefusedNodeIsSaidWhenTheNetworkCannotBeComposed(t *testing.T) { + in := aMesh(t) + nodeOf(t, &in, "anchor").Place.Key = "" + one := catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + two := catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + in.Catalogue["rival-one"], in.Catalogue["rival-two"] = one, two + hub := nodeOf(t, &in, "anchor") + hub.Assigned = append(hub.Assigned, "rival-one", "rival-two") // its own set is incoherent: the hub does not resolve + _, err := Generators(in) + if err == nil { + t.Fatal("a network without its hub was composed") + } + for _, want := range []string{overlay.ErrNoHub.Error(), "and 1 node(s) could not be resolved at all, which is probably why:", " anchor: "} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not say %q:\n%v", want, err) + } + } +} + +// 1360/AC4: Generators changes nothing in its Input. +func TestGeneratorsChangesNothingInItsInput(t *testing.T) { + unchanged(t, func() Input { + in := aMesh(t) + onTheTunnel(t, &in, true) + in.Joining = []string{"laptop"} + return in + }, func(in Input) { + if _, err := Generators(in); err != nil { + t.Fatal(err) + } + }) +} + +// Each field of Input that the network reads reaches the generator: change it and the generator changes. +func TestEachFieldOfTheInputReachesTheGenerator(t *testing.T) { + addressesOf := func(in Input) map[string]string { + t.Helper() + gens, err := Generators(in) + if err != nil { + t.Fatalf("%v", err) + } + out := map[string]string{} + for _, n := range gens[overlay.Name].(*overlay.Generator).Nodes() { + out[n.Name] = n.Address + } + return out + } + base := addressesOf(aMesh(t)) + if len(base) != 2 { + t.Fatalf("the base mesh has %v", base) + } + t.Run("Catalogue: without the module offering it nobody is on the network", func(t *testing.T) { + in := aMesh(t) + // The module stays known, so every machine still resolves: a machine that does not resolve keeps its place (issue 1833). + in.Catalogue = map[string]catalogue.Manifest{overlay.Name: {Module: overlay.Name, Version: "1"}} + if got := addressesOf(in); len(got) != 0 { + t.Errorf("got %v", got) + } + }) + t.Run("Nodes.Place: an address", func(t *testing.T) { + in := aMesh(t) + nodeOf(t, &in, "laptop").Place.Address = "10.77.0.9" + if got := addressesOf(in); got["laptop"] != "10.77.0.9" { + t.Errorf("got %v", got) + } + }) + t.Run("Nodes.Assigned", func(t *testing.T) { + in := aMesh(t) + nodeOf(t, &in, "laptop").Assigned = nil + if got := addressesOf(in); len(got) != 1 { + t.Errorf("got %v", got) + } + }) + t.Run("Nodes.Capabilities: a capability the module needs", func(t *testing.T) { + in := aMesh(t) + m := in.Catalogue[overlay.Name] + m.Capabilities = []string{"wireguard"} + in.Catalogue[overlay.Name] = m + nodeOf(t, &in, "laptop").Capabilities = map[string]bool{} + got, err := Generators(in) + if err != nil { + t.Fatal(err) + } + if n := got[overlay.Name].(*overlay.Generator).Nodes(); len(n) != 1 || n[0].Name != "anchor" { + t.Errorf("a machine without the capability is on the network: %+v", n) + } + }) + t.Run("OverlayCIDR", func(t *testing.T) { + in := aMesh(t) + in.OverlayCIDR = "10.99.0.0/16" + _, graph, err := Graph(in) + if err != nil { + t.Fatal(err) + } + found := false + for _, p := range graph["laptop"] { + found = found || p.Allowed == "10.99.0.0/16" + } + if !found { + t.Errorf("the hub's route is not the range given: %+v", graph["laptop"]) + } + }) + t.Run("Tunnels and Adopted and CarriedPeers", func(t *testing.T) { + in := aMesh(t) + onTheTunnel(t, &in, true) + nodes, graph, err := Graph(in) + if err != nil || len(nodes) != 2 { + t.Fatalf("%v %v", nodes, err) + } + if len(graph["anchor"]) < 2 { + t.Errorf("the hub's peers do not include the carried one: %+v", graph["anchor"]) + } + }) + t.Run("Joining: a machine with a key and an address is on the network before it is anything else", func(t *testing.T) { + in := aMesh(t) + in.Nodes = append(in.Nodes, Node{Name: "newcomer", Place: Place{Key: keyOf(t, "newcomer"), Address: "10.77.0.3"}}) + if got := addressesOf(in); got["newcomer"] != "" { + t.Fatalf("a machine that runs nothing is on the network: %v", got) + } + in.Joining = []string{"newcomer"} + if got := addressesOf(in); got["newcomer"] != "10.77.0.3" { + t.Errorf("a joining machine is not on the network: %v", got) + } + }) + t.Run("Holdings: a holder on record settles a seat two assignments could hold", func(t *testing.T) { + withSeat(t) + in := aMesh(t) + for _, name := range []string{"holder-one", "holder-two"} { + in.Catalogue[name] = aBrokerClaiming(name) + } + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "holder-one", "holder-two") + // Without a key: a machine that does not resolve and holds a complete place keeps it on the network (issue 1833). + laptop.Place.Key = "" + if got := addressesOf(in); got["laptop"] != "" { + t.Fatalf("a machine whose set claims one seat twice is on the network: %v", got) + } + in.Holdings = []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "holder-one"}} + if got := addressesOf(in); got["laptop"] != "10.77.0.2" { + t.Errorf("a holder on record did not settle the seat: %v", got) + } + }) +} diff --git a/internal/compose/node.go b/internal/compose/node.go new file mode 100644 index 0000000..550b572 --- /dev/null +++ b/internal/compose/node.go @@ -0,0 +1,351 @@ +package compose + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/validate" +) + +// Declared is a declaration as a machine is sent it (the controller's sendable, sendable.go): its resources and, for an adopted +// node, its mode and which modules were taken on it (novox/hq ADR 0100), with what the composition says beside it and never puts on +// the wire. +// +// **Body is the only place the envelope is marshalled**, so the digest the mesh compares and the body validated are one. +type Declared struct { + Resources []map[string]any + // Sequence, Epoch and Generation are what the Input gave the node: zero is not sent at all. + Sequence int64 + Epoch uint64 + Generation int64 + // ReadsModules says the machine's node-engine reads a module per resource and a build per module (novox/hq issue 1145): only + // then are they sent. ModuleOf is the composer's owner of each resource id and BuildIDs the build each module carries. + ReadsModules bool + ModuleOf map[string]string + BuildIDs map[string]string + // Adoption is nil for a converged node, and then the body is byte for byte what it was before adoption existed. + Adoption *Adoption + // Filled is the path of every file this declaration hands to the provisioner, and Provisioner the account its client runs as + // (novox/hq ADR 0349): on the wire as `filled_files` and `provisioner_account`, both absent when nothing is handed. + Filled []string + Provisioner string + // LeftOut is every module of the machine's set left out because a stored setting cannot compose with its definition (novox/hq + // ADR 0163, rule 6), sorted; LeftOutWhy is why each was. + LeftOut []string + LeftOutWhy map[string]string + + // Not on the wire: + Received map[string]map[string][]catalogue.Contribution + Mesh []string + BusUsers string + // Modules are the modules this declaration names, sorted. + Modules []string + // LocalNetwork is every port admitted from the local network and what it resolved to (novox/hq ADR 0340). + LocalNetwork []catalogue.LocalNetworkPort + Withheld []catalogue.Overflow + Unbound []catalogue.Unbound + Unplaced []string + HandedOnly []string + // RequiredBy is, for each handed module only pulled in by another, the module that requires it (issue 765). + RequiredBy map[string]string + // Foreseen is every own secret composed with a stand-in, as `module/name`; StoodIn is everything the composition stood in for + // (an own secret, a grant's sealed copy, the certificate, the bus's user list); ForeseenPorts the ports the mesh may assign that + // the Input holds none for. A declaration that has any is never sent: what a send would make, named, never invented. + Foreseen []string + StoodIn []string + ForeseenPorts []string + // CoreBoxesWanted are the core credentials whose box the send must ask the vault for and keep. + CoreBoxesWanted []CoreBoxWanted + // Passes are the seats the mesh would pass on, for the caller to record, and Said the lines found while composing. + Passes Passes + Said []string +} + +// Adoption is what an adopted node is told about its mode. Taken is every module taken on it that it runs; Untaken is, for every +// module it runs that is not taken, the ids of every one of that module's resources: what the host keeps as found until the module +// is taken (ADR 0103). +type Adoption struct { + Taken []string `json:"taken"` + Untaken map[string][]string `json:"untaken,omitempty"` +} + +// Body is the declaration's bytes, as sent and as digested. +func (d Declared) Body() ([]byte, error) { + envelope := map[string]any{"declaration": 1, "resources": d.Resources} + if d.ReadsModules { + // The module of each resource the composer owns, and the build of each module (novox/hq issue 1145): to a machine that + // said it reads them, and no other. + named := make([]map[string]any, len(d.Resources)) + for n, r := range d.Resources { + module, owned := d.ModuleOf[fmt.Sprint(r["id"])] + if !owned { + named[n] = r + continue + } + with := make(map[string]any, len(r)+1) + for k, v := range r { + with[k] = v + } + with["module"] = module + named[n] = with + } + envelope["resources"] = named + if len(d.BuildIDs) > 0 { + envelope["builds"] = d.BuildIDs + } + } + if d.Adoption != nil { + envelope["adoption"] = d.Adoption + } + if d.Sequence > 0 { + envelope["sequence"] = d.Sequence + } + if d.Epoch > 0 { + envelope["epoch"] = d.Epoch + } + if len(d.LeftOut) > 0 { + envelope["left_out"] = d.LeftOut + } + if d.Generation > 0 { + envelope["generation"] = d.Generation + } + if len(d.Filled) > 0 { + envelope["filled_files"] = d.Filled + envelope["provisioner_account"] = d.Provisioner + } + // An empty declaration is deliberate: the node owns nothing the mesh put there (novox/hq issue 127), and the host refuses an + // empty body unless it is told the emptiness is meant. + if len(d.Resources) == 0 { + envelope["owns_nothing"] = true + } + return json.Marshal(envelope) +} + +// Refusals are the findings a check refuses a change for, beside the validator's problems: a consumer left out of the grants +// because its identity overflows the provision's bound, and a credential bound to another consumer (novox/hq ADR 0446 clause 1). +// Each names the consumer and the provision. +func (d Declared) Refusals() []string { + var out []string + for _, o := range d.Withheld { + out = append(out, o.String()) + } + for _, u := range d.Unbound { + out = append(out, u.String()) + } + sort.Strings(out) + return out +} + +// Compose composes one node's declaration (the issue calls it Node; the Input already has a type of that name) over an Input -- as a push would, without the sending (the controller's +// composedAndValidated in the Foreseeing mode) -- and runs the node-engine's own validator over its body. An error is that the +// node did not compose (a set that does not compose satisfies IsUnresolvable); problems are what the validator refuses, in the +// node-engine's words. It allocates, makes, keeps and writes nothing, and names what a send would do as foreseen. +func Compose(in Input, node string) (Declared, []string, error) { + return ComposeIn(in, node, Foreseeing) +} + +// ComposeIn is Compose in a chosen mode. +func ComposeIn(in Input, node string, mode Mode) (Declared, []string, error) { + c := newComposer(in) + d, err := c.declaration(node, mode) + if err != nil { + return Declared{}, nil, err + } + body, err := d.Body() + if err != nil { + return Declared{}, nil, err + } + return d, validate.Declaration(body), nil +} + +// declaration is the controller's declarationWith over the plain Input. +func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) { + resolved, err := c.planFor(nodeName) + if err != nil { + return Declared{}, err + } + gens, err := c.generators() + if err != nil { + return Declared{}, err + } + rendered, err := c.render(nodeName, mode) + if err != nil { + return Declared{}, err + } + with := rendered.Rendering + with.Generators = gens + plan := resolved.Plan + composed, err := plan.Compose(with) + if err != nil { + return Declared{}, err + } + // Which ports the filter admits from the local network, and from which networks (novox/hq ADR 0340 clause 8): the same rules the + // composition rendered, never sent. + rules, err := plan.Rules(with) + if err != nil { + return Declared{}, err + } + node, err := c.in.node(nodeName) + if err != nil { + return Declared{}, err + } + out := Declared{Resources: composed.Resources, Adoption: adoptionFor(plan, node, composed), + Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers, + LeftOut: sortedKeysOf(composed.LeftOut), LeftOutWhy: composed.LeftOut, + Withheld: rendered.Withheld, Unbound: rendered.Unbound, + Foreseen: composed.Foreseen, StoodIn: rendered.Foreseen, ForeseenPorts: rendered.ForeseenPorts, + Unplaced: composed.Unplaced, HandedOnly: composed.HandedOnly, Modules: namedModules(plan, composed.LeftOut), + Filled: composed.Filled, LocalNetwork: catalogue.LocalNetworkPorts(rules, with.LocalNetworks), + CoreBoxesWanted: rendered.CoreBoxesWanted, Said: rendered.Said, + Sequence: node.Sequence, Epoch: node.Epoch, Generation: node.Generation} + if len(composed.Filled) > 0 { + out.Provisioner = with.Provisioner + } + // A handed module only pulled in by another keeps what requires it, for its value of its own in the assignments mirror (issue 765). + for module := range composed.Plans { + if by := plan.RequiredBy(module); by != "" { + if out.RequiredBy == nil { + out.RequiredBy = map[string]string{} + } + out.RequiredBy[module] = by + } + } + // And the module of each resource and the build of each module, to a machine whose node-engine said it reads them (novox/hq issue + // 1145) and to no other. + if node.ReadsModules { + carried := c.carriedOn(node, plan, composed.LeftOut) + out.ReadsModules, out.ModuleOf, out.BuildIDs = true, composed.Owner, c.wireBuilds(carried, node.Runs) + } + _, passes, left, err := c.theRestOfTheMesh("") + if err != nil { + return Declared{}, err + } + out.Passes = passes + for _, l := range left { + out.Said = append(out.Said, l.String()) + } + out.Said = c.saidBeside(out.Said) + return out, nil +} + +// carriedOn is the build of each module a declaration for the node carries (plan.go carriedOn, in a question that sends nothing): the +// module's current build for each module in it, and for a module left out of it the build it was last sent, since the machine keeps +// that one -- or nothing, when that is not known. +func (c *composer) carriedOn(node Node, plan catalogue.Resolution, leftOut map[string]string) map[string]string { + before := node.SentBuilds + if !node.SentKnown { + before = nil + } + out := map[string]string{} + for _, m := range plan.Modules { + if _, left := leftOut[m.Module]; left { + if was, kept := before[m.Module]; kept { + out[m.Module] = was + } + continue + } + out[m.Module] = c.in.CurrentBuilds[m.Module] + // A module kept at the build the machine runs is carried at that build (plan.go carriedOn). + if kept, ok := node.KeptBuilds[m.Module]; ok && kept != "" { + out[m.Module] = kept + } + } + return out +} + +// wireBuilds is the build of each carried module that goes on the wire (novox/hq issue 1145): the build its assignment names when +// that assignment is of the commit carried, else the build made from that commit; a module with neither is not named. +func (c *composer) wireBuilds(carried map[string]string, runs map[string]Run) map[string]string { + out := map[string]string{} + for module, commit := range carried { + if commit == "" { + continue + } + if ref, ok := runs[module]; ok && ref.Commit == commit && ref.Build != "" { + out[module] = ref.Build + continue + } + if build := c.in.BuildIDs[module][commit]; build != "" { + out[module] = build + } + } + return out +} + +// adoptionFor is the envelope computed from what was taken and who owns each resource: nil for a converged node. Every module the +// node runs that is not taken is untaken -- including one pulled in by another rather than assigned. +func adoptionFor(plan catalogue.Resolution, node Node, composed catalogue.Composed) *Adoption { + if !node.Adopted { + return nil + } + isTaken := map[string]bool{} + for _, m := range node.Taken { + isTaken[m] = true + } + out := &Adoption{Taken: []string{}} + runs := map[string]bool{} + for _, m := range plan.Modules { + runs[m.Module] = true + if isTaken[m.Module] { + out.Taken = append(out.Taken, m.Module) + } + } + sort.Strings(out.Taken) + for _, r := range composed.Resources { + id, _ := r["id"].(string) + module, owned := composed.Owner[id] + // Every kind, not only files and containers (novox/hq ADR 0103). What the mesh declares of its own is never held. + if !owned || !runs[module] || isTaken[module] || strings.HasPrefix(id, catalogue.AdoptionPrefix) { + continue + } + if out.Untaken == nil { + out.Untaken = map[string][]string{} + } + out.Untaken[module] = append(out.Untaken[module], id) + } + return out +} + +// sortedKeysOf is a map's keys, sorted: so what a declaration says it left out does not move for a reordering nobody made. +func sortedKeysOf(m map[string]string) []string { + if len(m) == 0 { + return nil + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// namedModules are the modules a declaration names: its machine's set, less what was left out of it. +func namedModules(plan catalogue.Resolution, leftOut map[string]string) []string { + out := make([]string, 0, len(plan.Modules)) + for _, m := range plan.Modules { + if _, left := leftOut[m.Module]; !left { + out = append(out, m.Module) + } + } + sort.Strings(out) + return out +} + +// DataItems is every data item the modules placed on a node declare, placed there (novox/hq ADR 0571 clause 3): what the +// node-backup holder is handed as `${backup:data}`, composed over the Input as Compose composes the node, so disk-load +// reads the list from the bus with no composed file. Unplaced names each item left out and why. +func DataItems(in Input, node string) (map[string][]catalogue.PlacedDataItem, []string, error) { + c := newComposer(in) + resolved, err := c.planFor(node) + if err != nil { + return nil, nil, err + } + rendered, err := c.render(node, Planning) + if err != nil { + return nil, nil, err + } + return resolved.Plan.DataItems(rendered.Rendering) +} diff --git a/internal/compose/node_test.go b/internal/compose/node_test.go new file mode 100644 index 0000000..5dcbee4 --- /dev/null +++ b/internal/compose/node_test.go @@ -0,0 +1,424 @@ +package compose + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/overlay" +) + +func resourceIDs(d Declared) []string { + var ids []string + for _, r := range d.Resources { + ids = append(ids, r["type"].(string)+":"+r["id"].(string)) + } + return ids +} + +func has(ids []string, want string) bool { + for _, id := range ids { + if id == want { + return true + } + } + return false +} + +// aSmallMesh is three nodes, a provider and its consumer, a seat and the private network: aMesh with `ace` running a database and +// a resolver holding the mesh's resolver seat on record (the providers and consumer of mesh-controller's facts_test.go +// aMeshWithSecrets, the seat of bindings_test.go storeManifests). +func aSmallMesh(t *testing.T) Input { + t.Helper() + in := threeNodes(t) + for _, m := range storeManifests() { + if m.Module == "resolver" { + in.Catalogue[m.Module] = m + } + } + ace := nodeOf(t, &in, "ace") + ace.Assigned = append(ace.Assigned, "resolver") + in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "ace", Module: "resolver", Site: "there"}} + return in +} + +// 1298/AC1: each node of a small mesh composes, its body is accepted by the node-engine's validator, and it names the resources the +// controller composes for the same case: the private network's files on the nodes that run it, the provider's received file, the +// consumer's file. +func TestEachNodeOfASmallMeshComposesAndItsBodyIsAcceptedByTheValidator(t *testing.T) { + in := aSmallMesh(t) + want := map[string][]string{ + "anchor": {"package:mesh-wireguard.overlay-tools", "file:mesh-wireguard.overlay-config", "service:mesh-wireguard.overlay-up", "file:objects.received-s3-bucket"}, + "laptop": {"package:mesh-wireguard.overlay-tools", "file:mesh-wireguard.overlay-config", "service:mesh-wireguard.overlay-up", "file:files.config"}, + "ace": nil, + } + for node, ids := range want { + d, problems, err := Compose(in, node) + if err != nil || len(problems) != 0 { + t.Fatalf("%s: %v %v", node, err, problems) + } + for _, id := range ids { + if !has(resourceIDs(d), id) { + t.Errorf("%s does not name %s: %v", node, id, resourceIDs(d)) + } + } + body, err := d.Body() + if err != nil { + t.Fatal(err) + } + var parsed struct { + Declaration int `json:"declaration"` + Resources []map[string]any `json:"resources"` + } + if err := json.Unmarshal(body, &parsed); err != nil || parsed.Declaration != 1 || len(parsed.Resources) != len(d.Resources) { + t.Errorf("%s: body %s %v", node, body, err) + } + } + // ace runs a resolver and a database and nothing the mesh declares of its own: an empty declaration says it owns nothing. + d, _, _ := Compose(in, "ace") + if body, _ := d.Body(); !strings.Contains(string(body), `"owns_nothing":true`) { + t.Errorf("an empty declaration is not said to be deliberate: %s", body) + } +} + +// 1298/AC2 (mesh-controller merge_gate_integration_test.go TestIssue236AManifestTheNodeEngineRefusesFailsThePullRequest, the manifest): +// a service that omits its state is accepted by the catalogue's check and refused by the node-engine; Compose returns the validator's +// problems in the node-engine's words and no error. +func TestAManifestTheNodeEngineRefusesComesBackAsProblemsAndNoError(t *testing.T) { + in := aMesh(t) + var lemurs catalogue.Manifest + if err := json.Unmarshal([]byte(`{"module":"lemurs","version":"1","capabilities":["systemd"], + "resources":[{"id":"service","type":"service","unit":"lemurs.service","boot":"enabled"}]}`), &lemurs); err != nil { + t.Fatal(err) + } + in.Catalogue["lemurs"] = lemurs + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "lemurs") + d, problems, err := Compose(in, "laptop") + if err != nil { + t.Fatalf("a refusal of the validator is an error: %v", err) + } + if len(problems) == 0 || !strings.Contains(strings.Join(problems, "\n"), "lemurs.service") { + t.Fatalf("problems are %v", problems) + } + if !has(resourceIDs(d), "service:lemurs.service") { + t.Errorf("the declaration does not carry the resource: %v", resourceIDs(d)) + } + // And the same, as the validator says it of the body. + body, _ := d.Body() + if again := strings.Join(problems, "\n"); again == "" || !strings.Contains(string(body), "lemurs.service") { + t.Errorf("the body does not name the service: %s", body) + } +} + +// 1298/AC3: a node whose own set does not compose returns an error and no declaration, told apart from an unreadable Input. +func TestANodeWhoseSetDoesNotComposeReturnsAnErrorAndNoDeclaration(t *testing.T) { + in := aMesh(t) + in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two") + d, problems, err := Compose(in, "laptop") + if err == nil || !IsUnresolvable(err) || d.Resources != nil || problems != nil { + t.Fatalf("a set that does not compose: %v %v %v", d.Resources, problems, err) + } + if _, _, err := Compose(in, "nowhere"); err == nil || IsUnresolvable(err) { + t.Fatalf("a node the Input does not have: %v", err) + } +} + +// 1298/AC4: composing the same Input twice gives byte-identical bodies, and the Input is unchanged. +func TestComposingTwiceGivesTheSameBytesAndChangesNothing(t *testing.T) { + mk := func() Input { + in := aSmallMesh(t) + nodeOf(t, &in, "anchor").Account = "jochens" + nodeOf(t, &in, "laptop").Sequence = 7 + return in + } + in := mk() + for _, node := range []string{"anchor", "laptop", "ace"} { + a, _, err := Compose(in, node) + if err != nil { + t.Fatal(err) + } + b, _, err := Compose(in, node) + if err != nil { + t.Fatal(err) + } + ab, _ := a.Body() + bb, _ := b.Body() + if string(ab) != string(bb) { + t.Errorf("%s: two compositions differ:\n%s\n%s", node, ab, bb) + } + } + unchanged(t, mk, func(in Input) { + for _, node := range []string{"anchor", "laptop", "ace"} { + if _, _, err := Compose(in, node); err != nil { + t.Fatal(err) + } + } + }) +} + +// 1298/AC5: what a send would make is named in the result as foreseen and never invented: an own secret, a port, a core box, a grant's +// sealed copy. +func TestWhatASendWouldMakeIsNamedAsForeseenAndNeverInvented(t *testing.T) { + in := aGrantMesh(t) + in.Issued[0].ForProvider = "" + in.Catalogue["assistant"] = anAssistant() + in.Catalogue["web"] = aWebModule() + in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", + OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}} + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "assistant", "web", "nats") + anchor.SealingKey = keyOf(t, "anchor") + anchor.Stated, anchor.Contract = true, VaultBoxContract + in.BusMinted = []string{"anchor.nats"} + d, problems, err := Compose(in, "anchor") + if err != nil || len(problems) != 0 { + t.Fatalf("%v %v", err, problems) + } + if !has(d.Foreseen, "assistant/password") || !has(d.Foreseen, "nats/broker") { + t.Errorf("own secrets foreseen: %v", d.Foreseen) + } + if len(d.ForeseenPorts) != 1 || d.ForeseenPorts[0] != "web:8080" { + t.Errorf("foreseen ports: %v", d.ForeseenPorts) + } + if len(d.CoreBoxesWanted) != 1 || d.CoreBoxesWanted[0].Module != "nats" { + t.Errorf("core boxes wanted: %v", d.CoreBoxesWanted) + } + if !has(d.StoodIn, "the grant of s3-bucket for files on laptop") { + t.Errorf("stood in for: %v", d.StoodIn) + } + body, _ := d.Body() + if strings.Contains(string(body), secretValue) { + t.Error("a secret value is in the body") + } +} + +// 1298/AC6: the body carries the sequence, epoch and generation the Input gives, and the module of each resource and the carried builds +// only to a node whose Input says it reads modules (novox/hq issue 1145). +func TestTheBodyCarriesTheOrderTheInputGivesAndTheBuildsOnlyToAnEngineThatReadsThem(t *testing.T) { + in := aSmallMesh(t) + laptop := nodeOf(t, &in, "laptop") + laptop.Sequence, laptop.Epoch, laptop.Generation = 42, 57, 9 + in.CurrentBuilds = map[string]string{"files": "commit-files", "mesh-wireguard": "commit-wg"} + in.BuildIDs = map[string]map[string]string{"files": {"commit-files": "build-7"}} + // An assignment of another commit than the one carried does not name the build; the build made from the commit carried does. + laptop.Runs = map[string]Run{"mesh-wireguard": {Commit: "commit-wg", Build: "build-9"}, "files": {Commit: "an-older-commit", Build: "build-6"}} + d, _, err := Compose(in, "laptop") + if err != nil { + t.Fatal(err) + } + body, _ := d.Body() + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatal(err) + } + if got["sequence"] != float64(42) || got["epoch"] != float64(57) || got["generation"] != float64(9) { + t.Errorf("the order is %v %v %v", got["sequence"], got["epoch"], got["generation"]) + } + if _, there := got["builds"]; there { + t.Errorf("builds are sent to an engine that does not read them: %s", body) + } + for _, r := range got["resources"].([]any) { + if _, there := r.(map[string]any)["module"]; there { + t.Errorf("a module is named on a resource of an engine that does not read them: %v", r) + } + } + laptop.ReadsModules = true + d, _, err = Compose(in, "laptop") + if err != nil { + t.Fatal(err) + } + body, _ = d.Body() + got = nil + if err := json.Unmarshal(body, &got); err != nil { + t.Fatal(err) + } + builds, _ := got["builds"].(map[string]any) + if builds["files"] != "build-7" || builds["mesh-wireguard"] != "build-9" { + t.Errorf("builds are %v", builds) + } + named := false + for _, r := range got["resources"].([]any) { + if r.(map[string]any)["id"] == "files.config" && r.(map[string]any)["module"] == "files" { + named = true + } + } + if !named { + t.Errorf("a resource is not named for its module: %s", body) + } + // Zero is not sent at all. + laptop.Sequence, laptop.Epoch, laptop.Generation = 0, 0, 0 + d, _, _ = Compose(in, "laptop") + body, _ = d.Body() + for _, key := range []string{`"sequence"`, `"epoch"`, `"generation"`} { + if strings.Contains(string(body), key) { + t.Errorf("%s is sent as zero: %s", key, body) + } + } +} + +// A module left out of a build set keeps the build it was last sent (novox/hq issue 259): the machine keeps that one. +func TestAModuleLeftOutKeepsTheBuildItWasLastSent(t *testing.T) { + c := newComposer(Input{CurrentBuilds: map[string]string{"a": "new-a", "b": "new-b"}}) + plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "a"}, {Module: "b"}, {Module: "c"}}} + node := Node{SentBuilds: map[string]string{"b": "old-b", "c": "old-c"}, SentKnown: true} + got := c.carriedOn(node, plan, map[string]string{"b": "its settings cannot compose"}) + if got["a"] != "new-a" || got["b"] != "old-b" || got["c"] != "" { + t.Errorf("carried builds are %v", got) + } + node.SentKnown = false + if got := c.carriedOn(node, plan, map[string]string{"b": "x"}); len(got) != 2 || got["b"] != "" { + t.Errorf("when what was sent is not known: %v", got) + } +} + +// An adopted node is told which modules were taken and, for every module it runs that is not, the ids of its resources +// (novox/hq ADR 0100 and 0103); a converged node is told nothing, and its body is as it was before adoption. +func TestAnAdoptedNodeIsToldWhatWasTakenAndWhatIsKept(t *testing.T) { + in := aSmallMesh(t) + d, _, err := Compose(in, "laptop") + if err != nil || d.Adoption != nil { + t.Fatalf("a converged node: %v %v", d.Adoption, err) + } + laptop := nodeOf(t, &in, "laptop") + laptop.Adopted, laptop.Taken = true, []string{"mesh-wireguard"} + d, problems, err := Compose(in, "laptop") + if err != nil || len(problems) != 0 { + t.Fatalf("%v %v", err, problems) + } + if d.Adoption == nil || len(d.Adoption.Taken) != 1 || d.Adoption.Taken[0] != "mesh-wireguard" || + len(d.Adoption.Untaken["files"]) == 0 || d.Adoption.Untaken["mesh-wireguard"] != nil { + t.Fatalf("adoption is %+v", d.Adoption) + } + if body, _ := d.Body(); !strings.Contains(string(body), `"adoption"`) { + t.Errorf("the adoption is not on the wire: %s", body) + } +} + +// Issue 263 (merge_gate_integration_test.go TestIssue263AnIdentityARealMachineNameOverflows...): 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. The +// composition of the provider returns the refusal, naming the module and the provision. +func TestIssue263AnIdentityARealMachineNameOverflowsIsARefusal(t *testing.T) { + in := aGrantMesh(t) + in.Catalogue["resolver"] = storeManifests()[1] + in.Catalogue["networkmanager"] = catalogue.Manifest{Module: "networkmanager", Version: "1", Requires: []string{"wildcard-resolution", "s3-bucket"}} + laptop, anchor := nodeOf(t, &in, "laptop"), nodeOf(t, &in, "anchor") + laptop.Assigned = append(laptop.Assigned, "networkmanager") + anchor.Assigned = append(anchor.Assigned, "resolver") + in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}} + in.Issued = append(in.Issued, Issued{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "networkmanager", Provider: "anchor", ForProvider: "sealed"}) + d, problems, err := Compose(in, "anchor") + if err != nil || len(problems) != 0 { + t.Fatalf("%v %v", err, problems) + } + refusals := strings.Join(d.Refusals(), "\n") + if !strings.Contains(refusals, "networkmanager") || !strings.Contains(refusals, "s3-bucket") || !strings.Contains(refusals, "laptop") { + t.Fatalf("the refusal does not name the module, the provision and the machine: %q", refusals) + } + // And the consumer that fits is granted. + if len(d.Withheld) != 1 || d.Withheld[0].Module != "networkmanager" { + t.Errorf("withheld %+v", d.Withheld) + } +} + +// Round 1, finding 1 (plan.go keepRecorded): a recorded module kept at the build a machine runs is that machine's own resolution +// and no other's; the commit carried for it is the kept one. +func TestAKeptModuleChangesOnlyTheResolutionOfTheNodeThatKeepsIt(t *testing.T) { + in := aSmallMesh(t) + in.Catalogue["tool"] = catalogue.Manifest{Module: "tool", Version: "2"} + for _, name := range []string{"anchor", "laptop"} { + n := nodeOf(t, &in, name) + n.Assigned = append(n.Assigned, "tool") + } + anchor := nodeOf(t, &in, "anchor") + anchor.Kept = map[string]catalogue.Manifest{"tool": {Module: "tool", Version: "1"}} + versionOn := func(in Input, node string) string { + t.Helper() + got, err := Resolve(in, node) + if err != nil { + t.Fatal(err) + } + for _, m := range got.Plan.Modules { + if m.Module == "tool" { + return m.Version + } + } + t.Fatalf("no tool on %s", node) + return "" + } + if v := versionOn(in, "anchor"); v != "1" { + t.Errorf("the node that keeps it runs version %s", v) + } + if v := versionOn(in, "laptop"); v != "2" { + t.Errorf("another node runs version %s: a kept build leaked", v) + } + if got := in.Catalogue["tool"].Version; got != "2" { + t.Errorf("the Input's catalogue was changed: %s", got) + } + // And the commit carried for it is the kept one. + c := newComposer(in) + plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "tool"}, {Module: "other"}}} + in.CurrentBuilds = map[string]string{"tool": "current", "other": "current-other"} + c = newComposer(in) + node := Node{KeptBuilds: map[string]string{"tool": "kept-commit"}} + if got := c.carriedOn(node, plan, nil); got["tool"] != "kept-commit" || got["other"] != "current-other" { + t.Errorf("carried builds are %v", got) + } +} + +// Another node's kept build changes neither the rest of the mesh nor who is on the network: the controller reads both with the +// current manifests, and only then keeps a recorded module for the node it composes (recorded_kept.go keepRecorded). +func TestAnotherNodesKeptBuildDoesNotChangeTheRestOfTheMeshOrWhoIsOnTheNetwork(t *testing.T) { + in := threeNodes(t) + nodeOf(t, &in, "anchor").Kept = map[string]catalogue.Manifest{"objects": {Module: "objects", Version: "0"}, overlayName(): {Module: overlayName(), Version: "0"}} + w, _, _, err := TheRestOfTheMesh(in, "laptop") + if err != nil || len(providers(w, "s3-bucket")) != 1 { + t.Errorf("a kept build changed what anchor offers: %v %v", providers(w, "s3-bucket"), err) + } + where, err := newComposer(in).whereEveryoneIs() + if err != nil || where["anchor"] == "" { + t.Errorf("a kept build took anchor off the network: %v %v", where, err) + } +} + +func overlayName() string { return overlay.Name } + +// Round 1, finding 6: the nodes the composition did not count as on the network are returned, said, not lost. laptop holds no +// complete place (no key): one that does keeps it on the network (issue 1833) and is counted. +func TestANodeNotCountedAsOnTheNetworkIsSaidInTheResult(t *testing.T) { + in := aMesh(t) + nodeOf(t, &in, "laptop").Place.Key = "" + in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two") + said := func(lines []string) bool { + for _, l := range lines { + if strings.HasPrefix(l, "laptop is not counted as on the network") { + return true + } + } + return false + } + got, err := Render(in, "anchor", Foreseeing) + if err != nil || !said(got.Said) { + t.Errorf("rendered: %v %v", got.Said, err) + } + d, _, err := Compose(in, "anchor") + if err != nil || !said(d.Said) { + t.Errorf("declared: %v %v", d.Said, err) + } + n := 0 + for _, l := range d.Said { + if strings.HasPrefix(l, "laptop is not counted") { + n++ + } + } + if n != 1 { + t.Errorf("said %d times: %v", n, d.Said) + } +} diff --git a/internal/compose/one_machine_alone_test.go b/internal/compose/one_machine_alone_test.go new file mode 100644 index 0000000..3c49abb --- /dev/null +++ b/internal/compose/one_machine_alone_test.go @@ -0,0 +1,167 @@ +package compose + +import ( + "slices" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/overlay" +) + +// fourOnTheNetwork is aMesh and two more machines on the private network at the site `there`: `ace`, dialled at +// ace.example:51820, at 10.77.0.3, and `box`, behind no endpoint, at 10.77.0.4. A module `office` requires +// `document-server`, which nothing in the catalogue provides: the shape of issue 1833 (nextcloud on the hub wanting a +// document server nobody offered). +func fourOnTheNetwork(t *testing.T) Input { + t.Helper() + in := aMesh(t) + in.Catalogue["office"] = catalogue.Manifest{Module: "office", Version: "1", Requires: []string{"document-server"}} + caps := map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true} + in.Nodes = append(in.Nodes, + Node{Name: "ace", Place: Place{Key: keyOf(t, "ace"), Endpoint: "ace.example:51820", Site: "there", Address: "10.77.0.3"}, + Assigned: []string{overlay.Name}, Capabilities: caps}, + Node{Name: "box", Place: Place{Key: keyOf(t, "box"), Site: "there", Address: "10.77.0.4"}, + Assigned: []string{overlay.Name}, Capabilities: caps}) + return in +} + +// unresolvableBy makes one machine's own set fail to resolve: it is also given `office`, whose requirement nothing provides. +func unresolvableBy(t *testing.T, in *Input, name string) { + t.Helper() + n := nodeOf(t, in, name) + n.Assigned = append(n.Assigned, "office") +} + +// networkOf is the generator of an Input's private network, failing the test when it is not composed. +func networkOf(t *testing.T, in Input) *overlay.Generator { + t.Helper() + gens, err := Generators(in) + if err != nil { + t.Fatalf("the private network was not composed: %v", err) + } + return gens[overlay.Name].(*overlay.Generator) +} + +func placesOf(g *overlay.Generator) map[string]overlay.Node { + out := map[string]overlay.Node{} + for _, n := range g.Nodes() { + out[n.Name] = n + } + return out +} + +// 1833/AC1: four machines, the hub's own set does not resolve; the other three compose their declarations, and the hub alone is +// refused, with an error that is its own set failing and names what could not be resolved. +func TestOneMachineThatDoesNotResolveIsRefusedAlone(t *testing.T) { + in := fourOnTheNetwork(t) + unresolvableBy(t, &in, "anchor") + for _, node := range []string{"laptop", "ace", "box"} { + d, problems, err := Compose(in, node) + if err != nil || len(problems) != 0 { + t.Errorf("%s does not compose because another machine does not resolve: %v %v", node, err, problems) + continue + } + if !has(resourceIDs(d), "file:mesh-wireguard.overlay-config") { + t.Errorf("%s is given no private network: %v", node, resourceIDs(d)) + } + } + _, _, err := Compose(in, "anchor") + if err == nil || !IsUnresolvable(err) { + t.Fatalf("the machine that does not resolve is not refused as its own set failing: %v", err) + } + if !strings.Contains(err.Error(), `nothing provides "document-server", wanted by office`) { + t.Errorf("the refusal does not name what could not be resolved: %v", err) + } +} + +// 1833/AC2: the private network is composed with every machine on it: the hub that does not resolve keeps its place (key, +// address, endpoint, site, hub role), so every other machine keeps its path through it. +func TestAMachineThatDoesNotResolveKeepsItsPlaceOnTheNetwork(t *testing.T) { + in := fourOnTheNetwork(t) + unresolvableBy(t, &in, "anchor") + got := placesOf(networkOf(t, in)) + if len(got) != 4 { + t.Fatalf("the network is over %v, want all four machines", got) + } + hub := got["anchor"] + if !hub.Hub || hub.Key != keyOf(t, "anchor") || hub.Address != "10.77.0.1" || hub.Endpoint != "anchor.example:51820" || hub.Site != "here" { + t.Errorf("the hub did not keep its place: %+v", hub) + } + nodes, graph, err := Graph(in) + if err != nil || len(nodes) != 4 { + t.Fatalf("the graph is not shown with every machine: %v %v", nodes, err) + } + for _, node := range []string{"ace", "box"} { + through := false + for _, p := range graph[node] { + through = through || (p.Name == "anchor" && p.Endpoint == "anchor.example:51820") + } + if !through { + t.Errorf("%s has no path through the hub: %+v", node, graph[node]) + } + } +} + +// 1833/AC3: a machine that does not resolve and was never placed (a new machine: no key, no address) is left off the network, +// which is composed from the rest -- here with the hub that does not resolve on its last place -- and that machine alone is refused. +func TestANewMachineThatDoesNotResolveIsLeftOffAndRefusedAlone(t *testing.T) { + in := fourOnTheNetwork(t) + unresolvableBy(t, &in, "anchor") + in.Nodes = append(in.Nodes, Node{Name: "fresh", Place: Place{Site: "there"}, + Assigned: []string{overlay.Name, "office"}, Capabilities: map[string]bool{"wireguard": true, "systemd": true}}) + got := placesOf(networkOf(t, in)) + if _, on := got["fresh"]; on || len(got) != 4 { + t.Fatalf("the network is over %v, want the four placed machines without fresh", got) + } + if _, _, err := Compose(in, "fresh"); err == nil || !IsUnresolvable(err) { + t.Errorf("the new machine is not refused as its own set failing: %v", err) + } + if _, problems, err := Compose(in, "box"); err != nil || len(problems) != 0 { + t.Errorf("another machine does not compose: %v %v", err, problems) + } +} + +// 1833/AC4: a network that truly cannot be composed from what remains still fails: the hub does not resolve and has no +// complete place (no key), so nothing is the hub; the refusal is today's, naming the machine that could not be resolved. +func TestANetworkThatCannotBeComposedFromWhatRemainsStillFails(t *testing.T) { + in := fourOnTheNetwork(t) + unresolvableBy(t, &in, "anchor") + nodeOf(t, &in, "anchor").Place.Key = "" + _, err := Generators(in) + if err == nil { + t.Fatal("a network without a hub was composed") + } + for _, want := range []string{overlay.ErrNoHub.Error(), "and 1 node(s) could not be resolved at all, which is probably why:", " anchor: "} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not say %q:\n%v", want, err) + } + } + if _, _, err := Compose(in, "laptop"); err == nil { + t.Error("a declaration was composed over a network that cannot be") + } +} + +// 1833/AC2, a failure the composition's unchecked pass refuses too (two modules claiming one seat on the hub, the 1360/AC3 +// shape): the hub keeps its place in what every other machine is sent besides the tunnel -- its internal name and its address +// among the sources "from the mesh" -- and another machine still composes. +func TestAMachineThatDoesNotResolveKeepsItsNameAndItsAddressInTheMesh(t *testing.T) { + in := fourOnTheNetwork(t) + in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + hub := nodeOf(t, &in, "anchor") + hub.Assigned = append(hub.Assigned, "rival-one", "rival-two") + if _, err := Resolve(in, "anchor"); !IsUnresolvable(err) { + t.Fatalf("the hub is meant not to resolve: %v", err) + } + c := newComposer(in) + if got := c.namesInTheMesh()["anchor."+c.suffix()]; got != "10.77.0.1" { + t.Errorf("the hub's name is not in the mesh's names: %v", c.namesInTheMesh()) + } + if got := c.onThePrivateNetwork(); !slices.Contains(got, "10.77.0.1") || len(got) != 4 { + t.Errorf("the sources from the mesh are %v, want all four machines with the hub", got) + } + if _, problems, err := Compose(in, "ace"); err != nil || len(problems) != 0 { + t.Errorf("another machine does not compose: %v %v", err, problems) + } +} diff --git a/internal/compose/render.go b/internal/compose/render.go new file mode 100644 index 0000000..3354fad --- /dev/null +++ b/internal/compose/render.go @@ -0,0 +1,150 @@ +package compose + +import ( + "fmt" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// Rendered is everything a node's declaration is composed with (plan.go renderingFor), over an Input: the place half (Places) +// and the credential half. Nothing was allocated, made, kept or written; what a send would do is returned for the caller to do. +type Rendered struct { + Rendering catalogue.Rendering + // ForeseenPorts are the ports the mesh may assign that the Input holds none for (":"). + ForeseenPorts []string + // Foreseen are the stand-ins the composition used for what the Input does not hold: an own secret the next send makes + // ("/"), a grant's sealed copy, the certificate and the bus's user list. + Foreseen []string + // CoreBoxesWanted are the core credentials whose box the send must ask the vault for and keep. + CoreBoxesWanted []CoreBoxWanted + // Withheld are the consumers the grants leave out because their identity overflows the provision's bound (novox/hq ADR 0225), + // and Unbound those whose credential from this node is on record and whose own resolution binds them elsewhere (issue + // 274). The controller says them; a check refuses a change that adds one (ADR 0446 clause 1). + Withheld []catalogue.Overflow + Unbound []catalogue.Unbound + // Said are the lines the controller says while composing (the bus users left out for want of a credential). + Said []string +} + +// Render composes the rendering of a node: the grants it must create, where it puts its ports, its own secrets, the vault's boxes, +// its certificate, the names of the mesh, the bus's user list and its membership. The refusals are the controller's, in its words; +// where more than one fault stands, the first reported may differ from the controller's, which interleaves the halves. +func Render(in Input, node string, mode Mode) (Rendered, error) { + return newComposer(in).render(node, mode) +} + +// Composing is Resolve, Render and Generators of one node over one composition, which resolves each node of the mesh +// once for all three (issue 1954: three compositions resolved the mesh again each, 11.7 s for one node's plans). Each +// answers as it does alone; the first refusal ends it, in that order, with what follows it empty. +func Composing(in Input, node string, mode Mode) (Resolved, Rendered, map[string]catalogue.Generator, error) { + c := newComposer(in) + resolved, err := c.planFor(node) + if err != nil { + return Resolved{}, Rendered{}, nil, err + } + rendered, err := c.render(node, mode) + if err != nil { + return resolved, Rendered{}, nil, err + } + gens, err := c.generators() + if err != nil { + return resolved, rendered, nil, err + } + return resolved, rendered, gens, nil +} + +func (c *composer) render(nodeName string, mode Mode) (Rendered, error) { + resolved, err := c.planFor(nodeName) + if err != nil { + return Rendered{}, err + } + node, err := c.in.node(nodeName) + if err != nil { + return Rendered{}, err + } + plan := resolved.Plan + var out Rendered + foreseen := func(s string) { out.Foreseen = append(out.Foreseen, s) } + + grants, withheld, unbound, grantsForeseen, err := c.grantsFor(nodeName) + if err != nil { + return Rendered{}, err + } + placed, err := c.places(nodeName, resolved) + if err != nil { + return Rendered{}, err + } + r := placed.Rendering + out.ForeseenPorts = placed.ForeseenPorts + // A plan carries its contributions' machine ports, refused where the placement assigned none (issue 774). + r.Planned = mode == Planning + r.Grants, r.Withheld, r.Unbound = grants, withheld, unbound + out.Withheld, out.Unbound = withheld, unbound + for _, g := range grantsForeseen { + foreseen(g) + } + + // And each module's own secrets: a module handed to the provisioner has its secrets from the vault through the client. + handing := catalogue.Rendering{Hands: r.Hands, Provisioner: r.Provisioner, Assigned: r.Assigned} + needed, foreseenOwn, err := c.ownSecretsFor(node, plan, handing, mode) + if err != nil { + return Rendered{}, err + } + r.Needed = needed + if mode == Foreseeing || mode == Planning || len(foreseenOwn) > 0 { + r.Foreseen = foreseenOwn + } + var names []string + for module, secrets := range foreseenOwn { + for name := range secrets { + names = append(names, module+"/"+name) + } + } + sort.Strings(names) + for _, n := range names { + foreseen(n) + } + + // The core's own credentials as the vault sealed them, for an engine that opens the vault's box. + if node.Stated && node.Contract >= VaultBoxContract { + sealed, notPlaced, wanted := c.coreBoxesFor(node, plan.Modules, handing) + r.VaultSealed, r.NotPlaced = sealed, notPlaced + out.CoreBoxesWanted = wanted + } + + // And a certificate for this machine's name inside the mesh, when anything on it asks. + for _, m := range plan.Modules { + if m.Certificate == nil { + continue + } + certificate, authority, stood, err := c.certificateFor(node) + if err != nil { + return Rendered{}, err + } + r.Certificate, r.Authority = certificate, authority + if stood { + foreseen("the certificate and the authority of " + nodeName) + } + break + } + + // The bus's user list, for the machine that runs the bus: names and hashes the Input carries, never passwords. + if holdsTheBus(plan.Modules) { + r.BusUsers = c.in.BusUsers + if r.BusUsers == "" { + r.BusUsers = ForeseenBusUsers + foreseen(BusUsersStandIn) + } + if len(c.in.BusMissing) > 0 { + out.Said = append(out.Said, fmt.Sprintf("the bus's user list leaves out %d user(s) the mesh has minted no credential "+ + "for: %s. Each is a user that cannot connect until one is issued", len(c.in.BusMissing), strings.Join(c.in.BusMissing, ", "))) + } + } + r.BusMembership = c.in.BusMemberships[nodeName] + out.Rendering = r + // And what the composition found on the way, which the controller writes to its log: a machine not counted as on the network. + out.Said = c.saidBeside(out.Said) + return out, nil +} diff --git a/internal/compose/rendering.go b/internal/compose/rendering.go new file mode 100644 index 0000000..646ac86 --- /dev/null +++ b/internal/compose/rendering.go @@ -0,0 +1,543 @@ +package compose + +import ( + "errors" + "fmt" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/overlay" +) + +// The contracts of the node-engine's statement that gate what a machine is sent, as the controller's link package names them +// (mesh-controller internal/link/protocol.go at 6c2a8d62). An older engine parses strictly and refuses a field it does not +// know, whole. +const ( + ReadinessContract = 2 // the engine reads a resource's `health` (novox/hq ADR 0240) + RootContract = 3 // the engine judges a user's declared `root` (ADR 0266) + BusyContract = 8 // the engine reads a container's `busy` (ADR 0335) + HandedContract = 11 // the engine writes a filled file on the provisioner client's word alone + VaultBoxContract = 12 // the engine opens the vault's box +) + +// The provisioner client's seat and own secret (the controller's broker.NodeProvisionerSeat and ProvisionerSecret). +const ( + NodeProvisionerSeat = "node-provisioner" + ProvisionerSecret = "provisioner-broker" +) + +// ErrPortTaken is the controller's refusal for a machine port held twice on one machine. +var ErrPortTaken = errors.New("that port is already held on this machine") + +// Placed is the place half of a rendering (plan.go renderingFor): where a machine puts what its modules need reachable, the +// seats and who holds them, the names of the mesh, the networks, accounts and the operator's export. It is a catalogue.Rendering +// with those fields set, and the ports it could not read named foreseen. Nothing was allocated. +type Placed struct { + Rendering catalogue.Rendering + // ForeseenPorts are the ports the mesh may assign that the Input holds none for, as ":": composed with the + // manifest's own number as the Reading mode composes them, and never invented. The controller's adapter allocates and + // composes again. + ForeseenPorts []string +} + +// Places composes the place half of the rendering for a node (the controller's renderingFor in the Reading and Foreseeing modes, +// without the credentials): over an Input, allocating nothing and changing nothing in it. The refusals are the controller's, +// in its words. +func Places(in Input, node string) (Placed, error) { + c := newComposer(in) + resolved, err := c.planFor(node) + if err != nil { + return Placed{}, err + } + return c.places(node, resolved) +} + +func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) { + var out Placed + node, err := c.in.node(nodeName) + if err != nil { + return out, err + } + plan, settings := resolved.Plan, resolved.Settings + r := &out.Rendering + + // Where this machine puts what each module needs reachable (novox/hq ADR 0038): what it was already given, and the ports + // it was given over those (ADR 0100). + given, err := c.portsFor(node, plan, settings, r, &out.ForeseenPorts) + if err != nil { + return out, err + } + + // Hands: whether the engine writes a filled file on the provisioner client's word alone; engines older than a contract are + // not sent the field they would refuse. + r.Hands = takesHanded(node) + r.Provisioner = provisionerAccount(plan) + r.Assigned = node.Handed + r.ReadsHealth = node.Stated && node.Contract >= ReadinessContract + r.JudgesRoot = node.Stated && node.Contract >= RootContract + r.ReadsBusy = node.Stated && node.Contract >= BusyContract + + // And who else is on the private network, which is what a rule saying "from the mesh" resolves to. + r.Mesh = c.onThePrivateNetwork() + // The private network's range, offered to a module as ${machine:mesh-range}. + r.MeshRange = c.overlayRange() + + // The artifact store as this node reaches it now, with what the mesh has built. + store, err := c.artifactStoreAddress(nodeName) + if err != nil { + return out, err + } + r.ArtifactStore = store + r.Built = make(map[string]bool, len(c.in.Built)) + for _, repository := range c.in.Built { + r.Built[repository] = true + } + + // And every machine's name, so a container can reach one. + r.Names = c.namesInTheMesh() + + // Each machine's operator account, so an ssh Host block can name the login for every node (novox/hq to-be 29). + r.Accounts = map[string]string{} + for _, n := range c.in.sorted() { + if n.Account != "" { + r.Accounts[n.Name] = n.Account + } + } + + // The roster is the machines and nothing else (novox/hq ADR 0191). + r.Machines = make(map[string]string, len(r.Names)) + for name, at := range r.Names { + r.Machines[name] = at + } + + // And every zone a module in the mesh answers itself (novox/hq ADR 0199). + zones, err := c.zonesInTheMesh() + if err != nil { + return out, err + } + r.Zones = zones + + // And who holds each replicated seat, where (novox/hq ADR 0223). + r.Holders = c.replicatedHolders() + + r.Suffix, r.TunnelInterface = c.suffix(), overlay.Interface + + // And, for a module that keeps them, every operator-sealed secret in the mesh (novox/hq ADR 0085, amended). + for _, m := range plan.Modules { + if m.Keeps == "" { + continue + } + r.Kept = c.in.OperatorExport + if r.Kept == nil { + r.KeptRuns = c.in.KeptRuns[node.Name] + } + break + } + + // Whether this node is adopted (novox/hq ADR 0100), and which modules were taken there. + r.Adopted = node.Adopted + var taken map[string]bool + if node.Adopted { + taken = map[string]bool{} + for _, m := range node.Taken { + taken[m] = true + } + } + r.Taken = taken + + // Where this node put the foundation's servers (novox/hq 04-ISSUES/102). + seats, err := c.seatsOn(node, plan.Modules, node.Adopted, taken) + if err != nil { + return out, err + } + r.Seats = seats + + // Which of this machine's links face outside (novox/hq ADR 0140), and which of its networks count as its local network + // (ADR 0340). + r.OutwardLinks = node.OutwardLinks + r.LocalNetworks = catalogue.CountedLocalNetworks(localNetworksOf(node)) + + // Where this machine reaches each mesh seat's holder, for ${seat::reach} (novox/hq ADR 0222). + if store != "" { + r.SeatReach = map[string]string{"mesh-artifact-store": store} + } + r.Settings = settings + r.Given = given + return out, nil +} + +// takesHanded says whether a machine's newest statement is of an engine that writes a filled file on the provisioner client's +// word alone. +func takesHanded(n Node) bool { return n.Stated && n.Contract >= HandedContract } + +// provisionerAccount is the account the provisioner's client runs as on this machine (novox/hq ADR 0349 clause 4): the user +// the process of the module running it runs as, empty where no such module is on the machine. +func provisionerAccount(plan catalogue.Resolution) string { + for _, m := range plan.Modules { + if !runsTheProvisionerClient(m) { + continue + } + for _, r := range m.Resources { + if r["type"] == "process" { + if user, _ := r["user"].(string); user != "" && user != "root" { + return user + } + } + } + } + return "" +} + +// runsTheProvisionerClient says a module runs its machine's provisioner client: it claims the seat and declares the own +// secret that account is delivered in. +func runsTheProvisionerClient(m catalogue.Manifest) bool { + _, reads := m.OwnSecrets[ProvisionerSecret] + return reads && m.ClaimsSeat(NodeProvisionerSeat) +} + +// portsFor is where a node puts what each module needs reachable, in the Reading mode: the ports it was given win, then the +// ones the mesh assigned, and a port the Input holds none for is composed as the manifest says and named foreseen. One holder +// per machine port across the node's modules: settings written before this was refused are refused here. +func (c *composer) portsFor(node Node, plan catalogue.Resolution, settings catalogue.SettingsBy, r *catalogue.Rendering, + foreseen *[]string) (map[string]map[int]int, error) { + already := node.Ports + + given := map[string]map[int]int{} + for _, m := range plan.Modules { + g, err := catalogue.GivenPorts(m, settings[m.Module]) + if err != nil { + // A given port its definition no longer publishes: the module is left out of the declaration, by name, when it is + // composed (novox/hq ADR 0163, rule 6) -- never the machine refused here for it. + continue + } + if g != nil { + given[m.Module] = g + } + } + holders := map[int]string{} + for _, m := range plan.Modules { + for _, at := range given[m.Module] { + if other, twice := holders[at]; twice && other != m.Module { + return nil, fmt.Errorf("%w: %s and %s are both given machine port %d on %s", ErrPortTaken, other, m.Module, + at, node.Name) + } + holders[at] = m.Module + } + } + + ports := map[string]map[int]int{} + for _, m := range plan.Modules { + for _, l := range m.Listens { + if at, isGiven := given[m.Module][l.Port]; isGiven { + if ports[m.Module] == nil { + ports[m.Module] = map[int]int{} + } + ports[m.Module][l.Port] = at + continue + } + // Only a port the module actually publishes is the mesh's to move. Recorded either way, because this map means where + // this module's port is on this machine. + where, mayAssign := m.MachineSide(l.Port) + if mayAssign { + // Whatever was chosen last time, and nothing if there was no last time. + if at, known := already[m.Module][l.Port]; known { + where = at + } else { + *foreseen = append(*foreseen, fmt.Sprintf("%s:%d", m.Module, l.Port)) + } + } + if ports[m.Module] == nil { + ports[m.Module] = map[int]int{} + } + ports[m.Module][l.Port] = where + } + } + r.Ports = ports + return given, nil +} + +// onThePrivateNetwork is every node's address on the private network, sorted: the same set the names mean by it, so a rule +// saying "from the mesh" admits exactly the machines the mesh names. A node with no address is left out, not rendered as an +// empty source. +func (c *composer) onThePrivateNetwork() []string { + var out []string + for _, p := range c.onTheNetwork() { + if strings.TrimSpace(p.Place.Address) != "" { + out = append(out, p.Place.Address) + } + } + sort.Strings(out) + return out +} + +// namesInTheMesh is every machine's internal name and the address behind it, for every machine on the private network, and +// the carried peers the operator has named (novox/hq issue 112). +func (c *composer) namesInTheMesh() map[string]string { + out := map[string]string{} + for _, p := range c.onTheNetwork() { + out[c.internalName(p.Name)] = p.Place.Address + } + for _, p := range c.in.CarriedPeers { + if p.Named == "" || p.EnrolledAs != "" { + continue + } + if _, taken := out[c.internalName(p.Named)]; taken { + continue // a node of the mesh owns the name; the stale statement loses + } + out[c.internalName(p.Named)] = p.Address + } + return out +} + +// artifactStoreOnNetwork is the machine on this network that offers the artifact store, and the port that machine put it on. +func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string, found bool, err error) { + providers := map[string]catalogue.Manifest{} + for name, m := range c.in.Catalogue { + if _, offers := m.Serves[catalogue.ArtifactStoreProvision]; offers { + providers[name] = m + } + } + if len(providers) == 0 { + return "", "", false, nil + } + // In a stated order, so two machines offering it would always answer the same one. + machines := make([]string, 0, len(on)) + for machine := range on { + machines = append(machines, machine) + } + sort.Strings(machines) + for _, machine := range machines { + n, err := c.in.node(machine) + if err != nil { + return "", "", false, fmt.Errorf("reading what %s is assigned: %w", machine, err) + } + for _, a := range n.Assigned { + m, offers := providers[a] + if !offers { + continue + } + serves, _, err := c.servedOnNode(machine, m, catalogue.ArtifactStoreProvision) + if err != nil { + return "", "", false, fmt.Errorf("reading where %s puts the artifact store: %w", machine, err) + } + if p, ok := serves["port"]; ok { + return machine, fmt.Sprintf("%v", p), true, nil + } + } + } + return "", "", false, nil +} + +// ArtifactStoreAddress is the artifact store as node reaches it, by the rule its composition routes every kept reference +// with (artifactStoreAddress): empty when node reaches none. A node composing its own modules reads a templates archive +// there (issue 1126). +func ArtifactStoreAddress(in Input, node string) (string, error) { + return newComposer(in).artifactStoreAddress(node) +} + +// artifactStoreAddress is the artifact store as `forNode` reaches it: `.internal:` over the private network, or +// `127.0.0.1:` for the node that holds the store itself when nothing is on the network yet, and empty for any other +// (novox/hq 04-ISSUES/102). +func (c *composer) artifactStoreAddress(forNode string) (string, error) { + onNetwork, err := c.whereEveryoneIs() + if err != nil { + return "", err + } + on := map[string]bool{} + for name := range onNetwork { + on[name] = true + } + node, port, found, err := c.artifactStoreOnNetwork(on) + if err != nil { + return "", err + } + if found { + return c.internalName(node) + ":" + port, nil + } + all := map[string]bool{} + for _, n := range c.in.Nodes { + all[n.Name] = true + } + holder, port, found, err := c.artifactStoreOnNetwork(all) + if err != nil || !found || holder != forNode { + return "", err + } + return "127.0.0.1:" + port, nil +} + +// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR 0199): the zone settled +// from that node's settings, the node's private address, the port the answering listen is published on there. A node whose set +// does not compose declares nothing and is passed over; what the mesh refuses about the zones together is refused, by name. +func (c *composer) zonesInTheMesh() ([]catalogue.ZoneAt, error) { + address := map[string]string{} + for _, n := range c.in.sorted() { + if strings.TrimSpace(n.Place.Address) != "" { + address[n.Name] = n.Place.Address + } + } + var zones []catalogue.ZoneAt + var public []string + for _, n := range c.in.sorted() { + resolved, err := c.planFor(n.Name) + switch { + case unresolvable(err): + continue + case err != nil: + return nil, fmt.Errorf("the zones %s answers cannot be read: %w", n.Name, err) + } + plan := resolved.Plan + if plan.PublicDomain != "" { + public = append(public, plan.PublicDomain) + } + for _, m := range plan.Modules { + if m.Zone == nil { + continue + } + at := address[n.Name] + if at == "" { + // Not on the private network yet: nothing could reach its answerer. + continue + } + published, layers, err := c.portsGivenOn(n.Name, m) + if err != nil { + return nil, fmt.Errorf("the zone %s declares on %s cannot be read: %w", m.Module, n.Name, err) + } + z, err := catalogue.ZoneOn(m, layers, published, n.Name, at) + if err != nil { + return nil, err + } + zones = append(zones, *z) + } + } + if problems := catalogue.ZonesProblems(zones, c.suffix(), public); len(problems) > 0 { + return nil, fmt.Errorf("the mesh's zones cannot be forwarded:\n - %s", strings.Join(problems, "\n - ")) + } + return zones, nil +} + +// replicatedHolders is, for each replicated mesh seat, every machine on the private network holding it, by internal name at its +// private address (novox/hq ADR 0223): the holders on record, and only the sole claimant when there are none. A holder off +// the private network is left out. +func (c *composer) replicatedHolders() map[string]map[string]string { + var replicated []catalogue.Seat + for _, s := range catalogue.Seats() { + if s.Replicated && s.Scope == catalogue.ScopeMesh { + replicated = append(replicated, s) + } + } + if len(replicated) == 0 { + return nil + } + address := map[string]string{} + for _, p := range c.onTheNetwork() { + address[p.Name] = p.Place.Address + } + // Every module of the catalogue with the nodes it is assigned on, sorted, as the controller's Catalogued answers. + on := map[string][]string{} + for _, n := range c.in.sorted() { + for _, module := range n.Assigned { + on[module] = append(on[module], n.Name) + } + } + names := make([]string, 0, len(c.in.Catalogue)) + for name := range c.in.Catalogue { + names = append(names, name) + } + sort.Strings(names) + out := map[string]map[string]string{} + for _, seat := range replicated { + var nodes []string + for _, h := range c.in.Holdings { + if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat.Name && h.Scope == seat.Scope { + nodes = append(nodes, h.Node) + } + } + if len(nodes) == 0 { + var derived []string + for _, name := range names { + for _, cl := range c.in.Catalogue[name].Claims { + if cs, ok := catalogue.SeatNamed(cl.Name); ok && cs.Name == seat.Name && cl.At() == seat.Scope { + derived = append(derived, on[name]...) + } + } + } + if len(derived) == 1 { + nodes = derived + } + } + at := map[string]string{} + for _, n := range nodes { + if address[n] != "" { + at[c.internalName(n)] = address[n] + } + } + out[seat.Name] = at + } + return out +} + +// seatsOn is where a node put the holder of each mesh-scoped seat, by seat and by the port the holder's software uses: what +// ${seat:...} answers with (novox/hq 04-ISSUES/102). Read for every module in the catalogue that claims a seat, in this node's set +// or not; a holder in this node's set wins over one that is not. +func (c *composer) seatsOn(node Node, inSet []catalogue.Manifest, adopted bool, taken map[string]bool) (map[string]map[int]int, error) { + assigned := map[string]bool{} + for _, m := range inSet { + assigned[m.Module] = true + } + names := make([]string, 0, len(c.in.Catalogue)) + for name := range c.in.Catalogue { + names = append(names, name) + } + sort.Strings(names) + seats := map[string]map[int]int{} + for _, name := range names { + m := c.in.Catalogue[name] + var claims []string + for _, cl := range m.Claims { + if cl.At() == catalogue.ScopeMesh { + claims = append(claims, cl.Name) + } + } + if len(claims) == 0 { + continue + } + // Only a port the node was given or the mesh assigned, never the manifest's own number. And on an adopted node a holder + // assigned but not yet taken is the found container, so its mesh-assigned ports do not count there; a given port does. + ports, _, err := c.portsGivenOn(node.Name, m) + if err != nil { + return nil, err + } + if adopted && !taken[name] { + layers := node.Settings[m.Module] + ports = map[int]int{} + if given, err := catalogue.GivenPorts(m, layers); err == nil { + ports = given + } + } + if len(ports) == 0 { + continue + } + for _, seat := range claims { + if seats[seat] == nil { + seats[seat] = map[int]int{} + } + for wanted, at := range ports { + if _, said := seats[seat][wanted]; said && !assigned[name] { + continue + } + seats[seat][wanted] = at + } + } + } + return seats, nil +} + +// localNetworksOf is a machine's attached networks judged (novox/hq ADR 0340): which count as its local network, from what it +// last said of itself and the links it said face outside it. A machine that has said none judges to nothing. +func localNetworksOf(n Node) []catalogue.LocalNetwork { + if !n.NetworksSaid { + return nil + } + return catalogue.JudgeLocalNetworks(n.Networks, n.OutwardLinks, overlay.Interface) +} diff --git a/internal/compose/rendering_test.go b/internal/compose/rendering_test.go new file mode 100644 index 0000000..54b9c71 --- /dev/null +++ b/internal/compose/rendering_test.go @@ -0,0 +1,329 @@ +package compose + +import ( + "errors" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/overlay" +) + +// aWebModule publishes one port in the short form, which the mesh is free to move (MachineSide). +func aWebModule() catalogue.Manifest { + return catalogue.Manifest{Module: "web", Version: "1", + Listens: []catalogue.Listening{{Name: "http", Port: 8080, From: catalogue.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "web", "ports": []any{"8080"}, + "image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}}} +} + +// aStoreModule is mesh-controller's sendable_integration_test.go TestConsumersAreToldTheGivenPort store (cmd/mesh-controller +// at 6c2a8d62): publishing 5432:5432, offering a database to the mesh. +func aStoreModule() catalogue.Manifest { + return catalogue.Manifest{Module: "store", Version: "1", + Provides: []catalogue.Offer{{Name: "database", Scope: catalogue.ScopeMesh}}, + Listens: []catalogue.Listening{{Port: 5432, From: catalogue.FromMesh}}, + Guards: []int{5432}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store", + "ports": []any{"5432:5432"}, + "image": "registry.example/pg@sha256:" + strings.Repeat("b", 64)}}} +} + +func placed(t *testing.T, in Input, node string) Placed { + t.Helper() + got, err := Places(in, node) + if err != nil { + t.Fatalf("%v", err) + } + return got +} + +// 1363/AC1 and AC2: a port the Input holds as allocated on the node is the one composed; with none, no number is invented: the +// manifest's own is composed and the port is named foreseen, as the Foreseeing mode does. +func TestAnAllocatedPortIsComposedAndAnUnallocatedOneIsNamedForeseenNotInvented(t *testing.T) { + mk := func() Input { + in := aMesh(t) + in.Catalogue["web"] = aWebModule() + nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "web") + return in + } + in := mk() + got := placed(t, in, "laptop") + if p := got.Rendering.Ports["web"][8080]; p != 8080 { + t.Errorf("a port nobody allocated is composed as %d, not as the manifest's own", p) + } + if len(got.ForeseenPorts) != 1 || got.ForeseenPorts[0] != "web:8080" { + t.Errorf("foreseen ports are %v", got.ForeseenPorts) + } + nodeOf(t, &in, "laptop").Ports = map[string]map[int]int{"web": {8080: 20123}} + got = placed(t, in, "laptop") + if p := got.Rendering.Ports["web"][8080]; p != 20123 || len(got.ForeseenPorts) != 0 { + t.Errorf("the allocated port is composed as %d (foreseen %v)", p, got.ForeseenPorts) + } + // A port the module does not publish is not the mesh's to move, so it is never foreseen. + m := aWebModule() + m.Resources = nil + in.Catalogue["web"] = m + nodeOf(t, &in, "laptop").Ports = nil + got = placed(t, in, "laptop") + if got.Rendering.Ports["web"][8080] != 8080 || len(got.ForeseenPorts) != 0 { + t.Errorf("an unpublished port: %v foreseen %v", got.Rendering.Ports, got.ForeseenPorts) + } + unchanged(t, mk, func(in Input) { placed(t, in, "laptop") }) + // Only a composition for plans refuses a contribution whose port the module does not listen on (issue 774). + for _, mode := range []Mode{Reading, Foreseeing, Planning} { + if got := rendered(t, mk(), "laptop", mode).Rendering; got.Planned != (mode == Planning) { + t.Errorf("mode %d composes with Planned %v", mode, got.Planned) + } + } +} + +// 1363/AC3: a port the operator gave is used, over the allocated one, and a consumer elsewhere is told it +// (mesh-controller sendable_integration_test.go TestConsumersAreToldTheGivenPort). +func TestAPortTheOperatorGaveIsUsedAndNotReallocated(t *testing.T) { + in := aMesh(t) + in.Catalogue["store"] = aStoreModule() + in.Catalogue["app"] = catalogue.Manifest{Module: "app", Version: "1", Requires: []string{"database"}} + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "store") + nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "app") + anchor.Ports = map[string]map[int]int{"store": {5432: 20999}} + anchor.Settings = map[string][]catalogue.Layer{"store": {{From: "anchor", + Placement: map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}}}} + + got := placed(t, in, "anchor") + if p := got.Rendering.Ports["store"][5432]; p != 5433 { + t.Errorf("the store is on %d, not the port it was given", p) + } + if g := got.Rendering.Given["store"][5432]; g != 5433 { + t.Errorf("the given port is %d", g) + } + if len(got.ForeseenPorts) != 0 { + t.Errorf("a given port is foreseen: %v", got.ForeseenPorts) + } + resolved, err := Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + if told := need(t, resolved.Plan, "app", "database").Serves["port"]; told != 5433 { + t.Errorf("the consumer is told the store is on %v", told) + } + + // One holder per machine port across the node's modules: two given the same refused, in the controller's words. + in.Catalogue["store2"] = func() catalogue.Manifest { + m := aStoreModule() + m.Module, m.Provides = "store2", nil + m.Resources = []map[string]any{{"id": "server", "type": "container", "name": "other", "ports": []any{"5432:5432"}, + "image": "registry.example/pg@sha256:" + strings.Repeat("c", 64)}} + return m + }() + anchor.Assigned = append(anchor.Assigned, "store2") + anchor.Settings["store2"] = []catalogue.Layer{{From: "anchor", + Placement: map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}}} + _, err = Places(in, "anchor") + if err == nil || !errors.Is(err, ErrPortTaken) || !strings.Contains(err.Error(), "store and store2 are both given machine port 5433 on anchor") && + !strings.Contains(err.Error(), "store2 and store are both given machine port 5433 on anchor") { + t.Fatalf("two modules given one machine port: %v", err) + } +} + +// 1363/AC4: the seats held and taken reach the Rendering: where each mesh-scoped seat's holder put its ports, from the node's +// given and assigned ports, never the manifest's own; and on an adopted node a holder assigned but not taken counts only by its +// given ports (novox/hq ADR 0100). +func TestSeatsHeldAndTakenReachTheRendering(t *testing.T) { + in := aMesh(t) + holder := aStoreModule() + holder.Claims = []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}} + in.Catalogue["store"] = holder + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "store") + // The shelf is read whole, in this node's set or not: laptop, running none, is told where the anchor's store is only by the + // settings and ports of its own machine, which it has none of. + anchor.Ports = map[string]map[int]int{"store": {5432: 20999}} + got := placed(t, in, "anchor") + if at := got.Rendering.Seats["mesh-store"][5432]; at != 20999 { + t.Errorf("the seat's holder is told to be on %d", at) + } + if len(placed(t, in, "laptop").Rendering.Seats) != 0 { + t.Errorf("laptop is told of a holder it has no port for: %v", placed(t, in, "laptop").Rendering.Seats) + } + // Adopted, and the holder assigned but not taken: the found container's ports, not the mesh's assignment. + anchor.Adopted = true + if len(placed(t, in, "anchor").Rendering.Seats) != 0 { + t.Errorf("an untaken holder on an adopted node counts by the mesh's assignment: %v", placed(t, in, "anchor").Rendering.Seats) + } + anchor.Taken = []string{"store"} + got = placed(t, in, "anchor") + if at := got.Rendering.Seats["mesh-store"][5432]; at != 20999 || !got.Rendering.Adopted || !got.Rendering.Taken["store"] { + t.Errorf("a taken holder: seats %v adopted %v taken %v", got.Rendering.Seats, got.Rendering.Adopted, got.Rendering.Taken) + } +} + +// 1363/AC4: local networks and outward links (mesh-controller local_network_test.go TestNodeShowsWhichAttachedNetworksCountAndWhy, +// the values): a private network on a link facing outside counts, a public subnet and the private network's own link never do; a +// machine that said none judges to nothing. +func TestLocalNetworksAndOutwardLinksReachTheRendering(t *testing.T) { + in := aMesh(t) + laptop := nodeOf(t, &in, "laptop") + laptop.OutwardLinks = []string{"eth0", "eth1"} + laptop.Networks = []catalogue.AttachedNetwork{ + {Link: "eth0", Network: "192.168.1.10/24"}, {Link: "eth1", Network: "203.0.113.7/24"}, {Link: overlay.Interface, Network: "10.10.0.4/24"}} + laptop.NetworksSaid = true + got := placed(t, in, "laptop").Rendering + if len(got.LocalNetworks) != 1 || got.LocalNetworks[0] != "192.168.1.0/24" { + t.Errorf("the local networks are %v", got.LocalNetworks) + } + if len(got.OutwardLinks) != 2 || got.OutwardLinks[0] != "eth0" { + t.Errorf("the outward links are %v", got.OutwardLinks) + } + laptop.NetworksSaid = false + if got := placed(t, in, "laptop").Rendering; len(got.LocalNetworks) != 0 { + t.Errorf("a machine that never reported has local networks %v", got.LocalNetworks) + } +} + +// 1363/AC4: accounts, the names of the mesh, the mesh range and the operator's export reach the Rendering. +func TestAccountsNamesAndTheOperatorsExportReachTheRendering(t *testing.T) { + in := aMesh(t) + nodeOf(t, &in, "anchor").Account = "jochens" + in.OperatorExport = &catalogue.KeptExport{} + in.OverlayCIDR = "10.99.0.0/16" + got := placed(t, in, "anchor").Rendering + if got.Accounts["anchor"] != "jochens" || len(got.Accounts) != 1 { + t.Errorf("accounts are %v", got.Accounts) + } + if got.Names["anchor.internal"] != "10.77.0.1" || got.Names["laptop.internal"] != "10.77.0.2" || len(got.Names) != 2 { + t.Errorf("names are %v", got.Names) + } + if len(got.Machines) != 2 || got.Machines["laptop.internal"] != "10.77.0.2" { + t.Errorf("machines are %v", got.Machines) + } + if len(got.Mesh) != 2 || got.Mesh[0] != "10.77.0.1" || got.Mesh[1] != "10.77.0.2" { + t.Errorf("the mesh's addresses are %v", got.Mesh) + } + if got.MeshRange != "10.99.0.0/16" || got.Suffix != "internal" || got.TunnelInterface != overlay.Interface { + t.Errorf("range %q suffix %q tunnel %q", got.MeshRange, got.Suffix, got.TunnelInterface) + } + if got.Kept != nil { + t.Error("the operator's export reaches a node where nothing keeps it") + } + keeper := catalogue.Manifest{Module: "keeper", Version: "1", Keeps: "/var/lib/keeper/export.json"} + in.Catalogue["keeper"] = keeper + nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, "keeper") + if got := placed(t, in, "anchor").Rendering; got.Kept != in.OperatorExport { + t.Errorf("a module that keeps the export is not given it: %v", got.Kept) + } + // And the names of a machine that left the network leave with it. + nodeOf(t, &in, "laptop").Assigned = nil + if got := placed(t, in, "anchor").Rendering; len(got.Names) != 1 || got.Names["laptop.internal"] != "" { + t.Errorf("a machine off the network is named: %v", got.Names) + } +} + +// The names the operator gave to peers of an adopted tunnel answer until they enrol (novox/hq issue 112); a node of the mesh owns +// its name. +func TestACarriedPeerIsNamedUntilItEnrols(t *testing.T) { + in := aMesh(t) + in.CarriedPeers = []CarriedPeer{{PublicKey: "K1", Address: "192.0.2.5", Named: "printer"}, + {PublicKey: "K2", Address: "192.0.2.6", Named: "laptop"}, {PublicKey: "K3", Address: "192.0.2.7", Named: "late", EnrolledAs: "late"}} + got := placed(t, in, "anchor").Rendering + if got.Names["printer.internal"] != "192.0.2.5" || got.Names["laptop.internal"] != "10.77.0.2" || got.Names["late.internal"] != "" { + t.Errorf("names are %v", got.Names) + } +} + +// A node-engine older than a contract is not sent the field it would refuse (novox/hq ADR 0240 and after). +func TestTheNodeEnginesContractGatesWhatItIsSent(t *testing.T) { + in := aMesh(t) + laptop := nodeOf(t, &in, "laptop") + if r := placed(t, in, "laptop").Rendering; r.ReadsHealth || r.JudgesRoot || r.ReadsBusy || r.Hands { + t.Errorf("an engine that never stated anything reads %+v", r) + } + laptop.Stated, laptop.Contract = true, ReadinessContract + if r := placed(t, in, "laptop").Rendering; !r.ReadsHealth || r.JudgesRoot || r.ReadsBusy || r.Hands { + t.Errorf("an engine at the readiness contract reads %+v", r) + } + laptop.Contract = HandedContract + if r := placed(t, in, "laptop").Rendering; !r.ReadsHealth || !r.JudgesRoot || !r.ReadsBusy || !r.Hands { + t.Errorf("an engine at the handed contract reads %+v", r) + } +} + +// The artifact store as a node reaches it: over the private network by its holder's name and the port it put it on, or on loopback +// for the holder alone while nothing is on the network (novox/hq 04-ISSUES/102). +func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *testing.T) { + in := aMesh(t) + in.Catalogue["artifacts"] = catalogue.Manifest{Module: "artifacts", Version: "1", + Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}} + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "artifacts") + if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "anchor.internal:5100" { + t.Errorf("laptop reaches the store at %q", got) + } + nodeOf(t, &in, "laptop").Assigned = nil + anchor.Assigned = []string{"artifacts"} // off the network: the holder alone reaches it + if got := placed(t, in, "anchor").Rendering.ArtifactStore; got != "127.0.0.1:5100" { + t.Errorf("the holder reaches the store at %q", got) + } + if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "" { + t.Errorf("a machine off the network reaches the store at %q", got) + } + in.Built = []string{"mesh-sdk"} + if got := placed(t, in, "anchor").Rendering.Built; !got["mesh-sdk"] { + t.Errorf("built is %v", got) + } +} + +// A zone a module declares is placed where the mesh put it: settled from that node's settings, at the node's private address, on +// the port the answering listen is published on there (novox/hq ADR 0199); the same zone declared twice is refused, by name. +func TestAZoneIsPlacedWhereTheMeshPutItAndTheSameZoneTwiceIsRefused(t *testing.T) { + in := aMesh(t) + lab := catalogue.Manifest{Module: "lab", Version: "1", Zone: &catalogue.Zone{Name: "${setting:zone}", Listen: "dns"}, + Listens: []catalogue.Listening{{Name: "dns", Port: 53, Protocol: "udp", From: catalogue.FromMesh}}} + in.Catalogue["lab"] = lab + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "lab") + anchor.Settings = map[string][]catalogue.Layer{"lab": {{From: "anchor", Values: map[string]any{"zone": "Lab.Example."}}}} + anchor.Ports = map[string]map[int]int{"lab": {53: 5353}} + zones := placed(t, in, "laptop").Rendering.Zones + if len(zones) != 1 || zones[0] != (catalogue.ZoneAt{Zone: "lab.example", Node: "anchor", Module: "lab", Address: "10.77.0.1", Port: 5353}) { + t.Fatalf("zones are %+v", zones) + } + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "lab") + laptop.Settings = map[string][]catalogue.Layer{"lab": {{From: "laptop", Values: map[string]any{"zone": "lab.example"}}}} + if _, err := Places(in, "laptop"); err == nil || !strings.Contains(err.Error(), "the mesh's zones cannot be forwarded") { + t.Fatalf("one zone declared twice: %v", err) + } +} + +// Every holder of a replicated seat is listed by internal name at its private address (novox/hq ADR 0223): the holders on record, +// and only the sole claimant when there are none; a holder off the private network is left out. +func TestEveryHolderOfAReplicatedSeatIsListedOnTheNetwork(t *testing.T) { + in := aMesh(t) + resolver := catalogue.Manifest{Module: "resolver", Version: "1", + Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}}, + Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}} + in.Catalogue["resolver"] = resolver + anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop") + anchor.Assigned = append(anchor.Assigned, "resolver") + // One claimant and nothing on record: the sole claimant is listed. + if got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]; len(got) != 1 || got["anchor.internal"] != "10.77.0.1" { + t.Errorf("the sole claimant: %v", got) + } + // Both on record: both are listed (two claimants with nothing on record would be refused at resolution). + laptop.Assigned = append(laptop.Assigned, "resolver") + in.Holdings = []catalogue.Held{ + {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver"}, + {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "resolver"}} + got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"] + if len(got) != 2 || got["anchor.internal"] != "10.77.0.1" || got["laptop.internal"] != "10.77.0.2" { + t.Errorf("both holders on record: %v", got) + } + // A holder off the private network is left out: a resolver named at an address nothing answers waits out its timeout. + in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Address: "10.77.0.3"}, Assigned: []string{"resolver"}}) + in.Holdings = append(in.Holdings, catalogue.Held{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "ace", Module: "resolver"}) + if got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]; len(got) != 2 || got["ace.internal"] != "" { + t.Errorf("a holder off the network is listed: %v", got) + } +} diff --git a/internal/compose/resolve.go b/internal/compose/resolve.go new file mode 100644 index 0000000..6931adc --- /dev/null +++ b/internal/compose/resolve.go @@ -0,0 +1,347 @@ +package compose + +import ( + "fmt" + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// LicenceProvision is what a module requires in order to be given a licence (the controller's licences.Provision). +const LicenceProvision = "model-access" + +// Resolved is what one node should run, from what was assigned to it, with the settings of everything that resolved. +type Resolved struct { + Plan catalogue.Resolution + Settings catalogue.SettingsBy + // Foreseen are the needs composed with a stand-in for a credential the next send makes (or holds with no value given + // here), as " on needs from ". Nothing was made. + Foreseen []string + // NotGiven are the words of each credential only a person gives that nobody has: the consumer still resolves without it + // (novox/hq ADR 0337 clause 4). + NotGiven []string +} + +// Resolve works out everything a node should run, from what was assigned to it (the controller's planFor, plan.go): the +// resolution over the rest of the mesh, the credential of each thing the node takes from elsewhere, and the settings of +// every module that resolved. It makes and writes nothing: a credential not held is composed with a stand-in and named +// foreseen, one only a person gives and nobody has is left out and said, one the send would refuse is refused with the +// controller's words. A failure to compose the node's own modules is an error that satisfies IsUnresolvable, told apart +// from an unreadable Input (novox/hq 04-ISSUES/152). +func Resolve(in Input, node string) (Resolved, error) { + return newComposer(in).planFor(node) +} + +// IsUnresolvable reports whether err is a node's own set failing to compose, rather than the mesh being unable to answer. +func IsUnresolvable(err error) bool { return unresolvable(err) } + +// planFor is the controller's planFor, worked out once per node in one composition (issue 1954): each caller has its +// own copy of what a caller may set (a need's fields, as Resolution.Compose sets its Serves), the rest only read. +func (c *composer) planFor(nodeName string) (Resolved, error) { + p, ok := c.plans[nodeName] + if !ok { + p.resolved, p.err = c.planForNow(nodeName) + if c.plans == nil { + c.plans = map[string]planOf{} + } + c.plans[nodeName] = p + } + if p.err != nil { + return Resolved{}, p.err + } + return p.resolved.copied(), nil +} + +// planOf is planFor's answer for one node, kept. +type planOf struct { + resolved Resolved + err error +} + +// copied is r with its own needs, words and settings lists: what a caller may change of it. +func (r Resolved) copied() Resolved { + out := r + out.Plan.Needs = slices.Clone(r.Plan.Needs) + out.Foreseen = slices.Clone(r.Foreseen) + out.NotGiven = slices.Clone(r.NotGiven) + if r.Settings != nil { + out.Settings = make(catalogue.SettingsBy, len(r.Settings)) + for m, layers := range r.Settings { + out.Settings[m] = slices.Clone(layers) + } + } + return out +} + +// planForNow is planFor worked out. +func (c *composer) planForNow(nodeName string) (Resolved, error) { + resolved, err := c.resolution(nodeName) + if err != nil { + return Resolved{}, err + } + node, err := c.in.node(nodeName) + if err != nil { + return Resolved{}, err + } + shelf := c.shelfFor(node) + var out Resolved + + // The credential for each thing this node takes from elsewhere. + for i, n := range resolved.Needs { + // Issued for this holder alone and kept by the vault (issue 757). + resolved.Needs[i].IssuedToHolder = issuedToHolder(shelf, n) + resolved.Needs[i].ProviderMoved = providerMoved(shelf, n) + resolved.Needs[i].AnsweredByHelper = answeredByHelper(shelf, n) + if n.ByRecord { + // Answered by something the mesh holds, so there is no pair-wise secret between two machines. + licence, _ := c.licence(n.From) + if licence.Keys == nil { + // The keys are not given: composed with a stand-in and named, not failed for a value only a send reads. + resolved.Needs[i].Sealed = catalogue.ForeseenSealed + out.Foreseen = append(out.Foreseen, fmt.Sprintf("%s on %s needs %s from %s", n.For, nodeName, n.Name, n.From)) + } else { + // Given, and none for this holder: the mesh holds none. + resolved.Needs[i].Sealed = licence.Keys[Holder{Node: nodeName, Module: n.For}] + } + // If this holder is the licence's manager, hand it the manager node's PUBLIC sealing key in its bound facts + // (novox/hq ADR 0050). + if pub := c.managerPublicKeyFor(licence, nodeName, n.For); pub != "" { + serves := map[string]any{} + for k, v := range resolved.Needs[i].Serves { + serves[k] = v + } + serves["manager_public_key"] = pub + resolved.Needs[i].Serves = serves + // The manager holder: its empty pre-adoption refresh token is a waiting state, not a missing consumer key. + resolved.Needs[i].Manager = true + } + continue + } + if credentialless(n) { + // Its provider's offer presents no credential (form none): nothing is sealed, made or stood in for (issue 1126). + continue + } + cred, held := c.credential(n, nodeName) + _, outside := issuedOutsideBy(shelf, n) + switch { + case cred.State == CredentialRefused: + // Said rather than skipped. A machine that resolves cleanly and receives no credential is one that will fail to + // authenticate at some later, less obvious moment. + return Resolved{}, fmt.Errorf("%s on %s needs %s from %s and no credential could be made for it: %s", + n.For, nodeName, n.Name, n.From, cred.Why) + case cred.State == CredentialNotGiven, outside && !(held && cred.State == CredentialHeld): + // Not given, and the rest of the consumer runs (ADR 0337 clause 4): no credential file, so the module can tell + // it was not given; nothing the mesh made stands in for it. + why := cred.Why + if why == "" { + why = "nobody gave it" + } + out.NotGiven = append(out.NotGiven, fmt.Sprintf("%s on %s holds no %s from %s: %s", n.For, nodeName, n.Name, n.From, why)) + continue + case cred.State == CredentialHeld && cred.Sealed != "": + resolved.Needs[i].Sealed = cred.Sealed + default: + // Held with no value here, or none held: the next send makes it (or reads it), and a stand-in is composed. + resolved.Needs[i].Sealed = catalogue.ForeseenSealed + out.Foreseen = append(out.Foreseen, fmt.Sprintf("%s on %s needs %s from %s", n.For, nodeName, n.Name, n.From)) + } + } + + // Settings for everything that resolved, including modules nobody assigned directly: a requirement pulled in by + // something else is still configurable. + settings := catalogue.SettingsBy{} + for _, m := range resolved.Modules { + layers := node.Settings[m.Module] + if len(layers) == 0 { + continue + } + settings[m.Module] = append([]catalogue.Layer(nil), layers...) + } + out.Plan, out.Settings = resolved, settings + return out, nil +} + +// credential is how the pair credential of one need stands in the Input, and whether the Input names it at all. +func (c *composer) credential(n catalogue.Needed, consumer string) (Credential, bool) { + for _, cr := range c.in.Credentials { + if cr.Provision == n.Name && cr.Consumer == consumer && cr.ConsumerModule == n.For && cr.Provider == n.From && cr.Local == n.Local { + return cr, true + } + } + return Credential{State: CredentialWouldMake}, false +} + +// licence is the licence of that name. +func (c *composer) licence(name string) (Licence, bool) { + for _, l := range c.in.Licences { + if l.Name == name { + return l, true + } + } + return Licence{}, false +} + +// managerPublicKeyFor is the manager node's public sealing key, but only when (node, module) is the licence's manager holder +// and empty otherwise (plan.go managerPublicKeyFor): delivered to the manager module in its bound facts so it can re-seal a +// rotated refresh token to this node (novox/hq ADR 0050), and to no consumer. +func (c *composer) managerPublicKeyFor(l Licence, node, module string) string { + if l.Manager == (Holder{}) || node != l.Manager.Node || module != l.Manager.Module { + return "" + } + n, err := c.in.node(node) + if err != nil { + return "" + } + return n.SealingKey +} + +// issuedOutsideBy is whether a need's provision is offered by its provider with a credential issued outside the mesh +// (novox/hq ADR 0337), and that provider module: the one answering it when known, else any that offers it so. +func issuedOutsideBy(shelf map[string]catalogue.Manifest, n catalogue.Needed) (string, bool) { + if n.Module != "" { + if m, known := shelf[n.Module]; known { + return n.Module, m.IssuedOutsideOf(n.Name) + } + } + return catalogue.OffersIssuedOutside(shelf, n.Name) +} + +// issuedToHolder is whether the vault keeps a need's credential for its holder alone (issues 749, 757 and 779). +func issuedToHolder(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool { + if n.ByRecord || n.Module == "" { + return false + } + provider, known := shelf[n.Module] + if !known || !movedProvider(provider) { + return false + } + if _, issued := provider.IssuedCredentialOf(n.Name); issued { + return true + } + return provider.CredentialFormOf(n.Name) == catalogue.CredentialFormPair +} + +// providerMoved is whether a need is answered by a module marked moved (issue 788), or by one whose offer of the provision +// says the provider helper answers it (issue 792). +func providerMoved(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool { + if n.ByRecord || n.Module == "" { + return false + } + provider, known := shelf[n.Module] + return known && (movedProvider(provider) || provider.AnsweredByHelperFor(n.Name)) +} + +// answeredByHelper is whether a need's provider offers it answered by the provider helper (issue 792): keyed by the seat +// mesh-provisioner for every consumer, handed or not (catalogue.Keyed, issue 774). +func answeredByHelper(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool { + if n.ByRecord || n.Module == "" { + return false + } + provider, known := shelf[n.Module] + return known && provider.AnsweredByHelperFor(n.Name) +} + +// movedProvider is whether a provider's credentials are the vault's: the module is marked moved, or it holds the +// mesh-vault seat (issue 793). +func movedProvider(m catalogue.Manifest) bool { + return m.Moved || slices.ContainsFunc(m.Claims, func(c catalogue.Claim) bool { return c.Name == "mesh-vault" }) +} + +// resolution works out what one node should run, from what was assigned to it: the controller's planFor up to and +// including the resolution (plan.go planFor), and no further. A failure to compose the node's own modules is wrapped as +// notResolvable; every other failure is returned as it is. +func (c *composer) resolution(nodeName string) (catalogue.Resolution, error) { + node, err := c.in.node(nodeName) + if err != nil { + return catalogue.Resolution{}, err + } + shelf := c.shelfFor(node) + world, _, left, err := c.theRestOfTheMesh(nodeName) + if err != nil { + return catalogue.Resolution{}, err + } + world.Pinned = node.Pins + // Where each of its consumers of a provision that keeps data was last sent (novox/hq ADR 0232). + world.Bound = node.Bound + + onNetwork, err := c.whereEveryoneIs() + if err != nil { + return catalogue.Resolution{}, err + } + + // What this mesh can answer with a record rather than a machine, and which record each of this node's modules was + // put on. + world.Licences, world.Using = c.licencesFor(nodeName) + + resolved, err := catalogue.Resolve(shelf, node.Assigned, + catalogue.Node{Name: nodeName, Site: node.Place.Site, Capabilities: node.Capabilities, + At: onNetwork[nodeName], PublicDomain: node.PublicDomain, + Account: node.Account, AccountHome: node.AccountHome, + AgentAccount: node.AgentAccount, AgentAccountHome: node.AgentAccountHome}, world) + if err != nil { + // What another node's set would have offered is missing when that set does not resolve here: said with the + // refusal, so "nothing provides" is read beside why (issue 1126, probe #532). + if len(left) > 0 { + var why []string + for _, l := range left { + why = append(why, l.String()) + } + err = fmt.Errorf("%w\n\nand %d node(s) offer nothing here, as they could not be resolved: %s", err, len(left), strings.Join(why, "; ")) + } + if len(c.unhostable) > 0 { + err = fmt.Errorf("%w\n\nand these modules offer nothing here, as their node cannot run them in this composition: %s", + err, strings.Join(c.unhostable, "; ")) + } + // The node's own set does not compose: the only failure a mesh-wide gatherer may pass over. + return catalogue.Resolution{}, notResolvable{err} + } + return resolved, nil +} + +// shelfFor is the catalogue as one node composes its own set: the Input's, with each recorded or core module the node keeps at the +// build it runs put in place of the current one (plan.go keepRecorded). A copy: the Input's catalogue is untouched. Only the node's own planFor and +// resolution use it; the rest of the mesh and who is on the network are read with the current manifests, as the controller reads them +// before it keeps a recorded module. +func (c *composer) shelfFor(node Node) map[string]catalogue.Manifest { + if len(node.Kept) == 0 { + return c.in.Catalogue + } + shelf := make(map[string]catalogue.Manifest, len(c.in.Catalogue)+len(node.Kept)) + for name, m := range c.in.Catalogue { + shelf[name] = m + } + for name, m := range node.Kept { + shelf[name] = m + } + return shelf +} + +// licencesFor is what this node can be answered with by record, and what it was put on. A mesh with no licences at all +// is the ordinary case and not an error. +func (c *composer) licencesFor(node string) (map[string][]catalogue.Record, map[string]map[string]catalogue.Record) { + all := c.in.Licences + if len(all) == 0 { + return nil, nil + } + offered := map[string][]catalogue.Record{} + byName := map[string]catalogue.Record{} + for _, one := range all { + record := catalogue.Record{Name: one.Name, Serves: one.Serves} + offered[LicenceProvision] = append(offered[LicenceProvision], record) + byName[one.Name] = record + } + using := map[string]map[string]catalogue.Record{} + for _, one := range all { + for _, h := range one.Holders { + if h.Node != node { + continue + } + if using[h.Module] == nil { + using[h.Module] = map[string]catalogue.Record{} + } + using[h.Module][LicenceProvision] = byName[one.Name] + } + } + return offered, using +} diff --git a/internal/compose/resolve_test.go b/internal/compose/resolve_test.go new file mode 100644 index 0000000..08c8ea9 --- /dev/null +++ b/internal/compose/resolve_test.go @@ -0,0 +1,388 @@ +package compose + +import ( + "errors" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/overlay" +) + +// storeManifests is mesh-controller's bindings_test.go storeManifests (cmd/mesh-controller at 6c2a8d62): a store holding the +// mesh-store seat, a resolver holding mesh-dns-resolver, a network that requires wildcard resolution, and a board that +// requires a database. +func storeManifests() []catalogue.Manifest { + return []catalogue.Manifest{ + {Module: "store", Version: "1", + Provides: []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh}}, + Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}, + Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, + Grants: map[string]string{"postgres-database": "/var/lib/mesh/store/grants"}}, + {Module: "resolver", Version: "1", + Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}}, + Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}}, + {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}}, + {Module: "board", Version: "1", Requires: []string{"postgres-database"}}, + } +} + +func need(t *testing.T, plan catalogue.Resolution, consumer, provision string) catalogue.Needed { + t.Helper() + for _, n := range plan.Needs { + if n.For == consumer && n.Name == provision { + return n + } + } + t.Fatalf("no %s for %s: %+v", provision, consumer, plan.Needs) + return catalogue.Needed{} +} + +// theStoreMesh is the mesh of mesh-controller's bindings_integration_test.go +// TestTheIncidentAConsumerStaysBesideItsDataUntilAPersonPinsIt: the anchor's store and resolver hold the mesh's seats on +// record; the laptop runs its own of each, and a network and a board. +func theStoreMesh(t *testing.T) Input { + t.Helper() + in := aMesh(t) + for _, m := range storeManifests() { + in.Catalogue[m.Module] = m + } + anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop") + anchor.Assigned = append(anchor.Assigned, "store", "resolver") + laptop.Assigned = append(laptop.Assigned, "store", "resolver", "network", "board") + in.Holdings = []catalogue.Held{ + {Claim: "mesh-store", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "store", Site: "here"}, + {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}, + } + return in +} + +// 1362/AC7 and AC4 (bindings_integration_test.go): a consumer of a provision that keeps its data stays beside its data, one that +// does not follows the seat; the laptop's store taken away, the consumer bound there on record is refused, not moved to the +// anchor's empty one; a person's pin moves it. +func TestAConsumerStaysBesideItsDataUntilAPersonPinsIt(t *testing.T) { + in := theStoreMesh(t) + got, err := Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + if n := need(t, got.Plan, "board", "postgres-database"); n.From != "laptop" || n.Module != "store" || !n.KeepsData { + t.Fatalf("the consumer was bound to %s/%s (keeps data: %v); its data is beside it", n.From, n.Module, n.KeepsData) + } + if n := need(t, got.Plan, "network", "wildcard-resolution"); n.From != "anchor" || n.KeepsData { + t.Fatalf("the resolver was bound to %s (keeps data: %v); its seat is held on anchor (issue 258)", n.From, n.KeepsData) + } + + // Sent, and recorded: the binding to data. The laptop's store taken away: refused, not moved to the anchor's empty one. + laptop := nodeOf(t, &in, "laptop") + laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "laptop", Module: "store"}}} + laptop.Assigned = []string{overlay.Name, "resolver", "network", "board"} + _, err = Resolve(in, "laptop") + if err == nil || !IsUnresolvable(err) || + !strings.Contains(err.Error(), "board on laptop is bound to laptop/store") || + !strings.Contains(err.Error(), "pin laptop postgres-database anchor store") { + t.Fatalf("the consumer's store went and it was answered elsewhere: %v", err) + } + + // A person pins the anchor's: it moves, and the resolution says where it was bound (Kept is empty, as it moves). + laptop.Pins = map[string]catalogue.Chosen{"postgres-database": {Node: "anchor", Module: "store"}} + got, err = Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + if n := need(t, got.Plan, "board", "postgres-database"); n.From != "anchor" { + t.Fatalf("pinned to the anchor and bound to %s", n.From) + } +} + +// 1362/AC4: a data-keeping consumer bound on record to a provider elsewhere is kept on that provider, and the resolution says +// so (Resolution.Kept) when the mesh would answer it otherwise. +func TestADataKeepingConsumerBoundElsewhereIsKeptThereAndSaid(t *testing.T) { + in := theStoreMesh(t) + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = []string{overlay.Name, "resolver", "network", "board"} // no store of its own + laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "anchor", Module: "store"}}} + got, err := Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + if n := need(t, got.Plan, "board", "postgres-database"); n.From != "anchor" || !n.KeepsData { + t.Fatalf("the consumer is bound to %s (keeps data: %v), not where it was sent", n.From, n.KeepsData) + } + if len(got.Plan.Kept) != 0 { + t.Errorf("a binding that was kept where it is is said as a kept move: %+v", got.Plan.Kept) + } + // And bound somewhere else on record while the mesh would answer from the anchor: kept, and said. + laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + home := Node{Name: "home", Place: Place{Site: "there", Address: "10.77.0.7"}, Assigned: []string{overlay.Name, "store"}, + Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}} + in.Nodes = append(in.Nodes, home) + got, err = Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + if n := need(t, got.Plan, "board", "postgres-database"); n.From != "home" { + t.Errorf("the consumer was moved from where it was sent: %s", n.From) + } +} + +// A provider module whose offer says only a person gives its credential (ADR 0337), and a consumer of it. +func outsideObjects() catalogue.Manifest { + m := objects() + m.Provides[0].Credential = &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside} + return m +} + +func theObjectsMesh(t *testing.T, provider catalogue.Manifest) Input { + t.Helper() + in := aMesh(t) + in.Catalogue[provider.Module], in.Catalogue["files"] = provider, files() + nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, provider.Module) + nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "files") + nodeOf(t, &in, "anchor").SealingKey = "anchor-key" + nodeOf(t, &in, "laptop").SealingKey = "laptop-key" + return in +} + +func theFilesNeed(t *testing.T, got Resolved) catalogue.Needed { + t.Helper() + return need(t, got.Plan, "files", "s3-bucket") +} + +// 1362/AC1: a pair credential the Input names as existing with no value, and one it names not at all, are composed with a +// stand-in and named foreseen, and nothing is made; one with a value is composed with it and foreseen is empty. +func TestAPairCredentialNotHeldIsComposedWithAStandInAndNamedForeseen(t *testing.T) { + mk := func() Input { return theObjectsMesh(t, objects()) } + for name, creds := range map[string][]Credential{ + "no entry": nil, + "held with no value": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialHeld}}, + "to be made": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialWouldMake}}, + } { + in := mk() + in.Credentials = creds + got, err := Resolve(in, "laptop") + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if n := theFilesNeed(t, got); n.Sealed != catalogue.ForeseenSealed { + t.Errorf("%s: the credential is %q, not the stand-in", name, n.Sealed) + } + if len(got.Foreseen) != 1 || got.Foreseen[0] != "files on laptop needs s3-bucket from anchor" { + t.Errorf("%s: foreseen is %v", name, got.Foreseen) + } + } + in := mk() + in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", + State: CredentialHeld, Sealed: "sealed-to-laptop"}} + got, err := Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + if n := theFilesNeed(t, got); n.Sealed != "sealed-to-laptop" || len(got.Foreseen) != 0 { + t.Errorf("a held credential: %q foreseen %v", n.Sealed, got.Foreseen) + } + unchanged(t, mk, func(in Input) { + if _, err := Resolve(in, "laptop"); err != nil { + t.Fatal(err) + } + }) +} + +// 1362/AC2: a credential only a person gives and nobody has is not given and not made: the consumer still resolves without it +// (novox/hq ADR 0337 clause 4), said with the controller's words; one given is composed with it. +func TestACredentialOnlyAPersonGivesIsNotMadeAndTheConsumerStillResolves(t *testing.T) { + for name, creds := range map[string][]Credential{ + "no entry": nil, + "to be made (never, for one issued outside)": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialWouldMake}}, + "not given": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven, + Why: "the value held was made by the mesh, which objects's software would refuse"}}, + } { + in := theObjectsMesh(t, outsideObjects()) + in.Credentials = creds + got, err := Resolve(in, "laptop") + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if n := theFilesNeed(t, got); n.Sealed != "" { + t.Errorf("%s: a value stands in for what nobody gave: %q", name, n.Sealed) + } + if len(got.Foreseen) != 0 || len(got.NotGiven) != 1 || !strings.Contains(got.NotGiven[0], "files on laptop holds no s3-bucket from anchor: ") { + t.Errorf("%s: foreseen %v not given %v", name, got.Foreseen, got.NotGiven) + } + if len(got.Plan.Modules) == 0 { + t.Errorf("%s: the machine did not resolve", name) + } + } + in := theObjectsMesh(t, outsideObjects()) + in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", + State: CredentialHeld, Sealed: "given-and-sealed"}} + got, err := Resolve(in, "laptop") + if err != nil || theFilesNeed(t, got).Sealed != "given-and-sealed" || len(got.NotGiven) != 0 { + t.Fatalf("a value a person gave: %+v %v", got.NotGiven, err) + } +} + +// 1362/AC3: a credential the send would refuse, a given value sealed to a key a machine no longer has, refuses the resolution with +// today's words, and is not a node whose set does not compose. +func TestACredentialTheSendWouldRefuseRefusesTheResolutionInTheSendsWords(t *testing.T) { + why := "files's \"s3-bucket\" credential from anchor was accepted from a person, and a sealing key at one end has changed since. " + + "The mesh cannot re-seal a value it does not hold: accept it again with `secret accept laptop files s3-bucket --provider anchor`" + in := theObjectsMesh(t, objects()) + in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", + State: CredentialRefused, Why: why}} + _, err := Resolve(in, "laptop") + want := "files on laptop needs s3-bucket from anchor and no credential could be made for it: " + why + if err == nil || err.Error() != want { + t.Fatalf("got %v, want %q", err, want) + } + if IsUnresolvable(err) { + t.Error("a refused credential is a node whose set does not compose") + } +} + +// A record-answered need is filled with the licence key sealed to this holder, and the manager holder is handed the manager node's +// public sealing key (novox/hq ADR 0050) and no other. +func TestARecordAnsweredNeedGetsItsKeyAndTheManagerItsPublicKey(t *testing.T) { + in := aMesh(t) + in.Catalogue["assistant"] = catalogue.Manifest{Module: "assistant", Version: "1", Requires: []string{LicenceProvision}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "assistant") + laptop.SealingKey = "laptop-key" + in.Licences = []Licence{{Name: "personal", Serves: map[string]any{"model": "x"}, + Holders: []Holder{{Node: "laptop", Module: "assistant"}}, + Keys: map[Holder]string{{Node: "laptop", Module: "assistant"}: "key-sealed-to-laptop"}}} + got, err := Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + n := need(t, got.Plan, "assistant", LicenceProvision) + if !n.ByRecord || n.Sealed != "key-sealed-to-laptop" || n.Manager || n.Serves["manager_public_key"] != nil { + t.Fatalf("the record-answered need is %+v", n) + } + in.Licences[0].Manager = Holder{Node: "laptop", Module: "assistant"} + got, err = Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + n = need(t, got.Plan, "assistant", LicenceProvision) + if !n.Manager || n.Serves["manager_public_key"] != "laptop-key" || n.Serves["model"] != "x" { + t.Fatalf("the manager is %+v", n) + } + if len(got.Foreseen) != 0 || len(got.NotGiven) != 0 { + t.Errorf("a record-answered need needs no pair credential: %v %v", got.Foreseen, got.NotGiven) + } +} + +// 1362/AC5: a node whose own set does not compose returns an error callers can tell from an unreadable Input. +func TestANodeWhoseOwnSetDoesNotComposeIsToldApartFromAnUnreadableInput(t *testing.T) { + in := aMesh(t) + in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two") + if _, err := Resolve(in, "laptop"); err == nil || !IsUnresolvable(err) { + t.Fatalf("a set that does not compose: %v", err) + } + if _, err := Resolve(in, "nowhere"); err == nil || IsUnresolvable(err) || !errors.Is(err, ErrNoSuchNode) { + t.Fatalf("a node the mesh does not have: %v", err) + } + in.Catalogue = nil + if _, err := Resolve(in, "anchor"); err == nil || IsUnresolvable(err) { + t.Fatalf("an Input without the catalogue: %v", err) + } +} + +// 1362/AC6: settings are returned for every resolved module that has layers in the Input, including one pulled in by another. +func TestSettingsAreReturnedForEveryResolvedModuleIncludingOneThatWasPulledIn(t *testing.T) { + in := aMesh(t) + in.Catalogue["lib"] = catalogue.Manifest{Module: "lib", Version: "1", Provides: catalogue.Offers("blob")} + in.Catalogue["app"] = catalogue.Manifest{Module: "app", Version: "1", Requires: []string{"blob"}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "app") + laptop.Settings = map[string][]catalogue.Layer{ + "lib": {{From: "mesh", Values: map[string]any{"x": 1}}}, + "app": {{From: "laptop", Values: map[string]any{"y": 2}}}, + "other": {{From: "laptop", Values: map[string]any{"z": 3}}}, // not resolved here: no settings come of it + "empty": nil, + } + got, err := Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + pulled := false + for _, m := range got.Plan.Modules { + pulled = pulled || m.Module == "lib" + } + if !pulled { + t.Fatalf("lib was not pulled in: %+v", got.Plan.Modules) + } + if len(got.Settings) != 2 || got.Settings["lib"][0].Values["x"] != 1 || got.Settings["app"][0].Values["y"] != 2 { + t.Errorf("settings are %+v", got.Settings) + } + // The settings returned are copies: changing them does not change the Input. + got.Settings["app"][0].From = "changed" + if laptop.Settings["app"][0].From != "laptop" { + t.Error("the settings returned share the Input's layers") + } +} + +// Round 1, finding 3: a licence key the Input does not carry (Keys not given) is composed with a stand-in and named foreseen, so the +// node still composes; Keys given that lack the holder is the mesh holding none, and the need stays empty. +func TestALicenceKeyTheInputDoesNotCarryIsAStandInAndNamedForeseen(t *testing.T) { + in := aMesh(t) + in.Catalogue["assistant"] = catalogue.Manifest{Module: "assistant", Version: "1", Requires: []string{LicenceProvision}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "assistant") + in.Licences = []Licence{{Name: "personal", Serves: map[string]any{"model": "x"}, Holders: []Holder{{Node: "laptop", Module: "assistant"}}}} + got, err := Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + if n := need(t, got.Plan, "assistant", LicenceProvision); n.Sealed != catalogue.ForeseenSealed { + t.Errorf("a key not given is composed as %q", n.Sealed) + } + if len(got.Foreseen) != 1 || !strings.Contains(got.Foreseen[0], "assistant on laptop needs model-access") { + t.Errorf("foreseen %v", got.Foreseen) + } + // And a whole node composes with it. + if _, problems, err := Compose(in, "laptop"); err != nil || len(problems) != 0 { + t.Errorf("a node using a licence whose key is not carried: %v %v", err, problems) + } + // Keys given, and none for this holder: the mesh holds none. + in.Licences[0].Keys = map[Holder]string{} + got, err = Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + if n := need(t, got.Plan, "assistant", LicenceProvision); n.Sealed != "" || len(got.Foreseen) != 0 { + t.Errorf("a key the mesh holds none of: %q %v", n.Sealed, got.Foreseen) + } +} + +// A consumer not handed of a provision whose provider's offer says the helper answers it has that provision in a plan of +// its own (issue 774: grafana's route): the composition marks the need answered by the helper, and the plan keys it. +func TestANeedAnsweredByTheHelperIsInTheConsumersPlan(t *testing.T) { + in := theStoreMesh(t) + store := in.Catalogue["store"] + store.Provides = []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}} + in.Catalogue["store"] = store + got, err := Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + if n := need(t, got.Plan, "board", "postgres-database"); !n.AnsweredByHelper { + t.Fatalf("the need is not marked answered by the helper: %+v", n) + } + with := rendered(t, in, "laptop", Foreseeing).Rendering + with.Hands, with.Provisioner = true, "mesh-provisioner-client" + if with.Generators, err = newComposer(in).generators(); err != nil { + t.Fatal(err) + } + c, err := got.Plan.Compose(with) + if err != nil { + t.Fatal(err) + } + if p := c.Plans["board"].Provisions; len(p) != 1 || p[0].Provision != "postgres-database" || p[0].ProviderModule != "store" { + t.Fatalf("board's plan is %+v", c.Plans["board"]) + } +} diff --git a/internal/compose/rest.go b/internal/compose/rest.go new file mode 100644 index 0000000..7ddb928 --- /dev/null +++ b/internal/compose/rest.go @@ -0,0 +1,255 @@ +package compose + +import ( + "errors" + "fmt" + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// notResolvable marks the one failure in a node's resolution that is a statement about the node: its assigned modules +// do not compose. Every other failure means the mesh could not be asked. Whoever gathers across the mesh skips a node +// that cannot resolve and never a node that could not be read (novox/hq 04-ISSUES/152). +type notResolvable struct{ err error } + +func (n notResolvable) Error() string { return n.err.Error() } +func (n notResolvable) Unwrap() error { return n.err } + +// unresolvable reports whether err is a node's own set failing to compose. +func unresolvable(err error) bool { + var n notResolvable + return errors.As(err, &n) +} + +// Passes is what a composition found that the mesh must record, and did not: each seat held once whose holder on record +// is assigned nowhere that exactly one claimant takes over (novox/hq ADR 0449 clause 4), and the lines that say what was +// found. The caller records a pass and writes its hand act; this package does neither. +type Passes struct { + // Passes are the seats to pass, in the order they were found. + Passes []catalogue.SeatPass + // Said are the lines the finding says (a seat two claimants could take, one nothing can). + Said []string +} + +// LeftOut is a node that does not resolve in the view of the rest of the mesh, with why: it offers nothing and holds +// nothing as far as every other machine's plan can tell (novox/hq issue 188). +type LeftOut struct { + Node string + Err error +} + +// String is the line the controller says of it. +func (l LeftOut) String() string { + return fmt.Sprintf("%s is left out of the rest of the mesh: it does not resolve: %v", l.Node, l.Err) +} + +// TheRestOfTheMesh is what every other node holds and offers, as the controller works it out (plan.go theRestOfTheMesh): +// two passes over the others, because what a node offers needs it resolved and resolving it may need what the mesh +// offers. The first takes brokered requirements on trust and answers only what each node offers; the second answers +// everything with that in hand. Nothing is declared from the first. +// +// It writes nothing. A seat the mesh passes on is returned in Passes for the caller to record; the World carries the +// holdings as they stand after the passes, and Input's own are unchanged. A node left out is returned with its reason, in +// the order the controller said them (the first pass's, then the second's when no node is excluded). +func TheRestOfTheMesh(in Input, exclude string) (catalogue.World, Passes, []LeftOut, error) { + c := newComposer(in) + return c.theRestOfTheMesh(exclude) +} + +func (c *composer) theRestOfTheMesh(exclude string) (catalogue.World, Passes, []LeftOut, error) { + r, ok := c.rest[exclude] + if !ok { + r.world, r.passes, r.left, r.err = c.theRestOfTheMeshNow(exclude) + r.unhostable = c.unhostable + if c.rest == nil { + c.rest = map[string]restOf{} + } + c.rest[exclude] = r + } + // Each caller has its own copy of what it may set or add to; the maps and the slices within are only read. + c.unhostable = slices.Clone(r.unhostable) + return r.world, r.passes, slices.Clone(r.left), r.err +} + +// theRestOfTheMeshNow is theRestOfTheMesh worked out: every other machine's set resolved, twice. +func (c *composer) theRestOfTheMeshNow(exclude string) (catalogue.World, Passes, []LeftOut, error) { + shelf := c.in.Catalogue + holdings := append([]catalogue.Held(nil), c.in.Holdings...) + nodes := c.in.sorted() + + onNetwork, err := c.whereEveryoneIs() + if err != nil { + return catalogue.World{}, Passes{}, nil, err + } + + type candidate struct { + node catalogue.Node + assigned []string + pins map[string]catalogue.Chosen + } + var others []candidate + // Every machine's assignments, before the holdings are read by any resolution (novox/hq ADR 0449 clause 4): a seat + // held once whose holder on record is assigned on no machine passes to its only claimant here. + everyAssigned := map[string][]string{} + for _, n := range nodes { + if len(n.Assigned) > 0 { + everyAssigned[n.Name] = n.Assigned + } + } + var passes Passes + holdings, passes = passSeatsOn(holdings, assignmentsOf(shelf, everyAssigned)) + for _, n := range nodes { + if n.Name == exclude { + continue + } + theirs := everyAssigned[n.Name] + if len(theirs) == 0 { + continue + } + others = append(others, candidate{ + node: catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities, + At: onNetwork[n.Name], PublicDomain: n.PublicDomain}, + assigned: theirs, pins: n.Pins}) + } + + var left []LeftOut + offered := map[string][]catalogue.Provider{} + var firstHeld []catalogue.Held + c.unhostable = nil + for _, o := range others { + got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true, Holdings: holdings}) + if err != nil { + // Said, not skipped: a machine dropped here offers nothing and holds nothing. + left = append(left, LeftOut{Node: o.node.Name, Err: err}) + continue + } + for _, u := range got.Unhostable { + c.unhostable = append(c.unhostable, fmt.Sprintf("%s on %s lacks %s", u.Module, o.node.Name, strings.Join(u.Missing, ", "))) + } + firstHeld = append(firstHeld, got.Claims...) + for _, m := range got.Modules { + for _, name := range m.OffersAt(catalogue.ScopeMesh) { + // What that module says a consumer needs to know, with that node's settings on it. + serves, layers, err := c.servedOnNode(o.node.Name, m, name) + if err != nil { + return catalogue.World{}, Passes{}, nil, err + } + // And its own composed names, as that node composes them (novox/hq ADR 0346). + serves, leftOut, err := got.ServedOwnNames(m, name, serves, catalogue.WithDefaults(m, layers)) + if err != nil { + return catalogue.World{}, Passes{}, nil, err + } + offered[name] = append(offered[name], catalogue.Provider{ + Node: o.node.Name, At: o.node.At, Serves: serves, Module: m.Module, LeftOut: leftOut}) + } + } + } + for k := range offered { + sort.Slice(offered[k], func(i, j int) bool { + return offered[k][i].Node < offered[k][j].Node + }) + } + + // The second pass is given the first pass's holdings (novox/hq ADR 0110). + world := catalogue.World{Offered: offered, Held: firstHeld, Holdings: holdings} + var held []catalogue.Held + for _, o := range others { + // Each machine is resolved with its own pins, as its plan is. + theirs := world + theirs.Pinned = o.pins + got, err := catalogue.Resolve(shelf, o.assigned, o.node, theirs) + if err != nil { + // Said only for the whole-mesh view: with one machine excluded, the view is partial by design. + if exclude == "" { + left = append(left, LeftOut{Node: o.node.Name, Err: err}) + } + continue + } + held = append(held, got.Claims...) + } + world.Held = held + return world, passes, left, nil +} + +// passSeatsOn finds, in the composition that finds it, the new holder of each seat held once whose holder on record is +// assigned nowhere and which has exactly one claimant able to hold it (novox/hq ADR 0449 clause 4), and answers the +// holdings as they are after it, with the passes. Nothing is recorded. +func passSeatsOn(holdings []catalogue.Held, assignments []catalogue.Assignment) ([]catalogue.Held, Passes) { + passes, said := catalogue.SeatsToPassOn(holdings, assignments) + out := Passes{Said: said} + for _, p := range passes { + out.Passes = append(out.Passes, p) + var next []catalogue.Held + for _, h := range holdings { + if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == p.Seat.Name { + continue + } + next = append(next, h) + } + p.To.Site = p.From.Site + holdings = append(next, p.To) + } + return holdings, out +} + +// assignmentsOf are the assignments of every machine, as the passing of a seat reads them. A module assigned and +// missing from the shelf is still assigned: an assignment with no claims, so its holder is not taken for gone. +func assignmentsOf(shelf map[string]catalogue.Manifest, assigned map[string][]string) []catalogue.Assignment { + var out []catalogue.Assignment + names := make([]string, 0, len(assigned)) + for node := range assigned { + names = append(names, node) + } + sort.Strings(names) + for _, node := range names { + for _, module := range assigned[node] { + m, ok := shelf[module] + if !ok { + m = catalogue.Manifest{Module: module} + } + out = append(out, catalogue.Assignment{Node: node, Manifest: m}) + } + } + return out +} + +// servedOnNode is what a module on a node tells a consumer of one of its provisions, with THAT node's ports on it: the +// port the node was given over the one the mesh assigned over the manifest's own, settled with the node's settings +// layers. The one derivation for every reader of a provider's address. +func (c *composer) servedOnNode(node string, m catalogue.Manifest, provision string) (map[string]any, []catalogue.Layer, error) { + ports, layers, err := c.portsGivenOn(node, m) + if err != nil { + return nil, nil, err + } + serves := catalogue.ServedOn(m, provision, ports) + if len(serves) > 0 { + serves, err = catalogue.Settle(serves, catalogue.WithDefaults(m, layers)) + if err != nil { + return nil, nil, err + } + } + return serves, layers, nil +} + +// portsGivenOn is where a node puts a module's ports — assigned, then given over them — and the node's settings layers +// for the module. +func (c *composer) portsGivenOn(node string, m catalogue.Manifest) (map[int]int, []catalogue.Layer, error) { + n, err := c.in.node(node) + if err != nil { + return nil, nil, err + } + ports := map[int]int{} + for wanted, at := range n.Ports[m.Module] { + ports[wanted] = at + } + layers := n.Settings[m.Module] + if given, err := catalogue.GivenPorts(m, layers); err == nil { + for wanted, at := range given { + ports[wanted] = at + } + } + return ports, layers, nil +} diff --git a/internal/compose/rest_test.go b/internal/compose/rest_test.go new file mode 100644 index 0000000..99ed3f2 --- /dev/null +++ b/internal/compose/rest_test.go @@ -0,0 +1,231 @@ +package compose + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/overlay" +) + +// objects and files are the provider and consumer of mesh-controller's facts_test.go aMeshWithSecrets (cmd/mesh-controller at +// 6c2a8d62): a module providing s3-bucket at mesh scope with an identity bound, and one requiring it. +func objects() catalogue.Manifest { + return catalogue.Manifest{Module: "objects", Version: "1", + Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh, + Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}}, + Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}} +} + +func files() catalogue.Manifest { + return catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"}, + Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", + "mode": "0600", "content": "{}", "merge": "json"}}} +} + +// threeNodes is aMesh and a third node `ace` running a database, so the mesh has three nodes that each offer something: +// anchor objects, ace a database, laptop a consumer of the first. +func threeNodes(t *testing.T) Input { + t.Helper() + in := aMesh(t) + db := catalogue.Manifest{Module: "pg", Version: "1", Provides: catalogue.FromAnywhere("postgres-database")} + in.Catalogue["objects"], in.Catalogue["files"], in.Catalogue["pg"] = objects(), files(), db + anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop") + anchor.Assigned = append(anchor.Assigned, "objects") + laptop.Assigned = append(laptop.Assigned, "files") + in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Site: "there", Address: "10.77.0.3"}, + Assigned: []string{"pg"}, Capabilities: map[string]bool{}}) + return in +} + +func providers(w catalogue.World, provision string) []string { + var out []string + for _, p := range w.Offered[provision] { + out = append(out, p.Node+"/"+p.Module) + } + return out +} + +// 1361/AC1: three nodes each offering a provision; the rest of the mesh, for one of them, offers every other node's and none of its +// own, and holds what they hold. +func TestTheRestOfTheMeshOffersEveryOtherNodesProvisionsAndNoneOfTheExcluded(t *testing.T) { + in := threeNodes(t) + w, passes, left, err := TheRestOfTheMesh(in, "laptop") + if err != nil || len(left) != 0 || len(passes.Passes) != 0 { + t.Fatalf("%v %v %+v", err, left, passes) + } + if got := providers(w, "s3-bucket"); len(got) != 1 || got[0] != "anchor/objects" { + t.Errorf("s3-bucket is offered by %v", got) + } + if got := providers(w, "postgres-database"); len(got) != 1 || got[0] != "ace/pg" { + t.Errorf("postgres-database is offered by %v", got) + } + if p := w.Offered["s3-bucket"][0]; p.At != "anchor.internal" { + t.Errorf("anchor is at %q on the private network", p.At) + } + // Excluding anchor takes its offer away and keeps the others'. + w, _, _, err = TheRestOfTheMesh(in, "anchor") + if err != nil { + t.Fatal(err) + } + if got := providers(w, "s3-bucket"); len(got) != 0 { + t.Errorf("the excluded node's offer is in the world: %v", got) + } + if got := providers(w, "postgres-database"); len(got) != 1 { + t.Errorf("another node's offer is missing: %v", got) + } + // With nobody excluded, every offer is there, and everyone's claims are held. + w, _, _, err = TheRestOfTheMesh(in, "") + if err != nil || len(providers(w, "s3-bucket")) != 1 || len(providers(w, "postgres-database")) != 1 { + t.Fatalf("%v %v", providers(w, "s3-bucket"), err) + } + // Each machine is resolved with its own pins, as its plan is: the world is the same for any pin of the excluded node. + nodeOf(t, &in, "laptop").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "anchor", Module: "objects"}} + if _, _, _, err := TheRestOfTheMesh(in, "laptop"); err != nil { + t.Fatal(err) + } +} + +// 1361/AC2: a node whose assigned set does not resolve is left out and named with today's reason, and the others still offer. +func TestANodeWhoseSetDoesNotResolveIsLeftOutAndTheOthersStillOffer(t *testing.T) { + in := threeNodes(t) + rivalOne := catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + rivalTwo := catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + in.Catalogue["rival-one"], in.Catalogue["rival-two"] = rivalOne, rivalTwo + ace := nodeOf(t, &in, "ace") + ace.Assigned = append(ace.Assigned, "rival-one", "rival-two") + w, _, left, err := TheRestOfTheMesh(in, "laptop") + if err != nil { + t.Fatal(err) + } + if len(left) == 0 { + t.Fatal("a node whose set does not resolve was not left out") + } + for _, l := range left { + if l.Node != "ace" || !strings.Contains(l.String(), "ace is left out of the rest of the mesh: it does not resolve: ") { + t.Errorf("left out %q", l.String()) + } + } + if got := providers(w, "s3-bucket"); len(got) != 1 { + t.Errorf("the others do not offer: %v", got) + } + if got := providers(w, "postgres-database"); len(got) != 0 { + t.Errorf("the node left out offers: %v", got) + } + // Said for the whole-mesh view in both passes (as the controller says it), and for a view with one machine excluded in the first only. + _, _, whole, _ := TheRestOfTheMesh(in, "") + if len(whole) != 2 { + t.Errorf("the whole-mesh view says %d line(s), want the two passes'", len(whole)) + } + if len(left) != 1 { + t.Errorf("a view with one excluded says %d line(s), want the first pass's", len(left)) + } +} + +// 1361/AC3 and AC4 (mesh-controller seat_pass_test.go TestACompositionPassesTheSeatAndWritesOneHandActByTheController, in +// the part that is not a write): a seat held once whose holder on record is assigned nowhere and one claimant: the seat +// passes to that claimant in the World, and is returned as a pass; Input's holdings are unchanged. +func TestASeatWhoseHolderIsAssignedNowhereGoesToItsOnlyClaimant(t *testing.T) { + withSeat(t) + mk := func() Input { + return Input{ + Catalogue: map[string]catalogue.Manifest{"gate": aBrokerClaiming("gate")}, + Nodes: []Node{{Name: "novox", Place: Place{Site: "here"}, Assigned: []string{"gate"}}}, + Holdings: []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker", Site: "here"}}, + } + } + in := mk() + w, passes, _, err := TheRestOfTheMesh(in, "") + if err != nil { + t.Fatal(err) + } + if len(w.Holdings) != 1 || w.Holdings[0].Module != "gate" || w.Holdings[0].Node != "novox" { + t.Fatalf("the holdings after the composition do not name the gate: %+v", w.Holdings) + } + if len(passes.Passes) != 1 { + t.Fatalf("passes=%d, want one", len(passes.Passes)) + } + p := passes.Passes[0] + if p.Seat.Name != "mesh-broker" || p.From.Module != "old-broker" || p.To.Module != "gate" || p.To.Node != "novox" { + t.Errorf("the pass is %+v", p) + } + unchanged(t, mk, func(in Input) { + if _, _, _, err := TheRestOfTheMesh(in, ""); err != nil { + t.Fatal(err) + } + }) +} + +// 1317/AC2 (seat_pass_test.go TestACompositionWithTwoClaimantsRecordsNoneAndSaysSo): two claimants, no pass, the refusal said with +// both and the remedy. 1317/AC3 and AC4 (TestACompositionChangesNothingWhileTheHolderIsAssignedOrNobodyClaims): nothing changes while +// the holder is assigned, nor with no claimant. +func TestNoSeatIsPassedForTwoClaimantsAnAssignedHolderOrNoClaimant(t *testing.T) { + withSeat(t) + held := []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}} + _, passes := passSeatsOn(held, []catalogue.Assignment{ + {Node: "novox", Manifest: aBrokerClaiming("gate")}, {Node: "ace", Manifest: aBrokerClaiming("other")}}) + said := strings.Join(passes.Said, "\n") + if len(passes.Passes) != 0 || !strings.Contains(said, "gate") || !strings.Contains(said, "other") || + !strings.Contains(said, "seat mesh-broker --to") { + t.Fatalf("passes %+v said %q", passes.Passes, said) + } + got, passes := passSeatsOn(held, []catalogue.Assignment{ + {Node: "novox", Manifest: aBrokerClaiming("old-broker")}, {Node: "novox", Manifest: aBrokerClaiming("gate")}}) + if len(passes.Passes) != 0 || len(got) != 1 || got[0].Module != "old-broker" { + t.Fatalf("holder assigned: %+v %+v", got, passes) + } + got, passes = passSeatsOn(held, nil) + if len(passes.Passes) != 0 || len(got) != 1 || got[0].Module != "old-broker" { + t.Fatalf("no claimant: %+v %+v", got, passes) + } +} + +// seat_pass_test.go TestAHolderAssignedButNotOnTheShelfIsNotTakenForGone. +func TestAHolderAssignedButNotOnTheShelfIsNotTakenForGone(t *testing.T) { + withSeat(t) + as := assignmentsOf(map[string]catalogue.Manifest{"gate": aBrokerClaiming("gate")}, + map[string][]string{"novox": {"gate", "old-broker"}}) + passes, _ := catalogue.SeatsToPassOn( + []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}}, as) + if len(passes) != 0 { + t.Fatalf("a holder missing from the shelf lost its seat: %+v", passes) + } +} + +// The not-resolving node of a view with a holder on record: holdings reach the first pass (novox/hq ADR 0131), so a node running +// the next holder beside the current one is not refused as a second holder. +func TestAHolderOnRecordSettlesTheSeatInTheRestOfTheMesh(t *testing.T) { + withSeat(t) + in := Input{ + Catalogue: map[string]catalogue.Manifest{"old-broker": aBrokerClaiming("old-broker"), "gate": aBrokerClaiming("gate")}, + Nodes: []Node{ + {Name: "novox", Assigned: []string{"old-broker", "gate"}}, + {Name: "ace", Assigned: []string{}}}, + Holdings: []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}}, + } + _, passes, left, err := TheRestOfTheMesh(in, "ace") + if err != nil || len(left) != 0 || len(passes.Passes) != 0 { + t.Fatalf("%v %v %+v", err, left, passes) + } + in.Holdings = nil + _, _, left, err = TheRestOfTheMesh(in, "ace") + if err != nil || len(left) == 0 { + t.Fatalf("two claimants with nothing on record were not refused: %v %v", err, left) + } +} + +// Each machine is resolved in the second pass with its own pins, as its plan is (novox/hq issue 188): a pin to a provider that +// does not answer is its own refusal, so the machine is left out for it; the same machine without the pin is not. +func TestEachNodeIsResolvedWithItsOwnPinsInTheRestOfTheMesh(t *testing.T) { + in := threeNodes(t) + in.Nodes = append(in.Nodes, Node{Name: "newt", Place: Place{Site: "there", Address: "10.77.0.4"}, Assigned: []string{overlay.Name, "files"}, Capabilities: map[string]bool{}}) + _, _, left, err := TheRestOfTheMesh(in, "") + if err != nil || len(left) != 0 { + t.Fatalf("without a pin the consumer is left out: %v %v", left, err) + } + nodeOf(t, &in, "newt").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "ace", Module: "nothing"}} + _, _, left, err = TheRestOfTheMesh(in, "") + if err != nil || len(left) != 1 || left[0].Node != "newt" || !strings.Contains(left[0].String(), "was told to get \"s3-bucket\" from") { + t.Fatalf("a pin to a provider that does not answer did not leave the consumer out: %v %v", left, err) + } +} diff --git a/internal/compose/seats_test.go b/internal/compose/seats_test.go new file mode 100644 index 0000000..a07e0c2 --- /dev/null +++ b/internal/compose/seats_test.go @@ -0,0 +1,22 @@ +package compose + +import ( + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// aBrokerClaiming is mesh-controller's seat_pass_test.go helper of the same name. +func aBrokerClaiming(module string) catalogue.Manifest { + return catalogue.Manifest{Module: module, + Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}}, + Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}} +} + +// withSeat is mesh-controller's withBusSeat: a single mesh seat, mesh-broker, for the test. +func withSeat(t *testing.T) { + t.Helper() + was := catalogue.Seats() + t.Cleanup(func() { catalogue.UseSeats(was) }) + catalogue.UseSeats([]catalogue.Seat{{Name: "mesh-broker", Scope: catalogue.ScopeMesh, Delivers: "mesh-bus", Decision: "test"}}) +} diff --git a/internal/composer/composer.go b/internal/composer/composer.go new file mode 100644 index 0000000..d5bba7c --- /dev/null +++ b/internal/composer/composer.go @@ -0,0 +1,236 @@ +// Package composer is the node-engine's composition as one function over a plain request (novox/hq ADR 0482 clause 4, +// ADR 0593 §7): what the command mesh-compose runs, so a group that changes the node-engine's composition is composed by +// the composer built at that group's head of the node-engine's repository, in a build module's sandbox. +// +// **It reads its request and nothing else.** No bus, store, network, file or environment: the manifests come inside the +// request (raw, as their repository holds them), the rest of the mesh is the request's input, and the answer is a value. +// The request's and the verdict's shapes are mesh-sdk's composer package; their logic, which composes, is here. +package composer + +import ( + "encoding/json" + "fmt" + "slices" + "sort" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + sdk "git.novox.be/novox/mesh-sdk/go/composer" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/sdkversion" + + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/frombus" +) + +// The shapes of mesh-sdk's composer package, by the names this package's code was written with. +type ( + Request = sdk.Request + Case = sdk.Case + Manifest = sdk.Manifest + Verdict = sdk.Verdict + CaseVerdict = sdk.CaseVerdict + ManifestRead = sdk.ManifestRead + Machine = sdk.Machine + Path = sdk.Path +) + +const ( + Version = sdk.Version + DepthResolve = sdk.DepthResolve + DepthCompose = sdk.DepthCompose +) + +// ErrUnfilled is a manifest named by a digest that nobody replaced by its bytes (sdk.ErrUnfilled). +var ErrUnfilled = sdk.ErrUnfilled + +// inputOf is the request's input as this composer's composition input: the request carries it as JSON (mesh-sdk holds no +// composition), the same bytes either way. +func inputOf(r Request) (compose.Input, error) { + var in compose.Input + raw, err := json.Marshal(r.Input) + if err != nil { + return in, fmt.Errorf("composer: the request's input cannot be read: %w", err) + } + if len(raw) == 0 || string(raw) == "null" { + return in, nil + } + if err := json.Unmarshal(raw, &in); err != nil { + return in, fmt.Errorf("composer: the request's input is not a composition input: %w", err) + } + return in, nil +} + +// Run composes every case of r and answers the verdict, its SDK this binary's mesh-sdk. An error is a request it +// cannot run (an unknown depth, a case named twice, a manifest left as a digest); a machine that does not compose is a +// verdict. +func Run(r Request) (Verdict, error) { + depth := r.Depth + if depth == "" { + depth = DepthCompose + } + if depth != DepthResolve && depth != DepthCompose { + return Verdict{}, fmt.Errorf("composer: the depth %q is not resolve or compose", r.Depth) + } + v := Verdict{V: Version, SDK: sdkversion.Version()} + seen := map[string]bool{} + for _, c := range r.Cases { + if seen[c.Name] { + return Verdict{}, fmt.Errorf("composer: the case %q is named twice", c.Name) + } + seen[c.Name] = true + cv, err := runCase(r, c, depth) + if err != nil { + return Verdict{}, err + } + v.Cases = append(v.Cases, cv) + } + return v, nil +} + +// parse decodes every manifest leniently and takes its verdict (novox/hq ADR 0578 §3, §5): the composer judges none. A +// manifest refused by its verdict is held, composed as its stub without its items (as the bus reader holds one); bytes +// that do not decode are left out. Both are named in reads. +func parse(m map[string]Manifest, reads map[string]ManifestRead, verdicts map[string]delivery.ManifestVerdict) (map[string]catalogue.Manifest, error) { + out := map[string]catalogue.Manifest{} + for name, man := range m { + if len(man.Raw) == 0 { + return nil, fmt.Errorf("%w: %s (digest %s)", ErrUnfilled, name, man.Digest) + } + decoded, err := catalogue.Decode(man.Raw) + read := ManifestRead{Accepted: err == nil, SetAside: decoded.SetAside()} + if err != nil { + read.Refused = fmt.Sprintf("the manifest of %s does not decode: %v", name, err) + } else if v, ok := verdicts[name]; ok { + if refused, _ := delivery.TakeManifestVerdict(&v); refused { + if v.Module == "" { + v.Module = name + } + read = ManifestRead{Refused: v.Why()} + decoded.Items = nil + decoded = compose.Stub(decoded) + } + } + if reads != nil { + reads[name] = read + } + if err == nil { + out[name] = decoded + } + } + return out, nil +} + +func runCase(r Request, c Case, depth string) (CaseVerdict, error) { + cv := CaseVerdict{Name: c.Name, Manifests: map[string]ManifestRead{}} + shelf, err := parse(c.Manifests, cv.Manifests, c.Verdicts) + if err != nil { + return CaseVerdict{}, err + } + base, err := inputOf(r) + if err != nil { + return CaseVerdict{}, err + } + in, err := frombus.Placed(base, c.Placements) + if err != nil { + return CaseVerdict{}, fmt.Errorf("composer: the case %q: %w", c.Name, err) + } + in.Catalogue = shelf + in.Nodes = slices.Clone(in.Nodes) + in.Holdings = slices.Clone(in.Holdings) + for i := range in.Nodes { + in.Nodes[i].Kept = nil + if kept := c.Kept[in.Nodes[i].Name]; len(kept) > 0 { + k, err := parse(kept, nil, nil) + if err != nil { + return CaseVerdict{}, err + } + in.Nodes[i].Kept = k + } + } + if r.HoldingsFromClaims { + in.Holdings = append(in.Holdings, HoldingsOf(shelf, in.Nodes)...) + } + if len(r.Machines) == 0 { + return cv, nil + } + cv.Machines = map[string]Machine{} + for _, node := range r.Machines { + cv.Machines[node] = machine(in, node, depth, r.Declarations) + } + return cv, nil +} + +// HoldingsOf is every seat an assigned module claims, held on the node it is assigned to, in node order: the holdings +// of an input that has none of record (the assigner's, mesh-delivery-assigner internal/assign input). +func HoldingsOf(shelf map[string]catalogue.Manifest, nodes []compose.Node) []catalogue.Held { + var out []catalogue.Held + sorted := slices.Clone(nodes) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) + for _, n := range sorted { + for _, mod := range n.Assigned { + for _, cl := range shelf[mod].Claims { + scope := cl.Scope + if seat, ok := catalogue.SeatNamed(cl.Name); ok && scope == "" { + scope = seat.Scope + } + out = append(out, catalogue.Held{Claim: cl.Name, Scope: scope, Node: n.Name, Module: mod}) + } + } + } + return out +} + +func machine(in compose.Input, node, depth string, declarations bool) Machine { + if depth == DepthResolve { + if _, err := compose.Resolve(in, node); err != nil { + return Machine{Reason: err.Error()} + } + return Machine{Composes: true} + } + d, problems, err := compose.Compose(in, node) + if err != nil { + return Machine{Reason: err.Error()} + } + m := Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()} + if len(problems) > 0 { + m.Reason = "the node-engine's validator refuses the declaration" + } + if declarations { + m.Declared = pathsOf(d) + } + return m +} + +// pathsOf is every resource of a declaration that names a path, with its owner, group and mode as declared, sorted by +// path. +func pathsOf(d compose.Declared) []Path { + var out []Path + str := func(r map[string]any, key string) string { + if v, ok := r[key]; ok && v != nil { + return fmt.Sprint(v) + } + return "" + } + for _, r := range d.Resources { + p := str(r, "path") + if p == "" { + continue + } + out = append(out, Path{Path: p, Kind: str(r, "type"), Owner: str(r, "owner"), Group: str(r, "group"), + Mode: str(r, "mode"), Module: d.ModuleOf[str(r, "id")]}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Path != out[j].Path { + return out[i].Path < out[j].Path + } + return out[i].Kind < out[j].Kind + }) + return out +} + +// Package is the import path of the command, which a build module builds at the node-engine's repository's head of a +// group that changes the node-engine's composition (novox/hq ADR 0593 §7): mesh-sdk's composer.Package names the same. +const Package = "github.com/novox/mesh-host/cmd/mesh-compose" + +// Decode reads a request (sdk.Decode); it refuses one that is not JSON or of another version. +func Decode(data []byte) (Request, error) { return sdk.Decode(data) } diff --git a/internal/composer/composer_test.go b/internal/composer/composer_test.go new file mode 100644 index 0000000..dcce0ac --- /dev/null +++ b/internal/composer/composer_test.go @@ -0,0 +1,100 @@ +package composer + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" +) + +const demo = `{"module": "demo", "version": "1", "claims": [{"name": "node-files"}]}` + +// withInput is r with in as its input, as a caller puts it on the wire: the request's shape carries the input as JSON. +func withInput(t *testing.T, r Request, in compose.Input) Request { + t.Helper() + raw, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &r.Input); err != nil { + t.Fatal(err) + } + return r +} + +// The seats a node's assigned modules claim are held there when the request says the holdings come from the claims. +func TestHoldingsComeFromTheClaims(t *testing.T) { + m, err := catalogue.Decode([]byte(demo)) + if err != nil { + t.Fatal(err) + } + got := HoldingsOf(map[string]catalogue.Manifest{"demo": m}, []compose.Node{{Name: "b", Assigned: []string{"demo"}}, {Name: "a", Assigned: []string{"demo"}}}) + if len(got) != 2 || got[0].Node != "a" || got[0].Claim != "node-files" || got[0].Scope != "node" { + t.Fatalf("holdings = %+v", got) + } +} + +// A request the command cannot run is refused, never answered as a verdict: a case named twice, an unknown depth. +func TestARequestItCannotRunIsRefused(t *testing.T) { + if _, err := Run(Request{V: Version, Cases: []Case{{Name: "a"}, {Name: "a"}}}); err == nil { + t.Error("a case named twice runs") + } + if _, err := Run(Request{V: Version, Depth: "deep"}); err == nil { + t.Error("an unknown depth runs") + } + // A case's own placement (issue 1126) composes over a node the input holds, and is refused for one it does not. + placed := withInput(t, Request{V: Version, Machines: []string{"a"}, Depth: DepthResolve, + Cases: []Case{{Name: "trunk", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}, + {Name: "change", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}, + Placements: map[string]*delivery.Placement{"a": {Node: "a", Modules: []delivery.PlacedModule{{Module: "ghost"}}}}}}}, + compose.Input{Nodes: []compose.Node{{Name: "a"}}}) + if v, err := Run(placed); err != nil || !v.Cases[0].Machines["a"].Composes || v.Cases[1].Machines["a"].Composes || + !strings.Contains(v.Cases[1].Machines["a"].Reason, "ghost") { + t.Errorf("a case composes its own placement: %+v, %v", v, err) + } + placed.Cases[1].Placements = map[string]*delivery.Placement{"z": {Node: "z"}} + if _, err := Run(placed); err == nil || !strings.Contains(err.Error(), "holds no node for") { + t.Errorf("a case placing a node the input lacks: %v", err) + } + v, err := Run(Request{V: Version, Cases: []Case{{Name: "only-read", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}}) + if err != nil || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted || v.Cases[0].Machines != nil || v.SDK == "" { + t.Fatalf("a request with no machines: %+v, %v", v, err) + } +} + +// The composer judges no manifest (novox/hq ADR 0578 §5): xterm's module.json from mesh-catalog at 835bf22b, whose +// config value falls back to "font-size", is read whatever list of predefined keys a request carries (a group touching +// xterm failed its composition on this, group check 18df81380db3); a module whose verdict in the input refuses it is +// held, composed as its stub, and named in the verdict's words. +func TestTheComposerTakesTheVerdictsOfItsInput(t *testing.T) { + raw, err := os.ReadFile("testdata/catalogue-835bf22b-xterm.module.json") + if err != nil { + t.Fatal(err) + } + r := Request{V: Version, Predefined: catalogue.PredefinedList{"something-else": {Type: "string", Description: "x"}}, + Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"xterm": {Raw: raw}}}}} + v, err := Run(r) + if err != nil { + t.Fatal(err) + } + if got := v.Cases[0].Manifests["xterm"]; !got.Accepted { + t.Fatalf("xterm: %+v, want accepted", got) + } + r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"xterm": {Module: "xterm", State: delivery.VerdictRefused, + Problems: []string{"falls back to a key not listed"}, Judge: "src2:j"}} + shelf, err := parse(r.Cases[0].Manifests, map[string]ManifestRead{}, r.Cases[0].Verdicts) + if err != nil || !shelf["xterm"].Stubbed { + t.Fatalf("the refused module is not held as its stub: %+v %v", shelf["xterm"], err) + } + if v, err = Run(r); err != nil { + t.Fatal(err) + } + if got := v.Cases[0].Manifests["xterm"]; got.Accepted || + !strings.Contains(got.Refused, "the indexer's judge (src2:j) refused the manifest of xterm: falls back to a key not listed") { + t.Fatalf("xterm refused by its verdict: %+v, want refused in the verdict's words", got) + } +} diff --git a/internal/composer/testdata/catalogue-835bf22b-xterm.module.json b/internal/composer/testdata/catalogue-835bf22b-xterm.module.json new file mode 100644 index 0000000..b65be8e --- /dev/null +++ b/internal/composer/testdata/catalogue-835bf22b-xterm.module.json @@ -0,0 +1,236 @@ +{ + "module": "xterm", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-terminal-emulator", + "scope": "node", + "serves": [ + "open" + ] + } + ], + "tools": [ + "xterm_font", + "xterm_colours" + ], + "config": { + "face": { + "type": "string", + "description": "The font family xterm draws with, as fc-list names it: JetBrainsMono Nerd Font, because research 026/04 chose it for its glyphs the prompt uses. A family the machine does not have falls back to the nearest installed face (xterm_font says which).", + "pattern": "[A-Za-z0-9 ._-]{1,64}", + "default": "JetBrainsMono Nerd Font" + }, + "size": { + "type": "number", + "description": "The size of xterm's text, in points: 12 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.", + "minimum": 6, + "maximum": 72, + "predefined": "font-size", + "default": 12 + }, + "save-lines": { + "type": "number", + "description": "How many lines of scrollback xterm keeps per terminal: 10000 holds a long build log, and each line costs memory in every open terminal.", + "minimum": 0, + "maximum": 1000000, + "default": 10000 + }, + "term-name": { + "type": "string", + "description": "The TERM value xterm sets for the programs it runs: xterm-256color, because the palette has 256 colours and every program knows the name.", + "pattern": "[A-Za-z0-9._-]{1,64}", + "default": "xterm-256color" + }, + "render-font": { + "type": "boolean", + "description": "Whether xterm draws with fontconfig faces (true) or the old X core fonts (false): true, because the face is a fontconfig family and core fonts cannot show it.", + "default": true + }, + "bold-mode": { + "type": "boolean", + "description": "Whether xterm draws bold text by overstriking the normal face (true): false, because the face has a real bold style and overstriking blurs it.", + "default": false + }, + "always-bold-mode": { + "type": "boolean", + "description": "Whether xterm overstrikes bold even where the face has a bold style (true): false, for the same reason as bold-mode.", + "default": false + }, + "allow-bold-fonts": { + "type": "boolean", + "description": "Whether xterm may use the face's bold style for bold text (true): true, so bold text is drawn with the real bold weight.", + "default": true + }, + "background": { + "type": "string", + "description": "The terminal's background colour, as #rrggbb: black, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#000000" + }, + "foreground": { + "type": "string", + "description": "The terminal's text colour, as #rrggbb: a light grey, easier on the eyes than white on black.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#a8a8a8" + }, + "color0": { + "type": "string", + "description": "Palette entry 0, the normal black, as #rrggbb: #000000, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#000000" + }, + "color1": { + "type": "string", + "description": "Palette entry 1, the normal red, as #rrggbb: #9e1828, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#9e1828" + }, + "color2": { + "type": "string", + "description": "Palette entry 2, the normal green, as #rrggbb: #008800, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#008800" + }, + "color3": { + "type": "string", + "description": "Palette entry 3, the normal yellow, as #rrggbb: #d2bb4b, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#d2bb4b" + }, + "color4": { + "type": "string", + "description": "Palette entry 4, the normal blue, as #rrggbb: #414171, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#414171" + }, + "color5": { + "type": "string", + "description": "Palette entry 5, the normal magenta, as #rrggbb: #963c59, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#963c59" + }, + "color6": { + "type": "string", + "description": "Palette entry 6, the normal cyan, as #rrggbb: #418179, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#418179" + }, + "color7": { + "type": "string", + "description": "Palette entry 7, the normal white, as #rrggbb: #bebebe, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#bebebe" + }, + "color8": { + "type": "string", + "description": "Palette entry 8, the bright black, as #rrggbb: #666666, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#666666" + }, + "color9": { + "type": "string", + "description": "Palette entry 9, the bright red, as #rrggbb: #bc5766, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#bc5766" + }, + "color10": { + "type": "string", + "description": "Palette entry 10, the bright green, as #rrggbb: #61a171, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#61a171" + }, + "color11": { + "type": "string", + "description": "Palette entry 11, the bright yellow, as #rrggbb: #e7db52, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#e7db52" + }, + "color12": { + "type": "string", + "description": "Palette entry 12, the bright blue, as #rrggbb: #5085af, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#5085af" + }, + "color13": { + "type": "string", + "description": "Palette entry 13, the bright magenta, as #rrggbb: #a97a99, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#a97a99" + }, + "color14": { + "type": "string", + "description": "Palette entry 14, the bright cyan, as #rrggbb: #6ba4a4, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#6ba4a4" + }, + "color15": { + "type": "string", + "description": "Palette entry 15, the bright white, as #rrggbb: #ffffff, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#ffffff" + }, + "copy-binding": { + "type": "string", + "description": "The key combination that copies the selection to the clipboard, in X translation syntax: Ctrl Shift ~Alt C, so a plain Ctrl+C still interrupts the program. It holds no colon, backslash or line break, which would end the resource.", + "pattern": "[^:\\\\\\n]+", + "default": "Ctrl Shift ~Alt C" + }, + "paste-binding": { + "type": "string", + "description": "The key combination that pastes the clipboard, in X translation syntax: Ctrl Shift ~Alt V, the pair of copy-binding. It holds no colon, backslash or line break, which would end the resource.", + "pattern": "[^:\\\\\\n]+", + "default": "Ctrl Shift ~Alt V" + } + }, + "items": [ + { + "collection": "environment", + "name": "terminal", + "attributes": { + "kind": "variable", + "name": "TERMINAL" + }, + "template": "files/env-terminal" + }, + { + "collection": "shell:xresources:normal", + "name": "xresources-normal", + "template": "files/xresources-normal" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "xterm" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/xterm-tools", + "binary": "xterm-tools", + "loads": [ + "xterm-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/internal/judge/aliases.go b/internal/judge/aliases.go new file mode 100644 index 0000000..589de07 --- /dev/null +++ b/internal/judge/aliases.go @@ -0,0 +1,24 @@ +// Package judge is the node-engine's judgement of a move (novox/hq ADR 0377 §3, ADR 0547 §3): what moving a module from +// the composition it runs to a new one does, and how a placement candidate's answer is fitted to the node's key. It +// moved here from mesh-sdk's delivery package (novox/hq ADR 0593 §1, §7), since only the node-engine judges a move; +// the shapes it judges (a composition, a judgement's words, a candidate's answer) stay in mesh-sdk. +package judge + +import "git.novox.be/novox/mesh-sdk/go/delivery" + +// The shapes of mesh-sdk's delivery package this package judges, by the names its code was written with. +type ( + Composed = delivery.Composed + Judgement = delivery.Judgement + CandidateAnswer = delivery.CandidateAnswer + ModuleDiffers = delivery.ModuleDiffers + ResourceBodies = delivery.ResourceBodies +) + +const ( + JudgedRecreates = delivery.JudgedRecreates + JudgedMovesData = delivery.JudgedMovesData + JudgedNeither = delivery.JudgedNeither + JudgedCannot = delivery.JudgedCannot + JudgedUnchanged = delivery.JudgedUnchanged +) diff --git a/internal/judge/answer.go b/internal/judge/answer.go new file mode 100644 index 0000000..d8502c3 --- /dev/null +++ b/internal/judge/answer.go @@ -0,0 +1,48 @@ +package judge + +import ( + "encoding/json" + "strconv" + "strings" +) + +// FitAnswer is a with its shown bodies cut until its JSON is within limit bytes (MaxAnswerBytes for the node's key): +// first the bodies' texts, from the last differing module back, keeping their paths; then the paths too. What is cut +// is said in the answer's note. The verdict and every list of modules are never cut. +func FitAnswer(a CandidateAnswer, limit int) CandidateAnswer { + size := func() int { + raw, err := json.Marshal(a) + if err != nil { + return 0 + } + return len(raw) + } + if size() <= limit { + return a + } + a.Differs = append([]ModuleDiffers(nil), a.Differs...) + for i := range a.Differs { + a.Differs[i].Bodies = append([]ResourceBodies(nil), a.Differs[i].Bodies...) + } + cutTexts, cutPaths := false, false + for i := len(a.Differs) - 1; i >= 0 && size() > limit; i-- { + for j := range a.Differs[i].Bodies { + a.Differs[i].Bodies[j].Composed, a.Differs[i].Bodies[j].Running = "", "" + } + cutTexts = true + } + for i := len(a.Differs) - 1; i >= 0 && size() > limit; i-- { + for j := range a.Differs[i].Bodies { + a.Differs[i].Bodies[j].Paths = nil + } + cutPaths = true + } + why := "the bodies shown were cut to keep the answer within " + strconv.Itoa(limit>>10) + " KiB" + if cutPaths { + why += ", their paths too" + } + if cutTexts { + a.Note = strings.TrimPrefix(a.Note+"; ", "; ") + why + } + return a +} diff --git a/internal/judge/answer_test.go b/internal/judge/answer_test.go new file mode 100644 index 0000000..62535c6 --- /dev/null +++ b/internal/judge/answer_test.go @@ -0,0 +1,25 @@ +package judge + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// An answer too large for the node's key is fitted: the texts go first, the verdict and the paths stay, and the answer +// given is not changed (moved from mesh-sdk's placement test with FitAnswer, novox/hq ADR 0593 §7). +func TestFitAnswer(t *testing.T) { + long := strings.Repeat("x", delivery.MaxBody) + big := CandidateAnswer{Proposal: "p-x1", Revision: 1, Verdict: delivery.CandidateDiffers, Differs: []ModuleDiffers{{ + Module: "nats", Why: "differs", Bodies: []ResourceBodies{{ID: "conf", Why: "changed", + Paths: []string{"content.links.1", "content.password"}, Composed: long, Running: long}}}}} + if same := FitAnswer(big, 1<<20); same.Note != "" || same.Differs[0].Bodies[0].Composed != long { + t.Errorf("an answer within the bound was cut: %+v", same.Note) + } + fitted := FitAnswer(big, 4<<10) + if len(fitted.Differs[0].Bodies[0].Composed) != 0 || len(fitted.Differs[0].Bodies[0].Paths) != 2 || + fitted.Verdict != delivery.CandidateDiffers || !strings.Contains(fitted.Note, "cut") || big.Differs[0].Bodies[0].Composed == "" { + t.Errorf("fitted as %+v", fitted) + } +} diff --git a/internal/judge/judge.go b/internal/judge/judge.go new file mode 100644 index 0000000..99ab051 --- /dev/null +++ b/internal/judge/judge.go @@ -0,0 +1,365 @@ +package judge + +import ( + "bytes" + "encoding/json" + "fmt" + "path" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/declaration" +) + +// The judgement of a move (ADR 0377 §3), as a pure function over two compositions of one module, so the node agent that +// composes its own modules judges what it would apply (novox/hq ADR 0547 §3; issue 1126's S8). Moved from +// mesh-controller cmd/mesh-controller/composed_judge.go at origin/main (the controller keeps its copy, unchanged, until +// it loses the lease, ADR 0547 §1): judged, onlyTheUserList and sameResources, with their helpers, are its code. +// +// ADR 0377 §3 over two compositions of one module: **recreates** when a container resource is changed, added or +// removed; **moves data** when, with no container changed, a directory or file resource is changed, added or removed +// that a container of the module mounts or that holds or lies within one of its directories (where a module keeps its +// data), when its `data` declaration changes, or, for a module that declares data, when one of its long-running +// processes changes; **neither** otherwise, and for a module the node has never run. + +// Judged is one move's judgement: what it does, its words, and whether only the bus's user list changes (ADR 0539 §7: +// a file change and a reload of the bus, no move of it, never asked). +type Judged struct { + Judgement Judgement + Words string + UserListOnly bool +} + +// JudgeMove judges moving a node's module from the composition it runs to the one composed for it. Running is nil when the +// node runs no composition of the module. A composition equal to the one running (resource for resource, each +// canonical) is unchanged: no move, and no ask (ADR 0547 §3). +func JudgeMove(running *Composed, composed Composed) Judged { + if composed.Module == "" { + return Judged{Judgement: JudgedNeither, Words: "the node's own composition"} + } + if running == nil { + return Judged{Judgement: JudgedNeither, Words: "the node runs no composition of " + composed.Module} + } + if running.Data == composed.Data && SameComposedResources(*running, composed) { + return Judged{Judgement: JudgedUnchanged, Words: "equal to the composition the node runs"} + } + if onlyTheUserList(*running, composed) { + return Judged{Judgement: JudgedNeither, Words: "only the bus's user list changes", UserListOnly: true} + } + j, words := judged(*running, composed) + return Judged{Judgement: j, Words: words} +} + +// busUsersSuffix ends the id of the bus module's user list as the composer writes it: `.bus-users`. +const busUsersSuffix = ".bus-users" + +// onlyTheUserList says two compositions of a module differ, and only in its file of the bus's user list. +func onlyTheUserList(from, to Composed) bool { + if from.Data != to.Data { + return false + } + before, after := judgedResourcesByID(from), judgedResourcesByID(to) + if len(before) != len(after) { + return false + } + changed := false + for id, now := range after { + was, ok := before[id] + if !ok { + return false + } + if bytes.Equal(was.raw, now.raw) { + continue + } + if !strings.HasSuffix(id, busUsersSuffix) || now.kind != string(declaration.TypeFile) { + return false + } + changed = true + } + return changed +} + +// judged applies ADR 0377 §3 to two compositions of one module. +func judged(from, to Composed) (Judgement, string) { + before, after := judgedResourcesByID(from), judgedResourcesByID(to) + if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 { + return JudgedRecreates, "it recreates " + strings.Join(ids, ", ") + } + places := dataPlaces(before, after) + held := func(kind string) bool { + return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile) + } + var moved []string + for _, id := range changedOf(before, after, held) { + r := before[id] + if r == nil { + r = after[id] + } + if withinPlaces(r.path, places) { + moved = append(moved, id) + } + } + if to.Data || from.Data { + moved = append(moved, changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeProcess) })...) + } + // A data flag that differs is no move of data by itself: data moves only when a data item does (issue 1126, 2026-10-29: + // a running composition read back from the kept declaration carries no flag, and mongodb's toolchain move asked a yes for + // moving its data). It is said loudly in the words, and never hides an item that moved. + if len(moved) > 0 { + sort.Strings(moved) + // A file the mesh makes for the module's provisions (what its consumers contribute, what it is bound to, the + // secret it is given) is said as the consumers that join or leave, not as its data moving (issue 774, + // 2026-10-31: postgres's yes said "where its data is kept" when nextcloud left its grants). Still a move: the + // yes it asks is what keeps a consumer's login while that consumer is held. + var kept, made []string + for _, id := range moved { + if who := consumersOf(id, before[id], after[id]); who != nil { + made = append(made, who...) + } else { + kept = append(kept, id) + } + } + var parts []string + if len(kept) > 0 { + parts = append(parts, "it changes where its data is kept: "+strings.Join(kept, ", ")) + } + if len(made) > 0 { + parts = append(parts, "consumers join or leave: "+strings.Join(made, ", ")) + } + words := strings.Join(parts, "; ") + if from.Data != to.Data { + words += fmt.Sprintf("; its data flag differs too (running %v, composed %v)", from.Data, to.Data) + } + return JudgedMovesData, words + } + if from.Data != to.Data { + return JudgedNeither, fmt.Sprintf("it recreates no container and moves no data; WARNING: its data flag differs "+ + "(running %v, composed %v) while no data item does, so one side's flag is wrong", from.Data, to.Data) + } + return JudgedNeither, "it recreates no container and moves no data" +} + +// ResourceChange is one resource that differs between two compositions of a module: its id, its kind, whether it is +// added, removed or changed, and for a file the mesh makes for a provision (received-, bound-, secret-) the consumers +// that join or leave, in words. +type ResourceChange struct { + ID string `json:"id"` + Kind string `json:"kind"` + Change string `json:"change"` + Consumers []string `json:"consumers,omitempty"` +} + +// ChangedResources is every resource added, removed or changed from running to composed, by id, each compared +// canonical (issue 774: the engine's verb composition shows what a move waiting for a yes would change). Running nil +// is every resource added. +func ChangedResources(running *Composed, composed Composed) []ResourceChange { + var from Composed + if running != nil { + from = *running + } + before, after := judgedResourcesByID(from), judgedResourcesByID(composed) + var out []ResourceChange + for _, id := range changedOf(before, after, func(string) bool { return true }) { + was, now := before[id], after[id] + c := ResourceChange{ID: id, Change: "changed"} + switch { + case was == nil: + c.Change, c.Kind = "added", now.kind + case now == nil: + c.Change, c.Kind = "removed", was.kind + default: + c.Kind = now.kind + } + c.Consumers = consumersOf(id, was, now) + out = append(out, c) + } + return out +} + +// consumersOf is, for a changed file the mesh makes for a provision (received-, bound- or secret-, catalogue.ReceivedID, +// BoundID, SecretID), what changes of it in words: each consumer that joins or leaves a received file, by the given +// list's from and node; else that what it is given for the provision changes. Nil for any other resource. +func consumersOf(id string, before, after *judgedResource) []string { + _, local, _ := strings.Cut(id, ".") + r := after + if r == nil { + r = before + } + if r == nil || r.kind != string(declaration.TypeFile) { + return nil + } + if provision, ok := strings.CutPrefix(local, "received-"); ok { + was, now := givenIn(before), givenIn(after) + var out []string + for _, c := range sortedGiven(was) { + if !now[c] { + out = append(out, c+" leaves "+provision) + } + } + for _, c := range sortedGiven(now) { + if !was[c] { + out = append(out, c+" joins "+provision) + } + } + if len(out) == 0 { + out = append(out, "what "+provision+"'s consumers are given changes") + } + return out + } + for _, prefix := range []string{"bound-", "secret-"} { + if provision, ok := strings.CutPrefix(local, prefix); ok { + return []string{"what it is given for " + provision + " changes"} + } + } + return nil +} + +// givenIn is the consumers a received file's content names, each as " on ". +func givenIn(r *judgedResource) map[string]bool { + out := map[string]bool{} + if r == nil { + return out + } + var file struct { + Content string `json:"content"` + } + var received struct { + Given []struct { + From string `json:"from"` + Node string `json:"node"` + } `json:"given"` + } + if json.Unmarshal(r.raw, &file) != nil || json.Unmarshal([]byte(file.Content), &received) != nil { + return out + } + for _, g := range received.Given { + out[g.From+" on "+g.Node] = true + } + return out +} + +func sortedGiven(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// judgedResource is a resource as the judgement reads it: its kind, the path it acts on, what its containers mount, +// and its canonical bytes. +type judgedResource struct { + kind string + path string + mounts []string + raw []byte +} + +func judgedResourcesByID(c Composed) map[string]*judgedResource { + out := map[string]*judgedResource{} + for _, raw := range c.Resources { + var head struct { + ID string `json:"id"` + Type string `json:"type"` + Path string `json:"path"` + // A container's volumes, `:[:]`. + Volumes []string `json:"volumes"` + } + if err := json.Unmarshal(raw, &head); err != nil { + continue + } + r := &judgedResource{kind: head.Type, path: head.Path, raw: compactResource(raw)} + for _, v := range head.Volumes { + source, _, _ := strings.Cut(v, ":") + r.mounts = append(r.mounts, source) + } + out[head.ID] = r + } + return out +} + +// compactResource is a resource's bytes canonical: its keys sorted, its spacing dropped, its numbers as written, so two +// compositions that say the same compare equal however each was written. +func compactResource(raw []byte) []byte { + var v any + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + if err := d.Decode(&v); err != nil { + return raw + } + out, err := json.Marshal(v) + if err != nil { + return raw + } + return out +} + +// changedOf are the ids of the resources of the kinds counted that are changed, added or removed, sorted. +func changedOf(before, after map[string]*judgedResource, counted func(kind string) bool) []string { + var out []string + for id, now := range after { + if !counted(now.kind) { + continue + } + if was, ok := before[id]; !ok || !bytes.Equal(was.raw, now.raw) { + out = append(out, id) + } + } + for id, was := range before { + if _, ok := after[id]; !ok && counted(was.kind) { + out = append(out, id) + } + } + sort.Strings(out) + return out +} + +// dataPlaces are the paths where the module keeps what a move must not lose: what its containers mount, and its +// directories, before and after. +func dataPlaces(before, after map[string]*judgedResource) []string { + var out []string + for _, rs := range []map[string]*judgedResource{before, after} { + for _, r := range rs { + if r.kind == string(declaration.TypeContainer) { + out = append(out, r.mounts...) + } + if r.kind == string(declaration.TypeDirectory) && r.path != "" { + out = append(out, r.path) + } + } + } + return out +} + +// withinPlaces says p is one of places, or lies within one, or holds one. +func withinPlaces(p string, places []string) bool { + if p == "" { + return false + } + p = path.Clean(p) + for _, place := range places { + if place == "" || !strings.HasPrefix(place, "/") { + continue + } + place = path.Clean(place) + if p == place || strings.HasPrefix(p, place+"/") || strings.HasPrefix(place, p+"/") { + return true + } + } + return false +} + +// SameComposedResources says two compositions of a module hold the same resources in the same order, each compared +// canonical. +func SameComposedResources(a, b Composed) bool { + if len(a.Resources) != len(b.Resources) { + return false + } + for i := range a.Resources { + if !bytes.Equal(compactResource(a.Resources[i]), compactResource(b.Resources[i])) { + return false + } + } + return true +} diff --git a/internal/judge/judge_test.go b/internal/judge/judge_test.go new file mode 100644 index 0000000..2a5b7e3 --- /dev/null +++ b/internal/judge/judge_test.go @@ -0,0 +1,96 @@ +package judge + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +func composedOf(module string, c byte, data bool, resources ...string) Composed { + out := Composed{Node: "ace", Module: module, FP: delivery.SourcePrefix2 + strings.Repeat(string(c), 64), Data: data} + for _, r := range resources { + out.Resources = append(out.Resources, json.RawMessage(r)) + } + return out +} + +// The pairs mesh-controller's composed_judge_test.go captured (issue 1945 AC12), and the bus's user list, judged as the +// controller judges them (ADR 0377 §3, ADR 0539 §7), by the node agent's function (ADR 0547 §3, issue 1126 S8). +func TestJudgeMove(t *testing.T) { + const ( + container = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:1111111111111111111111111111111111111111111111111111111111111111","volumes":["/var/lib/mesh-mail/spool:/spool"]}` + container2 = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:2222222222222222222222222222222222222222222222222222222222222222","volumes":["/var/lib/mesh-mail/spool:/spool"]}` + spool = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}` + spoolMoved = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/srv/mesh-mail/spool"}` + tools = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"a"}` + tools2 = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"b"}` + config = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"a"}` + config2 = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"b"}` + process = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/a"}` + process2 = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/b"}` + received = `{"id":"mesh-mail.received-smtp","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/received.json","content":"{\"given\":[{\"from\":\"cloud\",\"node\":\"novox\"},{\"from\":\"photos\",\"node\":\"novox\"}]}"}` + received2 = `{"id":"mesh-mail.received-smtp","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/received.json","content":"{\"given\":[{\"from\":\"photos\",\"node\":\"novox\"},{\"from\":\"wiki\",\"node\":\"ace\"}]}"}` + users = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"a"}` + users2 = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"b"}` + natsConf = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"a"}` + natsConf2 = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"b"}` + ) + mail := func(c byte, data bool, rs ...string) *Composed { + m := composedOf("mesh-mail", c, data, rs...) + return &m + } + bus := func(rs ...string) *Composed { m := composedOf("nats", 'b', false, rs...); return &m } + for _, c := range []struct { + name string + from, to *Composed + want Judgement + userList bool + words string // what the words must say, when given + }{ + {"a new image", mail('a', true, container, spool, tools), mail('b', true, container2, spool, tools), JudgedRecreates, false, ""}, + {"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false, ""}, + {"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false, ""}, + {"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false, "it changes where its data is kept: mesh-mail.conf"}, + // A received file the mesh makes, in its data's place, names its consumers joining or leaving, not its data moving + // (issue 774, 2026-10-31: postgres asked a yes for "where its data is kept" when nextcloud left its grants). + {"a consumer leaves", mail('a', true, container, spool, received), mail('b', true, container, spool, received2), JudgedMovesData, false, + "consumers join or leave: cloud on novox leaves smtp, wiki on ace joins smtp"}, + {"a consumer leaves and a file moves", mail('a', true, container, spool, received, config), mail('b', true, container, spool, received2, config2), JudgedMovesData, false, + "it changes where its data is kept: mesh-mail.conf; consumers join or leave: cloud on novox leaves smtp, wiki on ace joins smtp"}, + {"its data flag alone", mail('a', false, container, spool), mail('b', true, container, spool), JudgedNeither, false, ""}, + {"its data flag and its spool", mail('a', false, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false, ""}, + {"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false, ""}, + {"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false, ""}, + {"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false, ""}, + {"never ran", nil, mail('b', true, container), JudgedNeither, false, ""}, + {"equal, written otherwise", mail('a', true, `{"type":"directory","id":"mesh-mail.spool","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}`), mail('b', true, spool), JudgedUnchanged, false, ""}, + {"only the user list", bus(natsConf, users), bus(natsConf, users2), JudgedNeither, true, ""}, + {"the user list and more", bus(natsConf, users), bus(natsConf2, users2), JudgedNeither, false, ""}, + } { + got := JudgeMove(c.from, *c.to) + if got.Judgement != c.want || got.UserListOnly != c.userList || got.Words == "" { + t.Errorf("%s: %+v, want %s (user list only %v)", c.name, got, c.want, c.userList) + } + if c.words != "" && got.Words != c.words { + t.Errorf("%s: says %q, want %q", c.name, got.Words, c.words) + } + if strings.Contains(c.name, "data flag alone") && !strings.Contains(got.Words, "WARNING: its data flag differs") { + t.Errorf("%s: the flag's difference is not said: %q", c.name, got.Words) + } + } + // The resources that differ, as the engine's verb composition lists them (issue 774): a provider's received file + // names its consumers, a file changed and one added are said as such, and nothing equal is listed. + changes := ChangedResources(mail('a', true, container, spool, received, config), *mail('b', true, container, spool, received2, config2, tools)) + if want := `[{mesh-mail.conf file changed []} {mesh-mail.received-smtp file changed [cloud on novox leaves smtp wiki on ace joins smtp]} {mesh-mail.tools file added []}]`; fmt.Sprint(changes) != want { + t.Errorf("changed resources: %v, want %s", changes, want) + } + if got := ChangedResources(nil, *mail('b', true, spool)); len(got) != 1 || got[0].Change != "added" { + t.Errorf("never ran: %v, want its one resource added", got) + } + if got := JudgeMove(nil, Composed{Node: "ace"}); got.Judgement != JudgedNeither { + t.Errorf("the node's own composition: %+v", got) + } +} diff --git a/internal/nodegrant/nodegrant.go b/internal/nodegrant/nodegrant.go new file mode 100644 index 0000000..eb7c088 --- /dev/null +++ b/internal/nodegrant/nodegrant.go @@ -0,0 +1,48 @@ +// Package nodegrant is the grant the grants part composes for a node's engine (its principal of kind node), as a golden +// file the node-engine's tests run under (novox/hq ADR 0593 §1): the composer of the bus's user list is the grants +// part's own, so this repository holds what it composes for a node rather than calling it. testdata/node.json is that +// grant for the node NODE, captured from mesh-sdk's buscompose.PermissionsFor at 3d87ddc, with what this release adds +// to it (ADR 0593 §6, §7): the node's writes of its own composition state, and its reads of the indexer's candidates. +// When the grants part changes a node's grant, this file changes with it in the same group. +package nodegrant + +import ( + _ "embed" + "encoding/json" + "strings" +) + +//go:embed testdata/node.json +var golden []byte + +// Permissions is a node's grant: the subjects it may publish to, those refused though an allow covers them, those it +// may subscribe to, and whether it may answer a request once on its reply subject. +type Permissions struct { + Publish []string + PublishDeny []string + Subscribe []string + AllowResponses bool +} + +// For is the grant of node, with the served keys (`..` of mesh-config's resolved values) it may +// read of another node: each one read by its watch and by a direct get, as the grants part grants a served read. +func For(node string, servedReads ...string) Permissions { + var p Permissions + if err := json.Unmarshal(golden, &p); err != nil { + panic("nodegrant: the golden grant is not JSON: " + err.Error()) + } + swap := func(in []string) []string { + out := make([]string, len(in)) + for i, s := range in { + out[i] = strings.ReplaceAll(s, "NODE", node) + } + return out + } + p.Publish, p.PublishDeny, p.Subscribe = swap(p.Publish), swap(p.PublishDeny), swap(p.Subscribe) + for _, key := range servedReads { + p.Publish = append(p.Publish, + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved."+key, + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved."+key) + } + return p +} diff --git a/internal/nodegrant/testdata/node.json b/internal/nodegrant/testdata/node.json new file mode 100644 index 0000000..445ea31 --- /dev/null +++ b/internal/nodegrant/testdata/node.json @@ -0,0 +1,129 @@ +{ + "Publish": [ + "$JS.ACK.NODES.NODE.>", + "$JS.API.CONSUMER.CREATE.KV_composed-NODE.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.NODE.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.NODE.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.NODE.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.NODE.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.NODE", + "$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.NODE", + "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.NODE.>", + "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node", + "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.NODE.>", + "$JS.API.CONSUMER.CREATE.OBJ_declaration-files-NODE.*.$O.declaration-files-NODE.C.*", + "$JS.API.CONSUMER.DELETE.KV_composed-NODE.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-host_data.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-host_health.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.>", + "$JS.API.CONSUMER.DELETE.KV_node-engine_have.>", + "$JS.API.CONSUMER.DELETE.OBJ_declaration-files-NODE.*", + "$JS.API.CONSUMER.INFO.NODES.NODE", + "$JS.API.DIRECT.GET.KV_composed-NODE.>", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.NODE.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.NODE.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_candidates.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.>", + "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.NODE.>", + "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.NODE.>", + "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.NODE", + "$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.NODE", + "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.NODE.>", + "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node", + "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.NODE.>", + "$JS.API.DIRECT.GET.OBJ_declaration-files-NODE.$O.declaration-files-NODE.M.*", + "$JS.API.STREAM.INFO.KV_composed-NODE", + "$JS.API.STREAM.INFO.KV_mesh-config_resolved", + "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates", + "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement", + "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants", + "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_candidates", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules", + "$JS.API.STREAM.INFO.KV_mesh-host_acts", + "$JS.API.STREAM.INFO.KV_mesh-host_data", + "$JS.API.STREAM.INFO.KV_mesh-host_health", + "$JS.API.STREAM.INFO.KV_mesh-host_packages", + "$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions", + "$JS.API.STREAM.INFO.KV_node-engine_have", + "$JS.FC.KV_composed-NODE.>", + "$JS.FC.KV_mesh-config_resolved.>", + "$JS.FC.KV_mesh-delivery-assigner_candidates.>", + "$JS.FC.KV_mesh-delivery-assigner_placement.>", + "$JS.FC.KV_mesh-delivery-assigner_wants.>", + "$JS.FC.KV_mesh-delivery-builder_builds.>", + "$JS.FC.KV_mesh-delivery-indexer_manifests.>", + "$JS.FC.KV_mesh-delivery-indexer_modules.>", + "$JS.FC.KV_mesh-host_acts.>", + "$JS.FC.KV_mesh-host_data.>", + "$JS.FC.KV_mesh-host_health.>", + "$JS.FC.KV_mesh-host_packages.>", + "$JS.FC.KV_mesh-provisioner_provisions.>", + "$JS.FC.KV_node-engine_have.>", + "$JS.FC.OBJ_declaration-files-NODE.>", + "$KV.composed-NODE.>", + "$KV.mesh-host_acts.NODE.>", + "$KV.mesh-host_data.NODE.>", + "$KV.mesh-host_health.NODE", + "$KV.mesh-host_packages.NODE", + "$KV.node-engine_have.NODE.>", + "$SRV.INFO", + "$SRV.INFO.>", + "$SRV.PING", + "$SRV.PING.>", + "$SRV.PING.node-tools.NODE", + "mesh.control.NODE.>", + "mesh.seat.node-backup.call.backed-up.NODE.node~NODE", + "mesh.seat.node-backup.call.now.NODE.node~NODE", + "mesh.seat.node-backup.tool.backed-up.NODE", + "mesh.seat.node-backup.tool.now.NODE", + "mesh.seat.node-engine.event.*.NODE" + ], + "PublishDeny": null, + "Subscribe": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement", + "$SRV.INFO", + "$SRV.INFO.node-engine", + "$SRV.INFO.node-engine.>", + "$SRV.PING", + "$SRV.PING.node-engine", + "$SRV.PING.node-engine.>", + "$SRV.STATS", + "$SRV.STATS.node-engine", + "$SRV.STATS.node-engine.>", + "_DELIVER.NODE", + "_DELIVER.NODE.>", + "_INBOX.node.NODE.>", + "mesh.mod.restic.event.backed-up", + "mesh.node.NODE.ask.access-act", + "mesh.node.NODE.ask.hand-over", + "mesh.node.NODE.ask.judge-directories", + "mesh.node.NODE.ask.move-secret", + "mesh.node.NODE.ask.report", + "mesh.node.NODE.ask.setuid-search", + "mesh.node.NODE.declare", + "mesh.seat.node-engine.call.*.NODE.*", + "mesh.seat.node-engine.tool.*.NODE" + ], + "AllowResponses": true +} \ No newline at end of file diff --git a/internal/overlay/declaration.go b/internal/overlay/declaration.go new file mode 100644 index 0000000..b248bdd --- /dev/null +++ b/internal/overlay/declaration.go @@ -0,0 +1,187 @@ +package overlay + +import ( + "encoding/json" + "fmt" + "strings" +) + +// The overlay is delivered as an ordinary declaration. +// +// novox/hq 08-connectivity: what the host receives is an interface configuration and a peer list, +// as files. It does not compute them and it does not know what a private network is — the shapes +// it already has are enough, which is why connectivity needs nothing new from tier 0. +// +// **No private key travels.** The configuration points at a file the node wrote from a key the +// mesh has never seen, using WireGuard's own ability to set one after the interface is up. So the +// control plane composes a complete configuration for a node it cannot impersonate. + +// Resource is one entry in a declaration, built here and read by the host. +type Resource map[string]any + +// Declaration is what the mesh sends a node to put it on the network. +// +// Three resources and nothing clever: the tools, the configuration, and the interface running. A +// person can read it, which is the point — this is the first thing a node is ever told, and if it +// is wrong the node is unreachable and the mistake has to be findable by eye. +func Declaration(node Node, peers []Peer, keyPath string) ([]byte, error) { + if node.Address == "" { + return nil, fmt.Errorf("%s has no address on the overlay, so there is nothing to configure", + node.Name) + } + if keyPath == "" { + keyPath = DefaultKeyPath + } + + up := Resource{ + "id": "overlay-up", "type": "service", "unit": Unit, + "state": "running", + // Enabled, so the node comes back onto the network after a reboot without waiting to + // be told again. A node whose overlay only exists while something is watching is not + // a node that survives being switched off and on. + "boot": "enabled", + // And restarted when the peer list changes, because a running interface does not + // re-read its configuration. + // + // This is the whole of it: a node joins, every existing node's peer list changes, + // each file is replaced — and without this the service is already running, nothing + // reloads it, and every node keeps a network that no longer matches the mesh. It + // reports complete success. The lab found it the moment a third node arrived. + // + // Declared state rather than a command. The service must reflect the file; the host + // works out that it does not. A command to restart would be an action, and the link + // may not carry one (novox/hq ADR 0005) — the host refused exactly that, correctly, + // which is how this shape was arrived at. + "restart-on": []string{"overlay-config"}, + } + if node.TakesOver != nil { + // The private network takes over the tunnel it found (novox/hq ADR 0105): before this + // unit starts, the host stops and disables the found one — never flushing it — and keeps + // its configuration like any held file. The key is already the found one: the node took + // it as its own overlay key when it enrolled, which is why the mesh's peer list for it + // carries the found peers under the key they know. + up["takes-over"] = map[string]any{ + "interface": node.TakesOver.Interface, + "unit": node.TakesOver.Unit, + "config": node.TakesOver.Config, + } + } + + resources := []Resource{ + { + "id": "overlay-tools", "type": "package", "package": "wireguard-tools", + }, + { + "id": "overlay-config", "type": "file", "path": ConfigPath, + // Readable only by root: it lists every peer's key and endpoint, which is a map of + // the mesh. Not secret in the way a private key is, and not something to leave + // world-readable on a machine somebody else also uses. + "mode": "0600", + "content": config(node, peers, keyPath), + }, + up, + } + + // The names used to be appended here, on the argument that a node with peers and no names is + // half on the network. True, and the wrong place to fix it: names would be identical over a + // different private network, so bundling them with WireGuard made one module out of two + // things. They are their own module now, requiring this one — which is what keeps them + // arriving together without pretending they are the same concern. + return json.Marshal(map[string]any{"declaration": 1, "resources": resources}) +} + +// config writes the interface file. +// +// Deliberately in the order a person would read it: who I am, then who I talk to, each with a +// line saying why it is there. A generated file that cannot be understood by the person it +// confuses is a generated file that gets edited by hand. +func config(node Node, peers []Peer, keyPath string) string { + var b strings.Builder + b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n") + b.WriteString("# peer graph changes, and an edit would survive until the next change and\n") + b.WriteString("# then vanish, which is worse than not being applied at all.\n") + fmt.Fprintf(&b, "#\n# node %s", node.Name) + if node.Site != "" { + fmt.Fprintf(&b, ", at %s", node.Site) + } + if !node.Reachable() { + b.WriteString(", not dialable — it opens every path itself") + } + b.WriteString("\n\n[Interface]\n") + fmt.Fprintf(&b, "Address = %s/32\n", node.Address) + if node.Reachable() { + if port := portOf(node.Endpoint); port != "" { + fmt.Fprintf(&b, "ListenPort = %s\n", port) + } + } else if node.TakesOver != nil && node.TakesOver.Port != 0 { + // Not dialable from the hub, but a LAN peer dials this node on the tunnel it took over, + // so the mesh's interface must listen on that same port (novox/hq: a taken tunnel brings + // its port). Without this the takeover guard refuses overlay-up, and re-placing the node + // with an endpoint — the guard's suggested remedy — breaks a NAT'd node's path. + fmt.Fprintf(&b, "ListenPort = %d\n", node.TakesOver.Port) + } + // The MTU the found tunnel carried, when it set one: a path tuned to 1380 (say) stalls TLS + // and hangs transfers if the mesh's interface comes up at the 1420 default, and no ping shows + // it (novox/hq: a taken tunnel carries its MTU). + if node.TakesOver != nil && node.TakesOver.MTU != 0 { + fmt.Fprintf(&b, "MTU = %d\n", node.TakesOver.MTU) + } + // The private key is set from a file the node wrote, so it never appears here and never + // travelled. Everything else in this file came from the mesh; this one line is the node's. + fmt.Fprintf(&b, "PostUp = wg set %%i private-key %s\n", keyPath) + + if node.Hub { + // A hub carries traffic *between* its spokes, and a Linux machine does not forward + // packets unless it is told to. Without this every spoke reaches the hub perfectly and + // no spoke reaches any other — which is exactly how it failed in the lab, and it looks + // like a peering problem rather than a kernel setting. + // + // Here rather than in a separate resource because it is part of what being a hub means, + // and because it should last exactly as long as the interface does: a machine that stops + // being the hub should stop forwarding, and PostDown below is how that happens. + b.WriteString("PostUp = sysctl -q -w net.ipv4.ip_forward=1\n") + b.WriteString("PostDown = sysctl -q -w net.ipv4.ip_forward=0\n") + + // And past the machine's own firewall, which on any node with a container runtime is + // closed. Docker sets the FORWARD policy to DROP and inserts its chains, so the foundation + // this mesh installs at tier 1 silently breaks the network it builds at tier 2: every + // spoke reaches the hub, no spoke reaches any other, and every part of it reports + // success. Found in the lab; nothing about it is visible from the mesh's own state. + // + // Inserted at the top so it precedes those chains, and removed on the way down so a + // machine that stops being the hub stops carrying other people's traffic. Guarded on + // iptables existing: a machine without it has no policy to get past. + for _, direction := range []string{"-i", "-o"} { + fmt.Fprintf(&b, + "PostUp = command -v iptables >/dev/null && iptables -I FORWARD 1 %s %%i -j ACCEPT || true\n", + direction) + } + for _, direction := range []string{"-i", "-o"} { + fmt.Fprintf(&b, + "PostDown = command -v iptables >/dev/null && iptables -D FORWARD %s %%i -j ACCEPT || true\n", + direction) + } + } + + for _, p := range peers { + fmt.Fprintf(&b, "\n# %s — %s\n[Peer]\n", p.Name, p.Why) + fmt.Fprintf(&b, "PublicKey = %s\n", p.Key) + fmt.Fprintf(&b, "AllowedIPs = %s\n", p.Allowed) + if p.Endpoint != "" { + fmt.Fprintf(&b, "Endpoint = %s\n", p.Endpoint) + } else { + b.WriteString("# no endpoint: this peer cannot be dialled and opens the path itself\n") + } + if p.Keepalive { + b.WriteString("PersistentKeepalive = 25\n") + } + } + return b.String() +} + +func portOf(endpoint string) string { + if i := strings.LastIndex(endpoint, ":"); i >= 0 { + return endpoint[i+1:] + } + return "" +} diff --git a/internal/overlay/declaration_test.go b/internal/overlay/declaration_test.go new file mode 100644 index 0000000..64fabd9 --- /dev/null +++ b/internal/overlay/declaration_test.go @@ -0,0 +1,311 @@ +package overlay + +import ( + "encoding/json" + "fmt" + "strings" + "testing" +) + +func declarationFor(t *testing.T, node Node, peers []Peer) (string, []map[string]any) { + t.Helper() + raw, err := Declaration(node, peers, "") + if err != nil { + t.Fatal(err) + } + var d struct { + Declaration int `json:"declaration"` + Resources []map[string]any `json:"resources"` + } + if err := json.Unmarshal(raw, &d); err != nil { + t.Fatal(err) + } + if d.Declaration != 1 { + t.Fatalf("declaration version %d", d.Declaration) + } + for _, r := range d.Resources { + if r["type"] == "file" { + return r["content"].(string), d.Resources + } + } + t.Fatal("the declaration has no configuration file in it") + return "", nil +} + +func TestNoPrivateKeyEverTravels(t *testing.T) { + // The property the whole design rests on: the node generated its keypair and kept the private + // half, so the mesh composes a configuration for a node it cannot impersonate. A private key + // appearing here would mean the control plane had one — and a copy of its database would then + // be every node's network identity. + config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, + []Peer{{Name: "anchor", Key: "HUB", Allowed: "10.42.0.0/16", Endpoint: "198.51.100.1:51820"}}) + + if strings.Contains(config, "PrivateKey") { + t.Error("the configuration carries a PrivateKey line; the mesh must never hold one") + } + if !strings.Contains(config, "private-key "+DefaultKeyPath) { + t.Error("the configuration does not point at the key file the node wrote, so the " + + "interface would come up with no key at all") + } +} + +func TestTheDeclarationUsesOnlyShapesTheHostAlreadyHas(t *testing.T) { + // Connectivity needs nothing new from tier 0, and that is worth holding: the host does not + // know what a private network is, and should not learn. + _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) + + allowed := map[string]bool{"package": true, "file": true, "service": true, + "directory": true, "container": true, "action": true} + for _, r := range resources { + if !allowed[r["type"].(string)] { + t.Errorf("the overlay declaration uses %q, which the host does not have", r["type"]) + } + } +} + +func TestTheInterfaceComesBackAfterAReboot(t *testing.T) { + // A node whose overlay only exists while something is watching is not a node that survives + // being switched off and on — and it would come back unreachable, which is the worst way to + // come back. + _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) + for _, r := range resources { + if r["type"] == "service" { + if r["boot"] != "enabled" { + t.Error("the overlay interface is not enabled at boot") + } + if r["state"] != "running" { + t.Error("the overlay interface is not asked to be running") + } + return + } + } + t.Error("nothing in the declaration brings the interface up") +} + +func TestTheConfigurationIsNotWorldReadable(t *testing.T) { + // It lists every peer's key and endpoint, which is a map of the mesh. Not secret the way a + // private key is, and not something to leave readable on a machine somebody else also uses. + // The peer list specifically, not every file. `/etc/hosts` is in here too and must be + // world-readable, or nothing on the machine resolves anything — a check that swept all files + // would force it to 0600 and break the machine to protect a file that is not secret. + _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) + for _, r := range resources { + if r["id"] == "overlay-config" && r["mode"] != "0600" { + t.Errorf("the peer list is mode %v", r["mode"]) + } + if r["id"] == "mesh-names" && r["mode"] != "0644" { + t.Errorf("the name file is mode %v; nothing on the machine could read it", r["mode"]) + } + } +} + +func TestAPeerThatCannotBeDialledSaysSo(t *testing.T) { + // A [Peer] with no Endpoint is correct and looks like a mistake. Saying why stops somebody + // helpfully adding one that cannot work. + config, _ := declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1", + Endpoint: "198.51.100.1:51820", Hub: true}, + []Peer{{Name: "laptop", Key: "PUB", Allowed: "10.42.0.2/32", Why: "routes through this hub"}}) + + if strings.Contains(config, "Endpoint =") { + t.Error("an endpoint was written for a peer that has none") + } + if !strings.Contains(config, "cannot be dialled") { + t.Error("the file does not say why that peer has no endpoint") + } +} + +func TestTheFileSaysNotToEditIt(t *testing.T) { + // It is replaced whenever the graph changes. An edit survives until then and vanishes, which + // is worse than never being applied — the machine works, then stops, and nothing changed + // that anybody remembers. + config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) + if !strings.Contains(config, "Do not edit") { + t.Error("a generated file does not say it is generated") + } +} + +func TestANodeWithNoAddressIsRefused(t *testing.T) { + // Rather than a configuration with a blank address, which wg-quick would reject on the + // machine, at boot, where the failure is much harder to see. + if _, err := Declaration(Node{Name: "laptop", Key: "PUB"}, nil, ""); err == nil { + t.Fatal("a node with no overlay address was given a configuration") + } +} + +func TestOnlyAReachableNodeListens(t *testing.T) { + // A ListenPort on a node nothing can dial is a port open for no reason. + config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) + if strings.Contains(config, "ListenPort") { + t.Error("a node that cannot be dialled was told to listen") + } + config, _ = declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1", + Endpoint: "198.51.100.1:51820"}, nil) + if !strings.Contains(config, "ListenPort = 51820") { + t.Error("a reachable node does not listen on the port its endpoint names") + } +} + +func TestTheServiceIsRestartedWhenThePeerListChanges(t *testing.T) { + // The fault this exists to catch, found in the lab the moment a third node arrived: a running + // WireGuard interface does not re-read its configuration. The file was replaced, the service + // was already running so nothing reloaded it, and every existing node kept a network that no + // longer matched the mesh — while looking entirely successful. + // + // So the declaration has to verify what the interface is *carrying*, not what the file says. + // + // And it must be declared state rather than a command: the host refuses an action arriving + // over the link (novox/hq ADR 0005), correctly, which is how this shape was arrived at. There + // is a test below that no action is ever in here. + _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, + []Peer{{Name: "anchor", Key: "HUBKEY", Allowed: "10.42.0.0/16"}}) + + for _, r := range resources { + if r["type"] != "service" { + continue + } + reflects := fmt.Sprint(r["restart-on"]) + if !strings.Contains(reflects, "overlay-config") { + t.Errorf("the interface does not restart when its configuration changes: %v", reflects) + } + return + } + t.Error("nothing in the declaration brings the interface up") +} + +func TestTheOverlayDeclarationCarriesNoAction(t *testing.T) { + // The link may not carry an action, and the host refuses a declaration containing one — whole, + // not in part. An overlay declaration with an action in it does not half-apply: it leaves the + // node with no network at all. + _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) + for _, r := range resources { + if r["type"] == "action" { + t.Errorf("the declaration contains an action (%v); the host will refuse the whole "+ + "thing and the node will have no network", r["id"]) + } + } +} + +func TestTheHubForwardsAndNobodyElseDoes(t *testing.T) { + // A hub carries traffic between its spokes, and Linux does not forward packets unless told + // to. Without it every spoke reaches the hub perfectly and no spoke reaches any other — which + // is how it failed in the lab, and it presents as a peering problem rather than a kernel + // setting, so it is worth being sure of. + hub, _ := declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1", + Endpoint: "198.51.100.1:51820", Hub: true}, nil) + if !strings.Contains(hub, "ip_forward=1") { + t.Error("the hub does not enable forwarding, so its spokes cannot reach each other") + } + if !strings.Contains(hub, "ip_forward=0") { + t.Error("the hub never stops forwarding; a machine that stops being the hub would keep " + + "passing traffic it is no longer part of") + } + + // And past the machine's own firewall. Any node with a container runtime has FORWARD set to + // DROP by Docker, so enabling ip_forward alone changes nothing — which is exactly how it + // failed, with every spoke reaching the hub and no spoke reaching any other. + // Checked as PostUp specifically. "contains FORWARD" passes on the PostDown line alone, + // which would leave a hub that tears down rules it never put up. + if !strings.Contains(hub, "PostUp = command -v iptables") { + t.Error("the hub does not open its own firewall, so a container runtime's DROP policy " + + "silently eats everything it was supposed to carry") + } + if !strings.Contains(hub, "PostDown = command -v iptables") { + t.Error("the hub never removes those rules, so a machine that stops being the hub keeps " + + "passing traffic it is no longer part of") + } + + spoke, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) + if strings.Contains(spoke, "FORWARD") { + t.Error("a spoke was given forwarding rules it has no use for") + } + if strings.Contains(spoke, "ip_forward") { + t.Error("a spoke was told to forward packets, which is not its job and widens what a " + + "compromised one could do") + } +} + +// novox/hq ADR 0105: a node whose private network takes over the tunnel it found is told so on +// the interface's service, and nothing else about the declaration changes — the key is already +// the found one, taken at enrolment. +func TestTakingOverAFoundTunnelIsSaidOnTheInterfacesService(t *testing.T) { + node := Node{Name: "anchor", Key: "PUB", Address: "192.0.2.1", Hub: true, + Endpoint: "198.51.100.1:51900", + TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf"}} + config, resources := declarationFor(t, node, nil) + + var up map[string]any + for _, r := range resources { + if r["type"] == "service" { + up = r + } + } + takes, ok := up["takes-over"].(map[string]any) + if !ok { + t.Fatalf("the interface's service does not say what it takes over: %+v", up) + } + if takes["unit"] != "wg-quick@wg0" || takes["config"] != "/etc/wireguard/wg0.conf" || takes["interface"] != "wg0" { + t.Errorf("the takeover names the wrong tunnel: %+v", takes) + } + if !strings.Contains(config, "ListenPort = 51900") { + t.Errorf("the hub's interface does not listen on the tunnel's port:\n%s", config) + } + if strings.Contains(config, "PrivateKey") { + t.Error("the found key travelled in the configuration; it is the node's own, set from its key file") + } + + _, plain := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "192.0.2.4"}, nil) + for _, r := range plain { + if _, says := r["takes-over"]; says { + t.Error("a node taking over nothing was told to take something over") + } + } +} + +func TestATakenTunnelBringsItsListenPortEvenWhenNotDialable(t *testing.T) { + // A home node behind NAT (no Endpoint, so not Reachable) that took over a tunnel must still + // listen on that tunnel's port, because its LAN peers dial it there (novox/hq: a taken tunnel + // brings its port). Without this the takeover guard refuses overlay-up. + config, _ := declarationFor(t, Node{ + Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", + TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Port: 51820}, + }, nil) + + if !strings.Contains(config, "ListenPort = 51820") { + t.Fatalf("a taken tunnel's port must be the mesh interface's ListenPort:\n%s", config) + } + if strings.Contains(config, "Endpoint =") { + t.Error("a node that only listens for LAN peers must not advertise an endpoint") + } +} + +func TestANodeWithNoTunnelAndNoEndpointStillListensOnNothing(t *testing.T) { + // The guard against over-emitting: a plain spoke with neither an endpoint nor a taken tunnel + // writes no ListenPort — it purely dials out. + config, _ := declarationFor(t, Node{Name: "laptop", Key: "K", Address: "10.10.0.9"}, nil) + if strings.Contains(config, "ListenPort") { + t.Fatalf("a dial-only node needs no ListenPort:\n%s", config) + } +} + +func TestATakenTunnelCarriesItsMTU(t *testing.T) { + // A path tuned to a smaller MTU (1380 here) must survive the takeover — the mesh interface + // comes up with the same MTU, or transfers hang silently (novox/hq: a taken tunnel carries + // its MTU). + config, _ := declarationFor(t, Node{ + Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", + TakesOver: &TakeOver{Interface: "wg0", Port: 51820, MTU: 1380}, + }, nil) + if !strings.Contains(config, "MTU = 1380") { + t.Fatalf("the tuned MTU was dropped:\n%s", config) + } +} + +func TestNoMTULineWhenTheTunnelSetNone(t *testing.T) { + config, _ := declarationFor(t, Node{ + Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", + TakesOver: &TakeOver{Interface: "wg0", Port: 51820}, + }, nil) + if strings.Contains(config, "MTU") { + t.Fatalf("no MTU was found, so none should be written:\n%s", config) + } +} diff --git a/internal/overlay/generator.go b/internal/overlay/generator.go new file mode 100644 index 0000000..eb56a43 --- /dev/null +++ b/internal/overlay/generator.go @@ -0,0 +1,151 @@ +package overlay + +import ( + "encoding/json" + "fmt" + "strconv" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The private network as a module rather than as code beside the module system. +// +// A machine's peer list is derived from every other machine, so it differs on each one and +// changes when any of them changes — there is nothing that could be written in a manifest. So the +// module says its resources are computed, and this computes them. +// +// What that buys, beyond one mechanism instead of two: **a machine is on the private network +// because it was assigned the module.** Before this, every machine with a key and an address was +// on it, and there was no way to say a machine should stay off. +// +// It is worth saying what is *not* here, because networking looks like it should be foundational. +// The host needs none of this. It has an address and a route to the broker before the mesh exists +// — that is the machine's own networking — and the broker's address is carried in the enrolment +// token rather than resolved. So the private network is something the mesh installs on top, like +// anything else, and a node without it is an ordinary node that nothing reaches directly. + +// **A resolver's data went the same way as the names.** Two constants used to sit here — a +// `mesh-resolver` module that would write the machines as wildcards, and a `resolver-data` +// requirement a daemon would ask for. Nothing ever provided or consumed either: a module that +// answers names asks for the `node-zones` fact in its own manifest (catalogue.FactsInto) and +// requires Addressing, since the file is made of the mesh's addresses and means nothing off the +// network. A requirement nothing provides is refused at resolution, so leaving the names here +// would only have documented a mechanism that does not exist. + +// **There is no bundle any more** (novox/hq ADR 0226). A `networking` module requiring this one and +// nothing else used to be what a machine was assigned, so that "get the network working" was one +// word and a second VPN could be chosen by assigning it instead. The mesh has one private network, +// every machine is on it, and the bundle was a second name for this module that every machine +// carried. A machine is assigned Name directly; another VPN is still chosen by assigning it in its +// place, which is all the bundle ever did. + +// Generator answers what one node's network configuration is. +type Generator struct { + nodes []Node + graph Graph + // keyPath is where each node keeps the private half it generated. Named rather than carried: + // the mesh has never seen it and never will. + keyPath string + // No registry. Being on the network is still what grants a machine the right to pull from the + // mesh's artifact store in the clear (novox/hq ADR 0082), but the runtime's file is the runtime's + // module's: the private network writes nothing into it, and that module states the registry + // itself, told where the store is reached by ${seat:mesh-artifact-store:reach} (novox/hq ADR + // 0222, issue 190). +} + +// From builds a generator over the machines that are part of the network. +// +// The nodes given are the ones assigned the module — not every node the mesh knows. A machine +// that was never given it is absent from everybody's peer list and from the names, which is what +// "not on the network" has to mean. +func From(nodes []Node, cidr, keyPath string) (*Generator, error) { + graph, err := Compute(nodes, cidr) + if err != nil { + return nil, err + } + return &Generator{nodes: nodes, graph: graph, keyPath: keyPath}, nil +} + +// Resources is one node's interface, peers and names. +func (g *Generator) Resources(node string) ([]map[string]any, bool, error) { + peers, part := g.graph[node] + if !part { + // Assigned and not yet placed on the network. Ordinary and brief, so it is an answer + // rather than an error. + return nil, false, nil + } + var self Node + for _, n := range g.nodes { + if n.Name == node { + self = n + } + } + + raw, err := Declaration(self, peers, g.keyPath) + if err != nil { + return nil, false, err + } + var parsed struct { + Resources []map[string]any `json:"resources"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, false, err + } + return parsed.Resources, true, nil +} + +// Nodes are the machines this generator was built over, so a caller can say who is on the network. +func (g *Generator) Nodes() []Node { return g.nodes } + +// Graph is the peer list per node, for showing. +func (g *Generator) Graph() Graph { return g.graph } + +// Empty is a network nobody is on. +// +// A mesh where no machine was given the module. Legitimate rather than broken — every node still +// reaches the broker, which is what being in the mesh is — so it answers "not part of this" for +// everyone instead of refusing for want of a hub. +func Empty() *Generator { return &Generator{graph: Graph{}} } + +// Listens is the port this node accepts the private network on, which only a hub has. +// +// **A fact about this machine's place in the mesh, not about the module.** Every machine on the +// network runs the same module; a hub is dialled by every node at other sites and needs its port +// open, and a machine that is not a hub dials out and needs nothing open at all. A static field in +// a manifest is one answer for every machine that runs it, so it cannot say this — and the machine +// it would get wrong is the one facing the public internet, which is the machine that most needs +// filtering. +// +// The port is the one in the endpoint, which is also where the interface takes its ListenPort +// from. One source, so a rule set cannot open a port the interface is not on. +func (g *Generator) Listens(node string) ([]catalogue.Listening, error) { + for _, n := range g.nodes { + if n.Name != node { + continue + } + if !n.Reachable() { + // It dials out and nothing dials it. Opening a port here would be opening one on a + // machine nothing connects to, which is not harmless — it is a rule with no source + // that somebody later has to work out the reason for. + return nil, nil + } + port := portOf(n.Endpoint) + if port == "" { + return nil, fmt.Errorf( + "%s is reachable at %q and no port can be read from it, so what it must accept "+ + "the private network on is unknown", node, n.Endpoint) + } + number, err := strconv.Atoi(port) + if err != nil { + return nil, fmt.Errorf("%s is reachable at %q, and %q is not a port", node, n.Endpoint, port) + } + return []catalogue.Listening{{ + Port: number, Protocol: "udp", From: catalogue.FromEverywhere, + // From everywhere, and deliberately: a node at another site is not on the private + // network until this port lets it on, so restricting this to the mesh would be a + // rule that can never be satisfied by the thing it exists for. + Why: "the private network — a node at another site has no other way in", + }}, nil + } + return nil, nil +} diff --git a/internal/overlay/generator_test.go b/internal/overlay/generator_test.go new file mode 100644 index 0000000..569322b --- /dev/null +++ b/internal/overlay/generator_test.go @@ -0,0 +1,30 @@ +package overlay + +import ( + "testing" +) + +// novox/hq ADR 0222, issue 190: the runtime's file and service are the runtime's module's. The +// private network writes nothing into either — being on it still grants the right to pull from the +// mesh's store in the clear (ADR 0082), and the runtime's module states that trust itself. +func TestTheNetworkWritesNothingOfTheRuntimes(t *testing.T) { + nodes := []Node{ + {Name: "anchor", Site: "lab", Hub: true, Endpoint: "192.0.2.10:51820", Key: "k1", Address: "10.42.0.1"}, + {Name: "node2", Site: "lab", Key: "k2", Address: "10.42.0.2"}, + } + g, err := From(nodes, "10.42.0.0/16", "") + if err != nil { + t.Fatal(err) + } + for _, node := range []string{"anchor", "node2"} { + resources, part, err := g.Resources(node) + if err != nil || !part { + t.Fatalf("%s: resources: %v part=%v", node, err, part) + } + for _, r := range resources { + if r["path"] == "/etc/docker/daemon.json" || r["unit"] == "docker.service" { + t.Errorf("%s: the private network declares the runtime's %v", node, r) + } + } + } +} diff --git a/internal/overlay/graph.go b/internal/overlay/graph.go new file mode 100644 index 0000000..4be1a43 --- /dev/null +++ b/internal/overlay/graph.go @@ -0,0 +1,231 @@ +// Package overlay computes the private network every node runs on. +// +// novox/hq 08-connectivity. This is control-plane work by definition: a peer list is derived from +// every node at once, and no node has that. A node computes nothing about the mesh — it generates +// a keypair, publishes the public half, and receives the rest. +// +// The shape is a hub, with direct peering between nodes at the same site. Not a full mesh, and +// the reason is a property of WireGuard rather than a preference: there is no failover. A more +// specific route to a dead endpoint blackholes; it does not fall back to the general one. So a +// node gets exactly one path to any peer, because two would mean one of them silently swallowing +// traffic. +package overlay + +import ( + "errors" + "fmt" + "sort" + "strings" +) + +// Node is one machine's place on the network, as the mesh holds it. +type Node struct { + Name string + Key string + Endpoint string + Site string + Hub bool + Address string + + // Carried are the peers of the tunnel this node took over (novox/hq ADR 0105): machines the + // mesh has no record of, each known by the public key and the address the found tunnel routed + // to it. Only a hub has any. They stay in its peer list until a node enrols with that key — + // from then on the node is the peer. + Carried []Carried + // TakesOver names the found tunnel this node's private network replaces: its unit is stopped + // and disabled, never flushed, and its configuration kept, before the mesh's interface comes + // up with the found key. Nil on a node that raises the mesh's interface beside whatever it has. + TakesOver *TakeOver +} + +// Carried is one peer of an adopted tunnel that has not enrolled: a peer of the tunnel, not a +// node of the mesh. +type Carried struct { + Key string + Address string +} + +// TakeOver is the found tunnel a node's private network takes over, as the host is told it. +type TakeOver struct { + Interface string + Unit string + Config string + // MTU is the found tunnel's, when it set one — emitted so a tuned path keeps its MTU. + MTU int + // Port is the port the found tunnel listened on. The mesh's interface must listen on it too, + // even on a node that is not dialable from the hub: a home node's LAN peers dial it there + // (novox/hq: a taken tunnel brings its port). Listening is "a peer dials me here"; it is not + // "the hub can dial me", which is Reachable — the two were conflated. + Port int +} + +// HostPrefix is one address as a route: /32 for IPv4, /128 for IPv6. +func HostPrefix(address string) string { + if strings.Contains(address, ":") { + return address + "/128" + } + return address + "/32" +} + +// CarriedName is how a carried peer is named in a peer list: it has no node name, so it is named +// by the key its packets arrive under. +func CarriedName(key string) string { + short := key + if len(short) > 8 { + short = short[:8] + "…" + } + return "a peer of the tunnel (" + short + ")" +} + +// Reachable reports whether other nodes can dial this one. Declared, never inferred. +func (n Node) Reachable() bool { return strings.TrimSpace(n.Endpoint) != "" } + +// Peer is one entry in a node's peer list. +type Peer struct { + Name string + Key string + // Endpoint is empty when this peer cannot be dialled — it must dial us instead. + Endpoint string + // Allowed is what traffic goes down this tunnel. A single address for a direct peer; the + // whole overlay for the hub, which is what makes it the route of last resort. + Allowed string + // Keepalive matters only on the side behind NAT: a node that cannot be dialled has to keep + // the path open from its end, or the peer's first packet arrives at a mapping that has + // already expired. + Keepalive bool + // Why this peer is in the list, for a person reading a generated file and wondering. + Why string +} + +// Graph is every node's peer list. +type Graph map[string][]Peer + +// ErrNoHub means nobody has said which node is the hub. +// +// Its own error rather than an empty graph: a mesh with no hub has no path between sites, and +// answering with "no peers" would look like a working mesh where nothing can reach anything. +var ErrNoHub = errors.New("this mesh has no hub, so there is no path between sites") + +// Compute derives every node's peer list. +// +// Nodes without a key or an address are skipped rather than refused: a node that has enrolled and +// not yet been given a place on the network is an ordinary in-between state, and failing the whole +// graph because one node is half-configured would mean no node gets a network. +func Compute(nodes []Node, overlayCIDR string) (Graph, error) { + var hub *Node + usable := make([]Node, 0, len(nodes)) + for i := range nodes { + n := nodes[i] + if n.Key == "" || n.Address == "" { + continue + } + usable = append(usable, n) + if n.Hub { + hub = &usable[len(usable)-1] + } + } + if len(usable) == 0 { + return Graph{}, nil + } + if hub == nil { + return nil, ErrNoHub + } + if !hub.Reachable() { + return nil, fmt.Errorf( + "%s is the hub and has no endpoint, so nothing can dial it. The hub is the one node "+ + "that must be reachable from wherever the others are", hub.Name) + } + + graph := Graph{} + for _, self := range usable { + var peers []Peer + + // A node may share a site with the hub, and then the hub is one peer rather than two. + // Written before the loop because it changes what that loop may emit: WireGuard takes one + // entry per public key, so a hub appearing twice is a configuration it refuses — and the + // mesh would have produced it silently. The lab found this on the first two machines that + // shared a site with their hub. + hubIsHere := !self.Hub && self.Site != "" && self.Site == hub.Site + + for _, other := range usable { + if other.Name == self.Name || (hubIsHere && other.Name == hub.Name) { + continue + } + // Two nodes at the same site peer directly — but only if one of them can be + // dialled. If neither can, nobody opens the path, and the direct route is more + // specific than the hub's, so it wins and blackholes. That is this design's own + // stated hazard arriving in it: *a more specific route to a dead endpoint + // blackholes; it does not fall back to the general one.* + // + // Found in the lab with two machines at one site behind no reachable address, which + // is the ordinary shape of a home: they were given each other as peers, neither + // could start, and they could not reach each other at all while both reached the hub + // perfectly. + if self.Site != "" && self.Site == other.Site && (self.Reachable() || other.Reachable()) { + peers = append(peers, Peer{ + Name: other.Name, Key: other.Key, + Endpoint: other.Endpoint, + Allowed: other.Address + "/32", + Keepalive: !self.Reachable(), + Why: "at the same site", + }) + } + } + + if !self.Hub { + // Everything else goes through the hub, including a node that roams. AllowedIPs is + // the whole overlay, so this is the route of last resort — and because direct peers + // above are single addresses, they win on specificity without either being ambiguous. + why := "the hub — everything not at this site" + if hubIsHere { + // One entry doing both jobs: the direct path to a machine that happens to be + // here, and the route to everywhere else. Splitting them would need two entries + // for one key, which is the thing being avoided. + why = "the hub, which is also at this site — everything goes here" + } + peers = append(peers, Peer{ + Name: hub.Name, Key: hub.Key, + Endpoint: hub.Endpoint, + Allowed: overlayCIDR, + Keepalive: !self.Reachable(), + Why: why, + }) + } else { + // The hub holds every node that does not share a site with it, because those nodes + // route through it and it must know where to send the replies. Ones it cannot dial + // will dial it. + enrolled := map[string]bool{} + for _, other := range usable { + enrolled[other.Key] = true + if other.Name == self.Name || (self.Site != "" && self.Site == other.Site) { + continue + } + peers = append(peers, Peer{ + Name: other.Name, Key: other.Key, + Endpoint: other.Endpoint, + Allowed: other.Address + "/32", + Why: "routes through this hub", + }) + } + // And every peer of the tunnel it took over that has not enrolled (novox/hq ADR + // 0105): the same key and the same address the found tunnel had for it, so the + // machine behind it cannot tell the tunnel changed hands. No endpoint — it dials in, + // as it always did. Once a node enrols with that key, the node's entry above is the + // peer, and WireGuard takes one entry per key. + for _, c := range self.Carried { + if enrolled[c.Key] { + continue + } + peers = append(peers, Peer{ + Name: CarriedName(c.Key), Key: c.Key, + Allowed: HostPrefix(c.Address), + Why: "carried from the tunnel this hub took over — a peer of the tunnel, not yet a node of the mesh", + }) + } + } + + sort.Slice(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name }) + graph[self.Name] = peers + } + return graph, nil +} diff --git a/internal/overlay/graph_test.go b/internal/overlay/graph_test.go new file mode 100644 index 0000000..5cfe6ea --- /dev/null +++ b/internal/overlay/graph_test.go @@ -0,0 +1,342 @@ +package overlay + +import ( + "errors" + "strings" + "testing" +) + +const cidr = "10.42.0.0/16" + +func at(name, site, address, endpoint string, hub bool) Node { + return Node{Name: name, Key: "key-" + name, Site: site, Address: address, + Endpoint: endpoint, Hub: hub} +} + +func peersOf(t *testing.T, g Graph, node string) map[string]Peer { + t.Helper() + out := map[string]Peer{} + for _, p := range g[node] { + out[p.Name] = p + } + return out +} + +func TestEveryNodeReachesTheHub(t *testing.T) { + // The property that makes this a network at all. Without it a node has no route to anything + // it does not share a site with. + g, err := Compute([]Node{ + at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), + at("laptop", "", "10.42.0.2", "", false), + at("home", "house", "10.42.0.3", "", false), + }, cidr) + if err != nil { + t.Fatal(err) + } + for _, node := range []string{"laptop", "home"} { + hub, ok := peersOf(t, g, node)["anchor"] + if !ok { + t.Fatalf("%s has no route to the hub", node) + } + if hub.Allowed != cidr { + t.Errorf("%s routes %s through the hub; it must be the whole overlay or the hub is "+ + "not a route of last resort", node, hub.Allowed) + } + } +} + +func TestNodesAtOneSitePeerDirectly(t *testing.T) { + // Machines that share a site have a path that does not need the hub, and using it keeps their + // traffic off a link that may be on another continent. + g, err := Compute([]Node{ + at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), + at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), + at("server", "house", "10.42.0.3", "192.0.2.3:51820", false), + }, cidr) + if err != nil { + t.Fatal(err) + } + + direct, ok := peersOf(t, g, "desk")["server"] + if !ok { + t.Fatal("two machines at one site do not peer directly") + } + if direct.Allowed != "10.42.0.3/32" { + t.Errorf("the direct peer allows %s; a single address is what makes it win on "+ + "specificity over the hub's whole-overlay route", direct.Allowed) + } +} + +func TestARoamingNodeGetsExactlyOnePath(t *testing.T) { + // Hub-only, and not as a simplification. WireGuard has no failover: a more specific route to + // a dead endpoint blackholes rather than falling back, so two paths would mean one of them + // silently swallowing traffic. + g, err := Compute([]Node{ + at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), + at("laptop", "", "10.42.0.2", "", false), + at("other", "", "10.42.0.3", "", false), + }, cidr) + if err != nil { + t.Fatal(err) + } + if got := len(g["laptop"]); got != 1 { + t.Fatalf("a roaming node has %d peers; it gets exactly one path", got) + } + if g["laptop"][0].Name != "anchor" { + t.Errorf("a roaming node's single path is %s, not the hub", g["laptop"][0].Name) + } +} + +func TestTwoRoamingNodesDoNotPeerWithEachOther(t *testing.T) { + // An empty site is not a site. Nodes that roam have no shared location, and treating "" as + // one would have every roaming machine try to dial every other, none of which can be dialled. + g, err := Compute([]Node{ + at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), + at("laptop", "", "10.42.0.2", "", false), + at("phone", "", "10.42.0.3", "", false), + }, cidr) + if err != nil { + t.Fatal(err) + } + if _, ok := peersOf(t, g, "laptop")["phone"]; ok { + t.Error("two nodes with no site peered as though they shared one") + } +} + +func TestOnlyTheSideThatCannotBeDialledKeepsThePathOpen(t *testing.T) { + // Keepalive matters exactly once: on the node behind NAT. Without it the peer's first packet + // arrives at a mapping that has already expired. On the reachable side it is pointless + // traffic for ever. + g, err := Compute([]Node{ + at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), + at("laptop", "", "10.42.0.2", "", false), + }, cidr) + if err != nil { + t.Fatal(err) + } + if !peersOf(t, g, "laptop")["anchor"].Keepalive { + t.Error("a node that cannot be dialled does not keep its path open") + } + if peersOf(t, g, "anchor")["laptop"].Keepalive { + t.Error("a reachable node sends keepalives it does not need") + } + + // And between two direct peers at one site, where whether to keep the path open depends on + // which end you are. Checked here because the hub's own peer list happens not to set the + // field at all, so asserting only against the hub tests an absence rather than the rule. + same, err := Compute([]Node{ + at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), + at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), + at("server", "house", "10.42.0.3", "", false), + }, cidr) + if err != nil { + t.Fatal(err) + } + if !peersOf(t, same, "server")["desk"].Keepalive { + t.Error("the peer that cannot be dialled does not keep the path open") + } + if peersOf(t, same, "desk")["server"].Keepalive { + t.Error("the peer that can be dialled sends keepalives it does not need") + } +} + +func TestTheHubKnowsEveryoneItRoutesFor(t *testing.T) { + // The replies have to get back. A hub that does not hold a peer cannot answer it. + g, err := Compute([]Node{ + at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), + at("laptop", "", "10.42.0.2", "", false), + at("home", "house", "10.42.0.3", "", false), + }, cidr) + if err != nil { + t.Fatal(err) + } + hub := peersOf(t, g, "anchor") + for _, want := range []string{"laptop", "home"} { + if _, ok := hub[want]; !ok { + t.Errorf("the hub does not hold %s, so replies to it have nowhere to go", want) + } + } +} + +func TestAMeshWithNoHubIsRefused(t *testing.T) { + // Not an empty graph. A mesh with no hub has no path between sites, and answering "no peers" + // would look like a working network in which nothing can reach anything — which is how the + // old arrangement failed, silently, when nobody knew the address convention. + _, err := Compute([]Node{ + at("a", "", "10.42.0.1", "", false), + at("b", "", "10.42.0.2", "", false), + }, cidr) + if !errors.Is(err, ErrNoHub) { + t.Fatalf("a mesh with no hub gave %v", err) + } +} + +func TestAnUnreachableHubIsRefused(t *testing.T) { + // The hub is the one node that must be dialable from wherever the others are. Left + // unchecked, every node would be given a peer it can never reach and the mesh would look + // configured and be silent. + _, err := Compute([]Node{ + at("anchor", "datacentre", "10.42.0.1", "", true), + at("laptop", "", "10.42.0.2", "", false), + }, cidr) + if err == nil { + t.Fatal("a hub with no endpoint was accepted") + } + if !strings.Contains(err.Error(), "must be reachable") { + t.Errorf("the refusal does not say why: %v", err) + } +} + +func TestANodeWithNoPlaceYetIsSkippedRatherThanFatal(t *testing.T) { + // A node that has enrolled and not yet been given an address is an ordinary in-between state. + // Failing the whole graph over it would mean no node gets a network because one is half done. + g, err := Compute([]Node{ + at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), + at("laptop", "", "10.42.0.2", "", false), + {Name: "newcomer", Key: "", Address: ""}, + }, cidr) + if err != nil { + t.Fatal(err) + } + if _, ok := g["newcomer"]; ok { + t.Error("a node with no key or address was given a peer list") + } + if _, ok := peersOf(t, g, "laptop")["newcomer"]; ok { + t.Error("a node with no key was put in somebody's peer list") + } +} + +func TestNobodyPeersWithThemselves(t *testing.T) { + g, err := Compute([]Node{ + at("anchor", "house", "10.42.0.1", "198.51.100.1:51820", true), + at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), + }, cidr) + if err != nil { + t.Fatal(err) + } + for node, peers := range g { + for _, p := range peers { + if p.Name == node { + t.Errorf("%s peers with itself", node) + } + } + } +} + +func TestAHubAtYourOwnSiteAppearsOnceNotTwice(t *testing.T) { + // WireGuard takes one entry per public key. A hub that shares a site with a spoke was being + // emitted twice — once as a direct peer and once as the route of last resort — producing a + // configuration the interface refuses, from a mesh that thought it had succeeded. + // + // Found in the lab on the first two machines that shared a site with their hub, which is the + // ordinary case for a small mesh and was in none of the tests above. + g, err := Compute([]Node{ + at("anchor", "lab", "10.42.0.1", "192.0.2.10:51820", true), + at("laptop", "lab", "10.42.0.2", "", false), + }, cidr) + if err != nil { + t.Fatal(err) + } + + seen := map[string]int{} + for _, p := range g["laptop"] { + seen[p.Key]++ + } + for key, count := range seen { + if count > 1 { + t.Errorf("the key %s appears %d times; WireGuard takes one entry per key", key, count) + } + } + + if len(g["laptop"]) != 1 { + t.Fatalf("laptop has %d peers; the hub at its own site is one peer, not two", len(g["laptop"])) + } + // And that single entry has to carry everything, or the node keeps a direct path to the hub + // and loses its route to everywhere else. + if got := g["laptop"][0].Allowed; got != cidr { + t.Errorf("the single entry allows %s; it is both the direct path and the route of last "+ + "resort, so it carries the whole overlay", got) + } +} + +func TestTwoUnreachableNodesAtOneSiteDoNotPeerDirectly(t *testing.T) { + // Nobody would open the path, and the direct route is more specific than the hub's — so it + // wins and blackholes. Both nodes would reach the hub perfectly and be unable to reach each + // other, which is the worst arrangement available: it looks configured and is not. + // + // This is the design's own warning arriving in its implementation, and the lab found it with + // two machines at one site behind no reachable address — the ordinary shape of a house. + g, err := Compute([]Node{ + at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), + at("desk", "house", "10.42.0.2", "", false), + at("server", "house", "10.42.0.3", "", false), + }, cidr) + if err != nil { + t.Fatal(err) + } + + if _, ok := peersOf(t, g, "desk")["server"]; ok { + t.Error("two nodes that neither can be dialled were peered directly; neither could open " + + "the path and the route is more specific than the hub's, so it blackholes") + } + // And they must still be able to reach each other — through the hub. + if hub := peersOf(t, g, "desk")["anchor"]; hub.Allowed != cidr { + t.Errorf("desk's route to everything else allows %s", hub.Allowed) + } +} + +func TestOneReachableNodeIsEnoughToPeerDirectly(t *testing.T) { + // The other side of it: if either end can be dialled, the path can be opened, and using it + // keeps their traffic off a hub that may be on another continent. + g, err := Compute([]Node{ + at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), + at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), + at("server", "house", "10.42.0.3", "", false), + }, cidr) + if err != nil { + t.Fatal(err) + } + if _, ok := peersOf(t, g, "server")["desk"]; !ok { + t.Error("a node did not peer with a reachable neighbour at its own site") + } + if _, ok := peersOf(t, g, "desk")["server"]; !ok { + t.Error("the reachable node did not hold its unreachable neighbour, so replies have " + + "nowhere to go") + } +} + +// novox/hq ADR 0105: a hub that took over the predecessor's tunnel carries every peer that tunnel +// had, under the key and at the address the peer knows, until a node enrols with that key. +func TestTheHubCarriesTheTunnelsPeersUntilTheyEnrol(t *testing.T) { + hub := at("anchor", "hosting", "192.0.2.1", "198.51.100.1:51900", true) + hub.Carried = []Carried{ + {Key: "key-home", Address: "192.0.2.2"}, + {Key: "key-workstation", Address: "192.0.2.3"}, + } + // The machine behind key-home enrolled: it is a node now, at the address it kept. + home := at("home", "house", "192.0.2.2", "", false) + home.Key = "key-home" + + g, err := Compute([]Node{hub, home}, "192.0.2.0/24") + if err != nil { + t.Fatal(err) + } + peers := peersOf(t, g, "anchor") + carried, ok := peers[CarriedName("key-workstation")] + if !ok { + t.Fatalf("the hub does not carry the peer that has not enrolled: %+v", g["anchor"]) + } + if carried.Allowed != "192.0.2.3/32" || carried.Endpoint != "" || carried.Key != "key-workstation" { + t.Errorf("a carried peer is not the tunnel's own entry — same key, its one address, no endpoint: %+v", carried) + } + if _, twice := peers[CarriedName("key-home")]; twice { + t.Error("a peer that enrolled is carried as well as listed as a node: WireGuard takes one entry per key") + } + if node, ok := peers["home"]; !ok || node.Key != "key-home" || node.Allowed != "192.0.2.2/32" { + t.Errorf("the enrolled peer is not the node it became: %+v", node) + } + // Carried peers are the hub's business only: a spoke routes everything through the hub. + if _, leaked := peersOf(t, g, "home")[CarriedName("key-workstation")]; leaked { + t.Error("a carried peer appeared in a spoke's peer list") + } +} diff --git a/internal/overlay/names.go b/internal/overlay/names.go new file mode 100644 index 0000000..3aba422 --- /dev/null +++ b/internal/overlay/names.go @@ -0,0 +1,30 @@ +package overlay + +import ( + sdk "git.novox.be/novox/mesh-sdk/go/overlay" +) + +// The private network's names and the shape of its module stay in mesh-sdk's overlay package, which more parts read +// (novox/hq ADR 0593 §1); the generator of each node's network configuration is the node-engine's own, here. These +// are those names, so the generator and the composition read one package. +const ( + Requirement = sdk.Requirement + Name = sdk.Name + Addressing = sdk.Addressing + TheNetwork = sdk.TheNetwork + SuffixVar = sdk.SuffixVar + DefaultSuffix = sdk.DefaultSuffix + Interface = sdk.Interface + ConfigPath = sdk.ConfigPath + Unit = sdk.Unit + DefaultKeyPath = sdk.DefaultKeyPath +) + +// Manifest is the module the mesh provides for itself (sdk.Manifest). +func Manifest() map[string]any { return sdk.Manifest() } + +// Suffix is the internal names' suffix (sdk.Suffix). +func Suffix() string { return sdk.Suffix() } + +// InternalName is a node's internal name (sdk.InternalName). +func InternalName(node string) string { return sdk.InternalName(node) } diff --git a/internal/overlay/opens_test.go b/internal/overlay/opens_test.go new file mode 100644 index 0000000..7abf1bd --- /dev/null +++ b/internal/overlay/opens_test.go @@ -0,0 +1,113 @@ +package overlay + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func networkOf(t *testing.T, nodes []Node) *Generator { + t.Helper() + made, err := From(nodes, "10.42.0.0/16", "/var/lib/mesh-host/overlay.key") + if err != nil { + t.Fatal(err) + } + return made +} + +// A hub is dialled by every node at other sites, and needs its port open. A machine that is not a +// hub dials out and needs nothing open at all. +// +// They are the same module, which is why a static field in a manifest cannot say it — and the +// machine it gets wrong is the one facing the public internet, which is the machine that most +// needs filtering. +func TestOnlyAMachineThatCanBeDialledOpensTheOverlaysPort(t *testing.T) { + network := networkOf(t, []Node{ + {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:51820", Site: "one", Hub: true}, + {Name: "laptop", Key: "b", Site: "one"}, + }) + + opens, err := network.Listens("anchor") + if err != nil { + t.Fatal(err) + } + if len(opens) != 1 { + t.Fatalf("the machine every other one dials opens %d ports", len(opens)) + } + if opens[0].Port != 51820 || opens[0].At() != "udp" { + t.Fatalf("the port is not the one the interface listens on: %+v", opens[0]) + } + // From everywhere, and deliberately: a node at another site is not on the private network + // until this port lets it on, so restricting it to the mesh would be a rule that can never be + // satisfied by the thing it exists for. + if opens[0].From != catalogue.FromEverywhere { + t.Fatalf("the way onto the private network is restricted to the private network: %+v", opens[0]) + } + if opens[0].Why == "" { + t.Fatal("a port is opened and nothing says why, which is what makes a rule set unreadable") + } + + // And the machine nothing dials opens nothing. Not harmless to get wrong: a rule with no + // reason is one somebody later has to work out the reason for. + quiet, err := network.Listens("laptop") + if err != nil { + t.Fatal(err) + } + if len(quiet) != 0 { + t.Fatalf("a machine nothing dials opened %d port(s)", len(quiet)) + } +} + +// The port comes from the endpoint, which is where the interface takes its ListenPort from. One +// source, so a rule set cannot open a port the interface is not on. +func TestTheOpenedPortIsTheOneTheInterfaceListensOn(t *testing.T) { + network := networkOf(t, []Node{ + {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:60000", Site: "one", Hub: true}, + }) + opens, err := network.Listens("anchor") + if err != nil { + t.Fatal(err) + } + if len(opens) != 1 || opens[0].Port != 60000 { + t.Fatalf("the rule set would open a port the interface is not on: %+v", opens) + } + + written, err := Declaration(Node{Name: "anchor", Key: "a", Address: "10.42.0.1", + Endpoint: "198.51.100.10:60000", Hub: true}, nil, "/k") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(written), "ListenPort = 60000") { + t.Fatalf("the interface and the rule set disagree about the port:\n%s", written) + } +} + +// An endpoint with no port is refused rather than treated as a machine that opens nothing. +// +// A generator that cannot say what a machine opens is not one that says it opens nothing, and +// closing a port on the evidence of a failure to look is how a machine is severed by a fault +// somewhere else entirely. +func TestAnEndpointWithNoPortIsRefusedRatherThanTakenAsSilence(t *testing.T) { + network := networkOf(t, []Node{ + {Name: "anchor", Key: "a", Endpoint: "198.51.100.10", Site: "one", Hub: true}, + }) + if _, err := network.Listens("anchor"); err == nil { + t.Fatal("a machine whose port could not be read was treated as opening nothing") + } +} + +// A machine the network has never heard of opens nothing, and that is an answer rather than an +// error: a node assigned the module before it is placed is in exactly that state. +func TestAMachineNotOnTheNetworkOpensNothing(t *testing.T) { + network := networkOf(t, []Node{ + {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:51820", Site: "one", Hub: true}, + }) + opens, err := network.Listens("a-stranger") + if err != nil { + t.Fatal(err) + } + if len(opens) != 0 { + t.Fatalf("a machine not on the network opened %d port(s)", len(opens)) + } +} diff --git a/internal/selfcompose/data.go b/internal/selfcompose/data.go index bdcb148..bd06c96 100644 --- a/internal/selfcompose/data.go +++ b/internal/selfcompose/data.go @@ -9,8 +9,8 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/frombus" "git.novox.be/novox/mesh-sdk/go/delivery" ) diff --git a/internal/selfcompose/data_test.go b/internal/selfcompose/data_test.go index 3b7112c..7e08855 100644 --- a/internal/selfcompose/data_test.go +++ b/internal/selfcompose/data_test.go @@ -10,7 +10,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" + "github.com/novox/mesh-host/internal/compose/frombus" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/agent" diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 67e9d66..f1d2dff 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -29,8 +29,8 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/frombus" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/overlay" diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index d14cde2..7b3a698 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -18,12 +18,12 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/buscompose" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/overlay" "github.com/nats-io/nats.go" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/frombus" + "github.com/novox/mesh-host/internal/nodegrant" "github.com/novox/mesh-host/internal/agent" ) @@ -81,15 +81,12 @@ func (b *fakeBus) put(t *testing.T, bucket, key string, value []byte) { b.send(bucket, agent.Update{Key: key, Value: value}) } -// grantAllows says whether the node's grant (buscompose.PermissionsFor, KindNode) lets it watch or read pattern of +// grantAllows says whether the node's grant (the grants part's, nodegrant.For) lets it watch or read pattern of // bucket: the consumer a watch or a get makes (`$JS.API.CONSUMER.CREATE.KV_..$KV..`) // and the stream's information, matched as the bus matches a permission against a subject. func grantAllows(t *testing.T, node, bucket, pattern string) bool { t.Helper() - perms, err := buscompose.PermissionsFor(buscompose.Principal{Kind: buscompose.KindNode, Node: node}) - if err != nil { - t.Fatal(err) - } + perms := nodegrant.For(node) allowed := func(subject string) bool { for _, deny := range perms.PublishDeny { if subjectMatches(deny, subject) { @@ -1455,10 +1452,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { if got, err := r.Served(ctx, "n2", "mailer", []string{"domain"}); err != nil || fmt.Sprint(got) != "map[domain:example.org]" { t.Errorf("what n2's mailer serves: %v, %v", got, err) } - served, err := buscompose.PermissionsFor(buscompose.Principal{Kind: buscompose.KindNode, Node: "n1", ServedReads: []string{"n2.mailer.domain"}}) - if err != nil { - t.Fatal(err) - } + served := nodegrant.For("n1", "n2.mailer.domain") b.mu.Lock() for _, read := range b.reads { ok := grantAllows(t, "n1", read[0], read[1]) diff --git a/internal/selfcompose/shadow.go b/internal/selfcompose/shadow.go index bf6c878..67804d4 100644 --- a/internal/selfcompose/shadow.go +++ b/internal/selfcompose/shadow.go @@ -9,8 +9,9 @@ import ( "sync" "time" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose/frombus" + "github.com/novox/mesh-host/internal/judge" "github.com/novox/mesh-host/internal/agent" ) @@ -459,7 +460,7 @@ func (s *Shadow) answerAll(ctx context.Context) { } } if ok { - a = delivery.FitAnswer(a, delivery.MaxAnswerBytes) + a = judge.FitAnswer(a, delivery.MaxAnswerBytes) next[proposal] = a c.env.Say(fmt.Sprintf("answered placement candidate %s in %s: %s", proposal, took.Round(time.Millisecond), a.Verdict)) } else { diff --git a/internal/takein/takein.go b/internal/takein/takein.go new file mode 100644 index 0000000..657ab45 --- /dev/null +++ b/internal/takein/takein.go @@ -0,0 +1,134 @@ +// Package takein is the one rule that turns a builder's done key into the manifest the mesh composes a module from +// (issue 1126): the module's manifest resolved with what its build made. It is mesh-controller's take-in of a done key +// (cmd/mesh-controller/key_takein.go builtOfKey, unpackTexts and Manifest.Resolve) moved into the SDK, so a node agent +// composing its own modules (novox/hq ADR 0547 §2) composes from the same built manifest the controller composes from: +// every artifact a resource names resolved to its reference and digest, a TypeScript bundle's launchers, and, for a +// moved module, each template's text read back from its templates archive and what its build keeps for the +// provisioner (catalogue.Handed). Composing from the manifest the indexer read instead hands no moved module and +// leaves every artifact unbuilt. +// +// It reads nothing itself: the archive is read through the caller's Archive, from wherever the caller reaches the +// artifact store. +package takein + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Archive reads the archive a recorded reference names (catalogue.Recorded: the artifact store's address left out), +// checked against its digest. +type Archive func(ctx context.Context, reference, digest string) ([]byte, error) + +// Manifest is m, as its manifest at b's fingerprint reads, resolved with what b made: the built manifest. b must be done +// and every artifact must name its reference. +// A templated module's templates archive is read through archive; a module without templates reads none, and archive +// may then be nil. +func Manifest(ctx context.Context, m catalogue.Manifest, b delivery.BuilderBuild, archive Archive) (catalogue.Manifest, error) { + if b.State != delivery.BuildDone { + return catalogue.Manifest{}, fmt.Errorf("takein: the build of %s is %s, not done: nothing to resolve it with", m.Module, b.State) + } + // **A done key whose artifact names no reference is waited for, never composed from** (novox/hq ADR 0498 §8), in the + // controller's words (mesh-controller internal/builderkeys Key.Says): a key done before R4e, or a candidate's image or + // package not yet built again for the trunk. + var missing []string + for _, a := range b.Artifacts { + if strings.TrimSpace(a.Reference) == "" { + missing = append(missing, a.Name) + } + } + if len(missing) > 0 { + return catalogue.Manifest{}, fmt.Errorf("takein: the build of %s is done and waits for the reference of %s", + m.Module, strings.Join(missing, ", ")) + } + built, err := Built(ctx, m, b.Artifacts, archive) + if err != nil { + return catalogue.Manifest{}, err + } + resolved, err := m.Resolve(built) + if err != nil { + return catalogue.Manifest{}, fmt.Errorf("takein: the build of %s does not resolve: %w", m.Module, err) + } + return resolved, nil +} + +// Built is each of a done key's artifacts as catalogue.Manifest.Resolve reads it: its recorded reference and digest, a +// TypeScript bundle's launchers by the build modules' one rule (catalogue.Launchers), and, for the archive a module packs +// its templates into, each template's text read back from that archive. +func Built(ctx context.Context, m catalogue.Manifest, artifacts []delivery.BuiltArtifact, archive Archive) ([]catalogue.Built, error) { + declared := map[string]catalogue.Artifact{} + if m.Build != nil { + for _, a := range m.Build.Artifacts { + declared[a.Name] = a + } + } + templates := "" + if m.Templated() { + templates = catalogue.TemplatesArtifact(m) + } + var out []catalogue.Built + for _, a := range artifacts { + d := declared[a.Name] + kind := a.Kind + if kind == "" { + kind = d.Kind + } + b := catalogue.Built{Name: a.Name, Kind: kind, Reference: catalogue.Recorded(a.Reference), Digest: a.Digest} + if launchers := catalogue.Launchers(d.Language, d.Entrypoints); len(launchers) > 0 { + b.Launchers = launchers + } + if a.Name == templates && templates != "" { + if archive == nil { + return nil, fmt.Errorf("takein: %s packs its templates into %q, and nothing here reads the artifact store", + m.Module, a.Name) + } + body, err := archive(ctx, b.Reference, b.Digest) + if err != nil { + return nil, fmt.Errorf("takein: the templates archive %q of %s cannot be read from the artifact store: %w", + a.Name, m.Module, err) + } + if b.Texts, err = UnpackTexts(body); err != nil { + return nil, fmt.Errorf("takein: the templates archive %q of %s cannot be read back: %w", a.Name, m.Module, err) + } + } + out = append(out, b) + } + return out, nil +} + +// UnpackTexts is every regular file of a packed templates archive (gzip over tar), by its path inside it. +func UnpackTexts(body []byte) (map[string]string, error) { + zipped, err := gzip.NewReader(bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer zipped.Close() + reader := tar.NewReader(zipped) + texts := map[string]string{} + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + return texts, nil + } + if err != nil { + return nil, err + } + if header.Typeflag != tar.TypeReg { + continue + } + text, err := io.ReadAll(io.LimitReader(reader, 16<<20)) + if err != nil { + return nil, err + } + texts[strings.TrimPrefix(header.Name, "./")] = string(text) + } +} diff --git a/internal/validate/sdk_test.go b/internal/validate/sdk_test.go new file mode 100644 index 0000000..d946fff --- /dev/null +++ b/internal/validate/sdk_test.go @@ -0,0 +1,31 @@ +package validate + +import ( + "errors" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/declaration" +) + +// The SDK's validator speaks the copied parser's vocabulary version (issue 1353 AC3). +func TestVersionIsTheParsersVersion(t *testing.T) { + if Version != declaration.Version { + t.Fatalf("validate.Version = %d, declaration.Version = %d", Version, declaration.Version) + } +} + +// A body of another declaration version is refused whole, by both the validator and the parser (AC3). The body is +// the shape mesh-host's validate_test.go uses for its good declaration, with the version changed. +func TestAnotherVersionIsRefusedWhole(t *testing.T) { + body := `{"declaration": 99, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}` + problems := Declaration([]byte(body)) + if len(problems) == 0 { + t.Fatal("a declaration of another version was taken") + } + _, err := declaration.Parse([]byte(body)) + var refused *declaration.RefusalError + if !errors.As(err, &refused) || strings.Join(refused.Problems, "\n") != strings.Join(problems, "\n") { + t.Fatalf("the parser and the validator disagree: %v / %v", err, problems) + } +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go new file mode 100644 index 0000000..1291866 --- /dev/null +++ b/internal/validate/validate.go @@ -0,0 +1,42 @@ +// Package validate is the node-engine's own judgement of a declaration, for whoever must know before +// a declaration is sent whether a machine would take it (novox/hq to-be 45 §4, D1; §9, the merge gate). +// +// **One validator.** The controller composed declarations the host then refused whole — a manifest +// the catalogue check passed (novox/hq issue 236), a consumer's identity too long for the machine's +// names (issue 263) — because the only validator was the one the host runs as it applies. A second, +// written in the controller from the host's rules, would drift from them the first time either +// changed. So the host's own parsing is exported here, unchanged: what the controller's self-check and +// the merge gate run is what every machine runs. +// +// Copied from mesh-host at commit 619e14aa8e572c5056e3d2a2412c7f8b364bf840 (validate/validate.go), unchanged but for +// the import path of the parser, which is mesh-sdk's go/declaration (novox/hq ADR 0453 clause 3, ADR 0446 clause 4). +// mesh-host keeps its own copy until release 4b. +// +// It judges a declaration as it arrives over the link: actions are refused, as the host refuses them +// from the link (novox/hq ADR 0005). Nothing here touches a machine. +package validate + +import ( + "errors" + + "git.novox.be/novox/mesh-sdk/go/declaration" +) + +// Declaration is every problem the node-engine would refuse a declaration for, each in its own words; +// nil when it would take it. The body is the declaration as the controller composes it — the bytes a +// signature is made over — not the signed envelope. +func Declaration(raw []byte) []string { + _, err := declaration.Parse(raw) + if err == nil { + return nil + } + var refused *declaration.RefusalError + if errors.As(err, &refused) { + return refused.Problems + } + return []string{err.Error()} +} + +// Version is the declaration vocabulary this validator speaks: a declaration of another version is +// refused whole by Declaration, as by the host. +const Version = declaration.Version diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go new file mode 100644 index 0000000..51ade73 --- /dev/null +++ b/internal/validate/validate_test.go @@ -0,0 +1,50 @@ +package validate + +import ( + "strings" + "testing" +) + +// **The validator a controller imports is the host's**: a declaration the host takes passes, and one +// it refuses is refused with the host's own words, every problem at once. +func TestTheValidatorIsTheHosts(t *testing.T) { + good := `{"declaration": 1, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}` + if problems := Declaration([]byte(good)); problems != nil { + t.Fatalf("a declaration the host takes was refused: %v", problems) + } + // And one carrying the lease epoch and sequence the controller sends it under (novox/hq to-be 45 + // §6): a controller validating with this package may send them. + ordered := `{"declaration": 1, "epoch": 57, "sequence": 3, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}` + if problems := Declaration([]byte(ordered)); problems != nil { + t.Fatalf("a declaration carrying its order was refused: %v", problems) + } + // And one carrying a file's owner-at-runtime and the files the provisioner fills (novox/hq ADR 0349 §5): the + // reader takes them before any controller sends them. + owned := `{"declaration": 1, "filled_files": ["/etc/x"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}` + if problems := Declaration([]byte(owned)); problems != nil { + t.Fatalf("a declaration saying who owns a file at runtime was refused: %v", problems) + } + // And one naming the provisioner client's account (novox/hq ADR 0349 §4). + named := `{"declaration": 1, "provisioner_account": "mesh-node-provisioner", "filled_files": ["/etc/x"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}` + if problems := Declaration([]byte(named)); problems != nil { + t.Fatalf("a declaration naming the provisioner client's account was refused: %v", problems) + } + for _, bad := range []string{ + `{"declaration": 1, "provisioner_account": "root", "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a"}]}`, + `{"declaration": 1, "filled_files": ["/etc/none"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}`, + `{"declaration": 1, "resources": []}`, + `{"declaration": 99, "resources": [{"id": "d", "type": "directory", "path": "/x"}]}`, + `{"declaration": 1, "resources": [{"id": "d", "type": "nothing-the-host-knows"}]}`, + `not json`, + } { + problems := Declaration([]byte(bad)) + if len(problems) == 0 { + t.Errorf("a declaration the host refuses was passed: %s", bad) + } + for _, p := range problems { + if strings.TrimSpace(p) == "" { + t.Errorf("a problem said nothing, for %s", bad) + } + } + } +} From 4f51393b720b6437d230c34c98ab8e324b936195 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:29:20 +0100 Subject: [PATCH 02/14] Publish every composition the node makes in composed- (H3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0593 §6: the provisioner, the assigner and the view read compositions instead of composing, so the node's engine writes each one it makes to its composition state: the wanted builds, every placed module at its newest done build (so the plan exists before the want moves), each with its provisioning plan, and a module refused alone with its refusal. A key is written only when it differs, and kept while among the module's five newest or named by a want. A placed module's trunk build landing now makes a pass. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- cmd/mesh-host/nodeagent.go | 5 +- internal/link/composedstate.go | 103 +++++++ internal/selfcompose/composedstate.go | 320 +++++++++++++++++++++ internal/selfcompose/composedstate_test.go | 173 +++++++++++ internal/selfcompose/data.go | 2 +- internal/selfcompose/data_test.go | 2 +- internal/selfcompose/selfcompose.go | 44 ++- internal/selfcompose/selfcompose_test.go | 7 +- 8 files changed, 647 insertions(+), 9 deletions(-) create mode 100644 internal/link/composedstate.go create mode 100644 internal/selfcompose/composedstate.go create mode 100644 internal/selfcompose/composedstate_test.go diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 31ebb60..d6b2c40 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -14,12 +14,12 @@ import ( "sync/atomic" "time" - "github.com/novox/mesh-host/internal/compose/frombus" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/overlay" "github.com/novox/mesh-host/internal/agent" "github.com/novox/mesh-host/internal/apply" + "github.com/novox/mesh-host/internal/compose/frombus" "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/liveness" "github.com/novox/mesh-host/internal/selfcompose" @@ -968,6 +968,9 @@ func selfComposer(session *link.Session, node string, hooks composerHooks, say f // And the packages its compositions declare in its own key of the state packages, which pacman reads (novox/hq ADR // 0565 §6). env.Packages = link.EnginePackages{Session: session} + // And every composition it makes in its composition state composed-, a record the provisioner, the assigner + // and the view read and nothing applies (novox/hq ADR 0593 §6). + env.ComposedState = link.EngineComposed{Session: session, Node: node} return selfcompose.New(env) } diff --git a/internal/link/composedstate.go b/internal/link/composedstate.go new file mode 100644 index 0000000..b069208 --- /dev/null +++ b/internal/link/composedstate.go @@ -0,0 +1,103 @@ +package link + +import ( + "context" + "errors" + "fmt" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-host/internal/selfcompose" +) + +// ---- this node's composition state, in the bus's own storage (novox/hq ADR 0593 §6) -------------------------------- + +// composedStore binds the node's composition state `composed-` over the link open now: bound, never created (the +// grants part creates it, create-only, history delivery.ComposedHistory). +func composedStore(js nats.JetStreamContext, node string) (nats.KeyValue, error) { + bucket, err := delivery.ComposedBucketFor(node) + if err != nil { + return nil, err + } + kv, err := js.KeyValue(bucket) + if err != nil { + return nil, fmt.Errorf("this node's composition state %s cannot be bound (the grants part creates it; written "+ + "again at the next pass that composes): %w", bucket, err) + } + return kv, nil +} + +func (l *natsLink) composedStore(node string) (nats.KeyValue, error) { + return composedStore(l.js, node) +} + +func (b OverNATS) composedStore(node string) (nats.KeyValue, error) { return composedStore(b.JS, node) } + +// EngineComposed is this node's composition state over whichever link the session has open (selfcompose.ComposedStore); +// with none open every read and write is refused, and nothing is kept anywhere else. +type EngineComposed struct { + Session *Session + Node string +} + +var _ selfcompose.ComposedStore = EngineComposed{} + +func (e EngineComposed) kv() (nats.KeyValue, error) { + e.Session.init() + e.Session.mu.Lock() + bus := e.Session.bus + e.Session.mu.Unlock() + l, ok := bus.(interface { + composedStore(node string) (nats.KeyValue, error) + }) + if bus == nil || !ok { + return nil, ErrNoLink + } + return l.composedStore(e.Node) +} + +// Entries is every key the state holds with its latest value and revision. +func (e EngineComposed) Entries(ctx context.Context) (map[string]selfcompose.ComposedEntry, error) { + kv, err := e.kv() + if err != nil { + return nil, err + } + keys, err := kv.Keys(nats.Context(ctx)) + if errors.Is(err, nats.ErrNoKeysFound) { + return map[string]selfcompose.ComposedEntry{}, nil + } + if err != nil { + return nil, err + } + out := make(map[string]selfcompose.ComposedEntry, len(keys)) + for _, key := range keys { + got, err := kv.Get(key) + if errors.Is(err, nats.ErrKeyNotFound) { + continue + } + if err != nil { + return nil, err + } + out[key] = selfcompose.ComposedEntry{Value: got.Value(), Revision: got.Revision()} + } + return out, nil +} + +// Put writes a key and answers its revision. +func (e EngineComposed) Put(_ context.Context, key string, value []byte) (uint64, error) { + kv, err := e.kv() + if err != nil { + return 0, err + } + return kv.Put(key, value) +} + +// Delete deletes a key (a delete marker, kept with the key's history). +func (e EngineComposed) Delete(_ context.Context, key string) error { + kv, err := e.kv() + if err != nil { + return err + } + return kv.Delete(key) +} diff --git a/internal/selfcompose/composedstate.go b/internal/selfcompose/composedstate.go new file mode 100644 index 0000000..8da3ea6 --- /dev/null +++ b/internal/selfcompose/composedstate.go @@ -0,0 +1,320 @@ +package selfcompose + +import ( + "bytes" + "context" + "fmt" + "maps" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + + "github.com/novox/mesh-host/internal/agent" + "github.com/novox/mesh-host/internal/compose/frombus" +) + +// **The compositions this node makes, published in its composition state** (novox/hq ADR 0593 §6). The node's engine +// alone composes, and it writes every composition it makes into `composed-` (delivery.ComposedBucketFor), a +// record for the provisioner, the assigner and the view, never an instruction: the engine applies only what it composes +// itself, never a value read from the state, so a value carries no signature. After a pass that composed, it writes: +// +// - each module at the fingerprint its want names, as the agent applies it (`..`), and the node's own +// composition (`.node`) when its want exists; +// - each module placed on the node at its newest done build: the indexer's fingerprint once the builder holds a done +// build of it, so the plan exists before the want moves (build done, the node composes and writes, the provisioner +// reads, the providers provide, the assigner moves the want); a module it runs that no want names, at what runs; +// - each module's plan (catalogue.Plan) exactly when the provisioner's plans name it, from the same pass's builds; +// - a module refused alone at a fingerprint as that key with its refusal (Refused), and no resources. +// +// A key is written only when its value differs from what the state holds (read once per run), and a module's key is +// deleted once its fingerprint is not among the five it wrote last (delivery.ComposedKeep) and no want names it. A pass +// refused whole, or a bus that did not answer, writes nothing: what was written stands, and each module's have says why. + +// ComposedStore is this node's composition state, bound and never created (the grants part creates it). +type ComposedStore interface { + // Entries is every key the state holds with its value and revision. + Entries(ctx context.Context) (map[string]ComposedEntry, error) + Put(ctx context.Context, key string, value []byte) (revision uint64, err error) + Delete(ctx context.Context, key string) error +} + +// ComposedEntry is one key's value and revision in the state. +type ComposedEntry struct { + Value []byte + Revision uint64 +} + +// published is what a pass composed at the wanted builds, for publishComposed: under mu. +type published struct { + scope frombus.Scope + fps map[string]string // by module, the wanted fingerprint + comps map[string]delivery.Composed + held map[string]string + // placed is the modules the node's placement puts here, ran what runs of each module (by its composition's FP). + placed []string + ran map[string]string +} + +// publishComposed writes the node's composition state from the last pass that composed, when one asked it (pubDue). +func (c *Composer) publishComposed(ctx context.Context) { + c.mu.Lock() + due, at := c.pubDue, c.pubAt + c.pubDue = false + c.mu.Unlock() + if !due || c.env.ComposedState == nil || at == nil { + return + } + wrote, deleted, err := c.writeComposed(ctx, at) + if err != nil { + c.sayComposed("this node's compositions are not kept in its composition state; " + c.oweComposed() + ": " + err.Error()) + return + } + c.mu.Lock() + c.pubTries = 0 + c.mu.Unlock() + c.sayComposed("") + if wrote+deleted > 0 { + c.env.Say(fmt.Sprintf("this node's composition state: %d key(s) written, %d deleted", wrote, deleted)) + } +} + +// newestFPs is, of the modules placed here, the fingerprint each is composed at for the state beyond the wanted ones: +// the indexer's, where the builder holds a done build of it; else the want's; else what runs of it. A module with none +// is left out. +func (c *Composer) newestFPs(ctx context.Context, at *published) map[string]string { + out := map[string]string{} + r := reader{c: c} + for _, module := range at.placed { + c.mu.Lock() + fp2 := c.modules[module].FP2 + c.mu.Unlock() + if fp2 != "" { + if b, err := r.Build(ctx, module, fp2); err == nil && b != nil && b.State == delivery.BuildDone { + out[module] = fp2 + continue + } + } + if fp, ok := at.fps[module]; ok { + out[module] = fp + } else if fp, ok := at.ran[module]; ok { + out[module] = fp + } + } + return out +} + +// writeComposed works out the state's keys from at and writes those that differ, then deletes what is past keeping. +func (c *Composer) writeComposed(ctx context.Context, at *published) (wrote, deleted int, err error) { + values, err := c.composedValues(ctx, at) + if err != nil { + return 0, 0, err + } + c.pubWrite.Lock() + defer c.pubWrite.Unlock() + if c.pubKept == nil { + kept, err := c.env.ComposedState.Entries(ctx) + if err != nil { + return 0, 0, fmt.Errorf("the state cannot be read: %w", err) + } + c.pubKept = kept + } + keys := make([]string, 0, len(values)) + for k := range values { + keys = append(keys, k) + } + sort.Strings(keys) + for _, key := range keys { + if bytes.Equal(c.pubKept[key].Value, values[key]) { + continue + } + rev, err := c.env.ComposedState.Put(ctx, key, values[key]) + if err != nil { + return wrote, deleted, fmt.Errorf("%s cannot be written: %w", key, err) + } + c.pubKept[key] = ComposedEntry{Value: values[key], Revision: rev} + wrote++ + } + for _, key := range c.pastKeeping() { + if err := c.env.ComposedState.Delete(ctx, key); err != nil { + return wrote, deleted, fmt.Errorf("%s cannot be deleted: %w", key, err) + } + delete(c.pubKept, key) + deleted++ + } + return wrote, deleted, nil +} + +// composedValues is every key the state should hold after the pass at, encoded. +func (c *Composer) composedValues(ctx context.Context, at *published) (map[string][]byte, error) { + r := reader{c: c} + out := map[string][]byte{} + put := func(comp delivery.Composed, plans map[string]catalogue.Plan) error { + if comp.Module != "" && comp.Refused == "" { + if p, ok := plans[comp.Module]; ok { + p := p + comp.Plan = &p + } + } + key, err := composedKeyOf(comp) + if err != nil { + return err + } + raw, err := delivery.EncodeComposed(comp) + if err != nil { + return fmt.Errorf("the composition %s cannot be written: %w", key, err) + } + out[key] = raw + return nil + } + runs := []struct { + scope frombus.Scope + fps map[string]string + comps map[string]delivery.Composed + held map[string]string + }{{at.scope, at.fps, at.comps, at.held}} + // The newest builds, composed only where they differ from the wanted ones. + newest := c.newestFPs(ctx, at) + if !maps.Equal(newest, at.fps) { + scope := at.scope + scope.KeptAt = map[string]string{} + var inOwn []string + c.mu.Lock() + for module, fp := range newest { + if fp != c.modules[module].FP2 { + scope.KeptAt[module] = fp + } + } + c.mu.Unlock() + for _, m := range at.scope.InOwn { + if _, given := newest[m]; !given { + inOwn = append(inOwn, m) + } + } + scope.InOwn = inOwn + comps, _, held, err := frombus.Compose(ctx, r, c.env.Mesh, scope, newest) + switch { + case err != nil && BusFailed(err): + return nil, fmt.Errorf("the newest builds could not be composed: %w", err) + case err != nil: + // A refusal of the node at its newest builds is no refusal of what it applies: the wanted keys are written, + // and the newest are said (each module's have says what holds it when its want moves). + c.sayComposed("this node's newest builds do not compose, so its composition state holds the wanted ones alone: " + err.Error()) + default: + for key, comp := range comps { + comps[key] = c.keepRunning(comp, scope.Running) + } + runs = append(runs, struct { + scope frombus.Scope + fps map[string]string + comps map[string]delivery.Composed + held map[string]string + }{scope, newest, comps, held}) + } + } + for i, run := range runs { + plans, _, err := frombus.PlansHeld(ctx, r, c.env.Mesh, run.scope) + if err != nil && BusFailed(err) { + return nil, fmt.Errorf("the plans could not be composed: %w", err) + } + // A node whose plans refuse it whole hands the provisioner nothing, as the provisioner's own composing did: no + // key carries a plan then. + for _, comp := range run.comps { + if comp.Module == "" && i > 0 { + continue // the node's own composition is the wanted pass's alone + } + if err := put(comp, plans); err != nil { + return nil, err + } + } + for module, why := range run.held { + fp, ok := run.fps[module] + if !ok || module == agent.NodeModule { + continue + } + if err := put(delivery.Composed{Node: c.env.Node, Module: module, FP: fp, Refused: why}, nil); err != nil { + return nil, err + } + } + } + return out, nil +} + +// composedKeyOf is a composition's key in the state. +func composedKeyOf(comp delivery.Composed) (string, error) { + if comp.Module == "" { + return delivery.ComposedNodeKey(comp.Node) + } + return delivery.ComposedKey(comp.Node, comp.Module, comp.FP) +} + +// pastKeeping is, of the module keys the state holds, those whose fingerprint is not among the five its module wrote +// last (delivery.ComposedKeep, by revision) and that no want names (ADR 0593 §6). Under pubWrite. +func (c *Composer) pastKeeping() []string { + c.mu.Lock() + wanted := map[string]string{} + for module, w := range c.wants { + wanted[module] = w.FP + } + c.mu.Unlock() + type kept struct { + key string + fp string + rev uint64 + } + byModule := map[string][]kept{} + for key, e := range c.pubKept { + _, module, fp, isNode, err := delivery.ParseComposedKey(key) + if err != nil || isNode { + continue + } + byModule[module] = append(byModule[module], kept{key, fp, e.Revision}) + } + var out []string + for module, ks := range byModule { + sort.Slice(ks, func(i, j int) bool { return ks[i].rev > ks[j].rev }) + for i, k := range ks { + if i < delivery.ComposedKeep || wanted[module] == k.fp { + continue + } + out = append(out, k.key) + } + } + sort.Strings(out) + return out +} + +// oweComposed keeps the state due and has the loop make a pass again after the growing bound, as owePackages does. +func (c *Composer) oweComposed() string { + c.mu.Lock() + defer c.mu.Unlock() + c.pubDue = true + c.wait = LaterWait(c.pubTries) + c.pubTries++ + c.owed.Store(true) + return fmt.Sprintf("tried again at %s (try %d)", time.Now().Add(c.wait).Format("15:04:05"), c.pubTries) +} + +// sayComposed says a line about the state once until it changes; "" clears it. +func (c *Composer) sayComposed(line string) { + c.mu.Lock() + same := c.pubSaid == line + c.pubSaid = line + c.mu.Unlock() + if !same && line != "" { + c.env.Say(line) + } +} + +// ranOf is, of what runs, each module's fingerprint by its composition (the node's own left out). +func ranOf(running []delivery.Composed) map[string]string { + out := map[string]string{} + for _, comp := range running { + if comp.Module != "" && comp.FP != "" && !strings.EqualFold(comp.Module, agent.NodeModule) { + out[comp.Module] = comp.FP + } + } + return out +} diff --git a/internal/selfcompose/composedstate_test.go b/internal/selfcompose/composedstate_test.go new file mode 100644 index 0000000..665e370 --- /dev/null +++ b/internal/selfcompose/composedstate_test.go @@ -0,0 +1,173 @@ +package selfcompose + +import ( + "context" + "slices" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" + + "github.com/novox/mesh-host/internal/agent" +) + +// fakeComposed is a node's composition state: its keys, and every write and deletion made. +type fakeComposed struct { + mu sync.Mutex + kv map[string]ComposedEntry + rev uint64 + puts []string + deletes []string +} + +func (f *fakeComposed) Entries(context.Context) (map[string]ComposedEntry, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := map[string]ComposedEntry{} + for k, v := range f.kv { + out[k] = v + } + return out, nil +} + +func (f *fakeComposed) Put(_ context.Context, key string, value []byte) (uint64, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.rev++ + f.kv[key] = ComposedEntry{Value: value, Revision: f.rev} + f.puts = append(f.puts, key) + return f.rev, nil +} + +func (f *fakeComposed) Delete(_ context.Context, key string) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.kv, key) + f.deletes = append(f.deletes, key) + return nil +} + +func (f *fakeComposed) written() []string { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.puts) +} + +// novox/hq ADR 0593 §6: the node publishes each composition it makes in its composition state, a record never applied: +// the wanted module and the node's own at the first pass; nothing again for a pass that changes nothing; a done build +// of a module placed here that no want names, at its trunk's build, written once as its own key (before any want moves); +// the same build landing again writes nothing. And a module's keys are kept while among the five it wrote last or named +// by a want, and deleted otherwise. +func TestTheNodePublishesItsCompositions(t *testing.T) { + b := newFakeBus() + fp := delivery.SourcePrefix2 + strings.Repeat("a", 64) + ofp := delivery.SourcePrefix2 + strings.Repeat("c", 64) + digest := delivery.ManifestDigest([]byte(lemurs)) + oldie := `{"module":"oldie","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"oldie.service","state":"running","boot":"enabled"}]}` + odigest := delivery.ManifestDigest([]byte(oldie)) + for d, json := range map[string]string{digest: lemurs, odigest: oldie} { + man, _ := delivery.EncodeManifest(delivery.Manifest{Module: map[string]string{digest: "lemurs", odigest: "oldie"}[d], JSON: json}) + key, _ := delivery.ManifestsKey(d) + b.kv[delivery.ManifestsBucket+"/"+key] = man + } + state := &fakeComposed{kv: map[string]ComposedEntry{}} + env := b.env() + env.ComposedState = state + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c := New(env) + out, err := c.Watch(ctx, "n1.>") + if err != nil { + t.Fatal(err) + } + place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, + Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}}) + have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) + mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) + omod, _ := delivery.EncodeModule(delivery.Module{FP2: ofp, Manifest: odigest, Repository: "novox/o", Commit: "c"}) + want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true}) + nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true}) + b.put(t, delivery.PlacementBucket, "placement.n1", place) + b.put(t, delivery.HaveBucket, "n1.node", have) + b.put(t, delivery.ModulesBucket, "lemurs", mod) + b.put(t, delivery.ModulesBucket, "oldie", omod) + b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want) + b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant) + for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { + b.send(bucket, agent.Update{Caught: true}) + } + for u := next(t, out); !u.Caught; u = next(t, out) { + } + lemursKey, _ := delivery.ComposedKey("n1", "lemurs", fp) + oldieKey, _ := delivery.ComposedKey("n1", "oldie", ofp) + waitFor := func(what string, ok func([]string) bool) []string { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if got := state.written(); ok(got) { + return got + } + time.Sleep(10 * time.Millisecond) + } + b.mu.Lock() + t.Logf("said: %q", b.said) + b.mu.Unlock() + t.Fatalf("%s: written %v", what, state.written()) + return nil + } + first := waitFor("the first pass", func(got []string) bool { return len(got) >= 2 }) + if !slices.Equal(first, []string{lemursKey, "n1.node"}) { + t.Errorf("the first pass wrote %v, want the wanted module's key and the node's own", first) + } + if raw := state.kv[lemursKey].Value; raw != nil { + comp, err := delivery.DecodeComposed(raw) + if err != nil || comp.Module != "lemurs" || comp.FP != fp || comp.Signature != nil || len(comp.Resources) == 0 { + t.Errorf("the published composition of lemurs: %+v, %v", comp, err) + } + } + // A pass that changes nothing writes nothing. + setFor(t, &askedWithin, 0) + if _, err := c.ComposeNow(ctx); err != nil { + t.Fatal(err) + } + time.Sleep(50 * time.Millisecond) + if got := state.written(); len(got) != 2 { + t.Errorf("a pass that changed nothing wrote %v", got[2:]) + } + // oldie's trunk build is done: its key at that build is written once, though no want names it. + build, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: odigest}) + bkey, _ := delivery.BuilderBuildKey("oldie", ofp) + b.mu.Lock() + b.kv[delivery.BuilderBuildsBucket+"/"+bkey] = build + b.mu.Unlock() + b.put(t, delivery.BuilderBuildsBucket, bkey, build) + got := waitFor("oldie's done build", func(got []string) bool { return len(got) >= 3 }) + if got[2] != oldieKey { + t.Errorf("the done build of oldie wrote %v, want %s", got[2:], oldieKey) + } + b.put(t, delivery.BuilderBuildsBucket, bkey, build) + time.Sleep(100 * time.Millisecond) + if got := state.written(); len(got) != 3 { + t.Errorf("the same build landing again wrote %v", got[3:]) + } + + // What is kept: the five keys of a module written last, and one a want names; the rest is deleted. + keep := New(b.env()) + keep.pubKept = map[string]ComposedEntry{} + var keys []string + for i, ch := range "123456" { + key, _ := delivery.ComposedKey("n1", "lemurs", delivery.SourcePrefix2+strings.Repeat(string(ch), 64)) + keys = append(keys, key) + keep.pubKept[key] = ComposedEntry{Revision: uint64(10 + i)} + } + keep.pubKept["n1.node"] = ComposedEntry{Revision: 1} + if past := keep.pastKeeping(); !slices.Equal(past, []string{keys[0]}) { + t.Errorf("past keeping: %v, want the oldest of six, %s", past, keys[0]) + } + keep.wants = map[string]delivery.Want{"lemurs": {FP: delivery.SourcePrefix2 + strings.Repeat("1", 64)}} + if past := keep.pastKeeping(); len(past) != 0 { + t.Errorf("past keeping with a want naming the oldest: %v, want none", past) + } +} diff --git a/internal/selfcompose/data.go b/internal/selfcompose/data.go index bd06c96..6c1e86d 100644 --- a/internal/selfcompose/data.go +++ b/internal/selfcompose/data.go @@ -9,9 +9,9 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // **The data items of each module placed here, as this engine composed them** (novox/hq ADR 0577 clause 1). After a diff --git a/internal/selfcompose/data_test.go b/internal/selfcompose/data_test.go index 7e08855..d88f0a1 100644 --- a/internal/selfcompose/data_test.go +++ b/internal/selfcompose/data_test.go @@ -10,8 +10,8 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-host/internal/compose/frombus" "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose/frombus" "github.com/novox/mesh-host/internal/agent" ) diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index f1d2dff..0adeae2 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -29,10 +29,10 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-host/internal/compose" - "github.com/novox/mesh-host/internal/compose/frombus" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/overlay" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/frombus" "github.com/nats-io/nats.go" @@ -93,6 +93,9 @@ type Env struct { // Packages is this node's key of the engine's state packages (novox/hq ADR 0565 §6), written after a pass that // handed something new when the packages its compositions declare differ. Nil keeps none. Packages PackagesStore + // ComposedState is this node's composition state `composed-` (novox/hq ADR 0593 §6), where every composition + // a pass makes is published for its readers (publishComposed). Nil keeps none. + ComposedState ComposedStore } // VaultSecretChanged is the vault's event that a secret changed: a name and its generation, never a value. @@ -175,6 +178,15 @@ type Composer struct { pkgsWrite sync.Mutex pkgsKept []byte pkgsRead bool + // pubAt is what the last pass that composed made, and pubDue whether the composition state is to be written from it + // (publishComposed); pubTries and pubSaid are as the packages' are. Under mu. pubKept is what the state holds, read + // once per run, under pubWrite. + pubAt *published + pubDue bool + pubTries int + pubSaid string + pubWrite sync.Mutex + pubKept map[string]ComposedEntry } // Pass is how one pass went, as the node-engine's verb compose answers it. @@ -323,7 +335,7 @@ func (c *Composer) inputs() []input { } // takeBuildDone says whether a build that landed is one this node composes from: done, of a module it wants at that -// build or whose trunk's build it is. Every other build, and a build not done, moves nothing. +// build, or wanted or placed here whose trunk's build it is. Every other build, and a build not done, moves nothing. func (c *Composer) takeBuildDone(u agent.Update) bool { // Any write or delete of a build's key forgets the build kept as done: the builder claims a done key again as a new // revision (a gone step, issue 1937), which lands done with other artifacts. @@ -340,8 +352,14 @@ func (c *Composer) takeBuildDone(u agent.Update) bool { c.mu.Lock() w, wanted := c.wants[module] m := c.modules[module] + placedHere := false + for _, pm := range c.placements[c.env.Node].Modules { + placedHere = placedHere || pm.Module == module + } c.mu.Unlock() - if !wanted || (w.FP != fp && m.FP2 != fp) { + // A module placed here is composed at its trunk's build once that is done, for the composition state, before its + // want moves (ADR 0593 §6). + if !(wanted && w.FP == fp) && !((wanted || placedHere) && m.FP2 == fp) { return false } b, err := delivery.DecodeBuilderBuild(u.Value) @@ -468,6 +486,7 @@ func (c *Composer) Watch(ctx context.Context, _ string) (<-chan agent.Update, er } c.publishData(ctx) c.publishPackages(ctx) + c.publishComposed(ctx) if c.owed.Load() { wait, _ := c.waitNow() c.env.Say(fmt.Sprintf("the node composes again in %s: a read on the bus did not answer", wait)) @@ -935,6 +954,23 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { } } pass.Handed = len(out) + // The composition state is written from this pass (ADR 0593 §6): every pass that composed, since a build done or a + // plan changed moves what is published though nothing new is handed. + placedMods := make([]string, 0, len(own.Modules)) + for _, m := range own.Modules { + placedMods = append(placedMods, m.Module) + } + sort.Strings(placedMods) + pubComps := make(map[string]delivery.Composed, len(compositions)) + for key, comp := range compositions { + if comp.Module == "" && !nodeWanted { + continue + } + pubComps[key] = comp + } + c.pubAt = &published{scope: scope, fps: maps.Clone(fps), comps: pubComps, held: maps.Clone(held), placed: placedMods, + ran: ranOf(running)} + c.pubDue = true // The data items are kept again after a pass that handed something new, and after the run's first (ADR 0577 clause 1). c.dataDue = c.dataDue || len(out) > 0 || c.dataKept == nil // And the node's packages after the same passes (ADR 0565 §6): written only when they differ. diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index 7b3a698..a9e663a 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -526,7 +526,9 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // Issue 774, 2026-10-30 19:30: node-tools' want moved to a build not yet done, and the node stayed refused after it // landed until another input moved. A done build of a module the node wants, at its want's or the trunk's build, - // makes a pass; a build not done, of another build or of a module not wanted, makes none. + // makes a pass, and so does one of a module placed here at its trunk's build though no want names it (novox/hq ADR 0593 + // §6: the composition state holds it at its newest done build before its want moves); a build not done, of another + // build or of a module neither wanted nor placed here, makes none. passes := func() int64 { return composed.Load() } for _, row := range []struct { name, module, fp, state string @@ -535,7 +537,8 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { {"lemurs's wanted build, not done", "lemurs", fp, "building", false}, {"lemurs's wanted build lands", "lemurs", fp, delivery.BuildDone, true}, {"another build of lemurs", "lemurs", delivery.SourcePrefix2 + strings.Repeat("e", 64), delivery.BuildDone, false}, - {"a module the node does not want", "oldie", delivery.SourcePrefix2 + strings.Repeat("c", 64), delivery.BuildDone, false}, + {"a placed module no want names, at its trunk's build", "oldie", delivery.SourcePrefix2 + strings.Repeat("c", 64), delivery.BuildDone, true}, + {"a module neither wanted nor placed here", "ghost", delivery.SourcePrefix2 + strings.Repeat("c", 64), delivery.BuildDone, false}, } { before := passes() key, _ := delivery.BuilderBuildKey(row.module, row.fp) From 3fa41dfe1ab1bae583e24a6214ba6221d2196b32 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:33:58 +0100 Subject: [PATCH 03/14] Serve node-engine.compose-candidate from the node's shadow (H4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0593 §7: a check's composition is the checker's, made from each placed node's answer, since only a node can compose with its own sealed values. The node composes the indexer's candidate in shadow on a copy of its shadow's inputs, the candidate's modules laid over the indexer's with their manifest verdicts, and answers per module composed (changed or not), refused or held, and its own composition's verdict, within the answer bound; it applies, writes and hands nothing. With input it also answers the composition input, for the sandbox path. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- cmd/mesh-host/engineseat.go | 26 +++ cmd/mesh-host/main.go | 1 + cmd/mesh-host/nodeagent.go | 6 + internal/link/handacts.go | 15 ++ internal/selfcompose/candidate.go | 289 +++++++++++++++++++++++++ internal/selfcompose/candidate_test.go | 125 +++++++++++ internal/selfcompose/shadow.go | 6 + 7 files changed, 468 insertions(+) create mode 100644 internal/selfcompose/candidate.go create mode 100644 internal/selfcompose/candidate_test.go diff --git a/cmd/mesh-host/engineseat.go b/cmd/mesh-host/engineseat.go index 6cba6a6..772ff55 100644 --- a/cmd/mesh-host/engineseat.go +++ b/cmd/mesh-host/engineseat.go @@ -3,6 +3,7 @@ package main import ( "bufio" "context" + "encoding/json" "errors" "fmt" "os" @@ -15,6 +16,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" meshasker "git.novox.be/novox/mesh-sdk/go/asker" "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/accounts" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/link" @@ -359,3 +361,27 @@ func packagesFrom(node string, session *link.Session, composer *atomic.Pointer[s return v, nil } } + +// composeCandidateFrom answers the verb compose-candidate (novox/hq ADR 0593 §7): the check's candidate composed on this +// node in shadow by the run's shadow, applying and writing nothing, within delivery.ComposeAnswerBound. The answer is +// the seat's shape (delivery.ComposeCandidateAnswer), checked as it is encoded; a node whose shadow does not run yet +// refuses in words, which the checker takes as no answer. +func composeCandidateFrom(shadow *atomic.Pointer[selfcompose.Shadow]) func(candidate string, input bool) (any, error) { + return func(candidate string, input bool) (any, error) { + ask := delivery.ComposeCandidateAsk{Candidate: candidate, Input: input} + if _, err := delivery.EncodeComposeCandidateAsk(ask); err != nil { + return nil, err + } + s := shadow.Load() + if s == nil { + return nil, errors.New("this node's shadow does not run now (its node agent is starting), so it composes no candidate") + } + ctx, cancel := context.WithTimeout(context.Background(), delivery.ComposeAnswerBound) + defer cancel() + raw, err := delivery.EncodeComposeCandidateAnswer(s.ComposeCandidate(ctx, ask)) + if err != nil { + return nil, fmt.Errorf("this node's answer to %s does not fit the seat's shape: %w", candidate, err) + } + return json.RawMessage(raw), nil + } +} diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 3045eb9..1bd7840 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1001,6 +1001,7 @@ func runLink(ctx context.Context, opts options) error { queue.Acts.Composition = compositionFrom(current) queue.Acts.Compose = composeFrom(current, &composerNow) queue.Acts.Packages = packagesFrom(mine.Node, queue, &composerNow) + queue.Acts.ComposeCandidate = composeCandidateFrom(&shadowNow) setRunsHere(runsFromAgent(current)) factsOfNow := func(ctx context.Context, ag *agent.Agent) { sayFacts(ctx, opts, mine.Membership.Signer, queue, say, ag) diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index d6b2c40..8001ee3 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -623,6 +623,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus session.NoteCandidates(ctx, delivery.WithValuesDiffer(answers)) }) go shadow.Keep(run, link.Backoff) // this run's alone: a switch begins a run, and its shadow, again + shadowNow.Store(shadow) // what the verb compose-candidate composes a check's candidate with // Its own composer, always (issue 1126, A7): a composition made elsewhere (the controller's, in the node's // composition bucket) is never applied. While the seat's placement holds no key for this node it composes // nothing and keeps running what it runs; a placement it cannot watch stops the agent, said, and it begins again. @@ -640,6 +641,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus cancel() current.set(nil) composerNow.Store(nil) + shadowNow.Store(nil) if ctx.Err() != nil { return } @@ -766,6 +768,10 @@ var machinesNow atomic.Pointer[selfcompose.Composer] // compositions or no run is going: what the verb compose asks a pass of (issue 774). var composerNow atomic.Pointer[selfcompose.Composer] +// shadowNow is the shadow of the run now, which answers placement candidates and the verb compose-candidate; nil +// between runs. +var shadowNow atomic.Pointer[selfcompose.Shadow] + // keptRetired says the kept declaration is read no more: every module the seat's placement puts on this node has a have of // its own (placedAllHave), so what runs is the agent's alone (ADR 0550 §2, design 47's L3 s9). var keptRetired atomic.Bool diff --git a/internal/link/handacts.go b/internal/link/handacts.go index 2a0f538..3412de9 100644 --- a/internal/link/handacts.go +++ b/internal/link/handacts.go @@ -45,6 +45,8 @@ const ( // ActPackages reads the packages this node's compositions declare, and with write writes its key again (novox/hq // ADR 0565 §6, §15). ActPackages = "packages" + // ActComposeCandidate composes a check's candidate on this node in shadow, read-only (novox/hq ADR 0593 §7). + ActComposeCandidate = "compose-candidate" ) // The states of an act. @@ -143,6 +145,10 @@ type HandActs struct { // Packages answers the node's key of the engine's state packages beside the controller's mirror's key and their // difference, and with write writes the key again from what the engine composed (the verb packages, ADR 0565 §6, §15). Packages func(write bool) (any, error) + // ComposeCandidate composes a check's candidate (the indexer's candidate key) on this node in shadow and answers per + // module, applying and writing nothing; with input, also the composition input it composed from (the verb + // compose-candidate, ADR 0593 §7). + ComposeCandidate func(candidate string, input bool) (any, error) // Say says one of the seat's events, asked, done or refused; nil says nothing. Say func(event string, body any) Now func() time.Time @@ -215,6 +221,15 @@ func (h *HandActs) Call(c EngineCall) (any, error) { write = b } return h.Packages(write) + case ActComposeCandidate: + if h.ComposeCandidate == nil { + return nil, errors.New("this engine composes no candidate yet: its node agent has not started") + } + input := arg("input") == "true" + if b, ok := c.Args["input"].(bool); ok { + input = b + } + return h.ComposeCandidate(arg("candidate"), input) case "acts": if id := arg("id"); id != "" { return h.Act(id) diff --git a/internal/selfcompose/candidate.go b/internal/selfcompose/candidate.go new file mode 100644 index 0000000..c215cc0 --- /dev/null +++ b/internal/selfcompose/candidate.go @@ -0,0 +1,289 @@ +package selfcompose + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "sort" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" + + "github.com/novox/mesh-host/internal/agent" + "github.com/novox/mesh-host/internal/compose/frombus" +) + +// **A check's candidate, composed in shadow on a call** (novox/hq ADR 0593 §7): the node seat's verb compose-candidate. +// Given the key of the indexer's candidate (delivery.CandidatesBucket, `.`), the node composes itself +// twice from what its shadow holds (placement, records, the indexer's modules, its config values), with its own sealed +// values: once as it is, and once with the candidate's modules laid over the indexer's (each at the candidate's +// fingerprint, its manifest by digest and the indexer's verdict on it, ADR 0578 §3; a module the candidate removes left +// out). It answers per module placed here composed (its digest, and whether the candidate changes it), refused by the +// candidate, or held without it too, and the verdict of its own composition. It applies nothing and writes nothing: it +// composes on a copy of the shadow's inputs, so neither the shadow's answers nor the node's own composer move. + +// errNotReady is a shadow that has not read every input yet. +var errNotReady = errors.New("this node's shadow has not read every input yet") + +// ComposeCandidate is the node's answer to ask, within delivery.ComposeAnswerBound (or ctx's deadline, if sooner). +func (s *Shadow) ComposeCandidate(ctx context.Context, ask delivery.ComposeCandidateAsk) delivery.ComposeCandidateAnswer { + began := time.Now() + s.mu.Lock() + c := s.c + s.mu.Unlock() + node := c.env.Node + a := delivery.ComposeCandidateAnswer{Node: node, Candidate: ask.Candidate} + refuse := func(why string) delivery.ComposeCandidateAnswer { + a.Verdict, a.Refused, a.Modules, a.Own = delivery.ComposeCandidateRefused, why, nil, delivery.ModuleComposed{} + a.Took = time.Since(began).Milliseconds() + return a + } + if _, _, err := delivery.ParseCompositionKey(ask.Candidate); err != nil { + return refuse(err.Error()) + } + ctx, cancel := context.WithTimeout(ctx, delivery.ComposeAnswerBound) + defer cancel() + if !s.ready.Load() { + return refuse(errNotReady.Error()) + } + base := c.scratch() + raw, err := base.get(ctx, delivery.CandidatesBucket, ask.Candidate) + if err != nil { + return refuse(fmt.Sprintf("the indexer's candidate %s cannot be read: %v", ask.Candidate, err)) + } + if raw == nil { + return refuse("the indexer holds no candidate " + ask.Candidate) + } + cand, err := delivery.DecodeCandidate(raw) + if err != nil { + return refuse(fmt.Sprintf("the indexer's candidate %s cannot be read: %v", ask.Candidate, err)) + } + laid := c.scratch() + touched, err := laid.layCandidate(ctx, cand) + if err != nil { + return refuse(err.Error()) + } + + var running []delivery.Composed + if c.env.Running != nil { + running = c.env.Running() + } + baseComps, baseHeld, baseErr := base.composeShadow(ctx, running, nil, nil) + laidComps, laidHeld, laidErr := laid.composeShadow(ctx, running, touched, func(scope frombus.Scope) { + if !ask.Input { + return + } + in, err := frombus.Input(ctx, reader{c: laid}, laid.env.Mesh, scope) + if err != nil { + return + } + // The request's input carries no catalogue (composer.Request.Input): the cases give the manifests. + in.Catalogue = nil + for i := range in.Nodes { + in.Nodes[i].Kept = nil + } + if body, err := json.Marshal(in); err == nil { + a.Input = body + } + }) + if ctx.Err() != nil { + return refuse(fmt.Sprintf("the bound of %s passed before the node composed the candidate", delivery.ComposeAnswerBound)) + } + switch { + case laidErr != nil && BusFailed(laidErr): + return refuse("a read on the bus did not answer: " + laidErr.Error()) + case laidErr != nil && baseErr != nil: + // The node composes none of its own without the candidate either: the candidate changes nothing of that. + a.Verdict, a.Modules = delivery.ComposeCandidateComposed, []delivery.ModuleComposed{} + a.Own = delivery.ModuleComposed{Module: delivery.HaveNodeWord, State: delivery.ModuleHeldState, Words: laidErr.Error()} + a.Took = time.Since(began).Milliseconds() + return a + case laidErr != nil: + return refuse(laidErr.Error()) + } + byModule := func(comps map[string]delivery.Composed) map[string]delivery.Composed { + out := map[string]delivery.Composed{} + for _, comp := range comps { + out[comp.Module] = comp + } + return out + } + was, now := byModule(baseComps), byModule(laidComps) + one := func(module string) (delivery.ModuleComposed, bool) { + m := delivery.ModuleComposed{Module: module} + if module == "" { + m.Module = delivery.HaveNodeWord + } + if why, held := laidHeld[module]; held { + m.State, m.Words = delivery.ModuleRefusedState, why + if _, before := baseHeld[module]; before || baseErr != nil { + m.State = delivery.ModuleHeldState + } + return m, true + } + comp, ok := now[module] + if !ok { + return m, false + } + dig, err := delivery.ComposedDigestOf(comp) + if err != nil { + m.State, m.Words = delivery.ModuleRefusedState, err.Error() + return m, true + } + m.State, m.FP, m.Digest = delivery.ModuleComposedState, comp.FP, dig + if before, ok := was[module]; !ok { + m.Changed = true + } else if d, err := delivery.ComposedDigestOf(before); err != nil || d != dig { + m.Changed = true + } + return m, true + } + modules := map[string]bool{} + for m := range now { + modules[m] = m != "" + } + for m := range laidHeld { + modules[m] = m != "" && m != agent.NodeModule + } + names := make([]string, 0, len(modules)) + for m, isModule := range modules { + if isModule { + names = append(names, m) + } + } + sort.Strings(names) + a.Modules = []delivery.ModuleComposed{} + refused := false + for _, m := range names { + if mc, ok := one(m); ok { + a.Modules = append(a.Modules, mc) + refused = refused || mc.State == delivery.ModuleRefusedState + } + } + if own, ok := one(""); ok { + a.Own = own + } else { + a.Own = delivery.ModuleComposed{Module: delivery.HaveNodeWord, State: delivery.ModuleHeldState, + Words: "the node composes no composition of its own"} + } + refused = refused || a.Own.State == delivery.ModuleRefusedState + a.Verdict = delivery.ComposeCandidateComposed + if refused { + a.Verdict = delivery.ComposeCandidateRefused + } + a.Took = time.Since(began).Milliseconds() + return a +} + +// scratch is a composer over a copy of what c holds of its inputs, for composing in shadow without moving c. +func (c *Composer) scratch() *Composer { + out := New(c.env) + c.mu.Lock() + defer c.mu.Unlock() + out.placements = maps.Clone(c.placements) + out.records = maps.Clone(c.records) + out.inOwn = maps.Clone(c.inOwn) + out.modules = maps.Clone(c.modules) + out.values = maps.Clone(c.values) + out.manifests = maps.Clone(c.manifests) + out.archives = maps.Clone(c.archives) + out.builds = maps.Clone(c.builds) + out.sealedOwn = maps.Clone(c.sealedOwn) + out.ownHanded = maps.Clone(c.ownHanded) + out.certified = c.certified + out.placementKeys = c.placementKeys + return out +} + +// layCandidate lays the candidate's modules over the indexer's: each at its fingerprint, with its manifest's digest and +// the indexer's verdict on it (read from its verdicts, none read taken as accepted); a module the candidate removes goes. +// It answers the modules laid. +func (c *Composer) layCandidate(ctx context.Context, cand delivery.Candidate) (map[string]bool, error) { + touched := map[string]bool{} + for _, pull := range cand.Pulls { + for module, cm := range pull.Fps { + if cm.None2 == delivery.CandidateNoneNoModule { + c.mu.Lock() + delete(c.modules, module) + c.mu.Unlock() + continue + } + if cm.FP2 == "" { + continue // no fingerprint at the preview: composed as the trunk holds it + } + m := delivery.Module{FP2: cm.FP2, Manifest: cm.Manifest, Repository: pull.Repository, Commit: pull.Head.Head} + if cm.Manifest != "" { + key, err := delivery.VerdictsKey(cm.Manifest) + if err != nil { + return nil, err + } + raw, err := c.get(ctx, delivery.VerdictsBucket, key) + if err != nil { + return nil, fmt.Errorf("the indexer's verdict on %s's manifest cannot be read: %w", module, err) + } + if raw != nil { + v, err := delivery.DecodeManifestVerdict(raw) + if err != nil { + return nil, fmt.Errorf("the indexer's verdict on %s's manifest cannot be read: %w", module, err) + } + m.Verdict, m.Problems, m.Warnings = v.State, v.Problems, v.Warnings + } + } + c.mu.Lock() + c.modules[module] = m + c.mu.Unlock() + touched[module] = true + } + } + return touched, nil +} + +// composeShadow composes this node from what c holds, as the shadow composes it (shadowBuilds): each module at the build +// it runs, one placed and not run at the indexer's, and one the candidate laid (laid) at the candidate's. input, when +// given, is handed the scope. +func (c *Composer) composeShadow(ctx context.Context, running []delivery.Composed, laid map[string]bool, input func(frombus.Scope)) (map[string]delivery.Composed, map[string]string, error) { + node := c.env.Node + c.mu.Lock() + placement, placed := c.placements[node] + modules := maps.Clone(c.modules) + inOwn := c.inOwnOf(placement) + c.mu.Unlock() + if !placed { + return nil, nil, errors.New("this node's key in the seat's placement state is gone: it composes nothing of its own") + } + fps := shadowBuilds(running, placement, modules, inOwn) + // A module the laid candidate changes is composed at the candidate's build, though the node runs another. + for module := range fps { + if mod, ok := modules[module]; ok && laid[module] { + fps[module] = mod.FP2 + } + } + kept := map[string]string{} + for module, fp := range fps { + if mod, ok := modules[module]; ok && mod.FP2 == fp { + continue + } + kept[module] = fp + } + wants := map[string]delivery.Want{agent.NodeModule: {}} + for module, fp := range fps { + wants[module] = delivery.Want{FP: fp} + } + c.mu.Lock() + c.wants = wants + c.mu.Unlock() + scope := frombus.Scope{Node: node, KeptAt: kept, InOwn: inOwn, Running: running, SameValue: c.sameValue} + if input != nil { + input(scope) + } + comps, _, held, err := frombus.Compose(ctx, reader{c: c}, c.env.Mesh, scope, fps) + if err != nil { + return nil, nil, err + } + for key, comp := range comps { + comps[key] = c.keepRunning(comp, running) + } + return comps, held, nil +} diff --git a/internal/selfcompose/candidate_test.go b/internal/selfcompose/candidate_test.go new file mode 100644 index 0000000..a01ab32 --- /dev/null +++ b/internal/selfcompose/candidate_test.go @@ -0,0 +1,125 @@ +package selfcompose + +import ( + "context" + "strings" + "sync/atomic" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" + + "github.com/novox/mesh-host/internal/agent" +) + +// novox/hq ADR 0593 §7: the verb compose-candidate composes a check's candidate on this node in shadow. A candidate that +// changes a placed module composes it at the candidate's build, changed; one whose manifest the indexer refused is +// answered refused, naming the module and the judge's words, and the verdict is refused; a candidate the indexer does not +// hold is refused in words. Nothing is applied, nothing is written, no placement answer is handed, and the answer comes +// within the bound (an ended bound answers at once, refused). +func TestTheNodeComposesACandidateOnAsk(t *testing.T) { + b := newFakeBus() + fp := delivery.SourcePrefix2 + strings.Repeat("a", 64) + fpNew := delivery.SourcePrefix2 + strings.Repeat("b", 64) + digest := delivery.ManifestDigest([]byte(lemurs)) + lemurs2 := strings.Replace(lemurs, `"version":"1"`, `"version":"2"`, 1) + digest2 := delivery.ManifestDigest([]byte(lemurs2)) + for d, body := range map[string]string{digest: lemurs, digest2: lemurs2} { + man, _ := delivery.EncodeManifest(delivery.Manifest{Module: "lemurs", JSON: body}) + key, _ := delivery.ManifestsKey(d) + b.kv[delivery.ManifestsBucket+"/"+key] = man + } + candidateOf := func(manifest string) string { + head := delivery.Head{Repository: "novox/x", Number: 7, Head: "c0ffee" + manifest[len(manifest)-6:]} + heads, _ := delivery.HeadsHash([]delivery.Head{head}) + pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"lemurs"}, + Fps: map[string]delivery.CandidateModule{"lemurs": {FP2: fpNew, Manifest: manifest}}}} + raw, err := delivery.EncodeCandidate(delivery.Candidate{Group: "novox/x#7", HeadsHash: heads, Pulls: pulls, + FPSet: delivery.FingerprintSet(pulls)}) + if err != nil { + t.Fatal(err) + } + key, _ := delivery.CompositionKey("novox/x#7", heads) + b.kv[delivery.CandidatesBucket+"/"+key] = raw + return key + } + accepted, refusedKey := candidateOf(digest2), "" + { + // The same change, its manifest refused by the indexer's judge. + refusedMan := strings.Replace(lemurs2, `"version":"2"`, `"version":"3"`, 1) + d3 := delivery.ManifestDigest([]byte(refusedMan)) + man, _ := delivery.EncodeManifest(delivery.Manifest{Module: "lemurs", JSON: refusedMan}) + key, _ := delivery.ManifestsKey(d3) + b.kv[delivery.ManifestsBucket+"/"+key] = man + vkey, _ := delivery.VerdictsKey(d3) + v, err := delivery.EncodeManifestVerdict(delivery.ManifestVerdict{Module: "lemurs", State: delivery.VerdictRefused, + Problems: []string{"a field the judge refuses"}, Judge: "src2:j", At: time.Now()}) + if err != nil { + t.Fatal(err) + } + b.kv[delivery.VerdictsBucket+"/"+vkey] = v + refusedKey = candidateOf(d3) + } + + env := b.env() + state := &fakeComposed{kv: map[string]ComposedEntry{}} + env.ComposedState = state + var applied atomic.Int64 + env.Refused = func(string, map[string]string) { applied.Add(1) } + var handed atomic.Int64 + s := NewShadow(env, func(context.Context, map[string]delivery.CandidateAnswer) { handed.Add(1) }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = s.Run(ctx) }() + if a := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: accepted}); !strings.Contains(a.Refused, "not read every input") { + t.Errorf("a shadow not ready answered %+v", a) + } + place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, + Modules: []delivery.PlacedModule{{Module: "lemurs"}}}) + have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) + mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) + deadline := time.Now().Add(5 * time.Second) + watching := func() int { b.mu.Lock(); defer b.mu.Unlock(); return len(b.watches) } + for watching() < 6 && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + b.put(t, delivery.PlacementBucket, "placement.n1", place) + b.put(t, delivery.HaveBucket, "n1.node", have) + b.put(t, delivery.ModulesBucket, "lemurs", mod) + for _, bucket := range []string{delivery.PlacementCandidatesBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, ResolvedBucket} { + b.send(bucket, agent.Update{Caught: true}) + } + for !s.ready.Load() && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + + a := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: accepted}) + if _, err := delivery.EncodeComposeCandidateAnswer(a); err != nil { + t.Fatalf("the answer does not fit the seat's shape: %v (%+v)", err, a) + } + if a.Verdict != delivery.ComposeCandidateComposed || len(a.Modules) != 1 || a.Modules[0].Module != "lemurs" || + a.Modules[0].State != delivery.ModuleComposedState || a.Modules[0].FP != fpNew || !a.Modules[0].Changed || + a.Own.Module != delivery.HaveNodeWord || a.Took > delivery.ComposeAnswerBound.Milliseconds() { + t.Errorf("an accepted candidate answered %+v", a) + } + r := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: refusedKey}) + if r.Verdict != delivery.ComposeCandidateRefused || len(r.Modules) != 1 || r.Modules[0].State != delivery.ModuleRefusedState || + !strings.Contains(r.Modules[0].Words, "a field the judge refuses") { + t.Errorf("a candidate whose manifest the indexer refused answered %+v", r) + } + if _, err := delivery.EncodeComposeCandidateAnswer(r); err != nil { + t.Errorf("the refused answer does not fit the seat's shape: %v", err) + } + missing, _ := delivery.CompositionKey("novox/x#8", "0123456789ab") + if m := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: missing}); !strings.Contains(m.Refused, "holds no candidate") { + t.Errorf("a candidate the indexer does not hold answered %+v", m) + } + ended, end := context.WithCancel(ctx) + end() + if e := s.ComposeCandidate(ended, delivery.ComposeCandidateAsk{Candidate: accepted}); e.Verdict != delivery.ComposeCandidateRefused { + t.Errorf("an ended bound answered %+v", e) + } + if len(state.written()) != 0 || applied.Load() != 0 || handed.Load() != 0 { + t.Errorf("composing candidates wrote %v, marked %d have(s), handed %d answer(s); want nothing", state.written(), applied.Load(), handed.Load()) + } +} diff --git a/internal/selfcompose/shadow.go b/internal/selfcompose/shadow.go index 67804d4..4feed7c 100644 --- a/internal/selfcompose/shadow.go +++ b/internal/selfcompose/shadow.go @@ -7,6 +7,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -54,6 +55,8 @@ type Shadow struct { // after a bound (laterAgain), whether or not an input moves (issue 1126, 2026-10-28: ace kept "the vault's own: not // yet ... no responders" in its answer to seed-7). owed bool + // ready says this run read every input once: the verb compose-candidate composes only then (ComposeCandidate). + ready atomic.Bool } // answeredAt is what one proposal's answer was composed at: its keys' revisions, by node, and the changes of each input. @@ -146,6 +149,8 @@ func (s *Shadow) Run(ctx context.Context) error { s.retry = map[string]bool{} s.c = New(s.c.env) s.mu.Unlock() + s.ready.Store(false) + defer s.ready.Store(false) c := s.c node := c.env.Node // In inputWords' order. @@ -238,6 +243,7 @@ func (s *Shadow) Run(ctx context.Context) error { if ready = all(caught); !ready { continue } + s.ready.Store(true) dirty = true } if dirty { From 4b8b5c732993b6a4a0ae232b902b3fa4c4e3d8f2 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:35:00 +0100 Subject: [PATCH 04/14] The composition verb reads the revisions the engine publishes (H5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0593 §6: composed- is written again, by the node's engine, each composition with its plan. The verb composition finds a published revision by the digest the have and want name, its resources', so a plan on the record does not hide it. The golden node grant also reads the indexer's verdicts, which compose-candidate takes for a candidate's manifests. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- internal/agent/agent_test.go | 13 +++++++++++++ internal/nodegrant/nodegrant.go | 3 ++- internal/nodegrant/testdata/node.json | 2 ++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index e6c128a..2ce56b1 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -1560,6 +1560,19 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( if _, err := b.ag.Composition(context.Background(), "db", "sha256:"+strings.Repeat("0", 64), false); err == nil { t.Error("a digest no revision has was answered") } + // novox/hq ADR 0593 §6: the engine publishes each composition with its plan in its composition state; the verb reads + // that revision by the digest its have and want name, its resources' (the plan left out of the digest). + planned, _ := delivery.DecodeComposed(draw) + planned.Plan = &catalogue.Plan{} + praw, err := delivery.EncodeComposed(planned) + if err != nil { + t.Fatal(err) + } + keep(dk, praw) + if view, err := b.ag.Composition(context.Background(), "db", ddig, false); err != nil || view.Composition.Digest != ddig || + len(view.Revisions) != 3 || view.Revisions[2].Digest != ddig { + t.Errorf("db's published revision with its plan, asked by its digest, reads %+v (%v)", view, err) + } b.comps.put(dk, draw) eventually(t, "db's have stays waiting-yes after its composition went back to what runs", func() bool { h := b.haves.last("db") diff --git a/internal/nodegrant/nodegrant.go b/internal/nodegrant/nodegrant.go index eb7c088..5553804 100644 --- a/internal/nodegrant/nodegrant.go +++ b/internal/nodegrant/nodegrant.go @@ -2,7 +2,8 @@ // file the node-engine's tests run under (novox/hq ADR 0593 §1): the composer of the bus's user list is the grants // part's own, so this repository holds what it composes for a node rather than calling it. testdata/node.json is that // grant for the node NODE, captured from mesh-sdk's buscompose.PermissionsFor at 3d87ddc, with what this release adds -// to it (ADR 0593 §6, §7): the node's writes of its own composition state, and its reads of the indexer's candidates. +// to it (ADR 0593 §6, §7): the node's writes of its own composition state, and its reads of the indexer's candidates +// and of its verdicts on their manifests. // When the grants part changes a node's grant, this file changes with it in the same group. package nodegrant diff --git a/internal/nodegrant/testdata/node.json b/internal/nodegrant/testdata/node.json index 445ea31..92e72e8 100644 --- a/internal/nodegrant/testdata/node.json +++ b/internal/nodegrant/testdata/node.json @@ -42,6 +42,7 @@ "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_candidates.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_verdicts.>", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.NODE.>", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.NODE.>", "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.NODE", @@ -59,6 +60,7 @@ "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_candidates", "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests", "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_verdicts", "$JS.API.STREAM.INFO.KV_mesh-host_acts", "$JS.API.STREAM.INFO.KV_mesh-host_data", "$JS.API.STREAM.INFO.KV_mesh-host_health", From f6bda12a32b8fdb0ce7879efe1e7829a913c2a76 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:39:12 +0100 Subject: [PATCH 05/14] The node whole publishes its compositions and answers a candidate (H6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0593 §6, §7, §9: the integration test runs the node agent against the real bus under the grant the grants part composes for a node: it publishes web's composition in composed- with the digest its have names, and answers compose-candidate for the indexer's candidate, changing nothing. A node that cannot compose now answers unready, so the checker counts it as no answer by the verdict and never by words. The golden grant reads the indexer's candidates and verdicts as it reads the builder's builds. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- cmd/mesh-host/engineseat.go | 20 +++-- cmd/mesh-host/main.go | 2 +- cmd/mesh-host/nodeagent_integration_test.go | 92 ++++++++++++++++++++- internal/nodegrant/testdata/node.json | 6 ++ internal/selfcompose/candidate.go | 17 ++-- internal/selfcompose/candidate_test.go | 6 +- 6 files changed, 123 insertions(+), 20 deletions(-) diff --git a/cmd/mesh-host/engineseat.go b/cmd/mesh-host/engineseat.go index 772ff55..443f0fc 100644 --- a/cmd/mesh-host/engineseat.go +++ b/cmd/mesh-host/engineseat.go @@ -365,20 +365,24 @@ func packagesFrom(node string, session *link.Session, composer *atomic.Pointer[s // composeCandidateFrom answers the verb compose-candidate (novox/hq ADR 0593 §7): the check's candidate composed on this // node in shadow by the run's shadow, applying and writing nothing, within delivery.ComposeAnswerBound. The answer is // the seat's shape (delivery.ComposeCandidateAnswer), checked as it is encoded; a node whose shadow does not run yet -// refuses in words, which the checker takes as no answer. -func composeCandidateFrom(shadow *atomic.Pointer[selfcompose.Shadow]) func(candidate string, input bool) (any, error) { +// answers unready, which the checker takes as no answer. +func composeCandidateFrom(node string, shadow *atomic.Pointer[selfcompose.Shadow]) func(candidate string, input bool) (any, error) { return func(candidate string, input bool) (any, error) { ask := delivery.ComposeCandidateAsk{Candidate: candidate, Input: input} if _, err := delivery.EncodeComposeCandidateAsk(ask); err != nil { return nil, err } - s := shadow.Load() - if s == nil { - return nil, errors.New("this node's shadow does not run now (its node agent is starting), so it composes no candidate") + var a delivery.ComposeCandidateAnswer + if s := shadow.Load(); s == nil { + // Unready, never an error the checker reads words of: its agent is starting (ADR 0593 §9). + a = delivery.ComposeCandidateAnswer{Node: node, Candidate: candidate, Verdict: delivery.ComposeCandidateUnready, + Refused: "this node's shadow does not run now (its node agent is starting), so it composes no candidate"} + } else { + ctx, cancel := context.WithTimeout(context.Background(), delivery.ComposeAnswerBound) + defer cancel() + a = s.ComposeCandidate(ctx, ask) } - ctx, cancel := context.WithTimeout(context.Background(), delivery.ComposeAnswerBound) - defer cancel() - raw, err := delivery.EncodeComposeCandidateAnswer(s.ComposeCandidate(ctx, ask)) + raw, err := delivery.EncodeComposeCandidateAnswer(a) if err != nil { return nil, fmt.Errorf("this node's answer to %s does not fit the seat's shape: %w", candidate, err) } diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 1bd7840..b352d48 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1001,7 +1001,7 @@ func runLink(ctx context.Context, opts options) error { queue.Acts.Composition = compositionFrom(current) queue.Acts.Compose = composeFrom(current, &composerNow) queue.Acts.Packages = packagesFrom(mine.Node, queue, &composerNow) - queue.Acts.ComposeCandidate = composeCandidateFrom(&shadowNow) + queue.Acts.ComposeCandidate = composeCandidateFrom(mine.Node, &shadowNow) setRunsHere(runsFromAgent(current)) factsOfNow := func(ctx context.Context, ag *agent.Agent) { sayFacts(ctx, opts, mine.Membership.Signer, queue, say, ag) diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index 17eab9b..e5518af 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -507,10 +507,22 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str h := b.have("web") return h.FP == fp && h.Composed != "" && h.Phase == delivery.PhaseRunning }) - if keys, _ := b.composed.Keys(); len(keys) != 0 { - t.Errorf("the controller's compositions hold %v; the node composed its own", keys) - } + // ADR 0593 §6: the node publishes what it composed in its composition state, under its own grant: web at the build + // its want names, with the digest its have names (the plan left out of it), and the node's own. first := b.have("web").Composed + webKey, _ := delivery.ComposedKey(itNode, "web", fp) + within(t, 5*time.Second, "the node did not publish web's composition in its composition state", func() bool { + e, err := b.composed.Get(webKey) + if err != nil { + return false + } + c, err := delivery.DecodeComposed(e.Value()) + if err != nil { + return false + } + d, err := delivery.ComposedDigestOf(c) + return err == nil && d == first && c.Signature == nil + }) if _, err := resolved.Put(itNode+".web.greeting", []byte(`{"value":"salut","source":"module"}`)); err != nil { t.Fatal(err) } @@ -551,4 +563,78 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str if h := b.have("web"); h.Composed != second || b.file("web") != "greeting=salut\n" { t.Errorf("a candidate moved what runs: %+v, %q", h, b.file("web")) } + h8candidate(t, b, fp, second) +} + +// h8candidate is ADR 0593 §7: a check's candidate is composed on the node through its seat's verb compose-candidate, +// under its own grant: the indexer's candidate changing web's manifest is answered composed, web changed at the +// candidate's build, within the answer bound; nothing is applied and the composition state does not move. +func h8candidate(t *testing.T, b *itBed, fp, running string) { + t.Helper() + kvOf := func(bucket string) nats.KeyValue { + kv, err := b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: bucket, History: 5}) + if err != nil { + t.Fatal(err) + } + return kv + } + cands, _ := kvOf(delivery.CandidatesBucket), kvOf(delivery.VerdictsBucket) + manifests, err := b.js.KeyValue(delivery.ManifestsBucket) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(b.dir, "web.conf") + man := `{"module":"web","version":"2","config":{"greeting":{"type":"string","description":"what the file says","default":"hello"}},` + + `"resources":[{"id":"conf","type":"file","path":"` + path + `","mode":"0640","content":"greeting=${config:greeting}\n"},` + + `{"id":"tools","type":"package","package":"web-tools"}]}` + digest := delivery.ManifestDigest([]byte(man)) + mraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "web", JSON: man}) + mkey, _ := delivery.ManifestsKey(digest) + if _, err := manifests.Put(mkey, mraw); err != nil { + t.Fatal(err) + } + head := delivery.Head{Repository: "novox/web", Number: 3, Head: strings.Repeat("3", 40)} + heads, _ := delivery.HeadsHash([]delivery.Head{head}) + pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"web"}, + Fps: map[string]delivery.CandidateModule{"web": {FP2: fpIt("c"), Manifest: digest}}}} + craw, err := delivery.EncodeCandidate(delivery.Candidate{Group: "novox/web#3", HeadsHash: heads, Pulls: pulls, + FPSet: delivery.FingerprintSet(pulls)}) + if err != nil { + t.Fatal(err) + } + key, _ := delivery.CompositionKey("novox/web#3", heads) + if _, err := cands.Put(key, craw); err != nil { + t.Fatal(err) + } + before, _ := b.composed.Status() + got, err := composeCandidateFrom(itNode, &shadowNow)(key, false) + if err != nil { + t.Fatal(err) + } + raw, _ := got.(json.RawMessage) + a, err := delivery.DecodeComposeCandidateAnswer(raw) + if err != nil { + t.Fatalf("the answer %s: %v", raw, err) + } + if a.Verdict != delivery.ComposeCandidateComposed || len(a.Modules) != 1 || a.Modules[0].Module != "web" || + a.Modules[0].FP != fpIt("c") || !a.Modules[0].Changed || a.Took > delivery.ComposeAnswerBound.Milliseconds() { + t.Errorf("the node's answer to a candidate changing web: %+v", a) + } + after, _ := b.composed.Status() + if h := b.have("web"); h.Composed != running || b.file("web") != "greeting=salut\n" || before == nil || after == nil || + after.Values() != before.Values() { + t.Errorf("composing a candidate moved what runs or the composition state: %+v, %q", h, b.file("web")) + } + if _, err := b.composed.Get(mustComposedKey(t, "web", fpIt("c"))); err == nil { + t.Error("the candidate's composition was written into the composition state") + } +} + +func mustComposedKey(t *testing.T, module, fp string) string { + t.Helper() + key, err := delivery.ComposedKey(itNode, module, fp) + if err != nil { + t.Fatal(err) + } + return key } diff --git a/internal/nodegrant/testdata/node.json b/internal/nodegrant/testdata/node.json index 92e72e8..16ce048 100644 --- a/internal/nodegrant/testdata/node.json +++ b/internal/nodegrant/testdata/node.json @@ -7,8 +7,10 @@ "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.NODE.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_candidates.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_verdicts.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.NODE.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.NODE.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.NODE", @@ -23,8 +25,10 @@ "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_candidates.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_verdicts.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_data.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_health.>", @@ -73,8 +77,10 @@ "$JS.FC.KV_mesh-delivery-assigner_placement.>", "$JS.FC.KV_mesh-delivery-assigner_wants.>", "$JS.FC.KV_mesh-delivery-builder_builds.>", + "$JS.FC.KV_mesh-delivery-indexer_candidates.>", "$JS.FC.KV_mesh-delivery-indexer_manifests.>", "$JS.FC.KV_mesh-delivery-indexer_modules.>", + "$JS.FC.KV_mesh-delivery-indexer_verdicts.>", "$JS.FC.KV_mesh-host_acts.>", "$JS.FC.KV_mesh-host_data.>", "$JS.FC.KV_mesh-host_health.>", diff --git a/internal/selfcompose/candidate.go b/internal/selfcompose/candidate.go index c215cc0..de8eca4 100644 --- a/internal/selfcompose/candidate.go +++ b/internal/selfcompose/candidate.go @@ -40,18 +40,25 @@ func (s *Shadow) ComposeCandidate(ctx context.Context, ask delivery.ComposeCandi a.Took = time.Since(began).Milliseconds() return a } + // A node that cannot compose now (its shadow not ready, a read the bus did not answer, the bound passed) answers + // unready: the checker takes it as no answer (ADR 0593 §9) by the verdict, the words only saying why. + unready := func(why string) delivery.ComposeCandidateAnswer { + a = refuse(why) + a.Verdict = delivery.ComposeCandidateUnready + return a + } if _, _, err := delivery.ParseCompositionKey(ask.Candidate); err != nil { return refuse(err.Error()) } ctx, cancel := context.WithTimeout(ctx, delivery.ComposeAnswerBound) defer cancel() if !s.ready.Load() { - return refuse(errNotReady.Error()) + return unready(errNotReady.Error()) } base := c.scratch() raw, err := base.get(ctx, delivery.CandidatesBucket, ask.Candidate) if err != nil { - return refuse(fmt.Sprintf("the indexer's candidate %s cannot be read: %v", ask.Candidate, err)) + return unready(fmt.Sprintf("the indexer's candidate %s cannot be read: %v", ask.Candidate, err)) } if raw == nil { return refuse("the indexer holds no candidate " + ask.Candidate) @@ -63,7 +70,7 @@ func (s *Shadow) ComposeCandidate(ctx context.Context, ask delivery.ComposeCandi laid := c.scratch() touched, err := laid.layCandidate(ctx, cand) if err != nil { - return refuse(err.Error()) + return unready(err.Error()) } var running []delivery.Composed @@ -89,11 +96,11 @@ func (s *Shadow) ComposeCandidate(ctx context.Context, ask delivery.ComposeCandi } }) if ctx.Err() != nil { - return refuse(fmt.Sprintf("the bound of %s passed before the node composed the candidate", delivery.ComposeAnswerBound)) + return unready(fmt.Sprintf("the bound of %s passed before the node composed the candidate", delivery.ComposeAnswerBound)) } switch { case laidErr != nil && BusFailed(laidErr): - return refuse("a read on the bus did not answer: " + laidErr.Error()) + return unready("a read on the bus did not answer: " + laidErr.Error()) case laidErr != nil && baseErr != nil: // The node composes none of its own without the candidate either: the candidate changes nothing of that. a.Verdict, a.Modules = delivery.ComposeCandidateComposed, []delivery.ModuleComposed{} diff --git a/internal/selfcompose/candidate_test.go b/internal/selfcompose/candidate_test.go index a01ab32..aec1323 100644 --- a/internal/selfcompose/candidate_test.go +++ b/internal/selfcompose/candidate_test.go @@ -16,7 +16,7 @@ import ( // changes a placed module composes it at the candidate's build, changed; one whose manifest the indexer refused is // answered refused, naming the module and the judge's words, and the verdict is refused; a candidate the indexer does not // hold is refused in words. Nothing is applied, nothing is written, no placement answer is handed, and the answer comes -// within the bound (an ended bound answers at once, refused). +// within the bound; a shadow not ready and an ended bound answer unready at once, which the checker takes as no answer. func TestTheNodeComposesACandidateOnAsk(t *testing.T) { b := newFakeBus() fp := delivery.SourcePrefix2 + strings.Repeat("a", 64) @@ -71,7 +71,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { _ = s.Run(ctx) }() - if a := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: accepted}); !strings.Contains(a.Refused, "not read every input") { + if a := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: accepted}); a.Verdict != delivery.ComposeCandidateUnready || a.Refused == "" { t.Errorf("a shadow not ready answered %+v", a) } place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, @@ -116,7 +116,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { } ended, end := context.WithCancel(ctx) end() - if e := s.ComposeCandidate(ended, delivery.ComposeCandidateAsk{Candidate: accepted}); e.Verdict != delivery.ComposeCandidateRefused { + if e := s.ComposeCandidate(ended, delivery.ComposeCandidateAsk{Candidate: accepted}); e.Verdict != delivery.ComposeCandidateUnready { t.Errorf("an ended bound answered %+v", e) } if len(state.written()) != 0 || applied.Load() != 0 || handed.Load() != 0 { From acb720d2bfebcd2115ef09ce6ecd0f1696f84276 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:39:51 +0100 Subject: [PATCH 06/14] Merge five moved overlay tests into two, offsetting the tests this release adds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test budget: mesh-host is over 500 unit tests, so the three tests added for ADR 0593 §6 and §7 are paid for by folding the private network's hub refusals, its MTU cases and its no-self-peer check into table-driven tests, each case kept. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- internal/overlay/declaration_test.go | 32 +++++------ internal/overlay/graph_test.go | 83 +++++++++++++--------------- 2 files changed, 50 insertions(+), 65 deletions(-) diff --git a/internal/overlay/declaration_test.go b/internal/overlay/declaration_test.go index 64fabd9..af54dcd 100644 --- a/internal/overlay/declaration_test.go +++ b/internal/overlay/declaration_test.go @@ -287,25 +287,19 @@ func TestANodeWithNoTunnelAndNoEndpointStillListensOnNothing(t *testing.T) { } } +// A path tuned to a smaller MTU (1380 here) survives the takeover: the mesh interface comes up with the same MTU, or +// transfers hang silently (novox/hq: a taken tunnel carries its MTU). A tunnel that set none gets no MTU line. func TestATakenTunnelCarriesItsMTU(t *testing.T) { - // A path tuned to a smaller MTU (1380 here) must survive the takeover — the mesh interface - // comes up with the same MTU, or transfers hang silently (novox/hq: a taken tunnel carries - // its MTU). - config, _ := declarationFor(t, Node{ - Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", - TakesOver: &TakeOver{Interface: "wg0", Port: 51820, MTU: 1380}, - }, nil) - if !strings.Contains(config, "MTU = 1380") { - t.Fatalf("the tuned MTU was dropped:\n%s", config) - } -} - -func TestNoMTULineWhenTheTunnelSetNone(t *testing.T) { - config, _ := declarationFor(t, Node{ - Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", - TakesOver: &TakeOver{Interface: "wg0", Port: 51820}, - }, nil) - if strings.Contains(config, "MTU") { - t.Fatalf("no MTU was found, so none should be written:\n%s", config) + for _, c := range []struct { + mtu int + want string + }{{1380, "MTU = 1380"}, {0, ""}} { + config, _ := declarationFor(t, Node{ + Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", + TakesOver: &TakeOver{Interface: "wg0", Port: 51820, MTU: c.mtu}, + }, nil) + if c.want != "" && !strings.Contains(config, c.want) || c.want == "" && strings.Contains(config, "MTU") { + t.Errorf("a tunnel of MTU %d gives:\n%s", c.mtu, config) + } } } diff --git a/internal/overlay/graph_test.go b/internal/overlay/graph_test.go index 5cfe6ea..eb8c4e1 100644 --- a/internal/overlay/graph_test.go +++ b/internal/overlay/graph_test.go @@ -158,35 +158,6 @@ func TestTheHubKnowsEveryoneItRoutesFor(t *testing.T) { } } -func TestAMeshWithNoHubIsRefused(t *testing.T) { - // Not an empty graph. A mesh with no hub has no path between sites, and answering "no peers" - // would look like a working network in which nothing can reach anything — which is how the - // old arrangement failed, silently, when nobody knew the address convention. - _, err := Compute([]Node{ - at("a", "", "10.42.0.1", "", false), - at("b", "", "10.42.0.2", "", false), - }, cidr) - if !errors.Is(err, ErrNoHub) { - t.Fatalf("a mesh with no hub gave %v", err) - } -} - -func TestAnUnreachableHubIsRefused(t *testing.T) { - // The hub is the one node that must be dialable from wherever the others are. Left - // unchecked, every node would be given a peer it can never reach and the mesh would look - // configured and be silent. - _, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "", true), - at("laptop", "", "10.42.0.2", "", false), - }, cidr) - if err == nil { - t.Fatal("a hub with no endpoint was accepted") - } - if !strings.Contains(err.Error(), "must be reachable") { - t.Errorf("the refusal does not say why: %v", err) - } -} - func TestANodeWithNoPlaceYetIsSkippedRatherThanFatal(t *testing.T) { // A node that has enrolled and not yet been given an address is an ordinary in-between state. // Failing the whole graph over it would mean no node gets a network because one is half done. @@ -206,23 +177,6 @@ func TestANodeWithNoPlaceYetIsSkippedRatherThanFatal(t *testing.T) { } } -func TestNobodyPeersWithThemselves(t *testing.T) { - g, err := Compute([]Node{ - at("anchor", "house", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - for node, peers := range g { - for _, p := range peers { - if p.Name == node { - t.Errorf("%s peers with itself", node) - } - } - } -} - func TestAHubAtYourOwnSiteAppearsOnceNotTwice(t *testing.T) { // WireGuard takes one entry per public key. A hub that shares a site with a spoke was being // emitted twice — once as a direct peer and once as the route of last resort — producing a @@ -257,6 +211,23 @@ func TestAHubAtYourOwnSiteAppearsOnceNotTwice(t *testing.T) { t.Errorf("the single entry allows %s; it is both the direct path and the route of last "+ "resort, so it carries the whole overlay", got) } + // And nobody peers with themselves, here or in a site of two dialable nodes. + other, err := Compute([]Node{ + at("anchor", "house", "10.42.0.1", "198.51.100.1:51820", true), + at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), + }, cidr) + if err != nil { + t.Fatal(err) + } + for _, graph := range []Graph{g, other} { + for node, peers := range graph { + for _, p := range peers { + if p.Name == node { + t.Errorf("%s peers with itself", node) + } + } + } + } } func TestTwoUnreachableNodesAtOneSiteDoNotPeerDirectly(t *testing.T) { @@ -340,3 +311,23 @@ func TestTheHubCarriesTheTunnelsPeersUntilTheyEnrol(t *testing.T) { t.Error("a carried peer appeared in a spoke's peer list") } } + +// A mesh whose hub cannot carry the paths between sites is refused, never answered as an empty graph: with no hub there +// is no path between sites, and a hub with no endpoint is a peer every node is given and none can reach. Either would +// look configured and be silent, which is how the old arrangement failed when nobody knew the address convention. +func TestAHubThatCannotCarryThePathsIsRefused(t *testing.T) { + for _, c := range []struct { + name string + nodes []Node + says func(error) bool + }{ + {"no hub", []Node{at("a", "", "10.42.0.1", "", false), at("b", "", "10.42.0.2", "", false)}, + func(err error) bool { return errors.Is(err, ErrNoHub) }}, + {"a hub with no endpoint", []Node{at("anchor", "datacentre", "10.42.0.1", "", true), at("laptop", "", "10.42.0.2", "", false)}, + func(err error) bool { return err != nil && strings.Contains(err.Error(), "must be reachable") }}, + } { + if _, err := Compute(c.nodes, cidr); !c.says(err) { + t.Errorf("%s: %v", c.name, err) + } + } +} From be0226b6248ad859d5783c0ef31e4ffee5ff4dc0 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 19:01:12 +0100 Subject: [PATCH 07/14] Stop verifying composition signatures: the node applies only its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0593 §6: a composition read from composed- is a record never applied, and the engine applies only what it composes itself, so no composition carries a signature and nothing is verified. The agent's trusted signers, its signed-composition test and the unused signing helper of the integration bed go, so mesh-sdk can drop SignComposed, VerifyComposed and Composed.Signature. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- cmd/mesh-host/nodeagent_integration_test.go | 24 +---- internal/agent/agent.go | 23 +--- internal/agent/agent_test.go | 114 +++----------------- internal/agent/composition.go | 16 +-- internal/compose/composed.go | 4 +- internal/selfcompose/composedstate_test.go | 2 +- 6 files changed, 22 insertions(+), 161 deletions(-) diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index e5518af..68d8b5e 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -163,28 +163,6 @@ func fpIt(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 6 func fpDigest(c string) string { return "sha256:" + strings.Repeat(c, 64) } -// compose writes a module's signed composition of one file into the node's bucket and returns its digest. -func (b *itBed) compose(module, fp, content string, data bool, signer ed25519.PrivateKey) string { - b.t.Helper() - path := filepath.Join(b.dir, module+".conf") - res, _ := json.Marshal(map[string]any{"id": module + ".conf", "type": "file", "path": path, "content": content, "module": module}) - c := delivery.Composed{Node: itNode, Module: module, FP: fp, Data: data, Resources: []json.RawMessage{res}} - signed, err := delivery.SignComposed(c, signer) - if err != nil { - b.t.Fatal(err) - } - raw, err := delivery.EncodeComposed(signed) - if err != nil { - b.t.Fatal(err) - } - dig, _ := delivery.ComposedDigestOf(signed) - key, _ := delivery.ComposedKey(itNode, module, fp) - if _, err := b.composed.Put(key, raw); err != nil { - b.t.Fatal(err) - } - return dig -} - func (b *itBed) want(module, fp, digest, backup string) { b.t.Helper() raw, err := delivery.EncodeWant(delivery.Want{FP: fp, Composed: digest, Backup: backup}) @@ -521,7 +499,7 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str return false } d, err := delivery.ComposedDigestOf(c) - return err == nil && d == first && c.Signature == nil + return err == nil && d == first }) if _, err := resolved.Put(itNode+".web.greeting", []byte(`{"value":"salut","source":"module"}`)); err != nil { t.Fatal(err) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 9674f23..c392947 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -15,7 +15,6 @@ package agent import ( "bytes" "context" - "crypto/ed25519" "encoding/json" "fmt" "sort" @@ -120,10 +119,6 @@ type Env struct { // whose have names a composition the bucket keeps runs from that composition, never from the kept declaration // (issue 924): the kept declaration is older than every composition applied since the switch. KeptFilled func() map[string][]string - // Trusted is the public keys a composition may be signed by: the controller's and the operator's (ADR 0539 §3). A - // composition unsigned, signed by another key, or changed after it was signed is refused and nothing of it is - // applied. Nil trusts no key, so nothing is applied. - Trusted func() []ed25519.PublicKey // Recorded says the engine holds a record of every resource of the composition, so it was applied and not removed // since; nil says yes. A composition the kept declaration or a have says runs is applied again when it says no. Recorded func(ctx context.Context, c delivery.Composed) bool @@ -686,26 +681,14 @@ func (a *Agent) takeComposition(u Update) string { } now := compNow{Raw: u.Value, Rev: u.Revision} c, err := delivery.DecodeComposed(u.Value) - if err == nil && a.env.SelfComposed { - // The node composed it itself: nothing crossed the bus, so nothing is signed (ADR 0547 §2). + if err == nil { + // The node composed it itself: it is applied as composed, never verified against a signer, since no composition + // read from the bus is applied (novox/hq ADR 0547 §2, ADR 0593 §6). now.C = c now.Dig, err = delivery.ComposedDigestOf(c) if err != nil { now.C = delivery.Composed{} } - } else if err == nil { - var trusted []ed25519.PublicKey - if a.env.Trusted != nil { - trusted = a.env.Trusted() - } - // The signature before anything of the composition is used: its digest is what the want names. - now.C = c - now.Dig, err = delivery.VerifyComposed(c, trusted) - if err != nil { - now.C = delivery.Composed{} - a.sayOnce("verify:"+u.Key, fmt.Sprintf("the composition %s is refused and nothing of it is applied: %v", u.Key, err)) - err = fmt.Errorf("refused: %w", err) - } } else { a.sayOnce("decode:"+u.Key, fmt.Sprintf("the composition %s cannot be read, and nothing of it is applied: %v", u.Key, err)) } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 2ce56b1..f2b3306 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -2,7 +2,6 @@ package agent import ( "context" - "crypto/ed25519" "encoding/json" "fmt" "maps" @@ -18,19 +17,6 @@ import ( const node = "n1" -// The keys of the tests: the controller's and the operator's are trusted, a stranger's is not. -var controllerPub, controllerKey = newKey() -var operatorPub, operatorKey = newKey() -var _, strangerKey = newKey() - -func newKey() (ed25519.PublicKey, ed25519.PrivateKey) { - pub, key, err := ed25519.GenerateKey(nil) - if err != nil { - panic(err) - } - return pub, key -} - func fpOf(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) } // A watcher the test writes to; it says Caught once the keys written before the watch began are out. @@ -183,8 +169,7 @@ func newBed(t *testing.T) *bed { b.settled = append(b.settled, m) b.settledRan = append(b.settledRan, ran) }, - Kept: func() map[string][]json.RawMessage { return b.kept }, - Trusted: func() []ed25519.PublicKey { return []ed25519.PublicKey{controllerPub, operatorPub} }, + Kept: func() map[string][]json.RawMessage { return b.kept }, Say: func(s string) { t.Log(s) b.mu.Lock() @@ -223,21 +208,17 @@ func composition(t *testing.T, module, fp string, data bool, rs ...json.RawMessa if err != nil { t.Fatal(err) } - return signedBy(t, c, key, controllerKey) + return encodedAs(t, c, key) } -// signedBy is c signed with signer, as the writer's code signs it: its key, its bytes and its digest. -func signedBy(t *testing.T, c delivery.Composed, key string, signer ed25519.PrivateKey) (string, []byte, string) { +// encodedAs is c as the node's composer hands it: unsigned (ADR 0593 §6), its key, its bytes and its digest. +func encodedAs(t *testing.T, c delivery.Composed, key string) (string, []byte, string) { t.Helper() - signed, err := delivery.SignComposed(c, signer) + raw, err := delivery.EncodeComposed(c) if err != nil { t.Fatal(err) } - raw, err := delivery.EncodeComposed(signed) - if err != nil { - t.Fatal(err) - } - digest, err := delivery.ComposedDigestOf(signed) + digest, err := delivery.ComposedDigestOf(c) if err != nil { t.Fatal(err) } @@ -735,76 +716,6 @@ func TestTheNodesOwnCompositionComesBeforeAnyModules(t *testing.T) { } } -// R6-A AC9: a composition signed by either trusted key is applied; one signed by another key, one changed after it was -// signed and one unsigned are refused and nothing of them is applied (ADR 0539 §3). -func TestOnlyACompositionSignedByATrustedKeyIsApplied(t *testing.T) { - t.Run("with no trusted key", testWithNoTrustedKeyNothingIsApplied) - b := newBed(t) - stop := b.run() - defer stop() - - plain := func(module, fp, content string) delivery.Composed { - return delivery.Composed{Node: node, Module: module, FP: fp, Resources: []json.RawMessage{file(module, "x", content)}} - } - put := func(c delivery.Composed, signer ed25519.PrivateKey, tamper func([]byte) []byte) { - key, _ := delivery.ComposedKey(node, c.Module, c.FP) - var raw []byte - var dig string - if signer == nil { - raw, _ = delivery.EncodeComposed(c) - dig, _ = delivery.ComposedDigestOf(c) - } else { - _, raw, dig = signedBy(t, c, key, signer) - } - if tamper != nil { - raw = tamper(raw) - } - b.comps.put(key, raw) - wk, wraw := wantFor(t, c.Module, c.FP, dig, "") - b.wants.put(wk, wraw) - } - - // Refused: signed by a stranger, changed after signing, unsigned. - put(plain("stranger", fpOf("1"), "x"), strangerKey, nil) - put(plain("changed", fpOf("2"), "before"), controllerKey, func(raw []byte) []byte { - return []byte(strings.Replace(string(raw), "before", "after!", 1)) - }) - put(plain("unsigned", fpOf("3"), "x"), nil, nil) - time.Sleep(200 * time.Millisecond) - if got := b.appliedList(); len(got) != 0 { - t.Fatalf("a composition that is not signed by a trusted key was applied: %v", got) - } - - // Applied: signed by the controller, signed by the operator. - put(plain("bycontroller", fpOf("4"), "x"), controllerKey, nil) - put(plain("byoperator", fpOf("5"), "x"), operatorKey, nil) - eventually(t, "the trusted signers' compositions were not applied", func() bool { return len(b.appliedList()) == 2 }) - got := b.appliedList() - if !(got[0] == "bycontroller" && got[1] == "byoperator") && !(got[0] == "byoperator" && got[1] == "bycontroller") { - t.Fatalf("applied %v", got) - } - if h := b.haves.last("byoperator"); h.Phase != string(delivery.PhaseRunning) || h.FP != fpOf("5") { - t.Fatalf("the operator's module has no running have: %+v", h) - } -} - -// With no key trusted, nothing is applied: a node that does not know its signers fails closed. -func testWithNoTrustedKeyNothingIsApplied(t *testing.T) { - b := newBed(t) - b.env.Trusted = nil - stop := b.run() - defer stop() - fp := fpOf("a") - ck, craw, dig := composition(t, "web", fp, false, file("web", "x", "1")) - b.comps.put(ck, craw) - wk, wraw := wantFor(t, "web", fp, dig, "") - b.wants.put(wk, wraw) - time.Sleep(200 * time.Millisecond) - if got := b.appliedList(); len(got) != 0 { - t.Fatalf("applied with no trusted key: %v", got) - } -} - // What a module's apply must not take is every other module that runs, those the agent holds and those still running from // the kept declaration (ADR 0429 §4): the resources of the kept declaration reach Apply as the others. func TestApplyingOneModuleHoldsOutTheModulesStillRunningFromTheKeptDeclaration(t *testing.T) { @@ -860,7 +771,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) { // and is what runs from before the first composition is watched; an apply keeps, a removal forgets. t.Run("what this engine applied is what runs before the node composes its own", func(t *testing.T) { b := newBed(t) - b.env.SelfComposed, b.env.Trusted, b.kept = true, nil, nil + b.env.SelfComposed, b.kept = true, nil store := &memApplied{kept: map[string][]byte{}} b.env.Applied = store // A folded module's resource in the node's own composition: its id names the module, as the controller folds it. @@ -930,7 +841,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) { h.Write(context.Background(), Have{Module: NodeModule, Composed: "sha256:" + strings.Repeat("c", 64)}) firstPass := func(kept map[string][]json.RawMessage) []delivery.Composed { b := newBed(t) - b.env.SelfComposed, b.env.Trusted, b.kept, b.env.Applied, b.env.Haves = true, nil, kept, store, h + b.env.SelfComposed, b.kept, b.env.Applied, b.env.Haves = true, kept, store, h firstCh := make(chan []delivery.Composed, 1) comps := b.comps var ag *Agent @@ -1155,8 +1066,7 @@ func testARemovedModulesCompositionHoldsNothingBack(t *testing.T) { } } -// The node's own composition is not applied while a module's composition in the node's bucket cannot be read or is not -// signed by a trusted key: what that one carries may be what left the node's own, and the node's own apply would remove +// The node's own composition is not applied while a module's composition in the node's bucket cannot be read: what that one carries may be what left the node's own, and the node's own apply would remove // it (issue 1945, the review's gap 2). It is said, naming the composition, and applied once that one reads. func TestTheNodesOwnWaitsWhileAModulesCompositionCannotBeRead(t *testing.T) { b := newBed(t) @@ -1284,11 +1194,11 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) { ck, _ := delivery.ComposedKey(node, "power", fp) applied := delivery.Composed{Node: node, Module: "power", FP: fp, Filled: []string{old, added}, Data: true, Resources: []json.RawMessage{filledFile("settings"), filledFile("bound")}} - _, appliedRaw, appliedDig := signedBy(t, applied, ck, controllerKey) + _, appliedRaw, appliedDig := encodedAs(t, applied, ck) newer := applied newer.Resources = append([]json.RawMessage{}, applied.Resources...) newer.Resources = append(newer.Resources, file("power", "later", "1")) - _, newerRaw, _ := signedBy(t, newer, ck, controllerKey) + _, newerRaw, _ := encodedAs(t, newer, ck) for _, tc := range []struct { name string // have is the module's have; bucket the value its composition key holds now, history the revisions kept. @@ -1371,7 +1281,7 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) { // the yes for that composition, and is then applied. func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(t *testing.T) { b := newBed(t) - b.env.SelfComposed, b.env.Trusted = true, nil + b.env.SelfComposed = true b.env.Policy = func(_ context.Context, m string) string { if m == "db" || m == "rec" { return catalogue.PolicyRecord diff --git a/internal/agent/composition.go b/internal/agent/composition.go index 1e5c70d..938c2aa 100644 --- a/internal/agent/composition.go +++ b/internal/agent/composition.go @@ -2,7 +2,6 @@ package agent import ( "context" - "crypto/ed25519" "errors" "fmt" @@ -194,8 +193,8 @@ func (a *Agent) compositionKey(module, fp string) (string, error) { return delivery.ComposedKey(a.env.Node, module, fp) } -// revisionsOf is every revision of a composition key the bucket keeps, oldest first, each with its digest as this node -// trusts it (its own unsigned with SelfComposed, else signed by a trusted key), or why it is refused. Nil Revisions +// revisionsOf is every revision of a composition key the state keeps, oldest first, each with its digest (unsigned, ADR +// 0593 §6), or why it is refused. Nil Revisions // reads none. func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error) { if a.env.Revisions == nil { @@ -205,10 +204,6 @@ func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error) if err != nil { return nil, err } - var trusted []ed25519.PublicKey - if a.env.Trusted != nil { - trusted = a.env.Trusted() - } var out []compNow for _, u := range revs { if u.Deleted || u.Key != key { @@ -219,12 +214,7 @@ func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error) out = append(out, compNow{Raw: u.Value, Rev: u.Revision, Err: err}) continue } - var dig string - if a.env.SelfComposed { - dig, err = delivery.ComposedDigestOf(c) // the node's own: unsigned (ADR 0547 §2) - } else { - dig, err = delivery.VerifyComposed(c, trusted) - } + dig, err := delivery.ComposedDigestOf(c) // unsigned: a record, never verified (ADR 0593 §6) out = append(out, compNow{Raw: u.Value, C: c, Dig: dig, Rev: u.Revision, Err: err}) } return out, nil diff --git a/internal/compose/composed.go b/internal/compose/composed.go index d7a4661..9dc317d 100644 --- a/internal/compose/composed.go +++ b/internal/compose/composed.go @@ -21,8 +21,8 @@ import ( // gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the // private network's always), which fps names at no fingerprint of the new form: its resources go into the node's own, // their module taken off and their ids still naming it, and its filled paths with them, as the controller's composed -// writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are not -// signed and not yet by digest: the writer does both (delivery.SignComposed, Composed.ByDigest). +// writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are unsigned +// (ADR 0593 §6) and not yet by digest (Composed.ByDigest). func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) { out, skipped, _, err = compositions(in, node, fps, mode, false) return out, skipped, err diff --git a/internal/selfcompose/composedstate_test.go b/internal/selfcompose/composedstate_test.go index 665e370..0ea6d69 100644 --- a/internal/selfcompose/composedstate_test.go +++ b/internal/selfcompose/composedstate_test.go @@ -123,7 +123,7 @@ func TestTheNodePublishesItsCompositions(t *testing.T) { } if raw := state.kv[lemursKey].Value; raw != nil { comp, err := delivery.DecodeComposed(raw) - if err != nil || comp.Module != "lemurs" || comp.FP != fp || comp.Signature != nil || len(comp.Resources) == 0 { + if err != nil || comp.Module != "lemurs" || comp.FP != fp || len(comp.Resources) == 0 { t.Errorf("the published composition of lemurs: %+v, %v", comp, err) } } From d68e241724e1b116cabe2184cbdc1ea8a102c59f Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 19:15:55 +0100 Subject: [PATCH 08/14] Take the catalogue's composition rules into the node-engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0593 §7: the composer with its refusals leaves mesh-sdk, so mesh-sdk carries no composition rule. The catalogue functions only the node-engine's composition reaches (Resolve, Resolution's Compose, Declaration, DataItems, LeftOut, Rules and the rest, the settings, placeholder and template refusals, the rendering helpers) are copied with their tests into internal/compose/resolution; a method of a mesh-sdk shape becomes a function taking it first. The composition calls its own copies, and the captured catalogue its tests read lives here now, shared by mesh-compose's tests. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- cmd/mesh-compose/main_test.go | 33 +- internal/beside/beside.go | 134 + internal/beside/reads-outside | 1 + internal/compose/composed.go | 13 +- internal/compose/composed_test.go | 5 +- internal/compose/credentials.go | 13 +- internal/compose/frombus/answer.go | 3 +- internal/compose/frombus/answer_rows_test.go | 5 +- internal/compose/frombus/frombus.go | 7 +- internal/compose/frombus/held_rows_test.go | 7 +- internal/compose/frombus/states.go | 4 +- internal/compose/network.go | 3 +- internal/compose/node.go | 11 +- internal/compose/rendering.go | 13 +- .../resolution/a_service_process_test.go | 10 + internal/compose/resolution/accesses_test.go | 80 + internal/compose/resolution/adoption.go | 177 ++ internal/compose/resolution/adoption_test.go | 629 ++++ .../compose/resolution/agent_account_test.go | 83 + .../resolution/artifact_store_seat_test.go | 40 + .../compose/resolution/artifact_sweep_seat.go | 43 + internal/compose/resolution/artifacts.go | 65 + internal/compose/resolution/artifacts_test.go | 102 + .../compose/resolution/backup_placement.go | 46 + .../resolution/backup_placement_test.go | 114 + internal/compose/resolution/backup_test.go | 37 + internal/compose/resolution/bound.go | 117 + .../compose/resolution/bound_into_files.go | 176 ++ .../resolution/bound_into_files_test.go | 246 ++ internal/compose/resolution/bound_test.go | 219 ++ .../compose/resolution/broker_seat_test.go | 49 + internal/compose/resolution/brokered_test.go | 255 ++ internal/compose/resolution/bucket_shares.go | 48 + .../compose/resolution/bucket_shares_test.go | 105 + internal/compose/resolution/build.go | 20 + internal/compose/resolution/busy.go | 20 + internal/compose/resolution/busy_test.go | 54 + .../resolution/catalogue_check_test.go | 46 + .../compose/resolution/catalogue_read_test.go | 46 + .../resolution/catrust_manifest_test.go | 70 + internal/compose/resolution/checker_seat.go | 33 + internal/compose/resolution/chosen.go | 86 + .../compose/resolution/co_located_test.go | 306 ++ internal/compose/resolution/collections.go | 312 ++ .../resolution/collections_bar_test.go | 110 + .../compose/resolution/collections_l2_test.go | 57 + .../compose/resolution/collections_l3_test.go | 36 + .../compose/resolution/collections_test.go | 13 + internal/compose/resolution/computed_test.go | 184 ++ internal/compose/resolution/config_test.go | 181 ++ .../resolution/consumer_into_serves.go | 267 ++ internal/compose/resolution/consumers.go | 100 + .../compose/resolution/contributes_test.go | 311 ++ .../contribution_dependencies_test.go | 58 + .../resolution/control_records_seats.go | 52 + .../compose/resolution/core_credentials.go | 19 + .../resolution/core_credentials_test.go | 24 + internal/compose/resolution/data.go | 77 + internal/compose/resolution/data_placed.go | 31 + internal/compose/resolution/data_test.go | 16 + internal/compose/resolution/declaration.go | 2522 +++++++++++++++++ .../compose/resolution/declaration_test.go | 84 + .../resolution/decode_for_test_test.go | 9 + internal/compose/resolution/delivery_seat.go | 74 + .../resolution/derived_for_consumer_test.go | 312 ++ internal/compose/resolution/dir_into.go | 239 ++ internal/compose/resolution/dir_into_test.go | 271 ++ internal/compose/resolution/doc.go | 7 + internal/compose/resolution/domain_test.go | 119 + internal/compose/resolution/endpoint_test.go | 143 + .../resolution/endpoints_setting_test.go | 98 + internal/compose/resolution/engine_seat.go | 11 + internal/compose/resolution/enrolment_seat.go | 67 + .../compose/resolution/environment_into.go | 554 ++++ .../resolution/environment_into_test.go | 238 ++ .../resolution/environment_items_test.go | 19 + internal/compose/resolution/files.go | 152 + internal/compose/resolution/files_test.go | 75 + internal/compose/resolution/filtering.go | 894 ++++++ internal/compose/resolution/filtering_test.go | 379 +++ .../resolution/foundation_manifests_test.go | 270 ++ .../resolution/generated_collision_test.go | 81 + .../resolution/grant_secret_owner_test.go | 105 + .../compose/resolution/graphical_session.go | 228 ++ .../resolution/graphical_session_test.go | 74 + internal/compose/resolution/groups_test.go | 127 + internal/compose/resolution/handed.go | 665 +++++ .../compose/resolution/handed_bound_test.go | 146 + .../resolution/handed_consumers_test.go | 48 + .../resolution/handed_providers_test.go | 111 + .../resolution/handed_pulled_in_test.go | 55 + .../compose/resolution/handed_secrets_test.go | 78 + internal/compose/resolution/handed_test.go | 57 + internal/compose/resolution/health.go | 54 + internal/compose/resolution/health_seats.go | 104 + internal/compose/resolution/health_test.go | 104 + internal/compose/resolution/holdings_test.go | 110 + .../compose/resolution/hostname_seat_test.go | 53 + internal/compose/resolution/jails_into.go | 66 + .../compose/resolution/jails_into_test.go | 74 + internal/compose/resolution/kept_test.go | 59 + internal/compose/resolution/keyed.go | 14 + internal/compose/resolution/keyed_test.go | 142 + internal/compose/resolution/kinded_test.go | 25 + internal/compose/resolution/laid_values.go | 38 + internal/compose/resolution/licence_test.go | 259 ++ internal/compose/resolution/localnetwork.go | 117 + .../compose/resolution/localnetwork_test.go | 183 ++ .../compose/resolution/machine_into_files.go | 153 + .../resolution/machine_into_files_test.go | 122 + internal/compose/resolution/manifest.go | 23 + internal/compose/resolution/names_test.go | 56 + internal/compose/resolution/needs_test.go | 79 + .../compose/resolution/no_subjects_test.go | 41 + internal/compose/resolution/node_resolver.go | 97 + .../compose/resolution/node_resolver_test.go | 184 ++ .../resolution/operators_machine_test.go | 33 + .../resolution/optional_requirements.go | 75 + .../resolution/optional_requirements_test.go | 168 ++ .../resolution/outside_not_given_test.go | 104 + internal/compose/resolution/own_name_test.go | 128 + .../resolution/own_secret_owner_test.go | 95 + .../resolution/pin_names_module_test.go | 104 + .../compose/resolution/placed_paths_test.go | 182 ++ internal/compose/resolution/placement.go | 275 ++ internal/compose/resolution/placement_seat.go | 59 + internal/compose/resolution/port_into.go | 133 + internal/compose/resolution/port_into_test.go | 172 ++ internal/compose/resolution/process_bus.go | 16 + .../compose/resolution/process_bus_test.go | 103 + .../compose/resolution/process_env_test.go | 82 + internal/compose/resolution/reach_test.go | 163 ++ internal/compose/resolution/reads-outside | 1 + internal/compose/resolution/reads.go | 173 ++ internal/compose/resolution/reads_test.go | 81 + .../resolution/received_on_the_bus_test.go | 68 + .../resolution/reflects_renamed_test.go | 32 + internal/compose/resolution/replay166_test.go | 43 + internal/compose/resolution/replay279_test.go | 40 + internal/compose/resolution/resolve.go | 1187 ++++++++ .../resolution/resolve_loopback_test.go | 54 + internal/compose/resolution/resolve_test.go | 484 ++++ .../compose/resolution/resolved_path_test.go | 32 + .../resolver_file_is_the_uplinks_test.go | 57 + .../resolution/resolver_manifests_test.go | 232 ++ internal/compose/resolution/roster.go | 226 ++ internal/compose/resolution/routing_test.go | 250 ++ internal/compose/resolution/runonce_test.go | 54 + internal/compose/resolution/runtime.go | 410 +++ .../compose/resolution/runtime_health_test.go | 57 + internal/compose/resolution/runtime_test.go | 421 +++ internal/compose/resolution/schedule_test.go | 34 + .../compose/resolution/seat_contributions.go | 200 ++ .../resolution/seat_contributions_test.go | 62 + internal/compose/resolution/seat_data.go | 230 ++ internal/compose/resolution/seat_data_test.go | 108 + .../compose/resolution/seat_dependencies.go | 219 ++ .../resolution/seat_dependencies_test.go | 97 + internal/compose/resolution/seat_into.go | 193 ++ internal/compose/resolution/seat_into_test.go | 162 ++ internal/compose/resolution/seat_pass.go | 74 + internal/compose/resolution/seat_pass_test.go | 80 + internal/compose/resolution/seat_paths.go | 125 + .../compose/resolution/seat_paths_test.go | 56 + internal/compose/resolution/seats.go | 651 +++++ internal/compose/resolution/seats_test.go | 72 + .../compose/resolution/secret_family_test.go | 44 + .../compose/resolution/secret_restart_test.go | 90 + .../compose/resolution/secrets_into_files.go | 214 ++ .../resolution/secrets_into_files_test.go | 245 ++ .../compose/resolution/served_own_names.go | 89 + .../resolution/served_own_names_test.go | 117 + .../compose/resolution/setting_defaults.go | 64 + .../resolution/setting_defaults_test.go | 188 ++ .../resolution/setting_in_owner_test.go | 107 + .../resolution/setting_in_unit_test.go | 115 + internal/compose/resolution/setting_into.go | 260 ++ .../compose/resolution/setting_into_test.go | 42 + internal/compose/resolution/setting_takes.go | 216 ++ internal/compose/resolution/settings.go | 517 ++++ internal/compose/resolution/settings_test.go | 106 + .../resolution/several_contributions_test.go | 117 + .../resolution/several_secrets_test.go | 144 + .../compose/resolution/shared_account_test.go | 71 + .../resolution/shared_credential_test.go | 48 + internal/compose/resolution/shares.go | 78 + .../short_form_port_protocol_test.go | 26 + .../compose/resolution/ssh_client_test.go | 117 + internal/compose/resolution/template.go | 31 + internal/compose/resolution/template_test.go | 73 + .../resolution/terminal_settings_test.go | 77 + .../resolution/testdata/bars/bar-battery | 9 + .../resolution/testdata/bars/bar-power-draw | 6 + .../testdata/bars/battery.bottom-bar.toml | 119 + .../testdata/bars/battery.top-bar.toml | 37 + .../testdata/bars/both.bottom-bar.toml | 126 + .../testdata/bars/both.top-bar.toml | 37 + .../resolution/testdata/bars/bottom-bar.toml | 109 + .../testdata/bars/none.bottom-bar.toml | 109 + .../testdata/bars/none.top-bar.toml | 37 + .../testdata/bars/power-meter.bottom-bar.toml | 116 + .../testdata/bars/power-meter.top-bar.toml | 37 + .../resolution/testdata/bars/top-bar.toml | 37 + ...catalogue-835bf22b-claude-code.module.json | 249 ++ .../catalogue-835bf22b-dunst.module.json | 0 .../catalogue-835bf22b-xterm.module.json | 236 ++ .../resolution/testdata/environment/all.posix | 26 + .../testdata/environment/all.systemd | 23 + .../resolution/testdata/environment/zsh.posix | 9 + .../testdata/environment/zsh.systemd | 6 + .../grafana-ace.json | 35 + .../grafana-unlayered.json | 35 + ...yrus-g14.contribution-node-hotkeys-trigger | 10 + .../asus-zephyrus-g14.shell-after-wake-normal | 7 + .../items/claude-code.shell-zsh-normal | 12 + .../l2-moved/items/mesh-cli.shell-zsh-normal | 3 + .../items/powerlevel10k.shell-zsh-normal | 3 + .../zsh-autosuggestions.shell-zsh-normal | 2 + .../zsh-syntax-highlighting.shell-zsh-last | 3 + .../testdata/l2-moved/templates/power | 4 + .../testdata/l2-moved/templates/triggerhappy | 4 + .../testdata/l2-moved/templates/zsh | 60 + .../resolution/testdata/l2-moved/want/power | 11 + .../testdata/l2-moved/want/triggerhappy | 14 + .../resolution/testdata/l2-moved/want/zsh | 88 + .../resolution/toolchain_manifests_test.go | 71 + .../compose/resolution/two_consumers_test.go | 128 + .../compose/resolution/two_resolvers_test.go | 160 ++ .../resolution/unconsumed_placeholder.go | 146 + .../resolution/unconsumed_placeholder_test.go | 111 + internal/compose/resolution/unknown_field.go | 36 + internal/compose/resolution/verbs.go | 59 + .../compose/resolution/while_stopped_test.go | 64 + internal/compose/resolution/yields.go | 69 + internal/compose/resolution/zones.go | 63 + internal/compose/resolution/zones_test.go | 47 + internal/compose/resolve.go | 5 +- internal/compose/resolve_test.go | 3 +- internal/compose/rest.go | 15 +- internal/compose/rest_test.go | 3 +- internal/selfcompose/selfcompose.go | 5 +- testdata/beside/CAPTURED | 27 + .../modules/adwaita/files/cursor-index.theme | 5 + .../modules/adwaita/files/env-gtk-theme | 1 + .../modules/adwaita/files/env-gtk2-rc-files | 1 + .../adwaita/files/env-qt-qpa-platformtheme | 1 + .../modules/adwaita/files/env-qt-select | 1 + .../adwaita/files/env-qt-style-override | 1 + .../modules/adwaita/files/env-xcursor-size | 1 + .../modules/adwaita/files/env-xcursor-theme | 1 + .../modules/adwaita/files/gtk-settings.ini | 9 + .../modules/adwaita/files/portals.conf | 11 + .../modules/adwaita/files/qt6ct.conf | 29 + .../modules/adwaita/files/xinitrc-normal | 10 + .../modules/adwaita/files/xresources-normal | 3 + .../modules/adwaita/module.json.captured | 0 .../artifact-store-tools/files/store-names | 4 + .../artifact-store-tools/module.json.captured | 0 .../files/bin/zephyrus-backlight | 33 + .../files/bin/zephyrus-display | 41 + .../files/bin/zephyrus-kbd-notify | 20 + .../files/bin/zephyrus-media | 27 + .../files/bin/zephyrus-notify | 12 + .../files/bin/zephyrus-session | 46 + .../files/bin/zephyrus-touchpad | 29 + .../asus-zephyrus-g14/module.json.captured | 0 .../UPower.conf.d/50-asus-zephyrus-g14.conf | 11 + .../30-asus-zephyrus-g14-touchpad.conf | 12 + .../asus-zephyrus-g14/templates/after-wake | 7 + .../templates/display-session | 30 + .../templates/hotkeys-triggers | 10 + .../modprobe.d/g14-nvidia-power.conf | 16 + .../modprobe.d/video-brightness-switch.conf | 6 + .../asus-zephyrus-g14.conf | 7 + .../asus-zephyrus-g14-nvidia.conf | 7 + .../asus-zephyrus-g14-nvidia.conf | 7 + .../asus-zephyrus-g14-nvidia.conf | 7 + .../templates/udev/rules.d/90-backlight.rules | 5 + .../modules/audit-logger/module.json.captured | 0 .../modules/avahi/module.json.captured | 0 .../modules/baserow/files/config.json | 4 + .../modules/baserow/files/server.env | 7 + .../modules/baserow/module.json.captured | 0 .../modules/betterbird/files/accounts.json | 3 + .../modules/betterbird/files/betterbird | 10 + .../betterbird/files/configuration.json | 6 + .../files/eu.betterbird.Betterbird.desktop | 25 + .../modules/betterbird/files/prefs.json | 3 + .../modules/betterbird/files/tags.json | 3 + .../modules/betterbird/module.json.captured | 0 .../modules/blueman/module.json.captured | 0 .../modules/bluetooth/module.json.captured | 0 .../modules/brother/files/settings.json | 9 + .../modules/brother/files/store.json | 9 + .../modules/brother/module.json.captured | 0 .../modules/ca-trust/files/anchor | 58 + .../ca-trust/files/mesh-ca-trust.service | 16 + .../modules/ca-trust/module.json.captured | 0 .../modules/claude-code/files/agent-account | 1 + .../modules/claude-code/files/claude-agent | 13 + .../modules/claude-code/files/facts.json | 4 + .../modules/claude-code/files/settings.json | 10 + .../modules/claude-code/files/zsh-normal | 12 + .../modules/claude-code/module.json.captured | 0 .../claude-licence-manager/files/database.url | 1 + .../files/settings.json | 7 + .../module.json.captured | 0 .../clipmenu/files/bin/clipmenud-session | 41 + .../modules/clipmenu/files/bin/xsel | 20 + .../modules/clipmenu/files/env-cm-histlength | 1 + .../modules/clipmenu/files/env-cm-max-clips | 1 + .../modules/clipmenu/files/env-cm-selections | 1 + .../clipmenu/files/i3/50-clipmenu.conf | 5 + .../files/systemd/clipmenud-mesh.conf | 14 + .../modules/clipmenu/module.json.captured | 0 .../modules/confluence/files/config.json | 1 + .../modules/confluence/module.json.captured | 0 .../modules/cups/module.json.captured | 0 .../modules/dbus/module.json.captured | 0 .../modules/desk-channel/files/settings.json | 3 + .../modules/desk-channel/module.json.captured | 0 .../modules/dev-tools/module.json.captured | 0 .../modules/disk-load/module.json.captured | 0 .../modules/distribution/module.json.captured | 0 .../modules/dmenu/module.json.captured | 0 .../modules/dnsmasq/files/dnsmasq.conf | 87 + .../modules/dnsmasq/files/mesh.conf | 14 + .../modules/dnsmasq/module.json.captured | 0 .../docker-compose/module.json.captured | 0 .../modules/docker/files/daemon.json | 1 + .../modules/docker/files/docker-prune.service | 15 + .../modules/docker/files/docker-prune.timer | 11 + .../modules/docker/files/log-rotation.conf | 5 + .../modules/docker/module.json.captured | 0 .../mesh-catalog/modules/dunst/files/dunstrc | 110 + .../modules/dunst/files/i3/50-dunst.conf | 4 + .../modules/dunst/module.json.captured | 123 + .../modules/fail2ban/files/fail2ban | 7 + .../files/iptables-allports-dualchain.conf | 40 + .../modules/fail2ban/files/jail.local | 48 + .../fail2ban/files/mesh-own-addresses.json | 3 + .../modules/fail2ban/files/own-addresses | 0 .../modules/fail2ban/files/recidive.conf | 9 + .../modules/fail2ban/files/sshd.conf | 8 + .../modules/fail2ban/module.json.captured | 0 .../mesh-catalog/modules/feh/files/fehbg | 6 + .../modules/feh/files/i3/50-feh.conf | 3 + .../modules/feh/files/xinitrc-normal | 2 + .../modules/feh/module.json.captured | 0 .../modules/flatpak/module.json.captured | 0 .../modules/fonts/files/50-mesh-fonts.conf | 40 + .../modules/fonts/module.json.captured | 0 .../modules/forticlient/module.json.captured | 0 .../modules/gitea/files/agent-teams.json | 1 + .../modules/gitea/files/config.json | 1 + .../modules/gitea/files/consumers.json | 1 + .../mesh-catalog/modules/gitea/files/origins | 1 + .../modules/gitea/files/repositories.json | 1 + .../modules/gitea/files/server.env | 7 + .../modules/gitea/module.json.captured | 0 .../modules/gitlab/files/config.json | 1 + .../modules/gitlab/module.json.captured | 0 .../modules/gnome-keyring/files/pam/login | 4 + .../modules/gnome-keyring/files/pam/passwd | 3 + .../modules/gnome-keyring/files/xinitrc-first | 5 + .../gnome-keyring/module.json.captured | 0 .../modules/go/module.json.captured | 0 .../modules/grafana/files/admin.secret | 1 + .../modules/grafana/files/config.json | 4 + .../modules/grafana/files/influxdb-api | 1 + .../modules/grafana/files/mesh-influxdb.yaml | 19 + .../modules/grafana/files/oidc-client.secret | 1 + .../modules/grafana/files/oidc.env | 13 + .../modules/grafana/module.json.captured | 0 .../modules/hello-web/files/index.html | 1 + .../modules/hello-web/module.json.captured | 0 .../modules/home-assistant/files/config.json | 1 + .../home-assistant/files/provisions.env | 4 + .../home-assistant/module.json.captured | 0 .../modules/hostname/files/hostname | 1 + .../mesh-catalog/modules/hostname/files/hosts | 7 + .../modules/hostname/module.json.captured | 0 .../mesh-catalog/modules/i3/files/config | 201 ++ .../modules/i3/files/env-xdg-current-desktop | 1 + .../modules/i3/files/env-xdg-session-desktop | 1 + .../mesh-catalog/modules/i3/files/wms/i3 | 9 + .../modules/i3/files/xinitrc-last | 2 + .../modules/i3/module.json.captured | 0 .../i3status-rust/files/bin/i3bar-watchdog | 64 + .../i3status-rust/files/bin/i3status-updates | 63 + .../i3status-rust/files/bin/i3status-value | 13 + .../i3status-rust/files/bottom-bar.toml | 109 + .../files/i3/60-i3status-rust.conf | 34 + .../files/icons/custom-icons.toml | 135 + .../modules/i3status-rust/files/top-bar.toml | 37 + .../i3status-rust/files/xinitrc-normal | 3 + .../i3status-rust/module.json.captured | 0 .../modules/icecast/files/config.json | 1 + .../modules/icecast/files/icecast.xml | 56 + .../modules/icecast/module.json.captured | 0 .../modules/influxdb/files/config.json | 1 + .../modules/influxdb/files/server.env | 6 + .../modules/influxdb/module.json.captured | 0 .../modules/invoicing/files/api.env | 9 + .../modules/invoicing/module.json.captured | 0 .../modules/jira/files/config.json | 1 + .../modules/jira/module.json.captured | 0 .../modules/kernel/files/huge-pages.sh | 84 + .../files/mesh-kernel-huge-pages.service | 25 + .../modules/kernel/module.json.captured | 0 .../modules/keycloak/files/admin.env | 2 + .../modules/keycloak/files/config.json | 1 + .../modules/keycloak/files/consumers.json | 1 + .../modules/keycloak/files/database.env | 3 + .../modules/keycloak/files/hostname.env | 1 + .../modules/keycloak/module.json.captured | 0 .../mesh-catalog/modules/lab/files/lab.env | 3 + .../modules/lab/module.json.captured | 0 .../modules/lemurs/files/config.toml | 369 +++ .../mesh-catalog/modules/lemurs/files/xserver | 8 + .../modules/lemurs/module.json.captured | 0 .../modules/letta/files/config.json | 3 + .../mesh-catalog/modules/letta/files/pgpass | 1 + .../modules/letta/files/server.env | 6 + .../mesh-catalog/modules/letta/files/start.sh | 23 + .../modules/letta/module.json.captured | 0 .../local-model-consumer/files/openai.env | 3 + .../local-model-consumer/module.json.captured | 0 .../modules/localization/files/locale.conf | 3 + .../modules/localization/files/vconsole.conf | 3 + .../modules/localization/module.json.captured | 0 .../modules/logrotate/files/logrotate.conf | 32 + .../modules/logrotate/module.json.captured | 0 .../modules/mailu/files/admin.env | 2 + .../modules/mailu/files/config.json | 1 + .../modules/mailu/files/database.env | 5 + .../modules/mailu/files/mailu.env | 78 + .../modules/mailu/files/secret.env | 1 + .../modules/mailu/module.json.captured | 0 .../modules/marrytts/module.json.captured | 0 .../modules/matrix/files/conduit.toml | 15 + .../modules/matrix/files/element.json | 53 + .../modules/matrix/module.json.captured | 0 .../modules/memory-pressure/files/oomd.conf | 8 + .../memory-pressure/files/root-slice.conf | 5 + .../modules/memory-pressure/files/sysctl.conf | 9 + .../memory-pressure/files/user-manager.conf | 7 + .../memory-pressure/files/zram-generator.conf | 11 + .../memory-pressure/module.json.captured | 0 .../modules/mesh-build-go/files/forge | 7 + .../modules/mesh-build-go/files/gitconfig | 2 + .../modules/mesh-build-go/files/known_hosts | 1 + .../mesh-build-go/module.json.captured | 0 .../modules/mesh-build-node/files/forge | 7 + .../modules/mesh-build-node/files/gitconfig | 2 + .../modules/mesh-build-node/files/known_hosts | 1 + .../mesh-build-node/module.json.captured | 0 .../modules/mesh-catalog/files/database.url | 1 + .../modules/mesh-catalog/module.json.captured | 0 .../modules/mesh-cli/files/zsh-normal | 3 + .../modules/mesh-cli/module.json.captured | 0 .../modules/mesh-config/module.json.captured | 0 .../mesh-delivery-gate/module.json.captured | 0 .../modules/mesh-files/module.json.captured | 0 .../modules/mesh-issues/files/settings.json | 147 + .../modules/mesh-issues/module.json.captured | 0 .../modules/mesh-knowledge/files/database.url | 1 + .../mesh-knowledge/files/settings.json | 5 + .../mesh-knowledge/module.json.captured | 0 .../mesh-mail-notifier/module.json.captured | 0 .../mesh-mail-reader/files/callers.json | 3 + .../mesh-mail-reader/files/database-url | 1 + .../mesh-mail-reader/module.json.captured | 0 .../modules/mesh-mail-web/files/oidc.json | 5 + .../mesh-mail-web/files/operators.json | 3 + .../mesh-mail-web/module.json.captured | 0 .../modules/mesh-mail/files/accounts.json | 3 + .../modules/mesh-mail/files/database-url | 1 + .../modules/mesh-mail/files/grants.json | 3 + .../modules/mesh-mail/files/store.json | 9 + .../modules/mesh-mail/module.json.captured | 0 .../module.json.captured | 0 .../mesh-provisioner/module.json.captured | 0 .../modules/mesh-reads/module.json.captured | 0 .../modules/mesh-vault/module.json.captured | 0 .../modules/mesh-watcher/files/settings.json | 3 + .../modules/mesh-watcher/module.json.captured | 0 .../modules/messenger/files/settings.json | 5 + .../modules/messenger/module.json.captured | 0 .../mesh-catalog/modules/minio/files/root.env | 2 + .../modules/minio/module.json.captured | 0 .../modules/model-usage/files/database.url | 1 + .../modules/model-usage/module.json.captured | 0 .../modules/mongodb/files/server-root.secret | 1 + .../modules/mongodb/module.json.captured | 0 .../modules/mosquitto/files/bootstrap.env | 7 + .../modules/mosquitto/files/mosquitto.conf | 20 + .../modules/mosquitto/module.json.captured | 0 .../modules/mounts/files/mounts.conf | 11 + .../modules/mounts/module.json.captured | 0 .../modules/mssql/files/backup.sql | 14 + .../mesh-catalog/modules/mssql/files/sa.env | 2 + .../modules/mssql/module.json.captured | 0 .../modules/n8n/files/n8n-database.secret | 1 + .../mesh-catalog/modules/n8n/files/server.env | 12 + .../modules/n8n/module.json.captured | 0 .../mesh-catalog/modules/nats/files/nats.conf | 46 + .../modules/nats/module.json.captured | 0 .../modules/netcheck/module.json.captured | 0 .../modules/networkmanager/files/50-mesh.conf | 63 + .../networkmanager/module.json.captured | 0 .../nextcloud-client/module.json.captured | 0 .../modules/nextcloud/files/config.json | 1 + .../modules/nextcloud/files/server.env | 14 + .../modules/nextcloud/module.json.captured | 0 .../modules/nfs-server/files/50-mesh.conf | 12 + .../modules/nfs-server/files/shares.conf | 10 + .../modules/nfs-server/module.json.captured | 0 .../nftables/files/mesh-filter.service | 14 + .../modules/nftables/files/mesh.conf | 5 + .../modules/nftables/module.json.captured | 0 .../modules/nm-applet/module.json.captured | 0 .../modules/node-env/files/50-mesh.conf | 4 + .../modules/node-env/files/environment.sh | 5 + .../modules/node-env/module.json.captured | 0 .../modules/nodejs/module.json.captured | 0 .../modules/nodered/files/config.json | 3 + .../modules/nodered/files/mqtt.env | 4 + .../modules/nodered/files/settings.js | 48 + .../modules/nodered/files/settings.json | 14 + .../modules/nodered/module.json.captured | 0 .../modules/ollama/module.json.captured | 0 .../modules/only-office/files/server.env | 5 + .../modules/only-office/module.json.captured | 0 .../openai-consumer/module.json.captured | 0 .../modules/openrazer/module.json.captured | 0 .../files/mesh-pacman-weekly-upgrade.service | 12 + .../files/mesh-pacman-weekly-upgrade.timer | 11 + .../modules/pacman/files/pacman.conf | 29 + .../modules/pacman/files/reflector.conf | 8 + .../modules/pacman/module.json.captured | 0 .../modules/photos-eef/module.json.captured | 0 .../modules/photos-filip/module.json.captured | 0 .../modules/picom/files/picom.conf | 38 + .../modules/picom/module.json.captured | 0 .../polychromatic/files/display-session | 6 + .../polychromatic/module.json.captured | 0 .../modules/postgres/files/consumers.json | 1 + .../modules/postgres/module.json.captured | 0 .../modules/power/files/bin/power-moment | 90 + .../modules/power/module.json.captured | 0 .../modules/power/templates/bar-battery | 9 + .../modules/power/templates/bar-power-draw | 6 + .../templates/mesh-power/moments/after-boot | 4 + .../templates/mesh-power/moments/after-wake | 4 + .../mesh-power/moments/before-shutdown | 4 + .../templates/mesh-power/moments/before-sleep | 4 + .../templates/mesh-power/moments/on-battery | 4 + .../templates/mesh-power/moments/on-mains | 4 + .../systemd/logind.conf.d/power.conf | 8 + .../system/hibernate.target.d/mesh-power.conf | 3 + .../hybrid-sleep.target.d/mesh-power.conf | 3 + .../system/mesh-power-after-boot.service | 13 + .../system/mesh-power-after-wake.service | 11 + .../system/mesh-power-before-shutdown.service | 18 + .../system/mesh-power-before-sleep.service | 11 + .../systemd/system/mesh-power-supply.service | 10 + .../system/sleep.target.d/mesh-power.conf | 3 + .../mesh-power.conf | 3 + .../system/suspend.target.d/mesh-power.conf | 3 + .../udev/rules.d/90-mesh-power.rules | 5 + .../modules/powerlevel10k/files/zsh-normal | 3 + .../powerlevel10k/module.json.captured | 0 .../modules/records/files/config.json | 1 + .../mesh-catalog/modules/records/files/origin | 1 + .../modules/records/module.json.captured | 0 .../modules/redis/files/redis.conf | 3 + .../modules/redis/module.json.captured | 0 .../modules/restic/files/backups.conf | 2 + .../modules/restic/files/data.conf | 2 + .../modules/restic/files/retention.conf | 6 + .../modules/restic/module.json.captured | 0 .../mesh-catalog/modules/rofi/files/bin/dmenu | 26 + .../modules/rofi/files/bin/rofi-launch | 39 + .../modules/rofi/files/bin/rofi-powermenu | 54 + .../modules/rofi/files/config.rasi | 54 + .../modules/rofi/files/i3/50-rofi.conf | 8 + .../rofi/files/themes/mesh-powermenu.rasi | 172 ++ .../modules/rofi/files/themes/mesh.rasi | 263 ++ .../modules/rofi/module.json.captured | 0 .../modules/route-adapter/files/config.json | 6 + .../route-adapter/module.json.captured | 0 .../modules/screen-lock/files/bin/screen-lock | 57 + .../modules/screen-lock/files/xinitrc-normal | 8 + .../modules/screen-lock/module.json.captured | 0 .../modules/searxng/files/config.json | 1 + .../modules/searxng/files/settings.yml | 16 + .../modules/searxng/module.json.captured | 0 .../modules/sensors/module.json.captured | 0 .../modules/showcase/files/README.md | 6 + .../modules/showcase/files/marker.txt | 1 + .../modules/showcase/module.json.captured | 0 .../modules/showcase/templates/showcase.env | 5 + .../modules/slack/files/display-session | 11 + .../modules/slack/module.json.captured | 0 .../modules/snapd/module.json.captured | 0 .../modules/ssh-client/files/config | 6 + .../modules/ssh-client/files/forge | 7 + .../modules/ssh-client/files/gitconfig | 2 + .../modules/ssh-client/files/known_hosts | 1 + .../modules/ssh-client/module.json.captured | 0 .../modules/sshd/files/10-mesh.conf | 10 + .../modules/sshd/module.json.captured | 0 .../modules/step-ca/files/config.json | 1 + .../modules/step-ca/files/init.env | 2 + .../modules/step-ca/module.json.captured | 0 .../modules/sudo/files/10-mesh-operator | 9 + .../modules/sudo/module.json.captured | 0 .../modules/supabase/files/analytics-env.env | 14 + .../supabase/files/analytics-reconcile.exs | 86 + .../modules/supabase/files/analytics-start.sh | 41 + .../modules/supabase/files/db-init/jwt.sql | 2 + .../modules/supabase/files/db-init/logs.sql | 6 + .../modules/supabase/files/db-init/pooler.sql | 6 + .../supabase/files/db-init/realtime.sql | 4 + .../modules/supabase/files/db-init/roles.sql | 7 + .../supabase/files/db-init/supabase.sql | 3 + .../supabase/files/db-init/webhooks.sql | 208 ++ .../modules/supabase/files/functions-env.env | 6 + .../supabase/files/functions-main/index.ts | 94 + .../modules/supabase/files/gotrue.env | 28 + .../modules/supabase/files/kong.yml | 242 ++ .../modules/supabase/files/meta-env.env | 6 + .../modules/supabase/files/pooler-env.env | 14 + .../modules/supabase/files/pooler.exs | 43 + .../modules/supabase/files/postgrest.conf | 7 + .../modules/supabase/files/realtime-env.env | 16 + .../modules/supabase/files/settings.json | 7 + .../modules/supabase/files/storage-env.env | 13 + .../modules/supabase/files/studio-env.env | 18 + .../modules/supabase/files/vector.yml | 258 ++ .../modules/supabase/module.json.captured | 0 .../systemd-networkd/files/00-mesh0.network | 35 + .../systemd-networkd/module.json.captured | 0 .../systemd-resolved/module.json.captured | 0 .../modules/systemd/module.json.captured | 0 .../modules/telegram/module.json.captured | 0 .../modules/time-sync/files/50-mesh.conf | 6 + .../modules/time-sync/module.json.captured | 0 .../modules/triggerhappy/files/service.conf | 8 + .../modules/triggerhappy/files/triggers.conf | 4 + .../modules/triggerhappy/module.json.captured | 0 .../modules/umami/files/server.env | 3 + .../modules/umami/module.json.captured | 0 .../modules/unifi/files/config.json | 4 + .../modules/unifi/module.json.captured | 0 .../modules/website/module.json.captured | 0 .../modules/xclip/module.json.captured | 0 .../modules/xdg/files/mimeapps.list | 29 + .../modules/xdg/files/user-dirs.conf | 9 + .../modules/xdg/files/user-dirs.dirs | 17 + .../modules/xdg/files/user-dirs.locale | 1 + .../modules/xdg/module.json.captured | 0 .../mesh-catalog/modules/xorg/files/xinitrc | 51 + .../modules/xorg/files/xresources | 13 + .../modules/xorg/module.json.captured | 0 .../modules/xterm/files/env-terminal | 1 + .../modules/xterm/files/xresources-normal | 31 + .../modules/xterm/module.json.captured | 0 .../mesh-catalog/modules/zfs/files/scrub.conf | 6 + .../modules/zfs/module.json.captured | 0 .../zsh-autosuggestions/files/zsh-normal | 2 + .../zsh-autosuggestions/module.json.captured | 0 .../zsh-syntax-highlighting/files/zsh-last | 3 + .../module.json.captured | 0 .../mesh-catalog/modules/zsh/files/env-editor | 1 + .../modules/zsh/files/env-path-local-bin | 1 + .../modules/zsh/files/env-path-scripts | 1 + .../modules/zsh/files/env-path-scripts-bin | 1 + .../mesh-catalog/modules/zsh/files/env-visual | 1 + .../modules/zsh/files/env-xdg-config-home | 1 + .../mesh-catalog/modules/zsh/files/tools.conf | 3 + .../mesh-catalog/modules/zsh/files/zshenv | 9 + .../mesh-catalog/modules/zsh/files/zshrc | 60 + .../modules/zsh/module.json.captured | 0 testdata/beside/mesh-catalog/predefined.json | 13 + 686 files changed, 35703 insertions(+), 80 deletions(-) create mode 100644 internal/beside/beside.go create mode 100644 internal/beside/reads-outside create mode 100644 internal/compose/resolution/a_service_process_test.go create mode 100644 internal/compose/resolution/accesses_test.go create mode 100644 internal/compose/resolution/adoption.go create mode 100644 internal/compose/resolution/adoption_test.go create mode 100644 internal/compose/resolution/agent_account_test.go create mode 100644 internal/compose/resolution/artifact_store_seat_test.go create mode 100644 internal/compose/resolution/artifact_sweep_seat.go create mode 100644 internal/compose/resolution/artifacts.go create mode 100644 internal/compose/resolution/artifacts_test.go create mode 100644 internal/compose/resolution/backup_placement.go create mode 100644 internal/compose/resolution/backup_placement_test.go create mode 100644 internal/compose/resolution/backup_test.go create mode 100644 internal/compose/resolution/bound.go create mode 100644 internal/compose/resolution/bound_into_files.go create mode 100644 internal/compose/resolution/bound_into_files_test.go create mode 100644 internal/compose/resolution/bound_test.go create mode 100644 internal/compose/resolution/broker_seat_test.go create mode 100644 internal/compose/resolution/brokered_test.go create mode 100644 internal/compose/resolution/bucket_shares.go create mode 100644 internal/compose/resolution/bucket_shares_test.go create mode 100644 internal/compose/resolution/build.go create mode 100644 internal/compose/resolution/busy.go create mode 100644 internal/compose/resolution/busy_test.go create mode 100644 internal/compose/resolution/catalogue_check_test.go create mode 100644 internal/compose/resolution/catalogue_read_test.go create mode 100644 internal/compose/resolution/catrust_manifest_test.go create mode 100644 internal/compose/resolution/checker_seat.go create mode 100644 internal/compose/resolution/chosen.go create mode 100644 internal/compose/resolution/co_located_test.go create mode 100644 internal/compose/resolution/collections.go create mode 100644 internal/compose/resolution/collections_bar_test.go create mode 100644 internal/compose/resolution/collections_l2_test.go create mode 100644 internal/compose/resolution/collections_l3_test.go create mode 100644 internal/compose/resolution/collections_test.go create mode 100644 internal/compose/resolution/computed_test.go create mode 100644 internal/compose/resolution/config_test.go create mode 100644 internal/compose/resolution/consumer_into_serves.go create mode 100644 internal/compose/resolution/consumers.go create mode 100644 internal/compose/resolution/contributes_test.go create mode 100644 internal/compose/resolution/contribution_dependencies_test.go create mode 100644 internal/compose/resolution/control_records_seats.go create mode 100644 internal/compose/resolution/core_credentials.go create mode 100644 internal/compose/resolution/core_credentials_test.go create mode 100644 internal/compose/resolution/data.go create mode 100644 internal/compose/resolution/data_placed.go create mode 100644 internal/compose/resolution/data_test.go create mode 100644 internal/compose/resolution/declaration.go create mode 100644 internal/compose/resolution/declaration_test.go create mode 100644 internal/compose/resolution/decode_for_test_test.go create mode 100644 internal/compose/resolution/delivery_seat.go create mode 100644 internal/compose/resolution/derived_for_consumer_test.go create mode 100644 internal/compose/resolution/dir_into.go create mode 100644 internal/compose/resolution/dir_into_test.go create mode 100644 internal/compose/resolution/doc.go create mode 100644 internal/compose/resolution/domain_test.go create mode 100644 internal/compose/resolution/endpoint_test.go create mode 100644 internal/compose/resolution/endpoints_setting_test.go create mode 100644 internal/compose/resolution/engine_seat.go create mode 100644 internal/compose/resolution/enrolment_seat.go create mode 100644 internal/compose/resolution/environment_into.go create mode 100644 internal/compose/resolution/environment_into_test.go create mode 100644 internal/compose/resolution/environment_items_test.go create mode 100644 internal/compose/resolution/files.go create mode 100644 internal/compose/resolution/files_test.go create mode 100644 internal/compose/resolution/filtering.go create mode 100644 internal/compose/resolution/filtering_test.go create mode 100644 internal/compose/resolution/foundation_manifests_test.go create mode 100644 internal/compose/resolution/generated_collision_test.go create mode 100644 internal/compose/resolution/grant_secret_owner_test.go create mode 100644 internal/compose/resolution/graphical_session.go create mode 100644 internal/compose/resolution/graphical_session_test.go create mode 100644 internal/compose/resolution/groups_test.go create mode 100644 internal/compose/resolution/handed.go create mode 100644 internal/compose/resolution/handed_bound_test.go create mode 100644 internal/compose/resolution/handed_consumers_test.go create mode 100644 internal/compose/resolution/handed_providers_test.go create mode 100644 internal/compose/resolution/handed_pulled_in_test.go create mode 100644 internal/compose/resolution/handed_secrets_test.go create mode 100644 internal/compose/resolution/handed_test.go create mode 100644 internal/compose/resolution/health.go create mode 100644 internal/compose/resolution/health_seats.go create mode 100644 internal/compose/resolution/health_test.go create mode 100644 internal/compose/resolution/holdings_test.go create mode 100644 internal/compose/resolution/hostname_seat_test.go create mode 100644 internal/compose/resolution/jails_into.go create mode 100644 internal/compose/resolution/jails_into_test.go create mode 100644 internal/compose/resolution/kept_test.go create mode 100644 internal/compose/resolution/keyed.go create mode 100644 internal/compose/resolution/keyed_test.go create mode 100644 internal/compose/resolution/kinded_test.go create mode 100644 internal/compose/resolution/laid_values.go create mode 100644 internal/compose/resolution/licence_test.go create mode 100644 internal/compose/resolution/localnetwork.go create mode 100644 internal/compose/resolution/localnetwork_test.go create mode 100644 internal/compose/resolution/machine_into_files.go create mode 100644 internal/compose/resolution/machine_into_files_test.go create mode 100644 internal/compose/resolution/manifest.go create mode 100644 internal/compose/resolution/names_test.go create mode 100644 internal/compose/resolution/needs_test.go create mode 100644 internal/compose/resolution/no_subjects_test.go create mode 100644 internal/compose/resolution/node_resolver.go create mode 100644 internal/compose/resolution/node_resolver_test.go create mode 100644 internal/compose/resolution/operators_machine_test.go create mode 100644 internal/compose/resolution/optional_requirements.go create mode 100644 internal/compose/resolution/optional_requirements_test.go create mode 100644 internal/compose/resolution/outside_not_given_test.go create mode 100644 internal/compose/resolution/own_name_test.go create mode 100644 internal/compose/resolution/own_secret_owner_test.go create mode 100644 internal/compose/resolution/pin_names_module_test.go create mode 100644 internal/compose/resolution/placed_paths_test.go create mode 100644 internal/compose/resolution/placement.go create mode 100644 internal/compose/resolution/placement_seat.go create mode 100644 internal/compose/resolution/port_into.go create mode 100644 internal/compose/resolution/port_into_test.go create mode 100644 internal/compose/resolution/process_bus.go create mode 100644 internal/compose/resolution/process_bus_test.go create mode 100644 internal/compose/resolution/process_env_test.go create mode 100644 internal/compose/resolution/reach_test.go create mode 100644 internal/compose/resolution/reads-outside create mode 100644 internal/compose/resolution/reads.go create mode 100644 internal/compose/resolution/reads_test.go create mode 100644 internal/compose/resolution/received_on_the_bus_test.go create mode 100644 internal/compose/resolution/reflects_renamed_test.go create mode 100644 internal/compose/resolution/replay166_test.go create mode 100644 internal/compose/resolution/replay279_test.go create mode 100644 internal/compose/resolution/resolve.go create mode 100644 internal/compose/resolution/resolve_loopback_test.go create mode 100644 internal/compose/resolution/resolve_test.go create mode 100644 internal/compose/resolution/resolved_path_test.go create mode 100644 internal/compose/resolution/resolver_file_is_the_uplinks_test.go create mode 100644 internal/compose/resolution/resolver_manifests_test.go create mode 100644 internal/compose/resolution/roster.go create mode 100644 internal/compose/resolution/routing_test.go create mode 100644 internal/compose/resolution/runonce_test.go create mode 100644 internal/compose/resolution/runtime.go create mode 100644 internal/compose/resolution/runtime_health_test.go create mode 100644 internal/compose/resolution/runtime_test.go create mode 100644 internal/compose/resolution/schedule_test.go create mode 100644 internal/compose/resolution/seat_contributions.go create mode 100644 internal/compose/resolution/seat_contributions_test.go create mode 100644 internal/compose/resolution/seat_data.go create mode 100644 internal/compose/resolution/seat_data_test.go create mode 100644 internal/compose/resolution/seat_dependencies.go create mode 100644 internal/compose/resolution/seat_dependencies_test.go create mode 100644 internal/compose/resolution/seat_into.go create mode 100644 internal/compose/resolution/seat_into_test.go create mode 100644 internal/compose/resolution/seat_pass.go create mode 100644 internal/compose/resolution/seat_pass_test.go create mode 100644 internal/compose/resolution/seat_paths.go create mode 100644 internal/compose/resolution/seat_paths_test.go create mode 100644 internal/compose/resolution/seats.go create mode 100644 internal/compose/resolution/seats_test.go create mode 100644 internal/compose/resolution/secret_family_test.go create mode 100644 internal/compose/resolution/secret_restart_test.go create mode 100644 internal/compose/resolution/secrets_into_files.go create mode 100644 internal/compose/resolution/secrets_into_files_test.go create mode 100644 internal/compose/resolution/served_own_names.go create mode 100644 internal/compose/resolution/served_own_names_test.go create mode 100644 internal/compose/resolution/setting_defaults.go create mode 100644 internal/compose/resolution/setting_defaults_test.go create mode 100644 internal/compose/resolution/setting_in_owner_test.go create mode 100644 internal/compose/resolution/setting_in_unit_test.go create mode 100644 internal/compose/resolution/setting_into.go create mode 100644 internal/compose/resolution/setting_into_test.go create mode 100644 internal/compose/resolution/setting_takes.go create mode 100644 internal/compose/resolution/settings.go create mode 100644 internal/compose/resolution/settings_test.go create mode 100644 internal/compose/resolution/several_contributions_test.go create mode 100644 internal/compose/resolution/several_secrets_test.go create mode 100644 internal/compose/resolution/shared_account_test.go create mode 100644 internal/compose/resolution/shared_credential_test.go create mode 100644 internal/compose/resolution/shares.go create mode 100644 internal/compose/resolution/short_form_port_protocol_test.go create mode 100644 internal/compose/resolution/ssh_client_test.go create mode 100644 internal/compose/resolution/template.go create mode 100644 internal/compose/resolution/template_test.go create mode 100644 internal/compose/resolution/terminal_settings_test.go create mode 100644 internal/compose/resolution/testdata/bars/bar-battery create mode 100644 internal/compose/resolution/testdata/bars/bar-power-draw create mode 100644 internal/compose/resolution/testdata/bars/battery.bottom-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/battery.top-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/both.bottom-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/both.top-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/bottom-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/none.bottom-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/none.top-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/power-meter.bottom-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/power-meter.top-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/top-bar.toml create mode 100644 internal/compose/resolution/testdata/catalogue-835bf22b-claude-code.module.json rename cmd/mesh-compose/testdata/mesh-catalog/modules/dunst/module.json.captured => internal/compose/resolution/testdata/catalogue-835bf22b-dunst.module.json (100%) create mode 100644 internal/compose/resolution/testdata/catalogue-835bf22b-xterm.module.json create mode 100644 internal/compose/resolution/testdata/environment/all.posix create mode 100644 internal/compose/resolution/testdata/environment/all.systemd create mode 100644 internal/compose/resolution/testdata/environment/zsh.posix create mode 100644 internal/compose/resolution/testdata/environment/zsh.systemd create mode 100644 internal/compose/resolution/testdata/influxdb-served-by-setting/grafana-ace.json create mode 100644 internal/compose/resolution/testdata/influxdb-served-by-setting/grafana-unlayered.json create mode 100644 internal/compose/resolution/testdata/l2-moved/items/asus-zephyrus-g14.contribution-node-hotkeys-trigger create mode 100644 internal/compose/resolution/testdata/l2-moved/items/asus-zephyrus-g14.shell-after-wake-normal create mode 100644 internal/compose/resolution/testdata/l2-moved/items/claude-code.shell-zsh-normal create mode 100644 internal/compose/resolution/testdata/l2-moved/items/mesh-cli.shell-zsh-normal create mode 100644 internal/compose/resolution/testdata/l2-moved/items/powerlevel10k.shell-zsh-normal create mode 100644 internal/compose/resolution/testdata/l2-moved/items/zsh-autosuggestions.shell-zsh-normal create mode 100644 internal/compose/resolution/testdata/l2-moved/items/zsh-syntax-highlighting.shell-zsh-last create mode 100644 internal/compose/resolution/testdata/l2-moved/templates/power create mode 100644 internal/compose/resolution/testdata/l2-moved/templates/triggerhappy create mode 100644 internal/compose/resolution/testdata/l2-moved/templates/zsh create mode 100644 internal/compose/resolution/testdata/l2-moved/want/power create mode 100644 internal/compose/resolution/testdata/l2-moved/want/triggerhappy create mode 100644 internal/compose/resolution/testdata/l2-moved/want/zsh create mode 100644 internal/compose/resolution/toolchain_manifests_test.go create mode 100644 internal/compose/resolution/two_consumers_test.go create mode 100644 internal/compose/resolution/two_resolvers_test.go create mode 100644 internal/compose/resolution/unconsumed_placeholder.go create mode 100644 internal/compose/resolution/unconsumed_placeholder_test.go create mode 100644 internal/compose/resolution/unknown_field.go create mode 100644 internal/compose/resolution/verbs.go create mode 100644 internal/compose/resolution/while_stopped_test.go create mode 100644 internal/compose/resolution/yields.go create mode 100644 internal/compose/resolution/zones.go create mode 100644 internal/compose/resolution/zones_test.go create mode 100644 testdata/beside/CAPTURED create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/cursor-index.theme create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk-theme create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk2-rc-files create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-qpa-platformtheme create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-select create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-style-override create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-size create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-theme create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/gtk-settings.ini create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/portals.conf create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/qt6ct.conf create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/xinitrc-normal create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/xresources-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/adwaita/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/artifact-store-tools/files/store-names rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/artifact-store-tools/module.json.captured (100%) create mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-backlight create mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-display create mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-kbd-notify create mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-media create mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-notify create mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-session create mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-touchpad rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/after-wake create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/display-session create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/hotkeys-triggers create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/video-brightness-switch.conf create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/udev/rules.d/90-backlight.rules rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/audit-logger/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/avahi/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/baserow/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/baserow/files/server.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/baserow/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/betterbird/files/accounts.json create mode 100755 testdata/beside/mesh-catalog/modules/betterbird/files/betterbird create mode 100644 testdata/beside/mesh-catalog/modules/betterbird/files/configuration.json create mode 100644 testdata/beside/mesh-catalog/modules/betterbird/files/eu.betterbird.Betterbird.desktop create mode 100644 testdata/beside/mesh-catalog/modules/betterbird/files/prefs.json create mode 100644 testdata/beside/mesh-catalog/modules/betterbird/files/tags.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/betterbird/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/blueman/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/bluetooth/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/brother/files/settings.json create mode 100644 testdata/beside/mesh-catalog/modules/brother/files/store.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/brother/module.json.captured (100%) create mode 100755 testdata/beside/mesh-catalog/modules/ca-trust/files/anchor create mode 100644 testdata/beside/mesh-catalog/modules/ca-trust/files/mesh-ca-trust.service rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/ca-trust/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/claude-code/files/agent-account create mode 100755 testdata/beside/mesh-catalog/modules/claude-code/files/claude-agent create mode 100644 testdata/beside/mesh-catalog/modules/claude-code/files/facts.json create mode 100644 testdata/beside/mesh-catalog/modules/claude-code/files/settings.json create mode 100644 testdata/beside/mesh-catalog/modules/claude-code/files/zsh-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/claude-code/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/claude-licence-manager/files/database.url create mode 100644 testdata/beside/mesh-catalog/modules/claude-licence-manager/files/settings.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/claude-licence-manager/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/bin/clipmenud-session create mode 100755 testdata/beside/mesh-catalog/modules/clipmenu/files/bin/xsel create mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-histlength create mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-max-clips create mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-selections create mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/i3/50-clipmenu.conf create mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/systemd/clipmenud-mesh.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/clipmenu/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/confluence/files/config.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/confluence/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/cups/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/dbus/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/desk-channel/files/settings.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/desk-channel/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/dev-tools/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/disk-load/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/distribution/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/dmenu/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/dnsmasq/files/dnsmasq.conf create mode 100644 testdata/beside/mesh-catalog/modules/dnsmasq/files/mesh.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/dnsmasq/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/docker-compose/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/docker/files/daemon.json create mode 100644 testdata/beside/mesh-catalog/modules/docker/files/docker-prune.service create mode 100644 testdata/beside/mesh-catalog/modules/docker/files/docker-prune.timer create mode 100644 testdata/beside/mesh-catalog/modules/docker/files/log-rotation.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/docker/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/dunst/files/dunstrc create mode 100644 testdata/beside/mesh-catalog/modules/dunst/files/i3/50-dunst.conf create mode 100644 testdata/beside/mesh-catalog/modules/dunst/module.json.captured create mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/fail2ban create mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/iptables-allports-dualchain.conf create mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/jail.local create mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/mesh-own-addresses.json create mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/own-addresses create mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/recidive.conf create mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/sshd.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/fail2ban/module.json.captured (100%) create mode 100755 testdata/beside/mesh-catalog/modules/feh/files/fehbg create mode 100644 testdata/beside/mesh-catalog/modules/feh/files/i3/50-feh.conf create mode 100644 testdata/beside/mesh-catalog/modules/feh/files/xinitrc-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/feh/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/flatpak/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/fonts/files/50-mesh-fonts.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/fonts/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/forticlient/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/agent-teams.json create mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/consumers.json create mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/origins create mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/repositories.json create mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/server.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/gitea/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/gitlab/files/config.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/gitlab/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/login create mode 100644 testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/passwd create mode 100644 testdata/beside/mesh-catalog/modules/gnome-keyring/files/xinitrc-first rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/gnome-keyring/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/go/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/admin.secret create mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/influxdb-api create mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/mesh-influxdb.yaml create mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/oidc-client.secret create mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/oidc.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/grafana/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/hello-web/files/index.html rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/hello-web/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/home-assistant/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/home-assistant/files/provisions.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/home-assistant/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/hostname/files/hostname create mode 100644 testdata/beside/mesh-catalog/modules/hostname/files/hosts rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/hostname/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/i3/files/config create mode 100644 testdata/beside/mesh-catalog/modules/i3/files/env-xdg-current-desktop create mode 100644 testdata/beside/mesh-catalog/modules/i3/files/env-xdg-session-desktop create mode 100755 testdata/beside/mesh-catalog/modules/i3/files/wms/i3 create mode 100644 testdata/beside/mesh-catalog/modules/i3/files/xinitrc-last rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/i3/module.json.captured (100%) create mode 100755 testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3bar-watchdog create mode 100755 testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-updates create mode 100755 testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-value create mode 100644 testdata/beside/mesh-catalog/modules/i3status-rust/files/bottom-bar.toml create mode 100644 testdata/beside/mesh-catalog/modules/i3status-rust/files/i3/60-i3status-rust.conf create mode 100644 testdata/beside/mesh-catalog/modules/i3status-rust/files/icons/custom-icons.toml create mode 100644 testdata/beside/mesh-catalog/modules/i3status-rust/files/top-bar.toml create mode 100644 testdata/beside/mesh-catalog/modules/i3status-rust/files/xinitrc-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/i3status-rust/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/icecast/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/icecast/files/icecast.xml rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/icecast/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/influxdb/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/influxdb/files/server.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/influxdb/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/invoicing/files/api.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/invoicing/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/jira/files/config.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/jira/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/kernel/files/huge-pages.sh create mode 100644 testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-huge-pages.service rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/kernel/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/keycloak/files/admin.env create mode 100644 testdata/beside/mesh-catalog/modules/keycloak/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/keycloak/files/consumers.json create mode 100644 testdata/beside/mesh-catalog/modules/keycloak/files/database.env create mode 100644 testdata/beside/mesh-catalog/modules/keycloak/files/hostname.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/keycloak/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/lab/files/lab.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/lab/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/lemurs/files/config.toml create mode 100755 testdata/beside/mesh-catalog/modules/lemurs/files/xserver rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/lemurs/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/letta/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/letta/files/pgpass create mode 100644 testdata/beside/mesh-catalog/modules/letta/files/server.env create mode 100644 testdata/beside/mesh-catalog/modules/letta/files/start.sh rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/letta/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/local-model-consumer/files/openai.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/local-model-consumer/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/localization/files/locale.conf create mode 100644 testdata/beside/mesh-catalog/modules/localization/files/vconsole.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/localization/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/logrotate/files/logrotate.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/logrotate/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mailu/files/admin.env create mode 100644 testdata/beside/mesh-catalog/modules/mailu/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/mailu/files/database.env create mode 100644 testdata/beside/mesh-catalog/modules/mailu/files/mailu.env create mode 100644 testdata/beside/mesh-catalog/modules/mailu/files/secret.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mailu/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/marrytts/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/matrix/files/conduit.toml create mode 100644 testdata/beside/mesh-catalog/modules/matrix/files/element.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/matrix/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/memory-pressure/files/oomd.conf create mode 100644 testdata/beside/mesh-catalog/modules/memory-pressure/files/root-slice.conf create mode 100644 testdata/beside/mesh-catalog/modules/memory-pressure/files/sysctl.conf create mode 100644 testdata/beside/mesh-catalog/modules/memory-pressure/files/user-manager.conf create mode 100644 testdata/beside/mesh-catalog/modules/memory-pressure/files/zram-generator.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/memory-pressure/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-go/files/forge create mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-go/files/gitconfig create mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-go/files/known_hosts rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-build-go/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-node/files/forge create mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-node/files/gitconfig create mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-node/files/known_hosts rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-build-node/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-catalog/files/database.url rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-catalog/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-cli/files/zsh-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-cli/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-config/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-delivery-gate/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-files/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-issues/files/settings.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-issues/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-knowledge/files/database.url create mode 100644 testdata/beside/mesh-catalog/modules/mesh-knowledge/files/settings.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-knowledge/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-mail-notifier/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/callers.json create mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/database-url rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-mail-reader/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail-web/files/oidc.json create mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail-web/files/operators.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-mail-web/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail/files/accounts.json create mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail/files/database-url create mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail/files/grants.json create mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail/files/store.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-mail/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-provisioner-client/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-provisioner/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-reads/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-vault/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-watcher/files/settings.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-watcher/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/messenger/files/settings.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/messenger/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/minio/files/root.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/minio/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/model-usage/files/database.url rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/model-usage/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mongodb/files/server-root.secret rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mongodb/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mosquitto/files/bootstrap.env create mode 100644 testdata/beside/mesh-catalog/modules/mosquitto/files/mosquitto.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mosquitto/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mounts/files/mounts.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mounts/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mssql/files/backup.sql create mode 100644 testdata/beside/mesh-catalog/modules/mssql/files/sa.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mssql/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/n8n/files/n8n-database.secret create mode 100644 testdata/beside/mesh-catalog/modules/n8n/files/server.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/n8n/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/nats/files/nats.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/nats/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/netcheck/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/networkmanager/files/50-mesh.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/networkmanager/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/nextcloud-client/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/nextcloud/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/nextcloud/files/server.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/nextcloud/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/nfs-server/files/50-mesh.conf create mode 100644 testdata/beside/mesh-catalog/modules/nfs-server/files/shares.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/nfs-server/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/nftables/files/mesh-filter.service create mode 100644 testdata/beside/mesh-catalog/modules/nftables/files/mesh.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/nftables/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/nm-applet/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/node-env/files/50-mesh.conf create mode 100644 testdata/beside/mesh-catalog/modules/node-env/files/environment.sh rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/node-env/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/nodejs/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/nodered/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/nodered/files/mqtt.env create mode 100644 testdata/beside/mesh-catalog/modules/nodered/files/settings.js create mode 100644 testdata/beside/mesh-catalog/modules/nodered/files/settings.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/nodered/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/ollama/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/only-office/files/server.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/only-office/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/openai-consumer/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/openrazer/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/pacman/files/mesh-pacman-weekly-upgrade.service create mode 100644 testdata/beside/mesh-catalog/modules/pacman/files/mesh-pacman-weekly-upgrade.timer create mode 100644 testdata/beside/mesh-catalog/modules/pacman/files/pacman.conf create mode 100644 testdata/beside/mesh-catalog/modules/pacman/files/reflector.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/pacman/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/photos-eef/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/photos-filip/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/picom/files/picom.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/picom/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/polychromatic/files/display-session rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/polychromatic/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/postgres/files/consumers.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/postgres/module.json.captured (100%) create mode 100755 testdata/beside/mesh-catalog/modules/power/files/bin/power-moment rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/power/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/bar-battery create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/bar-power-draw create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-boot create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-wake create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-shutdown create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-sleep create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-battery create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-mains create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/logind.conf.d/power.conf create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hibernate.target.d/mesh-power.conf create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-boot.service create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-wake.service create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-shutdown.service create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-sleep.service create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-supply.service create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/sleep.target.d/mesh-power.conf create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend.target.d/mesh-power.conf create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/udev/rules.d/90-mesh-power.rules create mode 100644 testdata/beside/mesh-catalog/modules/powerlevel10k/files/zsh-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/powerlevel10k/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/records/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/records/files/origin rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/records/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/redis/files/redis.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/redis/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/restic/files/backups.conf create mode 100644 testdata/beside/mesh-catalog/modules/restic/files/data.conf create mode 100644 testdata/beside/mesh-catalog/modules/restic/files/retention.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/restic/module.json.captured (100%) create mode 100755 testdata/beside/mesh-catalog/modules/rofi/files/bin/dmenu create mode 100755 testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-launch create mode 100755 testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-powermenu create mode 100644 testdata/beside/mesh-catalog/modules/rofi/files/config.rasi create mode 100644 testdata/beside/mesh-catalog/modules/rofi/files/i3/50-rofi.conf create mode 100644 testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh-powermenu.rasi create mode 100644 testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh.rasi rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/rofi/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/route-adapter/files/config.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/route-adapter/module.json.captured (100%) create mode 100755 testdata/beside/mesh-catalog/modules/screen-lock/files/bin/screen-lock create mode 100644 testdata/beside/mesh-catalog/modules/screen-lock/files/xinitrc-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/screen-lock/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/searxng/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/searxng/files/settings.yml rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/searxng/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/sensors/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/showcase/files/README.md create mode 100644 testdata/beside/mesh-catalog/modules/showcase/files/marker.txt rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/showcase/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/showcase/templates/showcase.env create mode 100644 testdata/beside/mesh-catalog/modules/slack/files/display-session rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/slack/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/snapd/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/ssh-client/files/config create mode 100644 testdata/beside/mesh-catalog/modules/ssh-client/files/forge create mode 100644 testdata/beside/mesh-catalog/modules/ssh-client/files/gitconfig create mode 100644 testdata/beside/mesh-catalog/modules/ssh-client/files/known_hosts rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/ssh-client/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/sshd/files/10-mesh.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/sshd/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/step-ca/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/step-ca/files/init.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/step-ca/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/sudo/files/10-mesh-operator rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/sudo/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/analytics-env.env create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/analytics-reconcile.exs create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/analytics-start.sh create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/jwt.sql create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/logs.sql create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/pooler.sql create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/realtime.sql create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/roles.sql create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/supabase.sql create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/webhooks.sql create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/functions-env.env create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/functions-main/index.ts create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/gotrue.env create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/kong.yml create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/meta-env.env create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/pooler-env.env create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/pooler.exs create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/postgrest.conf create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/realtime-env.env create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/settings.json create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/storage-env.env create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/studio-env.env create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/vector.yml rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/supabase/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/systemd-networkd/files/00-mesh0.network rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/systemd-networkd/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/systemd-resolved/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/systemd/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/telegram/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/time-sync/files/50-mesh.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/time-sync/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/triggerhappy/files/service.conf create mode 100644 testdata/beside/mesh-catalog/modules/triggerhappy/files/triggers.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/triggerhappy/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/umami/files/server.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/umami/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/unifi/files/config.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/unifi/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/website/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/xclip/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/xdg/files/mimeapps.list create mode 100644 testdata/beside/mesh-catalog/modules/xdg/files/user-dirs.conf create mode 100644 testdata/beside/mesh-catalog/modules/xdg/files/user-dirs.dirs create mode 100644 testdata/beside/mesh-catalog/modules/xdg/files/user-dirs.locale rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/xdg/module.json.captured (100%) create mode 100755 testdata/beside/mesh-catalog/modules/xorg/files/xinitrc create mode 100644 testdata/beside/mesh-catalog/modules/xorg/files/xresources rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/xorg/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/xterm/files/env-terminal create mode 100644 testdata/beside/mesh-catalog/modules/xterm/files/xresources-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/xterm/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/zfs/files/scrub.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/zfs/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/zsh-autosuggestions/files/zsh-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/zsh-autosuggestions/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/zsh-syntax-highlighting/files/zsh-last rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/zsh-syntax-highlighting/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-editor create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-path-local-bin create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-path-scripts create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-path-scripts-bin create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-visual create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-xdg-config-home create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/tools.conf create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/zshenv create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/zshrc rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/zsh/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/predefined.json diff --git a/cmd/mesh-compose/main_test.go b/cmd/mesh-compose/main_test.go index dbb62af..83a5faf 100644 --- a/cmd/mesh-compose/main_test.go +++ b/cmd/mesh-compose/main_test.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "go/build" - "io/fs" "os" "os/exec" "path/filepath" @@ -16,41 +15,15 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/beside" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/composer" ) -// captured is every manifest of the catalogue, raw, by module name: the clone beside a merge check (MESH_CHECK_BESIDE), -// or the copy captured in testdata/mesh-catalog from mesh-sdk's testdata/beside (mesh-catalog b5dbfbb7, its -// module.json files alone), each kept as module.json.captured so this repository holds no module of the catalogue. +// captured is every manifest of the catalogue captured in testdata/beside (internal/beside), raw, by module name. func captured(t *testing.T) map[string]composer.Manifest { t.Helper() - dir := "" - if root := os.Getenv("MESH_CHECK_BESIDE"); root != "" { - dir = filepath.Join(root, "mesh-catalog") - } else { - dir = t.TempDir() - from, _ := filepath.Abs("testdata/mesh-catalog") - err := filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - rel, _ := filepath.Rel(from, path) - into := filepath.Join(dir, strings.TrimSuffix(rel, ".captured")) - raw, err := os.ReadFile(path) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(into), 0o755); err != nil { - return err - } - return os.WriteFile(into, raw, 0o644) - }) - if err != nil { - t.Fatal(err) - } - } - read, err := readCatalogue(dir) + read, err := readCatalogue(filepath.Dir(beside.Catalogue(t))) if err != nil { t.Fatal(err) } diff --git a/internal/beside/beside.go b/internal/beside/beside.go new file mode 100644 index 0000000..d31ec1d --- /dev/null +++ b/internal/beside/beside.go @@ -0,0 +1,134 @@ +// Package beside is where a test finds another repository of the mesh it reads: the catalogue's manifests, +// the node-engine's genesis template (novox/hq issue 432), the SDK's conformance fixtures (issue 449). +// +// Copied from mesh-controller internal/beside at commit 6c2a8d62 (R4b, issue 1379); the one change is that Dir +// lays out the captured copy of a repository once per process, not once per test (see laidOut). In mesh-sdk the +// catalogue's tests read mesh-catalog and mesh-host only; the captured copy holds just what they read. +// +// A test used to read the checkout beside this one, `../../../mesh-catalog`, so its verdict depended on +// whatever sat on the machine running it: a stale or dirty checkout failed it on a desktop, and where none +// was beside it skipped and said nothing. Now there are two inputs, both named, and no third: +// +// - 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 +// 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. Each repository's tests read these siblings, and the checker places exactly these +// (mesh-delivery's checker, besideRules, follows this list): mesh-controller's read mesh-catalog, +// mesh-host and mesh-sdk; mesh-host's read mesh-catalog, mesh-sdk and mesh-controller (its step-nine +// test reads the controller's own manifest, novox/hq issue 1295); mesh-sdk's read mesh-catalog and +// mesh-host; mesh-catalog's read mesh-sdk and mesh-controller; mesh-delivery's read mesh-sdk and +// mesh-catalog. In a mesh where either module has no source repository nothing is +// cloned, and these tests fail loudly with "not beside this check": a cause in the setup, not in the +// change. And in a delivery group that holds a mesh-catalog pull request, these tests read the +// catalogue's main, not the group's head. +// - Anywhere else, the test judges against the copy captured in this repository's testdata/beside, at the +// commit testdata/beside/CAPTURED names. Never against a developer's own checkout: to judge one, set +// MESH_CHECK_BESIDE to the directory holding it, as the check does. +// +// The captured manifests are named module.json.captured, and put back as module.json in a directory of +// the test's own: a module.json anywhere in this repository is a module of it to the forge's announcer and +// the planner, and a merge would build and register a hundred copies of the catalogue's. +package beside + +import ( + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +// Env is where a merge check says the repositories cloned beside the one checked are (internal/builder's +// EnvBeside, repeated here so a test does not import the builder). +const Env = "MESH_CHECK_BESIDE" + +// CapturedSuffix is what a captured file's name carries that the repository's own does not. +const CapturedSuffix = ".captured" + +// Dir is the named repository a test reads — the clone beside a merge check, or the captured copy — and +// says which in the test's log. +func Dir(t testing.TB, repository string) string { + t.Helper() + if root := os.Getenv(Env); root != "" { + dir := filepath.Join(root, repository) + if _, err := os.Stat(dir); err != nil { + t.Fatalf("%s is not beside this check in %s=%s, so its agreement with this repository cannot be "+ + "judged here: %v", repository, Env, root, err) + } + t.Logf("judged against %s as cloned beside this check", dir) + return dir + } + from := filepath.Join(Captured(), repository) + if _, err := os.Stat(from); err != nil { + t.Fatalf("no captured copy of %s at %s: %v", repository, from, err) + } + dir, err := layOut(repository, from) + if err != nil { + t.Fatalf("the captured copy of %s could not be laid out: %v", repository, err) + } + t.Logf("judged against the copy of %s captured in testdata/beside (see CAPTURED); a merge check "+ + "judges it against the repository's own clone", repository) + return dir +} + +// laidOut is the captured copies already put back in place in this process, by repository. A copy is +// read-only to every test, so it is laid out once per process and not once per test: with a copy per test +// the catalogue's tests alone took 2.14 s, over mesh-sdk's unit bound of 2 s, and with one 0.81 s (measured +// 2026-10-20). The directories are in the temp directory, which the system clears; a process cannot remove +// them while another test may still read them. +var ( + laidOutMu sync.Mutex + laidOut = map[string]string{} +) + +func layOut(repository, from string) (string, error) { + laidOutMu.Lock() + defer laidOutMu.Unlock() + if dir, ok := laidOut[repository]; ok { + return dir, nil + } + root, err := os.MkdirTemp("", "beside-") + if err != nil { + return "", err + } + dir := filepath.Join(root, repository) + err = filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(from, path) + into := filepath.Join(dir, strings.TrimSuffix(rel, CapturedSuffix)) + if d.IsDir() { + return os.MkdirAll(into, 0o755) + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(into, raw, 0o644) + }) + if err != nil { + os.RemoveAll(root) + return "", err + } + laidOut[repository] = dir + return dir, nil +} + +// Catalogue is the catalogue's modules directory, as Dir finds the catalogue. +func Catalogue(t testing.TB) string { + t.Helper() + return filepath.Join(Dir(t, "mesh-catalog"), "modules") +} + +// Captured is this repository's testdata/beside, found from this file rather than from the working +// directory, so a test in any package reaches it. +func Captured() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "..", "..", "testdata", "beside") +} diff --git a/internal/beside/reads-outside b/internal/beside/reads-outside new file mode 100644 index 0000000..f6b2d87 --- /dev/null +++ b/internal/beside/reads-outside @@ -0,0 +1 @@ +These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935). diff --git a/internal/compose/composed.go b/internal/compose/composed.go index 9dc317d..fffd0b8 100644 --- a/internal/compose/composed.go +++ b/internal/compose/composed.go @@ -11,6 +11,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/overlay" ) @@ -315,7 +316,7 @@ func RunsAnyOf(runs *delivery.Composed, module string) bool { // nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build // (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process. func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle { - runtimeID := catalogue.RuntimeModule + "." + catalogue.RuntimeProcessID() + runtimeID := catalogue.RuntimeModule + "." + resolution.RuntimeProcessID() type resource struct { ID string `json:"id"` Module string `json:"module"` @@ -340,9 +341,9 @@ func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle { switch { case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule: env = r.Env - case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+catalogue.BundleID("")): - name := strings.TrimPrefix(r.ID, owner+"."+catalogue.BundleID("")) - if r.Path == catalogue.BundlePath(owner, name) { + case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+resolution.BundleID("")): + name := strings.TrimPrefix(r.ID, owner+"."+resolution.BundleID("")) + if r.Path == resolution.BundlePath(owner, name) { out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest}) } } @@ -396,7 +397,7 @@ func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue } } if man.Build != nil { - run := catalogue.RunByAResource(man) + run := resolution.RunByAResource(man) for _, a := range man.Build.Artifacts { if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name { b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a) @@ -431,7 +432,7 @@ func NamesBuild(m catalogue.Manifest) bool { func Stub(m catalogue.Manifest) catalogue.Manifest { // The paths it owns stay its manifest's, as the controller tells the holder of node-files them (issue 1126, seed-8). if m.OwnsPaths == nil { - m.OwnsPaths = catalogue.DeclaredPaths(m) + m.OwnsPaths = resolution.DeclaredPaths(m) } m.Build = nil // What it hands the provisioner is left out as it runs, not refused for want of a plan (catalogue.Manifest.Stubbed). diff --git a/internal/compose/composed_test.go b/internal/compose/composed_test.go index c5373f4..35091c1 100644 --- a/internal/compose/composed_test.go +++ b/internal/compose/composed_test.go @@ -8,6 +8,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/overlay" ) @@ -118,7 +119,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { if err != nil { t.Fatal(err) } - served := "lamp=" + catalogue.BundlePath("lamp", "tools") + "/lamp-tools" + served := "lamp=" + resolution.BundlePath("lamp", "tools") + "/lamp-tools" runtimeRuns := func(dropped bool) delivery.Composed { env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"` if dropped { @@ -128,7 +129,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { `{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}} } lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') + - `","digest":"` + dg('b') + `","path":"` + catalogue.BundlePath("lamp", "tools") + `"}` + `","digest":"` + dg('b') + `","path":"` + resolution.BundlePath("lamp", "tools") + `"}` for _, row := range []struct { name string set func(n *Node) delivery.Composed // and what runs of lamp diff --git a/internal/compose/credentials.go b/internal/compose/credentials.go index 480c988..374302d 100644 --- a/internal/compose/credentials.go +++ b/internal/compose/credentials.go @@ -11,6 +11,7 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/compose/resolution" ) // Mode says what a composition does about what a send would make. Both modes read what the Input holds and make nothing; they @@ -64,7 +65,7 @@ func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overfl return nil, nil, nil, nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Provision, err) } plan := resolved.Plan - values, asks, err := plan.ContributionsFrom(s.Provision, s.ConsumerModule, resolved.Settings) + values, asks, err := resolution.ContributionsFrom(plan, s.Provision, s.ConsumerModule, resolved.Settings) if err != nil { return nil, nil, nil, nil, err } @@ -73,13 +74,13 @@ func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overfl if err != nil { return nil, nil, nil, nil, err } - values = catalogue.AtPublishedPort(values, s.ConsumerModule, consumer.Ports[s.ConsumerModule]) + values = resolution.AtPublishedPort(values, s.ConsumerModule, consumer.Ports[s.ConsumerModule]) from := s.ConsumerModule if !asks { // That module no longer wants this: left empty, which is how a login is withdrawn rather than kept working for ever. from = "" } - if bound := plan.BindsFrom(s.Provision, s.ConsumerModule, s.Local); from != "" && !slices.Contains(bound, node) { + if bound := resolution.BindsFrom(plan, s.Provision, s.ConsumerModule, s.Local); from != "" && !slices.Contains(bound, node) { // It still asks, and not of this node: withdrawn exactly as a credential nobody asks for, and said. from = "" unbound = append(unbound, catalogue.Unbound{Provision: s.Provision, Provider: node, @@ -294,7 +295,7 @@ func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing c foreseen[module][name] = true } for _, m := range plan.Modules { - if handing.Handing(m) { + if resolution.Handing(handing, m) { continue } // A secret family is never made (novox/hq ADR 0283): each member a person gave on this machine is placed, and one not @@ -391,7 +392,7 @@ type CoreBoxWanted struct { func coreModulesOf(modules []catalogue.Manifest, handed func(catalogue.Manifest) bool) map[string][]string { out := map[string][]string{} for _, m := range modules { - if !catalogue.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) { + if !resolution.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) { continue } var names []string @@ -424,7 +425,7 @@ func sealingKeyValid(raw string) error { // sealing key, or one whose key is not a key, leaves its module out, said. func (c *composer) coreBoxesFor(node Node, modules []catalogue.Manifest, handing catalogue.Rendering) ( sealed map[string]map[string]json.RawMessage, notPlaced map[string]string, wanted []CoreBoxWanted) { - core := coreModulesOf(modules, func(m catalogue.Manifest) bool { return handing.Handing(m) }) + core := coreModulesOf(modules, func(m catalogue.Manifest) bool { return resolution.Handing(handing, m) }) sealed, notPlaced = map[string]map[string]json.RawMessage{}, map[string]string{} mods := make([]string, 0, len(core)) for m := range core { diff --git a/internal/compose/frombus/answer.go b/internal/compose/frombus/answer.go index f5a5e9e..d2b0162 100644 --- a/internal/compose/frombus/answer.go +++ b/internal/compose/frombus/answer.go @@ -13,6 +13,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" ) // Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with @@ -410,7 +411,7 @@ func blames(m string, again, err error) bool { if m == catalogue.RuntimeModule { // Or where its stub fails only for the modules of their own account it would run (issue 774): they are held // after it, each for its own words, and the node composes the rest. - var none *catalogue.NoRuntimeToRunError + var none *resolution.NoRuntimeToRunError return again == nil || errors.As(again, &none) && none.Held } return again == nil || again.Error() != err.Error() diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go index a6c9c1c..daee654 100644 --- a/internal/compose/frombus/answer_rows_test.go +++ b/internal/compose/frombus/answer_rows_test.go @@ -13,6 +13,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/overlay" ) @@ -260,7 +261,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { switch r.ID { case "nft." + catalogue.FilteringID(): rules = r.Content - case "vault." + catalogue.KeptID(): + case "vault." + resolution.KeptID(): keptComposed = raw } } @@ -291,7 +292,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`), json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`), json.RawMessage(`{"id":"f1-circuits.server","module":"f1-circuits","type":"container","name":"f1-circuits","image":"registry.example/f1@sha256:` + strings.Repeat("c", 64) + `"}`)}} - if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) { + if got, want := resolution.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), resolution.DeclaredPaths(f1); !reflect.DeepEqual(got, want) { t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want) } // Issue 1126: a held module hands no template its build kept for a file it no longer carries (novox's node: diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index c5795c2..f063f49 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -45,6 +45,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/overlay" "github.com/novox/mesh-host/internal/takein" ) @@ -666,7 +667,7 @@ func plansIn(in compose.Input, node string, mode compose.Mode) (map[string]catal } with := rendered.Rendering with.Generators = gens - composed, err := resolved.Plan.Compose(with) + composed, err := resolution.Compose(resolved.Plan, with) if err != nil { return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err) } @@ -1095,7 +1096,7 @@ func storeSought(in compose.Input) string { func keptOf(c delivery.Composed) map[string]any { for _, raw := range c.Resources { var r map[string]any - if json.Unmarshal(raw, &r) == nil && r["id"] == c.Module+"."+catalogue.KeptID() { + if json.Unmarshal(raw, &r) == nil && r["id"] == c.Module+"."+resolution.KeptID() { return r } } @@ -1110,7 +1111,7 @@ func membershipOf(own delivery.Composed) string { ID string `json:"id"` Sealed string `json:"sealed"` } - if json.Unmarshal(raw, &r) == nil && r.ID == catalogue.BusMembershipID() { + if json.Unmarshal(raw, &r) == nil && r.ID == resolution.BusMembershipID() { return r.Sealed } } diff --git a/internal/compose/frombus/held_rows_test.go b/internal/compose/frombus/held_rows_test.go index 7bde353..bc8fae7 100644 --- a/internal/compose/frombus/held_rows_test.go +++ b/internal/compose/frombus/held_rows_test.go @@ -11,6 +11,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" ) // aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given. @@ -101,8 +102,8 @@ func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) { {"lamp", errors.New("lamp refuses"), false}, {catalogue.RuntimeModule, nil, true}, {catalogue.RuntimeModule, errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false}, - {catalogue.RuntimeModule, fmt.Errorf("compose: %w", &catalogue.NoRuntimeToRunError{Held: true}), true}, - {catalogue.RuntimeModule, &catalogue.NoRuntimeToRunError{}, false}, + {catalogue.RuntimeModule, fmt.Errorf("compose: %w", &resolution.NoRuntimeToRunError{Held: true}), true}, + {catalogue.RuntimeModule, &resolution.NoRuntimeToRunError{}, false}, } { if got := blames(row.module, row.again, err); got != row.blamed { t.Errorf("%s with %v: blamed %v, want %v", row.module, row.again, got, row.blamed) @@ -125,7 +126,7 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) { running := []delivery.Composed{{Node: "n1", Module: catalogue.RuntimeModule, FP: old, Resources: []json.RawMessage{ json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`), json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` + - dg("1") + `","digest":"` + dg("1") + `","path":"` + catalogue.BundlePath("node-tools", "runtime") + `"}`)}}} + dg("1") + `","digest":"` + dg("1") + `","path":"` + resolution.BundlePath("node-tools", "runtime") + `"}`)}}} for _, row := range []struct { name string want string // the build node-tools' want names diff --git a/internal/compose/frombus/states.go b/internal/compose/frombus/states.go index fbf92b0..ca932f3 100644 --- a/internal/compose/frombus/states.go +++ b/internal/compose/frombus/states.go @@ -15,9 +15,9 @@ import ( "strings" "time" - "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" ) // The states a module names in its manifest's `reads` to compose through StatesReader. @@ -135,7 +135,7 @@ func (r statesReader) Values(_ context.Context, node, module string) (map[string if !ok || strings.Contains(name, ".") { return } - if value, laid := catalogue.LaidValue(v); laid { + if value, laid := resolution.LaidValue(v); laid { if out == nil { out = map[string]any{} } diff --git a/internal/compose/network.go b/internal/compose/network.go index 2438efa..4e11abf 100644 --- a/internal/compose/network.go +++ b/internal/compose/network.go @@ -9,6 +9,7 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/overlay" ) @@ -282,7 +283,7 @@ func (c *composer) onTheNetworkNow() []Node { if len(n.Assigned) == 0 { continue } - got, err := catalogue.Resolve(c.in.Catalogue, n.Assigned, + got, err := resolution.Resolve(c.in.Catalogue, n.Assigned, catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities}, catalogue.World{Unchecked: true, Holdings: c.in.Holdings}) if err != nil { diff --git a/internal/compose/node.go b/internal/compose/node.go index 550b572..0aff1dc 100644 --- a/internal/compose/node.go +++ b/internal/compose/node.go @@ -7,6 +7,7 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/validate" ) @@ -178,13 +179,13 @@ func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) { with := rendered.Rendering with.Generators = gens plan := resolved.Plan - composed, err := plan.Compose(with) + composed, err := resolution.Compose(plan, with) if err != nil { return Declared{}, err } // Which ports the filter admits from the local network, and from which networks (novox/hq ADR 0340 clause 8): the same rules the // composition rendered, never sent. - rules, err := plan.Rules(with) + rules, err := resolution.Rules(plan, with) if err != nil { return Declared{}, err } @@ -198,7 +199,7 @@ func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) { Withheld: rendered.Withheld, Unbound: rendered.Unbound, Foreseen: composed.Foreseen, StoodIn: rendered.Foreseen, ForeseenPorts: rendered.ForeseenPorts, Unplaced: composed.Unplaced, HandedOnly: composed.HandedOnly, Modules: namedModules(plan, composed.LeftOut), - Filled: composed.Filled, LocalNetwork: catalogue.LocalNetworkPorts(rules, with.LocalNetworks), + Filled: composed.Filled, LocalNetwork: resolution.LocalNetworkPorts(rules, with.LocalNetworks), CoreBoxesWanted: rendered.CoreBoxesWanted, Said: rendered.Said, Sequence: node.Sequence, Epoch: node.Epoch, Generation: node.Generation} if len(composed.Filled) > 0 { @@ -206,7 +207,7 @@ func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) { } // A handed module only pulled in by another keeps what requires it, for its value of its own in the assignments mirror (issue 765). for module := range composed.Plans { - if by := plan.RequiredBy(module); by != "" { + if by := resolution.RequiredBy(plan, module); by != "" { if out.RequiredBy == nil { out.RequiredBy = map[string]string{} } @@ -347,5 +348,5 @@ func DataItems(in Input, node string) (map[string][]catalogue.PlacedDataItem, [] if err != nil { return nil, nil, err } - return resolved.Plan.DataItems(rendered.Rendering) + return resolution.DataItems(resolved.Plan, rendered.Rendering) } diff --git a/internal/compose/rendering.go b/internal/compose/rendering.go index 646ac86..c8db3e0 100644 --- a/internal/compose/rendering.go +++ b/internal/compose/rendering.go @@ -7,6 +7,7 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/overlay" ) @@ -156,7 +157,7 @@ func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) { // Which of this machine's links face outside (novox/hq ADR 0140), and which of its networks count as its local network // (ADR 0340). r.OutwardLinks = node.OutwardLinks - r.LocalNetworks = catalogue.CountedLocalNetworks(localNetworksOf(node)) + r.LocalNetworks = resolution.CountedLocalNetworks(localNetworksOf(node)) // Where this machine reaches each mesh seat's holder, for ${seat::reach} (novox/hq ADR 0222). if store != "" { @@ -205,7 +206,7 @@ func (c *composer) portsFor(node Node, plan catalogue.Resolution, settings catal given := map[string]map[int]int{} for _, m := range plan.Modules { - g, err := catalogue.GivenPorts(m, settings[m.Module]) + g, err := resolution.GivenPorts(m, settings[m.Module]) if err != nil { // A given port its definition no longer publishes: the module is left out of the declaration, by name, when it is // composed (novox/hq ADR 0163, rule 6) -- never the machine refused here for it. @@ -403,14 +404,14 @@ func (c *composer) zonesInTheMesh() ([]catalogue.ZoneAt, error) { if err != nil { return nil, fmt.Errorf("the zone %s declares on %s cannot be read: %w", m.Module, n.Name, err) } - z, err := catalogue.ZoneOn(m, layers, published, n.Name, at) + z, err := resolution.ZoneOn(m, layers, published, n.Name, at) if err != nil { return nil, err } zones = append(zones, *z) } } - if problems := catalogue.ZonesProblems(zones, c.suffix(), public); len(problems) > 0 { + if problems := resolution.ZonesProblems(zones, c.suffix(), public); len(problems) > 0 { return nil, fmt.Errorf("the mesh's zones cannot be forwarded:\n - %s", strings.Join(problems, "\n - ")) } return zones, nil @@ -511,7 +512,7 @@ func (c *composer) seatsOn(node Node, inSet []catalogue.Manifest, adopted bool, if adopted && !taken[name] { layers := node.Settings[m.Module] ports = map[int]int{} - if given, err := catalogue.GivenPorts(m, layers); err == nil { + if given, err := resolution.GivenPorts(m, layers); err == nil { ports = given } } @@ -539,5 +540,5 @@ func localNetworksOf(n Node) []catalogue.LocalNetwork { if !n.NetworksSaid { return nil } - return catalogue.JudgeLocalNetworks(n.Networks, n.OutwardLinks, overlay.Interface) + return resolution.JudgeLocalNetworks(n.Networks, n.OutwardLinks, overlay.Interface) } diff --git a/internal/compose/resolution/a_service_process_test.go b/internal/compose/resolution/a_service_process_test.go new file mode 100644 index 0000000..de7b3ac --- /dev/null +++ b/internal/compose/resolution/a_service_process_test.go @@ -0,0 +1,10 @@ +package resolution + +func indexOf(out []map[string]any, id string) int { + for i, r := range out { + if r["id"] == id { + return i + } + } + return -1 +} diff --git a/internal/compose/resolution/accesses_test.go b/internal/compose/resolution/accesses_test.go new file mode 100644 index 0000000..5bfc9f3 --- /dev/null +++ b/internal/compose/resolution/accesses_test.go @@ -0,0 +1,80 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The fault 04-ISSUES/036 records: a media stack is several modules — a library server, the +// acquisition managers, a download client — that must share directories on one machine. Written +// as owned `directory` resources, two of them on one node are refused as rivalrous owners, which +// is right in general and wrong here: sharing those directories is the whole point of the stack. +// +// Declared as accesses to an operator-owned path (novox/hq ADR 0051), they co-resolve. An access +// is not a resource and never enters the duplicate-owner map, so this is the exact case the +// resolver refuses above when the same path is owned — and does not, when it is merely accessed. +func TestTwoModulesAccessingOnePathCoResolve(t *testing.T) { + a := mod("a", nil, nil, nil) + a.Accesses = []mcat.Access{{Path: "/services/media/downloads", Mode: mcat.AccessReadWrite}} + b := mod("b", nil, nil, nil) + b.Accesses = []mcat.Access{{Path: "/services/media/downloads", Mode: mcat.AccessRead}} + + got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), mcat.World{}) + if err != nil { + t.Fatalf("two modules accessing one operator-owned path were refused: %v", err) + } + + // And each accessing module's grant reaches the machine as its own `access` resource, so the + // host can find the path present or refuse — both naming the one operator-owned path. + var accesses int + for _, r := range mustDeclare(t, got) { + if r["type"] == "access" && r["path"] == "/services/media/downloads" { + accesses++ + } + } + if accesses != 2 { + t.Errorf("expected both modules' accesses in the declaration, got %d", accesses) + } +} + +// The mirror of the case above, so the sharing vocabulary does not weaken the rule it sits beside: +// two modules OWNING one path is still refused, exactly as before accesses existed. +func TestTwoModulesOwningOnePathAreStillRefused(t *testing.T) { + a := mod("a", nil, nil, nil) + a.Resources = []map[string]any{ + {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}} + b := mod("b", nil, nil, nil) + b.Resources = []map[string]any{ + {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}} + + _, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), mcat.World{}) + if err == nil { + t.Fatal("two modules owning the same directory were both assigned") + } + if !strings.Contains(err.Error(), "/services/media/movies") { + t.Errorf("the refusal does not name the path: %v", err) + } +} + +// Shared data is the operator's, owned by no module (novox/hq ADR 0051). A module owning the path +// another module was told to expect the operator to provide would create and chown it — and the +// two intentions cannot both hold, so it is refused by name. This is what keeps the distinction +// machine-checkable rather than a convention nobody enforces. +func TestAModuleMayNotOwnWhatAnotherAccesses(t *testing.T) { + owns := mod("owns", nil, nil, nil) + owns.Resources = []map[string]any{ + {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}} + uses := mod("uses", nil, nil, nil) + uses.Accesses = []mcat.Access{{Path: "/services/media/movies", Mode: mcat.AccessRead}} + + _, err := Resolve(shelf(owns, uses), []string{"owns", "uses"}, workstation(), mcat.World{}) + if err == nil { + t.Fatal("a module owning a path another accesses was allowed") + } + if !strings.Contains(err.Error(), "/services/media/movies") || + !strings.Contains(err.Error(), "the operator's") { + t.Errorf("the refusal does not explain the ownership conflict: %v", err) + } +} diff --git a/internal/compose/resolution/adoption.go b/internal/compose/resolution/adoption.go new file mode 100644 index 0000000..5017ab9 --- /dev/null +++ b/internal/compose/resolution/adoption.go @@ -0,0 +1,177 @@ +package resolution + +import ( + "fmt" + "sort" + "strconv" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// GuardID, GuardUnitID and GuardRunningID are the guard's resource identities. The installer +// raises the same three on an adopted genesis, so the first push finds them already there. +func GuardID() string { return mcat.AdoptionPrefix + "guard" } + +func GuardUnitID() string { return mcat.AdoptionPrefix + "guard-unit" } + +func GuardRunningID() string { return mcat.AdoptionPrefix + "guard-running" } + +// GuardPackageID is the tool that loads the guard, declared first: a node joining adopted has +// no filter module and may have no nft at all, and a table nothing can load guards nothing. +func GuardPackageID() string { return mcat.AdoptionPrefix + "guard-package" } + +// OpeningID is an opening's resource identity: its protocol, port and path say what it is. +func OpeningID(protocol string, port int, path string) string { + return fmt.Sprintf("%sopening-%s-%d-%s", mcat.AdoptionPrefix, protocol, port, path) +} + +// Openings are what the mesh needs reachable on an adopted node, from the same inputs as the +// filter it would load were the node converged, each from where that filter would admit it. +// +// `rules` is Filtering's answer — every module's listens, the hub's port, the per-node exposure — +// and `foundation` is the ports the mesh itself needs, from everywhere. A rule for this machine +// only opens nothing. `published` maps a machine port a container publishes to the container's +// port: a published port is forwarded, not received, so its opening names the forwarded path and +// the port the packet is forwarded to. +func Openings(rules []mcat.Rule, foundation []int, published map[string]map[int]int) []map[string]any { + type key struct { + protocol string + port int + } + from := map[key]string{} + var order []key + widen := func(k key, f string) { + was, seen := from[k] + if !seen { + order = append(order, k) + } + if !seen || was != mcat.OpeningFromEverywhere { + from[k] = f + } + } + for _, rule := range rules { + switch rule.From { + case mcat.FromEverywhere: + widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromEverywhere) + case mcat.FromMesh: + widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromMesh) + case mcat.FromLocalNetwork: + // The private network's part only (novox/hq ADR 0340). An adopted node's found firewall + // stays, and it is what admits the machine's local network; an opening has no word for a + // local network, and widening it to everywhere would open the port to the internet. + widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromMesh) + } + } + for _, port := range foundation { + widen(key{"tcp", port}, mcat.OpeningFromEverywhere) + } + sort.Slice(order, func(a, b int) bool { + if order[a].port != order[b].port { + return order[a].port < order[b].port + } + return order[a].protocol < order[b].protocol + }) + out := make([]map[string]any, 0, len(order)) + for _, k := range order { + opening := map[string]any{"type": "opening", "port": k.port, "protocol": k.protocol, + "from": from[k]} + if to, forwarded := published[k.protocol][k.port]; forwarded { + opening["id"] = OpeningID(k.protocol, k.port, mcat.PathForwarded) + opening["path"] = mcat.PathForwarded + opening["to"] = to + } else { + opening["id"] = OpeningID(k.protocol, k.port, mcat.PathIncoming) + opening["path"] = mcat.PathIncoming + } + out = append(out, opening) + } + return out +} + +// AsGuard renders the mesh's refusal-only table for the given machine ports. +// +// It passes everything by default and holds nothing but a refusal, so it cannot close anything +// the machine serves; and it is the mesh's own table, so the found firewall reloading does not +// touch it. It refuses only packets addressed to this machine, and the ports except from the +// machine itself — its loopback and the container runtime's own networks — and from the private +// network, known by the interface a packet arrives on and never by its source address. At +// prerouting, ahead of the runtime's destination translation, so it matches the port the packet +// was sent to; in the inet family, so both address families. +// +// **The machine's own interfaces are named, where the derived filter names address ranges.** The +// filter accepts the container runtime's networks by CIDR; this excludes its bridges by name — lo, +// docker0, the br-* a compose network gets, and the mesh's own mesh0. A runtime whose bridge is +// named anything else (a podman or libvirt bridge, or a docker network created with a fixed name) +// would have its containers' traffic to a guarded port refused, which reads as the port being +// down. Names rather than addresses is deliberate: a source address can be claimed by whoever +// sends the packet, and this table exists to refuse what the found firewall never sees. Widening +// it means adding names here and in the installer's copy together, which the golden test holds to +// one text. +// +// The same text the installer raises on an adopted genesis; a test holds both to it. +func AsGuard(ports []int) string { + sorted := append([]int{}, ports...) + sort.Ints(sorted) + listed := make([]string, len(sorted)) + for i, p := range sorted { + listed[i] = strconv.Itoa(p) + } + var b strings.Builder + b.WriteString("table inet mesh_guard {}\n") + b.WriteString("delete table inet mesh_guard\n") + b.WriteString("table inet mesh_guard {\n") + b.WriteString("\tchain prerouting {\n") + b.WriteString("\t\ttype filter hook prerouting priority raw; policy accept;\n") + // Only packets addressed to this machine: traffic it routes for others — a predecessor's hub, + // say — is never the guard's business (novox/hq ADR 0103). + fmt.Fprintf(&b, "\t\tfib daddr type local iifname != \"lo\" iifname != \"docker0\" "+ + "iifname != \"br-*\" iifname != \"mesh0\" tcp dport { %s } drop\n", + strings.Join(listed, ", ")) + b.WriteString("\t}\n") + b.WriteString("}\n") + return b.String() +} + +// GuardUnitText is the unit that loads the guard. Stopping it deletes only its own table: never +// a flush, which would take the container runtime's rules and the found firewall with it. +func GuardUnitText() string { + return "[Unit]\n" + + "Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)\n" + + // Early, before the network is up, and without the default dependencies that would + // order it after the network; stopped at shutdown like any unit. + "DefaultDependencies=no\n" + + "Wants=network-pre.target\n" + + "Before=network-pre.target shutdown.target\n" + + "Conflicts=shutdown.target\n" + + "\n" + + "[Service]\n" + + "Type=oneshot\n" + + "RemainAfterExit=yes\n" + + "ExecStart=nft -f " + mcat.GuardPath + "\n" + + "ExecReload=nft -f " + mcat.GuardPath + "\n" + + "ExecStop=nft delete table inet mesh_guard\n" + + "\n" + + "[Install]\n" + + "WantedBy=multi-user.target\n" +} + +// GuardResources are the guard as four resources of the existing kinds: the tool that loads it, +// the table, the unit, and the unit running — reloaded when the table changes, so the new table +// replaces the old in one `nft -f` through the unit's ExecReload with no moment unguarded, and +// restarted only when the unit itself changes. Nothing when there is nothing to guard: an empty +// set is not a table nft loads. +func GuardResources(ports []int) []map[string]any { + if len(ports) == 0 { + return nil + } + return []map[string]any{ + {"id": GuardPackageID(), "type": "package", "package": mcat.GuardPackage}, + {"id": GuardID(), "type": "file", "path": mcat.GuardPath, "content": AsGuard(ports), + "mode": "0644"}, + {"id": GuardUnitID(), "type": "file", "path": mcat.GuardUnitPath, "content": GuardUnitText(), + "mode": "0644"}, + {"id": GuardRunningID(), "type": "service", "unit": mcat.GuardUnit, "state": "running", + "boot": "enabled", "restart-on": []any{GuardUnitID()}, "reload-on": []any{GuardID()}}, + } +} diff --git a/internal/compose/resolution/adoption_test.go b/internal/compose/resolution/adoption_test.go new file mode 100644 index 0000000..d704884 --- /dev/null +++ b/internal/compose/resolution/adoption_test.go @@ -0,0 +1,629 @@ +package resolution + +import ( + "encoding/json" + "reflect" + "slices" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// hub is the private network's generator on the hub: it opens the hub's port from anywhere. +type hub struct{} + +func (hub) Resources(string) ([]map[string]any, bool, error) { + return []map[string]any{{"id": "config", "type": "file", "path": "/etc/wireguard/mesh0.conf", + "content": "[Interface]\n"}}, true, nil +} + +func (hub) Listens(string) ([]mcat.Listening, error) { + return []mcat.Listening{{Port: 51820, Protocol: "udp", From: mcat.FromEverywhere}}, nil +} + +// anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network, +// a served module and the filter module. +func anAdoptedAnchor() mcat.Resolution { + return mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{ + {Module: "network", Computed: "overlay"}, + {Module: "postgres", Guards: []int{5432}, + Listens: []mcat.Listening{{Port: 5432, From: mcat.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store", + "ports": []any{"5432:5432"}}}}, + {Module: "lavinmq", Guards: []int{15672}, + Listens: []mcat.Listening{{Port: 5671, From: mcat.FromMesh}, {Port: 5672, From: mcat.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker", + "ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}}}}, + {Module: "distribution", + Listens: []mcat.Listening{{Port: 5000, From: mcat.FromMesh}}, + Resources: []map[string]any{{"id": "store", "type": "container", "name": "registry", + "ports": []any{"5000"}}}}, + {Module: "hello-web", + Listens: []mcat.Listening{{Port: 8080, From: mcat.FromEverywhere}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web", + "ports": []any{"8080"}}}}, + {Module: "helper", Listens: []mcat.Listening{{Port: 9000, From: mcat.FromMachine}}}, + {Module: "nftables", Filtering: &mcat.Filtering{Into: "/etc/nftables.conf"}, + Resources: []map[string]any{{"id": "load", "type": "service", "unit": "mesh-filter.service", + "state": "running", "restart-on": []any{"filtering"}}}}, + }} +} + +func anchorRendering(adopted bool) mcat.Rendering { + return mcat.Rendering{ + Generators: map[string]mcat.Generator{"overlay": hub{}}, + Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}}, + Settings: mcat.SettingsBy{"distribution": {{From: "node anchor", + Placement: map[string]any{mcat.ExposeSetting: map[string]any{"5000": mcat.FromEverywhere}}}}}, + Mesh: []string{"10.42.0.1"}, + Foundation: []int{5671}, + // What the machine reported faces outside, which every rule in the filter is written + // around (novox/hq ADR 0140). + OutwardLinks: []string{"eth0"}, + TunnelInterface: "mesh0", + Adopted: adopted, + // Genesis takes the foundation's modules. + Taken: map[string]bool{"postgres": true, "lavinmq": true}, + } +} + +func byID(resources []map[string]any) map[string]map[string]any { + out := map[string]map[string]any{} + for _, r := range resources { + out[r["id"].(string)] = r + } + return out +} + +func TestAnAdoptedNodeIsDeclaredOpeningsFromTheSameInputsAsTheFilter(t *testing.T) { + composed, err := Compose(anAdoptedAnchor(), anchorRendering(true)) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + want := map[string]map[string]any{ + // The hub's port, from anywhere, received. + "adoption.opening-udp-51820-incoming": {"port": 51820, "protocol": "udp", + "from": "everywhere", "path": "incoming"}, + // The store's port from the private network only, and forwarded: a container publishes it. + "adoption.opening-tcp-5432-forwarded": {"port": 5432, "protocol": "tcp", "from": "mesh", + "path": "forwarded", "to": 5432}, + // The bus from anywhere: a node enrols over it before it has a private address. + "adoption.opening-tcp-5671-forwarded": {"port": 5671, "protocol": "tcp", + "from": "everywhere", "path": "forwarded", "to": 5671}, + "adoption.opening-tcp-5672-forwarded": {"port": 5672, "protocol": "tcp", "from": "mesh", + "path": "forwarded", "to": 5672}, + // The registry from anywhere, by its node's exposure setting. + "adoption.opening-tcp-5000-forwarded": {"port": 5000, "protocol": "tcp", + "from": "everywhere", "path": "forwarded", "to": 5000}, + // A published port names the machine port and the container port it is forwarded to. + "adoption.opening-tcp-20001-forwarded": {"port": 20001, "protocol": "tcp", + "from": "everywhere", "path": "forwarded", "to": 8080}, + } + for id, fields := range want { + opening, ok := got[id] + if !ok { + t.Errorf("no %s among %v", id, keys(got)) + continue + } + if opening["type"] != "opening" { + t.Errorf("%s is a %v", id, opening["type"]) + } + for k, v := range fields { + if opening[k] != v { + t.Errorf("%s: %s is %v, want %v", id, k, opening[k], v) + } + } + } + for id := range got { + if strings.HasPrefix(id, "adoption.opening-") && want[id] == nil { + t.Errorf("an opening nothing asked for: %s", id) + } + } + // A port for this machine only opens nothing, and the management port is not opened at all. + for id := range got { + if strings.Contains(id, "-9000-") || strings.Contains(id, "-15672-") { + t.Errorf("%s is opened", id) + } + } + + // And openings come first, in the order the machine applies them. + if !strings.HasPrefix(composed.Resources[0]["id"].(string), "adoption.opening-") { + t.Errorf("openings are not first: %v", composed.Resources[0]["id"]) + } +} + +func TestAnAdoptedNodeLoadsNoFilterOfTheMeshs(t *testing.T) { + composed, err := Compose(anAdoptedAnchor(), anchorRendering(true)) + if err != nil { + t.Fatal(err) + } + for _, r := range composed.Resources { + if r["path"] == "/etc/nftables.conf" || strings.HasPrefix(r["id"].(string), "nftables.") { + t.Fatalf("an adopted node is declared the filter module's %v", r["id"]) + } + if content, _ := r["content"].(string); strings.Contains(content, "policy drop") { + t.Fatalf("an adopted node is declared a table that drops by default: %v", r["id"]) + } + // Nothing but refusals: the only accept in the guard is its policy. + if content, _ := r["content"].(string); r["id"] == GuardID() && + strings.Count(content, "accept") != 1 { + t.Fatalf("the guard holds an accept:\n%s", content) + } + } + got := byID(composed.Resources) + guard := got[GuardID()] + if guard == nil || got[GuardUnitID()] == nil || got[GuardRunningID()] == nil { + t.Fatalf("no guard: %v", keys(got)) + } + if guard["content"] != AsGuard([]int{5432, 5672, 15672}) { + t.Fatalf("the guard does not guard the store, the broker and its management port:\n%s", + guard["content"]) + } + // The tool that loads it comes first, and the table after it: a node joining adopted has no + // filter module and may have no nft. + pkg, table := -1, -1 + for i, r := range composed.Resources { + switch r["id"] { + case GuardPackageID(): + pkg = i + if r["type"] != "package" || r["package"] != "nftables" { + t.Fatalf("the guard's package is %v", r) + } + case GuardID(): + table = i + } + } + if pkg < 0 || pkg > table { + t.Fatalf("nftables is not declared before the guard's table (%d, %d)", pkg, table) + } + // A changed table is reloaded — one `nft -f`, atomic — never restarted, which would delete the + // table and leave the ports unguarded until it is loaded again. Only a changed unit restarts. + if !reflect.DeepEqual(got[GuardRunningID()]["reload-on"], []any{GuardID()}) || + !reflect.DeepEqual(got[GuardRunningID()]["restart-on"], []any{GuardUnitID()}) { + t.Fatalf("the guard is not reloaded on its table and restarted on its unit: %v", + got[GuardRunningID()]) + } + // Nothing of the mesh's own is anybody's to hold. + for id, module := range composed.Owner { + if strings.HasPrefix(id, mcat.AdoptionPrefix) { + t.Fatalf("%s is owned by %s", id, module) + } + } +} + +func TestAConvergedNodeIsDeclaredItsFilterAndNoOpenings(t *testing.T) { + composed, err := Compose(anAdoptedAnchor(), anchorRendering(false)) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + if got["nftables.filtering"] == nil || got["nftables.load"] == nil { + t.Fatalf("a converged node lost its filter: %v", keys(got)) + } + for id := range got { + if strings.HasPrefix(id, mcat.AdoptionPrefix) { + t.Fatalf("a converged node is declared %s", id) + } + } + plain, err := Declaration(anAdoptedAnchor(), anchorRendering(false)) + if err != nil { + t.Fatal(err) + } + a, _ := json.Marshal(plain) + b, _ := json.Marshal(composed.Resources) + if string(a) != string(b) { + t.Fatal("Compose and Declaration disagree on a converged node") + } +} + +// The table the installer raises and the controller declares, character for character. +func TestTheGuardIsExactlyThisTable(t *testing.T) { + const golden = `table inet mesh_guard {} +delete table inet mesh_guard +table inet mesh_guard { + chain prerouting { + type filter hook prerouting priority raw; policy accept; + fib daddr type local iifname != "lo" iifname != "docker0" iifname != "br-*" iifname != "mesh0" tcp dport { 5432, 15672 } drop + } +} +` + if got := AsGuard([]int{15672, 5432}); got != golden { + t.Fatalf("the guard changed:\n%s", got) + } + const unit = `[Unit] +Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100) +DefaultDependencies=no +Wants=network-pre.target +Before=network-pre.target shutdown.target +Conflicts=shutdown.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=nft -f /etc/mesh/guard.nft +ExecReload=nft -f /etc/mesh/guard.nft +ExecStop=nft delete table inet mesh_guard + +[Install] +WantedBy=multi-user.target +` + if got := GuardUnitText(); got != unit { + t.Fatalf("the guard's unit changed:\n%s", got) + } + if GuardResources(nil) != nil { + t.Fatal("a guard with nothing to guard is an empty set nft refuses to load") + } +} + +func keys[V any](m map[string]V) []string { + return sortedKeys(m) +} + +// novox/hq ADR 0100: the foundation's ports are the node's. Given 5433 for the store, every place +// that uses the port reads it from there: the container, the filter, the openings, the guard. +func TestAGivenPortIsUsedEverywhereThePortIs(t *testing.T) { + given := map[string]map[int]int{"postgres": {5432: 5433}, "lavinmq": {15672: 15673}} + for _, adopted := range []bool{true, false} { + with := anchorRendering(adopted) + with.Given = given + with.Ports["postgres"] = map[int]int{5432: 5433} + composed, err := Compose(anAdoptedAnchor(), with) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + if ports := got["postgres.server"]["ports"]; !reflect.DeepEqual(ports, []any{"5433:5432"}) { + t.Fatalf("the store's container publishes %v", ports) + } + if ports := got["lavinmq.server"]["ports"]; !reflect.DeepEqual(ports, + []any{"5671:5671", "5672:5672", "127.0.0.1:15673:15672"}) { + t.Fatalf("the broker's container publishes %v", ports) + } + if !adopted { + filter, _ := got["nftables.filtering"]["content"].(string) + if !strings.Contains(filter, "tcp dport 5433 accept") || strings.Contains(filter, "5432") { + t.Fatalf("the filter does not use the given port:\n%s", filter) + } + continue + } + if o := got["adoption.opening-tcp-5433-forwarded"]; o == nil || o["to"] != 5432 { + t.Fatalf("no opening for the given port: %v", keys(got)) + } + if guard := got[GuardID()]["content"]; guard != AsGuard([]int{5433, 5672, 15673}) { + t.Fatalf("the guard does not guard the given ports:\n%s", guard) + } + } +} + +// novox/hq ADR 0103: the guard is derived, and from taken modules only — every machine port a taken +// module publishes that the filter admits from the private network only, and the ports its +// manifest guards. A module assigned but not taken is not guarded: its port may still be the +// predecessor's. +func TestTheGuardIsDerivedFromTakenModulesOnly(t *testing.T) { + guardOf := func(with mcat.Rendering) string { + t.Helper() + composed, err := Compose(anAdoptedAnchor(), with) + if err != nil { + t.Fatal(err) + } + content, _ := byID(composed.Resources)[GuardID()]["content"].(string) + return content + } + + // The broker taken, the store not: the broker's plain port follows from its listens (from + // the mesh, published), its management port from its manifest; the store is not guarded, and + // neither is the bus, which the mesh needs from everywhere. + with := anchorRendering(true) + with.Taken = map[string]bool{"lavinmq": true} + if got := guardOf(with); got != AsGuard([]int{5672, 15672}) { + t.Fatalf("the guard is not the taken broker's ports:\n%s", got) + } + + // A taken module publishing a port admitted from everywhere is not guarded; one admitted from + // the mesh is. The registry is exposed everywhere on this node, and hello-web listens from + // everywhere. + with.Taken = map[string]bool{"distribution": true, "hello-web": true} + if got := guardOf(with); got != "" { + t.Fatalf("a port admitted from everywhere is guarded:\n%s", got) + } + with.Settings = nil + if got := guardOf(with); got != AsGuard([]int{5000}) { + t.Fatalf("the registry, from the mesh only, is not guarded:\n%s", got) + } + + // Nothing taken, nothing guarded — and no guard at all rather than an empty set. + with = anchorRendering(true) + with.Taken = nil + if got := guardOf(with); got != "" { + t.Fatalf("an untaken store is guarded:\n%s", got) + } + + // A given port is followed: where the machine put it is what is refused. + with = anchorRendering(true) + with.Given = map[string]map[int]int{"lavinmq": {5672: 5682, 15672: 15673}} + with.Ports["lavinmq"] = map[int]int{5671: 5671, 5672: 5682} + if got := guardOf(with); got != AsGuard([]int{5432, 5682, 15673}) { + t.Fatalf("the guard does not follow the given ports:\n%s", got) + } +} + +// novox/hq ADR 0038: only a published port is the mesh's to move. A module that binds the machine +// itself listens where its software was told to, so giving it a machine port is refused. +func TestAGivenPortIsRefusedForAPortNoContainerPublishes(t *testing.T) { + onTheMachine := mcat.Manifest{Module: "daemon", + Listens: []mcat.Listening{{Port: 9000, From: mcat.FromMesh}}, Guards: []int{9000}} + layers := []mcat.Layer{{From: "node anchor", + Placement: map[string]any{mcat.PortsSetting: map[string]any{"9000": float64(9100)}}}} + _, err := GivenPorts(onTheMachine, layers) + if err == nil || !strings.Contains(err.Error(), "does not publish") { + t.Fatalf("a port no container publishes was given: %v", err) + } +} + +// novox/hq ADR 0103: a guarded port this node is told to open to everyone is opened, not guarded. +// An opening from everywhere beside a guard dropping it is one statement refusing the other. +func TestAGuardedPortOpenedToEveryoneIsNotGuarded(t *testing.T) { + with := anchorRendering(true) + with.Settings["postgres"] = []mcat.Layer{{From: "node anchor", + Placement: map[string]any{mcat.ExposeSetting: map[string]any{"5432": mcat.FromEverywhere}}}} + composed, err := Compose(anAdoptedAnchor(), with) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + if o := got["adoption.opening-tcp-5432-forwarded"]; o == nil || o["from"] != mcat.OpeningFromEverywhere { + t.Fatalf("the store's port is not opened to everyone: %v", o) + } + if guard, _ := got[GuardID()]["content"].(string); guard != AsGuard([]int{5672, 15672}) { + t.Fatalf("a port opened to everyone is still guarded:\n%s", guard) + } +} + +// A module that publishes its ssh port the long way — `2222:22`, because the machine's own daemon +// holds 22 — and says it listens on the machine side of that mapping, which is what anything +// reaching it dials. +func aForge() mcat.Manifest { + return mcat.Manifest{Module: "forge", + Provides: []mcat.Offer{{Name: "git-over-ssh", Scope: mcat.ScopeMesh}}, + Serves: map[string]map[string]any{"git-over-ssh": {"port": 2222}}, + Listens: []mcat.Listening{{Port: 3000, From: mcat.FromMesh}, {Port: 2222, From: mcat.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "forge", + "ports": []any{"3000", "2222:22"}}}} +} + +// portsAsThePlanWould is where this machine puts each of a module's ports, derived the way +// cmd/mesh-controller/plan.go derives it: a given port first, looked up by the port the module +// says it listens on, and otherwise wherever the manifest's own mapping already put it. Written +// here because everything below — the filter, the openings, the guard, what a consumer is told — +// reads that map, and a given port that the lookup does not find moves the container's mapping +// and nothing else. +// +// It stands in for the plan only where the plan does not allocate: a port the manifest already +// placed, or one a node was given. For a short form with no given port the real plan asks the +// inventory for a machine port and may come back with one from the pool, which needs a store and +// is what cmd/mesh-controller's own tests exercise. So an assertion here about such a port asserts +// this helper, not the mesh; keep the assertions to the ports under test. +func portsAsThePlanWould(m mcat.Manifest, given map[int]int) map[int]int { + out := map[int]int{} + for _, l := range m.Listens { + if at, is := given[l.Port]; is { + out[l.Port] = at + continue + } + at, _ := m.MachineSide(l.Port) + out[l.Port] = at + } + return out +} + +// novox/hq ADR 0100 and 0038: the machine side of a long-form mapping is a port the module +// publishes, so a node may move it — and a module may name a mapping by either end. +func TestAGivenPortNamesEitherEndOfWhatTheModulePublishes(t *testing.T) { + forge := aForge() + node := func(v any) []mcat.Layer { + return []mcat.Layer{{From: "node anchor", Placement: map[string]any{mcat.PortsSetting: v}}} + } + + // The machine side — the number this module says it listens on, and the one the predecessor + // had somewhere else. Answered under 2222, the end the module itself names, which is what + // every reader of this map asks for. One entry, not two: a second key for the same answer is + // an entry another mapping's reader could find instead. + given, err := GivenPorts(forge, node(map[string]any{"2222": float64(222)})) + if err != nil { + t.Fatalf("the machine side of a mapping cannot be given a port: %v", err) + } + if want := map[int]int{2222: 222}; !reflect.DeepEqual(given, want) { + t.Fatalf("the forge was given %v; the mapping it names is filed under %v", given, want) + } + + // The container's own port names the same mapping and means the same thing — and is filed + // under the same name, because the module's name for it has not changed. + if inside, err := GivenPorts(forge, node(map[string]any{"22": float64(222)})); err != nil || + !reflect.DeepEqual(inside, map[int]int{2222: 222}) { + t.Fatalf("the container's end of the mapping was given %v: %v", inside, err) + } + + // A short form is published on the number it names, and is unchanged by any of this. + if short, err := GivenPorts(forge, node(map[string]any{"3000": float64(2999)})); err != nil || + short[3000] != 2999 { + t.Fatalf("the short form was given %v: %v", short, err) + } + + // A port no container publishes is still refused, in the same words. + if _, err := GivenPorts(forge, node(map[string]any{"9000": float64(9100)})); err == nil || + !strings.Contains(err.Error(), "does not publish") { + t.Fatalf("a port the forge does not publish was given: %v", err) + } + + // And the two ends of one mapping given two different numbers is one setting contradicting + // the other: the machine publishes it once. + if _, err := GivenPorts(forge, node(map[string]any{ + "2222": float64(222), "22": float64(300)})); err == nil || + !strings.Contains(err.Error(), "one mapping") { + t.Fatalf("the two ends of one mapping were given different ports: %v", err) + } + // Said at both ends with the same number, it is still said twice, and refused where every + // other repeated machine port is — as the inventory refuses it when the setting is stored, + // which is the layer that sees it first. + if _, err := GivenPorts(forge, node(map[string]any{ + "2222": float64(222), "22": float64(222)})); err == nil || + !strings.Contains(err.Error(), "to both its 22 and its 2222") { + t.Fatalf("one mapping given one machine port at both ends: %v", err) + } + // A number that names two different mappings names neither: which one to move is not said. + twice := aForge() + twice.Resources[0]["ports"] = []any{"22", "2222:22"} + if _, err := GivenPorts(twice, node(map[string]any{"22": float64(222)})); err == nil || + !strings.Contains(err.Error(), "twice") { + t.Fatalf("a number naming two of the module's mappings was accepted: %v", err) + } + + // And the same refusal when the number naming two mappings is not the one the setting used + // but the one the answer would be filed under. Here `80` is the module's own name for a + // mapping, and two mappings wear it; filing an answer there is one container's port standing + // where the other's is read, and both containers then publish it. + shared := mcat.Manifest{Module: "gallery", + Listens: []mcat.Listening{{Port: 80, From: mcat.FromMesh}}, + Resources: []map[string]any{ + {"id": "a", "type": "container", "name": "a", "ports": []any{"4001:80"}}, + {"id": "b", "type": "container", "name": "b", "ports": []any{"4002:80"}}}} + if _, err := GivenPorts(shared, node(map[string]any{"4001": float64(1234)})); err == nil || + !strings.Contains(err.Error(), "twice") { + t.Fatalf("two containers were put on one machine port: %v", err) + } + + // A module whose mappings chain — one's machine side is another's container port — keeps them + // apart, because each is filed under the port the module names it by and neither name is + // shared. Given both, each moves on its own and neither overwrites the other. + chained := mcat.Manifest{Module: "chain", + Listens: []mcat.Listening{{Port: 80, From: mcat.FromMesh}, {Port: 9090, From: mcat.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "chain", + "ports": []any{"8080:80", "9090:8080"}}}} + both, err := GivenPorts(chained, node(map[string]any{"80": float64(1234), "9090": float64(5678)})) + if err != nil || !reflect.DeepEqual(both, map[int]int{80: 1234, 9090: 5678}) { + t.Fatalf("chained mappings were given %v: %v", both, err) + } + if moved := givenOuter("8080:80", both); moved != "1234:80" { + t.Fatalf("the first mapping moved to %q, and it was given 1234", moved) + } + if moved := givenOuter("9090:8080", both); moved != "5678:8080" { + t.Fatalf("the second mapping moved to %q, and it was given 5678", moved) + } +} + +// And the number reaches everything derived from it. The fault this is written against moved the +// container's mapping alone: the filter opened the port the software had left, the adopted node's +// opening named it too, the guard refused it, and a consumer was sent to it. +func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T) { + forge := aForge() + given, err := GivenPorts(forge, []mcat.Layer{{From: "node anchor", + Placement: map[string]any{mcat.PortsSetting: map[string]any{"2222": float64(222)}}}}) + if err != nil { + t.Fatalf("the machine side of a mapping cannot be given a port: %v", err) + } + r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{forge}} + with := mcat.Rendering{ + Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)}, + Given: map[string]map[int]int{"forge": given}, + Mesh: []string{"10.77.0.1"}, + Adopted: true, + OutwardLinks: []string{"eth0"}, + TunnelInterface: "mesh0", + Taken: map[string]bool{"forge": true}, + } + + // What the runtime is handed: the machine's own port on the outside, the container's within. + composed, err := Compose(r, with) + if err != nil { + t.Fatalf("the forge does not compose: %v", err) + } + got := byID(composed.Resources) + if ports := got["forge.server"]["ports"]; !reflect.DeepEqual(ports, []any{"3000:3000", "222:22"}) { + t.Fatalf("the forge's container publishes %v", ports) + } + + // What the filter would open, were the node converged. + rules, err := Rules(r, with) + if err != nil { + t.Fatal(err) + } + var opened []int + for _, rule := range rules { + opened = append(opened, rule.Port) + } + // Only the moved port is asserted: the forge's other port is a short form the real plan would + // allocate rather than read off the manifest, so its number here is portsAsThePlanWould's and + // not the mesh's. + if !slices.Contains(opened, 222) || slices.Contains(opened, 2222) { + t.Fatalf("the filter opens %v, not where the machine puts the forge", opened) + } + + // And what it is declared instead, adopted: an opening on the machine's port, naming the + // container's port on the forwarded path — a published port is forwarded, never received. + opening := got[OpeningID("tcp", 222, mcat.PathForwarded)] + if opening == nil || opening["to"] != 22 || opening["from"] != mcat.OpeningFromMesh { + t.Fatalf("no opening for the port this node gave the forge: %v", keys(got)) + } + for id := range got { + if strings.HasPrefix(id, "adoption.opening-tcp-2222-") { + t.Errorf("an opening for the port the forge was moved off: %s", id) + } + } + + // The guard refuses it where the machine put it, and nothing where it used to be. + guard, _ := got[GuardID()]["content"].(string) + if !strings.Contains(guard, "222") || strings.Contains(guard, "2222") { + t.Fatalf("the guard does not follow the given port:\n%s", guard) + } + + // And a consumer is sent to the same number, which is read from what the module serves. + if told := ServedOn(forge, "git-over-ssh", with.Ports["forge"])["port"]; told != 222 { + t.Fatalf("a consumer is told the forge answers on %v", told) + } +} + +// And the mapping itself moves under either name, because Rendering.Given is a map anybody +// composing a declaration hands in: keyed by the machine side, which is what a module declaring +// 2222 calls its port, only the outside moves and the container's own port stays as written. +func TestAMappingIsMovedUnderEitherOfItsNames(t *testing.T) { + for _, c := range []struct { + written string + given map[int]int + want string + }{ + {"2222:22", map[int]int{2222: 222}, "222:22"}, + {"2222:22", map[int]int{22: 222}, "222:22"}, + {"127.0.0.1:15672:15672/tcp", map[int]int{15672: 15673}, "127.0.0.1:15673:15672/tcp"}, + {"2222:22", map[int]int{3000: 2999}, "2222:22"}, + {"2222:22", nil, "2222:22"}, + } { + if got := givenOuter(c.written, c.given); got != c.want { + t.Errorf("%s given %v is published as %s, want %s", c.written, c.given, got, c.want) + } + } +} + +// The same on a node that was given nothing, which is where the derivation was never at fault: +// a long-form mapping is published where the manifest put it, and an adopted node opens that port +// on the forwarded path like any other. What broke it was the number reaching only the mapping. +func TestALongFormPortIsOpenedWhereTheManifestPublishesIt(t *testing.T) { + forge := aForge() + r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{forge}} + composed, err := Compose(r, mcat.Rendering{ + Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)}, + Mesh: []string{"10.77.0.1"}, + Adopted: true, + OutwardLinks: []string{"eth0"}, + TunnelInterface: "mesh0", + }) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + opening := got[OpeningID("tcp", 2222, mcat.PathForwarded)] + if opening == nil || opening["to"] != 22 { + t.Fatalf("no opening for the forge's published ssh port: %v", keys(got)) + } +} diff --git a/internal/compose/resolution/agent_account_test.go b/internal/compose/resolution/agent_account_test.go new file mode 100644 index 0000000..a54e628 --- /dev/null +++ b/internal/compose/resolution/agent_account_test.go @@ -0,0 +1,83 @@ +package resolution + +import ( + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) { + facts := machineFacts(mcat.Resolution{Node: "anchor", Account: "ops"}, nil, "") + if facts["agent-account"] != "ops" || facts["agent-home"] != "/home/ops" || facts["agent-root"] != "" { + t.Errorf("with no agent account named, agents run as the operator: %v", facts) + } + facts = machineFacts(mcat.Resolution{Node: "anchor", Account: "ops", AccountHome: "/srv/ops"}, nil, "") + if facts["agent-home"] != "/srv/ops" { + t.Errorf("the operator's stated home is the agent's home when they are one account: %v", facts) + } + facts = machineFacts(mcat.Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "") + if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != mcat.RootNever { + t.Errorf("a named agent account is the agents', never root: %v", facts) + } + if facts["account"] != "ops" { + t.Errorf("the operator account is still the operator's: %v", facts) + } + facts = machineFacts(mcat.Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "") + if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != mcat.RootNever { + t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts) + } + if _, has := machineFacts(mcat.Resolution{Node: "anchor"}, nil, "")["agent-account"]; has { + t.Error("a machine with no account at all names an agent account") + } +} + +// The agent's module, in the shape the catalogue's declares it: the account, never root where it is its +// own; its directory under that home, owned by it. +const agentModule = `{"module": "agent", "version": "1", "resources": [ + {"id": "account", "type": "user", "name": "${machine:agent-account}", "home": "${machine:agent-home}", + "root": "${machine:agent-root}"}, + {"id": "home", "type": "directory", "path": "${machine:agent-home}/.agent", "mode": "0700", + "owner": "${machine:agent-account}"} +]}` + +func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) { + m, err := decodeForTest([]byte(agentModule)) + if err != nil { + t.Fatal(err) + } + compose := func(r mcat.Resolution, with mcat.Rendering) (user, home map[string]any) { + t.Helper() + r.Node, r.Modules = "anchor", []mcat.Manifest{m} + out, err := Declaration(r, with) + if err != nil { + t.Fatal(err) + } + return fileNamed(out, "agent.account"), fileNamed(out, "agent.home") + } + + user, home := compose(mcat.Resolution{Account: "ops", AgentAccount: "agent"}, mcat.Rendering{JudgesRoot: true}) + if user["name"] != "agent" || user[mcat.RootField] != mcat.RootNever || user["home"] != "/home/agent" { + t.Errorf("an engine that judges root is sent the agent account never to become root: %v", user) + } + if home["path"] != "/home/agent/.agent" || home["owner"] != "agent" { + t.Errorf("the agent's directory is under its own home, its own: %v", home) + } + + user, _ = compose(mcat.Resolution{Account: "ops", AgentAccount: "agent", AgentAccountHome: "/srv/agent"}, mcat.Rendering{JudgesRoot: true}) + if user["home"] != "/srv/agent" { + t.Errorf("an agent account named with a home of its own is made there: %v", user) + } + + user, _ = compose(mcat.Resolution{Account: "ops", AgentAccount: "agent"}, mcat.Rendering{}) + if _, sent := user[mcat.RootField]; sent || user["name"] != "agent" { + t.Errorf("an older engine, which parses strictly, is sent root: %v", user) + } + + user, home = compose(mcat.Resolution{Account: "ops"}, mcat.Rendering{JudgesRoot: true}) + if _, sent := user[mcat.RootField]; sent || user["name"] != "ops" { + t.Errorf("where agents run as the operator, root asserts nothing and is not sent: %v", user) + } + if home["path"] != "/home/ops/.agent" || home["owner"] != "ops" { + t.Errorf("with no agent account, the agent's directory is the operator's: %v", home) + } +} diff --git a/internal/compose/resolution/artifact_store_seat_test.go b/internal/compose/resolution/artifact_store_seat_test.go new file mode 100644 index 0000000..ebe2ce2 --- /dev/null +++ b/internal/compose/resolution/artifact_store_seat_test.go @@ -0,0 +1,40 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The artifact store's seat is one per mesh, read from the catalogue beside this checkout. +// +// **A second store anywhere is refused by name, not discovered as a consumer failure.** The seat +// was node-scoped, so a second `distribution` on another machine resolved cleanly there — and a +// node-scoped requirement with one candidate installs that candidate on the node, so anything that +// required the store's presence beside it would have raised a fresh, empty store on the wrong +// machine. Only afterwards did the mesh notice: `artifact-store` offered by two nodes, and every +// consumer elsewhere refusing to choose. The claim says it first, where the second store is +// assigned. +func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) { + store := catalogueManifest(t, "distribution") + + // The first store resolves as it always has. + if _, err := Resolve(shelf(store), []string{"distribution"}, workstation(), mcat.World{}); err != nil { + t.Fatalf("the store alone does not resolve: %v", err) + } + + // A second one, on any other machine, is refused — and the refusal names the seat. + elsewhere := mcat.World{Held: []mcat.Held{{Claim: "mesh-artifact-store", Scope: mcat.ScopeMesh, + Node: "anchor", Module: "distribution"}}} + other := workstation() + other.Name = "laptop" + _, err := Resolve(shelf(store), []string{"distribution"}, other, elsewhere) + if err == nil { + t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " + + "second time and every consumer elsewhere would refuse to choose") + } + if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") { + t.Fatalf("refused without naming the seat: %v", err) + } +} diff --git a/internal/compose/resolution/artifact_sweep_seat.go b/internal/compose/resolution/artifact_sweep_seat.go new file mode 100644 index 0000000..98888e7 --- /dev/null +++ b/internal/compose/resolution/artifact_sweep_seat.go @@ -0,0 +1,43 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks. +func artifactSweepVerbs() []mcat.Verb { + return []mcat.Verb{ + {Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " + + "repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " + + "newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " + + "unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.", + Input: schema(map[string]string{ + "repository": "only this repository's references, as / (the counts stay whole)", + "collected": "\"true\": list the collected references too (counted always)", + }, nil, "collected")}, + {Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " + + "held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " + + "Reads only.", + Input: listed(schema(map[string]string{ + "references": "the artifacts' references, artifact-store:////blobs/sha256:: a list, " + + "or one text separated by commas", + }, []string{"references"}), "references")}, + {Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " + + "dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " + + "then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " + + "names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " + + "seconds; what is left is said. Never touches a digest the mesh did not make.", + Input: schema(map[string]string{ + "confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done", + "why": "with confirm: why, required and recorded", + "most": "let go of at most this many (500 by default, at most 5000)", + }, nil, "confirm")}, + {Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " + + "it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " + + "it: never removed), by what the builder's log says each build stood on. Records nothing by hand.", + Input: schema(map[string]string{}, nil)}, + {Name: "images", Description: "Every container image one machine's want and have name, each with the resources " + + "naming it: what the machine keeps when it prunes its images. Reads only.", + Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})}, + } +} diff --git a/internal/compose/resolution/artifacts.go b/internal/compose/resolution/artifacts.go new file mode 100644 index 0000000..559ac1b --- /dev/null +++ b/internal/compose/resolution/artifacts.go @@ -0,0 +1,65 @@ +package resolution + +import ( + "fmt" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// artifactsInto composes the artifact store's address into a resource's `image` and `source`. +// +// **Composed here, at the last moment before a machine, and stored nowhere.** A kept reference is +// routed through the store as this network reaches it now. A reference recorded with an address +// before references were kept without one is re-routed the same way — but only when the mesh +// built it (`with.Built` names every `/` it has), because a module may run an +// image from a public registry under its own name and that one is exactly where it says. +// +// A kept reference with no store to route it through is refused: sent as it is, the runtime would +// refuse the scheme on the machine, one push away from the reason. +// +// **What this does not reach: the images genesis pinned.** The installer builds the control plane +// and the builder before the mesh exists, pushes them itself and pins their manifests to +// `:/mesh-controller@…` and `:/mesh-builder@…` — single-segment +// repositories with no build record, so `with.Built` does not name them and they are left as +// written until each is rebuilt through the mesh, which records it by digest and path. Until then +// a registry that moves strands exactly those two on a recreate, and the control plane's is the +// one that cannot be repaired through the mesh. Rebuild both through `build` before moving the +// store (novox/hq 04-ISSUES/102, finding F4). +func artifactsInto(resource map[string]any, module string, with mcat.Rendering) error { + for _, key := range []string{"image", "source"} { + written, ok := resource[key].(string) + if !ok { + continue + } + if _, kept := mcat.InArtifactStore(written); kept { + if with.ArtifactStore == "" { + return fmt.Errorf( + "%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+ + "artifact store on its network to fetch it from — nothing assigned offers "+ + "%s, or the machine offering it is not on the private network", + module, resource["id"], written, mcat.ArtifactStoreProvision) + } + resource[key] = mcat.Routed(written, with.ArtifactStore) + continue + } + if with.ArtifactStore == "" { + continue + } + recorded := mcat.Recorded(written) + if recorded == written { + continue + } + path, _ := mcat.InArtifactStore(recorded) + repository := path + if at := strings.IndexAny(path, "@"); at >= 0 { + repository = path[:at] + } else if blobs := strings.Index(path, "/blobs/"); blobs >= 0 { + repository = path[:blobs] + } + if with.Built[repository] { + resource[key] = mcat.Routed(recorded, with.ArtifactStore) + } + } + return nil +} diff --git a/internal/compose/resolution/artifacts_test.go b/internal/compose/resolution/artifacts_test.go new file mode 100644 index 0000000..e9ab076 --- /dev/null +++ b/internal/compose/resolution/artifacts_test.go @@ -0,0 +1,102 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +var digest = "sha256:" + strings.Repeat("d", 64) + +// **The address is composed into a declaration, and the record never carries it.** +func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) { + m := mcat.Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "mesh-gitea", "artifact": "server"}, + {"id": "config", "type": "archive", "path": "/etc/gitea", "artifact": "config"}, + {"id": "cache", "type": "container", "name": "mesh-gitea-cache", "image": "valkey/valkey@" + digest}, + }, Build: &mcat.Build{Artifacts: []mcat.Artifact{ + {Name: "server", Kind: mcat.ArtifactImage, From: "Dockerfile"}, + {Name: "config", Kind: mcat.ArtifactArchive, From: "config"}, + }}} + resolved, err := m.Resolve([]mcat.Built{ + {Name: "server", Kind: mcat.ArtifactImage, Reference: mcat.ArtifactStoreScheme + "gitea/server@" + digest}, + {Name: "config", Kind: mcat.ArtifactArchive, Reference: mcat.ArtifactStoreScheme + "gitea/config/blobs/" + digest, Digest: digest}, + }) + if err != nil { + t.Fatal(err) + } + r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{resolved}} + + out, err := Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101"}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "gitea.server")["image"]; got != "anchor.internal:5101/gitea/server@"+digest { + t.Errorf("the image the mesh built is fetched as %v", got) + } + if got := fileNamed(out, "gitea.config")["source"]; got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest { + t.Errorf("the archive the mesh built is fetched from %v", got) + } + if got := fileNamed(out, "gitea.cache")["image"]; got != "valkey/valkey@"+digest { + t.Errorf("an image from a public registry was routed through the store: %v", got) + } + // The manifest the mesh holds still says what it is, not where it was fetched from. + if got := resolved.Resources[0]["image"]; got != mcat.ArtifactStoreScheme+"gitea/server@"+digest { + t.Errorf("composing wrote the address into the catalogue's copy: %v", got) + } + + // And the store moves: the same record, another address, without a rebuild. + out, err = Declaration(r, mcat.Rendering{ArtifactStore: "laptop.internal:5000"}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "gitea.server")["image"]; got != "laptop.internal:5000/gitea/server@"+digest { + t.Errorf("after the store moved, the image is still fetched as %v", got) + } +} + +func TestAnImageInTheStoreWithNoStoreToFetchItFromIsRefused(t *testing.T) { + m := mcat.Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "mesh-gitea", + "image": mcat.ArtifactStoreScheme + "gitea/server@" + digest}, + }} + _, err := Declaration(mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}}, mcat.Rendering{}) + if err == nil || !strings.Contains(err.Error(), "no artifact store") { + t.Fatalf("a reference nothing can fetch was sent to a machine: %v", err) + } +} + +// **A reference recorded with an address before this follows the store too** — when the mesh +// built it. A module running an image straight from a public registry under its own name is left +// exactly where it says: `quay.io/keycloak/keycloak` is not the mesh's, whatever it is called. +func TestAReferenceRecordedWithAnAddressFollowsTheStoreWhenTheMeshBuiltIt(t *testing.T) { + m := mcat.Manifest{Module: "keycloak", Version: "1", Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "mesh-keycloak", + "image": "anchor.internal:5100/keycloak/server@" + digest}, + {"id": "upstream", "type": "container", "name": "mesh-keycloak-upstream", + "image": "quay.io/keycloak/keycloak@" + digest}, + }} + r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}} + out, err := Declaration(r, mcat.Rendering{ + ArtifactStore: "anchor.internal:5101", + Built: map[string]bool{"keycloak/server": true}, + }) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5101/keycloak/server@"+digest { + t.Errorf("an image the mesh built, recorded with the old address, is fetched as %v", got) + } + if got := fileNamed(out, "keycloak.upstream")["image"]; got != "quay.io/keycloak/keycloak@"+digest { + t.Errorf("a public image was re-routed through the store: %v", got) + } + // Nothing known to be built: nothing re-routed, nothing refused. + out, err = Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101"}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5100/keycloak/server@"+digest { + t.Errorf("with no build record, a reference was rewritten: %v", got) + } +} diff --git a/internal/compose/resolution/backup_placement.go b/internal/compose/resolution/backup_placement.go new file mode 100644 index 0000000..46156a9 --- /dev/null +++ b/internal/compose/resolution/backup_placement.go @@ -0,0 +1,46 @@ +package resolution + +import ( + "fmt" + "regexp" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// ofBackup is where a holder places them: ${backup:}. Loose inside the braces, so a misspelt key is found and +// refused rather than written out as text. +var ofBackup = regexp.MustCompile(`\$\{backup:([^}]*)\}`) + +// backupKinds is the contribution kind each key of the backup placement renders. +var backupKinds = map[string]string{"lines": mcat.BackupKindBackup, "data": mcat.BackupKindData} + +// backupPlacer is the machine's modules the backup placement is composed over, as the old contributions are. +type backupPlacer struct { + modules []mcat.Manifest + with mcat.Rendering + facts map[string]string + caps map[string]bool + unplaced *[]string +} + +// placed is the text of one key for a holder, or what is wrong with naming it. +func (b backupPlacer) placed(holder mcat.Manifest, key string, item bool) (string, string) { + kind, known := backupKinds[key] + switch { + case item: + return "", "an item is kept per item; the machine's backup lines are placed only in a file" + case !known: + return "", fmt.Sprintf("the backup placement has `lines` and `data`, not %q", key) + case !holder.ClaimsSeat(mcat.BackupSeat): + return "", fmt.Sprintf("%s does not claim %s; the machine's backup lines are placed by its holder alone "+ + "(novox/hq ADR 0212)", holder.Module, mcat.BackupSeat) + } + text, left, err := seatContributions(b.modules, holder, mcat.BackupSeat+":"+kind, b.with, b.facts, b.caps) + if b.unplaced != nil { + *b.unplaced = append(*b.unplaced, left...) + } + if err != nil { + return "", err.Error() + } + return text, "" +} diff --git a/internal/compose/resolution/backup_placement_test.go b/internal/compose/resolution/backup_placement_test.go new file mode 100644 index 0000000..9035fc1 --- /dev/null +++ b/internal/compose/resolution/backup_placement_test.go @@ -0,0 +1,114 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +const oldBackupText = "# lines\n${contribution:node-backup:backup}" + +const oldDataText = "# data\n${contribution:node-backup:data}" + +const placedBackupTxt = "# lines\n${backup:lines}" + +const placedDataTxt = "# data\n${backup:data}" + +const backupHolder = `{"module":"restic","version":"1","moved":true,"claims":[{"name":"node-backup","scope":"node"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"declared","type":"file","path":"/etc/backups.conf","template":"files/backups"}, + {"id":"data-declared","type":"file","path":"/etc/data.conf","template":"files/data"}]}` + +func dataModule(name string) string { + return `{"module":"` + name + `","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}], + "data":{"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable","why":"the ` + name + ` data"}]}}` +} + +// backupsOn is a machine of a holder whose files say these texts and the modules that declare data. +func backupsOn(t *testing.T, lines, data string, modules ...string) mcat.Resolution { + t.Helper() + all := []mcat.Manifest{held(t, backupHolder, builtWith("restic", map[string]string{"backups": lines, "data": data}))} + names := []string{"restic"} + for _, name := range modules { + all = append(all, mustParse(t, dataModule(name))) + names = append(names, name) + } + r, err := Resolve(shelf(all...), names, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + return r +} + +var backupModules = []string{"alpha", "beta", "gamma"} + +// gammaLeftOut is a machine whose gamma does not place: its lines are kept, best effort. +func gammaLeftOut(with mcat.Rendering) mcat.Rendering { + with.NotPlaced = map[string]string{"gamma": "its placement setting does not read"} + return with +} + +func TestTheBackupPlacementIsTheTextTheOldPlaceholdersRender(t *testing.T) { + // The old form is an unmoved holder: its placeholders are the controller's alone. + oldHolder := strings.Replace(backupHolder, `"moved":true,`, "", 1) + all := []mcat.Manifest{held(t, oldHolder, builtWith("restic", map[string]string{"backups": oldBackupText, "data": oldDataText}))} + for _, name := range backupModules { + all = append(all, mustParse(t, dataModule(name))) + } + old, err := Resolve(shelf(all...), append([]string{"restic"}, backupModules...), workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + old.Account = "op" + wantLines, wantData := fileOf(t, old, gammaLeftOut(mcat.Rendering{}), "restic.declared"), fileOf(t, old, gammaLeftOut(mcat.Rendering{}), "restic.data-declared") + if !strings.Contains(wantLines, "# alpha\npath ") || !strings.Contains(wantLines, "# gamma\n") || !strings.Contains(wantData, "item d") { + t.Fatalf("the old form renders\n%s\n%s", wantLines, wantData) + } + moved := backupsOn(t, placedBackupTxt, placedDataTxt, backupModules...) + // Not handed: the controller fills the new placeholders with the old text. + if got := fileOf(t, moved, gammaLeftOut(mcat.Rendering{}), "restic.declared"); got != wantLines { + t.Errorf("not handed, the lines are\n%s\nwant\n%s", got, wantLines) + } + if got := fileOf(t, moved, gammaLeftOut(mcat.Rendering{}), "restic.data-declared"); got != wantData { + t.Errorf("not handed, the data is\n%s\nwant\n%s", got, wantData) + } + // Handed: the plan carries the same text as placement, and the files are the client's. + c, _ := composeWith(t, moved, gammaLeftOut(handingTo("store:5000"))) + p := c.Plans["restic"].Placement + if got := "# lines\n" + p["backup:lines"]; got != wantLines { + t.Errorf("handed, the plan's lines are\n%s\nwant\n%s", got, wantLines) + } + if got := "# data\n" + p["backup:data"]; got != wantData { + t.Errorf("handed, the plan's data is\n%s\nwant\n%s", got, wantData) + } + if len(p) != 2 { + t.Errorf("the plan's placement is %v", p) + } +} + +func TestAnUnknownBackupKeyIsRefused(t *testing.T) { + r := backupsOn(t, "${backup:rows}", placedDataTxt, backupModules...) + if _, err := Compose(r, handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "backup:rows") { + t.Errorf("handed, an unknown key is %v", err) + } + if _, err := Declaration(r, mcat.Rendering{}); err == nil || !strings.Contains(err.Error(), "backup:rows") { + t.Errorf("not handed, an unknown key is %v", err) + } +} + +func fileOf(t *testing.T, r mcat.Resolution, with mcat.Rendering, id string) string { + t.Helper() + out, err := Declaration(r, with) + if err != nil { + t.Fatal(err) + } + for _, res := range out { + if res["id"] == id { + return res["content"].(string) + } + } + t.Fatalf("%s was not composed", id) + return "" +} diff --git a/internal/compose/resolution/backup_test.go b/internal/compose/resolution/backup_test.go new file mode 100644 index 0000000..6583285 --- /dev/null +++ b/internal/compose/resolution/backup_test.go @@ -0,0 +1,37 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// A module on the shelf from before ADR 0233, which writes its backup lines by hand and declares no +// data, is still backed up: its lines are placed as written, each module's under a comment naming it. +func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) { + pg, err := decodeForTest([]byte(`{"module":"pg","version":"1", + "resources":[{"id":"dumps","type":"directory","path":"/srv/pg/dumps","mode":"0700"}], + "contributions":[{"seat":"node-backup","kind":"backup","content":"run pg-dump-all\npath ${dir:dumps}"}]}`)) + if err != nil { + t.Fatal(err) + } + mail, err := decodeForTest([]byte(`{"module":"mail","version":"1", + "resources":[{"id":"spool","type":"directory","mode":"0700"}], + "contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:spool}"}]}`)) + if err != nil { + t.Fatal(err) + } + placed, _, err := seatContributions([]mcat.Manifest{pg, mail}, mcat.Manifest{}, mcat.BackupSeat+":"+"backup", mcat.Rendering{}, nil, nil) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"# pg\nrun pg-dump-all\npath /srv/pg/dumps\n", "# mail\npath /var/lib/mail/spool\n"} { + if !strings.Contains(placed, want) { + t.Errorf("placed contributions lack %q:\n%s", want, placed) + } + } + if strings.Contains(placed, "${dir:") { + t.Errorf("a directory reached the holder unfilled:\n%s", placed) + } +} diff --git a/internal/compose/resolution/bound.go b/internal/compose/resolution/bound.go new file mode 100644 index 0000000..bf7b620 --- /dev/null +++ b/internal/compose/resolution/bound.go @@ -0,0 +1,117 @@ +package resolution + +import ( + "fmt" + "slices" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// keepBound holds every need for a provision that keeps its consumers' data at the provider its +// consumer was bound to, where the resolution chose another. +// +// A need with no record is a binding being made, and is left as resolved: it is recorded when it is +// first sent. A pin naming the provider the resolution chose is a person moving it, and is left too. +// Otherwise the recorded provider answers, if it still provides the provision — beside the consumer, +// or offered from elsewhere — and the move is returned as kept. **One that no longer does is refused, +// never answered by the provider chosen**: answering it there is exactly the silent move this exists +// to stop, and the consumer's data is still wherever it was. +func keepBound(needs []mcat.Needed, catalogue map[string]mcat.Manifest, node mcat.Node, world mcat.World, + keeps map[string]bool, here func(string) bool) ([]mcat.Needed, []mcat.KeptBinding, []string) { + var kept []mcat.KeptBinding + var problems []string + refused := map[[2]string]bool{} + out := make([]mcat.Needed, 0, len(needs)) + for _, n := range needs { + if n.ByRecord || !keeps[n.Name] { + out = append(out, n) + continue + } + n.KeepsData = true + bound, recorded := world.Bound[n.For][n.Name] + chose := mcat.Chosen{Node: n.From, Module: n.Module} + if !recorded || sameProvider(bound, chose) { + out = append(out, n) + continue + } + if pin, pinned := world.Pinned[n.Name]; pinned && chosenMatches(pin, mcat.Provider{Node: chose.Node, Module: chose.Module}) { + out = append(out, n) + continue + } + held, ok, why := boundNeed(n, bound, catalogue, node, world, here) + if !ok { + if key := [2]string{n.For, n.Name}; !refused[key] { + refused[key] = true + problems = append(problems, fmt.Sprintf( + "%s on %s is bound to %s for %q, which keeps its data, and %s — it would move to %s, "+ + "which holds none of it. Move its data and say so with `pin %s %s %s %s`, or give "+ + "%s back what it provided", + n.For, node.Name, bound, n.Name, why, chose, node.Name, n.Name, chose.Node, chose.Module, + bound.Node)) + } + continue + } + out = append(out, held) + kept = append(kept, mcat.KeptBinding{Machine: node.Name, Consumer: n.For, Provision: n.Name, Bound: bound, Would: chose}) + } + return out, kept, problems +} + +// sameProvider is whether a recorded provider is the one chosen. A record naming no module (none +// is written without one, but a person's hand might) matches any module on its node. +func sameProvider(bound, chose mcat.Chosen) bool { + return bound.Node == chose.Node && (bound.Module == "" || bound.Module == chose.Module) +} + +// boundNeed is the need answered by the recorded provider, or why it cannot be. +func boundNeed(n mcat.Needed, bound mcat.Chosen, catalogue map[string]mcat.Manifest, node mcat.Node, world mcat.World, + here func(string) bool) (mcat.Needed, bool, string) { + held := mcat.Needed{Name: n.Name, For: n.For, Local: n.Local, KeepsData: true} + if bound.Node == node.Name { + m, known := catalogue[bound.Module] + if !known || !here(bound.Module) || !providesAt(m, n.Name, mcat.ScopeMesh) { + return mcat.Needed{}, false, fmt.Sprintf("%s no longer runs on %s", bound.Module, node.Name) + } + at := node.At + if at == "" { + at = "127.0.0.1" + } + held.From, held.At, held.Module = node.Name, at, m.Module + held.Serves, held.SharedOwn, held.Identity = servedByOne(m, n.Name), sharedByOne(m, n.Name), m.IdentityBoundOf(n.Name) + held.CredentialForm, held.CredentialTool = m.CredentialFormOf(n.Name), m.CredentialToolOf(n.Name) + return held, true, "" + } + matching := chosenAmong(bound, world.Offered[n.Name]) + if len(matching) != 1 { + return mcat.Needed{}, false, fmt.Sprintf("%s no longer provides it", bound) + } + p := matching[0] + if node.At == "" || p.At == "" { + return mcat.Needed{}, false, fmt.Sprintf("%s and %s are not both on the private network", node.Name, p.Node) + } + identity := mcat.DefaultIdentityBound + if pm, known := catalogue[p.Module]; known { + held.SharedOwn, _ = pm.SharedCredentialOf(n.Name) + held.CredentialForm, held.CredentialTool = pm.CredentialFormOf(n.Name), pm.CredentialToolOf(n.Name) + identity = pm.IdentityBoundOf(n.Name) + } + held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity + held.LeftOut = p.LeftOut + return held, true, "" +} + +// BindsFrom is the providers this resolution binds a pair credential's need to — the provision, the +// module that requires it and the credential's local name — answered by a machine rather than by a +// record. None means this machine states no such binding. +func BindsFrom(r mcat.Resolution, provision, module, local string) []string { + var from []string + for _, n := range r.Needs { + if n.ByRecord || n.Name != provision || n.For != module || n.Local != local { + continue + } + if !slices.Contains(from, n.From) { + from = append(from, n.From) + } + } + return from +} diff --git a/internal/compose/resolution/bound_into_files.go b/internal/compose/resolution/bound_into_files.go new file mode 100644 index 0000000..d27401e --- /dev/null +++ b/internal/compose/resolution/bound_into_files.go @@ -0,0 +1,176 @@ +package resolution + +import ( + "fmt" + "regexp" + "sort" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// bound is where a module says a value from one of its bindings belongs: +// ${bound::}, and for an optional requirement the value it takes while absent, +// ${bound::|default:} (novox/hq ADR 0330 §4). A default is never empty: an +// absent requirement is never rendered as an empty string. +var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)(?:\|default:([^}\n]+))?\}`) + +// knownFor is everything a module may name from one of its bindings. +// +// Three facts the mesh states about any provision, plus whatever the provider said it serves. A +// module may not reach a binding it does not have — the same boundary as a secret, for the same +// reason. +func knownFor(m mcat.Manifest, needs []mcat.Needed, node string) (map[string]map[string]string, error) { + out := map[string]map[string]string{} + for _, want := range m.Wants() { + for i := range needs { + n := needs[i] + if n.Name != want || n.For != m.Module { + continue + } + as := mcat.ConsumerIdentity(node, mcat.IdentitySource(m.Slug, m.Module)) + values := map[string]string{ + "at": n.At, + "from": n.From, + "as": as, + } + // What the provider derives for this consumer rather than for all of them + // (novox/hq ADR 0202). Filled here, the one place a provision and the module + // requiring it are both in hand. + served, err := ServedTo(n.Serves, as) + if err != nil { + return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err) + } + for key, value := range served { + // The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000, + // which is what a float would write and what a connection string would refuse. + values[key] = plainly(value) + } + out[want] = values + } + } + return out, nil +} + +// withOwnNames adds a module's own composed names to what it may name from one binding: +// `${bound::name}` and `:internal-name`, and for several contributions to one requirement +// `:name-` / `:internal-name-`. Set over anything the provider serves under those keys: +// what the module is called is the mesh's statement, not the provider's. +func withOwnNames(values map[string]string, own map[string]any) { + for _, key := range []string{"name", "internal-name"} { + if v, ok := own[key].(string); ok { + values[key] = v + } + } + many, _ := own["names"].(map[string]any) + for local, raw := range many { + names, _ := raw.(map[string]any) + for _, key := range []string{"name", "internal-name"} { + if v, ok := names[key].(string); ok { + values[key+"-"+local] = v + } + } + } +} + +// plainly renders a served value as a program would expect to read it. +func plainly(value any) string { + switch v := value.(type) { + case string: + return v + case float64: + if v == float64(int64(v)) { + return fmt.Sprintf("%d", int64(v)) + } + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", v), "0"), ".") + case bool: + return fmt.Sprintf("%t", v) + case nil: + return "" + default: + return fmt.Sprint(v) + } +} + +// boundInto replaces a file's ${bound:…} placeholders with what the mesh knows. +// +// A placeholder naming something the module does not require, or a key the provider does not +// serve, is refused. Left as it was, the literal `${bound:x:y}` would be written into a +// configuration file and read as a value — a connection to a host called `${bound:x:y}`, failing +// somewhere that names neither the module nor the mesh. +// +// **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR +// 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses +// that already; this is the same rule where the file is made, for a manifest registered before it. +func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error { + if fmt.Sprint(resource["type"]) != "file" { + return nil + } + content, ok := resource["content"].(string) + if !ok { + return nil + } + var refused error + filled := bound.ReplaceAllStringFunc(content, func(token string) string { + if refused != nil { + return token + } + m := bound.FindStringSubmatch(token) + provision, key, fallback := m[1], m[2], m[3] + values, has := known[provision] + if !has { + if absent[provision] { + if fallback != "" { + return fallback + } + refused = fmt.Errorf( + "%s has a file that says ${bound:%s:%s}, and %q is optional and absent here: it names no "+ + "value for then (${bound:%s:%s|default:}), so it would be written empty "+ + "(novox/hq ADR 0330 §4)", module, provision, key, provision, provision, key) + return token + } + refused = fmt.Errorf( + "%s has a file that says ${bound:%s:%s}, and %s does not require %q. It may name %s", + module, provision, key, module, provision, orNothing(namesOfBindings(known))) + return token + } + value, said := values[key] + if !said { + refused = fmt.Errorf( + "%s asks its %s binding for %q, and what answers it says %s", + module, provision, key, orNothing(namesOfKeys(values))) + return token + } + return value + }) + if refused != nil { + return refused + } + resource["content"] = filled + return nil +} + +func namesOfBindings(known map[string]map[string]string) []string { + var names []string + for name := range known { + names = append(names, fmt.Sprintf("%q", name)) + } + sort.Strings(names) + return names +} + +func namesOfKeys(values map[string]string) []string { + var names []string + for key := range values { + names = append(names, fmt.Sprintf("%q", key)) + } + sort.Strings(names) + return names +} + +func orNothing(names []string) string { + if len(names) == 0 { + return "nothing" + } + return join(names) +} diff --git a/internal/compose/resolution/bound_into_files_test.go b/internal/compose/resolution/bound_into_files_test.go new file mode 100644 index 0000000..0da5176 --- /dev/null +++ b/internal/compose/resolution/bound_into_files_test.go @@ -0,0 +1,246 @@ +package resolution + +import ( + "fmt" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// A consumer writes its own connection string (novox/hq 04-ISSUES/023). +// +// Everything needed for one now reaches the file that needs it: the address and port from what the +// provider serves, the name to present from what the mesh decided, the database name from what the +// module itself contributed, and the password through the sealed hole the host fills. +func TestAConsumerCanWriteAConnectionString(t *testing.T) { + r := mcat.Resolution{ + Node: "workstation", + Modules: []mcat.Manifest{{ + Module: "keycloak", + Requires: []string{"postgres-database"}, + Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/db.secret"}, + Resources: []map[string]any{{ + "id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env", + "mode": "0600", + "content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" + + "${bound:postgres-database:port}/keycloak\n" + + "KC_DB_USERNAME=${bound:postgres-database:as}\n" + + "KC_DB_PASSWORD=${secret:postgres-database}\n", + }}, + }}, + Needs: []mcat.Needed{{ + Name: "postgres-database", From: "anchor", At: "anchor.internal", For: "keycloak", + Sealed: "sealed-db", Serves: map[string]any{"port": float64(5432)}, + }}, + } + out, err := Declaration(r, mcat.Rendering{}) + if err != nil { + t.Fatal(err) + } + file := fileNamed(out, "keycloak.dbenv") + if file == nil { + t.Fatalf("no file in %v", out) + } + got := file["content"].(string) + + want := "KC_DB_URL=jdbc:postgresql://anchor.internal:5432/keycloak\n" + + "KC_DB_USERNAME=mesh_workstation_keycloak\n" + + "KC_DB_PASSWORD=${secret:postgres-database}\n" + if got != want { + t.Errorf("the consumer cannot connect with what it was given:\n got %q\nwant %q", got, want) + } + // The password is still a hole. Only the host may fill that one, and only on the machine. + if sealed, _ := file["secrets"].(map[string]any); sealed["postgres-database"] != "sealed-db" { + t.Errorf("the credential did not travel with the file: %v", file) + } +} + +// A provider answered on its consumer's own machine is announced at the port the machine PUBLISHES +// it on, not the port the module declared (novox/hq 04-ISSUES/038). +// +// Issue 018 made the same-node provider announced at all; this is the promise after it — that the +// port a co-located consumer is told is the one actually listening at the address it is handed. The +// mesh assigns a bare `ports` mapping a host port (here the container's 5432 becomes 15432 on this +// machine), and it must announce 15432, or the consumer dials workstation.internal:5432 where the +// provider is not published. Cross-node consumers already got this (the plan re-derives the served +// facts with the provider's assignment); the same-node need was settled before the assignment and +// carried the declared port until now. +func TestASameNodeProviderIsAnnouncedAtThePortItIsPublishedOn(t *testing.T) { + provider := mcat.Manifest{ + Module: "postgres", Version: "1", + Provides: mcat.FromAnywhere("postgres-database"), + Listens: []mcat.Listening{{Port: 5432, Protocol: "tcp", From: "mesh"}}, + Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "postgres", + "ports": []any{"5432"}, + }}, + } + consumer := mcat.Manifest{ + Module: "keycloak", Version: "1", Requires: []string{"postgres-database"}, + Resources: []map[string]any{{ + "id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env", "mode": "0600", + "content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" + + "${bound:postgres-database:port}/keycloak\n", + }}, + } + r, err := Resolve(shelf(provider, consumer), + []string{"postgres", "keycloak"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + // The mesh assigned the container's 5432 to 15432 on this machine. + out, err := Declaration(r, mcat.Rendering{Ports: map[string]map[int]int{"postgres": {5432: 15432}}}) + if err != nil { + t.Fatal(err) + } + + // Where the provider is actually published: the assigned host port, on the machine at large. + server := fileNamed(out, "postgres.server") + if server == nil { + t.Fatalf("the provider container is not in the declaration: %v", out) + } + if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "15432:5432") { + t.Fatalf("the provider was not published on the assigned port: %v", server["ports"]) + } + + // What the consumer was told to dial: the SAME port, at the announced address — not the 5432 the + // module declared, which is closed on that address. + dbenv := fileNamed(out, "keycloak.dbenv") + if dbenv == nil { + t.Fatalf("the consumer's binding file is not in the declaration: %v", out) + } + got := dbenv["content"].(string) + want := "KC_DB_URL=jdbc:postgresql://workstation.internal:15432/keycloak\n" + if got != want { + t.Errorf("the consumer dials a port the provider does not listen on:\n got %q\nwant %q", + got, want) + } +} + +// A port is 5432, not 5432.000000 — which is what a number decoded from JSON would write, and what +// every connection string in the world would refuse. +func TestAPortIsWrittenAsAPort(t *testing.T) { + if got := plainly(float64(5432)); got != "5432" { + t.Errorf("a port was rendered as %q", got) + } + if got := plainly("postgres"); got != "postgres" { + t.Errorf("a string was rendered as %q", got) + } +} + +// The name the provider will create is the name the consumer is told to present. One derivation, +// so the two agree by construction rather than by two conventions written on the same day. +func TestTheProviderAndTheConsumerAgreeOnTheName(t *testing.T) { + provider, err := Resolve(shelf(mcat.Manifest{ + Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database"), + Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"}, + Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"}, + }), []string{"postgres"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(provider, mcat.Rendering{Grants: []mcat.Grant{{ + Provision: "postgres-database", Consumer: "workstation", From: "keycloak", + Values: map[string]any{"name": "keycloak"}, Sealed: "sealed", + }}}) + if err != nil { + t.Fatal(err) + } + var told string + for _, res := range out { + if res["path"] == "/var/lib/postgres/grants/mesh.json" { + told = res["content"].(string) + } + } + if !strings.Contains(told, `"as": "mesh_workstation_keycloak"`) { + t.Errorf("the provider was not told what to call the login: %s", told) + } + if told == "" { + t.Fatal("the provider was told nothing at all") + } + // Which is exactly what the consumer, on its own machine, is told to present. + if mcat.ConsumerIdentity("workstation", "keycloak") != "mesh_workstation_keycloak" { + t.Errorf("got %q", mcat.ConsumerIdentity("workstation", "keycloak")) + } +} + +// A binding the module does not have is a typo, refused where it was written. +func TestAFileCannotNameABindingTheModuleDoesNotHave(t *testing.T) { + r := mcat.Resolution{ + Node: "workstation", + Modules: []mcat.Manifest{{ + Module: "keycloak", Requires: []string{"postgres-database"}, + Resources: []map[string]any{{ + "id": "conf", "type": "file", "path": "/etc/keycloak.conf", + "content": "host=${bound:s3-bucket:at}\n", + }}, + }}, + Needs: []mcat.Needed{{Name: "postgres-database", From: "anchor", At: "a", For: "keycloak"}}, + } + _, err := Declaration(r, mcat.Rendering{}) + if err == nil { + t.Fatal("a module read a binding it does not have") + } + if !strings.Contains(err.Error(), "postgres-database") { + t.Errorf("the refusal does not say what it could have named: %v", err) + } +} + +// A key the provider does not serve is refused too. Written through, the literal placeholder +// becomes a hostname, and the failure names neither the module nor the mesh. +func TestAKeyTheProviderDoesNotServeIsRefused(t *testing.T) { + r := mcat.Resolution{ + Node: "workstation", + Modules: []mcat.Manifest{{ + Module: "keycloak", Requires: []string{"postgres-database"}, + Resources: []map[string]any{{ + "id": "conf", "type": "file", "path": "/etc/keycloak.conf", + "content": "sock=${bound:postgres-database:socket}\n", + }}, + }}, + Needs: []mcat.Needed{{ + Name: "postgres-database", From: "anchor", At: "a", For: "keycloak", + Serves: map[string]any{"port": float64(5432)}, + }}, + } + _, err := Declaration(r, mcat.Rendering{}) + if err == nil { + t.Fatal("a module read a key nothing serves, and the placeholder became a value") + } + if !strings.Contains(err.Error(), `"port"`) { + t.Errorf("the refusal does not say what is served: %v", err) + } +} + +// A machine's resolver configuration must name its resolver by address — it cannot resolve the name +// of the thing it resolves names with (novox/hq ADR 0194). So a binding offers the providing +// machine's private address beside its name, and only when the machine has one. +func TestABindingOffersTheProvidersAddress(t *testing.T) { + consumer := func() mcat.Resolution { + return mcat.Resolution{ + Node: "workstation", + Modules: []mcat.Manifest{{ + Module: "resolv-conf", + Requires: []string{"wildcard-resolution"}, + Resources: []map[string]any{{ + "id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644", + "content": "nameserver ${bound:wildcard-resolution:address}\n", + }}, + }}, + Needs: []mcat.Needed{{Name: "wildcard-resolution", From: "anchor", At: "anchor.internal", For: "resolv-conf"}}, + } + } + out, err := Declaration(consumer(), mcat.Rendering{Machines: map[string]string{"anchor.internal": "10.77.0.1"}}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "resolv-conf.resolv")["content"]; got != "nameserver 10.77.0.1\n" { + t.Fatalf("the resolver is not named by its address: %q", got) + } + // A machine with no address yet: refused, never written with a blank where the address belongs. + if _, err := Declaration(consumer(), mcat.Rendering{}); err == nil { + t.Fatal("a file naming an address the mesh does not have was composed") + } +} diff --git a/internal/compose/resolution/bound_test.go b/internal/compose/resolution/bound_test.go new file mode 100644 index 0000000..98df9cb --- /dev/null +++ b/internal/compose/resolution/bound_test.go @@ -0,0 +1,219 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +var incidentConsumers = []string{"board", "listings", "workflows", "agents", "game"} + +func storeMesh() map[string]mcat.Manifest { + shelf := map[string]mcat.Manifest{ + "store": {Module: "store", Version: "1", + Provides: []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh}}, + Claims: []mcat.Claim{{Name: "mesh-store", Scope: mcat.ScopeMesh}}, + Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, + Grants: map[string]string{"postgres-database": "/grants"}}, + "resolver": {Module: "resolver", Version: "1", + Provides: []mcat.Offer{{Name: "wildcard-resolution", Scope: mcat.ScopeMesh}}, + Claims: []mcat.Claim{{Name: "mesh-dns-resolver", Scope: mcat.ScopeMesh}}}, + "network": {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}}, + } + for _, c := range incidentConsumers { + shelf[c] = mcat.Manifest{Module: c, Version: "1", Requires: []string{"postgres-database"}} + } + return shelf +} + +// storeWorld is the rest of the mesh as the home server's plan sees it: the anchor runs a store and a +// resolver and holds both seats; the home server runs its own of each. +func storeWorld() mcat.World { + w := mcat.World{Offered: map[string][]mcat.Provider{ + "postgres-database": { + {Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}}, + {Node: "home", At: "home.internal", Module: "store", Serves: map[string]any{"port": 5434}}, + }, + "wildcard-resolution": { + {Node: "anchor", At: "anchor.internal", Module: "resolver"}, + {Node: "home", At: "home.internal", Module: "resolver"}, + }, + }} + w.Held = []mcat.Held{ + {Claim: "mesh-store", Scope: mcat.ScopeMesh, Node: "anchor", Module: "store"}, + {Claim: "mesh-dns-resolver", Scope: mcat.ScopeMesh, Node: "anchor", Module: "resolver"}, + } + w.Holdings = w.Held + return w +} + +var home = mcat.Node{Name: "home", At: "home.internal"} + +func homeAssigned() []string { + return append([]string{"store", "resolver", "network"}, incidentConsumers...) +} + +func boundFrom(t *testing.T, r mcat.Resolution, consumer, provision string) mcat.Needed { + t.Helper() + for _, n := range r.Needs { + if n.For == consumer && n.Name == provision { + return n + } + } + t.Fatalf("no binding of %s for %s: %+v", consumer, provision, r.Needs) + return mcat.Needed{} +} + +func TestTheIncidentAMachinesOwnStoreKeepsItsConsumersWhenTheSeatIsHeldElsewhere(t *testing.T) { + got, err := Resolve(storeMesh(), homeAssigned(), home, storeWorld()) + if err != nil { + t.Fatal(err) + } + for _, c := range incidentConsumers { + if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" || n.Module != "store" { + t.Errorf("%s bound to %s/%s; its data is on the store beside it (issue 273)", c, n.From, n.Module) + } + } + // And the resolver, which keeps nothing of anybody's, still answers from the seat's holder (258). + if n := boundFrom(t, got, "network", "wildcard-resolution"); n.From != "anchor" { + t.Errorf("the resolver bound to %s; the seat is held on anchor (issue 258)", n.From) + } + if len(got.Kept) != 0 { + t.Errorf("nothing was moved, and %d kept: %+v", len(got.Kept), got.Kept) + } +} + +func TestTheIncidentWithEveryConsumerOnRecordStillMovesNothing(t *testing.T) { + w := storeWorld() + w.Bound = map[string]map[string]mcat.Chosen{} + for _, c := range incidentConsumers { + w.Bound[c] = map[string]mcat.Chosen{"postgres-database": {Node: "home", Module: "store"}} + } + got, err := Resolve(storeMesh(), homeAssigned(), home, w) + if err != nil { + t.Fatal(err) + } + for _, c := range incidentConsumers { + if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" { + t.Errorf("%s bound to %s", c, n.From) + } + } + if len(got.Kept) != 0 { + t.Errorf("kept %+v", got.Kept) + } +} + +func TestAPinElsewhereStillMovesAStoresConsumers(t *testing.T) { + w := storeWorld() + w.Pinned = map[string]mcat.Chosen{"postgres-database": {Node: "anchor", Module: "store"}} + w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + got, err := Resolve(storeMesh(), homeAssigned(), home, w) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" || n.Module != "store" { + t.Errorf("bound to %s/%s; a person pinned it to anchor", n.From, n.Module) + } + if len(got.Kept) != 0 { + t.Errorf("a pin is a person's move, not one to keep: %+v", got.Kept) + } +} + +// A consumer on a machine with no store of its own, bound to one store, when the seat moves to +// another: the holder would answer, and the binding stays. +func laptopWorld(holder string) mcat.World { + w := storeWorld() + w.Held = []mcat.Held{{Claim: "mesh-store", Scope: mcat.ScopeMesh, Node: holder, Module: "store"}} + w.Holdings = w.Held + return w +} + +var laptop = mcat.Node{Name: "laptop", At: "laptop.internal"} + +func TestABoundConsumerStaysWhenTheSeatsHolderChanges(t *testing.T) { + w := laptopWorld("home") + w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "anchor", Module: "store"}}} + got, err := Resolve(storeMesh(), []string{"board"}, laptop, w) + if err != nil { + t.Fatal(err) + } + n := boundFrom(t, got, "board", "postgres-database") + if n.From != "anchor" || n.At != "anchor.internal" || n.Serves["port"] != 5432 { + t.Fatalf("bound to %s at %s %v; its data is on anchor", n.From, n.At, n.Serves) + } + if len(got.Kept) != 1 { + t.Fatalf("the move was not said: %+v", got.Kept) + } + k := got.Kept[0] + if k.Bound != (mcat.Chosen{Node: "anchor", Module: "store"}) || k.Would != (mcat.Chosen{Node: "home", Module: "store"}) || k.Consumer != "board" { + t.Fatalf("kept %+v", k) + } + for _, want := range []string{"would move board's postgres-database from anchor/store to home/store", + "its data is on anchor/store", "pin laptop postgres-database home store", "move the data first"} { + if !strings.Contains(k.String(), want) { + t.Errorf("%q does not say %q", k.String(), want) + } + } + // Without a record it is a binding being made, and the holder answers it as before. + w.Bound = nil + got, err = Resolve(storeMesh(), []string{"board"}, laptop, w) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, got, "board", "postgres-database"); n.From != "home" { + t.Errorf("a new consumer bound to %s; the seat is held on home", n.From) + } +} + +func TestABoundConsumerWhoseProviderIsGoneIsRefusedNotMoved(t *testing.T) { + w := laptopWorld("anchor") + w.Offered["postgres-database"] = w.Offered["postgres-database"][:1] // only anchor's store is left + w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + _, err := Resolve(storeMesh(), []string{"board"}, laptop, w) + if err == nil { + t.Fatal("bound to home's store, which is gone, and answered by anchor's — the silent move") + } + for _, want := range []string{"board on laptop is bound to home/store", "home/store no longer provides it", + "pin laptop postgres-database anchor store"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not say %q", err, want) + } + } +} + +func TestAMachinesOwnStoreUnassignedRefusesItsBoundConsumers(t *testing.T) { + w := storeWorld() + w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + _, err := Resolve(storeMesh(), []string{"board"}, home, w) + if err == nil || !strings.Contains(err.Error(), "store no longer runs on home") { + t.Fatalf("got %v", err) + } +} + +// The first pass asks only what a machine offers, and is never refused by a binding. +func TestTheFirstPassKeepsNoBinding(t *testing.T) { + w := storeWorld() + w.Unchecked = true + w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "gone", Module: "store"}}} + if _, err := Resolve(storeMesh(), []string{"board"}, laptop, w); err != nil { + t.Fatal(err) + } +} + +// An offer saying it keeps nothing is answered by the seat's holder as the resolver is, even where it +// grants a credential. +func TestAStoreSayingItKeepsNothingFollowsTheSeat(t *testing.T) { + shelf := storeMesh() + no := false + s := shelf["store"] + s.Provides = []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh, KeepsConsumerData: &no}} + shelf["store"] = s + got, err := Resolve(shelf, homeAssigned(), home, storeWorld()) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" { + t.Errorf("bound to %s; it keeps nothing, and the seat is held on anchor", n.From) + } +} diff --git a/internal/compose/resolution/broker_seat_test.go b/internal/compose/resolution/broker_seat_test.go new file mode 100644 index 0000000..524f390 --- /dev/null +++ b/internal/compose/resolution/broker_seat_test.go @@ -0,0 +1,49 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The mesh's bus is one per mesh, read from the catalogue beside this checkout. +// +// **This is step 2's claim, and it is checked here rather than in a bed** (novox/hq ADR 0116): +// adoption puts the NATS server into the `mesh-broker` seat on a mesh that is already running, +// and the property that matters is that a second one anywhere is refused *when it is assigned*, +// not discovered later as two servers holding different halves of the mesh's traffic. A second +// bus is not a degraded mesh; it is two meshes that both believe they are the one. +func TestASecondMeshBusAnywhereIsRefusedByName(t *testing.T) { + nats := catalogueManifest(t, "nats") + + if _, err := Resolve(shelf(nats), []string{"nats"}, workstation(), mcat.World{}); err != nil { + t.Fatalf("the bus alone does not resolve: %v", err) + } + + elsewhere := mcat.World{Held: []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh, + Node: "anchor", Module: "nats"}}} + other := workstation() + other.Name = "laptop" + _, err := Resolve(shelf(nats), []string{"nats"}, other, elsewhere) + if err == nil { + t.Fatal("a second bus was accepted on another machine") + } + if !strings.Contains(err.Error(), "mesh-broker") || !strings.Contains(err.Error(), "one per mesh") { + t.Fatalf("refused without naming the seat: %v", err) + } +} + +// The seat is the server's role, not the product's name (novox/hq ADR 0079). A different +// implementation of the bus claims the same seat, and the mesh refuses it for the same reason — +// which is the property that lets the bus be replaced at all. +func TestTheSeatRefusesADifferentBusToo(t *testing.T) { + nats := catalogueManifest(t, "nats") + held := mcat.World{Held: []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh, + Node: "anchor", Module: "some-other-broker"}}} + other := workstation() + other.Name = "laptop" + if _, err := Resolve(shelf(nats), []string{"nats"}, other, held); err == nil { + t.Fatal("the seat admitted a second holder because the module's name differed") + } +} diff --git a/internal/compose/resolution/brokered_test.go b/internal/compose/resolution/brokered_test.go new file mode 100644 index 0000000..2932a08 --- /dev/null +++ b/internal/compose/resolution/brokered_test.go @@ -0,0 +1,255 @@ +package resolution + +import ( + "encoding/json" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// onNetwork is a set of providers, all of them reachable. Written as a helper because a machine +// that cannot reach the one answering its requirement is its own case, tested separately. +// reachable is this machine, on the private network. +func reachable() mcat.Node { + n := workstation() + n.At = "workstation.internal" + return n +} + +func onNetwork(nodes ...string) map[string][]mcat.Provider { + out := make([]mcat.Provider, 0, len(nodes)) + for _, n := range nodes { + out = append(out, mcat.Provider{Node: n, At: n + ".internal", Module: "postgres"}) + } + return map[string][]mcat.Provider{"postgres-database": out} +} + +func brokeredShelf() map[string]mcat.Manifest { + return shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database")}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}}) +} + +func TestADatabaseIsNotInstalledOnEveryMachineThatUsesOne(t *testing.T) { + // The fault this whole distinction exists for. + got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor")}) + if err != nil { + t.Fatal(err) + } + if have := strings.Join(names(got), " "); strings.Contains(have, "postgres") { + t.Fatalf("using a database installed one here: %s", have) + } +} + +func TestWhatAMachineTakesFromElsewhereIsRecorded(t *testing.T) { + // It is the only part of a node's set that stops working when a *different* machine goes + // away, and it is where a credential will have to be handed back. + got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor")}) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 { + t.Fatalf("got %v", got.Needs) + } + if got.Needs[0].Name != "postgres-database" || got.Needs[0].From != "anchor" { + t.Fatalf("got %v", got.Needs[0]) + } + if got.Needs[0].For != "meshboard" { + t.Fatalf("it does not say what wanted it: %v", got.Needs[0]) + } +} + +func TestNothingInTheMeshProvidingItIsRefusedWithSomewhereToPutIt(t *testing.T) { + // Refused rather than installed here. Choosing a machine to put a database on is a decision + // with consequences, and nothing resolving a web application should make it silently. + _, err := Resolve(brokeredShelf(), []string{"meshboard"}, workstation(), mcat.World{}) + if err == nil { + t.Fatal("a database was found in a mesh that has none") + } + if !strings.Contains(err.Error(), "assign") || !strings.Contains(err.Error(), "postgres") { + t.Fatalf("the refusal does not say what to do: %v", err) + } +} + +func TestTwoNodesProvidingItIsRefusedRatherThanPicked(t *testing.T) { + // Same rule as everywhere else. Picking one would be a guess about which database a person + // meant, and the wrong guess is somebody's data in the wrong place. + _, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor", "archive")}) + if err == nil { + t.Fatal("one of two databases was picked silently") + } + for _, want := range []string{"anchor", "archive", "pin"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal does not name %s: %v", want, err) + } + } +} + +func TestSayingWhichOneSettlesIt(t *testing.T) { + got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{ + Offered: onNetwork("anchor", "archive"), + Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "archive", Module: "postgres"}}, + }) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 || got.Needs[0].From != "archive" { + t.Fatalf("the choice was not taken: %v", got.Needs) + } +} + +func TestBeingPointedAtAMachineThatDoesNotProvideItIsRefused(t *testing.T) { + // Rather than falling back to one that does. A fallback would quietly move somebody's data to + // a machine they did not choose, which is the whole reason the question is asked. + _, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{ + Offered: onNetwork("anchor", "archive"), + Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "somewhere-else", Module: "postgres"}}, + }) + if err == nil { + t.Fatal("a machine was silently given a different database from the one chosen") + } + if !strings.Contains(err.Error(), "somewhere-else") { + t.Fatalf("the refusal does not say what was chosen: %v", err) + } +} + +func TestOneProviderDoesNotOverruleAChoice(t *testing.T) { + // A single answer is normally taken silently. Not when somebody said they wanted a different + // one -- that is the mesh overruling a person, which it does nowhere else. + _, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{ + Offered: onNetwork("anchor"), + Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "archive", Module: "postgres"}}, + }) + if err == nil { + t.Fatal("the only database was used although another was chosen") + } + if !strings.Contains(err.Error(), "only anchor/postgres provides it") { + t.Fatalf("the refusal does not say what is available: %v", err) + } +} + +func TestACatalogueThatDisagreesAboutScopeIsRefused(t *testing.T) { + // If one module says a database is local and another says it is anywhere, the same + // requirement means two things depending on which one happens to answer it. + _, err := Resolve(shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database")}, mcat.Manifest{Module: "sqlite", Version: "1", Provides: mcat.Offers("postgres-database")}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}}), []string{"meshboard"}, workstation(), mcat.World{}) + if err == nil { + t.Fatal("a catalogue that disagrees about where a database lives was accepted") + } + if !strings.Contains(err.Error(), "two things") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +func TestALocalRequirementIsStillAnsweredLocally(t *testing.T) { + // The change must not have made everything brokered. A shell is still installed here. + got, err := Resolve(shelf(mcat.Manifest{Module: "zsh", Version: "1", Provides: mcat.Offers("shell")}, mcat.Manifest{Module: "tools", Version: "1", Requires: []string{"shell"}}), []string{"tools"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + if have := strings.Join(names(got), " "); !strings.Contains(have, "zsh") { + t.Fatalf("a shell was not installed on the machine that needs one: %s", have) + } +} + +func boundShelf() map[string]mcat.Manifest { + return shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database"), + Serves: map[string]map[string]any{"postgres-database": {"port": 5432, "driver": "postgres"}}}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}, + Binds: map[string]string{"postgres-database": "/etc/meshboard/database.json"}}, + ) +} + +func binding(t *testing.T, out []map[string]any) map[string]any { + t.Helper() + for _, r := range out { + if r["path"] != "/etc/meshboard/database.json" { + continue + } + var parsed map[string]any + if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil { + t.Fatalf("what the app is told is not readable: %v", err) + } + return parsed + } + t.Fatalf("the app was told nothing: %v", out) + return nil +} + +func TestAnAppIsToldWhereItsDatabaseIs(t *testing.T) { + // Knowing it needs the anchor's database is useless to the program that needs it unless the + // program is told. This is the whole point of the field. + got, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal", + Serves: map[string]any{"port": 5432, "driver": "postgres"}}}}}) + if err != nil { + t.Fatal(err) + } + told := binding(t, mustDeclare(t, got)) + if told["from"] != "anchor" || told["at"] != "anchor.internal" { + t.Fatalf("it was not told where: %v", told) + } + serves, _ := told["serves"].(map[string]any) + if serves["port"] != float64(5432) || serves["driver"] != "postgres" { + t.Fatalf("it was not told how: %v", serves) + } +} + +func TestItSaysWhereTheCredentialIsInstead(t *testing.T) { + // A missing field looks like a bug; a stated absence looks like a boundary. Somebody wiring + // this up must not spend an afternoon looking for the password field. + // + // It used to say only that the mesh had no way to issue one, which stopped being true when + // 021 was fixed. A stated absence is only useful while it is accurate — once it is not, it + // sends the reader somewhere there is nothing to find. + got, _ := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}}) + told := binding(t, mustDeclare(t, got)) + note, _ := told["generated"].(string) + if !strings.Contains(note, "not here") || !strings.Contains(note, "secrets") { + t.Fatalf("the file does not say where the credential is instead: %v", note) + } + for key := range told { + if strings.Contains(key, "password") || strings.Contains(key, "secret") { + t.Fatalf("something that looks like a credential appeared: %q", key) + } + } +} + +func TestTwoMachinesThatCannotReachEachOtherAreRefused(t *testing.T) { + // An app on one machine and a database on another that share no private network is a mesh + // that reports itself configured and does not work. Said here rather than discovered as a + // connection timing out. + _, err := Resolve(boundShelf(), []string{"meshboard"}, workstation(), mcat. // not on the network + World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}}) + if err == nil { + t.Fatal("an app was pointed at a database it has no path to") + } + if !strings.Contains(err.Error(), "private network") { + t.Fatalf("the refusal does not say what is wrong: %v", err) + } + if !strings.Contains(err.Error(), meshNetwork) { + t.Fatalf("the refusal does not say what to assign: %v", err) + } +} + +func TestTheProviderBeingOffTheNetworkIsAlsoRefused(t *testing.T) { + // Both directions, because the failure is identical from either end and the remedy differs. + _, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor"}}}}) + if err == nil { + t.Fatal("an app was pointed at a database that is not on the private network") + } + if !strings.Contains(err.Error(), "anchor") { + t.Fatalf("the refusal does not name the unreachable end: %v", err) + } +} + +func TestBindingSomethingAnsweredHereWritesNothing(t *testing.T) { + // A file saying "it is on this node" is a fact nobody needs and one more thing to keep true. + got, err := Resolve(shelf(mcat.Manifest{Module: "zsh", Version: "1", Provides: mcat.Offers("shell")}, mcat.Manifest{Module: "tools", Version: "1", Requires: []string{"shell"}, + Binds: map[string]string{"shell": "/etc/tools/shell.json"}}, + ), []string{"tools"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + for _, r := range mustDeclare(t, got) { + if r["path"] == "/etc/tools/shell.json" { + t.Fatalf("a binding was written for something on this machine: %v", r) + } + } +} diff --git a/internal/compose/resolution/bucket_shares.go b/internal/compose/resolution/bucket_shares.go new file mode 100644 index 0000000..0ada52a --- /dev/null +++ b/internal/compose/resolution/bucket_shares.go @@ -0,0 +1,48 @@ +package resolution + +import ( + "fmt" + "sort" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// sortedShares is a requirement's shares as they are handed to the provider: sorted by `to` (a file whose lines move +// looks changed), each entry's methods sorted, and a copy so the manifest is not reordered. +func sortedShares(shares []mcat.BucketShare) []mcat.BucketShare { + out := make([]mcat.BucketShare, 0, len(shares)) + for _, s := range shares { + methods := append([]string(nil), s.Methods...) + sort.Strings(methods) + out = append(out, mcat.BucketShare{To: s.To, Methods: methods}) + } + sort.Slice(out, func(i, j int) bool { return out[i].To < out[j].To }) + return out +} + +// withShares adds this module's shares of the requirement to the values it gives the provider. A module whose own +// `contributes` already names `shares` is refused: the field is the only way to say it (ADR 0468 §8). +func withShares(m mcat.Manifest, requirement string, values map[string]any) (map[string]any, error) { + shares := m.RequireShares[requirement] + if _, named := values[mcat.SharesValue]; named && requirement == mcat.BucketRequirement { + return nil, fmt.Errorf("%s contributing to %s names `shares`; write it on its requirement instead (novox/hq ADR 0468 §8)", + m.Module, requirement) + } + if len(shares) == 0 { + return values, nil + } + out := make(map[string]any, len(values)+1) + for k, v := range values { + out[k] = v + } + listed := []any{} + for _, s := range sortedShares(shares) { + methods := []any{} + for _, method := range s.Methods { + methods = append(methods, method) + } + listed = append(listed, map[string]any{"to": s.To, "methods": methods}) + } + out[mcat.SharesValue] = listed + return out, nil +} diff --git a/internal/compose/resolution/bucket_shares_test.go b/internal/compose/resolution/bucket_shares_test.go new file mode 100644 index 0000000..4c5a2cc --- /dev/null +++ b/internal/compose/resolution/bucket_shares_test.go @@ -0,0 +1,105 @@ +package resolution + +import ( + "encoding/json" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +const filesWithShares = `{"module":"files","version":"1","requires":["shell", + {"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["get","put"]}]}]}` + +func minioLike() mcat.Manifest { + return mcat.Manifest{Module: "minio", Version: "1", Provides: mcat.Offers("s3-bucket"), + Receives: map[string]string{"s3-bucket": "/grants/mesh.json"}} +} + +func givenToMinio(t *testing.T, out []map[string]any) []mcat.Contribution { + t.Helper() + for _, r := range out { + if r["path"] != "/grants/mesh.json" { + continue + } + var parsed struct { + Given []mcat.Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil { + t.Fatal(err) + } + return parsed.Given + } + t.Fatalf("minio was given no file: %v", out) + return nil +} + +func sharesOf(t *testing.T, values map[string]any) string { + t.Helper() + raw, err := json.Marshal(values["shares"]) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +const sortedSharesJSON = `[{"methods":["get","put"],"to":"print-and-scan"},{"methods":["put"],"to":"seat:node-files"}]` + +// 1591/AC3: the values a consumer gives carry the shares sorted by `to`, remote (grant) and co-located alike. +func TestTheGrantCarriesTheSharesSorted(t *testing.T) { + m := mustParse(t, filesWithShares) + remote := mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}} + values, asks, err := ContributionsFrom(remote, "s3-bucket", "files", mcat.SettingsBy{}) + if err != nil || !asks { + t.Fatalf("asks %v, err %v", asks, err) + } + if got := sharesOf(t, values); got != sortedSharesJSON { + t.Fatalf("the grant values carry %s", got) + } + // As the provider's file shows it, through a grant. + provider, err := Resolve(shelf(minioLike()), []string{"minio"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(provider, mcat.Rendering{Grants: []mcat.Grant{{Provision: "s3-bucket", Consumer: "laptop", From: "files", Values: values}}}) + if err != nil { + t.Fatal(err) + } + given := givenToMinio(t, out) + if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON { + t.Fatalf("the remote consumer's shares did not reach the file: %+v", given) + } + + // Co-located: the consumer contributes to s3-bucket too, and no grant is passed. + co := mustParse(t, `{"module":"files","version":"1","contributes":{"s3-bucket":{"name":"files"}}, + "requires":[{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["put","get"]}]}]}`) + both, err := Resolve(shelf(minioLike(), co), []string{"files"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err = Declaration(both, mcat.Rendering{}) + if err != nil { + t.Fatal(err) + } + given = givenToMinio(t, out) + if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON || given[0].Values["name"] != "files" { + t.Fatalf("the co-located consumer's shares did not reach the file: %+v", given) + } +} + +// 1591/AC5: a consumer whose contributes to s3-bucket names `shares` itself is refused. +func TestAConsumerCannotContributeSharesItself(t *testing.T) { + _, err := decodeForTest([]byte(`{"module":"files","version":"1", + "contributes":{"s3-bucket":{"shares":"x"}}, + "requires":[{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]}]}]}`)) + if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") { + t.Errorf("both written, accepted: %v", err) + } + // Without the field the grant is still refused, so the provider is never told something the manifest did not say. + m := mcat.Manifest{Module: "files", Requires: []string{"s3-bucket"}, + Contributes: map[string]map[string]any{"s3-bucket": {"shares": "x"}}} + r := mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}} + if _, _, err := ContributionsFrom(r, "s3-bucket", "files", mcat.SettingsBy{}); err == nil { + t.Error("a contributed `shares` reached the grant") + } +} diff --git a/internal/compose/resolution/build.go b/internal/compose/resolution/build.go new file mode 100644 index 0000000..08bfe76 --- /dev/null +++ b/internal/compose/resolution/build.go @@ -0,0 +1,20 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// runByAResource is the artifacts one of a module's own resources names — a process that runs it, +// a step, an archive that unpacks it — by name. +func runByAResource(m mcat.Manifest) map[string]bool { + named := map[string]bool{} + for _, r := range m.Resources { + if a, ok := r["artifact"].(string); ok && a != "" { + named[a] = true + } + } + return named +} + +// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools). +func RunByAResource(m mcat.Manifest) map[string]bool { return runByAResource(m) } diff --git a/internal/compose/resolution/busy.go b/internal/compose/resolution/busy.go new file mode 100644 index 0000000..d27ba08 --- /dev/null +++ b/internal/compose/resolution/busy.go @@ -0,0 +1,20 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that +// reads it, the command and its timeout written out; for one that does not, the field is not sent, and the +// container is replaced at once, as before. +func busyInto(resource map[string]any, with mcat.Rendering) { + if _, has := resource[mcat.BusyField]; !has { + return + } + if !with.ReadsBusy { + delete(resource, mcat.BusyField) + return + } + b, _, _ := mcat.ReadBusy(resource) + resource[mcat.BusyField] = map[string]any{"command": b.Command, "timeout": b.Timeout.String()} +} diff --git a/internal/compose/resolution/busy_test.go b/internal/compose/resolution/busy_test.go new file mode 100644 index 0000000..8940b78 --- /dev/null +++ b/internal/compose/resolution/busy_test.go @@ -0,0 +1,54 @@ +package resolution + +import ( + "encoding/json" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func busyManifest(t *testing.T, on string, busy any) []byte { + t.Helper() + extra := map[string]any{"id": "agent", "type": "container", "name": "app-agent", + "image": "registry.example/app" + healthDigest} + var m map[string]any + if err := json.Unmarshal(healthManifest(t, "", nil, extra), &m); err != nil { + t.Fatal(err) + } + for _, r := range m["resources"].([]any) { + if r := r.(map[string]any); r["id"] == on { + r[mcat.BusyField] = busy + } + } + out, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) { + m, err := decodeForTest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})) + if err != nil { + t.Fatal(err) + } + compose := func(with mcat.Rendering) []map[string]any { + out, err := Declaration(mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}, with) + if err != nil { + t.Fatal(err) + } + return out + } + older := compose(mcat.Rendering{}) + if b, sent := older[indexOfID(older, "app.agent")][mcat.BusyField]; sent { + t.Fatalf("busy was sent to an engine that does not read it: %v", b) + } + newer := compose(mcat.Rendering{ReadsBusy: true}) + got, _ := newer[indexOfID(newer, "app.agent")][mcat.BusyField].(map[string]any) + if got["command"] != "mesh-builder busy" || got["timeout"] != "10s" { + t.Fatalf("busy composed as %v", got) + } + if _, has := m.Resources[len(m.Resources)-1][mcat.BusyField].(map[string]any)["timeout"]; has { + t.Error("composing wrote the timeout into the catalogue's own manifest") + } +} diff --git a/internal/compose/resolution/catalogue_check_test.go b/internal/compose/resolution/catalogue_check_test.go new file mode 100644 index 0000000..3e75f08 --- /dev/null +++ b/internal/compose/resolution/catalogue_check_test.go @@ -0,0 +1,46 @@ +package resolution + +import ( + "path/filepath" + "testing" + + "github.com/novox/mesh-host/internal/beside" +) + +// TestEveryCatalogueManifestParses runs the real catalogue through the real gate. +// +// Not a fixture: the point is whether the manifests as written are accepted by the control plane that +// will read them, and a copy of one manifest proves nothing about the other seventy-one. +// catalogueRoot is the catalogue these checks run over: in a merge check the clone the build seat put +// beside this one, elsewhere the copy captured in testdata/beside (internal/beside). A check that only +// ran when somebody remembered a variable was a check nobody ran (novox/hq issue 134), and one that read +// whatever checkout sat beside judged the machine, not the change (novox/hq issue 432): it never skips. +func catalogueRoot(t *testing.T) string { + t.Helper() + return beside.Dir(t, "mesh-catalog") +} + +func TestEveryCatalogueManifestParses(t *testing.T) { + _, found := catalogueManifests(t) + named, routed := 0, 0 + for _, r := range found { + m, err := r.manifest, r.err + if err != nil { + t.Errorf("%s: %v", filepath.Base(filepath.Dir(r.path)), err) + continue + } + for _, l := range m.Listens { + if l.Name != "" { + named++ + } + } + for port := range RoutedPorts(m) { + _ = port + routed++ + } + } + t.Logf("%d manifests, %d named endpoints, %d routed endpoints resolved", len(found), named, routed) + if named == 0 { + t.Fatal("no endpoint in the catalogue is named, so this proved nothing") + } +} diff --git a/internal/compose/resolution/catalogue_read_test.go b/internal/compose/resolution/catalogue_read_test.go new file mode 100644 index 0000000..08bd7c2 --- /dev/null +++ b/internal/compose/resolution/catalogue_read_test.go @@ -0,0 +1,46 @@ +package resolution + +import ( + "path/filepath" + "sync" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// readManifest is one manifest of the catalogue as ReadTreeManifest read it. +type readManifest struct { + path string // the module.json + manifest mcat.Manifest + err error +} + +var readOnce sync.Once + +var readAll []readManifest + +var readRoot string + +// catalogueManifests is every manifest of the catalogue (catalogueRoot), read through ReadTreeManifest once +// per test binary and not once per test: the checks over the real catalogue each read and parsed every +// manifest again, and together took 1.5 s of mesh-sdk's unit bound of 2 s (issue 1756). What it returns is +// shared and read-only: a test that changes a manifest copies it first. +func catalogueManifests(t *testing.T) (string, []readManifest) { + t.Helper() + root := catalogueRoot(t) + readOnce.Do(func() { + readRoot = root + found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil { + return + } + for _, p := range found { + m, err := mcat.ReadTreeManifest(root, filepath.Dir(p)) + readAll = append(readAll, readManifest{path: p, manifest: m, err: err}) + } + }) + if len(readAll) == 0 { + t.Fatalf("no manifests under %s", root) + } + return readRoot, readAll +} diff --git a/internal/compose/resolution/catrust_manifest_test.go b/internal/compose/resolution/catrust_manifest_test.go new file mode 100644 index 0000000..67a3a8c --- /dev/null +++ b/internal/compose/resolution/catrust_manifest_test.go @@ -0,0 +1,70 @@ +package resolution + +import ( + "path/filepath" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/beside" +) + +// **A machine trusts the mesh's authority because a module put its root there** (novox/hq ADR +// 0147, issue 129). The module carries a shell script and a unit, and both are worthless unless +// the mesh fills in where the authority is — which is the one thing about it the manifest cannot +// state, because the authority's address is a fact about the mesh and not about the module. +// +// So what is checked here is the rendering, not the parsing: the script the machine will run +// names the authority it was bound to, and the unit runs that script both ways. The verification +// itself — a plain client trusting an internal name on a machine holding this, and failing on one +// that does not — is the lab's, and cannot be had here. +func TestCaTrustRendersTheAuthorityItWasBoundTo(t *testing.T) { + m, err := mcat.ReadTreeManifest(beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), "ca-trust")) + if err != nil { + t.Fatalf("the trust module does not parse:\n%v", err) + } + + r := mcat.Resolution{ + Node: "workstation", + Modules: []mcat.Manifest{m}, + Needs: []mcat.Needed{{ + Name: "internal-acme-ca", From: "anchor", At: "anchor.internal", For: "ca-trust", + Serves: map[string]any{ + "port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem", + }, + }}, + } + out, err := Declaration(r, mcat.Rendering{}) + if err != nil { + t.Fatalf("the trust module could not be composed for a machine: %v", err) + } + + script := fileNamed(out, "ca-trust.anchor") + if script == nil { + t.Fatalf("nothing writes the script the unit runs: %v", out) + } + body, _ := script["content"].(string) + if !strings.Contains(body, "https://anchor.internal:9000/roots.pem") { + t.Errorf("the script does not fetch from the authority it was bound to:\n%s", body) + } + if script["mode"] != "0755" { + t.Errorf("the script is written %v, which systemd cannot execute", script["mode"]) + } + + unit := fileNamed(out, "ca-trust.unit") + if unit == nil { + t.Fatalf("no unit: %v", out) + } + text, _ := unit["content"].(string) + // Both halves. A unit that only installs the anchor leaves a machine trusting an authority + // nobody assigned it to any more, which is the half issue 129 asked for by name. + for _, want := range []string{ + "ExecStart=" + script["path"].(string) + " install", + "ExecStop=" + script["path"].(string) + " remove", + "RemainAfterExit=yes", + } { + if !strings.Contains(text, want) { + t.Errorf("the unit does not say %q:\n%s", want, text) + } + } +} diff --git a/internal/compose/resolution/checker_seat.go b/internal/compose/resolution/checker_seat.go new file mode 100644 index 0000000..337ffa9 --- /dev/null +++ b/internal/compose/resolution/checker_seat.go @@ -0,0 +1,33 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them +// everywhere. +func checkerVerbs() []mcat.Verb { + return []mcat.Verb{ + {Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " + + "judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration " + + "levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read " + + "reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most " + + "4 wait or run at once, mesh-wide.", + Input: schema(map[string]string{"repository": "/", + "head": "the full commit to try, 40 or 64 hex digits", + "trunk": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent", + "tests": "a go test -run pattern: only those tests run, and nothing is kept", + "level": "unit or integration: only this level; both when absent"}, + []string{"repository", "head"}), + Replaces: []string{"sh merge-check.sh", "docker run postgres"}}, + {Name: "try-read", Optional: true, Description: "Where a try stands, as stored: queued, running, or its end " + + "(pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit " + + "it was merged onto, the time it took and what failed first. Read-only.", + Input: schema(map[string]string{"id": "the try's id, try-"}, []string{"id"})}, + {Name: "try-end", Optional: true, Description: "End a try that waits or runs, as error, by hand: say why (kept in " + + "what it says). Its place among the tries is free at once and the next try of its head asks anew with a new " + + "id. A try that ended already is refused.", + Input: schema(map[string]string{"id": "the try's id, try-", "why": "why it is ended, in words"}, + []string{"id", "why"})}, + } +} diff --git a/internal/compose/resolution/chosen.go b/internal/compose/resolution/chosen.go new file mode 100644 index 0000000..fffdd19 --- /dev/null +++ b/internal/compose/resolution/chosen.go @@ -0,0 +1,86 @@ +package resolution + +import ( + "sort" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// matches is whether this provider is the one chosen. +func chosenMatches(c mcat.Chosen, p mcat.Provider) bool { + return p.Node == c.Node && (c.Module == "" || p.Module == c.Module) +} + +// among is every offered provider the choice names — one, when the choice is whole. +func chosenAmong(c mcat.Chosen, where []mcat.Provider) []mcat.Provider { + var out []mcat.Provider + for _, p := range where { + if chosenMatches(c, p) { + out = append(out, p) + } + } + return out +} + +// nameOf is how a refusal names a provider: the node and the module on it. +func nameOf(p mcat.Provider) string { + return mcat.Chosen{Node: p.Node, Module: p.Module}.String() +} + +// providerNames is every provider named, sorted, for a refusal to list. +func providerNames(where []mcat.Provider) []string { + out := make([]string, 0, len(where)) + for _, p := range where { + out = append(out, nameOf(p)) + } + sort.Strings(out) + return out +} + +// providersHere is which modules in this node's own set offer a provision, sorted. +func providersHere(catalogue map[string]mcat.Manifest, here func(string) bool, want string) []string { + var out []string + for name, m := range catalogue { + if !here(name) { + continue + } + for _, o := range m.Offers() { + if o == want { + out = append(out, name) + break + } + } + } + sort.Strings(out) + return out +} + +// servedByOne is what one provider beside the consumer says a consumer needs to know, or nothing. +// +// Serving is *whether* a need is created at all when the provider is on this same machine (novox/hq +// 04-ISSUES/038's sibling): a need never created is a binding the consumer never gets. The manifest +// alone answers that; the values are settled later, with the node's settings. +func servedByOne(m mcat.Manifest, want string) map[string]any { + if _, ok := m.Serves[want]; ok { + return ServedOn(m, want, nil) + } + return nil +} + +// sharedByOne is the own secret that provider names as its credential (ADR 0158), or "" when it +// gives each consumer its own. +func sharedByOne(m mcat.Manifest, want string) string { + if own, shared := m.SharedCredentialOf(want); shared { + return own + } + return "" +} + +func oneOf(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} diff --git a/internal/compose/resolution/co_located_test.go b/internal/compose/resolution/co_located_test.go new file mode 100644 index 0000000..0b39a17 --- /dev/null +++ b/internal/compose/resolution/co_located_test.go @@ -0,0 +1,306 @@ +package resolution + +import ( + "encoding/json" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// A root certificate, as a certificate authority serves one: a real, self-signed one made for these tests (its key +// thrown away), because the one setting that may hold lines must parse as a certificate (novox/hq issue 339). +const servedRoot = `-----BEGIN CERTIFICATE----- +MIIBPjCB8aADAgECAhRZG3p93hUUB5bz00uxhYo/nJnTQjAFBgMrZXAwFDESMBAG +A1UEAwwJdGVzdCByb290MCAXDTI2MTAwODIyMjE0NloYDzIxMjYwOTE0MjIyMTQ2 +WjAUMRIwEAYDVQQDDAl0ZXN0IHJvb3QwKjAFBgMrZXADIQA0pt/ld+W0MXwBhPfO +cuAt56kIW6Qcn+4vqWpuvHTiqaNTMFEwHQYDVR0OBBYEFIjgaLk4OVGIOeZQiqnN +p9vhciFjMB8GA1UdIwQYMBaAFIjgaLk4OVGIOeZQiqnNp9vhciFjMA8GA1UdEwEB +/wQFMAMBAf8wBQYDK2VwA0EAl9uWeSM2XAV8u0reyV3BLRxNVik+4FCRO1QKPs2k +IlB1rK9oAOYManH+VFuBMI/JJ31ajSti81q4E0CSw8r5DQ== +-----END CERTIFICATE----- +` + +// stepCA is an internal ACME authority: it answers `acme-ca` from anywhere in the mesh, and what a +// consumer must know is the directory URL and the root to trust. **The root is not knowable when +// the module is written** — it exists only once the CA has been initialised — so the manifest +// declares the key and the operator supplies the value as a setting, which is exactly the shape the +// cross-node path settles and the same-node path did not. +func stepCA() mcat.Manifest { + return mcat.Manifest{ + Module: "step-ca", Version: "1", + Provides: mcat.FromAnywhere("acme-ca"), + Listens: []mcat.Listening{{Port: 9000, Protocol: "tcp", From: mcat.FromMesh}}, + Serves: map[string]map[string]any{"acme-ca": { + "directory": "https://anchor.internal/acme/acme/directory", + // Declared empty: a manifest is the same on every mesh, and this mesh's root is not. + "root": "", + }}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "step-ca", "ports": []any{"9000"}, + }}, + } +} + +// stepCAWithItsRoot is the authority whose root the operator supplies: the served key asks for it as +// `${setting:root}` (ADR 0155), the only way a setting reaches what a provider serves since a layer +// stopped replacing a served key (novox/hq issue 696; before it, the empty `root` was replaced whole). +func stepCAWithItsRoot() mcat.Manifest { + m := stepCA() + m.Serves["acme-ca"]["root"] = "${setting:root}" + return m +} + +// routeProxy issues from that authority, so it must be told the root to verify it with. +func routeProxy() mcat.Manifest { + return mcat.Manifest{ + Module: "route-proxy", Version: "1", + Requires: []string{"acme-ca"}, + Provides: mcat.FromAnywhere("route"), + Binds: map[string]string{"acme-ca": "/var/lib/route-proxy/acme-ca.json"}, + Receives: map[string]string{"route": "/var/lib/route-proxy/routes.json"}, + Resources: []map[string]any{{ + "id": "bundle", "type": "file", "path": "/var/lib/route-proxy/ca.pem", "mode": "0644", + "content": "${bound:acme-ca:root}", + }}, + } +} + +// A co-located provider's served VALUES reach its consumer, not just its served keys. +// +// The mesh walks a provider on ANOTHER machine and settles what it serves with that node's settings +// layers before offering it (cmd/mesh-controller plan.go, theRestOfTheMesh). A provider on the +// consumer's own machine was never settled at all: resolve.go's servedHere and declaration.go's +// here() both read the manifest and stop there. So a served value the operator supplied — the one +// kind of value a manifest cannot carry, because it is different on every mesh — arrived as the +// manifest's empty default. +// +// The consequence is silent and total: route-proxy wrote an empty CA bundle, fell back to the +// system trust store, could not verify the internal authority, and no certificate was ever issued. +func TestACoLocatedProvidersServedValuesReachItsConsumer(t *testing.T) { + r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()), + []string{"step-ca", "route-proxy"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + + // What the operator set on the provider, on this node — the CA's root, which only exists once + // the CA has been initialised. + out, err := Declaration(r, mcat.Rendering{Settings: mcat.SettingsBy{ + "step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}}, + }}) + if err != nil { + t.Fatal(err) + } + + bundle := fileNamed(out, "route-proxy.bundle") + if bundle == nil { + t.Fatalf("the consumer was given no bundle at all: %v", out) + } + if got := bundle["content"]; got != servedRoot { + t.Errorf("the co-located consumer was not told the root it must trust:\n got %q\nwant %q", + got, servedRoot) + } +} + +// And the binding file says the same thing, for a consumer that reads the binding rather than a +// substituted placeholder. The two are one fact and must not be able to disagree. +func TestACoLocatedConsumersBindingCarriesTheSettledValues(t *testing.T) { + r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()), + []string{"step-ca", "route-proxy"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(r, mcat.Rendering{ + Settings: mcat.SettingsBy{ + "step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}}, + }, + // And the machine moved the provider's port while it was at it, so both halves of the + // cross-node derivation are exercised at once. + Ports: map[string]map[int]int{"step-ca": {9000: 19000}}, + }) + if err != nil { + t.Fatal(err) + } + + binding := fileNamed(out, "route-proxy.bound-acme-ca") + if binding == nil { + t.Fatalf("the consumer was given no binding at all: %v", out) + } + var said struct { + Serves map[string]any `json:"serves"` + } + if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil { + t.Fatal(err) + } + if said.Serves["root"] != servedRoot { + t.Errorf("the binding does not carry the settled root: %q", said.Serves["root"]) + } + if port, _ := asPort(said.Serves["port"]); port != 19000 { + t.Errorf("the binding does not carry the port the machine publishes: %v", said.Serves["port"]) + } +} + +// A provider PULLED IN rather than assigned is settled the same way. +// +// The resolver's same-node need is built during the walk, from whichever modules had been chosen by +// the time the requirement came up — so which path a co-located binding took depended on the order +// a person happened to assign things in. Settling after the closure is known removes that: both +// orders now produce the same file. +func TestACoLocatedProviderIsSettledWhicheverWayItWasPulledIn(t *testing.T) { + for _, assigned := range [][]string{ + {"step-ca", "route-proxy"}, + {"route-proxy", "step-ca"}, + } { + r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()), assigned, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(r, mcat.Rendering{Settings: mcat.SettingsBy{ + "step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}}, + }}) + if err != nil { + t.Fatal(err) + } + bundle := fileNamed(out, "route-proxy.bundle") + if bundle == nil || bundle["content"] != servedRoot { + t.Errorf("assigned %v: the consumer was not told the root", assigned) + } + } +} + +// A same-node contribution names the port the MACHINE publishes, not the one the module declared. +// +// 04-ISSUES/038 fixed this for what a consumer is TOLD about a provider. This is the other +// direction: what a workload TELLS the provider about itself. gitea declares a bare container port +// 3000, the mesh publishes it as 20000:3000 — and gitea's route contribution still said 3000, so +// the proxy beside it dialled a port nothing listens on and answered 502 for every request. +// +// The redirect uses the CONTRIBUTING module's assignment: the port belongs to the workload, and +// using the provider's map would move it to wherever the proxy happens to be published. +func TestASameNodeContributionNamesThePortTheMachinePublishes(t *testing.T) { + gitea := mcat.Manifest{ + Module: "gitea", Version: "1", + Listens: []mcat.Listening{{Port: 3000, Protocol: "tcp", From: mcat.FromMesh}}, + Contributes: map[string]map[string]any{"route": {"label": "git", "port": 3000}}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "gitea", "ports": []any{"3000"}, + }}, + } + r, err := Resolve(shelf(gitea, routeProxy(), stepCA()), + []string{"gitea", "route-proxy", "step-ca"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(r, mcat.Rendering{ + // The machine put gitea's 3000 on 20000, and published it that way. + Ports: map[string]map[int]int{"gitea": {3000: 20000}}, + }) + if err != nil { + t.Fatal(err) + } + + // Where the workload is actually published. + server := fileNamed(out, "gitea.server") + if published := strings.Join(asStrings(server["ports"]), ","); published != "20000:3000" { + t.Fatalf("the workload was not published on the assigned port: %v", server["ports"]) + } + + // What the proxy beside it was told to dial: the SAME port. + routes := fileNamed(out, "route-proxy.received-route") + if routes == nil { + t.Fatalf("the proxy was given no routes file at all: %v", out) + } + var given struct { + Given []mcat.Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil { + t.Fatal(err) + } + if len(given.Given) != 1 { + t.Fatalf("expected one route, got %v", given.Given) + } + port, ok := asPort(given.Given[0].Values["port"]) + if !ok || port != 20000 { + t.Errorf("the proxy was told to dial a port nothing listens on: %v", + given.Given[0].Values["port"]) + } +} + +// A contribution from ANOTHER machine is left exactly as it was. +// +// Its port belongs to that machine's assignment, which this node's map knows nothing about — +// applying this node's map to it would move a remote workload's port to wherever a local module of +// the same name happens to be published, which is worse than the fault being fixed. +func TestAContributionFromAnotherMachineKeepsItsOwnPort(t *testing.T) { + r, err := Resolve(shelf(routeProxy(), stepCA()), + []string{"route-proxy", "step-ca"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(r, mcat.Rendering{ + // A same-named module on this machine, moved. The grant below is a laptop's, and must not + // be dragged along with it. + Ports: map[string]map[int]int{"gitea": {3000: 20000}}, + Grants: []mcat.Grant{{ + Provision: "route", Consumer: "laptop", From: "gitea", At: "laptop.internal", + Values: map[string]any{"name": "git.example", "port": 3000}, + }}, + }) + if err != nil { + t.Fatal(err) + } + routes := fileNamed(out, "route-proxy.received-route") + var given struct { + Given []mcat.Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil { + t.Fatal(err) + } + if len(given.Given) != 1 { + t.Fatalf("expected one route, got %v", given.Given) + } + if port, _ := asPort(given.Given[0].Values["port"]); port != 3000 { + t.Errorf("another machine's contribution was rewritten with this machine's ports: %v", + given.Given[0].Values["port"]) + } +} + +// The cross-node half of the same fault: a grant assembled on the providing machine carries the +// consumer's declared port until the CONSUMER's machine's assignments are applied to it. The +// declaration layer cannot do it — those assignments are not this machine's, which is the line the +// test above holds — so the grant layer is handed them and does it there. +func TestAContributionIsRedirectedToWhereItsOwnMachinePublishedIt(t *testing.T) { + published := map[int]int{3000: 20000} + + got := AtPublishedPort(map[string]any{"name": "git.example.tld", "port": 3000}, "forge", published) + if port, _ := asPort(got["port"]); port != 20000 { + t.Errorf("the proxy would dial a port that machine never published: %v", got["port"]) + } + if got["name"] != "git.example.tld" { + t.Errorf("redirecting the port disturbed the rest of the contribution: %v", got) + } + + // Idempotent, and silent about a port nobody moved: a module that pinned its own mapping has no + // assignment, and must come back exactly as it was written. + again := AtPublishedPort(got, "forge", published) + if port, _ := asPort(again["port"]); port != 20000 { + t.Errorf("applying it twice moved the port again: %v", again["port"]) + } + pinned := AtPublishedPort(map[string]any{"port": 9070}, "office", published) + if port, _ := asPort(pinned["port"]); port != 9070 { + t.Errorf("a port the mesh never assigned was rewritten: %v", pinned["port"]) + } +} + +func asStrings(v any) []string { + listed, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(listed)) + for _, one := range listed { + out = append(out, strings.TrimSpace(plainly(one))) + } + return out +} diff --git a/internal/compose/resolution/collections.go b/internal/compose/resolution/collections.go new file mode 100644 index 0000000..472fc46 --- /dev/null +++ b/internal/compose/resolution/collections.go @@ -0,0 +1,312 @@ +package resolution + +import ( + "fmt" + "regexp" + "slices" + "sort" + "strconv" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func itemKey(it mcat.Item) string { return mcat.ItemKey(it.Collection, it.Name) } + +// order is the item's place among the items of one placeholder: its `order` attribute, or OrderDefault. +func itemOrder(it mcat.Item) int { + if n, err := strconv.Atoi(it.Attributes[mcat.OrderAttribute]); err == nil && n >= 0 && n <= 99 { + return n + } + return mcat.OrderDefault +} + +// matches is whether the item's attributes carry every pair of the selector. +func itemMatches(it mcat.Item, where map[string]string) bool { + for k, v := range where { + if got, has := it.Attributes[k]; !has || got != v { + return false + } + } + return true +} + +// itemsInOrder is the module's items by order, then name; the order the old form placed a module's pieces in. +func itemsInOrder(items []mcat.Item) []mcat.Item { + out := append([]mcat.Item(nil), items...) + sort.SliceStable(out, func(a, b int) bool { + if oa, ob := itemOrder(out[a]), itemOrder(out[b]); oa != ob { + return oa < ob + } + return out[a].Name < out[b].Name + }) + return out +} + +// oldCollectionNamespaces are the namespaces read as collections of their own name. +var oldCollectionNamespaces = []string{"shell", "environment", "contribution"} + +// ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused. +var ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`) + +var attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + +var attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`) + +// splitSelector is a placeholder's name without its selector, and the selector: `widgets:bar=top,place=left` is +// `widgets` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon +// before the first one. +func splitSelector(name string) (collection, selector string) { + eq := strings.Index(name, "=") + if eq < 0 { + return name, "" + } + colon := strings.LastIndex(name[:eq], ":") + if colon < 0 { + return name, "" + } + return name[:colon], name[colon+1:] +} + +// selectorOf is a selector's pairs, or why it is none. +func selectorOf(selector string) (map[string]string, error) { + out := map[string]string{} + if selector == "" { + return out, nil + } + for _, pair := range strings.Split(selector, ",") { + k, v, ok := strings.Cut(pair, "=") + switch { + case !ok || !attributeKey.MatchString(k) || !attributeValue.MatchString(v): + return nil, fmt.Errorf("%q in %q is not =", pair, selector) + case out[k] != "": + return nil, fmt.Errorf("%q names %s twice", selector, k) + } + out[k] = v + } + return out, nil +} + +// collectionOfPlaceholder is the collection a placeholder's namespace and key name, and whether they name one. +func collectionOfPlaceholder(namespace, key string) (string, bool) { + key, _ = splitSelector(key) + switch { + case namespace == mcat.CollectedNamespace && strings.HasPrefix(key, mcat.EnvironmentCollection+":"): + // The environment is one collection, and the rest of the name picks the rendering (issue 795). + return mcat.EnvironmentCollection, true + case namespace == mcat.CollectedNamespace: + return key, true + case namespace == "environment": + return mcat.EnvironmentCollection, true + case slices.Contains(oldCollectionNamespaces, namespace): + return namespace + ":" + key, true + } + return "", false +} + +// oldCollection is an old namespace's collection by its name: its comment, or why no item may be contributed to it. +// ok is false for a name that is not an old namespace's. +// +// forItem is whether an item is to be contributed to it: an item may be contributed to a kind received as data, +// which a collector may place as a collection only where its shape says its pieces are text (design 57 L3). +func oldCollection(name string, forItem bool) (comment string, old bool, wrong error) { + namespace, key, _ := strings.Cut(name, ":") + if !slices.Contains(oldCollectionNamespaces, namespace) { + return "", false, nil + } + switch namespace { + case "shell": + target, slot, two := strings.Cut(key, ":") + if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) { + return "", true, fmt.Errorf("%s is no slot: shell code is shell::, the shell one of %s, "+ + "the session's file one of %s or the power moment one of %s, and the slot one of %s", name, + strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "), + strings.Join(knownSlots, ", ")) + } + // shellCode's line before each module's code. + return "#", true, nil + case "environment": + if key == "" { + // The collection itself, which an item is contributed to; a placeholder names a rendering. + return "#", true, nil + } + if key != mcat.EnvironmentPOSIX && key != mcat.EnvironmentSystemd { + return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s", + name, mcat.EnvironmentPOSIX, mcat.EnvironmentSystemd) + } + return "#", true, nil + } + seat, kind, _ := strings.Cut(key, ":") + s, r, ok := receivable(seat, kind) + switch { + case !ok && s.Name == "": + return "", true, fmt.Errorf("%s names the seat %q, which the mesh does not define", name, seat) + case !ok: + return "", true, fmt.Errorf("%s: %s; %s", name, s.Name, kindsOf(s)) + case r.Shape != nil && (forItem || collectableAsText(r)): + return r.Comment, true, nil + case r.Shape != nil: + return "", true, fmt.Errorf("%s is received by %s as data in its own shape, which no item of text is "+ + "(novox/hq ADR 0255)", name, s.Name) + case r.Dirs: + return "", true, fmt.Errorf("%s names its contributors' directories, which the holder reads as paths "+ + "the controller fills; it is not yet a collection (design 57 L1)", name) + case s.Name != canonicalSeat(seat): + return "", true, fmt.Errorf("%s names the seat by another name; a collection names it %s", name, s.Name) + } + return r.Comment, true, nil +} + +// isOld is whether a collection's name is an old namespace's. +func isOld(name string) bool { + _, old, _ := oldCollection(name, true) + return old +} + +// collectedPlaceholderProblems is what is wrong with one resource's ${collected:…}: placed only in a file's content, +// and naming a collection the module declares it collects. +func collectedPlaceholderProblems(m mcat.Manifest, r map[string]any) []string { + var problems []string + for _, field := range sortedKeys(r) { + v, ok := r[field].(string) + if !ok { + continue + } + for _, f := range ofCollected.FindAllStringSubmatch(v, -1) { + base, selector := splitSelector(f[1]) + if _, err := selectorOf(selector); err != nil { + problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err)) + continue + } + switch { + case field != "content": + problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a collection is placed "+ + "only in a file's content", m.Module, r["id"], f[0], field)) + case isOld(base): + if _, _, wrong := oldCollection(base, false); wrong != nil { + problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], wrong)) + } + case !slices.ContainsFunc(m.Collections, func(c mcat.Collection) bool { return c.Name == base }): + problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not collect %q: "+ + "declare it under collections (novox/hq ADR 0349 §13)", m.Module, r["id"], f[0], m.Module, base)) + } + } + } + return problems +} + +// collectionComment is the comment of a collection a module's file names: declared, or the old namespace's. +func collectionComment(m mcat.Manifest, name string) (string, error) { + if comment, old, wrong := oldCollection(name, false); old { + return comment, wrong + } + for _, c := range m.Collections { + if c.Name == name { + return c.Comment, nil + } + } + return "", fmt.Errorf("%s names ${collected:%s} and does not collect it", m.Module, name) +} + +// itemsLeftOutFor is itemsLeftOut for a placeholder that selects by attributes (where), on a machine with these +// capabilities: an item the selector does not take, or whose capability the machine lacks, is not left out but not +// wanted here. +func itemsLeftOutFor(modules []mcat.Manifest, holder mcat.Manifest, collection string, where map[string]string, + facts map[string]string, caps map[string]bool) []string { + var out []string + for _, m := range inModuleOrder(modules) { + for _, it := range itemsInOrder(m.Items) { + if it.Collection != collection || !itemMatches(it, where) || (it.IfCapability != "" && !caps[it.IfCapability]) { + continue + } + if _, rendered := heldText(it, facts); rendered && renderedOldWay(collection) { + continue + } + out = append(out, fmt.Sprintf("%s's item of %s is not placed: %s is filled the old way on this "+ + "machine, from contributions, and an item only by the provisioner's client (design 57 L1)", + m.Module, itemKey(it), holder.Module)) + } + } + return out +} + +// oldFormsLeftOut says each piece of an old form a handed collector's collections would have held: the collector is +// filled from items alone, and the piece's module contributes none (ADR 0349 clause 17). +func oldFormsLeftOut(modules []mcat.Manifest, holder mcat.Manifest, collections []mcat.PlanCollection) []string { + var out []string + for _, c := range collections { + namespace, key, _ := strings.Cut(c.Name, ":") + for _, m := range inModuleOrder(modules) { + old := false + switch namespace { + case "shell": + target, slot, _ := strings.Cut(key, ":") + old = slices.ContainsFunc(m.Shell, func(s mcat.ShellCode) bool { return s.For == target && s.Slot == slot }) + case "environment": + old = m.Environment != nil && (len(m.Environment.Variables) > 0 || len(m.Environment.Path) > 0) + case "contribution": + seat, kind, _ := strings.Cut(key, ":") + old = slices.ContainsFunc(manifestAllContributions(m), func(s mcat.SeatContribution) bool { + return canonicalSeat(s.Seat) == canonicalSeat(seat) && s.Kind == kind + }) + } + if old { + out = append(out, fmt.Sprintf("%s's %s is not placed: %s is filled by the provisioner's client on "+ + "this machine, from items, and %s contributes it the old way (design 57 L1)", m.Module, c.Name, + holder.Module, m.Module)) + } + } + } + return out +} + +// renderedOldWay is whether the controller renders a collection's items into a collector not handed. +func renderedOldWay(collection string) bool { + namespace, _, _ := strings.Cut(collection, ":") + return namespace == "shell" || namespace == "contribution" || namespace == mcat.EnvironmentCollection +} + +// heldItem is one item of a module rendered the old way. +type heldItem struct { + item mcat.Item + text string +} + +// heldItems is a module's items of the collection that the selector takes and the machine's capabilities allow, by +// order then name, each rendered as heldText does; the ones it cannot render are left out (itemsLeftOutFor says so). +func heldItems(m mcat.Manifest, collection string, where map[string]string, facts map[string]string, + caps map[string]bool) []heldItem { + if !renderedOldWay(collection) { + return nil + } + var out []heldItem + for _, it := range itemsInOrder(m.Items) { + if it.Collection != collection || !itemMatches(it, where) || (it.IfCapability != "" && !caps[it.IfCapability]) { + continue + } + if text, ok := heldText(it, facts); ok { + out = append(out, heldItem{item: it, text: text}) + } + } + return out +} + +// heldText is an item's text with the machine's facts filled, ending its line; false for an item whose text the mesh +// does not hold or which names anything but a machine fact this machine has. +func heldText(it mcat.Item, facts map[string]string) (string, bool) { + if it.Content == "" { + return "", false + } + text := it.Content + for _, f := range templatePlaceholder.FindAllStringSubmatch(text, -1) { + value, has := facts[f[2]] + if f[1] != "machine" || !has { + return "", false + } + text = strings.ReplaceAll(text, f[0], value) + } + if !strings.HasSuffix(text, "\n") { + text += "\n" + } + return text, true +} diff --git a/internal/compose/resolution/collections_bar_test.go b/internal/compose/resolution/collections_bar_test.go new file mode 100644 index 0000000..c00debd --- /dev/null +++ b/internal/compose/resolution/collections_bar_test.go @@ -0,0 +1,110 @@ +package resolution + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +const collectedBarsText = "top\n${collected:contribution:node-bar:block:bar=top,place=status}" + + "bottom\n${collected:contribution:node-bar:block:bar=bottom,place=status}" + +const collectedBars = `{"module":"bars","version":"1","moved":true, + "claims":[{"name":"node-bar","scope":"node","renders":{"block":"{{.shows}}"}}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}` + +const itemsOfPower = `{"module":"power","version":"1","moved":true, + "items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"}, + {"collection":"contribution:node-bar:block","name":"draw","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` + +func collectedBarsOn(t *testing.T, caps map[string]bool) mcat.Resolution { + t.Helper() + bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText})) + power := held(t, itemsOfPower, builtWith("power", map[string]string{"battery": "B\n", "draw": "D ${machine:name}\n"})) + r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + r.Account, r.Capabilities = "op", caps + return r +} + +func TestACollectorMayNameTheBarsBlocksAsACollectionAndNoOtherDataKind(t *testing.T) { + bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText})) + if _, err := Compose(collectedBarsOn(t, nil), handingTo("store:5000")); err != nil { + t.Fatalf("the handed collector is refused: %v", err) + } + if got := collectedPlaceholderProblems(bars, map[string]any{"id": "x", + "content": "${collected:contribution:node-bar:block:bar=top,place=status}"}); len(got) != 0 { + t.Fatalf("the check refuses it: %v", got) + } + // A data kind whose shape does not say its items are text stays refused. + _, plain, _ := receivable(mcat.BarSeat, mcat.BarKindBlock) + plain.Shape = &mcat.Shape{} + if collectableAsText(plain) { + t.Fatal("a shape not marked as text is collectable") + } + if _, _, err := oldCollection("contribution:node-bar:block", false); err != nil { + t.Fatal(err) + } +} + +// The bars of the catalogue's i3status-rust, with the blocks of power's items, compose to the files the old shaped +// contribution made (issue 781). testdata/bars holds the catalogue's bars and block texts as of the move and the bars as +// composed before it, for a machine with neither capability, the battery, the power meter and both. Where the +// collector is not handed the controller fills the files; where it is, it names the items for the client, which +// fills the same files (mesh-catalog's provisioner client holds that half against the same golden files). +func TestTheBarsOfTheCatalogueAreTheSameHandedOrNot(t *testing.T) { + read := func(name string) string { + b, err := os.ReadFile(filepath.Join("testdata", "bars", name)) + if err != nil { + t.Fatal(err) + } + return string(b) + } + collector := `{"module":"bars","version":"1","moved":true,"claims":[{"name":"node-bar","scope":"node"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"top","type":"file","path":"/t","template":"files/top"}, + {"id":"bottom","type":"file","path":"/b","template":"files/bottom"}]}` + power := `{"module":"power","version":"1","moved":true, + "items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"}, + {"collection":"contribution:node-bar:block","name":"power-draw","if-capability":"power-meter","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` + for name, caps := range map[string]map[string]bool{"none": nil, "battery": {"battery": true}, + "power-meter": {"power-meter": true}, "both": {"battery": true, "power-meter": true}} { + bars := held(t, collector, builtWith("bars", map[string]string{"top": read("top-bar.toml"), "bottom": read("bottom-bar.toml")})) + blocks := held(t, power, builtWith("power", map[string]string{"battery": read("bar-battery"), "draw": read("bar-power-draw")})) + r, err := Resolve(shelf(bars, blocks), []string{"bars", "power"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + r.Account, r.Capabilities = "op", caps + // Not handed: the controller fills them. + for id, which := range map[string]string{"bars.top": "top", "bars.bottom": "bottom"} { + if got := composedFile(t, r, id); got != read(name+"."+which+"-bar.toml") { + t.Errorf("%s, %s bar, not handed:\n%s\nwant\n%s", name, which, got, read(name+"."+which+"-bar.toml")) + } + } + // Handed: the plan names the items the machine's capabilities allow, for the client to fill. + c, _ := composeWith(t, r, handingTo("store:5000")) + var names []string + for _, it := range c.Plans["power"].Items { + names = append(names, it.Name) + } + want := []string{} + if caps["battery"] { + want = append(want, "battery") + } + if caps["power-meter"] { + want = append(want, "power-draw") + } + if !reflect.DeepEqual(append([]string{}, names...), want) { + t.Errorf("%s: the plan names %v, want %v", name, names, want) + } + } +} diff --git a/internal/compose/resolution/collections_l2_test.go b/internal/compose/resolution/collections_l2_test.go new file mode 100644 index 0000000..cda4d55 --- /dev/null +++ b/internal/compose/resolution/collections_l2_test.go @@ -0,0 +1,57 @@ +package resolution + +import ( + "os" + "path/filepath" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func l2Moved(t *testing.T, parts ...string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(append([]string{"testdata", "l2-moved"}, parts...)...)) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func TestTheCollectorsL2MovedComposeAsBeforeWhereNothingIsHanded(t *testing.T) { + // Each moved contributor, its items read back from its templates archive as the mesh holds them. + items := map[string][]string{ + "asus-zephyrus-g14": {"shell:after-wake:normal", "contribution:node-hotkeys:trigger"}, + "claude-code": {"shell:zsh:normal"}, + "mesh-cli": {"shell:zsh:normal"}, + "powerlevel10k": {"shell:zsh:normal"}, + "zsh-autosuggestions": {"shell:zsh:normal"}, + "zsh-syntax-highlighting": {"shell:zsh:last"}, + } + var modules []mcat.Manifest + for _, module := range sortedKeys(items) { + m := mcat.Manifest{Module: module, Moved: true} + for _, collection := range items[module] { + m.Items = append(m.Items, mcat.Item{Collection: collection, + Content: l2Moved(t, "items", module+"."+strings.ReplaceAll(collection, ":", "-"))}) + } + modules = append(modules, m) + } + for _, c := range []struct{ module, seat string }{{"zsh", mcat.LoginShellSeat}, {"triggerhappy", mcat.HotkeysSeat}, + {"power", mcat.PowerSeat}} { + holder := mcat.Manifest{Module: c.module, Moved: true, Claims: []mcat.Claim{{Name: c.seat}}} + resource := map[string]any{"id": "file", "type": "file", "path": "/etc/" + c.module, + "content": l2Moved(t, "templates", c.module)} + var unplaced []string + if err := contributionsInto(resource, holder, append([]mcat.Manifest{holder}, modules...), + map[string]string{"name": "g14"}, mcat.Rendering{}, map[string]bool{}, &unplaced); err != nil { + t.Fatalf("%s: %v", c.module, err) + } + if got, want := resource["content"], l2Moved(t, "want", c.module); got != want { + t.Errorf("%s's file is not what the old forms made:\n%s\nwant:\n%s", c.module, got, want) + } + if len(unplaced) != 0 { + t.Errorf("%s: unplaced %v", c.module, unplaced) + } + } +} diff --git a/internal/compose/resolution/collections_l3_test.go b/internal/compose/resolution/collections_l3_test.go new file mode 100644 index 0000000..8b66048 --- /dev/null +++ b/internal/compose/resolution/collections_l3_test.go @@ -0,0 +1,36 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +const barCollector = `{"module":"bars","version":"1","moved":true, + "collections":[{"name":"blocks","comment":"#"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}` + +func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testing.T) { + for placeholder, want := range map[string]string{ + "${collected:blocks:bar}": "", // no equals sign: a collection named blocks:bar + "${collected:blocks:bar=top,bar=bottom}": "names bar twice", + "${collected:blocks:bar=}": "is not =", + "${collected:other:bar=top}": `does not collect "other"`, + } { + raw := strings.Replace(barCollector, `"template":"files/bars"`, `"content":"`+placeholder+`"`, 1) + raw = strings.Replace(raw, `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1) + raw = strings.Replace(raw, `"moved":true,`, "", 1) + m, err := decodeForTest([]byte(raw)) + if err == nil { + _, err = Declaration((mcat.Resolution{Node: "n", Modules: []mcat.Manifest{m}}), mcat.Rendering{}) + } + if want == "" { + continue + } + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: %v, want %q", placeholder, err, want) + } + } +} diff --git a/internal/compose/resolution/collections_test.go b/internal/compose/resolution/collections_test.go new file mode 100644 index 0000000..006624d --- /dev/null +++ b/internal/compose/resolution/collections_test.go @@ -0,0 +1,13 @@ +package resolution + +import ( + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func builtWith(module string, texts map[string]string) []mcat.Built { + return []mcat.Built{{Name: "files", Kind: mcat.ArtifactArchive, + Reference: mcat.ArtifactStoreScheme + module + "/files/blobs/sha256:" + strings.Repeat("e", 64), + Digest: "sha256:" + strings.Repeat("e", 64), Texts: texts}} +} diff --git a/internal/compose/resolution/computed_test.go b/internal/compose/resolution/computed_test.go new file mode 100644 index 0000000..ed7e4f7 --- /dev/null +++ b/internal/compose/resolution/computed_test.go @@ -0,0 +1,184 @@ +package resolution + +import ( + "fmt" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +type fake struct { + on map[string]bool + asked []string + err error +} + +func (f *fake) Resources(node string) ([]map[string]any, bool, error) { + f.asked = append(f.asked, node) + if f.err != nil { + return nil, false, f.err + } + if !f.on[node] { + return nil, false, nil + } + return []map[string]any{{"id": "peers", "type": "file", "path": "/etc/x.conf", + "merge": mcat.MergeJSON, "content": `{"peer":"` + node + `"}`}}, true, nil +} + +func computedShelf() map[string]mcat.Manifest { + return shelf(mcat.Manifest{Module: "mesh-network", Computed: "mesh-network", + Provides: mcat.Offers("private-network")}) +} + +func TestAComputedModuleIsAskedAboutTheNodeItIsFor(t *testing.T) { + // The generator gets a node name, not a plan. Everything it needs is the whole mesh, which + // it was built with — this is the one thing a node could never work out for itself. + got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + gen := &fake{on: map[string]bool{"workstation": true}} + out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}}) + if err != nil { + t.Fatal(err) + } + if len(gen.asked) != 1 || gen.asked[0] != "workstation" { + t.Fatalf("asked about %v, wanted workstation once", gen.asked) + } + if len(out) != 1 || !strings.Contains(out[0]["content"].(string), `"peer": "workstation"`) { + t.Fatalf("got %v", out) + } +} + +func TestAMachineNobodyGaveItGetsNothing(t *testing.T) { + // The whole point of making the network a module. Before this, every machine with an address + // was on the private network and there was no way to say one should stay off. + got, err := Resolve(computedShelf(), nil, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + gen := &fake{on: map[string]bool{"workstation": true}} + out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}}) + if err != nil { + t.Fatal(err) + } + if len(out) != 0 { + t.Fatalf("a machine that was assigned nothing got %d resource(s)", len(out)) + } + if len(gen.asked) != 0 { + t.Fatalf("the generator was asked about %v, and nobody had asked for it", gen.asked) + } +} + +func TestAssignedAndNotYetPartOfItIsNotAFailure(t *testing.T) { + // A machine given the network module before it has a place on it. Brief and ordinary — the + // answer is "nothing yet", and treating it as an error would make an ordering a fault. + got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{}) + gen := &fake{on: map[string]bool{}} + out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}}) + if err != nil { + t.Fatalf("refused a node that is not on the network yet: %v", err) + } + if len(out) != 0 { + t.Fatalf("got %v", out) + } +} + +func TestAGeneratorThisControlPlaneDoesNotHaveIsRefused(t *testing.T) { + // Sending a machine a module with no files would look like it worked. Named in the message, + // because the only fix is a control plane that has it. + got, _ := Resolve(shelf(mcat.Manifest{Module: "weather", Computed: "the-weather"}), []string{"weather"}, workstation(), mcat.World{}) + + _, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": &fake{}}}) + if err == nil { + t.Fatal("a module computed by nothing was accepted") + } + if !strings.Contains(err.Error(), "the-weather") { + t.Fatalf("the refusal does not name what is missing: %v", err) + } +} + +func TestSettingsApplyToAComputedModuleToo(t *testing.T) { + // Being computed is about where the files come from, not about whether they are configurable. + // A line drawn there would be arbitrary and nobody could predict it. + got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{}) + gen := &fake{on: map[string]bool{"workstation": true}} + out, err := Declaration(got, mcat.Rendering{ + Generators: map[string]mcat.Generator{"mesh-network": gen}, + Settings: mcat.SettingsBy{"mesh-network": {{From: "the mesh", + Values: map[string]any{"keepalive": 25}}}}, + }) + if err != nil { + t.Fatal(err) + } + content := out[0]["content"].(string) + if !strings.Contains(content, `"keepalive": 25`) { + t.Fatalf("the setting did not reach a computed file: %s", content) + } + if !strings.Contains(content, `"peer": "workstation"`) { + t.Fatalf("the setting replaced what the generator computed: %s", content) + } +} + +func TestTwoWaysToBeOnAPrivateNetworkRefuseAndNameBoth(t *testing.T) { + // WireGuard is one way. The refusing rule is what makes a second one safe to add: assigning + // both is caught rather than producing a machine on two networks that each half-work. + _, err := Resolve(shelf(mcat.Manifest{Module: "mesh-network", Computed: "mesh-network", + Provides: mcat.Offers("private-network")}, mcat.Manifest{Module: "tailscale", Provides: mcat.Offers("private-network")}, mcat.Manifest{Module: "backups", Requires: []string{"private-network"}}, + ), []string{"backups"}, workstation(), mcat.World{}) + + if err == nil { + t.Fatal("two answers to one requirement were taken silently") + } + for _, want := range []string{"mesh-network", "tailscale", "private-network"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal does not name %s: %v", want, err) + } + } +} + +// reloading answers the runtime's trust as the networking module once did (novox/hq ADR 0102; it no +// longer does, ADR 0222 — and beside the runtime's module it is refused, generated_collision_test): a file +// written into, and the runtime reloaded on it. +type reloading struct{} + +func (reloading) Resources(node string) ([]map[string]any, bool, error) { + return []map[string]any{ + {"id": "registry-trust", "type": "file", "path": "/etc/docker/daemon.json", + "merge": mcat.MergeJSON, "into": "json", "content": `{"insecure-registries":["r:5000"]}`}, + {"id": "registry-trust-reload", "type": "service", "unit": "docker.service", + "state": "running", "reload-on": []string{"registry-trust"}}, + }, true, nil +} + +func TestWhatAServiceIsReloadedOnIsNamedAsTheHostWillSeeIt(t *testing.T) { + // Ids are prefixed with their module on the way out. An unprefixed reload-on would name a + // resource the host never sees, and the runtime would never be reloaded for its trust. + got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": reloading{}}}) + if err != nil { + t.Fatal(err) + } + var file, service map[string]any + for _, r := range out { + switch r["type"] { + case "file": + file = r + case "service": + service = r + } + } + if file == nil || service == nil { + t.Fatalf("got %v", out) + } + if file["into"] != "json" { + t.Errorf("into did not reach the host: %v", file) + } + if want := "[" + file["id"].(string) + "]"; fmt.Sprint(service["reload-on"]) != want { + t.Errorf("reload-on names %v, the file is %v", service["reload-on"], file["id"]) + } +} diff --git a/internal/compose/resolution/config_test.go b/internal/compose/resolution/config_test.go new file mode 100644 index 0000000..d731c9d --- /dev/null +++ b/internal/compose/resolution/config_test.go @@ -0,0 +1,181 @@ +package resolution + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// inConfigForm is a module in the config form that registers: every value typed, described, defaulted or +// required, placed in a file or read at runtime, and its mergeable file naming its keys. +func inConfigForm() map[string]any { + return map[string]any{ + "module": "notifier", + "version": "1", + "config": map[string]any{ + "font-size": map[string]any{"type": "number", "description": "the notification's font size, in points", + "minimum": 6, "maximum": 40, "default": 10}, + "width": map[string]any{"type": "number", "description": "the notification's width, in pixels", + "default": 250}, + "position": map[string]any{"type": "string", "description": "the corner it shows in, a word", + "enum": []any{"top-right", "top-left"}, "default": "top-right"}, + "theme": map[string]any{"type": "string", "description": "the colour theme, a lower-case name", + "pattern": "[a-z]+", "default": "dark"}, + "history": map[string]any{"type": "number", "description": "how many notifications it keeps, a count", + "default": 20, "runtime": true}, + "sender": map[string]any{"type": "string", "description": "the address it mails a digest from", + "required": true}, + }, + "resources": []any{ + map[string]any{"id": "configuration", "type": "file", "path": "/x/notifierrc", + "content": "font = Inter ${config:font-size}\nwidth = ${config:width}\nfrom = ${config:sender}\n"}, + map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", + "takes-config": []any{"position", "theme"}, "content": "{\"position\": \"top-right\"}\n"}, + map[string]any{"id": "notifier", "type": "service", "unit": "notifier.service", + "reload-on": []any{"configuration"}, "restart-on": []any{"rules"}}, + }, + } +} + +func parseRaw(t *testing.T, m map[string]any) (mcat.Manifest, error) { + t.Helper() + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return decodeForTest(raw) +} + +// settingsDunst is the dunst module as it is written today; configDunst the same module rewritten from settings to +// config. +const settingsDunst = `{"module": "dunst", "version": "1", + "settings": { + "font-size": {"kind": "preference", "default": 10, "why": "10 points of Inter reads well on a screen of about 100 DPI"}, + "width": {"kind": "preference", "default": 250, "why": "250 pixels fits a title of about forty characters at 10 points"} + }, + "resources": [ + {"id": "package", "type": "package", "package": "dunst"}, + {"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644", + "content": "[global]\n font = Inter ${setting:font-size}\n width = ${setting:width}\n"}, + {"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json", + "takes-settings": [], "content": "{\"urgency\": \"low\"}\n"}, + {"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]} + ]}` + +const configDunst = `{"module": "dunst", "version": "1", + "config": { + "font-size": {"type": "number", "description": "the font size of a notification, in points", "minimum": 6, + "maximum": 40, "default": 10}, + "width": {"type": "number", "description": "the width of a notification, in pixels", "minimum": 100, + "default": 250} + }, + "resources": [ + {"id": "package", "type": "package", "package": "dunst"}, + {"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644", + "content": "[global]\n font = Inter ${config:font-size}\n width = ${config:width}\n"}, + {"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json", + "takes-config": [], "content": "{\"urgency\": \"low\"}\n"}, + {"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]} + ]}` + +// ${config:} fills exactly as ${setting:} does: the module rewritten from settings to config composes +// to the same bytes, with no layer, with the node's, and with the mesh's under the node's. +func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) { + composed := func(manifest string, layers []mcat.Layer) []byte { + t.Helper() + m, err := decodeForTest([]byte(manifest)) + if err != nil { + t.Fatal(err) + } + r := anAdoptedAnchor() + r.Modules = append(r.Modules, m) + with := anchorRendering(false) + if layers != nil { + with.Settings["dunst"] = layers + } + c, err := Compose(r, with) + if err != nil { + t.Fatal(err) + } + if why, left := c.LeftOut["dunst"]; left { + t.Fatalf("dunst was left out: %s", why) + } + raw, err := json.Marshal(c) + if err != nil { + t.Fatal(err) + } + return raw + } + for _, layers := range [][]mcat.Layer{ + nil, + {{From: "anchor", Values: map[string]any{"font-size": float64(13)}}}, + {{From: mcat.MeshWideLayer, Values: map[string]any{"width": float64(300)}}, + {From: "anchor", Values: map[string]any{"font-size": float64(12), "width": float64(320)}}}, + } { + was, is := composed(settingsDunst, layers), composed(configDunst, layers) + if !bytes.Equal(was, is) { + t.Fatalf("composed differently with %v:\nsettings: %s\nconfig: %s", layers, was, is) + } + if !strings.Contains(string(is), "font = Inter 1") || strings.Contains(string(is), "${config:") { + t.Fatalf("not filled: %s", is) + } + } +} + +// A required value nothing sets is refused at composition in the config form's words: ${config:} and the +// config verb, never `settings set` (review of mesh-controller#320). The settings form's words are unchanged. +func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) { + m, err := parseRaw(t, inConfigForm()) + if err != nil { + t.Fatal(err) + } + r := anAdoptedAnchor() + r.Modules = append(r.Modules, m) + composed, err := Compose(r, anchorRendering(false)) + if err != nil { + t.Fatal(err) + } + why, left := composed.LeftOut["notifier"] + if !left { + t.Fatal("a module whose required value nothing sets was not left out") + } + for _, said := range []string{"${config:sender}", `required config value "sender"`, "config verb"} { + if !strings.Contains(why, said) { + t.Fatalf("left out without %q: %s", said, why) + } + } + for _, unsaid := range []string{"${setting:", "settings set"} { + if strings.Contains(why, unsaid) { + t.Fatalf("left out in the settings form's words (%q): %s", unsaid, why) + } + } + var unset *UnsetSettingError + if err := unsetInConfigWords(settingInto(map[string]any{"type": "file", "content": "${setting:sender}"}, nil, + "notifier"), m); !errors.As(err, &unset) || unset.Setting != "sender" { + t.Fatalf("not typed as a value nobody gave: %v", err) + } + + // A contribution's value too (issue 1126, seed-8: de-spiegel's route names were refused with ${setting:…}). + withRoute := inConfigForm() + withRoute["contributes"] = map[string]any{"route": map[string]any{"site": map[string]any{"name": "${config:sender}", "port": 80}}} + routed, err := parseRaw(t, withRoute) + if err != nil { + t.Fatal(err) + } + _, err = resolutionComposed(mcat.Resolution{}, routed, "route", routed.ContributesMany["route"]["site"], nil, "notifier contributing site to route") + if !errors.As(err, &unset) || unset.Module != "notifier" || !strings.Contains(err.Error(), + `notifier has a contribution site to route, in "name", that says ${config:sender}`) || strings.Contains(err.Error(), "${setting:") { + t.Fatalf("a contribution's unset value is not said in the config form's words: %v", err) + } + + settingsForm := notifier() + settingsForm.Resources[0]["content"] = "from = ${setting:domain}\n" + err = unsetInConfigWords(settingInto(settingsForm.Resources[0], nil, "notifier"), settingsForm) + if err == nil || !strings.Contains(err.Error(), "`settings set notifier `") { + t.Fatalf("the settings form's refusal changed: %v", err) + } +} diff --git a/internal/compose/resolution/consumer_into_serves.go b/internal/compose/resolution/consumer_into_serves.go new file mode 100644 index 0000000..abaaf55 --- /dev/null +++ b/internal/compose/resolution/consumer_into_serves.go @@ -0,0 +1,267 @@ +package resolution + +import ( + "fmt" + "regexp" + "sort" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// consumerFact is `${consumer:}` or `${consumer::}`. +var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`) + +// consumerFacts are what a served value may name about the consumer it is being derived for. +// One entry, deliberately: the identity is the one thing about a consumer the mesh itself chose, +// so it is the one thing the mesh can hand to a provider without either end guessing. +var consumerFacts = []string{"as"} + +// consumerAlphabets are the ways the mesh will write that identity. `dns` is the mesh's own +// identifier with its separator written `-` instead of `_` — the whole of the difference between +// the alphabet the mesh mints in and the one buckets, vhosts and hostnames accept. +var consumerAlphabets = []string{"dns"} + +// ServedTo fills a provider's served values for one consumer. +// +// `as` is the identity the mesh minted for that consumer — the same string it is told to present +// as a login. Values with no placeholder are returned exactly as they were, and a block with no +// placeholder at all is returned unchanged, so this costs nothing for the providers that derive +// nothing. +// +// Only strings carry placeholders. A number, a boolean or a nested object is a value the provider +// stated outright, and is left alone. +func ServedTo(serves map[string]any, as string) (map[string]any, error) { + if len(serves) == 0 { + return serves, nil + } + var out map[string]any + for _, key := range sortedAnyKeys(serves) { + text, ok := serves[key].(string) + if !ok || !strings.Contains(text, "${consumer:") { + continue + } + filled, err := consumerInto(text, as) + if err != nil { + return nil, fmt.Errorf("the value served as %q: %w", key, err) + } + if out == nil { + // Copied only once something actually changes: the caller's map is the manifest's, + // and a provider that derives nothing must not have it rewritten underneath it. + out = make(map[string]any, len(serves)) + for k, v := range serves { + out[k] = v + } + } + out[key] = filled + } + if out == nil { + return serves, nil + } + return out, nil +} + +// consumerInto replaces every `${consumer:…}` in one value. +// +// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through, +// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name, +// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}` +// is refused by (boundInto). +func consumerInto(value, as string) (string, error) { + var failed error + out := consumerFact.ReplaceAllStringFunc(value, func(match string) string { + parts := consumerFact.FindStringSubmatch(match) + fact, alphabet := parts[1], parts[2] + if fact != "as" { + if failed == nil { + failed = fmt.Errorf( + "says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts)) + } + return match + } + switch alphabet { + case "": + return as + case "dns": + return asDNSLabel(as) + default: + if failed == nil { + failed = fmt.Errorf( + "says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets)) + } + return match + } + }) + if failed != nil { + return "", failed + } + return out, nil +} + +// asDNSLabel writes a minted identity as a DNS label. +// +// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and +// already inside the bound of the provision serving them: a provider that serves one as a DNS label +// bounds it at 63 or less (CheckServes, novox/hq ADR 0225), and one that serves it at all without +// saying is held to twenty (IdentityBoundOf). So +// this is the separator and nothing else — no lower-casing of what is already lower case, no +// truncation to a limit the identity is already inside, no padding of a name that is already long +// enough. Each of those would be the mesh guessing at a rule it has not been given. +func asDNSLabel(as string) string { + return strings.ReplaceAll(as, "_", "-") +} + +func sortedAnyKeys(values map[string]any) []string { + out := make([]string, 0, len(values)) + for k := range values { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// derivedFor is what the provider on this machine derives for one consumer of one provision +// (novox/hq ADR 0202). +// +// Settled first, then derived: an operator may set a prefix on what the provider serves and the +// mesh still fills the consumer's half of it ([ADR 0174]). Only the keys that actually name the +// consumer are returned — the rest of a `serves` block is the same for every consumer and is +// already in the provider's own definition, so repeating it here would be a second copy to go +// stale. +// +// The first module in the resolved order that says it serves the provision answers, which is the +// choice servedOnThisMachine makes for the consumer's half. Nothing serving it on this machine is +// not an error: a contribution can reach a machine whose provider is a record or an adapter, and +// then there is nothing derived to tell. +func resolutionDerivedFor(r mcat.Resolution, provision, as, consumer, local string, settings mcat.SettingsBy) (map[string]any, error) { + for _, m := range r.Modules { + serves, said := m.Serves[provision] + if !said { + continue + } + var names map[string]any + for key, value := range serves { + if text, ok := value.(string); ok && strings.Contains(text, "${consumer:") { + if names == nil { + names = map[string]any{} + } + names[key] = value + } + } + if names == nil { + return nil, nil + } + // **A consumer that keeps several holders of this provision is refused** — this is issue + // 124's own failure one case to the side, and it would be just as quiet. + // + // Each holder gets its own login, `…_` (ADR 0094), and a provider derives from the + // login, so it would make one resource per holder. The consumer's side has no such + // dimension: one binding file per provision, one `${bound::}`, both + // derived from the un-suffixed identity. So the provider would create the holder's + // resource and the consumer would be configured against a name nothing made — it would + // authenticate successfully and be refused on every object, which reads like a credential + // fault and is not one. + // + // Lifting this means giving the consumer's side a local dimension. That is a decision, + // not an omission, and until it is taken the mesh says so rather than guessing. + if local != "" { + return nil, fmt.Errorf( + "%s keeps several holders of %s (this one is %q), and %s derives %s for each "+ + "consumer from the login the mesh minted. Each holder has its own login, and a "+ + "consumer is told one value per requirement — so the two ends would name "+ + "different things and nothing would compare them (novox/hq ADR 0202)", + consumer, local, provision, m.Module, orNothing(sortedAnyKeys(names))) + } + settled, err := Settle(names, WithDefaults(m, settings[m.Module])) + if err != nil { + return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err) + } + derived, err := ServedTo(settled, as) + if err != nil { + return nil, fmt.Errorf("%s serving %s to %s: %w", m.Module, provision, as, err) + } + return derived, nil + } + return nil, nil +} + +// notTranscribed refuses a consumer's file that writes out the value its provider derives for it, +// instead of asking for it (novox/hq ADR 0202, issue 124). +// +// **What would have caught the one wrong instance.** The object store's three consumers each wrote +// their bucket into their own configuration by hand. One of them named a predecessor's bucket, and +// nothing compared it to what the provider would actually create: the module would have +// authenticated successfully and been refused on every object, which reads like a credential fault +// and is not one. It looked authoritative for months. +// +// The test is exact and costs one string search: a definition whose file already contains the +// value the mesh is about to derive for it has written down somebody else's rule. It cannot be a +// coincidence — a derived value carries the identity the mesh minted for this very consumer on +// this very machine, which nothing else would spell out — and it cannot be checked afterwards, +// because after substitution every consumer's file contains it legitimately. +// +// Only values that actually name the consumer are judged. A provider that serves a constant under +// the same key serves the same constant to everyone, and a consumer repeating it is redundant +// rather than wrong. +func notTranscribed(resource map[string]any, known map[string]map[string]string, module string) error { + if fmt.Sprint(resource["type"]) != "file" { + return nil + } + content, ok := resource["content"].(string) + if !ok || content == "" { + return nil + } + for _, provision := range sortedKnown(known) { + values := known[provision] + identity := values["as"] + if identity == "" { + continue + } + for _, key := range sortedStringKeys(values) { + if key == "as" { + // The login is not derived from itself, and a consumer that must present it in a + // connection string legitimately has it from `${bound:…}` — which is what it will + // be after substitution, so this would judge the substitution, not the module. + continue + } + value := values[key] + if value == "" || !namesTheConsumer(value, identity) { + continue + } + if !strings.Contains(content, value) { + continue + } + return fmt.Errorf( + "%s writes %q into %v, and that is exactly what %s derives for it — a definition "+ + "keeping its own copy of somebody else's naming rule is one that can disagree "+ + "with it, silently. Say ${bound:%s:%s} and be told", + module, value, resource["id"], provision, provision, key) + } + } + return nil +} + +// namesTheConsumer is whether a derived value was built from this consumer's identity — in the +// alphabet it was minted in, or as a DNS label. A value that does not contain it was not derived +// from it, whatever else it may be. +func namesTheConsumer(value, identity string) bool { + return strings.Contains(value, identity) || strings.Contains(value, asDNSLabel(identity)) +} + +func sortedKnown(known map[string]map[string]string) []string { + out := make([]string, 0, len(known)) + for k := range known { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func sortedStringKeys(values map[string]string) []string { + out := make([]string, 0, len(values)) + for k := range values { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/compose/resolution/consumers.go b/internal/compose/resolution/consumers.go new file mode 100644 index 0000000..b74e74f --- /dev/null +++ b/internal/compose/resolution/consumers.go @@ -0,0 +1,100 @@ +package resolution + +import ( + "fmt" + "regexp" + "slices" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// ofConsumers is where a provider places its consumers' credentials. Loose inside the braces, so a misspelt name is +// refused rather than passed over. +var ofConsumers = regexp.MustCompile(`\$\{consumers:([^}]*)\}`) + +// consumersPlaceholderProblems is what is wrong with one resource's ${consumers:…}: placed only in a file's content, +// for a provision the module provides, in a file only the provider reads and the client writes back. +func consumersPlaceholderProblems(m mcat.Manifest, r map[string]any) []string { + var problems []string + for _, field := range sortedKeys(r) { + v, ok := r[field].(string) + if !ok { + continue + } + for _, f := range ofConsumers.FindAllStringSubmatch(v, -1) { + switch { + case field != "content" || fmt.Sprint(r["type"]) != "file": + problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a provider's consumers "+ + "are placed only in a file's content (novox/hq ADR 0373)", m.Module, r["id"], f[0], field)) + continue + case !slices.ContainsFunc(m.Provides, func(o mcat.Offer) bool { return o.Name == f[1] }): + problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not provide %q: only a "+ + "provider's template holds its consumers' credentials (novox/hq ADR 0373)", m.Module, r["id"], f[0], + m.Module, f[1])) + } + if mode := fmt.Sprint(r["mode"]); mode != "0600" && mode != "0400" { + problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) with "+ + "mode %q: the provider alone reads it, 0600 or 0400 (novox/hq ADR 0373)", m.Module, r["id"], f[0], + r["mode"])) + } + if owner := ownershipOf(r); owner != mcat.RuntimeOwned { + problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) and is "+ + "%s at runtime: it is owned, written back whenever it differs (novox/hq ADR 0373)", m.Module, + r["id"], f[0], owner)) + } + } + } + return problems +} + +// consumersHandedOnly says what a file naming ${consumers:…} is where it would be written the old way: the provisioner's +// client alone fills it, so on a machine that does not hand the module it is **left out** of the declaration, never +// written as that text or as an empty list the provider would read as no consumers, and never the reason the module is +// refused: a provider not handed there keeps working as it did, from its grant files (novox/hq ADR 0373, issue 780). +// A moved module judged from its repository's tree (WithTemplatesFrom: `module check`), on a machine +// that would hand it, has no templates archive to hand and is judged as it composes; there the placeholder is judged +// and taken out of the text, since what is composed so is never sent (judged answers whether the file is so judged). +func consumersHandedOnly(m mcat.Manifest, r map[string]any, with mcat.Rendering) (judged, leftOut bool) { + content, _ := r["content"].(string) + switch { + case ofConsumers.FindString(content) == "": + return false, false + case m.Moved && m.Handed == nil && with.Hands && with.Provisioner != "": + return true, false + } + return false, true +} + +// consumersLeftOutLine is the plan's one line for a file left out because the module is not handed. +func consumersLeftOutLine(m mcat.Manifest, r map[string]any) string { + return fmt.Sprintf("%s's %v names ${consumers:…}, which the provisioner's client alone fills: left out, handed only "+ + "(novox/hq ADR 0373)", m.Module, r["id"]) +} + +// consumersNotHanded is consumersHandedOnly as a refusal, for a caller that has no way to leave the file out. +func consumersNotHanded(m mcat.Manifest, r map[string]any, with mcat.Rendering) (judged bool, err error) { + judged, left := consumersHandedOnly(m, r, with) + if left { + return false, fmt.Errorf("%s's resource %v names %s, which the provisioner's client alone fills, and %s is not "+ + "handed to it on this machine: it would be written as that text, or empty, and the provider would read no "+ + "consumers (novox/hq ADR 0373)", m.Module, r["id"], ofConsumers.FindString(fmt.Sprint(r["content"])), m.Module) + } + return judged, nil +} + +// consumersOfTemplate is what is wrong with a handed file's template naming ${consumers:…}, judged on the file's +// resource as if its text were inline. +func consumersOfTemplate(m mcat.Manifest, id, text string) []string { + for _, r := range m.Resources { + if fmt.Sprint(r["id"]) != id { + continue + } + withText := map[string]any{} + for k, v := range r { + withText[k] = v + } + withText["content"] = text + return consumersPlaceholderProblems(m, withText) + } + return nil +} diff --git a/internal/compose/resolution/contributes_test.go b/internal/compose/resolution/contributes_test.go new file mode 100644 index 0000000..44f9b9c --- /dev/null +++ b/internal/compose/resolution/contributes_test.go @@ -0,0 +1,311 @@ +package resolution + +import ( + "encoding/json" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func published(module, host string, port int) mcat.Manifest { + return mcat.Manifest{Module: module, Version: "1", + Contributes: map[string]map[string]any{ + "reverse-proxy": {"host": host, "port": port}, + }} +} + +func proxy() mcat.Manifest { + return mcat.Manifest{Module: "traefik", Version: "1", + Provides: mcat.Offers("reverse-proxy"), + Receives: map[string]string{"reverse-proxy": "/etc/traefik/mesh.json"}, + Resources: []map[string]any{ + {"id": "up", "type": "service", "unit": "traefik", "state": "running", + "restart-on": []any{mcat.ReceivedID("reverse-proxy")}}, + }} +} + +// received digs the delivered contributions back out of a declaration. +func received(t *testing.T, out []map[string]any) []mcat.Contribution { + t.Helper() + for _, r := range out { + if r["path"] != "/etc/traefik/mesh.json" { + continue + } + body := r["content"].(string) + var parsed struct { + Requirement string `json:"requirement"` + Given []mcat.Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(body), &parsed); err != nil { + t.Fatalf("the file the proxy is given is not readable: %v\n%s", err, body) + } + if parsed.Requirement != "reverse-proxy" { + t.Fatalf("the file does not say what it is about: %q", parsed.Requirement) + } + return parsed.Given + } + t.Fatalf("the provider was given no file at all: %v", out) + return nil +} + +func TestTheFileTheProviderGetsIsMachineReadable(t *testing.T) { + // It is written for a program, and the first version put a `//` header above the JSON — a + // file that says "do not edit" to a person and fails to parse for the thing meant to read it. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + for _, r := range mustDeclare(t, got) { + if r["path"] != "/etc/traefik/mesh.json" { + continue + } + var any map[string]any + if err := json.Unmarshal([]byte(r["content"].(string)), &any); err != nil { + t.Fatalf("the file is not parseable: %v\n%s", err, r["content"]) + } + if note, _ := any["generated"].(string); !strings.Contains(note, "do not edit") { + // Inside the document rather than above it, so it reaches a person without + // breaking the parse. + t.Fatalf("the file does not say it is generated: %v", any["generated"]) + } + return + } + t.Fatal("no received file") +} + +func TestAModuleTellsItsProviderWhatItNeeds(t *testing.T) { + got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + if err != nil { + t.Fatal(err) + } + out, err := Declaration(got, mcat.Rendering{}) + if err != nil { + t.Fatal(err) + } + given := received(t, out) + if len(given) != 1 || given[0].From != "board" { + t.Fatalf("the proxy was not told about board: %v", given) + } + if given[0].Values["host"] != "board" || given[0].Values["port"] != float64(8080) { + t.Fatalf("the contribution did not survive: %v", given[0].Values) + } +} + +func TestContributingToSomethingIsRequiringIt(t *testing.T) { + // Asking to be published means a publisher must exist. A module that had to say both would + // eventually say one, and the failure would be a machine where nothing serves the route. + got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(names(got), " "), "traefik") { + t.Fatalf("contributing to reverse-proxy did not bring one in: %v", names(got)) + } +} + +func TestNothingToContributeToIsRefused(t *testing.T) { + _, err := Resolve(shelf(published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + if err == nil { + t.Fatal("a module was published through a proxy that does not exist") + } + if !strings.Contains(err.Error(), "reverse-proxy") { + t.Fatalf("the refusal does not name what is missing: %v", err) + } +} + +func TestEveryPublisherOnTheMachineIsInOneFile(t *testing.T) { + got, err := Resolve(shelf(proxy(), + published("board", "board", 8080), + published("archive", "archive", 9000), + ), []string{"board", "archive"}, workstation(), mcat.World{}) + + if err != nil { + t.Fatal(err) + } + given := received(t, mustDeclare(t, got)) + if len(given) != 2 { + t.Fatalf("the proxy was told about %d of 2: %v", len(given), given) + } + // Ordered by module, because this becomes a file and a file whose lines move about looks + // changed when nothing changed — which would restart the proxy for ever. + if given[0].From != "archive" || given[1].From != "board" { + t.Fatalf("the order is not stable: %v", given) + } +} + +func TestAProviderWithNoConsumersStillGetsTheFile(t *testing.T) { + // Empty rather than absent. A provider that finds no file cannot tell "nothing asked for me" + // from "the mesh never wrote it", and those want completely different responses — the same + // rule the host follows about a service that does not exist. + got, err := Resolve(shelf(proxy()), []string{"traefik"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + if given := received(t, mustDeclare(t, got)); len(given) != 0 { + t.Fatalf("got %v", given) + } +} + +func TestTheProviderCanReloadWhenTheRoutesChange(t *testing.T) { + // A proxy that got a new route and did not reload is a route that silently does not work. + // The same fault the private network had when a peer list changed under a running interface, + // which is why the received file has a name a module can point at. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + out := mustDeclare(t, got) + for _, r := range out { + if r["type"] != "service" { + continue + } + reflects, ok := r["restart-on"].([]any) + if !ok || len(reflects) != 1 { + t.Fatalf("the proxy does not reflect anything: %v", r) + } + if reflects[0] != "traefik."+mcat.ReceivedID("reverse-proxy") { + t.Fatalf("it reflects %v, which is not the file it was given", reflects[0]) + } + return + } + t.Fatal("no service in the declaration") +} + +func TestARouteCanBeSetPerMesh(t *testing.T) { + // The hostname is exactly the kind of thing that differs between one mesh and the next. A + // module whose route could not be set would have to be edited to be reused anywhere. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + out, err := Declaration(got, mcat.Rendering{Settings: mcat.SettingsBy{ + "board": {{From: "the mesh", Values: map[string]any{"host": "dashboard"}}}, + }}) + if err != nil { + t.Fatal(err) + } + given := received(t, out) + if given[0].Values["host"] != "dashboard" { + t.Fatalf("the setting did not reach the route: %v", given[0].Values) + } + if given[0].Values["port"] != float64(8080) { + t.Fatalf("setting the host dropped the port: %v", given[0].Values) + } +} + +func TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) { + // novox/hq 04-ISSUES/173: the mail module's site name, set so its environment file could read + // it, arrived in every route it contributed. A setting overrides a key the contribution + // declares and adds none — the provider reads the contribution as a contract. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + out, err := Declaration(got, mcat.Rendering{Settings: mcat.SettingsBy{ + "board": {{From: "the mesh", Values: map[string]any{"host": "dashboard", "sitename": "Board"}}}, + }}) + if err != nil { + t.Fatal(err) + } + given := received(t, out) + if given[0].Values["host"] != "dashboard" { + t.Fatalf("the setting did not override the route's host: %v", given[0].Values) + } + if _, leaked := given[0].Values["sitename"]; leaked { + t.Fatalf("a setting the route never declared reached the proxy: %v", given[0].Values) + } +} + +func provider() mcat.Manifest { + return mcat.Manifest{Module: "postgres", Version: "1", + Provides: mcat.FromAnywhere("postgres-database"), + Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"}, + Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"}, + } +} + +func grantedTo(t *testing.T, out []map[string]any) []mcat.Contribution { + t.Helper() + for _, r := range out { + if r["path"] != "/var/lib/postgres/grants/mesh.json" { + continue + } + var parsed struct { + Given []mcat.Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil { + t.Fatal(err) + } + return parsed.Given + } + t.Fatalf("the provider was given no manifest: %v", out) + return nil +} + +func TestAConsumersOwnContributionsAreStillThere(t *testing.T) { + // Cross-node grants are merged in with this machine's own, because from the provider's side + // they are the same thing — somebody wanting something — and a provider that had to read two + // lists would read one of them. + got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), + []string{"board"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + given := received(t, mustDeclare(t, got)) + if len(given) != 1 || given[0].Node != "" { + t.Fatalf("a local contribution grew a node: %v", given) + } +} + +func TestARequireOnlyConsumerOfAParameterlessProvisionStillAsks(t *testing.T) { + // A consumer that requires a parameterless provision (one whose serves names no consumer key — + // redis-cache, amqp) contributes no payload, but it still ASKS for the provision and must be + // granted a credential. Keying "asks" on contributions alone gave its grant From="" — read as + // withdrawn — so the provider never created the account and the consumer authenticated nowhere. + r := mcat.Resolution{ + Node: "laptop", + Modules: []mcat.Manifest{{ + Module: "amqp-ping", + Requires: []string{"amqp"}, + }}, + } + values, asks, err := ContributionsFrom(r, "amqp", "amqp-ping", mcat.SettingsBy{}) + if err != nil { + t.Fatal(err) + } + if !asks { + t.Fatal("a require-only consumer was treated as not asking; its grant would be withdrawn and it would never be provisioned") + } + if values == nil { + t.Fatalf("asks is true but values is nil; expected an empty contribution, got %v", values) + } +} + +func TestAConsumerThatStoppedAskingIsWithdrawn(t *testing.T) { + // Withdrawal is how a credential is taken away. The provisioner removes what nobody asks for, + // and it can only do that if the mesh stops asking — a consumer that was unassigned would + // otherwise keep a working login for ever, and nothing would say so. + // + // A grant with no asking module is exactly that state: the mesh still holds the secret, + // because it is sealed and unusable to the mesh anyway, and the machine no longer wants it. + got, err := Resolve(shelf(provider()), []string{"postgres"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(got, mcat.Rendering{Grants: []mcat.Grant{ + {Provision: "postgres-database", Consumer: "still-here", From: "meshboard", + Values: map[string]any{"name": "meshboard"}, Sealed: "c2VhbGVk"}, + {Provision: "postgres-database", Consumer: "gone-away", Sealed: "c3RhbGU="}, + }}) + if err != nil { + t.Fatal(err) + } + + given := grantedTo(t, out) + if len(given) != 1 || given[0].Node != "still-here" { + t.Fatalf("the provider is still told about a machine that stopped asking: %v", given) + } + // And no credential is written for it either, or the provisioner would find a file for a + // consumer its manifest does not mention and have to guess what that means. + for _, r := range out { + if path, _ := r["path"].(string); strings.Contains(path, "gone-away") { + t.Fatalf("a withdrawn consumer's credential is still delivered: %v", r) + } + } +} diff --git a/internal/compose/resolution/contribution_dependencies_test.go b/internal/compose/resolution/contribution_dependencies_test.go new file mode 100644 index 0000000..10fd91d --- /dev/null +++ b/internal/compose/resolution/contribution_dependencies_test.go @@ -0,0 +1,58 @@ +package resolution + +import ( + "reflect" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) { + env := mod("theme", nil, nil, nil) + env.Environment = &mcat.Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}} + path := mod("toolchain", nil, nil, nil) + path.Environment = &mcat.Environment{Path: []mcat.PathEntry{{Entry: "/opt/x/bin"}}} + shell := mod("prompt", nil, nil, nil) + shell.Shell = []mcat.ShellCode{{For: "zsh", Slot: "first", Code: "true"}} + session := mod("wallpaper", nil, nil, nil) + session.Shell = []mcat.ShellCode{{For: "xinitrc", Slot: "normal", Code: "true"}} + resources := mod("bar", nil, nil, nil) + resources.Shell = []mcat.ShellCode{{For: "xresources", Slot: "normal", Code: "x: y"}} + empty := mod("nothing", nil, nil, nil) + empty.Environment = &mcat.Environment{} + + cases := map[string]struct { + m mcat.Manifest + want []string + }{ + "a variable": {env, []string{mcat.EnvironmentSeat}}, + "a path entry": {path, []string{mcat.EnvironmentSeat}}, + "shell code": {shell, []string{mcat.LoginShellSeat}}, + "the session's start": {session, []string{mcat.DisplayServerSeat}}, + "the session's X resources": {resources, []string{mcat.DisplayServerSeat}}, + "an empty environment": {empty, nil}, + } + for name, c := range cases { + got := DependsOn(nil, c.m) + if len(got) == 0 { + got = nil + } + if !reflect.DeepEqual(got, c.want) { + t.Errorf("%s depends on %v, want %v", name, got, c.want) + } + } +} + +func TestAHolderMeetsItsOwnContribution(t *testing.T) { + // The display server's module contributes nothing to its own seat today, but the zsh module's + // environment contributions do go to another seat: a claim meets only the seat it names. + zsh := mod("zsh", nil, nil, nil, mcat.Claim{Name: mcat.LoginShellSeat}) + zsh.Shell = []mcat.ShellCode{{For: "zsh", Slot: "normal", Code: "true"}} + zsh.Environment = &mcat.Environment{Variables: map[string]string{"EDITOR": "vim"}} + catalogue := map[string]mcat.Manifest{"zsh": zsh, + "node-env": mod("node-env", nil, nil, nil, mcat.Claim{Name: mcat.EnvironmentSeat})} + unheld := UnheldDependencies(catalogue, "laptop", []mcat.Manifest{zsh}, nil) + if len(unheld) != 1 || unheld[0].Seat != mcat.EnvironmentSeat { + t.Errorf("zsh alone: unheld %v, want only %s (its shell code is its own seat's)", unheld, mcat.EnvironmentSeat) + } +} diff --git a/internal/compose/resolution/control_records_seats.go b/internal/compose/resolution/control_records_seats.go new file mode 100644 index 0000000..d3b53f1 --- /dev/null +++ b/internal/compose/resolution/control_records_seats.go @@ -0,0 +1,52 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// controlRecordsDecision is the record that made the two seats. +const controlRecordsDecision = "novox/hq ADR 0565" + +// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15). +func wantsVerbs() []mcat.Verb { + return []mcat.Verb{ + {Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " + + "and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " + + "rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " + + "with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.", + Input: schema(map[string]string{ + "module": "one module", + "policy": "with module: roll, together, record, or default to take a person's choice back", + "why": "with policy: why, required and kept", + "compare": "\"true\": each node's own composition, by its digest, and the assigner's findings", + }, nil, "compare")}, + {Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " + + "indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " + + "the step itself is the put on the operator's warrant.", + Input: schema(map[string]string{}, nil)}, + } +} + +// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them. +func handActsVerbs() []mcat.Verb { + return []mcat.Verb{ + {Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " + + "(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " + + "runtime proves, never said by the caller. Said as act-recorded.", + Input: schema(map[string]string{ + "what": "what was done, in a line", + "why": "why it had to be done by hand", + "cause": "the cause in a word, or a condition's kind", + "condition": "the key of the condition it addressed, if any (optional)", + "verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own", + }, []string{"what", "why"})}, + {Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " + + "cause of each, and the causes recorded twice within a fortnight (a healer is wanted).", + Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause", + "who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")}, + {Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " + + "with which proofs and which answer read from the router's own record of that module's ask, never from the " + + "caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.", + Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})}, + } +} diff --git a/internal/compose/resolution/core_credentials.go b/internal/compose/resolution/core_credentials.go new file mode 100644 index 0000000..6b667f3 --- /dev/null +++ b/internal/compose/resolution/core_credentials.go @@ -0,0 +1,19 @@ +package resolution + +import ( + "encoding/json" + "slices" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// CarriesVaultSealed says whether module's own secrets are the core's credentials. +func CarriesVaultSealed(module string) bool { + return slices.Contains(mcat.CoreCredentialModules, module) +} + +// vaultSealedOf is the vault's box for one of module's own secrets, if the rendering was given one. +func renderingVaultSealedOf(with mcat.Rendering, module, name string) (json.RawMessage, bool) { + box, ok := with.VaultSealed[module][name] + return box, ok && len(box) > 0 +} diff --git a/internal/compose/resolution/core_credentials_test.go b/internal/compose/resolution/core_credentials_test.go new file mode 100644 index 0000000..6c7ffd2 --- /dev/null +++ b/internal/compose/resolution/core_credentials_test.go @@ -0,0 +1,24 @@ +package resolution + +import ( + "reflect" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func TestTheCoresOwnCredentialsAreTheBusTheProvisionerAndItsClient(t *testing.T) { + if want := []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}; !reflect.DeepEqual(mcat.CoreCredentialModules, want) { + t.Fatalf("the core's modules are %v, want %v (mesh-vault's CoreModules is the same list)", mcat.CoreCredentialModules, want) + } + for _, m := range []string{"nats", "mesh-provisioner", "mesh-provisioner-client"} { + if !CarriesVaultSealed(m) { + t.Errorf("%s is not on the list", m) + } + } + for _, m := range []string{"postgres", "mesh-vault", "mesh-controller", ""} { + if CarriesVaultSealed(m) { + t.Errorf("%s is on the list", m) + } + } +} diff --git a/internal/compose/resolution/data.go b/internal/compose/resolution/data.go new file mode 100644 index 0000000..1d81163 --- /dev/null +++ b/internal/compose/resolution/data.go @@ -0,0 +1,77 @@ +package resolution + +import ( + "fmt" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// derivedContributions is what a module's data section gives the backup holder: its backup lines and +// its items. Every item is listed, so a valuable one on a machine is measured whether or not it is +// copied; a cache is listed and not measured. +func manifestDerivedContributions(m mcat.Manifest) []mcat.SeatContribution { + items := m.DataItems() + if len(items) == 0 { + return nil + } + var lines []string + kept := map[string]bool{} + keep := func(path string) { + if !kept[path] { + kept[path] = true + lines = append(lines, "path "+path) + } + } + for _, it := range items { + if !it.BackedUp() { + continue + } + if it.Backup.IsDump() { + lines = append(lines, "run "+strings.TrimSpace(it.Backup.Dump)) + if into, ok := m.DataItem(it.Backup.Into); ok { + keep(into.Path) + } + continue + } + keep(it.Path) + } + var described []string + for _, it := range items { + covered := "-" + switch { + case it.Backup.IsDump(): + if into, ok := m.DataItem(it.Backup.Into); ok { + covered = into.Path + } + case it.BackedUp(): + covered = it.Path + } + described = append(described, fmt.Sprintf("item %s %s %s %s %s %s", it.ID, it.Class, it.Path, covered, + it.Protection(), it.MeasuredBy())) + } + var out []mcat.SeatContribution + if len(lines) > 0 { + out = append(out, mcat.SeatContribution{Seat: mcat.BackupSeat, Kind: mcat.BackupKindBackup, Content: strings.Join(lines, "\n") + "\n"}) + } + return append(out, mcat.SeatContribution{Seat: mcat.BackupSeat, Kind: mcat.BackupKindData, Content: strings.Join(described, "\n") + "\n"}) +} + +// allContributions is every contribution a module makes to a seat's holder: what it wrote, and what +// its data section derives. **One list**: a module that declares its data has its backup lines +// composed from it, and a backup line it still writes by hand is not placed — the catalogue check +// refuses it — so the holder never copies two lists that disagree. +func manifestAllContributions(m mcat.Manifest) []mcat.SeatContribution { + if m.Data == nil { + return m.Contributions + } + derived := manifestDerivedContributions(m) + out := make([]mcat.SeatContribution, 0, len(m.Contributions)+len(derived)) + for _, c := range m.Contributions { + if s, known := mcat.SeatNamed(c.Seat); known && s.Name == mcat.BackupSeat { + continue + } + out = append(out, c) + } + return append(out, derived...) +} diff --git a/internal/compose/resolution/data_placed.go b/internal/compose/resolution/data_placed.go new file mode 100644 index 0000000..d1a17ee --- /dev/null +++ b/internal/compose/resolution/data_placed.go @@ -0,0 +1,31 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// DataItems is every data item the resolution's modules declare, placed on its node with the rendering given: the text +// the node-backup holder is handed, read. A module left out of the node's declaration keeps its items, as its data is +// still on the node (ADR 0262); an item that cannot be placed is said in unplaced and left out. +func DataItems(r mcat.Resolution, with mcat.Rendering) (items map[string][]mcat.PlacedDataItem, unplaced []string, err error) { + left := LeftOut(r, with.Settings, with.Adopted) + for module, why := range with.NotPlaced { + if _, already := left[module]; !already { + left[module] = why + } + } + modules := make([]mcat.Manifest, 0, len(r.Modules)) + for _, m := range r.Modules { + if _, isLeft := left[m.Module]; isLeft { + m = backupView(m) + } + modules = append(modules, m) + } + text, unplaced, err := seatContributions(modules, mcat.Manifest{}, mcat.BackupSeat+":"+mcat.BackupKindData, with, + machineFacts(r, with.Names, with.MeshRange), r.Capabilities) + if err != nil { + return nil, unplaced, err + } + items, err = mcat.ParseDataItems(text) + return items, unplaced, err +} diff --git a/internal/compose/resolution/data_test.go b/internal/compose/resolution/data_test.go new file mode 100644 index 0000000..29731f7 --- /dev/null +++ b/internal/compose/resolution/data_test.go @@ -0,0 +1,16 @@ +package resolution + +import ( + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func mustParse(t *testing.T, raw string) mcat.Manifest { + t.Helper() + m, err := decodeForTest([]byte(raw)) + if err != nil { + t.Fatalf("refused: %v", err) + } + return m +} diff --git a/internal/compose/resolution/declaration.go b/internal/compose/resolution/declaration.go new file mode 100644 index 0000000..3a774e3 --- /dev/null +++ b/internal/compose/resolution/declaration.go @@ -0,0 +1,2522 @@ +package resolution + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// rootInto composes a user's `root` for the node-engine: taken away when it asserts nothing (empty — a +// machine where agents run as the operator) or when the engine is older than the field and parses +// strictly; kept as "never" otherwise. +func rootInto(resource map[string]any, with mcat.Rendering) { + if resource["type"] != "user" { + return + } + value, has := resource[mcat.RootField] + if !has { + return + } + if s, _ := value.(string); s == "" || !with.JudgesRoot { + delete(resource, mcat.RootField) + } +} + +// machinePort is where a module's port lives on this machine, or the port itself when the mesh has +// not been asked. Unassigned is not an error here: a module with no `listens` never needed one, +// and a caller composing a declaration without a store still gets something coherent. +func renderingMachinePort(r mcat.Rendering, module string, wanted int) int { + if at, given := r.Given[module][wanted]; given { + return at + } + if at, known := r.Ports[module][wanted]; known { + return at + } + return wanted +} + +// Declaration is everything the resolved modules put on the node, with settings applied. +// +// Resource identities are prefixed with the module they came from. Two modules may reasonably +// both call something "config", and without this the second would silently replace the first — +// the node applying one of them and reporting success. +func Declaration(r mcat.Resolution, with mcat.Rendering) ([]map[string]any, error) { + composed, err := Compose(r, with) + if err != nil { + return nil, err + } + return composed.Resources, nil +} + +// NotMadeError is a module's own secret the composition was given no value for (novox/hq issue 275): +// typed, so that a caller can tell "a send would make this, and none has yet" from a composition that +// fails for any other reason without reading the words. +type NotMadeError struct { + Module, Name string +} + +func (e *NotMadeError) Error() string { + return fmt.Sprintf("%s needs a secret called %q and none was made for it", e.Module, e.Name) +} + +// LeftOut is which of this machine's modules a declaration composed with these settings leaves +// out, and why (novox/hq ADR 0163, rule 6): each whose stored settings its definition can no longer +// compose. Empty when every module composes. The same judgement SetSettings makes before storing. +func LeftOut(r mcat.Resolution, settings mcat.SettingsBy, adopted bool) map[string]string { + out := map[string]string{} + for module, why := range LeftOutBecause(r, settings, adopted) { + out[module] = why.Error() + } + return out +} + +// LeftOutBecause is LeftOut with each reason as the error it was, so a reader can tell a setting nobody +// gave (an *UnsetSettingError) from any other cause and say it as the operator's to give (novox/hq issue +// 380). An UnreadManifestError for a stored manifest this controller cannot read whole. +func LeftOutBecause(r mcat.Resolution, settings mcat.SettingsBy, adopted bool) map[string]error { + out := map[string]error{} + for _, m := range r.Modules { + if why := UnknownFieldReason(m); why != "" { + out[m.Module] = &UnreadManifestError{Module: m.Module, said: why} + continue + } + if err := JudgeSettings(m, settings[m.Module], adopted); err != nil { + out[m.Module] = err + } + } + return out +} + +// UnreadManifestError is a module left out because its stored manifest has a key this controller does not know +// (novox/hq ADR 0262): said once for the whole mesh, by the catalogue's own condition, not per machine. +type UnreadManifestError struct { + Module string + said string +} + +func (e *UnreadManifestError) Error() string { return e.said } + +// Compose is Declaration with the owner of every resource said. +func Compose(r mcat.Resolution, with mcat.Rendering) (mcat.Composed, error) { + owner := map[string]string{} + received := map[string]map[string][]mcat.Contribution{} + leftOut := map[string]string{} + var foreseen, unplaced, handedOnly []string + handed := &handedFiles{plans: map[string]mcat.Plan{}} + resources, err := resolutionCompose(r, with, owner, received, leftOut, &foreseen, &unplaced, &handedOnly, handed) + if err != nil { + return mcat.Composed{}, err + } + sort.Strings(foreseen) + if with.BusMembership != "" { + // The machine's own, not any module's: how it reaches the mesh from now on. Sealed like a + // secret and placed where the host looks for exactly this (design 28, task 5.2). + resources = append(resources, map[string]any{ + "id": BusMembershipID(), "type": "file", "path": mcat.BusMembershipPath, + "sealed": with.BusMembership, "mode": "0600", + }) + } + sort.Strings(unplaced) + sort.Strings(handedOnly) + out := mcat.Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut, + Foreseen: foreseen, Unplaced: unplaced, HandedOnly: handedOnly, Filled: handed.paths} + if plans := handed.finished(r.Needs); len(plans) > 0 { + out.Plans = plans + } + return out, nil +} + +// BusMembershipID names the resource carrying a machine's membership for the new bus, and +// BusMembershipPath is where the host reads it — the same constant on both sides. +func BusMembershipID() string { return "bus-membership" } + +func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string]string, + received map[string]map[string][]mcat.Contribution, leftOut map[string]string, + foreseen, unplaced, handedOnly *[]string, handed *handedFiles) ([]map[string]any, error) { + // **A setting is judged where it is stored, and an impossible one costs a module, not a + // machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes + // this module uncomposable; it is left out of the declaration — its held things kept, its + // containers untouched, the machine told so by name — and the machine is told everything else. + // Before placing, because a placement is a setting too. + left := LeftOut(r, with.Settings, with.Adopted) + kept := make([]mcat.Manifest, 0, len(r.Modules)) + var stillBackedUp []mcat.Manifest + for module, why := range with.NotPlaced { + if _, already := left[module]; !already { + left[module] = why + } + } + for _, m := range r.Modules { + if why, isLeft := left[m.Module]; isLeft { + if leftOut != nil { + leftOut[m.Module] = why + } + // **Its data is still copied** (novox/hq ADR 0262): a module left out runs nothing new, and + // the data it already holds on the machine is the reason to keep copying it. Only its data, + // as the backup holder's lines are derived from it, and the directories they name. + stillBackedUp = append(stillBackedUp, backupView(m)) + continue + } + kept = append(kept, m) + } + r.Modules = kept + + // Every manifest is placed first (novox/hq ADR 0112): the maps naming where its bindings, + // credentials and contributions land are resolved against this node's directories, so every + // reader below — the binding files, the sealed secrets, the grant paths a contribution + // names — sees a concrete place and none learns the vocabulary. + // Into a fresh slice, never the caller's: one resolution may compose for many nodes, and a + // slice element written in place would carry the first node's places into the second's. + placed := make([]mcat.Manifest, len(r.Modules)) + for i, m := range r.Modules { + var err error + if placed[i], err = placedManifest(m, with); err != nil { + return nil, err + } + } + r.Modules = placed + + // Where each provision's credentials land, so a contribution can name the file rather than + // carry a value the mesh does not have. + directories := map[string]string{} + for _, m := range r.Modules { + for provision, where := range m.Grants { + directories[provision] = where + } + } + given, err := resolutionContributions(r, with.Settings, with.Grants, directories) + if err != nil { + return nil, err + } + + // A workload on THIS machine contributes the port it declared, and the machine may have + // published it somewhere else (novox/hq ADR 0066). The mirror of the redirect below: 038 fixed + // what a consumer is TOLD about a provider, and this is what a workload TELLS the provider about + // itself — gitea declaring 3000, published as 20000:3000, and the co-located proxy dialling 3000, + // where nothing listens, for every request. + // + // **The contributing module's assignment, never the provider's.** The port belongs to the + // workload; using the map of whatever answers the requirement would move a route to wherever the + // proxy happens to be published. A contribution carried here from another machine is left exactly + // as it is — its port is that machine's to assign, and this map knows nothing about it. + for provision := range given { + for i := range given[provision] { + said := &given[provision][i] + if said.Node != "" && said.Node != r.Node { + continue + } + said.Values = atMachinePort(said.Values, said.From, with.Ports) + } + } + + // A provider answered on this same machine never passed through the walk that works out what a + // provider on ANOTHER machine serves (novox/hq 04-ISSUES/038, ADR 0066). That walk does two + // things — it derives the served facts with the provider node's port assignments, and it settles + // them with that node's settings layers — and the same-node paths (resolve.go's servedHere, and + // here() below) did neither, because both run while resolving, before either is known. + // + // The port was the first half to be noticed (038). The second half is worse and quieter: a served + // value the OPERATOR supplied — a certificate authority's root, which is the one kind of value a + // manifest cannot carry, because it differs on every mesh — arrived as the manifest's empty + // default. A proxy then wrote an empty CA bundle, fell back to the system trust store, could not + // verify the internal authority, and stopped issuing with nothing saying why. + // + // So it is re-derived here, in full, exactly as the cross-node path derives it. **After the + // closure is known**, which also removes an order dependence: the resolver built these needs + // mid-walk from whichever modules had been chosen by then, so what a co-located binding carried + // depended on the order somebody happened to assign things in. + for i := range r.Needs { + if r.Needs[i].ByRecord || r.Needs[i].From != r.Node { + continue + } + serves, answered, err := resolutionServedOnThisMachine(r, r.Needs[i].Name, with) + if err != nil { + return nil, err + } + if answered { + r.Needs[i].Serves = serves + } + } + + // Once, from every module's listens -- not per module. A module receiving only its own ports + // would write a rule set that closed every other module on the machine. Each module's per-node + // exposure settings override its listens' source first (novox/hq ADR 0046). + rules, err := Rules(r, with) + if err != nil { + return nil, err + } + // **A machine that has not said which links face outside is sent no filter** (novox/hq ADR + // 0140). The whole chain is written around those links: with none, the rule that lets this + // machine's own guests keep working would name an empty set, which nftables refuses, and a rule + // set that does not load is a machine filtering nothing while its unit reports success. Refused + // here, where a person reads it, rather than on the machine — and the machine keeps the filter + // it already has. + if filters := resolutionFiltersHere(r); filters != "" && len(with.OutwardLinks) == 0 { + return nil, fmt.Errorf( + "%s cannot be sent a filter: it has not reported which of its links face outside, and "+ + "every rule in the chain is written around them. It reports that on each apply; "+ + "`node show %s` says whether it has. Until then %s is not sent, and the machine "+ + "keeps the filter it has", r.Node, r.Node, filters) + } + filtering := AsNftablesWithLocal(rules, with.Mesh, r.PublicDomain != "", with.Foundation, + with.OutwardLinks, with.TunnelInterface, with.LocalNetworks) + + // **A variable two modules set is refused whether or not anything places it** (novox/hq ADR + // 0203 §5): the account has one environment, and a machine whose holder arrives later should not + // be the moment two modules are found to disagree about it. + if err := variablesSetOnce(r.Modules); err != nil { + return nil, err + } + + generated, err := resolutionGeneratedHere(r, with) + if err != nil { + return nil, err + } + + var out []map[string]any + for _, m := range r.Modules { + if with.Adopted && m.Filtering != nil { + // Nothing of a module that loads a filter, on an adopted node: its table would drop + // by default and hold accepts, and the found firewall stays in force. Every resource, + // not only the rule set — its service must not run, and a node returned to adopted + // stops it by the ordinary removal of what is no longer declared. + continue + } + resources := m.Resources + + // What the mesh computes for this module goes FIRST, before the module's own resources. + // + // **Order is stated, not derived — the host does not sort** (novox/hq ADR 0005), so + // whatever the mesh writes down is the order a machine applies. A module's service or + // container routinely depends on one of these files; nothing here ever depends on a + // module's resources, because none of it is computed from them. + // + // Appended, this was wrong in a way that only showed on the first apply and then healed: + // the service started before its certificate or its rule set existed, failed, and the next + // reconcile fixed it. A fault that repairs itself on the second attempt is worse than one + // that does not, because what gets remembered is that it works. + var first []map[string]any + if f := m.Filtering; f != nil { + first = append(first, map[string]any{ + "id": mcat.FilteringID(), "type": "file", "path": f.Into, + "content": filtering, "mode": "0600", + }) + } + // The node's fail2ban jails, composed from every module it runs (novox/hq to-be 31), written + // where the intrusion-prevention holder owns them. Like the rule set above: gathered from all + // modules, written by the one that holds the role. + if j := m.Jailing; j != nil { + first = append(first, jailsInto(r.Modules, j)...) + } + if c := m.Certificate; c != nil { + if with.Certificate == "" { + // Asked for and not issued. Refused rather than skipped: a module that serves TLS + // with no certificate does not start, and the reason is somewhere else entirely. + return nil, fmt.Errorf( + "%s wants a certificate for this machine and none was issued", m.Module) + } + first = append(first, map[string]any{ + "id": mcat.CertificateID(), "type": "file", "path": c.Into, + // Public. It travels in the open like any other file, because it is a statement + // about a key rather than the key. + "content": with.Certificate, "mode": "0644", + }) + if c.Authority != "" { + first = append(first, map[string]any{ + "id": mcat.AuthorityID(), "type": "file", "path": c.Authority, + "content": with.Authority, "mode": "0644", + }) + } + } + if m.BusUsers != "" { + // **The claim authorises it, not the field.** This file holds every user's password + // hash, so a module that could ask for it could read every credential on the bus. + // Checked from this manifest alone, which is the cheapest check there is: whether some + // other module also claims the seat is resolution's business elsewhere, and one holder + // mesh-wide is already guaranteed. + if !m.ClaimsSeat("mesh-broker") { + return nil, fmt.Errorf( + "%s asks for the mesh's user list and does not claim mesh-broker. That file "+ + "holds every user's password hash, so the seat is what authorises it", + m.Module) + } + if with.BusUsers == "" { + // Asked for and not composed. Refused rather than skipped, for the reason a + // certificate is: a bus with no user list refuses every connection in the mesh, and + // an empty file would look like a configuration problem on the machine. + return nil, fmt.Errorf( + "%s holds mesh-broker and the mesh composed no user list, so the bus would "+ + "refuse every connection", m.Module) + } + first = append(first, map[string]any{ + "id": mcat.BusUsersID(), "type": "file", "path": m.BusUsers, + "content": with.BusUsers, + // Readable by the server and nothing else. Hashes rather than passwords, so this is + // not a set of working credentials — but a list of every user in the mesh is worth + // keeping to the one process that needs it. + "mode": "0600", + }) + } + // **A secret family's members, as given** (novox/hq ADR 0283): one file each at the family's path, and + // nothing for a member not given — the mesh never makes one, and the module says it waits for it. + for _, name := range sortedKeys(with.Needed[m.Module]) { + own, family, ok := m.OwnSecrets.Lookup(name) + if !ok || family == "" || with.Needed[m.Module][name] == "" { + continue + } + // A per-secret owner (novox/hq ADR 0436 §1) stands in for the module's secrets-owner, when + // the manifest named one for this secret (or the family it belongs to). + owner := m.SecretsOwner + if own.Owner != "" { + owner = own.Owner + } + first = append(first, ownedBy(owner, map[string]any{ + "id": mcat.NeedID(name), "type": "file", "path": own.Path, "sealed": with.Needed[m.Module][name], + })) + } + // **A handed module's own secrets are the vault's** (design 57 V4, issue 647): each file holding one is + // declared placed and without a value, filled by the machine's provisioner client from the vault; the + // controller makes and seals none of them here. + handsSecrets := Handing(with, m) + for _, name := range sortedKeys(m.OwnSecrets.Plain()) { + if handsSecrets { + owner := resolutionOwnSecretOwner(r, m, name) + first = append(first, ownedBy(owner, map[string]any{ + "id": mcat.NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, mcat.HandedSecretField: name, + })) + continue + } + // **A core credential is the vault's box** (ADR 0349 §16, issue 755): carried as the vault sealed it to this + // machine, which the controller cannot open; the controller's own sealed value is not sent with it. + if box, ok := renderingVaultSealedOf(with, m.Module, name); ok { + if string(box) == string(mcat.ForeseenVaultBox) { + *foreseen = append(*foreseen, m.Module+"/"+name) + } + owner := resolutionOwnSecretOwner(r, m, name) + first = append(first, ownedBy(owner, map[string]any{ + "id": mcat.NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, mcat.VaultSealedField: box, + })) + continue + } + sealed := with.Needed[m.Module][name] + if sealed == "" && with.Foreseen[m.Module][name] { + // Not made, and the next send makes it: composed with a stand-in so that whatever + // else this composition would refuse is still found (novox/hq issue 275). + sealed = mcat.ForeseenSealed + *foreseen = append(*foreseen, m.Module+"/"+name) + } + if sealed == "" && m.OwnSecrets[name].IssuedBy == mcat.IssuedOutside { + // **Not given, and the mesh never makes it** (novox/hq issue 401, ADR 0228): no file, as for a + // family's member (ADR 0283), so the module reads its absence as not given. + continue + } + if sealed == "" { + // Declared and not made. Refused rather than skipped: a module whose own + // credential is silently absent starts, fails to authenticate, and the reason is + // three layers away from the machine reporting it. + return nil, &NotMadeError{Module: m.Module, Name: name} + } + // The runtime's credential belongs to the account the runtime runs as (novox/hq ADR 0175, + // to-be 38 WP3): its process is composed `user: ` where the node has one, and a + // root-owned 0600 file is one that process cannot read. Composed here rather than said in + // the manifest, because a manifest cannot say ${machine:account} safely — a node with no + // account has nothing to resolve it to, and then the runtime runs as root and the file + // stays root's. + first = append(first, ownedBy(resolutionOwnSecretOwner(r, m, name), map[string]any{ + "id": mcat.NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed, + })) + } + // This module's tools bundles, where the machine runs the node's tool runtime (novox/hq + // ADR 0175, to-be 38 WP2). Mesh-computed like everything above it, and before the module's + // own resources for the same reason: the runtime's process names the files inside these + // and is restarted when one changes, so they are on the machine before it is. + if resolutionRuntimeHere(r) { + first = append(first, bundleArchives(m)...) + } + // Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before + // the module's own resources, and so before the container that mounts them: the host must + // find each present — refusing clearly if the operator has not provided it — before it + // starts anything that depends on it. The mesh creates, chowns and reconciles none of it; + // an `access` resource says only *this path must exist, and this module reaches it*. + // Where each is on THIS machine is the assignment's (novox/hq issue 153): placed by id + // where the operator said, the definition's default otherwise, refused where neither. + accesses, accessPaths, err := accessesFor(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } + for _, a := range accesses { + first = append(first, map[string]any{ + "id": mcat.AccessID(a.Path), "type": "access", "path": a.Path, "mode": a.Mode, + }) + } + for _, to := range m.SecretRequirements() { + for _, file := range m.SecretFiles(to) { + var found *mcat.Needed + for i, n := range r.Needs { + // **This module's need, not the provision's** (novox/hq 04-ISSUES/022). Matching + // on the name alone, every consumer of a provision took whichever credential + // happened to be last in the list — so on a node with two of them, one module + // would be given the other's password and fail to authenticate with a valid + // credential belonging to somebody else. And this file's local name, where the + // module keeps several (ADR 0094). + if n.Name == to && n.For == m.Module && n.Local == file.Local { + found = &r.Needs[i] + } + } + // **A credential the vault keeps for this holder alone** (issue 757): where the module is handed, its + // file is declared placed and without a value, and the machine's client fills it from the vault. + if found != nil && found.IssuedToHolder && Handing(with, m) { + first = append(first, ownedBy(m.SecretsOwner, map[string]any{ + "id": mcat.SecretID(mcat.SecretLocal(to, file.Local)), "type": "file", "path": file.Path, mcat.HandedProvisionField: mcat.PlanProvision{Name: mcat.SecretLocal(to, file.Local), Provision: to, + Local: file.Local, Provider: found.From, ProviderModule: found.Module}, + })) + continue + } + if found != nil && found.ByRecord && found.Sealed == "" && !found.Manager { + // Answered by a record whose key has not been supplied since this consumer was + // put on it. **Refused, not skipped.** The mesh discarded the plaintext when the + // key was accepted and cannot seal another, so a machine that resolved cleanly + // would receive no file at all and fail at whatever tried to read it — which is + // the outcome ADR 0024 exists to avoid, arrived at politely. + // + // The manager holder is the one exception (novox/hq ADR 0050): an empty refresh token + // is a licence whose manager has not adopted one yet, a real waiting state rather than + // a lost key. It falls through to the skip below — its bound facts (carrying the + // manager's public key) are still delivered, which is what adoption needs to seal the + // first refresh token. + return nil, fmt.Errorf( + "%s on this machine uses the licence %q and no key has been sealed to it. "+ + "The mesh cannot make one; supply it again with `licence key %s`", + m.Module, found.From, found.From) + } + if found == nil || found.Sealed == "" { + // Answered on this machine, or answered by a node the mesh could not seal to. + // Nothing to write either way, and writing an empty credential file would be + // worse than none: something would read it and fail authenticating. + continue + } + first = append(first, ownedBy(m.SecretsOwner, map[string]any{ + "id": mcat.SecretID(mcat.SecretLocal(to, file.Local)), "type": "file", "path": file.Path, + "sealed": found.Sealed, + })) + } + } + for _, to := range sortedKeys(m.Grants) { + for _, g := range with.Grants { + if g.Provision != to { + continue + } + if g.From == "" { + // Nothing on that machine asks for this any more. Skipped here rather than + // where grants are gathered, so the rule holds whoever gathers them. + // + // **This is how a credential is withdrawn.** The provisioner removes what + // nobody asks for, and it can only do that if the mesh stops asking — a + // consumer that was unassigned would otherwise keep a working login for ever, + // and nothing would say so. + continue + } + if g.Sealed == "" { + // Its offer presents no credential (form none, issue 1126): nothing to write, and an empty credential + // file would be read as one that failed. Who asked still reaches the provider in its contributions. + continue + } + first = append(first, ownedBy(resolutionProvisionsAs(r, m), map[string]any{ + // One file per holder — the consumer's module with its local name after it + // where it keeps several (ADR 0094); the lab found two files with one id. + "id": mcat.GrantID(to, g.Consumer+"."+holderAs(g.From, g.Local)), + "type": "file", + "path": grantPath(m.Grants[to], g.Consumer, holderAs(g.From, g.Local)), + "sealed": g.Sealed, + })) + } + } + for _, to := range sortedKeys(m.Binds) { + var found *mcat.Needed + for i, n := range r.Needs { + if n.Name == to && n.For == m.Module { + found = &r.Needs[i] + } + } + if found == nil { + // Answered on this same machine rather than from elsewhere in the mesh. + // + // **Still written.** It used to be skipped, reasoning that "it is on this node" + // is a fact nobody needs — and that is true of the *location* and false of + // everything beside it. A binding also carries what the provider said a consumer + // must know, which is the port; a consumer cannot invent that, and got an absent + // file with no explanation. A build machine sharing a node with the registry it + // pushes to sat in a loop saying it could not read its own binding. + here, err := here(r, to, with) + if err != nil { + return nil, err + } + if here == nil { + // Nothing in this node's set offers it either, so there is genuinely nothing + // to say. Resolution has already refused anything unanswerable, so this is a + // requirement met by the module itself. + continue + } + found = here + } + own, err := resolutionOwnNames(r, m, to, with.Settings[m.Module]) + if err != nil { + return nil, err + } + as := mcat.ConsumerIdentity(r.Node, mcat.IdentitySource(m.Slug, m.Module)) + // What the provider derives for THIS consumer, filled here where the consumer is + // known (novox/hq ADR 0202). The same fill knownFor does below, so the binding file + // and the module's `${bound:…}` substitutions cannot say different things. + told := *found + told.Serves, err = ServedTo(told.Serves, as) + if err != nil { + return nil, fmt.Errorf("%s is told about %s: %w", m.Module, to, err) + } + file, err := boundFile(told, m.Binds[to], as, own) + if err != nil { + return nil, err + } + first = append(first, file) + } + for _, to := range sortedKeys(m.Receives) { + file, err := receivedFile(to, m.Receives[to], given[to]) + if err != nil { + return nil, err + } + first = append(first, file) + if received[m.Module] == nil { + received[m.Module] = map[string][]mcat.Contribution{} + } + // Empty rather than absent when nobody contributed, for the reason the file is + // written empty: "nothing asked" and "never told" want different responses. + received[m.Module][to] = append([]mcat.Contribution{}, given[to]...) + } + if m.Keeps != "" && with.Kept != nil { + file, err := keptFile(m.Keeps, with.Kept) + if err != nil { + return nil, err + } + // Read by the module as its secrets' owner when it names one: the vault, as its own account (issue 723). + first = append(first, ownedBy(m.SecretsOwner, file)) + } else if runs, ok := with.KeptRuns[m.Module]; m.Keeps != "" && ok { + file := make(map[string]any, len(runs)) + for k, v := range runs { + file[k] = v + } + file["id"] = KeptID() + delete(file, "module") + first = append(first, file) + } + if m.Computed != "" { + mine, part := generated[m.Module] + if !part { + // Assigned, and not yet part of what this generates. Nothing to put on the + // machine, which is different from an error: a node given the network module + // before it has an address is in exactly that state, briefly. + continue + } + resources = mine + } + + // Now, and not before: a module whose resources are computed replaces them wholesale, and + // merging earlier would throw away the files it still needs. + // + // **Except the module's own accounts, which go before even those** (novox/hq issue 213). What + // the mesh computes may belong to one: a module whose code runs as an account it declares has + // its secrets written owned by that account, and a file given to a user the machine does not + // have yet fails — so on the first apply the secrets were refused, the process started without + // them, and the second apply healed it, which is the fault the paragraph above describes. + // An account depends on nothing the mesh computes. + accounts, rest := accountsFirst(resources) + resources = append(append(accounts, first...), rest...) + // **A file delivered only while its optional requirement is bound is left out while it is + // absent** (novox/hq ADR 0330 §5), and the field is dropped from the rest: the node-engine + // parses strictly and knows no such field. + absent := AbsentFor(r, m.Module) + resources = withoutAbsent(resources, absent) + + // No container is given the mesh's names (novox/hq ADR 0148). It used to be: every + // container got the whole roster as `--add-host` entries at creation, and a name that + // moved afterwards was wrong inside it for as long as it ran (issues 109, 135) — and once + // the roster was made part of a container's identity so that could be caught, one name + // moving anywhere replaced every container in the mesh (issue 151). A container resolves a + // mesh name through its machine's resolver at the moment it asks, which the runtime is + // told once per machine, as a file, by the resolver's own module. The names a module + // declares for itself are its own and stay exactly as written: they are part of what the + // module is, and the mesh does not know what they mean. + // What this module may name from inside one of its own files. Gathered once per module + // rather than per file, because it is a fact about the module. + sealed, err := sealedFor(m, r.Needs, with) + if err != nil { + return nil, err + } + // And what its bindings say, for the half of a connection that is not secret. + known, err := knownFor(m, r.Needs, r.Node) + if err != nil { + return nil, err + } + // A requirement answered on this same machine is not in r.Needs — its binding file is + // written from `here` (above) — and so `${bound:…}` could not name it, though the file + // beside it said the same facts. Filled from the same answer, so the two cannot disagree. + for _, want := range m.Wants() { + if _, has := known[want]; has { + continue + } + answered, err := here(r, want, with) + if err != nil { + return nil, err + } + if answered == nil { + continue + } + local := *answered + local.For = m.Module + here, err := knownFor(m, []mcat.Needed{local}, r.Node) + if err != nil { + return nil, err + } + for provision, values := range here { + known[provision] = values + } + } + // And the providing machine's private address, beside its name (novox/hq ADR 0194). A name is + // what nearly every consumer wants; the one that cannot use it is a machine's resolver + // configuration, which must reach the resolver before it can resolve anything — the resolver's + // own name included. Absent when the machine has no address yet, so a file naming it is refused + // rather than written with a blank where an address belongs. + for _, values := range known { + if address := with.Machines[values["at"]]; address != "" { + values["address"] = address + } + } + // And what the module is called through each requirement it contributes to (novox/hq + // 04-ISSUES/122) — the same composition its binding file carries. + for provision, values := range known { + own, err := resolutionOwnNames(r, m, provision, with.Settings[m.Module]) + if err != nil { + return nil, err + } + withOwnNames(values, own) + } + // And where this node places the directories the module declared without a path + // (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource. + // — and, on an adopted machine, where the assignment says they already are, with the + // owner the data already has (novox/hq issue 153). Malformed placements are refused here. + placed, err := mcat.Places(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } + dirs := dirsFor(m, with) + // And the machine underneath, which no binding of its own can tell it. + thisMachine := machineFacts(r, with.Names, with.MeshRange) + + // **A definition that already holds the answer transcribed it** (novox/hq ADR 0202). + // Judged over what the module itself declares, and before anything is substituted: the + // mesh's own generated files — the binding, the contributions — legitimately carry the + // derived value, and after substitution so does every consumer's file, so this is the one + // moment the two can be told apart. + for _, own := range m.Resources { + if err := notTranscribed(own, known, m.Module); err != nil { + return nil, err + } + } + // **And every placeholder no pass fills where it stands is refused** (novox/hq issue 231): + // judged here, over the definition as written and before any pass, by the function the + // catalogue check runs — so a manifest registered by an older binary is judged too, and + // what a setting or a binding later puts into a file is its software's text, never swept. + for _, own := range m.Resources { + if problems := unconsumedPlaceholders(m.Module, own); len(problems) > 0 { + return nil, fmt.Errorf("%s", problems[0]) + } + } + + // Which of this module's files carry a secret, for the rule that a container may not read + // one of them as its environment without saying so (ADR 0086, issue 041). + secretFiles := secretFilesOf(resources) + + // Which of this module's resources its preparation runs before, if it prepares anything. + prepareBefore := preparationTarget(m) + + // Which found networks this machine's setting keeps for each of its containers (novox/hq + // ADR 0163, rule 4); judged above, so an invalid one is not here. + keptNetworks, _ := KeptNetworks(m, with.Settings[m.Module], with.Adopted) + + // **A moved module's templated files are the provisioner's** (novox/hq ADR 0349 §4, §6, design 57 P6): on a + // machine that can take them, each is declared placed and without its text, and no pass below fills it. + handing, err := handed.begin(r.Node, m, dirs, accessPaths, thisMachine, with, r.Capabilities, r.Needs, + backupPlacer{append(append([]mcat.Manifest(nil), r.Modules...), stillBackedUp...), with, thisMachine, r.Capabilities, + unplaced}) + if err != nil { + return nil, err + } + // **A plan for what the seat keys of it all the same** (catalogue.Keyed, issue 774): a module not handed whose + // provider's helper answers a provision of it, its route the commonest, has that provision in a plan of its own, + // so the seat keys it and the helper hears it. + keyedOnly := handing == nil && handed.keyed(r.Node, m, r.Needs) + if _, ok := handed.plans[m.Module]; ok && (handing != nil || keyedOnly) { + for _, to := range requirementsContributedTo(m) { + own, err := resolutionOwnNames(r, m, to, with.Settings[m.Module]) + if err != nil { + return nil, err + } + handed.name(m.Module, to, own) + contributed, err := resolutionContributedOf(r, m, to, with) + if err != nil { + return nil, err + } + handed.contribute(m.Module, to, contributed) + } + } + // A handed collector is filled from items alone: each old form its collections would have held is said + // (design 57 L1). + if plan, ok := handed.plans[m.Module]; ok && handing != nil && len(plan.Collections) > 0 { + *unplaced = append(*unplaced, oldFormsLeftOut(append(append([]mcat.Manifest(nil), r.Modules...), + stillBackedUp...), m, plan.Collections)...) + } + for _, unsettled := range resources { + if pr, ok := unsettled[mcat.HandedProvisionField].(mcat.PlanProvision); ok { + if handing == nil && m.Stubbed { + continue // held: its credential file is what runs of it, left out (issue 1126) + } + if handing == nil { + return nil, fmt.Errorf("%s: its credential of %s is handed to the provisioner and the module is "+ + "not: nothing would fill it", m.Module, pr.Provision) + } + marked := map[string]any{} + for k, v := range unsettled { + marked[k] = v + } + delete(marked, mcat.HandedProvisionField) + marked[mcat.HandedSecretField] = pr.Name + file, sec, err := handedSecretFile(marked, m, dirs, accessPaths, thisMachine, placed) + if err != nil { + return nil, err + } + file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"]) + owner[fmt.Sprint(file["id"])] = m.Module + pr.Path = sec.Path + if err := handed.addProvisionFile(m.Module, pr); err != nil { + return nil, err + } + out = append(out, file) + continue + } + if unsettled[mcat.HandedSecretField] != nil { + if handing == nil && m.Stubbed { + continue // held: its own secret's file is what runs of it, left out (issue 1126) + } + if handing == nil { + return nil, fmt.Errorf("%s: its own secret %v is handed to the provisioner and the module is "+ + "not: nothing would fill it", m.Module, unsettled[mcat.HandedSecretField]) + } + file, sec, err := handedSecretFile(unsettled, m, dirs, accessPaths, thisMachine, placed) + if err != nil { + return nil, err + } + file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"]) + owner[fmt.Sprint(file["id"])] = m.Module + if err := handed.addSecretFile(m.Module, sec); err != nil { + return nil, err + } + out = append(out, file) + continue + } + if handing != nil && fmt.Sprint(unsettled["type"]) == "file" { + if text, _ := unsettled["content"].(string); len(secretsUsed(text)) > 0 && + !handing[fmt.Sprint(unsettled["id"])] { + return nil, fmt.Errorf("%s: its file %v names a secret in its text, and a handed module's "+ + "secrets reach only the files its provisioner client fills: keep it in a template "+ + "(design 57 V4)", m.Module, unsettled["id"]) + } + } + if handing[fmt.Sprint(unsettled["id"])] { + file, line, err := handedFile(unsettled, m, dirs, accessPaths, thisMachine, placed, with) + if err != nil { + return nil, err + } + file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"]) + owner[fmt.Sprint(file["id"])] = m.Module + if err := handed.add(m.Module, line); err != nil { + return nil, err + } + out = append(out, file) + continue + } + if fmt.Sprint(unsettled["type"]) == "file" { + if _, left := consumersHandedOnly(m, unsettled, with); left { + *handedOnly = append(*handedOnly, consumersLeftOutLine(m, unsettled)) + continue + } + } + resource, err := ApplySettings(unsettled, with.Settings[m.Module]) + if err != nil { + return nil, err + } + copied := map[string]any{} + for k, v := range resource { + copied[k] = v + } + if networks, keeps := keptNetworks[fmt.Sprint(copied["id"])]; keeps { + // The container also joins the found network the setting names, so a neighbour + // that resolves it there keeps resolving it. Passed to the host as its own field, + // which it joins after the container is made. + joins := make([]any, 0, len(networks)) + for _, n := range networks { + joins = append(joins, n) + } + copied["networks"] = joins + } + if err := refuseSecretsInEnvironment(copied, secretFiles, m.Module); err != nil { + return nil, err + } + // Said in the catalogue, not on the machine: the host parses strictly and knows no + // such field, and the reason is for a reader of the manifest. + delete(copied, mcat.SecretsInEnvironment) + delete(copied, mcat.NamesOnPurpose) + delete(copied, mcat.TrustedField) + // A process's bus stays controller-side (novox/hq ADR 0436 §1): never composed onto the + // node-engine's process resource, whose wire declaration is mesh-host's own, vendored, with a + // strict decoder that refuses an unknown key. + delete(copied, mcat.ProcessBusField) + // **An operator's value, from the assignment** (novox/hq ADR 0112, ADR 0155): what a + // definition may not carry because it is true of one installation only. Filled from + // the same layers a mergeable file takes, and refused when no layer set it. + if err := unsetInConfigWords(settingInto(copied, WithDefaults(m, with.Settings[m.Module]), m.Module), m); err != nil { + return nil, err + } + // **Placed before anything reads a path.** A pathless directory receives the path + // this node resolves for it, and every ${dir:…} — in paths, mounts, content and + // environment — becomes that path, so what follows sees only concrete places + // (novox/hq ADR 0112). The host receives paths exactly as it always has. + if err := dirInto(copied, dirs, m.Module); err != nil { + return nil, err + } + // The operator's data the same way: ${access:…} becomes where this node keeps it, + // and a placed directory takes the owner the assignment said (issue 153). + if err := accessInto(copied, accessPaths, m.Module); err != nil { + return nil, err + } + ownerInto(copied, placed) + // **After settings, and that is the whole reason it is here.** A module's file + // content is where a setting lands, so a placeholder may only exist once the setting + // has been put in — filling secrets first would look at content that is not yet what + // the machine receives. + if err := intoFile(copied, sealed, outsideNotGiven(m, with.Needed[m.Module]), m.Module); err != nil { + return nil, err + } + // **Substituted here, not on the machine.** A bound value is not secret — the mesh + // holds it in the clear — so there is nothing for the host to be the only witness of, + // and sending it already filled in means the host learns no new field. + if err := boundInto(copied, known, m.Module, absent); err != nil { + return nil, err + } + // And what the module could not have written: the machine it turned out to be + // assigned to. Beside the bound values because it is the same kind of fact — the + // mesh's own, held in the clear — and because a module that must name itself to + // something else has no binding to learn it from (novox/hq ADR 0066). + // Which port this machine gave it, for a module that binds directly rather than + // through a runtime that can remap (ADR 0038). Applied before the machine's facts so + // a refusal names the port rather than whatever came after it. + if err := portInto(copied, m.Module, m.Listens, with); err != nil { + return nil, err + } + // And where this machine put the foundation's servers, for the one module that + // reaches them by seat rather than by binding (novox/hq 04-ISSUES/102). + if err := seatInto(copied, m.Module, with); err != nil { + return nil, err + } + if err := machineInto(copied, thisMachine, m.Module); err != nil { + return nil, err + } + if err := pinned(copied, m.Module); err != nil { + return nil, err + } + // And the other half of the same question: an image nobody has published yet is named + // by an artifact rather than by a placeholder digest, and is just as unrunnable. + if err := built(copied, m.Module); err != nil { + return nil, err + } + // What the mesh built is kept by digest and path; the store's address is this + // network's now, composed here and never recorded (novox/hq 04-ISSUES/102). + if err := artifactsInto(copied, m.Module, with); err != nil { + return nil, err + } + publishedOn(copied, m.Module, with) + // How it is ready, in the node-engine's words: its endpoint as the port this machine + // published it on — or not sent at all to an engine older than the field (ADR 0240). + healthInto(copied, m, with) + // And whether it is busy (novox/hq ADR 0335): sent only to an engine that asks it. + busyInto(copied, with) + // And a user's `root` (novox/hq ADR 0266): sent only when it asserts something, to an engine + // that judges it. + rootInto(copied, with) + // The account's environment and every module's shell code, where this module holds the + // seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on + // the node, as the jails are, and **last of every placeholder pass**: shell code is a + // shell's own syntax, full of `${…}` no pass above should ever be shown. + contributing := append(append([]mcat.Manifest(nil), r.Modules...), stillBackedUp...) + if err := contributionsInto(copied, m, contributing, thisMachine, with, r.Capabilities, unplaced); err != nil { + return nil, err + } + copied["id"] = m.Module + "." + fmt.Sprint(resource["id"]) + // A service saying what it reflects names resources within its own module, so those + // are prefixed too or they would point at nothing. + // + // **Unless it already names one.** A module may reflect a file another module put on + // the machine — the case this exists for is a resolver restarting when the mesh + // rewrites the names, which are computed by the mesh and belong to its module, not to + // the daemon's. Written `.`, and a dot is what marks it as already + // answered: prefixing it again would point at nothing, silently, and the daemon would + // serve the old names for ever while everything reported success. + if renamed := reflectsRenamed(m.Module, resource["restart-on"]); renamed != nil { + copied["restart-on"] = renamed + } + // And what it is reloaded on, by the same rule (novox/hq ADR 0102): an id left + // unprefixed matches nothing, and the service is never reloaded. + if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil { + copied["reload-on"] = renamed + } + // And which of its module's containers a scheduled step holds still (novox/hq ADR 0189). + // **The loudest of the three when it is missed.** An unprefixed `restart-on` matches + // nothing and a service quietly never restarts; an unprefixed `while-stopped` names a + // container the declaration does not contain, and the host refuses the whole + // declaration — so the machine takes nothing at all, for every push, until this is + // right. That is what it did on the control node (2026-10-04). + if renamed := reflectsRenamed(m.Module, resource[mcat.WhileStopped]); renamed != nil { + copied[mcat.WhileStopped] = renamed + } + // And what a process replaces (novox/hq issue 213): a resource of this module's that it + // no longer declares, named as the host recorded it, or the host hands nothing over and + // removes it first. + if renamed := reflectsRenamed(m.Module, resource["replaces"]); renamed != nil { + copied["replaces"] = renamed + } + // **What reads one of this module's own secrets is restarted when it changes** (novox/hq + // issue 203, issue 206). A credential is re-issued by the mesh, and a container that + // mounted the old file keeps the old one open: the build machine ran for an hour on a + // credential the mesh had replaced, because its manifest restarted it on its + // environment file and nobody had thought to name the credential too. Composed here so + // no manifest has to say it, for a container or a daemon that names the secret's path. + if reads := secretsReadBy(copied, m, with.Needed[m.Module]); len(reads) > 0 { + copied["restart-on"] = withRestartOn(copied["restart-on"], reads) + } + // **A version prepares its state before it runs** (novox/hq ADR 0135). Derived from the + // module's own resource rather than declared beside it: what prepares the state is the + // module's own code, so what it is given has to be what that code is given — and a + // second resource written by hand is a second copy to drift from the first. Placed + // immediately before it, because a run-once step stops everything the declaration + // places after it (ADR 0052), which is how a version whose preparation failed does not + // serve. + if prepareBefore != "" && fmt.Sprint(resource["id"]) == prepareBefore { + step := prepared(copied) + owner[fmt.Sprint(step["id"])] = m.Module + out = append(out, step) + } + owner[fmt.Sprint(copied["id"])] = m.Module + out = append(out, copied) + } + + // **What only the mesh knows, where this module asked for it.** The graph is the control + // plane's; making a name resolve is the module's software. Emitted as ordinary files under + // this module's name, so they are applied, reported and removed exactly as anything else + // it declares. + // The resolver file is the node-resolver holder's where one is held here, and the uplink's + // holder steps back from it (novox/hq ADR 0247). + given, err := FactsFrom(stepsBack(m, r.Modules), r, with) + if err != nil { + return nil, err + } + for _, fact := range given { + fact["id"] = m.Module + "." + fmt.Sprint(fact["id"]) + owner[fmt.Sprint(fact["id"])] = m.Module + out = append(out, fact) + } + } + // The node's tool runtime, last (novox/hq ADR 0175, to-be 38 WP2.3): one process loading every + // bundle delivered above and holding the credential sealed above, so both exist before it starts + // — the order written here is the order the machine applies. + if resolutionRuntimeHere(r) { + process, err := resolutionRuntimeProcess(r, with) + if err != nil { + return nil, err + } + if process != nil { // none for a held runtime's stand-in: what runs of it is carried + owner[fmt.Sprint(process["id"])] = mcat.RuntimeModule + out = append(out, process) + } + // And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8). + owns, err := resolutionOwnRuntimes(r, with) + if err != nil { + return nil, err + } + for _, p := range owns { + id := fmt.Sprint(p["id"]) + owner[id] = strings.TrimSuffix(id, "."+mcat.OwnRuntimeID()) + out = append(out, p) + } + // What each module's bundles are given is read as the account the runtime runs as — not what a + // module of its own account is given, which its own account reads. + words := map[string]map[string]string{} + for _, m := range r.Modules { + if m.RunsAs != "" { + continue + } + w, err := bundleWords(m, with) + if err != nil { + return nil, err + } + words[m.Module] = w + } + // And a file a module's words name is one the runtime is restarted for when it changes. + if named := givenTo(out, owner, words, r.Account); len(named) > 0 && process != nil { + restarts, _ := process["restart-on"].([]any) + seen := map[string]bool{} + for _, id := range restarts { + seen[fmt.Sprint(id)] = true + } + for _, id := range named { + if !seen[id] { + restarts = append(restarts, id) + seen[id] = true + } + } + process["restart-on"] = restarts + } + // **And what a module of its own account is given, its own account reads** (issue 723): the files and + // directories its words name that say no owner, given to the account it runs as, never the operator's. + for _, m := range r.Modules { + if m.RunsAs == "" { + continue + } + w, err := bundleWords(m, with) + if err != nil { + return nil, err + } + givenTo(out, owner, map[string]map[string]string{m.Module: w}, m.RunsAs) + } + } + if with.Adopted { + // First, before anything a module declares: what the mesh needs reachable, then its guard. + // The order a machine applies is the order written here. + ours := Openings(rules, with.Foundation, mcat.Published(out)) + ours = append(ours, GuardResources(resolutionGuarded(r, out, owner, rules, with))...) + out = append(ours, out...) + } + return out, nil +} + +// guarded is what the mesh's guard refuses on an adopted node (novox/hq ADR 0103): derived, and +// for taken modules only. +// +// For each taken module, every machine port its containers publish that the filter would admit +// from the private network only — a published port is forwarded, not received, so a found +// firewall filtering only what it receives never sees it — together with the ports the module's +// manifest guards explicitly (the store's port, the broker's management port), wherever the +// machine put them. A port of a module assigned but not taken is not guarded: it may still be the +// predecessor's, serving the predecessor's other machines. The foundation's ports are admitted +// from everywhere and are never guarded. +func resolutionGuarded(r mcat.Resolution, out []map[string]any, owner map[string]string, rules []mcat.Rule, + with mcat.Rendering) []int { + meshOnly := map[int]bool{} + fromEverywhere := map[int]bool{} + for _, rule := range rules { + if rule.Protocol != "tcp" { + continue + } + switch rule.From { + case mcat.FromMesh: + meshOnly[rule.Port] = true + case mcat.FromEverywhere: + fromEverywhere[rule.Port] = true + } + } + for _, port := range with.Foundation { + delete(meshOnly, port) + fromEverywhere[port] = true + } + seen := map[int]bool{} + var ports []int + guard := func(at int) { + if !seen[at] { + seen[at] = true + ports = append(ports, at) + } + } + for _, m := range r.Modules { + if !with.Taken[m.Module] { + continue + } + var mine []map[string]any + for _, resource := range out { + if owner[fmt.Sprint(resource["id"])] == m.Module { + mine = append(mine, resource) + } + } + for outer := range mcat.Published(mine)["tcp"] { + if meshOnly[outer] { + guard(outer) + } + } + for _, want := range m.Guards { + at := renderingMachinePort(with, m.Module, want) + for _, resource := range mine { + if fmt.Sprint(resource["type"]) != "container" { + continue + } + listed, _ := resource["ports"].([]any) + for _, entry := range listed { + outer, inner, _, ok := mapping(fmt.Sprint(entry)) + if ok && inner == want { + at = outer + } + } + } + // Not what this node is told to open to everyone: a per-node exposure setting that + // widens a guarded port is the operator saying so, and declaring an opening for it + // and a guard dropping it would be one statement refusing the other. + if !fromEverywhere[at] { + guard(at) + } + } + } + sort.Ints(ports) + return ports +} + +// mapping reads a container's port mapping — `[address:]outer:inner[/protocol]`, the address +// possibly an IPv6 one in brackets — indexing from the end, so an address's own colons never +// shift the ports. Not ok for a short form or anything that is not a mapping. +func mapping(written string) (outer, inner int, address string, ok bool) { + written = strings.TrimSpace(written) + if cut := strings.LastIndex(written, "/"); cut >= 0 { + written = written[:cut] + } + parts := strings.Split(written, ":") + if len(parts) < 2 { + return 0, 0, "", false + } + inner, err := strconv.Atoi(parts[len(parts)-1]) + if err != nil { + return 0, 0, "", false + } + outer, err = strconv.Atoi(parts[len(parts)-2]) + if err != nil { + return 0, 0, "", false + } + return outer, inner, strings.Join(parts[:len(parts)-2], ":"), true +} + +// filtersHere is the module on this node that loads the machine's packet filter, or empty when none +// does. Named rather than counted: a refusal that says which module is one step from acted on. +func resolutionFiltersHere(r mcat.Resolution) string { + for _, m := range r.Modules { + if m.Filtering != nil { + return m.Module + } + } + return "" +} + +// Rules is the rule set this node's filter is derived from: every module's listens, what was +// computed for this machine, and each module's per-node exposure. The same answer whether the node +// is adopted or converged — the one loads it as a filter, the other declares it as openings. +func Rules(r mcat.Resolution, with mcat.Rendering) ([]mcat.Rule, error) { + // A module whose settings cannot compose is left out of the declaration (novox/hq ADR 0163, + // rule 6), and out of the filter with it: nothing of it is declared, so nothing of it is let + // through. + left := LeftOut(r, with.Settings, with.Adopted) + exposure := map[string]map[int]string{} + for _, m := range r.Modules { + if _, isLeft := left[m.Module]; isLeft { + continue + } + e, err := Exposure(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } + // And how far each endpoint reaches, which says the same thing to the filter and more + // besides (novox/hq ADR 0138). Folded in here rather than beside: the filter has one + // question — from where — and a reach answers it, so giving it two inputs would let them + // disagree. Reaches refuses a port that both name, so this cannot silently prefer one. + reaches, err := Reaches(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } + // **Only for an endpoint the proxy does not serve.** A routed endpoint's port is how the + // proxy reaches it and nothing else (ADR 0045), so `public` there asks for a public name and + // says nothing about the port — opening it to the world as well would undo the arrangement + // the proxy exists for, and would silently reopen a port an operator had narrowed. + // + // Found by trying to express a real module: one whose routed name must be public and whose + // machine-side port must not be. Under one value for both, there was no way to say it. + routed := RoutedPorts(m) + for port, reach := range reaches { + if routed[port] { + continue + } + source, ok := FilterSource(reach) + if !ok { + return nil, fmt.Errorf("%s: %q is not a reach the filter can read", m.Module, reach) + } + if e == nil { + e = map[int]string{} + } + e[port] = source + } + if e != nil { + exposure[m.Module] = e + } + } + return Filtering(r, with.Generators, with.Ports, exposure) +} + +// provisionsAs is the account that reads what the mesh writes for this provider: the one secret +// per consumer it must open to set that consumer's password (novox/hq issue 225). +// +// **A root-owned 0600 file is one that process cannot read**, which is the same sentence already +// written above for a module's own secrets — and the grant secret is the other kind of secret +// the mesh writes for a module, so it is the same rule. +// +// Which account depends on where the module's code runs. A module whose code is a bundle is run +// by the node's tool runtime, as the node's account ([ADR 0198](0198)); one still in a container +// is whatever it declares as its secrets owner. Nothing names these paths, so the rule that +// claims a bundle's other files by the words that name them (givenTo) cannot reach them: the +// harness composes a grant secret's path from the contributions file, not from a word. +// +// Empty is root, which is what it was and what a module with no bundle and no declared owner +// still wants. +func resolutionProvisionsAs(r mcat.Resolution, m mcat.Manifest) string { + // A module of its own account reads them as that account (ADR 0259 §8, issue 723). + if m.RunsAs != "" { + return m.RunsAs + } + if len(m.Bundles) > 0 && r.Account != "" { + return r.Account + } + return m.SecretsOwner +} + +// ownSecretOwner is who one of a module's own secret files belongs to: the account the manifest names for it, else the +// runtime's account for the runtime's own credential and for a tools bundle's bus account (ToolsBusSecret; novox/hq ADR +// 0564 Limits 4), which the runtime runs that bundle as, else the module's secrets-owner (root when none). +func resolutionOwnSecretOwner(r mcat.Resolution, m mcat.Manifest, name string) string { + if o := m.OwnSecrets[name].Owner; o != "" { + return o + } + if r.Account != "" && (m.Module == mcat.RuntimeModule || ToolsBusSecret(m, name)) { + return r.Account + } + return m.SecretsOwner +} + +// grantPath is where one consumer's sealed credential lands on the providing machine. +// +// Suffixed, so the directory can also hold whatever the module writing it keeps there and so a +// node named like something else in that directory cannot collide with it. +// One file per consumer, and a consumer is a module on a machine (novox/hq 04-ISSUES/022). +// +// Named after both. Named after the machine alone, two modules on one node wrote to one path: the +// second overwrote the first, and the provisioner — reading a directory — saw one consumer where +// there were two. +// reflectsRenamed is a resource's restart-on list under the module's prefix, or nil when it has +// none. It reads both the shape JSON parsing produces ([]any) and the shape code composing +// resources natively produces ([]string): a reference that was skipped because its list arrived +// in the other shape would point at nothing — silently, with the service never restarting and +// every check passing, which is how a runtime kept serving without the registry trust its +// daemon file already carried. +func reflectsRenamed(module string, reflects any) []any { + var names []string + switch v := reflects.(type) { + case []any: + for _, id := range v { + names = append(names, fmt.Sprint(id)) + } + case []string: + names = v + } + if names == nil { + return nil + } + var renamed []any + for _, named := range names { + if strings.Contains(named, ".") { + renamed = append(renamed, named) + continue + } + renamed = append(renamed, module+"."+named) + } + return renamed +} + +func grantPath(directory, consumer, module string) string { + return strings.TrimRight(directory, "/") + "/" + consumer + "." + module + ".secret" +} + +// holderAs is a consumer's name at the provider with a local name after it, where it keeps several +// credentials for one provision (ADR 0094); the name alone otherwise. +func holderAs(as, local string) string { + if local == "" { + return as + } + return as + "_" + local +} + +// contributions collects what every module in this set contributes, by requirement. +// +// Ordered by contributing module, because the result becomes a file on a machine and a file whose +// lines move about is a file that looks changed when nothing changed. +func resolutionContributions(r mcat.Resolution, settings mcat.SettingsBy, grants []mcat.Grant, + directories map[string]string) (map[string][]mcat.Contribution, error) { + out := map[string][]mcat.Contribution{} + modules := append([]mcat.Manifest{}, r.Modules...) + sort.Slice(modules, func(i, j int) bool { return modules[i].Module < modules[j].Module }) + + // What consumers on other machines asked for. Merged in with this machine's own, because from + // the provider's side they are the same thing — somebody wanting something — and a provider + // that had to read two lists would be a provider that reads one of them. + sorted := append([]mcat.Grant{}, grants...) + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Provision != sorted[j].Provision { + return sorted[i].Provision < sorted[j].Provision + } + if sorted[i].Consumer != sorted[j].Consumer { + return sorted[i].Consumer < sorted[j].Consumer + } + if sorted[i].From != sorted[j].From { + return sorted[i].From < sorted[j].From + } + return sorted[i].Local < sorted[j].Local + }) + // A consumer already carried by the grants loop, keyed (provision, module). When provider and + // consumer are co-located, `grantsFor` enumerates the same-node consumer too, so without this the + // module would be emitted a second time by the m.Contributes loop below — once full (with the + // grant's secret/as) and once partial — which is the duplicate seen in a co-located mesh.json. + granted := map[string]map[string]bool{} + for _, g := range sorted { + if g.From == "" { + // As above: nothing on that machine asks for this any more, so the provider is not + // told about it and withdraws the login on its next pass. + continue + } + as := holderAs(mcat.ConsumerIdentity(g.Consumer, mcat.IdentitySource(g.Slug, g.From)), g.Local) + derived, err := resolutionDerivedFor(r, g.Provision, as, g.From, g.Local, settings) + if err != nil { + return nil, err + } + out[g.Provision] = append(out[g.Provision], mcat.Contribution{ + From: g.From, Node: g.Consumer, At: g.At, Values: g.Values, Derived: derived, + // One holder per local name: the identity the consumer is known by, and the local name + // after it where the module keeps several (ADR 0094). Not a login any backend checks — + // a secret is not a login — so the identity limit does not apply to the suffix. + As: as, + Secret: grantPath(directories[g.Provision], g.Consumer, holderAs(g.From, g.Local)), + }) + if granted[g.Provision] == nil { + granted[g.Provision] = map[string]bool{} + } + granted[g.Provision][g.From] = true + } + for _, m := range modules { + for _, to := range sortedKeys(m.Contributes) { + // The grants loop already emitted this module's contribution to this provision, with its + // minted secret — emitting the partial copy again would duplicate it. A contribution with + // no grant (a reverse-proxy route names a host, not a credential) is not in `granted`, so + // it still reaches the provider from here. + if granted[to][m.Module] { + continue + } + // Settings reach a contribution the same way they reach a file. A route's hostname is + // exactly the kind of thing that differs between one mesh and the next, and a module + // that could not have it set would have to be edited to be reused. + values, err := resolutionComposed(r, m, to, m.Contributes[to], settings[m.Module], + m.Module+" contributing to "+to) + if err != nil { + return nil, err + } + // What the module shares of its bucket reaches the provider with the rest (novox/hq ADR 0468 §8). + if values, err = withShares(m, to, values); err != nil { + return nil, err + } + out[to] = append(out[to], mcat.Contribution{From: m.Module, Values: values}) + } + // Several contributions to one requirement (ADR 0094's sibling for `contributes`): an + // object store's data API and its console are two different public names from one module, + // not one. Never in `granted` — a route names a host, not a credential — so every local + // name always reaches the provider from here. + for _, to := range sortedKeys(m.ContributesMany) { + for _, local := range sortedKeys(m.ContributesMany[to]) { + values, err := resolutionComposed(r, m, to, m.ContributesMany[to][local], settings[m.Module], + m.Module+" contributing "+local+" to "+to) + if err != nil { + return nil, err + } + out[to] = append(out[to], mcat.Contribution{From: m.Module, Values: values}) + } + } + } + return out, nil +} + +// contributedOf is each contribution m makes to one requirement, composed as its provider receives it in a received +// file (contributions: the one of `contributes` with what the module shares of its bucket, then each of several by +// local name), with where m's machine is, encoded for PlanProvision.Contributed (issue 774): one composition, so a +// plan and a file cannot say different things. Nil where m contributes nothing to it. +// +// **Its port is the one the machine published** (atAssignedPort): a plan is read on whatever machine its provider runs, +// which cannot redirect the number as a received file on this machine is redirected (atMachinePort), so the plan +// carries the machine port already. route-proxy dialled gitea's 3000 for git (published at 20000) and grafana's 3000 +// on another machine (published at 20012) until it did (2026-10-31). +func resolutionContributedOf(r mcat.Resolution, m mcat.Manifest, to string, with mcat.Rendering) (json.RawMessage, error) { + layers := with.Settings[m.Module] + form := mcat.ContributedForm{At: r.At} + add := func(local string, values map[string]any) error { + values, err := resolutionAtAssignedPort(r, m, to, values, with) + if err != nil { + return err + } + raw, err := json.Marshal(values) + if err != nil { + return fmt.Errorf("%s contributing to %s: %w", m.Module, to, err) + } + form.Each = append(form.Each, mcat.ContributedOne{Local: local, Values: raw}) + return nil + } + if raw, ok := m.Contributes[to]; ok { + values, err := resolutionComposed(r, m, to, raw, layers, m.Module+" contributing to "+to) + if err != nil { + return nil, err + } + if values, err = withShares(m, to, values); err != nil { + return nil, err + } + if err := add("", values); err != nil { + return nil, err + } + } + for _, local := range sortedKeys(m.ContributesMany[to]) { + values, err := resolutionComposed(r, m, to, m.ContributesMany[to][local], layers, m.Module+" contributing "+local+" to "+to) + if err != nil { + return nil, err + } + if err := add(local, values); err != nil { + return nil, err + } + } + if len(form.Each) == 0 { + return nil, nil + } + return EncodeContributed(form) +} + +// atAssignedPort is a contribution of m with its port where this machine publishes it for m: the number +// `${port:}` gives m itself and its declaration publishes (machinePort, port_into.go), the placement's +// assignment where it holds one and the software's own number where it holds none (a bare port not yet moved, a fixed +// mapping, the host's network). What its provider dials, on this machine over loopback and from another at m's address. +// +// **Refused where a plan carries a port m does not listen on** (with.Planned): no machine port stands for it, so the +// provider would dial a number nothing on the machine is known to publish. Elsewhere (a declaration, a composition +// with no ports) such a port is left as it is, as machinePort leaves it. +func resolutionAtAssignedPort(r mcat.Resolution, m mcat.Manifest, to string, values map[string]any, with mcat.Rendering) (map[string]any, error) { + number, ok := asPort(values["port"]) + if !ok { + return values, nil + } + _, given := with.Given[m.Module][number] + _, placed := with.Ports[m.Module][number] + if !given && !placed { + if with.Planned { + return nil, fmt.Errorf("%s contributing to %s: its port %d on %s is none of the ports it listens on, so no "+ + "machine port stands for it", m.Module, to, number, r.Node) + } + return values, nil + } + return atMachinePort(values, m.Module, map[string]map[int]int{m.Module: {number: renderingMachinePort(with, m.Module, number)}}), nil +} + +// composed is one contribution as its provider receives it: settled with this node's settings, its +// endpoint's port filled in, and its names composed from the label. +// +// **One function, because two readers must agree.** The provider is told the names in its received +// file; the contributing module is told the same names in its own binding (novox/hq 04-ISSUES/122). +// Composing them twice, in two places, is how the proxy would come to serve one name while the +// module wrote another into its configuration. +func resolutionComposed(r mcat.Resolution, m mcat.Manifest, to string, raw map[string]any, layers []mcat.Layer, what string) ( + map[string]any, error) { + // Overridden, not merged: a setting changes a key the contribution declares and adds none. + // The provider reads the contribution as a contract, and a setting made for one of this + // module's files is no part of it (novox/hq 04-ISSUES/173). + values, err := overridden(raw, WithDefaults(m, layers), what) + if err != nil { + // A required value nothing sets is said in the words of the module's form, as a file's, a unit's and an owner's + // are (issue 1126, seed-8: de-spiegel in the config form was refused with ${setting:…} and `settings set`). + var unset *UnsetSettingError + if errors.As(err, &unset) && unset.Module == "" { + unset.Module = m.Module + unset.where = fmt.Sprintf("a contribution%s, in %q,", strings.TrimPrefix(what, m.Module+" contributing"), unset.field) + } + return nil, unsetInConfigWords(fmt.Errorf("%s: %w", what, err), m) + } + reaches, err := Reaches(m, layers) + if err != nil { + return nil, fmt.Errorf("%s: %w", what, err) + } + blocks, err := mcat.Endpoints(m, layers) + if err != nil { + return nil, fmt.Errorf("%s: %w", what, err) + } + portOfEndpoint(values, endpointPorts(m)) + composeName(values, r.PublicDomain, servingAt(r, to), reaches, endpointPorts(m), blocks) + return values, nil +} + +// ownNames is what a module is known by through what it contributes to one requirement — the names +// the mesh composed for it, and nothing else of the contribution. +// +// **The half a module could not learn** (novox/hq 04-ISSUES/122). A module contributes a label, the +// mesh joins it with this node's domains, and the provider serves the result — and the module itself +// was never told. Software that must know its own address (a login redirect, a canonical URL, an +// issuer) had it written into the manifest as a literal, which is a domain in a definition and wrong +// on every other machine. `${bound::name}` is the answer, from the same composition the +// provider receives. +// +// Several contributions to one requirement are keyed by their local name under `names`. +func resolutionOwnNames(r mcat.Resolution, m mcat.Manifest, to string, layers []mcat.Layer) (map[string]any, error) { + pick := func(values map[string]any) map[string]any { + names := map[string]any{} + for _, key := range []string{"name", "internal-name"} { + if v, ok := values[key].(string); ok && v != "" { + names[key] = v + } + } + return names + } + out := map[string]any{} + if raw, ok := m.Contributes[to]; ok { + values, err := resolutionComposed(r, m, to, raw, layers, m.Module+" contributing to "+to) + if err != nil { + return nil, err + } + for k, v := range pick(values) { + out[k] = v + } + } + if locals := m.ContributesMany[to]; len(locals) > 0 { + many := map[string]any{} + for _, local := range sortedKeys(locals) { + values, err := resolutionComposed(r, m, to, locals[local], layers, + m.Module+" contributing "+local+" to "+to) + if err != nil { + return nil, err + } + if names := pick(values); len(names) > 0 { + many[local] = names + } + } + if len(many) > 0 { + out["names"] = many + } + } + return out, nil +} + +// composeName joins a contribution's label with a node's public domain, and separately with its +// private one, in place (novox/hq ADR 0056). +// +// **The whole of what the mesh does with a route's name: join two given strings — twice.** A +// contribution carries a `label` — the subdomain its operator chose — and the node carries its +// public domain and its own private-network address; the granted names are `