From 9a12f2851f0b04223a24b09ca167e2ce310f79db Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:24:05 +0100 Subject: [PATCH] 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) + } + } + } +}