From 8a982716805bf145e9cb352975accbe96fb89c4c Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 1 Nov 2026 02:34:26 +0100 Subject: [PATCH] =?UTF-8?q?Move=20the=20judge's=20rules=20and=20their=20te?= =?UTF-8?q?sts=20from=20mesh-sdk=20into=20the=20indexer's=20judge=20(ADR?= =?UTF-8?q?=200578=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1585, S6-S8: 192 judge-only declarations (the strict parser, every manifest problem function, the tables) moved whole by an AST mover, methods becoming functions; 163 helpers composition keeps too are copied. 245 tests move with them, reading the captured catalogue through a copy of internal/beside. judge.WithTemplatesFrom and ReadTreeManifest judge what mesh-sdk's readers now only fill. mesh-delivery's own tests decode. Pinned to mesh-sdk 25a2cec. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- .../integration_test.go | 2 +- .../cmd/mesh-delivery-assigner/main_test.go | 2 +- .../mesh-delivery-assigner/seat_tools_test.go | 4 +- modules/mesh-delivery-assigner/go.mod | 2 +- modules/mesh-delivery-assigner/go.sum | 2 + .../internal/assign/compose_test.go | 2 +- .../internal/assign/composer_test.go | 5 +- .../mesh-delivery-assigner/manifest_test.go | 4 +- modules/mesh-delivery-builder/go.mod | 2 +- modules/mesh-delivery-builder/go.sum | 2 + modules/mesh-delivery-checker/go.mod | 2 +- modules/mesh-delivery-checker/go.sum | 2 + modules/mesh-delivery-indexer/go.mod | 2 +- modules/mesh-delivery-indexer/go.sum | 2 + .../internal/beside/beside.go | 134 + modules/mesh-delivery-indexer/judge/judge.go | 61 +- .../mesh-delivery-indexer/judge/judge_test.go | 9 + .../mesh-delivery-indexer/judge/reads-outside | 1 + modules/mesh-delivery-indexer/judge/rules.go | 8831 +++++++++++++++++ .../mesh-delivery-indexer/judge/rules_test.go | 6413 ++++++++++++ ...catalogue-835bf22b-claude-code.module.json | 249 + .../catalogue-835bf22b-dunst.module.json | 123 + .../catalogue-835bf22b-xterm.module.json | 236 + .../testdata/beside/CAPTURED | 27 + .../modules/adwaita/files/cursor-index.theme | 5 + .../modules/adwaita/files/env-gtk-theme | 1 + .../modules/adwaita/files/env-gtk2-rc-files | 1 + .../adwaita/files/env-qt-qpa-platformtheme | 1 + .../modules/adwaita/files/env-qt-select | 1 + .../adwaita/files/env-qt-style-override | 1 + .../modules/adwaita/files/env-xcursor-size | 1 + .../modules/adwaita/files/env-xcursor-theme | 1 + .../modules/adwaita/files/gtk-settings.ini | 9 + .../modules/adwaita/files/portals.conf | 11 + .../modules/adwaita/files/qt6ct.conf | 29 + .../modules/adwaita/files/xinitrc-normal | 10 + .../modules/adwaita/files/xresources-normal | 3 + .../modules/adwaita/module.json.captured | 176 + .../artifact-store-tools/files/store-names | 4 + .../artifact-store-tools/module.json.captured | 65 + .../files/bin/zephyrus-backlight | 33 + .../files/bin/zephyrus-display | 41 + .../files/bin/zephyrus-kbd-notify | 20 + .../files/bin/zephyrus-media | 27 + .../files/bin/zephyrus-notify | 12 + .../files/bin/zephyrus-session | 46 + .../files/bin/zephyrus-touchpad | 29 + .../asus-zephyrus-g14/module.json.captured | 229 + .../UPower.conf.d/50-asus-zephyrus-g14.conf | 11 + .../30-asus-zephyrus-g14-touchpad.conf | 12 + .../asus-zephyrus-g14/templates/after-wake | 7 + .../templates/display-session | 30 + .../templates/hotkeys-triggers | 10 + .../modprobe.d/g14-nvidia-power.conf | 16 + .../modprobe.d/video-brightness-switch.conf | 6 + .../asus-zephyrus-g14.conf | 7 + .../asus-zephyrus-g14-nvidia.conf | 7 + .../asus-zephyrus-g14-nvidia.conf | 7 + .../asus-zephyrus-g14-nvidia.conf | 7 + .../templates/udev/rules.d/90-backlight.rules | 5 + .../modules/audit-logger/module.json.captured | 69 + .../modules/avahi/module.json.captured | 43 + .../modules/baserow/files/config.json | 4 + .../modules/baserow/files/server.env | 7 + .../modules/baserow/module.json.captured | 158 + .../modules/betterbird/files/accounts.json | 3 + .../modules/betterbird/files/betterbird | 10 + .../betterbird/files/configuration.json | 6 + .../files/eu.betterbird.Betterbird.desktop | 25 + .../modules/betterbird/files/prefs.json | 3 + .../modules/betterbird/files/tags.json | 3 + .../modules/betterbird/module.json.captured | 253 + .../modules/blueman/module.json.captured | 37 + .../modules/bluetooth/module.json.captured | 53 + .../modules/brother/files/settings.json | 9 + .../modules/brother/files/store.json | 9 + .../modules/brother/module.json.captured | 280 + .../modules/ca-trust/files/anchor | 58 + .../ca-trust/files/mesh-ca-trust.service | 16 + .../modules/ca-trust/module.json.captured | 66 + .../modules/claude-code/files/agent-account | 1 + .../modules/claude-code/files/claude-agent | 13 + .../modules/claude-code/files/facts.json | 4 + .../modules/claude-code/files/settings.json | 10 + .../modules/claude-code/files/zsh-normal | 12 + .../modules/claude-code/module.json.captured | 247 + .../claude-licence-manager/files/database.url | 1 + .../files/settings.json | 7 + .../module.json.captured | 184 + .../clipmenu/files/bin/clipmenud-session | 41 + .../modules/clipmenu/files/bin/xsel | 20 + .../modules/clipmenu/files/env-cm-histlength | 1 + .../modules/clipmenu/files/env-cm-max-clips | 1 + .../modules/clipmenu/files/env-cm-selections | 1 + .../clipmenu/files/i3/50-clipmenu.conf | 5 + .../files/systemd/clipmenud-mesh.conf | 14 + .../modules/clipmenu/module.json.captured | 135 + .../modules/confluence/files/config.json | 1 + .../modules/confluence/module.json.captured | 76 + .../modules/cups/module.json.captured | 58 + .../modules/dbus/module.json.captured | 67 + .../modules/desk-channel/files/settings.json | 3 + .../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/files/dnsmasq.conf | 87 + .../modules/dnsmasq/files/mesh.conf | 14 + .../modules/dnsmasq/module.json.captured | 162 + .../docker-compose/module.json.captured | 40 + .../modules/docker/files/daemon.json | 1 + .../modules/docker/files/docker-prune.service | 15 + .../modules/docker/files/docker-prune.timer | 11 + .../modules/docker/files/log-rotation.conf | 5 + .../modules/docker/module.json.captured | 179 + .../mesh-catalog/modules/dunst/files/dunstrc | 110 + .../modules/dunst/files/i3/50-dunst.conf | 4 + .../modules/dunst/module.json.captured | 123 + .../modules/fail2ban/files/fail2ban | 7 + .../files/iptables-allports-dualchain.conf | 40 + .../modules/fail2ban/files/jail.local | 48 + .../fail2ban/files/mesh-own-addresses.json | 3 + .../modules/fail2ban/files/own-addresses | 0 .../modules/fail2ban/files/recidive.conf | 9 + .../modules/fail2ban/files/sshd.conf | 8 + .../modules/fail2ban/module.json.captured | 195 + .../mesh-catalog/modules/feh/files/fehbg | 6 + .../modules/feh/files/i3/50-feh.conf | 3 + .../modules/feh/files/xinitrc-normal | 2 + .../modules/feh/module.json.captured | 75 + .../modules/flatpak/module.json.captured | 42 + .../modules/fonts/files/50-mesh-fonts.conf | 40 + .../modules/fonts/module.json.captured | 70 + .../modules/forticlient/module.json.captured | 57 + .../modules/gitea/files/agent-teams.json | 1 + .../modules/gitea/files/config.json | 1 + .../modules/gitea/files/consumers.json | 1 + .../mesh-catalog/modules/gitea/files/origins | 1 + .../modules/gitea/files/repositories.json | 1 + .../modules/gitea/files/server.env | 7 + .../modules/gitea/module.json.captured | 547 + .../modules/gitlab/files/config.json | 1 + .../modules/gitlab/module.json.captured | 65 + .../modules/gnome-keyring/files/pam/login | 4 + .../modules/gnome-keyring/files/pam/passwd | 3 + .../modules/gnome-keyring/files/xinitrc-first | 5 + .../gnome-keyring/module.json.captured | 82 + .../modules/go/module.json.captured | 20 + .../modules/grafana/files/admin.secret | 1 + .../modules/grafana/files/config.json | 4 + .../modules/grafana/files/influxdb-api | 1 + .../modules/grafana/files/mesh-influxdb.yaml | 19 + .../modules/grafana/files/oidc-client.secret | 1 + .../modules/grafana/files/oidc.env | 13 + .../modules/grafana/module.json.captured | 225 + .../modules/hello-web/files/index.html | 1 + .../modules/hello-web/module.json.captured | 82 + .../modules/home-assistant/files/config.json | 1 + .../home-assistant/files/provisions.env | 4 + .../home-assistant/module.json.captured | 209 + .../modules/hostname/files/hostname | 1 + .../mesh-catalog/modules/hostname/files/hosts | 7 + .../modules/hostname/module.json.captured | 61 + .../mesh-catalog/modules/i3/files/config | 201 + .../modules/i3/files/env-xdg-current-desktop | 1 + .../modules/i3/files/env-xdg-session-desktop | 1 + .../mesh-catalog/modules/i3/files/wms/i3 | 9 + .../modules/i3/files/xinitrc-last | 2 + .../modules/i3/module.json.captured | 124 + .../i3status-rust/files/bin/i3bar-watchdog | 64 + .../i3status-rust/files/bin/i3status-updates | 63 + .../i3status-rust/files/bin/i3status-value | 13 + .../i3status-rust/files/bottom-bar.toml | 109 + .../files/i3/60-i3status-rust.conf | 34 + .../files/icons/custom-icons.toml | 135 + .../modules/i3status-rust/files/top-bar.toml | 37 + .../i3status-rust/files/xinitrc-normal | 3 + .../i3status-rust/module.json.captured | 126 + .../modules/icecast/files/config.json | 1 + .../modules/icecast/files/icecast.xml | 56 + .../modules/icecast/module.json.captured | 152 + .../modules/influxdb/files/config.json | 1 + .../modules/influxdb/files/server.env | 6 + .../modules/influxdb/module.json.captured | 198 + .../modules/invoicing/files/api.env | 9 + .../modules/invoicing/module.json.captured | 128 + .../modules/jira/files/config.json | 1 + .../modules/jira/module.json.captured | 75 + .../modules/kernel/files/huge-pages.sh | 84 + .../files/mesh-kernel-huge-pages.service | 25 + .../modules/kernel/module.json.captured | 78 + .../modules/keycloak/files/admin.env | 2 + .../modules/keycloak/files/config.json | 1 + .../modules/keycloak/files/consumers.json | 1 + .../modules/keycloak/files/database.env | 3 + .../modules/keycloak/files/hostname.env | 1 + .../modules/keycloak/module.json.captured | 245 + .../mesh-catalog/modules/lab/files/lab.env | 3 + .../modules/lab/module.json.captured | 121 + .../modules/lemurs/files/config.toml | 369 + .../mesh-catalog/modules/lemurs/files/xserver | 8 + .../modules/lemurs/module.json.captured | 84 + .../modules/letta/files/config.json | 3 + .../mesh-catalog/modules/letta/files/pgpass | 1 + .../modules/letta/files/server.env | 6 + .../mesh-catalog/modules/letta/files/start.sh | 23 + .../modules/letta/module.json.captured | 156 + .../local-model-consumer/files/openai.env | 3 + .../local-model-consumer/module.json.captured | 42 + .../modules/localization/files/locale.conf | 3 + .../modules/localization/files/vconsole.conf | 3 + .../modules/localization/module.json.captured | 61 + .../modules/logrotate/files/logrotate.conf | 32 + .../modules/logrotate/module.json.captured | 58 + .../modules/mailu/files/admin.env | 2 + .../modules/mailu/files/config.json | 1 + .../modules/mailu/files/database.env | 5 + .../modules/mailu/files/mailu.env | 78 + .../modules/mailu/files/secret.env | 1 + .../modules/mailu/module.json.captured | 772 ++ .../modules/marrytts/module.json.captured | 37 + .../modules/matrix/files/conduit.toml | 15 + .../modules/matrix/files/element.json | 53 + .../modules/matrix/module.json.captured | 257 + .../modules/memory-pressure/files/oomd.conf | 8 + .../memory-pressure/files/root-slice.conf | 5 + .../modules/memory-pressure/files/sysctl.conf | 9 + .../memory-pressure/files/user-manager.conf | 7 + .../memory-pressure/files/zram-generator.conf | 11 + .../memory-pressure/module.json.captured | 130 + .../modules/mesh-build-go/files/forge | 7 + .../modules/mesh-build-go/files/gitconfig | 2 + .../modules/mesh-build-go/files/known_hosts | 1 + .../mesh-build-go/module.json.captured | 148 + .../modules/mesh-build-node/files/forge | 7 + .../modules/mesh-build-node/files/gitconfig | 2 + .../modules/mesh-build-node/files/known_hosts | 1 + .../mesh-build-node/module.json.captured | 169 + .../modules/mesh-catalog/files/database.url | 1 + .../modules/mesh-catalog/module.json.captured | 98 + .../modules/mesh-cli/files/zsh-normal | 3 + .../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/files/settings.json | 147 + .../modules/mesh-issues/module.json.captured | 329 + .../modules/mesh-knowledge/files/database.url | 1 + .../mesh-knowledge/files/settings.json | 5 + .../mesh-knowledge/module.json.captured | 218 + .../mesh-mail-notifier/module.json.captured | 68 + .../mesh-mail-reader/files/callers.json | 3 + .../mesh-mail-reader/files/database-url | 1 + .../mesh-mail-reader/module.json.captured | 141 + .../modules/mesh-mail-web/files/oidc.json | 5 + .../mesh-mail-web/files/operators.json | 3 + .../mesh-mail-web/module.json.captured | 131 + .../modules/mesh-mail/files/accounts.json | 3 + .../modules/mesh-mail/files/database-url | 1 + .../modules/mesh-mail/files/grants.json | 3 + .../modules/mesh-mail/files/store.json | 9 + .../modules/mesh-mail/module.json.captured | 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/files/settings.json | 3 + .../modules/mesh-watcher/module.json.captured | 71 + .../modules/messenger/files/settings.json | 5 + .../modules/messenger/module.json.captured | 196 + .../mesh-catalog/modules/minio/files/root.env | 2 + .../modules/minio/module.json.captured | 188 + .../modules/model-usage/files/database.url | 1 + .../modules/model-usage/module.json.captured | 107 + .../modules/mongodb/files/server-root.secret | 1 + .../modules/mongodb/module.json.captured | 170 + .../modules/mosquitto/files/bootstrap.env | 7 + .../modules/mosquitto/files/mosquitto.conf | 20 + .../modules/mosquitto/module.json.captured | 194 + .../modules/mounts/files/mounts.conf | 11 + .../modules/mounts/module.json.captured | 160 + .../modules/mssql/files/backup.sql | 14 + .../mesh-catalog/modules/mssql/files/sa.env | 2 + .../modules/mssql/module.json.captured | 174 + .../modules/n8n/files/n8n-database.secret | 1 + .../mesh-catalog/modules/n8n/files/server.env | 12 + .../modules/n8n/module.json.captured | 173 + .../mesh-catalog/modules/nats/files/nats.conf | 46 + .../modules/nats/module.json.captured | 171 + .../modules/netcheck/module.json.captured | 57 + .../modules/networkmanager/files/50-mesh.conf | 63 + .../networkmanager/module.json.captured | 71 + .../nextcloud-client/module.json.captured | 38 + .../modules/nextcloud/files/config.json | 1 + .../modules/nextcloud/files/server.env | 14 + .../modules/nextcloud/module.json.captured | 168 + .../modules/nfs-server/files/50-mesh.conf | 12 + .../modules/nfs-server/files/shares.conf | 10 + .../modules/nfs-server/module.json.captured | 191 + .../nftables/files/mesh-filter.service | 14 + .../modules/nftables/files/mesh.conf | 5 + .../modules/nftables/module.json.captured | 91 + .../modules/nm-applet/module.json.captured | 37 + .../modules/node-env/files/50-mesh.conf | 4 + .../modules/node-env/files/environment.sh | 5 + .../modules/node-env/module.json.captured | 55 + .../modules/nodejs/module.json.captured | 25 + .../modules/nodered/files/config.json | 3 + .../modules/nodered/files/mqtt.env | 4 + .../modules/nodered/files/settings.js | 48 + .../modules/nodered/files/settings.json | 14 + .../modules/nodered/module.json.captured | 297 + .../modules/ollama/module.json.captured | 62 + .../modules/only-office/files/server.env | 5 + .../modules/only-office/module.json.captured | 173 + .../openai-consumer/module.json.captured | 61 + .../modules/openrazer/module.json.captured | 52 + .../files/mesh-pacman-weekly-upgrade.service | 12 + .../files/mesh-pacman-weekly-upgrade.timer | 11 + .../modules/pacman/files/pacman.conf | 29 + .../modules/pacman/files/reflector.conf | 8 + .../modules/pacman/module.json.captured | 136 + .../modules/photos-eef/module.json.captured | 56 + .../modules/photos-filip/module.json.captured | 56 + .../modules/picom/files/picom.conf | 38 + .../modules/picom/module.json.captured | 90 + .../polychromatic/files/display-session | 6 + .../polychromatic/module.json.captured | 40 + .../modules/postgres/files/consumers.json | 1 + .../modules/postgres/module.json.captured | 191 + .../modules/power/files/bin/power-moment | 90 + .../modules/power/module.json.captured | 408 + .../modules/power/templates/bar-battery | 9 + .../modules/power/templates/bar-power-draw | 6 + .../templates/mesh-power/moments/after-boot | 4 + .../templates/mesh-power/moments/after-wake | 4 + .../mesh-power/moments/before-shutdown | 4 + .../templates/mesh-power/moments/before-sleep | 4 + .../templates/mesh-power/moments/on-battery | 4 + .../templates/mesh-power/moments/on-mains | 4 + .../systemd/logind.conf.d/power.conf | 8 + .../system/hibernate.target.d/mesh-power.conf | 3 + .../hybrid-sleep.target.d/mesh-power.conf | 3 + .../system/mesh-power-after-boot.service | 13 + .../system/mesh-power-after-wake.service | 11 + .../system/mesh-power-before-shutdown.service | 18 + .../system/mesh-power-before-sleep.service | 11 + .../systemd/system/mesh-power-supply.service | 10 + .../system/sleep.target.d/mesh-power.conf | 3 + .../mesh-power.conf | 3 + .../system/suspend.target.d/mesh-power.conf | 3 + .../udev/rules.d/90-mesh-power.rules | 5 + .../modules/powerlevel10k/files/zsh-normal | 3 + .../powerlevel10k/module.json.captured | 49 + .../modules/records/files/config.json | 1 + .../mesh-catalog/modules/records/files/origin | 1 + .../modules/records/module.json.captured | 103 + .../modules/redis/files/redis.conf | 3 + .../modules/redis/module.json.captured | 154 + .../modules/restic/files/backups.conf | 2 + .../modules/restic/files/data.conf | 2 + .../modules/restic/files/retention.conf | 6 + .../modules/restic/module.json.captured | 196 + .../mesh-catalog/modules/rofi/files/bin/dmenu | 26 + .../modules/rofi/files/bin/rofi-launch | 39 + .../modules/rofi/files/bin/rofi-powermenu | 54 + .../modules/rofi/files/config.rasi | 54 + .../modules/rofi/files/i3/50-rofi.conf | 8 + .../rofi/files/themes/mesh-powermenu.rasi | 172 + .../modules/rofi/files/themes/mesh.rasi | 263 + .../modules/rofi/module.json.captured | 124 + .../modules/route-adapter/files/config.json | 6 + .../route-adapter/module.json.captured | 134 + .../modules/screen-lock/files/bin/screen-lock | 57 + .../modules/screen-lock/files/xinitrc-normal | 8 + .../modules/screen-lock/module.json.captured | 78 + .../modules/searxng/files/config.json | 1 + .../modules/searxng/files/settings.yml | 16 + .../modules/searxng/module.json.captured | 229 + .../modules/sensors/module.json.captured | 69 + .../modules/showcase/files/README.md | 6 + .../modules/showcase/files/marker.txt | 1 + .../modules/showcase/module.json.captured | 207 + .../modules/showcase/templates/showcase.env | 5 + .../modules/slack/files/display-session | 11 + .../modules/slack/module.json.captured | 41 + .../modules/snapd/module.json.captured | 31 + .../modules/ssh-client/files/config | 6 + .../modules/ssh-client/files/forge | 7 + .../modules/ssh-client/files/gitconfig | 2 + .../modules/ssh-client/files/known_hosts | 1 + .../modules/ssh-client/module.json.captured | 112 + .../modules/sshd/files/10-mesh.conf | 10 + .../modules/sshd/module.json.captured | 54 + .../modules/step-ca/files/config.json | 1 + .../modules/step-ca/files/init.env | 2 + .../modules/step-ca/module.json.captured | 106 + .../modules/sudo/files/10-mesh-operator | 9 + .../modules/sudo/module.json.captured | 47 + .../modules/supabase/files/analytics-env.env | 14 + .../supabase/files/analytics-reconcile.exs | 86 + .../modules/supabase/files/analytics-start.sh | 41 + .../modules/supabase/files/db-init/jwt.sql | 2 + .../modules/supabase/files/db-init/logs.sql | 6 + .../modules/supabase/files/db-init/pooler.sql | 6 + .../supabase/files/db-init/realtime.sql | 4 + .../modules/supabase/files/db-init/roles.sql | 7 + .../supabase/files/db-init/supabase.sql | 3 + .../supabase/files/db-init/webhooks.sql | 208 + .../modules/supabase/files/functions-env.env | 6 + .../supabase/files/functions-main/index.ts | 94 + .../modules/supabase/files/gotrue.env | 28 + .../modules/supabase/files/kong.yml | 242 + .../modules/supabase/files/meta-env.env | 6 + .../modules/supabase/files/pooler-env.env | 14 + .../modules/supabase/files/pooler.exs | 43 + .../modules/supabase/files/postgrest.conf | 7 + .../modules/supabase/files/realtime-env.env | 16 + .../modules/supabase/files/settings.json | 7 + .../modules/supabase/files/storage-env.env | 13 + .../modules/supabase/files/studio-env.env | 18 + .../modules/supabase/files/vector.yml | 258 + .../modules/supabase/module.json.captured | 659 ++ .../systemd-networkd/files/00-mesh0.network | 35 + .../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/files/50-mesh.conf | 6 + .../modules/time-sync/module.json.captured | 81 + .../modules/triggerhappy/files/service.conf | 8 + .../modules/triggerhappy/files/triggers.conf | 4 + .../modules/triggerhappy/module.json.captured | 81 + .../modules/umami/files/server.env | 3 + .../modules/umami/module.json.captured | 142 + .../modules/unifi/files/config.json | 4 + .../modules/unifi/module.json.captured | 203 + .../modules/website/module.json.captured | 60 + .../modules/xclip/module.json.captured | 35 + .../modules/xdg/files/mimeapps.list | 29 + .../modules/xdg/files/user-dirs.conf | 9 + .../modules/xdg/files/user-dirs.dirs | 17 + .../modules/xdg/files/user-dirs.locale | 1 + .../modules/xdg/module.json.captured | 97 + .../mesh-catalog/modules/xorg/files/xinitrc | 51 + .../modules/xorg/files/xresources | 13 + .../modules/xorg/module.json.captured | 134 + .../modules/xterm/files/env-terminal | 1 + .../modules/xterm/files/xresources-normal | 31 + .../modules/xterm/module.json.captured | 67 + .../mesh-catalog/modules/zfs/files/scrub.conf | 6 + .../modules/zfs/module.json.captured | 235 + .../zsh-autosuggestions/files/zsh-normal | 2 + .../zsh-autosuggestions/module.json.captured | 30 + .../zsh-syntax-highlighting/files/zsh-last | 3 + .../module.json.captured | 30 + .../mesh-catalog/modules/zsh/files/env-editor | 1 + .../modules/zsh/files/env-path-local-bin | 1 + .../modules/zsh/files/env-path-scripts | 1 + .../modules/zsh/files/env-path-scripts-bin | 1 + .../mesh-catalog/modules/zsh/files/env-visual | 1 + .../modules/zsh/files/env-xdg-config-home | 1 + .../mesh-catalog/modules/zsh/files/tools.conf | 3 + .../mesh-catalog/modules/zsh/files/zshenv | 9 + .../mesh-catalog/modules/zsh/files/zshrc | 60 + .../modules/zsh/module.json.captured | 160 + .../beside/mesh-catalog/predefined.json | 13 + .../apply/placement_guard.go.captured | 626 ++ modules/mesh-delivery-watchman/go.mod | 2 +- modules/mesh-delivery-watchman/go.sum | 2 + modules/mesh-grants/go.mod | 2 +- modules/mesh-grants/go.sum | 2 + modules/mesh-grants/grants_test.go | 4 +- 474 files changed, 40354 insertions(+), 26 deletions(-) create mode 100644 modules/mesh-delivery-indexer/internal/beside/beside.go create mode 100644 modules/mesh-delivery-indexer/judge/reads-outside create mode 100644 modules/mesh-delivery-indexer/judge/rules.go create mode 100644 modules/mesh-delivery-indexer/judge/rules_test.go create mode 100644 modules/mesh-delivery-indexer/judge/testdata/catalogue-835bf22b-claude-code.module.json create mode 100644 modules/mesh-delivery-indexer/judge/testdata/catalogue-835bf22b-dunst.module.json create mode 100644 modules/mesh-delivery-indexer/judge/testdata/catalogue-835bf22b-xterm.module.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/CAPTURED create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/cursor-index.theme create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk-theme create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk2-rc-files create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-qpa-platformtheme create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-select create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-style-override create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-size create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-theme create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/gtk-settings.ini create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/portals.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/qt6ct.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/xinitrc-normal create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/xresources-normal create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/artifact-store-tools/files/store-names create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/artifact-store-tools/module.json.captured create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-backlight create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-display create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-kbd-notify create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-media create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-notify create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-session create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-touchpad create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/after-wake create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/display-session create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/hotkeys-triggers create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/video-brightness-switch.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/udev/rules.d/90-backlight.rules create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/audit-logger/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/avahi/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/baserow/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/baserow/files/server.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/baserow/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/accounts.json create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/betterbird create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/configuration.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/eu.betterbird.Betterbird.desktop create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/prefs.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/tags.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/blueman/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/bluetooth/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/brother/files/settings.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/brother/files/store.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/brother/module.json.captured create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ca-trust/files/anchor create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ca-trust/files/mesh-ca-trust.service create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ca-trust/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/agent-account create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/claude-agent create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/facts.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/settings.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/zsh-normal create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-licence-manager/files/database.url create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-licence-manager/files/settings.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-licence-manager/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/bin/clipmenud-session create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/bin/xsel create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-histlength create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-max-clips create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-selections create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/i3/50-clipmenu.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/systemd/clipmenud-mesh.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/confluence/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/confluence/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/cups/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dbus/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/desk-channel/files/settings.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/desk-channel/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dev-tools/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/disk-load/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/distribution/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dmenu/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dnsmasq/files/dnsmasq.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dnsmasq/files/mesh.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dnsmasq/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker-compose/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/files/daemon.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.service create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.timer create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/files/log-rotation.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dunst/files/dunstrc create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dunst/files/i3/50-dunst.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dunst/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/fail2ban create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/iptables-allports-dualchain.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/jail.local create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/mesh-own-addresses.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/own-addresses create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/recidive.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/sshd.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/module.json.captured create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/feh/files/fehbg create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/feh/files/i3/50-feh.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/feh/files/xinitrc-normal create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/feh/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/flatpak/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fonts/files/50-mesh-fonts.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fonts/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/forticlient/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/agent-teams.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/consumers.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/origins create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/repositories.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/server.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitlab/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitlab/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/login create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/passwd create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gnome-keyring/files/xinitrc-first create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gnome-keyring/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/go/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/admin.secret create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/influxdb-api create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/mesh-influxdb.yaml create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/oidc-client.secret create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/oidc.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hello-web/files/index.html create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hello-web/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/home-assistant/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/home-assistant/files/provisions.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/home-assistant/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hostname/files/hostname create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hostname/files/hosts create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hostname/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/config create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-current-desktop create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-session-desktop create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/wms/i3 create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/xinitrc-last create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/module.json.captured create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3bar-watchdog create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-updates create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-value create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/bottom-bar.toml create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/i3/60-i3status-rust.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/icons/custom-icons.toml create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/top-bar.toml create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/xinitrc-normal create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/icecast/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/icecast/files/icecast.xml create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/icecast/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/influxdb/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/influxdb/files/server.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/influxdb/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/invoicing/files/api.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/invoicing/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/jira/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/jira/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/kernel/files/huge-pages.sh create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-huge-pages.service create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/kernel/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/admin.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/consumers.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/database.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/hostname.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lab/files/lab.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lab/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lemurs/files/config.toml create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lemurs/files/xserver create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lemurs/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/files/pgpass create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/files/server.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/files/start.sh create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/local-model-consumer/files/openai.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/local-model-consumer/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/localization/files/locale.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/localization/files/vconsole.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/localization/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/logrotate/files/logrotate.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/logrotate/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/admin.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/database.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/mailu.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/secret.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/marrytts/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/matrix/files/conduit.toml create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/matrix/files/element.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/matrix/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/oomd.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/root-slice.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/sysctl.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/user-manager.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/zram-generator.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-go/files/forge create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-go/files/gitconfig create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-go/files/known_hosts create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-go/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-node/files/forge create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-node/files/gitconfig create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-node/files/known_hosts create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-node/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-catalog/files/database.url create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-catalog/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-cli/files/zsh-normal create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-cli/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-config/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-delivery-gate/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-files/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-issues/files/settings.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-issues/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/database.url create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/settings.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-knowledge/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-notifier/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/callers.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/database-url create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-reader/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/oidc.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/operators.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-web/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/files/accounts.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/files/database-url create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/files/grants.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/files/store.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-provisioner-client/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-provisioner/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-reads/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-vault/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-watcher/files/settings.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-watcher/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/messenger/files/settings.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/messenger/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/minio/files/root.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/minio/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/model-usage/files/database.url create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/model-usage/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mongodb/files/server-root.secret create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mongodb/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mosquitto/files/bootstrap.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mosquitto/files/mosquitto.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mosquitto/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mounts/files/mounts.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mounts/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mssql/files/backup.sql create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mssql/files/sa.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mssql/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/n8n/files/n8n-database.secret create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/n8n/files/server.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/n8n/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nats/files/nats.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nats/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/netcheck/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/networkmanager/files/50-mesh.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/networkmanager/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nextcloud-client/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nextcloud/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nextcloud/files/server.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nextcloud/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nfs-server/files/50-mesh.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nfs-server/files/shares.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nfs-server/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nftables/files/mesh-filter.service create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nftables/files/mesh.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nftables/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nm-applet/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/node-env/files/50-mesh.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/node-env/files/environment.sh create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/node-env/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodejs/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/files/mqtt.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/files/settings.js create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/files/settings.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ollama/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/only-office/files/server.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/only-office/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/openai-consumer/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/openrazer/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/files/mesh-pacman-weekly-upgrade.service create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/files/mesh-pacman-weekly-upgrade.timer create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/files/pacman.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/files/reflector.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/photos-eef/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/photos-filip/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/picom/files/picom.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/picom/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/polychromatic/files/display-session create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/polychromatic/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/postgres/files/consumers.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/postgres/module.json.captured create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/files/bin/power-moment create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/bar-battery create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/bar-power-draw create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-boot create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-wake create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-shutdown create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-sleep create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-battery create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-mains create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/logind.conf.d/power.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hibernate.target.d/mesh-power.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-boot.service create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-wake.service create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-shutdown.service create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-sleep.service create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-supply.service create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/sleep.target.d/mesh-power.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend.target.d/mesh-power.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/udev/rules.d/90-mesh-power.rules create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/powerlevel10k/files/zsh-normal create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/powerlevel10k/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/records/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/records/files/origin create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/records/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/redis/files/redis.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/redis/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/restic/files/backups.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/restic/files/data.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/restic/files/retention.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/restic/module.json.captured create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/bin/dmenu create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-launch create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-powermenu create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/config.rasi create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/i3/50-rofi.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh-powermenu.rasi create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh.rasi create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/route-adapter/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/route-adapter/module.json.captured create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/screen-lock/files/bin/screen-lock create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/screen-lock/files/xinitrc-normal create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/screen-lock/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/searxng/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/searxng/files/settings.yml create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/searxng/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sensors/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/showcase/files/README.md create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/showcase/files/marker.txt create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/showcase/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/showcase/templates/showcase.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/slack/files/display-session create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/slack/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/snapd/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/files/config create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/files/forge create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/files/gitconfig create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/files/known_hosts create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sshd/files/10-mesh.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sshd/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/step-ca/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/step-ca/files/init.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/step-ca/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sudo/files/10-mesh-operator create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sudo/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/analytics-env.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/analytics-reconcile.exs create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/analytics-start.sh create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/jwt.sql create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/logs.sql create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/pooler.sql create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/realtime.sql create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/roles.sql create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/supabase.sql create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/webhooks.sql create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/functions-env.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/functions-main/index.ts create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/gotrue.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/kong.yml create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/meta-env.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/pooler-env.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/pooler.exs create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/postgrest.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/realtime-env.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/settings.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/storage-env.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/studio-env.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/vector.yml create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/systemd-networkd/files/00-mesh0.network create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/systemd-networkd/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/systemd-resolved/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/systemd/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/telegram/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/time-sync/files/50-mesh.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/time-sync/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/triggerhappy/files/service.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/triggerhappy/files/triggers.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/triggerhappy/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/umami/files/server.env create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/umami/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/unifi/files/config.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/unifi/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/website/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/xclip/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/xdg/files/mimeapps.list create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/xdg/files/user-dirs.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/xdg/files/user-dirs.dirs create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/xdg/files/user-dirs.locale create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/xdg/module.json.captured create mode 100755 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/xorg/files/xinitrc create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/xorg/files/xresources create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/xorg/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/xterm/files/env-terminal create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/xterm/files/xresources-normal create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/xterm/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zfs/files/scrub.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zfs/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh-autosuggestions/files/zsh-normal create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh-autosuggestions/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh-syntax-highlighting/files/zsh-last create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh-syntax-highlighting/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/files/env-editor create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/files/env-path-local-bin create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/files/env-path-scripts create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/files/env-path-scripts-bin create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/files/env-visual create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/files/env-xdg-config-home create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/files/tools.conf create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/files/zshenv create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/files/zshrc create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/module.json.captured create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/predefined.json create mode 100644 modules/mesh-delivery-indexer/testdata/beside/mesh-host/internal/apply/placement_guard.go.captured diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go index 63937d4..da1fc30 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go @@ -167,7 +167,7 @@ func saysWhatItKeeps(t *testing.T, b *bed, done <-chan error) { if err != nil { t.Fatal(err) } - m, err := catalogue.ParseManifest(raw) + m, err := catalogue.Decode(raw) if err != nil { t.Fatal(err) } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main_test.go index 54f98ec..0028f68 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main_test.go @@ -36,7 +36,7 @@ func TestOpensWhatItsManifestNames(t *testing.T) { if err != nil { t.Fatal(err) } - m, err := catalogue.ParseManifest(raw) + m, err := catalogue.Decode(raw) if err != nil { t.Fatal(err) } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go index 0340a67..1de1966 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go @@ -415,7 +415,7 @@ func wantsVerbs(t *testing.T) { mod, _ := delivery.DecodeModule(raw) mraw, _, _, _ := st.get(manifestsState, mod.Manifest) kept, _ := delivery.DecodeManifest(mraw) - man[name], _ = catalogue.ParseManifest([]byte(kept.JSON)) + man[name], _ = catalogue.Decode([]byte(kept.JSON)) } findings["module.gone.policy-split"] = []byte("{}") p.policySplits(man, nil) @@ -464,7 +464,7 @@ func readManifest() (catalogue.Manifest, error) { if err != nil { return catalogue.Manifest{}, err } - return catalogue.ParseManifest(raw) + return catalogue.Decode(raw) } // countingKV is a memKV that counts its puts. diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index 4aa3699..a6d6c62 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index 89f286b..4c2c5c5 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502 h1:XndvkzyA git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3 h1:an2f0MLx9OhaDLu5i51OMPBbuGwX+aoTzqOkFLMpVfE= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c h1:rlUCZ5ifLojqDUTkcCCcPrugTzV2RT6F6JbIVTJkaC8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-assigner/internal/assign/compose_test.go b/modules/mesh-delivery-assigner/internal/assign/compose_test.go index 86e217b..c8921b4 100644 --- a/modules/mesh-delivery-assigner/internal/assign/compose_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/compose_test.go @@ -42,7 +42,7 @@ func TestAChangeOfOneModuleComposesItsMachine(t *testing.T) { } // 1953/D7 (ADR 0565 §5, ADR 0502 §4): the group's own pull request's held value at its head is laid and named, judged // against the head's declaration; one of another pull request or another head is not laid. - web, err := catalogue.ParseManifest([]byte(`{"module":"web","version":"2","config":{"port":{"type":"number","default":80,"description":"the port, a number","runtime":true}}}`)) + web, err := catalogue.Decode([]byte(`{"module":"web","version":"2","config":{"port":{"type":"number","default":80,"description":"the port, a number","runtime":true}}}`)) if err != nil { t.Fatal(err) } diff --git a/modules/mesh-delivery-assigner/internal/assign/composer_test.go b/modules/mesh-delivery-assigner/internal/assign/composer_test.go index 8a07f1f..1985104 100644 --- a/modules/mesh-delivery-assigner/internal/assign/composer_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/composer_test.go @@ -40,9 +40,10 @@ func digestedMesh() Mesh { // webShares is a group of mesh-sdk and mesh-catalog whose catalogue member gives web the field only the SDK head reads: // the assigner's own reader refuses it (Unreadable), as the indexer's manifest is read in world.change. func webShares(members map[string]string) Change { - _, err := catalogue.ParseManifest([]byte(raws["d-web2"])) + // As an older reader refused it; the field set aside by the lenient decoder is named the same (ADR 0578 §6). + m, _ := catalogue.Decode([]byte(raws["d-web2"])) return Change{Group: "g", Heads: "0123456789ab", SDKHead: sdkHead, Manifests: map[string]catalogue.Manifest{}, - Unreadable: map[string]string{"web": err.Error()}, Digests: map[string]string{"web": "d-web2"}, Members: members} + Unreadable: map[string]string{"web": m.SetAside()}, Digests: map[string]string{"web": "d-web2"}, Members: members} } // run is the composer's step for a job, as the build module answers it: the request filled from the indexer's digests diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index f722280..405447b 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -16,9 +16,9 @@ func load(t *testing.T) catalogue.Manifest { if err != nil { t.Fatal(err) } - m, err := catalogue.ParseManifest(raw) + m, err := catalogue.Decode(raw) if err != nil { - t.Fatalf("mesh-sdk's manifest reader refuses the manifest: %v", err) + t.Fatalf("mesh-sdk cannot decode the manifest: %v", err) } return m } diff --git a/modules/mesh-delivery-builder/go.mod b/modules/mesh-delivery-builder/go.mod index fda5e23..5649805 100644 --- a/modules/mesh-delivery-builder/go.mod +++ b/modules/mesh-delivery-builder/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-builder go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-builder/go.sum b/modules/mesh-delivery-builder/go.sum index 89f286b..4c2c5c5 100644 --- a/modules/mesh-delivery-builder/go.sum +++ b/modules/mesh-delivery-builder/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502 h1:XndvkzyA git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3 h1:an2f0MLx9OhaDLu5i51OMPBbuGwX+aoTzqOkFLMpVfE= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c h1:rlUCZ5ifLojqDUTkcCCcPrugTzV2RT6F6JbIVTJkaC8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-checker/go.mod b/modules/mesh-delivery-checker/go.mod index 26976a3..3cb7a51 100644 --- a/modules/mesh-delivery-checker/go.mod +++ b/modules/mesh-delivery-checker/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-checker go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-checker/go.sum b/modules/mesh-delivery-checker/go.sum index 89f286b..4c2c5c5 100644 --- a/modules/mesh-delivery-checker/go.sum +++ b/modules/mesh-delivery-checker/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502 h1:XndvkzyA git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3 h1:an2f0MLx9OhaDLu5i51OMPBbuGwX+aoTzqOkFLMpVfE= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c h1:rlUCZ5ifLojqDUTkcCCcPrugTzV2RT6F6JbIVTJkaC8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-indexer/go.mod b/modules/mesh-delivery-indexer/go.mod index 89b2270..d20f0fd 100644 --- a/modules/mesh-delivery-indexer/go.mod +++ b/modules/mesh-delivery-indexer/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-indexer go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-indexer/go.sum b/modules/mesh-delivery-indexer/go.sum index 89f286b..4c2c5c5 100644 --- a/modules/mesh-delivery-indexer/go.sum +++ b/modules/mesh-delivery-indexer/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502 h1:XndvkzyA git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3 h1:an2f0MLx9OhaDLu5i51OMPBbuGwX+aoTzqOkFLMpVfE= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c h1:rlUCZ5ifLojqDUTkcCCcPrugTzV2RT6F6JbIVTJkaC8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-indexer/internal/beside/beside.go b/modules/mesh-delivery-indexer/internal/beside/beside.go new file mode 100644 index 0000000..d31ec1d --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/beside/beside.go @@ -0,0 +1,134 @@ +// Package beside is where a test finds another repository of the mesh it reads: the catalogue's manifests, +// the node-engine's genesis template (novox/hq issue 432), the SDK's conformance fixtures (issue 449). +// +// Copied from mesh-controller internal/beside at commit 6c2a8d62 (R4b, issue 1379); the one change is that Dir +// lays out the captured copy of a repository once per process, not once per test (see laidOut). In mesh-sdk the +// catalogue's tests read mesh-catalog and mesh-host only; the captured copy holds just what they read. +// +// A test used to read the checkout beside this one, `../../../mesh-catalog`, so its verdict depended on +// whatever sat on the machine running it: a stale or dirty checkout failed it on a desktop, and where none +// was beside it skipped and said nothing. Now there are two inputs, both named, and no third: +// +// - In a merge check, the build seat clones each core repository beside the one checked (the catalogue +// at its main, the node-engine at the commit the mesh runs) and says where in MESH_CHECK_BESIDE. A +// test judges against those clones, so agreement with the other repository is checked where +// `mesh/repo-check` runs; a repository missing there fails the test, never skips it. Which clones a +// check gets is chosen from the inventory: mesh-catalog by the source of the `nats` module, mesh-host +// by the source of `mesh-host`, and mesh-sdk as the controller's sibling at the commit the controller's +// go.mod pins. Each repository's tests read these siblings, and the checker places exactly these +// (mesh-delivery's checker, besideRules, follows this list): mesh-controller's read mesh-catalog, +// mesh-host and mesh-sdk; mesh-host's read mesh-catalog, mesh-sdk and mesh-controller (its step-nine +// test reads the controller's own manifest, novox/hq issue 1295); mesh-sdk's read mesh-catalog and +// mesh-host; mesh-catalog's read mesh-sdk and mesh-controller; mesh-delivery's read mesh-sdk and +// mesh-catalog. In a mesh where either module has no source repository nothing is +// cloned, and these tests fail loudly with "not beside this check": a cause in the setup, not in the +// change. And in a delivery group that holds a mesh-catalog pull request, these tests read the +// catalogue's main, not the group's head. +// - Anywhere else, the test judges against the copy captured in this repository's testdata/beside, at the +// commit testdata/beside/CAPTURED names. Never against a developer's own checkout: to judge one, set +// MESH_CHECK_BESIDE to the directory holding it, as the check does. +// +// The captured manifests are named module.json.captured, and put back as module.json in a directory of +// the test's own: a module.json anywhere in this repository is a module of it to the forge's announcer and +// the planner, and a merge would build and register a hundred copies of the catalogue's. +package beside + +import ( + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +// Env is where a merge check says the repositories cloned beside the one checked are (internal/builder's +// EnvBeside, repeated here so a test does not import the builder). +const Env = "MESH_CHECK_BESIDE" + +// CapturedSuffix is what a captured file's name carries that the repository's own does not. +const CapturedSuffix = ".captured" + +// Dir is the named repository a test reads — the clone beside a merge check, or the captured copy — and +// says which in the test's log. +func Dir(t testing.TB, repository string) string { + t.Helper() + if root := os.Getenv(Env); root != "" { + dir := filepath.Join(root, repository) + if _, err := os.Stat(dir); err != nil { + t.Fatalf("%s is not beside this check in %s=%s, so its agreement with this repository cannot be "+ + "judged here: %v", repository, Env, root, err) + } + t.Logf("judged against %s as cloned beside this check", dir) + return dir + } + from := filepath.Join(Captured(), repository) + if _, err := os.Stat(from); err != nil { + t.Fatalf("no captured copy of %s at %s: %v", repository, from, err) + } + dir, err := layOut(repository, from) + if err != nil { + t.Fatalf("the captured copy of %s could not be laid out: %v", repository, err) + } + t.Logf("judged against the copy of %s captured in testdata/beside (see CAPTURED); a merge check "+ + "judges it against the repository's own clone", repository) + return dir +} + +// laidOut is the captured copies already put back in place in this process, by repository. A copy is +// read-only to every test, so it is laid out once per process and not once per test: with a copy per test +// the catalogue's tests alone took 2.14 s, over mesh-sdk's unit bound of 2 s, and with one 0.81 s (measured +// 2026-10-20). The directories are in the temp directory, which the system clears; a process cannot remove +// them while another test may still read them. +var ( + laidOutMu sync.Mutex + laidOut = map[string]string{} +) + +func layOut(repository, from string) (string, error) { + laidOutMu.Lock() + defer laidOutMu.Unlock() + if dir, ok := laidOut[repository]; ok { + return dir, nil + } + root, err := os.MkdirTemp("", "beside-") + if err != nil { + return "", err + } + dir := filepath.Join(root, repository) + err = filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(from, path) + into := filepath.Join(dir, strings.TrimSuffix(rel, CapturedSuffix)) + if d.IsDir() { + return os.MkdirAll(into, 0o755) + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(into, raw, 0o644) + }) + if err != nil { + os.RemoveAll(root) + return "", err + } + laidOut[repository] = dir + return dir, nil +} + +// Catalogue is the catalogue's modules directory, as Dir finds the catalogue. +func Catalogue(t testing.TB) string { + t.Helper() + return filepath.Join(Dir(t, "mesh-catalog"), "modules") +} + +// Captured is this repository's testdata/beside, found from this file rather than from the working +// directory, so a test in any package reaches it. +func Captured() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "..", "..", "testdata", "beside") +} diff --git a/modules/mesh-delivery-indexer/judge/judge.go b/modules/mesh-delivery-indexer/judge/judge.go index b14c448..96cd4a2 100644 --- a/modules/mesh-delivery-indexer/judge/judge.go +++ b/modules/mesh-delivery-indexer/judge/judge.go @@ -13,10 +13,12 @@ package judge import ( "crypto/sha256" "encoding/hex" + json "encoding/json" "fmt" "io" "maps" "os" + filepath "path/filepath" "regexp" "slices" "sort" @@ -34,9 +36,9 @@ import ( func Manifest(raw []byte, predefined catalogue.PredefinedList) (m catalogue.Manifest, problems []string) { var err error if predefined != nil { - m, err = catalogue.ParseManifestWith(raw, predefined) + m, err = ParseManifestWith(raw, predefined) } else { - m, err = catalogue.ParseManifestForInputs(raw) + m, err = ParseManifestForInputs(raw) } if err != nil { return catalogue.Manifest{}, problemsOf(err) @@ -47,16 +49,16 @@ func Manifest(raw []byte, predefined catalogue.PredefinedList) (m catalogue.Mani // ForInputs is Manifest worded as the strict parser words a refusal: the indexer's read of a manifest for the inputs of its // fingerprint, which keeps a refused manifest's reason in its value (None2) as it always has. func ForInputs(raw []byte) (catalogue.Manifest, error) { - return catalogue.ParseManifestForInputs(raw) + return ParseManifestForInputs(raw) } -// listed is how the strict parser words a refusal of several problems, one per line after its header. -const listed = "this manifest cannot be used:\n - " +// listedRefusal is how the strict parser words a refusal of several problems, one per line after its header. +const listedRefusal = "this manifest cannot be used:\n - " // problemsOf splits the strict parser's refusal into its problems, one each. func problemsOf(err error) []string { text := err.Error() - if rest, ok := strings.CutPrefix(text, listed); ok { + if rest, ok := strings.CutPrefix(text, listedRefusal); ok { return strings.Split(rest, "\n - ") } return []string{text} @@ -129,9 +131,9 @@ func Between(shelf catalogue.Shelf, longestMachine int) []string { if longestMachine <= 0 { longestMachine = catalogue.DefaultLongestMachine } - between := catalogue.CatalogueProblems(shelf) - between = append(between, catalogue.IdentityProblems(shelf, longestMachine)...) - between = append(between, catalogue.DataProblems(shelf)...) + between := CatalogueProblems(shelf) + between = append(between, IdentityProblems(shelf, longestMachine)...) + between = append(between, DataProblems(shelf)...) sort.Strings(between) return slices.Compact(between) } @@ -234,3 +236,44 @@ func Identity() string { }) return identity } + +// WithTemplatesFrom is catalogue.WithTemplatesFrom judged: m with each template's text read from dir, then judged again +// as filled, against the list of predefined keys m was read with (a template's text can ask for what the manifest +// does not declare). The catalogue's reader only fills it (novox/hq ADR 0578 §3); judging the result is this judge's. +func WithTemplatesFrom(m catalogue.Manifest, dir string) (catalogue.Manifest, error) { + filled, err := catalogue.WithTemplatesFrom(m, dir) + if err != nil { + return catalogue.Manifest{}, err + } + // Judged as it composes without its build's templates archive, as the catalogue judged it before (template.go). + filled.Moved = false + raw, err := json.Marshal(filled) + if err != nil { + return catalogue.Manifest{}, err + } + again, err := parseManifest(raw, m.Predefined()) + if err != nil { + return catalogue.Manifest{}, err + } + again.Moved = m.Moved + return again, nil +} + +// ReadTreeManifest is the manifest in a module's directory of a repository tree judged as the merge gate judges it: +// strictly, against the list of predefined keys at the tree's root, then with each template's text read from its +// directory and judged again (WithTemplatesFrom). +func ReadTreeManifest(root, dir string) (catalogue.Manifest, error) { + list, err := catalogue.ReadPredefined(root) + if err != nil { + return catalogue.Manifest{}, err + } + raw, err := os.ReadFile(filepath.Join(dir, "module.json")) + if err != nil { + return catalogue.Manifest{}, err + } + m, err := ParseManifestWith(raw, list) + if err != nil { + return catalogue.Manifest{}, err + } + return WithTemplatesFrom(m, dir) +} diff --git a/modules/mesh-delivery-indexer/judge/judge_test.go b/modules/mesh-delivery-indexer/judge/judge_test.go index 81967ba..8243b33 100644 --- a/modules/mesh-delivery-indexer/judge/judge_test.go +++ b/modules/mesh-delivery-indexer/judge/judge_test.go @@ -8,6 +8,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/composer" + "git.novox.be/novox/mesh-sdk/go/overlay" ) // The judge (novox/hq ADR 0578 §1): a manifest alone is accepted, or refused with each problem; an unknown field is @@ -22,6 +23,8 @@ func TestTheJudge(t *testing.T) { {name: "a valid manifest", raws: map[string]string{"a": `{"module":"a","version":"1"}`}}, {name: "a refused manifest", raws: map[string]string{"a": `{"module":"a","version":"1","provides":[{"name":"amqp"}]}`}, want: map[string]string{"a": `provides "amqp", which is not a provision`}}, + {name: "a provision named as a credential's", raws: map[string]string{"a": `{"module":"a","version":"1","provides":[{"name":"secret-db","scope":"mesh"}]}`}, + want: map[string]string{"a": `secret-`}}, {name: "an unknown field", raws: map[string]string{"a": `{"module":"a","version":"1","a-field-from-later":1}`}, want: map[string]string{"a": `"a-field-from-later"`}}, {name: "a manifest under another name", raws: map[string]string{"b": `{"module":"a","version":"1"}`}, @@ -51,6 +54,12 @@ func TestTheJudge(t *testing.T) { } } } + // The private network's manifest, which the mesh provides itself, is accepted (moved from mesh-sdk overlay's test). + if raw, err := json.Marshal(overlay.Manifest()); err != nil { + t.Fatal(err) + } else if _, problems := Manifest(raw, nil); len(problems) > 0 { + t.Errorf("the provided private network is refused: %v", problems) + } shelf := catalogue.Shelf{"nats": {}, "mesh-nats": {}, "a.b": {}} for problem, want := range map[string][]string{ "nats removes a stream": {"nats"}, diff --git a/modules/mesh-delivery-indexer/judge/reads-outside b/modules/mesh-delivery-indexer/judge/reads-outside new file mode 100644 index 0000000..b2cdf54 --- /dev/null +++ b/modules/mesh-delivery-indexer/judge/reads-outside @@ -0,0 +1 @@ +These tests read files outside this directory: the catalogue and the node-engine captured in the module's testdata/beside (internal/beside). diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go new file mode 100644 index 0000000..c1c9d5c --- /dev/null +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -0,0 +1,8831 @@ +// The judge's rules, moved from mesh-sdk go/catalogue at 25a2cec (novox/hq ADR 0578 §4, issue 1585): every manifest +// problem function, the strict parser and its tables. Generated by moving each judge-only declaration whole, a method +// on a catalogue type becoming a function taking it first (manifestJailProblems(m) for m.jailProblems()); the helpers +// composition keeps too are copied at the end, so mesh-sdk keeps its own. Change them here; mesh-sdk carries none. + +package judge + +import ( + bytes "bytes" + json "encoding/json" + errors "errors" + fmt "fmt" + catalogue "git.novox.be/novox/mesh-sdk/go/catalogue" + math "math" + net "net" + url "net/url" + path "path" + filepath "path/filepath" + regexp "regexp" + slices "slices" + sort "sort" + strconv "strconv" + strings "strings" + template "text/template" + time "time" + unicode "unicode" + utf8 "unicode/utf8" +) + +// ---- moved from catalogue ---- + +// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192): +// a value is a path or a constant written with the references a container's environment may use +// for a place or a port, and never a secret's content or another module's binding — a secret +// reaches a tool as a file whose path is named. +func bundleEnvProblems(module string, a catalogue.Artifact) []string { + if len(a.Env) == 0 { + return nil + } + var problems []string + for _, word := range sortedKeys(a.Env) { + value := a.Env[word] + if bundleEnvWords[word] { + problems = append(problems, fmt.Sprintf( + "%s: %q gives itself %s, which the node's runtime sets for itself; a bundle is "+ + "given its own words beside the runtime's, never in place of them (novox/hq ADR 0192)", + module, a.Name, word)) + } + rest := ofPort.ReplaceAllString(dirRef.ReplaceAllString(value, ""), "") + if strings.Contains(rest, "${") { + problems = append(problems, fmt.Sprintf( + "%s: %q gives %s the value %q. A bundle's word is a path or a constant, written with "+ + "${dir:…} and ${port:…} only; a secret reaches a tool as a file the mesh places, "+ + "named by its path, never as its content (novox/hq ADR 0192)", + module, a.Name, word, value)) + } + } + return problems +} + +// knownSystem is whether the mesh builds for it. +func knownSystem(system string) bool { + want := strings.ToLower(strings.TrimSpace(system)) + for _, s := range systems { + if s == want { + return true + } + } + return false +} + +// notAPublicHost says why a host is not a public vendor's name, or "". +func notAPublicHost(host string) string { + h := strings.ToLower(strings.TrimSuffix(host, ".")) + if net.ParseIP(h) != nil { + return "an address rather than a name" + } + if !strings.Contains(h, ".") { + return "a name with no domain" + } + for _, s := range reservedSuffixes { + if strings.HasSuffix(h, s) { + return "a name under " + s + ", which is nobody's public domain" + } + } + return "" +} + +// pathsProblems is what is wrong with an artifact's `paths` (novox/hq ADR 0499 clause 1): named on a kind whose build +// source is derived or named otherwise, or a path that leaves the module's directory, is its whole directory, or is a +// pattern. +func pathsProblems(module string, a catalogue.Artifact) []string { + if a.Paths == nil { + return nil + } + switch { + case a.Kind == catalogue.ArtifactBundle && a.Language == "go": + return []string{fmt.Sprintf("%s: %q is a Go program and names paths: its build source is derived from the "+ + "package it compiles (novox/hq ADR 0267 clause 1, ADR 0499 clause 1)", module, a.Name)} + case a.Kind != catalogue.ArtifactBundle && a.Kind != catalogue.ArtifactPackage: + return []string{fmt.Sprintf("%s: %q is a %q and names paths; only a package or a bundle in a language other "+ + "than Go names the paths it is built from (novox/hq ADR 0499 clause 1)", module, a.Name, a.Kind)} + case len(a.Paths) == 0: + return []string{fmt.Sprintf("%s: %q names no path in paths; leave the word out to build from the module's "+ + "directory", module, a.Name)} + } + var problems []string + for _, p := range a.Paths { + clean := path.Clean(p) + switch { + case strings.TrimSpace(p) == "": + problems = append(problems, fmt.Sprintf("%s: %q names an empty path", module, a.Name)) + case strings.HasPrefix(p, "/") || clean == ".." || strings.HasPrefix(clean, "../"): + problems = append(problems, fmt.Sprintf("%s: %q names the path %q, which leaves the module's directory", + module, a.Name, p)) + case clean == ".": + problems = append(problems, fmt.Sprintf("%s: %q names the module's whole directory (%q) as a path; leave "+ + "the word out for that", module, a.Name, p)) + case strings.ContainsAny(p, "*?[]{}\\"): + problems = append(problems, fmt.Sprintf("%s: %q names %q, which is a pattern; paths names files and "+ + "directories", module, a.Name, p)) + } + } + return problems +} + +// spokenSystems is the list as a refusal says it, so a reader is one edit from right. +func spokenSystems() string { + return strings.Join(systems, ", ") +} + +// undeliveredBundles says which of a module's bundles nothing would ever put on a machine (novox/hq +// 04-ISSUES/216). A bundle reaches a machine three ways: the node's runtime serves it (it says +// `loads`, or its module declares `tools`), a resource names it (a process, a step, an archive), or +// it is the runtime itself. One reached by none of them was built, recorded and pushed as success, +// and was simply absent — seven modules' tools went missing that way on 2026-10-03. Refused here, +// naming the field that would deliver it. +func undeliveredBundles(m catalogue.Manifest) []string { + if m.Build == nil || m.Module == catalogue.RuntimeModule { + return nil + } + named := runByAResource(m) + var problems []string + for _, a := range m.Build.Artifacts { + if a.Kind != catalogue.ArtifactBundle || named[a.Name] || len(a.Loads) > 0 || len(m.Tools) > 0 { + continue + } + problems = append(problems, fmt.Sprintf( + "%s: the bundle %q would be built and never reach a machine: nothing loads it, runs it or "+ + "unpacks it. A tools bundle says `loads` (the entrypoints the node's runtime serves) or its "+ + "module lists its `tools`; a daemon or a step is a resource naming it (novox/hq 04-ISSUES/216)", + m.Module, a.Name)) + } + return problems +} + +// upstreamFileProblems is what is wrong with an archive pinned by URL (novox/hq ADR 0316 §2, §6): it is an +// archive, fetched over https, pinned by a sha256 and a version, and it names no directory of the repository +// besides; a file name or a directory within is a plain path that stays where it is put. +func upstreamFileProblems(module string, a catalogue.Artifact) []string { + pinned := a.URL != "" || a.SHA256 != "" || a.Version != "" || a.File != "" || a.Within != "" + if !pinned { + return nil + } + var problems []string + say := func(format string, args ...any) { + problems = append(problems, fmt.Sprintf("%s: %q ", module, a.Name)+fmt.Sprintf(format, args...)) + } + if a.Kind != catalogue.ArtifactArchive { + say("is a %q and names an upstream file (url, sha256, version, file, within): only an archive is "+ + "mirrored from one (novox/hq ADR 0316)", a.Kind) + return problems + } + if a.URL == "" { + say("names a sha256, a version, a file or a directory within, and no url to fetch it from") + return problems + } + if a.From != "" { + say("is built from %q and fetched from %s: an archive is one or the other", a.From, a.URL) + } + if u, err := url.Parse(a.URL); err != nil || u.Scheme != "https" || u.Host == "" { + say("is fetched from %q, and an upstream file is fetched over https from a named host", a.URL) + } else if why := notAPublicHost(u.Hostname()); why != "" { + say("is fetched from %s, %s: an upstream file is somebody else's release, published under a public name", a.URL, why) + } + if !sha256Hex.MatchString(a.SHA256) { + say("is fetched from %s without a pinned sha256 (64 lower-case hexadecimal characters): what that "+ + "address serves changes under the mesh, and only the digest says it is what was reviewed", a.URL) + } + if strings.TrimSpace(a.Version) == "" { + say("pins %s and says no version, so a person reading the pin cannot tell which release it is", a.URL) + } + if a.File != "" && a.Within != "" { + say("names a file (%q) and a directory within (%q): a file is kept whole, a directory is taken out "+ + "of an archive", a.File, a.Within) + } + for field, p := range map[string]string{"file": a.File, "within": a.Within} { + if p == "" { + continue + } + clean := path.Clean(p) + if clean != p || strings.HasPrefix(p, "/") || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || + (field == "file" && strings.Contains(p, "/")) { + say("names %s %q, which is not a plain relative %s", field, p, map[string]string{ + "file": "file name", "within": "directory"}[field]) + } + } + return problems +} + +// checkBuild is the manifest's own account of what it builds. +func buildProblems(b *catalogue.Build, module string) []string { + if b == nil { + return nil + } + var problems []string + seen := map[string]bool{} + for _, a := range b.Artifacts { + if a.Name == "" { + problems = append(problems, module+" builds an artifact with no name") + continue + } + if seen[a.Name] { + problems = append(problems, fmt.Sprintf( + "%s builds two artifacts called %q, and a resource naming it could mean either", + module, a.Name)) + } + seen[a.Name] = true + switch a.Kind { + case catalogue.ArtifactImage, catalogue.ArtifactArchive, catalogue.ArtifactUpstream, catalogue.ArtifactBundle, catalogue.ArtifactPackage: + default: + problems = append(problems, fmt.Sprintf( + "%s: %q is a %q, and an artifact is %q, %q, %q or %q", + module, a.Name, a.Kind, catalogue.ArtifactImage, catalogue.ArtifactArchive, catalogue.ArtifactUpstream, + catalogue.ArtifactBundle+", "+catalogue.ArtifactPackage)) + } + // **A bundle is built from the module itself, so it says a language instead.** Everything + // else names what it is built from: a Dockerfile, a directory, somebody else's reference. + // A bundle's source is the module's own directory by definition, and what it needs to say + // is which compiler — because the mesh chooses that, and cannot choose for a module that + // has not said. + if len(a.External) > 0 && (a.Kind != catalogue.ArtifactBundle || a.Language != "typescript") { + problems = append(problems, fmt.Sprintf( + "%s: %q names packages it keeps external, and only a TypeScript bundle is bundled into "+ + "one file with some kept out (novox/hq ADR 0193)", module, a.Name)) + } + if len(a.Env) > 0 && a.Kind != catalogue.ArtifactBundle { + problems = append(problems, fmt.Sprintf( + "%s: %q is a %q and says what it is given (env). Only a bundle the node's runtime "+ + "serves is given words (novox/hq ADR 0192); a container says its own environment", + module, a.Name, a.Kind)) + } + if a.Kind == catalogue.ArtifactBundle || a.Kind == catalogue.ArtifactPackage { + // **Except for a language that compiles to a binary, where it names which one** + // (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own + // directory compiled whole, and naming a source would be describing its own build. A + // repository written in a compiled language holds several commands — the host and its + // bootstrap live in one, and the mesh needs the host — and "the module's own directory" + // is then not a package at all. So the compiled case may say which package, and says + // the module root by saying nothing. + if a.From != "" && !compilesToABinary(a.Language) { + problems = append(problems, fmt.Sprintf( + "%s: %q is a bundle and names what it is built from (%q). A bundle is built "+ + "from the module's own directory; what it says is the language", + module, a.Name, a.From)) + } + if strings.TrimSpace(a.Language) == "" { + problems = append(problems, fmt.Sprintf( + "%s: %q is a bundle and says no language, so nothing can choose a compiler "+ + "for it", module, a.Name)) + } + problems = append(problems, bundleEnvProblems(module, a)...) + // What the runtime loads is among what was compiled (ADR 0175): a name here that is + // not an entrypoint is a file the bundle does not contain, and the runtime would + // fail to import it on every machine rather than here. + for _, load := range a.Loads { + found := false + for _, e := range a.Entrypoints { + found = found || e == load + } + // A bundle compiled to a binary is one executable: the runtime loads that or nothing + // (novox/hq ADR 0193). + if bin := catalogue.BinaryOf(a); bin != "" { + found = load == bin + } + if !found { + problems = append(problems, fmt.Sprintf( + "%s: %q says the runtime loads %q, which is not among its entrypoints — "+ + "what is loaded is compiled, so it is named there too", module, a.Name, load)) + } + } + // **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary + // is pinned to one operating system at link time so a host refuses to touch a machine + // it was not built for (novox/hq ADR 0005); an artifact that says nothing would be + // compiled for whatever the build machine happened to be, which reads as portable and + // is not. + if compiled := compilesToABinary(a.Language); compiled && strings.TrimSpace(a.System) == "" { + problems = append(problems, fmt.Sprintf( + "%s: %q is compiled to a binary and says no system, so it would be built for "+ + "whatever the build machine happens to be. Declare one artifact per "+ + "system: %s", module, a.Name, spokenSystems())) + } else if !compiled && strings.TrimSpace(a.System) != "" { + problems = append(problems, fmt.Sprintf( + "%s: %q names the system %q and is written in %q, which compiles to code that "+ + "runs anywhere — a system that decides nothing reads as though it did", + module, a.Name, a.System, a.Language)) + } else if compiled && !knownSystem(a.System) { + problems = append(problems, fmt.Sprintf( + "%s: %q is built for %q, and a system is %s", + module, a.Name, a.System, spokenSystems())) + } + } else { + if a.From == "" && a.URL == "" { + problems = append(problems, fmt.Sprintf( + "%s: %q says nothing about what it is built from", module, a.Name)) + } + if a.Language != "" { + problems = append(problems, fmt.Sprintf( + "%s: %q is a %q and names a language. Only a bundle is compiled by the mesh; "+ + "everything else brings its own recipe", module, a.Name, a.Kind)) + } + } + if a.Compiles != "" { + if a.Kind != catalogue.ArtifactImage { + problems = append(problems, fmt.Sprintf( + "%s: %q is a %q and names a Go package it compiles. Only an image's recipe is "+ + "told which; a bundle names its package in from (novox/hq ADR 0267)", module, a.Name, a.Kind)) + } else if c := strings.TrimSpace(a.Compiles); strings.HasPrefix(c, "/") || c == ".." || + strings.HasPrefix(c, "../") || strings.Contains(c, "/../") || strings.HasSuffix(c, "/..") { + problems = append(problems, fmt.Sprintf( + "%s: %q compiles %q, which leaves the tree it is built in", module, a.Name, a.Compiles)) + } + } + // An upstream image is named, not read from the repository, so the path rule does not + // apply to it — and applying it anyway would refuse every reference with a registry host + // in it. + if a.Kind != catalogue.ArtifactUpstream && + (strings.HasPrefix(a.From, "/") || strings.Contains(a.From, "..")) { + // A build reads its own repository and nothing else. A path leaving it would make + // what gets built depend on whatever happens to be on the machine building it. + problems = append(problems, fmt.Sprintf( + "%s: %q is built from %q, which is outside its own repository", + module, a.Name, a.From)) + } + if a.Kind == catalogue.ArtifactUpstream && !strings.Contains(a.From, ":") { + // Without a tag or digest, what gets mirrored is whatever `latest` means today, and + // a module pinned to that is not pinned. + problems = append(problems, fmt.Sprintf( + "%s: %q mirrors %q, which names no tag or digest", module, a.Name, a.From)) + } + problems = append(problems, upstreamFileProblems(module, a)...) + problems = append(problems, pathsProblems(module, a)...) + } + return problems +} + +var bundleEnvWords = map[string]bool{ + catalogue.RuntimeToolModules: true, catalogue.RuntimeBrokerFile: true, catalogue.RuntimeOperatorAccount: true, + catalogue.RuntimeOperatorHome: true, catalogue.RuntimeToolEnv: true, catalogue.RuntimeAgentAccount: true, catalogue.RuntimeAgentHome: true, +} + +var reservedSuffixes = []string{".internal", ".local", ".lan", ".home", ".corp", ".localhost", ".home.arpa", + ".test", ".invalid", ".example", ".intranet", ".private"} + +var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`) + +var systems = []string{"alpine", "android", "arch"} + +// busyProblems is everything wrong with a manifest's `busy` fields, in the manifest's words: a command on +// one line, a timeout within its bounds, and only on a container that stays up. +func busyProblems(m catalogue.Manifest) []string { + var problems []string + for _, r := range m.Resources { + b, has, read := catalogue.ReadBusy(r) + if !has { + continue + } + where := m.Module + ": " + fmt.Sprint(r["id"]) + for _, p := range read { + problems = append(problems, m.Module+": "+p) + } + if fmt.Sprint(r["type"]) != "container" || !catalogue.LongRunning(r) { + problems = append(problems, fmt.Sprintf("%s declares busy and is not a container that stays up: busy is "+ + "asked of a running container before it is replaced (ADR 0335)", where)) + } + if b.Command == "" { + problems = append(problems, where+"'s busy names no command: one run inside the container, exiting 0 "+ + "while it is busy") + } + if strings.ContainsAny(b.Command, "\n\r") { + problems = append(problems, where+"'s busy command is one line") + } + } + return problems +} + +// AttributeFits is whether an item's attribute is of the shape its collection allows: words of lower case, except +// the environment's `name`, which is a variable's name (design 57 L3, issue 795). The same rule is mesh-catalog's +// provisioner client's, and the inventory's. +func attributeFits(collection, k, v string) bool { + if collection == catalogue.EnvironmentCollection && k == "name" { + return variableName.MatchString(v) + } + return attributeKey.MatchString(k) && attributeValue.MatchString(v) +} + +// collectionProblems is what is wrong with a module's collections and items, from the manifest alone. +func collectionProblems(m catalogue.Manifest) []string { + var problems []string + declared := map[string]bool{} + for i, c := range m.Collections { + switch { + case !collectionName.MatchString(c.Name): + problems = append(problems, fmt.Sprintf("%s's collection %d is %q, which is not a collection's name: "+ + "words of lower-case letters, digits and hyphens, joined by colons", m.Module, i+1, c.Name)) + case declared[c.Name]: + problems = append(problems, fmt.Sprintf("%s collects %s twice", m.Module, c.Name)) + } + if _, old, _ := oldCollection(c.Name, false); old { + problems = append(problems, fmt.Sprintf("%s collects %s, which is read from its placeholder: a module "+ + "places it as ${%s} where it holds the seat that places it, and declares nothing", m.Module, c.Name, + c.Name)) + } + if strings.ContainsAny(c.Comment, "\n\r") { + problems = append(problems, fmt.Sprintf("%s's collection %s has a comment holding a line break", + m.Module, c.Name)) + } + declared[c.Name] = true + } + // A collection declared and placed nowhere is a word read by nothing; judged where every file's text is here. + if !m.Templated() { + named := map[string]bool{} + for _, r := range m.Resources { + if text, ok := r["content"].(string); ok { + for _, f := range ofCollected.FindAllStringSubmatch(text, -1) { + base, _ := splitSelector(f[1]) + named[base] = true + } + } + } + for _, c := range m.Collections { + if !named[c.Name] && collectionName.MatchString(c.Name) { + problems = append(problems, fmt.Sprintf("%s collects %s and no file of its places it as "+ + "${collected:%s}", m.Module, c.Name, c.Name)) + } + } + } + contributed := map[string]bool{} + for i, it := range m.Items { + where := fmt.Sprintf("%s's item %d", m.Module, i+1) + if !collectionName.MatchString(it.Collection) { + problems = append(problems, fmt.Sprintf("%s is to %q, which is not a collection's name", where, + it.Collection)) + } else if _, old, wrong := oldCollection(it.Collection, true); old && wrong != nil { + problems = append(problems, fmt.Sprintf("%s: %v", where, wrong)) + } + switch { + case it.Name != "" && !itemName.MatchString(it.Name): + problems = append(problems, fmt.Sprintf("%s: the name %q is not a name: lower-case letters, digits and "+ + "hyphens", where, it.Name)) + case contributed[itemKey(it)]: + problems = append(problems, fmt.Sprintf("%s contributes to %s twice; a module contributes one item to "+ + "a collection, or several each with a name of its own", m.Module, itemKey(it))) + case it.Name == "" && slices.ContainsFunc(m.Items, func(o catalogue.Item) bool { + return o.Collection == it.Collection && o.Name != "" + }): + problems = append(problems, fmt.Sprintf("%s contributes several items to %s and %s has no name; each "+ + "of several items has a name of its own", m.Module, it.Collection, where)) + } + contributed[itemKey(it)] = true + problems = append(problems, itemAttributeProblems(m.Module, where, it)...) + if it.Collection == catalogue.EnvironmentCollection { + problems = append(problems, environmentItemProblems(where, it)...) + } + switch { + case it.Template == "" && it.Content == "": + problems = append(problems, fmt.Sprintf("%s names no template: an item is a template of the module's "+ + "own, \"template\": \"%s/\" (novox/hq ADR 0349 §13)", where, m.TemplatesDir())) + case it.Template != "" && it.Content != "": + problems = append(problems, fmt.Sprintf("%s names its template and says its content too", where)) + case it.Template != "": + if wrong := itemTemplateProblem(m, it.Template); wrong != "" { + problems = append(problems, fmt.Sprintf("%s: %s", where, wrong)) + } + // **Filled by the provisioner's client alone**: the controller renders no item. + if !m.Moved { + problems = append(problems, fmt.Sprintf("%s: an item is filled by the provisioner's client of the "+ + "node its contributor is placed on, so a module contributing one is marked moved "+ + "(\"moved\": true; novox/hq ADR 0349 §13, §17)", where)) + } + } + if it.Content != "" { + problems = append(problems, itemTextProblems(m.Module, it)...) + } + } + return problems +} + +// environmentItemProblems is what is wrong with an item of the environment collection: typed by `kind`, a variable +// with its `name`, a PATH entry `at` the start or the end, and no attribute besides `order`. +func environmentItemProblems(where string, it catalogue.Item) []string { + var problems []string + for _, k := range sortedKeys(it.Attributes) { + if !slices.Contains([]string{"kind", "name", "at", catalogue.OrderAttribute}, k) { + problems = append(problems, fmt.Sprintf("%s: %s is typed by kind, name and at, and %q is none of them", + where, it.Collection, k)) + } + } + switch kind := it.Attributes["kind"]; kind { + case "variable": + if it.Attributes["name"] == "" || it.Attributes["at"] != "" { + problems = append(problems, fmt.Sprintf("%s: a variable names itself with name= and has no at", where)) + } else if it.Attributes["name"] == "PATH" { + problems = append(problems, fmt.Sprintf("%s: PATH is not a variable; a PATH entry is kind=path", where)) + } + case "path": + if it.Attributes["name"] != "" || (it.Attributes["at"] != catalogue.PathAtStart && it.Attributes["at"] != catalogue.PathAtEnd) { + problems = append(problems, fmt.Sprintf("%s: a PATH entry is at=%s or at=%s and has no name", where, + catalogue.PathAtStart, catalogue.PathAtEnd)) + } + default: + problems = append(problems, fmt.Sprintf("%s: an item of %s is kind=variable or kind=path, not %q", where, + it.Collection, kind)) + } + return problems +} + +// itemAttributeProblems is what is wrong with an item's attributes and capability. The attributes of an item of a kind +// received as data are the fields its shape places by (and `order`), each with a value the shape takes. +func itemAttributeProblems(module, where string, it catalogue.Item) []string { + var problems []string + for _, k := range sortedKeys(it.Attributes) { + v := it.Attributes[k] + switch { + case !attributeKey.MatchString(k): + problems = append(problems, fmt.Sprintf("%s: the attribute %q is not a name: lower-case letters, digits "+ + "and hyphens", where, k)) + continue + case !attributeFits(it.Collection, k, v): + problems = append(problems, fmt.Sprintf("%s: %s=%q is not a value: lower-case letters, digits, hyphens, "+ + "dots and underscores (an environment variable's name, letters, digits and underscores)", where, k, v)) + continue + } + if n, err := strconv.Atoi(v); k == catalogue.OrderAttribute && (err != nil || n < 0 || n > 99) { + problems = append(problems, fmt.Sprintf("%s: order=%q is not a whole number from 0 to 99", where, v)) + } + } + if namespace, key, _ := strings.Cut(it.Collection, ":"); namespace == "contribution" { + seat, kind, _ := strings.Cut(key, ":") + if _, r, ok := receivable(seat, kind); ok && r.Shape != nil { + for _, k := range sortedKeys(it.Attributes) { + var place *catalogue.PlaceField + for i := range r.Shape.Places { + if r.Shape.Places[i].Field == k { + place = &r.Shape.Places[i] + } + } + switch { + case k == catalogue.OrderAttribute: + case place == nil: + problems = append(problems, fmt.Sprintf("%s: %s is placed by %s, and %q is none of them", where, + it.Collection, placeFields(r), k)) + case !oneOf(place.Values, it.Attributes[k]): + problems = append(problems, fmt.Sprintf("%s: %s=%s; %s is one of %s", where, k, it.Attributes[k], k, + strings.Join(place.Values, ", "))) + } + } + } + } + switch { + case it.IfCapability == "": + case !capabilityName.MatchString(it.IfCapability): + problems = append(problems, fmt.Sprintf("%s: if-capability %q is not a capability's name", where, it.IfCapability)) + case !oneOf(catalogue.KnownCapabilities, it.IfCapability): + problems = append(problems, fmt.Sprintf("%s: if-capability %q is one no machine reports; the node-engine "+ + "detects %s", where, it.IfCapability, strings.Join(catalogue.KnownCapabilities, ", "))) + } + return problems +} + +// itemTextProblems is what an item's text may not name: a secret or a bound value, since an item is kept in a +// bucket and no secret is (ADR 0349 clause 7), and a collection, since an item is not a collector. And every +// placeholder no pass fills, as in a file's content. +func itemTextProblems(module string, it catalogue.Item) []string { + var problems []string + for _, f := range templatePlaceholder.FindAllStringSubmatch(it.Content, -1) { + namespace, key := f[1], f[2] + switch _, collection := collectionOfPlaceholder(namespace, key); { + case namespace == "secret" || namespace == "bound": + problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is kept in a bucket, where "+ + "no secret is (novox/hq ADR 0349 §7)", module, it.Collection, f[0])) + case collection: + problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is not a collector", + module, it.Collection, f[0])) + } + } + return append(problems, unconsumedPlaceholders(module, map[string]any{"id": "item:" + itemKey(it), + "type": "file", "content": it.Content})...) +} + +var collectionName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)*$`) + +var itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// ConfigProblems is every way a module in the config form declares its config wrongly, each said by name with +// what to do (ADR 0348 How it is checked, design 57 package K1). +func ConfigProblems(m catalogue.Manifest) []string { + if !m.DeclaresConfig() { + return nil + } + var problems []string + placed := configPlaced(m) + fallsBack := fileFallsBack(m) + renamed := renamedTaken(m) + for _, key := range sortedSettingKeys(m.Settings) { + d := m.Settings[key] + say := func(format string, args ...any) { + problems = append(problems, fmt.Sprintf("%s's config value %q: ", m.Module, key)+fmt.Sprintf(format, args...)) + } + if !settingRef.MatchString("${setting:" + key + "}") { + say("not a usable key: lower-case letters, digits, dots, dashes and underscores") + continue + } + if meshWords[key] || accountFacts[key] { + say("%q is placement, which the mesh decides, never a config value (novox/hq ADR 0348 §11)", key) + continue + } + if d.Kind != "" || d.Why != "" { + say("kind and why are the settings form's; under config a value says its type and its description " + + "(novox/hq ADR 0348 §1, §4)") + } + switch d.Type { + case catalogue.ConfigNumber, catalogue.ConfigString, catalogue.ConfigBoolean: + case catalogue.ConfigObject, catalogue.ConfigList: + if !d.Trusted { + say("is %s and not trusted: an object or a list decides more than one value can say, so it is "+ + "trusted, \"trusted\": true, or split into numbers, strings and booleans (novox/hq ADR 0348 §4)", d.Type) + } + case "": + say("no type: say \"type\": number, string or boolean, or object or list when trusted (novox/hq ADR 0348 §4)") + default: + say("type %q is none of number, string, boolean, object and list (novox/hq ADR 0348 §4)", d.Type) + } + if strings.TrimSpace(d.Description) == "" { + say("no description: say in a sentence what the value is, including its unit (novox/hq ADR 0348 §4)") + } + switch { + case d.Required && d.Default != nil: + say("both a default and required: a required value is the operator's own and has no default; give " + + "one or the other (novox/hq ADR 0348 §4)") + case !d.Required && d.Default == nil: + say("neither a default nor required: give a default, or say \"required\": true for a value that is " + + "the operator's own (novox/hq ADR 0348 §4)") + } + if as := renamed[key]; as != "" && d.Required { + say("is laid into its file as %q, and only when it is set, so the file and its code fall back when "+ + "it is not: it is never required; give it the file's default, the empty value of its type "+ + "(novox/hq issue 751)", as) + } + for _, wrong := range validationProblems(d) { + say("%s", wrong) + } + if word := operatorsWord(key); word != "" && d.Default != nil && !fallsBack[key] { + say("a key naming %q is the operator's value: declare it \"required\": true, with no default, "+ + "unless a mergeable file takes it and no placeholder asks for it, where the file falls back "+ + "(novox/hq ADR 0348 §4, ADR 0112, issue 735)", word) + } + if d.Predefined != "" { + p, listed := m.Predefined()[d.Predefined] + switch { + case m.Predefined() == nil: + say("falls back to the predefined key %q, and the list of predefined keys (%s at the root of "+ + "the catalogue's repository) is not present where it is judged: a value falls back only to a "+ + "key on that list; judge it in the catalogue, or leave \"predefined\" out (novox/hq ADR 0348 §7)", + d.Predefined, catalogue.PredefinedFile) + case !listed: + say("falls back to %q, which is not on the list of predefined keys: name a key on it, or leave "+ + "\"predefined\" out (novox/hq ADR 0348 §7)", d.Predefined) + default: + if p.Type != d.Type { + say("falls back to the predefined key %q, which is a %s, and this value is a %s: a value falls "+ + "back only to a key of its own type (novox/hq ADR 0348 §4)", d.Predefined, p.Type, d.Type) + } + if p.Trusted && !d.Trusted { + say("falls back to the trusted predefined key %q and is not trusted itself: a value that falls "+ + "back to a trusted key is trusted, \"trusted\": true (novox/hq ADR 0348 §8)", d.Predefined) + } + } + } + // A template's text is not here yet. Resolve fills it from the build, WithTemplatesFrom from the tree; neither + // judges again itself, but WithTemplatesFrom parses its result again, and a resolved build is parsed again + // where it is registered, and this is judged then. + if !placed[key] && !d.Runtime && !m.Templated() { + say("placed nowhere: no file says ${config:%s} or takes it under %s, and it is not marked "+ + "\"runtime\": true for a value the module's code reads itself (novox/hq ADR 0348 §5)", key, catalogue.TakesConfigField) + } + } + used := settingKeysUsedBy(m) + for _, key := range sortedKeys(used) { + if _, declared := m.Settings[key]; !declared { + problems = append(problems, fmt.Sprintf("%s says ${config:%s} and declares no such value under %s: "+ + "declare %q with its type, description and a default or required (novox/hq ADR 0348 §4, §5)", + m.Module, key, catalogue.ConfigField, key)) + } + } + problems = append(problems, configTakesProblems(m)...) + problems = append(problems, reflectsProblems(m)...) + return problems +} + +// MixedFormProblems is a manifest using both forms, said with the words of each (ADR 0348 §1). +func MixedFormProblems(m catalogue.Manifest) []string { + if config, settings := m.FormsWritten(); len(config) == 0 || len(settings) == 0 { + return nil + } + return []string{fmt.Sprintf("%s mixes the config form (%s) with the settings form (%s): a module uses one form "+ + "or the other, never both; write %s, %s and ${config:} throughout (novox/hq ADR 0348 §1)", + m.Module, strings.Join(first(m.FormsWritten()), ", "), strings.Join(second(m.FormsWritten()), ", "), + catalogue.ConfigField, catalogue.TakesConfigField)} +} + +// TypedSettingProblems is a declaration under `settings` that says what only `config` says: a value with a type, +// a description and its validation is declared in the config form (ADR 0348 §1, §4). +func TypedSettingProblems(m catalogue.Manifest) []string { + var problems []string + for _, key := range sortedSettingKeys(m.Settings) { + if configShaped(m.Settings[key]) { + problems = append(problems, fmt.Sprintf("%s's setting %q says its type, description or validation "+ + "under %s: a value declared with those is declared under config, with ${config:} and "+ + "takes-config, never under %s (novox/hq ADR 0348 §1, §4)", m.Module, key, catalogue.SettingsField, catalogue.SettingsField)) + } + } + return problems +} + +// configPlaced is every value a file, a unit, a directory's owner, a contribution or a served fact asks for, and +// every value a mergeable file takes. +func configPlaced(m catalogue.Manifest) map[string]bool { + placed := settingKeysUsedBy(m) + for _, r := range m.Resources { + for _, k := range takenKeyList(r) { + placed[k] = true + } + } + return placed +} + +// configShaped is whether a declaration says anything of the config form's. +func configShaped(d catalogue.SettingDeclaration) bool { + return d.Type != "" || d.Description != "" || len(d.Enum) > 0 || d.Minimum != nil || d.Maximum != nil || + d.Pattern != "" || d.Trusted || d.Predefined != "" || d.Required || d.Runtime +} + +// configTakesProblems is what ADR 0348 §11 adds for a module in the config form: an account fact in +// takes-config (the mesh's own words are refused for both forms by TakenSettingsProblems), and a mergeable file +// naming no keys where none of the module's does (where one does, TakenSettingsProblems names every one that +// does not). +func configTakesProblems(m catalogue.Manifest) []string { + var problems, silent []string + naming := false + for _, r := range m.Resources { + if how, _ := r["merge"].(string); how == "" { + continue + } + if _, said := r[catalogue.TakesSettingsField]; !said { + silent = append(silent, fmt.Sprint(r["id"])) + continue + } + naming = true + for _, k := range takenKeyList(r) { + _, declared := m.Settings[k] + switch { + case accountFacts[k]: + problems = append(problems, fmt.Sprintf("%s's %v names %q in %s, and %q is an account fact the "+ + "mesh places, never a config value (novox/hq ADR 0348 §11)", m.Module, r["id"], k, + catalogue.TakesConfigField, k)) + case meshWords[k]: + // Refused for both forms by TakenSettingsProblems, in its words. + case !declared: + problems = append(problems, fmt.Sprintf("%s's %v names %q in %s and declares no such value under "+ + "%s: a value a file takes is declared like every other, with its type, description and a "+ + "default or required, or the config verb could never set it (novox/hq ADR 0348 §4, §11)", + m.Module, r["id"], k, catalogue.TakesConfigField, catalogue.ConfigField)) + } + } + } + if !naming { + for _, id := range silent { + problems = append(problems, fmt.Sprintf("%s's mergeable %s names no %s: a module that declares config "+ + "names the keys each mergeable file takes, an empty list for none (novox/hq ADR 0348 §11)", + m.Module, id, catalogue.TakesConfigField)) + } + } + return problems +} + +// fileFallsBack is every value a mergeable file takes and no placeholder asks for. Unset, such a value is simply +// not laid over the file: the file's own content, or the module's code reading the file, falls back. So one named +// after the operator's value (a url, a user, an address) is not `required`, and its default is the file's: its +// value in the content, else the empty value of its type, never laid over the file (the operator's decision on +// issue 664, issue 735). A placeholder has no such fallback: its default would be filled in where the operator's +// value belongs (ADR 0112). +func fileFallsBack(m catalogue.Manifest) map[string]bool { + asked := settingKeysUsedBy(m) + out := map[string]bool{} + for _, r := range m.Resources { + if how, _ := r["merge"].(string); how == "" { + continue + } + for _, k := range takenKeyList(r) { + if !asked[k] { + out[k] = true + } + } + } + return out +} + +// inConfigWords is a refusal said of a module in the config form in its words: `${config:}` and +// takes-config, where the rules shared with the settings form say `${setting:}` and takes-settings. +func inConfigWords(problem string) string { + problem = settingRef.ReplaceAllStringFunc(problem, func(ref string) string { + return "${config:" + settingRef.FindStringSubmatch(ref)[1] + "}" + }) + return strings.ReplaceAll(problem, catalogue.TakesSettingsField, catalogue.TakesConfigField) +} + +// reflectsProblems is a `reload-on` or `restart-on` naming a file the module does not declare (ADR 0348 §6): not +// one of its own resources, not one the mesh composes for it, and not another module's, which is named +// `.`. +func reflectsProblems(m catalogue.Manifest) []string { + own := map[string]bool{} + for _, r := range m.Resources { + own[fmt.Sprint(r["id"])] = true + } + meshMade := func(id string) bool { + if slices.Contains(meshMadeIDs, id) { + return true + } + for _, p := range meshMadePrefixes { + if strings.HasPrefix(id, p) && len(id) > len(p) { + return true + } + } + return false + } + var problems []string + for _, r := range m.Resources { + for _, field := range []string{"reload-on", "restart-on"} { + for _, id := range stringsOf(r[field]) { + if strings.Contains(id, ".") || own[id] || meshMade(id) { + continue + } + problems = append(problems, fmt.Sprintf("%s's %v names %q in its %s, and %s declares no such "+ + "file: name a file of its own, or another module's as . (novox/hq ADR 0348 §6)", + m.Module, r["id"], id, field, m.Module)) + } + } + } + return problems +} + +var meshMadeIDs = []string{catalogue.ComposedJailsID(), catalogue.FilteringID(), catalogue.CertificateID(), catalogue.AuthorityID(), catalogue.BusUsersID()} + +var meshMadePrefixes = []string{catalogue.NeedID(""), catalogue.SecretID(""), strings.TrimSuffix(catalogue.GrantID("", ""), "-"), catalogue.BoundID(""), catalogue.ReceivedID(""), catalogue.AccessID("/")} + +const dnsLabelLimit = 63 + +// CheckServes refuses a `serves` block that names a consumer fact or an alphabet the mesh does not +// have, when the definition is parsed rather than when a consumer is resolved. +// +// A provision nobody consumes yet still has its rule read: a definition that would be refused the +// first time somebody required it is a definition that is wrong now. +func CheckServes(m catalogue.Manifest) []string { + var problems []string + for _, provision := range sortedServes(m.Serves) { + for _, key := range sortedAnyKeys(m.Serves[provision]) { + text, ok := m.Serves[provision][key].(string) + if !ok { + // A provider's own name is filled only in a value of its own (novox/hq ADR 0346): one + // nested in a list or an object would be served as the placeholder, unsaid. + problems = append(problems, nestedOwnNames(m, provision, key, m.Serves[provision][key])...) + continue + } + // A probe identity, because what is checked is the shape of the statement and not + // what any consumer is called. + if _, err := consumerInto(text, "mesh_node_module"); err != nil { + problems = append(problems, fmt.Sprintf( + "%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err)) + } + // And the provider's own names it may serve (novox/hq ADR 0346). + problems = append(problems, checkServedOwnNames(m, provision, key, text)...) + // A label longer than DNS keeps is not truncated here (asDNSLabel), so the offer's own + // bound has to keep the identity inside one (ADR 0225). + if strings.Contains(text, "${consumer:as:dns}") { + if b := m.IdentityBoundOf(provision); !b.Bounded() || b.Max > dnsLabelLimit { + problems = append(problems, fmt.Sprintf( + "%s serves %s's consumers their identity as a DNS label in %q, and bounds "+ + "that identity at %s: a label keeps %d — state an `identity` of at most %d", + m.Module, provision, key, boundWords(b), dnsLabelLimit, dnsLabelLimit)) + } + } + } + } + return problems +} + +func boundWords(b catalogue.IdentityBound) string { + if !b.Bounded() { + return "nothing" + } + return fmt.Sprintf("%d", b.Max) +} + +// validateCron reports why a string is not a five-field cron expression, or nil if it is one. +func validateCron(expr string) error { + fields := strings.Fields(expr) + if len(fields) != 5 { + return fmt.Errorf( + "a schedule is a five-field cron expression (minute hour day-of-month month "+ + "day-of-week), and this has %d field(s)", len(fields)) + } + bounds := []struct { + name string + min, max int + }{ + {"minute", 0, 59}, + {"hour", 0, 23}, + {"day-of-month", 1, 31}, + {"month", 1, 12}, + {"day-of-week", 0, 7}, // 0 and 7 are both Sunday, the convention every cron keeps + } + for i, b := range bounds { + if err := validateCronField(fields[i], b.min, b.max); err != nil { + return fmt.Errorf("%s field: %w", b.name, err) + } + } + return nil +} + +// validateCronField checks one field's elements are within range and well-formed. +func validateCronField(spec string, min, max int) error { + if spec == "" { + return fmt.Errorf("is empty") + } + for _, part := range strings.Split(spec, ",") { + if part == "" { + return fmt.Errorf("%q has an empty element between commas", spec) + } + + rangePart := part + if slash := strings.IndexByte(part, '/'); slash >= 0 { + rangePart = part[:slash] + n, err := strconv.Atoi(part[slash+1:]) + if err != nil || n < 1 { + return fmt.Errorf("step in %q is not a positive number", part) + } + } + + switch { + case rangePart == "*": + // Any value; nothing to bound. + case strings.IndexByte(rangePart, '-') >= 0: + dash := strings.IndexByte(rangePart, '-') + lo, errLo := strconv.Atoi(rangePart[:dash]) + hi, errHi := strconv.Atoi(rangePart[dash+1:]) + if errLo != nil || errHi != nil { + return fmt.Errorf("range %q is not two numbers", rangePart) + } + if lo < min || hi > max || lo > hi { + return fmt.Errorf("range %q is outside the allowed %d-%d", part, min, max) + } + default: + v, err := strconv.Atoi(rangePart) + if err != nil { + return fmt.Errorf("%q is not a number", rangePart) + } + if v < min || v > max { + return fmt.Errorf("%q is outside the allowed range %d-%d", part, min, max) + } + } + } + return nil +} + +// DataProblems is what the catalogue check refuses about the data a module declares (novox/hq ADR +// 0233), judged over the manifests given together: +// +// - a provider that grants a provision says what it keeps for that provision's consumers; +// - nobody says it on the offer any more (`keeps-consumer-data`): the data section is the one place; +// - nobody writes a backup line by hand: it is derived from the data section; +// - a directory a container writes, mounted whole, is a data item of some class; +// - consumer data said to live in a required provision lives where that provision's provider keeps +// its consumers' data, as preciously, when the provider is given; +// - data a consumer keeps with a provider as irreplaceable is protected there, when the provider is +// given. +// +// **The check's, not registration's**, as ADR 0214's backup rule was: a module already on the shelf +// was written before the rule, and refusing it there would refuse the very providers whose data the +// rule protects. Each module meets it where it is written. +func DataProblems(shelf catalogue.Shelf) []string { + var problems []string + for _, name := range shelfOrder(shelf) { + m := shelf[name] + for _, provision := range sortedKeys(m.Grants) { + if _, said := m.ConsumerDataOf(provision); !said { + problems = append(problems, fmt.Sprintf( + "%s grants %s and does not say what it keeps for its consumers: data.consumers.%s with a "+ + "class — irreplaceable, valuable, rebuildable, cache, or none (novox/hq ADR 0233)", name, provision, provision)) + } + } + for _, o := range m.Provides { + if o.KeepsConsumerData != nil { + problems = append(problems, fmt.Sprintf( + "%s says keeps-consumer-data on its offer of %s; it is said once, in data.consumers.%s, with a "+ + "class (novox/hq ADR 0233)", name, o.Name, o.Name)) + } + } + for i, c := range m.Contributions { + if s, known := catalogue.SeatNamed(c.Seat); known && s.Name == catalogue.BackupSeat { + problems = append(problems, fmt.Sprintf( + "%s's contribution %d is a backup line written by hand; backups are derived from the data "+ + "section — declare the data, with its class and how it is protected (novox/hq ADR 0233)", name, i+1)) + } + } + for _, dir := range writtenDirectories(m) { + if !coversDirectory(m, dir) { + problems = append(problems, fmt.Sprintf( + "%s mounts its directory %q into a container to be written, and declares no data in it: "+ + "data.own with a class — cache if it is disposable (novox/hq ADR 0233)", name, dir)) + } + } + if m.Data == nil { + continue + } + for _, provision := range sortedKeys(m.Data.Consumers) { + c := m.Data.Consumers[provision] + if c.In == "" { + continue + } + if _, own := m.DataItem(c.In); own { + continue + } + for _, pname := range shelfOrder(shelf) { + p := shelf[pname] + pc, said := p.ConsumerDataOf(c.In) + if !said || !providesName(p, c.In) { + continue + } + if classRank[pc.Class] < classRank[c.Class] { + problems = append(problems, fmt.Sprintf( + "%s keeps its consumers' %s, %s, in %s — and %s keeps its consumers' %s as %s, "+ + "so it is not protected as %s", name, provision, c.Class, c.In, pname, c.In, pc.Class, c.Class)) + } + } + } + for _, provision := range sortedKeys(m.Data.KeptBy) { + k := m.Data.KeptBy[provision] + if k.Class != catalogue.ClassIrreplaceable { + continue + } + for _, pname := range shelfOrder(shelf) { + p := shelf[pname] + pc, said := p.ConsumerDataOf(provision) + if !said || !providesName(p, provision) { + continue + } + if !keepsByClass(pc.Class) { + problems = append(problems, fmt.Sprintf( + "%s keeps irreplaceable data with %s, and %s keeps its consumers' %s as %s — nothing of it is "+ + "protected there", name, provision, pname, provision, pc.Class)) + continue + } + if in, own := p.DataItem(pc.In); own && !in.BackedUp() && in.Redundancy == "" { + problems = append(problems, fmt.Sprintf( + "%s keeps irreplaceable data with %s, and %s keeps it in %s, which is neither backed up nor "+ + "on declared redundancy", name, provision, pname, pc.In)) + } + } + } + } + return problems +} + +// coversDirectory is whether a data item names the directory, a path within it, or a directory it +// is placed beneath. +func coversDirectory(m catalogue.Manifest, dir string) bool { + parents := map[string]string{} + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "directory" { + continue + } + if p, ok := r["path"].(string); ok { + if ref := dirRef.FindStringSubmatch(p); ref != nil && strings.HasPrefix(p, "${dir:") { + parents[fmt.Sprint(r["id"])] = ref[1] + } + } + } + for _, it := range m.DataItems() { + ref := dataPathRef.FindStringSubmatch(it.Path) + if ref == nil || ref[1] != "dir" { + continue + } + for d, hops := dir, 0; d != "" && hops < 4; d, hops = parents[d], hops+1 { + if ref[2] == d { + return true + } + } + } + return false +} + +func providesName(m catalogue.Manifest, provision string) bool { + for _, o := range m.Provides { + if o.Name == provision { + return true + } + } + return false +} + +// volumeReadOnly is whether a volume's options mount it read-only. +func volumeReadOnly(options string) bool { + for _, o := range strings.Split(options, ",") { + if strings.TrimSpace(o) == "ro" { + return true + } + } + return false +} + +// writtenDirectories are the module's directories a container mounts whole and may write: a volume +// `${dir:}:` without `:ro`, or a directory stated by an absolute path mounted the same +// way. A file beneath a directory, or a read-only mount, is configuration the mesh wrote. +func writtenDirectories(m catalogue.Manifest) []string { + absolute := map[string]string{} + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "directory" { + continue + } + if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") { + absolute[strings.TrimRight(p, "/")] = fmt.Sprint(r["id"]) + } + } + seen := map[string]bool{} + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "container" { + continue + } + vols, _ := r["volumes"].([]any) + for _, v := range vols { + s, _ := v.(string) + mount := volumeMount.FindStringSubmatch(s) + if mount == nil || volumeReadOnly(mount[3]) { + continue + } + src := strings.TrimRight(mount[1], "/") + if ref := dirPlain.FindStringSubmatch(src); ref != nil { + seen[ref[1]] = true + } else if id, ok := absolute[src]; ok { + seen[id] = true + } + } + } + out := make([]string, 0, len(seen)) + for id := range seen { + out = append(out, id) + } + sort.Strings(out) + return out +} + +// dataProblems is what is wrong with the `data` section itself, from the manifest alone: judged at +// registration as every other per-manifest problem is. Whether a module declares what it should is +// the catalogue check's (DataProblems), because a module already running was written before it. +func manifestDataProblems(m catalogue.Manifest) []string { + if m.Data == nil { + return nil + } + var problems []string + say := func(format string, args ...any) { + problems = append(problems, fmt.Sprintf("%s's data: ", m.Module)+fmt.Sprintf(format, args...)) + } + dirs := map[string]bool{} + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "directory" { + dirs[fmt.Sprint(r["id"])] = true + } + } + accesses := map[string]bool{} + for _, a := range m.Accesses { + if a.ID != "" { + accesses[a.ID] = true + } + } + ids := map[string]catalogue.DataItem{} + paths := map[string]string{} + for i, it := range m.Data.Own { + label := it.ID + if label == "" { + label = fmt.Sprintf("item %d", i+1) + } + if !dataID.MatchString(it.ID) { + say("%s has no usable id: lower-case letters, digits and dashes", label) + } else if _, twice := ids[it.ID]; twice { + say("%s is declared twice", it.ID) + } + ids[it.ID] = it + ref := dataPathRef.FindStringSubmatch(it.Path) + switch { + case ref == nil: + say("%s's path %q is not one of the module's directories or accesses: ${dir:} or ${access:}, "+ + "or a path beneath one (a definition names no machine path, novox/hq ADR 0112)", label, it.Path) + case ref[1] == "dir" && !dirs[ref[2]]: + say("%s's path names ${dir:%s}, and %s declares no directory %q", label, ref[2], m.Module, ref[2]) + case ref[1] == "access" && !accesses[ref[2]]: + say("%s's path names ${access:%s}, and %s declares no access %q", label, ref[2], m.Module, ref[2]) + case strings.Contains(it.Path, ".."): + say("%s's path %q climbs out of its directory", label, it.Path) + } + if other, twice := paths[it.Path]; twice && it.Path != "" { + say("%s and %s name the same path %s", other, label, it.Path) + } + paths[it.Path] = label + switch it.Class { + case catalogue.ClassIrreplaceable, catalogue.ClassValuable, catalogue.ClassRebuildable, catalogue.ClassCache: + case catalogue.ClassNone: + say("%s is class none, which only a provision keeping nothing of its consumers' is; own data "+ + "that is disposable is cache", label) + default: + say("%s's class %q is not one the mesh protects by: irreplaceable, valuable, rebuildable or cache", + label, it.Class) + } + if it.Class == catalogue.ClassIrreplaceable && !it.BackedUp() && strings.TrimSpace(it.Redundancy) == "" { + say("%s is irreplaceable and is neither backed up nor said to be protected by redundancy; the only "+ + "copy of something is protected one way or the other (novox/hq ADR 0233) — copy it, dump it into "+ + "another item, or say `redundancy` with why that is enough", label) + } + if it.Class == catalogue.ClassCache && it.Backup != nil && !it.Backup.None { + say("%s is a cache and asks to be backed up; a cache is disposable, or it is not a cache", label) + } + if it.Class == catalogue.ClassCache && it.Redundancy != "" { + say("%s is a cache and says it is protected by redundancy; a cache is not protected", label) + } + switch it.Measure { + case "", catalogue.MeasureWalk, catalogue.MeasureDataset, catalogue.MeasureShallow: + default: + say("%s's measure %q is walk, dataset or shallow", label, it.Measure) + } + if _, err := catalogue.ParseDataDuration(it.Within); err != nil { + say("%s's within: %v", label, err) + } + if _, err := catalogue.ParseDataDuration(it.Active); err != nil { + say("%s's active: %v", label, err) + } + if it.Within != "" && !it.BackedUp() { + say("%s states within, and is not backed up", label) + } + if it.Active != "" && !catalogue.Watched(it.Class) { + say("%s is %s and expects writes; only irreplaceable and valuable data is watched", label, it.Class) + } + } + // Dumps go into an item of the same module, declared, and not into themselves. + for _, it := range m.Data.Own { + if it.Backup == nil || it.Backup.Copy || it.Backup.None { + continue + } + switch into, ok := ids[it.Backup.Into]; { + case strings.TrimSpace(it.Backup.Dump) == "": + say("%s's backup names no dump command", it.ID) + case it.Backup.Into == "": + say("%s's dump says no item it writes into", it.ID) + case !ok: + say("%s's dump writes into %q, which is not one of the module's data items", it.ID, it.Backup.Into) + case into.ID == it.ID: + say("%s's dump writes into itself; a dump is copied from where it lands", it.ID) + case into.Class == catalogue.ClassCache: + say("%s's dump writes into %s, a cache; what is copied must not be disposable", it.ID, into.ID) + } + if it.Backup.Dump != "" { + declared := map[string]string{} + for d := range dirs { + declared[d] = "" + } + if _, err := dirFill(it.Backup.Dump, declared, m.Module); err != nil { + say("%s's dump: %v", it.ID, err) + } + } + } + provided := map[string]bool{} + for _, o := range m.Provides { + provided[o.Name] = true + } + wants := map[string]bool{} + for _, w := range m.Wants() { + wants[w] = true + } + for _, provision := range sortedKeys(m.Data.Consumers) { + c := m.Data.Consumers[provision] + if !provided[provision] { + say("says what it keeps for the consumers of %q, which it does not provide", provision) + } + switch c.Class { + case catalogue.ClassIrreplaceable, catalogue.ClassValuable, catalogue.ClassRebuildable, catalogue.ClassCache, catalogue.ClassNone: + default: + say("its consumers' %s is class %q; one of irreplaceable, valuable, rebuildable, cache or none", provision, c.Class) + } + switch { + case c.Class == catalogue.ClassNone && c.In != "": + say("its consumers' %s keeps nothing and says where it is kept (%s)", provision, c.In) + case keepsByClass(c.Class) && c.In == "": + say("its consumers' %s is %s and says nowhere it lives: `in` names one of the module's items, or a "+ + "provision it requires", provision, c.Class) + case c.In != "": + if own, ok := ids[c.In]; ok { + if c.Class == catalogue.ClassIrreplaceable && !own.BackedUp() && own.Redundancy == "" { + say("its consumers' %s is irreplaceable and lives in %s, which is not protected", provision, c.In) + } + if classRank[own.Class] < classRank[c.Class] { + say("its consumers' %s is %s and lives in %s, which is only %s", provision, c.Class, c.In, own.Class) + } + } else if !wants[c.In] { + say("its consumers' %s lives in %q, which is neither one of its data items nor a provision it "+ + "requires", provision, c.In) + } + } + } + for _, provision := range sortedKeys(m.Data.KeptBy) { + k := m.Data.KeptBy[provision] + if !wants[provision] { + say("says it keeps data with the provider of %q, which it does not require", provision) + } + switch k.Class { + case catalogue.ClassIrreplaceable, catalogue.ClassValuable, catalogue.ClassRebuildable, catalogue.ClassCache: + default: + say("what it keeps with %s is class %q; one of irreplaceable, valuable, rebuildable or cache", provision, k.Class) + } + } + return problems +} + +var dataID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +var dataPathRef = regexp.MustCompile(`^\$\{(dir|access):([a-z0-9][a-z0-9-]*)\}(/[^\s$]*)?$`) + +// seatEventProblems refuses a module that lists among its own `emits` an event a seat it claims says (the lead, +// 2026-10-29, issue 1952, the second of a kind: the self-check's `checked` and mesh-hand-acts' `act-recorded`). A +// consumer of `.` hears the seat's subject (SeatSays), so the holder says it there, with stdio.SeatPublish +// on `mesh.seat..event.`; said through stdio.Emit as the module's own, it lands where nobody listens. Caught +// here, when the manifest is read at the build, not when nothing is heard on the machines. +func seatEventProblems(m catalogue.Manifest) []string { + var problems []string + for _, c := range m.Claims { + seat, known := catalogue.SeatNamed(c.Name) + if !known || len(seat.Emits) == 0 { + continue + } + for _, e := range m.Emits { + if catalogue.SeatSays(seat.Emits, e) { + problems = append(problems, fmt.Sprintf("%s lists %q among its own emits, and the seat %s it claims says it: "+ + "say it on the seat with stdio.SeatPublish(\"mesh.seat.%s.event.%s\", ...) and leave it out of emits, or "+ + "no consumer of %s.%s hears it", m.Module, e, seat.Name, seat.Name, e, seat.Name, e)) + } + } + } + return problems +} + +// placedOrAbsolute says a path is usable where the mesh needs one: absolute already, or +// beginning with a placed reference — resolution makes it absolute before anything reads it. +// (unknownDirRefs is what checks the reference names a real directory.) +func placedOrAbsolute(path string) bool { + return strings.HasPrefix(path, "/") || + (strings.HasPrefix(path, "${dir:") && dirRef.MatchString(path)) +} + +// unknownDirRefs is every ${dir:…} in the definition that names no directory the definition +// declares — refused where the author is, not at composition on some later day (the same +// near-versus-far reasoning as the host's strict parse). +func manifestUnknownDirRefs(m catalogue.Manifest) []string { + declared := map[string]bool{} + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "directory" { + declared[fmt.Sprint(r["id"])] = true + } + } + referenced := func(s string) []string { + var ids []string + for _, match := range dirRef.FindAllStringSubmatch(s, -1) { + ids = append(ids, match[1]) + } + return ids + } + var problems []string + for _, r := range m.Resources { + place, said := r["place"].(string) + if !said { + continue + } + if fmt.Sprint(r["type"]) != "directory" { + problems = append(problems, fmt.Sprintf( + "%s says place on %v, which is not a directory — only a directory is placed", + m.Module, r["id"])) + continue + } + if path, stated := r["path"].(string); stated && path != "" { + problems = append(problems, fmt.Sprintf( + "%s states both path and place on %v — a stated path IS the placement", + m.Module, r["id"])) + } + if place != placeOwn && place != placeMesh { + problems = append(problems, fmt.Sprintf( + "%s says place %q on %v, and the places are %q — the assignment's own root — and "+ + "%q — where the mesh keeps what it writes for the module", + m.Module, place, r["id"], placeOwn, placeMesh)) + } + } + seen := map[string]bool{} + refuse := func(id string, where any) { + if declared[id] || seen[id] { + return + } + seen[id] = true + problems = append(problems, fmt.Sprintf( + "%s says ${dir:%s} in %v, and declares no directory %q — a reference the mesh "+ + "cannot place would reach the machine as a literal path", + m.Module, id, where, id)) + } + for _, r := range m.Resources { + for _, field := range []string{"path", "content"} { + if s, ok := r[field].(string); ok { + for _, id := range referenced(s) { + refuse(id, r["id"]) + } + } + } + for _, field := range []string{"volumes", "env-file"} { + if list, ok := r[field].([]any); ok { + for _, v := range list { + if s, ok := v.(string); ok { + for _, id := range referenced(s) { + refuse(id, r["id"]) + } + } + } + } + } + if env, ok := r["env"].(map[string]any); ok { + for _, v := range env { + if s, ok := v.(string); ok { + for _, id := range referenced(s) { + refuse(id, r["id"]) + } + } + } + } + } + maps := map[string]map[string]string{ + "receives": m.Receives, "binds": m.Binds, "secrets": m.Secrets, + "own-secrets": m.OwnSecrets.Paths(), "grants": m.Grants, + } + for field, entries := range maps { + for _, value := range entries { + for _, id := range referenced(value) { + refuse(id, field) + } + } + } + for to, locals := range m.SecretsMany { + for _, value := range locals { + for _, id := range referenced(value) { + refuse(id, "secrets."+to) + } + } + } + sort.Strings(problems) + return problems +} + +// contributionPlaceholderProblems is every place this module's resources name the environment or +// the shell's code and may not — judged from the manifest, so the catalogue check refuses it before +// a mesh does, and again at composition in the same words. +func manifestContributionPlaceholderProblems(m catalogue.Manifest) []string { + var problems []string + for _, r := range m.Resources { + problems = append(problems, placeholderProblems(m, r)...) + problems = append(problems, seatPlaceholderProblems(m, r)...) + problems = append(problems, collectedPlaceholderProblems(m, r)...) + problems = append(problems, consumersPlaceholderProblems(m, r)...) + // And every placeholder no pass fills where it stands: a misspelt namespace, a key its + // namespace cannot take, or a field its pass does not read (novox/hq issue 231). + problems = append(problems, unconsumedPlaceholders(m.Module, r)...) + } + return problems +} + +// environmentProblems is what is wrong with this module's environment contribution, from the +// manifest alone. +func manifestEnvironmentProblems(m catalogue.Manifest) []string { + if m.Environment == nil { + return nil + } + var problems []string + for _, n := range sortedKeys(m.Environment.Variables) { + switch { + case !variableName.MatchString(n): + problems = append(problems, fmt.Sprintf( + "%s sets the variable %q, which is not a name a shell accepts: a letter or an "+ + "underscore, then letters, digits and underscores", m.Module, n)) + continue + case n == "PATH": + // PATH is the one variable every module shares, so no module may set it whole: a second + // setter would replace the first's entries, and the account's own PATH with them. + problems = append(problems, fmt.Sprintf( + "%s sets PATH as a variable; a module adds an entry under environment.path, at the "+ + "start or the end, and PATH is composed from every module's (novox/hq ADR 0203)", m.Module)) + continue + } + if why := literalProblem(m.Environment.Variables[n]); why != "" { + problems = append(problems, fmt.Sprintf( + "%s sets %s to %q, which %s — %s", m.Module, n, m.Environment.Variables[n], why, literalRule)) + } + } + seen := map[string]bool{} + for i, p := range m.Environment.Path { + switch { + case p.Entry == "": + problems = append(problems, fmt.Sprintf("%s's PATH entry %d names no directory", m.Module, i+1)) + case strings.Contains(ofMachine.ReplaceAllString(p.Entry, ""), ":"): + // A colon is PATH's own separator, so an entry holding one is two entries, and the + // check that it is already present would look for the wrong thing. + problems = append(problems, fmt.Sprintf( + "%s puts %q on PATH, which holds a colon, PATH's own separator", m.Module, p.Entry)) + case seen[p.Entry]: + problems = append(problems, fmt.Sprintf("%s puts %q on PATH twice", m.Module, p.Entry)) + default: + if why := literalProblem(p.Entry); why != "" { + problems = append(problems, fmt.Sprintf( + "%s puts %q on PATH, which %s — %s", m.Module, p.Entry, why, literalRule)) + } + } + seen[p.Entry] = true + if p.At != catalogue.PathAtStart && p.At != catalogue.PathAtEnd { + problems = append(problems, fmt.Sprintf( + "%s puts %q on PATH at %q; an entry goes at %q or %q of the account's PATH", + m.Module, p.Entry, p.At, catalogue.PathAtStart, catalogue.PathAtEnd)) + } + } + return problems +} + +// shellProblems is what is wrong with this module's shell code, from the manifest alone. The code +// itself is not judged: it is the shell's syntax, which the controller does not read. +func manifestShellProblems(m catalogue.Manifest) []string { + var problems []string + for i, c := range m.Shell { + if !oneOf(contributionTargets(), c.For) { + problems = append(problems, fmt.Sprintf( + "%s's shell code %d is for %q; the shells are %s, the session's files %s, and the power "+ + "moments %s", m.Module, i+1, c.For, strings.Join(knownShells, ", "), + strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "))) + } + if !oneOf(knownSlots, c.Slot) { + problems = append(problems, fmt.Sprintf( + "%s's shell code %d goes in the slot %q; the slots are %s", m.Module, i+1, c.Slot, + strings.Join(knownSlots, ", "))) + } + if strings.TrimSpace(c.Code) == "" { + problems = append(problems, fmt.Sprintf("%s's shell code %d has no code", m.Module, i+1)) + } + } + return problems +} + +var variableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// EventProblems is what is wrong with a manifest's events. +// +// Refused at registration, because the alternative is a module that installs, starts, connects and +// reacts to nothing — and every log line says it is fine. +func EventProblems(m catalogue.Manifest) []string { + var problems []string + + for _, e := range m.Emits { + if was, stale := staleEventForm(e, m.Module); stale { + problems = append(problems, fmt.Sprintf( + "%s emits %q, which is the old bus's routing key. An event is named locally now, so "+ + "write %q — the mesh derives the subject (novox/hq design 29 §1)", + m.Module, was, strings.TrimPrefix(was, "module."+m.Module+"."))) + continue + } + if strings.HasPrefix(e, "module.") { + problems = append(problems, fmt.Sprintf( + "%s emits %q: `module.` is reserved, because it is how the old bus spelled a "+ + "routing key and an event named that way derives into a namespace nobody owns", + m.Module, e)) + continue + } + if err := localName(e); err != nil { + problems = append(problems, fmt.Sprintf("%s emits %q: %v", m.Module, e, err)) + continue + } + // **Its own name, never another's.** The bus enforces that a namespace belongs to the module + // it is named for, so an event named for somebody else cannot be published at all. If the + // event is about a role rather than about this module, it belongs on the seat: a name that + // is stable across whoever fills it (04-ISSUES/127). + if first, _, split := strings.Cut(e, "."); split && isAModuleNameOtherThan(first, m.Module) { + problems = append(problems, fmt.Sprintf( + "%s emits %q, which reads as another module's event. A module publishes under its "+ + "own name only. If this is about a role rather than about %s, declare it on that "+ + "seat, where the name survives the holder changing", + m.Module, e, m.Module)) + } + } + + for _, c := range m.Consumes { + if strings.HasPrefix(c, "module.") { + problems = append(problems, fmt.Sprintf( + "%s consumes %q, which is the old bus's pattern. A consumed event names its emitter "+ + "and the event: write %q", m.Module, c, strings.TrimPrefix(c, "module."))) + continue + } + if c == "#" { + problems = append(problems, fmt.Sprintf( + "%s consumes %q, which is the old bus's wildcard for everything. Write %q", + m.Module, c, catalogue.TheRest)) + continue + } + if err := consumePattern(c); err != nil { + problems = append(problems, fmt.Sprintf("%s consumes %q: %v", m.Module, c, err)) + } + } + return problems +} + +// consumePattern checks a consumed pattern: the emitter, then the event, with wildcards. +func consumePattern(pattern string) error { + if pattern == "" { + return fmt.Errorf("a consumed event needs an emitter and an event") + } + parts := strings.Split(pattern, ".") + for i, part := range parts { + switch { + case part == catalogue.TheRest: + if i != len(parts)-1 { + return fmt.Errorf("%q stands for the rest of a name, so nothing may follow it", catalogue.TheRest) + } + case part == catalogue.OneName: + case !eventName.MatchString(part): + return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part) + } + } + // `**` alone is every event from every module, which the audit logger wants and says plainly. + if len(parts) == 1 && parts[0] != catalogue.TheRest { + return fmt.Errorf( + "%q names an emitter and no event. Write ., or %q for every event", + pattern, catalogue.TheRest) + } + return nil +} + +// isAModuleNameOtherThan says a first token names some module of this mesh that is not this one. +// +// Only the mesh's own seats and the catalogue could answer this properly, and neither is reachable +// from a parser given one manifest. So this catches the case that actually happened — a name that +// is a *provision* the mesh defines, which is where "another module's event" comes from in practice +// — and the whole-catalogue check catches the rest. +func isAModuleNameOtherThan(first, module string) bool { + if first == module || first == "" { + return false + } + if _, isASeat := catalogue.SeatNamed(first); isASeat { + return true + } + if _, isASeat := catalogue.SeatDelivering(first); isASeat { + return true + } + return false +} + +// localName checks one event name: dot-separated names, no wildcards, nothing else. +func localName(event string) error { + if event == "" { + return fmt.Errorf("an event needs a name") + } + for _, part := range strings.Split(event, ".") { + if part == catalogue.OneName || part == catalogue.TheRest { + return fmt.Errorf("an emitted event names one event, so it carries no wildcard") + } + if !eventName.MatchString(part) { + return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part) + } + } + return nil +} + +// staleEventForm says an emitted name is this module's own old routing key, and what it was. +func staleEventForm(event, module string) (string, bool) { + return event, module != "" && strings.HasPrefix(event, "module."+module+".") +} + +// FilesHolderProblems is what a holder of node-files is held to (novox/hq ADR 0394 §2): it runs as the tool +// runner's account, the node's operator account, so it declares no runs-as. +func FilesHolderProblems(m catalogue.Manifest) []string { + if !m.ClaimsSeat(catalogue.FilesSeat) || m.RunsAs == "" { + return nil + } + return []string{fmt.Sprintf("%s claims %s and declares runs-as %q: its holder runs as the tool runner's account, "+ + "the node's operator account (novox/hq ADR 0394 §2)", m.Module, catalogue.FilesSeat, m.RunsAs)} +} + +// RouteProblems holds a module's route contributions to naming an endpoint it actually has. +// +// A route naming an endpoint the module does not declare reaches nothing, and is refused where it is +// written rather than resolving to no port and serving nothing — the fault this repository names most +// often, a declaration that reads as though it did something. +func RouteProblems(m catalogue.Manifest) []string { + var problems []string + check := func(where string, values map[string]any) { + name, ok := values[catalogue.RouteEndpoint].(string) + if !ok || strings.TrimSpace(name) == "" { + return + } + if _, found := catalogue.EndpointPort(m, name); !found { + problems = append(problems, fmt.Sprintf( + "%s routes %s to the endpoint %q, which it does not declare", m.Module, where, name)) + } + } + if values, ok := m.Contributes["route"]; ok { + check("a name", values) + } + for local, values := range m.ContributesMany["route"] { + check(local, values) + } + return problems +} + +// movedProblems is what is wrong with a module's mark: a moved module must name a template (as written) or carry +// what its build kept (as held), and Handed is the build's word, never a repository's. +func movedProblems(m catalogue.Manifest) []string { + switch { + case m.Handed != nil && !m.Moved: + return []string{fmt.Sprintf("%s carries what a moved module's build keeps (handed) and is not marked moved", + m.Module)} + case m.Handed != nil && m.Build != nil: + return []string{fmt.Sprintf("%s says handed in its repository: that is what its build keeps, never "+ + "written by hand (novox/hq ADR 0349 §2)", m.Module)} + case m.Moved && m.Handed == nil && !m.Templated(): + return []string{fmt.Sprintf("%s is marked moved and names no template: a moved module's files are filled "+ + "from template files of its own, `\"template\": \"%s/\"` (novox/hq ADR 0349 §2)", m.Module, + m.TemplatesDir())} + } + if !m.Moved { + return nil + } + // **A moved module's secrets are the vault's, handed to its machine's client** (design 57 V4): a secret the + // mesh makes, by name. A family's members and a value issued outside the mesh are given by a person and may be + // absent, which the client cannot tell from one the vault does not hold yet; they move with a package of their + // own. + var problems []string + if fams := m.OwnSecrets.Families(); len(fams) > 0 { + problems = append(problems, fmt.Sprintf("%s is marked moved and keeps a secret family (%s): a moved module's "+ + "secrets are handed by the vault by name, and a family's members are not yet (design 57 V4)", m.Module, + strings.Join(fams, ", "))) + } + for _, name := range sortedKeys(m.OwnSecrets.Plain()) { + if m.OwnSecrets[name].IssuedBy == catalogue.IssuedOutside { + problems = append(problems, fmt.Sprintf("%s is marked moved and its secret %s is issued outside the mesh: "+ + "a value that may not be given is not yet handed by the vault (design 57 V4)", m.Module, name)) + } + } + return problems +} + +func healthKindsWords() string { + return "a check is runtime (the image's own, adopted by name), http, tcp, exec, unit or tool" +} + +// healthProblems is everything wrong with a manifest's `health` fields, in the manifest's words (to-be +// 48 §8): a field on something that does not stay up, a kind its resource cannot have, an endpoint the +// module does not declare, a port or an address, a timing outside its bounds, a tool the module does not +// serve, and a tool check with no check of another kind beside it on the module. +func healthProblems(m catalogue.Manifest) []string { + var problems []string + endpoints := map[string]catalogue.Listening{} + for _, l := range m.Listens { + if n := strings.TrimSpace(l.Name); n != "" { + endpoints[n] = l + } + } + tools := map[string]bool{} + for _, t := range m.Tools { + tools[t] = true + } + wants := map[string]bool{} + for _, w := range m.Wants() { + wants[w] = true + } + var toolChecks []string + otherKinds := 0 + resources := m.Resources + // A module's own runtime is judged as the process the controller writes for it (novox/hq ADR 0366 §1), + // and only a module that runs as its own account has one. + own, hasOwn := catalogue.OwnRuntimeHealth(m) + if hasOwn { + if m.RunsAs == "" { + problems = append(problems, fmt.Sprintf("%s declares runtime-health and runs as no account of its own "+ + "(runs-as): only a module served by a runtime of its own has that runtime's health to declare "+ + "(novox/hq ADR 0366 §1)", m.Module)) + hasOwn = false + } else { + resources = append(append([]map[string]any(nil), m.Resources...), own) + } + } + for i, r := range resources { + h, has, read := catalogue.ReadHealth(r) + if !has { + continue + } + ownRuntime := hasOwn && i == len(resources)-1 + id, typ := fmt.Sprint(r["id"]), fmt.Sprint(r["type"]) + where := m.Module + ": " + id + if ownRuntime { + where = m.Module + ": its runtime-health" + } + for _, p := range read { + problems = append(problems, m.Module+": "+p) + } + if !catalogue.LongRunning(r) { + problems = append(problems, fmt.Sprintf("%s declares health and does not stay up: a step, anything on a "+ + "schedule and a service not stated running are judged by their step and their schedule (ADR 0240 rule 1)", where)) + continue + } + switch h.Kind { + case catalogue.HealthRuntime, catalogue.HealthExec: + if typ != "container" { + problems = append(problems, fmt.Sprintf("%s is a %s and its health is %q, which only a container has: "+ + "the runtime runs it inside the container", where, typ, h.Kind)) + } + case catalogue.HealthUnit: + if typ == "container" { + problems = append(problems, fmt.Sprintf("%s is a container and its health is %q, which is a service's "+ + "or a process's own readiness", where, h.Kind)) + } + case catalogue.HealthHTTP, catalogue.HealthTCP, catalogue.HealthTool: + case "": + problems = append(problems, fmt.Sprintf("%s declares health with no kind: %s", where, healthKindsWords())) + default: + problems = append(problems, fmt.Sprintf("%s declares health of kind %q: %s", where, h.Kind, healthKindsWords())) + } + switch h.Kind { + case catalogue.HealthHTTP, catalogue.HealthTCP: + l, declared := endpoints[h.Endpoint] + switch { + case h.Endpoint == "": + problems = append(problems, fmt.Sprintf("%s's %s check names no endpoint: it names one the module "+ + "declares under listens, by its name", where, h.Kind)) + case !declared: + problems = append(problems, fmt.Sprintf("%s's %s check names the endpoint %q, which %s does not declare "+ + "under listens (%s)", where, h.Kind, h.Endpoint, m.Module, namedEndpointsWords(endpoints))) + case l.At() != "tcp": + problems = append(problems, fmt.Sprintf("%s's %s check names %q, which is %s: a check connects over tcp", + where, h.Kind, h.Endpoint, l.At())) + } + default: + if h.Endpoint != "" { + problems = append(problems, fmt.Sprintf("%s's %s check names an endpoint, which only an http or tcp check "+ + "looks at", where, h.Kind)) + } + } + if h.Kind != catalogue.HealthHTTP && (h.Path != "" || h.Status != 0 || h.Body != "" || h.Scheme != "") { + problems = append(problems, fmt.Sprintf("%s's %s check says a path, a status, a body or a scheme, which "+ + "only an http check has", where, h.Kind)) + } + if h.Kind == catalogue.HealthHTTP { + if h.Path != "" && !strings.HasPrefix(h.Path, "/") { + problems = append(problems, fmt.Sprintf("%s's http check asks %q; a path starts with /", where, h.Path)) + } + if h.Status != 0 && (h.Status < 100 || h.Status > 599) { + problems = append(problems, fmt.Sprintf("%s's http check expects status %d, which is not one", where, h.Status)) + } + if h.Scheme != "" && h.Scheme != "http" && h.Scheme != "https" { + problems = append(problems, fmt.Sprintf("%s's http check is over %q; it is http or https", where, h.Scheme)) + } + } + if (h.Command != "") != (h.Kind == catalogue.HealthExec) { + if h.Kind == catalogue.HealthExec { + problems = append(problems, fmt.Sprintf("%s's exec check says no command", where)) + } else { + problems = append(problems, fmt.Sprintf("%s's %s check says a command, which only an exec check runs", + where, h.Kind)) + } + } + if h.Kind == catalogue.HealthTool { + switch { + case h.Tool == "": + problems = append(problems, fmt.Sprintf("%s's tool check names no tool", where)) + case !tools[h.Tool]: + problems = append(problems, fmt.Sprintf("%s's tool check asks %q, which %s does not serve (its tools: %s)", + where, h.Tool, m.Module, orNoneWords(m.Tools))) + } + // Its own runtime being alive, judged by the node-engine, is the check the module does not run + // itself that this tool check stands beside (novox/hq ADR 0366 §2). + if !ownRuntime { + toolChecks = append(toolChecks, id) + } + } else { + if h.Tool != "" { + problems = append(problems, fmt.Sprintf("%s's %s check names a tool, which only a tool check asks", where, h.Kind)) + } + if h.Kind != "" { + otherKinds++ + } + } + if h.Needs != "" && !wants[h.Needs] { + problems = append(problems, fmt.Sprintf("%s's check needs %q, which %s does not require: a check names "+ + "the provision it exercises, among those the module requires", where, h.Needs, m.Module)) + } + problems = append(problems, healthTimingProblems(where, h)...) + } + if len(toolChecks) > 0 && otherKinds == 0 { + problems = append(problems, fmt.Sprintf("%s judges itself only by its own tool (%s): a tool check is for "+ + "function no endpoint shows, and only beside a check of another kind the module does not run itself "+ + "(ADR 0227 rule 8, ADR 0240 rule 2)", m.Module, strings.Join(toolChecks, ", "))) + } + return problems +} + +// healthTimingProblems holds a check's timing to its bounds. +func healthTimingProblems(where string, h catalogue.Health) []string { + var problems []string + if h.Interval < catalogue.HealthIntervalFloor { + problems = append(problems, fmt.Sprintf("%s looks every %s; a check looks no more often than every %s — "+ + "the mesh is a guest on the machine (ADR 0240 rule 2)", where, h.Interval, catalogue.HealthIntervalFloor)) + } + if h.Timeout <= 0 || h.Timeout >= h.Interval { + problems = append(problems, fmt.Sprintf("%s gives a look %s, which must be more than nothing and under its "+ + "interval of %s", where, h.Timeout, h.Interval)) + } + if h.Looks < catalogue.HealthLooksFloor { + problems = append(problems, fmt.Sprintf("%s is unhealthy after %d failing look(s); it is at least %d — one "+ + "look can be wrong (issue 277)", where, h.Looks, catalogue.HealthLooksFloor)) + } + if h.Grace < 0 { + problems = append(problems, fmt.Sprintf("%s has a grace of %s", where, h.Grace)) + } + if h.Looks >= catalogue.HealthLooksFloor && h.Interval >= catalogue.HealthIntervalFloor { + if took := h.Grace + time.Duration(h.Looks)*h.Interval; took > catalogue.HealthWithin { + problems = append(problems, fmt.Sprintf("%s is said unhealthy at the earliest %s after it starts (a grace "+ + "of %s and %d looks every %s); it is at most %s, so a resource broken from its start is said within "+ + "the gate's bound", where, took, h.Grace, h.Looks, h.Interval, catalogue.HealthWithin)) + } + } + return problems +} + +func namedEndpointsWords(endpoints map[string]catalogue.Listening) string { + if len(endpoints) == 0 { + return "it names none" + } + names := make([]string, 0, len(endpoints)) + for n := range endpoints { + names = append(names, n) + } + sort.Strings(names) + return "it names " + strings.Join(names, ", ") +} + +func orNoneWords(names []string) string { + if len(names) == 0 { + return "none" + } + return strings.Join(names, ", ") +} + +// IdentityProblems is every module whose identity would overflow a provision it wants, on a machine +// whose name is `longestMachine` characters — judged before merge, over the catalogue alone, so the +// pull request that introduces an overflow is the one refused (novox/hq ADR 0225, issue 263). A +// provision no module in the shelf offers is not judged: its bound is not known here. +func IdentityProblems(shelf catalogue.Shelf, longestMachine int) []string { + offeredBy := map[string][]string{} + for _, name := range shelfOrder(shelf) { + for _, o := range shelf[name].Offers() { + offeredBy[o] = append(offeredBy[o], name) + } + } + machine := strings.Repeat("n", longestMachine) + var problems []string + for _, name := range shelfOrder(shelf) { + m := shelf[name] + source := catalogue.IdentitySource(m.Slug, m.Module) + for _, want := range m.Wants() { + // The tightest bound among the modules offering it: whichever one answers on a given + // machine, the identity has to fit it. + tightest, by := catalogue.IdentityBound{}, "" + for _, provider := range offeredBy[want] { + if provider == name { + continue // a module answering its own requirement is not its own consumer + } + b := shelf[provider].IdentityBoundOf(want) + if b.Bounded() && (!tightest.Bounded() || b.Max < tightest.Max) { + tightest, by = b, provider + } + } + if catalogue.CheckIdentityWithin(machine, source, tightest) == nil { + continue + } + got := catalogue.ConsumerIdentity(machine, source) + problems = append(problems, fmt.Sprintf( + "%s wants %s, and %s keeps its consumers' identities in %s of at most %d characters: "+ + "on a machine with a %d-character name it is identified as %q, %d — give %s a "+ + "`slug` of at most %d characters", + name, want, by, tightest.In, tightest.Max, longestMachine, got, len(got), name, + tightest.Max-len(catalogue.IdentityPrefix)-longestMachine-1)) + } + } + return problems +} + +// InstallationProblems is every value of a definition that names an installation, in the +// definition's own words: where it is, and what it names. +func InstallationProblems(m catalogue.Manifest) []string { + raw, err := json.Marshal(m) + if err != nil { + return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)} + } + var tree any + if err := json.Unmarshal(raw, &tree); err != nil { + return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)} + } + var problems []string + // The module's own name is a value too: a module named after the domain it serves is a + // definition that can only be installed there (issue 134). + for _, name := range namesIn(m.Module) { + problems = append(problems, fmt.Sprintf( + "%s is named after %s, and a module is named for what it is, not for where it runs", m.Module, name)) + } + walk(tree, "", nil, func(at string, value string, meant map[string]bool, isImage bool) { + for _, name := range namesIn(value) { + if (isImage && worldsRegistries[strings.ToLower(name)]) || meant[name] { + continue + } + problems = append(problems, fmt.Sprintf("%s names %s at %s", m.Module, name, at)) + } + for _, ip := range addressesIn(value) { + // An upstream pin's version may have four dotted numbers (Owl 1.5.6.1), which is no address; a name + // in it is still judged above. Its URL is judged whole: the vendor's host goes through the artifact's + // names-on-purpose like any name a definition means (novox/hq ADR 0316). + if meant[ip] || upstreamVersion.MatchString(at) { + continue + } + problems = append(problems, fmt.Sprintf("%s names the public address %s at %s", m.Module, ip, at)) + } + }) + sort.Strings(problems) + return problems +} + +// addressesIn is every public address in a value: not a private range, loopback, link-local, the +// unspecified address, a documentation range, or a resolver the world runs. +func addressesIn(value string) []string { + var out []string + for _, found := range standalone(address, value) { + ip := net.ParseIP(found) + if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || + ip.IsUnspecified() || ip.IsMulticast() || worldsResolvers[found] || documentation(ip) { + continue + } + out = append(out, found) + } + return out +} + +func documentation(ip net.IP) bool { + for _, cidr := range []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "100.64.0.0/10"} { + _, block, _ := net.ParseCIDR(cidr) + if block.Contains(ip) { + return true + } + } + return false +} + +// isName is whether a byte may be part of a name; a match bordered by one is a longer token. +func isName(b byte) bool { + return b == '.' || b == '-' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') +} + +// namesIn is every hostname in a value that could belong to an installation. +func namesIn(value string) []string { + var out []string + for _, found := range standalone(hostname, value) { + name := strings.ToLower(found) + switch { + case strings.HasSuffix(name, ".docker.internal"): + // The container runtime's alias for its own host: every machine running it has one. + case worldsServices[name]: + // A public authority named as a policy default, true of any mesh that wants it. + case name == "example.tld", strings.HasSuffix(name, ".example.tld"), + name == "example.com", name == "example.net", name == "example.org", + strings.HasSuffix(name, ".example.com"), strings.HasSuffix(name, ".example.net"), + strings.HasSuffix(name, ".example.org"), strings.HasSuffix(name, ".example"), + strings.HasSuffix(name, ".test"), strings.HasSuffix(name, ".invalid"): + // Documentation names, which is what a definition's own example should use. + default: + out = append(out, name) + } + } + return out +} + +// standalone are the matches of re in value that are whole tokens, not parts of a longer one. +func standalone(re *regexp.Regexp, value string) []string { + var out []string + for _, span := range re.FindAllStringIndex(value, -1) { + if span[0] > 0 && isName(value[span[0]-1]) { + continue + } + if span[1] < len(value) && isName(value[span[1]]) { + continue + } + out = append(out, value[span[0]:span[1]]) + } + return out +} + +// walk visits every string in the tree with its path, the names the enclosing resource means to +// name (with a reason), and whether it is an image reference. +func walk(node any, at string, meant map[string]bool, visit func(at, value string, meant map[string]bool, isImage bool)) { + switch v := node.(type) { + case map[string]any: + if declared, has := v[catalogue.NamesOnPurpose].(map[string]any); has { + widened := map[string]bool{} + for name := range meant { + widened[name] = true + } + for name, reason := range declared { + if r, ok := reason.(string); ok && strings.TrimSpace(r) != "" { + widened[strings.ToLower(name)] = true + } + } + meant = widened + } + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + // Prose is a string a person reads. A key that is called `why` or `description` and holds + // anything else — a setting of that name, whose default the mesh writes — is walked like any + // other (novox/hq ADR 0262). + if _, isString := v[k].(string); (prose[k] && isString) || k == catalogue.NamesOnPurpose || (at == "" && k == "module") { + continue + } + child := at + "." + k + if at == "" { + child = k + } + if s, isString := v[k].(string); isString { + visit(child, s, meant, k == "image") + continue + } + walk(v[k], child, meant, visit) + } + case []any: + for i, item := range v { + child := fmt.Sprintf("%s[%d]", at, i) + if s, isString := item.(string); isString { + visit(child, s, meant, false) + continue + } + walk(item, child, meant, visit) + } + } +} + +var address = regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}`) + +var hostname = regexp.MustCompile( + `(?i)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+` + + `(?:be|nl|de|fr|uk|eu|com|net|org|io|dev|app|cloud|site|online|me|co|ch|at|lu|` + + `internal|example|tld|test|invalid)`) + +var prose = map[string]bool{"why": true, "description": true} + +var upstreamVersion = regexp.MustCompile(`^build\.artifacts\[[0-9]+\]\.version$`) + +var worldsRegistries = map[string]bool{ + "docker.io": true, "registry-1.docker.io": true, "index.docker.io": true, "ghcr.io": true, + "quay.io": true, "gcr.io": true, "registry.k8s.io": true, "k8s.gcr.io": true, + "mcr.microsoft.com": true, "lscr.io": true, "public.ecr.aws": true, "registry.gitlab.com": true, + "codeberg.org": true, "cgr.dev": true, +} + +var worldsResolvers = map[string]bool{ + "1.1.1.1": true, "1.0.0.1": true, "8.8.8.8": true, "8.8.4.4": true, "9.9.9.9": true, + "149.112.112.112": true, "208.67.222.222": true, "208.67.220.220": true, +} + +var worldsServices = map[string]bool{ + "acme-v02.api.letsencrypt.org": true, "acme-staging-v02.api.letsencrypt.org": true, + "api.buypass.com": true, "api.test4.buypass.no": true, "dv.acme-v02.api.pki.goog": true, + "acme.zerossl.com": true, +} + +// LogProblems is what is wrong with a manifest's logs. +// +// Refused at registration, for a bucket's reason: a stream name the bus cannot hold, or a cap the +// mesh would not grant, is a module that installs, starts, and is refused on its first append. +func LogProblems(m catalogue.Manifest) []string { + var problems []string + if len(m.Logs) > 0 && !stateName.MatchString(m.Module) { + problems = append(problems, fmt.Sprintf( + "%s keeps a log, and a module's name is part of its logs' names, which take one plain "+ + "name — no dot (novox/hq ADR 0297)", m.Module)) + } + seen := map[string]bool{} + for _, l := range m.Logs { + switch { + case !stateName.MatchString(l.Name): + problems = append(problems, fmt.Sprintf( + "%s keeps log %q: a log is named locally — lower-case letters, digits and hyphens, "+ + "no dot and no underscore; the mesh derives the stream (novox/hq ADR 0297)", m.Module, l.Name)) + case seen[l.Name]: + problems = append(problems, fmt.Sprintf("%s keeps log %q twice", m.Module, l.Name)) + } + seen[l.Name] = true + if l.MaxMiB != 0 && (l.MaxMiB < catalogue.LogLeastMiB || l.MaxMiB > catalogue.LogMostMiB) { + problems = append(problems, fmt.Sprintf( + "%s caps log %q at %d MiB; a log holds between %d and %d MiB (novox/hq ADR 0297)", + m.Module, l.Name, l.MaxMiB, catalogue.LogLeastMiB, catalogue.LogMostMiB)) + } + } + return problems +} + +// CredentialFormProblems refuses an offer that does not say its credential's form (the lead, 2026-10-29): an own +// secret, an issuer, or `form` pair or none, and `form` only where neither of the others is said. +func CredentialFormProblems(m catalogue.Manifest) []string { + var problems []string + for _, o := range m.Provides { + c := o.Credential + switch { + case c == nil || (c.Own == "" && c.IssuedBy == "" && c.Form == ""): + problems = append(problems, fmt.Sprintf("%s provides %q and says no credential form: say `credential` with "+ + "`form` pair (a login of each consumer's own) or none (no credential), or the own secret or issuer it is", + m.Module, o.Name)) + case c.Form != "" && (c.Own != "" || c.IssuedBy != ""): + problems = append(problems, fmt.Sprintf("%s provides %q with `form` beside an own secret or an issuer; `form` "+ + "says only pair or none", m.Module, o.Name)) + case c.Form != "" && c.Form != catalogue.CredentialFormPair && c.Form != catalogue.CredentialFormNone: + problems = append(problems, fmt.Sprintf("%s provides %q with the form %q, not %q or %q", m.Module, o.Name, + c.Form, catalogue.CredentialFormPair, catalogue.CredentialFormNone)) + } + } + return problems +} + +func ParseManifest(raw []byte) (catalogue.Manifest, error) { + return parseManifest(raw, catalogue.PublishedPredefined()) +} + +// ParseManifestWith is ParseManifest judging every value that falls back to a predefined key against the list +// given — the one read from the tree the manifest is in (predefined.go, novox/hq ADR 0348 §7) — where +// ParseManifest judges against the list the mesh published. Nil is no list: a value naming a key is refused. +func ParseManifestWith(raw []byte, predefined catalogue.PredefinedList) (catalogue.Manifest, error) { + return parseManifest(raw, predefined) +} + +// endpointNameProblems holds a module's endpoint names to being usable as references (novox/hq ADR +// 0138). +// +// **Unique, because the point of a name is that it identifies one thing.** Two endpoints called the +// same would make an assignment that configures one silently configure whichever the mesh read last +// — the shape of fault this repository keeps finding, where a declaration appears to say something +// and says something else. +func endpointNameProblems(m catalogue.Manifest) []string { + var problems []string + seen := map[string]int{} + for _, l := range m.Listens { + name := strings.TrimSpace(l.Name) + if name == "" { + continue + } + if !endpointName.MatchString(name) { + problems = append(problems, fmt.Sprintf( + "%s calls the endpoint on port %d %q; a name is lowercase letters, digits and "+ + "dashes, starting with a letter", m.Module, l.Port, l.Name)) + continue + } + if before, already := seen[name]; already { + problems = append(problems, fmt.Sprintf( + "%s calls both port %d and port %d %q, so anything naming that endpoint could mean "+ + "either", m.Module, before, l.Port, name)) + continue + } + seen[name] = l.Port + } + return problems +} + +// hasSchedule is whether a resource declares a cadence, as a string. +func hasSchedule(r map[string]any) bool { + s, _ := r["schedule"].(string) + return s != "" +} + +// invokeProblems judges what a module says it calls (novox/hq ADR 0152). +// +// Refused here, in the manifest's words, rather than at the next composition of the bus's user +// list — where a bad entry would stop the whole file being written for everybody, as a person's +// malformed grant would have (operator.go). An entry that names a module and no tool is the one +// mistake worth naming: `shop` reads like a grant to a module's tools and would be a grant to nothing. +func invokeProblems(m catalogue.Manifest) []string { + var problems []string + for _, t := range m.Invokes { + if !invokeNameValid(t) { + problems = append(problems, fmt.Sprintf( + "%s invokes %q, which does not name a tool: a module invokes ., or "+ + "* for every tool on the mesh (novox/hq ADR 0152)", m.Module, t)) + } + } + return problems +} + +// ownSecretOwnerProblems is what is wrong with an own secret's declared owner (novox/hq ADR 0436 §1): naming +// an account the module makes — a `user` resource of that name — never root, which a secret file owned by it +// would make readable by anything, and never the operator account's placeholder (`${machine:account}`), which +// the mesh fills per machine and a manifest cannot say safely (novox/hq ADR 0436, settled by the lead). +func ownSecretOwnerProblems(m catalogue.Manifest, name, owner string) []string { + if owner == "" { + return nil + } + if owner == "root" { + return []string{fmt.Sprintf( + "%s names %q as the owner of its secret %q; an own secret's owner is an account the module "+ + "makes, never root (novox/hq ADR 0436 §1)", m.Module, owner, name)} + } + if owner == "${machine:account}" { + return []string{fmt.Sprintf( + "%s names %q as the owner of its secret %q; an own secret's owner is an account the module "+ + "makes, never the operator account (novox/hq ADR 0436 §1)", m.Module, owner, name)} + } + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == owner { + return nil + } + } + return []string{fmt.Sprintf( + "%s names %q as the owner of its secret %q, which it does not make: a user resource named %q "+ + "(novox/hq ADR 0436 §1)", m.Module, owner, name, owner)} +} + +// ownSecretPromptProblems is what is wrong with an own secret's declared prompt, in the module check's words: +// empty is fine (no prompt is declared, and the desk asks by name alone as it does today); otherwise one line, +// at most PromptMax characters — counting what a person reads, not its bytes — holding only the `{member}` +// placeholder, and only where the secret is a family: a single secret's prompt has no member to fill, so +// `{member}` there would stay in the prompt literally (novox/hq issue 789). +func ownSecretPromptProblems(m catalogue.Manifest, name, prompt string, family bool) []string { + if prompt == "" { + return nil + } + var problems []string + if strings.ContainsAny(prompt, "\n\r") { + problems = append(problems, fmt.Sprintf( + "%s names its secret %q with a prompt that holds more than one line; a prompt is one line (novox/hq issue 789)", + m.Module, name)) + } + if n := utf8.RuneCountInString(prompt); n > catalogue.PromptMax { + problems = append(problems, fmt.Sprintf( + "%s names its secret %q with a prompt %d characters long; a prompt is at most %d characters", + m.Module, name, n, catalogue.PromptMax)) + } + for _, ph := range promptPlaceholder.FindAllString(prompt, -1) { + if ph != "{member}" { + problems = append(problems, fmt.Sprintf( + "%s names its secret %q with a prompt that holds %s; a prompt holds only the {member} placeholder, "+ + "filled with a family's member suffix (novox/hq issue 789)", + m.Module, name, ph)) + } else if !family { + problems = append(problems, fmt.Sprintf( + "%s names its secret %q with a prompt that holds {member}; {member} is replaced only for a "+ + "family's member (novox/hq issue 789)", + m.Module, name)) + } + } + return problems +} + +func parseManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.Manifest, error) { + var m catalogue.Manifest + // Strictly. **An unknown key is refused**, which is the discipline the host's declaration + // parser has and manifests lacked (novox/hq 04-ISSUES/003): a `scope:` key survived in five + // manifests, read by nothing, making them appear to restrict a port and restrict nothing. + // + // "An unenforced rule is indistinguishable from a wrong one, and costs more, because people + // believe it" — and a silently-accepted key is worse than unenforced, because a reviewer + // checking whether something is restricted will find that it is, and be wrong. + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + err := decoder.Decode(&m) + if err == nil && m.SetAside() != "" { + // Kept aside by UnmarshalJSON for the stored catalogue's sake; registration refuses it. + err = errors.New(m.SetAside()) + } + if err != nil { + // A key that used to mean something says what it became. Refusing a renamed field with + // "unknown field" is correct and unhelpful: whoever wrote it knew what they meant, and + // the mesh knows what it is called now. + for was, is := range renamed { + if strings.Contains(err.Error(), `"`+was+`"`) { + return catalogue.Manifest{}, fmt.Errorf( + "this manifest says %q, which is now called %q: %w", was, is, err) + } + } + return catalogue.Manifest{}, fmt.Errorf("this is not a module manifest: %w", err) + } + m.SetPredefined(predefined) + + var problems []string + if !name.MatchString(m.Module) { + problems = append(problems, fmt.Sprintf( + "%q is not a usable module name: lower-case letters, digits, dashes and dots", m.Module)) + } + // A slug is a short identifier the mesh derives a login from (novox/hq ADR 0049). The same + // charset as a name; its length is judged against the bound of each provision it wants on the + // longest machine name by the catalogue check (IdentityProblems, ADR 0225), and against the + // machine it is on when its provider grants it — a slug that is fine on one machine's short name + // can overflow another's. + if m.Slug != "" && !name.MatchString(m.Slug) { + problems = append(problems, fmt.Sprintf( + "%q is not a usable slug: lower-case letters, digits, dashes and dots", m.Slug)) + } + // **`amqp` is not a provision, and not a requirement** (novox/hq ADR 0131). A module that wants + // messaging wants the mesh's bus — it emits and consumes through the sdk, which the mesh hands the + // bus with the module's own credential — and the bus is whatever holds `mesh-broker`, spoken in + // whatever that holder speaks. Naming the old wire protocol asks for a specific server, and the + // only one that could answer is the one being retired. Refused here so the word cannot come back + // through a manifest. + for _, offer := range m.Provides { + if offer.Name == "amqp" { + problems = append(problems, fmt.Sprintf( + "%s provides %q, which is not a provision: the mesh's bus is whatever holds "+ + "mesh-broker, and a module provides mesh-bus to be it (novox/hq ADR 0131)", + m.Module, offer.Name)) + } + } + // **The bus's own account copies the bus and says nothing on it** (novox/hq ADR 0235). The module + // holding mesh-broker is granted the snapshot API and nothing else, so anything it declared to say + // or hear on the bus would be granted nothing — refused here rather than silently dropped. + // + // **But its tools bundle may say what it declares** (novox/hq ADR 0357): the node's runtime carries + // the bundle and is granted the events the module emits, under the module's name, as for any module it + // carries — the bus's account is not who publishes them. So emits are refused only for a bus module + // with no bundle to say them. + if _, account := m.OwnSecrets["broker"]; account && m.ClaimsSeat(catalogue.BrokerSeat) { + var said []string + if len(m.EmitsAll()) > 0 && !manifestShipsABundle(m) { + said = append(said, "emits") + } + for _, f := range []struct { + name string + n int + }{{"consumes", len(m.Consumes)}, {"uses", len(m.Uses)}, {"invokes", len(m.Invokes)}, + {"state", len(m.State)}, {"logs", len(m.Logs)}, {"reads", len(m.Reads)}, + {"streams", len(m.Streams)}} { + if f.n > 0 { + said = append(said, f.name) + } + } + if len(said) > 0 { + problems = append(problems, fmt.Sprintf( + "%s holds %s and declares a bus account, which is granted the bus's snapshot API and nothing "+ + "else (novox/hq ADR 0235); its %s would be granted nothing", m.Module, catalogue.BrokerSeat, strings.Join(said, ", "))) + } + } + for _, r := range m.Requires { + if r == "amqp" { + problems = append(problems, fmt.Sprintf( + "%s requires %q, which is not a provision: a module reaches the mesh's bus through "+ + "the sdk, and depends on the mesh-broker seat, not on a protocol (novox/hq ADR 0131)", + m.Module, r)) + } + } + if m.Instances != "" && m.Instances != catalogue.InstancesInterchangeable { + problems = append(problems, fmt.Sprintf( + "%s says its instances are %q; the one word is %q, for a module that is the same on every machine", + m.Module, m.Instances, catalogue.InstancesInterchangeable)) + } + for _, offer := range m.Provides { + p := offer.Name + if !name.MatchString(p) { + problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p)) + } + // A provision's credential is a provision of its own, `secret-` (novox/hq ADR 0570 clause 6, the + // lead 2026-10-29): no module provides a name of that form, so the two keys never meet. + if strings.HasPrefix(p, catalogue.CredentialProvisionPrefix) { + problems = append(problems, fmt.Sprintf("%s provides %q; a name starting %q is the mesh's for a provision's "+ + "credential, kept by the vault (novox/hq ADR 0570)", m.Module, p, catalogue.CredentialProvisionPrefix)) + } + // A bound says what keeps the name, so the refusal it causes can say it (ADR 0225); and it + // leaves room for the shortest identity the mesh makes, or it would refuse every consumer. + if id := offer.Identity; id != nil && !id.None { + if strings.TrimSpace(id.In) == "" { + problems = append(problems, fmt.Sprintf( + "%s bounds the identities of %s's consumers without saying what keeps them: `in`", + m.Module, p)) + } + if shortest := len(catalogue.IdentityPrefix) + 3; id.Max < 0 || id.Max > 0 && id.Max < shortest { + problems = append(problems, fmt.Sprintf( + "%s bounds %s's consumers' identities at %d characters, and the shortest the mesh "+ + "makes is %d", m.Module, p, id.Max, shortest)) + } + } + if c := offer.Credential; c != nil && c.IssuedBy == catalogue.IssuedOutside { + // Only a person can give it (novox/hq ADR 0337 §1, issue 164): the software holds a value the mesh can + // neither make nor fetch, so the offer names nothing that would answer it — no own secret, no tool. + switch { + case c.Own != "": + problems = append(problems, fmt.Sprintf( + "%s provides %q with a credential issued outside the mesh and names its own secret %q too; a "+ + "person gives the value with `secret accept`, so the offer names no own secret (ADR 0337)", + m.Module, p, c.Own)) + case c.Tool != "": + problems = append(problems, fmt.Sprintf( + "%s provides %q with a credential issued outside the mesh and names the tool %q; only a "+ + "person gives it, so the offer names no tool (ADR 0337)", m.Module, p, c.Tool)) + } + } else if c := offer.Credential; c != nil && (c.IssuedBy != "" || c.Tool != "") { + // The software issues it (novox/hq ADR 0305): named by the tool that answers it, never by an own + // secret, which would be a second copy for the mesh to keep in step with the software's. + switch { + case c.IssuedBy != catalogue.IssuedBySoftware: + problems = append(problems, fmt.Sprintf( + "%s provides %q with a credential issued by %q; the issuers an offer may name are %q and %q "+ + "(ADR 0305, ADR 0337)", m.Module, p, c.IssuedBy, catalogue.IssuedBySoftware, catalogue.IssuedOutside)) + case c.Own != "": + problems = append(problems, fmt.Sprintf( + "%s provides %q with a credential its software issues and names its own secret %q too; the "+ + "software's value is the one, so the offer names no own secret (ADR 0305)", m.Module, p, c.Own)) + case strings.TrimSpace(c.Tool) == "": + problems = append(problems, fmt.Sprintf( + "%s provides %q with a credential its software issues and names no tool that answers it: "+ + "\"tool\": \"\" (ADR 0305)", m.Module, p)) + } + } else if offer.Credential != nil { + own, declared := m.OwnSecrets[offer.Credential.Own] + switch { + case offer.Credential.Own == "" && offer.Credential.Form != "": + // The form alone, pair or none (CredentialFormProblems judges it). + case offer.Credential.Own == "": + problems = append(problems, fmt.Sprintf( + "%s provides %q with a credential that names no own secret", m.Module, p)) + case !declared: + problems = append(problems, fmt.Sprintf( + "%s provides %q with its own secret %q as the credential, and declares no such secret", + m.Module, p, offer.Credential.Own)) + case own.Taken == "": + problems = append(problems, fmt.Sprintf( + "%s provides %q with its own secret %q as the credential every consumer receives, so "+ + "the secret must say how the module takes it: \"taken\": \"at-start\" or \"applied\" (ADR 0158)", + m.Module, p, offer.Credential.Own)) + } + } + if instead, generic := engineGeneric[p]; generic { + // A consumer is written against an engine, not a role (novox/hq ADR 0027). Providing + // the role means a requirement for it matches any engine, resolves as satisfied, and + // fails on the first query — with nothing pointing back at the match. + problems = append(problems, fmt.Sprintf( + "%s provides %q, which hides which engine it is: a requirement for %q would match "+ + "any of them and fail on the first query. Name the engine — %s", + m.Module, p, p, instead)) + } + if s := offer.At(); s != catalogue.ScopeNode && s != catalogue.ScopeMesh { + // Site scope is meaningful for a claim — one DHCP server per segment — and is not + // yet meaningful for a provision, because nothing knows how to reach "the one at my + // site". Refused rather than silently treated as mesh-wide. + problems = append(problems, fmt.Sprintf( + "%s provides %q at scope %q; a provision is %q or %q", + m.Module, p, s, catalogue.ScopeNode, catalogue.ScopeMesh)) + } + switch { + case offer.Reach == "": + case offer.Reach != catalogue.ReachMachine: + // The one reach a provision has (novox/hq ADR 0208): a provision reached over the private + // network is mesh scope, and the world reaches nothing but a name. + problems = append(problems, fmt.Sprintf( + "%s provides %q with reach %q; a provision's reach is %q or nothing", + m.Module, p, offer.Reach, catalogue.ReachMachine)) + case offer.At() != catalogue.ScopeNode: + problems = append(problems, fmt.Sprintf( + "%s provides %q at scope %q with the machine's reach; a provision usable only on its own "+ + "machine is node-scoped (novox/hq ADR 0208)", m.Module, p, offer.At())) + } + if p == m.Module { + // Harmless and worth saying: a module always provides its own name, so writing it + // suggests the author expected it not to. + problems = append(problems, fmt.Sprintf( + "%s provides its own name already; listing it says nothing", m.Module)) + } + } + for _, r := range m.Requires { + if !name.MatchString(r) { + problems = append(problems, fmt.Sprintf("%q is not a usable name to require", r)) + } + if r == m.Module { + problems = append(problems, fmt.Sprintf("%s requires itself", m.Module)) + } + } + // What it may call an event, and what it may ask to hear (events.go). Checked here because a + // module whose event names are wrong installs, starts, connects and reacts to nothing, with + // every log line saying it is fine (novox/hq 04-ISSUES/127). + problems = append(problems, EventProblems(m)...) + // And what it may call its state, and whose it may read (state.go, novox/hq ADR 0201). + problems = append(problems, StateProblems(m)...) + // And what it may call its logs, and how large it may ask them to grow (logs.go, novox/hq ADR 0297). + problems = append(problems, LogProblems(m)...) + // And the streams it owns, and who may hear them (streams.go, novox/hq ADR 0363). + problems = append(problems, StreamProblems(m)...) + problems = append(problems, WorkQueueStreamProblems(m)...) + // And the defaults it gives its settings (setting_defaults.go, novox/hq ADR 0262), or, written in the + // config form, the whole declaration of each value (config.go, novox/hq ADR 0348). + if m.DeclaresConfig() { + problems = append(problems, ConfigProblems(m)...) + } else { + problems = append(problems, SettingProblems(m)...) + problems = append(problems, TypedSettingProblems(m)...) + } + // And what it requires optionally, and the files it delivers only while bound (novox/hq ADR 0330). + problems = append(problems, OptionalProblems(m)...) + // And which setting keys each mergeable file takes (setting_takes.go, novox/hq ADR 0326). + problems = append(problems, TakenSettingsProblems(m)...) + wellFormed := true + for _, c := range m.Claims { + if !name.MatchString(c.Name) { + problems = append(problems, fmt.Sprintf("%q is not a usable claim name", c.Name)) + wellFormed = false + } + switch c.At() { + case catalogue.ScopeNode, catalogue.ScopeSite, catalogue.ScopeMesh: + default: + problems = append(problems, fmt.Sprintf( + "%s claims %s at scope %q; a claim is held per node, per site or per mesh", + m.Module, c.Name, c.Scope)) + wellFormed = false + } + } + // Against the seats the mesh defines (novox/hq ADR 0110), once every claim is at least a name + // and a scope — a malformed claim is refused for that, not a second time for being unknown. + if wellFormed { + problems = append(problems, claimProblems(m)...) + } + // What one manifest can be judged on: a declaration's shape, its scope, and the reserved + // prefix. Whether a seat anybody names exists, and whether a holder answers for it, are + // facts about the catalogue and are checked at registration (CatalogueProblems). + problems = append(problems, declaredSeatProblems(m)...) + problems = append(problems, RunsAsProblems(m)...) + problems = append(problems, ProcessBusProblems(m)...) + problems = append(problems, seatEventProblems(m)...) + problems = append(problems, ReadsHolderProblems(m)...) + problems = append(problems, FilesHolderProblems(m)...) + if m.Computed != "" && len(m.Resources) > 0 { + // One or the other. A module that both ships files and has them computed would leave + // nobody able to say where a given file came from. + problems = append(problems, fmt.Sprintf( + "%s has resources of its own and says they are computed by %q; it is one or the other", + m.Module, m.Computed)) + } + for to, values := range m.Contributes { + if !name.MatchString(to) { + problems = append(problems, fmt.Sprintf("%q is not a usable name to contribute to", to)) + } + if len(values) == 0 { + // An empty contribution is either a mistake or a requirement written the long way + // round, and both are better said plainly. + problems = append(problems, fmt.Sprintf( + "%s contributes nothing to %q; if it only needs one, require it", m.Module, to)) + } + } + for to, locals := range m.ContributesMany { + if !name.MatchString(to) { + problems = append(problems, fmt.Sprintf("%q is not a usable name to contribute to", to)) + } + if len(locals) == 0 { + problems = append(problems, fmt.Sprintf( + "%s contributes nothing to %q; if it only needs one, require it", m.Module, to)) + } + for local, values := range locals { + if !name.MatchString(local) { + problems = append(problems, fmt.Sprintf( + "%s contributes to %q under %q, which is not a usable name", m.Module, to, local)) + } + if len(values) == 0 { + problems = append(problems, fmt.Sprintf( + "%s contributes nothing to %q under %q", m.Module, to, local)) + } + } + } + problems = append(problems, buildProblems(m.Build, m.Module)...) + // A file's text in a template file of the module's repository (novox/hq ADR 0349 §2): only a file, never beside + // content, only under files/, and packed by the module's build. + problems = append(problems, templateProblems(m)...) + problems = append(problems, movedProblems(m)...) + problems = append(problems, collectionProblems(m)...) + problems = append(problems, undeliveredBundles(m)...) + // **What provides the artifact store cannot be delivered through it** (novox/hq 04-ISSUES/029). + // + // Building publishes to the store, and the builder will not start without one. So a module + // that provides the store and also builds something asks the mesh to put an artifact into the + // thing that artifact is needed to create. + // + // It is the question the foundation record asks of every candidate — can it grant itself the + // thing it provides? The store cannot create its own database, the broker cannot create its + // own virtual host, and a registry cannot grant itself a repository. Such a module names its + // image, exactly as the bundle names the three a first node starts from. + // + // Refused here because the alternative is a build that never returns, on a mesh new enough + // that nobody is watching it yet. + if m.Build != nil && len(m.Bundles) > 0 { + // The output of a build, written beside the build that produces it (ADR 0175). A resource + // naming a digest beside an `artifact` would be the same mistake, and is caught the same way: + // what the mesh derives, a repository does not state. + problems = append(problems, fmt.Sprintf( + "%s writes `bundles` beside its build. The mesh derives that from what the build "+ + "produced; a manifest states `build.artifacts` and nothing about what came out", + m.Module)) + } + if m.Build != nil && len(m.Build.Artifacts) > 0 { + for _, o := range m.Offers() { + if o != catalogue.ArtifactStoreProvision { + continue + } + problems = append(problems, fmt.Sprintf( + "%s provides %q and also builds %d artifact(s), which cannot both be true: "+ + "building publishes to the artifact store, so this asks the mesh to put an "+ + "artifact into the thing that artifact is needed to create. A module that "+ + "provides the store names its image instead. One that wants more beside it "+ + "— an interface, a tool server — is a second module, mirrored in the "+ + "ordinary way once this one is running", + m.Module, catalogue.ArtifactStoreProvision, len(m.Build.Artifacts))) + } + } + for to := range m.Serves { + var offered bool + for _, o := range m.Offers() { + if o == to { + offered = true + } + } + if !offered { + problems = append(problems, fmt.Sprintf( + "%s serves %q to whoever requires it, and does not provide it", m.Module, to)) + } + } + // A served value may be derived for the consumer it is served to (novox/hq ADR 0202). Read + // here, where the definition is, rather than when somebody first requires it: a rule that + // would be refused at the first consumer is wrong from the moment it is written. + problems = append(problems, CheckServes(m)...) + for to, where := range m.Binds { + if !placedOrAbsolute(where) { + problems = append(problems, fmt.Sprintf( + "%s binds %q at %q, which is neither an absolute path nor a placed one", m.Module, to, where)) + } + var wanted bool + for _, w := range m.Wants() { + if w == to { + wanted = true + } + } + if !wanted { + // Being told about something you never asked for would write a file describing a + // machine this one has no business talking to. + problems = append(problems, fmt.Sprintf( + "%s binds %q and does not require it", m.Module, to)) + } + } + if f := m.Filtering; f != nil && strings.TrimSpace(f.Into) == "" { + problems = append(problems, fmt.Sprintf( + "%s asks for the computed rule set and does not say where to put it", m.Module)) + } + for _, l := range m.Listens { + if l.Port < 1 || l.Port > 65535 { + problems = append(problems, fmt.Sprintf( + "%s listens on port %d, which is not a port", m.Module, l.Port)) + } + switch l.From { + case catalogue.FromMesh, catalogue.FromEverywhere, catalogue.FromMachine, catalogue.FromLocalNetwork: + case "": + // The fault this field exists to prevent. A rule with no source is open, and a + // manifest that omitted it would read as a restriction and be none. + problems = append(problems, fmt.Sprintf( + "%s listens on %d and does not say from where; it is %q, %q, %q or %q", + m.Module, l.Port, catalogue.FromMesh, catalogue.FromEverywhere, catalogue.FromMachine, catalogue.FromLocalNetwork)) + default: + problems = append(problems, fmt.Sprintf( + "%s listens on %d from %q; it is %q, %q, %q or %q", + m.Module, l.Port, l.From, catalogue.FromMesh, catalogue.FromEverywhere, catalogue.FromMachine, catalogue.FromLocalNetwork)) + } + if p := l.At(); p != "tcp" && p != "udp" { + problems = append(problems, fmt.Sprintf( + "%s listens on %d over %q, which is tcp or udp", m.Module, l.Port, p)) + } + } + problems = append(problems, invokeProblems(m)...) + problems = append(problems, waitProblems(m)...) + problems = append(problems, replacesProblems(m)...) + problems = append(problems, UnitSettingProblems(m)...) + problems = append(problems, OwnerSettingProblems(m)...) + problems = append(problems, GroupSettingProblems(m)...) + problems = append(problems, endpointNameProblems(m)...) + problems = append(problems, RouteProblems(m)...) + for _, port := range m.Guards { + if port < 1 || port > 65535 { + problems = append(problems, fmt.Sprintf( + "%s guards port %d, which is not a port", m.Module, port)) + } + } + if c := m.Certificate; c != nil { + if !strings.HasPrefix(c.Into, "/") { + problems = append(problems, fmt.Sprintf( + "%s wants its certificate at %q, which is not an absolute path", m.Module, c.Into)) + } + if c.Authority != "" && !strings.HasPrefix(c.Authority, "/") { + problems = append(problems, fmt.Sprintf( + "%s wants the authority at %q, which is not an absolute path", + m.Module, c.Authority)) + } + } + // **A module may not declare an action, and this is where it is said** (novox/hq ADR 0005). + // + // The host already refuses one, correctly and for the right reason: the link may not carry a + // command to run, and that bound is what limits a compromised control plane to shapes it + // cannot turn into arbitrary code. But a module's resources reach a machine over the link, so + // a manifest carrying an action was accepted here, stored, resolved, planned and pushed — and + // refused on the machine, in the host's log, with nothing connecting it back to the manifest + // that caused it. + // + // That is the same failure as the network shape earlier today: the refusal was right, arrived + // far from its cause, and nobody was reading the log. A rule enforced only at the far end is + // enforced; it is just not usable. + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "action" { + continue + } + problems = append(problems, fmt.Sprintf( + "%s declares %v, which is an action, and a module may not: the link may not carry a "+ + "command to run (novox/hq ADR 0005). A module that needs something done ships a "+ + "program that reads what the mesh delivered and reconciles", + m.Module, r["id"])) + } + // **What a process replaces is something the module no longer declares** (novox/hq issue 213). + // The host keeps it running until the process is, then removes it: so it is named by the id the + // module used to give it, it is never a resource the module still declares — that would be + // applied and removed by one declaration — and only a process that stays up has anything to + // hand over to. Said here, near the author, as the host would refuse it far away. + ids := map[string]bool{} + for _, r := range m.Resources { + ids[fmt.Sprint(r["id"])] = true + } + for _, r := range m.Resources { + raw, present := r["replaces"] + if !present { + continue + } + if fmt.Sprint(r["type"]) != "process" { + problems = append(problems, fmt.Sprintf( + "%s: %v says what it replaces, and only a process does", m.Module, r["id"])) + continue + } + if once, _ := r["run-once"].(bool); once || r["schedule"] != nil { + problems = append(problems, fmt.Sprintf( + "%s: %v replaces something and runs once or on a schedule — only a process that stays "+ + "up is there a moment later to hand over to", m.Module, r["id"])) + } + list, ok := raw.([]any) + if !ok { + problems = append(problems, fmt.Sprintf( + "%s: %v replaces %v; replaces is a list of the ids this module no longer declares", + m.Module, r["id"], raw)) + continue + } + for _, item := range list { + id, ok := item.(string) + switch { + case !ok || strings.TrimSpace(id) == "": + problems = append(problems, fmt.Sprintf( + "%s: %v replaces %v, which is not an id", m.Module, r["id"], item)) + case strings.Contains(id, "."): + problems = append(problems, fmt.Sprintf( + "%s: %v replaces %q; a process replaces only a resource of its own module, named "+ + "by its own id", m.Module, r["id"], id)) + case ids[id]: + problems = append(problems, fmt.Sprintf( + "%s: %v replaces %q, which this module still declares", m.Module, r["id"], id)) + } + } + } + // **A module that prepares its state must have code the mesh can run** (novox/hq ADR 0135). The + // preparation is the module's own program in its preparation mode, so it is derived from the + // resource that runs that program — and a module declaring none has asked for something the mesh + // cannot compose. Said here, where the manifest is read, rather than by a declaration that + // quietly prepares nothing. + if m.Prepares && preparationTarget(m) == "" { + problems = append(problems, fmt.Sprintf( + "%s says it prepares its state, and declares no container or process running an artifact it built — "+ + "the preparation is this module's own program, so there has to be one for the mesh to "+ + "run it in", m.Module)) + } + // **A run-once container is a step the host runs to completion** (novox/hq ADR 0052). It is a + // boolean modifier on the container shape — the host runs the container, requires it to exit 0, + // and starts whatever the declaration places after it only once it has. A value that is not a + // boolean is refused here rather than only on the machine, for the same near-versus-far reason + // the action ban above records. A run-once step may name what it reads under restart-on: for a + // step the word means *run again* when one of those changed, which is how a fact fetched from a + // provider is fetched again when the provider moved (novox/hq ADR 0099). + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "container" { + continue + } + var runOnce bool + if raw, present := r["run-once"]; present { + once, ok := raw.(bool) + if !ok { + problems = append(problems, fmt.Sprintf( + "%s declares run-once on %v as a %T; run-once is true or false", + m.Module, r["id"], raw)) + } else { + runOnce = once + } + } + // **A scheduled container runs on a recurring cadence** (novox/hq ADR 0053), the recurring + // twin of run-once. The control plane carries the field to the host unchanged (the resolver + // copies every key of a resource, so `schedule` reaches the rendered declaration on its own); + // what belongs here is refusing, near its author, a value the host would only refuse far away. + // Three things: a value that is not a string, a string that is not a valid cron, and the pair + // run-once + schedule — a container runs once, or on a cadence, or stays up, never two. + if raw, present := r["schedule"]; present { + cron, ok := raw.(string) + switch { + case !ok: + problems = append(problems, fmt.Sprintf( + "%s declares schedule on %v as a %T; a schedule is a five-field cron string", + m.Module, r["id"], raw)) + default: + if err := validateCron(cron); err != nil { + problems = append(problems, fmt.Sprintf( + "%s declares schedule %q on %v, which is not a valid cron: %v", + m.Module, cron, r["id"], err)) + } + if runOnce { + problems = append(problems, fmt.Sprintf( + "%s declares %v as both run-once and schedule; a container runs once and "+ + "gates, or on a cadence, or stays up — never two (novox/hq ADR 0053)", + m.Module, r["id"])) + } + } + } + // **A scheduled step may hold this module's own containers still while it runs** + // (novox/hq ADR 0189). What the host judges is the declaration it receives — whether each + // id is a container placed on that machine; what belongs here is what only the definition + // shows: that the ids are this module's, that they are containers, and that the step is + // scheduled. A module naming a neighbour's container would be a module that can stop the + // mesh, and the manifest is where that is visible. + problems = append(problems, whileStoppedProblems(m, r, hasSchedule(r))...) + } + for name, own := range m.OwnSecrets { + // **A family is issued outside the mesh, and lands one file per member** (novox/hq ADR 0283): the mesh + // never makes a member, so a family the mesh may make would be one nothing ever fills. + if catalogue.IsFamily(name) { + if !memberRest.MatchString(strings.TrimSuffix(catalogue.FamilyPrefix(name), "-")) { + problems = append(problems, fmt.Sprintf( + "%s declares the secret family %q, whose prefix is not a name", m.Module, name)) + } + if own.IssuedBy != catalogue.IssuedOutside { + problems = append(problems, fmt.Sprintf( + "%s declares the secret family %q without \"issued-by\": %q; the mesh never makes a "+ + "member of a family, so only a party outside the mesh can fill one (novox/hq ADR 0283)", + m.Module, name, catalogue.IssuedOutside)) + } + if strings.Count(own.Path, "*") != 1 { + problems = append(problems, fmt.Sprintf( + "%s keeps the secret family %q at %q, which does not hold exactly one *: each member lands "+ + "where the * is replaced by its name (novox/hq ADR 0283)", m.Module, name, own.Path)) + } + for other := range m.OwnSecrets { + if other != name && !catalogue.IsFamily(other) { + if rest := strings.TrimPrefix(other, catalogue.FamilyPrefix(name)); rest != other && memberRest.MatchString(rest) { + problems = append(problems, fmt.Sprintf( + "%s declares the secret %q, which is also a member of its family %q — a name names one", + m.Module, other, name)) + } + } + } + } else if strings.Contains(name, "*") { + problems = append(problems, fmt.Sprintf( + "%s declares the secret %q: a * only ends a family's name, as \"-*\"", m.Module, name)) + } + if !placedOrAbsolute(own.Path) { + problems = append(problems, fmt.Sprintf( + "%s needs %q at %q, which is neither an absolute path nor a placed one", m.Module, name, own.Path)) + } + if name == "" { + problems = append(problems, m.Module+" needs a secret with no name") + } + if own.Taken != "" && own.Taken != catalogue.TakenAtStart && own.Taken != catalogue.TakenApplied { + problems = append(problems, fmt.Sprintf( + "%s says its secret %q is taken %q; a secret is taken %q (read when the module starts) "+ + "or %q (applied by the module's own code to a backend that takes it once)", + m.Module, name, own.Taken, catalogue.TakenAtStart, catalogue.TakenApplied)) + } + if own.IssuedBy != "" && own.IssuedBy != catalogue.IssuedOutside { + problems = append(problems, fmt.Sprintf( + "%s says its secret %q is issued by %q; a secret says %q when a party outside the mesh "+ + "issues it — a vendor's key, a bot's token, a licence — and says nothing when the mesh "+ + "may make it (novox/hq ADR 0228)", + m.Module, name, own.IssuedBy, catalogue.IssuedOutside)) + } + problems = append(problems, ownSecretPromptProblems(m, name, own.Prompt, catalogue.IsFamily(name))...) + problems = append(problems, ownSecretOwnerProblems(m, name, own.Owner)...) + } + localOf := map[string]string{} + for _, to := range m.SecretRequirements() { + if _, plain := m.Secrets[to]; plain { + if _, also := m.SecretsMany[to]; also { + problems = append(problems, fmt.Sprintf( + "%s keeps the credential for %q both as one file and as several", m.Module, to)) + } + } + for _, f := range m.SecretFiles(to) { + if !placedOrAbsolute(f.Path) { + problems = append(problems, fmt.Sprintf( + "%s keeps the credential for %q at %q, which is neither an absolute path nor a placed one", + m.Module, catalogue.SecretLocal(to, f.Local), f.Path)) + } + if f.Local != "" && !name.MatchString(f.Local) { + problems = append(problems, fmt.Sprintf( + "%s keeps a credential for %q under %q, which is not a usable name", + m.Module, to, f.Local)) + } + // A local name is what `${secret:}` says, so it may not be another requirement's + // name, another requirement's local name, or one of the module's own secrets — the + // file would hold the wrong credential while every check passed. + if f.Local != "" { + if other, taken := localOf[f.Local]; taken && other != to { + problems = append(problems, fmt.Sprintf( + "%s keeps credentials for %q and %q both under %q — a local name names one", + m.Module, other, to, f.Local)) + } + localOf[f.Local] = to + if _, own := m.OwnSecrets[f.Local]; own { + problems = append(problems, fmt.Sprintf( + "%s keeps a credential for %q under %q, which is also one of its own secrets", + m.Module, to, f.Local)) + } + for _, w := range m.Wants() { + if w == f.Local { + problems = append(problems, fmt.Sprintf( + "%s keeps a credential for %q under %q, which is also something it requires", + m.Module, to, f.Local)) + } + } + } + } + if len(m.SecretsMany[to]) == 0 && m.Secrets[to] == "" { + if _, many := m.SecretsMany[to]; many { + problems = append(problems, fmt.Sprintf( + "%s keeps the credential for %q as several files and names none", m.Module, to)) + } + } + var wanted bool + for _, w := range m.Wants() { + if w == to { + wanted = true + } + } + if !wanted { + problems = append(problems, fmt.Sprintf( + "%s wants the credential for %q and does not require it", m.Module, to)) + } + } + for to, where := range m.Grants { + if !placedOrAbsolute(where) { + problems = append(problems, fmt.Sprintf( + "%s grants %q into %q, which is neither an absolute path nor a placed one", m.Module, to, where)) + } + var offered bool + for _, o := range m.Offers() { + if o == to { + offered = true + } + } + if !offered { + problems = append(problems, fmt.Sprintf( + "%s grants %q to its consumers and does not provide it", m.Module, to)) + } + } + if m.Keeps != "" && !strings.HasPrefix(m.Keeps, "/") { + problems = append(problems, fmt.Sprintf( + "%s keeps the operator-sealed secrets at %q, which is not an absolute path", m.Module, m.Keeps)) + } + for to, where := range m.Receives { + if !name.MatchString(to) { + problems = append(problems, fmt.Sprintf("%q is not a usable name to receive", to)) + } + if !placedOrAbsolute(where) { + problems = append(problems, fmt.Sprintf( + "%s receives %q at %q, which is neither an absolute path nor a placed one", m.Module, to, where)) + } + var offered bool + for _, o := range m.Offers() { + if o == to { + offered = true + } + } + if !offered { + // Receiving contributions to something you do not provide would create a file nobody + // ever writes to, on a machine where nothing asked for it. + problems = append(problems, fmt.Sprintf( + "%s receives contributions to %q and does not provide it", m.Module, to)) + } + } + for _, a := range m.Accesses { + if a.ID == "" && a.Path == "" { + problems = append(problems, fmt.Sprintf( + "%s declares an access with neither an id nor a path — an id, which the assignment places", + m.Module)) + } + if a.ID != "" && !accessRef.MatchString("${access:"+a.ID+"}") { + problems = append(problems, fmt.Sprintf( + "%s accesses %q; an access id is lowercase letters, digits and dashes", m.Module, a.ID)) + } + if a.Path != "" && !strings.HasPrefix(a.Path, "/") { + problems = append(problems, fmt.Sprintf( + "%s accesses %q, which is not an absolute path", m.Module, a.Path)) + } + switch a.At() { + case catalogue.AccessRead, catalogue.AccessReadWrite: + default: + problems = append(problems, fmt.Sprintf( + "%s accesses %q at mode %q; an access is %q or %q", + m.Module, a.Path, a.Mode, catalogue.AccessRead, catalogue.AccessReadWrite)) + } + } + + // **A container may not mount a path the module never declared** (novox/hq 04-ISSUES/026, + // ADR 0091). A bind mount whose source does not exist is created by the container runtime, as + // root, with whatever mode it picks — so `owner` and `mode` never reach the directory holding + // the module's data, and the rule that keeps data when a module goes away (ADR 0030) does not + // cover it, because the mesh has never heard of it. + // + // Three things declare a path, and they are the three kinds of thing a path can be: + // - the module's own: a directory or file resource, or where a secret, a grant or a + // contribution lands — created and owned by the mesh for this module; + // - the operator's: an `accesses` entry (ADR 0051) — pre-existing, shared, granted for use; + // - the machine's: a facility a declared capability grants, such as the container runtime's + // socket. It exists, the machine owns it, and declaring it as the module's own directory + // would be a lie the host would act on — which is why the first version of this check was + // withdrawn: it refused the builder. + // + // Checked here rather than on the machine because the machine cannot tell the difference: by + // the time it sees the mount it is being asked to create the directory, which it can do. + problems = append(problems, manifestUndeclaredMounts(m)...) + problems = append(problems, manifestUnknownDirRefs(m)...) + problems = append(problems, manifestUnknownAccessRefs(m)...) + problems = append(problems, manifestResolvedPathProblems(m)...) + problems = append(problems, manifestJailProblems(m)...) + // What a module adds to the account's environment and to the login shell, and the holder's + // placeholders for them (novox/hq ADR 0203, ADR 0204) — here, so the catalogue check refuses + // them in the words registration does. + problems = append(problems, manifestEnvironmentProblems(m)...) + problems = append(problems, manifestShellProblems(m)...) + problems = append(problems, manifestContributionPlaceholderProblems(m)...) + problems = append(problems, manifestSeatContributionProblems(m)...) + problems = append(problems, manifestPlacementProblems(m)...) + problems = append(problems, manifestDataProblems(m)...) + problems = append(problems, manifestUpgradeProblems(m)...) + problems = append(problems, manifestUnhealthyProblems(m)...) + + for i, r := range m.Resources { + id, _ := r["id"].(string) + if id == "" { + problems = append(problems, fmt.Sprintf("resource %d has no id", i)) + } + if _, ok := r["type"].(string); !ok { + problems = append(problems, fmt.Sprintf("resource %q has no type", id)) + } + } + + problems = append(problems, zoneProblems(m)...) + // How each long-running resource is ready (novox/hq ADR 0240 rule 2): said near its author, in the + // words the node-engine would refuse it in far away. + problems = append(problems, healthProblems(m)...) + // And whether a container is busy (novox/hq ADR 0335), likewise. + problems = append(problems, busyProblems(m)...) + if m.DeclaresConfig() { + // Said in the words it was written in: the rules shared with the settings form say ${setting:} + // and takes-settings. + for i, p := range problems { + problems[i] = inConfigWords(p) + } + problems = append(problems, MixedFormProblems(m)...) + } + if len(problems) > 0 { + sort.Strings(problems) + return catalogue.Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s", + strings.Join(problems, "\n - ")) + } + return m, nil +} + +// replacesProblems judges what a module says its tools replace (novox/hq ADR 0245): each key a tool it +// declares, each entry a command line of one line, short, and said once. +func replacesProblems(m catalogue.Manifest) []string { + var problems []string + tools := map[string]bool{} + for _, t := range m.Tools { + tools[t] = true + } + keys := make([]string, 0, len(m.Replaces)) + for k := range m.Replaces { + keys = append(keys, k) + } + sort.Strings(keys) + for _, tool := range keys { + if !tools[tool] { + problems = append(problems, fmt.Sprintf("%s says what %q replaces, and declares no such tool: "+ + "a module says it for a tool in its `tools`; a seat's verb says it in the seat's definition "+ + "(novox/hq ADR 0245)", m.Module, tool)) + continue + } + if len(m.Replaces[tool]) == 0 { + problems = append(problems, fmt.Sprintf("%s says %s replaces nothing: leave it out", m.Module, tool)) + } + seen := map[string]bool{} + for _, c := range m.Replaces[tool] { + c = strings.TrimSpace(c) + switch { + case c == "": + problems = append(problems, fmt.Sprintf("%s: %s replaces an empty command", m.Module, tool)) + case strings.ContainsAny(c, "\n\r"): + problems = append(problems, fmt.Sprintf("%s: %s replaces %q, which is more than one line", m.Module, tool, c)) + case len(c) > catalogue.MaxReplaced: + problems = append(problems, fmt.Sprintf("%s: %s replaces %q, longer than %d characters: "+ + "the command's name and the words that make it this command", m.Module, tool, c, catalogue.MaxReplaced)) + case seen[c]: + problems = append(problems, fmt.Sprintf("%s: %s replaces %q twice", m.Module, tool, c)) + } + seen[c] = true + } + } + return problems +} + +// waitProblems judges the event families a module says a session may wait on (novox/hq ADR 0367 §2, §6): +// the tool runner's alone, each named whole — emitter and event, with no wildcard — and each once. Refused +// here, in the manifest's words, rather than at the composition of the bus's users. +func waitProblems(m catalogue.Manifest) []string { + if len(m.Waits) == 0 { + return nil + } + if m.Module != catalogue.RuntimeModule { + return []string{fmt.Sprintf("%s says what a session waits on; only %s, the tool runner's module, "+ + "declares waits (novox/hq ADR 0367 §2)", m.Module, catalogue.RuntimeModule)} + } + var problems []string + seen := map[string]bool{} + for _, w := range m.Waits { + if strings.ContainsAny(w, "*>") { + problems = append(problems, fmt.Sprintf("%s waits on %q: a family is named one by one, never a "+ + "wildcard (novox/hq ADR 0367 §2)", m.Module, w)) + continue + } + tokens := strings.Split(w, ".") + whole := len(tokens) >= 2 && name.MatchString(tokens[0]) + for _, t := range tokens[1:] { + whole = whole && waitToken.MatchString(t) + } + if !whole { + problems = append(problems, fmt.Sprintf("%s waits on %q, which does not name an emitter and an "+ + "event: a family is ., as consumes names one", m.Module, w)) + continue + } + if seen[w] { + problems = append(problems, fmt.Sprintf("%s waits on %q twice", m.Module, w)) + } + seen[w] = true + } + return problems +} + +// whileStoppedProblems judges one container's maintenance window against its own definition +// (novox/hq ADR 0189). +// +// Three things the manifest is the only place to see: that the step is scheduled (a one-time +// offline job says *before* rather than *instead of* — at apply the host already has a window, +// because the declaration is applied in order and a run-once step gates what follows); that every +// id it names is **this module's own** container; and that it does not name itself. +// +// The host checks the fourth — that the container is actually placed on that machine — because +// that is a fact about the declaration and not about the definition. +func whileStoppedProblems(m catalogue.Manifest, r map[string]any, scheduled bool) []string { + raw, present := r[catalogue.WhileStopped] + if !present { + return nil + } + ids, ok := raw.([]any) + if !ok { + return []string{fmt.Sprintf( + "%s declares %s on %v as a %T; it is a list of this module's container ids", + m.Module, catalogue.WhileStopped, r["id"], raw)} + } + var problems []string + if len(ids) > 0 && !scheduled { + problems = append(problems, fmt.Sprintf( + "%s declares %s on %v, which has no schedule. A maintenance window is for a recurring "+ + "step: at apply the mesh already has one, because a run-once step gates what is "+ + "declared after it (novox/hq ADR 0189)", m.Module, catalogue.WhileStopped, r["id"])) + } + containers := map[string]bool{} + for _, own := range m.Resources { + if fmt.Sprint(own["type"]) == "container" { + containers[fmt.Sprint(own["id"])] = true + } + } + for _, each := range ids { + id, ok := each.(string) + if !ok { + problems = append(problems, fmt.Sprintf( + "%s declares %s on %v naming a %T; each entry is a container's id", + m.Module, catalogue.WhileStopped, r["id"], each)) + continue + } + if id == fmt.Sprint(r["id"]) { + problems = append(problems, fmt.Sprintf( + "%s declares %s on %v naming itself", m.Module, catalogue.WhileStopped, r["id"])) + continue + } + if !containers[id] { + problems = append(problems, fmt.Sprintf( + "%s declares %s on %v naming %q, which is not a container this module declares. "+ + "A step may hold still its own module's containers and nobody else's — one "+ + "that could quiesce a neighbour could stop the mesh", + m.Module, catalogue.WhileStopped, r["id"], id)) + } + } + return problems +} + +// jailProblems is every jail this module declares that the machine's intrusion prevention would +// refuse (novox/hq ADR 0179). +// +// **Because one bad pattern stops every jail, not its own.** fail2ban expands `` into a named +// capture group, so a pattern naming it twice is a duplicate group name, and the daemon refuses the +// whole configuration and exits — the machine keeps no bans at all, for any jail, including the one +// watching its ssh. Caught live on the control node the day this was built, where a proxy's pattern +// matched two shapes of refusal in one line. A pattern matches one shape; several shapes are several +// patterns, one per line, as fail2ban's own filters are written. +func manifestJailProblems(m catalogue.Manifest) []string { + var problems []string + seen := map[string]bool{} + for _, j := range m.Jails { + switch { + case strings.TrimSpace(j.Name) == "": + problems = append(problems, m.Module+" declares a jail with no name") + case seen[j.Name]: + problems = append(problems, m.Module+" declares two jails called "+strconv.Quote(j.Name)) + } + seen[j.Name] = true + if strings.TrimSpace(j.Failregex) == "" { + problems = append(problems, m.Module+"'s jail "+strconv.Quote(j.Name)+" says nothing a failed attempt looks like") + } + for _, line := range strings.Split(j.Failregex, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + if n := strings.Count(line, ""); n > 1 { + problems = append(problems, fmt.Sprintf("%s's jail %s names %d times in one pattern; "+ + "fail2ban reads it as one capture group and refuses the whole configuration, so the machine "+ + "keeps no bans at all — write one pattern per shape, each naming once", + m.Module, strconv.Quote(j.Name), n)) + } + } + } + return problems +} + +// shipsABundle says the module builds a tools bundle, which the node's runtime carries and speaks for. +func manifestShipsABundle(m catalogue.Manifest) bool { + // A built manifest says its bundles in Bundles, its build read away (Resolve; issue 774: nats's built manifest was + // refused for the emits its tools bundle says). + if len(m.Bundles) > 0 { + return true + } + if m.Build == nil { + return false + } + for _, a := range m.Build.Artifacts { + if a.Kind == catalogue.ArtifactBundle { + return true + } + } + return false +} + +// undeclaredMounts is every bind-mount source no declaration covers — see the check above. +func manifestUndeclaredMounts(m catalogue.Manifest) []string { + declared := map[string]bool{} + claim := func(p string) { + if strings.HasPrefix(p, "/") { + declared[strings.TrimRight(p, "/")] = true + } + } + for _, r := range m.Resources { + switch fmt.Sprint(r["type"]) { + case "directory", "file": + claim(fmt.Sprint(r["path"])) + } + } + for _, own := range m.OwnSecrets { + claim(own.Path) + } + for _, to := range m.SecretRequirements() { + for _, f := range m.SecretFiles(to) { + claim(f.Path) + } + } + for _, where := range m.Receives { + claim(where) + } + for _, where := range m.Grants { + claim(where) + } + for _, where := range m.Binds { + claim(where) + } + for _, a := range m.Accesses { + claim(a.Path) + } + for _, c := range m.Capabilities { + for _, p := range facilitiesOf[c] { + claim(p) + } + } + // The file a filter module's rule set is written to is declared by `filtering.into`: the mesh + // writes it, the module loads it, and the module's runtime may read it back to reload the + // mesh's own table (novox/hq ADR 0170). + if m.Filtering != nil { + claim(m.Filtering.Into) + } + // Under a declared directory is declared: a module that says where its data lives has said so + // for what it puts inside. + covers := func(path string) bool { + for at := path; strings.HasPrefix(at, "/"); { + if declared[at] { + return true + } + cut := strings.LastIndex(at, "/") + if cut <= 0 { + return false + } + at = at[:cut] + } + return false + } + var problems []string + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "container" { + continue + } + mounts, _ := r["volumes"].([]any) + for _, v := range mounts { + from, _, _ := strings.Cut(fmt.Sprint(v), ":") + if !strings.HasPrefix(from, "/") || covers(strings.TrimRight(from, "/")) { + continue + } + problems = append(problems, fmt.Sprintf( + "%s mounts %q into %v, and nothing in %s declares it. A bind mount the module did "+ + "not declare is created by the container runtime as root, so the module's own "+ + "owner and mode do not reach the directory that holds its data, and the rule "+ + "that keeps data when a module goes away (novox/hq ADR 0030) does not cover it. "+ + "Declare it: a directory resource if it is the module's, an `accesses` entry if "+ + "it is the operator's, or the capability that grants it if it is the machine's", + m.Module, from, r["id"], m.Module)) + } + } + return problems +} + +var endpointName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + +var facilitiesOf = map[string][]string{ + // Both spellings: /var/run is a link to /run on every machine the mesh runs on. + "container-runtime": {"/var/run/docker.sock", "/run/docker.sock"}, + // The virtualisation daemon's socket, for the lab (novox/hq ADR 0172): it raises machines there. + "virtualisation": {"/var/lib/incus/unix.socket"}, +} + +var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$`) + +var promptPlaceholder = regexp.MustCompile(`\{[^{}]*\}`) + +var renamed = map[string]string{ + // `needs` and `secrets` were both name-to-path and differed only in whose secret it was, so + // reaching for the wrong one parsed cleanly and failed somewhere else entirely. + "needs": "own-secrets", +} + +var toolName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + +var waitToken = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + +var engineGeneric = map[string]string{ + "database": "postgres-database, mssql-database, mariadb-database", + "db": "postgres-database, mssql-database, mariadb-database", + "sql": "postgres-database, mssql-database, mariadb-database", + "sql-database": "postgres-database, mssql-database, mariadb-database", +} + +// OptionalProblems is what the module check refuses of a manifest's optional requirements and its +// only-when-bound resources (ADR 0330 §4–§6). +func OptionalProblems(m catalogue.Manifest) []string { + var problems []string + required := map[string]bool{} + for _, r := range m.Requires { + if required[r] && m.IsOptional(r) { + problems = append(problems, fmt.Sprintf("%s requires %q twice; say it once, optional or not", m.Module, r)) + } + required[r] = true + } + for _, r := range optionalRequiresList(m) { + if !required[r] { + problems = append(problems, fmt.Sprintf( + "%s says %q is optional in %s and does not require it", m.Module, r, catalogue.OptionalRequiresKey)) + } + } + // A contribution is to whoever answers the requirement, so it goes with it: contributing to one + // that is optional is allowed, and composed only while it is bound. + for _, r := range m.Resources { + id := fmt.Sprint(r["id"]) + when, marked := r[catalogue.OnlyWhenBound] + on := "" + if marked { + name, ok := when.(string) + switch { + case !ok || name == "": + problems = append(problems, fmt.Sprintf( + "%s's resource %s says %s %v; it names one requirement the module requires optionally", + m.Module, id, catalogue.OnlyWhenBound, when)) + continue + case fmt.Sprint(r["type"]) != "file": + problems = append(problems, fmt.Sprintf( + "%s's resource %s is a %v and says %s: only a file is left out while a requirement is "+ + "absent (novox/hq ADR 0330 §5)", m.Module, id, r["type"], catalogue.OnlyWhenBound)) + continue + case !required[name]: + problems = append(problems, fmt.Sprintf( + "%s's resource %s is delivered only when %q is bound, and %s does not require it", + m.Module, id, name, m.Module)) + continue + case !m.IsOptional(name): + problems = append(problems, fmt.Sprintf( + "%s's resource %s is delivered only when %q is bound, which is a hard requirement and "+ + "always bound: mark the requirement optional, or drop %s", m.Module, id, name, catalogue.OnlyWhenBound)) + continue + } + on = name + } + if fmt.Sprint(r["type"]) != "file" { + continue + } + content, _ := r["content"].(string) + for _, use := range bound.FindAllStringSubmatch(content, -1) { + provision, key, hasDefault := use[1], use[2], use[3] != "" + if !m.IsOptional(provision) { + if hasDefault { + problems = append(problems, fmt.Sprintf( + "%s's resource %s gives ${bound:%s:%s} a default, and %q is a hard requirement, always "+ + "bound: a default it never renders only misleads a reader", m.Module, id, provision, key, provision)) + } + continue + } + if !hasDefault && on != provision { + problems = append(problems, fmt.Sprintf( + "%s's resource %s says ${bound:%s:%s}, and %q is optional: while it is absent this would "+ + "be empty. Name its value then — ${bound:%s:%s|default:} — or deliver the file "+ + "only while it is bound, with \"%s\": %q (novox/hq ADR 0330 §4)", + m.Module, id, provision, key, provision, provision, key, catalogue.OnlyWhenBound, provision)) + } + } + for _, provision := range m.Requires { + if !m.IsOptional(provision) || on == provision { + continue + } + for _, name := range secretNamesOf(m, provision) { + if strings.Contains(content, "${secret:"+name+"}") { + problems = append(problems, fmt.Sprintf( + "%s's resource %s says ${secret:%s}, the credential of %q, which is optional and not "+ + "delivered while absent: deliver the file only while it is bound, with \"%s\": %q "+ + "(novox/hq ADR 0330 §6)", m.Module, id, name, provision, catalogue.OnlyWhenBound, provision)) + } + } + } + } + return problems +} + +// secretNamesOf is every `${secret:…}` name that carries a credential of this requirement: its own +// name, and each local name it keeps several under (ADR 0094). +func secretNamesOf(m catalogue.Manifest, requirement string) []string { + names := []string{requirement} + for local := range m.SecretsMany[requirement] { + names = append(names, local) + } + sort.Strings(names) + return names +} + +// enginePath says why the node-engine refuses a directory or file at path for module, or "". +func enginePath(path, module string) string { + path = filepath.Clean(path) + if !filepath.IsAbs(path) { + return "" + } + atOrBelow := func(tree string) bool { return path == tree || strings.HasPrefix(path, tree+"/") } + for _, root := range protectedRoots { + if path == root || path == "/" || strings.HasPrefix(root, path+"/") { + return root + " is one of the machine's own directories, and owning it is owning everything in it" + } + } + for _, tree := range forbiddenBelow { + if atOrBelow(tree) { + return "nothing is placed in " + tree + } + } + if module != engineModule { + for _, tree := range engineTrees { + if atOrBelow(tree) { + return tree + " is the node-engine's own, placed in by its own module alone" + } + } + } + return "" +} + +// resolvedPathProblems is every directory and file of the definition whose path, resolved as a node with the +// default root resolves it, the node-engine would refuse (novox/hq issue 496). A path still holding a placeholder +// only a machine fills (a setting, an access the definition gives no default) is the engine's to judge. +func manifestResolvedPathProblems(m catalogue.Manifest) []string { + dirs := dirsFor(m, catalogue.Rendering{}) + accesses := map[string]string{} + for _, a := range m.Accesses { + if a.ID != "" && a.Path != "" { + accesses[a.ID] = a.Path + } + } + var problems []string + for _, r := range m.Resources { + kind := fmt.Sprint(r["type"]) + if kind != "directory" && kind != "file" { + continue + } + id := fmt.Sprint(r["id"]) + path, _ := r["path"].(string) + if kind == "directory" && path == "" { + path = dirs[id] + } + path, _ = dirFill(path, dirs, m.Module) + path, _ = accessFill(path, accesses, m.Module) + if path == "" || strings.Contains(path, "${") { + continue + } + if why := enginePath(path, m.Module); why != "" { + problems = append(problems, fmt.Sprintf("%s's %s %q resolves to %s, which the node-engine refuses: %s. "+ + "A module's directories and files are judged when its definition is, so the merge gate refuses it "+ + "before any machine does (novox/hq issue 496)", m.Module, kind, id, filepath.Clean(path), why)) + } + } + return problems +} + +// unknownAccessRefs is every ${access:…} in the definition that names no access the definition +// declares by id — refused where the author is, as unknownDirRefs does for directories. +func manifestUnknownAccessRefs(m catalogue.Manifest) []string { + declared := map[string]bool{} + for _, a := range m.Accesses { + if a.ID != "" { + declared[a.ID] = true + } + } + seen := map[string]bool{} + var problems []string + refuse := func(s string, where any) { + for _, match := range accessRef.FindAllStringSubmatch(s, -1) { + id := match[1] + if declared[id] || seen[id] { + continue + } + seen[id] = true + problems = append(problems, fmt.Sprintf( + "%s says ${access:%s} in %v, and declares no access %q — a reference the mesh "+ + "cannot place would reach the machine as a literal path", + m.Module, id, where, id)) + } + } + for _, r := range m.Resources { + for _, field := range []string{"path", "content"} { + if s, ok := r[field].(string); ok { + refuse(s, r["id"]) + } + } + for _, field := range []string{"volumes", "env-file"} { + if list, ok := r[field].([]any); ok { + for _, v := range list { + if s, ok := v.(string); ok { + refuse(s, r["id"]) + } + } + } + } + if env, ok := r["env"].(map[string]any); ok { + for _, v := range env { + if s, ok := v.(string); ok { + refuse(s, r["id"]) + } + } + } + } + sort.Strings(problems) + return problems +} + +var engineTrees = []string{"/var/lib/mesh-host", "/usr/lib/nox-mesh-host"} + +var forbiddenBelow = []string{"/proc", "/sys", "/dev", "/boot", "/root", "/var/spool", "/opt", "/var/lib/docker", + "/var/lib/containers", "/var/lib/containerd"} + +var protectedRoots = []string{"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64", + "/media", "/mnt", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp", "/usr", "/usr/bin", + "/usr/lib", "/usr/lib64", "/usr/local", "/usr/local/bin", "/usr/local/lib", "/usr/local/sbin", "/usr/sbin", + "/usr/share", "/var", "/var/cache", "/var/lib", "/var/lib/mesh", "/var/log", "/var/run", "/var/tmp", + "/var/spool"} + +// ParseManifestForInputs reads a manifest for the inputs of its build and its fingerprint: the indexer's and the +// builder's read (novox/hq issue 1751). It is never a judge of config values. A value falling back to a predefined key +// (ADR 0348 §7) is judged against the list the mesh published when this process holds one (PublishedPredefined); +// where it holds none, as in the indexer and the builder, each key a value names is taken as the value declares it (its +// type, trusted when it is), since the list is read where a manifest is judged: the merge gate from the tree's root, +// the controller from what it published. No input of a fingerprint or a build comes from the list. Everything else +// ParseManifest refuses is refused here too. +func ParseManifestForInputs(raw []byte) (catalogue.Manifest, error) { + list := catalogue.PublishedPredefined() + if list == nil { + list = fallbacksOf(raw) + } + return parseManifest(raw, list) +} + +// fallbacksOf is the list of the predefined keys a manifest's config values fall back to, each as the value declares +// it. A manifest that is not JSON gives an empty list, and the parser says what is wrong with it. +func fallbacksOf(raw []byte) catalogue.PredefinedList { + var m struct { + Config map[string]struct { + Type string `json:"type"` + Description string `json:"description"` + Predefined string `json:"predefined"` + Trusted bool `json:"trusted"` + } `json:"config"` + } + list := catalogue.PredefinedList{} + if json.Unmarshal(raw, &m) != nil { + return list + } + for _, d := range m.Config { + if d.Predefined != "" { + list[d.Predefined] = catalogue.PredefinedKey{Type: d.Type, Description: d.Description, Trusted: d.Trusted} + } + } + return list +} + +const meshStateRoot = defaultDataRoot + "/" + placeMesh + +// ProcessBusProblems is what is wrong with a manifest's processes' bus declarations (novox/hq ADR 0436 §1, +// §3): the account rule is modelled on RunsAsProblems (seats_declared.go:522-550), and an invoked tool or +// seat verb's shape on invokeProblems. +func ProcessBusProblems(m catalogue.Manifest) []string { + problems := append(toolsBusProblems(m), toolsSecretProblems(m)...) + streams := map[string]bool{} + for _, s := range m.Streams { + streams[s.Name] = true + } + state := map[string]bool{} + for _, s := range m.State { + state[s.Name] = true + } + emits := map[string]bool{} + for _, e := range m.EmitsAll() { + emits[e] = true + } + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "process" { + continue + } + id := fmt.Sprint(r["id"]) + if _, present := r["bus"]; !present { + continue + } + b, _, err := processBusOf(r) + if err != nil { + problems = append(problems, fmt.Sprintf("%s's process %s declares bus: %v", m.Module, id, err)) + continue + } + user, _ := r["user"].(string) + problems = append(problems, processAccountProblems(m, id, user)...) + for _, e := range b.Emits { + if !emits[e] { + problems = append(problems, fmt.Sprintf( + "%s's process %s's bus emits %q, which %s does not declare as its own (novox/hq ADR 0436 §1)", + m.Module, id, e, m.Module)) + } + } + for _, q := range b.Queues { + if !streams[q] { + problems = append(problems, fmt.Sprintf( + "%s's process %s's bus queues %q, which %s does not declare as its own stream (novox/hq "+ + "ADR 0436 §1)", m.Module, id, q, m.Module)) + } + } + for _, s := range b.State { + if !state[s] { + problems = append(problems, fmt.Sprintf( + "%s's process %s's bus reads and writes state %q, which %s does not declare as its own "+ + "(novox/hq ADR 0436 §1)", m.Module, id, s, m.Module)) + } + } + for _, t := range b.Invokes { + if !invokeNameValid(t) { + problems = append(problems, fmt.Sprintf( + "%s's process %s's bus invokes %q, which does not name a tool: a process invokes "+ + "., or a role's seat:. (novox/hq ADR 0436 §1)", m.Module, id, t)) + } + } + } + return problems +} + +func filepathDir(p string) string { + if i := strings.LastIndex(p, "/"); i > 0 { + return p[:i] + } + return "/" +} + +// invokeNameValid says a module or process's invoked entry names a tool: ., a role's +// seat:., or * for every tool on the mesh (novox/hq ADR 0152, ADR 0436 §1). +func invokeNameValid(t string) bool { + if t == "*" { + return true + } + t = strings.TrimPrefix(t, "seat:") + first, second, named := strings.Cut(t, ".") + return named && name.MatchString(first) && toolName.MatchString(second) +} + +// passableDir says the runtime's account may pass the directory resource id and every parent the mesh makes for it: its +// own mode lets others pass (the last digit odd), and it is either stated directly under meshStateRoot or beneath a +// placed directory that is passable in turn. A pathless directory resolves under the module's own root or its mesh +// place, which are the module's own, so it never is. +func passableDir(m catalogue.Manifest, id string, depth int) bool { + if depth > 4 { + return false + } + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "directory" || fmt.Sprint(r["id"]) != id { + continue + } + mode, _ := r["mode"].(string) + if mode == "" || !strings.ContainsAny(mode[len(mode)-1:], "1357") { + return false + } + path, _ := r["path"].(string) + switch { + case strings.HasPrefix(path, "${dir:"): + parent, _, _ := strings.Cut(strings.TrimPrefix(path, "${dir:"), "}") + return passableDir(m, parent, depth+1) + case strings.HasPrefix(path, "/"): + return filepathDir(strings.TrimRight(path, "/")) == meshStateRoot + } + return false + } + return false +} + +// processAccountProblems is what is wrong with one process's bus account (novox/hq ADR 0436 §1, §2): an +// account the module makes — a `user` resource of that name — never root and never the operator account, +// which a manifest cannot say safely (a node with no account has nothing to resolve it to, novox/hq ADR +// 0436, settled by the lead). +func processAccountProblems(m catalogue.Manifest, processID, user string) []string { + switch { + case user == "": + return []string{fmt.Sprintf( + "%s's process %s declares bus and names no account; a process account is a user resource the "+ + "module makes, never root and never the operator account (novox/hq ADR 0436 §1, §2)", + m.Module, processID)} + case user == "root": + return []string{fmt.Sprintf( + "%s's process %s runs as root; a process account is the module's own, never root (novox/hq ADR "+ + "0436 §1, §2)", m.Module, processID)} + case user == "${machine:account}": + return []string{fmt.Sprintf( + "%s's process %s runs as the operator account; a process account is the module's own, never the "+ + "operator's (novox/hq ADR 0436 §1, §2)", m.Module, processID)} + } + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == user { + return nil + } + } + return []string{fmt.Sprintf( + "%s's process %s runs as %s, which it does not make: a user resource named %s (novox/hq ADR 0436 §1, "+ + "§2)", m.Module, processID, user, user)} +} + +// toolsBusProblems is what is wrong with a tools bundle's bus (ADR 0564 Limits 4): a bus on anything but a bundle +// compiled to a binary the runtime loads, a removal outside the closed set, or a removal beside any other grant or by a +// module that does not hold mesh-broker. Every other field is judged as a process's. +func toolsBusProblems(m catalogue.Manifest) []string { + var problems []string + var artifacts []catalogue.Artifact + if m.Build != nil { + artifacts = m.Build.Artifacts + } + claimsBroker := false + for _, c := range m.Claims { + claimsBroker = claimsBroker || c.Name == "mesh-broker" + } + for _, a := range artifacts { + if a.Bus == nil { + continue + } + if a.Kind != catalogue.ArtifactBundle || catalogue.BinaryOf(a) == "" { + problems = append(problems, fmt.Sprintf("%s's artifact %s declares bus, and only a tools bundle compiled to a "+ + "binary has an account of its own (novox/hq ADR 0564 Limits 4)", m.Module, a.Name)) + continue + } + b := *a.Bus + if len(b.Queues) > 0 || b.Asks { + problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus consumes a queue or reads asks; a tools "+ + "bundle runs per call and holds no consumer (novox/hq ADR 0564 Limits 4)", m.Module, a.Name)) + } + for _, r := range b.Removes { + if !slices.Contains(BusRemovals, r) { + problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus removes %q, which is not one of %s "+ + "(novox/hq ADR 0564 Limits 4)", m.Module, a.Name, r, strings.Join(BusRemovals, ", "))) + } + } + if len(b.Removes) > 0 && (!claimsBroker || len(b.Emits)+len(b.State)+len(b.Invokes) > 0) { + problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus removes from the bus; only the bus "+ + "module's (the holder of mesh-broker) may, and with nothing else (novox/hq ADR 0564 Limits 4)", m.Module, a.Name)) + } + } + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "process" { + continue + } + if b, present, err := processBusOf(r); present && err == nil && len(b.Removes) > 0 { + problems = append(problems, fmt.Sprintf("%s's process %v's bus removes from the bus; only a tools bundle's "+ + "may (novox/hq ADR 0564 Limits 4)", m.Module, r["id"])) + } + } + return problems +} + +// toolsSecretProblems refuses a tools bundle's bus account secret the runtime's account could not read: it is declared +// as an own secret `broker-`, at `${dir:}/broker-` for a directory resource every parent of which the +// runtime's account may pass (passableDir); the file itself is 0600 and the runtime account's. +func toolsSecretProblems(m catalogue.Manifest) []string { + var problems []string + for _, a := range m.BusAccounts() { + if !a.Tools { + continue + } + name := "broker-" + a.ID + own, declared := m.OwnSecrets.Plain()[name] + if !declared { + problems = append(problems, fmt.Sprintf("%s's tools bundle %s declares bus and no own secret %s to hold its "+ + "account in (novox/hq ADR 0564 Limits 4)", m.Module, a.ID, name)) + continue + } + dir, _, _ := strings.Cut(strings.TrimPrefix(own.Path, "${dir:"), "}") + if !strings.HasPrefix(own.Path, "${dir:"+dir+"}/") || !passableDir(m, dir, 0) { + problems = append(problems, fmt.Sprintf("%s's own secret %s is at %s, which the node's tool runtime cannot "+ + "reach: a tools bundle's account lies in a directory resource the runtime's account may pass, every parent "+ + "included, such as {\"id\":\"tools-secret\",\"type\":\"directory\",\"path\":\"%s/%s-tools\",\"mode\":\"0711\"} "+ + "with the secret at ${dir:tools-secret}/%s; never under the module's own or mesh place, which are its "+ + "own (novox/hq ADR 0564 Limits 4)", m.Module, name, own.Path, meshStateRoot, m.Module, name)) + } + } + return problems +} + +var BusRemovals = []string{catalogue.RemovesControllerDeadLetterConsumer, catalogue.RemovesEngineActs, catalogue.RemovesAssignmentsStream} + +// ReadsHolderProblems is what a holder of node-reads is held to (novox/hq ADR 0310 §3): it reads as an +// account of its own, so the node's file modes bound what it sees. Carried by the machine's runtime it would +// read as the operator's account, which sees the operator's keys and the agent's credentials. +func ReadsHolderProblems(m catalogue.Manifest) []string { + if !m.ClaimsSeat(catalogue.ReadsSeat) { + return nil + } + if m.RunsAs == "" { + return []string{fmt.Sprintf("%s claims %s and must run as an account of its own (runs-as): its reads are "+ + "bounded by that account's file modes, and the machine's runtime runs as the operator's account "+ + "(novox/hq ADR 0310 §3)", m.Module, catalogue.ReadsSeat)} + } + // In no group beside its own: a group is a way to a secret (docker's is root's), and the account's file modes + // are what bound its reads. + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "user" || fmt.Sprint(r["name"]) != m.RunsAs { + continue + } + if groups, said := r["groups"]; said { + if list, ok := groups.([]any); !ok || len(list) > 0 { + return []string{fmt.Sprintf("%s claims %s, and its account %s is put in groups %v: the account that "+ + "reads is in no group beside its own (novox/hq ADR 0310 §3)", m.Module, catalogue.ReadsSeat, m.RunsAs, groups)} + } + } + } + return nil +} + +// seatContributionProblems is what is wrong with this module's contributions, from the manifest +// alone (novox/hq ADR 0212 §2). +func manifestSeatContributionProblems(m catalogue.Manifest) []string { + var problems []string + for i, c := range m.Contributions { + s, r, ok := receivable(c.Seat, c.Kind) + // A directory a contribution names must be one of this module's own, here rather than on the + // machine — where a `${dir:x}` nobody declared would reach the holder as the literal text. + if ok && r.Dirs { + declared := map[string]string{} + for _, res := range m.Resources { + if fmt.Sprint(res["type"]) == "directory" { + declared[fmt.Sprint(res["id"])] = "" + } + } + if _, err := dirFill(c.Content, declared, m.Module); err != nil { + problems = append(problems, fmt.Sprintf("%s's contribution %d: %v", m.Module, i+1, err)) + } + } + switch { + case s.Name == "": + problems = append(problems, fmt.Sprintf( + "%s's contribution %d is to the seat %q, which the mesh does not define", m.Module, i+1, c.Seat)) + case !ok: + problems = append(problems, fmt.Sprintf( + "%s's contribution %d to %s is of the kind %q; %s (novox/hq ADR 0212)", + m.Module, i+1, s.Name, c.Kind, kindsOf(s))) + } + // Only on a kind the seat offers (novox/hq ADR 0255): a piece shown where the hardware is. On a + // kind a holder depends on — what a backup keeps, a key's trigger — a machine missing the + // capability would lose the piece without a word. + if c.IfCapability != "" { + switch { + case !capabilityName.MatchString(c.IfCapability): + problems = append(problems, fmt.Sprintf("%s's contribution %d is if-capability %q, which is not a "+ + "capability's name", m.Module, i+1, c.IfCapability)) + case !oneOf(catalogue.KnownCapabilities, c.IfCapability): + problems = append(problems, fmt.Sprintf("%s's contribution %d is if-capability %q, which no machine "+ + "reports; the node-engine detects %s", m.Module, i+1, c.IfCapability, strings.Join(catalogue.KnownCapabilities, ", "))) + case ok && !r.Offered: + problems = append(problems, fmt.Sprintf("%s's contribution %d to %s (%s) is if-capability %s; only a "+ + "kind the seat offers may be left out where a machine lacks something (novox/hq ADR 0255)", + m.Module, i+1, s.Name, c.Kind, c.IfCapability)) + } + } + switch { + case ok && r.Shape != nil: + problems = append(problems, shapedProblems(m.Module, i, r, c)...) + problems = append(problems, ownStateProblems(m, i, r, c)...) + case len(c.Data) > 0: + problems = append(problems, fmt.Sprintf("%s's contribution %d has data; %s receives %s as text, "+ + "given as `content`", m.Module, i+1, s.Name, c.Kind)) + case strings.TrimSpace(c.Content) == "": + problems = append(problems, fmt.Sprintf("%s's contribution %d has no content", m.Module, i+1)) + } + } + return problems +} + +// combinations is every assignment of a value to each placing field. +func combinations(places []catalogue.PlaceField) []map[string]string { + out := []map[string]string{{}} + for _, p := range places { + var next []map[string]string + for _, partial := range out { + for _, v := range p.Values { + c := map[string]string{p.Field: v} + for k, w := range partial { + c[k] = w + } + next = append(next, c) + } + } + out = next + } + return out +} + +func comboKey(combo map[string]string) string { + var parts []string + for _, k := range sortedKeys(combo) { + parts = append(parts, k+"="+combo[k]) + } + return strings.Join(parts, ",") +} + +func covers(where, combo map[string]string) bool { + for field, value := range where { + if combo[field] != value { + return false + } + } + return true +} + +// ownStateProblems is a data contribution showing state its module does not keep (novox/hq ADR 0255): +// a module offers the holder its own state and nobody else's, so what it publishes stays its own. +func ownStateProblems(m catalogue.Manifest, i int, r catalogue.Receivable, c catalogue.SeatContribution) []string { + if r.Shape == nil || r.Shape.Reads == nil { + return nil + } + keeps := map[string]bool{} + for _, s := range m.State { + keeps[s.Name] = true + } + var problems []string + for _, sr := range r.Shape.Reads(c.Data) { + module, local, err := catalogue.ReadState(sr.Read) + switch { + case err != nil: + problems = append(problems, fmt.Sprintf("%s's contribution %d shows state %v", m.Module, i+1, err)) + case module != m.Module || !keeps[local]: + problems = append(problems, fmt.Sprintf("%s's contribution %d shows the state %s, which %s does not keep; a "+ + "module offers its own state only (novox/hq ADR 0260)", m.Module, i+1, sr.Read, m.Module)) + } + if sr.Key != "" && !stateKeyName.MatchString(sr.Key) { + problems = append(problems, fmt.Sprintf("%s's contribution %d shows the key %q; a key is lower-case letters, "+ + "digits and hyphens", m.Module, i+1, sr.Key)) + } + } + return problems +} + +// placesKind is whether any of a holder's files names the kind's placeholder. +func placesKind(m catalogue.Manifest, s catalogue.Seat, kind string) bool { + for _, res := range m.Resources { + content, _ := res["content"].(string) + for _, f := range ofContribution.FindAllStringSubmatch(content, -1) { + seat, rest, _ := strings.Cut(f[1], ":") + k, _, _ := strings.Cut(rest, ":") + if cs, ok := catalogue.SeatNamed(seat); ok && cs.Name == s.Name && k == kind { + return true + } + } + } + return false +} + +// shapedProblems is what is wrong with one contribution of a kind received as data. +func shapedProblems(module string, i int, r catalogue.Receivable, c catalogue.SeatContribution) []string { + var problems []string + at := fmt.Sprintf("%s's contribution %d to %s (%s)", module, i+1, c.Seat, c.Kind) + if strings.TrimSpace(c.Content) != "" { + problems = append(problems, at+" has content; this kind is data, given as `data` (novox/hq ADR 0255)") + } + if len(c.Data) == 0 { + return append(problems, at+" has no data") + } + for _, p := range r.Shape.Places { + v, _ := c.Data[p.Field].(string) + if !oneOf(p.Values, v) { + problems = append(problems, fmt.Sprintf("%s says %s %q; it is one of %s", + at, p.Field, fmt.Sprint(c.Data[p.Field]), strings.Join(p.Values, ", "))) + } + } + if r.Shape.Order != "" { + if v, has := c.Data[r.Shape.Order]; has { + if _, ok := wholeIn(v, 0, 99); !ok { + problems = append(problems, fmt.Sprintf("%s says %s %v; it is a whole number from 0 to 99", + at, r.Shape.Order, v)) + } + } + } + if r.Shape.Check != nil { + for _, p := range r.Shape.Check(c.Data) { + problems = append(problems, at+": "+p) + } + } + return problems +} + +// placementProblems is what is wrong with how a holder places the kinds its seats receive as data: +// once it places one at all, it needs a template that renders every example of the shape, and every combination of the placing +// fields placed exactly once across its files — or something contributed would be dropped, or written +// twice (novox/hq ADR 0255). +func manifestPlacementProblems(m catalogue.Manifest) []string { + var problems []string + for _, c := range m.Claims { + s, known := catalogue.SeatNamed(c.Name) + if !known { + continue + } + for _, r := range s.Receives { + // A holder that places none of the kind is older than it (novox/hq ADR 0255): refusing it + // would refuse every bar registered before the kind existed, and the controller that + // defines the kind ships first. Its contributions wait, unplaced, until it places them. + if r.Shape == nil || !placesKind(m, s, r.Kind) { + continue + } + t, err := holderTemplate(m, s, r.Kind) + if err != nil { + problems = append(problems, err.Error()) + continue + } + for _, ex := range r.Shape.Examples { + if out, err := renderPiece(t, "example", "machine", ex); err != nil || strings.TrimSpace(out) == "" { + problems = append(problems, fmt.Sprintf("%s's template for %s:%s renders nothing for %v: %v", + m.Module, s.Name, r.Kind, ex, err)) + } + } + placed := map[string]int{} + for _, res := range m.Resources { + content, _ := res["content"].(string) + for _, f := range ofContribution.FindAllStringSubmatch(content, -1) { + seat, rest, _ := strings.Cut(f[1], ":") + kind, whereText, _ := strings.Cut(rest, ":") + if cs, ok := catalogue.SeatNamed(seat); !ok || cs.Name != s.Name || kind != r.Kind { + continue + } + where, err := placeholderWhere(r, whereText) + if err != nil { + continue // said by seatPlaceholderProblems + } + for _, combo := range combinations(r.Shape.Places) { + if covers(where, combo) { + placed[comboKey(combo)]++ + } + } + } + } + for _, combo := range combinations(r.Shape.Places) { + switch n := placed[comboKey(combo)]; { + case n == 0: + problems = append(problems, fmt.Sprintf("%s never places %s:%s for %s; every combination of %s is "+ + "placed once, or a contribution to it is lost (novox/hq ADR 0255)", + m.Module, s.Name, r.Kind, comboKey(combo), placeFields(r))) + case n > 1: + problems = append(problems, fmt.Sprintf("%s places %s:%s for %s %d times; once, or a contribution "+ + "is written twice (novox/hq ADR 0255)", m.Module, s.Name, r.Kind, comboKey(combo), n)) + } + } + } + } + return problems +} + +var capabilityName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// invokesOf is everything a module says it invokes: its own and each process's bus, once each. +func invokesOf(m catalogue.Manifest) []string { + out := append([]string(nil), m.Invokes...) + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "process" { + continue + } + if _, present := r["bus"]; !present { + continue + } + if b, _, err := processBusOf(r); err == nil { + out = append(out, b.Invokes...) + } + } + slices.Sort(out) + return slices.Compact(out) +} + +// seatInvokeProblems are the `seat:.` a module or one of its processes invokes that nothing answers: a seat +// no module declares and the mesh does not define, a verb the seat does not promise, or a seat with no holder (a +// seat a module declares and no module claims; a mesh seat's holder, the mesh itself or a module of any repository, +// is not judged). +func seatInvokeProblems(shelf catalogue.Shelf, declared map[string]catalogue.SeatDeclaration) []string { + claimed := map[string]bool{} + for _, m := range shelf { + for _, c := range m.Claims { + claimed[c.Name] = true + if s, ok := catalogue.SeatNamed(c.Name); ok { + claimed[s.Name] = true + } + } + } + var problems []string + for _, module := range shelfOrder(shelf) { + m := shelf[module] + for _, invoke := range invokesOf(m) { + seat, verb, ok := catalogue.SeatInvoke(invoke) + if !ok { + continue + } + var verbs []catalogue.Verb + held := claimed[seat] + if s, isMesh := catalogue.SeatNamed(seat); isMesh { + // A mesh seat is held by the mesh itself or by a module of any repository: one repository's catalogue + // cannot say it has no holder, so only its verbs are judged (the lead, 2026-10-30: mesh-control's check + // runs over the captured mesh-catalog alone, whose mesh-self-check calls the watchman's seat). + verbs, held = s.Serves, true + } else if d, isModule := declared[seat]; isModule { + verbs = d.Serves + } else { + problems = append(problems, fmt.Sprintf("%s invokes %s, a verb of the seat %q, "+catalogue.UndeclaredSeat, module, invoke, seat)) + continue + } + if !slices.ContainsFunc(verbs, func(v catalogue.Verb) bool { return v.Name == verb }) { + problems = append(problems, fmt.Sprintf("%s invokes %s, and the seat %s serves no verb %q", module, invoke, seat, verb)) + continue + } + if !held { + problems = append(problems, fmt.Sprintf("%s invokes %s, a verb of the seat %q, "+catalogue.UnheldSeat, module, invoke, seat)) + } + } + } + return problems +} + +// SeatPathProblems is what is wrong with the paths a set of the mesh's own seats names (novox/hq ADR +// 0287): only a node seat names one, each absolute and literal; every path has exactly one seat naming it +// without stepping back; and a seat steps back only for another node seat naming the same path without +// stepping back, so the stepping back cannot cycle. +func SeatPathProblems(set []catalogue.Seat) []string { + var problems []string + owners := map[string][]string{} // path → the seats naming it without stepping back + stepping := map[string][]catalogue.SeatPath{} // seat → the paths it names stepping back + byName := map[string]catalogue.Seat{} + for _, s := range set { + byName[s.Name] = s + for _, p := range s.Paths { + if why := seatPathProblem(s.Name, s.Scope, p.Path); why != "" { + problems = append(problems, why) + } + if p.StepsBackFor == "" { + owners[p.Path] = append(owners[p.Path], s.Name) + } else { + stepping[s.Name] = append(stepping[s.Name], p) + } + } + } + for _, s := range set { + for _, p := range stepping[s.Name] { + other, known := byName[p.StepsBackFor] + switch { + case !known || other.Scope != catalogue.ScopeNode: + problems = append(problems, fmt.Sprintf("the seat %s steps back from %q for %q, which is not a "+ + "node seat of the mesh's (novox/hq ADR 0287)", s.Name, p.Path, p.StepsBackFor)) + case !namesWithoutSteppingBack(other, p.Path): + problems = append(problems, fmt.Sprintf("the seat %s steps back from %q for %s, which does not "+ + "own that path itself; a seat steps back only for the one that owns it (novox/hq ADR 0287)", + s.Name, p.Path, other.Name)) + } + } + } + for _, p := range sortedKeys(owners) { + if len(owners[p]) > 1 { + problems = append(problems, fmt.Sprintf("the path %q is named by %s, each without stepping back; "+ + "no two seats name one path (novox/hq ADR 0287)", p, strings.Join(owners[p], " and "))) + } + } + for _, s := range set { + for _, p := range stepping[s.Name] { + if len(owners[p.Path]) == 0 { + problems = append(problems, fmt.Sprintf("the path %q has no seat naming it without stepping back "+ + "(novox/hq ADR 0287)", p.Path)) + } + } + } + return problems +} + +// asSeatPaths is a module's own seat's paths in the shape the mesh's seats name theirs. A module's seat +// never steps back: only the mesh's own seats do, each by a record. +func asSeatPaths(paths []string) []catalogue.SeatPath { + out := make([]catalogue.SeatPath, 0, len(paths)) + for _, p := range paths { + out = append(out, catalogue.SeatPath{Path: p}) + } + return out +} + +// declaredSeatPathProblems is what one module's own seat declaration can be judged on alone: a path +// only on a node seat, absolute, literal and clean. +func declaredSeatPathProblems(m catalogue.Manifest, s catalogue.SeatDeclaration) []string { + var problems []string + for _, p := range s.Paths { + if why := seatPathProblem(s.Name, s.At(), p); why != "" { + problems = append(problems, m.Module+": "+why) + } + } + return problems +} + +func namesWithoutSteppingBack(s catalogue.Seat, p string) bool { + for _, sp := range s.Paths { + if sp.Path == p && sp.StepsBackFor == "" { + return true + } + } + return false +} + +// seatPathProblem is what is wrong with one path a seat names, judged alone: absolute, written literally +// with no placeholder, and clean, so it compares with what a module declares byte for byte. +func seatPathProblem(seat, scope, p string) string { + switch { + case scope != catalogue.ScopeNode: + return fmt.Sprintf("the seat %s names the path %q and is a %s seat; only a node seat names paths, "+ + "because a path is a machine's (novox/hq ADR 0287)", seat, p, scope) + case strings.Contains(p, "${"): + return fmt.Sprintf("the seat %s names the path %q, which has a placeholder; a seat's path is written "+ + "literally (novox/hq ADR 0287)", seat, p) + case !strings.HasPrefix(p, "/") || path.Clean(p) != p: + return fmt.Sprintf("the seat %s names the path %q, which is not a clean absolute path (novox/hq ADR 0287)", + seat, p) + case path.Dir(p) == "/": + // The root or a top-level directory is every role's, never one role's own (ADR 0287). + return fmt.Sprintf("the seat %s names the path %q, the root or a top-level directory, which no one "+ + "role owns (novox/hq ADR 0287)", seat, p) + } + return "" +} + +// claimProblems is what is wrong with a manifest's claims and the seats it defines. +// +// A claim is one of three things (novox/hq ADR 0121): a **system seat** the control plane defines — +// checked for scope and, if it delivers a provision, that the claimant provides it; a **system name +// the mesh does not define** (`mesh-*`/`node-*`) — refused, because that namespace is the control +// plane's; or a **module-defined seat** — valid only when this manifest also declares it, since a +// module may coordinate its own instances through a seat of its own but may not invent one by +// claiming it. A module's own seat declaration may not sit in the system namespace or shadow a +// system seat. +func claimProblems(m catalogue.Manifest) []string { + var problems []string + + defined := map[string]catalogue.SeatDeclaration{} + for _, d := range m.DefinesSeats { + if _, isSystem := catalogue.SeatNamed(d.Name); isSystem || isSystemSeatName(d.Name) { + problems = append(problems, fmt.Sprintf( + "%s defines a seat %q in the mesh's own namespace; a module's seat is named outside "+ + "mesh-*/node-* (novox/hq ADR 0121)", m.Module, d.Name)) + continue + } + defined[d.Name] = d + } + + for _, c := range m.Claims { + if s, known := catalogue.SeatNamed(c.Name); known && s.HeldByTheEngine { + // The node's engine holds it on every node, and no module may (novox/hq ADR 0563 §1): a module answering + // root-free or a hand-over would be an agent answering it. + problems = append(problems, fmt.Sprintf("%s claims %s, which every node's engine holds itself and no "+ + "module may (novox/hq ADR 0563 §1)", m.Module, c.Name)) + continue + } + if _, known := catalogue.SeatNamed(c.Name); known { + // **A seat's scope and what it delivers are not judged here** (novox/hq ADR 0122). + // This function runs wherever a manifest is parsed, and one of those places is the + // build machine, which has no store: there, `SeatNamed` answers from the set the + // binary shipped with, so a build would be refused for disagreeing with a compiled + // copy of data the control plane owns. Exactly that happened — a holder of the bus + // seat was refused for not providing what a stale compiled row said the seat + // delivered, while the store's own row said otherwise. + // + // Both checks moved to CatalogueProblems, which only ever runs in the control plane, + // after UseSeats has replaced the set with the store's. + continue + } + if replaced, retired := RetiredSeats[c.Name]; retired { + problems = append(problems, fmt.Sprintf("%s claims the seat %q, which is retired: its work is %s", m.Module, c.Name, replaced)) + continue + } + if isSystemSeatName(c.Name) { + problems = append(problems, fmt.Sprintf( + "%s claims %q, which is a seat in the mesh's own namespace (mesh-*/node-*) that it "+ + "does not define (novox/hq ADR 0121) — the seats are: %s", m.Module, c.Name, seatNames())) + continue + } + d, ours := defined[c.Name] + if !ours { + // **A claim on a seat this manifest does not declare is not the parser's to judge.** + // A module may hold a seat another module declared — that is why ADR 0126 has callers + // name the seat and not its provider, so an implementation can be replaced without + // touching a caller. Whether the seat exists is a fact about the whole catalogue, so + // the refusal is at registration, where every declaration is in view + // (`CatalogueProblems`: "which no module declares and the mesh does not define"). + continue + } + if c.At() != d.At() { + problems = append(problems, fmt.Sprintf( + "%s claims its own seat %s at scope %q, having declared it at %q", + m.Module, c.Name, c.At(), d.At())) + } + } + return problems +} + +// A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for +// a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused; +// any other name is a module's own to define and claim. Some of the mesh's own seats predate this +// convention and are not yet renamed (git, npm-package-registry, the-private-network) — those are +// in the set, so they resolve by name, not by prefix. the-artifact-store was renamed on 2026-09-30 +// (novox/hq ADR 0156) and resolves through the alias table on a mesh that knew it. +func isSystemSeatName(name string) bool { + return strings.HasPrefix(name, "mesh-") || strings.HasPrefix(name, "node-") +} + +func seatNames() string { + names := make([]string, 0, len(seats)) + for _, s := range seats { + names = append(names, s.Name) + } + sort.Strings(names) + return strings.Join(names, ", ") +} + +var RetiredSeats = map[string]string{ + "node-build-agent": "node-build-go, node-build-node and node-build-image, held by the build modules (novox/hq ADR 0525 §10)", + "mesh-build-machine": "node-build-go, node-build-node and node-build-image, held by the build modules (novox/hq ADR 0190, ADR 0525 §10)", +} + +const meshSeatPrefix = "mesh-" + +const retiredLoginShell = "login-shell" + +func CatalogueProblems(shelf catalogue.Shelf) []string { + var problems []string + // A stream name has one owner (novox/hq ADR 0363). + problems = append(problems, StreamNameProblems(shelf)...) + // A module that hears a restricted stream runs as an account of its own (novox/hq ADR 0376 §2). + problems = append(problems, RestrictedHearingProblems(shelf)...) + // A work-queue stream is never asked for by another module's consumes (novox/hq ADR 0436 §6). + problems = append(problems, WorkQueueConsumedElsewhereProblems(shelf)...) + // Every offer says its credential's form, never left to a default (the lead, 2026-10-29, for novox/hq ADR 0570). + for _, module := range shelfOrder(shelf) { + problems = append(problems, CredentialFormProblems(shelf[module])...) + // And its data in the bus's predefined forms, a stream of its own refused (the lead, 2026-10-29). + problems = append(problems, PredefinedFormProblems(shelf[module])...) + } + + // Who declares what, and who declared it first. + declaredBy := map[string]string{} + declared := map[string]catalogue.SeatDeclaration{} + for _, module := range shelfOrder(shelf) { + for _, s := range shelf[module].DefinesSeats { + if s.Name == "" { + continue + } + if first, taken := declaredBy[s.Name]; taken { + // The second loses. A seat name meaning two different protocols is the failure + // nobody could diagnose afterwards — a caller would bind to whichever happened + // to register first, and the symptom would appear in the other module. + problems = append(problems, fmt.Sprintf( + "%s declares the seat %q, which %s already declares; a seat name means one "+ + "protocol", module, s.Name, first)) + continue + } + declaredBy[s.Name] = module + declared[s.Name] = s + } + } + + exists := func(seat string) bool { + if _, isMesh := catalogue.SeatNamed(seat); isMesh { + return true + } + _, ok := declaredBy[seat] + return ok + } + + // atNode says whether a seat, compiled or declared, is a node seat — judged here because only + // the whole catalogue knows a declared one's scope (novox/hq ADR 0378 §8). + atNode := func(seat string) bool { + if s, isMesh := catalogue.SeatNamed(seat); isMesh { + return s.Scope == catalogue.ScopeNode + } + if s, ok := declared[seat]; ok { + return s.At() == catalogue.ScopeNode + } + return false + } + + // A module-declared seat's needs name node seats that hold beside it (novox/hq ADR 0378 §8): + // whether one exists at all, compiled or declared by any module, only the whole catalogue can + // say — a single manifest's own problems (declaredSeatProblems) judge its needs no further. + for _, module := range shelfOrder(shelf) { + for _, s := range shelf[module].DefinesSeats { + for _, need := range s.Needs { + if !exists(need) { + problems = append(problems, fmt.Sprintf( + "%s declares %s needing %s, "+catalogue.UndeclaredSeat, module, s.Name, need)) + } else if !atNode(need) { + problems = append(problems, fmt.Sprintf( + "%s declares %s needing %s, which is not a node seat (novox/hq ADR 0378 §8)", + module, s.Name, need)) + } + } + } + } + + // A seat verb a module invokes is one some holder serves (issue 774). + problems = append(problems, seatInvokeProblems(shelf, declared)...) + + // Which seats name each path (novox/hq ADR 0287): a module's seat naming one another seat names loses. + named, pathProblems := pathSeats(shelfOrder(shelf), shelf, declaredBy) + problems = append(problems, pathProblems...) + + // Who claims each kind of a kinded bench, so a second claim of one kind is refused (ADR 0234 §2). + kindsTaken := map[string]string{} + + for _, module := range shelfOrder(shelf) { + m := shelf[module] + for _, c := range m.Claims { + if c.Kind != "" { + if _, isModuleSeat := declared[c.Name]; !isModuleSeat { + problems = append(problems, fmt.Sprintf( + "%s claims %s of kind %q, and only a kinded bench takes a kind", module, c.Name, c.Kind)) + } + } + } + + // A `uses` naming nothing is where ADR 0110's guarantee lands under a derived set: the + // same refusal, at the same moment, from a set nobody maintains by hand. + for _, u := range m.Uses { + if replaced, retired := RetiredSeats[u]; retired && !exists(u) { + problems = append(problems, fmt.Sprintf("%s uses the seat %q, which is retired: its work is %s", module, u, replaced)) + continue + } + if !exists(u) { + problems = append(problems, fmt.Sprintf( + "%s uses the seat %q, "+catalogue.UndeclaredSeat, + module, u)) + } + } + + for _, c := range m.Claims { + if replaced, retired := RetiredSeats[c.Name]; retired && !exists(c.Name) { + problems = append(problems, fmt.Sprintf("%s claims the seat %q, which is retired: its work is %s", module, c.Name, replaced)) + continue + } + if !exists(c.Name) { + problems = append(problems, fmt.Sprintf( + "%s claims the seat %q, "+catalogue.UndeclaredSeat, + module, c.Name)) + continue + } + s, isModuleSeat := declared[c.Name] + if !isModuleSeat { + // **A mesh seat is judged here and nowhere else** (novox/hq ADR 0122): the set is + // the store's, and this is the only place that runs with the store's set loaded. + // The parser cannot do it — it also runs on the build machine, against whatever + // set that binary was compiled with. + seat, _ := catalogue.SeatNamed(c.Name) + if err := catalogue.CanHold(m, seat); err != nil { + problems = append(problems, err.Error()) + } + continue + } + problems = append(problems, kindProblems(module, c, s, kindsTaken)...) + if c.At() != s.At() { + problems = append(problems, fmt.Sprintf( + "%s claims %s at scope %q, and %s declares it at %s", + module, c.Name, c.At(), declaredBy[c.Name], s.At())) + } + // A holder that does not answer what the seat promises is a caller's timeout, found + // at assignment instead. + if missing := unserved(m, c, s); len(missing) > 0 { + problems = append(problems, fmt.Sprintf( + "%s claims %s but does not serve %s, which that seat's protocol promises", + module, c.Name, strings.Join(missing, ", "))) + } + // And owns what the seat's holder owns, as a holder of the mesh's own seats does (ADR 0287). + if missing := missingPaths(m, asSeatPaths(s.Paths)); len(missing) > 0 { + problems = append(problems, fmt.Sprintf( + "%s claims %s but does not declare %s, which that seat's holder owns (novox/hq ADR 0287)", + module, c.Name, strings.Join(missing, ", "))) + } + } + // Only a claimant declares a seat's path (novox/hq ADR 0287). + problems = append(problems, unclaimedSeatPaths(m, named)...) + } + // **A trusted holder runs as its own account** (novox/hq ADR 0259 §8): a module saying warrants, or + // speaking for a kind that proves its sender, is never carried by a machine's runtime. + for _, module := range shelfOrder(shelf) { + m := shelf[module] + if why := catalogue.TrustedHolding(m, declared); why != "" && m.RunsAs == "" { + problems = append(problems, fmt.Sprintf( + "%s must run as an account of its own (runs-as): %s, and the machine's runtime runs as the "+ + "operator's account, which every agent runs as (novox/hq ADR 0259)", module, why)) + } + } + sort.Strings(problems) + return problems +} + +// RunsAsProblems is what one manifest's `runs-as` is held to (novox/hq ADR 0259 §8): an account of the +// module's own making — a `user` resource of that name — that owns its secrets, with a bus account of its own, +// and that is neither root nor the operator's. +func RunsAsProblems(m catalogue.Manifest) []string { + if m.RunsAs == "" { + return nil + } + var problems []string + say := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) } + switch { + case !accountName.MatchString(m.RunsAs): + say("%s runs as %q, which is not an account name of the module's own", m.Module, m.RunsAs) + return problems + case m.RunsAs == "root": + say("%s runs as root; a module of its own account runs as an account it makes, never root", m.Module) + } + made := false + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == m.RunsAs { + made = true + } + } + if !made { + say("%s runs as %s, which it does not make: a user resource named %s", m.Module, m.RunsAs, m.RunsAs) + } + if _, has := m.OwnSecrets["broker"]; !has { + say("%s runs as its own account and declares no own secret broker: its runtime reaches the bus on an "+ + "account of its own", m.Module) + } + if m.SecretsOwner != m.RunsAs { + say("%s runs as %s, and its secrets belong to %q: they are the account's own", m.Module, m.RunsAs, m.SecretsOwner) + } + return problems +} + +// capabilityProblems are the words of a claim outside the vocabulary, and capabilities on a claim of a +// seat that is not kinded. +func capabilityProblems(module string, c catalogue.Claim, kinded bool) []string { + if len(c.Capabilities) == 0 { + return nil + } + if !kinded { + return []string{fmt.Sprintf("%s claims %s with capabilities, and only a kinded bench's claim carries them", + module, c.Name)} + } + var problems []string + for _, w := range c.Capabilities { + if !catalogue.ChannelCapabilities[w] && !maxLength.MatchString(w) { + problems = append(problems, fmt.Sprintf( + "%s claims %s with the capability %q, which channel-capabilities/1 does not have", module, c.Name, w)) + } + } + return problems +} + +// declaredSeatNeedsProblems is what one declaration's needs can be judged on alone (novox/hq ADR +// 0378 §8): only a node seat is ever held beside another on the same node, so only a node seat may +// carry needs; and a seat needing itself names nothing that was not already true of holding it. +// Whether each named need itself exists as a node seat somewhere in the catalogue is CatalogueProblems' +// to say, once every manifest is known. +func declaredSeatNeedsProblems(m catalogue.Manifest, s catalogue.SeatDeclaration) []string { + var problems []string + if len(s.Needs) == 0 { + return problems + } + if s.At() != catalogue.ScopeNode { + problems = append(problems, fmt.Sprintf( + "%s declares %s needing %s, and only a node seat needs another held beside it "+ + "(novox/hq ADR 0378 §8)", m.Module, s.Name, strings.Join(s.Needs, ", "))) + } + for _, need := range s.Needs { + if need == s.Name { + problems = append(problems, fmt.Sprintf( + "%s declares %s needing itself (novox/hq ADR 0378 §8)", m.Module, s.Name)) + } + } + return problems +} + +// declaredSeatProblems is what one manifest can be judged on alone. +func declaredSeatProblems(m catalogue.Manifest) []string { + var problems []string + seen := map[string]bool{} + + for _, s := range m.DefinesSeats { + switch { + case s.Name == "": + problems = append(problems, fmt.Sprintf("%s declares a seat with no name", m.Module)) + continue + case !name.MatchString(s.Name): + problems = append(problems, fmt.Sprintf( + "%s declares a seat named %q, which is not a usable name", m.Module, s.Name)) + continue + case strings.HasPrefix(s.Name, meshSeatPrefix): + // The mesh's own code dereferences its seats by name — the resolver *is* the thing + // that finds the store — so the prefix is not a convention, it is a namespace. + problems = append(problems, fmt.Sprintf( + "%s declares a seat named %q; %q is reserved to the mesh, which defines its own "+ + "seats (novox/hq ADR 0118)", m.Module, s.Name, meshSeatPrefix+"*")) + continue + } + if s.Name == retiredLoginShell { + // The name ADR 0176 gave the login shell when the zsh module declared it. The seat is + // the mesh's now, so a module declaring the old name would be a second login shell + // beside it, with a protocol of its own (novox/hq ADR 0204). + problems = append(problems, fmt.Sprintf( + "%s declares a seat named %q; the login shell is the mesh's own seat %s, which a shell "+ + "module claims and none declares (novox/hq ADR 0204)", m.Module, s.Name, catalogue.LoginShellSeat)) + continue + } + if seen[s.Name] { + problems = append(problems, fmt.Sprintf( + "%s declares the seat %q twice", m.Module, s.Name)) + continue + } + seen[s.Name] = true + + if _, isMesh := catalogue.SeatNamed(s.Name); isMesh { + problems = append(problems, fmt.Sprintf( + "%s declares %q, which is a seat the mesh already defines", m.Module, s.Name)) + } + switch s.At() { + case catalogue.ScopeNode, catalogue.ScopeSite, catalogue.ScopeMesh: + default: + problems = append(problems, fmt.Sprintf( + "%s declares seat %s at scope %q; a seat is held per node, per site or per mesh", + m.Module, s.Name, s.Scope)) + } + // A seat with an empty protocol is allowed, and is the mesh saying what a machine is: + // which module is this node's packet filter, or its showcase. Design 26 calls it a seat + // that delivers nothing, and that is most of the node-scoped ones. ADR 0126's "a declared + // seat carries a protocol" governs what a holder must satisfy, not that every seat offers + // something — a marker seat's protocol is satisfied by holding it. Nothing can reach this + // state by accident: a mistyped field name is refused by the parser above, so an empty + // protocol was written as one. + for _, v := range seatDeclarationVerbs(s) { + if !name.MatchString(v) { + problems = append(problems, fmt.Sprintf( + "%s declares %s.%s, which is not a usable verb", m.Module, s.Name, v)) + } + } + problems = append(problems, trafficProblems(m, s)...) + problems = append(problems, declaredSeatPathProblems(m, s)...) + problems = append(problems, declaredSeatNeedsProblems(m, s)...) + } + + for _, u := range m.Uses { + if !name.MatchString(u) { + problems = append(problems, fmt.Sprintf("%s uses %q, which is not a usable seat name", m.Module, u)) + } + } + return problems +} + +// kindProblems is a claim judged against a declared seat's kind: a kinded bench takes one claim per kind, +// a usable name; any other seat takes none. +func kindProblems(module string, c catalogue.Claim, s catalogue.SeatDeclaration, taken map[string]string) []string { + if problems := capabilityProblems(module, c, s.Kinded); len(problems) > 0 { + return problems + } + switch { + case !s.Kinded && c.Kind != "": + return []string{fmt.Sprintf("%s claims %s of kind %q, and only a kinded bench takes a kind", + module, c.Name, c.Kind)} + case !s.Kinded: + return nil + case c.Kind == "": + return []string{fmt.Sprintf("%s claims the kinded bench %s and names no kind", module, c.Name)} + case !name.MatchString(c.Kind) || strings.Contains(c.Kind, "."): + return []string{fmt.Sprintf("%s claims %s of kind %q, which is not a usable name", module, c.Name, c.Kind)} + } + key := c.Name + "/" + c.Kind + if first, ok := taken[key]; ok && first != module { + return []string{fmt.Sprintf("%s claims %s of kind %q, which %s already claims; a kind has one holder", + module, c.Name, c.Kind, first)} + } + taken[key] = module + return nil +} + +// shelfOrder is the catalogue in a stable order, so two runs report the same problems in the same +// sequence — a refusal that reorders itself is a refusal nobody can diff. +func shelfOrder(shelf catalogue.Shelf) []string { + out := make([]string, 0, len(shelf)) + for k := range shelf { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// trafficProblems is what one declaration of the rules of ADR 0259 §3 can be judged on alone. +func trafficProblems(m catalogue.Manifest, s catalogue.SeatDeclaration) []string { + var problems []string + if s.Kinded && !catalogue.KindedBenches[s.Name] { + problems = append(problems, fmt.Sprintf( + "%s declares %s as a kinded bench; only channel and intake are kinded, and another is a "+ + "decision, recorded (novox/hq ADR 0234)", m.Module, s.Name)) + } + if s.Kinded && len(s.ByCaller) > 0 { + problems = append(problems, fmt.Sprintf( + "%s declares %s kinded and names verbs by their caller; a kinded bench's subjects carry the kind", + m.Module, s.Name)) + } + for _, v := range s.ByCaller { + inAccepts, inEmits := false, false + for _, a := range s.Accepts { + inAccepts = inAccepts || a == v + } + for _, e := range s.Emits { + inEmits = inEmits || e == v + } + if !inAccepts && !inEmits { + problems = append(problems, fmt.Sprintf( + "%s names %s.%s by its caller, which the seat neither accepts nor emits", m.Module, s.Name, v)) + } + } + for _, v := range s.Proofs { + if !name.MatchString(v) || strings.Contains(v, ".") { + problems = append(problems, fmt.Sprintf("%s declares the proof %s.%s, which is not a usable verb", + m.Module, s.Name, v)) + } + } + if len(s.Proofs) > 0 && !s.Kinded { + problems = append(problems, fmt.Sprintf( + "%s declares proofs on %s, which is not kinded; a proof is asked by a holder of a kind", + m.Module, s.Name)) + } + for _, r := range s.Records { + kept := false + for _, st := range m.State { + kept = kept || st.Name == r + } + if !kept { + problems = append(problems, fmt.Sprintf( + "%s says %s's users read its records %q, which it keeps no state of", m.Module, s.Name, r)) + } + } + return problems +} + +// unserved is what a seat's protocol promises and the claimant does not answer. Only the tools +// are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool +// is code the module either has or has not written — under the claim's serves, or among its own. +func unserved(m catalogue.Manifest, c catalogue.Claim, s catalogue.SeatDeclaration) []string { + has := map[string]bool{} + for _, t := range c.ServesFor(m) { + has[t] = true + } + var missing []string + for _, t := range s.Serves { + if !has[t.Name] { + missing = append(missing, t.Name) + } + } + return missing +} + +// verbs is everything the protocol names, for the checks that do not care which half. +func seatDeclarationVerbs(s catalogue.SeatDeclaration) []string { + out := append([]string{}, s.Accepts...) + out = append(out, s.Emits...) + return append(out, catalogue.VerbNames(s.Serves)...) +} + +var accountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,30}$`) + +var maxLength = regexp.MustCompile(`^max-length:[1-9][0-9]{0,6}$`) + +// checkServedOwnNames refuses a served `${bound:…}` that names a requirement the module does not +// contribute to, a fact other than its own names, or a local name it does not contribute under; and +// one with a default, since a served value is left out rather than given one (ADR 0346 §3, §4). +func checkServedOwnNames(m catalogue.Manifest, provision, key, text string) []string { + var problems []string + contributes := requirementsContributedTo(m) + for _, parts := range bound.FindAllStringSubmatch(text, -1) { + to, fact, fallback := parts[1], parts[2], parts[3] + says := fmt.Sprintf("%s serves %s, and the value it serves as %q says %s", m.Module, provision, key, parts[0]) + switch { + case fallback != "": + problems = append(problems, says+": a served name takes no default; one the machine does not "+ + "compose leaves the key out (novox/hq ADR 0346)") + case !oneOf(contributes, to): + problems = append(problems, fmt.Sprintf( + "%s, and %s contributes to %s: a served value names only the provider's own names "+ + "(novox/hq ADR 0346)", says, m.Module, orNothing(quoted(contributes)))) + case !oneOf(ownNameKeys(m, to), fact): + problems = append(problems, fmt.Sprintf("%s, and what %s is called through %s is %s (novox/hq ADR 0346)", + says, m.Module, to, strings.Join(ownNameKeys(m, to), ", "))) + } + } + return problems +} + +// nestedOwnNames refuses a `${bound:…}` inside a served list or object: only a string value of its own is +// filled (ServedOwnNames), so a nested one would reach the consumer as the placeholder (ADR 0346 §5). +func nestedOwnNames(m catalogue.Manifest, provision, key string, value any) []string { + var found []string + var walk func(v any) + walk = func(v any) { + switch x := v.(type) { + case string: + found = append(found, bound.FindAllString(x, -1)...) + case []any: + for _, e := range x { + walk(e) + } + case map[string]any: + for _, k := range sortedAnyKeys(x) { + walk(x[k]) + } + } + } + walk(value) + if len(found) == 0 { + return nil + } + return []string{fmt.Sprintf( + "%s serves %s, and the value it serves as %q holds %s inside a list or an object: a provider's own "+ + "name is filled only in a string value of its own (novox/hq ADR 0346)", + m.Module, provision, key, strings.Join(found, ", "))} +} + +// ownNameKeys is every name a served value of m may say for one requirement it contributes to: +// `name` and `internal-name`, and `name-` and `internal-name-` for each local name it +// contributes under. +func ownNameKeys(m catalogue.Manifest, to string) []string { + keys := append([]string{}, ownNameFacts...) + for _, local := range sortedKeys(m.ContributesMany[to]) { + for _, fact := range ownNameFacts { + keys = append(keys, fact+"-"+local) + } + } + return keys +} + +func quoted(values []string) []string { + out := make([]string, len(values)) + for i, v := range values { + out[i] = fmt.Sprintf("%q", v) + } + return out +} + +var ownNameFacts = []string{"name", "internal-name"} + +// SettingProblems is every way a definition's setting defaults are wrong, in its own words. +func SettingProblems(m catalogue.Manifest) []string { + if len(m.Settings) == 0 { + return nil + } + used := settingKeysUsedBy(m) + var problems []string + for _, key := range sortedSettingKeys(m.Settings) { + d := m.Settings[key] + say := func(format string, args ...any) { + problems = append(problems, fmt.Sprintf("%s's setting %q: ", m.Module, key)+fmt.Sprintf(format, args...)) + } + if !settingRef.MatchString("${setting:" + key + "}") { + say("not a usable key: lower-case letters, digits, dots, dashes and underscores") + continue + } + if meshWords[key] { + say("the mesh reads %q itself, and a module gives it no default", key) + continue + } + if d.Kind != catalogue.KindPreference { + say("kind is %q, and only a %q has a default (novox/hq ADR 0262)", d.Kind, catalogue.KindPreference) + } + if word := operatorsWord(key); word != "" { + say("a key naming %q is the operator's value, and has no default — it is the "+ + "assignment's, never the definition's (novox/hq ADR 0112, ADR 0262)", word) + } + switch v := d.Default.(type) { + case string: + if strings.TrimSpace(v) == "" { + say("an empty default is no default; give the value, or declare nothing") + } + case float64, bool: + case nil: + say("no default: a key without one is the operator's, and is not declared here") + default: + say("a default is a string, a number or a boolean, and this is %T", d.Default) + } + if strings.TrimSpace(d.Why) == "" { + say("no why: say in one sentence why this default") + } + if !used[key] { + say("nothing asks for ${setting:%s}, so the default reaches nothing", key) + } + } + return problems +} + +// operatorsWord is what in a key's name says its value is the operator's, or "". A key is about its +// last word — `url-timeout` is a timeout, `user-agent` an agent, `mail-domain` a domain — or its last two +// as one of operatorsCompounds. A plural is read as its singular. +func operatorsWord(key string) string { + words := strings.FieldsFunc(key, func(r rune) bool { return r == '-' || r == '_' || r == '.' }) + if len(words) == 0 || (len(words) > 1 && aboutAnAmount[words[0]]) { + return "" + } + for i, w := range words { + switch { + case operatorsOwn[w]: + case strings.HasSuffix(w, "ies") && operatorsOwn[strings.TrimSuffix(w, "ies")+"y"]: + words[i] = strings.TrimSuffix(w, "ies") + "y" + case strings.HasSuffix(w, "s") && operatorsOwn[strings.TrimSuffix(w, "s")]: + words[i] = strings.TrimSuffix(w, "s") + } + } + if n := len(words); n > 1 { + // Where a secret or an identity is kept is the operator's too: `password-file`, `token-path`. + if (words[n-1] == "file" || words[n-1] == "path") && operatorsOwn[words[n-2]] { + return words[n-2] + "-" + words[n-1] + } + for _, last := range []string{words[n-1], strings.TrimSuffix(words[n-1], "s")} { + if pair := words[n-2] + "-" + last; operatorsCompounds[pair] { + return pair + } + } + } + if last := words[len(words)-1]; operatorsOwn[last] { + return last + } + return "" +} + +func sortedSettingKeys(in map[string]catalogue.SettingDeclaration) []string { + keys := make([]string, 0, len(in)) + for k := range in { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +var aboutAnAmount = map[string]bool{ + "max": true, "min": true, "num": true, "count": true, "show": true, "hide": true, "enable": true, + "disable": true, +} + +var operatorsCompounds = map[string]bool{ + "client-id": true, "site-name": true, "server-name": true, "public-name": true, "smtp-relay": true, + "host-name": true, "user-name": true, "domain-name": true, "dns-server": true, + // Whom a rule lets in or keeps out: a list of addresses or networks. + "allow-from": true, "deny-from": true, "allow-list": true, +} + +var operatorsOwn = map[string]bool{ + "domain": true, "host": true, "hostname": true, "servername": true, "fqdn": true, "zone": true, + "realm": true, "tenant": true, "site": true, "timezone": true, + "issuer": true, "url": true, "uri": true, "webhook": true, "origin": true, "dsn": true, + "ip": true, "ipv4": true, "ipv6": true, + "email": true, "mail": true, "phone": true, "address": true, + "identity": true, "login": true, "user": true, "username": true, "account": true, "owner": true, + "uid": true, "gid": true, "puid": true, "pgid": true, + "password": true, "pass": true, "passwd": true, "passphrase": true, "secret": true, "token": true, + "key": true, "apikey": true, "bearer": true, "cert": true, "certificate": true, "credential": true, + "nameserver": true, "gateway": true, "subnet": true, "sender": true, "recipient": true, "contact": true, + "trusted": true, "whitelist": true, "peer": true, "bind": true, "listen": true, "upstream": true, + "proxy": true, "admin": true, "mac": true, +} + +// GroupSettingProblems refuses, where a manifest is read, a container whose groups hold anything but whole +// placeholders and whole numbers, or a placeholder whose key the module neither declares nor asks for elsewhere. +func GroupSettingProblems(m catalogue.Manifest) []string { + var problems []string + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "container" { + continue + } + for _, g := range groupsOf(r) { + s, isText := g.(string) + if !isText { + continue // a number: the declaration judges its value + } + if !groupSide.MatchString(s) { + problems = append(problems, fmt.Sprintf("%s: the container %v's group %q is not a whole "+ + "${config:key} or a whole number: a supplementary group is a number, and nothing else is "+ + "written into it (novox/hq ADR 0480 clause 7)", m.Module, r["id"], s)) + continue + } + if m.Templated() { + continue + } + for _, key := range settingsUsed(s) { + if _, declared := m.Settings[key]; !declared && !m.DeclaresConfig() { + continue // the settings form judges its own keys + } + if _, declared := m.Settings[key]; !declared { + problems = append(problems, fmt.Sprintf("%s: the container %v's group says ${config:%s}, and the module "+ + "declares no such value (novox/hq ADR 0348 §4)", m.Module, r["id"], key)) + } + } + } + } + return problems +} + +// OwnerSettingProblems refuses, where a manifest is read, a directory whose owner carries a setting +// in any shape but a whole placeholder or number on each side of the colon, or names a key the module +// asks for nowhere else and declares no default for. +func OwnerSettingProblems(m catalogue.Manifest) []string { + var problems []string + asked := settingKeysAskedOutsideOwners(m) + for k := range m.Settings { + asked[k] = true + } + for _, r := range m.Resources { + owner, _ := r["owner"].(string) + if !strings.Contains(owner, "${setting:") { + continue + } + if fmt.Sprint(r["type"]) != "directory" { + // Said by the placeholder sweep: only a directory's owner takes a setting. + continue + } + if !ownerWithSetting.MatchString(owner) { + problems = append(problems, fmt.Sprintf("%s: the directory %v's owner %q carries a setting in a shape "+ + "the mesh does not fill; an owner names the account by number only, each side of the colon a whole "+ + "${setting:key} or a whole number (${setting:puid}:${setting:pgid}) (novox/hq issue 279)", + m.Module, r["id"], owner)) + continue + } + // A template's text is not here yet, so what the module asks for elsewhere cannot be known: WithTemplatesFrom + // parses its result again with every template filled, and this half is judged then (as config.go skips its + // "placed nowhere" rule while m.Templated()). + if m.Templated() { + continue + } + for _, key := range settingsUsed(owner) { + if !asked[key] { + problems = append(problems, fmt.Sprintf("%s: the directory %v's owner says ${setting:%s}, and the "+ + "module asks for %q nowhere else and declares no default for it: an owner follows the identity "+ + "the module's process is told it runs as (its identity file's ${setting:%s}), never a key of its "+ + "own (novox/hq issue 279)", m.Module, r["id"], key, key, key)) + } + } + } + return problems +} + +// UnitSettingProblems refuses, where a manifest is read, a service whose unit name carries a setting +// anywhere but as a template's whole instance. +func UnitSettingProblems(m catalogue.Manifest) []string { + var problems []string + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "service" { + continue + } + unit, _ := r["unit"].(string) + if len(settingsUsed(unit)) == 0 && !strings.Contains(unit, "${setting:") { + continue + } + if !unitInstance.MatchString(unit) { + problems = append(problems, fmt.Sprintf("%s: the service %v's unit %q carries a setting outside a template's "+ + "instance; a setting may stand only as the whole instance, after the @ and before the suffix "+ + "(name@${setting:key}.timer)", m.Module, r["id"], unit)) + } + } + return problems +} + +var groupSide = regexp.MustCompile(`^(?:\$\{setting:[a-z0-9][a-z0-9_.-]*\}|[0-9]+)$`) + +var ownerSide = `(?:\$\{setting:[a-z0-9][a-z0-9_.-]*\}|[0-9]+)` + +var ownerWithSetting = regexp.MustCompile(`^` + ownerSide + `(?::` + ownerSide + `)?$`) + +var unitInstance = regexp.MustCompile(`^[A-Za-z0-9:_.-]+@\$\{setting:[a-z0-9][a-z0-9_.-]*\}\.[a-z]+$`) + +// TakenSettingsProblems is every way a definition's takes-settings is wrong (ADR 0326 §2 and §4), in +// its own words: on a file that does not merge, not a list of keys, a key named twice, one the mesh +// reads itself, one the file keeps for itself, or one mergeable file naming its keys beside another +// that names none. +func TakenSettingsProblems(m catalogue.Manifest) []string { + return takenSettingsProblems(m) +} + +// WrittenSettingsProblems is ADR 0326 §3, judged only where a definition is written — the module +// check, `module add`, a build taken in, the builder and the merge gate's registration — and never +// where a stored one is read again (ParseManifest, a build's manifest at a commit, a module put back): +// a module that declares a setting names the keys of every mergeable file it owns (novox/hq issue +// 574, ADR 0164 §11 and §15). A build stored before the rule, such as betterbird's before its accounts +// file named its keys, must still compose and still be put back; only its next change is held to it. +// +// Said only when none of the module's mergeable files names its keys: when one does, §2 already +// names every file that does not, and a file is not reported twice. +func WrittenSettingsProblems(m catalogue.Manifest) []string { + if len(m.Settings) == 0 || m.DeclaresConfig() { + // A module in the config form is held to this where it is read, in its own words (ConfigProblems). + return nil + } + var silent []string + for _, r := range m.Resources { + how, _ := r["merge"].(string) + if how == "" { + continue + } + if _, said := r[catalogue.TakesSettingsField]; said { + return nil + } + silent = append(silent, fmt.Sprint(r["id"])) + } + var problems []string + for _, id := range silent { + problems = append(problems, fmt.Sprintf("%s declares settings, and its mergeable %s names no %s: "+ + "a module that declares a setting names the keys each mergeable file takes, an empty list "+ + "for none (novox/hq ADR 0326 §3, ADR 0164 §11)", m.Module, id, catalogue.TakesSettingsField)) + } + return problems +} + +// StateProblems is what is wrong with a manifest's state and reads. +// +// Refused at registration, because a bucket name the bus cannot hold is a module that installs, +// starts, and is refused on its first write with a reason about a bucket nobody named. +func StateProblems(m catalogue.Manifest) []string { + var problems []string + if len(m.State) > 0 && !stateName.MatchString(m.Module) { + problems = append(problems, fmt.Sprintf( + "%s keeps state, and a module's name is part of its buckets' names, which take one plain "+ + "name — no dot (novox/hq ADR 0201)", m.Module)) + } + seen := map[string]bool{} + for _, s := range m.State { + switch { + case !stateName.MatchString(s.Name): + problems = append(problems, fmt.Sprintf( + "%s keeps state %q: a state is named locally — lower-case letters, digits and hyphens, "+ + "no dot and no underscore; the mesh derives the bucket (novox/hq ADR 0201)", m.Module, s.Name)) + case seen[s.Name]: + problems = append(problems, fmt.Sprintf("%s keeps state %q twice", m.Module, s.Name)) + } + seen[s.Name] = true + if s.History < 0 || s.History > catalogue.StateMostHistory { + problems = append(problems, fmt.Sprintf( + "%s keeps %d values of %q; a key keeps between 1 and %d", m.Module, s.History, s.Name, catalogue.StateMostHistory)) + } + if s.TTLSeconds < 0 { + problems = append(problems, fmt.Sprintf("%s gives %q a negative lifetime", m.Module, s.Name)) + } + } + for _, r := range m.Reads { + module, _, err := catalogue.ReadState(r) + if err != nil { + problems = append(problems, fmt.Sprintf("%s reads %v", m.Module, err)) + continue + } + if module == m.Module { + problems = append(problems, fmt.Sprintf( + "%s reads %q, which is its own state: a module reads and writes what it keeps already", m.Module, r)) + } + } + return problems +} + +// PredefinedFormProblems refuses each stream a module declares that StreamsKeptUntilMoved does not name for it. +func PredefinedFormProblems(m catalogue.Manifest) []string { + var out []string + for _, s := range m.Streams { + if !slices.Contains(catalogue.StreamsKeptUntilMoved[m.Module], s.Name) { + out = append(out, fmt.Sprintf("%s keeps the stream %q, outside the bus's predefined forms: keep it as a state, "+ + "a log or an object store, which the runtime reads for the module's tools and others read by `reads`", m.Module, s.Name)) + } + } + return out +} + +// RestrictedHearingProblems refuses a module that would hear a restricted stream without an account of its own +// (novox/hq ADR 0376 §2): a `consumes` naming a module literally and asking for events a restricted stream of +// that module carries, by a manifest that declares no `runs-as`. A module the machine's tool runner carries +// reaches the bus as the tool runner's account, which every agent session there shares, so the controller would +// compose nothing for it. Judged only where the provider is on the shelf, as a consumer may be installed before +// its emitter; a wildcard emitter never reaches a module's stream (ADR 0363 §5) and is not judged. +func RestrictedHearingProblems(shelf catalogue.Shelf) []string { + var problems []string + for _, module := range shelfOrder(shelf) { + m := shelf[module] + if m.RunsAs != "" { + continue + } + for _, c := range m.Consumes { + emitter, rest, named := strings.Cut(c, ".") + provider, known := shelf[emitter] + if !named || emitter == catalogue.OneName || emitter == catalogue.TheRest || !known { + continue + } + hearing, _ := provider.StreamsHearing(rest) + for _, s := range hearing { + if s.Grant == nil { + continue + } + problems = append(problems, fmt.Sprintf( + "%s consumes %s, which %s's restricted stream %s carries, so %s must run as an account of its "+ + "own (runs-as): the machine's runtime runs as the operator's account, which every agent "+ + "runs as, and never hears a restricted stream (novox/hq ADR 0376)", + module, c, emitter, s.BusName(), module)) + } + } + } + return problems +} + +// StreamNameProblems are the rules about stream names no single manifest can be judged against: two +// modules declaring one name. +func StreamNameProblems(shelf catalogue.Shelf) []string { + var problems []string + declaredBy := map[string]string{} + for _, module := range shelfOrder(shelf) { + for _, s := range shelf[module].Streams { + name := s.BusName() + if first, taken := declaredBy[name]; taken && first != module { + problems = append(problems, fmt.Sprintf( + "%s declares the stream %s, which %s already declares; a stream has one owner (novox/hq ADR 0363)", + module, name, first)) + continue + } + declaredBy[name] = module + } + } + return problems +} + +// StreamProblems is what is wrong with a manifest's streams, judged on the one manifest. +func StreamProblems(m catalogue.Manifest) []string { + if len(m.Streams) == 0 { + return nil + } + var problems []string + if !stateName.MatchString(m.Module) { + problems = append(problems, fmt.Sprintf( + "%s declares a stream, and a module's name is part of its stream's subjects, which take one plain "+ + "name — no dot (novox/hq ADR 0363)", m.Module)) + } + seen := map[string]bool{} + terminal := catalogue.TerminalKeys(m) + for i, s := range m.Streams { + switch { + case !stateName.MatchString(s.Name): + problems = append(problems, fmt.Sprintf( + "%s declares stream %q: a stream is named locally — lower-case letters, digits and hyphens "+ + "(novox/hq ADR 0363)", m.Module, s.Name)) + case seen[s.BusName()]: + problems = append(problems, fmt.Sprintf("%s declares stream %q twice", m.Module, s.Name)) + case slices.Contains(catalogue.MeshStreamNames, s.BusName()): + problems = append(problems, fmt.Sprintf( + "%s declares stream %q, which is the mesh's own stream %s (novox/hq ADR 0363)", m.Module, s.Name, s.BusName())) + default: + for _, prefix := range catalogue.ReservedStreamPrefixes { + if strings.HasPrefix(s.BusName(), prefix) { + problems = append(problems, fmt.Sprintf( + "%s declares stream %q, whose name on the bus starts with %s, which the mesh derives names "+ + "with (novox/hq ADR 0363)", m.Module, s.Name, prefix)) + } + } + } + seen[s.BusName()] = true + if s.MaxAgeDays != 0 && (s.MaxAgeDays < catalogue.StreamLeastDays || s.MaxAgeDays > catalogue.StreamMostDays) { + problems = append(problems, fmt.Sprintf("%s keeps stream %q %d days; a stream keeps between %d and %d "+ + "(novox/hq ADR 0363)", m.Module, s.Name, s.MaxAgeDays, catalogue.StreamLeastDays, catalogue.StreamMostDays)) + } + if s.MaxMiB != 0 && (s.MaxMiB < catalogue.StreamLeastMiB || s.MaxMiB > catalogue.StreamMostMiB) { + problems = append(problems, fmt.Sprintf("%s caps stream %q at %d MiB; a stream holds between %d and %d "+ + "(novox/hq ADR 0363)", m.Module, s.Name, s.MaxMiB, catalogue.StreamLeastMiB, catalogue.StreamMostMiB)) + } + // A stream may declare retention: work-queue (novox/hq ADR 0436 §6): a message removed once a + // consumer acknowledges it, the bus refusing a new one when full rather than dropping its oldest, with + // max-deliveries (1 to 10) and a duplicate window (duplicates, hours, at most its maximum age) -- both + // meaningless, and refused, without it. A work-queue stream is read by its one consumer, never granted. + switch s.Retention { + case "", catalogue.StreamRetentionWorkQueue: + default: + problems = append(problems, fmt.Sprintf( + "%s's stream %q declares retention %q; a stream's retention is %q or nothing (novox/hq ADR 0436 §6)", + m.Module, s.Name, s.Retention, catalogue.StreamRetentionWorkQueue)) + } + switch { + case s.Retention == catalogue.StreamRetentionWorkQueue && + (s.MaxDeliveries < catalogue.StreamLeastDeliveries || s.MaxDeliveries > catalogue.StreamMostDeliveries): + problems = append(problems, fmt.Sprintf( + "%s's stream %q declares max-deliveries %d; a work-queue stream's max-deliveries is between %d "+ + "and %d (novox/hq ADR 0436 §6)", m.Module, s.Name, s.MaxDeliveries, catalogue.StreamLeastDeliveries, + catalogue.StreamMostDeliveries)) + case s.Retention != catalogue.StreamRetentionWorkQueue && s.MaxDeliveries != 0: + problems = append(problems, fmt.Sprintf( + "%s's stream %q declares max-deliveries %d without retention %q (novox/hq ADR 0436 §6)", + m.Module, s.Name, s.MaxDeliveries, catalogue.StreamRetentionWorkQueue)) + } + switch { + case s.Duplicates != 0 && s.Retention != catalogue.StreamRetentionWorkQueue: + problems = append(problems, fmt.Sprintf( + "%s's stream %q declares duplicates %d hours without retention %q (novox/hq ADR 0436 §6)", + m.Module, s.Name, s.Duplicates, catalogue.StreamRetentionWorkQueue)) + case s.Duplicates != 0 && s.Duplicates > s.Days()*24: + problems = append(problems, fmt.Sprintf( + "%s's stream %q keeps duplicates %d hours, longer than its maximum age of %d days (novox/hq "+ + "ADR 0436 §6)", m.Module, s.Name, s.Duplicates, s.Days())) + } + if s.Retention == catalogue.StreamRetentionWorkQueue && s.Grant != nil { + problems = append(problems, fmt.Sprintf( + "%s's stream %q declares retention %q carrying a grant; a work-queue stream is read by its "+ + "module's one consumer, never granted (novox/hq ADR 0436 §6)", m.Module, s.Name, s.Retention)) + } + if len(s.Events) == 0 { + problems = append(problems, fmt.Sprintf("%s declares stream %q with no events", m.Module, s.Name)) + } + for _, e := range s.Events { + if err := eventPattern(e, false); err != nil { + problems = append(problems, fmt.Sprintf("%s's stream %q carries %q: %v", m.Module, s.Name, e, err)) + } + } + // **An event goes to one place** (ADR 0363 §2): an emitted event a stream carries, or two streams + // carrying one event, would leave the module's runtime to choose. + for _, e := range m.EmitsAll() { + if s.Carries(e) { + problems = append(problems, fmt.Sprintf( + "%s emits %q, which its stream %q carries: an event goes to EVENTS or to the module's stream, "+ + "so name it in one place (novox/hq ADR 0363)", m.Module, e, s.Name)) + } + } + for _, other := range m.Streams[i+1:] { + for _, a := range s.Events { + for _, b := range other.Events { + if patternsOverlap(a, b) { + problems = append(problems, fmt.Sprintf( + "%s's streams %q and %q both carry %q and %q: an event goes to one stream (novox/hq "+ + "ADR 0363)", m.Module, s.Name, other.Name, a, b)) + } + } + } + } + if g := s.Grant; g != nil { + if !slices.Contains(terminal, g.Config) { + problems = append(problems, fmt.Sprintf( + "%s's stream %q is granted by %q, which is not a trusted config value of the module, set only at "+ + "the controller's terminal or on the operator's Approve: a grant of what a module hears is the "+ + "operator's (novox/hq ADR 0348 §8, ADR 0363)", m.Module, s.Name, g.Config)) + } + if strings.Count(g.Events, catalogue.GrantKey) != 1 { + problems = append(problems, fmt.Sprintf( + "%s's stream %q grants %q, which must hold %s exactly once, as a name of its own (novox/hq ADR 0363)", + m.Module, s.Name, g.Events, catalogue.GrantKey)) + } else if err := eventPattern(g.Events, true); err != nil { + problems = append(problems, fmt.Sprintf("%s's stream %q grants %q: %v", m.Module, s.Name, g.Events, err)) + } + } + } + return problems +} + +// WorkQueueConsumedElsewhereProblems refuses another module's `consumes` naming a work-queue stream's events +// (novox/hq ADR 0436 §6): read by its module's one process alone, never broadcast to a consumer that only +// subscribes. Modelled on RestrictedHearingProblems. +func WorkQueueConsumedElsewhereProblems(shelf catalogue.Shelf) []string { + var problems []string + for _, module := range shelfOrder(shelf) { + m := shelf[module] + for _, c := range m.Consumes { + emitter, rest, named := strings.Cut(c, ".") + provider, known := shelf[emitter] + if !named || emitter == catalogue.OneName || emitter == catalogue.TheRest || !known || emitter == module { + continue + } + hearing, _ := provider.StreamsHearing(rest) + for _, s := range hearing { + if s.Retention != catalogue.StreamRetentionWorkQueue { + continue + } + problems = append(problems, fmt.Sprintf( + "%s consumes %s, which %s's work-queue stream %s carries; a work-queue stream is read by "+ + "its module's one process alone, never by another module's consumes (novox/hq ADR 0436 "+ + "§6)", module, c, emitter, s.BusName())) + } + } + } + return problems +} + +// WorkQueueStreamProblems refuses a work-queue stream (novox/hq ADR 0436 §6) that is not read by exactly +// one process of its own module: named by none, it queues forever unconsumed; named by two, each would take +// half of what the other needed, silently. +func WorkQueueStreamProblems(m catalogue.Manifest) []string { + var problems []string + named := map[string]int{} + for _, b := range m.ProcessBuses() { + for _, q := range b.Queues { + named[q]++ + } + } + for _, s := range m.Streams { + if s.Retention != catalogue.StreamRetentionWorkQueue { + continue + } + switch named[s.Name] { + case 0: + problems = append(problems, fmt.Sprintf( + "%s's work-queue stream %q: no process names it in bus.queues; a work-queue stream is read by "+ + "exactly one process (novox/hq ADR 0436 §6)", m.Module, s.Name)) + case 1: + default: + problems = append(problems, fmt.Sprintf( + "%s's work-queue stream %q: %d processes name it in bus.queues; a work-queue stream is read by "+ + "exactly one (novox/hq ADR 0436 §6)", m.Module, s.Name, named[s.Name])) + } + } + return problems +} + +// eventPattern checks one local event pattern: names, `*` for one, `**` last for the rest; with key, +// GrantKey is a name too. +func eventPattern(pattern string, key bool) error { + if pattern == "" { + return fmt.Errorf("a pattern needs a name") + } + parts := strings.Split(pattern, ".") + for i, part := range parts { + switch { + case part == catalogue.TheRest: + if i != len(parts)-1 { + return fmt.Errorf("%q stands for the rest of a name, so nothing may follow it", catalogue.TheRest) + } + case part == catalogue.OneName: + case key && part == catalogue.GrantKey: + case !eventName.MatchString(part): + return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part) + } + } + return nil +} + +// templateProblems is what is wrong with the manifest's templates, as written: a template on something not a file, +// beside content, or outside the module's templates directory; a templates directory that is not one, or that no +// template is in; or a module naming templates whose build packs no archive from that directory. Whether each +// template exists is judged where a tree is (ReadTemplates) or the archive's text is (Resolve). +func templateProblems(m catalogue.Manifest) []string { + var problems []string + templated := m.Templated() + if wrong := templatesDirProblem(m, templated); wrong != "" { + return append(problems, wrong) + } + dir := m.TemplatesDir() + for _, r := range m.Resources { + if _, _, wrong := templateOf(m.Module, dir, r); wrong != "" { + problems = append(problems, wrong) + } + } + if !templated { + return problems + } + switch found := templatesArtifacts(m); len(found) { + case 0: + problems = append(problems, fmt.Sprintf("%s names templates and its build packs no archive from %s/: "+ + "declare an artifact {\"name\": %q, \"kind\": \"archive\", \"from\": %q} (novox/hq ADR 0349 §2)", + m.Module, dir, path.Base(dir), dir)) + case 1: + default: + sort.Strings(found) + problems = append(problems, fmt.Sprintf("%s packs %s/ into %s; its templates are read from one archive", + m.Module, dir, strings.Join(found, " and "))) + } + return problems +} + +// templatesDirProblem is what is wrong with the templates directory a manifest names, or "": one directory of the +// module's own, a plain relative path inside it — never absolute, never leaving it, never the module's directory +// itself — and named only by a module that names a template, since a word read by nothing is refused. +func templatesDirProblem(m catalogue.Manifest, templated bool) string { + d := m.Templates + if d == "" { + return "" + } + if strings.HasPrefix(d, "/") || strings.Contains(d, "\\") || path.Clean(d) != d || d == "." || d == ".." || + strings.HasPrefix(d, "../") { + return fmt.Sprintf("%s names the templates directory %q, which is not a directory of its own: a plain "+ + "relative path inside the module's directory, such as \"templates\" (issue 731)", m.Module, d) + } + if !templated { + return fmt.Sprintf("%s names the templates directory %q and names no template: a file resource names its "+ + "template as %q, or the manifest says no templates directory", m.Module, d, d+"/") + } + return "" +} + +// TrustProblems are the ways a manifest states `trusted` wrongly: anything but true or false, or on anything but +// a file. Refused at registration and by `module check`. +func TrustProblems(m catalogue.Manifest) []string { + var out []string + for _, r := range m.Resources { + v, said := r[catalogue.TrustedField] + if !said { + continue + } + if fmt.Sprint(r["type"]) != "file" { + out = append(out, fmt.Sprintf("%s: %v is a %v and says %q; only a file says whether the settings it "+ + "asks for are trusted (novox/hq issue 339)", m.Module, r["id"], r["type"], catalogue.TrustedField)) + continue + } + if _, ok := v.(bool); !ok { + out = append(out, fmt.Sprintf("%s: %v says %q as %v; it is true or false (novox/hq issue 339)", + m.Module, r["id"], catalogue.TrustedField, v)) + } + } + return out +} + +// unhealthyProblems refuses an `unhealthy` without a why, and any severity but urgent or warning (novox/hq ADR +// 0355 §2). +func manifestUnhealthyProblems(m catalogue.Manifest) []string { + if m.Unhealthy == nil { + return nil + } + var problems []string + switch m.Unhealthy.Severity { + case catalogue.UnhealthyUrgent, catalogue.UnhealthyWarning: + default: + problems = append(problems, fmt.Sprintf("unhealthy is %q or %q, not %q", catalogue.UnhealthyUrgent, catalogue.UnhealthyWarning, + m.Unhealthy.Severity)) + } + if strings.TrimSpace(m.Unhealthy.Why) == "" { + problems = append(problems, "unhealthy says why: a module that declares how loud its failure is names "+ + "what people lose while it is broken") + } + return problems +} + +func manifestUpgradeProblems(m catalogue.Manifest) []string { + if m.Upgrade == nil { + return nil + } + switch m.Upgrade.Policy { + case catalogue.PolicyRoll: + case catalogue.PolicyTogether, catalogue.PolicyRecord: + if strings.TrimSpace(m.Upgrade.Why) == "" { + return []string{fmt.Sprintf("upgrade %q says why: a module that does not roll out one machine first "+ + "names the reason a person reads", m.Upgrade.Policy)} + } + default: + return []string{fmt.Sprintf("upgrade is %q, %q or %q, not %q", catalogue.PolicyRoll, catalogue.PolicyTogether, catalogue.PolicyRecord, + m.Upgrade.Policy)} + } + return nil +} + +// zoneProblems is what is wrong with a module's zone declaration on its own, before any node. +func zoneProblems(m catalogue.Manifest) []string { + if m.Zone == nil { + return nil + } + var problems []string + if strings.TrimSpace(m.Zone.Name) == "" { + problems = append(problems, fmt.Sprintf("%s declares a zone with no name", m.Module)) + } + if !manifestHasListen(m, m.Zone.Listen) { + problems = append(problems, fmt.Sprintf( + "%s declares zone %q answered by listen %q, and has no listen of that name", + m.Module, m.Zone.Name, m.Zone.Listen)) + } + return problems +} + +func manifestHasListen(m catalogue.Manifest, name string) bool { + if name == "" { + return false + } + for _, l := range m.Listens { + if l.Name == name { + return true + } + } + return false +} + +// ---- copied from catalogue: helpers composition keeps too ---- + +// sortedKeys is map iteration made repeatable, which everything written to a machine needs. +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 +} + +var ofPort = regexp.MustCompile(`\$\{port:([0-9]+)\}`) + +var dirRef = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9-]*)\}`) + +// runByAResource is the artifacts one of a module's own resources names — a process that runs it, +// a step, an archive that unpacks it — by name. +func runByAResource(m catalogue.Manifest) map[string]bool { + named := map[string]bool{} + for _, r := range m.Resources { + if a, ok := r["artifact"].(string); ok && a != "" { + named[a] = true + } + } + return named +} + +// compilesToABinary is whether this language's bundle is a binary for one operating system rather +// than code that runs wherever its interpreter does. +// +// **Asked of the language, not of the artifact.** A module says what it is written in; what that +// implies is the mesh's to know, exactly as the compiler is (novox/hq ADR 0142). Asking the artifact +// would let two artifacts in one language disagree about whether they are portable. +func compilesToABinary(language string) bool { + switch strings.ToLower(strings.TrimSpace(language)) { + case "go": + return true + default: + return false + } +} + +var attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + +var attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`) + +// oldCollection is an old namespace's collection by its name: its comment, or why no item may be contributed to it. +// ok is false for a name that is not an old namespace's. +// +// forItem is whether an item is to be contributed to it: an item may be contributed to a kind received as data, +// which a collector may place as a collection only where its shape says its pieces are text (design 57 L3). +func oldCollection(name string, forItem bool) (comment string, old bool, wrong error) { + namespace, key, _ := strings.Cut(name, ":") + if !slices.Contains(oldCollectionNamespaces, namespace) { + return "", false, nil + } + switch namespace { + case "shell": + target, slot, two := strings.Cut(key, ":") + if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) { + return "", true, fmt.Errorf("%s is no slot: shell code is shell::, the shell one of %s, "+ + "the session's file one of %s or the power moment one of %s, and the slot one of %s", name, + strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "), + strings.Join(knownSlots, ", ")) + } + // shellCode's line before each module's code. + return "#", true, nil + case "environment": + if key == "" { + // The collection itself, which an item is contributed to; a placeholder names a rendering. + return "#", true, nil + } + if key != catalogue.EnvironmentPOSIX && key != catalogue.EnvironmentSystemd { + return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s", + name, catalogue.EnvironmentPOSIX, catalogue.EnvironmentSystemd) + } + return "#", true, nil + } + seat, kind, _ := strings.Cut(key, ":") + s, r, ok := receivable(seat, kind) + switch { + case !ok && s.Name == "": + return "", true, fmt.Errorf("%s names the seat %q, which the mesh does not define", name, seat) + case !ok: + return "", true, fmt.Errorf("%s: %s; %s", name, s.Name, kindsOf(s)) + case r.Shape != nil && (forItem || collectableAsText(r)): + return r.Comment, true, nil + case r.Shape != nil: + return "", true, fmt.Errorf("%s is received by %s as data in its own shape, which no item of text is "+ + "(novox/hq ADR 0255)", name, s.Name) + case r.Dirs: + return "", true, fmt.Errorf("%s names its contributors' directories, which the holder reads as paths "+ + "the controller fills; it is not yet a collection (design 57 L1)", name) + case s.Name != canonicalSeat(seat): + return "", true, fmt.Errorf("%s names the seat by another name; a collection names it %s", name, s.Name) + } + return r.Comment, true, nil +} + +var ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`) + +// splitSelector is a placeholder's name without its selector, and the selector: `widgets:bar=top,place=left` is +// `widgets` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon +// before the first one. +func splitSelector(name string) (collection, selector string) { + eq := strings.Index(name, "=") + if eq < 0 { + return name, "" + } + colon := strings.LastIndex(name[:eq], ":") + if colon < 0 { + return name, "" + } + return name[:colon], name[colon+1:] +} + +func itemKey(it catalogue.Item) string { return catalogue.ItemKey(it.Collection, it.Name) } + +// itemTemplateProblem is what is wrong with an item's template path, or "". +func itemTemplateProblem(m catalogue.Manifest, name string) string { + dir := m.TemplatesDir() + if strings.HasPrefix(name, "/") || path.Clean(name) != name || !strings.HasPrefix(name, dir+"/") || + strings.Contains(name, "\\") { + return fmt.Sprintf("the template %q is not a file under %s/ of its own repository: a template is %q, a "+ + "plain relative path, and the build packs only %s/", name, dir, dir+"/", dir) + } + return "" +} + +// receivable is what a seat receives of a kind, with the seat's canonical name; false when the seat +// is unknown or does not receive it. +func receivable(seat, kind string) (catalogue.Seat, catalogue.Receivable, bool) { + s, known := catalogue.SeatNamed(seat) + if !known { + return catalogue.Seat{}, catalogue.Receivable{}, false + } + for _, r := range s.Receives { + if r.Kind == kind { + return s, r, true + } + } + return s, catalogue.Receivable{}, false +} + +func placeFields(r catalogue.Receivable) string { + var names []string + for _, p := range r.Shape.Places { + names = append(names, p.Field) + } + return strings.Join(names, ", ") +} + +func oneOf(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +var templatePlaceholder = regexp.MustCompile(`\$\{([a-z][a-z-]*):([^}\s$]+)\}`) + +// collectionOfPlaceholder is the collection a placeholder's namespace and key name, and whether they name one. +func collectionOfPlaceholder(namespace, key string) (string, bool) { + key, _ = splitSelector(key) + switch { + case namespace == catalogue.CollectedNamespace && strings.HasPrefix(key, catalogue.EnvironmentCollection+":"): + // The environment is one collection, and the rest of the name picks the rendering (issue 795). + return catalogue.EnvironmentCollection, true + case namespace == catalogue.CollectedNamespace: + return key, true + case namespace == "environment": + return catalogue.EnvironmentCollection, true + case slices.Contains(oldCollectionNamespaces, namespace): + return namespace + ":" + key, true + } + return "", false +} + +// unconsumedPlaceholders is every placeholder in one resource of a definition that no pass fills +// where it stands, each named with the module, the resource, the field and the token. The same +// function at the catalogue check and at composition, over the resource as the manifest wrote it. +func unconsumedPlaceholders(module string, r map[string]any) []string { + kind := fmt.Sprint(r["type"]) + var problems []string + var sweep func(top, field string, v any) + sweep = func(top, field string, v any) { + switch v := v.(type) { + case string: + rest := v + for _, f := range fillers { + for _, p := range f.patterns { + if !fillerReads(f, kind, top) { + for _, token := range p.FindAllString(rest, -1) { + problems = append(problems, fmt.Sprintf( + "%s's resource %v holds %s in its %s, and ${%s:…} is not filled in this field: "+ + "it would reach the machine as that text (novox/hq issue 231)%s", + module, r["id"], token, field, f.namespace, whereFilled(f))) + } + } + rest = p.ReplaceAllString(rest, "") + } + } + for _, token := range ofKnownNamespace.FindAllString(rest, -1) { + problems = append(problems, fmt.Sprintf( + "%s's resource %v holds %s in its %s, which is no placeholder the mesh fills: a "+ + "misspelt key, or a namespace in the wrong case, would reach the machine as that "+ + "text (novox/hq issue 231)", module, r["id"], token, field)) + } + rest = ofKnownNamespace.ReplaceAllString(rest, "") + for _, token := range namespaceShaped.FindAllString(rest, -1) { + problems = append(problems, fmt.Sprintf( + "%s's resource %v holds %s in its %s, and no pass of the mesh fills it: a misspelt "+ + "placeholder would reach the machine as that text (novox/hq issue 231). The mesh "+ + "fills ${%s:…}; the shell's own syntax belongs in the module's shell code (ADR 0204)", + module, r["id"], token, field, strings.Join(namespacesOf(fillers), ":…}, ${"))) + } + case []any: + for i, e := range v { + sweep(top, fmt.Sprintf("%s[%d]", field, i), e) + } + case map[string]any: + for _, k := range sortedKeys(v) { + sweep(top, field+"."+k, v[k]) + } + } + } + for _, k := range sortedKeys(r) { + sweep(k, k, r[k]) + } + return problems +} + +// renamedTaken is every value a mergeable file takes under a name of its own, with that name (issue 751). +func renamedTaken(m catalogue.Manifest) map[string]string { + out := map[string]string{} + for _, r := range m.Resources { + if how, _ := r["merge"].(string); how == "" { + continue + } + for _, e := range takenEntries(r[catalogue.TakesSettingsField]) { + if e.renamed { + out[e.key] = e.as + } + } + } + return out +} + +var settingRef = regexp.MustCompile(`\$\{setting:([a-z0-9][a-z0-9_.-]*)\}`) + +var meshWords = map[string]bool{ + catalogue.PortsSetting: true, catalogue.ExposeSetting: true, catalogue.ReachSetting: true, catalogue.EndpointsSetting: true, + catalogue.PlacesSetting: true, catalogue.AccessesSetting: true, catalogue.NetworksSetting: true, +} + +var accountFacts = map[string]bool{"puid": true, "pgid": true} + +// validationProblems is what is wrong with a declaration's validation, and its default against it. +func validationProblems(d catalogue.SettingDeclaration) []string { + var problems []string + isNumber := d.Type == catalogue.ConfigNumber + if len(d.Enum) > 0 && d.Type != catalogue.ConfigNumber && d.Type != catalogue.ConfigString { + problems = append(problems, fmt.Sprintf("an enum is for a number or a string, and this is a %s", d.Type)) + } + if (d.Minimum != nil || d.Maximum != nil) && !isNumber { + problems = append(problems, fmt.Sprintf("a minimum or maximum is for a number, and this is a %s", d.Type)) + } + if d.Minimum != nil && d.Maximum != nil && *d.Minimum > *d.Maximum { + problems = append(problems, fmt.Sprintf("its minimum %v is above its maximum %v", *d.Minimum, *d.Maximum)) + } + var pattern *regexp.Regexp + if d.Pattern != "" { + if d.Type != catalogue.ConfigString { + problems = append(problems, fmt.Sprintf("a pattern is for a string, and this is a %s", d.Type)) + } + var err error + if pattern, err = regexp.Compile(`^(?:` + d.Pattern + `)$`); err != nil { + problems = append(problems, fmt.Sprintf("its pattern %q is not a regular expression: %v", d.Pattern, err)) + } + } + for _, v := range d.Enum { + if !ofType(v, d.Type) { + problems = append(problems, fmt.Sprintf("its enum holds %v, which is not a %s", v, d.Type)) + } + } + if d.Default == nil { + return problems + } + if !ofType(d.Default, d.Type) { + if d.Type != "" { + problems = append(problems, fmt.Sprintf("its default %v is not a %s", d.Default, d.Type)) + } + return problems + } + if len(d.Enum) > 0 && !slices.ContainsFunc(d.Enum, func(v any) bool { return v == d.Default }) { + problems = append(problems, fmt.Sprintf("its default %v is not one of %v", d.Default, d.Enum)) + } + if n, ok := d.Default.(float64); ok { + if d.Minimum != nil && n < *d.Minimum { + problems = append(problems, fmt.Sprintf("its default %v is below its minimum %v", n, *d.Minimum)) + } + if d.Maximum != nil && n > *d.Maximum { + problems = append(problems, fmt.Sprintf("its default %v is above its maximum %v", n, *d.Maximum)) + } + } + if s, ok := d.Default.(string); ok && pattern != nil && !pattern.MatchString(s) { + problems = append(problems, fmt.Sprintf("its default %q does not match its pattern %q", s, d.Pattern)) + } + return problems +} + +// settingKeysUsedBy is every key a module's files, units, directory owners, contributions and served +// facts ask for, so a setting that lands in one is not called stray. +func settingKeysUsedBy(m catalogue.Manifest) map[string]bool { + used := settingKeysAskedOutsideOwners(m) + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "directory" { + if owner, ok := r["owner"].(string); ok { + for _, k := range settingsUsed(owner) { + used[k] = true + } + } + } + } + return used +} + +// takenKeyList is the keys a resource takes, in order: the config keys, never the file's names for them. +func takenKeyList(resource map[string]any) []string { + var keys []string + for _, e := range takenEntries(resource[catalogue.TakesSettingsField]) { + keys = append(keys, e.key) + } + return keys +} + +func stringsOf(v any) []string { + raw, ok := v.([]any) + if !ok { + return nil + } + var out []string + for _, item := range raw { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out +} + +func sortedServes(serves map[string]map[string]any) []string { + out := make([]string, 0, len(serves)) + for k := range serves { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func sortedAnyKeys(values map[string]any) []string { + out := make([]string, 0, len(values)) + for k := range values { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// consumerInto replaces every `${consumer:…}` in one value. +// +// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through, +// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name, +// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}` +// is refused by (boundInto). +func consumerInto(value, as string) (string, error) { + var failed error + out := consumerFact.ReplaceAllStringFunc(value, func(match string) string { + parts := consumerFact.FindStringSubmatch(match) + fact, alphabet := parts[1], parts[2] + if fact != "as" { + if failed == nil { + failed = fmt.Errorf( + "says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts)) + } + return match + } + switch alphabet { + case "": + return as + case "dns": + return asDNSLabel(as) + default: + if failed == nil { + failed = fmt.Errorf( + "says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets)) + } + return match + } + }) + if failed != nil { + return "", failed + } + return out, nil +} + +var classRank = map[string]int{catalogue.ClassNone: 0, catalogue.ClassCache: 1, catalogue.ClassRebuildable: 2, catalogue.ClassValuable: 3, catalogue.ClassIrreplaceable: 4} + +// keepsByClass is whether a class keeps something a binding must not move away from. +func keepsByClass(class string) bool { + return class == catalogue.ClassIrreplaceable || class == catalogue.ClassValuable || class == catalogue.ClassRebuildable +} + +var volumeMount = regexp.MustCompile(`^(\$\{(?:dir|access):[a-z0-9][a-z0-9-]*\}[^:]*|[^:]+):([^:]+)(?::(.*))?$`) + +var dirPlain = regexp.MustCompile(`^\$\{dir:([a-z0-9][a-z0-9-]*)\}$`) + +// dirFill resolves every ${dir:…} in one string, or refuses a reference naming no directory. +func dirFill(s string, dirs map[string]string, module string) (string, error) { + var missing error + out := dirRef.ReplaceAllStringFunc(s, func(ref string) string { + id := dirRef.FindStringSubmatch(ref)[1] + path, has := dirs[id] + if !has { + missing = fmt.Errorf( + "%s says ${dir:%s}, and %s declares no directory %q. It declares %s", + module, id, module, id, orNothing(namesOfDirs(dirs))) + return ref + } + return path + }) + return out, missing +} + +const placeOwn = "." + +const placeMesh = "mesh" + +// placeholderProblems is what is wrong with one resource's ${environment:…} and ${shell:…}. +// +// **The seat authorises it, not the placeholder** (novox/hq ADR 0203 §5, ADR 0204 §3), as the seat +// authorises the bus's user list: a module that does not hold the account's environment writing it +// would be a second writer of a file there is one of, and a module that does not hold the login +// shell writing every module's shell code would be a second shell. +func placeholderProblems(m catalogue.Manifest, r map[string]any) []string { + var problems []string + for _, field := range sortedKeys(r) { + s, ok := r[field].(string) + if !ok { + continue + } + env := ofEnvironment.FindAllStringSubmatch(s, -1) + code := ofShell.FindAllStringSubmatch(s, -1) + if len(env)+len(code) == 0 { + continue + } + if field != "content" { + // Placed only where a file's bytes are, which is where every one of them is meant to go: + // a path or an owner holding several lines of shell is nothing the host could act on. + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s in its %s; the environment and the shell's code are placed "+ + "only in a file's content", m.Module, r["id"], placeholderOf(env, code), field)) + continue + } + for _, e := range env { + if e[1] != catalogue.EnvironmentPOSIX && e[1] != catalogue.EnvironmentSystemd { + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s; the environment is ${environment:%s} or ${environment:%s}", + m.Module, r["id"], e[0], catalogue.EnvironmentPOSIX, catalogue.EnvironmentSystemd)) + } + } + if len(env) > 0 && !m.ClaimsSeat(catalogue.EnvironmentSeat) { + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s and %s does not claim %s; the account's environment is "+ + "written by that seat's holder alone (novox/hq ADR 0203)", + m.Module, r["id"], env[0][0], m.Module, catalogue.EnvironmentSeat)) + } + // Each placeholder judged by its own target: a shell's code is the login shell's holder's to + // place (ADR 0204), the session's files the display server's (ADR 0208 §4) — and a holder of + // one placing the other's would be a second writer of a file there is one of. + refusedFor := map[string]bool{} + for _, c := range code { + target, slot, two := strings.Cut(c[1], ":") + if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) { + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s; shell code is ${shell::}, the shell one of "+ + "%s, the session's file one of %s or the power moment one of %s, and the slot one of %s", + m.Module, r["id"], c[0], strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), + strings.Join(powerMoments, ", "), strings.Join(knownSlots, ", "))) + continue + } + seat := placerOf(target) + if m.ClaimsSeat(seat) || refusedFor[seat] { + continue + } + refusedFor[seat] = true + if seat == catalogue.PowerSeat { + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s and %s does not claim %s; every module's code for a power "+ + "moment is placed by the power seat's holder alone (novox/hq ADR 0211)", + m.Module, r["id"], c[0], m.Module, seat)) + continue + } + if seat == catalogue.DisplayServerSeat { + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s and %s does not claim %s; every module's %s is placed by "+ + "the display server's holder alone (novox/hq ADR 0208)", + m.Module, r["id"], c[0], m.Module, seat, target)) + continue + } + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s and %s does not claim %s; every module's shell code is "+ + "placed by the login shell's holder alone (novox/hq ADR 0204)", + m.Module, r["id"], c[0], m.Module, seat)) + } + } + return problems +} + +// seatPlaceholderProblems is what is wrong with one resource's ${contribution:…}: placed only in a +// file's content, naming a seat and a kind it receives, and only by a module that claims that seat +// — another would be a second writer of a file there is one of (novox/hq ADR 0212 §3). +func seatPlaceholderProblems(m catalogue.Manifest, r map[string]any) []string { + var problems []string + for _, field := range sortedKeys(r) { + v, ok := r[field].(string) + if !ok { + continue + } + found := ofContribution.FindAllStringSubmatch(v, -1) + if len(found) == 0 { + continue + } + if field != "content" { + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s in its %s; contributions are placed only in a file's content", + m.Module, r["id"], found[0][0], field)) + continue + } + for _, f := range found { + seat, rest, two := strings.Cut(f[1], ":") + // A kind received as data may be narrowed by its shape's placing fields (novox/hq ADR 0255). + kind, where, _ := strings.Cut(rest, ":") + s, rcv, ok := receivable(seat, kind) + if two && ok { + if _, err := placeholderWhere(rcv, where); err != nil { + problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err)) + } + } + if !two || !ok { + detail := "the mesh defines no seat " + fmt.Sprintf("%q", seat) + if s.Name != "" { + detail = s.Name + ": " + kindsOf(s) + } + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s; a contribution is ${contribution::} (%s)", + m.Module, r["id"], f[0], detail)) + continue + } + if !m.ClaimsSeat(s.Name) { + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s and %s does not claim %s; every module's contributions to a "+ + "seat are placed by its holder alone (novox/hq ADR 0212)", + m.Module, r["id"], f[0], m.Module, s.Name)) + } + } + } + return problems +} + +// collectedPlaceholderProblems is what is wrong with one resource's ${collected:…}: placed only in a file's content, +// and naming a collection the module declares it collects. +func collectedPlaceholderProblems(m catalogue.Manifest, r map[string]any) []string { + var problems []string + for _, field := range sortedKeys(r) { + v, ok := r[field].(string) + if !ok { + continue + } + for _, f := range ofCollected.FindAllStringSubmatch(v, -1) { + base, selector := splitSelector(f[1]) + if _, err := selectorOf(selector); err != nil { + problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err)) + continue + } + switch { + case field != "content": + problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a collection is placed "+ + "only in a file's content", m.Module, r["id"], f[0], field)) + case isOld(base): + if _, _, wrong := oldCollection(base, false); wrong != nil { + problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], wrong)) + } + case !slices.ContainsFunc(m.Collections, func(c catalogue.Collection) bool { return c.Name == base }): + problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not collect %q: "+ + "declare it under collections (novox/hq ADR 0349 §13)", m.Module, r["id"], f[0], m.Module, base)) + } + } + } + return problems +} + +// consumersPlaceholderProblems is what is wrong with one resource's ${consumers:…}: placed only in a file's content, +// for a provision the module provides, in a file only the provider reads and the client writes back. +func consumersPlaceholderProblems(m catalogue.Manifest, r map[string]any) []string { + var problems []string + for _, field := range sortedKeys(r) { + v, ok := r[field].(string) + if !ok { + continue + } + for _, f := range ofConsumers.FindAllStringSubmatch(v, -1) { + switch { + case field != "content" || fmt.Sprint(r["type"]) != "file": + problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a provider's consumers "+ + "are placed only in a file's content (novox/hq ADR 0373)", m.Module, r["id"], f[0], field)) + continue + case !slices.ContainsFunc(m.Provides, func(o catalogue.Offer) bool { return o.Name == f[1] }): + problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not provide %q: only a "+ + "provider's template holds its consumers' credentials (novox/hq ADR 0373)", m.Module, r["id"], f[0], + m.Module, f[1])) + } + if mode := fmt.Sprint(r["mode"]); mode != "0600" && mode != "0400" { + problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) with "+ + "mode %q: the provider alone reads it, 0600 or 0400 (novox/hq ADR 0373)", m.Module, r["id"], f[0], + r["mode"])) + } + if owner := ownershipOf(r); owner != catalogue.RuntimeOwned { + problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) and is "+ + "%s at runtime: it is owned, written back whenever it differs (novox/hq ADR 0373)", m.Module, + r["id"], f[0], owner)) + } + } + } + return problems +} + +// literalProblem is why a value cannot be written, unquoted by either reader, as the same string in +// both formats — or nothing. A `$` would expand differently in each; a quote or a backslash is +// quoting in one and a character in the other; a line break ends the line in both. +func literalProblem(v string) string { + switch { + case strings.ContainsAny(v, `'"`): + return "holds a quote" + case strings.Contains(v, `\`): + return "holds a backslash" + case strings.ContainsAny(v, "\n\r"): + return "holds a line break" + case strings.ContainsRune(v, 0): + return "holds a NUL" + case strings.Contains(ofMachine.ReplaceAllString(v, ""), "$"): + return "holds a $ that is not one of the machine's ${machine:…} facts" + } + return "" +} + +const literalRule = "a value is literal, so a POSIX shell and the service manager read it alike, and " + + "names the machine only through the mesh's own ${machine:…} facts (novox/hq ADR 0203)" + +var ofMachine = regexp.MustCompile(`\$\{machine:([a-z0-9][a-z0-9_-]*)\}`) + +// contributionTargets is every name a contribution's `for` may take. +func contributionTargets() []string { + out := append(append([]string(nil), knownShells...), sessionFiles...) + return append(out, powerMoments...) +} + +var knownShells = []string{"zsh", "bash", "fish"} + +var sessionFiles = []string{"xinitrc", "xresources"} + +var powerMoments = []string{"after-boot", "before-sleep", "after-wake", "before-shutdown", "on-mains", "on-battery"} + +var knownSlots = []string{"first", "normal", "last"} + +var eventName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +var stateName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// preparationTarget is the resource a module's preparation runs before: its own workload. +// +// The first container carrying an artifact this module built, and not itself a step — that is the +// thing that runs the module's code, and therefore the thing whose state must be ready. Empty when +// the module prepares nothing, or when nothing it declares could run its code. +// +// **A module with two own workloads gates the first of them.** Five modules in the catalogue declare +// more than one container of their own, none of them preparing anything today. If one ever does and +// its second workload shares the state, the gate is in front of the first — stated here because the +// alternative is a field asking an author to restate what the mesh can see. +func preparationTarget(m catalogue.Manifest) string { + if !m.Prepares { + return "" + } + for _, r := range m.Resources { + // A container, or a process the host runs from a bundle the module built (novox/hq issue + // 213): the same program in the same context, hosted as a unit rather than a container. + kind := fmt.Sprint(r["type"]) + if (kind != "container" && kind != "process") || !ownArtifact(r, m.Module) { + continue + } + if once, _ := r["run-once"].(bool); once { + continue + } + if r["schedule"] != nil { + continue + } + return fmt.Sprint(r["id"]) + } + return "" +} + +var memberRest = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`) + +// optionalRequiresList is the stored form's list: the optional requirements, sorted. +func optionalRequiresList(m catalogue.Manifest) []string { + var out []string + for name, optional := range m.OptionalRequires { + if optional { + out = append(out, name) + } + } + sort.Strings(out) + return out +} + +var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)(?:\|default:([^}\n]+))?\}`) + +const engineModule = "mesh-host" + +// dirsFor is every placed directory of a module, id → the path it resolves to on this node. +// +// A pathless directory saying `"place": "."` is the assignment's own root, /; one +// saying `"place": "mesh"` is the mesh's directory for the module, /mesh/; one +// saying neither is //. At most one of each place makes sense; nothing enforces +// one, because two ids resolving to one path is a mistake the module's own files make visible +// immediately. +// +// A stated path may itself begin with a placed reference — `${dir:mesh-state}/state` — and is +// filled after the directories it can name are resolved; one level, because a directory beneath +// a placed one is the whole of what an adopted layout needs (issue 174's `state` and `out`). +func dirsFor(m catalogue.Manifest, with catalogue.Rendering) map[string]string { + dirs := map[string]string{} + var beneath []map[string]any + // The assignment's placement wins over both (novox/hq issue 153). Refused elsewhere when + // malformed; here an invalid setting simply places nothing. + placed, _ := catalogue.Places(m, with.Settings[m.Module]) + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "directory" { + continue + } + id := fmt.Sprint(r["id"]) + if p, said := placed[id]; said { + dirs[id] = p.Path + continue + } + if path, stated := r["path"].(string); stated && path != "" { + if strings.HasPrefix(path, "${dir:") { + beneath = append(beneath, r) + continue + } + dirs[id] = strings.TrimRight(path, "/") + continue + } + switch place, _ := r["place"].(string); place { + case placeOwn: + dirs[id] = dataRoot(with) + "/" + m.Module + case placeMesh: + dirs[id] = dataRoot(with) + "/mesh/" + m.Module + default: + dirs[id] = dataRoot(with) + "/" + m.Module + "/" + id + } + } + for _, r := range beneath { + path := strings.TrimRight(r["path"].(string), "/") + // A reference to no directory is left as written and refused where the resource is + // placed (dirInto), with the message that names what exists. + filled, _ := dirFill(path, dirs, m.Module) + dirs[fmt.Sprint(r["id"])] = filled + } + return dirs +} + +// accessFill resolves every ${access:…} in one string, or refuses a reference naming no access. +func accessFill(s string, accesses map[string]string, module string) (string, error) { + var missing error + out := accessRef.ReplaceAllStringFunc(s, func(ref string) string { + id := accessRef.FindStringSubmatch(ref)[1] + path, has := accesses[id] + if !has { + missing = fmt.Errorf( + "%s says ${access:%s}, and %s declares no access %q. It declares %s", + module, id, module, id, orNothing(namesOfAccessIDs(accesses))) + return ref + } + return path + }) + return out, missing +} + +const defaultDataRoot = "/var/lib" + +// processBusOf is the bus a process resource declares, and whether it declared one at all. +func processBusOf(r map[string]any) (bus catalogue.ProcessBus, present bool, err error) { + raw, present := r["bus"] + if !present { + return catalogue.ProcessBus{}, false, nil + } + body, err := json.Marshal(raw) + if err != nil { + return catalogue.ProcessBus{}, true, err + } + if err := json.Unmarshal(body, &bus); err != nil { + return catalogue.ProcessBus{}, true, err + } + return bus, true, nil +} + +// kindsOf names a seat's receivable kinds for a refusal. +func kindsOf(s catalogue.Seat) string { + if len(s.Receives) == 0 { + return "it receives no contributions" + } + var kinds []string + for _, r := range s.Receives { + kinds = append(kinds, r.Kind) + } + return "it receives " + strings.Join(kinds, ", ") +} + +var stateKeyName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +var ofContribution = regexp.MustCompile(`\$\{contribution:([^}]*)\}`) + +// wholeIn is v as a whole number within [lo, hi]; JSON gives every number as a float. +func wholeIn(v any, lo, hi int) (int, bool) { + f, ok := v.(float64) + if !ok { + if i, isInt := v.(int); isInt { + f, ok = float64(i), true + } + } + if !ok || f != math.Trunc(f) || f < float64(lo) || f > float64(hi) { + return 0, false + } + return int(f), true +} + +// holderTemplate is the holder's template for a kind, parsed. +func holderTemplate(holder catalogue.Manifest, seat catalogue.Seat, kind string) (*template.Template, error) { + for _, c := range holder.Claims { + if s, known := catalogue.SeatNamed(c.Name); !known || s.Name != seat.Name { + continue + } + text, has := c.Renders[kind] + if !has || strings.TrimSpace(text) == "" { + break + } + t, err := template.New(seat.Name + ":" + kind).Funcs(renderFuncs).Parse(text) + if err != nil { + return nil, fmt.Errorf("%s's template for %s:%s does not parse: %v", holder.Module, seat.Name, kind, err) + } + return t, nil + } + return nil, fmt.Errorf("%s places %s:%s, which is data, and its claim gives no template for it in `renders` "+ + "(novox/hq ADR 0255)", holder.Module, seat.Name, kind) +} + +// renderPiece is one piece of data in the holder's grammar. The template sees the piece's fields, +// `module`, the contributor, and `machine`, the node it is composed for. A value the piece does not have reads as nothing in a `with` or an `if`; +// printed bare it would write `` into the tool's file, which is refused here instead. +func renderPiece(t *template.Template, module, machine string, data map[string]any) (string, error) { + view := map[string]any{"module": module, "machine": machine} + for k, v := range data { + view[k] = v + } + var b bytes.Buffer + if err := t.Execute(&b, view); err != nil { + return "", fmt.Errorf("rendering %s's piece: %v", module, err) + } + out := b.String() + if strings.Contains(out, "") { + return "", fmt.Errorf("rendering %s's piece printed a value it does not have: %q", module, out) + } + return out, nil +} + +// placeholderWhere parses the narrowing part of a placeholder, `bar=bottom,place=status`, against the +// kind's shape. Empty narrows nothing. +func placeholderWhere(r catalogue.Receivable, where string) (map[string]string, error) { + out := map[string]string{} + if where == "" { + return out, nil + } + if r.Shape == nil { + return nil, fmt.Errorf("%s is text, which a placeholder does not narrow", r.Kind) + } + for _, pair := range strings.Split(where, ",") { + field, value, ok := strings.Cut(pair, "=") + var p *catalogue.PlaceField + for i := range r.Shape.Places { + if r.Shape.Places[i].Field == field { + p = &r.Shape.Places[i] + } + } + switch { + case !ok || p == nil: + return nil, fmt.Errorf("%q narrows by %s, and %s is placed by %s", where, field, r.Kind, placeFields(r)) + case !oneOf(p.Values, value): + return nil, fmt.Errorf("%q says %s=%s; %s is one of %s", where, field, value, field, strings.Join(p.Values, ", ")) + case out[field] != "": + return nil, fmt.Errorf("%q names %s twice", where, field) + } + out[field] = value + } + return out, nil +} + +var seats = defaultSeats + +// pathSeats is which seats name each path: the mesh's own, and then those modules declare, in the order +// given. A module's seat naming a path a seat already names loses it, and the refusal says so (novox/hq +// ADR 0287 — as for a seat's name, the second declaration loses). +func pathSeats(order []string, shelf catalogue.Shelf, declaredBy map[string]string) (map[string][]string, []string) { + named := map[string][]string{} + for _, s := range catalogue.Seats() { + for _, p := range s.Paths { + named[p.Path] = append(named[p.Path], s.Name) + } + } + var problems []string + for _, module := range order { + for _, s := range shelf[module].DefinesSeats { + if declaredBy != nil && declaredBy[s.Name] != module { + continue // a second declaration of the seat's name, refused for that already + } + for _, p := range s.Paths { + if others := named[p]; len(others) > 0 { + problems = append(problems, fmt.Sprintf( + "%s declares the seat %s naming the path %q, which %s already names; no two seats "+ + "name one path (novox/hq ADR 0287)", module, s.Name, p, strings.Join(others, " and "))) + continue + } + named[p] = append(named[p], s.Name) + } + } + } + return named, problems +} + +// missingPaths is each path a seat names that a module does not declare, in the seat's order. +func missingPaths(m catalogue.Manifest, paths []catalogue.SeatPath) []string { + if len(paths) == 0 { + return nil + } + has := declaredPaths(m) + var missing []string + for _, p := range paths { + if !has[p.Path] { + missing = append(missing, p.Path) + } + } + return missing +} + +// unclaimedSeatPaths is each path a module declares that a seat names while it claims none of the seats +// naming it (novox/hq ADR 0287): only a claimant declares a seat's path, and a module claiming any one of +// them may. +func unclaimedSeatPaths(m catalogue.Manifest, named map[string][]string) []string { + claims := map[string]bool{} + for _, c := range m.Claims { + claims[canonicalSeat(c.Name)] = true + } + var problems []string + declared := declaredPaths(m) + for _, p := range sortedKeys(declared) { + seats := named[p] + if len(seats) == 0 { + continue + } + claimsOne := false + for _, s := range seats { + claimsOne = claimsOne || claims[s] + } + if !claimsOne { + problems = append(problems, fmt.Sprintf( + "%s declares the path %q, which the seat %s names, and claims none of them; only a holder of "+ + "a seat naming a path declares it (novox/hq ADR 0287)", m.Module, p, strings.Join(seats, " and "))) + } + } + return problems +} + +// requirementsContributedTo is every requirement m contributes to, sorted. +func requirementsContributedTo(m catalogue.Manifest) []string { + seen := map[string]bool{} + for to := range m.Contributes { + seen[to] = true + } + for to := range m.ContributesMany { + seen[to] = true + } + out := make([]string, 0, len(seen)) + for to := range seen { + out = append(out, to) + } + sort.Strings(out) + return out +} + +func orNothing(names []string) string { + if len(names) == 0 { + return "nothing" + } + return join(names) +} + +// groupsOf is a container resource's groups as the manifest wrote them (text or numbers), none when it names none. +func groupsOf(r map[string]any) []any { + groups, _ := r["groups"].([]any) + return groups +} + +// settingsUsed is every key a file's content asks for, once each, in order of first use. +func settingsUsed(content string) []string { + var keys []string + seen := map[string]bool{} + for _, m := range settingRef.FindAllStringSubmatch(content, -1) { + if !seen[m[1]] { + seen[m[1]] = true + keys = append(keys, m[1]) + } + } + return keys +} + +// settingKeysAskedOutsideOwners is every key a module's files, units, items, contributions and served facts +// ask for: what a directory's owner may follow (OwnerSettingProblems). +func settingKeysAskedOutsideOwners(m catalogue.Manifest) map[string]bool { + used := map[string]bool{} + note := func(s string) { + for _, k := range settingsUsed(s) { + used[k] = true + } + } + for _, r := range m.Resources { + switch fmt.Sprint(r["type"]) { + case "file": + if content, ok := r["content"].(string); ok { + note(content) + } + case "service": + if unit, ok := r["unit"].(string); ok { + note(unit) + } + case "container": + for _, g := range groupsOf(r) { + if s, ok := g.(string); ok { + note(s) + } + } + } + } + // An item's text, filled from its template (WithTemplatesFrom), places a key as a file's does (issue 1658). + for _, it := range m.Items { + note(it.Content) + } + inValues := func(values map[string]any) { + for _, v := range values { + if s, ok := v.(string); ok { + note(s) + } + } + } + for _, values := range m.Contributes { + inValues(values) + } + for _, locals := range m.ContributesMany { + for _, values := range locals { + inValues(values) + } + } + for _, values := range m.Serves { + inValues(values) + } + return used +} + +// takenSettingsProblems is every rule of ADR 0326 §2 and §4, judged where a definition is checked and +// where a stored one is composed. +func takenSettingsProblems(m catalogue.Manifest) []string { + var problems []string + var naming, silent []string + for _, r := range m.Resources { + id := fmt.Sprint(r["id"]) + how, _ := r["merge"].(string) + raw, said := r[catalogue.TakesSettingsField] + if how == "" { + if said { + problems = append(problems, fmt.Sprintf("%s's %s says %s and does not merge: only a mergeable "+ + "file takes settings by key (novox/hq ADR 0326)", m.Module, id, catalogue.TakesSettingsField)) + } + continue + } + if !said { + silent = append(silent, id) + continue + } + naming = append(naming, id) + list, isList := raw.([]any) + if !isList { + problems = append(problems, fmt.Sprintf("%s's %s: %s is a list of setting keys, and this is %T", + m.Module, id, catalogue.TakesSettingsField, raw)) + continue + } + protected := map[string]bool{} + for _, k := range stringsOf(r["protected"]) { + protected[k] = true + } + seen, landed := map[string]bool{}, map[string]bool{} + for _, item := range list { + if _, isObject := item.(map[string]any); isObject { + e, shaped := asTakenEntry(item) + switch { + case !m.DeclaresConfig(): + problems = append(problems, fmt.Sprintf("%s's %s: %v in %s lays a value under another name, "+ + "which only the config form says: write the key alone (novox/hq issue 751)", + m.Module, id, item, catalogue.TakesSettingsField)) + continue + case !shaped: + problems = append(problems, fmt.Sprintf("%s's %s: %v in %s is a key alone, or an object "+ + "saying exactly %q and %q: the config key and the file's name for it (novox/hq issue 751)", + m.Module, id, item, catalogue.TakesSettingsField, takenKeyField, takenAsField)) + continue + case !settingRef.MatchString("${setting:" + e.key + "}"): + problems = append(problems, fmt.Sprintf("%s's %s: %q in %s is not a usable key: a config "+ + "key is lower-case letters, digits, dots, dashes and underscores; the file's own spelling "+ + "is its %q", m.Module, id, e.key, catalogue.TakesSettingsField, takenAsField)) + continue + case !usableKey(e.as): + problems = append(problems, fmt.Sprintf("%s's %s: %q, the name %s takes %q as, is not a "+ + "usable name in a file", m.Module, id, e.as, catalogue.TakesSettingsField, e.key)) + continue + } + problems = append(problems, takenEntryProblems(m.Module, id, e, seen, landed, protected)...) + continue + } + key, isString := item.(string) + if !isString || !usableKey(key) { + problems = append(problems, fmt.Sprintf("%s's %s: %v in %s is not a usable setting key", + m.Module, id, item, catalogue.TakesSettingsField)) + continue + } + problems = append(problems, takenEntryProblems(m.Module, id, takenEntry{key: key, as: key}, seen, + landed, protected)...) + } + } + if len(naming) > 0 { + for _, id := range silent { + problems = append(problems, fmt.Sprintf("%s's %s merges and names no %s while %s does: once one "+ + "mergeable file of a module names the keys it takes, every one does, an empty list for none "+ + "(novox/hq ADR 0326)", m.Module, id, catalogue.TakesSettingsField, strings.Join(naming, ", "))) + } + } + return problems +} + +// patternsOverlap says some event both patterns name. +func patternsOverlap(a, b string) bool { + x, y := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; ; i++ { + switch { + case i == len(x) && i == len(y): + return true + case i == len(x) || i == len(y): + return false + case x[i] == catalogue.TheRest || y[i] == catalogue.TheRest: + return true + case x[i] == catalogue.OneName || y[i] == catalogue.OneName || x[i] == y[i]: + continue + default: + return false + } + } +} + +// templateOf is the template a resource names, whether it names one, and what is wrong with it ("" when nothing): +// dir is the module's templates directory. +func templateOf(module, dir string, r map[string]any) (string, bool, string) { + raw, has := r[catalogue.TemplateField] + if !has { + return "", false, "" + } + id := fmt.Sprint(r["id"]) + if t := fmt.Sprint(r["type"]); t != "file" { + return "", true, fmt.Sprintf("%s: %s is a %s and names a template; only a file takes its text from one "+ + "(novox/hq ADR 0349 §2)", module, id, t) + } + name, ok := raw.(string) + if !ok || strings.TrimSpace(name) == "" { + return "", true, fmt.Sprintf("%s: %s names a template that is not a path: %v; a template is %q", + module, id, raw, dir+"/") + } + if _, both := r["content"]; both { + return name, true, fmt.Sprintf("%s: %s names the template %q and says its content too; a file's text is "+ + "in one place, the template or the content, never both (novox/hq ADR 0349 §2)", module, id, name) + } + if strings.HasPrefix(name, "/") || path.Clean(name) != name || !strings.HasPrefix(name, dir+"/") || + strings.Contains(name, "\\") { + return name, true, fmt.Sprintf("%s: %s names the template %q, which is not a file under %s/ of its own "+ + "repository: a template is %q, a plain relative path, and the build packs only %s/", + module, id, name, dir, dir+"/", dir) + } + return name, true, "" +} + +func templatesArtifacts(m catalogue.Manifest) []string { + if m.Build == nil { + return nil + } + var found []string + for _, a := range m.Build.Artifacts { + if a.Kind == catalogue.ArtifactArchive && a.URL == "" && cleanFrom(a.From) == m.TemplatesDir() { + found = append(found, a.Name) + } + } + return found +} + +var oldCollectionNamespaces = []string{"shell", "environment", "contribution"} + +// collectableAsText is whether a collector may name a kind received as data as a collection. +func collectableAsText(r catalogue.Receivable) bool { return r.Shape != nil && r.Shape.Text } + +// canonicalSeat is the seat a claimed name refers to, by its current name: itself for a name the mesh +// does not know (a module's own seat), its seat's name for a former one. +func canonicalSeat(name string) string { + if s, known := catalogue.SeatNamed(name); known { + return s.Name + } + return name +} + +var fillers = []filler{ + {namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}, "directory": {"owner"}, "container": {"groups"}}}, + {namespace: "dir", patterns: []*regexp.Regexp{dirRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}}, + {namespace: "access", patterns: []*regexp.Regexp{accessRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}}, + {namespace: "secret", patterns: []*regexp.Regexp{placeholder}, fields: map[string][]string{"file": {"content"}}, + why: "a secret is never filled into an environment variable or any other field: put it in a file the " + + "module declares and mount that (novox/hq ADR 0086)"}, + {namespace: "bound", patterns: []*regexp.Regexp{bound}, fields: map[string][]string{"file": {"content"}}}, + {namespace: "port", patterns: []*regexp.Regexp{ofPort}, fields: map[string][]string{"file": {"content"}, "container": {"env"}, "process": {"env"}}}, + {namespace: "seat", patterns: []*regexp.Regexp{ofSeat, ofSeatReach}, fields: map[string][]string{"file": {"content"}, "container": {"env"}, "process": {"env"}}}, + {namespace: "machine", patterns: []*regexp.Regexp{ofMachine}, fields: map[string][]string{"*": {"path", "owner", "content", "name", "user", "root", "home"}}}, + // Placed in a file's content by their holders, and judged there by their own rules + // (placeholderProblems, seatPlaceholderProblems). + {namespace: "environment", patterns: []*regexp.Regexp{ofEnvironment}, fields: map[string][]string{"*": {"content"}}}, + {namespace: "shell", patterns: []*regexp.Regexp{ofShell}, fields: map[string][]string{"*": {"content"}}}, + {namespace: "contribution", patterns: []*regexp.Regexp{ofContribution}, fields: map[string][]string{"*": {"content"}}}, + // What the machine's modules back up, placed by the backup holder's file (backup_placement.go, issue 794). + {namespace: catalogue.BackupNamespace, patterns: []*regexp.Regexp{ofBackup}, fields: map[string][]string{"*": {"content"}}}, + // A collection, placed by its collector (collections.go): filled by the provisioner's client. + {namespace: catalogue.CollectedNamespace, patterns: []*regexp.Regexp{ofCollected}, fields: map[string][]string{"*": {"content"}}}, + // A provider's consumers' credentials (consumers.go): filled by the provisioner's client. + {namespace: catalogue.ConsumersNamespace, patterns: []*regexp.Regexp{ofConsumers}, fields: map[string][]string{"file": {"content"}}}, + // Filled in what a provider serves (consumer_into_serves.go), never in a resource. + {namespace: "consumer", patterns: []*regexp.Regexp{consumerFact}}, +} + +// reads is whether this pass fills a field of a resource of this type. +func fillerReads(f filler, kind, field string) bool { + return oneOf(f.fields[kind], field) || oneOf(f.fields["*"], field) +} + +// whereFilled says where a namespace's pass does fill, for a refusal of one written elsewhere. +func whereFilled(f filler) string { + if f.why != "" { + return ". " + strings.ToUpper(f.why[:1]) + f.why[1:] + } + if len(f.fields) == 0 { + return "; it is filled only in what a provider serves" + } + var where []string + for _, kind := range sortedKeys(f.fields) { + of := "a " + kind + "'s" + if kind == "*" { + of = "any resource's" + } + where = append(where, of+" "+strings.Join(f.fields[kind], ", ")) + } + return "; it is filled in " + strings.Join(where, "; ") +} + +var ofKnownNamespace = regexp.MustCompile(`(?im)\$\{(?:` + strings.Join(namespacesOf(fillers), "|") + + `):(?:[^-=+?}\n][^}\n]*\}?|\}|$)`) + +var namespaceShaped = regexp.MustCompile(`\$\{[a-z][a-z0-9_-]*:[A-Za-z0-9][^\s{}$]*\}`) + +func namespacesOf(fs []filler) []string { + out := make([]string, len(fs)) + for i, f := range fs { + out[i] = f.namespace + } + return out +} + +// takenEntries is the entries of a takes-settings list in their order, a usable entry only: a string is a key +// under its own name, an object with exactly key and as a key under another (TakenSettingsProblems says the rest). +func takenEntries(raw any) []takenEntry { + list, _ := raw.([]any) + var out []takenEntry + for _, item := range list { + if e, ok := asTakenEntry(item); ok { + out = append(out, e) + } + } + return out +} + +// ofType is whether a value read from JSON is of a config type. +func ofType(v any, kind string) bool { + switch v.(type) { + case float64: + return kind == catalogue.ConfigNumber + case string: + return kind == catalogue.ConfigString + case bool: + return kind == catalogue.ConfigBoolean + case map[string]any: + return kind == catalogue.ConfigObject + case []any: + return kind == catalogue.ConfigList + } + return false +} + +var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`) + +var consumerFacts = []string{"as"} + +// asDNSLabel writes a minted identity as a DNS label. +// +// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and +// already inside the bound of the provision serving them: a provider that serves one as a DNS label +// bounds it at 63 or less (CheckServes, novox/hq ADR 0225), and one that serves it at all without +// saying is held to twenty (IdentityBoundOf). So +// this is the separator and nothing else — no lower-casing of what is already lower case, no +// truncation to a limit the identity is already inside, no padding of a name that is already long +// enough. Each of those would be the mesh guessing at a rule it has not been given. +func asDNSLabel(as string) string { + return strings.ReplaceAll(as, "_", "-") +} + +var consumerAlphabets = []string{"dns"} + +func namesOfDirs(dirs map[string]string) []string { + var names []string + for id := range dirs { + names = append(names, fmt.Sprintf("%q", id)) + } + sort.Strings(names) + return names +} + +var ofEnvironment = regexp.MustCompile(`\$\{environment:([^}]*)\}`) + +var ofShell = regexp.MustCompile(`\$\{shell:([^}]*)\}`) + +func placeholderOf(env, code [][]string) string { + if len(env) > 0 { + return env[0][0] + } + return code[0][0] +} + +// placerOf is the seat whose holder places a contribution for this target (novox/hq ADR 0204, +// ADR 0208 §4). +func placerOf(target string) string { + if oneOf(sessionFiles, target) { + return catalogue.DisplayServerSeat + } + if oneOf(powerMoments, target) { + return catalogue.PowerSeat + } + return catalogue.LoginShellSeat +} + +// selectorOf is a selector's pairs, or why it is none. +func selectorOf(selector string) (map[string]string, error) { + out := map[string]string{} + if selector == "" { + return out, nil + } + for _, pair := range strings.Split(selector, ",") { + k, v, ok := strings.Cut(pair, "=") + switch { + case !ok || !attributeKey.MatchString(k) || !attributeValue.MatchString(v): + return nil, fmt.Errorf("%q in %q is not =", pair, selector) + case out[k] != "": + return nil, fmt.Errorf("%q names %s twice", selector, k) + } + out[k] = v + } + return out, nil +} + +// isOld is whether a collection's name is an old namespace's. +func isOld(name string) bool { + _, old, _ := oldCollection(name, true) + return old +} + +var ofConsumers = regexp.MustCompile(`\$\{consumers:([^}]*)\}`) + +// ownershipOf is who owns a file at runtime, by its form, as the node-engine maps the old forms (mesh-host +// declaration.File.Ownership): written into is managed, created once is a seed, any other is owned. +func ownershipOf(r map[string]any) string { + switch { + case r["into"] != nil && fmt.Sprint(r["into"]) != "": + return catalogue.RuntimeManaged + case r["create-once"] == true: + return catalogue.RuntimeSeed + } + return catalogue.RuntimeOwned +} + +// ownArtifact is whether a resource runs something this module built, in either spelling a manifest +// may be in: naming the artifact, before a build resolved it, or carrying the reference a build +// recorded — this mesh's own store, under this module's name. +func ownArtifact(resource map[string]any, module string) bool { + if named, _ := resource["artifact"].(string); named != "" { + return true + } + image, _ := resource["image"].(string) + // A process or an archive carries what was built as its source (novox/hq issue 213). + source, _ := resource["source"].(string) + return strings.HasPrefix(image, catalogue.ArtifactStoreScheme+module+"/") || + strings.HasPrefix(source, catalogue.ArtifactStoreScheme+module+"/") +} + +// dataRoot is the root this node keeps placed directories under. +func dataRoot(with catalogue.Rendering) string { + if root := strings.TrimRight(strings.TrimSpace(with.DataRoot), "/"); root != "" { + return root + } + return defaultDataRoot +} + +func namesOfAccessIDs(accesses map[string]string) []string { + var names []string + for id := range accesses { + names = append(names, fmt.Sprintf("%q", id)) + } + sort.Strings(names) + return names +} + +var renderFuncs = template.FuncMap{"quote": quote} + +var defaultSeats = append([]catalogue.Seat{ + // The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's + // events belong to the role, so they keep their address while the holder is replaced. No accepts, + // so no work queue is raised for it — only what its holder may say. + // And it serves the mesh's own verbs as the seat's tools (novox/hq ADR 0154): `status`, `push`, + // `assign` and the rest are a role's interface, not a container's, and stay addressable while + // the control plane is replaced. + {Name: catalogue.ControllerSeatName, Scope: catalogue.ScopeMesh, Decision: "novox/hq ADR 0079", + // And what is wrong, as it changes, and the self-check's heartbeat (novox/hq to-be 45 §2, §4). + Emits: []string{"applied", "refused", "built-before", + "condition-raised", "condition-changed", "condition-cleared", "doctor-heartbeat", + // A value given by hand, replaced after its module's first good start (novox/hq ADR 0228). + "secret-replaced", + // Every act a healer takes (novox/hq to-be 45 §7). + "healer-acted", + // A build put back after its gate failed (novox/hq ADR 0236, to-be 45 §8). + "rolled-back", + // A pull request's merge check, judged (novox/hq to-be 45 §9). + "checked", + // A walk kept in a new state, and a tier sent to a machine: kept until the catalogue's main consumes them no + // more; nothing emits them (ADR 0539). + "plan-moved", + "sent", + // A key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3; design 57 F3). + "assignment-changed", "declared-config-changed", "predefined-changed", + // A builder's key not taken in because the artifact store does not hold an artifact it names (novox/hq + // issue 1937): the builder builds it again. + "artifacts-gone", + // A bucket of a state this release deleted, deleted at the controller's first start (novox/hq ADR 0539 + // clause 13): the merge window's, the silent sends' and the building plans' hold, each said once. + "bucket-deleted"}, + Serves: catalogue.ControllerVerbs}, + // The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable, + // served by whichever module holds the seat with tools of these names. + {Name: "mesh-store", Scope: catalogue.ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079", + Serves: []catalogue.Verb{ + {Name: "databases", Description: "Every database the store holds, with its on-disk size.", + Input: schema(map[string]string{}, nil), Replaces: []string{"psql -l"}}, + {Name: "query", Description: "One read-only statement against one database the store holds.", + Input: schema(map[string]string{"database": "the database to query", "sql": "the read-only statement"}, + []string{"database", "sql"}), Replaces: []string{"psql"}}, + }}, + // **Delivers the mesh's own bus, not `amqp`.** Those were the same word until + // ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is + // the mesh's own transport. ADR 0128 then made that connection something a module requires + // rather than receives ambiently — 23 of the catalogue's modules never speak, and an ambient + // connection would mint a credential for each. + {Name: catalogue.BrokerSeat, Scope: catalogue.ScopeMesh, Delivers: "mesh-bus", Decision: "novox/hq ADR 0079"}, + // The vault: the controller seals every minted credential with what it provides, which is the + // test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a + // second claimant, refused by name, rather than a candidate for a pin. + // + // Its verb `get` hands a secret on request and reply, sealed to the key the asker sends and only to an + // asker the vault allows (novox/hq ADR 0349 §11, issue 674). `accept`, `secret-ask`, `issued` and `rotate` + // take a value in and make one anew (ADR 0349 clauses 8 to 10, issue 675): the first three in two steps from + // the controller alone, which carries the value sealed to a key the vault made for the call and cannot open + // it. All optional until the vault that runs serves them (design 33 §7). + {Name: "mesh-vault", Scope: catalogue.ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161", + Serves: []catalogue.Verb{ + {Name: "get", Optional: true, Description: "One secret, sealed to the public key the asker sends (the " + + "sealed box of ADR 0206), with its generation; answered only to an asker the vault allows, as the " + + "bus proved it. Never the value in the clear, and never to a private key.", + Input: schema(map[string]string{"name": "the secret's name", + "public_key": "the asker's X25519 public key as SPKI PEM — never a private key"}, + []string{"name", "public_key"})}, + {Name: "accept", Optional: true, Description: "Take a value given at the controller's terminal, from the " + + "controller alone: name the secret and get a call and a key made for it, then send the call and the " + + "value sealed to that key. The vault alone opens it, and never remakes an accepted credential.", + Input: schema(vaultIntake(map[string]string{"node": "the machine, for a module's own secret", + "module": "the module, for a module's own secret", "name": "the secret's name in the module", + "provider": "the provider's machine, for a provision's credential", "provision": "the provision", + "by": "who gave it at the terminal", + "origin": "issued for a bus account the controller minted for a core or moved module and hands " + + "at once (ADR 0374, issue 787); left out for a value given at the terminal"}), nil)}, + {Name: "secret-ask", Optional: true, Description: "Ask the operator for a module's own secret at a desk, " + + "from the controller or a caller the vault's manifest names, named as the bus proved it (novox/hq ADR " + + "0563 §8): the ask is recorded in the vault's store and bounded before the prompt " + + "opens, and what is typed is sealed to a key the vault made for the call and opens there alone.", + Input: schema(vaultIntake(map[string]string{"node": "the machine the secret is for", + "module": "the module", "name": "the secret's name in the module", + "desk": "the machine whose prompt asks", "asked_by": "who asked, as the bus named its caller", + "outcome": "instead of sealed: how the prompt ended"}), nil)}, + {Name: "issued", Optional: true, Description: "Take the credential a provider's software issued for a " + + "provision, from the controller alone, which asks the provider's tool with a key the vault made for " + + "the call; kept as the provision's one credential, or, when the tool answers a value per holder named, " + + "each as that holder's own; the same value again writes nothing.", + Input: withHolders(schema(vaultIntake(map[string]string{"provision": "the provision", + "provider": "the provider's machine", "module": "the provider's module"}), nil))}, + {Name: "rotate", Optional: true, Description: "Make a secret anew. Refused for a value a provider's " + + "software issued and for an accepted credential. Never answers the value.", + Input: schema(map[string]string{"node": "the machine, for a module's own secret", "module": "the module", + "name": "the secret's name in the module", "provider": "the provider's machine", + "provision": "the provision", "by": "who asked, when the controller carries the act"}, nil)}, + // The move of the controller's secrets (design 57's V3, issue 676): each machine's node-engine hands in what + // was sealed to its machine, which the controller cannot open; the controller copies its record of asks. + {Name: "move-in", Optional: true, Description: "Take one value of the controller's store in, as it is, " + + "from the node-engine of a machine it was sealed to and no other caller: name the row, its origin " + + "and generation and get a call and a key made for it, then send the call, the value sealed to that " + + "key and a key to read back to. Never replaces a value the vault holds; answers what it kept, read " + + "back sealed to that key.", + Input: schema(vaultIntake(map[string]string{"node": "a module's own secret: its machine", + "module": "its module", "name": "its name in the module", + "provider": "a pair credential: the provider's machine", "provision": "the provision", + "consumer": "the consumer's machine", "consumer_module": "the consuming module", + "local": "the credential's local name, when it has one", + "origin": "made, accepted or issued, as the controller's store says", + "generation": "the controller's generation for the value", "made_at": "when it was made (RFC 3339)", + "read_back_to": "the second step: a raw X25519 public key the read-back is sealed to"}), nil)}, + // A machine's node-engine states its own sealing key (novox/hq ADR 0372, issue 786). + {Name: "node-key", Optional: true, Description: "State this machine's sealing key to the vault, in its " + + "own name, from the node-engine of the machine alone: at its start and whenever the key changes. A " + + "first or changed key is recorded and the machine's core secrets are said changed; the same key " + + "again changes nothing.", + Input: schema(map[string]string{"key": "the machine's sealing key, a raw X25519 public key in base64"}, nil)}, + // A machine's node-engine composes its own modules and asks their secrets sealed to its key (novox/hq ADR 0547 + // §2, ADR 0550 §5, issue 1126); granted as buscompose's Principal.AsksItsOwnSecrets says. + {Name: "own", Optional: true, Description: "Answer the node-engine of one machine, about its own machine " + + "alone (as the bus proved the caller, never an argument), its modules' own secrets and the pair " + + "credentials it consumes or provides that it names, each sealed to the key it stated with node-key. A " + + "credential the vault does not hold is named missing; never a value in the clear.", + Input: map[string]any{"type": "object", "properties": map[string]any{"credentials": map[string]any{ + "type": "array", "items": map[string]any{"type": "object"}, + "description": "the pair credentials the machine's composition reads, each {role: consumer|provider, " + + "provision, consumer, consumer_module, provider, local}"}}}}, + {Name: "move-asks", Optional: true, Description: "Copy rows of the controller's record of asks at a desk " + + "into the vault's, as they are and once, from the controller alone. No value is in any of them.", + Input: map[string]any{"type": "object", "properties": map[string]any{"asks": map[string]any{ + "type": "array", "items": map[string]any{"type": "object"}, + "description": "the controller's rows: id, node, module, name, desk, asked_by, opened_at, ended_at, outcome"}}}}, + {Name: "issue-account", Optional: true, Description: "Mint one bus account the grants part wants, now (novox/hq " + + "ADR 0561 §9): only while the controller's lease is handed away, never a node's account or the " + + "controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.", + Input: schema(map[string]string{"user": "the bus user, e.g. ace.mesh-build-go"}, []string{"user"})}, + {Name: "sign", Optional: true, Description: "Sign with the vault's key or authority (novox/hq ADR 0550 §2): " + + "kind node-certificate signs the certificate of the calling machine's internal name for the serving key " + + "its record states, built from bus state alone and asked by that machine's node-engine only; kind " + + "enrolment signs an enrolment reply under its own context, for a caller the adoption record names. " + + "Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.", + Input: schema(map[string]string{"kind": "node-certificate or enrolment", + "node": "the machine, for a node certificate: the caller's own", + "reply": "the enrolment reply's bytes in base64, for an enrolment"}, []string{"kind"})}, + }}, + // The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets + // mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10). + // Optional until the mesh-config that runs serves it (design 33 §7). + {Name: "mesh-config", Scope: catalogue.ScopeMesh, Decision: "novox/hq ADR 0348, ADR 0565", + Serves: []catalogue.Verb{ + {Name: "config", Optional: true, Description: "Set, clear, list or read the history of config values: " + + "mesh config or node config for a predefined key, or a module's own value on one node, each set " + + "with why, validated, and naming each module on each node whose value moves. A trusted value only " + + "from the controller's terminal.", + Input: configInput()}, + // The switch of config to its own sources (novox/hq ADR 0565 §4): optional until every mesh-config serves it. + {Name: "compare", Optional: true, Description: "Every difference between config's own sources and the " + + "controller's records, each with its reason: a module's declared config against the controller's mirror, " + + "the indexer's predefined list against the mirror's, and each open pull request's config candidate against " + + "what the controller told. The take-over waits for none.", + Input: schema(map[string]string{}, nil)}, + {Name: "take-over", Optional: true, Description: "Ask the operator's Approve to switch config to its own " + + "sources (the indexer); refused while compare shows a difference.", + Input: schema(map[string]string{"why": "why, in a sentence"}, []string{"why"})}, + {Name: "hand-back", Optional: true, Description: "Switch config back to the controller's mirrors, until the " + + "controller hands its lease away; refused after it.", + Input: schema(map[string]string{"why": "why, in a sentence"}, []string{"why"})}, + }}, + // The provisioner's server (novox/hq ADR 0349 clause 3, design 57 P4): its holder keeps the registry of which + // domain answers which kind of placeholder, and the buckets mesh-provisioner_needs, _applied and _collect, + // which it and each machine's provisioner client (for its own machine's keys) alone write. Its one verb reads + // them. Optional until the mesh-provisioner that runs serves it (design 33 §7). + {Name: "mesh-provisioner", Scope: catalogue.ScopeMesh, Decision: "novox/hq ADR 0349", + Serves: []catalogue.Verb{ + {Name: "provisioning", Optional: true, Description: "Read what the provisioner keeps: the registry " + + "(which domain answers which kind of placeholder), the needs each module on each node recorded, what " + + "is applied where, or the items of every collection, narrowed by node and module. Reads only.", + Input: schema(map[string]string{"what": "registry (when not given), needs, applied or collect", + "node": "a node", "module": "a module: for collect, a contributor", "collection": "for collect: a collection"}, nil)}, + }}, + // Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as + // an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes — + // images and archives, by digest — which is why the provision is the artifact store and not an + // image registry. + {Name: "mesh-artifact-store", Scope: catalogue.ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"}, + {Name: "mesh-catalog", Scope: catalogue.ScopeMesh, Decision: "novox/hq ADR 0121"}, + // The delivery's owner (novox/hq ADR 0239): one commit's journey from its pull request's head to every + // machine, and a group of them, recorded and ordered by its holder, which asks the controller for each act. + // What it says it says as its module (`mesh-delivery.transition`), through the runtime that launches it. + {Name: catalogue.DeliverySeat, Scope: catalogue.ScopeMesh, Serves: deliveryVerbs(), Decision: "novox/hq ADR 0239"}, + // Placement (novox/hq ADR 0567): which module runs on which node, state on the bus held by the assigner, changed only by + // a proposal the nodes answer and the operator approves. Its states and events are the holder module's. + {Name: catalogue.PlacementSeat, Scope: catalogue.ScopeMesh, Serves: placementVerbs(), Decision: "novox/hq ADR 0567"}, + // The delivery's checker (novox/hq ADR 0547 §10, issue 774): the try is its verb, not the controller's. + {Name: catalogue.CheckerSeat, Scope: catalogue.ScopeMesh, Serves: checkerVerbs(), Decision: "novox/hq ADR 0547"}, + // The mesh's health, out of the controller (novox/hq ADR 0564 clause 1, health_seats.go): the condition keeper, the + // watchman's watchdogs, the self-check and the healers. + {Name: catalogue.ConditionsSeat, Scope: catalogue.ScopeMesh, Decision: healthDecision, + Emits: []string{"condition-raised", "condition-changed", "condition-cleared"}, Serves: conditionsVerbs()}, + {Name: catalogue.WatchdogsSeat, Scope: catalogue.ScopeMesh, Decision: healthDecision, Serves: watchdogsVerbs()}, + {Name: catalogue.SelfCheckSeat, Scope: catalogue.ScopeMesh, Decision: healthDecision, Emits: []string{"checked"}, Serves: selfCheckVerbs()}, + {Name: catalogue.HealersSeat, Scope: catalogue.ScopeMesh, Decision: healthDecision, Emits: []string{"healer-acted"}, Serves: healersVerbs()}, + // The control records, out of the controller (novox/hq ADR 0565 clause 1, control_records_seats.go): a person's + // policy and the bus step at the assigner, and the hand-act log. + {Name: catalogue.WantsSeat, Scope: catalogue.ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"policy-changed"}, Serves: wantsVerbs()}, + {Name: catalogue.HandActsSeat, Scope: catalogue.ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"act-recorded"}, Serves: handActsVerbs()}, + // The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go). + {Name: catalogue.ArtifactSweepSeat, Scope: catalogue.ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"}, + Serves: artifactSweepVerbs()}, + // Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a + // delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight. + // They keep their names until that migration is done deliberately, apart from the node-* pass. + {Name: "npm-package-registry", Scope: catalogue.ScopeMesh, Delivers: "npm-package-registry", Decision: "novox/hq ADR 0109"}, + {Name: "git", Scope: catalogue.ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0111"}, + // **A build node's work, one seat per build language** (novox/hq ADR 0525 §2, §10; ADR 0536 §4; issue 1887). The + // build module of each language claims its seat on every node it runs on; the builder and the checker put their + // asks on the seat's accepted kinds, which the broker derives as one work queue with a shared worker per kind, and + // each holder pulls delivery first, then check, then recheck, at most its node's build capacity at once. Its verbs + // say what the node runs and pause or resume it there: every holder pulls from the same worker, so a node's pause is + // its holder taking nothing, kept on the module's own pause key (issue 1887's design limit 2). They replace + // node-build-agent, retired with build-agent (ADR 0524 §5). + {Name: "node-build-go", Scope: catalogue.ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Go"), + Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"}, + {Name: "node-build-node", Scope: catalogue.ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Node"), + Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"}, + {Name: "node-build-image", Scope: catalogue.ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("image"), + Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"}, + // **The mesh's resolvers** (novox/hq ADR 0194, 0196, 0223): every node's internal domain, and + // every node and container asks them and nothing else. Replicated since ADR 0223: held on more + // than one machine, each answering the same names from the same roster, and every machine's + // resolver file lists every holder — its own first — and no public resolver, so whichever answers + // first gives the one answer. Delivers what a machine's resolver configuration requires, so that + // requirement resolves to a holder wherever they are placed. + {Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Delivers: "wildcard-resolution", Replicated: true, + Decision: "novox/hq ADR 0194, ADR 0223"}, + // **A machine's names are one module's** (novox/hq ADR 0199, ADR 0223): its holder writes + // /etc/hostname and the machine's own lines in /etc/hosts, and keeps every other line of the hosts + // file as the operator's, changed through these three verbs on that machine alone. The controller + // holds none of it. Named node-hosts-file until ADR 0223; the former name resolves to it as an + // alias on a mesh that knew it. + {Name: "node-hostname", Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0199, ADR 0223", + // The machine's names are the role's files, whatever program fills it (novox/hq ADR 0287). + Paths: []catalogue.SeatPath{{Path: "/etc/hosts"}, {Path: "/etc/hostname"}}, + Serves: []catalogue.Verb{ + {Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " + + "the operator's, or the block of the module or tool that writes it.", + Input: schema(map[string]string{}, nil), Replaces: []string{"cat /etc/hosts", "getent hosts"}}, + {Name: "add", Description: "Add one address and its names to the operator's lines of this machine's " + + "/etc/hosts — a name for this machine's own programs, not the mesh's.", + Input: schema(map[string]string{"address": "the IPv4 or IPv6 address", + "names": "the names for it, separated by spaces"}, []string{"address", "names"}), + Replaces: []string{"edit /etc/hosts", "HOSTALIASES"}}, + {Name: "remove", Description: "Remove one name, or every line of one address, from the operator's " + + "lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.", + Input: schema(map[string]string{"name": "a host name, or an address to remove every line of"}, + []string{"name"}), Replaces: []string{"sed -i /etc/hosts"}}, + }}, + // The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list + // whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all + // four; the jails themselves are composed from the modules the machine runs (to-be 31). + {Name: "node-intrusion-prevention", Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0121", + Serves: []catalogue.Verb{ + {Name: "status", Description: "Every jail on this machine with how many it is watching and " + + "holding now, and the totals since the jail started; one jail's detail when named.", + Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client status"}}, + {Name: "banned", Description: "Every address banned on this machine right now, with the jail " + + "that holds it and when the ban ends.", + Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client banned"}}, + {Name: "ban", Description: "Ban one address in one jail now, for the jail's ban time — an " + + "operator's act on the live ban list, which the mesh never writes itself.", + Input: schema(map[string]string{"ip": "the address", "jail": "the jail to hold it"}, []string{"ip", "jail"}), + Replaces: []string{"fail2ban-client set banip"}}, + {Name: "unban", Description: "Let one address go, from one jail or from every jail when none is named.", + Input: schema(map[string]string{"ip": "the address", "jail": "one jail (optional)"}, []string{"ip"}), + Replaces: []string{"fail2ban-client unban", "fail2ban-client set unbanip"}}, + }}, + // The packet filter's verbs (novox/hq ADR 0170): what a person asks a machine's filter whatever + // filter answers — the rules as enforced, reload the mesh's own, remove one thing the mesh did + // not write. Every holder serves all three; what differs by filter is the holder's own tools. + {Name: "node-packet-filter", Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0121", + Serves: []catalogue.Verb{ + {Name: "rules", Description: "The packet filter as this machine enforces it now: the nftables " + + "ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or " + + "chain when asked.", + Input: schema(map[string]string{"table": "one nftables table, as `family name` (optional)", + "chain": "one chain of that table (optional)"}, nil), + Replaces: []string{"nft list ruleset", "iptables -L", "iptables-save"}}, + {Name: "reload", Description: "Load the mesh's own filter again from the file the mesh writes, " + + "and answer with the mesh's table as loaded.", + Input: schema(map[string]string{}, nil), Replaces: []string{"nft -f"}}, + {Name: "remove", Description: "Remove one rule set the mesh did not write, named exactly as the " + + "host reports it (novox/hq ADR 0168) — `chain X (iptables-legacy)` or `table ip6 filter, chain " + + "DOCKER-USER`. Refuses the mesh's tables, the runtime's own chains, a built-in chain and an " + + "active found firewall's chains. An operator's act, by name, never a flush.", + Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"}), + Replaces: []string{"nft delete", "iptables -X"}}, + }}, + // The machine's service manager (novox/hq ADR 0177). The host applies every declared unit, + // system or user scope; the holder answers questions and operator acts about them, each verb + // taking the unit and an optional scope. The holder runs nothing of its own: its verbs are + // served by the node tools runtime (ADR 0175). + {Name: catalogue.ServiceManagerSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0177", + Serves: serviceManagerVerbs()}, + // The machine's package manager (novox/hq ADR 0207). A module declaring a `package` depends on + // it being held on its node, as one declaring a `service` depends on node-service-manager: the + // mesh's word for "something on this machine answers for installing", where a capability only + // says the software is there. No verbs yet — the seat says who answers, and what may be asked + // of it is decided when someone needs to ask. + {Name: catalogue.PackageManagerSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0207"}, + // The machine's container runtime (novox/hq ADR 0166, seeded now by ADR 0207): a module + // declaring a `container` depends on it being held on its node. Its verbs, and the host creating + // containers through its holder, wait for ADR 0166's acceptance — seeded without them so the + // dependency has a seat to name and the runtime's module has one to claim. + {Name: catalogue.ContainerRuntimeSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0166, ADR 0207"}, + // The operator account's environment (novox/hq ADR 0203): one module per machine writes it, and + // every module contributes to it. No verbs — the seat says who places the environment's files, + // and their path is its protocol: a shell sources ~/.config/mesh/environment.sh without knowing + // which module wrote it. + {Name: catalogue.EnvironmentSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0203"}, + // The provisioner client on every machine (novox/hq ADR 0349 clause 3, design 57 P5): a service of its own on a + // bus account of its own (mesh-controller internal/broker NodeProvisionerSeat, design 57 P3), which fills the + // machine's templates. It serves no verb an agent calls. + {Name: "node-provisioner", Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0349"}, + // The login shell (novox/hq ADR 0204, replacing the module-declared `login-shell` of ADR 0176): + // the mesh's, so a second shell module claims the seat rather than declaring a second one, and + // the seat exists whether or not zsh's definition is registered. `execute` is the contract a caller + // may call where the machine serves it (optional: ADR 0268); the holder places every module's shell + // code in its slots. + {Name: catalogue.LoginShellSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0204", + Serves: loginShellVerbs()}, + // A machine's power (novox/hq ADR 0211): its holder owns logind's power handling, places the + // code modules contribute for the power moments, and publishes the machine's power states as + // its events. Every machine has one — every machine boots and shuts down. No verbs yet. + {Name: catalogue.PowerSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0211"}, + // The machine's hotkeys (novox/hq ADR 0212): the daemon that sees the keys the window manager + // does not — a laptop's vendor keys — run by one module per machine, which owns its + // configuration. Every other module with keys contributes trigger lines to it. + {Name: catalogue.HotkeysSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0212", + Receives: []catalogue.Receivable{{Kind: "trigger", Comment: "#"}}}, + // The machine's message bus (novox/hq ADR 0215): its holder owns the D-Bus implementation and its + // system service, never restarts it live, and publishes curated events about it, never its traffic. + // It receives nothing yet: packages ship their own policies. + {Name: catalogue.MessageBusSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0215"}, + // The machine's backups (novox/hq ADR 0214, to-be 43): its holder keeps nightly restore points of + // the data every module on the machine declares, on the machine, against mistakes rather than + // disasters. A module contributes `backup` lines — what to run to take a consistent copy, and + // which of its directories to keep. + {Name: catalogue.BackupSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0214", + Serves: backupVerbs(), + // `backup` is what to run and which paths to keep; `data` every item a module declares, with its + // class, for the holder to measure (novox/hq ADR 0233). Both derived from modules' data sections. + Receives: []catalogue.Receivable{{Kind: catalogue.BackupKindBackup, Comment: "#", Dirs: true}, {Kind: catalogue.BackupKindData, Comment: "#", Dirs: true}}}, + // Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client + // model change, not a rename, so it stays until that is built. + {Name: "the-private-network", Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0110"}, + // The program that manages the machine's own network. It delivers nothing: its holder keeps the + // manager and the mesh from contradicting each other — the private network's interface left + // alone — and writes the machine's resolver file itself, because the manager is what would + // otherwise rewrite it (novox/hq ADR 0223, which retired node-resolver-config into this seat). + // It never declares a link, an address or a wireless network, because the link is the only + // channel a fix could arrive on. A seat + // rather than a condition in the resolver's module, so a machine running two managers is + // refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117). + // + // Its verbs say what the machine resolves through and over which links (novox/hq ADR 0241): the + // resolver file as it is and who wrote it, and every link with its addresses, routes and the resolvers + // the manager knows for it — the same for every holder, so a question about a machine's names is asked + // the same way whatever manages its network. Optional until both holders serve them. + // + // It owns the resolver file, stepping back for node-resolver where that is held (novox/hq ADR 0287, + // ADR 0247). + {Name: "node-uplink", Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0117", Serves: uplinkVerbs(), + Paths: []catalogue.SeatPath{{Path: catalogue.ResolverFile, StepsBackFor: catalogue.ResolverSeat}}}, + // The machine's own resolver (novox/hq ADR 0247): held only where something requires `split-dns` — a + // VPN client whose domains must go to its own servers while every other name goes to the mesh's. + // Where it is held, its holder writes the resolver file and the uplink's holder steps back from it + // (node_resolver.go). It knows nothing of any VPN: its verbs route domains to servers over a link. + {Name: catalogue.ResolverSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs(), + Paths: []catalogue.SeatPath{{Path: catalogue.ResolverFile}}}, + // A machine's shares and the shares it mounts (novox/hq ADR 0263): the holder of node-nfs-server + // exports a machine's folders to the private network and provides each as `nfs-share`; the holder of + // node-mounts writes a mount and an automount unit per share on a machine that asks (shares.go). + {Name: catalogue.NFSServerSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0263", Serves: nfsServerVerbs()}, + {Name: catalogue.MountsSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0263", Serves: mountsVerbs()}, + // A node's files and accounts, read (novox/hq ADR 0310, reads.go): by an account of the holder's own that holds + // no secret, on every node, so the reads ADR 0268 §5 takes over from the login shell's execute have a tool. + {Name: catalogue.ReadsSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0310", Serves: readsVerbs()}, + // A node's files, read and managed (novox/hq ADR 0394, files.go): beside node-reads until mesh-files is assigned + // on every node, then node-reads goes (ADR 0394 §9). + {Name: catalogue.FilesSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0394", Serves: filesVerbs()}, + // The node's engine's own verbs and asks (novox/hq ADR 0563 §1): held by the engine on every node, by no module. + engineSeat(), +}, + // The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's. + graphicalSessionSeats()...) + +// declaredPaths is every machine path a module declares it owns: a resource's literal path, and a file +// the mesh renders for it outside the operator's home. An access is not here — reading is not owning. +// +// **Cleaned, because the machine writes the cleaned path**: /etc//hosts, /etc/./hosts, /etc/../etc/hosts and +// /etc/hosts/ are all /etc/hosts to it, and a seat's path is clean by its own rule, so a declaration compared +// as written would pass for a path nobody named. +func declaredPaths(m catalogue.Manifest) map[string]bool { + out := map[string]bool{} + if m.OwnsPaths != nil { + // Carried: what its manifest declares, never what runs of it (issue 1126, seed-8: f1-circuits, snake and txt-game + // on ace owned their /var/lib paths on the node and none on the controller). + for _, p := range m.OwnsPaths { + out[p] = true + } + return out + } + for _, r := range m.Resources { + if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") { + out[path.Clean(p)] = true + } + } + for _, f := range m.Facts { + if !f.Home && strings.HasPrefix(f.Path, "/") { + out[path.Clean(f.Path)] = true + } + } + return out +} + +func join(names []string) string { + switch len(names) { + case 1: + return names[0] + case 2: + return names[0] + " or " + names[1] + } + out := "" + for i, n := range names[:len(names)-1] { + if i > 0 { + out += ", " + } + out += n + } + return out + " or " + names[len(names)-1] +} + +// asTakenEntry reads one item of a takes-settings list, and whether it is shaped as an entry at all. +func asTakenEntry(item any) (takenEntry, bool) { + switch v := item.(type) { + case string: + return takenEntry{key: v, as: v}, true + case map[string]any: + key, keyOK := v[takenKeyField].(string) + as, asOK := v[takenAsField].(string) + if !keyOK || !asOK || len(v) != 2 { + return takenEntry{}, false + } + return takenEntry{key: key, as: as, renamed: true}, true + } + return takenEntry{}, false +} + +const takenKeyField = "key" + +const takenAsField = "as" + +// usableKey is a key a file can be said to take: not empty, and no space, line end or control +// character. Not the lower-case rule of a `${setting:}`: a file's own keys are its software's +// spelling, such as `timeZone`. +func usableKey(key string) bool { + if key == "" { + return false + } + for _, r := range key { + if unicode.IsSpace(r) || unicode.IsControl(r) || !unicode.IsPrint(r) { + return false + } + } + return true +} + +// takenEntryProblems is what is wrong with one usable entry of a file's takes-settings, against those before it: +// a key taken twice, or two values laid under one name in the file; a word the mesh reads itself, as the key or as +// the file's name; a name the file keeps as protected. seen and landed are the keys and the names so far, added to. +func takenEntryProblems(module, id string, e takenEntry, seen, landed, protected map[string]bool) []string { + var problems []string + switch { + case seen[e.key]: + problems = append(problems, fmt.Sprintf("%s's %s names %q twice in %s", module, id, e.key, catalogue.TakesSettingsField)) + case landed[e.as]: + problems = append(problems, fmt.Sprintf("%s's %s names %q twice in %s, as a key or as the name a key is "+ + "laid under: one name in a file holds one value", module, id, e.as, catalogue.TakesSettingsField)) + case meshWords[e.key] || meshWords[e.as]: + word := e.key + if !meshWords[word] { + word = e.as + } + problems = append(problems, fmt.Sprintf("%s's %s names %q in %s, and the mesh reads %q itself "+ + "(novox/hq ADR 0164 §14)", module, id, word, catalogue.TakesSettingsField, word)) + case protected[e.as]: + problems = append(problems, fmt.Sprintf("%s's %s names %q in %s and keeps it as protected: a "+ + "key is the file's or the setting's, never both", module, id, e.as, catalogue.TakesSettingsField)) + } + seen[e.key], landed[e.as] = true, true + return problems +} + +type takenEntry struct { + key, as string + renamed bool +} + +// cleanFrom is an artifact's directory as one relative path: "./files/" is "files". +func cleanFrom(from string) string { + c := path.Clean(strings.TrimSpace(from)) + return strings.TrimPrefix(c, "./") +} + +type filler struct { + namespace string + patterns []*regexp.Regexp + fields map[string][]string + // why, when set, is the rule that keeps the namespace out of every other field, said with a + // refusal of one written there. + why string +} + +var placeholder = regexp.MustCompile(`\$\{secret:([a-z0-9][a-z0-9-]*)\}`) + +var ofSeat = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):([0-9]+)\}`) + +var ofSeatReach = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):reach\}`) + +var ofBackup = regexp.MustCompile(`\$\{backup:([^}]*)\}`) + +func quote(v any) (string, error) { + b, err := json.Marshal(fmt.Sprint(v)) + return strings.ReplaceAll(string(b), "\x7f", `\u007f`), err +} + +// schema is a JSON schema for an object of string properties, which is every argument the verbs +// above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call. The +// switches are the properties that are "true" or "false" and nothing else, said in the schema as an +// enum so a caller sees it and the verb can refuse any other word rather than read it as false. +// +// **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not +// name is refused when the verb is called, never passed over. +func schema(properties map[string]string, required []string, switches ...string) map[string]any { + isSwitch := map[string]bool{} + for _, s := range switches { + if _, declared := properties[s]; !declared { + panic("a switch that is not a property: " + s) + } + isSwitch[s] = true + } + props := map[string]any{} + for name, description := range properties { + p := map[string]any{"type": "string", "description": description} + if isSwitch[name] { + p["enum"] = []string{"true", "false"} + } + props[name] = p + } + out := map[string]any{"type": "object", "properties": props} + if len(required) > 0 { + out["required"] = required + } + return out +} + +func vaultIntake(first map[string]string) map[string]string { + out := map[string]string{"call": "the second step: the call the first step answered", + "sealed": "the second step: the value sealed to the call's key — never the value"} + for k, v := range first { + out[k] = v + } + return out +} + +// vaultIntake is a two-step vault verb's arguments: what its first step names, then the call and what was sealed. +// withHolders adds to `issued` the provision's holders from that provider (issue 749): a provider's tool may answer a +// value for each, which the vault keeps as that holder's own and hands to it alone. +func withHolders(input map[string]any) map[string]any { + props, _ := input["properties"].(map[string]any) + props["holders"] = map[string]any{"type": "array", + "description": "the provision's holders from that provider, each {node, module}: its tool may answer a value " + + "for each, kept as that holder's own and handed to it alone", + "items": map[string]any{"type": "object", "properties": map[string]any{ + "node": map[string]any{"type": "string", "description": "the holder's machine"}, + "module": map[string]any{"type": "string", "description": "the consuming module"}}}} + return input +} + +// configInput is the schema of mesh-config's verb: a value of the key's own type (a number for a number, never its +// text), and a limit that is a number; every other argument a string. +func configInput() map[string]any { + in := schema(map[string]string{"action": "list (when not given), set, clear or history", + "key": "the config key", "value": "for set: the value, of the key's type", + "module": "a module: its own value, with node", "node": "a node", + "why": "for set and clear: why", "by": "who gave the value at the controller's terminal", + "limit": "for history: at most this many, newest"}, nil) + props := in["properties"].(map[string]any) + props["value"] = map[string]any{"description": "for set: the value, of the key's type: a number, a string, " + + "a boolean, or for a trusted key an object or a list"} + props["limit"] = map[string]any{"type": "number", "description": "for history: at most this many, newest"} + return in +} + +// deliveryVerbs are the delivery seat's tools: what reads the merges and checks, and the acts of a person and healer H2 +// on a merge (ADR 0539 clause 17). +func deliveryVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "deliveries", Description: "Every delivery not final, and those that ended in the last day, one line " + + "each: its id (owner/repository@commit), its state, its group, what it waits for and since when. Narrowed " + + "by state, repository or group.", + Input: schema(map[string]string{"state": "one state, e.g. delivering or held", + "repository": "owner/repository", "group": "a group's id (its branch name)", + "all": "\"true\": the final ones of the last thirty days too"}, nil, "all")}, + deliveryRemovedInR6("times"), + {Name: "show", Description: "One delivery or group whole: its delivery plan (what it builds, what each " + + "machine receives, what is not an ordinary send), every transition with when and why, the machine " + + "steps of its walk, its group and its order.", + Input: schema(map[string]string{"id": "a delivery's id, or a group's"}, []string{"id"})}, + {Name: "groups", Description: "Every delivery group: its members in order, why each pair is ordered " + + "(declared, built by, version skew, engine before controller, by name), its composed check and its " + + "state, derived from its members.", + Input: schema(map[string]string{"all": "\"true\": the groups that ended too"}, nil, "all")}, + deliveryRemovedInR6("what-if"), + {Name: "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each " + + "of the commit's mesh statuses (mesh/check, the one a main requires from R4e, hq's mesh/repo-check, " + + "mesh/delivery, …) with its state, " + + "description and when it was set; the merge check's full verdict as the controller said it — each " + + "layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's " + + "protection would let it merge, every required status being success.", + Input: schema(map[string]string{"repository": "owner/repository", + "number": "a pull request's number: its head is read", + "commit": "a commit's sha, or the start of one, instead of a pull request"}, []string{"repository"}), + // Added after the seat's first holder shipped: optional until mesh-delivery serves it everywhere. + Optional: true}, + {Name: "table", Description: "The state table every delivery runs by: each transition with its guard, " + + "each state's bound and what healer H2 may do once it has passed; and the machine steps' table.", + Input: schema(map[string]string{}, nil)}, + {Name: "stalled", Description: "Every delivery held past its state's bound, with the transition the table " + + "lets healer H2 take for it, or none.", + Input: schema(map[string]string{}, nil)}, + {Name: "recheck", Description: "Check a rejected or ready delivery again: it goes back to proposed and the " + + "controller is asked for its check. With why.", + Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"}, + []string{"id", "why"})}, + deliveryRemovedInR6("release"), + deliveryRemovedInR6("stop"), + {Name: "close", Description: "Healer H2's verb: take the one transition the table names for a delivery held " + + "past its bound. Refused for any other.", + Input: schema(map[string]string{"id": "the delivery's id", "why": "the condition it answers"}, + []string{"id", "why"})}, + deliveryRemovedInR6("retire-history"), + {Name: "settle", Description: "Ask the operator to settle deliveries held because their merge reached the " + + "trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. " + + "The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines " + + "is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another " + + "reason, naming why.", + Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"ids", "why"}, + "properties": map[string]any{ + "ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1, + "description": "the held deliveries' ids (owner/repository@commit)"}, + "why": map[string]any{"type": "string", "description": "why they may be settled, shown to the operator"}, + }}, + // Added after the seat's release 6 holder shipped: optional until the gate serves it everywhere. + Optional: true}, + } +} + +// placementVerbs are the seat's verbs: what reads all it keeps and does by hand all it does by itself (ADR 0567 §10). +// Optional while the holder that serves them is not yet on every node (design 33 §7): the seat is new in the same +// delivery as its holder. +func placementVerbs() []catalogue.Verb { + changes := map[string]any{"type": "array", "minItems": 1, "description": "the changes, each an object with `op`: " + + "place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " + + "unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " + + "(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " + + "layer, mesh-wide-values, values)", + "items": map[string]any{"type": "object", "required": []string{"op", "node"}}} + return []catalogue.Verb{ + {Name: catalogue.PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " + + "module is placed on, or every key, each with its revision, and how full the state is against its cap. With " + + "history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the " + + "proposal's changes from its placement-changed event.", + Input: schema(map[string]string{"node": "one node", "module": "one module: the nodes it is placed on", + "history": "\"true\": the changes in order, from the stream and the state's revisions", + // ADR 0571 clause 4: who holds which seat on which node, from the state seat-holders. + "seats": "\"true\": who holds each seat, and on which node (the state seat-holders); with node, that node's"}, + nil, "history", "seats")}, + {Name: catalogue.PlacementVerbProposals, Optional: true, Description: "Every open proposal of placement: its changes, " + + "the revision of each key it was laid over, its candidates, every node's answer, the differences computed and " + + "answered, its verdict and its ask. Narrowed to one by id.", + Input: schema(map[string]string{"id": "one proposal's id"}, nil)}, + {Name: catalogue.PlacementVerbPropose, Optional: true, Description: "Propose a change of placement, with why: laid over " + + "the current keys and refused at once, naming every finding, when the result cannot be placed (two modules " + + "on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " + + "hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " + + "it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " + + "caller, as the runtime proves it.", + Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"}, + "properties": map[string]any{ + "why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"}, + "changes": changes, + }}}, + {Name: catalogue.PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " + + "candidates are deleted and placement-withdrawn carries it whole. With why.", + Input: schema(map[string]string{"id": "the proposal's id", "why": "why, kept in the event"}, []string{"id", "why"})}, + {Name: catalogue.PlacementVerbAgain, Optional: true, Description: "Judge an open proposal now with the answers that " + + "stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. " + + "Nothing more: the recovery of a proposal whose judgement or ask was lost.", + Input: schema(map[string]string{"id": "the proposal's id"}, []string{"id"})}, + {Name: catalogue.PlacementVerbAdopt, Optional: true, Description: "Take placement over from the controller: its " + + "placement-export is proposed whole as the seed, every node's key, which every node must answer as the same. " + + "Its Approve is the take-over; at the warrant the export is read again and a changed one is proposed anew.", + Input: schema(map[string]string{"why": "why, shown to the operator"}, []string{"why"})}, + {Name: catalogue.PlacementVerbHandBack, Optional: true, Description: "The operator's alone, recorded as a hand act: " + + "delete every placement key, the history kept, so every node returns to the controller's compositions. " + + "Refused after the lease write.", + Input: schema(map[string]string{"why": "why, recorded with the hand act"}, []string{"why"})}, + } +} + +// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them +// everywhere. +func checkerVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " + + "judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration " + + "levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read " + + "reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most " + + "4 wait or run at once, mesh-wide.", + Input: schema(map[string]string{"repository": "/", + "head": "the full commit to try, 40 or 64 hex digits", + "trunk": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent", + "tests": "a go test -run pattern: only those tests run, and nothing is kept", + "level": "unit or integration: only this level; both when absent"}, + []string{"repository", "head"}), + Replaces: []string{"sh merge-check.sh", "docker run postgres"}}, + {Name: "try-read", Optional: true, Description: "Where a try stands, as stored: queued, running, or its end " + + "(pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit " + + "it was merged onto, the time it took and what failed first. Read-only.", + Input: schema(map[string]string{"id": "the try's id, try-"}, []string{"id"})}, + {Name: "try-end", Optional: true, Description: "End a try that waits or runs, as error, by hand: say why (kept in " + + "what it says). Its place among the tries is free at once and the next try of its head asks anew with a new " + + "id. A try that ended already is refused.", + Input: schema(map[string]string{"id": "the try's id, try-", "why": "why it is ended, in words"}, + []string{"id", "why"})}, + } +} + +const healthDecision = "novox/hq ADR 0564" + +// conditionsVerbs are the condition keeper's verbs (ADR 0564 clauses 3, 9, 12). +func conditionsVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, then oldest, " + + "raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when " + + "the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition " + + "lately, the controller's before the take-over included.", + Input: schema(map[string]string{ + "key": "a condition's key: that one whole; with history, only its transitions", + "scope": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module", + "severity": "only urgent, or only warning", + "machine": "only those about this machine", + "history": "\"true\": every raising, change, silence and clearing lately, oldest first", + "days": "with history: how many days back (default 7, at most 90)", + }, nil, "history")}, + {Name: "silence", Description: "Stop one condition's messages for a while, a hand act with why: it stays open and in " + + "status, and the silence is kept as its transition in history.", + Input: schema(map[string]string{ + "key": "the condition's key", + "for": "how long: 30m, 4h, 2d; at most 7d", + "why": "why, required and recorded", + "cause": "the cause in a word (the condition's kind when absent)", + }, []string{"key", "for", "why"})}, + {Name: "compare", Description: "The keeper's open conditions against the controller's: every key open in one and not " + + "the other, or open in both with another severity, each with its reason; the kinds that end with the controller " + + "and the keys held while their module is delivered listed apart and not counted. The take-over waits for it empty.", + Input: schema(map[string]string{}, nil)}, + {Name: "take-over", Description: "Ask the operator's Approve to switch who the router, the grants part and " + + "mesh-watcher hear from the controller to the keeper; refused while compare shows a difference. On the warrant " + + "the keeper writes its take-over key.", + Input: schema(map[string]string{"why": "why now, shown to the operator"}, []string{"why"})}, + {Name: "hand-back", Description: "Switch the readers back to the controller's conditions, until the controller's " + + "lease is handed away; refused after it.", + Input: schema(map[string]string{"why": "why, kept with the switch"}, []string{"why"})}, + {Name: "resync", Description: "Read every observer's findings again and raise and clear what differs from the open " + + "conditions: the recovery of a keeper that missed a change.", + Input: schema(map[string]string{}, nil)}, + } +} + +// watchdogsVerbs are the watchman's verbs (ADR 0564 clauses 4, 5, 12). +func watchdogsVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "signals", Description: "Every watchdog of the signals table the watchman keeps, each a key that expires unless " + + "its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its " + + "expiry wrote.", + Input: schema(map[string]string{"row": "one row, e.g. S1"}, nil)}, + {Name: "stuck", Description: "Every want, build or step the watchman says stopped moving past its bound, with since " + + "when and the words of its finding.", + Input: schema(map[string]string{"machine": "only those on this machine"}, nil)}, + {Name: "presence", Description: "Every machine and tool runner the watchman sees connected, with its last word, and " + + "those it says silent; a machine that said it sleeps is shown as sleeping, not silent.", + Input: schema(map[string]string{"machine": "one machine"}, nil)}, + {Name: "dead-letters", Description: "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose " + + "consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one " + + "whole. With deliver or drop, a hand act with why, taken only once the controller's lease is handed away; " + + "before, refused naming the controller's verb.", + Input: schema(map[string]string{ + "id": "a dead letter's id, as the list gives it: that one whole, with its message", + "consumer": "a consumer's name, or .: only what that one gave up on", + "limit": "how many to list, newest first (default 50); only when listing", + "deliver": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)", + "drop": "a dead letter's id: drop it for good (needs why)", + "why": "with deliver or drop: why, required and recorded", + "cause": "with deliver or drop: the cause in a word (dead-letter when absent)", + }, nil)}, + {Name: "resync", Description: "Rebuild presence from the bus's connections, and the pulses and findings from the keys " + + "the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.", + Input: schema(map[string]string{}, nil)}, + } +} + +// selfCheckVerbs are the self-check's verbs (ADR 0564 clause 6). +func selfCheckVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "doctor", Description: "The self-check: the last run's verdict at once, each probe passed, failed, deferred " + + "(its facts not on the bus yet, named) or could not run, and how long ago. With run, a run now; with probe, one " + + "probe's verdict whole. Runs every five minutes on its own; each failure is a finding.", + Input: schema(map[string]string{ + "run": "\"true\": run every probe now and answer the verdict", + "probe": "one probe's id (D6): its verdict alone, with every finding", + }, nil, "run")}, + {Name: "probes", Description: "The registry: what each probe asserts, where it reads its facts, and the finding it writes.", + Input: schema(map[string]string{}, nil)}, + } +} + +// healersVerbs are the healers' verbs (ADR 0564 clauses 7, 8). +func healersVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "healers", Description: "The healers: each registered response to one kind of condition, its repair, its " + + "budget and what happens when it is spent; every act taken lately with its outcome, the acts it would have " + + "taken while the controller's lease names a controller, and the mesh-wide brake.", + Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)}, + {Name: "heal", Description: "Take one healer's act for one open condition now, within its budget and the brake.", + Input: schema(map[string]string{ + "key": "the condition's key", + "why": "why now, kept with the act", + }, []string{"key", "why"})}, + {Name: "drill", Description: "Record a drill: something broken on purpose to see the mesh raise and clear it, with " + + "what it tests. A drill is never a repair: no healer is wanted for it however often it is run.", + Input: schema(map[string]string{ + "what": "what was done on purpose, in a line", + "why": "what the drill tests", + "condition": "the key of the condition the drill is meant to raise, if any (optional)", + }, []string{"what", "why"})}, + } +} + +const controlRecordsDecision = "novox/hq ADR 0565" + +// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15). +func wantsVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " + + "and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " + + "rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " + + "with policy default, the choice taken back. With compare, every module whose policy differs from the one the " + + "controller writes beside its compositions.", + Input: schema(map[string]string{ + "module": "one module", + "policy": "with module: roll, together, record, or default to take a person's choice back", + "why": "with policy: why, required and kept", + "compare": "\"true\": every module whose policy at the assigner differs from the controller's", + }, nil, "compare")}, + {Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " + + "indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " + + "the step itself is the put on the operator's warrant.", + Input: schema(map[string]string{}, nil)}, + } +} + +// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them. +func handActsVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " + + "(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " + + "runtime proves, never said by the caller. Said as act-recorded.", + Input: schema(map[string]string{ + "what": "what was done, in a line", + "why": "why it had to be done by hand", + "cause": "the cause in a word, or a condition's kind", + "condition": "the key of the condition it addressed, if any (optional)", + "verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own", + }, []string{"what", "why"})}, + {Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " + + "cause of each, and the causes recorded twice within a fortnight (a healer is wanted).", + Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause", + "who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")}, + {Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " + + "with which proofs and which answer read from the router's own record of that module's ask, never from the " + + "caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.", + Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})}, + } +} + +// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks. +func artifactSweepVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " + + "repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " + + "newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " + + "unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.", + Input: schema(map[string]string{ + "repository": "only this repository's references, as / (the counts stay whole)", + "collected": "\"true\": list the collected references too (counted always)", + }, nil, "collected")}, + {Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " + + "held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " + + "Reads only.", + Input: listed(schema(map[string]string{ + "references": "the artifacts' references, artifact-store:////blobs/sha256:: a list, " + + "or one text separated by commas", + }, []string{"references"}), "references")}, + {Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " + + "dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " + + "then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " + + "names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " + + "seconds; what is left is said. Never touches a digest the mesh did not make.", + Input: schema(map[string]string{ + "confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done", + "why": "with confirm: why, required and recorded", + "most": "let go of at most this many (500 by default, at most 5000)", + }, nil, "confirm")}, + {Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " + + "it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " + + "it: never removed), by what the builder's log says each build stood on. Records nothing by hand.", + Input: schema(map[string]string{}, nil)}, + {Name: "images", Description: "Every container image one machine's want and have name, each with the resources " + + "naming it: what the machine keeps when it prunes its images. Reads only.", + Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})}, + } +} + +// buildAskKinds are a build seat's accepted kinds, in the order a holder pulls them (ADR 0536 §4; mesh-sdk +// delivery.AskKinds). +func buildAskKinds() []string { return []string{"delivery", "check", "recheck"} } + +// buildSeatVerbs are what a build module answers on its own node for its seat (ADR 0525 §10). +func buildSeatVerbs(language string) []catalogue.Verb { + return []catalogue.Verb{ + {Name: "current", Description: "The " + language + " asks this node runs now (each ask's id, kind, module and when it " + + "started), its build capacity, and whether it is paused, taking no new ask.", + Input: schema(map[string]string{}, nil)}, + {Name: "pause", Description: "Take no new " + language + " ask on this node until resumed; an ask running here " + + "ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the " + + "lasting way to keep a node from building.", + Input: schema(map[string]string{}, nil)}, + {Name: "resume", Description: "Take " + language + " asks again on this node.", + Input: schema(map[string]string{}, nil)}, + } +} + +// serviceManagerVerbs is the contract every holder of node-service-manager serves (novox/hq ADR +// 0177): the units on the machine in both scopes, read and acted on by name. Every verb takes an +// optional scope — "system" when absent, "user" for the operator account's own manager — so a +// caller asks for a user unit the way it asks for a system one; `failed` alone reads both managers +// when none is named. `failed`, `reset-failed`, `wanted-by` and `unlink-dangling` are optional +// (Verb.Optional) until every holder serves them (ADR 0246). +func serviceManagerVerbs() []catalogue.Verb { + scoped := func(more map[string]string, required []string) map[string]any { + props := map[string]string{"scope": "\"system\" (the default) or \"user\": the operator account's own manager"} + for k, v := range more { + props[k] = v + } + return schema(props, required) + } + unit := map[string]string{"unit": "the unit's name, as the service manager knows it"} + return []catalogue.Verb{ + {Name: "units", Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.", + Input: scoped(map[string]string{"pattern": "a glob the unit's name must match (optional)"}, nil), Replaces: []string{"systemctl list-units"}}, + {Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.", + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl status", "systemctl is-active"}}, + {Name: "start", Description: "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.", + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl start"}}, + {Name: "stop", Description: "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.", + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl stop"}}, + {Name: "restart", Description: "Restart one unit.", + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl restart"}}, + {Name: "enable", Description: "Make one unit start at boot (or at the account's login, in user scope).", + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl enable"}}, + {Name: "disable", Description: "Stop one unit starting at boot (or at login, in user scope).", + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl disable"}}, + // **A window, not only a tail** (the operator's direction 2026-10-07): an incident is read for the + // minutes it happened in, and with no window on the verb a person reached for a shell. Every + // argument is the holder's to validate — passed to journalctl as one word of its own, never through + // a shell — and what the unit printed of a secret is redacted before it is answered. + {Name: "journal", Description: "The last lines of one unit's journal (at most 2000), in a time window and " + + "narrowed to a priority and to lines holding a text when asked. A secret the unit printed is shown as " + + "[redacted: ].", + Input: scoped(map[string]string{ + "unit": unit["unit"], + "lines": "how many lines from the end of what matches (default 100, at most 2000)", + "since": "the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)", + "until": "the window's end, in the same forms (optional; now when absent)", + "match": "only the lines holding this text, as written — a fixed string, not a pattern (optional)", + "priority": "only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)", + }, []string{"unit"}), + Replaces: []string{"journalctl"}}, + // What has failed, on the seat rather than as one holder's own tool: whatever holds the role answers + // it, so a caller asks every machine the same way. **Optional while its holders catch up**: the + // systemd module running today serves it as its own systemd_failed, and a required verb would + // refuse it before the version serving `failed` could be delivered. + {Name: "failed", Optional: true, Description: "Every failed unit on this machine, in the system manager and in the operator " + + "account's; a manager that does not answer is reported with its error, never as nothing failed.", + Input: schema(map[string]string{"scope": "\"system\" or \"user\": only that manager (both when absent)"}, nil), + Replaces: []string{"systemctl --failed"}}, + // **Clearing a failed record, and finding what starts a unit** (novox/hq issue 332): a unit whose + // file is gone stays failed in its manager until the record is reset, and what keeps asking for it + // is a dependency or an enable link no verb could show. Required since every holder serves them + // (ADR 0246 step 3). + {Name: "reset-failed", Description: "Clear one unit's failed record in its manager — " + + "its failed state and its start-limit count — and answer its state after. It removes no file and " + + "starts nothing.", + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl reset-failed"}}, + {Name: "wanted-by", Description: "What wants, requires or triggers one unit, read-only: the " + + "manager's reverse dependencies (WantedBy, RequiredBy, UpheldBy, BoundBy, TriggeredBy and the rest) and " + + "every enable link naming it in the scope's configuration directories (*.wants, *.requires, *.upholds), " + + "each with where it points and whether it dangles — the account's own directory included in user scope.", + Input: scoped(unit, []string{"unit"}), + Replaces: []string{"systemctl list-dependencies --reverse", "systemctl show -p WantedBy", + "ls ~/.config/systemd/user/*.wants"}}, + // **A dangling enable link removed on the person's word** (novox/hq issue 332): `systemctl disable` + // leaves a link whose unit file is gone, so the manager keeps asking for the unit. Required since + // every holder serves it (ADR 0246 step 3). + {Name: "unlink-dangling", Description: "Remove the dangling enable links named for one " + + "unit — links in a *.wants, *.requires or *.upholds directory of the account's or the machine's own " + + "configuration whose target does not exist, which `disable` leaves once the unit's file is gone — on " + + "the person's word: `why` is required. Each is recorded (its path, its target, the reason) before it is " + + "removed, and the manager reloaded; a link whose target exists, or one a package, a generator or the " + + "runtime placed, is left and said.", + Input: scoped(map[string]string{"unit": unit["unit"], + "why": "the person's reason for removing the links, kept in the record"}, []string{"unit", "why"}), + Replaces: []string{"rm ~/.config/systemd/user/*.wants/", "unlink /etc/systemd/system/*.wants/"}}, + } +} + +// loginShellVerbs is the contract of node-login-shell (novox/hq ADR 0176, ADR 0204): one command, run +// the way the operator's own terminal would run it, bounded below the runtime's thirty-second call limit +// so a hung command answers rather than times the caller out. +// +// **`execute` is optional** (novox/hq ADR 0268). It runs any command as the operator account, which can +// become root without a person, so a machine may withhold it: the control-node does, through the +// holder's own `execute` setting, until a call to it needs a person's approval (hq research 039). The mark +// is ADR 0246's: a holder that does not serve the verb on a machine still holds the seat there, and its +// silence on the bus is not judged — a holder withholding it serves nothing on the seat. +func loginShellVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "execute", Optional: true, Description: "Run one command on this machine as the operator account, in a " + + "non-interactive login shell in its home; answers with what it printed and how it exited.", + Input: schema(map[string]string{ + "command": "the command line, as you would type it", + "timeout_seconds": "give up after this long, at most 25 (default 20)", + }, []string{"command"})}, + } +} + +// backupVerbs is the node-backup seat's protocol (novox/hq to-be 43): what is kept, take one now, +// and restore beside the live data — never over it. +func backupVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "backed-up", Description: "What this machine backs up: each module, what it declared, " + + "its last good night, how many restore points are kept and the repository's size.", + Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic snapshots"}}, + {Name: "now", Description: "Take a backup now, of one module or of every module on this " + + "machine — before a migration, a retirement or anything else that could go wrong.", + Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic backup"}}, + {Name: "restore", Description: "Restore one module's data from a restore point BESIDE the live " + + "data, never over it: each directory as .restored-. Swapping it in is a " + + "person's act. Lists the restore points when none is named.", + Input: schema(map[string]string{ + "module": "the module", + "snapshot": "the restore point (from `backed-up`; the newest when omitted)", + "path": "one of the module's directories (all of them when omitted)", + }, []string{"module"}), Replaces: []string{"restic restore"}}, + } +} + +// uplinkVerbs is the contract every holder of node-uplink serves (novox/hq ADR 0241): read-only answers +// about what the machine resolves through and over which links, whatever program manages its network. +func uplinkVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "resolvers", Description: "The machine's resolver file as it is now: the resolvers, search " + + "domains and options it lists, whether it is the file the mesh declares, and when it is not, who " + + "wrote it as far as the machine shows — its header, a backup a VPN client left beside it, a link " + + "in its place, a known writer running.", + Input: schema(nil, nil), Optional: true}, + {Name: "links", Description: "Every network link on the machine: its state, its addresses, whether " + + "the default route leaves through it, and the resolvers and search domains the network manager " + + "knows for it — a VPN's tunnel included.", + Input: schema(nil, nil), Optional: true}, + } +} + +// resolverVerbs is the contract every holder of node-resolver serves (novox/hq ADR 0247): what is routed +// where, route a set of domains, and take a route away. The same verbs are served on the machine itself to +// the modules there, so what a VPN pushed never crosses the bus to be routed; on the mesh they are the +// operator's way to read and correct the same. +func resolverVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "routes", Description: "What this machine's own resolver sends where: the mesh's resolvers, " + + "which answer every name not routed elsewhere, and each link given servers of its own with the " + + "domains routed to them. Also the resolver file's outside writes it kept, newest first: when, who " + + "wrote it as far as the file says, whether a module took it, and when the resolver's own file was " + + "put back.", + Input: schema(map[string]string{}, nil), + Replaces: []string{"resolvectl status", "resolvectl dns", "resolvectl domain"}}, + {Name: "route", Description: "Send these domains, and every name under them, to these servers over " + + "this link — and only them: the link is never the machine's default route for names, and the " + + "mesh's own domain is refused. Replaces whatever the link was given before. A link that goes away " + + "takes its route with it.", + Input: schema(map[string]string{ + "link": "the network link the servers are reached over, by name (a VPN's tunnel interface)", + "domains": "the domains to route there, separated by spaces or commas", + "servers": "the servers' addresses, separated by spaces or commas", + }, []string{"link", "domains", "servers"}), + Replaces: []string{"resolvectl dns", "resolvectl domain", "resolvectl default-route"}}, + {Name: "unroute", Description: "Take one link's route away: its domains go back to the mesh's " + + "resolvers. Nothing changes when the link has none.", + Input: schema(map[string]string{"link": "the network link, by name"}, []string{"link"}), + Replaces: []string{"resolvectl revert"}}, + } +} + +// nfsServerVerbs is the contract every holder of node-nfs-server serves (novox/hq ADR 0263). +func nfsServerVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "exports", Description: "Every share this machine exports: its name, its path, read-write or " + + "read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that " + + "node's private address and access (only the nodes the server grants it to and that ask for it), " + + "what is granted and not asked for or asked for and not granted, and whether the kernel holds it " + + "now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in " + + "/etc/exports.", + Input: schema(map[string]string{}, nil), + Replaces: []string{"exportfs -v", "cat /etc/exports", "zfs get sharenfs"}}, + {Name: "clients", Description: "Which machines have mounted which share now, as the NFS server " + + "knows its clients.", + Input: schema(map[string]string{}, nil), + Replaces: []string{"ss -tn sport = :2049", "cat /proc/fs/nfsd/clients/*/info"}}, + {Name: "test", Description: "Whether this machine exports one share for the mesh now, and whether its " + + "NFS service is up; with an address, also whether the share is exported to that address: a node's " + + "own private address, when the server grants it the share and the node asks for it. What a machine " + + "mounting the share asks before it mounts.", + Input: schema(map[string]string{ + "share": "the share, by its name", + "address": "a node's private address, to ask whether the share is exported to it (optional)", + }, []string{"share"}), + Replaces: []string{"showmount -e"}}, + {Name: "reload", Description: "Have the kernel read this machine's export files again now (exportfs " + + "-ra) and answer the shares as it then holds them. The module's own process writes its export " + + "file; this is for after a change made outside it. Changes no shared path.", + Input: schema(map[string]string{}, nil), + Replaces: []string{"exportfs -ra"}}, + {Name: "adopt", Description: "Take over an export a person made by hand: clear a dataset's sharenfs " + + "property for a path the mesh's own export now serves — only when that export is live. Without " + + "confirm, says what it would do and changes nothing.", + Input: schema(map[string]string{ + "path": "the shared path whose hand-made export is taken over", + "confirm": "\"true\": change it (needs why); anything else is a dry run", + "why": "why, for the record", + }, []string{"path"}, "confirm"), + Replaces: []string{"zfs set sharenfs=off"}}, + } +} + +// mountsVerbs is the contract every holder of node-mounts serves (novox/hq ADR 0263). +func mountsVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "list", Description: "Every mount point on this machine: its fstab line, the mesh's mount and " + + "automount units for it, its state (armed, mounted, absent, unreachable, failed) and since when, " + + "and which settings asked for it. A password in a mount's options is never shown.", + Input: schema(map[string]string{}, nil), + Replaces: []string{"cat /etc/fstab", "findmnt", "systemctl list-units --type=mount"}}, + {Name: "test", Description: "Whether one share's or occasional source's server answers from this " + + "machine now, without touching its mount point.", + Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}), + Replaces: []string{"showmount -e", "ping"}}, + {Name: "mount", Description: "Mount one share or occasional source now, rather than at its first " + + "access.", + Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}), + Replaces: []string{"mount"}}, + {Name: "unmount", Description: "Release one share or occasional source now; its automount stays " + + "armed, so the next access mounts it again.", + Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}), + Replaces: []string{"umount"}}, + {Name: "adopt", Description: "Take over a mount a person wrote by hand: comment out the /etc/fstab " + + "line for a mount point the mesh's own units now serve, keeping a copy of the file — only when " + + "the mesh's automount for it is armed. Without confirm, says what it would do and changes nothing.", + Input: schema(map[string]string{ + "mountpoint": "the mount point whose fstab line is taken over", + "confirm": "\"true\": change it (needs why); anything else is a dry run", + "why": "why, for the record", + }, []string{"mountpoint"}, "confirm"), + Replaces: []string{"sed -i /etc/fstab"}}, + } +} + +// readsVerbs is the contract every holder of node-reads serves (novox/hq ADR 0310 §2). The bounds are the +// build's to set and each verb's description to state (ADR 0310, Consequences); the holder states them in +// every answer it cuts. +func readsVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "stat", Description: "Each component of one path or several, from / down: its type, owner and group " + + "(name and number), mode, whether it is a link and to what, and whether it is a mount point and of which " + + "file system. A link is not followed unless follow is \"true\". Read as the holder's own account, which " + + "holds no secret: a component it may not reach says so. At most 200 paths in one call.", + Input: schema(map[string]string{ + "paths": "one absolute path, or several separated by line breaks", + "follow": "\"true\": also say where the path leads once every link is followed", + }, []string{"paths"}, "follow"), + Replaces: []string{"stat", "ls -ld", "namei -l", "findmnt -T"}}, + {Name: "readlink", Description: "What one link points to, as written, and where the path leads once every " + + "link is followed, and whether that exists.", + Input: schema(map[string]string{"path": "an absolute path"}, []string{"path"}), + Replaces: []string{"readlink", "readlink -f", "realpath"}}, + {Name: "list", Description: "Every entry of one directory with its type, owner, group, mode, size and, for a " + + "link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept " + + "is refused by name.", + Input: schema(map[string]string{"path": "an absolute path to a directory"}, []string{"path"}), + Replaces: []string{"ls", "ls -la"}}, + {Name: "read", Description: "One text file, or a part of it by line: at most 256 KiB in one answer " + + "(64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is " + + "kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its " + + "number said. /proc and /sys are read as far as the holder's account may.", + Input: schema(map[string]string{ + "path": "an absolute path to a file", + "from": "the first line to answer, counted from 1 (optional)", + "lines": "how many lines to answer (optional)", + "bytes": "the most bytes to answer, up to 262144 (optional, 65536 unless given)", + }, []string{"path"}), + Replaces: []string{"cat", "head", "tail", "less"}}, + {Name: "search", Description: "Find a fixed string (never a pattern) in one file or under one directory: " + + "every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), " + + "8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and " + + "binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a " + + "secret is withheld. The answer says which bound it met.", + Input: schema(map[string]string{ + "text": "the fixed string to find", + "path": "an absolute path to a file or a directory", + "depth": "how many directories deep, up to 8 (optional)", + "matches": "the most matches to answer, up to 500 (optional)", + "case": "\"false\": match whatever the case (optional)", + }, []string{"text", "path"}, "case"), + Replaces: []string{"grep", "grep -r", "grep -F"}}, + {Name: "account", Description: "One account or group by name or number, as this node's account files say: " + + "an account's number, group, home, shell and every group it is in; a group's number and members. With " + + "neither, every account and group. Never a password field.", + Input: schema(map[string]string{ + "name": "an account's name or number (optional)", + "group": "a group's name or number (optional)", + }, nil), + Replaces: []string{"getent passwd", "getent group", "id", "groups"}}, + } +} + +// filesVerbs is the contract every holder of node-files serves (novox/hq ADR 0394 §1): the reads of node-reads, +// with their descriptions restated for the operator's account, and the writes. +func filesVerbs() []catalogue.Verb { + var verbs []catalogue.Verb + for _, v := range readsVerbs() { + v.Description = strings.Replace(v.Description, + "Read as the holder's own account, which holds no secret: a component it may not reach says so.", + "", 1) + v.Description = strings.TrimSpace(v.Description) + " Read " + filesAsAccount + "." + verbs = append(verbs, v) + } + write := func(name, description string, in map[string]string, required []string, optional []string, replaces ...string) catalogue.Verb { + in["reason"] = "why the file is changed, for the audit trail" + in["override"] = overrideWord + return catalogue.Verb{Name: name, Description: description + " Refuses a path a module owns " + + "unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + Input: schema(in, append(required, "reason"), append(optional, "override")...), Replaces: replaces} + } + return append(verbs, + write("write", "Write one text file whole, replacing what it holds; the old content goes to the node's trash.", + map[string]string{"path": "an absolute path", "content": "the text to write", + "mode": "the file's mode, such as 0644 (optional)"}, + []string{"path", "content"}, []string{"mode"}, "tee", ">"), + write("copy", "Copy a file or directory to a path on this node. A target on another node (:/path) is "+ + "refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one "+ + "kept in the node's trash for 7 days.", + map[string]string{"from": "an absolute path", "to": "an absolute path on this node"}, + []string{"from", "to"}, nil, "cp", "cp -a", "ssh cp"), + write("move", "Move or rename a file or directory. A target on another node is refused: copying between nodes "+ + "comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days.", + map[string]string{"from": "an absolute path", "to": "an absolute path"}, + []string{"from", "to"}, nil, "mv"), + write("delete", "Delete a file or directory into the node's trash, where restore can bring it back.", + map[string]string{"path": "an absolute path", "recursive": "\"true\": delete a directory with what it holds"}, + []string{"path"}, []string{"recursive"}, "rm", "rm -r"), + write("mkdir", "Make a directory.", + map[string]string{"path": "an absolute path", "parents": "\"true\": make the missing parents too"}, + []string{"path"}, []string{"parents"}, "mkdir", "mkdir -p"), + write("chmod", "Change the mode of a file or directory.", + map[string]string{"path": "an absolute path", "mode": "the mode, such as 0644 or u+x", + "recursive": "\"true\": also everything under a directory"}, + []string{"path", "mode"}, []string{"recursive"}, "chmod"), + write("chown", "Change the owner and group of a file or directory.", + map[string]string{"path": "an absolute path", "owner": "an account's name or number", + "group": "a group's name or number (optional)", "recursive": "\"true\": also everything under a directory"}, + []string{"path", "owner"}, []string{"group", "recursive"}, "chown", "chgrp"), + catalogue.Verb{Name: "upload", Description: "Send one file of this node into a bucket of the object store, over a short signed link " + + "and never over the bus; answers the key (the file's SHA-256 when none is given), the size and the SHA-256. " + + "At most 100 MiB. A place a secret is kept, a known credential file, or a link to one is refused by name, " + + "before anything is sent. Read " + filesAsAccount + ".", + Input: schema(map[string]string{"path": "an absolute path to a file", "bucket": "the bucket the file goes into", + "key": "the object's key (optional; the file's SHA-256 when not given)"}, []string{"path", "bucket"})}, + // download is a plain Verb, not through write(): write() types its optional arguments as true/false + // switches, which a hash and a reason are not (issue 1719). + catalogue.Verb{Name: "download", Description: "Write one object of a bucket to a file of this node, over a short signed link and " + + "never over the bus; the old file goes to the node's trash. At most 100 MiB. The bytes must hash to sha256, or to " + + "the key when the key is a SHA-256; otherwise no file is written. Refuses a path a module owns " + + "unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + Input: schema(map[string]string{"bucket": "the bucket", "key": "the object's key", "path": "an absolute path", + "sha256": "the SHA-256 the bytes must have (optional; the key when that is a SHA-256)", + "reason": "why the file is changed, for the audit trail", + "override": overrideWord}, []string{"bucket", "key", "path", "reason"})}, + catalogue.Verb{Name: "trash", Description: "What the node's trash holds: each deleted or replaced item with its id, " + + "original path and when it went.", + Input: schema(map[string]string{}, nil), Replaces: []string{"ls ~/.local/share/Trash"}}, + write("restore", "Put an item of the node's trash back where it was; refuses when something is there now.", + map[string]string{"id": "an id from trash"}, []string{"id"}, nil, "trash-restore"), + ) +} + +// engineSeat is the seat's row in the closed set. +func engineSeat() catalogue.Seat { + return catalogue.Seat{Name: catalogue.EngineSeat, Scope: catalogue.ScopeNode, Serves: catalogue.EngineVerbs(), Emits: append([]string(nil), catalogue.EngineEvents...), + HeldByTheEngine: true, Decision: "novox/hq ADR 0563"} +} + +// graphicalSessionSeats are the eleven, in the order ADR 0208's table reads, each with the verbs +// research 026/05 starts it with. Three have none yet: the bar (which receives blocks instead, +// ADR 0255), the compositor and the secret +// service are roles a second holder competes for, and nothing has needed to ask them anything. +func graphicalSessionSeats() []catalogue.Seat { + const decided = "novox/hq ADR 0208" + return []catalogue.Seat{ + {Name: catalogue.LoginManagerSeat, Scope: catalogue.ScopeNode, Decision: decided, Serves: []catalogue.Verb{ + {Name: "sessions", Description: "The sessions the login manager offers on this machine, and which " + + "one the operator account starts by default.", + Input: schema(map[string]string{}, nil)}, + }}, + {Name: catalogue.DisplayServerSeat, Scope: catalogue.ScopeNode, Decision: decided, Serves: []catalogue.Verb{ + {Name: "displays", Description: "The monitors connected now, each with its identity, its modes and " + + "where it is placed; and the layout profile in force, if one matches.", + Input: schema(map[string]string{}, nil)}, + // Profiles are keyed by the monitors' identities and are the operator's data (ADR 0208 §6). + {Name: "layout", Description: "The monitor layout profiles, keyed by the connected monitors' " + + "identities: list them, save the current arrangement under a name, or apply one.", + Input: withEnum(schema(map[string]string{ + "action": "list, save or apply", + "name": "the profile to save or apply (save and apply only)", + }, []string{"action"}), "action", "list", "save", "apply")}, + }}, + // It receives window-manager configuration lines from every other module (novox/hq ADR 0212): + // bindings, start-up commands, rules — placed by the session's holder, never written into + // its directory by the contributor. + {Name: catalogue.DisplaySessionSeat, Scope: catalogue.ScopeNode, Decision: decided, + Receives: []catalogue.Receivable{{Kind: "config", Comment: "#"}}, Serves: []catalogue.Verb{ + {Name: "reload", Description: "Reload the session's configuration in place, keeping its windows.", + Input: schema(map[string]string{}, nil)}, + {Name: "workspaces", Description: "The session's workspaces: each one's name, output, and whether " + + "it is visible or focused.", + Input: schema(map[string]string{}, nil)}, + {Name: "windows", Description: "The session's windows: each one's title, class, workspace and " + + "whether it has focus; narrowed to one workspace when named.", + Input: schema(map[string]string{"workspace": "one workspace (optional)"}, nil)}, + }}, + {Name: catalogue.TerminalEmulatorSeat, Scope: catalogue.ScopeNode, Decision: decided, Serves: []catalogue.Verb{ + {Name: "open", Description: "Open a terminal window in the operator's session, running a command " + + "or the login shell, in a directory or the account's home.", + Input: schema(map[string]string{ + "command": "what to run in it (optional; the login shell when absent)", + "directory": "where it starts (optional; the account's home when absent)", + }, nil)}, + }}, + {Name: catalogue.LauncherSeat, Scope: catalogue.ScopeNode, Decision: decided, Serves: []catalogue.Verb{ + {Name: "menu", Description: "Put a menu of choices in front of the operator and answer with the " + + "one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.", + Input: map[string]any{"type": "object", "required": []string{"choices"}, + "properties": map[string]any{ + "choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "the lines to choose between, in order"}, + "prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"}, + }}}, + // A value the operator types and nobody sees (novox/hq ADR 0259 §10): a hidden prompt whose answer + // is sealed to the asker's key, so it is never plaintext on the bus or in any call's record. + // **Optional while its holders catch up** (ADR 0246): rofi serves it once this is live. + {Name: "secret", Optional: true, Description: "Ask the operator for a value in a prompt that " + + "does not show what is typed, and answer it sealed to the key the asker gives — never in " + + "the clear — or cancelled when the prompt was dismissed or not answered in time.", + // By name, never by words (the review of 2026-10-09, M4): the holder writes the prompt from the + // module, the secret and the machine, and says the controller asks — the bus lets nobody else + // ask it (broker.ControllerOnly) — so no caller puts words of its own before the operator. + Input: schema(map[string]string{ + "module": "the module whose own secret is asked for", + "secret": "the own secret's name", + "node": "the machine the module runs on", + "seal_to": "the asker's public sealing key: the answer is sealed to it", + "timeout_seconds": "give up after this long (optional)", + }, []string{"module", "secret", "node", "seal_to"})}, + }}, + {Name: catalogue.NotifierSeat, Scope: catalogue.ScopeNode, Decision: decided, Serves: []catalogue.Verb{ + {Name: "send", Description: "Show the operator a notification.", + Input: withEnum(schema(map[string]string{ + "title": "the notification's summary", + "body": "its text (optional)", + "urgency": "low, normal (the default) or critical", + }, []string{"title"}), "urgency", "low", "normal", "critical")}, + {Name: "history", Description: "The notifications shown lately, newest first.", + Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)}, + }}, + {Name: catalogue.LockScreenSeat, Scope: catalogue.ScopeNode, Decision: decided, Serves: []catalogue.Verb{ + {Name: "lock", Description: "Lock the operator's session now.", + Input: schema(map[string]string{}, nil)}, + }}, + {Name: catalogue.ClipboardSeat, Scope: catalogue.ScopeNode, Decision: decided, Serves: []catalogue.Verb{ + {Name: "history", Description: "What the clipboard held lately, newest first.", + Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)}, + {Name: "copy", Description: "Put text on the operator's clipboard.", + Input: schema(map[string]string{"text": "the text"}, []string{"text"})}, + }}, + // It receives blocks as data (novox/hq ADR 0255): what a block shows, said for any bar, which + // the holder renders in its own grammar. A module that knows something about the machine — the + // power module its battery — gives the block; the bar knows no machine's hardware. + {Name: catalogue.BarSeat, Scope: catalogue.ScopeNode, Decision: decided, + Receives: []catalogue.Receivable{{Kind: catalogue.BarKindBlock, Comment: "#", Offered: true, Shape: barBlockShape()}}}, + {Name: catalogue.CompositorSeat, Scope: catalogue.ScopeNode, Decision: decided}, + {Name: catalogue.SecretServiceSeat, Scope: catalogue.ScopeNode, Decision: decided}, + } +} + +// deliveryRemovedInR6 is the row of a verb release 6 removed from the delivery seat. +func deliveryRemovedInR6(name string) catalogue.Verb { + return catalogue.Verb{Name: name, Description: "Removed in R6 (ADR 0539): refused by name, naming its replacement: " + + catalogue.RemovedDeliveryVerbs[name], Input: schema(nil, nil), Optional: true} +} + +// listed makes the named properties of a schema lists of text: a caller gives them as a JSON array (or, as +// any argument, one text separated by commas). +func listed(in map[string]any, names ...string) map[string]any { + props, _ := in["properties"].(map[string]any) + for _, n := range names { + p, _ := props[n].(map[string]any) + if p == nil { + panic("a list that is not a property: " + n) + } + props[n] = map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": p["description"]} + } + return in +} + +const filesAsAccount = "as the node's operator account, through sudo -n where its rights do not reach; a place a " + + "secret is kept, or a known credential file, is refused by name" + +const overrideWord = "a reason, to change a path a module owns anyway (optional)" + +// withEnum narrows one string property of a schema to the values it may take, so a caller is told +// the choices by the schema rather than by a refusal. +func withEnum(s map[string]any, property string, values ...string) map[string]any { + props := s["properties"].(map[string]any) + p := props[property].(map[string]any) + p["enum"] = values + return s +} + +// barBlockShape is the shape of a block (novox/hq ADR 0255): which bar, where on it, in what order, +// what it shows, and that kind's options. +// +// - bar: top or bottom; +// - place: resources (beside the processor, memory and disks) or status (beside the sound, before the +// clock) — the two places every holder keeps for contributed blocks; +// - order: 0–99 within the place, 50 when absent; +// - shows: battery or command; options: what that kind takes. +func barBlockShape() *catalogue.Shape { + return &catalogue.Shape{ + Text: true, + Places: []catalogue.PlaceField{{Field: "bar", Values: []string{"bottom", "top"}}, + {Field: "place", Values: []string{"resources", "status"}}}, + Order: "order", + Check: barBlockProblems, + Reads: barBlockReads, + Examples: []map[string]any{ + {"bar": "bottom", "place": "status", "shows": "battery"}, + {"bar": "bottom", "place": "status", "shows": "battery", "options": map[string]any{"device": "BAT0"}}, + {"bar": "bottom", "place": "status", "shows": "command", + "options": map[string]any{"command": "echo \"a line\"", "interval": float64(5)}}, + {"bar": "bottom", "place": "status", "shows": "state", "options": map[string]any{"state": "power.draw"}}, + {"bar": "bottom", "place": "status", "shows": "state", + "options": map[string]any{"state": "power.draw", "key": "a-machine"}}, + }, + } +} + +func barBlockProblems(data map[string]any) []string { + var problems []string + for field := range data { + if !oneOf([]string{"bar", "place", "order", "shows", "options"}, field) { + problems = append(problems, fmt.Sprintf("a block has no field %q; it has bar, place, order, shows and options", field)) + } + } + shows, _ := data["shows"].(string) + takes, known := barShows[shows] + if !known { + return append(problems, fmt.Sprintf("a block shows %v; it shows one of %s", data["shows"], + strings.Join(sortedKeys(barShows), ", "))) + } + options := map[string]any{} + if raw, has := data["options"]; has { + o, ok := raw.(map[string]any) + if !ok { + return append(problems, "a block's options are an object") + } + options = o + } + for name, v := range options { + opt, ok := takes[name] + if !ok { + problems = append(problems, fmt.Sprintf("a %s block has no option %q; it takes %s", shows, name, + orNothing(sortedKeys(takes)))) + continue + } + switch opt.kind { + case "text": + if t, isText := v.(string); !isText || strings.TrimSpace(t) == "" { + problems = append(problems, fmt.Sprintf("a %s block's %s is text", shows, name)) + } + case "seconds": + if _, whole := wholeIn(v, 1, 86400); !whole { + problems = append(problems, fmt.Sprintf("a %s block's %s is a whole number of seconds from 1 to 86400", shows, name)) + } + } + } + for _, name := range sortedKeys(takes) { + if _, has := options[name]; takes[name].required && !has { + problems = append(problems, fmt.Sprintf("a %s block needs the option %s", shows, name)) + } + } + return problems +} + +// barBlockReads is the state and key a block shows, if any; an empty key is the machine's own. +func barBlockReads(data map[string]any) []catalogue.StateRead { + if data["shows"] != "state" { + return nil + } + options, _ := data["options"].(map[string]any) + s, ok := options["state"].(string) + if !ok || s == "" { + return nil + } + key, _ := options["key"].(string) + return []catalogue.StateRead{{Read: s, Key: key}} +} + +var barShows = map[string]map[string]barOption{ + // The machine's battery: its charge, and how long it lasts or takes to fill. + "battery": {"device": {kind: "text"}}, + // One line a command prints, run every interval seconds: what the bar cannot know itself. + "command": {"command": {kind: "text", required: true}, "interval": {kind: "seconds", required: true}}, + // A value the contributor keeps on the bus (novox/hq ADR 0255): its state `.` and the + // key, this machine's name when absent. The value is an object whose `text` is what is shown. The + // holder is granted to read it and shows it however its bar shows a changing value; no file and + // no command passes between the two modules. + "state": {"state": {kind: "text", required: true}, "key": {kind: "text"}}, +} + +type barOption struct { + kind string // text, or seconds (a whole number from 1 to 86400) + required bool +} + +func first(a, _ []string) []string { return a } +func second(_, b []string) []string { return b } diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go new file mode 100644 index 0000000..370e79a --- /dev/null +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -0,0 +1,6413 @@ +// The judge's tests, moved from mesh-sdk go/catalogue at 25a2cec with the rules they test (novox/hq ADR 0578 §4, issue +// 1585); those reading the catalogue read the copy captured in testdata/beside (internal/beside). + +package judge + +import ( + json "encoding/json" + fmt "fmt" + catalogue "git.novox.be/novox/mesh-sdk/go/catalogue" + ast "go/ast" + parser "go/parser" + token "go/token" + beside "mesh-delivery-indexer/internal/beside" + os "os" + filepath "path/filepath" + reflect "reflect" + regexp "regexp" + slices "slices" + strconv "strconv" + strings "strings" + sync "sync" + testing "testing" +) + +// ---- moved from catalogue ---- + +// A backup contribution written by hand still names its module's own directories; one it does not +// declare is refused where it is written rather than reaching the holder as the literal text. (The +// catalogue check refuses a hand-written backup line at all since ADR 0233; parsing still reads one, +// because a module on the shelf was written before.) +func TestABackupNamingAnUndeclaredDirectoryIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"pg","version":"1", + "contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:nowhere}"}]}`)) + if err == nil || !strings.Contains(err.Error(), "nowhere") { + t.Fatalf("a backup of an undeclared directory was accepted: %v", err) + } +} + +// **A language that compiles to a binary must say which system.** +// +// A binary is pinned to one operating system at link time, so a host refuses to touch a machine it +// was not built for. An artifact that says nothing would be compiled for whatever the build machine +// happened to be — which reads as portable and is not, and is the fault this check exists for. +func TestABinaryMustSayWhichSystemItIsFor(t *testing.T) { + got := buildProblemsOf(t, bundleFor("go", "")) + if !strings.Contains(got, "says no system") { + t.Fatalf("a compiled bundle with no system was accepted:\n%s", got) + } + // And the refusal names what it could have said, so a reader is one edit from right. + for _, system := range []string{"alpine", "android", "arch"} { + if !strings.Contains(got, system) { + t.Fatalf("the refusal does not name %q as a choice:\n%s", system, got) + } + } +} + +func TestABinaryThatNamesASystemIsAccepted(t *testing.T) { + if got := buildProblemsOf(t, bundleFor("go", "arch")); got != "" { + t.Fatalf("a compiled bundle naming a system was refused:\n%s", got) + } +} + +func TestABlockOutsideTheShapeIsRefused(t *testing.T) { + cases := map[string]string{ + `{"seat":"node-bar","kind":"block","content":"[[block]]"}`: "has no data", + `{"seat":"node-bar","kind":"block","content":"x","data":{"bar":"top","place":"status","shows":"battery"}}`: "has content", + `{"seat":"node-hotkeys","kind":"trigger","data":{"a":"b"}}`: "receives trigger as text", + `{"seat":"node-bar","kind":"block","data":{"bar":"left","place":"status","shows":"battery"}}`: `bar "left"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"middle","shows":"battery"}}`: `place "middle"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"gpu"}}`: "shows one of battery, command", + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","colour":"red"}}`: `no field "colour"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","order":100}}`: "whole number from 0 to 99", + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command"}}`: "needs the option command", + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","options":{"x":"y"}}}`: `no option "x"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command","options":{"command":"c","interval":0}}}`: "seconds", + `{"seat":"node-bar","kind":"block","if-capability":"Has Battery","data":{"bar":"top","place":"status","shows":"battery"}}`: "not a capability", + } + for c, want := range cases { + raw := `{"module":"power","contributions":[` + c + `]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: accepted, or refused without %q: %v", c, want, err) + } + } + ok := `{"module":"power","contributions":[{"seat":"node-bar","kind":"block","if-capability":"battery",` + + `"data":{"bar":"bottom","place":"status","order":10,"shows":"command","options":{"command":"c","interval":5}}}]}` + if _, err := ParseManifest([]byte(ok)); err != nil { + t.Errorf("a block in the shape was refused: %v", err) + } +} + +func TestABuildContextOnASeatNamesNoForge(t *testing.T) { + m := catalogue.Manifest{Module: "packager", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{ + {Name: "server", Kind: "image", From: "Dockerfile", + Context: &catalogue.ArtifactContext{Seat: "git", Repository: "org/controller", Ref: "main"}}, + }}} + if got := InstallationProblems(m); len(got) != 0 { + t.Fatalf("a context on a seat was reported: %v", got) + } + m.Build.Artifacts[0].Context = &catalogue.ArtifactContext{Repository: "https://git.mesh-one.be/org/controller.git"} + if got := InstallationProblems(m); len(got) != 1 { + t.Fatalf("a context by URL was not reported: %v", got) + } +} + +func TestABuildsBundlesAreCarriedOnTheResolvedManifest(t *testing.T) { + m := aToolsModule(t, "nftables", "tools/index.js") + if len(m.Bundles) != 1 { + t.Fatalf("the resolved manifest carries %d bundle(s), not the one the build made", len(m.Bundles)) + } + b := m.Bundles[0] + if b.Name != "tools" || b.Digest != bundleDigest || b.Language != "typescript" || + b.Source != catalogue.ArtifactStoreScheme+"nftables/tools/blobs/"+bundleDigest || + len(b.Entrypoints) != 1 || b.Entrypoints[0] != "tools/index.js" { + t.Errorf("the bundle is carried as %+v", b) + } + // A repository manifest may not write what the build derives. + raw := `{"module":"x","version":"1","build":{"artifacts":[{"name":"t","kind":"bundle","language":"typescript"}]},` + + `"bundles":[{"name":"t","source":"s","digest":"` + bundleDigest + `"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "bundles") { + t.Errorf("a manifest stating its build's output by hand was accepted: %v", err) + } +} + +// novox/hq ADR 0192: a tools bundle says what it is given; the composer resolves it per machine as +// a container's environment, hands it to the runtime as the module's words, and makes what the +// words name readable by the account the runtime runs as. +func TestABundleIsGivenItsWordsResolvedForThisMachine(t *testing.T) { + with := catalogue.Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{ + catalogue.RuntimeModule: {"broker": "sealed-credential"}, + "dash": {"token": "sealed-token"}, + }} + dash := catalogue.Manifest{Module: "dash", Version: "1", Tools: []string{"status"}, + Listens: []catalogue.Listening{{Name: "web", Port: 3000, Protocol: "tcp"}}, + OwnSecrets: catalogue.OwnSecrets{"token": {Path: "${dir:mesh-state}/token"}}, + Resources: []map[string]any{ + {"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"}, + {"id": "config", "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", "content": "{}\n"}, + {"id": "unrelated", "type": "file", "path": "/etc/dash.conf", "content": "x\n"}, + }, + Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle, Language: "typescript", + Entrypoints: []string{"tools/index.js"}, + Env: map[string]string{ + "DASH_CONFIG_FILE": "${dir:mesh-state}/config.json", + "DASH_TOKEN_FILE": "${dir:mesh-state}/token", + "DASH_URL": "http://127.0.0.1:${port:3000}", + "DASH_ADMIN": "mesh-admin", + }}}}} + if problems := buildProblems(dash.Build, dash.Module); len(problems) > 0 { + t.Fatalf("a bundle's words written with ${dir:…} and ${port:…} were refused: %v", problems) + } + dash, err := dash.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle, + Reference: catalogue.ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + other := aToolsModule(t, "nftables", "tools/index.js") + + out, err := catalogue.Resolution{Node: "anchor", Account: "ops", Modules: []catalogue.Manifest{dash, other, theRuntime(t)}}.Declaration(with) + if err != nil { + t.Fatal(err) + } + dir := fileNamed(out, "dash.mesh-state") + if dir == nil { + t.Fatalf("no directory: %v", ids(out)) + } + at := fmt.Sprint(dir["path"]) + process := fileNamed(out, catalogue.RuntimeModule+"."+catalogue.RuntimeProcessID()) + env := process["env"].(map[string]string) + var given map[string]map[string]string + if err := json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &given); err != nil { + t.Fatalf("the runtime's %s is not JSON: %q", catalogue.RuntimeToolEnv, env[catalogue.RuntimeToolEnv]) + } + want := map[string]string{ + "DASH_CONFIG_FILE": at + "/config.json", + "DASH_TOKEN_FILE": at + "/token", + "DASH_URL": "http://127.0.0.1:3000", + "DASH_ADMIN": "mesh-admin", + } + if fmt.Sprint(given["dash"]) != fmt.Sprint(want) { + t.Errorf("dash is given %v, want %v", given["dash"], want) + } + if _, has := given["nftables"]; has { + t.Errorf("a module that declares no words was given some: %v", given) + } + // What the words name is the account's to read; nothing else of the module's is touched. + for _, id := range []string{"dash.mesh-state", "dash.config", "dash." + catalogue.NeedID("token")} { + if r := fileNamed(out, id); r == nil || r["owner"] != "ops" { + t.Errorf("%s is not the account's to read: %v", id, r) + } + } + if r := fileNamed(out, "dash.unrelated"); r == nil || r["owner"] != nil { + t.Errorf("a file no word names was given an owner: %v", r) + } + // A file a word names restarts the runtime when it changes, as it restarted the tool container. + restarts := fmt.Sprint(process["restart-on"]) + for _, want := range []string{"dash.config", "dash." + catalogue.NeedID("token")} { + if !strings.Contains(restarts, want) { + t.Errorf("the runtime is not restarted when %s changes: %s", want, restarts) + } + } + if strings.Contains(restarts, "dash.unrelated") || strings.Contains(restarts, "dash.mesh-state") { + t.Errorf("the runtime restarts for something no word names as a file: %s", restarts) + } + + t.Run("on a machine with no account the runtime is root and nothing is re-owned", func(t *testing.T) { + out, err := catalogue.Resolution{Node: "anchor", Modules: []catalogue.Manifest{dash, theRuntime(t)}}.Declaration(with) + if err != nil { + t.Fatal(err) + } + if r := fileNamed(out, "dash.config"); r["owner"] != nil { + t.Errorf("re-owned with no account: %v", r) + } + }) + + t.Run("a change to a module's words changes the runtime's process", func(t *testing.T) { + changed := dash + changed.Bundles = append([]catalogue.Bundle(nil), dash.Bundles...) + changed.Bundles[0].Env = map[string]string{"DASH_ADMIN": "somebody-else"} + out2, err := catalogue.Resolution{Node: "anchor", Account: "ops", Modules: []catalogue.Manifest{changed, theRuntime(t)}}.Declaration(with) + if err != nil { + t.Fatal(err) + } + if fmt.Sprint(fileNamed(out2, catalogue.RuntimeModule+"."+catalogue.RuntimeProcessID())["env"]) == fmt.Sprint(env) { + t.Error("the runtime's process is the same after a module's words changed, so it would not restart") + } + }) +} + +// novox/hq 04-ISSUES/216: a bundle nothing loads, runs or unpacks is refused at registration; saying +// `loads`, listing `tools`, or a resource naming it admits it. +func TestABundleNothingDeliversIsRefused(t *testing.T) { + base := func() catalogue.Manifest { + return catalogue.Manifest{Module: "baserow", Version: "1", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{ + {Name: "tools", Kind: catalogue.ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"}}}}} + } + if p := undeliveredBundles(base()); len(p) != 1 || !strings.Contains(p[0], "never reach a machine") { + t.Fatalf("a bundle nothing delivers was admitted: %v", p) + } + loads := base() + loads.Build.Artifacts[0].Loads = []string{"tools/index.js"} + tools := base() + tools.Tools = []string{"baserow_list_rows"} + run := base() + run.Resources = []map[string]any{{"id": "daemon", "type": "process", "artifact": "tools", "run": []any{"node", "tools/index.js"}}} + runtime := base() + runtime.Module = catalogue.RuntimeModule + for name, m := range map[string]catalogue.Manifest{"loads": loads, "tools": tools, "a process": run, "the runtime": runtime} { + if p := undeliveredBundles(m); len(p) != 0 { + t.Errorf("a bundle delivered by %s was refused: %v", name, p) + } + } +} + +func TestABundlesWordsAreRefusedWhenTheyAreNotPathsOrConstants(t *testing.T) { + m := catalogue.Manifest{Module: "dash", Version: "1", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{ + {Name: "tools", Kind: catalogue.ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"}, + Env: map[string]string{"DASH_TOKEN": "${secret:token}", catalogue.RuntimeBrokerFile: "/x", "DASH_PEER": "${bound:db:url}"}}, + {Name: "runtime", Kind: catalogue.ArtifactImage, From: "Dockerfile", Env: map[string]string{"X": "y"}}, + }}} + said := strings.Join(buildProblems(m.Build, m.Module), "\n") + for _, want := range []string{ + `"tools" gives DASH_TOKEN the value "${secret:token}"`, + `"tools" gives DASH_PEER the value "${bound:db:url}"`, + `"tools" gives itself ` + catalogue.RuntimeBrokerFile, + `"runtime" is a "image" and says what it is given`, + } { + if !strings.Contains(said, want) { + t.Errorf("not refused: %s\nsaid:\n%s", want, said) + } + } +} + +func TestABusyIsHeldToItsShape(t *testing.T) { + if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})); err != nil { + t.Fatalf("a well-formed busy was refused: %v", err) + } + if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "x", "timeout": "30s"})); err != nil { + t.Fatalf("a busy with its timeout was refused: %v", err) + } + for name, c := range map[string]struct { + on string + busy any + says string + }{ + "text": {"agent", "mesh-builder busy", "it is an object"}, + "no command": {"agent", map[string]any{"timeout": "5s"}, "names no command"}, + "two lines": {"agent", map[string]any{"command": "a\nb"}, "one line"}, + "a long timeout": {"agent", map[string]any{"command": "x", "timeout": "5m"}, "at most 1m0s"}, + "an unknown key": {"agent", map[string]any{"command": "x", "every": "1m"}, `says "every"`}, + "on a step": {"seed", map[string]any{"command": "x"}, "not a container that stays up"}, + "on a service": {"daemon", map[string]any{"command": "x"}, "not a container that stays up"}, + } { + _, err := ParseManifest(busyManifest(t, c.on, c.busy)) + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: want a refusal saying %q, got %v", name, c.says, err) + } + } +} + +// The claim may say what it serves for the role instead, when the module's own tools are not the +// seat's verbs (ADR 0160). +func TestAClaimMayNameWhatItServesForTheSeat(t *testing.T) { + m := telegram() + m.Tools = []string{"telegram_send"} + m.Claims = append([]catalogue.Claim(nil), m.Claims...) + for i := range m.Claims { + if m.Claims[i].Name == "telegram-sender" { + m.Claims[i].Serves = []string{"status"} + } + } + if got := problemsFor(t, catalogue.Shelf{"telegram": m}); strings.Contains(got, "does not serve") { + t.Fatalf("a claim naming the seat's verb was refused: %s", got) + } +} + +// A claim at the wrong scope is a different seat than the one declared. +func TestAClaimMustMatchTheDeclaredScope(t *testing.T) { + m := telegram() + m.Claims = []catalogue.Claim{{Name: "telegram-sender", Scope: catalogue.ScopeNode}} + got := problemsFor(t, catalogue.Shelf{"telegram": m}) + if !strings.Contains(got, "scope") { + t.Fatalf("a claim at the wrong scope was accepted: %s", got) + } +} + +// And the same claim is fine once something declares that seat, which is the case the parser +// could not have distinguished. +func TestAClaimOnASeatAnotherModuleDeclaresIsAccepted(t *testing.T) { + claimant, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)) + if err != nil { + t.Fatal(err) + } + declarer := catalogue.Manifest{Module: "someone", DefinesSeats: []catalogue.SeatDeclaration{ + {Name: "the-anything", Scope: catalogue.ScopeNode, Accepts: []string{"work"}}, + }} + if problems := CatalogueProblems(catalogue.Shelf{claimant.Module: claimant, "someone": declarer}); len(problems) != 0 { + t.Fatalf("a claim on a declared seat was refused: %v", problems) + } + // The second of a kind (issue 1952; the lead, 2026-10-29): a holder that lists its seat's event among its own emits + // would say it where no consumer hears it, so the manifest is refused, naming the seat's subject; the seat's + // holder saying nothing of its own is accepted. + holder := func(emits string) []byte { + return []byte(`{"module":"mesh-self-check","version":"1","emits":` + emits + `,"claims":[{"name":"mesh-self-check","scope":"mesh","serves":["doctor","probes"]}]}`) + } + if _, err := ParseManifest(holder(`["checked"]`)); err == nil || !strings.Contains(err.Error(), "mesh.seat.mesh-self-check.event.checked") { + t.Errorf("a seat's event listed as the module's own: %v", err) + } + if _, err := ParseManifest(holder(`[]`)); err != nil { + t.Errorf("a holder saying its seat's event on the seat was refused: %v", err) + } +} + +// **The refusal moved, it did not go** (novox/hq ADR 0118, superseding 0110). A module may now +// declare its own seats, so whether a claimed seat exists is a fact about the *catalogue* and not +// about the manifest in front of the parser: a claim on a seat another registered module declares +// is perfectly good, and the parser cannot tell the two cases apart. So the parser accepts it and +// registration refuses it — the same guarantee, at the same moment work would otherwise start, +// from a set nobody maintains by hand. +func TestAClaimOnASeatNobodyDeclaresIsRefusedAtRegistration(t *testing.T) { + m, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)) + if err != nil { + t.Fatalf("the parser judged a claim it cannot judge alone: %v", err) + } + + problems := CatalogueProblems(catalogue.Shelf{m.Module: m}) + if len(problems) == 0 { + t.Fatal("a module invented a seat by claiming it, and registration allowed it") + } + joined := strings.Join(problems, "; ") + if !strings.Contains(joined, "the-anything") || !strings.Contains(joined, "no module declares") { + t.Fatalf("the refusal does not say the seat is nobody's: %v", problems) + } +} + +func TestAClaimThatIsMalformedIsRefusedOnceForThat(t *testing.T) { + // Not a second time for being unknown: one mistake, one line. + _, err := ParseManifest(claimed(`[{"name":"Not A Name","scope":"node"}]`)) + if err == nil { + t.Fatal("a malformed claim was accepted") + } + if strings.Contains(err.Error(), "not a seat") { + t.Fatalf("a malformed claim was also called unknown: %v", err) + } +} + +// A holder declares every path its seat names, as a resource or as a file the mesh renders for it, or +// CanHold refuses it naming the seat and each missing path — the judgement registration and handover share. +func TestAClaimWithoutTheSeatsPathsIsRefused(t *testing.T) { + seat := hostnameSeat(t) + hosts := map[string]any{"id": "hosts", "type": "file", "path": "/etc/hosts", "content": "x"} + name := map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"} + + if err := catalogue.CanHold(hostnameHolder(hosts, name), seat); err != nil { + t.Errorf("a holder declaring both paths as resources was refused: %v", err) + } + rendered := hostnameHolder(hosts) + rendered.Facts = map[string]catalogue.RosterFile{"name": {Path: "/etc/hostname", Template: "{{.Node}}\n"}} + if err := catalogue.CanHold(rendered, seat); err != nil { + t.Errorf("a holder declaring a path as a file the mesh renders was refused: %v", err) + } + err := catalogue.CanHold(hostnameHolder(), seat) + if err == nil { + t.Fatal("a holder declaring neither path was accepted") + } + for _, want := range []string{"node-hostname", "/etc/hosts", "/etc/hostname", "ADR 0287"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %s: %v", want, err) + } + } + // A file under the operator's home is placed per account: it is not the machine's path. + home := hostnameHolder(hosts) + home.Facts = map[string]catalogue.RosterFile{"name": {Path: "/etc/hostname", Home: true, Template: "x"}} + if err := catalogue.CanHold(home, seat); err == nil { + t.Error("a file in the operator's home counted as the machine's /etc/hostname") + } + // At registration, the same refusal. + if got := strings.Join(CatalogueProblems(catalogue.Shelf{"names": hostnameHolder(hosts)}), "; "); !strings.Contains(got, + "does not declare /etc/hostname") { + t.Errorf("registration let a holder without /etc/hostname through: %q", got) + } +} + +func TestACompiledBundleStillMustSayItsSystem(t *testing.T) { + b := &catalogue.Build{Artifacts: []catalogue.Artifact{{ + Name: "host", Kind: catalogue.ArtifactBundle, Language: "go", From: "cmd/mesh-host", + }}} + if len(buildProblems(b, "mesh-host")) == 0 { + t.Fatal("a compiled bundle with no system was accepted") + } +} + +func TestAContainerCannotBeBothRunOnceAndScheduled(t *testing.T) { + // A container runs once and gates, or on a cadence, or stays up — never two (novox/hq ADR 0053). + digest := "@sha256:" + strings.Repeat("a", 64) + bad := []byte(`{"module":"m","resources":[ + {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","run-once":true,"schedule":"0 3 * * *"} + ]}`) + if _, err := ParseManifest(bad); err == nil { + t.Error("a container that was both run-once and scheduled was accepted") + } else if !strings.Contains(err.Error(), "both run-once and schedule") { + t.Errorf("refused for the wrong reason: %v", err) + } +} + +func TestAContributionLocalNameMustBeUsable(t *testing.T) { + for _, bad := range []string{ + // Not a usable name. + `{"module":"minio","version":"1","requires":["route"], + "contributes":{"route":{"Not OK":{"label":"files","port":9000}}}}`, + // A local contribution with nothing in it. + `{"module":"minio","version":"1","requires":["route"], + "contributes":{"route":{"api":{}}}}`, + } { + if _, err := ParseManifest([]byte(bad)); err == nil { + t.Errorf("accepted:\n%s", bad) + } + } +} + +func TestAContributionPlaceholderOutsideTheHolderIsRefused(t *testing.T) { + raw := `{"module":"laptop","resources":[{"id":"t","type":"file","path":"/etc/t","content":"${contribution:node-hotkeys:trigger}"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "laptop does not claim node-hotkeys") { + t.Errorf("a placeholder outside the holder was accepted: %v", err) + } + raw = `{"module":"triggerhappy","claims":[{"name":"node-hotkeys"}],"resources":[{"id":"t","type":"file","path":"/etc/t","content":"${contribution:node-hotkeys:keys}"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "it receives trigger") { + t.Errorf("a placeholder for a kind the seat does not receive was accepted: %v", err) + } +} + +func TestAContributionToASeatThatDoesNotReceiveItIsRefused(t *testing.T) { + cases := map[string]string{ + `{"seat":"node-hotkeys","kind":"config","content":"x"}`: "it receives trigger", + `{"seat":"node-nothing","kind":"trigger","content":"x"}`: "the mesh does not define", + `{"seat":"node-hotkeys","kind":"trigger","content":" "}`: "has no content", + `{"seat":"node-display-session","kind":"trigger","content":"x"}`: "it receives config", + } + for c, want := range cases { + raw := `{"module":"laptop","contributions":[` + c + `]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: accepted, or refused without %q: %v", c, want, err) + } + } +} + +func TestADeclaredStoreParsesAndPassesTheCheck(t *testing.T) { + m := mustParse(t, declaredStore) + if problems := DataProblems(catalogue.Shelf{"pg": m}); len(problems) > 0 { + t.Fatalf("a store declaring its data was refused: %v", problems) + } + if !m.KeepsConsumerData("postgres-database") { + t.Fatal("an irreplaceable consumer class does not keep the consumer's data") + } + if it, _ := m.DataItem("store"); !it.BackedUp() || it.BackupWithin() != catalogue.DefaultBackupWithin || it.ActiveWithin().Hours() != 24 { + t.Fatalf("the store item reads %+v", it) + } + if it, _ := m.DataItem("dumps"); it.BackedUp() { + t.Fatal("a rebuildable item that says none is backed up") + } +} + +// The node-engine's rules, for directories and — as the engine judges them since novox/hq issue 495 — files. +func TestADefinitionIsRefusedWhereTheNodeEngineRefusesItsPaths(t *testing.T) { + refused := map[string]string{ + "a stated directory in docker's data": `{"module": "sidecar", "version": "1", "resources": [ + {"id": "volumes", "type": "directory", "path": "/var/lib/docker/volumes/x"}]}`, + "a file in containerd's data": `{"module": "images", "version": "1", "resources": [ + {"id": "f", "type": "file", "path": "/var/lib/containerd/x", "content": "x"}]}`, + "a file beneath a placed directory that climbs out of it": `{"module": "docker", "version": "1", "resources": [ + {"id": "state", "type": "directory", "place": "mesh"}, + {"id": "f", "type": "file", "path": "${dir:state}/../../containers/x", "content": "x"}]}`, + "a file in /boot": `{"module": "grub", "version": "1", "resources": [ + {"id": "cfg", "type": "file", "path": "/boot/grub/custom.cfg", "content": "x"}]}`, + "a directory that is the mesh's whole tree": `{"module": "mesh", "version": "1", "resources": [ + {"id": "all", "type": "directory", "place": "."}]}`, + "a directory that holds /etc": `{"module": "x", "version": "1", "resources": [ + {"id": "d", "type": "directory", "path": "/"}]}`, + "a directory in the node-engine's own tree, by another module": `{"module": "intruder", "version": "1", + "resources": [{"id": "d", "type": "directory", "path": "/var/lib/mesh-host/x"}]}`, + } + for name, raw := range refused { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "issue 496") { + t.Errorf("%s: accepted, or refused for another reason: %v", name, err) + } + } + + // What the engine applies, the mesh's own modules' paths among them (read from every machine's live plan): + // the machine's configuration, run directories, programs below /usr/local, the node-engine's own trees by its + // own module, an account's keys, a module's own root, and — not on the engine's lists, so not refused here — + // below /lib and at /storage, /data, /services and /var/lock. + accepted := map[string]string{ + "a unit in /etc": `{"module": "power", "version": "1", "resources": [ + {"id": "d", "type": "directory", "path": "/etc/systemd/system/x.service.d"}, + {"id": "u", "type": "file", "path": "/etc/systemd/system/x.service.d/a.conf", "content": "x"}]}`, + "a run directory": `{"module": "fail2ban", "version": "1", "resources": [ + {"id": "run-dir", "type": "directory", "path": "/var/run/fail2ban"}]}`, + "a program in /usr/local/bin": `{"module": "claude-code", "version": "1", "resources": [ + {"id": "start", "type": "file", "path": "/usr/local/bin/claude-agent", "content": "x"}]}`, + "the node-engine's launcher and state, by the node-engine": `{"module": "mesh-host", "version": "1", "resources": [ + {"id": "launcher", "type": "file", "path": "/usr/lib/nox-mesh-host/launch", "content": "x"}, + {"id": "state", "type": "directory", "path": "/var/lib/mesh-host"}]}`, + "an account's .ssh": `{"module": "ssh-client", "version": "1", "resources": [ + {"id": "ssh-dir", "type": "directory", "path": "/home/someone/.ssh"}, + {"id": "config", "type": "file", "path": "/home/someone/.ssh/config", "content": "x"}]}`, + "a module's own root": `{"module": "mailu", "version": "1", "resources": [ + {"id": "state", "type": "directory", "place": "."}]}`, + "a file below /lib": `{"module": "udev", "version": "1", "resources": [ + {"id": "rule", "type": "file", "path": "/lib/udev/rules.d/99-x.rules", "content": "x"}]}`, + "directories at /storage, /data, /services and /var/lock": `{"module": "roots", "version": "1", "resources": [ + {"id": "a", "type": "directory", "path": "/storage"}, {"id": "b", "type": "directory", "path": "/data"}, + {"id": "c", "type": "directory", "path": "/services"}, {"id": "d", "type": "directory", "path": "/var/lock"}]}`, + } + for name, raw := range accepted { + if _, err := ParseManifest([]byte(raw)); err != nil { + t.Errorf("%s: refused: %v", name, err) + } + } +} + +// A definition names no installation (novox/hq ADR 0112, ADR 0155). What the mesh acts on is judged; +// prose is not; the world's registries and resolvers are the world's; a declared exception is a +// reason a reader sees. +func TestADefinitionNamingAnInstallationIsNamedBack(t *testing.T) { + m := catalogue.Manifest{Module: "idp", Resources: []map[string]any{ + {"id": "server", "type": "container", "image": "quay.io/keycloak/keycloak@sha256:aa", + "env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}}, + {"id": "env", "type": "file", "content": "REAL_IP_FROM=192.168.1.0/24,127.0.0.0/8,203.0.113.7,51.15.22.9\n"}, + }, Listens: []catalogue.Listening{{Port: 8080, From: catalogue.FromMesh, Why: "the login page; login.mesh-one.be is a route grant"}}} + got := strings.Join(InstallationProblems(m), "\n") + for _, want := range []string{ + "idp names login.mesh-one.be at resources[0].env.KC_HOSTNAME", + "idp names the public address 51.15.22.9 at resources[1].content", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } + for _, mustNot := range []string{"quay.io", "why", "203.0.113.7", "192.168.1.0", "127.0.0.0"} { + if strings.Contains(got, mustNot) { + t.Errorf("%q was reported and should not be:\n%s", mustNot, got) + } + } +} + +func TestADeliveringSeatIsOnlyHeldByAModuleThatProvides(t *testing.T) { + // Holding it makes the module the mesh's answer for the provision. A module that cannot answer + // would be the answer anyway, and every consumer would be sent to it. + // And refused at registration, where the seat set is the store's: what a seat delivers is + // data, so a compiled copy of it may not be what refuses a build (novox/hq ADR 0122). + raw := []byte(`{"module":"thing","version":"1","claims":[{"name":"git","scope":"mesh"}]}`) + m, err := ParseManifest(raw) + if err != nil { + t.Fatalf("the parser judged what a seat delivers: %v", err) + } + got := strings.Join(CatalogueProblems(catalogue.Shelf{m.Module: m}), "; ") + if !strings.Contains(got, `does not provide "git"`) { + t.Fatalf("the refusal does not say what is missing: %q", got) + } +} + +func TestADirectoryBeneathAPlacedOneIsPlacedWithIt(t *testing.T) { + // An adopted layout keeps a subdirectory the predecessor made under the mesh's directory + // (issue 174: a forge's runtime state, a manager's output). Stated as beneath the placed one, + // it moves with it — a node's root moves both, and the definition names no host path. + m := catalogue.Manifest{Module: "gitea", Resources: []map[string]any{ + {"id": "mesh-state", "type": "directory", "place": "mesh"}, + {"id": "runtime-state", "type": "directory", "path": "${dir:mesh-state}/state"}, + {"id": "server", "type": "container", "image": "x@sha256:aa", + "volumes": []any{"${dir:runtime-state}:/data"}}, + }} + if got := manifestUnknownDirRefs(m); len(got) != 0 { + t.Fatalf("a path beneath a placed directory is well formed; got %v", got) + } + dirs := dirsFor(m, catalogue.Rendering{DataRoot: "/srv"}) + if dirs["runtime-state"] != "/srv/mesh/gitea/state" { + t.Fatalf("the subdirectory follows the placed one; got %v", dirs) + } + +} + +func TestADirectoryPlacedInDockersDataIsRefusedWhereTheDefinitionIsJudged(t *testing.T) { + // The #205 shape, as it was merged. + _, err := ParseManifest([]byte(`{"module": "docker", "version": "1", + "resources": [{"id": "state", "type": "directory", "place": "."}]}`)) + if err == nil { + t.Fatal("a directory resolving to /var/lib/docker was accepted; the node-engine refuses it at apply") + } + for _, said := range []string{"docker", `"state"`, "/var/lib/docker", "issue 496"} { + if !strings.Contains(err.Error(), said) { + t.Errorf("the refusal names the module, the resource, the path and why; %q is missing from %q", said, err) + } + } +} + +func TestAFileNamingBothATemplateAndContentIsRefused(t *testing.T) { + raw := strings.Replace(templatedTwin, `"template":"files/tidy.conf"`, + `"template":"files/tidy.conf","content":"x"`, 1) + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "main") || !strings.Contains(err.Error(), "never both") { + t.Fatalf("a file with both a template and content was not refused by name: %v", err) + } +} + +// A file that asks for a setting says whether what it asks is trusted (novox/hq issue 339): `trusted` is a +// boolean, on a file alone, and a file asking for a setting without it is named. +func TestAFileSaysWhetherItsSettingsAreTrusted(t *testing.T) { + m := catalogue.Manifest{Module: "power", Resources: []map[string]any{ + {"id": "said", "type": "file", "path": "/etc/a", "trusted": true, "content": "${setting:a}"}, + {"id": "unsaid", "type": "file", "path": "/etc/b", "content": "${setting:b}"}, + {"id": "no-setting", "type": "file", "path": "/etc/c", "content": "plain"}}} + if got := strings.Join(catalogue.UnsaidTrust(m), ","); got != "unsaid" { + t.Errorf("unsaid: %s; want unsaid", got) + } + // A mergeable file that names keys asks for them, so it is named too; one that names none asks for nothing. + merged := catalogue.Manifest{Module: "agent", Resources: []map[string]any{ + {"id": "settings", "type": "file", "path": "/a", "merge": catalogue.MergeJSON, "content": `{"managed_settings": {}}`}, + {"id": "blank", "type": "file", "path": "/b", "merge": catalogue.MergeJSON, "content": `{}`}}} + if got := strings.Join(catalogue.UnsaidTrust(merged), ","); got != "settings" { + t.Errorf("unsaid: %s; want settings", got) + } + for _, bad := range []map[string]any{ + {"id": "x", "type": "file", "path": "/etc/x", "trusted": "yes", "content": "${setting:a}"}, + {"id": "y", "type": "directory", "trusted": true}, + } { + if problems := TrustProblems(catalogue.Manifest{Module: "power", Resources: []map[string]any{bad}}); len(problems) == 0 { + t.Errorf("%v was taken", bad) + } + } +} + +func TestAGoBundleMayNameItsCommand(t *testing.T) { + b := &catalogue.Build{Artifacts: []catalogue.Artifact{{ + Name: "host-arch", Kind: catalogue.ArtifactBundle, Language: "go", System: "arch", + From: "cmd/mesh-host", + }}} + if p := buildProblems(b, "mesh-host"); len(p) != 0 { + t.Fatalf("a go bundle naming its command was refused: %v", p) + } +} + +// novox/hq ADR 0193: a Go tools bundle is served — its binary is what the runtime starts, delivered +// like any tools bundle, named to the runtime where a TypeScript bundle names its launcher. +func TestAGoToolsBundleIsServedByItsBinary(t *testing.T) { + with := catalogue.Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{catalogue.RuntimeModule: {"broker": "sealed-credential"}}} + lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"}, + Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle, Language: "go", + System: "arch", From: "cmd/lamp-tools"}}}} + if p := buildProblems(lamp.Build, "lamp"); len(p) != 0 { + t.Fatalf("a Go tools bundle was refused: %v", p) + } + lamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle, + Reference: catalogue.ArtifactStoreScheme + "lamp/tools/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + if fmt.Sprint(lamp.Bundles[0].Loads) != "[lamp-tools]" { + t.Fatalf("the runtime loads %v from a Go bundle, want its binary", lamp.Bundles[0].Loads) + } + out, err := catalogue.Resolution{Node: "anchor", Account: "ops", Modules: []catalogue.Manifest{lamp, theRuntime(t)}}.Declaration(with) + if err != nil { + t.Fatal(err) + } + if fileNamed(out, "lamp."+catalogue.BundleID("tools")) == nil { + t.Errorf("the Go bundle is not delivered: %v", ids(out)) + } + env := fileNamed(out, catalogue.RuntimeModule+"."+catalogue.RuntimeProcessID())["env"].(map[string]string) + if env[catalogue.RuntimeToolModules] != "lamp="+catalogue.BundlePath("lamp", "tools")+"/lamp-tools" { + t.Errorf("the runtime is told %q, want the binary", env[catalogue.RuntimeToolModules]) + } + + // An artifact may say it explicitly; naming anything but the binary is refused. + said := catalogue.Manifest{Module: "lamp", Version: "1", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", + Kind: catalogue.ArtifactBundle, Language: "go", System: "arch", Binary: "lamp", Loads: []string{"lamp"}}}}} + if p := buildProblems(said.Build, "lamp"); len(p) != 0 { + t.Errorf("loads naming the binary was refused: %v", p) + } + said.Build.Artifacts[0].Loads = []string{"tools/index.js"} + if p := buildProblems(said.Build, "lamp"); len(p) == 0 { + t.Error("a Go bundle loading a file it does not contain was admitted") + } +} + +func TestAGuardedPortMustBeAPort(t *testing.T) { + if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[5432]}`)); err != nil { + t.Fatalf("guards is refused: %v", err) + } + if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[0]}`)); err == nil { + t.Fatal("guarding port 0 was accepted") + } +} + +// Each entry names the credential form the provider's offer declares (issue 768), from the provider's manifest, so +// the vault makes a first value in that form for a provider it holds no row of: one of its own secrets shared by +// every consumer, a login per consumer when the offer says no credential, issued by its software, or given by a +// person (the offer's own secret issued outside). +func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.T) { + storeOffering := func(credential string) catalogue.Manifest { + raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` + + credential + `}],"serves":{"postgres-database":{"port":5432}}` + if strings.Contains(credential, `"own"`) { + raw += `,"own-secrets":{"admin":{"path":"/var/lib/store/admin","taken":"at-start"},` + + `"token":{"path":"/var/lib/store/token","taken":"at-start","issued-by":"outside"}}` + } + return mustParse(t, raw+`}`) + } + for credential, want := range map[string]string{ + // No default (the lead, 2026-10-29): an offer that says nothing has no form, and the catalogue refuses it. + ``: "", + `,"credential":{"form":"pair"}`: catalogue.CredentialFormPair, + `,"credential":{"form":"none"}`: catalogue.CredentialFormNone, + `,"credential":{"own":"admin"}`: catalogue.CredentialFormShared, + `,"credential":{"own":"token"}`: catalogue.CredentialFormGiven, + `,"credential":{"issued-by":"outside"}`: catalogue.CredentialFormGiven, + `,"credential":{"issued-by":"software","tool":"store_issued_token"}`: catalogue.CredentialFormIssued, + } { + node := workstation() + node.At = "workstation.internal" + consumer := notifierConsumer(t, true, false) + r, err := catalogue.Resolve(shelf(consumer, storeOffering(credential)), []string{consumer.Module}, node, twoStores("anchor")) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + c, _ := composeWith(t, r, handingTo("store:5000")) + got := c.Plans["notify"].Provisions + if len(got) != 1 || got[0].CredentialForm != want { + t.Errorf("an offer with %q: the plan names %+v, want the form %q", credential, got, want) + } + // Every form a plan names is in the one list each reader checks (issue 774); an undeclared one is not. + if catalogue.IsCredentialForm(want) != (want != "") { + t.Errorf("the form %q of an offer with %q: in CredentialForms %v", want, credential, catalogue.IsCredentialForm(want)) + } + } + if forms := strings.Join(catalogue.CredentialForms(), ","); forms != "shared,pair,issued,given,none" || catalogue.IsCredentialForm("maybe") { + t.Errorf("the credential forms are %s, and maybe is one: %v", forms, catalogue.IsCredentialForm("maybe")) + } + if got := (catalogue.Manifest{Module: "x"}).CredentialFormOf("postgres-database"); got != "" { + t.Errorf("a module offering nothing has the credential form %q", got) + } + // The catalogue refuses an offer saying no form, and a form beside an own secret or not pair or none. + for credential, refused := range map[string]string{``: "says no credential form", `,"credential":{}`: "says no credential form", + `,"credential":{"form":"pair","own":"admin"}`: "beside an own secret", `,"credential":{"form":"maybe"}`: `not "pair" or "none"`, + `,"credential":{"form":"none"}`: ""} { + raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` + credential + `}]}` + var m catalogue.Manifest + _ = json.Unmarshal([]byte(raw), &m) + problems := strings.Join(CredentialFormProblems(m), "; ") + if (refused == "") != (problems == "") || !strings.Contains(problems, refused) { + t.Errorf("%q: %q, want %q", credential, problems, refused) + } + } +} + +// A holder that does not answer what the seat promises is a caller's timeout, found here instead. +func TestAHolderMustServeWhatItsSeatPromises(t *testing.T) { + m := telegram() + m.Tools = nil // declares the seat, serves none of it + got := problemsFor(t, catalogue.Shelf{"telegram": m}) + if !strings.Contains(got, "does not serve status") { + t.Fatalf("a holder was accepted that answers nothing its seat promises: %s", got) + } +} + +// issue 900, ADR 0394 §2: a holder of node-files runs as the tool runner's account, so it declares no runs-as. +func TestAHolderOfNodeFilesDeclaringRunsAsIsRefused(t *testing.T) { + p := FilesHolderProblems(catalogue.Manifest{Module: "files", RunsAs: "mesh-files", Claims: []catalogue.Claim{{Name: catalogue.FilesSeat}}}) + if len(p) != 1 || !strings.Contains(p[0], "0394") || !strings.Contains(p[0], "runs-as") { + t.Errorf("a holder declaring runs-as was not refused: %v", p) + } + if p := FilesHolderProblems(catalogue.Manifest{Module: "files", Claims: []catalogue.Claim{{Name: catalogue.FilesSeat}}}); len(p) != 0 { + t.Errorf("a holder declaring none was refused: %v", p) + } + if p := FilesHolderProblems(catalogue.Manifest{Module: "other", RunsAs: "x"}); len(p) != 0 { + t.Errorf("a non-holder was judged: %v", p) + } +} + +// novox/hq ADR 0310 §3: a holder of node-reads reads as an account of its own; one the machine's runtime would +// carry — as the operator's account — is refused at parse. +func TestAHolderOfNodeReadsWithoutAnAccountOfItsOwnIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module": "reads", "version": "1", + "claims": [{"name": "node-reads", "scope": "node"}]}`)) + if err == nil || !strings.Contains(err.Error(), "runs-as") || !strings.Contains(err.Error(), "0310") { + t.Errorf("a holder of node-reads without runs-as was not refused for it: %v", err) + } + if p := ReadsHolderProblems(catalogue.Manifest{Module: "reads", RunsAs: "mesh-reads", + Claims: []catalogue.Claim{{Name: catalogue.ReadsSeat}}}); len(p) != 0 { + t.Errorf("a holder running as its own account was refused: %v", p) + } +} + +func TestAHolderPlacesEveryCombinationOnceWithATemplateThatRendersEveryExample(t *testing.T) { + holder := func(template string, contents ...string) string { + var res []string + for i, c := range contents { + b, _ := json.Marshal(c) + res = append(res, `{"id":"f`+string(rune('a'+i))+`","type":"file","path":"/f`+string(rune('a'+i))+`","content":`+string(b)+`}`) + } + tb, _ := json.Marshal(template) + renders := "" + if template != "" { + renders = `,"renders":{"block":` + string(tb) + `}` + } + return `{"module":"a-bar","claims":[{"name":"node-bar"` + renders + `}],"resources":[` + strings.Join(res, ",") + `]}` + } + all := "${contribution:node-bar:block:bar=bottom,place=resources}${contribution:node-bar:block:bar=bottom,place=status}" + + "${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status}" + cases := map[string]string{ + holder(barTemplate, "${contribution:node-bar:block:bar=bottom}"): "never places node-bar:block for bar=top,place=resources", + holder(barTemplate, all, "${contribution:node-bar:block:place=status}"): "for bar=bottom,place=status 2 times", + holder("", all): "gives no template", + holder(`{{if eq .shows "battery"}}b{{end}}`, all): "renders nothing", + holder(`{{.nothing}}`, all): "renders nothing", + holder(`{{if}`, all): "does not parse", + holder(barTemplate, "${contribution:node-bar:block:bar=left}"+all): "bar is one of bottom, top", + holder(barTemplate, "${contribution:node-bar:block:colour=red}"+all): "placed by bar, place", + holder(barTemplate, "${contribution:node-hotkeys:trigger:bar=top}"): "does not claim node-hotkeys", + } + for raw, want := range cases { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s:\naccepted, or refused without %q: %v", raw, want, err) + } + } + for _, raw := range []string{holder(barTemplate, all), holder(barTemplate, "${contribution:node-bar:block}"), + // A holder that places no block at all is older than the kind, and is not refused for it. + holder("", "no blocks here")} { + if _, err := ParseManifest([]byte(raw)); err != nil { + t.Errorf("%s: refused: %v", raw, err) + } + } +} + +// A jail with no name, no pattern, or a name another jail of the same module already took is refused. +func TestAJailIsNamedOnceAndSaysWhatAFailureLooksLike(t *testing.T) { + m := catalogue.Manifest{Module: "mailu", Jails: []catalogue.Jail{ + {Name: "", Failregex: "^.*rip=$"}, + {Name: "mailu-front", Failregex: " "}, + {Name: "mailu-front", Failregex: "^.*rip=$"}, + }} + problems := strings.Join(manifestJailProblems(m), "\n") + for _, want := range []string{"a jail with no name", "says nothing a failed attempt looks like", "two jails called"} { + if !strings.Contains(problems, want) { + t.Errorf("missing %q in:\n%s", want, problems) + } + } +} + +// A jail whose pattern names twice is refused by name: fail2ban would read it as a duplicate +// capture group and refuse its whole configuration, leaving the machine with no bans at all — which +// is what happened on the control node the day this was built (novox/hq ADR 0179). +func TestAJailsPatternNamesTheHostOncePerShape(t *testing.T) { + twice := catalogue.Manifest{Module: "route-proxy", Jails: []catalogue.Jail{{ + Name: "route-proxy", + Failregex: `^.*(?:refused for :\d+|asked from :\d+)$`, + Jail: "backend = systemd", + }}} + problems := manifestJailProblems(twice) + if len(problems) != 1 || !strings.Contains(problems[0], "names 2 times") { + t.Fatalf("a pattern naming twice was not refused by name: %v", problems) + } + if !strings.Contains(problems[0], "no bans at all") { + t.Errorf("the refusal does not say what it costs: %q", problems[0]) + } + + // The same two shapes, one pattern each, is how it is written — and is accepted. + split := catalogue.Manifest{Module: "route-proxy", Jails: []catalogue.Jail{{ + Name: "route-proxy", + Failregex: "^.*refused for :\\d+$\n ^.*asked from :\\d+$", + Jail: "backend = systemd", + }}} + if problems := manifestJailProblems(split); len(problems) != 0 { + t.Fatalf("one per pattern was refused: %v", problems) + } +} + +// The operator's own value is told by what a key's name is about: its last word, or its last two as +// a known compound; a plural as its singular; and never a number or a switch. +func TestAKeyIsTheOperatorsByWhatItIsAbout(t *testing.T) { + for _, key := range []string{"max-tokens", "show-hostname", "ghost-opacity", "users-per-page", "mailbox-size", + "font-size", "width", "keyboard-delay", "ipv6-preferred", "client-width", "user-agent", "url-timeout", + "site-title", "cert-renewal-days", "name", "font-name", "allow-resize", "use-gpu", "disable-sender-check", + "max-recipients", "gateway-timeout", "sender-delay", "upstream-resolvers", "mirror-countries", + "pool-region", "proxy-timeout", "listen-backlog", "peer-keepalive", "admin-theme", "log-file", + "cache-path"} { + if w := operatorsWord(key); w != "" { + t.Errorf("%s read as the operator's (%s)", key, w) + } + } + for key, word := range map[string]string{"mail-domain": "domain", "api-key": "key", "admin-password": "password", + "oauth-client-id": "client-id", "db-dsn": "dsn", "public-ip": "ip", "puid": "puid", "dns-zone": "zone", + "webhook": "webhook", "notify_phone": "phone", "cors.origin": "origin", "smtp-pass": "pass", + "backup-passphrase": "passphrase", "data-owner": "owner", "fqdn": "fqdn", "tenant": "tenant", "host": "host", + "allowed-hosts": "host", "admin-emails": "email", "tokens": "token", "hostname": "hostname", + "apikey": "apikey", "servername": "servername", "tls-cert": "cert", "site": "site", "timezone": "timezone", + "bearer": "bearer", "bind-ipv4": "ipv4", "listen-ipv6": "ipv6", "site-name": "site-name", + "server-name": "server-name", "public-name": "public-name", "smtp-relay": "smtp-relay", + "host-name": "host-name", "user-name": "user-name", "domain-name": "domain-name", + "nameserver": "nameserver", "upstream-nameservers": "nameserver", "dns-server": "dns-server", + "dns-servers": "dns-server", "default-gateway": "gateway", "lan-subnet": "subnet", "sender": "sender", + "notify-recipients": "recipient", "contact": "contact", "tls-certificate": "certificate", + "allow-from": "allow-from", "deny-from": "deny-from", "allow-hosts": "host", "use-host": "host", + "trusted": "trusted", "allow-list": "allow-list", "ip-whitelist": "whitelist", "peer": "peer", + "wireguard-peers": "peer", "bind": "bind", "listen": "listen", "upstream": "upstream", "http-proxy": "proxy", + "trusted-proxies": "proxy", "admin": "admin", "notify-admin": "admin", "wake-mac": "mac", + "password-file": "password-file", "key-file": "key-file", "token-path": "token-path", + "secret-file": "secret-file", "cert-path": "cert-path"} { + if w := operatorsWord(key); w != word { + t.Errorf("%s: read %q, want %q", key, w, word) + } + } +} + +func TestAKindedBenchNeedsAKindAndNoOtherSeatTakesOne(t *testing.T) { + got := problemsFor(t, catalogue.Shelf{"messenger": router(), "nameless": aChannel("nameless", "")}) + if !strings.Contains(got, "claims the kinded bench channel and names no kind") { + t.Fatalf("a claim without a kind stood: %s", got) + } + odd := catalogue.Manifest{Module: "odd", Claims: []catalogue.Claim{{Name: "operator-channel", Scope: catalogue.ScopeMesh, Kind: "telegram"}}} + got = problemsFor(t, catalogue.Shelf{"messenger": router(), "odd": odd}) + if !strings.Contains(got, "only a kinded bench takes a kind") { + t.Fatalf("a kind on a seat that is not kinded stood: %s", got) + } + dotted := problemsFor(t, catalogue.Shelf{"messenger": router(), "dotted": aChannel("dotted", "a.b")}) + if !strings.Contains(dotted, "not a usable name") { + t.Fatalf("a kind that would widen a subject stood: %s", dotted) + } +} + +// A local name names one credential: two requirements may not share it (review C2). +func TestALocalNameIsUniqueAcrossRequirements(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"x","version":"1","requires":["secret","postgres-database"], + "secrets":{"secret":{"x":"/var/lib/x/a"},"postgres-database":{"x":"/var/lib/x/b"}}}`)) + if err == nil || !strings.Contains(err.Error(), "both under") { + t.Fatalf("two requirements under one local name were accepted: %v", err) + } +} + +func TestALocalNameMayNotCollideWithWhatTheModuleAlreadyCallsSomething(t *testing.T) { + for _, bad := range []string{ + // One of the module's own secrets. + `{"module":"ca","version":"1","requires":["secret"],"own-secrets":{"root-key":"/var/lib/ca/own"}, + "secrets":{"secret":{"root-key":"/var/lib/ca/root.key"}}}`, + // Something it requires. + `{"module":"ca","version":"1","requires":["secret","postgres-database"], + "secrets":{"secret":{"postgres-database":"/var/lib/ca/x"}}}`, + // Not a usable name. + `{"module":"ca","version":"1","requires":["secret"],"secrets":{"secret":{"Root Key":"/var/lib/ca/x"}}}`, + // A relative path. + `{"module":"ca","version":"1","requires":["secret"],"secrets":{"secret":{"root-key":"root.key"}}}`, + } { + if _, err := ParseManifest([]byte(bad)); err == nil { + t.Errorf("accepted:\n%s", bad) + } + } +} + +// The catalogue check refuses a log outside the caps, with a name that is not one plain token, with a +// field the mesh does not know, or kept by a module whose own name is not one plain token. +func TestALogIsNamedLocallyAndCapped(t *testing.T) { + for _, c := range []struct{ manifest, says string }{ + {`{"module":"a","version":"1","logs":["mesh.changes"]}`, `keeps log "mesh.changes": a log is named locally`}, + {`{"module":"a","version":"1","logs":["my_changes"]}`, `keeps log "my_changes"`}, + {`{"module":"a","version":"1","logs":["Changes"]}`, `keeps log "Changes"`}, + {`{"module":"a","version":"1","logs":["c","c"]}`, `keeps log "c" twice`}, + {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":8193}]}`, `between 1 and 8192 MiB`}, + {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`}, + {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`}, + {`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib, max-age-days}`}, + {`{"module":"a","version":"1","logs":[{"name":"c","max-age-days":0}]}`, `max-age-days is between 1 and 3650, not 0`}, + {`{"module":"a.b","version":"1","logs":["c"]}`, `no dot`}, + } { + _, err := ParseManifest([]byte(c.manifest)) + if err == nil { + t.Errorf("%s was accepted", c.manifest) + continue + } + if !strings.Contains(err.Error(), c.says) { + t.Errorf("%s refused for the wrong reason: %v", c.manifest, err) + } + } +} + +func TestAMaintenanceWindowIsRefusedWhereTheDefinitionShowsItCannotMean(t *testing.T) { + for _, c := range []struct { + name string + step map[string]any + says string + }{ + { + "on a step with no schedule", + map[string]any{"id": "collect", "type": "container", "name": "c", + "image": "registry@sha256:" + strings.Repeat("a", 64), + "while-stopped": []any{"store"}}, + "gates what is declared after it", + }, + { + "on a run-once step, which already has order", + map[string]any{"id": "collect", "type": "container", "name": "c", + "image": "registry@sha256:" + strings.Repeat("a", 64), + "run-once": true, "while-stopped": []any{"store"}}, + "A maintenance window is for a recurring step", + }, + { + "naming a container this module does not declare", + map[string]any{"id": "collect", "type": "container", "name": "c", + "image": "registry@sha256:" + strings.Repeat("a", 64), + "schedule": "30 3 * * *", "while-stopped": []any{"the-broker"}}, + "could quiesce a neighbour could stop the mesh", + }, + { + "naming itself", + map[string]any{"id": "collect", "type": "container", "name": "c", + "image": "registry@sha256:" + strings.Repeat("a", 64), + "schedule": "30 3 * * *", "while-stopped": []any{"collect"}}, + "naming itself", + }, + { + "written as something that is not a list", + map[string]any{"id": "collect", "type": "container", "name": "c", + "image": "registry@sha256:" + strings.Repeat("a", 64), + "schedule": "30 3 * * *", "while-stopped": "store"}, + "a list of this module's container ids", + }, + } { + _, err := ParseManifest(aStoreManifest(c.step)) + if err == nil { + t.Errorf("%s was accepted", c.name) + continue + } + if !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: the refusal does not say %q:\n%v", c.name, c.says, err) + } + } +} + +// What ADR 0203 §2 allows a contribution to say, refused at parse when it says anything else. +func TestAMalformedEnvironmentIsRefusedAtParse(t *testing.T) { + for _, c := range []struct{ environment, want string }{ + {`{"variables":{"1X":"a"}}`, `tool sets the variable "1X", which is not a name a shell accepts`}, + {`{"variables":{"MY-VAR":"a"}}`, `tool sets the variable "MY-VAR", which is not a name a shell accepts`}, + {`{"variables":{"PATH":"/bin"}}`, `tool sets PATH as a variable; a module adds an entry under environment.path`}, + {`{"variables":{"A":"$HOME/x"}}`, `tool sets A to "$HOME/x", which holds a $ that is not one of the machine's ${machine:…} facts`}, + {`{"variables":{"A":"${HOME}/x"}}`, `tool sets A to "${HOME}/x", which holds a $`}, + {`{"variables":{"A":"it's"}}`, `tool sets A to "it's", which holds a quote`}, + {`{"variables":{"A":"say \"hi\""}}`, `which holds a quote`}, + {`{"variables":{"A":"a\\b"}}`, `which holds a backslash`}, + {`{"variables":{"A":"a\nb"}}`, `which holds a line break`}, + {`{"variables":{"A":"a\u0000b"}}`, `which holds a NUL`}, + {`{"path":[{"entry":"","at":"start"}]}`, `tool's PATH entry 1 names no directory`}, + {`{"path":[{"entry":"/a:/b","at":"start"}]}`, `tool puts "/a:/b" on PATH, which holds a colon`}, + {`{"path":[{"entry":"$HOME/bin","at":"start"}]}`, `tool puts "$HOME/bin" on PATH, which holds a $`}, + {`{"path":[{"entry":"/a","at":"middle"}]}`, `tool puts "/a" on PATH at "middle"; an entry goes at "start" or "end"`}, + {`{"path":[{"entry":"/a"}]}`, `tool puts "/a" on PATH at ""`}, + {`{"path":[{"entry":"/a","at":"start"},{"entry":"/a","at":"end"}]}`, `tool puts "/a" on PATH twice`}, + {`{"variables":{"A":"x"},"paths":[]}`, `unknown field "paths"`}, + } { + _, err := ParseManifest([]byte(`{"module":"tool","environment":` + c.environment + `}`)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: want %q, got %v", c.environment, c.want, err) + } + } + // What is allowed: a literal, and the machine's own facts. + if _, err := ParseManifest([]byte(`{"module":"tool","environment":{` + + `"variables":{"GOPATH":"${machine:account-home}/go","DISABLE_X":"1","ANSWER":"a b+c=d"},` + + `"path":[{"entry":"${machine:account-home}/go/bin","at":"start"},{"entry":"/opt/x","at":"end"}]}}`)); err != nil { + t.Fatalf("a well-formed environment was refused: %v", err) + } +} + +func TestAMalformedFamilyIsRefused(t *testing.T) { + for name, c := range map[string]struct{ own, says string }{ + "made by the mesh": {`"smb-password-*":{"path":"/s/smb-password-*.secret","taken":"at-start"}`, "issued-by"}, + "no star in its path": {`"smb-password-*":{"path":"/s/smb-password.secret","issued-by":"outside"}`, + "exactly one *"}, + "two stars in its path": {`"smb-password-*":{"path":"/s/*/smb-password-*.secret","issued-by":"outside"}`, + "exactly one *"}, + "a star inside a name": {`"smb*password":{"path":"/s/x","issued-by":"outside"}`, "only ends a family"}, + "a name that is also a member": {`"smb-password-*":{"path":"/s/p-*","issued-by":"outside"}, + "smb-password-games":{"path":"/s/games","issued-by":"outside"}`, "also a member"}, + } { + if _, err := familyManifest(t, c.own); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v; want a refusal saying %q", name, err, c.says) + } + } +} + +// A prompt that is not one short line naming only {member} is refused by the module check (novox/hq issue 789). +func TestAMalformedPromptIsRefused(t *testing.T) { + for name, c := range map[string]struct{ prompt, says string }{ + "a newline": {"the password of\nthe mail account {member}", "one line"}, + "over 120 characters": {"the password of the mail account {member}, which this mesh needs to log in to " + + "send and receive mail for the operator's account on this machine today", "120 characters"}, + "a placeholder other than {member}": {"the password of the mail account {name}", "{member}"}, + "two placeholders, one unknown": {"the {kind} secret of the mail account {member}", "{member}"}, + } { + _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ + "mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside", + "prompt":` + jsonString(c.prompt) + `}}}`)) + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v; want a refusal saying %q", name, err, c.says) + } + } +} + +func TestAMalformedScheduleIsRefused(t *testing.T) { + digest := "@sha256:" + strings.Repeat("a", 64) + bad := []byte(`{"module":"m","resources":[ + {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"every night"} + ]}`) + if _, err := ParseManifest(bad); err == nil { + t.Error("a malformed schedule was accepted") + } else if !strings.Contains(err.Error(), "cron") { + t.Errorf("refused for the wrong reason: %v", err) + } + + // A schedule out of range is refused for the same reason, near its author. + outOfRange := []byte(`{"module":"m","resources":[ + {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"0 25 * * *"} + ]}`) + if _, err := ParseManifest(outOfRange); err == nil { + t.Error("a schedule with an hour out of range was accepted") + } + + good := []byte(`{"module":"m","resources":[ + {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"*/15 * * * *"} + ]}`) + if _, err := ParseManifest(good); err != nil { + t.Errorf("a valid scheduled container was refused: %v", err) + } +} + +// A manifest naming font-size registers against the list; one naming a key not on it, or of another type, is +// refused — judged against the list given, whatever the mesh published. +func TestAManifestIsJudgedAgainstTheListOfItsTree(t *testing.T) { + withPredefined(t, nil) + list, err := catalogue.ParsePredefined([]byte(theFirstKeys)) + if err != nil { + t.Fatal(err) + } + if _, err := ParseManifestWith(fallingBackTo(t, "font-size"), list); err != nil { + t.Fatalf("a value falling back to font-size is refused: %v", err) + } + if _, err := ParseManifestWith(fallingBackTo(t, "font-scale"), list); err == nil || + !strings.Contains(err.Error(), `"font-scale", which is not on the list`) { + t.Fatalf("a key not on the list: %v", err) + } + m := inConfigForm() + declaration(m, "theme")["predefined"] = "font-size" + raw, _ := json.Marshal(m) + if _, err := ParseManifestWith(raw, list); err == nil || !strings.Contains(err.Error(), "which is a number") { + t.Fatalf("a key of another type: %v", err) + } + // Where no tree is read, the published list judges: none published refuses. + if _, err := ParseManifest(fallingBackTo(t, "font-size")); err == nil || !strings.Contains(err.Error(), "is not present") { + t.Fatalf("judged against no list: %v", err) + } + withPredefined(t, list) + if _, err := ParseManifest(fallingBackTo(t, "font-size")); err != nil { + t.Fatalf("judged against the published list: %v", err) + } +} + +// A manifest key nothing reads is worse than a key that is refused: five HAL manifests carried +// `scope:`, which read as a restriction and restricted nothing (novox/hq 04-ISSUES/003). The host's +// declaration parser has always refused unknown keys; manifests never did. +func TestAManifestKeyNothingReadsIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"web","scope":"mesh"}`)) + if err == nil { + t.Fatal("a manifest with a key nothing reads was accepted, which is how scope: survived") + } + if !strings.Contains(err.Error(), "scope") { + t.Fatalf("refused without naming the key, which leaves the author guessing: %v", err) + } +} + +// `local-network` is a source a manifest may name (§1, changing ADR 0045 §3), and an unknown source is +// still refused. +func TestAManifestMayListenFromTheLocalNetwork(t *testing.T) { + if _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1", + "listens":[{"port":1883,"from":"local-network"}]}`)); err != nil { + t.Fatalf("a listen from the local network is refused: %v", err) + } + _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1", + "listens":[{"port":1883,"from":"lan"}]}`)) + if err == nil || !strings.Contains(err.Error(), `from "lan"`) { + t.Fatalf("a source the mesh cannot render is not refused: %v", err) + } +} + +// A module uses one form or the other, never both. +func TestAManifestMixingTheConfigAndSettingsFormsIsRefused(t *testing.T) { + m := inConfigForm() + resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${setting:width}\nfrom = ${config:sender}\n" + refused(t, m, "mixes", "${setting:", "config") + + m = inConfigForm() + delete(resourceOf(m, "rules"), "takes-config") + resourceOf(m, "rules")["takes-settings"] = []any{"position", "theme"} + refused(t, m, "mixes", "takes-settings") + + m = inConfigForm() + m["settings"] = map[string]any{"colour": map[string]any{"kind": "preference", "default": "red", "why": "red"}} + refused(t, m, "mixes", "settings") +} + +func TestAManifestProvidingAmqpIsRefused(t *testing.T) { + raw := []byte(`{"module":"old-broker","version":"1","provides":[{"name":"amqp","scope":"mesh"}]}`) + _, err := ParseManifest(raw) + if err == nil || !strings.Contains(err.Error(), `provides "amqp", which is not a provision`) { + t.Fatalf("a module providing amqp was not refused, or not for the reason: %v", err) + } +} + +func TestAManifestRequiringAmqpIsRefused(t *testing.T) { + raw := []byte(`{"module":"forwarder","version":"1","requires":["amqp"]}`) + _, err := ParseManifest(raw) + if err == nil || !strings.Contains(err.Error(), `requires "amqp", which is not a provision`) { + t.Fatalf("a module requiring amqp was not refused, or not for the reason: %v", err) + } +} + +// A manifest written against the old name is told what the field became. +// +// `needs` and `secrets` were both name-to-path and differed only in whose secret it was, so +// reaching for the wrong one parsed cleanly and failed somewhere else entirely. Refusing the old +// name with "unknown field" would be correct and unhelpful: whoever wrote it knew what they meant, +// and the mesh knows what it is called now. +func TestAManifestUsingTheOldNameIsToldTheNewOne(t *testing.T) { + _, err := ParseManifest([]byte( + `{"module":"postgres","version":"1","needs":{"superuser":"/var/lib/superuser"}}`)) + if err == nil { + t.Fatal("a manifest using the old name was accepted, so two fields now mean one thing") + } + for _, want := range []string{"needs", "own-secrets"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal does not mention %q: %v", want, err) + } + } +} + +// A manifest in the config form registers with the group a config value fills, and the value is placed (a container's +// groups count as a place for it); a placeholder in any other shape is refused where the manifest is read. +func TestAManifestWhoseContainerTakesItsGroupFromConfigRegisters(t *testing.T) { + m := map[string]any{ + "module": "n8n", "version": "1", + "config": map[string]any{"media-gid": map[string]any{"type": "number", "minimum": 1, "required": true, + "description": "the group that owns the media library on this machine"}}, + "resources": []any{aContainerWithGroups("${config:media-gid}")}, + } + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("refused: %v", err) + } + for _, shape := range []string{"x${config:media-gid}", "${config:media-gid}:1", "${config:other}"} { + m["resources"] = []any{aContainerWithGroups(shape)} + if _, err := parseRaw(t, m); err == nil { + t.Errorf("groups %q registered", shape) + } + } +} + +func TestAManifestWithoutUnhealthyIsNotUrgent(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"notes","version":"1"}`)) + if err != nil { + t.Fatal(err) + } + if m.UnhealthyIsUrgent() { + t.Errorf("a manifest saying nothing of its unhealthy reads as urgent") + } + warning := catalogue.Manifest{Module: "notes", Unhealthy: &catalogue.UnhealthyPolicy{Severity: catalogue.UnhealthyWarning, Why: "notes wait"}} + if warning.UnhealthyIsUrgent() || len(manifestUnhealthyProblems(warning)) != 0 { + t.Errorf("a warning said with why reads as urgent or is refused: %v", manifestUnhealthyProblems(warning)) + } +} + +func TestAMapReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) { + m := catalogue.Manifest{ + Module: "photos", + Resources: []map[string]any{{"id": "state", "type": "directory", "place": "."}}, + Binds: map[string]string{"route": "${dir:stat}/route.json"}, + } + problems := manifestUnknownDirRefs(m) + if len(problems) != 1 || !strings.Contains(problems[0], `${dir:stat}`) { + t.Fatalf("a map naming no directory is a manifest problem; got %v", problems) + } +} + +// The catalogue check — the strict parse registration runs too — refuses each by name, with the +// module and the field it stands in, and says nothing about the shell's own syntax. +func TestAMisspelledPlaceholderIsRefusedAtTheCheck(t *testing.T) { + raw := `{"module":"speller","version":"1","resources":[{"id":"rc","type":"file","path":"/etc/speller.rc",` + + `"content":"a=` + misspelledShell + `\nb=` + undeclaredSetting + `\nc=` + misspelledMachine + + `\nd=` + shellsOwn + `\n"}]}` + _, err := ParseManifest([]byte(raw)) + if err == nil { + t.Fatal("a file holding three placeholders no pass consumes was accepted") + } + for _, token := range []string{misspelledShell, undeclaredSetting, misspelledMachine} { + if !strings.Contains(err.Error(), "speller's resource rc holds "+token+" in its content") { + t.Errorf("the refusal does not name %s with its module and field: %v", token, err) + } + } + if strings.Contains(err.Error(), "XDG_CACHE_HOME") { + t.Errorf("the shell's own syntax was refused: %v", err) + } + + // A namespace the mesh knows, misspelt in any way its own pattern does not take, and the same + // namespace in a field its pass does not read: refused at the check as at composition. + for _, c := range []struct{ resource, token, field string }{ + {`{"id":"rc","type":"file","path":"/etc/rc","content":"${Machine:address}"}`, "${Machine:address}", "content"}, + {`{"id":"rc","type":"file","path":"/etc/rc","content":"${machine:.address}"}`, "${machine:.address}", "content"}, + {`{"id":"rc","type":"file","path":"/etc/rc","content":"${machine: address}"}`, "${machine: address}", "content"}, + {`{"id":"rc","type":"file","path":"/etc/rc","content":"${Dir:x}"}`, "${Dir:x}", "content"}, + {`{"id":"rc","type":"file","path":"/etc/rc","content":"a=${machine:address\nb=1"}`, "${machine:address", "content"}, + {`{"id":"rc","type":"process","name":"speller","env":{"NAME":"${machine:name}"}}`, "${machine:name}", "env.NAME"}, + } { + raw := `{"module":"speller","version":"1","resources":[` + c.resource + `]}` + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "speller's resource rc holds "+c.token+" in its "+c.field) { + t.Errorf("%s in its %s was accepted at the check, or not named: %v", c.token, c.field, err) + } + } + + // And the shell's syntax alone, beside placeholders every pass knows, is accepted — as is the + // shape a lower-case shell variable takes with an operator after its colon. + raw = `{"module":"speller","version":"1","resources":[{"id":"rc","type":"file","path":"${machine:account-home}/.rc",` + + `"content":"` + shellsOwn + ` ${(%):-%n} ${1:-.} ${count:-} ${trial:+ on trial} ${machine:address} ` + + shellsOperators + `\n"},` + + `{"id":"server","type":"container","name":"server","env":{"PORT":"${PORT:-80}"}}]}` + if _, err := ParseManifest([]byte(raw)); err != nil { + t.Fatalf("the shell's own syntax was refused: %v", err) + } +} + +// The prefix is the reservation rule, so there is no list to maintain and none to drift. +func TestAModuleCannotDeclareAMeshSeat(t *testing.T) { + for _, n := range []string{"mesh-broker", "mesh-anything", "mesh-store"} { + m := catalogue.Manifest{Module: "impostor", DefinesSeats: []catalogue.SeatDeclaration{{Name: n, Accepts: []string{"x"}}}} + got := strings.Join(declaredSeatProblems(m), "; ") + if !strings.Contains(got, "reserved to the mesh") { + t.Fatalf("%q was accepted as a module's seat: %q", n, got) + } + } +} + +// The whole point: a module contributes a capability without the mesh being changed. +func TestAModuleDeclaresItsOwnSeatAndHoldsIt(t *testing.T) { + shop := catalogue.Manifest{Module: "shop", Uses: []string{"telegram-sender"}} + if got := problemsFor(t, catalogue.Shelf{"telegram": telegram(), "shop": shop}); got != "" { + t.Fatalf("a declared seat and its caller were refused: %s", got) + } +} + +// A module may define its own seat and claim it — the mesh enforces exclusivity without knowing +// what it means (novox/hq ADR 0121). But it may not define one in the mesh's own namespace. +func TestAModuleDefinesAndClaimsItsOwnSeat(t *testing.T) { + ok := []byte(`{"module":"showcase","version":"1","seats":[{"name":"the-showcase","scope":"node"}],` + + `"claims":[{"name":"the-showcase","scope":"node"}]}`) + if _, err := ParseManifest(ok); err != nil { + t.Fatalf("a module could not define and claim its own seat: %v", err) + } + // Claiming a name nobody defines is still refused — but **at registration, not here**: with + // seats declared by modules, a claim on a seat *another* module declares is good, and the + // parser cannot tell that from an invented name. See + // TestAClaimOnASeatNobodyDeclaresIsRefusedAtRegistration. + claimant, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)) + if err != nil { + t.Fatalf("the parser judged a claim it cannot judge alone: %v", err) + } + if len(CatalogueProblems(catalogue.Shelf{claimant.Module: claimant})) == 0 { + t.Fatal("a module claimed a seat nobody defines") + } + // A module may not carve its seat out of the mesh's own namespace. + bad := []byte(`{"module":"x","version":"1","seats":[{"name":"node-mine","scope":"node"}],` + + `"claims":[{"name":"node-mine","scope":"node"}]}`) + if _, err := ParseManifest(bad); err == nil || !strings.Contains(err.Error(), "own namespace") { + t.Fatalf("a module defined a seat in the mesh's namespace and was not refused: %v", err) + } +} + +func TestAModuleIsEitherWrittenOrComputedNotBoth(t *testing.T) { + // Otherwise nobody could say where a given file on a machine came from. + _, err := ParseManifest([]byte(`{"module":"mesh-network","version":"1", + "computed":"mesh-network", + "resources":[{"id":"a","type":"package","package":"wireguard-tools"}]}`)) + if err == nil { + t.Fatal("a module that both ships files and has them computed was accepted") + } + if !strings.Contains(err.Error(), "one or the other") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +// A module may not declare an action, and it is refused where it was written. +// +// The host refuses one arriving over the link, which is the bound the whole security argument +// rests on (novox/hq ADR 0005) and is enforced in the right place. But a module's resources reach +// a machine *over* the link, so a manifest carrying an action used to be accepted here, stored, +// resolved and pushed — and then refused on the machine, in a log, with nothing tying it back to +// the manifest. Enforced at the far end only is enforced and not usable. +func TestAModuleMayNotDeclareAnAction(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"probe","version":"1","resources":[ + {"id":"migrate","type":"action","command":["/bin/true"],"verify":["/bin/true"]}]}`)) + if err == nil { + t.Fatal("a manifest declaring an action was accepted, and the machine would refuse it") + } + if !strings.Contains(err.Error(), "may not") || !strings.Contains(err.Error(), "0005") { + t.Errorf("the refusal does not say what rule it is: %v", err) + } + // And it says what to do instead, because "you may not" without an alternative is where a + // module author stops. + if !strings.Contains(err.Error(), "ships a program") { + t.Errorf("the refusal names no alternative: %v", err) + } +} + +// A provision names what the consumer is coupled to (novox/hq ADR 0027). +// +// The fault this defends against does not look like a fault: resolution succeeds, the module +// deploys, and the first query fails somewhere else entirely. Every earlier test had exactly one +// provider of each name, so no mismatch was expressible and none was caught. +func TestAModuleMayNotProvideAGenericDatabase(t *testing.T) { + for _, hidden := range []string{"database", "db", "sql", "sql-database"} { + _, err := ParseManifest([]byte(`{"module":"postgres","version":"1", + "provides":[{"name":"` + hidden + `","scope":"mesh"}]}`)) + if err == nil { + t.Fatalf("providing %q was accepted; a requirement for it matches any engine", hidden) + } + for _, want := range []string{hidden, "postgres-database", "first query"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("refusing %q did not mention %q, so nobody learns what to write:\n%v", + hidden, want, err) + } + } + } +} + +func TestAModuleNamedAfterADomainIsNamedBack(t *testing.T) { + got := InstallationProblems(catalogue.Manifest{Module: "mesh-one.be"}) + if len(got) != 1 || !strings.Contains(got[0], "named after mesh-one.be") { + t.Fatalf("got %v", got) + } +} + +func TestAModuleOffersItsOwnStateOnly(t *testing.T) { + block := `{"seat":"node-bar","kind":"block","data":{"bar":"bottom","place":"status","shows":"state","options":{"state":"%s"}}}` + cases := map[string]string{ + `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "other.draw") + `]}`: "which power does not keep", + `{"module":"power","contributions":[` + fmt.Sprintf(block, "power.draw") + `]}`: "which power does not keep", + `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "draw") + `]}`: "does not name a module", + } + for raw, want := range cases { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: accepted, or refused without %q: %v", raw, want, err) + } + } + ok := `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "power.draw") + `]}` + if _, err := ParseManifest([]byte(ok)); err != nil { + t.Errorf("its own state was refused: %v", err) + } +} + +// A module says what its own tools replace, and only for a tool it declares. +func TestAModuleSaysWhatItsOwnToolsReplace(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"docker","version":"1","tools":["docker_logs","docker_list"],` + + `"replaces":{"docker_logs":["docker logs"],"docker_list":["docker ps"]}}`)) + if err != nil { + t.Fatal(err) + } + if m.Replaces["docker_logs"][0] != "docker logs" || m.Replaces["docker_list"][0] != "docker ps" { + t.Fatalf("replaces not read: %v", m.Replaces) + } + for _, c := range []struct{ manifest, says string }{ + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_lgos":["docker logs"]}}`, + "declares no such tool"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":[]}}`, "replaces nothing"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":[" "]}}`, "empty command"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["docker logs\nrm -rf /"]}}`, + "more than one line"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["docker logs","docker logs"]}}`, + "twice"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["` + strings.Repeat("x", catalogue.MaxReplaced+1) + `"]}}`, + "longer than"}, + } { + if _, err := ParseManifest([]byte(c.manifest)); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: want a refusal saying %q, got %v", c.manifest, c.says, err) + } + } +} + +// A module's own seat names paths under the same rules: a node seat only, literal, and none another seat +// names — the second declaration loses. Its holder declares them, as a mesh seat's does. +func TestAModulesOwnSeatNamesPathsUnderTheSameRules(t *testing.T) { + meshScoped := catalogue.Manifest{Module: "a", Version: "1", DefinesSeats: []catalogue.SeatDeclaration{ + {Name: "a-seat", Paths: []string{"/etc/a"}}}} + if got := strings.Join(declaredSeatProblems(meshScoped), "; "); !strings.Contains(got, "only a node seat names paths") { + t.Errorf("a module's mesh seat naming a path was accepted: %q", got) + } + placeholder := catalogue.Manifest{Module: "a", Version: "1", DefinesSeats: []catalogue.SeatDeclaration{ + {Name: "a-seat", Scope: catalogue.ScopeNode, Paths: []string{"${dir:state}/a"}}}} + if got := strings.Join(declaredSeatProblems(placeholder), "; "); !strings.Contains(got, "placeholder") { + t.Errorf("a module's seat naming a path with a placeholder was accepted: %q", got) + } + + taken := catalogue.Manifest{Module: "b", Version: "1", DefinesSeats: []catalogue.SeatDeclaration{ + {Name: "b-seat", Scope: catalogue.ScopeNode, Paths: []string{"/etc/hosts"}}}} + if got := strings.Join(CatalogueProblems(catalogue.Shelf{"b": taken}), "; "); !strings.Contains(got, + `b declares the seat b-seat naming the path "/etc/hosts", which node-hostname already names`) { + t.Errorf("a module's seat naming /etc/hosts was accepted: %q", got) + } + + own := catalogue.Manifest{Module: "c", Version: "1", + DefinesSeats: []catalogue.SeatDeclaration{{Name: "c-seat", Scope: catalogue.ScopeNode, Paths: []string{"/etc/c.conf"}}}, + Claims: []catalogue.Claim{{Name: "c-seat", Scope: catalogue.ScopeNode}}} + if got := strings.Join(CatalogueProblems(catalogue.Shelf{"c": own}), "; "); !strings.Contains(got, + "c claims c-seat but does not declare /etc/c.conf") { + t.Errorf("a holder of a module's seat without its path was accepted: %q", got) + } + own.Resources = []map[string]any{{"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}} + if got := CatalogueProblems(catalogue.Shelf{"c": own}); len(got) != 0 { + t.Errorf("a holder declaring its own seat's path was refused: %v", got) + } + other := catalogue.Manifest{Module: "d", Version: "1", Resources: []map[string]any{ + {"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}}} + if got := strings.Join(CatalogueProblems(catalogue.Shelf{"c": own, "d": other}), "; "); !strings.Contains(got, + `d declares the path "/etc/c.conf", which the seat c-seat names`) { + t.Errorf("a module not claiming c-seat declared its path: %q", got) + } +} + +func TestAModuleThatDeclaresASettingNamesItsFilesKeys(t *testing.T) { + // ADR 0326 §3, ADR 0164 §15, novox/hq issue 574: only a module that declares no settings keeps + // today's form. One that declares a preference and owns a silent mergeable file is refused where + // a definition is written — and read and composed where a stored one is read again, so a build kept + // from before the rule is still sent and still put back. + declaring := func(app, sidecar []any) catalogue.Manifest { + m := twoFiles(app, sidecar) + m.Resources[2]["content"] = "WORKERS=${setting:workers}\n" + m.Settings = map[string]catalogue.SettingDeclaration{"workers": {Kind: catalogue.KindPreference, Default: 4.0, Why: "a number"}} + return m + } + m := declaring(nil, nil) + problems := WrittenSettingsProblems(m) + if len(problems) != 2 || !containsText(problems, "mergeable app names no takes-settings") || + !containsText(problems, "mergeable sidecar names no takes-settings") { + t.Errorf("where it is written: %v", problems) + } + if problems := TakenSettingsProblems(m); len(problems) != 0 { + t.Errorf("what a stored definition is read by refuses it: %v", problems) + } + raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1", "resources": m.Resources, + "settings": map[string]any{"workers": map[string]any{"kind": catalogue.KindPreference, "default": 4, "why": "a number"}}}) + if err != nil { + t.Fatal(err) + } + read, err := ParseManifest(raw) + if err != nil { + t.Fatalf("a stored build from before the rule is not read again: %v", err) + } + if err := catalogue.JudgeSettings(read, nil, false); err != nil { + t.Errorf("a stored definition is composed as before, and was refused: %v", err) + } + + if problems := WrittenSettingsProblems(declaring([]any{"server"}, []any{})); len(problems) != 0 { + t.Errorf("every mergeable file names its keys, and was refused: %v", problems) + } + if problems := WrittenSettingsProblems(twoFiles(nil, nil)); len(problems) != 0 { + t.Errorf("a module declaring no setting was refused: %v", problems) + } + // One naming file and one silent: §2 names the silent one, and §3 does not name it again. + mixed := declaring([]any{"server"}, nil) + if written, taken := WrittenSettingsProblems(mixed), TakenSettingsProblems(mixed); len(written) != 0 || len(taken) != 1 { + t.Errorf("the silent file is reported %d times by §3 and %d by §2; want once, by §2: %v %v", + len(written), len(taken), written, taken) + } +} + +// A module whose own code the mesh cannot find has nothing to ask, and saying so where the manifest +// is read beats a declaration that quietly prepares nothing. +func TestAModuleThatPreparesAndRunsNoneOfItsOwnCodeIsRefused(t *testing.T) { + m := catalogue.Manifest{ + Module: "gitea", + Prepares: true, + Resources: []map[string]any{ + // Only the upstream server it packages: nothing here runs gitea's own code. + {"id": "server", "type": "container", "name": "mesh-gitea-server", "image": "gitea/gitea@" + digest}, + }, + } + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + if _, err := ParseManifest(raw); err == nil { + t.Fatal("a module that prepares its state with nothing of its own to run was accepted") + } +} + +func TestAMountNothingDeclaresIsRefused(t *testing.T) { + _, err := ParseManifest(manifestMounting("/services/store/data", "")) + if err == nil { + t.Fatal("a container mounting a path no resource declares was accepted; the runtime " + + "would create it as root and the module's owner and mode would never apply") + } + if !strings.Contains(err.Error(), "/services/store/data") { + t.Fatalf("refused without naming the path, which leaves the author guessing: %v", err) + } +} + +func TestAMovedModulesMarkIsJudged(t *testing.T) { + for name, raw := range map[string]string{ + "marked moved and naming no template": `{"module":"bare","version":"1","moved":true, + "resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`, + "handed written in a repository": `{"module":"bare","version":"1","moved":true, + "handed":{"source":"s","digest":"d","templates":{}}, + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"conf","type":"file","path":"/etc/bare","template":"files/bare"}]}`, + "handed and not moved": `{"module":"bare","version":"1", + "handed":{"source":"s","digest":"d","templates":{}}, + "resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`, + } { + if _, err := ParseManifest([]byte(raw)); err == nil { + t.Errorf("%s: accepted", name) + } + } +} + +// A manifest states an access's extent, the way a listening port states its source. An absolute +// path and a mode of read or read-write; anything else is refused rather than acted on wrongly. +func TestAnAccessIsValidated(t *testing.T) { + good := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"read-write"}]}`) + if _, err := ParseManifest(good); err != nil { + t.Fatalf("a valid access was refused: %v", err) + } + + // Mode is optional and narrows to read — the safe default, because the danger with an access + // is being given more than was meant, not less. + bare := []byte(`{"module":"m","accesses":[{"path":"/services/media"}]}`) + m, err := ParseManifest(bare) + if err != nil { + t.Fatalf("an access with no mode was refused: %v", err) + } + if m.Accesses[0].At() != catalogue.AccessRead { + t.Errorf("an access with no mode is %q, want %q", m.Accesses[0].At(), catalogue.AccessRead) + } + + relative := []byte(`{"module":"m","accesses":[{"path":"services/media","mode":"read"}]}`) + if _, err := ParseManifest(relative); err == nil { + t.Error("a relative access path was accepted") + } + + wrong := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"append"}]}`) + if _, err := ParseManifest(wrong); err == nil { + t.Error("an access with an unknown mode was accepted") + } +} + +func TestANameMeantOnPurposeIsDeclaredWithItsReason(t *testing.T) { + // The federation's public directory in a homeserver's config: the world's, said so, and a name + // the map does not cover is still reported. + m := catalogue.Manifest{Module: "homeserver", Resources: []map[string]any{ + {"id": "conf", "type": "file", "content": "trusted_key_servers: matrix.org\nwell_known: https://mesh-one.be\n", + catalogue.NamesOnPurpose: map[string]any{"matrix.org": "the federation's public key server, the world's"}}, + }} + got := InstallationProblems(m) + if len(got) != 1 || !strings.Contains(got[0], "mesh-one.be") { + t.Fatalf("got %v", got) + } +} + +func TestAnArtifactBuiltFromOutsideItsRepositoryIsRefused(t *testing.T) { + // A build reads its own repository and nothing else. A path leaving it would make what gets + // built depend on whatever happens to be on the machine doing the building. + for _, from := range []string{"/etc/passwd", "../elsewhere"} { + _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ + {"name":"x","kind":"archive","from":"` + from + `"}]}}`)) + if err == nil { + t.Fatalf("%q was accepted as a build input", from) + } + if !strings.Contains(err.Error(), "outside its own repository") { + t.Fatalf("refused for the wrong reason: %v", err) + } + } +} + +// An artifact names the paths it is built from with `paths` (novox/hq ADR 0499 clause 1): a package, or a bundle in a +// language other than Go, may; a Go program's artifact, an archive, an image and an upstream artifact may not, and the +// refusal names the artifact and its kind. Each path stays inside the module's directory: no `..`, no absolute path, +// no pattern. +func TestAnArtifactNamesThePathsItIsBuiltFrom(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1","slug":"sdk","build":{"artifacts":[ + {"name":"lib","kind":"package","language":"typescript", + "paths":["src","test","package.json","package-lock.json","tsconfig.json"]}]},"resources":[]}`)) + if err != nil { + t.Fatalf("a package naming its paths was refused: %v", err) + } + if got := strings.Join(m.Build.Artifacts[0].Paths, ","); got != "src,test,package.json,package-lock.json,tsconfig.json" { + t.Fatalf("the paths were not kept as written: %q", got) + } + if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ + {"name":"tools","kind":"bundle","language":"typescript","entrypoints":["index.js"],"loads":["index.js"],"paths":["src","package.json"]}]}}`)); err != nil { + t.Fatalf("a TypeScript bundle naming its paths was refused: %v", err) + } + + for _, c := range []struct{ artifact, says string }{ + {`{"name":"x","kind":"bundle","language":"go","system":"linux-amd64","paths":["cmd"]}`, `"x" is a Go program`}, + {`{"name":"x","kind":"archive","from":"files","paths":["files"]}`, `"x" is a "archive"`}, + {`{"name":"x","kind":"image","from":"Dockerfile","paths":["src"]}`, `"x" is a "image"`}, + {`{"name":"x","kind":"upstream","from":"nats:2.10","paths":["src"]}`, `"x" is a "upstream"`}, + {`{"name":"x","kind":"package","language":"typescript","paths":["../other"]}`, "leaves the module's directory"}, + {`{"name":"x","kind":"package","language":"typescript","paths":["src/../../x"]}`, "leaves the module's directory"}, + {`{"name":"x","kind":"package","language":"typescript","paths":["/etc"]}`, "leaves the module's directory"}, + {`{"name":"x","kind":"package","language":"typescript","paths":["src/*.ts"]}`, "is a pattern"}, + {`{"name":"x","kind":"package","language":"typescript","paths":["src/[ab]"]}`, "is a pattern"}, + {`{"name":"x","kind":"package","language":"typescript","paths":[""]}`, "names an empty path"}, + {`{"name":"x","kind":"package","language":"typescript","paths":["."]}`, "names the module's whole directory"}, + {`{"name":"x","kind":"package","language":"typescript","paths":[]}`, "names no path"}, + } { + _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[` + c.artifact + `]}}`)) + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v, wanted a refusal saying %q", c.artifact, err, c.says) + } + } +} + +func TestAnArtifactOfAnUnknownKindIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ + {"name":"x","kind":"binary","from":"main.go"}]}}`)) + if err == nil { + t.Fatal("an artifact of an unknown kind was accepted") + } +} + +func TestANeedIsAnAbsolutePath(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"postgres","version":"1", + "own-secrets":{"superuser":"superuser.txt"}}`)) + if err == nil { + t.Fatal("a relative path was accepted") + } + if !strings.Contains(err.Error(), "absolute path") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +// A need naming nothing node-scope — undeclared, or declared at another scope — is the whole +// catalogue's to say (novox/hq ADR 0378 §8). +func TestANeedMustNameANodeSeatSomewhereInTheCatalogue(t *testing.T) { + mail := catalogue.Manifest{Module: "mail", DefinesSeats: []catalogue.SeatDeclaration{{ + Name: "mail-notifier", Scope: catalogue.ScopeNode, Needs: []string{"desk-beeper"}}}} + + // Nobody declares desk-beeper at all. + got := problemsFor(t, catalogue.Shelf{"mail": mail}) + if !strings.Contains(got, "desk-beeper") || !strings.Contains(got, "no module declares") { + t.Fatalf("needing an undeclared seat was accepted: %q", got) + } + + // Declared, but at mesh scope: still not a node seat to hold beside it. + notNode := catalogue.Manifest{Module: "dunst", DefinesSeats: []catalogue.SeatDeclaration{{Name: "desk-beeper", Scope: catalogue.ScopeMesh}}} + got = problemsFor(t, catalogue.Shelf{"mail": mail, "dunst": notNode}) + if !strings.Contains(got, "desk-beeper") || !strings.Contains(got, "not a node seat") { + t.Fatalf("needing a mesh-scope seat was accepted: %q", got) + } + + // Declared at node scope: accepted. + dunst := catalogue.Manifest{Module: "dunst", DefinesSeats: []catalogue.SeatDeclaration{{Name: "desk-beeper", Scope: catalogue.ScopeNode}}} + if got := problemsFor(t, catalogue.Shelf{"mail": mail, "dunst": dunst}); got != "" { + t.Fatalf("needing a node seat another module declares was refused: %q", got) + } +} + +func TestAnEmptyContributionIsRefused(t *testing.T) { + // Either a mistake or a requirement written the long way round, and both are better said. + _, err := ParseManifest([]byte(`{"module":"board","version":"1", + "contributes":{"reverse-proxy":{}}}`)) + if err == nil { + t.Fatal("a module contributed nothing and was accepted") + } + if !strings.Contains(err.Error(), "require it") { + t.Fatalf("the refusal does not say what to do instead: %v", err) + } +} + +// A name that is not a name is refused where it is written: it ends up in something a person types. +func TestAnEndpointNameIsHeldToItsShape(t *testing.T) { + for _, wrong := range []string{"Web", "web port", "3000", "-web", "web_surface"} { + m := catalogue.Manifest{Module: "odd", Listens: []catalogue.Listening{{Name: wrong, Port: 80, From: catalogue.FromMesh}}} + if got := strings.Join(endpointNameProblems(m), "\n"); !strings.Contains(got, "a name is lowercase") { + t.Fatalf("%q was accepted as an endpoint name:\n%s", wrong, got) + } + } +} + +func TestAnEnvironmentItemIsTypedAndNamesWhatItNeeds(t *testing.T) { + item := func(attrs map[string]string) []string { + return environmentItemProblems("x", catalogue.Item{Collection: "environment", Attributes: attrs}) + } + for _, ok := range []map[string]string{ + {"kind": "variable", "name": "GTK_THEME"}, + {"kind": "path", "at": "end", "order": "10"}, + } { + if p := item(ok); len(p) != 0 { + t.Errorf("%v is refused: %v", ok, p) + } + } + for _, bad := range []map[string]string{ + nil, {"kind": "other"}, {"kind": "variable"}, {"kind": "variable", "name": "PATH"}, + {"kind": "path"}, {"kind": "path", "at": "middle"}, {"kind": "path", "at": "start", "name": "X"}, + {"kind": "variable", "name": "A", "color": "red"}, + } { + if p := item(bad); len(p) == 0 { + t.Errorf("%v is taken", bad) + } + } + if !attributeFits("environment", "name", "GTK_THEME") || attributeFits("shell:zsh:normal", "name", "GTK_THEME") || + attributeFits("environment", "kind", "Variable") { + t.Error("an upper-case name is the environment's name alone") + } + +} + +// An identity served as a DNS label is never truncated, so the offer's bound must keep it in one. +func TestAnIdentityServedAsADNSLabelIsBoundedToOne(t *testing.T) { + serves := map[string]map[string]any{"bucket": {"name": "${consumer:as:dns}"}} + wide := catalogue.Manifest{Module: "wide", Serves: serves, Provides: []catalogue.Offer{{Name: "bucket", Scope: catalogue.ScopeMesh, + Identity: &catalogue.OfferIdentity{Max: 255, In: "a client id"}}}} + if got := CheckServes(wide); len(got) != 1 || !strings.Contains(got[0], "a label keeps 63") { + t.Fatalf("a 255-character bound on a DNS label passed: %q", got) + } + unsaid := catalogue.Manifest{Module: "unsaid", Serves: serves, Provides: []catalogue.Offer{{Name: "bucket", Scope: catalogue.ScopeMesh}}} + if got := CheckServes(unsaid); len(got) != 0 { + t.Fatalf("the default bound (20) on a DNS label was refused: %q", got) + } +} + +func TestAnImageFromAnInstallationsRegistryNeedsAReason(t *testing.T) { + bare := catalogue.Manifest{Module: "site", Resources: []map[string]any{ + {"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc"}, + }} + if got := InstallationProblems(bare); len(got) != 1 || !strings.Contains(got[0], "registry.mesh-one.be") { + t.Fatalf("an image on an installation's registry was not named: %v", got) + } + excepted := catalogue.Manifest{Module: "site", Resources: []map[string]any{ + {"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc", + catalogue.NamesOnPurpose: map[string]any{"registry.mesh-one.be": "built outside the mesh until the site's repository is a build source here"}}, + }} + if got := InstallationProblems(excepted); len(got) != 0 { + t.Fatalf("a declared exception was still reported: %v", got) + } +} + +func TestAnInterpretedBundleStillMayNotNameASource(t *testing.T) { + b := &catalogue.Build{Artifacts: []catalogue.Artifact{{ + Name: "tools", Kind: catalogue.ArtifactBundle, Language: "typescript", From: "src", + }}} + p := buildProblems(b, "something") + if len(p) == 0 { + t.Fatal("an interpreted bundle naming what it is built from was accepted") + } +} + +// And an ordinary unknown key is still refused as one, rather than being guessed at. +func TestAnInventedKeyIsNotTreatedAsARename(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"postgres","version":"1","nonsense":{}}`)) + if err == nil { + t.Fatal("an invented key was accepted") + } + if strings.Contains(err.Error(), "is now called") { + t.Fatalf("an invented key was reported as a rename: %v", err) + } +} + +// An entry that names a module and no tool is refused at parse, in the manifest's words, rather than +// at the composition of the bus's user list where it would stop the file for everybody. +func TestAnInvokeThatNamesNoToolIsRefusedAtParse(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1","invokes":["shop"]}`)) + if err == nil { + t.Fatal("an invoke naming no tool was accepted") + } + if !strings.Contains(err.Error(), `invokes "shop", which does not name a tool`) { + t.Fatalf("refused for the wrong reason: %v", err) + } +} + +// A provider with one credential shares it (novox/hq ADR 0158): the offer names the own secret, the +// secret says how it is taken, and a consumer's need carries the name so the plan mints its copy +// from the provider's value. +func TestAnOfferMayNameAnOwnSecretAsItsCredential(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"downloader","version":"1", + "own-secrets":{"password":{"path":"/var/lib/mesh/downloader/password","taken":"at-start"}}, + "provides":[{"name":"downloader-api","credential":{"own":"password"}}], + "serves":{"downloader-api":{"port":8080,"username":"admin"}}}`)) + if err != nil { + t.Fatal(err) + } + if own, shared := m.SharedCredentialOf("downloader-api"); !shared || own != "password" { + t.Fatalf("the offer's credential was not read: %v %v", own, shared) + } + if got := m.ProvisionsSharing("password"); len(got) != 1 || got[0] != "downloader-api" { + t.Fatalf("the provisions sharing the secret: %v", got) + } + + for want, raw := range map[string]string{ + "declares no such secret": `{"module":"d","version":"1","provides":[{"name":"d-api","credential":{"own":"password"}}]}`, + "must say how the module takes it": `{"module":"d","version":"1","own-secrets":{"password":"/p"}, + "provides":[{"name":"d-api","credential":{"own":"password"}}]}`, + "names no own secret": `{"module":"d","version":"1","provides":[{"name":"d-api","credential":{"own":""}}]}`, + } { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("expected a refusal saying %q, got %v", want, err) + } + } +} + +// A provision's credential may be one only a person outside the mesh can give (novox/hq ADR 0337 §1, issue 164): +// the offer says `issued-by: outside` and names neither an own secret nor a tool, and the parser refuses it beside +// either. An offer that says `software` keeps ADR 0305's shape. +func TestAnOfferMaySayAPersonOutsideTheMeshIssuesItsCredential(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"tv","version":"1", + "provides":[{"name":"tv-api","scope":"mesh","credential":{"issued-by":"outside"}}], + "serves":{"tv-api":{"port":8989}}}`)) + if err != nil { + t.Fatal(err) + } + if !m.IssuedOutsideOf("tv-api") { + t.Fatal("the offer's credential issued outside was not read") + } + if m.IssuedOutsideOf("other") { + t.Fatal("a provision the module does not offer reads as issued outside") + } + if _, issued := m.IssuedCredentialOf("tv-api"); issued { + t.Fatal("a credential issued outside was read as issued by the software") + } + if _, shared := m.SharedCredentialOf("tv-api"); shared { + t.Fatal("a credential issued outside was read as an own secret shared") + } + if who, offered := catalogue.OffersIssuedOutside(map[string]catalogue.Manifest{"tv": m}, "tv-api"); !offered || who != "tv" { + t.Fatalf("the catalogue does not say tv offers tv-api issued outside: %q %v", who, offered) + } + + for want, raw := range map[string]string{ + "names its own secret": `{"module":"d","version":"1","own-secrets":{"key":{"path":"/p","taken":"at-start"}}, + "provides":[{"name":"d-api","credential":{"issued-by":"outside","own":"key"}}]}`, + "names the tool": `{"module":"d","version":"1", + "provides":[{"name":"d-api","credential":{"issued-by":"outside","tool":"d_key"}}]}`, + "the issuers an offer may name": `{"module":"d","version":"1", + "provides":[{"name":"d-api","credential":{"issued-by":"a-person"}}]}`, + } { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("expected a refusal saying %q, got %v", want, err) + } + } +} + +// A provision's credential may be issued by its provider's software (novox/hq ADR 0305, issue 526): the offer +// names the tool that answers it and no own secret, and every other shape is refused at parse. +func TestAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"media","version":"1", + "provides":[{"name":"media-api","scope":"mesh","credential":{"issued-by":"software","tool":"media_issued_credential"}}], + "serves":{"media-api":{"port":32400}}}`)) + if err != nil { + t.Fatal(err) + } + if tool, issued := m.IssuedCredentialOf("media-api"); !issued || tool != "media_issued_credential" { + t.Fatalf("the offer's issued credential was not read: %q %v", tool, issued) + } + if _, shared := m.SharedCredentialOf("media-api"); shared { + t.Fatal("an issued credential was read as an own secret shared") + } + if _, issued := m.IssuedCredentialOf("other"); issued { + t.Fatal("a provision the module does not offer reads as issued") + } + + for want, raw := range map[string]string{ + "the issuers an offer may name": `{"module":"d","version":"1", + "provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`, + "names no tool that answers it": `{"module":"d","version":"1", + "provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`, + "names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}}, + "provides":[{"name":"d-api","credential":{"issued-by":"software","tool":"d_token","own":"token"}}]}`, + "the issuers an offer may name ": `{"module":"d","version":"1", + "provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`, + } { + want = strings.TrimSpace(want) + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("expected a refusal saying %q, got %v", want, err) + } + } +} + +// The field reads as written and writes back the same, and refuses what says nothing. +func TestAnOffersIdentityIsParsedStrictly(t *testing.T) { + for _, raw := range []string{ + `{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}`, + `{"name":"wildcard-resolution","scope":"mesh","identity":false}`, + `{"name":"redis-cache","scope":"mesh","identity":{"in":"a Redis ACL user"}}`, + } { + var o catalogue.Offer + if err := json.Unmarshal([]byte(raw), &o); err != nil { + t.Fatalf("%s: %v", raw, err) + } + back, err := json.Marshal(o) + if err != nil || string(back) != raw { + t.Errorf("did not round-trip:\n%s\n%s (%v)", raw, back, err) + } + } + for _, raw := range []string{ + `{"name":"x","identity":true}`, + `{"name":"x","identity":{"max":20,"in":"y","most":3}}`, + } { + var o catalogue.Offer + if err := json.Unmarshal([]byte(raw), &o); err == nil { + t.Errorf("accepted %s", raw) + } + } + bad := catalogue.Manifest{Module: "bad", Version: "1", Provides: []catalogue.Offer{ + {Name: "unsaid", Scope: catalogue.ScopeMesh, Identity: &catalogue.OfferIdentity{Max: 20}}, + {Name: "tiny", Scope: catalogue.ScopeMesh, Identity: &catalogue.OfferIdentity{Max: 4, In: "nothing usable"}}, + }} + raw, err := json.Marshal(bad) + if err != nil { + t.Fatal(err) + } + _, err = ParseManifest(raw) + if err == nil || !strings.Contains(err.Error(), "without saying what keeps them") || + !strings.Contains(err.Error(), "the shortest the mesh makes") { + t.Fatalf("a bound with no `in`, or too short for any identity, was accepted: %v", err) + } +} + +// A value named after the operator's own (a url, a user, an address) is `required` where nothing falls back when +// it is unset; where a mergeable file takes it and no placeholder asks for it, the file's own content (or the +// module's code reading the file) falls back, so it keeps a default and stays optional, as it is today: the +// operator's decision on issue 664's second question (issue 735). +func TestAnOperatorNamedValueAFileFallsBackOnMayHaveADefault(t *testing.T) { + m := inConfigForm() + m["config"].(map[string]any)["url"] = map[string]any{"type": "string", + "description": "the address the notifier posts to, a URL; empty for the one the file's software picks", + "default": ""} + resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "url"} + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("a taken value named after the operator's, with a default, was refused: %v", err) + } + + // A placeholder asking for it has no fallback but the default, which would put a value in the definition + // where the operator's belongs (ADR 0112). + asked := inConfigForm() + asked["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL", "default": ""} + resourceOf(asked, "rules")["takes-config"] = []any{"position", "theme", "url"} + resourceOf(asked, "configuration")["content"] = "font = Inter ${config:font-size}\nwidth = ${config:width}\n" + + "from = ${config:sender}\npost = ${config:url}\n" + refused(t, asked, `"url"`, `a key naming "url" is the operator's value`) + + // Read by the module's code alone, nothing in the definition says it falls back. + runtime := inConfigForm() + runtime["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL", + "default": "", "runtime": true} + refused(t, runtime, `"url"`, `a key naming "url" is the operator's value`) +} + +// A key asked only in a template is not refused while the manifest still names its templates (issue 817), and +// is judged once they are filled: WithTemplatesFrom over a tree whose template does not ask for it refuses it. +func TestAnOwnerSettingAskedOnlyInATemplateIsJudgedOnceFilled(t *testing.T) { + raw := `{"module":"sonarr","version":"1", + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"recycle-bin","type":"directory","path":"/srv/sonarr/recycle","mode":"0755","owner":"${setting:puid}:${setting:pgid}"}, + {"id":"identity","type":"file","path":"/srv/sonarr/identity.env","mode":"0644","template":"files/identity.env"}]}` + m, err := ParseManifest([]byte(raw)) + if err != nil { + t.Fatalf("a templated manifest was refused: %v", err) + } + write := func(text string) string { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "files", "identity.env"), []byte(text), 0o644); err != nil { + t.Fatal(err) + } + return dir + } + if _, err := WithTemplatesFrom(m, write("PUID=${setting:puid}\nPGID=${setting:pgid}\n")); err != nil { + t.Errorf("a template asking for both keys was refused: %v", err) + } + _, err = WithTemplatesFrom(m, write("NOTHING=1\n")) + if err == nil || !strings.Contains(err.Error(), "asks for \"puid\" nowhere else") { + t.Errorf("a template not asking for puid was not refused: %v", err) + } +} + +// Where the manifest is read: an owner holding a setting is a whole placeholder or a whole number on +// each side of the colon, and every key is one the module asks for elsewhere or declares a default for. +func TestAnOwnerSettingIsJudgedWhereTheManifestIsRead(t *testing.T) { + ok := []string{"${setting:puid}:${setting:pgid}", "${setting:puid}", "${setting:puid}:1000", "1000:${setting:pgid}", + "1000:1000", "media:media"} + bad := []string{ + "${setting:puid}x", + "x${setting:puid}", + "${setting:puid}:${setting:pgid}:1", + "${setting:puid}${setting:pgid}", + "media:${setting:pgid}", + "${setting:puid}:", + "${setting:other}:${setting:pgid}", + } + for _, owner := range append(ok, bad...) { + m := recycler() + m.Resources[0] = copied(m.Resources[0]) + m.Resources[0]["owner"] = owner + err := parsed(t, m) + refused := err != nil && strings.Contains(err.Error(), "owner") + want := false + for _, b := range bad { + want = want || b == owner + } + if refused != want { + t.Errorf("%s: refused %v, want %v (%v)", owner, refused, want, err) + } + } + // A key with a default is declared, though nothing else asks for it. + m := recycler() + m.Resources = m.Resources[:1] + m.Resources[0] = copied(m.Resources[0]) + m.Resources[0]["owner"] = "${setting:workers}" + m.Settings = map[string]catalogue.SettingDeclaration{"workers": {Kind: catalogue.KindPreference, Default: float64(4), Why: "a test"}} + if err := parsed(t, m); err != nil && strings.Contains(err.Error(), "owner") { + t.Errorf("a declared key was refused: %v", err) + } + // A file's owner takes no setting: the sweep says so. + f := recycler() + f.Resources[1] = copied(f.Resources[1]) + f.Resources[1]["owner"] = "${setting:puid}" + if err := parsed(t, f); err == nil || !strings.Contains(err.Error(), "not filled in this field") { + t.Errorf("a file's owner setting passed: %v", err) + } +} + +// A secret an outside party issues says so (novox/hq ADR 0228), is written back as it was read, and +// is the one at-start secret the mesh may not make; any other word for who issued it is refused. +func TestAnOwnSecretSaysWhenAnOutsidePartyIssuesIt(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"letta","version":"1","own-secrets":{ + "server-password":{"path":"/var/lib/letta/server-password","taken":"at-start"}, + "openai-api-key":{"path":"/var/lib/letta/openai-api-key","taken":"at-start","issued-by":"outside"}, + "telegram-token":{"path":"/var/lib/letta/telegram-token","issued-by":"outside"}, + "logflare":{"path":"/var/lib/letta/logflare","taken":"applied"}, + "broker":"/var/lib/mesh/letta/broker"}}`)) + if err != nil { + t.Fatal(err) + } + for name, may := range map[string]bool{"server-password": true, "openai-api-key": false, + "telegram-token": false, "logflare": false, "broker": false} { + if got := m.OwnSecrets[name].MeshMayMake(); got != may { + t.Errorf("%s: the mesh may make it = %v, want %v", name, got, may) + } + } + out, err := json.Marshal(m.OwnSecrets) + if err != nil { + t.Fatal(err) + } + var again catalogue.OwnSecrets + if err := json.Unmarshal(out, &again); err != nil { + t.Fatal(err) + } + if again["openai-api-key"] != m.OwnSecrets["openai-api-key"] || again["telegram-token"] != m.OwnSecrets["telegram-token"] { + t.Fatalf("the round trip lost who issued it: %s", out) + } + if strings.Contains(string(out), `"taken":""`) { + t.Fatalf("a secret that says only who issued it gained an empty taken: %s", out) + } + _, err = ParseManifest([]byte(`{"module":"letta","version":"1","own-secrets":{ + "openai-api-key":{"path":"/var/lib/letta/openai-api-key","issued-by":"openai"}}}`)) + if err == nil || !strings.Contains(err.Error(), `issued by "openai"`) { + t.Fatalf("an unknown word for who issued a secret was accepted: %v", err) + } +} + +// 1170/AC2: an owner naming an account the module does not make, root, or the operator account's placeholder is +// refused, naming the secret and the account. +func TestAnOwnSecretsOwnerIsRefusedWhenItIsNotAnAccountTheModuleMakes(t *testing.T) { + for name, c := range map[string]struct{ owner, says string }{ + "root": {"root", "root"}, + "the operator account": {"${machine:account}", "operator account"}, + "an account not declared": {"not-made", "not-made"}, + } { + _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{` + + `"mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside","owner":"` + + c.owner + `"}},"resources":[{"id":"account","type":"user","name":"mesh-mail-send"}]}`)) + if err == nil || !strings.Contains(err.Error(), "mail-credential-*") || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v; want a refusal naming the secret and %q", name, err, c.says) + } + } +} + +func TestAnOwnSecretTakenSomeOtherWayIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{ + "admin":{"path":"/var/lib/idp/admin.secret","taken":"sometimes"}}}`)) + if err == nil || !strings.Contains(err.Error(), `taken "sometimes"`) { + t.Fatalf("an unknown word for how a secret is taken was accepted: %v", err) + } + _, err = ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{ + "admin":{"path":"/var/lib/idp/admin.secret","rotate":"yes"}}}`)) + if err == nil { + t.Fatal("an unknown field on an own secret was accepted") + } +} + +// A path is compared as the machine writes it: cleaned. Each spelling of /etc/hosts is /etc/hosts. +func TestAnUncleanSpellingOfASeatsPathIsTheSeatsPath(t *testing.T) { + for _, spelled := range []string{"/etc//hosts", "/etc/./hosts", "/etc/../etc/hosts", "/etc/hosts/"} { + stranger := catalogue.Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{ + {"id": "hosts", "type": "file", "path": spelled, "content": "10.0.0.1 x\n"}}} + if got := strings.Join(CatalogueProblems(catalogue.Shelf{"stranger": stranger}), "; "); !strings.Contains(got, + `stranger declares the path "/etc/hosts", which the seat node-hostname names`) { + t.Errorf("%s from a module not claiming node-hostname was not refused: %q", spelled, got) + } + holder := hostnameHolder(map[string]any{"id": "hosts", "type": "file", "path": spelled, "content": "x"}, + map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"}) + if err := catalogue.CanHold(holder, hostnameSeat(t)); err != nil { + t.Errorf("a holder declaring %s was refused: %v", spelled, err) + } + } +} + +func TestAnUnhealthySeverityOtherThanUrgentOrWarningIsRefused(t *testing.T) { + if _, err := ParseManifest([]byte(`{"module":"mail","version":"1","unhealthy":{"severity":"loud","why":"mail stops"}}`)); err == nil || + !strings.Contains(err.Error(), `not "loud"`) { + t.Errorf("a manifest declaring its unhealthy loud parsed: %v", err) + } +} + +func TestAnUnhealthyWithoutWhyIsRefused(t *testing.T) { + for _, why := range []string{"", " "} { + if problems := manifestUnhealthyProblems((catalogue.Manifest{Module: "mail", Unhealthy: &catalogue.UnhealthyPolicy{Severity: catalogue.UnhealthyUrgent, Why: why}})); len(problems) == 0 { + t.Errorf("urgent with why %q was accepted", why) + } + } + if _, err := ParseManifest([]byte(`{"module":"mail","version":"1","unhealthy":{"severity":"urgent"}}`)); err == nil || + !strings.Contains(err.Error(), "unhealthy says why") { + t.Errorf("a manifest declaring its unhealthy urgent without why parsed: %v", err) + } +} + +// A reference to an access the definition does not declare is refused where the author is. +func TestAnUnknownAccessReferenceIsRefusedAtParse(t *testing.T) { + _, err := ParseManifest([]byte(`{ + "module": "arr", "version": "1", + "accesses": [{"id": "series", "mode": "read-write"}], + "resources": [ + {"id": "state", "type": "directory", "place": ".", "mode": "0700"}, + {"id": "server", "type": "container", "name": "arr", "image": "arr:1", + "volumes": ["${access:movies}:/movies"]} + ]}`)) + if err == nil || !strings.Contains(err.Error(), "${access:movies}") { + t.Fatalf("a reference to an undeclared access was accepted: %v", err) + } + _, err = ParseManifest([]byte(`{"module": "arr", "version": "1", "accesses": [{"mode": "read"}]}`)) + if err == nil || !strings.Contains(err.Error(), "neither an id nor a path") { + t.Fatalf("an access with no id and no path was accepted: %v", err) + } +} + +// A key nobody renders is refused, not left in the file as a literal. +func TestAnUnknownPlaceholderKeyIsRefused(t *testing.T) { + for _, c := range []struct{ content, want string }{ + {"${environment:foo}", "names ${environment:foo}; the environment is ${environment:posix} or ${environment:systemd}"}, + {"${shell:zsh:middle}", "names ${shell:zsh:middle}; shell code is ${shell::}"}, + {"${shell:tcsh:first}", "names ${shell:tcsh:first}; shell code is ${shell::}"}, + {"${shell:zsh}", "names ${shell:zsh}; shell code is ${shell::}"}, + } { + raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"},{"name":"node-login-shell","scope":"node"}],` + + `"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s was accepted: %v", c.content, err) + } + } + // And outside a file's content, where nothing could be placed. + raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"}],` + + `"resources":[{"id":"rc","type":"file","path":"/etc/${environment:posix}","content":"x"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || + !strings.Contains(err.Error(), "names ${environment:posix} in its path; the environment and the shell's code are placed only in a file's content") { + t.Errorf("a placeholder in a path was accepted: %v", err) + } +} + +// **Every endpoint in the catalogue is unnamed today, and must stay valid.** The word ships one +// release before anything uses it. +func TestAnUnnamedEndpointIsStillValid(t *testing.T) { + m := catalogue.Manifest{Module: "ordinary", Listens: []catalogue.Listening{{Port: 443, From: catalogue.FromEverywhere}}} + if got := endpointNameProblems(m); len(got) != 0 { + t.Fatalf("an unnamed endpoint was refused: %v", got) + } + if got := RouteProblems(m); len(got) != 0 { + t.Fatalf("a module with no route was refused: %v", got) + } +} + +func TestAnUpstreamFileFromAnAddressOrAReservedNameIsRefused(t *testing.T) { + for _, u := range []string{"https://git.novox.internal/r.tar.xz", "https://10.1.2.3/r.tar.xz", + "https://203.0.113.9/r.tar.xz", "https://[2001:db8::1]/r.tar.xz", "https://nas.lan/r.tar.xz", "https://vendor/r.tar.xz"} { + _, err := ParseManifest([]byte(upstreamManifest(`{"name":"x","kind":"archive","url":"` + u + `","sha256":"` + + pinnedSum + `","version":"1"}`))) + if err == nil || !strings.Contains(err.Error(), "published under a public name") { + t.Errorf("%s: %v", u, err) + } + } +} + +func TestAnUpstreamFilesVendorIsNamedOnPurposeAndAnInstallationsIsRefused(t *testing.T) { + // The vendor's host is judged like any name, and passes through names-on-purpose; a version of four dotted + // numbers is not an address, and a name in it is still judged. + m, err := ParseManifest([]byte(upstreamManifest(`{"name":"owl","kind":"archive","url":"https://vendor.be/owl/owl.xpi","sha256":"` + + pinnedSum + `","version":"1.5.6.1","file":"owl.xpi","names-on-purpose":{"vendor.be":"Owl's vendor publishes it there"}}`))) + if err != nil { + t.Fatal(err) + } + if problems := InstallationProblems(m); len(problems) != 0 { + t.Fatalf("the declared vendor and the version were taken for an installation: %v", problems) + } + m.Build.Artifacts[0].NamesOnPurpose = nil + if problems := InstallationProblems(m); len(problems) != 1 || !strings.Contains(problems[0], "vendor.be") { + t.Fatalf("an undeclared vendor host is judged: %v", problems) + } + m.Build.Artifacts[0].Version = "git.novox.be 10.0.0.1" + if problems := InstallationProblems(m); len(problems) != 2 { + t.Fatalf("a name in the version is judged: %v", problems) + } + m.Build.Artifacts[0].URL, m.Build.Artifacts[0].Version = "https://mail.novox.be/owl.xpi", "1" + if problems := InstallationProblems(m); len(problems) != 1 || !strings.Contains(problems[0], "mail.novox.be") { + t.Fatalf("an installation's host in the url is judged: %v", problems) + } +} + +func TestAnUpstreamFileThatIsNotPinnedIsRefused(t *testing.T) { + cases := map[string]string{ + // No digest: what the address serves changes under the mesh. + `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","version":"1"}`: "without a pinned sha256", + `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"ABC","version":"1"}`: "without a pinned sha256", + // No version: a person reading the pin cannot tell which release. + `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + `"}`: "says no version", + // Not https. + `{"name":"x","kind":"archive","url":"http://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + `","version":"1"}`: "over https", + // Both a directory of the repository and a URL. + `{"name":"x","kind":"archive","from":"files","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + + `","version":"1"}`: "one or the other", + // A pin without a URL. + `{"name":"x","kind":"archive","from":"files","sha256":"` + pinnedSum + `"}`: "no url", + // Only an archive is mirrored from a file. + `{"name":"x","kind":"image","from":"Dockerfile","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + + `","version":"1"}`: "only an archive", + // A file name that is a path, a directory within that leaves the archive, and both at once. + `{"name":"x","kind":"archive","url":"https://vendor.invalid/a.xpi","sha256":"` + pinnedSum + + `","version":"1","file":"../a.xpi"}`: "not a plain relative file name", + `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + + `","version":"1","within":"../up"}`: "not a plain relative directory", + `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + + `","version":"1","within":"r","file":"r.tar.xz"}`: "a file is kept whole", + } + for artifact, want := range cases { + _, err := ParseManifest([]byte(upstreamManifest(artifact))) + if err == nil { + t.Errorf("%s was accepted", artifact) + continue + } + if !strings.Contains(err.Error(), want) { + t.Errorf("%s was refused for another reason than %q: %v", artifact, want, err) + } + } +} + +func TestAPackageParsesLikeABundleAndNeedsALanguage(t *testing.T) { + // The SDK's shape: a package built from the module's own directory, naming a language. + m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1","slug":"sdk", + "build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]}, + "resources":[]}`)) + if err != nil { + t.Fatalf("the SDK's package manifest did not parse: %v", err) + } + if m.Build.Artifacts[0].Kind != catalogue.ArtifactPackage { + t.Fatalf("expected a package artifact, got %q", m.Build.Artifacts[0].Kind) + } + + // A package that names what it is built from is refused, exactly as a bundle is: it is built + // from the module's own directory. + if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ + {"name":"lib","kind":"package","language":"typescript","from":"Dockerfile"}]}}`)); err == nil { + t.Fatal("a package naming a source was accepted") + } + // A package with no language cannot choose a toolchain. + if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ + {"name":"lib","kind":"package"}]}}`)); err == nil { + t.Fatal("a package with no language was accepted") + } +} + +func TestAPathThroughAnAccessIsJudgedWithTheAccessFilledIn(t *testing.T) { + // A file's path may name an access; the access's default path is the definition's, so it is judged with it. + _, err := ParseManifest([]byte(`{"module": "backup", "version": "1", + "accesses": [{"id": "images", "path": "/var/lib/docker/volumes"}], + "resources": [{"id": "marker", "type": "file", "path": "${access:images}/marker", "content": "x"}]}`)) + if err == nil || !strings.Contains(err.Error(), "/var/lib/docker/volumes/marker") || + !strings.Contains(err.Error(), "issue 496") { + t.Fatalf("a file reaching Docker's data through an access's default path was accepted: %v", err) + } + // An access the definition gives no path is placed by a setting, which Places and AccessPlaces judge, and on + // the machine by the node-engine: nothing here to resolve it against, so nothing is refused for it. + if _, err := ParseManifest([]byte(`{"module": "backup", "version": "1", + "accesses": [{"id": "images"}], + "resources": [{"id": "marker", "type": "file", "path": "${access:images}/marker", "content": "x"}]}`)); err != nil { + t.Fatalf("an access placed only by a setting cannot be judged at the definition: %v", err) + } +} + +// ADR 0203 §5 and ADR 0204 §3: a placeholder outside the seat's holder is refused — by the parser, +// which is what the catalogue check and registration run, and again at composition, in the same words. +func TestAPlaceholderOutsideTheHolderIsRefused(t *testing.T) { + for _, c := range []struct{ content, want string }{ + {"${environment:posix}", "toolchain's resource rc names ${environment:posix} and toolchain does not claim node-environment"}, + {"${shell:zsh:normal}", "toolchain's resource rc names ${shell:zsh:normal} and toolchain does not claim node-login-shell"}, + } { + raw := `{"module":"toolchain","resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("the catalogue check accepted %s outside its holder: %v", c.content, err) + } + m := catalogue.Manifest{Module: "toolchain", Resources: []map[string]any{ + {"id": "rc", "type": "file", "path": "/etc/rc", "content": c.content}}} + r := catalogue.Resolution{Node: "workstation", Account: "op", Modules: []catalogue.Manifest{m}} + if _, err := r.Declaration(catalogue.Rendering{}); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("composition accepted %s outside its holder: %v", c.content, err) + } + } +} + +// **And a language that runs anywhere must not name one.** A system that decides nothing reads as +// though it did, which is the same fault as a restriction that restricts nothing (novox/hq ADR 0045). +func TestAPortableBundleMayNotNameASystem(t *testing.T) { + got := buildProblemsOf(t, bundleFor("typescript", "arch")) + if !strings.Contains(got, "runs anywhere") { + t.Fatalf("a portable bundle was allowed to name a system:\n%s", got) + } +} + +func TestAPortableBundleNamingNoSystemIsAccepted(t *testing.T) { + if got := buildProblemsOf(t, bundleFor("typescript", "")); got != "" { + t.Fatalf("an ordinary bundle was refused:\n%s", got) + } +} + +// A rule with no source is open, and must say so rather than appear to restrict something. +func TestAPortWithNoSourceIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443}]}`)) + if err == nil { + t.Fatal("a port that never said who may reach it was accepted; it reads as a restriction") + } + if !strings.Contains(err.Error(), "from where") { + t.Fatalf("refused without saying what is missing: %v", err) + } +} + +// 1179/AC2: a bus on a process with no user, user root, or the operator account is refused naming the +// process and the account rule. +func TestAProcessBusAccountMustBeTheModulesOwn(t *testing.T) { + for name, c := range map[string]struct{ user, says string }{ + "no user": {"", "send"}, + "user root": {"root", "root"}, + "the operator account": {"${machine:account}", "operator"}, + } { + raw := `{"module":"mesh-mail","version":"1","emits":["mail.send.sent"],"state":["sends"],` + + `"streams":[{"name":"mail-sends","events":["mail.send.approved"],"retention":"work-queue",` + + `"max-deliveries":5}],"resources":[{"id":"account","type":"user","name":"mesh-mail-send"},` + + `{"id":"send","type":"process","run":["./mesh-mail","send"]` + if c.user != "" { + raw += `,"user":"` + c.user + `"` + } + raw += `,"bus":{"emits":["mail.send.sent"]}}]}` + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "send") || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v; want a refusal naming the process and %q", name, err, c.says) + } + } +} + +// 1179/AC3: a bus naming a stream, state or event the module does not declare as its own is refused naming +// it. +func TestAProcessBusNamesOnlyTheModulesOwn(t *testing.T) { + for name, c := range map[string]struct{ bus, says string }{ + "another module's event": {`{"emits":["billing.paid"]}`, "billing.paid"}, + "another module's queue": {`{"queues":["other-queue"]}`, "other-queue"}, + "another module's state": {`{"state":["other-state"]}`, "other-state"}, + } { + _, err := ParseManifest([]byte(mailSenderProcessManifest(t, c.bus))) + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v; want a refusal naming %q", name, err, c.says) + } + } +} + +// 1179/AC4: a bus declaring serves, holds, claims or a seat is refused naming the key; a bus with invokes of +// a tool or a seat verb is accepted. +func TestAProcessBusNeverServesOrHoldsASeat(t *testing.T) { + for name, c := range map[string]struct{ bus, says string }{ + "serves": {`{"emits":["mail.send.sent"],"serves":["send"]}`, "serves"}, + "holds": {`{"emits":["mail.send.sent"],"holds":["mail-sender"]}`, "holds"}, + "claims": {`{"emits":["mail.send.sent"],"claims":["mail-sender"]}`, "claims"}, + "seat": {`{"emits":["mail.send.sent"],"seat":"mail-sender"}`, "seat"}, + } { + _, err := ParseManifest([]byte(mailSenderProcessManifest(t, c.bus))) + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v; want a refusal naming %q", name, err, c.says) + } + } + accepted := `{"emits":["mail.send.sent","mail.send.failed"],"invokes":["seat:operator-channel.tell","mail.status"]}` + if _, err := ParseManifest([]byte(mailSenderProcessManifest(t, accepted))); err != nil { + t.Fatalf("a bus invoking a tool and a seat verb was refused: %v", err) + } +} + +// 1179/AC1: a well-formed bus on a process whose account the module makes is accepted, and the catalogue +// gives it whole to the grant composition (ProcessBuses, which 1180 reads). +func TestAProcessDeclaresItsBusAndIsAccepted(t *testing.T) { + m, err := ParseManifest([]byte(mailSenderProcessManifest(t, mailSenderBus))) + if err != nil { + t.Fatalf("a well-formed process bus was refused: %v", err) + } + buses := m.ProcessBuses() + b, ok := buses["send"] + if !ok { + t.Fatalf("the process's bus was not given to the grant composition: %+v", buses) + } + if !strSliceEqual(b.Emits, []string{"mail.send.sent", "mail.send.failed"}) || + !strSliceEqual(b.Queues, []string{"mail-sends"}) || !strSliceEqual(b.State, []string{"sends"}) || + !b.Asks || !strSliceEqual(b.Invokes, []string{"seat:operator-channel.tell"}) { + t.Fatalf("the bus read as %+v", b) + } + + // 1952 S5 (novox/hq ADR 0564 Limits 4; the lead, 2026-10-29): a tools bundle declares its own bus account, read as a + // process's, named after its binary and kept through resolution; a removal is from the closed set, alone, and the + // bus module's (mesh-broker) alone. + toolsIn := func(claims, bus, mode, where string) string { + return `{"module":"nats","version":"1","claims":` + claims + `,"own-secrets":{"broker":"${dir:mesh-state}/broker",` + + `"broker-nats-tools":"${dir:tools-secret}/broker-nats-tools"},"resources":[{"id":"mesh-state","type":"directory",` + + `"mode":"0700","place":"mesh","unknown":"program"},{"id":"tools-secret","type":"directory","mode":"` + mode + + `","path":"` + where + `","unknown":"program"}],"build":{"artifacts":[{"name":"tools","kind":"bundle",` + + `"language":"go","system":"arch","from":"cmd/nats-tools","binary":"nats-tools","loads":["nats-tools"],"bus":` + bus + `}]}}` + } + tools := func(claims, bus string) string { return toolsIn(claims, bus, "0711", "/var/lib/mesh/nats-tools") } + broker := `[{"name":"mesh-broker","scope":"mesh"}]` + for _, c := range []struct { + name, manifest, refused string + }{ + {name: "the bus module's tools removing", manifest: tools(broker, `{"removes":["DEAD_LETTER_NOTICES.controller","mesh-host_acts","ASSIGNMENTS"]}`)}, + {name: "a removal outside the set", manifest: tools(broker, `{"removes":["EVENTS.a_m"]}`), refused: "not one of"}, + {name: "a removal beside a grant", manifest: tools(broker, `{"removes":["mesh-host_acts"],"invokes":["nats.nats_streams"]}`), refused: "with nothing else"}, + {name: "another module's tools removing", manifest: tools(`[]`, `{"removes":["mesh-host_acts"]}`), refused: "only the bus module"}, + {name: "a tools bundle consuming a queue", manifest: tools(broker, `{"queues":["x"]}`), refused: "holds no consumer"}, + {name: "a process removing", manifest: mailSenderProcessManifest(t, `{"removes":["mesh-host_acts"]}`), refused: "only a tools bundle"}, + // The runtime's account reads the account's secret: it lies where others may pass (the host agent, 2026-10-29). + {name: "a tools secret the runtime cannot reach", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0700", + "/var/lib/mesh/nats-tools"), refused: "cannot reach"}, + // Its parents too: under the module's mesh place, /var/lib/mesh/nats is 0700 (the lead, read on novox 2026-10-29). + {name: "a tools secret beneath the module's mesh place", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0711", + "${dir:mesh-state}/tools"), refused: "cannot reach"}, + } { + m, err := ParseManifest([]byte(c.manifest)) + if c.refused != "" { + if err == nil || !strings.Contains(err.Error(), c.refused) { + t.Errorf("%s: %v, want refused saying %q", c.name, err, c.refused) + } + continue + } + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + accounts := m.BusAccounts() + if len(accounts) != 1 || accounts[0].ID != "nats-tools" || !accounts[0].Tools || + !strSliceEqual(accounts[0].Bus.Removes, []string{catalogue.RemovesControllerDeadLetterConsumer, catalogue.RemovesEngineActs, catalogue.RemovesAssignmentsStream}) { + t.Errorf("%s: the accounts are %+v", c.name, accounts) + } + // Resolved, the build goes and its bundles keep the bus (build.go). + resolved := catalogue.Manifest{Module: "nats", Bundles: []catalogue.Bundle{{Name: "tools", Binary: "nats-tools", Bus: m.Build.Artifacts[0].Bus}}} + if got := resolved.BusAccounts(); len(got) != 1 || got[0].ID != "nats-tools" { + t.Errorf("%s: resolved, the accounts are %+v", c.name, got) + } + } + if got := m.BusAccounts(); len(got) != 1 || got[0].ID != "send" || got[0].Tools { + t.Errorf("the process's account is %+v", got) + } +} + +// {member} is replaced only for a family's member: a single secret's prompt may not hold it (novox/hq issue 789). +func TestAPromptHoldingMemberOnASingleSecretIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ + "smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start", + "prompt":"For {member}"}}}`)) + if err == nil || !strings.Contains(err.Error(), "{member} is replaced only for a family's member") { + t.Fatalf("a single secret's prompt holding {member}: %v; want a refusal saying so", err) + } +} + +// What `module check` and the merge gate judge: the provider read from its tree, its text inline. Judged by the same +// rules, a provider naming a provision it does not provide is refused there too, and one that does composes. +func TestAProviderReadFromItsTreeIsJudgedByTheSameRules(t *testing.T) { + read := func(text string) (catalogue.Manifest, error) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "files", "consumers.json"), []byte(text), 0o644); err != nil { + t.Fatal(err) + } + m, err := ParseManifest([]byte(handedProvider)) + if err != nil { + t.Fatal(err) + } + return WithTemplatesFrom(m, dir) + } + filled, err := read(consumersText) + if err != nil { + t.Fatalf("a provider read from its tree is refused: %v", err) + } + if _, err := notifierOn(t, filled).Compose(handingTo("store:5000")); err != nil { + t.Fatalf("a provider read from its tree does not compose where it would be handed: %v", err) + } + if _, err := read("${consumers:cache}"); err == nil || !strings.Contains(err.Error(), "does not provide") { + t.Fatalf("a provider read from its tree naming a provision it does not provide: %v", err) + } +} + +func TestAReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) { + m := placedModule() + m.Resources[2]["volumes"] = []any{"${dir:date}:/data"} // a typo, the likely shape + problems := manifestUnknownDirRefs(m) + if len(problems) != 1 || !strings.Contains(problems[0], `${dir:date}`) { + t.Fatalf("a reference naming no directory is a manifest problem; got %v", problems) + } + if got := manifestUnknownDirRefs(placedModule()); len(got) != 0 { + t.Fatalf("a correct definition has none; got %v", got) + } +} + +func TestARenamedEntryIsRefusedWhereItCouldNotHold(t *testing.T) { + entry := func(m map[string]any, e any) map[string]any { + resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", e} + return m + } + refused(t, entry(renaming(), map[string]any{"key": "url"}), "notifier's rules", `"key" and "as"`) + refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL", "when": "set"}), `"key" and "as"`) + refused(t, entry(renaming(), map[string]any{"key": "URL", "as": "NOTIFIER_URL"}), "not a usable key") + refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER URL"}), "not a usable name") + refused(t, entry(renaming(), map[string]any{"key": "url", "as": "endpoints"}), `"endpoints"`, "the mesh reads") + refused(t, entry(renaming(), map[string]any{"key": "url", "as": "theme"}), `"theme"`, "twice") + refused(t, entry(renaming(), map[string]any{"key": "theme", "as": "THEME"}), `"theme"`, "twice") + refused(t, entry(renaming(), map[string]any{"key": "nothing", "as": "NOTHING"}), `"nothing"`, "declares no such value") + + kept := entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL"}) + resourceOf(kept, "rules")["protected"] = []any{"NOTIFIER_URL"} + refused(t, kept, `"NOTIFIER_URL"`, "protected") + + // Laid only when set, so the code's own fallback holds: never required. + required := renaming() + declaration(required, "url")["required"] = true + delete(declaration(required, "url"), "default") + refused(t, required, `"url"`, "only when it is set", "never required") +} + +func TestARenamedEntryIsTheConfigFormsOnly(t *testing.T) { + m := map[string]any{ + "module": "notifier", "version": "1", + "resources": []any{ + map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", "content": "{}", + "takes-settings": []any{map[string]any{"key": "url", "as": "NOTIFIER_URL"}}}, + }, + } + refused(t, m, "notifier's rules", "config form") +} + +// Tonight's case (issue 263): networkmanager, no slug, requiring the mesh's resolver provision on a +// machine with a six-character name. Under ADR 0049's one bound it was refused, and its provider's +// whole machine with it; the resolver keeps no name, so it is not refused at all. +func TestARequirementOnAKeylessProvisionComposesWithALongName(t *testing.T) { + shelf := catalogue.Shelf{ + "resolver": {Module: "resolver", Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}}}, + "networkmanager": {Module: "networkmanager", Requires: []string{"wildcard-resolution"}}, + } + if catalogue.CheckIdentity("laptop", "networkmanager") == nil { + t.Fatal("the regression is not reproduced: mesh_laptop_networkmanager fits the old global bound") + } + if got := IdentityProblems(shelf, 6); len(got) != 0 { + t.Fatalf("a requirement on a keyless provision was refused for its length: %q", got) + } + r := catalogue.Resolution{Node: "laptop", Modules: []catalogue.Manifest{shelf["networkmanager"]}, + Needs: []catalogue.Needed{{Name: "wildcard-resolution", From: "anchor", For: "networkmanager", + Identity: shelf["resolver"].IdentityBoundOf("wildcard-resolution")}}} + if got := r.Overflowing(); len(got) != 0 { + t.Fatalf("a keyless requirement is reported as overflowing: %+v", got) + } +} + +// A resolved manifest keeps a built artifact's reference in the store-relative form, never the +// address the builder reached the store by (novox/hq ADR 0155): on 2026-10-02 the first bundle +// resolved on the mesh carried the store's host in bundles[0].source and registration refused it +// as naming an installation. Archive resources are the same kind of reference and get the same. +func TestAResolvedReferenceIsKeptNotRouted(t *testing.T) { + m, err := ParseManifest([]byte(`{ + "module": "sample", "version": "1", + "tools": ["one"], + "build": {"artifacts": [ + {"name": "code", "kind": "bundle", "language": "typescript", "entrypoints": ["tools/index.js"]}, + {"name": "files", "kind": "archive", "from": "files"} + ]}, + "resources": [{"id": "packed", "type": "archive", "path": "/opt/sample", "artifact": "files"}] + }`)) + if err != nil { + t.Fatal(err) + } + digest := "sha256:" + strings.Repeat("ab", 32) + resolved, err := m.Resolve([]catalogue.Built{ + {Name: "code", Kind: catalogue.ArtifactBundle, Reference: "http://store.example:5100/v2/sample/code/blobs/" + digest, Digest: digest}, + {Name: "files", Kind: catalogue.ArtifactArchive, Reference: "http://store.example:5100/v2/sample/files/blobs/" + digest, Digest: digest}, + }) + if err != nil { + t.Fatal(err) + } + if got, want := resolved.Bundles[0].Source, catalogue.ArtifactStoreScheme+"sample/code/blobs/"+digest; got != want { + t.Errorf("bundle source %q, want the kept form %q", got, want) + } + if got, want := resolved.Resources[0]["source"], catalogue.ArtifactStoreScheme+"sample/files/blobs/"+digest; got != want { + t.Errorf("archive source %q, want the kept form %q", got, want) + } + if problems := InstallationProblems(resolved); len(problems) != 0 { + t.Errorf("a resolved manifest names an installation: %v", problems) + } +} + +// A route naming an endpoint the module does not declare reaches nothing, and is refused where it is +// written rather than resolving to no port and serving nothing. +func TestARouteNamingAnEndpointTheModuleLacksIsRefused(t *testing.T) { + m := aMediaServer() + m.Contributes["route"][catalogue.RouteEndpoint] = "absent" + got := strings.Join(RouteProblems(m), "\n") + if !strings.Contains(got, "does not declare") { + t.Fatalf("a route naming an absent endpoint was accepted:\n%s", got) + } +} + +func TestARunOnceMustBeABoolean(t *testing.T) { + // A value that is not true or false is refused here, not sent to a machine that would then have + // to guess what a string means. + digest := "@sha256:" + strings.Repeat("a", 64) + bad := []byte(`{"module":"m","resources":[ + {"id":"seed","type":"container","name":"seed","image":"registry.example/x` + digest + `","run-once":"yes"} + ]}`) + if _, err := ParseManifest(bad); err == nil { + t.Error("a run-once that is not a boolean was accepted") + } else if !strings.Contains(err.Error(), "run-once") { + t.Errorf("refused for the wrong reason: %v", err) + } + + good := []byte(`{"module":"m","resources":[ + {"id":"seed","type":"container","name":"seed","image":"registry.example/x` + digest + `","run-once":true} + ]}`) + if _, err := ParseManifest(good); err != nil { + t.Errorf("a valid run-once container was refused: %v", err) + } +} + +func TestARuntimeHealthIsHeldToTheHealthRules(t *testing.T) { + for _, c := range []struct { + health any + says string + }{ + {map[string]any{"kind": "tool", "tool": "keeper_restart"}, "does not serve"}, + {map[string]any{"kind": "tool", "tool": "keeper_health", "interval": "2s"}, "no more often"}, + {map[string]any{"kind": "exec", "command": "true"}, "only a container"}, + {map[string]any{"kind": "tool", "tool": "keeper_health", "port": 80}, "names a port"}, + {"tool", "is an object"}, + } { + _, err := ParseManifest(ownAccountManifest(t, true, c.health)) + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%v: %v, want it refused saying %q", c.health, err, c.says) + } + } +} + +func TestAScheduleThatIsNotAStringIsRefused(t *testing.T) { + digest := "@sha256:" + strings.Repeat("a", 64) + bad := []byte(`{"module":"m","resources":[ + {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":true} + ]}`) + if _, err := ParseManifest(bad); err == nil { + t.Error("a schedule that is not a string was accepted") + } else if !strings.Contains(err.Error(), "schedule") { + t.Errorf("refused for the wrong reason: %v", err) + } +} + +// A seat needing itself names nothing (novox/hq ADR 0378 §8). +func TestASeatCannotNeedItself(t *testing.T) { + m := catalogue.Manifest{Module: "mail", DefinesSeats: []catalogue.SeatDeclaration{{ + Name: "mail-notifier", Scope: catalogue.ScopeNode, Needs: []string{"mail-notifier"}}}} + got := strings.Join(declaredSeatProblems(m), "; ") + if !strings.Contains(got, "needing itself") { + t.Fatalf("a seat needing itself was accepted: %q", got) + } +} + +// **At registration, not in the parser** (novox/hq ADR 0122): a seat's scope is a property of the +// set, the set is the store's, and the parser also runs on a build machine that has no store. +func TestASeatClaimedAtAnotherScopeIsRefused(t *testing.T) { + m, err := ParseManifest(claimed(`[{"name":"npm-package-registry","scope":"node"}]`)) + if err != nil { + t.Fatalf("the parser judged a scope it reads from data it may not have: %v", err) + } + got := strings.Join(CatalogueProblems(catalogue.Shelf{m.Module: m}), "; ") + if !strings.Contains(got, "mesh seat") { + t.Fatalf("the refusal does not say which scope the seat is: %q", got) + } +} + +// A seat names a role's own path, never the root or a top-level directory. +func TestASeatNamesNoRootOrTopLevelDirectory(t *testing.T) { + for _, p := range []string{"/", "/etc"} { + m := catalogue.Manifest{Module: "a", Version: "1", DefinesSeats: []catalogue.SeatDeclaration{ + {Name: "a-seat", Scope: catalogue.ScopeNode, Paths: []string{p}}}} + if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "top-level directory") { + t.Errorf("a module's seat naming %s was accepted: %q", p, got) + } + } +} + +// A seat with no protocol is a marker: which module is this node's showcase, or its packet filter. +// Most node-scoped seats are markers, so refusing one would refuse the majority of the set. +func TestASeatWithoutAProtocolIsAMarkerNotAMistake(t *testing.T) { + m := catalogue.Manifest{Module: "vague", DefinesSeats: []catalogue.SeatDeclaration{{Name: "something", Scope: catalogue.ScopeNode}}} + if got := strings.Join(declaredSeatProblems(m), "; "); got != "" { + t.Fatalf("a marker seat was refused: %s", got) + } +} + +func TestASecondClaimOfOneKindIsRefused(t *testing.T) { + got := problemsFor(t, catalogue.Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"), + "telegram-two": aChannel("telegram-two", "telegram")}) + if !strings.Contains(got, `of kind "telegram", which telegram already claims`) { + t.Fatalf("a second holder of one kind stood: %s", got) + } +} + +func TestAServedNameIsCheckedWhenTheDefinitionIsRead(t *testing.T) { + for bad, want := range map[string]string{ + `"public-root":"https://${bound:route:name-api}/x"`: "what forge is called through route is name, internal-name, name-web, internal-name-web", + `"public-root":"https://${bound:postgres-database:name}/x"`: `forge contributes to "route"`, + `"public-root":"https://${bound:route:name-web|default:x}/x"`: "takes no default", + `"public-root":"https://${bound:route:at}/x"`: "what forge is called through route", + } { + raw := strings.Replace(aRegistryForge, `"public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"`, bad, 1) + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: want a refusal saying %q, got %v", bad, want, err) + } + } + nested := strings.Replace(aRegistryForge, `"public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"`, + `"roots":["https://${bound:route:name-web}/x"]`, 1) + if _, err := ParseManifest([]byte(nested)); err == nil || !strings.Contains(err.Error(), "inside a list or an object") { + t.Errorf("a nested own name is refused, not served as the placeholder: %v", err) + } + if _, err := ParseManifest([]byte(aRegistryForge)); err != nil { + t.Fatalf("its own name is accepted: %v", err) + } +} + +// A fact or an alphabet the mesh does not have is refused where the definition is, not where a +// consumer happens to be resolved — and the refusal says what may be said instead. +func TestAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused(t *testing.T) { + for _, c := range []struct{ value, says string }{ + {"${consumer:node}", "as"}, + {"${consumer:as:punycode}", "dns"}, + } { + m := store() + m.Serves["s3-bucket"]["bucket"] = c.value + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + _, err = ParseManifest(raw) + if err == nil { + t.Fatalf("%s was accepted", c.value) + } + if !strings.Contains(err.Error(), c.value) { + t.Errorf("the refusal of %s does not quote it: %v", c.value, err) + } + if !strings.Contains(err.Error(), c.says) { + t.Errorf("the refusal of %s does not say what may be said (%q): %v", c.value, c.says, err) + } + } +} + +// Third review: a setting may name only a template's instance — right after the `@`, before the final +// suffix, and the whole instance — so a value can never make the unit another unit, nor another kind. +// Refused where the manifest is read, near its author. +func TestASettingInAUnitNameIsOnlyATemplatesInstance(t *testing.T) { + ok := []string{"zfs-scrub-weekly@${setting:scrub-pool}.timer", "getty@${setting:tty}.service"} + bad := []string{ + "${setting:unit}", + "${setting:name}.timer", + "zfs-scrub-${setting:cadence}@storage.timer", + "zfs-scrub@${setting:pool}-x.timer", + "zfs-scrub@x-${setting:pool}.timer", + "zfs-scrub@${setting:pool}.${setting:kind}", + "zfs-scrub@${setting:pool}", + "zfs-scrub@${setting:a}${setting:b}.timer", + } + for _, unit := range append(ok, bad...) { + m := catalogue.Manifest{Module: "zfs", Resources: []map[string]any{{"id": "t", "type": "service", "unit": unit, "state": "stopped"}}} + err := parsed(t, m) + refused := err != nil && strings.Contains(err.Error(), "instance") + want := false + for _, b := range bad { + want = want || b == unit + } + if refused != want { + t.Errorf("%s: refused %v, want %v (%v)", unit, refused, want, err) + } + } +} + +func TestASiteScopedProvisionIsRefused(t *testing.T) { + // Meaningful for a claim — one DHCP server per segment — and not yet meaningful for a + // provision, because nothing knows how to reach "the one at my site". + _, err := ParseManifest([]byte( + `{"module":"dns","version":"1","provides":[{"name":"resolver","scope":"site"}]}`)) + if err == nil { + t.Fatal("a site-scoped provision was accepted") + } + if !strings.Contains(err.Error(), "site") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +func TestAskingForTheRuleSetWithNowhereToPutItIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"firewall","filtering":{"into":""}}`)) + if err == nil { + t.Fatal("a module asked for the rule set and named no path; it would receive nothing") + } +} + +func TestASoftwareSpelledKeyIsUsable(t *testing.T) { + // A file's keys are its software's: nodered's timeZone is not lower case and is still a key. + m := catalogue.Manifest{Module: "flows", Resources: []map[string]any{{"id": "settings", "type": "file", + "path": "/x", "merge": catalogue.MergeJSON, "content": "{}", catalogue.TakesSettingsField: []any{"timeZone"}}}} + if problems := TakenSettingsProblems(m); len(problems) != 0 { + t.Errorf("timeZone was refused: %v", problems) + } +} + +func TestASourceNobodyDefinedIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443,"from":"lan"}]}`)) + if err == nil { + t.Fatal("a source the mesh cannot render was accepted, and would be silently dropped") + } +} + +// A name the bus could not hold, or that would let two modules derive one bucket, is refused at +// registration in the manifest's words. +func TestAStateNameIsLocalAndOneToken(t *testing.T) { + for _, c := range []struct{ manifest, says string }{ + {`{"module":"a","version":"1","state":["mesh.servers"]}`, `keeps state "mesh.servers": a state is named locally`}, + {`{"module":"a","version":"1","state":["my_servers"]}`, `keeps state "my_servers"`}, + {`{"module":"a","version":"1","state":["s","s"]}`, `keeps state "s" twice`}, + {`{"module":"a","version":"1","state":[{"name":"s","history":65}]}`, `a key keeps between 1 and 64`}, + {`{"module":"a.b","version":"1","state":["s"]}`, `no dot`}, + {`{"module":"a","version":"1","reads":["bindings"]}`, `a read is .`}, + {`{"module":"a","version":"1","reads":["a.s"]}`, `which is its own state`}, + {`{"module":"a","version":"1","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds, per-machine, key-ttl}`}, + } { + _, err := ParseManifest([]byte(c.manifest)) + if err == nil { + t.Errorf("%s was accepted", c.manifest) + continue + } + if !strings.Contains(err.Error(), c.says) { + t.Errorf("%s refused for the wrong reason: %v", c.manifest, err) + } + } +} + +// A stored manifest with a key this reader does not know, at the top or inside any block, is decoded with the key set +// aside and named, and its module is composed, never left out (novox/hq ADR 0578 §6, which changes ADR 0262); the judge +// refuses it. One case per block whose decoder wraps the decoder's words in its own. +func TestAStoredManifestWithAnUnknownKeyIsSetAsideAndTheJudgeRefusesIt(t *testing.T) { + t.Run("it still provides what it provides", aModuleWithAFieldSetAsideStillProvidesWhatItProvides) + t.Run("it is composed whole", aModuleWithAFieldSetAsideIsComposedWhole) + for where, raw := range map[string]string{ + "the top": `{"module": "later", "version": "2", "a-field-from-later": {"x": 1}}`, + "a state": `{"module": "later", "version": "2", "state": [{"name": "s", "a-field-from-later": 1}]}`, + "a provided name": `{"module": "later", "version": "2", "provides": [{"name": "p", "a-field-from-later": 1}]}`, + "an offer's identity": `{"module": "later", "version": "2", "provides": [{"name": "p", "identity": {"in": "x", "a-field-from-later": 1}}]}`, + "a backup": `{"module": "later", "version": "2", "data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable", "backup": {"dump": "x", "into": "y", "a-field-from-later": 1}}]}}`, + "an own secret": `{"module": "later", "version": "2", "own-secrets": {"s": {"path": "/x", "a-field-from-later": 1}}}`, + "a seat's verb": `{"module": "later", "version": "2", "seats": [{"name": "later-seat", "serves": [{"name": "v", "a-field-from-later": 1}]}]}`, + } { + m, err := catalogue.Decode([]byte(raw)) + if err != nil { + t.Errorf("%s: the stored manifest was not read: %v", where, err) + continue + } + if m.Module != "later" || m.Version != "2" || !strings.Contains(m.SetAside(), `"a-field-from-later"`) || m.ReadNoFurther() { + t.Errorf("%s: read as %q %q, unknown %q", where, m.Module, m.Version, m.UnknownField()) + continue + } + if where != "the top" && !strings.Contains(m.UnknownField(), ": json: unknown field") { + t.Errorf("%s: the block's decoder did not say it: %q", where, m.UnknownField()) + } + left := catalogue.Resolution{Node: "laptop", Modules: []catalogue.Manifest{m, notifier()}}.LeftOut(nil, false) + if len(left) != 0 { + t.Errorf("%s: a module was left out for a field set aside: %v", where, left) + } + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "a-field-from-later") { + t.Errorf("%s: the module check took it: %v", where, err) + } + } + var known catalogue.Manifest + if err := json.Unmarshal([]byte(`{"module": "now", "state": [{"name": "s"}]}`), &known); err != nil || known.UnknownField() != "" { + t.Fatalf("a known manifest: %v %q", err, known.UnknownField()) + } + // A malformed manifest is still refused: only an unknown key is read past. + var bad catalogue.Manifest + if err := json.Unmarshal([]byte(`{"module": "bad", "state": [{"name": 3}]}`), &bad); err == nil { + t.Fatal("a malformed stored manifest was read") + } +} + +// Registration refuses a stream the mesh could not hold or would leave ambiguous (ADR 0363 §1, §2, §4). +func TestAStreamIsNamedBoundedAndGrantedByATrustedConfigValue(t *testing.T) { + grants := `"grant":{"config":"grants","events":"mail.{key}.**"}` + for _, c := range []struct{ manifest, says string }{ + {mailManifest(t, `[{"name":"Mail","events":["mail.**"]}]`, ""), `a stream is named locally`}, + {mailManifest(t, `[{"name":"mail.x","events":["mail.**"]}]`, ""), `a stream is named locally`}, + {mailManifest(t, `[{"name":"events","events":["mail.**"]}]`, ""), `the mesh's own stream EVENTS`}, + {mailManifest(t, `[{"name":"log-x","events":["mail.**"]}]`, ""), `starts with LOG_`}, + {mailManifest(t, `[{"name":"mail","events":["mail.**"]},{"name":"mail","events":["x.**"]}]`, ""), `twice`}, + {mailManifest(t, `[{"name":"mail","events":[]}]`, ""), `with no events`}, + {mailManifest(t, `[{"name":"mail","events":["mail.**.x"]}]`, ""), `nothing may follow it`}, + {mailManifest(t, `[{"name":"mail","events":["mail.**"],"max-age-days":31}]`, ""), `between 1 and 30`}, + {mailManifest(t, `[{"name":"mail","events":["mail.**"],"max-mib":9000}]`, ""), `between 1 and 8192`}, + {mailManifest(t, `[{"name":"mail","events":["mail.**"],"stream":"MAIL"}]`, ""), `{name, events`}, + {mailManifest(t, `[{"name":"mail","events":["mail.**"]}]`, `"emits":["mail.mesh.message.added"],`), + `which its stream "mail" carries`}, + {mailManifest(t, `[{"name":"mail","events":["mail.**"]},{"name":"more","events":["mail.x.*"]}]`, ""), + `both carry`}, + {mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"tags","events":"mail.{key}.**"}}]`, ""), + `is not a trusted config value`}, + {mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"grants","events":"mail.**"}}]`, ""), + `exactly once`}, + {mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"grants","events":"{key}.{key}"}}]`, ""), + `exactly once`}, + {mailManifest(t, `[{"name":"mail","events":["mail.**"],`+grants+`,"why":1}]`, ""), `{name, events`}, + {`{"module":"a.b","version":"1","streams":[{"name":"x","events":["x"]}]}`, `no dot`}, + } { + _, err := ParseManifest([]byte(c.manifest)) + if err == nil { + t.Errorf("%s was accepted", c.manifest) + continue + } + if !strings.Contains(err.Error(), c.says) { + t.Errorf("%s refused for the wrong reason: %v", c.manifest, err) + } + } +} + +// A system the mesh does not build for is refused where it is written. These are the host's own +// names, not a compiler's: the difference between two of them is a C library rather than a kernel, +// so a value that looks like an operating system to a toolchain is still wrong here. +func TestASystemTheMeshDoesNotBuildForIsRefused(t *testing.T) { + for _, wrong := range []string{"linux", "debian", "darwin"} { + got := buildProblemsOf(t, bundleFor("go", wrong)) + if !strings.Contains(got, "and a system is") { + t.Fatalf("%q was accepted as a system:\n%s", wrong, got) + } + } +} + +func TestATemplatedModuleWhoseBuildPacksNoFilesIsRefused(t *testing.T) { + for _, build := range []string{ + ``, + `"build":{"artifacts":[{"name":"look","kind":"archive","from":"theme"}]},`, + `"build":{"artifacts":[{"name":"a","kind":"archive","from":"files"},{"name":"b","kind":"archive","from":"./files/"}]},`, + } { + raw := strings.Replace(templatedTwin, + `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, build, 1) + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "files/") { + t.Errorf("a templated module with the build %q was not refused: %v", build, err) + } + } +} + +func TestATemplateOnAResourceThatIsNotAFileIsRefused(t *testing.T) { + raw := strings.Replace(templatedTwin, `{"id":"conf","type":"directory","path":"${dir:conf}"}`, + `{"id":"conf","type":"directory","path":"${dir:conf}","template":"files/tidy.conf"}`, 1) + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "conf is a directory and names a template") { + t.Fatalf("a directory naming a template was not refused: %v", err) + } +} + +func TestATemplateOutsideFilesIsRefusedByName(t *testing.T) { + for _, bad := range []string{"../tidy.conf", "/etc/tidy.conf", "files", "files/", "files/../tidy.conf", + "files/./tidy.conf", "other/tidy.conf", "tidy.conf", "files//tidy.conf", `files\\tidy.conf`} { + raw := strings.Replace(templatedTwin, `"files/tidy.conf"`, `"`+bad+`"`, 1) + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "main") || !strings.Contains(err.Error(), "not a file under files/") { + t.Errorf("the template %q was not refused by name: %v", bad, err) + } + } +} + +func TestATemplateOutsideTheNamedTemplatesDirectoryIsRefused(t *testing.T) { + raw := strings.Replace(unpackingTemplated, `"template":"templates/tidy.conf"`, `"template":"files/tidy.conf"`, 1) + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "files/tidy.conf") || + !strings.Contains(err.Error(), "templates/") { + t.Fatalf("a template outside the named templates directory was not refused by name: %v", err) + } +} + +func TestATemplatesDirectoryNoArchiveIsBuiltFromIsRefused(t *testing.T) { + raw := strings.Replace(unpackingTemplated, `, + {"name":"templates","kind":"archive","from":"templates"}`, "", 1) + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "no archive from templates/") { + t.Fatalf("a module whose build packs no archive from its templates directory was not refused: %v", err) + } +} + +func TestATemplatesDirectoryOnAModuleNamingNoTemplateIsRefused(t *testing.T) { + raw := strings.Replace(unpackingInline, `"version":"1",`, `"version":"1","templates":"templates",`, 1) + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "names no template") { + t.Fatalf("a templates directory read by nothing was not refused: %v", err) + } +} + +func TestATemplatesDirectoryThatIsNotAPlainRelativeDirectoryIsRefused(t *testing.T) { + for _, dir := range []string{"/etc", "../other", "..", "templates/../files", "./templates", ".", "a\\b", "templates/"} { + raw := strings.Replace(unpackingTemplated, `"templates":"templates"`, `"templates":`+jsonString(dir), 1) + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "templates directory") { + t.Errorf("the templates directory %q was not refused: %v", dir, err) + } + } +} + +// novox/hq ADR 0259 §8: a module saying warrants, or speaking for a kind that proves its sender, runs as an +// account of its own — never carried by the machine's runtime, which runs as the operator's account. +func TestATrustedHolderMustRunAsAnAccountOfItsOwn(t *testing.T) { + r := router() + r.RunsAs = "" + if got := problemsFor(t, catalogue.Shelf{"messenger": r}); !strings.Contains(got, "messenger must run as an account of its own") { + t.Errorf("a router on the machine's runtime stood: %s", got) + } + tg := aChannel("telegram", "telegram") + tg.Claims[0].Capabilities = []string{"choice", "verified-sender"} + if got := problemsFor(t, catalogue.Shelf{"messenger": router(), "telegram": tg}); !strings.Contains(got, "telegram must run as an account of its own") { + t.Errorf("a verified channel on the machine's runtime stood: %s", got) + } + desk := aChannel("desk-channel", "desktop") + desk.Claims[0].Capabilities = []string{"choice"} + if got := problemsFor(t, catalogue.Shelf{"messenger": router(), "desk-channel": desk}); got != "" { + t.Errorf("a channel proving nothing was held to it: %s", got) + } + // A kind that is `private` shows a link's code, which links an account as the operator: its holder is + // trusted with it, so it runs as its own account too (the confirmation review of 2026-10-09). + private := aChannel("desk-channel", "desktop") + private.Claims[0].Capabilities = []string{"choice", "private"} + if got := problemsFor(t, catalogue.Shelf{"messenger": router(), "desk-channel": private}); !strings.Contains(got, "desk-channel must run as an account of its own") { + t.Errorf("a private channel on the machine's runtime stood: %s", got) + } +} + +// A misspelt key inside a verb's definition is refused, like one anywhere else in the manifest. +func TestAVerbWithAnUnknownKeyIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"till","version":"1",` + + `"seats":[{"name":"shop-till","serves":[{"name":"price","descripton":"typo"}]}]}`)) + if err == nil || !strings.Contains(err.Error(), "descripton") { + t.Fatalf("a verb with a misspelt key was accepted: %v", err) + } +} + +// A family is named one by one, never by a wildcard, and only by the tool runner's module (ADR 0367 §2, §6): +// each refusal is said in the manifest's words, at parse, rather than at the composition of the bus's users. +func TestAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner(t *testing.T) { + for manifest, want := range map[string]string{ + `{"module":"shop","version":"1","waits":["mesh-knowledge.reviewed"]}`: "only node-tools", + `{"module":"node-tools","version":"1","waits":["mesh-knowledge.*"]}`: "never a wildcard", + `{"module":"node-tools","version":"1","waits":["**"]}`: "never a wildcard", + `{"module":"node-tools","version":"1","waits":["mesh-knowledge.>"]}`: "never a wildcard", + `{"module":"node-tools","version":"1","waits":["*.reviewed"]}`: "never a wildcard", + `{"module":"node-tools","version":"1","waits":["reviewed"]}`: "does not name an emitter and an event", + `{"module":"node-tools","version":"1","waits":["mesh-knowledge..reviewed"]}`: "does not name an emitter and an event", + `{"module":"node-tools","version":"1","waits":["Mesh-Knowledge.reviewed x"]}`: "does not name an emitter and an event", + `{"module":"node-tools","version":"1","waits":["a.b","a.b"]}`: "twice", + } { + _, err := ParseManifest([]byte(manifest)) + if err == nil { + t.Errorf("accepted: %s", manifest) + continue + } + if !strings.Contains(err.Error(), want) { + t.Errorf("%s refused for the wrong reason (want %q): %v", manifest, want, err) + } + } +} + +// 1178/AC2 (another module's consumes): a work-queue stream another module's consumes asks for is refused. +func TestAWorkQueueStreamAnotherModulesConsumesIsRefused(t *testing.T) { + sender := mailSenderManifestWithQueue("mail-sends") + reader := catalogue.Manifest{Module: "reader", Version: "1", Consumes: []string{"mesh-mail.mail.send.approved"}} + got := strings.Join(WorkQueueConsumedElsewhereProblems(catalogue.Shelf{"mesh-mail": sender, "reader": reader}), "; ") + if !strings.Contains(got, "reader") || !strings.Contains(got, "MAIL_SENDS") { + t.Fatalf("another module's consumes of a work-queue stream: %q", got) + } +} + +// 1178/AC2 (named): a work-queue stream no process names is refused. +func TestAWorkQueueStreamNoProcessNamesIsRefused(t *testing.T) { + m := mailSenderManifestWithQueue("") + got := strings.Join(WorkQueueStreamProblems(m), "; ") + if !strings.Contains(got, "mail-sends") || !strings.Contains(got, "no process names it") { + t.Fatalf("an unnamed work-queue stream: %q", got) + } +} + +// 1178/AC2 (named once): a work-queue stream exactly one process names is accepted. +func TestAWorkQueueStreamOneProcessNamesIsAccepted(t *testing.T) { + m := mailSenderManifestWithQueue("mail-sends") + if got := WorkQueueStreamProblems(m); len(got) != 0 { + t.Fatalf("a work-queue stream one process names was refused: %v", got) + } +} + +// 1177/AC2: a stream whose work-queue values are out of range, contradictory, or carries a grant is refused +// naming the stream and the rule. +func TestAWorkQueueStreamsValuesAreBounded(t *testing.T) { + for name, c := range map[string]struct{ streams, says string }{ + "max-deliveries 0": {`[{"name":"mail-sends","events":["mail.send.approved"],"retention":"work-queue",` + + `"max-deliveries":0,"duplicates":24}]`, "between 1 and 10"}, + "max-deliveries 11": {`[{"name":"mail-sends","events":["mail.send.approved"],"retention":"work-queue",` + + `"max-deliveries":11,"duplicates":24}]`, "between 1 and 10"}, + "duplicates longer than its maximum age": {`[{"name":"mail-sends","events":["mail.send.approved"],` + + `"max-age-days":1,"retention":"work-queue","max-deliveries":5,"duplicates":25}]`, "longer than its maximum age"}, + "an unknown retention": {`[{"name":"mail-sends","events":["mail.send.approved"],"retention":"sometimes"}]`, + "retention"}, + "max-deliveries without retention work-queue": {`[{"name":"mail-sends","events":["mail.send.approved"],` + + `"max-deliveries":5}]`, "without retention"}, + "duplicates without retention work-queue": {`[{"name":"mail-sends","events":["mail.send.approved"],` + + `"duplicates":24}]`, "without retention"}, + "a work-queue stream carrying a grant": {`[{"name":"mail-sends","events":["mail.send.approved"],` + + `"retention":"work-queue","max-deliveries":5,` + + `"grant":{"config":"grants","events":"mail-sends.{key}.**"}}]`, "carrying a grant"}, + } { + _, err := ParseManifest([]byte(mailManifest(t, c.streams, ""))) + if err == nil || !strings.Contains(err.Error(), "mail-sends") || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v; want a refusal naming the stream and %q", name, err, c.says) + } + } +} + +// 1178/AC2 (named twice): a work-queue stream two processes name is refused. +func TestAWorkQueueStreamTwoProcessesNameIsRefused(t *testing.T) { + m := mailSenderManifestWithQueue("mail-sends") + m.Resources = append(m.Resources, map[string]any{"id": "resend", "type": "process", + "run": []any{"./mesh-mail", "send"}, "user": "mesh-mail-send", + "bus": map[string]any{"emits": []any{"mail.send.sent"}, "queues": []any{"mail-sends"}}}) + got := strings.Join(WorkQueueStreamProblems(m), "; ") + if !strings.Contains(got, "mail-sends") || !strings.Contains(got, "2 processes name") { + t.Fatalf("a work-queue stream two processes name: %q", got) + } +} + +// A zone names the listen that answers it, or there is nothing to forward to (novox/hq ADR 0199). +func TestAZoneMustNameOneOfTheModulesListens(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"lab","version":"1", + "listens":[{"name":"dns","port":5353,"protocol":"udp","from":"mesh","why":"the lab's names"}], + "zone":{"name":"${setting:zone}","listen":"web"}}`)) + if err == nil || !strings.Contains(err.Error(), `has no listen of that name`) { + t.Fatalf("a zone answered by a listen the module does not have was accepted: %v", err) + } + if _, err := ParseManifest([]byte(`{"module":"lab","version":"1", + "listens":[{"name":"dns","port":5353,"protocol":"udp","from":"mesh","why":"the lab's names"}], + "zone":{"name":"${setting:zone}","listen":"dns"}}`)); err != nil { + t.Fatalf("a well-formed zone was refused: %v", err) + } +} + +func TestBindingWhatYouDoNotRequireIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"app","version":"1", + "binds":{"postgres-database":"/etc/app/db.json"}}`)) + if err == nil { + t.Fatal("a module was told about something it never asked for") + } + if !strings.Contains(err.Error(), "does not require") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +// A channel's capabilities come from the fixed vocabulary, and only a kinded claim carries any. +func TestCapabilitiesAreTheVocabularysAndOnlyOnAKindedClaim(t *testing.T) { + good := aChannel("telegram", "telegram") + good.Claims[0].Capabilities = []string{"deliver", "choice", "verified-sender", "max-length:4096"} + good.RunsAs = "telegram" + if got := problemsFor(t, catalogue.Shelf{"messenger": router(), "telegram": good}); got != "" { + t.Fatalf("the vocabulary was refused: %s", got) + } + bad := aChannel("telegram", "telegram") + bad.Claims[0].Capabilities = []string{"trusted", "max-length:lots"} + got := problemsFor(t, catalogue.Shelf{"messenger": router(), "telegram": bad}) + for _, w := range []string{`"trusted"`, `"max-length:lots"`} { + if !strings.Contains(got, w+", which channel-capabilities/1 does not have") { + t.Errorf("%s was not refused: %s", w, got) + } + } + odd := catalogue.Manifest{Module: "odd", Claims: []catalogue.Claim{{Name: "operator-channel", Scope: catalogue.ScopeMesh, Capabilities: []string{"deliver"}}}} + if got := problemsFor(t, catalogue.Shelf{"messenger": router(), "odd": odd}); !strings.Contains(got, "only a kinded bench's claim carries them") { + t.Errorf("capabilities on a seat that is not kinded stood: %s", got) + } +} + +func TestCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) { + for _, moment := range powerMoments { + m := mod("laptop", nil, nil, nil) + m.Shell = []catalogue.ShellCode{{For: moment, Slot: "normal", Code: "true"}} + if got := catalogue.DependsOn(nil, m); !reflect.DeepEqual(got, []string{catalogue.PowerSeat}) { + t.Errorf("code for %s depends on %v, want %s", moment, got, catalogue.PowerSeat) + } + if p := manifestShellProblems(m); len(p) != 0 { + t.Errorf("code for %s is refused: %v", moment, p) + } + } + m := mod("laptop", nil, nil, nil) + m.Shell = []catalogue.ShellCode{{For: "after-lunch", Slot: "normal", Code: "true"}} + if p := manifestShellProblems(m); len(p) == 0 { + t.Error("code for a moment that does not exist was accepted") + } + if s, ok := catalogue.SeatNamed(catalogue.PowerSeat); !ok || s.Scope != catalogue.ScopeNode { + t.Errorf("%s is not a node seat of the mesh's own: %+v %v", catalogue.PowerSeat, s, ok) + } +} + +func TestCollectionsAndItemsAreJudged(t *testing.T) { + for name, c := range map[string]struct{ raw, says string }{ + "an item of a module not moved": {`{"module":"m","version":"1", + "items":[{"collection":"shell:zsh:normal","template":"files/x"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is marked moved"}, + "two items to one collection": {`{"module":"m","version":"1","moved":true, + "items":[{"collection":"a","template":"files/x"},{"collection":"a","template":"files/y"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "contributes to a twice"}, + "an item to no slot": {`{"module":"m","version":"1","moved":true, + "items":[{"collection":"shell:zsh:middle","template":"files/x"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is no slot"}, + "an item outside the templates": {`{"module":"m","version":"1","moved":true, + "items":[{"collection":"a","template":"x"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is not a file under files/"}, + "an item naming a secret": {`{"module":"m","version":"1", + "items":[{"collection":"a","content":"t=${secret:token}"}]}`, "where no secret is"}, + "a collection not declared": {`{"module":"m","version":"1", + "resources":[{"id":"f","type":"file","path":"/f","content":"${collected:a}"}]}`, "does not collect"}, + "an old namespace declared": {`{"module":"m","version":"1","collections":[{"name":"shell:zsh:normal"}], + "resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "read from its placeholder"}, + "a collection placed nowhere": {`{"module":"m","version":"1","collections":[{"name":"a"}], + "resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "no file of its places it"}, + "a collection in a path": {`{"module":"m","version":"1","collections":[{"name":"a"}], + "resources":[{"id":"f","type":"file","path":"/${collected:a}","content":"${collected:a}"}]}`, + "only in a file's content"}, + } { + _, err := ParseManifest([]byte(c.raw)) + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v, want it to say %q", name, err, c.says) + } + } + if _, err := ParseManifest([]byte(collectorZsh)); err != nil { + t.Errorf("a collector: %v", err) + } +} + +// Rule 3: a default failing its validation. +func TestConfigRefusesADefaultFailingItsValidation(t *testing.T) { + for _, c := range []struct { + key string + value any + said string + }{ + {"font-size", 4, "below its minimum"}, + {"font-size", 41, "above its maximum"}, + {"font-size", "10", "not a number"}, + {"position", "bottom", "not one of"}, + {"theme", "Dark", "does not match"}, + } { + m := inConfigForm() + declaration(m, c.key)["default"] = c.value + refused(t, m, `"`+c.key+`"`, c.said) + } +} + +// Rule 10: a moved module's mergeable file not naming its keys under takes-config. +func TestConfigRefusesAMergeableFileNotNamingItsKeys(t *testing.T) { + m := inConfigForm() + delete(resourceOf(m, "rules"), "takes-config") + // position and theme are then placed nowhere; say they are read at runtime so only the file's rule speaks. + declaration(m, "position")["runtime"] = true + declaration(m, "theme")["runtime"] = true + refused(t, m, "rules", "names no takes-config") +} + +// Rule 11: a mesh word, puid or pgid in takes-config. +func TestConfigRefusesAMeshWordOrAnAccountFactInTakesConfig(t *testing.T) { + for _, word := range []string{"ports", "expose", "reach", "endpoints", "places", "accesses", "networks", "puid", "pgid"} { + m := inConfigForm() + resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", word} + refused(t, m, `"`+word+`"`, "takes-config") + } +} + +// Rule 4: an object or a list not trusted. +func TestConfigRefusesAnObjectOrListNotTrusted(t *testing.T) { + for _, kind := range []string{"object", "list"} { + m := inConfigForm() + def := any(map[string]any{"a": 1}) + if kind == "list" { + def = []any{"a"} + } + m["config"].(map[string]any)["rules"] = map[string]any{"type": kind, "description": "the rules, as " + kind, + "default": def, "runtime": true} + refused(t, m, `"rules"`, "is "+kind+" and not trusted") + m["config"].(map[string]any)["rules"].(map[string]any)["trusted"] = true + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("a trusted %s is refused: %v", kind, err) + } + } +} + +// Rule 6: an untrusted value falling back to a trusted predefined key. +func TestConfigRefusesAnUntrustedValueFallingBackToATrustedKey(t *testing.T) { + withPredefined(t, nil) + m := inConfigForm() + declaration(m, "theme")["predefined"] = "theme" + refused(t, m, `"theme"`, "is not present") + + withPredefined(t, map[string]catalogue.PredefinedKey{"theme": {Type: "string", Trusted: true}}) + refused(t, m, `"theme"`, "trusted", "is not") + declaration(m, "theme")["trusted"] = true + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("a trusted value falling back to a trusted key is refused: %v", err) + } +} + +// Rule 8: a placeholder naming an undeclared value. +func TestConfigRefusesAPlaceholderNamingAnUndeclaredValue(t *testing.T) { + m := inConfigForm() + resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${config:width}\n" + + "from = ${config:sender}\ncolour = ${config:colour}\n" + refused(t, m, "${config:colour}", "declares no such value") +} + +// Rule 5: a predefined key not on the list, or of another type. Where no list is present, a value naming one is +// refused saying so. +func TestConfigRefusesAPredefinedKeyNotOnTheListOrOfAnotherType(t *testing.T) { + withPredefined(t, nil) + m := inConfigForm() + declaration(m, "font-size")["predefined"] = "font-size" + refused(t, m, `"font-size"`, "the list of predefined keys", "is not present") + + withPredefined(t, map[string]catalogue.PredefinedKey{"font-size": {Type: "number"}, "accent": {Type: "string"}}) + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("a value falling back to a listed key of its type is refused: %v", err) + } + m = inConfigForm() + declaration(m, "font-size")["predefined"] = "font-scale" + refused(t, m, `"font-size"`, `"font-scale"`, "not on the list") + m = inConfigForm() + declaration(m, "font-size")["predefined"] = "accent" + refused(t, m, `"font-size"`, `"accent"`, "is a string") +} + +// Rule 9: a reload-on or restart-on naming a file the module does not declare. +func TestConfigRefusesAReloadOrRestartOnNamingAnUndeclaredFile(t *testing.T) { + m := inConfigForm() + resourceOf(m, "notifier")["reload-on"] = []any{"configuration", "dunstrc"} + refused(t, m, "reload-on", `"dunstrc"`, "declares no such") + m = inConfigForm() + resourceOf(m, "notifier")["restart-on"] = []any{"rule"} + refused(t, m, "restart-on", `"rule"`) + // Another module's file, and one the mesh makes for the module, are not the module's to declare. + m = inConfigForm() + resourceOf(m, "notifier")["restart-on"] = []any{"rules", "resolver.fact-zones", "secret-mail", "composed-jails"} + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("refused: %v", err) + } +} + +// A key a mergeable file takes is a config value like every other: one not declared under config is refused by +// name (ADR 0348 §4, review of mesh-controller#320). +func TestConfigRefusesATakesConfigKeyNotDeclared(t *testing.T) { + m := inConfigForm() + resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "urgency"} + refused(t, m, `"urgency"`, "takes-config", "declares no such value under config") +} + +// Rule 7: a value neither placed in a template nor read at runtime. +func TestConfigRefusesAValueNeitherPlacedNorReadAtRuntime(t *testing.T) { + m := inConfigForm() + delete(declaration(m, "history"), "runtime") + refused(t, m, `"history"`, "${config:history}", `"runtime": true`) + // Taken by a mergeable file is placed. + m = inConfigForm() + if _, err := parseRaw(t, m); err != nil { + t.Fatal(err) + } +} + +// Rule 2: a value with neither a default nor required. +func TestConfigRefusesAValueWithNeitherDefaultNorRequired(t *testing.T) { + m := inConfigForm() + delete(declaration(m, "width"), "default") + refused(t, m, `"width"`, "neither a default nor required") + m = inConfigForm() + declaration(m, "sender")["default"] = "noreply@example.org" + refused(t, m, `"sender"`, "both a default and required") +} + +// Rule 1: a value without type or description. +func TestConfigRefusesAValueWithoutTypeOrDescription(t *testing.T) { + m := inConfigForm() + delete(declaration(m, "width"), "type") + refused(t, m, `notifier's config value "width"`, "no type") + m = inConfigForm() + declaration(m, "width")["type"] = "colour" + refused(t, m, `"width"`, `type "colour"`) + m = inConfigForm() + delete(declaration(m, "width"), "description") + refused(t, m, `"width"`, "no description") +} + +// 1658/AC2: a declared value placed nowhere, with an item that does not say it, is still refused. +func TestConfigStillRefusesAValueNoItemPlaces(t *testing.T) { + m := map[string]any{ + "module": "m", "version": "1", + "config": map[string]any{ + "k": map[string]any{"type": "string", "description": "a word nothing says", "default": "x"}, + }, + "items": []any{map[string]any{"collection": "a", "content": "k = fixed\n"}}, + } + refused(t, m, `"k"`, "placed nowhere") +} + +// Consumer data said to live in a provision the module requires is protected only as well as that +// provision's provider protects its consumers' data. +func TestConsumerDataInARequiredProvisionIsJudgedAgainstItsProvider(t *testing.T) { + idp := mustParse(t, `{"module":"idp","version":"1","requires":["postgres-database"], + "provides":[{"name":"oidc-client","scope":"mesh"}],"grants":{"oidc-client":"${dir:g}"}, + "resources":[{"id":"g","type":"directory","mode":"0700"}], + "data":{"consumers":{"oidc-client":{"class":"irreplaceable","in":"postgres-database"}}}}`) + cachy := mustParse(t, strings.Replace(declaredStore, `"consumers":{"postgres-database":{"class":"irreplaceable","in":"store"}}`, + `"consumers":{"postgres-database":{"class":"cache"}}`, 1)) + if p := strings.Join(DataProblems(catalogue.Shelf{"idp": idp, "pg": cachy}), "\n"); !strings.Contains(p, "not protected as irreplaceable") { + t.Fatalf("irreplaceable data kept in a cache passed: %s", p) + } + if p := DataProblems(catalogue.Shelf{"idp": idp, "pg": mustParse(t, declaredStore)}); len(p) > 0 { + t.Fatalf("refused against a provider that keeps its consumers' data: %v", p) + } +} + +// The settings form keeps its own declaration; the config form's has no kind or why. +func TestEachFormKeepsItsOwnDeclaration(t *testing.T) { + m := inConfigForm() + declaration(m, "width")["why"] = "fits a title" + refused(t, m, `"width"`, "why") + + err := parsed(t, catalogue.Manifest{Module: "notifier", + Settings: map[string]catalogue.SettingDeclaration{"width": {Kind: catalogue.KindPreference, Default: float64(250), + Why: "fits a title", Type: "number"}}, + Resources: []map[string]any{{"id": "c", "type": "file", "path": "/x", "content": "${setting:width}"}}}) + if err == nil || !strings.Contains(err.Error(), "under config") { + t.Fatalf("a typed value under settings: %v", err) + } +} + +// TestEveryCatalogueIdentityFitsWhatItRequires is ADR 0225's check over the real catalogue: every +// module's identity, on the longest machine name, fits the bound of every provision it wants. An +// overflow fails here, in the pull request that introduces it, rather than on the provider's machine +// the first time a real machine's name meets the module's (issue 263). +func TestEveryCatalogueIdentityFitsWhatItRequires(t *testing.T) { + _, found := catalogueManifests(t) + shelf := catalogue.Shelf{} + for _, r := range found { + m, err := r.manifest, r.err + if err != nil { + t.Fatalf("%s: %v", r.path, err) + } + shelf[m.Module] = m + } + for _, p := range IdentityProblems(shelf, catalogue.DefaultLongestMachine) { + t.Error(p) + } + // The night it was found: the resolver provision bounds nothing, and the object store still 20. + if dns, ok := shelf["dnsmasq"]; ok { + if b := dns.IdentityBoundOf("wildcard-resolution"); b.Bounded() { + t.Errorf("the resolver provision bounds its consumers' identities: %+v", b) + } + } + if store, ok := shelf["minio"]; ok { + if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 { + t.Errorf("the object store's access key is not bounded at 20: %+v", b) + } + } +} + +// TestEveryCatalogueModuleDeclaresItsData is ADR 0233's check over the real catalogue (it replaced ADR +// 0214's store list): every provider that grants says what it keeps for its consumers, nothing writes a +// backup line by hand, every directory a container writes is declared, and every irreplaceable item is +// backed up — so a store added is a store backed up, without a list of stores to keep in step. +func TestEveryCatalogueModuleDeclaresItsData(t *testing.T) { + _, found := catalogueManifests(t) + shelf := catalogue.Shelf{} + granting := 0 + for _, r := range found { + m, err := r.manifest, r.err + if err != nil { + continue // TestEveryCatalogueManifestParses says why + } + if len(m.Grants) > 0 { + granting++ + } + shelf[m.Module] = m + } + for _, problem := range DataProblems(shelf) { + t.Error(problem) + } + if granting == 0 { + t.Fatal("no module in the catalogue grants a provision, so this proved nothing") + } +} + +// **Every module's event names are what design 29 says, across the whole catalogue.** +// +// The rule above holds by construction and turned out to be weaker than it reads: a manifest holds +// no subject, and every manifest in the catalogue still held the old bus's routing key, which +// derives into a namespace nobody owns (novox/hq 04-ISSUES/127). Nothing failed — the services +// started and none of them reacted. This is the check that was missing. +func TestEveryManifestsEventNamesAreLocal(t *testing.T) { + manifests := theCatalogue(t) + + var problems []string + for _, m := range manifests { + problems = append(problems, EventProblems(m)...) + } + if len(problems) > 0 { + t.Fatalf("the catalogue holds %d event name(s) the mesh would derive wrongly:\n %s", + len(problems), strings.Join(problems, "\n ")) + } +} + +// **Across the whole catalogue**: every log is named locally and capped within the mesh's caps. +func TestEveryManifestsLogsAreLocalAndCapped(t *testing.T) { + var problems []string + for _, m := range theCatalogue(t) { + problems = append(problems, LogProblems(m)...) + } + if len(problems) > 0 { + t.Fatalf("the catalogue's logs are not what ADR 0297 says:\n %s", strings.Join(problems, "\n ")) + } +} + +// **Across the whole catalogue**: every state name is local and every read names a module's state. +func TestEveryManifestsStateIsLocalAndEveryReadIsKept(t *testing.T) { + manifests := theCatalogue(t) + var problems []string + for _, m := range manifests { + problems = append(problems, StateProblems(m)...) + } + if len(problems) > 0 { + t.Fatalf("the catalogue's state is not what ADR 0201 says:\n %s", strings.Join(problems, "\n ")) + } +} + +func TestEveryOutOfBoundsHealthIsRefusedByName(t *testing.T) { + cases := []struct { + name string + on string + health map[string]any + says string + }{ + {"no kind", "server", map[string]any{"endpoint": "web"}, "with no kind"}, + {"an unknown kind", "server", map[string]any{"kind": "ping"}, `of kind "ping"`}, + {"a port", "server", map[string]any{"kind": "tcp", "port": 8080}, "names a port"}, + {"an address", "server", map[string]any{"kind": "http", "endpoint": "web", "address": "127.0.0.1"}, "names a address"}, + {"a url", "server", map[string]any{"kind": "http", "url": "http://localhost:8080/"}, "names a url"}, + {"an unknown key", "server", map[string]any{"kind": "tcp", "endpoint": "web", "retries": 3}, `"retries"`}, + {"no endpoint", "server", map[string]any{"kind": "http"}, "names no endpoint"}, + {"an undeclared endpoint", "server", map[string]any{"kind": "tcp", "endpoint": "admin"}, "does not declare"}, + {"a udp endpoint", "server", map[string]any{"kind": "tcp", "endpoint": "beacon"}, "a check connects over tcp"}, + {"an interval under the floor", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "5s", "timeout": "1s"}, "no more often than every 10s"}, + {"a timeout of the interval", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "10s", "timeout": "10s"}, "under its interval"}, + {"one failing look", "server", map[string]any{"kind": "tcp", "endpoint": "web", "looks": 1}, "at least 2"}, + {"a grace and looks past five minutes", "server", map[string]any{"kind": "tcp", "endpoint": "web", "grace": "4m", "looks": 3, "interval": "30s"}, "at most 5m0s"}, + {"a duration that is not one", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "often"}, "is a duration"}, + {"looks that are not a number", "server", map[string]any{"kind": "tcp", "endpoint": "web", "looks": "three"}, "whole number"}, + {"a path without a slash", "server", map[string]any{"kind": "http", "endpoint": "web", "path": "health"}, "starts with /"}, + {"a status that is not one", "server", map[string]any{"kind": "http", "endpoint": "web", "status": 700}, "is not one"}, + {"a scheme that is not one", "server", map[string]any{"kind": "http", "endpoint": "web", "scheme": "ftp"}, "http or https"}, + {"a status on a tcp check", "server", map[string]any{"kind": "tcp", "endpoint": "web", "status": 200}, "only an http check has"}, + {"an exec with no command", "server", map[string]any{"kind": "exec"}, "says no command"}, + {"a command on an http check", "server", map[string]any{"kind": "http", "endpoint": "web", "command": "true"}, "only an exec check runs"}, + {"a runtime check on a service", "daemon", map[string]any{"kind": "runtime"}, "only a container has"}, + {"a unit check on a container", "server", map[string]any{"kind": "unit"}, "a service's or a process's"}, + {"a tool the module does not serve", "server", map[string]any{"kind": "tool", "tool": "app_admin"}, "does not serve"}, + {"a tool check alone", "server", map[string]any{"kind": "tool", "tool": "app_status"}, "judges itself only by its own tool"}, + {"needs not required", "server", map[string]any{"kind": "tcp", "endpoint": "web", "needs": "redis"}, "does not require"}, + {"health on a step", "seed", map[string]any{"kind": "runtime"}, "does not stay up"}, + {"health on a schedule", "sweep", map[string]any{"kind": "runtime"}, "does not stay up"}, + {"health that is not an object", "server", nil, "is an object"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + raw := healthManifest(t, c.on, c.health) + if c.health == nil { + raw = []byte(strings.Replace(string(raw), `"name":"app-server"`, `"name":"app-server","health":"tcp"`, 1)) + } + _, err := ParseManifest(raw) + if err == nil { + t.Fatalf("%s was accepted", c.name) + } + if !strings.Contains(err.Error(), c.says) { + t.Errorf("refused, but not for saying %q: %v", c.says, err) + } + }) + } +} + +// **A module that hears a restricted stream runs as an account of its own** (ADR 0376 §2): registration refuses a +// `consumes` naming a module and asking for events its restricted stream carries, unless the manifest declares +// `runs-as`; a stream without a grant, an event the provider says on EVENTS, and a wildcard emitter stay as they +// were, and a consumer whose provider is not on the shelf is judged once it is. +func TestHearingARestrictedStreamNeedsAnAccountOfItsOwn(t *testing.T) { + provider, err := ParseManifest([]byte(mailManifest(t, mailStream, `"emits":["account.added"],`))) + if err != nil { + t.Fatal(err) + } + feed := catalogue.Manifest{Module: "feed", Streams: []catalogue.StreamDeclaration{{Name: "items", Events: []string{"item.**"}}}} + carried := catalogue.Manifest{Module: "reader", Consumes: []string{"mesh-mail.mail.*.message.added"}} + got := RestrictedHearingProblems(catalogue.Shelf{"mesh-mail": provider, "reader": carried}) + if len(got) != 1 || !strings.Contains(got[0], "reader consumes mesh-mail.mail.*.message.added") || + !strings.Contains(got[0], "MAIL") || !strings.Contains(got[0], "runs-as") || !strings.Contains(got[0], "ADR 0376") { + t.Fatalf("a carried module hearing MAIL was not refused as it should be: %v", got) + } + if all := strings.Join(CatalogueProblems(catalogue.Shelf{"mesh-mail": provider, "reader": carried}), "; "); !strings.Contains(all, got[0]) { + t.Fatalf("the catalogue's check does not refuse it: %s", all) + } + own := carried + own.RunsAs = "reader" + if got := RestrictedHearingProblems(catalogue.Shelf{"mesh-mail": provider, "reader": own}); len(got) != 0 { + t.Fatalf("a module of its own account was refused: %v", got) + } + for _, m := range []catalogue.Manifest{ + {Module: "events", Consumes: []string{"mesh-mail.account.added"}}, + {Module: "items", Consumes: []string{"feed.item.**"}}, + {Module: "audit", Consumes: []string{"**", "*.mail.**"}}, + } { + if got := RestrictedHearingProblems(catalogue.Shelf{"mesh-mail": provider, "feed": feed, m.Module: m}); len(got) != 0 { + t.Errorf("%s was refused: %v", m.Module, got) + } + } + if got := RestrictedHearingProblems(catalogue.Shelf{"reader": carried}); len(got) != 0 { + t.Fatalf("judged without its provider: %v", got) + } +} + +// 1354/AC3: a contribution placed only where the machine reports the user namespaces capability (the name issue 1308 has +// the node-engine report) is accepted, and a misspelt name is still refused with today's words. The manifests are the +// shape of TestIfACapabilityNamesOneTheNodeEngineDetectsOnAnOfferedKindOnly (mesh-control 6c2a8d62 +// internal/catalogue/seat_data_test.go:105), which stays in mesh-controller with the tests that read its other packages. +func TestIfACapabilityMayNameUserNamespaces(t *testing.T) { + const piece = `{"module":"power","contributions":[{"seat":"node-bar","kind":"block","if-capability":%q,"data":{"bar":"top","place":"status","shows":"battery"}}]}` + if _, err := ParseManifest([]byte(strings.Replace(piece, "%q", `"user-namespaces"`, 1))); err != nil { + t.Fatalf("if-capability user-namespaces was refused: %v", err) + } + for name, want := range map[string]string{ + "user-namespace": "which no machine reports", + "userns": "which no machine reports", + "user_namespaces": "is not a capability's name", + } { + _, err := ParseManifest([]byte(strings.Replace(piece, "%q", `"`+name+`"`, 1))) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: accepted, or refused without today's words %q: %v", name, want, err) + } + } + if !oneOf(catalogue.KnownCapabilities, "user-namespaces") { + t.Error("user-namespaces is not a known capability") + } + for _, name := range []string{"battery", "power-meter", "seat", "container-runtime"} { + if !oneOf(catalogue.KnownCapabilities, name) { + t.Errorf("%s is no longer a known capability", name) + } + } +} + +func TestIfACapabilityNamesOneTheNodeEngineDetectsOnAnOfferedKindOnly(t *testing.T) { + cases := map[string]string{ + // A name nothing detects would leave the piece out on every machine. + `{"seat":"node-bar","kind":"block","if-capability":"batery","data":{"bar":"top","place":"status","shows":"battery"}}`: "which no machine reports", + // A kind the holder depends on is never left out for want of something. + `{"seat":"node-hotkeys","kind":"trigger","if-capability":"battery","content":"KEY_BATTERY 1 x"}`: "only a kind the seat offers", + `{"seat":"node-backup","kind":"backup","if-capability":"seat","content":"path /x"}`: "only a kind the seat offers", + } + for c, want := range cases { + raw := `{"module":"power","contributions":[` + c + `]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: accepted, or refused without %q: %v", c, want, err) + } + } + for _, name := range []string{"battery", "power-meter", "seat"} { + if !oneOf(catalogue.KnownCapabilities, name) { + t.Errorf("%s is not a known capability", name) + } + } +} + +// Data a consumer keeps with a provider as irreplaceable must be protected there: the check refuses a +// provider that keeps its consumers' data as a cache, or in an item with no protection. +func TestIrreplaceableDataKeptWithAProviderMustBeProtectedThere(t *testing.T) { + photos := mustParse(t, `{"module":"photos","version":"1","requires":["s3-bucket"], + "data":{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}}`) + store := func(backup string) catalogue.Manifest { + return mustParse(t, `{"module":"objects","version":"1","provides":[{"name":"s3-bucket","scope":"mesh"}], + "grants":{"s3-bucket":"${dir:g}"}, + "data":{"own":[{"id":"data","path":"${dir:data}","class":"valuable","backup":"`+backup+`"}], + "consumers":{"s3-bucket":{"class":"valuable","in":"data"}}}, + "resources":[{"id":"g","type":"directory","mode":"0700"},{"id":"data","type":"directory","mode":"0700"}]}`) + } + if p := DataProblems(catalogue.Shelf{"photos": photos, "objects": store("copy")}); len(p) > 0 { + t.Fatalf("a provider backing its consumers' data up was refused: %v", p) + } + if p := strings.Join(DataProblems(catalogue.Shelf{"photos": photos, "objects": store("none")}), "\n"); !strings.Contains(p, "neither backed up") { + t.Fatalf("irreplaceable photos in an unprotected store passed: %s", p) + } +} + +func TestItemNamesAttributesAndCapabilitiesAreJudged(t *testing.T) { + item := func(extra string) string { + return `{"module":"p","moved":true,"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` + + `"items":[` + extra + `]}` + } + for items, want := range map[string]string{ + `{"collection":"c","name":"a","template":"files/a"},{"collection":"c","template":"files/b"}`: "has no name", + `{"collection":"c","name":"a","template":"files/a"},{"collection":"c","name":"a","template":"files/b"}`: "twice", + `{"collection":"c","name":"Big","template":"files/a"}`: "is not a name", + `{"collection":"c","attributes":{"order":"100"},"template":"files/a"}`: "0 to 99", + `{"collection":"c","attributes":{"bar":"a b"},"template":"files/a"}`: "is not a value", + `{"collection":"c","if-capability":"batery","template":"files/a"}`: "no machine reports", + `{"collection":"contribution:node-bar:block","attributes":{"bar":"left"},"template":"files/a"}`: "bar=left", + `{"collection":"contribution:node-bar:block","attributes":{"colour":"red"},"template":"files/a"}`: `"colour" is none of them`, + `{"collection":"c","name":"a","template":"files/a","colour":"red"}`: "colour", + } { + _, err := ParseManifest([]byte(item(items))) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: %v, want %q", items, err, want) + } + } + ok := item(`{"collection":"contribution:node-bar:block","name":"a","attributes":{"bar":"top","place":"status","order":"5"},"if-capability":"battery","template":"files/a"}`) + if _, err := ParseManifest([]byte(ok)); err != nil { + t.Fatal(err) + } +} + +func TestKeepsMustBeAnAbsolutePath(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1","keeps":"root"}`)) + if err == nil || !strings.Contains(err.Error(), "keeps") { + t.Fatalf("a relative keeps path was not refused: %v", err) + } + if _, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1","keeps":"/var/lib/mesh-vault/root"}`)); err != nil { + t.Fatalf("an absolute keeps path was refused: %v", err) + } +} + +func TestMalformedShellCodeIsRefusedAtParse(t *testing.T) { + for _, c := range []struct{ shell, want string }{ + {`[{"for":"tcsh","slot":"normal","code":"x"}]`, `tool's shell code 1 is for "tcsh"; the shells are zsh, bash, fish`}, + {`[{"for":"zsh","slot":"middle","code":"x"}]`, `tool's shell code 1 goes in the slot "middle"; the slots are first, normal, last`}, + {`[{"for":"zsh","slot":"last","code":"x"},{"for":"zsh","slot":"last","code":" \n"}]`, `tool's shell code 2 has no code`}, + {`[{"for":"zsh","slot":"last","code":"x","order":1}]`, `unknown field "order"`}, + } { + _, err := ParseManifest([]byte(`{"module":"tool","shell":` + c.shell + `}`)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: want %q, got %v", c.shell, c.want, err) + } + } + // The code itself is never judged: a shell's own `${…}` is not the mesh's. + if _, err := ParseManifest([]byte(`{"module":"tool","shell":[{"for":"zsh","slot":"first",` + + `"code":"source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\""}]}`)); err != nil { + t.Fatalf("shell code was judged as if it were the mesh's: %v", err) + } +} + +// TestNoCatalogueManifestNamesAnInstallation is ADR 0112's check, run over the real catalogue: no +// definition names a domain or a public address the mesh acts on, and every value that must for now +// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks. +func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) { + _, found := catalogueManifests(t) + var named []string + for _, r := range found { + m, err := r.manifest, r.err + if err != nil { + t.Errorf("%s: %v", r.path, err) + continue + } + named = append(named, InstallationProblems(m)...) + } + if len(named) > 0 { + t.Fatalf("%d value(s) name an installation:\n %s", len(named), strings.Join(named, "\n ")) + } +} + +// 1887/A9a: the catalogue defines no seat node-build-agent, and a manifest claiming it is refused naming its +// replacement; the build modules' seats accept the three kinds in their pull order and serve current, pause, resume. +func TestNodeBuildAgentIsRetiredForTheBuildSeats(t *testing.T) { + for _, gone := range []string{"node-build-agent", "mesh-build-machine"} { + if _, ok := catalogue.SeatNamed(gone); ok { + t.Errorf("the mesh still defines %s", gone) + } + } + _, err := ParseManifest([]byte(`{"module":"build-agent","version":"1","claims":[{"name":"node-build-agent","scope":"node"}]}`)) + if err == nil || !strings.Contains(err.Error(), `"node-build-agent", which is retired`) || !strings.Contains(err.Error(), "node-build-go") { + t.Fatalf("the claim is not refused naming its replacement: %v", err) + } + // And over a catalogue whose store still holds no row for it, a use of it is refused the same way. + user := catalogue.Manifest{Module: "watcher", Uses: []string{"node-build-agent"}} + if joined := strings.Join(CatalogueProblems(catalogue.Shelf{user.Module: user}), "; "); !strings.Contains(joined, "which is retired: its work is node-build-go") { + t.Fatalf("a use is not refused naming the replacement: %s", joined) + } + for _, name := range []string{"node-build-go", "node-build-node", "node-build-image"} { + s, ok := catalogue.SeatNamed(name) + if !ok || s.Scope != catalogue.ScopeNode || !s.PulledByKind || strings.Join(s.Accepts, ",") != "delivery,check,recheck" { + t.Fatalf("%s = %+v", name, s) + } + if !s.Yields { + t.Errorf("%s does not yield (ADR 0553)", name) + } + var verbs []string + for _, v := range s.Serves { + verbs = append(verbs, v.Name) + } + if strings.Join(verbs, ",") != "current,pause,resume" { + t.Errorf("%s serves %v", name, verbs) + } + } + // The build seats are the seats that yield, and no other (ADR 0553 §1). + var yielding []string + for _, s := range catalogue.Seats() { + if s.Yields { + yielding = append(yielding, s.Name) + } + } + if strings.Join(yielding, ",") != "node-build-go,node-build-node,node-build-image" { + t.Errorf("the seats that yield are %v", yielding) + } +} + +// §2: a module may claim one of them, and may not declare it as its own. +func TestNoModuleMayDeclareAGraphicalSessionSeat(t *testing.T) { + raw := `{"module":"xorg","seats":[{"name":"node-display-server","scope":"node"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "mesh's own namespace") { + t.Fatalf("a module declared node-display-server as its own: %v", err) + } +} + +// ADR 0204 §1: the login shell is the mesh's, so no module declares it — neither under the mesh's +// name nor under the name a module gave it before. +func TestNoModuleMayDeclareTheLoginShell(t *testing.T) { + for _, n := range []string{"login-shell", "node-login-shell", "node-environment"} { + raw := `{"module":"zsh","seats":[{"name":"` + n + `","scope":"node","serves":["execute"]}],"tools":["execute"]}` + _, err := ParseManifest([]byte(raw)) + if err == nil { + t.Errorf("a module declaring %q was accepted", n) + } + } + got := strings.Join(declaredSeatProblems(catalogue.Manifest{Module: "zsh", + DefinesSeats: []catalogue.SeatDeclaration{{Name: "login-shell", Scope: catalogue.ScopeNode}}}), "; ") + if !strings.Contains(got, `zsh declares a seat named "login-shell"; the login shell is the mesh's own seat node-login-shell`) { + t.Fatalf("declaring login-shell was not refused by name: %q", got) + } + // And a shell module claiming the mesh's seat, serving execute, is what the seat is for. + m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` + + `"claims":[{"name":"node-login-shell","scope":"node"}]}`)) + if err != nil { + t.Fatal(err) + } + if err := catalogue.CanHold(m, catalogue.Seat{Name: catalogue.LoginShellSeat, Scope: catalogue.ScopeNode, Serves: loginShellVerbs()}); err != nil { + t.Fatalf("a shell module claiming the seat cannot hold it: %v", err) + } +} + +// One component, one artifact per system: the shape the mesh's own binaries are declared in, and the +// reason the target is the artifact's rather than the recipe's. +func TestOneArtifactPerSystemIsAccepted(t *testing.T) { + m := catalogue.Manifest{Module: "the-host", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{ + {Name: "arch", Kind: catalogue.ArtifactBundle, Language: "go", System: "arch"}, + {Name: "alpine", Kind: catalogue.ArtifactBundle, Language: "go", System: "alpine"}, + {Name: "android", Kind: catalogue.ArtifactBundle, Language: "go", System: "android"}, + }}} + if got := buildProblemsOf(t, m); got != "" { + t.Fatalf("one artifact per system was refused:\n%s", got) + } +} + +func TestOneNamingFileMakesEveryMergeableFileName(t *testing.T) { + // ADR 0326 §2: a module with one naming and one silent mergeable file is refused, naming the + // silent one — the silent one would otherwise still take every key. + m := twoFiles([]any{"server"}, nil) + problems := TakenSettingsProblems(m) + if len(problems) != 1 || !strings.Contains(problems[0], "sidecar merges and names no takes-settings") { + t.Fatalf("problems: %v", problems) + } + if err := catalogue.JudgeSettings(m, nil, false); err == nil || !strings.Contains(err.Error(), "sidecar") { + t.Errorf("composing it should refuse it too, and said: %v", err) + } + raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1", "resources": m.Resources}) + if err != nil { + t.Fatal(err) + } + if _, err := ParseManifest(raw); err == nil || !strings.Contains(err.Error(), "sidecar merges and names no") { + t.Errorf("the module check does not say it: %v", err) + } +} + +// Only a claimant declares a seat's path: a module claiming no seat that names /etc/hosts and declaring +// it is refused at registration naming the seat; the uplink's holder and the resolver's both declaring +// the resolver file are each a claimant of a seat naming it. Reading a seat's path is access, and allowed. +func TestOnlyAClaimantDeclaresASeatsPath(t *testing.T) { + stranger := catalogue.Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{ + {"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block", "content": "10.0.0.1 x\n"}}} + got := strings.Join(CatalogueProblems(catalogue.Shelf{"stranger": stranger}), "; ") + if !strings.Contains(got, `stranger declares the path "/etc/hosts", which the seat node-hostname names`) { + t.Errorf("a module declaring /etc/hosts without claiming node-hostname was not refused: %q", got) + } + // However the path is spelled (novox/hq issue 412 review). + unclean := catalogue.Manifest{Module: "unclean", Version: "1", Resources: []map[string]any{ + {"id": "hosts", "type": "file", "path": "/etc/../etc/hosts", "content": "10.0.0.1 x\n"}}} + if got := strings.Join(CatalogueProblems(catalogue.Shelf{"unclean": unclean}), "; "); !strings.Contains(got, "node-hostname") { + t.Errorf("/etc/../etc/hosts from a module not claiming node-hostname was not refused: %q", got) + } + reader := catalogue.Manifest{Module: "reader", Version: "1", Accesses: []catalogue.Access{{ID: "hosts", Path: "/etc/hosts"}}} + if got := CatalogueProblems(catalogue.Shelf{"reader": reader}); len(got) != 0 { + t.Errorf("reading /etc/hosts was refused: %v", got) + } + uplink := catalogue.Manifest{Module: "uplink", Version: "1", Claims: []catalogue.Claim{{Name: "node-uplink", Scope: catalogue.ScopeNode}}, + Facts: map[string]catalogue.RosterFile{"resolvers": {Path: catalogue.ResolverFile, Template: "nameserver 10.42.0.1\n"}}} + resolver := localResolver() + resolver.Claims[0].Serves = []string{"routes", "route", "unroute"} + if got := CatalogueProblems(catalogue.Shelf{"uplink": uplink, resolver.Module: resolver}); len(got) != 0 { + t.Errorf("the uplink's holder and the resolver's declaring the resolver file were refused: %v", got) + } +} + +// An image names the Go package its recipe compiles (novox/hq ADR 0267): within the tree it is built in, +// and only an image says one. +func TestOnlyAnImageNamesThePackageItCompilesWithinItsTree(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ + {"name":"x","kind":"image","from":"Dockerfile","compiles":"cmd/x"}]}}`)) + if err != nil || m.Build.Artifacts[0].Compiles != "cmd/x" { + t.Fatalf("an image naming its package was refused or lost it: %v", err) + } + for _, c := range []struct{ artifact, says string }{ + {`{"name":"x","kind":"archive","from":"files","compiles":"cmd/x"}`, "Only an image"}, + {`{"name":"x","kind":"image","from":"Dockerfile","compiles":"../x"}`, "leaves the tree"}, + {`{"name":"x","kind":"image","from":"Dockerfile","compiles":"/x"}`, "leaves the tree"}, + {`{"name":"x","kind":"image","from":"Dockerfile","compiles":"a/../../x"}`, "leaves the tree"}, + } { + _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[` + c.artifact + `]}}`)) + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v, wanted a refusal saying %q", c.artifact, err, c.says) + } + } +} + +// Only a node seat may carry needs (novox/hq ADR 0378 §8): a per-mesh seat's holder is not on any +// one node to depend on anything beside it there. +func TestOnlyANodeSeatMayCarryNeeds(t *testing.T) { + m := catalogue.Manifest{Module: "mail", DefinesSeats: []catalogue.SeatDeclaration{{ + Name: "mail-notifier", Scope: catalogue.ScopeMesh, Needs: []string{"node-notifier"}}}} + got := strings.Join(declaredSeatProblems(m), "; ") + if !strings.Contains(got, "needing") || !strings.Contains(got, "node seat") { + t.Fatalf("a mesh-scope seat's needs were accepted: %q", got) + } +} + +func TestOnlyChannelAndIntakeAreKinded(t *testing.T) { + m := catalogue.Manifest{Module: "x", DefinesSeats: []catalogue.SeatDeclaration{{Name: "pager", Kinded: true, Accepts: []string{"page"}}}} + if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "only channel and intake are kinded") { + t.Fatalf("another kinded bench was declared: %s", got) + } +} + +// The indexer and the builder hold no published list of predefined keys; a manifest whose config values fall back to +// one (claude-code, dunst, xterm at mesh-catalog 835bf22b) is still read for its build's and fingerprint's inputs +// (issue 1751). +func TestParseManifestForInputsReadsAValueFallingBackToAPredefinedKey(t *testing.T) { // issue 1751 + was := catalogue.ReplacePublished(nil) // the indexer's and the builder's case: no published list + defer catalogue.ReplacePublished(was) + for _, module := range []string{"claude-code", "dunst", "xterm"} { + raw, err := os.ReadFile("testdata/catalogue-835bf22b-" + module + ".module.json") + if err != nil { + t.Fatal(err) + } + if _, err := ParseManifest(raw); err == nil { + t.Fatalf("%s: ParseManifest reads it with no list; the test proves nothing", module) + } + if m, err := ParseManifestForInputs(raw); err != nil || m.Module != module { + t.Errorf("%s: %v", module, err) + } + } +} + +func TestPlaceIsValidatedAtTheManifest(t *testing.T) { + both := catalogue.Manifest{Module: "x", Resources: []map[string]any{ + {"id": "d", "type": "directory", "place": ".", "path": "/somewhere"}, + }} + if got := manifestUnknownDirRefs(both); len(got) != 1 || !strings.Contains(got[0], "both path and place") { + t.Fatalf("path beside place refuses; got %v", got) + } + elsewhere := catalogue.Manifest{Module: "x", Resources: []map[string]any{ + {"id": "f", "type": "file", "place": ".", "path": "/somewhere", "content": ""}, + }} + if got := manifestUnknownDirRefs(elsewhere); len(got) != 1 || !strings.Contains(got[0], "not a directory") { + t.Fatalf("place on a file refuses; got %v", got) + } + wrong := catalogue.Manifest{Module: "x", Resources: []map[string]any{ + {"id": "d", "type": "directory", "place": "sub/dir"}, + }} + if got := manifestUnknownDirRefs(wrong); len(got) != 1 || !strings.Contains(got[0], `the places are "."`) { + t.Fatalf("a place that is neither root refuses; got %v", got) + } +} + +func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) { + // It would create a file nobody ever writes to, on a machine where nothing asked for it. + _, err := ParseManifest([]byte(`{"module":"traefik","version":"1", + "receives":{"reverse-proxy":"/etc/traefik/mesh.json"}}`)) + if err == nil { + t.Fatal("a module received contributions to something it does not provide") + } + if !strings.Contains(err.Error(), "does not provide") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +// Replay of novox/hq issue 412 (2026-10-10): which module owns /etc/hosts was enforced only after the fact, +// when two modules on one machine declared it; a module that did not claim node-hostname could declare +// /etc/hosts, and a module could claim node-hostname declaring none of its paths, and nothing refused either. +// Written only with what the catalogue had before its fix (mesh-controller #267): CatalogueProblems, Shelf, +// SeatNamed and CanHold, so it is laid over the commit before. +func TestReplay412(t *testing.T) { + stranger := catalogue.Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{ + {"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block", "content": "10.0.0.1 x\n"}}} + got := strings.Join(CatalogueProblems(catalogue.Shelf{"stranger": stranger}), "; ") + if !strings.Contains(got, "node-hostname") { + t.Errorf("a module declaring /etc/hosts without claiming node-hostname was not refused: %q", got) + } + + seat, ok := catalogue.SeatNamed("node-hostname") + if !ok { + t.Fatal("no node-hostname seat") + } + holder := catalogue.Manifest{Module: "names", Version: "1", Claims: []catalogue.Claim{{Name: "node-hostname", Scope: catalogue.ScopeNode, + Serves: []string{"entries", "add", "remove"}}}} + err := catalogue.CanHold(holder, seat) + if err == nil { + t.Fatal("a holder of node-hostname declaring neither /etc/hosts nor /etc/hostname was accepted") + } + for _, want := range []string{"/etc/hosts", "/etc/hostname"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %s: %v", want, err) + } + } +} + +func TestRunsAsIsAnAccountOfTheModulesOwn(t *testing.T) { + ok := catalogue.Manifest{Module: "telegram", RunsAs: "telegram", SecretsOwner: "telegram", + OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}}, + Resources: []map[string]any{{"id": "account", "type": "user", "name": "telegram"}}} + if got := RunsAsProblems(ok); len(got) != 0 { + t.Fatalf("a sound runs-as was refused: %v", got) + } + for want, change := range map[string]func(*catalogue.Manifest){ + "never root": func(m *catalogue.Manifest) { m.RunsAs, m.SecretsOwner = "root", "root" }, + "not an account name": func(m *catalogue.Manifest) { m.RunsAs = "${machine:account}" }, + "which it does not make": func(m *catalogue.Manifest) { m.Resources = nil }, + "declares no own secret": func(m *catalogue.Manifest) { m.OwnSecrets = nil }, + "they are the account's own": func(m *catalogue.Manifest) { m.SecretsOwner = "" }, + } { + m := ok + m.Resources = append([]map[string]any(nil), ok.Resources...) + m.OwnSecrets = catalogue.OwnSecrets{"broker": {Path: "/x"}} + change(&m) + if got := strings.Join(RunsAsProblems(m), "; "); !strings.Contains(got, want) { + t.Errorf("want %q, got %q", want, got) + } + } +} + +func TestRuntimeHealthIsRefusedWithoutAnAccountOfItsOwn(t *testing.T) { + _, err := ParseManifest(ownAccountManifest(t, false, map[string]any{"kind": "tool", "tool": "keeper_health"})) + if err == nil || !strings.Contains(err.Error(), "runtime-health") || !strings.Contains(err.Error(), "runs-as") { + t.Errorf("runtime-health without runs-as: %v", err) + } +} + +func TestServingWhatYouDoNotProvideIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"app","version":"1", + "serves":{"postgres-database":{"port":5432}}}`)) + if err == nil { + t.Fatal("a module served something it does not provide") + } + if !strings.Contains(err.Error(), "does not provide") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +func TestTakesSettingsIsRefusedWhereItCannotMeanAnything(t *testing.T) { + // ADR 0326 §4. + cases := map[string]struct { + resource map[string]any + says string + }{ + "on a file that does not merge": { + map[string]any{"id": "plain", "type": "file", "path": "/x", "content": "x", + catalogue.TakesSettingsField: []any{"a"}}, + "does not merge"}, + "not a list": { + map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": catalogue.MergeJSON, "content": "{}", + catalogue.TakesSettingsField: "server"}, + "is a list of setting keys"}, + "a mesh word": { + map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": catalogue.MergeJSON, "content": "{}", + catalogue.TakesSettingsField: []any{"endpoints"}}, + "the mesh reads"}, + "a protected key": { + map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": catalogue.MergeJSON, "content": "{}", + "protected": []any{"secret"}, catalogue.TakesSettingsField: []any{"secret"}}, + "keeps it as protected"}, + "named twice": { + map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": catalogue.MergeJSON, "content": "{}", + catalogue.TakesSettingsField: []any{"a", "a"}}, + "twice"}, + "not a key": { + map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": catalogue.MergeJSON, "content": "{}", + catalogue.TakesSettingsField: []any{"two words"}}, + "not a usable setting key"}, + } + for name, c := range cases { + problems := TakenSettingsProblems(catalogue.Manifest{Module: "m", Resources: []map[string]any{c.resource}}) + if !containsText(problems, c.says) { + t.Errorf("%s: want a problem saying %q, got %v", name, c.says, problems) + } + } + // And a file that does not merge is refused where it is settled, not handed to the host. + plain := map[string]any{"id": "plain", "type": "file", "path": "/x", "content": "x", + catalogue.TakesSettingsField: []any{"a"}} + if _, err := catalogue.ApplySettings(plain, nil); err == nil { + t.Error("a file that does not merge and names keys was settled") + } +} + +// The module that provides the artifact store may not be delivered through it. +// +// Building publishes to the store and the builder will not start without one, so a module that +// provides the store and also builds something asks the mesh to put an artifact into the thing +// that artifact is needed to create. On a mesh new enough to have no registry, that is a build +// that never returns (novox/hq 04-ISSUES/029). +func TestTheArtifactStoreCannotBeDeliveredThroughItself(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"registry","version":"1",` + + `"provides":[{"name":"artifact-store","scope":"mesh"}],` + + `"build":{"artifacts":[{"name":"registry","kind":"upstream","from":"registry:2"}]},` + + `"resources":[{"id":"store","type":"container","name":"mesh-registry",` + + `"artifact":"registry","ports":["5000:5000"]}]}`)) + if err == nil { + t.Fatal("a registry module that builds its own image was accepted; the build has " + + "nowhere to publish until the module it belongs to is already running") + } + if !strings.Contains(err.Error(), "artifact-store") { + t.Fatalf("refused without naming the provision the cycle turns on: %v", err) + } +} + +// The module holding mesh-broker is the bus, and its account is granted the bus's snapshot API and +// nothing else (novox/hq ADR 0235). Anything it declared to say or hear on the bus would be granted +// nothing, so it is refused at the parser rather than silently dropped. +func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) { + raw := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], + "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, + "emits":["something.happened"]}`) + _, err := ParseManifest(raw) + if err == nil || !strings.Contains(err.Error(), "granted the bus's snapshot API and nothing else") { + t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err) + } + + // Its tools bundle says what it emits (novox/hq ADR 0357): the node's runtime carries the bundle and is + // granted the module's events, so a bus module that ships one may declare them. + bundled := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], + "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, + "emits":["bus.slow-consumer"],"tools":["bus_streams"], + "build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/t","binary":"t"}]}}`) + m, err := ParseManifest(bundled) + if err != nil { + t.Fatalf("a bus module whose tools bundle declares what it emits was refused: %v", err) + } + // And so does its built manifest, its bundle in Bundles and its build read away (issue 774: nats's built manifest + // was refused, and with it every plan of the control-node). + built, err := m.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle, Reference: "store/tools:1", Digest: "sha256:1"}}) + if err != nil { + t.Fatal(err) + } + raw, err = json.Marshal(built) + if err != nil { + t.Fatal(err) + } + if _, err := ParseManifestForInputs(raw); err != nil { + t.Fatalf("the built manifest of a bus module whose tools bundle declares what it emits was refused: %v", err) + } + + quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], + "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, + "tools":["bus_streams"]}`) + m, err = ParseManifest(quiet) + if err != nil { + t.Fatalf("a bus module with an account and tools was refused: %v", err) + } + if !m.ClaimsSeat(catalogue.BrokerSeat) { + t.Fatal("it does not read as holding the broker seat") + } +} + +// The catalogue check: a granting provider says what it keeps, says it in one place, writes no backup +// line by hand, and every directory a container writes is declared (novox/hq ADR 0233). +func TestTheCatalogueCheckAsksEveryModuleForItsData(t *testing.T) { + unsaid := mustParse(t, `{"module":"q","version":"1","provides":[{"name":"queue","scope":"mesh"}], + "grants":{"queue":"${dir:g}"},"resources":[{"id":"g","type":"directory","mode":"0700"}]}`) + onTheOffer := mustParse(t, `{"module":"r","version":"1","provides":[{"name":"resolver","scope":"mesh","keeps-consumer-data":false}]}`) + byHand := mustParse(t, `{"module":"h","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}], + "contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:d}"}]}`) + writes := mustParse(t, `{"module":"w","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}, + {"id":"c","type":"directory","mode":"0700"}, + {"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data","${dir:c}:/etc/app:ro","${dir:d}/f:/f"]}]}`) + problems := strings.Join(DataProblems(catalogue.Shelf{"q": unsaid, "r": onTheOffer, "h": byHand, "w": writes}), "\n") + for _, want := range []string{ + "q grants queue and does not say what it keeps", + "r says keeps-consumer-data on its offer", + "h's contribution 1 is a backup line written by hand", + `w mounts its directory "d" into a container to be written`, + } { + if !strings.Contains(problems, want) { + t.Errorf("the check does not say %q:\n%s", want, problems) + } + } + if strings.Contains(problems, `"c"`) { + t.Errorf("a read-only mount was taken for written data:\n%s", problems) + } + // The same module declaring the directory, as a cache, passes. + declared := mustParse(t, `{"module":"w","version":"1","data":{"own":[{"id":"d","path":"${dir:d}","class":"cache"}]}, + "resources":[{"id":"d","type":"directory","mode":"0700"}, + {"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data"]}]}`) + if p := DataProblems(catalogue.Shelf{"w": declared}); len(p) > 0 { + t.Fatalf("a declared directory is still refused: %v", p) + } +} + +// Refused before merge: the catalogue check judges each module's identity, on the longest machine +// name, against the bound of every provision it wants — and names the module and the slug to set. +func TestTheCatalogueCheckRefusesAnIdentityThatOverflowsWhatItRequires(t *testing.T) { + shelf := catalogue.Shelf{ + "objects": {Module: "objects", 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"}}, + "photoalbum": {Module: "photoalbum", Requires: []string{"s3-bucket"}}, + "files": {Module: "files", Requires: []string{"s3-bucket"}}, + } + problems := IdentityProblems(shelf, 6) + if len(problems) != 1 || !strings.Contains(problems[0], "photoalbum wants s3-bucket") || + !strings.Contains(problems[0], `"mesh_nnnnnn_photoalbum", 22`) || + !strings.Contains(problems[0], "`slug` of at most 8 characters") { + t.Fatalf("one overflow, named with its remedy, was expected: %q", problems) + } + // A longer machine name refuses more: the check is about the mesh's machines, not one. + if got := IdentityProblems(shelf, 10); len(got) != 2 { + t.Fatalf("on a 10-character name both overflow (mesh_nnnnnnnnnn_files is 21): %q", got) + } + // And a slug is the remedy it names. + album := shelf["photoalbum"] + album.Slug = "album" + shelf["photoalbum"] = album + if got := IdentityProblems(shelf, 6); len(got) != 0 { + t.Fatalf("a slug that fits is still refused: %q", got) + } +} + +// The catalogue as it is passes: no holder of a seat naming a path is missing one, and nobody else +// declares one. +func TestTheCataloguePassesTheSeatPathRules(t *testing.T) { + shelf := catalogue.Shelf{} + for _, m := range theCatalogue(t) { + shelf[m.Module] = m + } + for _, p := range CatalogueProblems(shelf) { + if strings.Contains(p, "ADR 0287") { + t.Errorf("the catalogue breaks ADR 0287: %s", p) + } + } +} + +// The catalogue pass no longer judges one module's reads against another's manifest (novox/hq ADR 0495 clauses 1 and 5, +// issue 1790): what an owner keeps is what it announces on the bus, and each module's own announcement test holds that. +func TestTheCataloguePassJudgesNoReadAgainstTheOwnersManifest(t *testing.T) { + shelf := catalogue.Shelf{ + "licence-manager": {Module: "licence-manager", State: []catalogue.StateDeclaration{{Name: "bindings"}}}, + "claude-code": {Module: "claude-code", Reads: []string{"licence-manager.tokens"}}, + } + for _, p := range CatalogueProblems(shelf) { + if strings.Contains(p, "keeps no state") { + t.Fatalf("the catalogue pass judged a read against the owner's manifest: %s", p) + } + } +} + +// A moved bar is rendered by the provisioner's client, not by the controller, so the rule that its template renders +// every example of the shape is no longer judged here (issue 915, which also covers placementProblems not reading the +// collected form). What the controller still owns is that the bar's files place the blocks at all: a moved bar's +// carry the collected placement, and one that stopped placing them fails here rather than being skipped (issue 432). +func TestTheCataloguesBarPlacesBlocksAndItsShapeCoversEveryShow(t *testing.T) { + holder := catalogueManifest(t, "i3status-rust") + s, _ := catalogue.SeatNamed(catalogue.BarSeat) + if holder.Moved { + placed := "${collected:contribution:" + catalogue.BarSeat + ":" + catalogue.BarKindBlock + ":" + found := false + for _, res := range holder.Resources { + if content, _ := res["content"].(string); strings.Contains(content, placed) { + found = true + } + } + if !found { + t.Fatalf("the catalogue's moved bar places no blocks: none of its files carries %s…}", placed) + } + } else { + if !placesKind(holder, s, catalogue.BarKindBlock) { + // It places them since the catalogue's bar took the seat; a skip here hid a bar that stopped + // (novox/hq issue 432). + t.Fatal("the catalogue's bar places no blocks, so none of the shape's examples would render") + } + tmpl, err := holderTemplate(holder, s, catalogue.BarKindBlock) + if err != nil { + t.Fatal(err) + } + for _, ex := range s.Receives[0].Shape.Examples { + out, err := renderPiece(tmpl, "example", "a-machine", ex) + if err != nil || !strings.Contains(out, "[[block]]") { + t.Errorf("the catalogue's bar renders %v as %q: %v", ex, out, err) + } + } + } + for shows := range barShows { + covered := false + for _, ex := range s.Receives[0].Shape.Examples { + covered = covered || ex["shows"] == shows + } + if !covered { + t.Errorf("the shape has no example that shows %s, so no holder is made to render it", shows) + } + } +} + +// The compiled set keeps the rules: node seats only, absolute and literal, one seat per path without +// stepping back, and a stepping back that cannot cycle. +func TestTheCompiledSeatPathsKeepTheRules(t *testing.T) { + if p := SeatPathProblems(catalogue.DefaultSeats()); len(p) != 0 { + t.Fatalf("the compiled set breaks ADR 0287: %v", p) + } + for _, c := range []struct { + why string + set []catalogue.Seat + want string + }{ + {"a mesh seat naming a path", []catalogue.Seat{{Name: "mesh-x", Scope: catalogue.ScopeMesh, Paths: []catalogue.SeatPath{{Path: "/etc/x"}}}}, + "only a node seat names paths"}, + {"a placeholder", []catalogue.Seat{{Name: "node-x", Scope: catalogue.ScopeNode, Paths: []catalogue.SeatPath{{Path: "/etc/${setting:x}"}}}}, + "placeholder"}, + {"a relative path", []catalogue.Seat{{Name: "node-x", Scope: catalogue.ScopeNode, Paths: []catalogue.SeatPath{{Path: "etc/x"}}}}, + "not a clean absolute path"}, + {"two owners", []catalogue.Seat{ + {Name: "node-a", Scope: catalogue.ScopeNode, Paths: []catalogue.SeatPath{{Path: "/etc/x"}}}, + {Name: "node-b", Scope: catalogue.ScopeNode, Paths: []catalogue.SeatPath{{Path: "/etc/x"}}}}, "no two seats name one path"}, + {"a cycle", []catalogue.Seat{ + {Name: "node-a", Scope: catalogue.ScopeNode, Paths: []catalogue.SeatPath{{Path: "/etc/x", StepsBackFor: "node-b"}}}, + {Name: "node-b", Scope: catalogue.ScopeNode, Paths: []catalogue.SeatPath{{Path: "/etc/x", StepsBackFor: "node-a"}}}}, + "does not own that path itself"}, + {"stepping back for nobody", []catalogue.Seat{ + {Name: "node-a", Scope: catalogue.ScopeNode, Paths: []catalogue.SeatPath{{Path: "/etc/x", StepsBackFor: "node-z"}}}}, + "not a node seat"}, + } { + if got := strings.Join(SeatPathProblems(c.set), "; "); !strings.Contains(got, c.want) { + t.Errorf("%s: %q does not say %q", c.why, got, c.want) + } + } +} + +// Every rule of the section itself, refused at parse in the words of the module. +func TestTheDataSectionIsRefusedWhereItIsWrong(t *testing.T) { + for _, c := range []struct{ name, data, want string }{ + {"a class it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"precious"}]}`, "not one the mesh protects by"}, + {"an access it does not have", `{"own":[{"id":"a","path":"${access:media}","class":"irreplaceable","redundancy":"an array"}]}`, "declares no access"}, + {"irreplaceable and protected by nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":"none"}]}`, "protected one way or the other"}, + {"a cache on redundancy", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","redundancy":"an array"}]}`, "a cache is not protected"}, + {"kept with something not required", `{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}`, "does not require"}, + {"a measure it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","measure":"du"}]}`, "walk, dataset or shallow"}, + {"a cache expecting writes", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","active":"1d"}]}`, "only irreplaceable and valuable"}, + {"a machine path", `{"own":[{"id":"a","path":"/srv/a","class":"cache"}]}`, "names no machine path"}, + {"a directory it does not declare", `{"own":[{"id":"a","path":"${dir:nowhere}","class":"cache"}]}`, "declares no directory"}, + {"a cache backed up", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","backup":"copy"}]}`, "disposable"}, + {"a dump into nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"b"}}]}`, "not one of the module's data items"}, + {"a dump into itself", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"a"}}]}`, "into itself"}, + {"a bad duration", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","within":"soon"}]}`, "not a duration"}, + {"the same id twice", `{"own":[{"id":"a","path":"${dir:d}","class":"cache"},{"id":"a","path":"${dir:d}/x","class":"cache"}]}`, "declared twice"}, + {"own data of class none", `{"own":[{"id":"a","path":"${dir:d}","class":"none"}]}`, "only a provision"}, + {"consumers of something not provided", `{"consumers":{"s3-bucket":{"class":"cache"}}}`, "does not provide"}, + {"consumer data that lives nowhere", `{"consumers":{"q":{"class":"irreplaceable"}}}`, "says nowhere it lives"}, + {"consumer data in an item less precious", `{"own":[{"id":"a","path":"${dir:d}","class":"rebuildable"}],"consumers":{"q":{"class":"irreplaceable","in":"a"}}}`, "which is only rebuildable"}, + {"consumer data in something unknown", `{"consumers":{"q":{"class":"rebuildable","in":"elsewhere"}}}`, "neither one of its data items"}, + } { + raw := `{"module":"m","version":"1","provides":["q"],"resources":[{"id":"d","type":"directory","mode":"0700"}],"data":` + c.data + `}` + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: %v, want %q", c.name, err, c.want) + } + } +} + +// A default is a value the mesh writes, so the installation check reads it, even under a setting +// called `description` or `why`; a setting's own why is prose and is not read. +func TestTheInstallationCheckReadsADefault(t *testing.T) { + m := notifier() + m.Settings["relay"] = catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: "relay.acme.be", Why: "as at relay.acme.be"} + m.Settings["description"] = catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: "notes.acme.be", Why: "x"} + problems := strings.Join(InstallationProblems(m), "; ") + for _, want := range []string{"relay.acme.be at settings.relay.default", "notes.acme.be at settings.description.default"} { + if !strings.Contains(problems, want) { + t.Errorf("not reported: %q in %s", want, problems) + } + } + if strings.Contains(problems, "settings.relay.why") { + t.Errorf("a why was read as a value: %s", problems) + } +} + +func TestTheMachinesReachIsAProvisionsOnlyReachAndIsNodeScoped(t *testing.T) { + for _, c := range []struct{ provides, want string }{ + {`{"name":"x11-display","reach":"internal"}`, `with reach "internal"; a provision's reach is "machine" or nothing`}, + {`{"name":"x11-display","scope":"mesh","reach":"machine"}`, `at scope "mesh" with the machine's reach`}, + } { + _, err := ParseManifest([]byte(`{"module":"xorg","provides":[` + c.provides + `]}`)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: want %q, got %v", c.provides, c.want, err) + } + } + m, err := ParseManifest([]byte(`{"module":"xorg","provides":[{"name":"x11-display","reach":"machine"}]}`)) + if err != nil { + t.Fatal(err) + } + if !m.Provides[0].MachineReach() { + t.Fatal("the reach was not read") + } + back, _ := json.Marshal(m.Provides[0]) + if string(back) != `{"name":"x11-display","reach":"machine"}` { + t.Errorf("written back as %s", back) + } +} + +func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) { + // novox/hq issue 174. What the mesh writes for a module — its bus credential, its bindings — + // is the mesh's plumbing under /mesh/, and the definition names it by id. + m := catalogue.Manifest{Module: "umami", + Resources: []map[string]any{ + {"id": "mesh-state", "type": "directory", "place": "mesh"}, + {"id": "state", "type": "directory", "place": "."}, + {"id": "server", "type": "container", "image": "x@sha256:aa", + "volumes": []any{"${dir:mesh-state}/broker:/run/secrets/broker:ro"}}, + }, + OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "${dir:mesh-state}/broker"}}, + Binds: map[string]string{"route": "${dir:state}/route.json"}, + } + if got := manifestUnknownDirRefs(m); len(got) != 0 { + t.Fatalf("place %q is a place; got %v", "mesh", got) + } + dirs := dirsFor(m, catalogue.Rendering{}) + if dirs["mesh-state"] != "/var/lib/mesh/umami" || dirs["state"] != "/var/lib/umami" { + t.Fatalf("the mesh's directory sits beside the module's, not in it; got %v", dirs) + } + dirs = dirsFor(m, catalogue.Rendering{DataRoot: "/srv"}) + if dirs["mesh-state"] != "/srv/mesh/umami" { + t.Fatalf("a node's root moves the mesh's files with the module's; got %v", dirs) + } + +} + +// The mesh's own seats still work, and are not shadowed by the derived half. +func TestTheMeshsOwnSeatsAreStillClaimable(t *testing.T) { + // It delivers the bus, so its holder provides the bus — the rule this check now enforces. + m := catalogue.Manifest{Module: "nats", + Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh, Credential: &catalogue.OfferCredential{Form: catalogue.CredentialFormNone}}}, + Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}} + if got := problemsFor(t, catalogue.Shelf{"nats": m}); got != "" { + t.Fatalf("a mesh seat was refused by the derived check: %s", got) + } +} + +func TestTheModuleCheckRefusesAnOptionalPlaceholderThatCouldRenderEmpty(t *testing.T) { + refused := map[string]string{ + "a placeholder without a default": `{"id":"ds","type":"file","path":"/a","mode":"0644", + "content":"url: ${bound:influxdb-api:at}"}`, + "a secret outside an only-when-bound file": `{"id":"s","type":"file","path":"/a","mode":"0400", + "content":"${secret:influxdb-api}"}`, + "only-when-bound on a hard requirement": `{"id":"r","type":"file","path":"/a","mode":"0644", + "content":"x","only-when-bound":"route"}`, + "only-when-bound on something not required": `{"id":"r","type":"file","path":"/a","mode":"0644", + "content":"x","only-when-bound":"mqtt-topic"}`, + "only-when-bound on a directory": `{"id":"d","type":"directory","mode":"0700", + "only-when-bound":"influxdb-api"}`, + "a default on a hard requirement": `{"id":"r","type":"file","path":"/a","mode":"0644", + "content":"${bound:route:name|default:x}"}`, + } + for what, resource := range refused { + if _, err := ParseManifest([]byte(optionalModule(resource))); err == nil { + t.Errorf("%s passed the module check", what) + } else if what == "a placeholder without a default" && !strings.Contains(err.Error(), "|default:") { + t.Errorf("the refusal does not say how to write it: %v", err) + } + } + accepted := map[string]string{ + "a placeholder with a default": `{"id":"ds","type":"file","path":"/a","mode":"0644", + "content":"url: ${bound:influxdb-api:at|default:none}"}`, + "a placeholder and a secret inside an only-when-bound file": `{"id":"ds","type":"file","path":"/a", + "mode":"0400","content":"url: ${bound:influxdb-api:at}\npassword: ${secret:influxdb-api}", + "only-when-bound":"influxdb-api"}`, + } + for what, resource := range accepted { + if _, err := ParseManifest([]byte(optionalModule(resource))); err != nil { + t.Errorf("%s was refused: %v", what, err) + } + } +} + +func TestTheNewRulesAreHeldToWhatTheSeatSays(t *testing.T) { + m := catalogue.Manifest{Module: "x", DefinesSeats: []catalogue.SeatDeclaration{{Name: "thing", Accepts: []string{"do"}, + ByCaller: []string{"undo"}, Proofs: []string{"code"}, Records: []string{"nothing"}}}} + got := strings.Join(declaredSeatProblems(m), "; ") + for _, want := range []string{"names thing.undo by its caller, which the seat neither accepts nor emits", + "declares proofs on thing, which is not kinded", `read its records "nothing", which it keeps no state of`} { + if !strings.Contains(got, want) { + t.Errorf("not refused: %q in %s", want, got) + } + } +} + +// **A build machine has no store, so it may not judge a seat.** The set is data the control plane +// owns (novox/hq ADR 0122), and `ParseManifest` runs on the build machine too, against whatever set +// that binary was compiled with. When the two disagreed, a valid holder of the bus seat was refused +// mid-rollout — the compiled row said the seat delivered one provision, the store's row said +// another, and the build failed on the copy rather than the truth. The parser judges the manifest; +// the seat set judges the claim, where it is loaded. +func TestTheParserDoesNotJudgeWhatOnlyTheStoreKnows(t *testing.T) { + was := catalogue.Seats() + t.Cleanup(func() { catalogue.UseSeats(was) }) + + // A store whose bus seat delivers something this module does provide. + catalogue.UseSeats([]catalogue.Seat{{Name: "mesh-broker", Scope: catalogue.ScopeMesh, Delivers: "mesh-bus", Decision: "test"}}) + raw := []byte(`{"module":"a-bus","version":"1",` + + `"provides":[{"name":"mesh-bus","scope":"mesh","credential":{"form":"none"}}],` + + `"claims":[{"name":"mesh-broker","scope":"mesh"}]}`) + + m, err := ParseManifest(raw) + if err != nil { + t.Fatalf("the parser refused a claim only the seat set can judge: %v", err) + } + if got := CatalogueProblems(catalogue.Shelf{m.Module: m}); len(got) != 0 { + t.Fatalf("a holder that provides what the store says the seat delivers was refused: %v", got) + } + + // And with the store saying the seat delivers something else, registration is what refuses it. + catalogue.UseSeats([]catalogue.Seat{{Name: "mesh-broker", Scope: catalogue.ScopeMesh, Delivers: "other-bus", Decision: "test"}}) + if _, err := ParseManifest(raw); err != nil { + t.Fatalf("the parser judged it the second time: %v", err) + } + got := strings.Join(CatalogueProblems(catalogue.Shelf{m.Module: m}), "; ") + if !strings.Contains(got, `does not provide "other-bus"`) { + t.Fatalf("registration did not refuse a holder that cannot answer for the seat: %q", got) + } +} + +func TestTheParserTakesAPreferenceAndRefusesTheRest(t *testing.T) { + if err := parsed(t, notifier()); err != nil { + t.Fatalf("a preference with a default was refused: %v", err) + } + for _, c := range []struct { + name string + key string + d catalogue.SettingDeclaration + refuse string + }{ + {"no kind", "font-size", catalogue.SettingDeclaration{Default: float64(10), Why: "x"}, `only a "preference" has a default`}, + {"another kind", "font-size", catalogue.SettingDeclaration{Kind: "operator", Default: float64(10), Why: "x"}, `only a "preference"`}, + {"no default", "font-size", catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Why: "x"}, "no default"}, + {"an empty default", "font-size", catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: " ", Why: "x"}, "an empty default"}, + {"an object", "font-size", catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: map[string]any{"a": 1.0}, Why: "x"}, "a string, a number or a boolean"}, + {"no why", "font-size", catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: float64(10)}, "no why"}, + {"the operator's", "mail-domain", catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: "example.tld", Why: "x"}, "is the operator's value"}, + {"a secret", "api-token", catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: "x", Why: "x"}, "is the operator's value"}, + {"the mesh's word", catalogue.PortsSetting, catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: float64(1), Why: "x"}, "the mesh reads"}, + {"read by nothing", "height", catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: float64(300), Why: "x"}, "reaches nothing"}, + } { + m := notifier() + m.Resources[0]["content"] = m.Resources[0]["content"].(string) + "x = ${setting:" + c.key + "}\n" + if c.name == "read by nothing" { + m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n" + } + m.Settings[c.key] = c.d + err := parsed(t, m) + if err == nil || !strings.Contains(err.Error(), c.refuse) { + t.Errorf("%s: parsed %v, want %q", c.name, err, c.refuse) + } + } +} + +// A refusal that reorders itself between runs is a refusal nobody can diff. +func TestTheProblemsAreStable(t *testing.T) { + shelf := catalogue.Shelf{"telegram": telegram(), "shop": {Module: "shop", Uses: []string{"nope"}}, + "other": {Module: "other", Uses: []string{"also-nope"}}} + first, second := problemsFor(t, shelf), problemsFor(t, shelf) + if first != second { + t.Fatalf("unstable:\n%s\n%s", first, second) + } +} + +// ADR 0310 §3: the account that reads is in no group beside its own; one put in a group is refused. +func TestTheReadsAccountIsInNoGroup(t *testing.T) { + m := catalogue.Manifest{Module: "reads", RunsAs: "mesh-reads", Claims: []catalogue.Claim{{Name: catalogue.ReadsSeat}}, + Resources: []map[string]any{{"id": "account", "type": "user", "name": "mesh-reads", "groups": []any{"docker"}}}} + if p := ReadsHolderProblems(m); len(p) != 1 || !strings.Contains(p[0], "docker") { + t.Errorf("an account in docker was not refused: %v", p) + } + m.Resources[0]["groups"] = []any{} + if p := ReadsHolderProblems(m); len(p) != 0 { + t.Errorf("an account in no group was refused: %v", p) + } +} + +// The controller refuses a definition's resolved path by the node-engine's own rules, no more (novox/hq issue +// 496): the same lists, read here from mesh-host's internal/apply/placement_guard.go — in a merge check the clone +// beside it at the commit the mesh runs, elsewhere the copy captured in testdata/beside. When the engine's lists +// move, this fails until the controller's move with them, so the gate never refuses what the engine applies nor +// passes what it refuses by path alone. +func TestTheResolvedPathRulesAreTheNodeEnginesOwn(t *testing.T) { + file := filepath.Join(beside.Dir(t, "mesh-host"), "internal", "apply", "placement_guard.go") + parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, 0) + if err != nil { + t.Fatalf("the node-engine's guard does not parse: %v", err) + } + lists := map[string][]string{} + consts := map[string]string{} + ast.Inspect(parsed, func(n ast.Node) bool { + spec, ok := n.(*ast.ValueSpec) + if !ok { + return true + } + for i, name := range spec.Names { + if i >= len(spec.Values) { + continue + } + switch v := spec.Values[i].(type) { + case *ast.CompositeLit: + for _, elt := range v.Elts { + if lit, ok := elt.(*ast.BasicLit); ok && lit.Kind == token.STRING { + s, _ := strconv.Unquote(lit.Value) + lists[name.Name] = append(lists[name.Name], s) + } + } + case *ast.BasicLit: + if v.Kind == token.STRING { + consts[name.Name], _ = strconv.Unquote(v.Value) + } + } + } + return true + }) + for name, ours := range map[string][]string{ + "protectedRoots": protectedRoots, "forbiddenBelow": forbiddenBelow, "engineTrees": engineTrees, + } { + theirs := lists[name] + if len(theirs) == 0 { + t.Errorf("the node-engine's guard names no %s any more; read it and say where its rule went", name) + continue + } + a, b := slices.Clone(ours), slices.Clone(theirs) + slices.Sort(a) + slices.Sort(b) + if !slices.Equal(a, b) { + t.Errorf("%s differs from the node-engine's:\n controller %v\n node-engine %v", name, a, b) + } + } + if consts["engineModule"] != engineModule { + t.Errorf("the node-engine's own module is %q there and %q here", consts["engineModule"], engineModule) + } +} + +func TestTheRuntimeIsToldTheAgentAccount(t *testing.T) { + with := catalogue.Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{catalogue.RuntimeModule: {"broker": "sealed-credential"}}} + envOf := func(r catalogue.Resolution) map[string]string { + t.Helper() + r.Node, r.Modules = "anchor", []catalogue.Manifest{aToolsModule(t, "nftables", "tools/index.js"), theRuntime(t)} + out, err := r.Declaration(with) + if err != nil { + t.Fatal(err) + } + process := fileNamed(out, catalogue.RuntimeModule+"."+catalogue.RuntimeProcessID()) + if process == nil { + t.Fatal("no runtime process was composed") + } + return process["env"].(map[string]string) + } + env := envOf(catalogue.Resolution{Account: "ops", AgentAccount: "agent"}) + if env[catalogue.RuntimeAgentAccount] != "agent" || env[catalogue.RuntimeAgentHome] != "/home/agent" || env[catalogue.RuntimeOperatorAccount] != "ops" { + t.Errorf("the runtime is not told whom agents run as: %v", env) + } + env = envOf(catalogue.Resolution{Account: "ops"}) + if env[catalogue.RuntimeAgentAccount] != "ops" || env[catalogue.RuntimeAgentHome] != "/home/ops" { + t.Errorf("with no agent account, agents run as the operator: %v", env) + } + env = envOf(catalogue.Resolution{}) + if _, set := env[catalogue.RuntimeAgentAccount]; set { + t.Errorf("a machine with no account names an agent account: %v", env) + } + if problems := bundleEnvProblems("x", catalogue.Artifact{Name: "b", Kind: catalogue.ArtifactBundle, Loads: []string{"x"}, + Env: map[string]string{catalogue.RuntimeAgentAccount: "me"}}); len(problems) == 0 { + t.Error("a bundle may tell the runtime whom agents run as") + } +} + +// The machine's facility — the container runtime's socket — is granted by the capability that names +// it, and declaring it as the module's own directory would be a lie the host would act on. This is +// the case the first version of this check refused, and was withdrawn for: the builder. +func TestTheRuntimeSocketIsGrantedByTheCapabilityAndNotOtherwise(t *testing.T) { + granted := `{"module":"builder","capabilities":["container-runtime"],"resources":[` + + strings.Replace(aContainerMounting, "%s", "/var/run/docker.sock", 1) + `]}` + if _, err := ParseManifest([]byte(granted)); err != nil { + t.Fatalf("a module with the container-runtime capability may mount its socket: %v", err) + } + if _, err := ParseManifest(manifestMounting("/var/run/docker.sock", "")); err == nil { + t.Fatal("a module mounted the container runtime's socket without declaring the capability, and was accepted") + } +} + +// The set is closed, and changing it is a decision. +// +// **The count is asserted, and every entry names the record that made it a seat**, so the next +// person changing the set finds the argument rather than a number to edit — the pattern the host's +// vocabulary test follows. If this fails because a seat was added, the fix is a record in novox/hq +// and a row in to-be 26, not a new number here. +func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { + // A seat a later record extends names both, "novox/hq ADR 0166, ADR 0207": the one that defined + // it and the one that seeded it. + record := regexp.MustCompile(`^novox/hq ADR \d{4}(, ADR \d{4})*$`) + seen := map[string]bool{} + delivered := map[string]string{} + for _, s := range catalogue.Seats() { + if seen[s.Name] { + t.Errorf("%s is in the set twice", s.Name) + } + seen[s.Name] = true + if !record.MatchString(s.Decision) { + t.Errorf("%s names %q as its decision; every seat names the record that made it one", + s.Name, s.Decision) + } + switch s.Scope { + case catalogue.ScopeNode, catalogue.ScopeSite, catalogue.ScopeMesh: + default: + t.Errorf("%s is held per %q, which is not a scope", s.Name, s.Scope) + } + if s.Delivers != "" { + // Two seats answering for one provision would put the question "which one?" back, + // which is the question a seat exists to answer. + if other, twice := delivered[s.Delivers]; twice { + t.Errorf("%s and %s both deliver %q", other, s.Name, s.Delivers) + } + delivered[s.Delivers] = s.Name + } + } + // Forty-seven with mesh-placement (novox/hq ADR 0567), serving its seven verbs. + if s, ok := catalogue.SeatNamed(catalogue.PlacementSeat); !ok || s.Scope != catalogue.ScopeMesh || !reflect.DeepEqual(catalogue.VerbNames(s.Serves), + []string{"placement", "proposals", "propose", "withdraw", "again", "adopt", "hand-back"}) { + t.Errorf("the placement seat is %v, %v", ok, s) + } + // The checker's seat serves the try, which replaces the shell's merge check (ADR 0547 §10, ADR 0245). + var checkerVerbs []string + for _, s := range catalogue.Seats() { + if s.Name == catalogue.CheckerSeat { + for _, v := range s.Serves { + checkerVerbs = append(checkerVerbs, v.Name) + if v.Name == "try" && strings.Join(v.Replaces, "|") != "sh merge-check.sh|docker run postgres" { + t.Errorf("try replaces %v", v.Replaces) + } + } + } + } + if strings.Join(checkerVerbs, ",") != "try,try-read,try-end" { + t.Errorf("the checker seat serves %v", checkerVerbs) + } + // The four health seats (novox/hq ADR 0564 clause 1): at mesh scope, each held once, each naming ADR 0564, with the + // verbs and events the record names; the events keep the controller's names (clause 3). + for _, c := range []struct{ seat, verbs, emits string }{ + {catalogue.ConditionsSeat, "conditions,silence,compare,take-over,hand-back,resync", "condition-raised,condition-changed,condition-cleared"}, + {catalogue.WatchdogsSeat, "signals,stuck,presence,dead-letters,resync", ""}, + {catalogue.SelfCheckSeat, "doctor,probes", "checked"}, + {catalogue.HealersSeat, "healers,heal,drill", "healer-acted"}, + // And mesh-config's switch (ADR 0565 §4), beside its config. + {"mesh-config", "config,compare,take-over,hand-back", ""}, + // And ADR 0565 clause 1's two. + {catalogue.WantsSeat, "upgrade,bus", "policy-changed"}, + {catalogue.HandActsSeat, "hand-act,hand-acts,warranted", "act-recorded"}, + // ADR 0571 clause 2: the artifact store's sweep. + {catalogue.ArtifactSweepSeat, "artifacts,held,collect,mirrors,images", "artifacts-gone"}, + } { + s, ok := catalogue.SeatNamed(c.seat) + if !ok || s.Scope != catalogue.ScopeMesh || s.Replicated || s.Delivers != "" || !strings.Contains(s.Decision, "ADR 05") || + strings.Join(catalogue.VerbNames(s.Serves), ",") != c.verbs || strings.Join(s.Emits, ",") != c.emits { + t.Errorf("the health seat %s is %v, %+v", c.seat, ok, s) + } + } + // The node seat node-engine is the node's engine's (novox/hq ADR 0563 §1): at node scope, delivering nothing, its six + // verbs and three events, held by no assignment, and a module's claim of it refused. + if engine, ok := catalogue.SeatNamed(catalogue.EngineSeat); !ok || engine.Scope != catalogue.ScopeNode || engine.Delivers != "" || !engine.HeldByTheEngine { + t.Errorf("node-engine is %+v, not a node seat delivering nothing that the engine holds", engine) + } else { + var verbs []string + for _, v := range engine.Serves { + verbs = append(verbs, v.Name) + } + // acts reads one act by its id too (ADR 0563 §9: the seat's tools are its command line). + if props, _ := engine.Serves[5].Input["properties"].(map[string]any); props["id"] == nil { + t.Errorf("acts takes no id: %v", engine.Serves[5].Input) + } + if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages,composition,compose" || + strings.Join(engine.Emits, ",") != "asked,done,refused" { + t.Errorf("node-engine serves %v and emits %v", verbs, engine.Emits) + } + } + if _, err := ParseManifest([]byte(`{"module":"thing","version":"1","claims":[{"name":"node-engine","scope":"node"}]}`)); err == nil || + !strings.Contains(err.Error(), "node-engine") { + t.Errorf("a module's claim of node-engine was not refused: %v", err) + } + // Forty-six with the three build seats replacing node-build-agent and the retired mesh-build-machine (novox/hq + // ADR 0525 §2, §10); forty-five with node-files (novox/hq ADR 0394); forty-four again once node-reads goes. + // Forty-four with node-provisioner (novox/hq ADR 0349 clause 3); forty-three with mesh-provisioner (novox/hq ADR 0349 clause 3); forty-two with mesh-config (novox/hq ADR 0348 §9); forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired + // into node-uplink (novox/hq ADR 0223); thirty-seven + // since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with + // node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215); + // thirty-six with mesh-dns-resolver (novox/hq ADR 0194) and node-hosts-file (ADR 0199, now + // node-hostname); thirty-four + // with node-hotkeys (ADR 0212); thirty-three with node-power (ADR 0211); thirty-two since the + // graphical session's eleven (ADR 0208); twenty-one with node-package-manager and + // node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203, + // ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired + // mesh-build-machine row goes, when no registered manifest claims it. + // Forty-eight with mesh-delivery-checker (novox/hq ADR 0547 §10, issue 774); forty-nine with node-engine (novox/hq ADR 0563 §1); + // fifty-three with the four health seats (novox/hq ADR 0564 clause 1); fifty-five with mesh-wants and mesh-hand-acts + // (novox/hq ADR 0565 clause 1); fifty-six with mesh-artifact-sweep (novox/hq ADR 0571 clause 2). + if len(catalogue.Seats()) != 56 { + t.Errorf("the mesh defines %d seats rather than 56; the set is closed, so a change here is "+ + "a decision (novox/hq ADR 0110): %s", len(catalogue.Seats()), seatNames()) + } +} + +// §4: xinitrc and xresources slots, placed by the display server's holder alone. +func TestTheSessionsFilesArePlacedByTheDisplayServersHolderAlone(t *testing.T) { + xorg := catalogue.Manifest{Module: "xorg", Claims: []catalogue.Claim{{Name: catalogue.DisplayServerSeat}}, + Shell: []catalogue.ShellCode{{For: "xinitrc", Slot: "first", Code: "xset s off"}}, + Resources: []map[string]any{ + {"id": "xinitrc", "type": "file", "path": "/home/op/.xinitrc", + "content": "${shell:xinitrc:first}${shell:xinitrc:normal}${shell:xinitrc:last}"}, + {"id": "xresources", "type": "file", "path": "/home/op/.Xresources", + "content": "${shell:xresources:normal}"}, + }} + i3 := catalogue.Manifest{Module: "i3", Shell: []catalogue.ShellCode{{For: "xinitrc", Slot: "last", Code: "exec i3"}}} + theme := catalogue.Manifest{Module: "theme", Shell: []catalogue.ShellCode{ + {For: "xresources", Slot: "normal", Code: "Xft.dpi: 96"}, + {For: "zsh", Slot: "normal", Code: "not for the session"}, + }} + r := catalogue.Resolution{Node: "workstation", Account: "op", Modules: []catalogue.Manifest{xorg, i3, theme}} + out, err := r.Declaration(catalogue.Rendering{}) + if err != nil { + t.Fatal(err) + } + by := map[string]any{} + for _, res := range out { + by[res["id"].(string)] = res["content"] + } + if got := by["xorg.xinitrc"]; got != "# xorg\nxset s off\n# i3\nexec i3\n" { + t.Errorf("the .xinitrc is %q", got) + } + if got := by["xorg.xresources"]; got != "# theme\nXft.dpi: 96\n" { + t.Errorf("the .Xresources is %q", got) + } + + // The contributions parse; the placeholders parse only in the holder. + if _, err := ParseManifest([]byte(`{"module":"i3","shell":[{"for":"xinitrc","slot":"last","code":"exec i3"},` + + `{"for":"xresources","slot":"normal","code":"i3.font: x"}]}`)); err != nil { + t.Fatalf("a session contribution was refused: %v", err) + } + for _, c := range []struct{ claims, content, want string }{ + {``, "${shell:xinitrc:normal}", "does not claim node-display-server; every module's xinitrc is placed by the display server's holder alone"}, + {`{"name":"node-login-shell"}`, "${shell:xresources:normal}", "does not claim node-display-server"}, + {`{"name":"node-display-server"}`, "${shell:zsh:normal}", "does not claim node-login-shell"}, + {`{"name":"node-display-server"}`, "${shell:xsession:normal}", "the session's file one of xinitrc, xresources"}, + } { + raw := `{"module":"holder","claims":[` + c.claims + `],"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + + c.content + `"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s with claims [%s]: want %q, got %v", c.content, c.claims, c.want, err) + } + } + if _, err := ParseManifest([]byte(`{"module":"xorg","claims":[{"name":"node-display-server"}],` + + `"resources":[{"id":"rc","type":"file","path":"/home/op/.xinitrc","content":"${shell:xinitrc:last}"}]}`)); err != nil { + t.Errorf("the display server's holder could not place the session's slots: %v", err) + } +} + +// The stored form reads on a controller older than optional requirements (review of mesh-controller +// #281). Inventory.Catalogue stops at the first manifest it cannot read, so a long-form entry inside +// `requires` would have stopped every plan after a rollback. Stored, `requires` is plain names, as main's +// manifest type reads it, and which are optional sits beside it in a key such a controller sets aside. +func TestTheStoredFormReadsWhereRequiresIsAListOfNames(t *testing.T) { + m := mustParse(t, `{"module":"grafana","version":"1", + "requires":["route",{"name":"influxdb-api","optional":true}]}`) + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + // main's field, exactly: `Requires []string`. + var older struct { + Requires []string `json:"requires"` + } + if err := json.Unmarshal(raw, &older); err != nil { + t.Fatalf("a controller reading requires as names cannot read the stored form: %v\n%s", err, raw) + } + if strings.Join(older.Requires, " ") != "route influxdb-api" { + t.Fatalf("it reads the requirements as %v", older.Requires) + } + var keys map[string]json.RawMessage + if err := json.Unmarshal(raw, &keys); err != nil { + t.Fatal(err) + } + if string(keys[catalogue.OptionalRequiresKey]) != `["influxdb-api"]` { + t.Fatalf("which are optional is stored as %s", keys[catalogue.OptionalRequiresKey]) + } + // And this controller reads the stored form back the same, leniently and strictly. + var back catalogue.Manifest + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatal(err) + } + strict, err := ParseManifest(raw) + if err != nil { + t.Fatal(err) + } + for _, got := range []catalogue.Manifest{back, strict} { + if !got.IsOptional("influxdb-api") || got.IsOptional("route") || len(got.Requires) != 2 { + t.Fatalf("read back as %v optional %v", got.Requires, got.OptionalRequires) + } + } + // A stored list naming something the module does not require is refused. + if _, err := ParseManifest([]byte(`{"module":"x","version":"1","requires":["route"],"optional-requires":["mqtt-topic"]}`)); err == nil { + t.Error("an optional requirement the module does not require passed the check") + } +} + +// novox/hq ADR 0117: a machine's uplink is a seat, held per machine, and delivers nothing. +// +// **Nothing, because nothing may be required of it.** A holder only keeps its network manager from +// contradicting the mesh; a requirement resolving to it would make the manager the mesh's answer +// for something, and the manager's link is the one thing the mesh must never be able to break. +func TestTheUplinkIsANodeSeatThatDeliversNothing(t *testing.T) { + seat, known := catalogue.SeatNamed("node-uplink") + if !known { + t.Fatalf("the uplink is not a seat; the seats are: %s", seatNames()) + } + if seat.Scope != catalogue.ScopeNode || seat.Delivers != "" || seat.Decision != "novox/hq ADR 0117" { + t.Fatalf("the uplink is %+v, not a node seat delivering nothing by ADR 0117", seat) + } + // And a manager's module can hold it without providing anything. + raw := []byte(`{"module":"networkmanager","version":"1","claims":[{"name":"node-uplink","scope":"node"}]}`) + if _, err := ParseManifest(raw); err != nil { + t.Fatalf("a network manager's module could not hold the uplink: %v", err) + } +} + +func TestTheWorldsNamesAreNotAnInstallations(t *testing.T) { + m := catalogue.Manifest{Module: "resolver", Resources: []map[string]any{ + {"id": "conf", "type": "file", "content": "server=1.1.1.1\nserver=8.8.8.8\nlisten=127.0.0.55\n"}, + {"id": "proxy", "type": "container", "image": "docker.io/library/traefik@sha256:bb"}, + {"id": "adapter", "type": "file", "content": "{\"machine\": \"host.docker.internal\"}\n"}, + {"id": "doc", "type": "file", "content": "root = https://git.example.tld/\n"}, + }} + if got := InstallationProblems(m); len(got) != 0 { + t.Fatalf("the world's names were reported: %v", got) + } +} + +func TestTwoArtifactsWithOneNameAreRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ + {"name":"x","kind":"image","from":"Dockerfile"}, + {"name":"x","kind":"archive","from":"files"}]}}`)) + if err == nil { + t.Fatal("two artifacts with one name were accepted") + } + if !strings.Contains(err.Error(), "could mean either") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +func TestTwoChannelsOfDifferentKindsHoldTheBenches(t *testing.T) { + shelf := catalogue.Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"), + "desk-channel": aChannel("desk-channel", "desktop")} + if got := problemsFor(t, shelf); got != "" { + t.Fatalf("two kinds were refused: %s", got) + } + for _, m := range shelf { + if got := declaredSeatProblems(m); len(got) > 0 { + t.Fatalf("%s: %v", m.Module, got) + } + } +} + +// **Two endpoints called the same would make an assignment configure whichever was read last.** The +// point of a name is that it identifies one thing. +func TestTwoEndpointsWithOneNameAreRefused(t *testing.T) { + m := catalogue.Manifest{Module: "twice", Listens: []catalogue.Listening{ + {Name: "web", Port: 80, From: catalogue.FromMesh}, + {Name: "web", Port: 8080, From: catalogue.FromMesh}, + }} + got := strings.Join(endpointNameProblems(m), "\n") + if !strings.Contains(got, "could mean either") { + t.Fatalf("two endpoints with one name were accepted:\n%s", got) + } +} + +// A stream name has one owner across the catalogue (ADR 0363 §1). +func TestTwoModulesCannotDeclareOneStream(t *testing.T) { + a := catalogue.Manifest{Module: "a", Streams: []catalogue.StreamDeclaration{{Name: "mail", Events: []string{"x"}}}} + b := catalogue.Manifest{Module: "b", Streams: []catalogue.StreamDeclaration{{Name: "mail", Events: []string{"y"}}}} + got := strings.Join(CatalogueProblems(catalogue.Shelf{"a": a, "b": b}), "; ") + if !strings.Contains(got, "b declares the stream MAIL, which a already declares") { + t.Fatalf("a second owner of MAIL was not refused: %s", got) + } + if got := StreamNameProblems(catalogue.Shelf{"a": a}); len(got) != 0 { + t.Fatalf("one owner refused: %v", got) + } + // And a stream of its own is outside the bus's predefined forms (the lead, 2026-10-29): refused, naming it, unless it + // is one kept by name until its module moves it. + if !strings.Contains(got, `a keeps the stream "mail", outside the bus's predefined forms`) { + t.Fatalf("a stream of its own was not refused: %s", got) + } + if got := PredefinedFormProblems(catalogue.Manifest{Module: "mesh-mail", Streams: a.Streams}); len(got) != 0 { + t.Fatalf("a stream kept until it moves was refused: %v", got) + } +} + +// A seat name meaning two protocols is the failure nobody could diagnose afterwards. +func TestTwoModulesCannotDeclareTheSameSeat(t *testing.T) { + other := catalogue.Manifest{Module: "aardvark", DefinesSeats: []catalogue.SeatDeclaration{{ + Name: "telegram-sender", Scope: catalogue.ScopeMesh, Accepts: []string{"something-else"}}}} + got := problemsFor(t, catalogue.Shelf{"telegram": telegram(), "aardvark": other}) + if !strings.Contains(got, "already declares") { + t.Fatalf("both declarations stood: %s", got) + } + // The first declarer keeps it; only the second is refused. + if strings.Count(got, "already declares") != 1 { + t.Fatalf("expected exactly one refusal: %s", got) + } +} + +// Where ADR 0110's guarantee lands under a derived set: a typo is refused, not resolved to +// nothing at runtime. And a seat verb a module invokes is one a holder serves (issue 774, sweep item 1: messenger +// invoked a verb nothing answered, and Telegram held every ask silently). +func TestUsingASeatNobodyDeclaresIsRefused(t *testing.T) { + unclaimed := telegram() + unclaimed.Claims = nil + for _, row := range []struct { + name, want string + shop catalogue.Manifest + seat catalogue.Manifest + note bool // over some manifests alone it is a note (IsUndeclaredSeat) + }{ + {"a misspelled seat used", `uses the seat "telegram-sendr", ` + catalogue.UndeclaredSeat, + catalogue.Manifest{Module: "shop", Uses: []string{"telegram-sendr"}}, telegram(), true}, + {"a declared verb invoked of a held seat", "", catalogue.Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.status"}}, telegram(), false}, + {"a verb the seat does not promise", `the seat telegram-sender serves no verb "resend"`, + catalogue.Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.resend"}}, telegram(), false}, + {"a verb of a misspelled seat", `a verb of the seat "telegram-sendr", ` + catalogue.UndeclaredSeat, + catalogue.Manifest{Module: "shop", Invokes: []string{"seat:telegram-sendr.status"}}, telegram(), true}, + {"a verb of a declared seat nobody claims", `a verb of the seat "telegram-sender", ` + catalogue.UnheldSeat, + catalogue.Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.status"}}, unclaimed, true}, + {"a verb of a mesh seat whose holder lives in another repository", "", + catalogue.Manifest{Module: "shop", Invokes: []string{"seat:mesh-watchdogs.signals"}}, telegram(), false}, + {"a verb a mesh seat does not promise", `the seat mesh-controller serves no verb "root-freed"`, + catalogue.Manifest{Module: "shop", Invokes: []string{"seat:mesh-controller.root-freed"}}, telegram(), false}, + {"the controller's verb: the controller answers its seat", "", + catalogue.Manifest{Module: "shop", Invokes: []string{"seat:mesh-controller.root-free"}}, telegram(), false}, + {"the engine's verb: every node's engine holds its seat", "", + catalogue.Manifest{Module: "shop", Invokes: []string{"seat:node-engine.root-free"}}, telegram(), false}, + {"a verb the engine's seat does not promise", `the seat node-engine serves no verb "root-freed"`, + catalogue.Manifest{Module: "shop", Invokes: []string{"seat:node-engine.root-freed"}}, telegram(), false}, + } { + got := CatalogueProblems(catalogue.Shelf{"telegram": row.seat, "shop": row.shop}) + joined := strings.Join(got, "; ") + if row.want == "" && joined != "" || row.want != "" && (len(got) != 1 || !strings.Contains(joined, row.want) || + catalogue.IsUndeclaredSeat(got[0]) != row.note) { + t.Errorf("%s: %s", row.name, joined) + } + } +} + +func TestWhatAHandedModuleCannotHaveFromTheVaultIsRefused(t *testing.T) { + // A template naming a credential the module requires: a binding's, which moves with its provider. + requires := strings.Replace(botMoved(), `"own-secrets":`, `"requires":["database"],"secrets":{"database":"/etc/bot/db"},"own-secrets":`, 1) + r, err := catalogue.Resolve(shelf(held(t, requires, builtBot("db = ${secret:database}\n")), + catalogue.Manifest{Module: "db", Version: "1", Provides: []catalogue.Offer{{Name: "database", Scope: catalogue.ScopeNode}}}), + []string{"bot"}, workstation(), catalogue.World{}) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "moves with its provider") { + t.Errorf("a template naming a required credential: %v", err) + } + // A file with its text inline naming a secret, in a handed module. + inline := strings.Replace(botMoved(), `{"id":"daemon"`, + `{"id":"legacy","type":"file","path":"/etc/bot/legacy","content":"t = ${secret:token}\n"},{"id":"daemon"`, 1) + r = notifierOn(t, held(t, inline, builtBot(botText))) + if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "keep it in a template") { + t.Errorf("an inline file naming a secret in a handed module: %v", err) + } + // A family and a secret issued outside the mesh are not handed by name yet: the mark is refused. + for why, raw := range map[string]string{ + "a family": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":"${dir:state}/token","key-*":"${dir:state}/keys"}`, 1), + "issued outside": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":{"path":"${dir:state}/token","issued-by":"outside"}}`, 1), + } { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "design 57 V4") { + t.Errorf("a moved module with %s: %v", why, err) + } + } +} + +func TestWhatReplacesMayNameIsRefusedNearItsAuthor(t *testing.T) { + for what, resource := range map[string]string{ + "a container": `{"id":"c","type":"container","name":"c","image":"x@` + aServiceDigest + `","replaces":["old"]}`, + "a step": `{"id":"p","type":"process","name":"p","run":["./p"],"run-once":true,"replaces":["old"]}`, + "something declared": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["p"]}`, + "another module's": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["other.old"]}`, + "not a list": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":"old"}`, + } { + raw := `{"module":"m","version":"1","resources":[` + resource + `]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "replace") { + t.Errorf("replaces on %s was accepted: %v", what, err) + } + } + ok := `{"module":"m","version":"1","resources":[{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["old"]}]}` + if _, err := ParseManifest([]byte(ok)); err != nil { + t.Errorf("a process replacing what its module no longer declares was refused: %v", err) + } +} + +// A module rolls out by default; it records where it says so with why, where it keeps irreplaceable +// data, and always where it is the bus (novox/hq ADR 0236). +func TestWhereAModulesUpgradePolicyComesFrom(t *testing.T) { + cases := []struct { + name string + m catalogue.Manifest + policy, from string + }{ + {"default", catalogue.Manifest{Module: "app"}, catalogue.PolicyRoll, catalogue.FromDefault}, + {"says record", catalogue.Manifest{Module: "db", Upgrade: &catalogue.UpgradePolicy{Policy: catalogue.PolicyRecord, Why: "a restart costs"}}, + catalogue.PolicyRecord, catalogue.FromModule}, + {"says together", catalogue.Manifest{Module: "dns", Upgrade: &catalogue.UpgradePolicy{Policy: catalogue.PolicyTogether, Why: "one zone"}}, + catalogue.PolicyTogether, catalogue.FromModule}, + {"irreplaceable data", catalogue.Manifest{Module: "media", Data: &catalogue.Data{Own: []catalogue.DataItem{{ID: "library", Class: catalogue.ClassIrreplaceable}}}}, + catalogue.PolicyRecord, catalogue.FromData}, + {"valuable data rolls", catalogue.Manifest{Module: "notes", Data: &catalogue.Data{Own: []catalogue.DataItem{{ID: "db", Class: catalogue.ClassValuable}}}}, + catalogue.PolicyRoll, catalogue.FromDefault}, + {"irreplaceable but says roll", catalogue.Manifest{Module: "photos", Upgrade: &catalogue.UpgradePolicy{Policy: catalogue.PolicyRoll}, + Data: &catalogue.Data{Own: []catalogue.DataItem{{ID: "originals", Class: catalogue.ClassIrreplaceable}}}}, catalogue.PolicyRoll, catalogue.FromModule}, + {"the bus, whatever it says", catalogue.Manifest{Module: "nats", Provides: []catalogue.Offer{{Name: "mesh-bus"}}, + Upgrade: &catalogue.UpgradePolicy{Policy: catalogue.PolicyRoll}}, catalogue.PolicyRecord, catalogue.FromBus}, + } + for _, c := range cases { + policy, from, _ := catalogue.DerivedUpgrade(c.m) + if policy != c.policy || from != c.from { + t.Errorf("%s: %s from %s, want %s from %s", c.name, policy, from, c.policy, c.from) + } + // ADR 0565 clause 7 (issue 1953 L5-S3): a person's choice wins over each source but the bus; none leaves the + // derivation as it was. + person := &catalogue.PersonPolicy{V: catalogue.PersonPolicyVersion, Module: c.m.Module, Policy: catalogue.PolicyTogether, Why: "one zone", By: "person.jochen"} + policy, from, _, by := catalogue.Upgrade(c.m, person) + wantPolicy, wantFrom, wantBy := catalogue.PolicyTogether, catalogue.FromPerson, "person.jochen" + if c.from == catalogue.FromBus { + wantPolicy, wantFrom, wantBy = c.policy, catalogue.FromBus, "" + } + if policy != wantPolicy || from != wantFrom || by != wantBy { + t.Errorf("%s with a person's choice: %s from %s by %q", c.name, policy, from, by) + } + if policy, from, _, _ := catalogue.Upgrade(c.m, nil); policy != c.policy || from != c.from { + t.Errorf("%s with no choice: %s from %s", c.name, policy, from) + } + } + for _, p := range []catalogue.PersonPolicy{{Module: "m", Policy: "sometimes", Why: "x", By: "p"}, {Module: "m", Policy: catalogue.PolicyRecord, By: "p"}, + {Module: "m", Policy: catalogue.PolicyRecord, Why: "x"}} { + if p.Check() == nil { + t.Errorf("%+v was accepted", p) + } + } + // A policy other than roll says why, and an unknown one is refused. + for _, u := range []catalogue.UpgradePolicy{{Policy: catalogue.PolicyRecord}, {Policy: catalogue.PolicyTogether}, {Policy: "sometimes", Why: "x"}} { + if problems := manifestUpgradeProblems((catalogue.Manifest{Module: "m", Upgrade: &u})); len(problems) == 0 { + t.Errorf("%+v was accepted", u) + } + } + if problems := manifestUpgradeProblems((catalogue.Manifest{Module: "m", Upgrade: &catalogue.UpgradePolicy{Policy: catalogue.PolicyRecord, Why: "a restart costs"}})); len(problems) != 0 { + t.Errorf("a record with why was refused: %v", problems) + } + if _, err := ParseManifest([]byte(`{"module":"m","upgrade":{"policy":"record"}}`)); err == nil || + !strings.Contains(err.Error(), "says why") { + t.Errorf("a manifest recording without why parsed: %v", err) + } +} + +// ---- copied from catalogue: helpers composition keeps too ---- + +func buildProblemsOf(t *testing.T, m catalogue.Manifest) string { + t.Helper() + return strings.Join(buildProblems(m.Build, m.Module), "\n") +} + +// bundleFor is a manifest whose one artifact is a bundle in the given language and system. +func bundleFor(language, system string) catalogue.Manifest { + return catalogue.Manifest{Module: "a-component", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{ + {Name: "binary", Kind: catalogue.ArtifactBundle, Language: language, System: system}, + }}} +} + +// aToolsModule is a module whose tools come as a compiled bundle and nothing else — the shape every +// module takes once its tool container goes (to-be 38 WP4). +func aToolsModule(t *testing.T, name string, entrypoints ...string) catalogue.Manifest { + t.Helper() + m := catalogue.Manifest{Module: name, Version: "1", Tools: []string{"status"}, + Build: &catalogue.Build{Artifacts: []catalogue.Artifact{ + {Name: "tools", Kind: catalogue.ArtifactBundle, Language: "typescript", Entrypoints: entrypoints}, + }}} + resolved, err := m.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle, + Reference: catalogue.ArtifactStoreScheme + name + "/tools/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + return resolved +} + +var bundleDigest = "sha256:" + strings.Repeat("b", 64) + +// theRuntime is the runtime module as the catalogue holds it: its own bundle, run rather than +// loaded, and its broker secret to receive the node's credential in. +func theRuntime(t *testing.T) catalogue.Manifest { + t.Helper() + m := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1", + OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + catalogue.RuntimeModule + "/broker"}}, + Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "runtime", Kind: catalogue.ArtifactBundle, Language: "typescript", + Entrypoints: []string{"src/main.js"}}}}} + resolved, err := m.Resolve([]catalogue.Built{{Name: "runtime", Kind: catalogue.ArtifactBundle, + Reference: catalogue.ArtifactStoreScheme + catalogue.RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + return resolved +} + +func fileNamed(out []map[string]any, id string) map[string]any { + for _, r := range out { + if r["id"] == id { + return r + } + } + return nil +} + +func ids(out []map[string]any) []string { + var names []string + for _, r := range out { + names = append(names, r["id"].(string)) + } + return names +} + +func busyManifest(t *testing.T, on string, busy any) []byte { + t.Helper() + extra := map[string]any{"id": "agent", "type": "container", "name": "app-agent", + "image": "registry.example/app" + healthDigest} + var m map[string]any + if err := json.Unmarshal(healthManifest(t, "", nil, extra), &m); err != nil { + t.Fatal(err) + } + for _, r := range m["resources"].([]any) { + if r := r.(map[string]any); r["id"] == on { + r[catalogue.BusyField] = busy + } + } + out, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return out +} + +func telegram() catalogue.Manifest { + return catalogue.Manifest{Module: "telegram", Tools: []string{"status"}, DefinesSeats: []catalogue.SeatDeclaration{{ + Name: "telegram-sender", Scope: catalogue.ScopeMesh, + Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}, Serves: []catalogue.Verb{{Name: "status"}}, + }}, Claims: []catalogue.Claim{{Name: "telegram-sender", Scope: catalogue.ScopeMesh}}} +} + +func problemsFor(t *testing.T, shelf catalogue.Shelf) string { + t.Helper() + return strings.Join(CatalogueProblems(shelf), "; ") +} + +func claimed(claims string) []byte { + return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh","credential":{"form":"none"}}],"claims":` + claims + `}`) +} + +func hostnameSeat(t *testing.T) catalogue.Seat { + t.Helper() + s, ok := catalogue.SeatNamed("node-hostname") + if !ok { + t.Fatal("no node-hostname seat") + } + return s +} + +func hostnameHolder(resources ...map[string]any) catalogue.Manifest { + return catalogue.Manifest{Module: "names", Version: "1", Claims: []catalogue.Claim{{Name: "node-hostname", Scope: catalogue.ScopeNode, + Serves: []string{"entries", "add", "remove"}}}, Resources: resources} +} + +func mustParse(t *testing.T, raw string) catalogue.Manifest { + t.Helper() + m, err := ParseManifest([]byte(raw)) + if err != nil { + t.Fatalf("refused: %v", err) + } + return m +} + +const declaredStore = `{"module":"pg","version":"1", + "provides":[{"name":"postgres-database","scope":"mesh"}], + "grants":{"postgres-database":"${dir:grants}"}, + "data":{ + "own":[ + {"id":"store","path":"${dir:store}","class":"irreplaceable","backup":{"dump":"dump-all > ${dir:dumps}/all","into":"dumps"},"active":"1d"}, + {"id":"dumps","path":"${dir:dumps}","class":"rebuildable","backup":"none"}], + "consumers":{"postgres-database":{"class":"irreplaceable","in":"store"}}}, + "resources":[ + {"id":"grants","type":"directory","mode":"0700"}, + {"id":"store","type":"directory","path":"/srv/store","mode":"0700"}, + {"id":"dumps","type":"directory","path":"${dir:store}/dumps","mode":"0700"}, + {"id":"server","type":"container","image":"x@sha256:aa","volumes":["${dir:store}:/var/lib/postgresql/data"]}]}` + +func shallowCopy(resource map[string]any) map[string]any { + copied := map[string]any{} + for k, v := range resource { + copied[k] = v + } + return copied +} + +const templatedTwin = `{"module":"tidy","version":"1", + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[ + {"id":"conf","type":"directory","path":"${dir:conf}"}, + {"id":"main","type":"file","path":"${dir:conf}/tidy.conf","mode":"0644","template":"files/tidy.conf"}, + {"id":"extra","type":"file","path":"${dir:conf}/extra.conf","template":"files/deeper/extra.conf"}]}` + +func workstation() catalogue.Node { + return catalogue.Node{Name: "workstation", Site: "house", + Capabilities: map[string]bool{"seat": true, "container-runtime": true}} +} + +// notifierConsumer is the notifier requiring a mesh-scoped database, marked moved when moved, with its credential +// kept under two local names (ADR 0094) when locals is set. +func notifierConsumer(t *testing.T, moved, locals bool) catalogue.Manifest { + t.Helper() + raw := handedNotifier + if moved { + raw = notifierMoved() + } + add := `"requires":["postgres-database"],` + if locals { + add += `"secrets":{"postgres-database":{"hot":"/var/lib/notify/hot","cold":"/var/lib/notify/cold"}},` + } + raw = strings.Replace(raw, `"build":`, add+`"build":`, 1) + return held(t, raw, builtNotifier()) +} + +func shelf(ms ...catalogue.Manifest) map[string]catalogue.Manifest { + out := map[string]catalogue.Manifest{} + for _, m := range ms { + out[m.Module] = m + } + return out +} + +// twoStores is a mesh where two machines each run a store providing the database, the consumer pinned to one. +func twoStores(pin string) catalogue.World { + return catalogue.World{Offered: map[string][]catalogue.Provider{"postgres-database": { + {Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}}, + {Node: "home", At: "home.internal", Module: "store-two", Serves: map[string]any{"port": 5434}}, + }}, Pinned: map[string]catalogue.Chosen{"postgres-database": {Node: pin}}} +} + +func composeWith(t *testing.T, r catalogue.Resolution, with catalogue.Rendering) (catalogue.Composed, string) { + t.Helper() + c, err := r.Compose(with) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(c.Resources) + if err != nil { + t.Fatal(err) + } + return c, string(raw) +} + +func handingTo(store string) catalogue.Rendering { + return catalogue.Rendering{Hands: true, Provisioner: "mesh-provisioner-client", ArtifactStore: store} +} + +const barTemplate = `{{if eq .shows "battery"}}[[block]] +block = "battery" +{{with .options}}{{with .device}}device = {{quote .}} +{{end}}{{end}}{{else if eq .shows "command"}}[[block]] +block = "custom" +command = {{quote .options.command}} +interval = {{.options.interval}} +{{else if eq .shows "state"}}[[block]] +block = "custom" +command = {{quote (printf "show %s %s" .options.state (or .options.key .machine))}} +{{end}}` + +// The router of novox/hq ADR 0259: it declares the operator's seat and the two kinded benches. +func router() catalogue.Manifest { + return catalogue.Manifest{Module: "messenger", Tools: []string{"open", "history", "notify"}, + State: []catalogue.StateDeclaration{{Name: "asks"}}, RunsAs: "messenger", SecretsOwner: "messenger", + DefinesSeats: []catalogue.SeatDeclaration{ + {Name: "operator-channel", Scope: catalogue.ScopeMesh, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"}, + ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"}, + Serves: []catalogue.Verb{{Name: "open"}, {Name: "history"}, {Name: "notify"}}}, + {Name: "channel", Scope: catalogue.ScopeMesh, Kinded: true, Accepts: []string{"show", "edit", "send"}}, + {Name: "intake", Scope: catalogue.ScopeMesh, Kinded: true, Emits: []string{"choice", "link"}, Proofs: []string{"code"}}, + }, + Claims: []catalogue.Claim{{Name: "operator-channel", Scope: catalogue.ScopeMesh}}, + Uses: []string{"channel"}} +} + +func aChannel(module, kind string) catalogue.Manifest { + return catalogue.Manifest{Module: module, Claims: []catalogue.Claim{ + {Name: "channel", Scope: catalogue.ScopeMesh, Kind: kind}, {Name: "intake", Scope: catalogue.ScopeMesh, Kind: kind}}} +} + +func aStoreManifest(step map[string]any) []byte { + m := map[string]any{ + "module": "distribution", "version": "1", + "resources": []any{ + map[string]any{"id": "store", "type": "container", "name": "mesh-registry", + "image": "registry@sha256:" + strings.Repeat("a", 64)}, + step, + }, + } + raw, _ := json.Marshal(m) + return raw +} + +func familyManifest(t *testing.T, ownSecrets string) (catalogue.Manifest, error) { + t.Helper() + return ParseManifest([]byte(`{"module":"mounts","version":"1","own-secrets":{` + ownSecrets + `}}`)) +} + +// jsonString quotes a Go string the way encoding/json would, for building a literal into a test's manifest JSON. +func jsonString(s string) string { + out, err := json.Marshal(s) + if err != nil { + panic(err) + } + return string(out) +} + +// withPredefined is the list of predefined keys the mesh published, for the length of a test. +func withPredefined(t *testing.T, keys catalogue.PredefinedList) { + t.Helper() + was := catalogue.ReplacePublished(keys) + t.Cleanup(func() { catalogue.ReplacePublished(was) }) +} + +const theFirstKeys = `{ + "font-size": {"type": "number", "description": "the size text is shown in, in points", "minimum": 6, "maximum": 72}, + "agent-managed-settings": {"type": "object", "description": "the managed settings of every agent session, a JSON object", "trusted": true} +}` + +// fallingBackTo is inConfigForm with its font size falling back to a predefined key. +func fallingBackTo(t *testing.T, key string) []byte { + t.Helper() + m := inConfigForm() + declaration(m, "font-size")["predefined"] = key + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return raw +} + +// inConfigForm is a module in the config form that registers: every value typed, described, defaulted or +// required, placed in a file or read at runtime, and its mergeable file naming its keys. +func inConfigForm() map[string]any { + return map[string]any{ + "module": "notifier", + "version": "1", + "config": map[string]any{ + "font-size": map[string]any{"type": "number", "description": "the notification's font size, in points", + "minimum": 6, "maximum": 40, "default": 10}, + "width": map[string]any{"type": "number", "description": "the notification's width, in pixels", + "default": 250}, + "position": map[string]any{"type": "string", "description": "the corner it shows in, a word", + "enum": []any{"top-right", "top-left"}, "default": "top-right"}, + "theme": map[string]any{"type": "string", "description": "the colour theme, a lower-case name", + "pattern": "[a-z]+", "default": "dark"}, + "history": map[string]any{"type": "number", "description": "how many notifications it keeps, a count", + "default": 20, "runtime": true}, + "sender": map[string]any{"type": "string", "description": "the address it mails a digest from", + "required": true}, + }, + "resources": []any{ + map[string]any{"id": "configuration", "type": "file", "path": "/x/notifierrc", + "content": "font = Inter ${config:font-size}\nwidth = ${config:width}\nfrom = ${config:sender}\n"}, + map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", + "takes-config": []any{"position", "theme"}, "content": "{\"position\": \"top-right\"}\n"}, + map[string]any{"id": "notifier", "type": "service", "unit": "notifier.service", + "reload-on": []any{"configuration"}, "restart-on": []any{"rules"}}, + }, + } +} + +// declaration is one value of inConfigForm's config, to change in place. +func declaration(m map[string]any, key string) map[string]any { + return m["config"].(map[string]any)[key].(map[string]any) +} + +func resourceOf(m map[string]any, id string) map[string]any { + for _, r := range m["resources"].([]any) { + if r.(map[string]any)["id"] == id { + return r.(map[string]any) + } + } + return nil +} + +// refused is that the manifest is refused with every one of these words. +func refused(t *testing.T, m map[string]any, words ...string) { + t.Helper() + _, err := parseRaw(t, m) + if err == nil { + t.Fatalf("registered; wanted a refusal saying %q", words) + } + for _, w := range words { + if !strings.Contains(err.Error(), w) { + t.Fatalf("refused without %q:\n%v", w, err) + } + } +} + +func aContainerWithGroups(groups ...any) map[string]any { + return map[string]any{"id": "server", "type": "container", "name": "n8n", "image": "x@sha256:" + strings.Repeat("a", 64), + "groups": groups} +} + +func parseRaw(t *testing.T, m map[string]any) (catalogue.Manifest, error) { + t.Helper() + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return ParseManifest(raw) +} + +const misspelledShell = "${shel:zsh:first}" + +const undeclaredSetting = "${setting:Undeclared}" + +const misspelledMachine = "${machnie:address}" + +const shellsOwn = "${XDG_CACHE_HOME:-x}" + +const shellsOperators = "${PORT:-8080} ${SHELL:-/bin/sh} ${SECRET:?unset} ${dir:-/tmp}" + +// twoFiles is a module that owns two configurable files, which issue 168 said no module could: +// every key of every layer reached both. +func twoFiles(app, sidecar []any) catalogue.Manifest { + appFile := map[string]any{"id": "app", "type": "file", "path": "/etc/app.json", "merge": catalogue.MergeJSON, + "content": `{"server":{"port":8080},"search":{"safe":1}}`} + sideFile := map[string]any{"id": "sidecar", "type": "file", "path": "/etc/sidecar.json", "merge": catalogue.MergeJSON, + "content": `{"url":"http://127.0.0.1:8080"}`} + if app != nil { + appFile[catalogue.TakesSettingsField] = app + } + if sidecar != nil { + sideFile[catalogue.TakesSettingsField] = sidecar + } + return catalogue.Manifest{Module: "search", Resources: []map[string]any{ + appFile, sideFile, + {"id": "env", "type": "file", "path": "/etc/search.env", "content": "SITE=${setting:site-name}\n"}, + }} +} + +func containsText(list []string, text string) bool { + for _, s := range list { + if strings.Contains(s, text) { + return true + } + } + return false +} + +var digest = "sha256:" + strings.Repeat("d", 64) + +func manifestMounting(from string, beside string) []byte { + res := aContainerMounting + if beside != "" { + res = beside + "," + res + } + return []byte(`{"module":"store","resources":[` + strings.Replace(res, "%s", from, 1) + `]}`) +} + +func recycler() catalogue.Manifest { + return catalogue.Manifest{Module: "sonarr", + Resources: []map[string]any{ + {"id": "recycle-bin", "type": "directory", "path": "/srv/sonarr/recycle", "mode": "0755", + "owner": "${setting:puid}:${setting:pgid}"}, + {"id": "identity", "type": "file", "path": "/srv/sonarr/identity.env", "mode": "0644", + "content": "PUID=${setting:puid}\nPGID=${setting:pgid}\n"}, + }, + } +} + +func copied(r map[string]any) map[string]any { + out := map[string]any{} + for k, v := range r { + out[k] = v + } + return out +} + +func parsed(t *testing.T, m catalogue.Manifest) error { + t.Helper() + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + _, err = ParseManifest(raw) + return err +} + +func upstreamManifest(artifact string) string { + return `{"module":"a","version":"1","build":{"artifacts":[` + artifact + `]}}` +} + +const pinnedSum = "38dd24890a66b4cf9a45bc24e15192e00c26b929e1bb8df7974c16a4539129e2" + +// A process resource may declare `bus` (novox/hq ADR 0436 §1): what it emits, the module's own work-queue +// streams it consumes, the module's own state it reads and writes, whether it reads the router's records of +// this module's own asks, and the tools and seat verbs it calls. The fixture is mesh-mail's future manifest +// shape (ADR 0436 §7, §8), copied from mesh-catalog/modules/mesh-mail/module.json and extended as settled by +// the lead: process id "send" is the manifest's own `id` (internal/catalogue/runtime.go:72-74 is the model for +// a process id); the account file uses the controller's real JSON tags (modules.go:1041-1056 issueWith). +func mailSenderProcessManifest(t *testing.T, bus string) string { + t.Helper() + process := `{"id":"send","type":"process","run":["./mesh-mail","send"],"user":"mesh-mail-send"` + if bus != "" { + process += `,"bus":` + bus + } + process += `}` + // The stream itself is plain here, not retention work-queue: 1179 checks only that a process names a + // stream the module owns, never its retention — that rule is 1178's (WorkQueueStreamProblems), and is + // tested against its own fixture there (work_queue_consumer_test.go) so this one stays accepted whether + // or not a process currently names "mail-sends" in bus.queues. + return `{"module":"mesh-mail","version":"1","emits":["mail.send.sent","mail.send.failed"],` + + `"state":["sends"],"streams":[{"name":"mail-sends","events":["mail.send.approved"],"max-age-days":1,` + + `"max-mib":64}],"resources":[{"id":"account","type":"user","name":"mesh-mail-send"},` + process + `]}` +} + +const mailSenderBus = `{"emits":["mail.send.sent","mail.send.failed"],"queues":["mail-sends"],` + + `"state":["sends"],"asks":true,"invokes":["seat:operator-channel.tell"]}` + +func strSliceEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +const handedProvider = `{"module":"db","version":"1","moved":true, + "provides":[{"name":"postgres-database","scope":"mesh","identity":{"max":63,"in":"a PostgreSQL role"}}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[ + {"id":"state","type":"directory","mode":"0700","place":"."}, + {"id":"consumers","type":"file","path":"${dir:state}/consumers.json","mode":"0600","template":"files/consumers.json"}]}` + +const consumersText = "${consumers:postgres-database}" + +func notifierOn(t *testing.T, m catalogue.Manifest) catalogue.Resolution { + t.Helper() + r, err := catalogue.Resolve(shelf(m), []string{m.Module}, workstation(), catalogue.World{}) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + return r +} + +func placedModule() catalogue.Manifest { + return catalogue.Manifest{ + Module: "photos", + Resources: []map[string]any{ + {"id": "data", "type": "directory", "mode": "0700"}, + {"id": "server-env", "type": "file", "path": "${dir:data}/server.env", + "content": "STORE=${dir:data}/objects\n"}, + {"id": "server", "type": "container", "name": "photos-server", + "volumes": []any{"${dir:data}:/data"}, + "env": map[string]any{"DATA": "${dir:data}/objects"}, + "env-file": []any{"${dir:data}/server.env"}}, + }, + } +} + +// renaming is inConfigForm with its rules file taking `url` as NOTIFIER_URL, a value the code reading the file +// falls back on when the file lacks it. +func renaming() map[string]any { + m := inConfigForm() + m["config"].(map[string]any)["url"] = map[string]any{"type": "string", + "description": "the address the notifier posts to, a URL; unset, the notifier's own environment says it", + "default": ""} + resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", + map[string]any{"key": "url", "as": "NOTIFIER_URL"}} + return m +} + +// aMediaServer is the shape one port number per key cannot express: two endpoints of different kinds. +// A web surface a proxy serves under a subdomain, and a protocol port clients dial directly because +// the client expects that number. +func aMediaServer() catalogue.Manifest { + return catalogue.Manifest{ + Module: "media", + Listens: []catalogue.Listening{ + {Name: "web", Port: 80, From: catalogue.FromMesh, Why: "the app, behind the proxy"}, + {Name: "stream", Port: 32400, From: catalogue.FromEverywhere, Fixed: true, + Why: "the client dials this number; the protocol chose it"}, + }, + Contributes: map[string]map[string]any{ + "route": {"label": "media", catalogue.RouteEndpoint: "web"}, + }, + // Both endpoints are published by its container, which is what lets a machine port be given + // for either: the mesh moves a port the module publishes, never one it merely listens on. + Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "media", + "ports": []any{"80", "32400"}}, + }, + } +} + +// ownAccountManifest is a module of its own account with nothing long-running but its own runtime, given +// runtime-health (or none, when health is nil), and running as no account when runsAs is false. +func ownAccountManifest(t *testing.T, runsAs bool, health any) []byte { + t.Helper() + m := map[string]any{"module": "keeper", "tools": []any{"keeper_status", "keeper_health"}, + "own-secrets": map[string]any{"broker": map[string]any{"path": "/var/lib/keeper/broker"}}, + "resources": []any{map[string]any{"id": "account", "type": "user", "name": "keeper"}}} + if runsAs { + m["runs-as"], m["secrets-owner"] = "keeper", "keeper" + } + if health != nil { + m["runtime-health"] = health + } + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return raw +} + +const aRegistryForge = `{"module":"forge","version":"1","requires":["route"], + "provides":[{"name":"npm-package-registry","scope":"mesh"}], + "contributes":{"route":{"web":{"label":"git","port":3000}}}, + "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/"}}}` + +// store is an object store in the shape minio has: it serves a region and a port to everyone, and +// a bucket named for whoever is asking. +func store() catalogue.Manifest { + return catalogue.Manifest{ + Module: "store", Version: "1", + Provides: catalogue.FromAnywhere("s3-bucket"), + Listens: []catalogue.Listening{{Port: 9000, Protocol: "tcp", From: catalogue.FromMesh}}, + Serves: map[string]map[string]any{"s3-bucket": { + "region": "eu-west", + "bucket": "${consumer:as:dns}", + }}, + Receives: map[string]string{"s3-bucket": "/var/lib/store/grants/mesh.json"}, + Grants: map[string]string{"s3-bucket": "/var/lib/store/grants"}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "store", "ports": []any{"9000"}, + }}, + } +} + +// A module left out for a key this controller does not know, inside an entry, is read past that key +// alone: it still provides what it provides, its other entries are whole, and a key of the same name +// that another entry knows is kept (novox/hq ADR 0262). +func aModuleWithAFieldSetAsideStillProvidesWhatItProvides(t *testing.T) { + var m catalogue.Manifest + raw := `{"module": "later", "version": "2", + "provides": [{"name": "db", "scope": "mesh"}, {"name": "cache", "a-field-from-later": 1}], + "state": [{"name": "s", "history": 3}], + "data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable", "backup": {"dump": "x", "into": "d", "class": "later"}}]}, + "resources": [{"id": "d", "type": "directory", "mode": "0700"}]}` + if err := json.Unmarshal([]byte(raw), &m); err != nil { + t.Fatal(err) + } + if len(m.Provides) != 2 || m.Provides[0].Name != "db" || m.Provides[1].Name != "cache" { + t.Fatalf("provides: %+v", m.Provides) + } + if len(m.State) != 1 || m.State[0].History != 3 { + t.Fatalf("state: %+v", m.State) + } + if m.Data == nil || len(m.Data.Own) != 1 || m.Data.Own[0].Class != "valuable" { + t.Fatalf("data: the item's own class was taken for the backup's unknown one: %+v", m.Data) + } + for _, want := range []string{"provides[1].a-field-from-later", "data.own[0].backup.class"} { + if !strings.Contains(m.UnknownField(), want) { + t.Errorf("unknown %q does not say it read without %s", m.UnknownField(), want) + } + } + if why := catalogue.UnknownFieldReason(m); why != "" { + t.Fatal(why) + } +} + +// A module with a field set aside is composed whole: its files are declared and its data backed up (novox/hq ADR 0578 +// §6; before, ADR 0262 left it out and backed up its data alone). +func aModuleWithAFieldSetAsideIsComposedWhole(t *testing.T) { + var later catalogue.Manifest + if err := json.Unmarshal([]byte(`{"module": "later", "version": "2", "a-field-from-later": 1, + "resources": [{"id": "d", "type": "directory", "mode": "0700"}, {"id": "rc", "type": "file", "path": "/etc/later.conf", "content": "x\n"}], + "data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable"}]}}`), &later); err != nil { + t.Fatal(err) + } + holder := catalogue.Manifest{Module: "backups", Version: "1", + Claims: []catalogue.Claim{{Name: catalogue.BackupSeat, Scope: catalogue.ScopeNode}}, + Resources: []map[string]any{{"id": "list", "type": "file", "path": "/etc/backups.list", "mode": "0644", + "content": "${contribution:" + catalogue.BackupSeat + ":backup}"}}} + r := anAdoptedAnchor() + r.Modules = append(r.Modules, holder, later) + composed, err := r.Compose(anchorRendering(false)) + if err != nil { + t.Fatal(err) + } + if _, left := composed.LeftOut["later"]; left { + t.Fatalf("left out: %v", composed.LeftOut) + } + got := byID(composed.Resources) + if _, declared := got["later.rc"]; !declared { + t.Fatal("the module's file is not declared") + } + list, _ := got["backups.list"]["content"].(string) + if !strings.Contains(list, "# later") || !strings.Contains(list, "path /var/lib/later/d") { + t.Fatalf("the left-out module's data is no longer backed up:\n%s", list) + } +} + +func notifier() catalogue.Manifest { + return catalogue.Manifest{Module: "notifier", + Settings: map[string]catalogue.SettingDeclaration{ + "font-size": {Kind: catalogue.KindPreference, Default: float64(10), Why: "readable at a scale of one"}, + "width": {Kind: catalogue.KindPreference, Default: float64(250), Why: "fits a title of forty characters"}, + }, + Resources: []map[string]any{{"id": "configuration", "type": "file", "path": "/x/notifierrc", + "content": "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n"}}, + } +} + +// mailManifest is a module owning a restricted stream, as mesh-mail declares it (novox/hq ADR 0363), with +// the trusted mergeable file that takes its grants setting; extra is spliced in at the top. +func mailManifest(t *testing.T, streams, extra string) string { + t.Helper() + return `{"module":"mesh-mail","version":"1",` + extra + `"streams":` + streams + `,` + + `"resources":[{"id":"state","type":"directory","mode":"0700","place":"."},` + + `{"id":"grants","trusted":true,"type":"file","path":"${dir:state}/grants.json","mode":"0600",` + + `"merge":"json","takes-settings":["grants"],"content":"{\"grants\": {}}"}]}` +} + +const unpackingTemplated = `{"module":"tidy","version":"1", + "templates":"templates", + "build":{"artifacts":[{"name":"scripts","kind":"archive","from":"files"}, + {"name":"templates","kind":"archive","from":"templates"}]}, + "resources":[ + {"id":"scripts","type":"archive","path":"/usr/local/lib/tidy","artifact":"scripts"}, + {"id":"conf","type":"directory","path":"${dir:conf}"}, + {"id":"main","type":"file","path":"/etc/tidy.conf","mode":"0644","template":"templates/tidy.conf"}, + {"id":"extra","type":"file","path":"/etc/extra.conf","template":"templates/deeper/extra.conf"}]}` + +const unpackingInline = `{"module":"tidy","version":"1", + "build":{"artifacts":[{"name":"scripts","kind":"archive","from":"files"}]}, + "resources":[ + {"id":"scripts","type":"archive","path":"/usr/local/lib/tidy","artifact":"scripts"}, + {"id":"conf","type":"directory","path":"${dir:conf}"}, + {"id":"main","type":"file","path":"/etc/tidy.conf","mode":"0644","content":"` + mainText + `"}, + {"id":"extra","type":"file","path":"/etc/extra.conf","content":"` + extraText + `"}]}` + +// mailSenderManifestWithQueue is the mesh-mail fixture (ADR 0436 §7, §8) with its process's bus.queues set +// as given, so each 1178/AC2 case can vary just that. +func mailSenderManifestWithQueue(queues string) catalogue.Manifest { + return catalogue.Manifest{ + Module: "mesh-mail", Version: "1", Emits: []string{"mail.send.sent", "mail.send.failed"}, + State: []catalogue.StateDeclaration{{Name: "sends"}}, + Streams: []catalogue.StreamDeclaration{{Name: "mail-sends", Events: []string{"mail.send.approved"}, + Retention: catalogue.StreamRetentionWorkQueue, MaxDeliveries: 5}}, + Resources: []map[string]any{ + {"id": "account", "type": "user", "name": "mesh-mail-send"}, + {"id": "send", "type": "process", "run": []any{"./mesh-mail", "send"}, "user": "mesh-mail-send", + "bus": map[string]any{"emits": []any{"mail.send.sent", "mail.send.failed"}, "queues": queueList(queues)}}, + }, + } +} + +func mod(name string, provides, requires, capabilities []string, claims ...catalogue.Claim) catalogue.Manifest { + return catalogue.Manifest{Module: name, Provides: catalogue.Offers(provides...), Requires: requires, + Capabilities: capabilities, Claims: claims} +} + +const collectorZsh = `{"module":"zsh","version":"1","moved":true, + "claims":[{"name":"node-login-shell","scope":"node"}], + "collections":[{"name":"widgets","comment":"#"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"rc","type":"file","path":"${machine:account-home}/.zshrc","owner":"${machine:account}", + "mode":"0644","template":"files/zshrc"}]}` + +// catalogueManifests is every manifest of the catalogue (catalogueRoot), read through ReadTreeManifest once +// per test binary and not once per test: the checks over the real catalogue each read and parsed every +// manifest again, and together took 1.5 s of mesh-sdk's unit bound of 2 s (issue 1756). What it returns is +// shared and read-only: a test that changes a manifest copies it first. +func catalogueManifests(t *testing.T) (string, []readManifest) { + t.Helper() + root := catalogueRoot(t) + readOnce.Do(func() { + readRoot = root + found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil { + return + } + for _, p := range found { + m, err := ReadTreeManifest(root, filepath.Dir(p)) + readAll = append(readAll, readManifest{path: p, manifest: m, err: err}) + } + }) + if len(readAll) == 0 { + t.Fatalf("no manifests under %s", root) + } + return readRoot, readAll +} + +// theCatalogue is every manifest of the catalogue internal/beside finds, parsed the way registration +// parses one. +func theCatalogue(t *testing.T) []catalogue.Manifest { + t.Helper() + root := beside.Catalogue(t) + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + var out []catalogue.Manifest + for _, e := range entries { + if !e.IsDir() { + continue + } + raw, err := os.ReadFile(filepath.Join(root, e.Name(), "module.json")) + if err != nil { + continue + } + var m catalogue.Manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("%s: %v", e.Name(), err) + } + out = append(out, m) + } + if len(out) == 0 { + t.Fatalf("no manifests under %s, so this proved nothing", root) + } + return out +} + +// healthManifest is a module of one web container, one running service and one step, with the health +// given on the container (or on the resource named by on). +func healthManifest(t *testing.T, on string, health map[string]any, more ...map[string]any) []byte { + t.Helper() + resources := []map[string]any{ + {"id": "server", "type": "container", "name": "app-server", "image": "registry.example/app" + healthDigest, + "ports": []any{"8080"}}, + {"id": "daemon", "type": "service", "unit": "app.service", "state": "running"}, + {"id": "seed", "type": "container", "name": "app-seed", "image": "registry.example/app" + healthDigest, + "run-once": true}, + {"id": "sweep", "type": "container", "name": "app-sweep", "image": "registry.example/app" + healthDigest, + "schedule": "0 3 * * *"}, + } + resources = append(resources, more...) + for _, r := range resources { + if r["id"] == on && health != nil { + r["health"] = health + } + } + m := map[string]any{"module": "app", "requires": []any{"postgres-database"}, "tools": []any{"app_status"}, + "listens": []any{ + map[string]any{"name": "web", "port": 8080, "from": "mesh"}, + map[string]any{"name": "beacon", "port": 9999, "protocol": "udp", "from": "mesh"}, + }, + "resources": resources} + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return raw +} + +const mailStream = `[{"name":"mail","events":["mail.*.message.added","mail.*.message.changed",` + + `"mail.*.message.removed"],"max-age-days":7,"grant":{"config":"grants","events":"mail.{key}.**"}}]` + +// localResolver is a stand-in holder: it claims the seat and renders the resolver file naming the +// machine's own address. What is under test is the controller's rule, not the catalogue's module. +func localResolver() catalogue.Manifest { + return catalogue.Manifest{Module: "local-resolver", Version: "1", + Claims: []catalogue.Claim{{Name: catalogue.ResolverSeat, Scope: catalogue.ScopeNode}}, + Facts: map[string]catalogue.RosterFile{"resolvers": {Path: catalogue.ResolverFile, + Template: "# Managed by the mesh\n{{range .Machines}}{{if eq .Name $.Node}}nameserver {{.Address}}\n{{end}}{{end}}"}}} +} + +func catalogueManifest(t *testing.T, module string) catalogue.Manifest { + t.Helper() + root, dir := moduleTree(t, module) + m, err := ReadTreeManifest(root, dir) + if err != nil { + t.Fatalf("%s does not parse:\n%v", module, err) + } + return m +} + +func optionalModule(resources string) string { + return `{"module":"grafana","version":"1", + "requires":["route",{"name":"influxdb-api","optional":true}], + "secrets":{"influxdb-api":"/var/lib/grafana/influxdb-api"}, + "resources":[` + resources + `]}` +} + +const aContainerMounting = `{"id":"server","type":"container","name":"store","image":"x@sha256:` + + `0000000000000000000000000000000000000000000000000000000000000000","volumes":["%s:/inside"]}` + +func botMoved() string { + return strings.Replace(handedBot, `"version":"1",`, `"version":"1","moved":true,`, 1) +} + +// held is a manifest as registration holds it: resolved with what was built, then read again strictly. +func held(t *testing.T, raw string, built []catalogue.Built) catalogue.Manifest { + t.Helper() + m, err := ParseManifest([]byte(raw)) + if err != nil { + t.Fatal(err) + } + resolved, err := m.Resolve(built) + if err != nil { + t.Fatal(err) + } + again, err := json.Marshal(resolved) + if err != nil { + t.Fatal(err) + } + out, err := ParseManifest(again) + if err != nil { + t.Fatal(err) + } + if problem := catalogue.HeldTemplateProblem(out); problem != "" { + t.Fatal(problem) + } + return out +} + +func builtBot(text string) []catalogue.Built { + return []catalogue.Built{{Name: "files", Kind: catalogue.ArtifactArchive, + Reference: catalogue.ArtifactStoreScheme + "bot/files/blobs/sha256:" + strings.Repeat("e", 64), + Digest: "sha256:" + strings.Repeat("e", 64), Texts: map[string]string{"bot.conf": text}}} +} + +const botText = "token = ${secret:token}\nhome = ${machine:account-home}\n" + +const aServiceDigest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + +const healthDigest = "@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +const handedNotifier = `{"module":"notify","version":"1", + "config":{"width":{"type":"number","description":"How wide a notification is, in pixels.","default":250}}, + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[ + {"id":"configuration-dir","type":"directory","path":"${machine:account-home}/.config/notify","owner":"${machine:account}","mode":"0755"}, + {"id":"configuration","type":"file","path":"${machine:account-home}/.config/notify/rc","owner":"${machine:account}","mode":"0644","template":"files/rc"}, + {"id":"legacy","type":"file","path":"/etc/notify.conf","content":"plain = yes\n"}, + {"id":"notifier","type":"service","unit":"notify.service","scope":"user","user":"${machine:account}","reload-on":["configuration"]}]}` + +func notifierMoved() string { + return strings.Replace(handedNotifier, `"version":"1",`, `"version":"1","moved":true,`, 1) +} + +func builtNotifier() []catalogue.Built { + return []catalogue.Built{{Name: "files", Kind: catalogue.ArtifactArchive, + Reference: catalogue.ArtifactStoreScheme + "notify/files/blobs/sha256:" + strings.Repeat("d", 64), + Digest: "sha256:" + strings.Repeat("d", 64), Texts: map[string]string{"rc": notifierText}}} +} + +// anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network, +// a served module and the filter module. +func anAdoptedAnchor() catalogue.Resolution { + return catalogue.Resolution{Node: "anchor", Modules: []catalogue.Manifest{ + {Module: "network", Computed: "overlay"}, + {Module: "postgres", Guards: []int{5432}, + Listens: []catalogue.Listening{{Port: 5432, From: catalogue.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store", + "ports": []any{"5432:5432"}}}}, + {Module: "lavinmq", Guards: []int{15672}, + Listens: []catalogue.Listening{{Port: 5671, From: catalogue.FromMesh}, {Port: 5672, From: catalogue.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker", + "ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}}}}, + {Module: "distribution", + Listens: []catalogue.Listening{{Port: 5000, From: catalogue.FromMesh}}, + Resources: []map[string]any{{"id": "store", "type": "container", "name": "registry", + "ports": []any{"5000"}}}}, + {Module: "hello-web", + Listens: []catalogue.Listening{{Port: 8080, From: catalogue.FromEverywhere}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web", + "ports": []any{"8080"}}}}, + {Module: "helper", Listens: []catalogue.Listening{{Port: 9000, From: catalogue.FromMachine}}}, + {Module: "nftables", Filtering: &catalogue.Filtering{Into: "/etc/nftables.conf"}, + Resources: []map[string]any{{"id": "load", "type": "service", "unit": "mesh-filter.service", + "state": "running", "restart-on": []any{"filtering"}}}}, + }} +} + +func anchorRendering(adopted bool) catalogue.Rendering { + return catalogue.Rendering{ + Generators: map[string]catalogue.Generator{"overlay": hub{}}, + Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}}, + Settings: catalogue.SettingsBy{"distribution": {{From: "node anchor", + Placement: map[string]any{catalogue.ExposeSetting: map[string]any{"5000": catalogue.FromEverywhere}}}}}, + Mesh: []string{"10.42.0.1"}, + Foundation: []int{5671}, + // What the machine reported faces outside, which every rule in the filter is written + // around (novox/hq ADR 0140). + OutwardLinks: []string{"eth0"}, + TunnelInterface: "mesh0", + Adopted: adopted, + // Genesis takes the foundation's modules. + Taken: map[string]bool{"postgres": true, "lavinmq": true}, + } +} + +func byID(resources []map[string]any) map[string]map[string]any { + out := map[string]map[string]any{} + for _, r := range resources { + out[r["id"].(string)] = r + } + return out +} + +const mainText = `# tidy\nroot = ${dir:conf}\nquoted = \"yes\" é\n` + +const extraText = `one\ntwo\n` + +func queueList(q string) []any { + if q == "" { + return nil + } + out := []any{} + for _, s := range strings.Split(q, ",") { + out = append(out, s) + } + return out +} + +type readManifest struct { + path string // the module.json + manifest catalogue.Manifest + err error +} + +// TestEveryCatalogueManifestParses runs the real catalogue through the real gate. +// +// Not a fixture: the point is whether the manifests as written are accepted by the control plane that +// will read them, and a copy of one manifest proves nothing about the other seventy-one. +// catalogueRoot is the catalogue these checks run over: in a merge check the clone the build seat put +// beside this one, elsewhere the copy captured in testdata/beside (internal/beside). A check that only +// ran when somebody remembered a variable was a check nobody ran (novox/hq issue 134), and one that read +// whatever checkout sat beside judged the machine, not the change (novox/hq issue 432): it never skips. +func catalogueRoot(t *testing.T) string { + t.Helper() + return beside.Dir(t, "mesh-catalog") +} + +var readOnce sync.Once + +var readRoot string + +var readAll []readManifest + +// The catalogue's foundation modules as they are — in a merge check the catalogue cloned beside it, +// elsewhere the copy captured in testdata/beside (internal/beside, novox/hq issue 432) — parsed by the +// real parser (novox/hq ADR 0100): +// the store and the broker say which of their ports the mesh guards on an adopted node, and the +// filter module loads its table through a unit of its own whose stop deletes only that table. +// moduleTree is where a module is read from: the catalogue beside, with the catalogue as the root. In +// mesh-controller it first looked in the repository's own modules/, where build-agent and +// route-proxy live whole (novox/hq ADR 0420 §1, issue 1019); mesh-sdk has no such directory, and the +// tests that read those two modules stay in mesh-controller (issue 1379). +func moduleTree(t *testing.T, module string) (root, dir string) { + t.Helper() + return beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), module) +} + +const handedBot = `{"module":"bot","version":"1", + "own-secrets":{"token":"${dir:state}/token"}, + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[ + {"id":"state","type":"directory","mode":"0700","place":"."}, + {"id":"conf","type":"file","path":"/etc/bot/bot.conf","mode":"0640","template":"files/bot.conf"}, + {"id":"daemon","type":"process","name":"bot","run":["bot"],"env":{"BOT_TOKEN_FILE":"${dir:state}/token"}}]}` + +const notifierText = "width = ${config:width}\nhome = ${machine:account-home}\n" + +type hub struct{} + +func (hub) Resources(string) ([]map[string]any, bool, error) { + return []map[string]any{{"id": "config", "type": "file", "path": "/etc/wireguard/mesh0.conf", + "content": "[Interface]\n"}}, true, nil +} +func (hub) Listens(string) ([]catalogue.Listening, error) { + return []catalogue.Listening{{Port: 51820, Protocol: "udp", From: catalogue.FromEverywhere}}, nil +} diff --git a/modules/mesh-delivery-indexer/judge/testdata/catalogue-835bf22b-claude-code.module.json b/modules/mesh-delivery-indexer/judge/testdata/catalogue-835bf22b-claude-code.module.json new file mode 100644 index 0000000..3b62d21 --- /dev/null +++ b/modules/mesh-delivery-indexer/judge/testdata/catalogue-835bf22b-claude-code.module.json @@ -0,0 +1,249 @@ +{ + "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", + "waits" + ], + "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_wait", + "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/modules/mesh-delivery-indexer/judge/testdata/catalogue-835bf22b-dunst.module.json b/modules/mesh-delivery-indexer/judge/testdata/catalogue-835bf22b-dunst.module.json new file mode 100644 index 0000000..0c6983b --- /dev/null +++ b/modules/mesh-delivery-indexer/judge/testdata/catalogue-835bf22b-dunst.module.json @@ -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/modules/mesh-delivery-indexer/judge/testdata/catalogue-835bf22b-xterm.module.json b/modules/mesh-delivery-indexer/judge/testdata/catalogue-835bf22b-xterm.module.json new file mode 100644 index 0000000..b65be8e --- /dev/null +++ b/modules/mesh-delivery-indexer/judge/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/modules/mesh-delivery-indexer/testdata/beside/CAPTURED b/modules/mesh-delivery-indexer/testdata/beside/CAPTURED new file mode 100644 index 0000000..9f1bb1c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/CAPTURED @@ -0,0 +1,27 @@ +What a test reads of another repository when no merge check has cloned it beside this one (internal/beside, +novox/hq issue 432). Copied from the repositories at the commits below, never written by hand. Each +module.json is kept as module.json.captured: a module.json in this repository is a module of it to the +forge and the planner, and a merge would build and register it. + +The copy carries the catalogue's private details: domains, first names in module names, a node name and +private addresses. That is acceptable while mesh-sdk is private; if it ever goes public, this copy is +a second place to clean besides the catalogue itself. + +Only what go/catalogue's tests read is captured (R4b, issue 1379). Refreshed 2026-10-21 from the mains of both +repositories; before that it was mesh-controller's copy, which still held two modules that had left the catalogue. + +mesh-catalog b5dbfbb784507e211372a7c9640d91f7d6dadd84 modules/*/module.json, modules/*/files/**, modules/*/templates/**, predefined.json +mesh-host cdcba9ca73755854d21e4e657d7e46c32b9b5265 internal/apply/placement_guard.go + +To move them, from this repository's go/ directory, with the two repositories checked out beside mesh-sdk: + + rm -rf testdata/beside/mesh-catalog testdata/beside/mesh-host + mkdir -p testdata/beside/mesh-catalog testdata/beside/mesh-host/internal/apply + git -C ../../mesh-catalog archive -- predefined.json ':(glob)modules/*/module.json' \ + ':(glob)modules/*/files/**' ':(glob)modules/*/templates/**' | tar -x -C testdata/beside/mesh-catalog + find testdata/beside -name module.json -exec mv {} {}.captured \; + git -C ../../mesh-host show :internal/apply/placement_guard.go \ + > testdata/beside/mesh-host/internal/apply/placement_guard.go.captured + +and write the commits here. The placement guard is kept as .captured so no Go tool reads it as this +repository's code. diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/cursor-index.theme b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/cursor-index.theme new file mode 100644 index 0000000..d848240 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/cursor-index.theme @@ -0,0 +1,5 @@ +# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that +# read neither XCURSOR_THEME nor the X resources. +[Icon Theme] +Name=Default +Inherits=Adwaita diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk-theme b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk-theme new file mode 100644 index 0000000..b98c809 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk-theme @@ -0,0 +1 @@ +Adwaita:dark diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk2-rc-files b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk2-rc-files new file mode 100644 index 0000000..8a782fb --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk2-rc-files @@ -0,0 +1 @@ +/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-qpa-platformtheme b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-qpa-platformtheme new file mode 100644 index 0000000..349e5d6 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-qpa-platformtheme @@ -0,0 +1 @@ +qt6ct diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-select b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-select new file mode 100644 index 0000000..1e8b314 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-select @@ -0,0 +1 @@ +6 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-style-override b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-style-override new file mode 100644 index 0000000..a0da7db --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-style-override @@ -0,0 +1 @@ +Fusion diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-size b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-size new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-size @@ -0,0 +1 @@ +24 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-theme b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-theme new file mode 100644 index 0000000..cb9676b --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-theme @@ -0,0 +1 @@ +Adwaita diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/gtk-settings.ini b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/gtk-settings.ini new file mode 100644 index 0000000..a535ba4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/gtk-settings.ini @@ -0,0 +1,9 @@ +# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at +# every push; adwaita_appearance switches dark and light for the running session. +[Settings] +gtk-theme-name=Adwaita +gtk-icon-theme-name=Adwaita +gtk-cursor-theme-name=Adwaita +gtk-cursor-theme-size=24 +gtk-font-name=Inter 11 +gtk-application-prefer-dark-theme=1 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/portals.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/portals.conf new file mode 100644 index 0000000..d2e813f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/portals.conf @@ -0,0 +1,11 @@ +# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push. +# +# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with +# no preference it uses whichever backend happens to be installed: fine while gtk is the only one, +# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves +# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets. +[preferred] +default=gtk +# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers +# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop. +org.freedesktop.impl.portal.Secret=gnome-keyring diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/qt6ct.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/qt6ct.conf new file mode 100644 index 0000000..7f396d2 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/qt6ct.conf @@ -0,0 +1,29 @@ +[Appearance] +color_scheme_path=/usr/share/qt6ct/colors/darker.conf +custom_palette=true +icon_theme=Adwaita +standard_dialogs=default +style=Fusion + +[Fonts] +fixed="JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0" +general="Inter,11,-1,5,50,0,0,0,0,0" + +[Interface] +activate_item_on_single_click=1 +buttonbox_layout=0 +cursor_flash_time=1000 +dialog_buttons_have_icons=1 +double_click_interval=400 +gui_effects=@Invalid() +keyboard_scheme=2 +menus_have_icons=true +show_shortcuts_in_context_menus=true +stylesheets=@Invalid() +toolbutton_style=4 +underline_shortcut=1 +wheel_scroll_lines=3 + +[Troubleshooting] +force_raster_widgets=1 +ignored_applications=@Invalid() diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/xinitrc-normal b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/xinitrc-normal new file mode 100644 index 0000000..864b3cf --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/xinitrc-normal @@ -0,0 +1,10 @@ +# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is +# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the +# module's default; adwaita_appearance switches it for a session. +gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true +gsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true +gsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true +gsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true +gsettings set org.gnome.desktop.interface cursor-size 24 || true +gsettings set org.gnome.desktop.interface font-name 'Inter 11' || true +gsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/xresources-normal b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/xresources-normal new file mode 100644 index 0000000..02e7ec3 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/files/xresources-normal @@ -0,0 +1,3 @@ +! adwaita: the cursor, for X programs that take it from the resources. +Xcursor.theme: Adwaita +Xcursor.size: 24 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/adwaita/module.json.captured new file mode 100644 index 0000000..bf81c83 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/artifact-store-tools/files/store-names b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/artifact-store-tools/files/store-names new file mode 100644 index 0000000..29cab31 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/artifact-store-tools/files/store-names @@ -0,0 +1,4 @@ +# The names the artifact store had before, from the setting older-store-names, written by the mesh. +# artifact_store_references reads every definition under these too (novox/hq issue 323). +# Replaced on every push; change the setting, never this file. +older=${config:older-store-names} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/artifact-store-tools/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/artifact-store-tools/module.json.captured new file mode 100644 index 0000000..208329c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-backlight b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-backlight new file mode 100755 index 0000000..1a3ec31 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-backlight @@ -0,0 +1,33 @@ +#!/bin/bash +# zephyrus-backlight + | - | PERCENT — step or set the internal panel's backlight, never below 1 %. +# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue. +# +# The panel is the backlight beneath the eDP connector, not a name: in hybrid mode this model also +# registers the discrete GPU's backlight (nvidia_0), which moves nothing. Writable by the video group +# through the module's udev rule, so the vendor-key trigger needs no root. +set -u +STEP=5 +dev="" +for d in /sys/class/backlight/*; do + [ -e "$d" ] || continue + case "$(readlink -f "$d")" in *-eDP-*) dev=$d; break ;; esac +done +if [ -z "$dev" ]; then + for d in /sys/class/backlight/*; do [ -e "$d" ] && { dev=$d; break; }; done +fi +[ -n "$dev" ] || { echo "zephyrus-backlight: no backlight" >&2; exit 1; } +cur=$(cat "$dev/brightness") +max=$(cat "$dev/max_brightness") +pct=$(( cur * 100 / max )) +case "${1:-}" in + +|up|Up) pct=$(( pct + STEP )) ;; + -|down|Down) pct=$(( pct - STEP )) ;; + ''|*[!0-9]*) echo "usage: zephyrus-backlight + | - | PERCENT" >&2; exit 2 ;; + *) pct=$1 ;; +esac +(( pct < 1 )) && pct=1 +(( pct > 100 )) && pct=100 +new=$(( max * pct / 100 )) +(( new < 1 )) && new=1 +printf '%s' "$new" >"$dev/brightness" || exit 1 +exec "$(dirname "$0")/zephyrus-notify" 5555 "Brightness: ${pct}%" diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-display b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-display new file mode 100755 index 0000000..0b7d21b --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-display @@ -0,0 +1,41 @@ +#!/bin/bash +# zephyrus-display primary | order — the laptop's internal panel among the outputs. +# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue. +# +# primary makes the internal panel X's primary output, where the bars' tray goes. +# order the display key (Fn+F9, which the firmware sends as Super+P): odd workspaces to the +# internal panel, even ones to the first external output, or all to the panel when it +# is alone. The predecessor's orden-workspaces. +# +# The panel is found, not named: the connected output whose name starts with eDP. The predecessor +# wrote eDP-1, which is what this model calls it today and not a promise. +set -u +here=$(dirname "$0") +panel=$(xrandr --query 2>/dev/null | awk '$2 == "connected" && $1 ~ /^eDP/ { print $1; exit }') +[ -n "$panel" ] || { echo "zephyrus-display: no internal panel connected" >&2; exit 1; } +case "${1:-}" in +primary) + exec xrandr --output "$panel" --primary + ;; +order) + external=$(xrandr --listmonitors 2>/dev/null | awk -v p="$panel" 'NR > 1 && $NF != p { print $NF; exit }') + [ -n "$external" ] || external=$panel + # i3's workspace list, one object per workspace; num and output read from each. No jq: the + # fields are flat strings and numbers, and rect, the only nested value, holds neither name. + i3-msg -t get_workspaces 2>/dev/null | sed 's/},{"id"/}\n{"id"/g' | + while read -r ws; do + num=$(printf '%s' "$ws" | grep -o '"num":-\?[0-9]*' | cut -d: -f2) + out=$(printf '%s' "$ws" | grep -o '"output":"[^"]*"' | cut -d'"' -f4) + [ -n "$num" ] && [ "$num" -ge 0 ] || continue + if [ $((num % 2)) -eq 0 ]; then dest=$external; else dest=$panel; fi + [ "$out" = "$dest" ] && continue + i3-msg "workspace number $num; move workspace to output $dest" >/dev/null + done + if [ "$external" = "$panel" ]; then + "$here/zephyrus-notify" 7780 "Workspaces: all on the panel" + else + "$here/zephyrus-notify" 7780 "Workspaces: odd on the panel, even on $external" + fi + ;; +*) echo "usage: zephyrus-display primary | order" >&2; exit 2 ;; +esac diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-kbd-notify b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-kbd-notify new file mode 100755 index 0000000..aee878b --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-kbd-notify @@ -0,0 +1,20 @@ +#!/bin/bash +# zephyrus-kbd-notify — shows the keyboard backlight's level when it changes. The level itself is set +# by the firmware and asusd (Fn+F2/F3), which tell UPower; this only listens and notifies. Started once +# per session from the module's i3 fragment. Shipped by the mesh's asus-zephyrus-g14 module. +# +# One instance per session: i3 runs its `exec` lines again on an in-place restart, and the +# predecessor's listener ran twice after one, showing every change twice. +set -u +here=$(dirname "$0") +lock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/zephyrus-kbd-notify.lock" +exec 9>"$lock" +flock -n 9 || exit 0 +levels=(Off Low Med High) +dbus-monitor --system "type='signal',interface='org.freedesktop.UPower.KbdBacklight',member='BrightnessChanged'" 2>/dev/null | + while read -r line; do + if [[ $line =~ int32\ ([0-9]+) ]]; then + v=${BASH_REMATCH[1]} + "$here/zephyrus-notify" 5556 "Keyboard: ${levels[$v]:-$v}" + fi + done diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-media b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-media new file mode 100755 index 0000000..8b77d79 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-media @@ -0,0 +1,27 @@ +#!/bin/bash +# zephyrus-media play-pause | next | previous — the media keys, through MPRIS (playerctl), with a short +# notification of what happened. Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue. +# +# From the predecessor's media-control, kept: the lock against a double fire (the vendor keys can +# repeat), and the notification. Dropped: its fallback to a media server's local API, which needed a +# token from a file of secrets (novox/hq research 027 question 2). A player that speaks MPRIS is +# reached; one that does not says so. +set -u +here=$(dirname "$0") +action=${1:-play-pause} +case "$action" in play-pause | next | previous) ;; *) echo "usage: zephyrus-media play-pause | next | previous" >&2; exit 2 ;; esac +lock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/zephyrus-media.lock" +exec 9>"$lock" +flock -n 9 || exit 0 +if ! playerctl status >/dev/null 2>&1; then + "$here/zephyrus-notify" 7777 "Media: no player" + exit 0 +fi +playerctl "$action" 2>/dev/null +if [ "$action" = play-pause ]; then + sleep 0.2 + [ "$(playerctl status 2>/dev/null)" = Playing ] && label=Playing || label=Paused +else + label=${action^} +fi +"$here/zephyrus-notify" 7777 "Media: $label" diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-notify b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-notify new file mode 100755 index 0000000..1e8273f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-notify @@ -0,0 +1,12 @@ +#!/bin/bash +# zephyrus-notify ID SUMMARY — a short desktop notification that replaces the previous one with the +# same ID, through the session's notification service on its bus. busctl is the service manager's +# own client, so nothing is installed for it. Shipped by the mesh's asus-zephyrus-g14 module. +set -u +id=${1:-0} +summary=${2:-} +uid=$(id -u) +DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \ + busctl --user call org.freedesktop.Notifications /org/freedesktop/Notifications \ + org.freedesktop.Notifications Notify susssasa{sv}i \ + asus-zephyrus-g14 "$id" "" "$summary" "" 0 1 urgency y 0 1500 >/dev/null 2>&1 || true diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-session b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-session new file mode 100755 index 0000000..0f21f91 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-session @@ -0,0 +1,46 @@ +#!/bin/bash +# zephyrus-session COMMAND [ARG...] — run a command in the operator's graphical session from outside +# it: from a vendor-key trigger, which triggerhappy runs as the operator's account but with none of +# the session's environment. Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue. +# +# What it replaces: the predecessor's `as-user`, which triggerhappy ran as root and which `su`-ed to +# a named person with a hard-coded user id and display, and sourced a file of secrets on the way. +# Here the account is whoever runs it, the bus is that account's, and the display is the one the +# account's own session uses. Nothing is sourced. +set -u +uid=$(id -u) +export XDG_RUNTIME_DIR="/run/user/${uid}" +export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus" +home=$(getent passwd "$uid" | cut -d: -f6) +[ -n "$home" ] && export HOME="$home" +# The session's own window manager says best which display and authority the session uses: read +# them from its environment, as the desktop modules' session finder does. Then logind, then any +# process of this account that has a display. +from_environ() { + env=$(tr '\0' '\n' <"/proc/$1/environ" 2>/dev/null) || return 1 + d=$(printf '%s\n' "$env" | sed -n 's/^DISPLAY=//p' | head -n1) + [ -n "$d" ] || return 1 + export DISPLAY="$d" + a=$(printf '%s\n' "$env" | sed -n 's/^XAUTHORITY=//p' | head -n1) + [ -n "$a" ] && export XAUTHORITY="$a" + return 0 +} +if [ -z "${DISPLAY:-}" ]; then + for pid in $(pgrep -xu "$uid" i3 2>/dev/null); do + from_environ "$pid" && break + done +fi +if [ -z "${DISPLAY:-}" ]; then + for s in $(loginctl list-sessions --no-legend 2>/dev/null | awk -v u="$uid" '$2 == u { print $1 }'); do + d=$(loginctl show-session "$s" -p Display --value 2>/dev/null) + if [ -n "$d" ]; then export DISPLAY="$d"; break; fi + done +fi +if [ -z "${DISPLAY:-}" ]; then + for pid in $(pgrep -u "$uid" 2>/dev/null); do + from_environ "$pid" && break + done +fi +: "${XAUTHORITY:=${HOME}/.Xauthority}" +export XAUTHORITY +exec "$@" diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-touchpad b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-touchpad new file mode 100755 index 0000000..637be36 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-touchpad @@ -0,0 +1,29 @@ +#!/bin/bash +# zephyrus-touchpad reset | toggle — apply the touchpad's settings again, or switch it on or off. +# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue. +# +# The settings themselves are an X input class (/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf), +# which X applies every time the device appears — after a resume too, which is what the predecessor's +# sleep hook existed for. This is the manual form, bound to the touchpad key. +set -u +here=$(dirname "$0") +name=$("$here/zephyrus-session" xinput list --name-only 2>/dev/null | grep -m1 -i 'touchpad') +[ -n "$name" ] || { echo "zephyrus-touchpad: no touchpad in this session" >&2; exit 1; } +x() { "$here/zephyrus-session" xinput "$@"; } +case "${1:-reset}" in + reset) + x set-prop "$name" "libinput Tapping Enabled" 1 + x set-prop "$name" "libinput Natural Scrolling Enabled" 1 + x set-prop "$name" "libinput Accel Speed" 0.15 + x enable "$name" + "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: reset" + ;; + toggle) + if x list-props "$name" | grep -q 'Device Enabled ([0-9]*):[[:space:]]*1'; then + x disable "$name"; "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: off" + else + x enable "$name"; "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: on" + fi + ;; + *) echo "usage: zephyrus-touchpad reset | toggle" >&2; exit 2 ;; +esac diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured new file mode 100644 index 0000000..d1ee8d3 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf new file mode 100644 index 0000000..f3d6bef --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf @@ -0,0 +1,11 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little, +# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's. +[UPower] +UsePercentageForPolicy=true +PercentageLow=15.0 +PercentageCritical=10.0 +PercentageAction=7.0 +CriticalPowerAction=Suspend +AllowRiskyCriticalPowerAction=true diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf new file mode 100644 index 0000000..f30830c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf @@ -0,0 +1,12 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The touchpad's settings, applied by X every time the device appears — at login and after every +# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran +# xinput after a resume as a named person on a guessed display. +Section "InputClass" + Identifier "asus-zephyrus-g14 touchpad" + MatchIsTouchpad "on" + Option "Tapping" "on" + Option "NaturalScrolling" "true" + Option "AccelSpeed" "0.15" +EndSection diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/after-wake b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/after-wake new file mode 100644 index 0000000..af60a9d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/after-wake @@ -0,0 +1,7 @@ +# The touchpad's settings again after waking, in the operator's session: X applies the module's +# input class when the device appears, and this covers a wake that does not initialise it again. +# Runs as root from the power module; the reset itself runs as the session's owner. +sleep 2 +owner=$(ps -o user= -C i3 | head -n 1) +[ -n "$owner" ] && runuser -u "$owner" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset +true diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/display-session b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/display-session new file mode 100644 index 0000000..38f9e5b --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/display-session @@ -0,0 +1,30 @@ +# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the +# mesh: replaced at every push. Once the controller places contributions to node-display-session +# (ADR 0210), these become the module's contribution instead of a file in i3's directory. +# +# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are +# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for +# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's. + +# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the +# screenshot grabs the pointer to select a region, which fails while the key is still held. +bindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh + +# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the +# external output. +bindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order + +# The keyboard backlight's level, shown when it changes. +exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify +# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the +# mesh: replaced at every push. Once the controller places contributions to node-display-session +# (ADR 0210), these become the module's contribution instead of a file in i3's directory. +# +# The model's panel and touchpad. + +# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where +# is the display server's (autorandr, run at every session start). +exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary + +# The touchpad's settings again, by hand. X applies them itself whenever the device appears. +bindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/hotkeys-triggers b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/hotkeys-triggers new file mode 100644 index 0000000..5e4d165 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/hotkeys-triggers @@ -0,0 +1,10 @@ +# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness, +# and the touchpad key. Each runs this module's own script, as the operator's account. +KEY_PROG1 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause +KEY_PROG3 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous +KEY_PROG4 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next +KEY_BRIGHTNESSDOWN 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight - +KEY_BRIGHTNESSDOWN 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight - +KEY_BRIGHTNESSUP 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight + +KEY_BRIGHTNESSUP 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight + +KEY_F21 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf new file mode 100644 index 0000000..7894102 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf @@ -0,0 +1,16 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics). +# +# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends +# the driver an ACPI notification it fails to handle on this model ("RmHandleDNotifierEvent: Failed to +# handle ACPI D-Notifier event, status=0x62"), and the GPU stops making progress until the machine is +# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up. +# +# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still +# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this +# module's drop-ins on the sleep services ask for. +# +# A change here applies when the driver next loads: at the next boot. +options nvidia NVreg_PreserveVideoMemoryAllocations=1 +options nvidia NVreg_DynamicPowerManagement=0x00 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/video-brightness-switch.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/video-brightness-switch.conf new file mode 100644 index 0000000..5b2de1d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/video-brightness-switch.conf @@ -0,0 +1,6 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it +# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf). +# Applies when the module next loads: at the next boot. +options video brightness_switch_enabled=0 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf new file mode 100644 index 0000000..4466dd0 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change +# of power source, and asusd starts it on mains. It runs only when the kernel command line says +# zephyrus.nvidia-powerd — an explicit opt-in, at boot. +[Unit] +ConditionKernelCommandLine=zephyrus.nvidia-powerd diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf new file mode 100644 index 0000000..1f56e3b --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as +# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service +# shape must not start these units by hand, which would put the GPU to sleep with the machine awake. +[Unit] +Wants=nvidia-hibernate.service nvidia-resume.service diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf new file mode 100644 index 0000000..5813188 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as +# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service +# shape must not start these units by hand, which would put the GPU to sleep with the machine awake. +[Unit] +Wants=nvidia-suspend-then-hibernate.service nvidia-resume.service diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf new file mode 100644 index 0000000..92a0111 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as +# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service +# shape must not start these units by hand, which would put the GPU to sleep with the machine awake. +[Unit] +Wants=nvidia-suspend.service nvidia-resume.service diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/udev/rules.d/90-backlight.rules b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/udev/rules.d/90-backlight.rules new file mode 100644 index 0000000..0a9a894 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/udev/rules.d/90-backlight.rules @@ -0,0 +1,5 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The backlights are writable by the video group, so the brightness keys and the module's brightness tool +# move the panel without root. +ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness" diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/audit-logger/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/audit-logger/module.json.captured new file mode 100644 index 0000000..122b94e --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/avahi/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/avahi/module.json.captured new file mode 100644 index 0000000..de99c7c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/baserow/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/baserow/files/config.json new file mode 100644 index 0000000..955ac24 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/baserow/files/config.json @@ -0,0 +1,4 @@ +{ + "password": "${secret:admin}", + "host": "${bound:route:name}" +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/baserow/files/server.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/baserow/files/server.env new file mode 100644 index 0000000..7b716b4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/baserow/files/server.env @@ -0,0 +1,7 @@ +DATABASE_HOST=${bound:postgres-database:at} +DATABASE_PORT=${bound:postgres-database:port} +DATABASE_NAME=${bound:postgres-database:as} +DATABASE_USER=${bound:postgres-database:as} +DATABASE_PASSWORD_FILE=/run/secrets/database +DISABLE_EMBEDDED_PSQL=true +BASEROW_PUBLIC_URL=https://${bound:route:name} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/baserow/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/baserow/module.json.captured new file mode 100644 index 0000000..8119621 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/accounts.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/accounts.json new file mode 100644 index 0000000..e182cc3 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/accounts.json @@ -0,0 +1,3 @@ +{ + "accounts": {} +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/betterbird b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/betterbird new file mode 100755 index 0000000..3ce088a --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/betterbird @@ -0,0 +1,10 @@ +#!/bin/sh +# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push. +# +# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars, +# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do +# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a +# second start while one runs only hands over to it. What the preparation could not do it says here, and +# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back. +/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo "betterbird: the mesh's preparation did not complete; betterbird_check says why" >&2 +exec /opt/betterbird/betterbird "$@" diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/configuration.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/configuration.json new file mode 100644 index 0000000..7020446 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/configuration.json @@ -0,0 +1,6 @@ +{ + "identities": {}, + "calendars": {}, + "address-books": {}, + "filters": {} +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/eu.betterbird.Betterbird.desktop b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/eu.betterbird.Betterbird.desktop new file mode 100644 index 0000000..e409fcf --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/eu.betterbird.Betterbird.desktop @@ -0,0 +1,25 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Betterbird +GenericName=Mail Client +Comment=Send and receive mail with Betterbird +Exec=/usr/local/bin/betterbird %u +Icon=/opt/betterbird/chrome/icons/default/default128.png +Terminal=false +Categories=Network;Email; +MimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid; +StartupNotify=true +StartupWMClass=eu.betterbird.Betterbird +Keywords=Email;E-mail;Calendar;Thunderbird; +Actions=ComposeMessage;OpenAddressBook; +# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry: +# it starts Betterbird through the module's start, and names the icon in the installation, with no link. + +[Desktop Action ComposeMessage] +Name=Write new message +Exec=/usr/local/bin/betterbird -compose + +[Desktop Action OpenAddressBook] +Name=Open address book +Exec=/usr/local/bin/betterbird -addressbook diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/prefs.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/prefs.json new file mode 100644 index 0000000..45fd973 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/prefs.json @@ -0,0 +1,3 @@ +{ + "prefs": {} +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/tags.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/tags.json new file mode 100644 index 0000000..4772f09 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/files/tags.json @@ -0,0 +1,3 @@ +{ + "tags": {} +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/betterbird/module.json.captured new file mode 100644 index 0000000..891f883 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/blueman/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/blueman/module.json.captured new file mode 100644 index 0000000..73a7640 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/bluetooth/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/bluetooth/module.json.captured new file mode 100644 index 0000000..dfaabf1 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/brother/files/settings.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/brother/files/settings.json new file mode 100644 index 0000000..80aed32 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/brother/files/settings.json @@ -0,0 +1,9 @@ +{ + "scanner": "", + "scan-folder": "", + "printer": "", + "printer-certificate": "", + "resolution": 300, + "color-mode": "color", + "crop": true +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/brother/files/store.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/brother/files/store.json new file mode 100644 index 0000000..8c2075d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/brother/files/store.json @@ -0,0 +1,9 @@ +{ + "scheme": "${bound:s3-bucket:scheme}", + "at": "${bound:s3-bucket:at}", + "port": "${bound:s3-bucket:port}", + "bucket": "${bound:s3-bucket:bucket}", + "region": "${bound:s3-bucket:region}", + "key": "${bound:s3-bucket:as}", + "secret": "${secret:s3-bucket}" +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/brother/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/brother/module.json.captured new file mode 100644 index 0000000..57559d5 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ca-trust/files/anchor b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ca-trust/files/anchor new file mode 100755 index 0000000..72576d2 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ca-trust/files/anchor @@ -0,0 +1,58 @@ +#!/bin/sh +# The mesh's internal certificate authority, trusted by this machine. +# +# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147). +# Editing it here lasts until the next apply. +# +# There is no prior trust to verify the fetch against — this is the thing that +# establishes it — so it is made over the mesh's own private network, which is +# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the +# route proxy fetch this root for itself). What comes back is checked here: a +# body that is not a certificate is refused now, rather than believed and then +# failed by whatever reads the trust store next. +set -eu + +ROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}' +ANCHORS=/etc/ca-certificates/trust-source/anchors +ANCHOR="$ANCHORS/mesh-internal-ca.crt" + +# Arch's layout, said out loud rather than assumed: a machine that keeps its +# anchors elsewhere fails here, visibly, instead of writing a file nothing +# reads. That failure is the signal that this belongs in the host, where one +# operating system's difference lives (novox/hq ADR 0147, option 2). +[ -d "$ANCHORS" ] || { + echo "this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout" >&2 + exit 1 +} + +case "${1:-}" in +install) + tmp=$(mktemp) + trap 'rm -f "$tmp"' EXIT + # The authority may still be starting, or this machine may have come up + # before it: two minutes of asking, then an honest failure. + n=0 + while [ "$n" -lt 60 ]; do + if curl --fail --silent --show-error --insecure --max-time 10 \ + --output "$tmp" "$ROOTS" && + grep -q 'BEGIN CERTIFICATE' "$tmp"; then + install -m 0644 "$tmp" "$ANCHOR" + update-ca-trust + exit 0 + fi + n=$((n + 1)) + sleep 2 + done + echo "the authority at $ROOTS did not serve a certificate within two minutes" >&2 + exit 1 + ;; +remove) + # What stopping the unit does, and therefore what being unassigned does. + rm -f "$ANCHOR" + update-ca-trust + ;; +*) + echo "usage: $(basename "$0") install|remove" >&2 + exit 2 + ;; +esac diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ca-trust/files/mesh-ca-trust.service b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ca-trust/files/mesh-ca-trust.service new file mode 100644 index 0000000..2411560 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ca-trust/files/mesh-ca-trust.service @@ -0,0 +1,16 @@ +[Unit] +Description=The mesh's internal certificate authority, trusted by this machine +# novox/hq ADR 0147. Starting this unit places the mesh's root among this +# machine's trust anchors; stopping it takes the root away again, which is what +# the host does when the module is no longer assigned here. +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=${dir:state}/anchor install +ExecStop=${dir:state}/anchor remove + +[Install] +WantedBy=multi-user.target diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ca-trust/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ca-trust/module.json.captured new file mode 100644 index 0000000..b3c9dc9 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/agent-account b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/agent-account new file mode 100644 index 0000000..b081a6c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/agent-account @@ -0,0 +1 @@ +${machine:agent-account} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/claude-agent b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/claude-agent new file mode 100755 index 0000000..e3d330f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/claude-agent @@ -0,0 +1,13 @@ +#!/bin/sh +# The mesh's (module claude-code, novox/hq ADR 0266): start the agent as the account agents run as on this +# machine. Written whole at every push: an edit here is overwritten. +agent='${machine:agent-account}' +if [ "$(id -un)" = "$agent" ]; then + exec claude "$@" +fi +# Another account, the operator's, becomes the agent's through its own sudo: the person is the authority. The +# operator's override of the guard travels only when it is set in this shell, as it would reach claude. +if [ -n "$MESH_GUARD_OVERRIDE" ]; then + exec sudo -iu "$agent" env MESH_GUARD_OVERRIDE="$MESH_GUARD_OVERRIDE" claude "$@" +fi +exec sudo -iu "$agent" claude "$@" diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/facts.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/facts.json new file mode 100644 index 0000000..7fd72bd --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/facts.json @@ -0,0 +1,4 @@ +{ + "node": "${machine:name}", + "console": "http://127.0.0.1:${bound:mcp-endpoint:port}/mcp" +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/settings.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/settings.json new file mode 100644 index 0000000..8284e66 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/settings.json @@ -0,0 +1,10 @@ +{ + "role": "", + "mcp_servers": {}, + "managed_settings": {}, + "instructions": {}, + "output_styles": {}, + "skills": {}, + "commands": {}, + "agents": {} +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/zsh-normal b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/zsh-normal new file mode 100644 index 0000000..b90b85f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/claude-code/files/zsh-normal @@ -0,0 +1,12 @@ +# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the +# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where +# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs +# the real claude: exec, sudo and its sh see no alias. +if [[ -r /etc/claude-code/agent-account ]]; then + () { + local agent=$(/dev/null | grep -E '^(DISPLAY|XAUTHORITY)='); do + export "$pair" + done + [ -n "$DISPLAY" ] && break + set -- /tmp/.X11-unix/X* + if [ $# -eq 1 ] && [ -S "$1" ]; then + DISPLAY=":${1##*/X}" + export DISPLAY + else + sleep 1 + fi +done +if [ -z "$XAUTHORITY" ] && [ -r "$HOME/.Xauthority" ]; then + XAUTHORITY="$HOME/.Xauthority" + export XAUTHORITY +fi +# clipmenud allows one daemon per account: it takes an exclusive flock on its session_lock file and exits +# with status 2 when that is held. After a delivery without a new login, the session's older clipmenud (from +# its xinitrc) still holds it, and a start would run the unit round its restart loop. So this waits, quietly, +# until the lock is free: it looks at the same file clipmenud locks (the directory is clipmenud's own rule) +# and lets go at once. Exit status 1 is "held" for util-linux and BusyBox flock alike (no -E: BusyBox lacks it); any other outcome starts clipmenud, which says its own +# error. Another daemon winning the gap between look and start costs one restart, not a loop. +lock="${CM_DIR:-${XDG_RUNTIME_DIR-${TMPDIR-/tmp}}}/clipmenu.6.$USER/session_lock" +while [ -e "$lock" ]; do + flock -n "$lock" true + [ $? -eq 1 ] || break + sleep 2 +done +PATH="/usr/local/lib/mesh-clipmenu:$PATH" +export PATH +exec clipmenud diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/bin/xsel b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/bin/xsel new file mode 100755 index 0000000..10f9bbd --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/bin/xsel @@ -0,0 +1,20 @@ +#!/bin/sh +# xsel as clipmenud sees it, written by the mesh (module clipmenu). Replaced at every push. +# +# clipmenud records text, and reads a selection with `timeout 1 xsel -o`. A selection holding an +# image (a screenshot copied as image/png) is sent in pieces; one second is too short for megabytes, +# timeout kills xsel half way, and the program owning the image waits for ever for a reader that is +# gone. From then on nothing can ask the clipboard anything: pastes hang, and an Electron app that +# asks on its main thread freezes. So a read goes ahead only when the selection offers text. +case " $* " in +*" -o "* | *" --output "*) + selection=clipboard + case " $* " in + *" --primary "* | *" -p "*) selection=primary ;; + *" --secondary "* | *" -s "*) selection=secondary ;; + esac + targets=$(timeout 1 xclip -selection "$selection" -t TARGETS -o 2>/dev/null) || exit 1 + printf '%s\n' "$targets" | grep -qxE 'UTF8_STRING|STRING|TEXT|text/plain(;charset=utf-8)?' || exit 1 + ;; +esac +exec /usr/bin/xsel "$@" diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-histlength b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-histlength new file mode 100644 index 0000000..60d3b2f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-histlength @@ -0,0 +1 @@ +15 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-max-clips b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-max-clips new file mode 100644 index 0000000..1b79f38 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-max-clips @@ -0,0 +1 @@ +500 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-selections b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-selections new file mode 100644 index 0000000..613d81a --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-selections @@ -0,0 +1 @@ +clipboard diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/i3/50-clipmenu.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/i3/50-clipmenu.conf new file mode 100644 index 0000000..41835e6 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/i3/50-clipmenu.conf @@ -0,0 +1,5 @@ +# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at +# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which +# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the +# clipboard. +bindsym $mod+period exec --no-startup-id clipmenu -p Clipboard diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/systemd/clipmenud-mesh.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/systemd/clipmenud-mesh.conf new file mode 100644 index 0000000..799e22d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/files/systemd/clipmenud-mesh.conf @@ -0,0 +1,14 @@ +# clipmenud in the operator's account (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at every push. +# The package's unit starts clipmenud with no DISPLAY; the module's launcher finds the display and puts the +# text-only xsel first on the PATH. The settings repeat the module's environment, which the account's +# service manager does not read. +[Unit] +StartLimitIntervalSec=0 + +[Service] +RestartSec=2s +Environment=CM_SELECTIONS=clipboard +Environment=CM_MAX_CLIPS=500 +Environment=CM_HISTLENGTH=15 +ExecStart= +ExecStart=/usr/local/lib/mesh-clipmenu/clipmenud-session diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/clipmenu/module.json.captured new file mode 100644 index 0000000..30ec9c0 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/confluence/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/confluence/files/config.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/confluence/files/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/confluence/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/confluence/module.json.captured new file mode 100644 index 0000000..caa0d1b --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/cups/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/cups/module.json.captured new file mode 100644 index 0000000..01a4822 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dbus/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dbus/module.json.captured new file mode 100644 index 0000000..c0e28b3 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/desk-channel/files/settings.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/desk-channel/files/settings.json new file mode 100644 index 0000000..1ef16fd --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/desk-channel/files/settings.json @@ -0,0 +1,3 @@ +{ + "desktop-machines": [] +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/desk-channel/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/desk-channel/module.json.captured new file mode 100644 index 0000000..115ce62 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dev-tools/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dev-tools/module.json.captured new file mode 100644 index 0000000..094f833 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/disk-load/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/disk-load/module.json.captured new file mode 100644 index 0000000..e50253e --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/distribution/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/distribution/module.json.captured new file mode 100644 index 0000000..eb9f7c2 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dmenu/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dmenu/module.json.captured new file mode 100644 index 0000000..aeeb3ce --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dnsmasq/files/dnsmasq.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dnsmasq/files/dnsmasq.conf new file mode 100644 index 0000000..10f3926 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dnsmasq/files/dnsmasq.conf @@ -0,0 +1,87 @@ +# Managed by the mesh. dnsmasq's own defaults are replaced whole rather than +# patched, because this module owns the file and a patch would leave whatever +# was there before to be discovered later. +# +# This is one of the mesh's resolvers (novox/hq ADR 0194, 0223): it holds the +# `mesh-dns-resolver` seat, which more than one machine may hold, each answering +# the same names from the same roster. Every node and every container lists every +# holder and no public resolver, so whichever answers first gives the one answer. +# It holds the mesh's own names and nothing else (ADR 0191) — the roster is the +# mesh's, rendered into each holder; no other copy lives on any machine. + +# What the mesh computed: one wildcard per machine — its name and everything +# under it — and the mesh's own suffix as a local domain, so a name under it is +# answered here or not at all and is never asked upstream. Rewritten whenever a +# machine joins or leaves, which is why the service below restarts on it: a +# reload makes dnsmasq re-read hosts files, not its configuration, and a +# wildcard is configuration. +conf-file=/etc/mesh-resolver/nodes.conf + +# Every zone a module answers itself (ADR 0199): one forwarding line per zone, to +# the private address of the machine its module runs on and the port its +# answerer is published on. Nothing in a zone is answered here; what names a zone +# holds is the module's. Rewritten whenever a zone is declared or moves, and the +# service restarts on it for the same reason as above. +conf-file=/etc/mesh-resolver/zones.conf + +# Where it answers: this machine's private address, so every node — and every +# container on every node, which copies its machine's resolvers — can ask; and +# loopback, for this machine's own use. Never a LAN address: a device that is +# not a member cannot reach what the mesh's names point at, and a LAN's resolver +# is its router's (ADR 0194). +# +# Named as an ADDRESS and not as the interface that carries it, on purpose. A +# container's query is addressed to this address but arrives on the runtime's +# bridge, and dnsmasq checks every query against what it was told to answer on: +# an `interface=` line admits queries by the interface they arrive on, a +# `listen-address=` line by the address they are sent to. With `interface=mesh0` +# alone, a query from a container was received and dropped without a word +# (novox/hq 04-ISSUES/110). The address admits it whatever bridge it comes in on. +# +# bind-interfaces, and never bind-dynamic (novox/hq issue 348). bind-dynamic +# re-reads the machine's addresses on every change of any link and closes each +# listener whose interface that reading did not find; a reading made while the +# container runtime deletes a bridge's virtual links fails ("index gone"), and +# then every listener is closed until a later change reads cleanly. On +# 2026-10-09 the private address was left unbound for twenty minutes, the daemon +# running and silent. bind-interfaces binds each address once, at start, and +# never closes it. It cannot bind an address that is not there yet, so the unit +# waits for it (the drop-in below); a new private address restarts the unit, as +# this file changing always did. +bind-interfaces +listen-address=${machine:address} +listen-address=127.0.0.1 + +# **It must never read resolv.conf to find out where to forward.** This +# machine's resolv.conf names this resolver — so a resolver that read it for +# upstreams would find itself, and every query it could not answer locally would +# loop until its receive queue filled. That is not theoretical: it filled with +# 15KB of queries and every lookup on the machine hung. no-resolv makes that loop +# impossible: the upstreams are the two lines below, and nothing on the machine +# can redirect them. The mesh's own names never reach them: the local= line in +# the file above stops them here, answered or refused. +no-resolv +server=1.1.1.1 +server=8.8.8.8 + +# Both upstreams validate DNSSEC and say so with the Authenticated Data bit, +# and this passes that bit down rather than dropping it, which dnsmasq does +# unless told. It does not validate itself: an answer's trust is the upstream's +# and the path to it, which is what a forwarding resolver's trust always was. +# Without it a program that refuses to run behind a resolver that does not +# validate — a mail system's admin does exactly that check at start — cannot +# use this resolver (novox/hq 04-ISSUES/171). +proxy-dnssec + +# A name without a dot is never forwarded, and reverse lookups of private ranges +# are answered here rather than asking the world who 10.x is. +domain-needed +bogus-priv + +# **No hosts file, and no operator's files.** This machine's /etc/hosts is its +# own — the hosts module's block and the operator's lines (ADR 0199) — and the +# mesh's resolver answers every node, so a line written for one machine's own +# programs must not become an answer for all of them. Every per-node resolver +# went wrong exactly here: a hosts file read once at start, an operator's old +# line beside the mesh's, a drop-in read from a directory nobody owned. +no-hosts diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dnsmasq/files/mesh.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dnsmasq/files/mesh.conf new file mode 100644 index 0000000..96d8d7d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dnsmasq/files/mesh.conf @@ -0,0 +1,14 @@ +# Written by the mesh (module dnsmasq, novox/hq issue 348). Replaced on every push. +# +# The resolver binds its addresses once, at start (bind-interfaces), so it starts only once this +# machine's private address is there: it waits up to 80 seconds for it, and a start that found it +# missing is tried again every ten seconds rather than left failed. +[Unit] +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=0 + +[Service] +ExecStartPre=/usr/bin/sh -c 'for i in $(seq 1 80); do ip -4 -o addr show to ${machine:address} | grep -q . && exit 0; sleep 1; done; echo "${machine:address} is not on this machine" >&2; exit 1' +Restart=on-failure +RestartSec=10s diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dnsmasq/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dnsmasq/module.json.captured new file mode 100644 index 0000000..58cc0da --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker-compose/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker-compose/module.json.captured new file mode 100644 index 0000000..2f576b4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/files/daemon.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/files/daemon.json new file mode 100644 index 0000000..83750f4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/files/daemon.json @@ -0,0 +1 @@ +{"live-restore": true, "insecure-registries": ["${seat:mesh-artifact-store:reach}"], "log-driver": "json-file", "log-opts": {"max-size": "${config:log-max-size}", "max-file": "${config:log-max-file}"}} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.service b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.service new file mode 100644 index 0000000..b9b92c1 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.service @@ -0,0 +1,15 @@ +# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push. +[Unit] +Description=Prune dangling images and unused build cache (the mesh's docker module) +# Never volumes, never a container, never an image a container uses: dangling +# images and build cache nothing refers to, unused for a week. What a person +# prunes beyond that is docker_prune's, by hand. +After=docker.service +ConditionPathExists=/run/docker.sock + +[Service] +Type=oneshot +Nice=19 +IOSchedulingClass=idle +ExecStart=/usr/bin/docker image prune --force --filter until=168h +ExecStart=/usr/bin/docker builder prune --force --filter until=168h diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.timer b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.timer new file mode 100644 index 0000000..82befd1 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.timer @@ -0,0 +1,11 @@ +# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push. +[Unit] +Description=Weekly prune of dangling images and unused build cache (the mesh's docker module) + +[Timer] +OnCalendar=weekly +RandomizedDelaySec=1h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/files/log-rotation.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/files/log-rotation.conf new file mode 100644 index 0000000..3919036 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/files/log-rotation.conf @@ -0,0 +1,5 @@ +# The log rotation daemon.json takes, written by the mesh from this machine's settings log-max-size and +# log-max-file, and judged by the step log-rotation-check before daemon.json is written (novox/hq issue 452). +# Replaced on every push; change the settings, never this file. +size=${config:log-max-size} +files=${config:log-max-file} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/docker/module.json.captured new file mode 100644 index 0000000..a3a75e0 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dunst/files/dunstrc b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dunst/files/dunstrc new file mode 100644 index 0000000..afba676 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dunst/files/dunstrc @@ -0,0 +1,110 @@ +# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced +# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in +# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the +# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on. +# +# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file, +# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it. +# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst +# to read it again (its unit's reload, dunstctl reload); what is on screen stays. + +[global] + monitor = 0 + follow = none + + # Geometry + width = ${config:width} + height = (0, 300) + origin = top-right + offset = (10, 50) + notification_limit = 20 + + progress_bar = true + progress_bar_height = 10 + progress_bar_frame_width = 1 + progress_bar_min_width = 150 + progress_bar_max_width = 300 + + indicate_hidden = yes + transparency = 0 + separator_height = 2 + padding = 8 + horizontal_padding = 8 + text_icon_padding = 0 + frame_width = 3 + frame_color = "#de5200" + gap_size = 0 + separator_color = frame + sort = yes + corner_radius = 0 + + # Text: the interface face (research 026/04) + font = Inter ${config:font-size} + line_height = 0 + markup = full + format = "%s\n%b" + alignment = left + vertical_alignment = center + show_age_threshold = 60 + ellipsize = middle + ignore_newline = no + stack_duplicates = true + hide_duplicate_count = false + show_indicators = yes + + # Icons, from the desktop's icon theme + enable_recursive_icon_lookup = true + icon_theme = Adwaita + icon_position = left + min_icon_size = 32 + max_icon_size = 128 + + # History + sticky_history = yes + history_length = 20 + + # The context menu is rofi (installed by the rofi module); -no-custom keeps typed text from becoming a choice. Not `dmenu`, the node-launcher's command in + # ~/.local/bin: dunst runs in the account's service manager, whose PATH does not hold that + # directory (seen on the laptop on 2026-10-10), so the menu never opened. Links open in the + # desktop's default browser. + dmenu = rofi -dmenu -no-custom -p dunst + browser = /usr/bin/xdg-open + always_run_script = true + + title = Dunst + class = Dunst + ignore_dbusclose = false + + # A tap answers (the touchpads have no middle button). do_action runs the notification's only + # action, or opens the context menu when it has several; without actions it opens its one link, + # if it has one. The notification then closes; a menu dismissed with Escape leaves it shown. + mouse_left_click = do_action, close_current + mouse_middle_click = do_action, close_current + mouse_right_click = close_all + +[urgency_low] + background = "#000000" + foreground = "#ffffff" + timeout = 10 + +[urgency_normal] + background = "#000000" + foreground = "#ffffff" + timeout = 10 + +[urgency_critical] + background = "#000000" + foreground = "#ffffff" + frame_color = "#ff0000" + timeout = 0 + +# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's +# code, category mesh.secret) and any transient one leave no entry in the history, which +# node-notifier.history serves to every caller of the mesh's verbs. +[mesh-secret] + category = "mesh.secret" + history_ignore = yes + +[transient-history-ignore] + match_transient = yes + history_ignore = yes diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dunst/files/i3/50-dunst.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dunst/files/i3/50-dunst.conf new file mode 100644 index 0000000..fec1f8d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dunst/files/i3/50-dunst.conf @@ -0,0 +1,4 @@ +# The notifications' key (module dunst, novox/hq ADR 0208). Owned by the mesh: replaced at every push. +# Super+N opens the menu (rofi) of the actions and links of every notification on screen, so one is +# chosen from the keyboard as a tap chooses it on the notification. +bindsym $mod+n exec --no-startup-id dunstctl context diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dunst/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/dunst/module.json.captured new file mode 100644 index 0000000..0c6983b --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/fail2ban b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/fail2ban new file mode 100644 index 0000000..b5395db --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/fail2ban @@ -0,0 +1,7 @@ +/var/log/fail2ban.log { + missingok + notifempty + postrotate + /usr/bin/fail2ban-client flushlogs >/dev/null || true + endscript +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/iptables-allports-dualchain.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/iptables-allports-dualchain.conf new file mode 100644 index 0000000..2c9fc73 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/iptables-allports-dualchain.conf @@ -0,0 +1,40 @@ +# Fail2Ban action: ban in both INPUT and DOCKER-USER chains +# Used by recidive to block repeat offenders from both host and Docker services + +[INCLUDES] + +before = iptables.conf + +[Definition] + +type = allports + +actionstart = { -C f2b- -j >/dev/null 2>&1; } || { -N f2b- || true; -A f2b- -j ; } + { -C INPUT -p -j f2b- >/dev/null 2>&1; } || { -I INPUT -p -j f2b-; } + { -C DOCKER-USER -p -j f2b- >/dev/null 2>&1; } || { -I DOCKER-USER -p -j f2b-; } + +actionstop = -D INPUT -p -j f2b- 2>/dev/null || true + -D DOCKER-USER -p -j f2b- 2>/dev/null || true + -F f2b- + -X f2b- + +actioncheck = -n -L f2b- >/dev/null + +actionban = -I f2b- 1 -s -j + +actionunban = -D f2b- -s -j + +[Init] + +chain = INPUT +name = default +protocol = tcp +blocktype = REJECT --reject-with icmp-port-unreachable +returntype = RETURN +lockingopt = -w +iptables = iptables + +[Init?family=inet6] + +blocktype = REJECT --reject-with icmp6-port-unreachable +iptables = ip6tables diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/jail.local b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/jail.local new file mode 100644 index 0000000..1fa0ffe --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/jail.local @@ -0,0 +1,48 @@ +[INCLUDES] + +before = paths-arch.conf + +[DEFAULT] + +# Never act on the machine itself or on a tunnel peer: the mesh's private range is +# ${machine:mesh-range}, named here rather than written as a value the module cannot +# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes. +# **A ban list never holds a neighbour.** The mesh's own range is named rather than written +# (novox/hq ADR 0112), and every private range beside it: a source on one is somebody's own +# network, not the internet. On a machine behind a router that reflects local traffic, every +# client in the house arrives as the gateway's address — so one mistyped local request banned +# 192.168.1.1 on the home server and would have cut the whole house off from it (ADR 0186). +# +# **Nor the operator's own addresses** (novox/hq ADR 0339, issue 238): the public addresses the operator names +# as theirs, in the mesh-wide setting own-addresses. An agent polling the forge from the operator's home banned +# that home for four weeks on 2026-10-04, and nothing in the house reached the control-node until it was found +# among 400 bans. The file is written by this module's watch from the setting, one entry per line; fail2ban +# reads it again within a second of a change, so a new value needs no restart. Empty until the operator +# fills the setting. A jail that sets its own ignoreip leaves all of this out; the watch says so. +ignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range} 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 fc00::/7 fe80::/10 file:/var/lib/mesh-fail2ban/own-addresses + +# Three failures in a day ban for a day (novox/hq ADR 0179). The attackers this mesh sees pace +# themselves at one try every ten minutes, under any ten-minute window; a day's window counts +# them, and a day's ban costs a person who mistyped three times once, from one address, while +# the mesh's own range is never banned at all. +bantime = 1d +findtime = 1d +maxretry = 3 + +# Ban through iptables, not through a firewall front-end the machine may not have. ufw is +# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out +# only at ban time: the service reports healthy, the jail counts the attempt, the ban command +# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a +# machine the mesh reported as protected. +# +# The action below is this module's own, already used by the recidive jail on every machine +# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is +# covered too. +banaction = iptables-allports-dualchain +banaction_allports = iptables-allports-dualchain + +[sshd] +enabled = true +port = ssh +logpath = %(sshd_log)s +backend = %(sshd_backend)s diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/mesh-own-addresses.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/mesh-own-addresses.json new file mode 100644 index 0000000..0eb9604 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/mesh-own-addresses.json @@ -0,0 +1,3 @@ +{ + "own-addresses": [] +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/own-addresses b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/own-addresses new file mode 100644 index 0000000..e69de29 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/recidive.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/recidive.conf new file mode 100644 index 0000000..1f8786f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/recidive.conf @@ -0,0 +1,9 @@ +[recidive] +enabled = true +logpath = /var/log/fail2ban.log +# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services) +banaction = iptables-allports-dualchain +# Banned twice in two weeks, by any jail, is banned for four (novox/hq ADR 0179). +bantime = 4w +findtime = 2w +maxretry = 2 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/sshd.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/sshd.conf new file mode 100644 index 0000000..c8a30f2 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/files/sshd.conf @@ -0,0 +1,8 @@ +[sshd] +enabled = true +port = ssh +logpath = %(sshd_log)s +backend = %(sshd_backend)s +maxretry = 3 +findtime = 1d +bantime = 1d diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fail2ban/module.json.captured new file mode 100644 index 0000000..446395f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/feh/files/fehbg b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/feh/files/fehbg new file mode 100755 index 0000000..a3159bb --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/feh/files/fehbg @@ -0,0 +1,6 @@ +#!/bin/sh +# The wallpaper (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. The +# session's start runs it, and so may anything that wants the declared wallpaper back. The image is +# the module's own, in ~/.local/share/feh/wallpapers. feh_set changes the wallpaper for a session +# without touching this file. +feh --no-fehbg --bg-fill "$HOME/.local/share/feh/wallpapers/default.jpg" diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/feh/files/i3/50-feh.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/feh/files/i3/50-feh.conf new file mode 100644 index 0000000..40ac7da --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/feh/files/i3/50-feh.conf @@ -0,0 +1,3 @@ +# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. +# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session. +bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/feh/files/xinitrc-normal b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/feh/files/xinitrc-normal new file mode 100644 index 0000000..8e91e82 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/feh/files/xinitrc-normal @@ -0,0 +1,2 @@ +# The wallpaper (module feh, novox/hq ADR 0208): the declared one, set once per session. +"$HOME/.fehbg" diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/feh/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/feh/module.json.captured new file mode 100644 index 0000000..d6e4407 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/flatpak/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/flatpak/module.json.captured new file mode 100644 index 0000000..76d4721 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fonts/files/50-mesh-fonts.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fonts/files/50-mesh-fonts.conf new file mode 100644 index 0000000..76d0c87 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fonts/files/50-mesh-fonts.conf @@ -0,0 +1,40 @@ + + + + + The mesh: the faces monospace, sans-serif, serif and emoji mean + + + Hack Nerd Fontmonospace + MesloLGS NFmonospace + Iosevka Nerd Fontmonospace + JetBrains Mono Nerd FontJetBrainsMono Nerd Font + + + monospaceJetBrainsMono Nerd Font + sans-serifInterNoto Sans + system-uiInter + serifNoto Serif + emojiNoto Color Emoji + + + + Symbols Nerd Font + Noto Color Emoji + + diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fonts/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/fonts/module.json.captured new file mode 100644 index 0000000..b2958da --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/forticlient/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/forticlient/module.json.captured new file mode 100644 index 0000000..631d3fa --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/agent-teams.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/agent-teams.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/agent-teams.json @@ -0,0 +1 @@ +{} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/consumers.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/consumers.json new file mode 100644 index 0000000..790b51f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/consumers.json @@ -0,0 +1 @@ +${consumers:forge-account} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/origins b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/origins new file mode 100644 index 0000000..6bbfb35 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/origins @@ -0,0 +1 @@ +https://${bound:route:name-web} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/repositories.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/repositories.json new file mode 100644 index 0000000..36626cc --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/repositories.json @@ -0,0 +1 @@ +{"repositories": ${config:repositories}} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/server.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/server.env new file mode 100644 index 0000000..8cb88e2 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/files/server.env @@ -0,0 +1,7 @@ +GITEA__security__INTERNAL_TOKEN=${secret:internal-token} +GITEA__database__DB_TYPE=postgres +GITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port} +GITEA__database__NAME=${bound:postgres-database:as} +GITEA__database__USER=${bound:postgres-database:as} +GITEA__database__PASSWD=${secret:postgres-database} +GITEA__webhook__ALLOWED_HOST_LIST=172.17.0.1 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitea/module.json.captured new file mode 100644 index 0000000..118224c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitlab/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitlab/files/config.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitlab/files/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitlab/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gitlab/module.json.captured new file mode 100644 index 0000000..3cb33fb --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/login b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/login new file mode 100644 index 0000000..47833d9 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/login @@ -0,0 +1,4 @@ +# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): the password typed at the +# login screen unlocks the keyring, and the login session starts the keyring daemon with it. +auth optional pam_gnome_keyring.so +session optional pam_gnome_keyring.so auto_start diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/passwd b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/passwd new file mode 100644 index 0000000..dd43bb8 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/passwd @@ -0,0 +1,3 @@ +# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): changing the account's +# password changes the login keyring's with it, so the next login still unlocks it. +password optional pam_gnome_keyring.so diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gnome-keyring/files/xinitrc-first b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gnome-keyring/files/xinitrc-first new file mode 100644 index 0000000..5fb92b8 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gnome-keyring/files/xinitrc-first @@ -0,0 +1,5 @@ +# The ssh agent (module gnome-keyring, novox/hq ADR 0208): gcr's, which the account's service manager +# starts on first use from its socket. Named here, for the session and every terminal it starts, until +# the account's environment can say a path under the runtime directory (see the module's README). +SSH_AUTH_SOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh" +export SSH_AUTH_SOCK diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gnome-keyring/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/gnome-keyring/module.json.captured new file mode 100644 index 0000000..9f75b9f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/go/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/go/module.json.captured new file mode 100644 index 0000000..48a6bf5 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/admin.secret b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/admin.secret new file mode 100644 index 0000000..51bf424 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/admin.secret @@ -0,0 +1 @@ +${secret:admin} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/config.json new file mode 100644 index 0000000..567df9a --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/config.json @@ -0,0 +1,4 @@ +{ + "user": "admin", + "password": "${secret:admin}" +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/influxdb-api b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/influxdb-api new file mode 100644 index 0000000..8329ab7 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/influxdb-api @@ -0,0 +1 @@ +${secret:influxdb-api} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/mesh-influxdb.yaml b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/mesh-influxdb.yaml new file mode 100644 index 0000000..e341005 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/mesh-influxdb.yaml @@ -0,0 +1,19 @@ +apiVersion: 1 +# Written by the mesh from grafana's influxdb-api binding, only while it is bound (novox/hq ADR 0330): +# without influxdb, grafana runs with no mesh data source. grafana reads it at start. Its own name and +# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because +# the mesh resets it. The password is read from the file the mesh delivers, never written here. +datasources: + - name: InfluxDB (mesh) + uid: mesh-influxdb-api + type: influxdb + access: proxy + url: ${bound:influxdb-api:scheme}://${bound:influxdb-api:at}:${bound:influxdb-api:port} + user: ${bound:influxdb-api:as} + isDefault: false + editable: false + jsonData: + dbName: ${bound:influxdb-api:bucket} + httpMode: POST + secureJsonData: + password: $__file{/run/secrets/influxdb/influxdb-api} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/oidc-client.secret b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/oidc-client.secret new file mode 100644 index 0000000..9146dc1 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/oidc-client.secret @@ -0,0 +1 @@ +${secret:oidc-client} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/oidc.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/oidc.env new file mode 100644 index 0000000..bcc1e9c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/files/oidc.env @@ -0,0 +1,13 @@ +GF_SERVER_ROOT_URL=https://${bound:route:name} +GF_AUTH_GENERIC_OAUTH_ENABLED=true +GF_AUTH_GENERIC_OAUTH_NAME=Keycloak +GF_AUTH_GENERIC_OAUTH_CLIENT_ID=${bound:oidc-client:as} +GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET__FILE=/run/secrets/oidc-client +GF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile roles +GF_AUTH_GENERIC_OAUTH_AUTH_URL=${bound:oidc-client:issuer}${bound:oidc-client:authorization-path} +GF_AUTH_GENERIC_OAUTH_TOKEN_URL=${bound:oidc-client:issuer}${bound:oidc-client:token-path} +GF_AUTH_GENERIC_OAUTH_API_URL=${bound:oidc-client:issuer}${bound:oidc-client:userinfo-path} +GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=contains(roles[*], 'admin') && 'Admin' || contains(realm_access.roles[*], 'admin') && 'Admin' || 'Viewer' +GF_AUTH_GENERIC_OAUTH_USE_PKCE=true +GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true +GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN=true diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/grafana/module.json.captured new file mode 100644 index 0000000..1cbf995 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hello-web/files/index.html b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hello-web/files/index.html new file mode 100644 index 0000000..0d513fd --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hello-web/files/index.html @@ -0,0 +1 @@ +hello from hello-web, routed by the mesh diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hello-web/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hello-web/module.json.captured new file mode 100644 index 0000000..005578c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/home-assistant/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/home-assistant/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/home-assistant/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/home-assistant/files/provisions.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/home-assistant/files/provisions.env new file mode 100644 index 0000000..93aba0c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/home-assistant/files/provisions.env @@ -0,0 +1,4 @@ +MESH_HOMEASSISTANT_URL=http://127.0.0.1:${port:8123} +MESH_HOMEASSISTANT_TOKEN_FILE=${dir:mesh-state}/token +MESH_PROVISIONS_DIR=${dir:state} +MESH_WRITTEN_DIR=${dir:written} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/home-assistant/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/home-assistant/module.json.captured new file mode 100644 index 0000000..9c4f99d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hostname/files/hostname b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hostname/files/hostname new file mode 100644 index 0000000..34c12a4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hostname/files/hostname @@ -0,0 +1 @@ +${config:hostname} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hostname/files/hosts b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hostname/files/hosts new file mode 100644 index 0000000..7c9c073 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hostname/files/hosts @@ -0,0 +1,7 @@ +# The machine's own names (module hostname, novox/hq ADR 0199, ADR 0223). Every line outside this +# block is the operator's: kept across every push, changed through the node-hostname verbs add and +# remove, and given back when this module goes. The mesh's names are not here: the mesh's resolver +# answers them. +127.0.0.1 localhost +::1 localhost +127.0.1.1 ${machine:name} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hostname/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/hostname/module.json.captured new file mode 100644 index 0000000..e84e7be --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/config b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/config new file mode 100644 index 0000000..c49398c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/config @@ -0,0 +1,201 @@ +# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push; +# change the module instead. i3's user guide is the reference. +# +# Other modules add to this configuration as contributions to node-display-session (novox/hq ADR +# 0212): the mesh places their lines near the end, each module's under a line naming it, where every +# variable set here ($mod, $ws1 … $ws10) is in scope. A file of yours in ~/.config/i3/config.d/ is read +# after them, by the include at the very end, and is yours. +# +# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the +# result with i3 -C; it never reloads into a configuration with errors. + +# Font for window titles, and the bars below: the mesh's monospace face (research 026/04). +font pango:JetBrainsMono Nerd Font 11 + +# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login. +exec --no-startup-id dex --autostart --environment i3 + +######################################### +###### Keys #### +######################################### +# To find key symbols: xmodmap -pke / xmodmap -pm +set $mod Mod4 +set $alt Mod1 +set $shift Shift +set $ctrl Control + +# use these keys for focus, movement, and resize directions when reaching for +# the arrows is not convenient +set $left h +set $down j +set $up k +set $right l + +# use Mouse+$mod to drag floating windows to their wanted position +floating_modifier $mod + +# move tiling windows via drag & drop by left-clicking into the title bar, +# or left-clicking anywhere into the window while holding the floating modifier. +tiling_drag modifier titlebar + +# start a terminal: whichever the terminal module names in $TERMINAL +bindsym $mod+Return exec i3-sensible-terminal + +# kill focused window +bindsym $mod+$shift+q kill + +# change focus +bindsym $mod+$left focus left +bindsym $mod+$down focus down +bindsym $mod+$up focus up +bindsym $mod+$right focus right + +bindsym $mod+Left focus left +bindsym $mod+Down focus down +bindsym $mod+Up focus up +bindsym $mod+Right focus right + +# move focused window +bindsym $mod+$shift+$left move left +bindsym $mod+$shift+$down move down +bindsym $mod+$shift+$up move up +bindsym $mod+$shift+$right move right + +bindsym $mod+$shift+Left move left +bindsym $mod+$shift+Down move down +bindsym $mod+$shift+Up move up +bindsym $mod+$shift+Right move right + +# split in horizontal orientation +bindsym $mod+c split h +# split in vertical orientation +bindsym $mod+v split v + +# enter fullscreen mode for the focused container +bindsym $mod+f fullscreen toggle + +# change container layout (stacked, tabbed, toggle split) +bindsym $mod+s layout stacking +bindsym $mod+w layout tabbed +bindsym $mod+e layout toggle split + +# toggle tiling / floating +bindsym $mod+$shift+space floating toggle + +# change focus between tiling / floating windows +bindsym $mod+space focus mode_toggle + +# focus the parent container +bindsym $mod+a focus parent + +# alt-tab functionality +bindsym $mod+Tab workspace back_and_forth + +######################################### +###### Workspace mgmt #### +######################################### +set $ws1 "1" +set $ws2 "2" +set $ws3 "3" +set $ws4 "4" +set $ws5 "5" +set $ws6 "6" +set $ws7 "7" +set $ws8 "8" +set $ws9 "9" +set $ws10 "10" + +bindsym $mod+1 workspace number $ws1 +bindsym $mod+2 workspace number $ws2 +bindsym $mod+3 workspace number $ws3 +bindsym $mod+4 workspace number $ws4 +bindsym $mod+5 workspace number $ws5 +bindsym $mod+6 workspace number $ws6 +bindsym $mod+7 workspace number $ws7 +bindsym $mod+8 workspace number $ws8 +bindsym $mod+9 workspace number $ws9 +bindsym $mod+0 workspace number $ws10 + +bindsym $mod+$shift+1 move container to workspace number $ws1 +bindsym $mod+$shift+2 move container to workspace number $ws2 +bindsym $mod+$shift+3 move container to workspace number $ws3 +bindsym $mod+$shift+4 move container to workspace number $ws4 +bindsym $mod+$shift+5 move container to workspace number $ws5 +bindsym $mod+$shift+6 move container to workspace number $ws6 +bindsym $mod+$shift+7 move container to workspace number $ws7 +bindsym $mod+$shift+8 move container to workspace number $ws8 +bindsym $mod+$shift+9 move container to workspace number $ws9 +bindsym $mod+$shift+0 move container to workspace number $ws10 + +######################################### +###### Window mgmt #### +######################################### +set $resize_px 10 px +bindsym $mod+$ctrl+$left resize shrink width $resize_px +bindsym $mod+$ctrl+$down resize grow height $resize_px +bindsym $mod+$ctrl+$up resize shrink height $resize_px +bindsym $mod+$ctrl+$right resize grow width $resize_px + +######################################### +###### Session mgmt #### +######################################### +bindsym $mod+$shift+e exec "i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'" + +# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and +# suspend and lid close too). +bindsym $mod+Delete exec --no-startup-id loginctl lock-session + +# reload the configuration file +bindsym $mod+$shift+c reload + +# restart i3 inplace (preserves your layout/session, can be used to upgrade i3) +bindsym $mod+$shift+r restart + +######################################### +###### Borders #### +######################################### +default_border pixel 1 +smart_borders on + +######################################### +###### Gaps #### +######################################### +gaps inner 0 +gaps outer 0 + +# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are +# `pixel`, so no title bar shows: the colour says which window has focus. +# border background text indicator child_border +client.focused #de5200 #de5200 #1E2127 #de5200 #de5200 +client.urgent #900000 #900000 #ffffff #900000 #900000 + +######################################### +###### Until their modules carry them ## +######################################### +# Each line below belongs to something other than i3, named on its line. When that module is written +# it contributes the line to node-display-session, and the line goes from here in the same change. +# The launcher, the clipboard, the wallpaper, the bars, a machine model's keys and the peripherals' +# tray already contribute theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14, +# polychromatic). + +# the operator's scripts: volume, games volume, sessions, screenshot +bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up +bindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down +bindsym XF86AudioMute exec --no-startup-id volume-notify mute +bindsym XF86AudioMicMute exec --no-startup-id mic-notify +bindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5 +bindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5 +bindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh +bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh + +######################################### +###### Other modules' lines #### +######################################### +# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the +# clipboard, the wallpaper, the bars, a machine model's keys, the peripherals' tray. Each module's +# under a line naming it. +${contribution:node-display-session:config} +######################################### +###### Your own files #### +######################################### +include ~/.config/i3/config.d/*.conf diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-current-desktop b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-current-desktop new file mode 100644 index 0000000..04ad29f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-current-desktop @@ -0,0 +1 @@ +i3 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-session-desktop b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-session-desktop new file mode 100644 index 0000000..04ad29f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-session-desktop @@ -0,0 +1 @@ +i3 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/wms/i3 b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/wms/i3 new file mode 100755 index 0000000..4e269ad --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/wms/i3 @@ -0,0 +1,9 @@ +#!/bin/sh +# The i3 session (module i3, novox/hq ADR 0208), offered by the login manager. It runs the session's +# start, ~/.xinitrc, which the display server's module writes and which ends by starting i3. +# Replaced at every push. +# +# The login manager hands the session a stdout and stderr whose reading end nobody holds, so a +# program that writes to them dies of EPIPE (an Electron tray app shows it as an error dialog). +# The session's output goes to the journal instead, under `journalctl -t x-session`. +exec systemd-cat -t x-session /bin/sh "$HOME/.xinitrc" diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/xinitrc-last b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/xinitrc-last new file mode 100644 index 0000000..e84ff3d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/files/xinitrc-last @@ -0,0 +1,2 @@ +# The session: i3, with 25 MiB of debug log kept in memory for i3-dump-log. +exec i3 --shmlog-size=26214400 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3/module.json.captured new file mode 100644 index 0000000..1ec9242 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3bar-watchdog b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3bar-watchdog new file mode 100755 index 0000000..63b2b02 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3bar-watchdog @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died. +# +# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not +# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the +# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the +# cause, which is i3bar's. +# +# Started once per session from the session's start, with the session's own pid; it ends when that +# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04. +set -uo pipefail + +session_pid="${1:-}" +interval="${I3_BAR_WATCHDOG_INTERVAL:-5}" +# Two misses in a row before acting: an i3 restart tears every bar down and starts them again. +confirm="${I3_BAR_WATCHDOG_CONFIRM:-2}" +# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop. +cooldown="${I3_BAR_WATCHDOG_COOLDOWN:-30}" + +log() { printf 'i3bar-watchdog: %s\n' "$*" >&2; } + +for tool in i3-msg i3bar pgrep; do + command -v "$tool" >/dev/null 2>&1 || { + log "$tool is missing; not watching" + exit 1 + } +done + +declare -A missed=() fixed=() + +bar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '"[^"]+"' | tr -d '"'; } +outputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2" "$3}' | tr '\n' ' '; } +session_alive() { [ -z "$session_pid" ] || kill -0 "$session_pid" 2>/dev/null; } + +while session_alive; do + sleep "$interval" + socket="$(i3 --get-socketpath 2>/dev/null)" + [ -n "$socket" ] && [ -S "$socket" ] || continue + ids="$(bar_ids)" + [ -n "$ids" ] || continue + while read -r id; do + [ -n "$id" ] || continue + if pgrep -u "$EUID" -f -- "i3bar --bar_id=$id" >/dev/null 2>&1; then + missed[$id]=0 + continue + fi + missed[$id]=$((${missed[$id]:-0} + 1)) + [ "${missed[$id]}" -ge "$confirm" ] || continue + now="$(date +%s)" + if [ $((now - ${fixed[$id]:-0})) -lt "$cooldown" ]; then + continue + fi + log "$id is gone; respawning it. Outputs now: $(outputs)" + nohup i3bar --bar_id="$id" --socket="$socket" >/dev/null 2>&1 & + disown 2>/dev/null || true + fixed[$id]="$now" + sleep 2 + if pgrep -u "$EUID" -f -- "i3bar --bar_id=$id" >/dev/null 2>&1; then + missed[$id]=0 + else + log "$id exited within 2s of respawning; check its status_command" + fi + done <<<"$ids" +done diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-updates b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-updates new file mode 100755 index 0000000..1a70cf9 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-updates @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# i3status-updates [threshold] (module i3status-rust, novox/hq ADR 0208): the pending updates, for +# the bottom bar's custom block (json = true). Emits one JSON line. +# +# Adopted from the operator's pkg-updates of 2026-10-04. The native `packages` block showed a raw +# red error on every transient network failure, which on a roaming laptop is often. This keeps the +# last good result through a failure and shows an error only after `threshold` failures in a row +# (default 10). Official packages through checkupdates (pacman-contrib, a temporary database, no +# root); AUR packages through paru when it is installed, and none counted when it is not. +set -uo pipefail + +threshold="${1:-10}" +icon="update" +state_dir="${XDG_RUNTIME_DIR:-/tmp}/i3status-updates" +mkdir -p "$state_dir" +last="$state_dir/last_good.json" +failures="$state_dir/failures" + +json() { printf '{"icon":"%s","state":"%s","text":"%s","short_text":"%s"}' "$icon" "$1" "$2" "$3"; } +count() { [ -n "$1" ] && printf '%s\n' "$1" | grep -c . || echo 0; } + +official="$(checkupdates 2>/dev/null)" +official_rc=$? +aur="" aur_rc=0 +if command -v paru >/dev/null 2>&1; then + aur="$(paru -Qua 2>/dev/null)" + aur_rc=$? +fi + +# checkupdates: 0 updates listed, 2 none pending; anything else is a failure. +if { [ "$official_rc" -eq 0 ] || [ "$official_rc" -eq 2 ]; } && [ "$aur_rc" -eq 0 ]; then + o="$(count "$official")" + a="$(count "$aur")" + total=$((o + a)) + if [ "$total" -eq 0 ]; then + payload="$(json Good "up to date" "")" + else + # A kernel update wants a reboot: the one worth not putting off. + state=Info + printf '%s\n' "$official" | grep -qE '^(linux|linux-lts|linux-zen) ' && state=Warning + label="$o + $a = $total updates" + [ "$a" -eq 0 ] && label="$total updates" + [ "$total" -eq 1 ] && label="1 update" + payload="$(json "$state" "$label" "$total")" + fi + printf '%s' "$payload" >"$last" + printf '0' >"$failures" + printf '%s\n' "$payload" + exit 0 +fi + +n=$(($(cat "$failures" 2>/dev/null || echo 0) + 1)) +printf '%s' "$n" >"$failures" +if [ "$n" -ge "$threshold" ]; then + json Critical "update check failing (${n}x)" "!" + echo +elif [ -s "$last" ]; then + cat "$last" + echo +else + json Idle "" "" + echo +fi diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-value b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-value new file mode 100755 index 0000000..8fea95e --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-value @@ -0,0 +1,13 @@ +#!/bin/sh +# i3status-value (module i3status-rust, novox/hq ADR 0260): one value another module keeps +# on the bus, for a block it contributed. +# +# This module's own tools bundle watches the state on the bus (the mesh grants it the read) and leaves +# the value's text in this module's own directory; this prints it. A value not refreshed for thirty +# seconds is one its module stopped giving, and prints nothing, so the block hides rather than show an +# old number as if it were now. +dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/i3status-rust/values" +f="$dir/$1@$2" +if [ -r "$f" ] && [ $(( $(date +%s) - $(stat -c %Y "$f") )) -le 30 ]; then + head -n 1 "$f" +fi diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/bottom-bar.toml b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/bottom-bar.toml new file mode 100644 index 0000000..12e6805 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/bottom-bar.toml @@ -0,0 +1,109 @@ +# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every +# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one +# person's devices left out: a block that depends on the hardware is contributed by the module that +# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written +# here works the same on any machine. +icons_format = "{icon}" + +[theme] +theme = "plain" +[theme.overrides] +idle_bg = "#000000" +idle_fg = "#f1f1f1" +info_bg = "#000000" +info_fg = "#f1f1f1" +good_bg = "#000000" +good_fg = "#859900" +warning_bg = "#000000" +warning_fg = "#de5200" +critical_bg = "#000000" +critical_fg = "#ff0000" +separator = " | " +separator_fg = "#ffffff" +separator_bg = "#000000" + +[icons] +icons = "custom-icons" + +[[block]] +block = "tea_timer" +format = "$icon{ $minutes:$seconds|}" +done_cmd = "notify-send 'Timer finished'" + +# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good +# count through a network gap and shows an error only after ten failed checks in a row. Click: the +# list, in the launcher's menu. +[[block]] +block = "custom" +command = "~/.local/bin/i3status-updates 10" +json = true +interval = 1800 +[[block.click]] +button = "left" +cmd = "checkupdates | dmenu -l 20 -p Updates" + +[[block]] +block = "cpu" + +[[block]] +block = "disk_space" +path = "/" +info_type = "used" +alert_unit = "GB" +interval = 20 +warning = 850 +alert = 920 +format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))" + +[[block]] +block = "disk_space" +path = "/home" +info_type = "used" +alert_unit = "GB" +interval = 20 +warning = 850 +alert = 920 +format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))" + +[[block]] +block = "memory" +# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure +# module judges by. Counting the cache as used read near full on a machine with room to spare. +format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))" +format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))" + +# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from +# what they show by this module's template, and only on a machine that has what they show. +${collected:contribution:node-bar:block:bar=bottom,place=resources} +[[block]] +block = "docker" +interval = 2 +format = "$icon $running/$total" + +[[block]] +block = "sound" +[[block.click]] +button = "left" +cmd = "pavucontrol" + +# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and +# power draw, where the machine has them. +${collected:contribution:node-bar:block:bar=bottom,place=status} +# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is. +[[block]] +block = "weather" +format = "$icon $weather_verbose ($location) $temp" +autolocate = true +autolocate_interval = 600 +[block.service] +name = "metno" + +[[block]] +block = "notify" +driver = "dunst" +format = " $icon {($notification_count.eng(w:1))|}" + +[[block]] +block = "time" +interval = 1 +format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')" diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/i3/60-i3status-rust.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/i3/60-i3status-rust.conf new file mode 100644 index 0000000..20acbb1 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/i3/60-i3status-rust.conf @@ -0,0 +1,34 @@ +# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push. +# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom +# bar shows the machine; the top bar the focused window and the tray, on the primary output. The +# face is the monospace one every desktop module names. +bar { + font pango:JetBrainsMono Nerd Font 11 + position bottom + status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml + tray_output none + colors { + separator #ffffff + background #000000 + statusline #ffffff + # The text on an accent-coloured button is dark: light text on the accent was barely + # legible. + focused_workspace #de5200 #de5200 #000000 + active_workspace #de5200 #de5200 #000000 + inactive_workspace #000000 #000000 #ffffff + urgent_workspace #2f343a #900000 #ffffff + } +} + +bar { + font pango:JetBrainsMono Nerd Font 11 + position top + status_command i3status-rs ~/.config/i3status-rust/top-bar.toml + workspace_buttons no + tray_output primary + colors { + separator #ffffff + background #000000 + statusline #ffffff + } +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/icons/custom-icons.toml b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/icons/custom-icons.toml new file mode 100644 index 0000000..9636a82 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/icons/custom-icons.toml @@ -0,0 +1,135 @@ +# Icons for the bars (module i3status-rust, novox/hq ADR 0208), from the JetBrains Mono Nerd Font. +# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04. +# Material from NerdFont +# Codepoints from the Nerd Fonts cheat sheet +backlight = [ + "\ue38d", # nf-weather-moon_new + "\ue3d4", # nf-weather-moon_alt_waxing_gibbous_6 + "\ue3d3", # nf-weather-moon_alt_waxing_gibbous_5 + "\ue3d2", # nf-weather-moon_alt_waxing_gibbous_4 + "\ue3d1", # nf-weather-moon_alt_waxing_gibbous_3 + "\ue3d0", # nf-weather-moon_alt_waxing_gibbous_2 + "\ue3cf", # nf-weather-moon_alt_waxing_gibbous_1 + "\ue3ce", # nf-weather-moon_alt_first_quarter + "\ue3cd", # nf-weather-moon_alt_waxing_crescent_6 + "\ue3cc", # nf-weather-moon_alt_waxing_crescent_5 + "\ue3cb", # nf-weather-moon_alt_waxing_crescent_4 + "\ue3ca", # nf-weather-moon_alt_waxing_crescent_3 + "\ue3c9", # nf-weather-moon_alt_waxing_crescent_2 + "\ue3c8", # nf-weather-moon_alt_waxing_crescent_1 + "\ue39b", # nf-weather-moon_full +] +bat_charging = "\U000f0084" # nf-md-battery_charging +bat_not_available = "\U000f0091" # nf-md-battery_unknown +bat = [ + "\U000f007a", # nf-md-battery_10 + "\U000f007b", # nf-md-battery_20 + "\U000f007c", # nf-md-battery_30 + "\U000f007d", # nf-md-battery_40 + "\U000f007e", # nf-md-battery_50 + "\U000f007f", # nf-md-battery_60 + "\U000f0080", # nf-md-battery_70 + "\U000f0081", # nf-md-battery_80 + "\U000f0082", # nf-md-battery_90 + "\U000f0079", # nf-md-battery +] +bell = "\U000f009c" # nf-md-bell_outline +bell-slash = "\U000f009b" # nf-md-bell_off +blackberry = "\uf307" # nf-md-blackberry +bluetooth = "\U000f00af" # nf-md-bluetooth +calendar = "\U000f00ed" # nf-md-calendar +cogs = "\U000f0493" # nf-md-cog +cpu = [ + "\U000F0F86", # nf-md-speedometer_slow + "\U000F0F85", # nf-md-speedometer_medium + "\U000F04C5", # nf-md-speedometer +] +cpu_boost_on = "\U000f0521" # nf-md-toggle_switch +cpu_boost_off = "\U000f0a19" # nf-md-toggle_switch_off_outline +disk_drive = "\U000f02ca" # nf-md-harddisk +docker = "\uf308" # nf-linux-docker +github = "\U000f02a4" # nf-md-github +gpu = "\U000f0379" # nf-md-monitor +headphones = "\U000f02cb" # nf-md-headphones +joystick = "\U000f0297" # nf-md-gamepad_variant +keyboard = "\U000f030c" # nf-md-keyboard +mail = "\U000f01ee" # nf-md-email +memory_mem = "\U000f035b" # nf-md-memory +memory_swap = "\U000f02ca" # nf-md-harddisk +mouse = "\U000f037d" # nf-md-mouse +music = "\U000f075a" # nf-md-music +music_next = "\U000f04ad" # nf-md-skip_next +music_pause = "\U000f03e4" # nf-md-pause +music_play = "\U000f040a" # nf-md-play +music_prev = "\U000f04ae" # nf-md-skip_previous +net_bridge = "\U000f04aa" # nf-md-sitemap +net_down = "\U000f01da" # nf-md-download +net_loopback = "\U000f006f" # nf-md-backup_restore +net_modem = "\U000f03f2" # nf-md-phone +net_cellular = [ + "\U000F08FD", # nf-md-network_strength_off_outline + "\U000F08FE", # nf-md-network_strength_outline + "\U000F08F4", # nf-md-network_strength_1 + "\U000F08F6", # nf-md-network_strength_2 + "\U000F08F8", # nf-md-network_strength_3 + "\U000F08FA", # nf-md-network_strength_4 +] +net_up = "\U000f0552" # nf-md-upload +net_vpn = "\U000f0582" # nf-md-vpn +net_wired = "\U000f0200" # nf-md-ethernet +net_wireless = [ + "\U000F092F", # nf-md-wifi_strength_outline + "\U000F091F", # nf-md-wifi_strength_1 + "\U000F0922", # nf-md-wifi_strength_2 + "\U000F0925", # nf-md-wifi_strength_3 + "\U000F0928", # nf-md-wifi_strength_4 +] +notification = "\U000f009c" # nf-md-bell_outline +phone = "\U000f03f2" # nf-md-phone +phone_disconnected = "\U000f0658" # nf-md-phone_minus +ping = "\U000f051f" # nf-md-timer_sand +pomodoro = "\ue001" # nf-pom-pomodoro_done +pomodoro_break = "\U000f0176" # nf-md-coffee +pomodoro_paused = "\U000f03e4" # nf-md-pause +pomodoro_started = "\U000f040a" # nf-md-play +pomodoro_stopped = "\U000f04db" # nf-md-stop +resolution = "\U000f0293" # nf-md-fullscreen +tasks = "\U000f05c7" # nf-md-playlist_check +tea = "\U000f0d9e" # nf-md-tea +thermometer = [ + "\U000f10c3", # nf-md-thermometer_low + "\U000f050f", # nf-md-thermometer + "\U000f10c2", # nf-md-thermometer_high +] +time = "\U000f0150" # nf-md-clock_outline +toggle_off = "\U000f0a19" # nf-md-toggle_switch_off_outline +toggle_on = "\U000f0521" # nf-md-toggle_switch +unknown = "\U000f0186" # nf-md-comment_question_outline | TODO: Make default? +update = "\U000f03d5" # nf-md-package_up +uptime = "\U000f0153" # nf-md-clock_in +volume_muted = "\U000f075f" # nf-md-volume_mute +volume = [ + "\U000f057f", # nf-md-volume_low + "\U000f0580", # nf-md-volume_medium + "\U000f057e", # nf-md-volume_high +] +microphone_muted = "\U000f036d" # nf-md-microphone_off +microphone = [ + "\U000f036e", # nf-md-microphone_outline + "\U000f036c", # nf-md-microphone + "\U000f036c", # nf-md-microphone +] +xrandr = "\U000f037a" # nf-md-monitor_multiple + +# Weather icons (the names the weather block documents) +weather_sun = "\ue30d" # nf-weather-day_sunny (when weather is reported as “Clear” during the day) +weather_moon = "\ue32b" # nf-weather-night_clear (when weather is reported as “Clear” at night) +weather_clouds = "\ue312" # nf-weather-cloudy (when weather is reported as “Clouds” during the day) +weather_clouds_night = "\ue37e" # nf-weather-night_alt_cloudy (when weather is reported as “Clouds” at night) +weather_fog = "\ue313" # nf-weather-fog (when weather is reported as “Fog” or “Mist” during the day) +weather_fog_night = "\ue346" # nf-weather-night_fog (when weather is reported as “Fog” or “Mist” at night) +weather_rain = "\ue318" # nf-weather-rain (when weather is reported as “Rain” or “Drizzle” during the day) +weather_rain_night = "\ue325" # nf-weather-night_alt_rain (when weather is reported as “Rain” or “Drizzle” at night) +weather_snow = "\ue31a" # nf-weather-snow (when weather is reported as “Snow”) +weather_thunder = "\ue31d" # nf-weather-thunderstorm (when weather is reported as “Thunderstorm” during the day) +weather_thunder_night = "\ue32a" # nf-weather-night_alt_thunderstorm (when weather is reported as “Thunderstorm” at night) diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/top-bar.toml b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/top-bar.toml new file mode 100644 index 0000000..be198e8 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/top-bar.toml @@ -0,0 +1,37 @@ +# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime. +# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04. +icons_format = "{icon}" + +[theme] +theme = "plain" +[theme.overrides] +idle_bg = "#000000" +idle_fg = "#f1f1f1" +info_bg = "#000000" +info_fg = "#f1f1f1" +good_bg = "#000000" +good_fg = "#859900" +warning_bg = "#000000" +warning_fg = "#de5200" +critical_bg = "#000000" +critical_fg = "#ff0000" +separator = " | " +separator_fg = "#ffffff" +separator_bg = "#000000" + +[icons] +icons = "custom-icons" + +[[block]] +block = "focused_window" +[block.format] +full = " $title.str(max_w:50) |" +short = " $title.str(max_w:100) |" + +[[block]] +block = "uptime" +interval = 60 + +# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept +# so that one that does is never dropped. +${collected:contribution:node-bar:block:bar=top,place=resources}${collected:contribution:node-bar:block:bar=top,place=status} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/xinitrc-normal b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/xinitrc-normal new file mode 100644 index 0000000..888acdb --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/files/xinitrc-normal @@ -0,0 +1,3 @@ +# The bar watchdog (module i3status-rust, novox/hq ADR 0208): i3 never restarts a bar that dies; this +# brings it back. Once per session, ending with the session's own process ($$). +"$HOME/.local/bin/i3bar-watchdog" "$$" & diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/i3status-rust/module.json.captured new file mode 100644 index 0000000..1e07556 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/icecast/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/icecast/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/icecast/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/icecast/files/icecast.xml b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/icecast/files/icecast.xml new file mode 100644 index 0000000..de4dd79 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/icecast/files/icecast.xml @@ -0,0 +1,56 @@ + + + Earth + icemaster@localhost + + 100 + 2 + 524288 + 30 + 15 + 10 + 1 + 65535 + + + ${secret:source} + ${secret:relay} + admin + ${secret:admin} + + + localhost + + 8000 + + +
+ + 1 + + /usr/share/icecast + /var/log/icecast + /usr/share/icecast/web + /usr/share/icecast/admin + + + + access.log + error.log + 3 + 10000 + + + 0 + + + icecast + icecast + + + diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/icecast/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/icecast/module.json.captured new file mode 100644 index 0000000..6f9ab54 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/influxdb/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/influxdb/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/influxdb/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/influxdb/files/server.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/influxdb/files/server.env new file mode 100644 index 0000000..d2552e0 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/influxdb/files/server.env @@ -0,0 +1,6 @@ +DOCKER_INFLUXDB_INIT_MODE=setup +DOCKER_INFLUXDB_INIT_USERNAME=admin +DOCKER_INFLUXDB_INIT_PASSWORD_FILE=/run/secrets/admin +DOCKER_INFLUXDB_INIT_ADMIN_TOKEN_FILE=/run/secrets/admin-token +DOCKER_INFLUXDB_INIT_ORG=mesh +DOCKER_INFLUXDB_INIT_BUCKET=default diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/influxdb/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/influxdb/module.json.captured new file mode 100644 index 0000000..53389a5 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/invoicing/files/api.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/invoicing/files/api.env new file mode 100644 index 0000000..8eaee6f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/invoicing/files/api.env @@ -0,0 +1,9 @@ +NODE_ENV=production +PORT=9000 +MONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as} +MONGO_DB=${bound:mongodb-database:as} +MINIO_BUCKET=${bound:s3-bucket:bucket} +MINIO_ENDPOINT=${bound:s3-bucket:at} +MINIO_PORT=${bound:s3-bucket:port} +MINIO_ACCESSKEY=${bound:s3-bucket:as} +MINIO_SECRET=${secret:s3-bucket} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/invoicing/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/invoicing/module.json.captured new file mode 100644 index 0000000..29670a3 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/jira/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/jira/files/config.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/jira/files/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/jira/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/jira/module.json.captured new file mode 100644 index 0000000..9f98dd8 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/kernel/files/huge-pages.sh b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/kernel/files/huge-pages.sh new file mode 100644 index 0000000..e0a9815 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/kernel/files/huge-pages.sh @@ -0,0 +1,84 @@ +#!/bin/sh +# Transparent huge pages as the mesh sets them on this machine (module kernel, novox/hq issue 746). +# Written by the mesh from this machine's setting transparent-huge-pages and run by +# mesh-kernel-huge-pages.service: `apply` at boot and whenever the setting changes, `restore` when the +# unit stops (the module unassigned, or the machine shutting down). Replaced on every push; change the +# setting, never this file. A change of the setting reloads the unit, which runs `apply` again: the mode +# moves straight to the new value and never passes back through the one the machine was found with. +# +# huge-pages apply|restore [ []] +# +# The two directories are arguments only so the module's tests can run this against a stand-in tree. +set -eu + +want='${config:transparent-huge-pages}' +sys=/sys/kernel/mm/transparent_hugepage +state='${dir:state}' +[ $# -ge 2 ] && sys=$2 +[ $# -ge 3 ] && state=$3 + +case "$want" in +always | madvise | never) ;; +*) + echo "the setting transparent-huge-pages is '$want'; the kernel takes always, madvise or never" >&2 + exit 2 + ;; +esac +[ -f "$sys/enabled" ] || { + echo "this kernel has no $sys/enabled: it was built without transparent huge pages, so there is nothing to set" >&2 + exit 1 +} + +# The mode in force is the word the kernel brackets: "always [madvise] never". A file holding one bare +# word (the stand-in tree of the tests) is read as that word. +mode() { sed -n -e 's/.*\[\([a-z]*\)\].*/\1/p' -e t -e 's/^ *\([a-z]*\) *$/\1/p' "$sys/enabled"; } + +[ $# -ge 1 ] || set -- usage +case "$1" in +apply) + now=$(mode) + # What the machine had before the mesh first set it, kept once, so `restore` gives it back. + [ -s "$state/found" ] || printf '%s\n' "$now" >"$state/found" + if [ "$now" = "$want" ]; then + echo "transparent huge pages: $want, as set" + exit 0 + fi + printf '%s\n' "$want" >"$sys/enabled" + got=$(mode) + [ "$got" = "$want" ] || { + echo "wrote $want to $sys/enabled and the kernel says $got" >&2 + exit 1 + } + echo "transparent huge pages: $now -> $want" + # A file folio already in the page cache keeps its size: the active journal files, mapped by journald + # and by every reader, stay in 2 MiB folios until they are archived (issue 746). Rotating starts new + # files that are mapped under the new mode. A failed rotation is said and does not fail the setting. + # At boot nothing is in huge folios yet, and the persistent journal opens only after this unit: no rotation + # while the service manager is still bringing the machine up. + booting=$(systemctl is-system-running 2>/dev/null || true) + case "$booting" in + initializing | starting) echo "the machine is still starting: nothing to rotate yet" ;; + *) + if [ "$want" != always ] && command -v journalctl >/dev/null 2>&1; then + journalctl --rotate || echo "the journal could not be rotated: its active files keep their huge pages until they are archived" >&2 + fi + ;; + esac + ;; +restore) + found=$(cat "$state/found" 2>/dev/null || true) + case "$found" in + always | madvise | never) + printf '%s\n' "$found" >"$sys/enabled" + echo "transparent huge pages: back to $found, as the machine had them" + ;; + *) echo "transparent huge pages: nothing recorded as found, left at $(mode)" ;; + esac + # Unassigned or shut down: nothing of the module is kept; the next start records what it finds again. + rm -f "$state/found" + ;; +*) + echo "usage: huge-pages apply|restore [ []]" >&2 + exit 2 + ;; +esac diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-huge-pages.service b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-huge-pages.service new file mode 100644 index 0000000..77a9059 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-huge-pages.service @@ -0,0 +1,25 @@ +[Unit] +Description=Transparent huge pages as the mesh sets them on this machine +# Module kernel, novox/hq issue 746. Starting this unit sets the mode from the setting +# transparent-huge-pages; reloading it (the setting changed) sets the new mode; stopping it gives back the +# mode the machine had before. It runs early in boot, before the services that fill the page cache. +DefaultDependencies=no +After=local-fs.target +Before=sysinit.target shutdown.target +Conflicts=shutdown.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=${dir:state}/huge-pages apply +ExecReload=${dir:state}/huge-pages apply +ExecStop=${dir:state}/huge-pages restore +# It writes one file under /sys and its own state, and asks the journal daemon to rotate over its socket. +ProtectSystem=strict +ReadWritePaths=${dir:state} +ProtectHome=yes +PrivateNetwork=yes +NoNewPrivileges=yes + +[Install] +WantedBy=sysinit.target diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/kernel/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/kernel/module.json.captured new file mode 100644 index 0000000..67d3215 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/admin.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/admin.env new file mode 100644 index 0000000..3e52ae5 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/admin.env @@ -0,0 +1,2 @@ +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=${secret:admin} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/consumers.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/consumers.json new file mode 100644 index 0000000..b0af957 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/consumers.json @@ -0,0 +1 @@ +${consumers:oidc-client} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/database.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/database.env new file mode 100644 index 0000000..454ee99 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/database.env @@ -0,0 +1,3 @@ +KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} +KC_DB_USERNAME=${bound:postgres-database:as} +KC_DB_PASSWORD=${secret:postgres-database} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/hostname.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/hostname.env new file mode 100644 index 0000000..92f061a --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/files/hostname.env @@ -0,0 +1 @@ +KC_HOSTNAME=https://${bound:route:name} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/keycloak/module.json.captured new file mode 100644 index 0000000..847d5f4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lab/files/lab.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lab/files/lab.env new file mode 100644 index 0000000..35aa1a2 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lab/files/lab.env @@ -0,0 +1,3 @@ +MESH_LAB_FORGE=${config:forge} +MESH_LAB_FORGE_BINDING=${dir:state}/package-registry.json +MESH_LAB_FORGE_SECRET=${dir:state}/package-registry.secret diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lab/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lab/module.json.captured new file mode 100644 index 0000000..ef82b87 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lemurs/files/config.toml b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lemurs/files/config.toml new file mode 100644 index 0000000..6e5667d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lemurs/files/config.toml @@ -0,0 +1,369 @@ +# The login manager's configuration, written by the mesh (module lemurs, novox/hq ADR 0208). Replaced +# at every push; change the module instead. The structure is lemurs 0.4's own (the shipped file, with +# every option, as lemurs requires); what the mesh changes from it is marked "mesh:". +# +# The sessions offered are the executable files other modules place in the two scripts directories +# below (/etc/lemurs/wms for X, /etc/lemurs/wayland for Wayland), one per session module. A file there +# is named as the session is offered. Desktop entries that packages install (/usr/share/xsessions) are +# not offered: they start the window manager bare, skipping the session's start (~/.xinitrc), which is +# where the account's environment, the X resources and every module's session lines are. +# +# Lemurs configuration file. +# Contains all the customization options of lemurs. +# +# Note: that as of now you need to have all options in the selected +# configuration file. Otherwise Lemurs will not work. +# +# Colors: +# --------- +# There is a list of predefined colors. These include: +# - black +# - white +# - (dark) gray +# - (light) red +# - (light) blue +# - (light) green +# - (light) magenta +# - (light) cyan +# - (light) yellow +# - orange +# +# You can also utilize custom colors with hex color codes. +# "#87CEEB" will create a Sky Blue color. +# +# Note: If the color wasn't recognized, it will default to white. +# --------- +# +# Modifiers: +# --------- +# There is a number of modifiers you can use. These can be combined by +# delimiting them with a comma (e.g. "bold,italic"). The modifiers are: +# - bold +# - dim +# - italic +# - underlined +# - reverse +# - crossed out +# - hidden +# --------- +# + +# The tty which contains lemurs. This has to be mirrored in the lemurs.service +tty = 2 + +# Where to log the main lemurs control flow. +main_log_path = "/var/log/lemurs.log" + +# Where to log to for the client. The Client is the Desktop Environment or +# Window Manager for Xorg, the Compositor for Wayland and the Shell for TTY. +client_log_path = "/var/log/lemurs.client.log" + +# At which point to point the cache. If you want to disable the cache globally +# you can use `/dev/null`. +cache_path = "/var/cache/lemurs" + +# Disable all logging. This is overwritten by the `--no-log` flag. +do_log = true + +# The PAM service that should be used to login +pam_service = "lemurs" + +# Path to system shell that gets used to execute linux commands. In almost all +# cases, this should refer to a bash shell. +system_shell = "/bin/sh" + +# Initial state of the `PATH` environment variable. +initial_path = "/usr/local/sbin:/usr/local/bin:/usr/bin" + +# The type flag that will be appended to the shell that calls the session +# environment. This may depend on your shell. Options: +# - 'none'. Disables calling a login shell +# - 'short'. Produces the `-l` flag. Supported by most shells. +# - 'long'. This produces the `--login` flag and is suited for bash and zsh. +shell_login_flag = "short" + +# Focus behaviour of fields when Lemurs is initially started +# +# Possible values: +# - default: Initially focus on first non-cached value +# - no-focus: No initial focus +# - environment: Initially focus on the environment selector +# - username: Initially focus on the username field +# - password: Initially focus on the password field +focus_behaviour = "default" + +# General settings for background style +[background] + +# Control whether to render background widget or not +show_background = false + +[background.style] +# Allow to set the default background color for the login shell +color = "black" +# Settings for the background block's borders +show_border = true +border_color = "white" + +[power_controls] +# The margin between hints +hint_margin = 2 + +# There are no additional entries by default +entries = [] + +# Example +# Reboot to another os option +#[[power_controls.entries]] +## The text in the top-left to display how to reboot. +#hint = "Reboot to OS" +# +## The color and modifiers of the hint in the top-left corner +#hint_color = "dark gray" +#hint_modifiers = "" +# +## The key used to reboot. Possibilities are F1 to F12. +#key = "F3" +## The command that is executed when the key is pressed +#cmd = "efibootmgr -n0 && systemctl reboot -l" + + +# If you want to remove the base_entries +# base_entries = [] + +# Shutdown option +[[power_controls.base_entries]] +# The text in the top-left to display how to shutdown. +hint = "Shutdown" + +# The color and modifiers of the hint in the top-left corner +hint_color = "dark gray" +hint_modifiers = "" + +# The key used to shutdown. Possibilities are F1 to F12. +key = "F1" +# The command that is executed when the key is pressed +cmd = "systemctl poweroff -l" + +# Reboot option +[[power_controls.base_entries]] +# The text in the top-left to display how to reboot. +hint = "Reboot" + +# The color and modifiers of the hint in the top-left corner +hint_color = "dark gray" +hint_modifiers = "" + +# The key used to reboot. Possibilities are F1 to F12. +key = "F2" +# The command that is executed when the key is pressed +cmd = "systemctl reboot -l" + +# Setting for the selector of the desktop environment you are using. +[environment_switcher] +# Terms: +# --------- +# Movers: indicators which show which direction one can move whilst selecting +# the desktop environment +# Selected: The currently selected desktop environment. +# Neighbours: The adjacent desktop environment to the one current selected +# +# Visualisation: +# +# < i3 bspwm awesome > +# +# ^ ^ ^ ^ ^ +# | | | | | +# mover | selected | mover +# | | +# neighbour neighbour +# --------- +# + +# Control the visibility of the switcher +# Options: +# - "visible" - Always show the switcher [default] +# - "hidden" - Always hide the switcher +# - [key] - F1-F12 to be able to toggle the visibility +# mesh: hidden, as both workstations had it, but F3 shows it, so a second session can be chosen. +switcher_visibility = "F3" + +# The text in the top-left to display how to toggle the switcher. The text +# '%key%' will be replaced with the switcher_visibility key. This is not shown +# if switcher_visibility is set to "visible" or "hidden". +toggle_hint = "Switcher %key%" + +# The color and modifiers of the hint in the top-left corner +toggle_hint_color = "dark gray" +toggle_hint_modifiers = "" + + +# Show an option for the TTY shell when logging in as one of the environments. +# NOTE: it is always shown when no viable options are found. +include_tty_shell = false + +# Remember the selected environment after logging in for the next time +remember = true + +# Enables showing the movers +show_movers = true + +# Mover's color and modifiers whilst the selector is unfocused +mover_color = "dark gray" +mover_modifiers = "" + +# Mover's color and modifiers whilst the selector is focused +mover_color_focused = "orange" +mover_modifiers_focused = "bold" + +# The characters used to display the movers. Suggestions are: +# - "<" ">" +# - "<-" "->" +# - "<<" ">>" +# - "[" "]" +left_mover = "<" +right_mover = ">" + +# The margin between the movers and the neighbours or selected (depending on +# `show_neighbours`) +mover_margin = 1 + +# Enables showing the neighbours +show_neighbours = true + +# Neighbours' color and modifiers whilst the selector is unfocused +neighbour_color = "dark gray" +neighbour_modifiers = "" + +# Neighbours' color and modifiers whilst the selector is focused +neighbour_color_focused = "gray" +neighbour_modifiers_focused = "" + +# Margin between neighbours and selected +neighbour_margin = 1 + +# Selected's color and modifiers whilst the selector is unfocused +selected_color = "gray" +selected_modifiers = "underlined" + +# Selected's color and modifiers whilst the selector is focused +selected_color_focused = "white" +selected_modifiers_focused = "bold" + +# The length of the name of the desktop environment which is displayed. +max_display_length = 8 + +# The text used when no desktop environments are available +no_envs_text = "No environments..." + +# The color and modifiers of the 'no desktop environments available text' +# whilst the selector is unfocused +no_envs_color = "white" +no_envs_modifiers = "" + +# The color and modifiers of the 'no desktop environments available text' +# whilst the selector is focused +no_envs_color_focused = "red" +no_envs_modifiers_focused = "" + +[username_field] + +# Remember the username for the next time after a successful login attempt. +remember = true + +[username_field.style] +# Enables showing a title +show_title = true +# The text used within the title +title = "Login" + +# The title's color and modifiers whilst the username field is unfocused +title_color = "white" +content_color = "white" + +# The title's color and modifiers whilst the username field is focused +title_color_focused = "orange" +content_color_focused = "orange" + +# Enables showing the borders +show_border = true +# The borders' color and modifiers whilst the username field is unfocused +border_color = "white" +# The borders' color and modifiers whilst the username field is focused +border_color_focused = "orange" + +# Constrain the width of the username field +use_max_width = true +# The constraint of the username field's width +max_width = 48 + +[password_field] + +# The character used for replacement when typing a password. Leave empty for no +# feedback. +# Note: Only one character is accepted. +content_replacement_character = "*" + +[password_field.style] +# Enables showing a title +show_title = true +# The text used within the title +title = "Password" + +# The title's color and modifiers whilst the password field is unfocused +title_color = "white" +content_color = "white" + +# The title's color and modifiers whilst the password field is focused +title_color_focused = "orange" +content_color_focused = "orange" + +# Enables showing the borders +show_border = true +# The borders' color and modifiers whilst the password field is unfocused +border_color = "white" +# The borders' color and modifiers whilst the password field is focused +border_color_focused = "orange" + +# Constrain the width of the password field +use_max_width = true +# The constraint of the password field's width +max_width = 48 + +[x11] +# Where to log to for the XServer. +xserver_log_path = "/var/log/lemurs.xorg.log" + +# The value of the `DISPLAY` environment variable for X11 sessions +x11_display = ":1" + +# How many seconds to give the X server to start. To make it infinitely, put it +# to 0. +xserver_timeout_secs = 60 + +# Where to find the X11 server binary +# mesh: the server through the wrapper below, which adds -noreset. +xserver_path = "/etc/lemurs/xserver" + +# Where to find the X11 xauth binary +xauth_path = "/usr/bin/xauth" + +# Path to the directory where the startup scripts for the X11 sessions are found +scripts_path = "/etc/lemurs/wms" + +# Path to the xsetup script that is needed for the environment setup of the +# window manager. +xsetup_path = "/etc/lemurs/xsetup.sh" + +# The directory to use for desktop entries X11 sessions. +# mesh: an empty directory of the module's own, so no package's desktop entry is offered. +xsessions_path = "/etc/lemurs/xsessions" + +[wayland] +# Path to the directory where the startup scripts for the Wayland sessions are +# found +scripts_path = "/etc/lemurs/wayland" + +# The directory to use for desktop entries wayland sessions. +# mesh: likewise for Wayland. +wayland_sessions_path = "/etc/lemurs/wayland-sessions" diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lemurs/files/xserver b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lemurs/files/xserver new file mode 100755 index 0000000..5908f22 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lemurs/files/xserver @@ -0,0 +1,8 @@ +#!/bin/sh +# The X server as lemurs starts it, written by the mesh (module lemurs). Replaced at every push. +# +# -noreset: without it, X resets whenever its last client disconnects, and a reset empties the +# resource database. The session's start merges the X resources with xrdb before any long-lived +# client is connected, so the server reset twice in the four seconds after a login and threw the +# resources away: xterm fell back to the bitmap "fixed" font, without the mesh's face and colours. +exec /usr/bin/X "$@" -noreset diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lemurs/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/lemurs/module.json.captured new file mode 100644 index 0000000..702a959 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/files/config.json new file mode 100644 index 0000000..8ed994d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/files/config.json @@ -0,0 +1,3 @@ +{ + "password": "${secret:server-password}" +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/files/pgpass b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/files/pgpass new file mode 100644 index 0000000..cd41b66 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/files/pgpass @@ -0,0 +1 @@ +*:*:*:${bound:postgres-database:as}:${secret:postgres-database} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/files/server.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/files/server.env new file mode 100644 index 0000000..fe2acb9 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/files/server.env @@ -0,0 +1,6 @@ +LETTA_PG_URI=postgresql://${bound:postgres-database:as}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} +PGPASSFILE=/run/secrets/pgpass +LETTA_SERVER_PASSWORD=${secret:server-password} +OPENAI_API_KEY=${secret:openai-api-key} +SECURE=true +TZ=Europe/Brussels diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/files/start.sh b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/files/start.sh new file mode 100644 index 0000000..9955592 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/files/start.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Generated by the mesh. Do not edit: module letta writes this file and replaces it at every push. +# +# letta 0.6.8 prints secrets it is given to its log (novox/hq issue 268): +# letta/server/rest_api/app.py prints its server password when it starts in secure mode; +# startup.sh, alembic/env.py and letta/server/server.py print LETTA_PG_URI whole. +# The URI carries no password (libpq reads it from PGPASSFILE), and the one print of the server +# password is rewritten before the server starts. Either one failing refuses the start: a letta +# that does not start says why here, and one that leaks says nothing. +set -e +app=/app/letta/server/rest_api/app.py +sed -i 's/Using secure mode with password: {random_password}/Using secure mode (the password is not printed)/' "$app" +if grep -q 'print(.*random_password' "$app"; then + echo "letta: $app still prints the server password; not starting (novox/hq issue 268)" >&2 + exit 1 +fi +case "$LETTA_PG_URI" in + *://*:*@*) + echo "letta: LETTA_PG_URI carries a password, and letta prints that URI; not starting (novox/hq issue 268)" >&2 + exit 1 + ;; +esac +exec ./letta/server/startup.sh diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/letta/module.json.captured new file mode 100644 index 0000000..de7baf6 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/local-model-consumer/files/openai.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/local-model-consumer/files/openai.env new file mode 100644 index 0000000..9aca396 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/local-model-consumer/files/openai.env @@ -0,0 +1,3 @@ +OPENAI_BASE_URL=http://${bound:model-access:at}:${bound:model-access:port}/v1 +OPENAI_MODEL=${bound:model-access:model} +OPENAI_API_KEY=local diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/local-model-consumer/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/local-model-consumer/module.json.captured new file mode 100644 index 0000000..7300209 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/localization/files/locale.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/localization/files/locale.conf new file mode 100644 index 0000000..fe405b5 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/localization/files/locale.conf @@ -0,0 +1,3 @@ +# The mesh's (module localization, novox/hq to-be 42): the system locale. Written whole at every +# push; an edit here is overwritten. Read at the next login. +LANG=en_US.UTF-8 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/localization/files/vconsole.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/localization/files/vconsole.conf new file mode 100644 index 0000000..88d96f6 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/localization/files/vconsole.conf @@ -0,0 +1,3 @@ +# The mesh's (module localization, novox/hq to-be 42): the console keymap. Written whole at every +# push; an edit here is overwritten. Read at the next boot. +KEYMAP=us diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/localization/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/localization/module.json.captured new file mode 100644 index 0000000..0974ed3 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/logrotate/files/logrotate.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/logrotate/files/logrotate.conf new file mode 100644 index 0000000..d646137 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/logrotate/files/logrotate.conf @@ -0,0 +1,32 @@ +# The mesh's (module logrotate, novox/hq to-be 42): the base configuration every rotation inherits. +# Written whole at every push; an edit here is overwritten. Each package's own rules are in +# /etc/logrotate.d and stay the packages'. + +# Weekly, four weeks kept, a new empty log created after each rotation. +weekly +rotate 4 +create + +# Rotated logs are compressed, one rotation late, so a program still writing to the file it had open +# loses nothing to the compression. +compress +delaycompress + +# A package's replaced configuration is never read as a rule. +tabooext + .pacorig .pacnew .pacsave + +include /etc/logrotate.d + +/var/log/wtmp { + monthly + create 0664 root utmp + minsize 1M + rotate 1 +} + +/var/log/btmp { + missingok + monthly + create 0600 root utmp + rotate 1 +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/logrotate/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/logrotate/module.json.captured new file mode 100644 index 0000000..8318451 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/admin.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/admin.env new file mode 100644 index 0000000..f066bb5 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/admin.env @@ -0,0 +1,2 @@ +INITIAL_ADMIN_PW=${secret:admin} +API_TOKEN=${secret:api-token} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/database.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/database.env new file mode 100644 index 0000000..ba70d3d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/database.env @@ -0,0 +1,5 @@ +DB_FLAVOR=postgresql +DB_HOST=${bound:postgres-database:at}:${bound:postgres-database:port} +DB_USER=${bound:postgres-database:as} +DB_NAME=${bound:postgres-database:as} +DB_PW=${secret:postgres-database} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/mailu.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/mailu.env new file mode 100644 index 0000000..2c977d3 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/mailu.env @@ -0,0 +1,78 @@ +ADMIN_ADDRESS=mailu-admin +ANTISPAM_ADDRESS=mailu-antispam +ANTIVIRUS_ADDRESS=mailu-antivirus +IMAP_ADDRESS=mailu-imap +SMTP_ADDRESS=mailu-smtp +FRONT_ADDRESS=mailu-front +WEBMAIL_ADDRESS=mailu-webmail +WEBDAV_ADDRESS=mailu-webdav +REDIS_ADDRESS=mailu-redis +PORTS=25,80,443,465,993,995,4190,110,143,587 +DOMAIN=${config:domain} +HOSTNAMES=${bound:route:name-web} +POSTMASTER=admin +SITENAME=${config:sitename} +WEBSITE=${config:website} +TLS_FLAVOR=letsencrypt +SUBNET=192.168.203.0/24 +COMPOSE_PROJECT_NAME=mailu +ANTIVIRUS=clamav +WEBMAIL=roundcube +WEBDAV=radicale +FETCHMAIL_ENABLED=True +FETCHMAIL_DELAY=600 +ADMIN=true +WEB_ADMIN=/admin +WEB_WEBMAIL=/webmail +WEBROOT_REDIRECT=/webmail +API=true +WEB_API=/api +AUTH_RATELIMIT_IP=6000/hour +AUTH_RATELIMIT_USER=1000/day +CREDENTIAL_ROUNDS=12 +PASSWORD_SCHEME=PBKDF2 +DISABLE_STATISTICS=True +MESSAGE_SIZE_LIMIT=50000000 +MESSAGE_RATELIMIT=200/day +RECIPIENT_DELIMITER=+ +POSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128 +RELAYNETS= +RELAYHOST= +REJECT_UNLISTED_RECIPIENT= +DB_FLAVOR=postgresql +INITIAL_ADMIN_ACCOUNT=admin +INITIAL_ADMIN_DOMAIN=${config:domain} +INITIAL_ADMIN_MODE=ifmissing +SMTP_PORT=25 +SMTPS_PORT=465 +SUBMISSION_PORT=587 +POP3_PORT=110 +POP3S_PORT=995 +IMAP_PORT=143 +IMAPS_PORT=993 +HTTP_PORT=7080 +HTTPS_PORT=7443 +AUTOMX_PORT=4243 +AMX_SMTP_ADDRESS=${bound:route:name-web} +AMX_SMTP_PORT=587 +AMX_IMAP_ADDRESS=${bound:route:name-web} +AMX_IMAP_PORT=143 +AMX_MAIL_DOMAINS=${config:domain} +DMARC_RUA=admin +DMARC_RUF=admin +LETSENCRYPT_SHORTCHAIN=True +TZ=Etc/UTC +LOG_LEVEL=INFO +WELCOME=false +REAL_IP_HEADER=X-Real-IP +REAL_IP_FROM=${config:proxy-address} +COMPRESSION= +COMPRESS_LEVEL= +COMPRESSION_LEVEL= +BIND_ADDRESS4=127.0.0.1 +BIND_ADDRESS6=::1 +MAILU_VERSION=1.9 +DOCKER_ORG=mailu +DOCKER_PREFIX= +WELCOME_SUBJECT=Welcome to your new email account +WELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly! diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/secret.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/secret.env new file mode 100644 index 0000000..c6f6b66 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/files/secret.env @@ -0,0 +1 @@ +SECRET_KEY=${secret:secret-key} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mailu/module.json.captured new file mode 100644 index 0000000..568be14 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/marrytts/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/marrytts/module.json.captured new file mode 100644 index 0000000..ed242aa --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/matrix/files/conduit.toml b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/matrix/files/conduit.toml new file mode 100644 index 0000000..9b72401 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/matrix/files/conduit.toml @@ -0,0 +1,15 @@ +# Written by the mesh (modules/matrix). Conduit reads this file (CONDUIT_CONFIG); nothing comes +# from the environment. server_name is the homeserver's permanent identity: every user id, room id +# and signature in the database carries it, so it is the name this module is served under +# (${bound:route:name-homeserver}) and never changes once a database exists. +[global] +server_name = "${bound:route:name-homeserver}" +database_backend = "rocksdb" +database_path = "/var/lib/matrix-conduit/" +address = "0.0.0.0" +port = 6167 +max_request_size = 20000000 +allow_registration = false +allow_federation = true +allow_check_for_updates = true +trusted_servers = ["matrix.org"] diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/matrix/files/element.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/matrix/files/element.json new file mode 100644 index 0000000..305a89f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/matrix/files/element.json @@ -0,0 +1,53 @@ +{ + "default_server_name": "${bound:route:name-homeserver}", + "default_server_config": { + "m.homeserver": { + "base_url": "https://${bound:route:name-homeserver}" + }, + "m.identity_server": { + "base_url": "https://vector.im" + } + }, + "brand": "Element", + "integrations_ui_url": "https://scalar.vector.im/", + "integrations_rest_url": "https://scalar.vector.im/api", + "integrations_widgets_urls": [ + "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": "https://element.io/bugreports/submit", + "uisi_autorageshake_app": "element-auto-uisi", + "show_labs_settings": true, + "room_directory": { + "servers": [ + "${bound:route:name-homeserver}", + "matrix.org", + "gitter.im", + "libera.chat" + ] + }, + "enable_presence_by_hs_url": { + "https://matrix.org": false, + "https://matrix-client.matrix.org": false + }, + "terms_and_conditions_links": [ + { + "url": "https://element.io/privacy", + "text": "Privacy Policy" + }, + { + "url": "https://element.io/cookie-policy", + "text": "Cookie Policy" + } + ], + "features": { + "feature_video_rooms": true, + "feature_rust_crypto": true + }, + "element_call": { + "url": "https://call.element.dev" + } +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/matrix/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/matrix/module.json.captured new file mode 100644 index 0000000..170719c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/oomd.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/oomd.conf new file mode 100644 index 0000000..39af6de --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/oomd.conf @@ -0,0 +1,8 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# systemd-oomd kills the worst unit before the kernel's OOM killer freezes the machine: when swap is +# 90 % used, or when a watched unit stalls on memory for 20 s above its limit. +[OOM] +SwapUsedLimit=90% +DefaultMemoryPressureLimit=60% +DefaultMemoryPressureDurationSec=20s diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/root-slice.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/root-slice.conf new file mode 100644 index 0000000..edae3de --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/root-slice.conf @@ -0,0 +1,5 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# systemd-oomd may act on swap exhaustion across the whole machine. +[Slice] +ManagedOOMSwap=kill diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/sysctl.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/sysctl.conf new file mode 100644 index 0000000..678251c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/sysctl.conf @@ -0,0 +1,9 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# Swap-in reads one page, not eight: read-ahead exists to amortise a disk's seek, and compressed swap in +# RAM has none — the speculation only costs decompression and memory. +vm.page-cluster = 0 +# +# vm.swappiness is deliberately left alone. The usual zram advice (150-180) holds only while the +# compressed swap has room; once it is full, a higher swappiness moves pages to the disk swap, the thing +# being avoided. Raise it only together with zram-size. diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/user-manager.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/user-manager.conf new file mode 100644 index 0000000..8ef300c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/user-manager.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# systemd-oomd may kill the worst unit in a person's service manager when its memory pressure stays +# above 80 % — instead of the kernel freezing the machine. +[Service] +ManagedOOMMemoryPressure=kill +ManagedOOMMemoryPressureLimit=80% diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/zram-generator.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/zram-generator.conf new file mode 100644 index 0000000..cccfb05 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/files/zram-generator.conf @@ -0,0 +1,11 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# Compressed swap in RAM: fast, at a higher priority than any swap on disk, so it takes the everyday +# pressure before anything spills to a disk. Half the RAM, at most 16 GiB, compressed with zstd. +# The swap on disk, its size and whether one exists at all are the machine's, not this module's. +# +# Read by the generator at boot: a change applies at the next boot. +[zram0] +zram-size = min(ram / 2, 16384) +compression-algorithm = zstd +swap-priority = 100 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/memory-pressure/module.json.captured new file mode 100644 index 0000000..1af96e3 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-go/files/forge b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-go/files/forge new file mode 100644 index 0000000..6bbf4e2 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-go/files/forge @@ -0,0 +1,7 @@ +Host ${bound:forge-account:ssh_alias} + HostName ${bound:forge-account:ssh_host} + Port ${bound:forge-account:ssh_port} + HostKeyAlias ${bound:forge-account:ssh_alias} + User git + IdentityFile ~/.ssh/mesh_ed25519 + IdentitiesOnly yes diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-go/files/gitconfig b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-go/files/gitconfig new file mode 100644 index 0000000..0525230 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-go/files/gitconfig @@ -0,0 +1,2 @@ +[url "ssh://${bound:forge-account:ssh_alias}/"] + insteadOf = ${bound:forge-account:https_base} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-go/files/known_hosts b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-go/files/known_hosts new file mode 100644 index 0000000..e0df6d8 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-go/files/known_hosts @@ -0,0 +1 @@ +${bound:forge-account:ssh_alias} ${bound:forge-account:host_key} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-go/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-go/module.json.captured new file mode 100644 index 0000000..9f0fd7f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-node/files/forge b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-node/files/forge new file mode 100644 index 0000000..6bbf4e2 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-node/files/forge @@ -0,0 +1,7 @@ +Host ${bound:forge-account:ssh_alias} + HostName ${bound:forge-account:ssh_host} + Port ${bound:forge-account:ssh_port} + HostKeyAlias ${bound:forge-account:ssh_alias} + User git + IdentityFile ~/.ssh/mesh_ed25519 + IdentitiesOnly yes diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-node/files/gitconfig b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-node/files/gitconfig new file mode 100644 index 0000000..0525230 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-node/files/gitconfig @@ -0,0 +1,2 @@ +[url "ssh://${bound:forge-account:ssh_alias}/"] + insteadOf = ${bound:forge-account:https_base} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-node/files/known_hosts b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-node/files/known_hosts new file mode 100644 index 0000000..e0df6d8 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-node/files/known_hosts @@ -0,0 +1 @@ +${bound:forge-account:ssh_alias} ${bound:forge-account:host_key} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-node/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-build-node/module.json.captured new file mode 100644 index 0000000..b92212a --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-catalog/files/database.url b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-catalog/files/database.url new file mode 100644 index 0000000..dc59da9 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-catalog/files/database.url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-catalog/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-catalog/module.json.captured new file mode 100644 index 0000000..8d8c659 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-cli/files/zsh-normal b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-cli/files/zsh-normal new file mode 100644 index 0000000..117f2a5 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-cli/files/zsh-normal @@ -0,0 +1,3 @@ +# The operator's command (module mesh-cli, novox/hq ADR 0272): nox is mesh-cli, in interactive zsh only. +# A script, sudo and a document meant to work everywhere say mesh-cli. +alias nox=mesh-cli diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-cli/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-cli/module.json.captured new file mode 100644 index 0000000..681a3e5 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-config/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-config/module.json.captured new file mode 100644 index 0000000..e03e891 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-delivery-gate/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-delivery-gate/module.json.captured new file mode 100644 index 0000000..ec83852 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-files/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-files/module.json.captured new file mode 100644 index 0000000..cc1df16 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-issues/files/settings.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-issues/files/settings.json new file mode 100644 index 0000000..66abd38 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-issues/files/settings.json @@ -0,0 +1,147 @@ +{ + "first-number": 365, + "owners": [ + "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": "on", + "judge-model": "sonnet", + "forge-owner": "novox", + "stuck-after": "2h", + "worker-stale-after": "24h", + "delivered-not-resolved-after": "1h", + "decision-floor": 293, + "research-floor": 45 +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-issues/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-issues/module.json.captured new file mode 100644 index 0000000..dcfc71c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/database.url b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/database.url new file mode 100644 index 0000000..dc59da9 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/database.url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/settings.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/settings.json new file mode 100644 index 0000000..6f682fe --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/settings.json @@ -0,0 +1,5 @@ +{ + "judge-model": "sonnet", + "drafting": "on", + "reserved-for": "168h" +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-knowledge/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-knowledge/module.json.captured new file mode 100644 index 0000000..92b15f2 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-notifier/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-notifier/module.json.captured new file mode 100644 index 0000000..9afba46 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/callers.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/callers.json new file mode 100644 index 0000000..84dd800 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/callers.json @@ -0,0 +1,3 @@ +{ + "callers": ["mesh-mail-web"] +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/database-url b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/database-url new file mode 100644 index 0000000..dc59da9 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/database-url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-reader/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-reader/module.json.captured new file mode 100644 index 0000000..b83e8bb --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/oidc.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/oidc.json new file mode 100644 index 0000000..f864608 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/oidc.json @@ -0,0 +1,5 @@ +{ + "client-id": "${bound:oidc-client:as}", + "client-secret": "${secret:oidc-client}", + "issuer": "${bound:oidc-client:issuer}" +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/operators.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/operators.json new file mode 100644 index 0000000..dea781c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/operators.json @@ -0,0 +1,3 @@ +{ + "operators": [] +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-web/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail-web/module.json.captured new file mode 100644 index 0000000..c6c0d3d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/files/accounts.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/files/accounts.json new file mode 100644 index 0000000..e182cc3 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/files/accounts.json @@ -0,0 +1,3 @@ +{ + "accounts": {} +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/files/database-url b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/files/database-url new file mode 100644 index 0000000..dc59da9 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/files/database-url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/files/grants.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/files/grants.json new file mode 100644 index 0000000..458eabb --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/files/grants.json @@ -0,0 +1,3 @@ +{ + "grants": {} +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/files/store.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/files/store.json new file mode 100644 index 0000000..8c2075d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/files/store.json @@ -0,0 +1,9 @@ +{ + "scheme": "${bound:s3-bucket:scheme}", + "at": "${bound:s3-bucket:at}", + "port": "${bound:s3-bucket:port}", + "bucket": "${bound:s3-bucket:bucket}", + "region": "${bound:s3-bucket:region}", + "key": "${bound:s3-bucket:as}", + "secret": "${secret:s3-bucket}" +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-mail/module.json.captured new file mode 100644 index 0000000..735fbc5 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-provisioner-client/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-provisioner-client/module.json.captured new file mode 100644 index 0000000..095128a --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-provisioner/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-provisioner/module.json.captured new file mode 100644 index 0000000..650425b --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-reads/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-reads/module.json.captured new file mode 100644 index 0000000..1806275 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-vault/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-vault/module.json.captured new file mode 100644 index 0000000..44944c9 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-watcher/files/settings.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-watcher/files/settings.json new file mode 100644 index 0000000..84b9ecc --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-watcher/files/settings.json @@ -0,0 +1,3 @@ +{ + "telegram-chat-id": "" +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-watcher/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mesh-watcher/module.json.captured new file mode 100644 index 0000000..e9543a5 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/messenger/files/settings.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/messenger/files/settings.json new file mode 100644 index 0000000..6d22854 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/messenger/files/settings.json @@ -0,0 +1,5 @@ +{ + "time-zone": "", + "only-what-needs-me": [], + "messages-per-hour": 100 +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/messenger/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/messenger/module.json.captured new file mode 100644 index 0000000..6045195 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/minio/files/root.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/minio/files/root.env new file mode 100644 index 0000000..2255260 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/minio/files/root.env @@ -0,0 +1,2 @@ +MINIO_ROOT_USER=meshroot +MINIO_BROWSER_REDIRECT_URL=https://${bound:route:name-console} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/minio/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/minio/module.json.captured new file mode 100644 index 0000000..85b8253 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/model-usage/files/database.url b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/model-usage/files/database.url new file mode 100644 index 0000000..dc59da9 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/model-usage/files/database.url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/model-usage/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/model-usage/module.json.captured new file mode 100644 index 0000000..50743a3 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mongodb/files/server-root.secret b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mongodb/files/server-root.secret new file mode 100644 index 0000000..0ccabee --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mongodb/files/server-root.secret @@ -0,0 +1 @@ +${secret:root} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mongodb/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mongodb/module.json.captured new file mode 100644 index 0000000..cbb1268 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mosquitto/files/bootstrap.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mosquitto/files/bootstrap.env new file mode 100644 index 0000000..65b710e --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mosquitto/files/bootstrap.env @@ -0,0 +1,7 @@ +MESH_PROVISION_MQTT=127.0.0.1:${port:1883} +MESH_PROVISION_ADMIN_USER=mesh-admin +MESH_PROVISION_PASSWORD_FILE=${dir:mesh-state}/admin +MESH_DYNSEC_FILE=${dir:data}/dynamic-security.json +MESH_MQTT_CTRL_IMAGE=eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408 +MESH_MQTT_CTRL_CONTAINER=mosquitto +MESH_MQTT_ADMIN_APPLIED_FILE=${dir:state}/admin.applied diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mosquitto/files/mosquitto.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mosquitto/files/mosquitto.conf new file mode 100644 index 0000000..500c2a6 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mosquitto/files/mosquitto.conf @@ -0,0 +1,20 @@ +persistence true +persistence_location /mosquitto/data + +log_dest stdout +log_type warning +log_type error +log_type notice + +# Every client authenticates; identities and their per-topic ACLs are managed +# at runtime by the dynamic security plugin, whose store the plugin itself owns. +allow_anonymous false +plugin /usr/lib/mosquitto_dynamic_security.so +plugin_opt_config_file /mosquitto/data/dynamic-security.json + +# MQTT listener +listener 1883 + +# MQTT-over-WebSockets listener +listener 8081 +protocol websockets diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mosquitto/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mosquitto/module.json.captured new file mode 100644 index 0000000..21058e0 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mounts/files/mounts.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mounts/files/mounts.conf new file mode 100644 index 0000000..1d5f87d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mounts/files/mounts.conf @@ -0,0 +1,11 @@ +# Written by the mesh (module mounts, novox/hq ADR 0263) from this machine's assignment: the settings +# shares and sources, and the binding of nfs-share. Replaced on every push; change the settings, not this. +# The module's process reads it as root every 30 seconds and writes one .mount and .automount per entry. +shares=${config:shares} +sources=${config:sources} +server=${bound:nfs-share:from} +at=${bound:nfs-share:at} +account=${machine:account} +node=${machine:name} +passwords=${dir:state} +state=${dir:state} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mounts/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mounts/module.json.captured new file mode 100644 index 0000000..488ebb9 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mssql/files/backup.sql b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mssql/files/backup.sql new file mode 100644 index 0000000..fd2ecce --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mssql/files/backup.sql @@ -0,0 +1,14 @@ +SET NOCOUNT ON; +DECLARE @n sysname, @s nvarchar(max); +DECLARE c CURSOR LOCAL FAST_FORWARD FOR + SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL; +OPEN c; +FETCH NEXT FROM c INTO @n; +WHILE @@FETCH_STATUS = 0 +BEGIN + SET @s = N'BACKUP DATABASE ' + QUOTENAME(@n) + N' TO DISK = N''/var/opt/mssql/backup/' + REPLACE(@n, N'''', N'''''') + N'.bak'' WITH INIT, COPY_ONLY, CHECKSUM'; + EXEC (@s); + FETCH NEXT FROM c INTO @n; +END +CLOSE c; +DEALLOCATE c; diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mssql/files/sa.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mssql/files/sa.env new file mode 100644 index 0000000..e7dd444 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mssql/files/sa.env @@ -0,0 +1,2 @@ +ACCEPT_EULA=Y +MSSQL_SA_PASSWORD=${secret:sa} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mssql/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/mssql/module.json.captured new file mode 100644 index 0000000..ed4e448 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/n8n/files/n8n-database.secret b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/n8n/files/n8n-database.secret new file mode 100644 index 0000000..e811e88 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/n8n/files/n8n-database.secret @@ -0,0 +1 @@ +${secret:postgres-database} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/n8n/files/server.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/n8n/files/server.env new file mode 100644 index 0000000..2f74e0f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/n8n/files/server.env @@ -0,0 +1,12 @@ +N8N_HOST=${bound:route:name} +N8N_PORT=5678 +N8N_PROTOCOL=https +WEBHOOK_URL=https://${bound:route:name}/ +NODE_FUNCTION_ALLOW_BUILTIN=* +NODE_FUNCTION_ALLOW_EXTERNAL=* +DB_TYPE=postgresdb +DB_POSTGRESDB_HOST=${bound:postgres-database:at} +DB_POSTGRESDB_PORT=${bound:postgres-database:port} +DB_POSTGRESDB_DATABASE=${bound:postgres-database:as} +DB_POSTGRESDB_USER=${bound:postgres-database:as} +DB_POSTGRESDB_PASSWORD_FILE=/run/secrets/database diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/n8n/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/n8n/module.json.captured new file mode 100644 index 0000000..975247f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nats/files/nats.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nats/files/nats.conf new file mode 100644 index 0000000..e1d33eb --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nats/files/nats.conf @@ -0,0 +1,46 @@ +# The nats module's own server settings. Declared by the module, because a port, a TLS path +# and a store directory are properties of the container this module raises: they live in its +# image and its mounts and change when it does. +# +# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the +# whole file would have to be kept in step with a Dockerfile it never sees. + +port: 4222 +# Monitoring on every interface *inside* the container, so the publish below can reach it; the publish +# is 127.0.0.1:8222 on the machine, so nothing beyond the machine reaches it, and the module guards 8222 +# too. Bound to the container's own loopback until 2026-10-04, the published port answered nothing +# (connection reset), and the only way in was `docker exec`. +http: 0.0.0.0:8222 + +# The mesh's own broker certificate — the one every machine already pins by fingerprint and the +# controller already trusts (MESH_BROKER_CERTIFICATE). Serving the new bus with it means no +# machine's pin changes when it moves, and no second certificate exists to be wrong about. +tls { + cert_file: "/tls/tls.crt" + key_file: "/tls/tls.key" +} + +# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That +# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a +# host pins this server's exact certificate and authenticates with the password the mesh minted +# (novox/hq ADR 0004, design 25 §4), and so does a module's runtime. With it on, every connection +# in the mesh is refused at the TLS handshake, before any password is looked at — and the error is +# "client didn't provide a certificate", which reads as a client fault. +# +# TLS is still required: a tls block is what makes it required, and verify only decides whether +# client certificates are checked. What is given up is a second factor the mesh has no machinery +# to issue or rotate — a certificate per module per node — and what is kept is stronger than a +# name check in both directions: an exact pin outward, a per-user password inward. + +jetstream { + store_dir: "/data" +} + +# Every user of the mesh, composed by the controller and rewritten whenever a module is +# assigned, a node enrols or a person's access changes. +# +# **Relative, and in this same directory, because it has to be.** An absolute include path is +# resolved relative to the including file's directory, not from the root: nats-server given +# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for +# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server. +include accounts.conf diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nats/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nats/module.json.captured new file mode 100644 index 0000000..3ad66bc --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/netcheck/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/netcheck/module.json.captured new file mode 100644 index 0000000..ec01003 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/networkmanager/files/50-mesh.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/networkmanager/files/50-mesh.conf new file mode 100644 index 0000000..f8c8e8a --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/networkmanager/files/50-mesh.conf @@ -0,0 +1,63 @@ +# Managed by the mesh (module networkmanager). Replaced on every push; edit the +# catalogue instead. +# +# This machine's uplink is NetworkManager's, and this file is the whole of what +# the mesh asks of it (novox/hq ADR 0117): leave the resolver file to the mesh, +# and leave the private network's interface alone. Nothing more. The mesh never +# declares a connection profile, an address, a route, a wireless network or its +# credentials — those are joined at the machine, by the person using it, and +# the link they make is the only channel the mesh reaches this machine over. A +# push that got a link wrong could not be undone by the next one. +# +# A drop-in of the mesh's own, beside NetworkManager.conf and whatever else the +# operator keeps in this directory. NetworkManager reads the files here sorted by +# name and a later one wins a key it sets again — so a file of the operator's +# that sorts after this one (any name starting with a letter does) and sets dns= +# or unmanaged-devices= overrides it. That is the operator's to decide, and the +# reason this file sets nothing but the two keys it must. +# +# NetworkManager itself is the machine's: the mesh never starts, stops, enables +# or disables it (its service is declared with no state), because stopping it +# takes every link down, this machine's channel to the mesh included — and a +# module unassigned by mistake must not be able to do that. When this file +# changes, a running NetworkManager is reloaded (its D-Bus Reload call, which +# re-reads its configuration — NetworkManager(8)), never restarted. + +[main] +# The resolver file is the mesh's: this module writes /etc/resolv.conf itself, +# listing the mesh's resolvers (novox/hq ADR 0223). Without this line +# NetworkManager rewrites that file on +# every connectivity change — every network joined, every lease renewed — +# and the mesh's resolver is silently replaced while every surface of the mesh +# still reads green. none: "NetworkManager will not modify resolv.conf. This +# implies rc-manager unmanaged" (NetworkManager.conf(5), 1.58). On an adopted +# machine the predecessor wrote the same line in a file of its own; both say one +# thing, and the predecessor's is retired by hand after the take. +# +# Not NetworkManager's own global DNS ([global-dns-domain-*] with rc-manager=file): +# it writes its own header and composes the options line itself, so it cannot +# write the mesh's file byte for byte, and the three uplink modules would write +# three different files for one fact. dns=none, and the file declared beside it. +dns=none + +[keyfile] +# mesh0 is the private network's interface: the mesh brings it up and the mesh +# alone configures it. A manager that considers every interface its own could +# try to configure it, or tear it down on a profile change. +# +# unmanaged-devices rather than a [device-mesh0] section with managed=0, because +# NetworkManager.conf(5) says a device unmanaged by this key "is strictly +# unmanaged and cannot be overruled by using the API like nmcli device set +# $IFNAME managed yes", while device*.managed "can be overruled at runtime via +# D-Bus". The same page adds that device*.managed "may be a better choice" for +# exactly those reasons — for an interface the operator might want to hand back +# at runtime. For the mesh's own interface, strict is the point. +# +# += rather than =: the same page documents appending to a list-valued key set +# earlier ("plugins+=another-plugin") as an extension of its key file format, +# and unmanaged-devices is a device list. = would replace whatever devices the +# operator already keeps NetworkManager away from; += adds this one to them +# (novox/hq ADR 0102: a list is added to, never replaced). A file of the +# operator's read after this one that sets the key with = replaces it again; +# that is the operator's to decide. +unmanaged-devices+=interface-name:mesh0 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/networkmanager/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/networkmanager/module.json.captured new file mode 100644 index 0000000..68ace34 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nextcloud-client/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nextcloud-client/module.json.captured new file mode 100644 index 0000000..af71acb --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nextcloud/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nextcloud/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nextcloud/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nextcloud/files/server.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nextcloud/files/server.env new file mode 100644 index 0000000..df6e20f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nextcloud/files/server.env @@ -0,0 +1,14 @@ +POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port} +POSTGRES_DB=${bound:postgres-database:as} +POSTGRES_USER=${bound:postgres-database:as} +POSTGRES_PASSWORD=${secret:postgres-database} +NEXTCLOUD_ADMIN_USER=mesh-admin +NEXTCLOUD_ADMIN_PASSWORD=${secret:admin} +OBJECTSTORE_S3_HOST=${bound:s3-bucket:at} +OBJECTSTORE_S3_PORT=${bound:s3-bucket:port} +OBJECTSTORE_S3_BUCKET=${bound:s3-bucket:bucket} +OBJECTSTORE_S3_KEY=${bound:s3-bucket:as} +OBJECTSTORE_S3_SECRET=${secret:s3-bucket} +OBJECTSTORE_S3_SSL=false +OBJECTSTORE_S3_USEPATH_STYLE=true +OBJECTSTORE_S3_REGION=${bound:s3-bucket:region} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nextcloud/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nextcloud/module.json.captured new file mode 100644 index 0000000..2854a16 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nfs-server/files/50-mesh.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nfs-server/files/50-mesh.conf new file mode 100644 index 0000000..2640a8b --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nfs-server/files/50-mesh.conf @@ -0,0 +1,12 @@ +# Written by the mesh (module nfs-server, novox/hq ADR 0263). Replaced on every push; a drop-in of +# the operator's that sorts after this one overrides it, and is theirs. +# +# NFS version 4 only: a client needs port 2049 and nothing else, so the module opens nothing more +# than that, to the private network. Version 3 needs rpcbind and mountd, on ports the mesh does not open. +[nfsd] +vers2=n +vers3=n +vers4=y +vers4.0=n +vers4.1=y +vers4.2=y diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nfs-server/files/shares.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nfs-server/files/shares.conf new file mode 100644 index 0000000..f25f026 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nfs-server/files/shares.conf @@ -0,0 +1,10 @@ +# Written by the mesh (module nfs-server, novox/hq ADR 0263) from this machine's assignment. +# Replaced on every push; change the `shares` setting, never this file. +# +# The shares: name=folder, or name=folder:ro, one share per folder. The grants: share=node:rw|ro[,…], +# which nodes may mount each and how. The module's process exports a share to a node's private address +# only when it is granted here and that node's mounts module wants it, every client mapped to the +# folder's owner, and only to addresses inside the range below. +shares=${config:shares} +grants=${config:grants} +range=${machine:mesh-range} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nfs-server/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nfs-server/module.json.captured new file mode 100644 index 0000000..f9994d1 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nftables/files/mesh-filter.service b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nftables/files/mesh-filter.service new file mode 100644 index 0000000..5a6cced --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nftables/files/mesh-filter.service @@ -0,0 +1,14 @@ +[Unit] +Description=The mesh's packet filter, derived from what is assigned to this node +Wants=network-pre.target +Before=network-pre.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=nft -f /etc/nftables.conf +ExecReload=nft -f /etc/nftables.conf +ExecStop=nft delete table inet mesh + +[Install] +WantedBy=multi-user.target diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nftables/files/mesh.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nftables/files/mesh.conf new file mode 100644 index 0000000..f1a7bb0 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nftables/files/mesh.conf @@ -0,0 +1,5 @@ +# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset +# (novox/hq ADR 0100) — a flush would take the container runtime's rules and any firewall with it. +[Service] +ExecStop= +ExecStop=nft delete table inet mesh diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nftables/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nftables/module.json.captured new file mode 100644 index 0000000..b7b0c83 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nm-applet/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nm-applet/module.json.captured new file mode 100644 index 0000000..1c6764e --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/node-env/files/50-mesh.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/node-env/files/50-mesh.conf new file mode 100644 index 0000000..3aebc87 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/node-env/files/50-mesh.conf @@ -0,0 +1,4 @@ +# The operator account's environment for its service manager and graphical session, generated by +# the mesh (module node-env, novox/hq ADR 0203). Do not edit: this file is replaced at every push. +# The same facts as ~/.config/mesh/environment.sh, in environment.d(5) syntax. +${collected:environment:systemd} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/node-env/files/environment.sh b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/node-env/files/environment.sh new file mode 100644 index 0000000..c28adf3 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/node-env/files/environment.sh @@ -0,0 +1,5 @@ +# The operator account's environment, generated by the mesh (module node-env, novox/hq ADR 0203). +# Do not edit: this file is replaced at every push. Every line names the module that contributed it. +# Sourced by the login shell from its always-read startup file (for zsh, ~/.zshenv), so a script, a +# login and the shell's execute verb all see it. Your own variables belong in your shell's own lines. +${collected:environment:posix} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/node-env/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/node-env/module.json.captured new file mode 100644 index 0000000..85fd7e5 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodejs/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodejs/module.json.captured new file mode 100644 index 0000000..c357344 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/files/config.json new file mode 100644 index 0000000..a396fbd --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/files/config.json @@ -0,0 +1,3 @@ +{ + "token": "${secret:api-token}" +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/files/mqtt.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/files/mqtt.env new file mode 100644 index 0000000..5331f3c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/files/mqtt.env @@ -0,0 +1,4 @@ +MESH_NODERED_URL=http://127.0.0.1:${port:1880} +MESH_NODERED_CONFIG_FILE=${dir:mesh-state}/config.json +MESH_PROVISIONS_DIR=${dir:state} +MESH_WRITTEN_DIR=${dir:written} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/files/settings.js b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/files/settings.js new file mode 100644 index 0000000..0d0f351 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/files/settings.js @@ -0,0 +1,48 @@ +// Node-RED's settings, written by the mesh from the nodered module. What an assignment may change +// is settings.json beside this file (merged key by key); the credentials are the mesh's secrets and +// reach Node-RED only through this file. The flows' own credentials stay encrypted in the user +// directory under the key Node-RED keeps there (.config.runtime.json), which is data, not this. +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const ADMIN_PASSWORD = "${secret:admin}"; +const API_TOKEN = "${secret:api-token}"; +const ADMIN = { username: "admin", permissions: "*" }; + +const settings = JSON.parse(fs.readFileSync(path.join(__dirname, "settings.json"), "utf8")); +// The mesh's keys, not Node-RED's: endpoints lands in every merged file; timeZone is the +// assignment's way to set the zone flows schedule and format in; mqtt names the broker nodes the +// module's MQTT step keeps pointed at the mesh's broker; topics is what nodered asks the broker for. +if (settings.timeZone) process.env.TZ = settings.timeZone; +delete settings.timeZone; +delete settings.endpoints; +delete settings.mqtt; +delete settings.topics; + +function same(a, b) { + const x = crypto.createHash("sha256").update(String(a)).digest(); + const y = crypto.createHash("sha256").update(String(b)).digest(); + return crypto.timingSafeEqual(x, y); +} + +// The admin secret is a password, or, accepted from an existing install, the bcrypt hash its +// settings held, so the password people already use keeps working. +function passwordMatches(given) { + if (/^\$2[aby]\$\d\d\$/.test(ADMIN_PASSWORD)) return require("bcryptjs").compare(String(given), ADMIN_PASSWORD); + return Promise.resolve(same(given, ADMIN_PASSWORD)); +} + +module.exports = Object.assign(settings, { + uiPort: 1880, + adminAuth: { + type: "credentials", + users: (username) => Promise.resolve(username === ADMIN.username ? ADMIN : null), + authenticate: (username, password) => + username === ADMIN.username + ? passwordMatches(password).then((ok) => (ok ? ADMIN : null)) + : Promise.resolve(null), + // The module's own tools call the admin API with this bearer token. + tokens: (token) => Promise.resolve(same(token, API_TOKEN) ? { username: "mesh", permissions: "*" } : null), + }, +}); diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/files/settings.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/files/settings.json new file mode 100644 index 0000000..93cf157 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/files/settings.json @@ -0,0 +1,14 @@ +{ + "flowFile": "flows.json", + "flowFilePretty": true, + "diagnostics": { "enabled": true, "ui": true }, + "runtimeState": { "enabled": false, "ui": false }, + "logging": { "console": { "level": "info", "metrics": false, "audit": false } }, + "exportGlobalContextKeys": false, + "externalModules": {}, + "editorTheme": { "projects": { "enabled": false } }, + "functionExternalModules": true, + "debugMaxLength": 1000, + "mqttReconnectTime": 15000, + "serialReconnectTime": 15000 +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/nodered/module.json.captured new file mode 100644 index 0000000..f8068dd --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ollama/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ollama/module.json.captured new file mode 100644 index 0000000..2d5fb48 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/only-office/files/server.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/only-office/files/server.env new file mode 100644 index 0000000..19fab11 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/only-office/files/server.env @@ -0,0 +1,5 @@ +JWT_ENABLED=true +JWT_SECRET=${secret:jwt} +JWT_HEADER=Authorization +JWT_IN_BODY=true +ALLOW_PRIVATE_IP_ADDRESS=true diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/only-office/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/only-office/module.json.captured new file mode 100644 index 0000000..4af651f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/openai-consumer/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/openai-consumer/module.json.captured new file mode 100644 index 0000000..2e98414 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/openrazer/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/openrazer/module.json.captured new file mode 100644 index 0000000..faa34f8 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/files/mesh-pacman-weekly-upgrade.service b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/files/mesh-pacman-weekly-upgrade.service new file mode 100644 index 0000000..69741dd --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/files/mesh-pacman-weekly-upgrade.service @@ -0,0 +1,12 @@ +# Generated by the mesh. Do not edit — module pacman writes this file and replaces it at every push. +[Unit] +Description=Weekly full upgrade (the mesh's pacman module, ADR 0477) +Wants=network-online.target +After=network-online.target + +[Service] +# The upgrade pacman_upgrade runs (pacman -Syu --noconfirm), as a oneshot whose result is the module's health +# (pacman_upgrade_health). The mesh restarts no machine for an upgrade: a new kernel waits for the next restart. +Type=oneshot +Nice=10 +ExecStart=/usr/bin/pacman -Syu --noconfirm diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/files/mesh-pacman-weekly-upgrade.timer b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/files/mesh-pacman-weekly-upgrade.timer new file mode 100644 index 0000000..a8a9b4d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/files/mesh-pacman-weekly-upgrade.timer @@ -0,0 +1,11 @@ +# Generated by the mesh. Do not edit — module pacman writes this file and replaces it at every push. +[Unit] +Description=Weekly full upgrade, Saturday 04:00 local time on every node (the mesh's pacman module, ADR 0477) + +[Timer] +# The same moment on every node: no random delay. A node that was off or asleep then upgrades at its next start. +OnCalendar=Sat *-*-* 04:00:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/files/pacman.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/files/pacman.conf new file mode 100644 index 0000000..0390e25 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/files/pacman.conf @@ -0,0 +1,29 @@ +# The mesh's (module pacman, novox/hq to-be 42): the package manager's configuration. Written +# whole at every push: an edit here is overwritten, and the file a machine had before is kept once by +# the host. Owned whole because [options] cannot take a block by appending: anything added at the end +# of the file lands in the last repository's section. +# +# The repositories are the union of what the machines had enabled when the module was written +# (core, extra, multilib). The options are the distribution's defaults with four more: colour on a +# terminal, parallel downloads, package lists in columns, and downloads run as the unprivileged +# alpm user, which pacman 7 creates. + +[options] +HoldPkg = pacman glibc +Architecture = auto +CheckSpace +Color +VerbosePkgLists +ParallelDownloads = 5 +DownloadUser = alpm +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional + +[core] +Include = /etc/pacman.d/mirrorlist + +[extra] +Include = /etc/pacman.d/mirrorlist + +[multilib] +Include = /etc/pacman.d/mirrorlist diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/files/reflector.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/files/reflector.conf new file mode 100644 index 0000000..eb9e731 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/files/reflector.conf @@ -0,0 +1,8 @@ +# The mesh's (module pacman, novox/hq to-be 42): how reflector refreshes the mirror list, weekly, +# through reflector.timer. Written whole at every push. Before the module, every machine's list was +# generated once and never again. +--save /etc/pacman.d/mirrorlist +--protocol https +--country Belgium,Netherlands,Luxembourg,Germany,France +--latest 20 +--sort rate diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/pacman/module.json.captured new file mode 100644 index 0000000..ca1a81a --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/photos-eef/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/photos-eef/module.json.captured new file mode 100644 index 0000000..101bdc2 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/photos-filip/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/photos-filip/module.json.captured new file mode 100644 index 0000000..9c1c83f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/picom/files/picom.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/picom/files/picom.conf new file mode 100644 index 0000000..c6b8555 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/picom/files/picom.conf @@ -0,0 +1,38 @@ +# picom, the X compositor (module picom, novox/hq ADR 0208). Owned by the mesh: this file is +# replaced at every push. Adopted from the two workstations' file of 2026-10-04 and moved to +# picom's window rules, which supersede opacity-rule, inactive-opacity and inactive-dim. + +# The backend and damage tracking are each machine's settings (backend, use-damage; novox/hq research +# 037, issue 345): the right ones depend on the machine's graphics driver. The defaults are what every +# machine ran before: glx, and picom's own default of repainting only what changed. xrender, or +# use-damage false (repaint the whole screen each frame), is what to try on a machine where a window is +# left unpainted or stale. +backend = "${config:backend}"; +use-damage = ${config:use-damage}; +vsync = true; + +fading = true; +fade-in-step = 0.05; +fade-out-step = 0.05; + +# Tiled windows hide their shadows and corners, so neither is drawn. +shadow = false; +corner-radius = 0; +blur-method = "none"; + +# "Focused" is i3's _NET_ACTIVE_WINDOW, not X focus events: under i3 those do not reliably reach +# the toplevel picom tracks, and a terminal then never lifts to its focused opacity. +use-ewmh-active-win = true; + +# Window rules are applied in order, and a later match wins. Only terminals are translucent: +# nothing else on screen (browsers, video, games) is touched. A terminal is matched by its class; +# the node's terminal emulator today is xterm (class XTerm). A window's own opacity property is +# used wherever no rule sets one, which is what the window-opacity tool sets. +rules = ( + { match = "window_type = 'tooltip'"; fade = false; opacity = 0.95; }, + { match = "window_type = 'dock' || window_type = 'desktop'"; fade = false; }, + { match = "class_g = 'XTerm' && focused"; opacity = 0.95; }, + { match = "class_g = 'XTerm' && !focused"; opacity = 0.75; }, + # A full-screen window is opaque, a terminal included: a video or a game is never see-through. + { match = "fullscreen"; opacity = 1; } +); diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/picom/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/picom/module.json.captured new file mode 100644 index 0000000..cfdc397 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/polychromatic/files/display-session b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/polychromatic/files/display-session new file mode 100644 index 0000000..bbdbd6f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/polychromatic/files/display-session @@ -0,0 +1,6 @@ +# The Razer peripherals' tray (module polychromatic, novox/hq ADR 0208, ADR 0212). Owned by the mesh: +# replaced at every push. This line is the tray's one start, at the session's start (exec, not +# exec_always, so a reload starts nothing). The package's own login helper starts it too while the +# application's setting "Start the tray applet when I log on" is ticked; polychromatic_check names +# that as a second start. +exec --no-startup-id polychromatic-tray-applet diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/polychromatic/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/polychromatic/module.json.captured new file mode 100644 index 0000000..4d6ba4b --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/postgres/files/consumers.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/postgres/files/consumers.json new file mode 100644 index 0000000..30d160e --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/postgres/files/consumers.json @@ -0,0 +1 @@ +${consumers:postgres-database} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/postgres/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/postgres/module.json.captured new file mode 100644 index 0000000..919df42 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/files/bin/power-moment b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/files/bin/power-moment new file mode 100755 index 0000000..e7095e4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/files/bin/power-moment @@ -0,0 +1,90 @@ +#!/bin/sh +# power-moment MOMENT [--force] — run every module's code for one power moment (novox/hq ADR 0211). +# Shipped by the mesh's power module; edit the catalogue. +# +# MOMENT is after-boot, before-sleep, after-wake, before-shutdown, on-mains or on-battery, or +# `supply`, which reads the machine's power source and runs on-mains or on-battery once per change. +# +# The moment's file holds what the controller placed: each module's code under a line `# `. +# Each module's piece runs on its own, with sh, as root, bounded by MESH_POWER_BOUND seconds +# (default 30) and killed 5 s after that, so one module that hangs cannot hold the machine awake or +# stop the next module. Every outcome goes to the journal under the tag mesh-power. +set -u + +moment=${1:-} +force=${2:-} +moments=/etc/mesh-power/moments +bound=${MESH_POWER_BOUND:-30} +runtime=/run/mesh-power + +say() { logger -t mesh-power -- "$*" 2>/dev/null || true; printf '%s\n' "$*"; } + +# The power source as the kernel says it: on mains when any Mains supply is online. A machine with +# no Mains supply (a desktop, a server, a USB-C source only) has no source to change. +source_now() { + found=no + for s in /sys/class/power_supply/*; do + [ "$(cat "$s/type" 2>/dev/null)" = Mains ] || continue + found=yes + [ "$(cat "$s/online" 2>/dev/null)" = 1 ] && { echo on-mains; return; } + done + [ "$found" = yes ] && echo on-battery || echo none +} + +case "$moment" in +supply) + now=$(source_now) + [ "$now" = none ] && exit 0 + mkdir -p "$runtime" + last=$(cat "$runtime/source" 2>/dev/null || true) + [ "$now" = "$last" ] && [ "$force" != --force ] && exit 0 + echo "$now" >"$runtime/source" + moment=$now + ;; +after-boot | before-sleep | after-wake | on-mains | on-battery) ;; +before-shutdown) + # Run only while the machine is really going down. The unit that runs it is stopped at shutdown, + # and also when the module is taken off the machine; the second must not run anyone's shutdown code. + state=$(systemctl is-system-running 2>/dev/null || true) + if [ "$state" != stopping ] && [ "$force" != --force ]; then + say "moment=before-shutdown skipped: the system is $state, not stopping" + exit 0 + fi + ;; +*) + echo "power-moment: no moment $moment (after-boot, before-sleep, after-wake, before-shutdown, on-mains, on-battery, supply)" >&2 + exit 2 + ;; +esac + +file="$moments/$moment" +[ -r "$file" ] || { say "moment=$moment: nothing placed"; exit 0; } + +work=$(mktemp -d) || exit 1 +trap 'rm -rf "$work"' EXIT + +# One file per module piece, in the order the controller placed them. A line that is exactly +# "# " opens a piece; lines before the first are the holder's own header. +awk -v dir="$work" ' + /^# [a-z0-9][a-z0-9-]*$/ { n++; name = substr($0, 3); out = sprintf("%s/%03d-%s", dir, n, name); next } + n > 0 { print > out } +' "$file" + +ran=0 failed=0 +for piece in "$work"/*; do + [ -e "$piece" ] || continue + module=${piece##*/} + module=${module#*-} + start=$(date +%s) + timeout --kill-after=5 "$bound" sh "$piece" "$piece.out" 2>&1 + rc=$? + took=$(($(date +%s) - start)) + ran=$((ran + 1)) + case $rc in + 0) say "moment=$moment module=$module ok in ${took}s" ;; + 124 | 137) failed=$((failed + 1)); say "moment=$moment module=$module ended after ${bound}s: it did not finish" ;; + *) failed=$((failed + 1)); say "moment=$moment module=$module exited $rc: $(tail -n 3 "$piece.out" | tr '\n' ' ')" ;; + esac +done +say "moment=$moment: $ran piece(s), $failed failed" +exit 0 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/module.json.captured new file mode 100644 index 0000000..a110d41 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/bar-battery b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/bar-battery new file mode 100644 index 0000000..d2386ba --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/bar-battery @@ -0,0 +1,9 @@ +[[block]] +block = "battery" +format = "$icon $percentage{ $time|}" +charging_format = "$icon $percentage{ $time|}" +not_charging_format = "$icon $percentage" +full_format = "$icon $percentage" +empty_format = "$icon $percentage" +missing_format = "" + diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/bar-power-draw b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/bar-power-draw new file mode 100644 index 0000000..3d0d338 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/bar-power-draw @@ -0,0 +1,6 @@ +[[block]] +block = "custom" +command = "~/.local/bin/i3status-value power.draw ${machine:name}" +interval = 5 +hide_when_empty = true + diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-boot b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-boot new file mode 100644 index 0000000..c5a90f6 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-boot @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment after-boot, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:after-boot:first}${shell:after-boot:normal}${shell:after-boot:last} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-wake b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-wake new file mode 100644 index 0000000..2655c22 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-wake @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment after-wake, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:after-wake:first}${shell:after-wake:normal}${shell:after-wake:last} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-shutdown b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-shutdown new file mode 100644 index 0000000..65cc76d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-shutdown @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment before-shutdown, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:before-shutdown:first}${shell:before-shutdown:normal}${shell:before-shutdown:last} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-sleep b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-sleep new file mode 100644 index 0000000..e5fc2a5 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-sleep @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment before-sleep, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:before-sleep:first}${shell:before-sleep:normal}${shell:before-sleep:last} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-battery b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-battery new file mode 100644 index 0000000..58e41a9 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-battery @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment on-battery, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:on-battery:first}${shell:on-battery:normal}${shell:on-battery:last} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-mains b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-mains new file mode 100644 index 0000000..b4a6586 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-mains @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment on-mains, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:on-mains:first}${shell:on-mains:normal}${shell:on-mains:last} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/logind.conf.d/power.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/logind.conf.d/power.conf new file mode 100644 index 0000000..9cf93bd --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/logind.conf.d/power.conf @@ -0,0 +1,8 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# The power key and the lid, from this module's settings: the mesh's, then this machine's. +[Login] +HandlePowerKey=${config:handle-power-key} +HandleLidSwitch=${config:handle-lid-switch} +HandleLidSwitchExternalPower=${config:handle-lid-switch-external-power} +HandleLidSwitchDocked=${config:handle-lid-switch-docked} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hibernate.target.d/mesh-power.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hibernate.target.d/mesh-power.conf new file mode 100644 index 0000000..81223d3 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hibernate.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-after-wake.service diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf new file mode 100644 index 0000000..81223d3 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-after-wake.service diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-boot.service b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-boot.service new file mode 100644 index 0000000..b704577 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-boot.service @@ -0,0 +1,13 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Description=Every module's code after boot (mesh power) +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/local/lib/mesh-power/bin/power-moment after-boot + +[Install] +WantedBy=multi-user.target diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-wake.service b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-wake.service new file mode 100644 index 0000000..773b228 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-wake.service @@ -0,0 +1,11 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# Pulled in by the four sleep targets through this module's drop-ins on them, and ordered after +# them, so it runs once the machine is awake again. Never enabled. +[Unit] +Description=Every module's code after waking (mesh power) +After=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target + +[Service] +Type=oneshot +ExecStart=/usr/local/lib/mesh-power/bin/power-moment after-wake diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-shutdown.service b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-shutdown.service new file mode 100644 index 0000000..52db0a4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-shutdown.service @@ -0,0 +1,18 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# Started at boot and left active; stopping it is the shutdown. Ordered after the network, so at +# shutdown it stops, and runs every module's code, while the network is still up. +[Unit] +Description=Every module's code before shutdown (mesh power) +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/bin/true +ExecStop=/usr/local/lib/mesh-power/bin/power-moment before-shutdown +TimeoutStopSec=120 + +[Install] +WantedBy=multi-user.target diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-sleep.service b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-sleep.service new file mode 100644 index 0000000..727587e --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-sleep.service @@ -0,0 +1,11 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# Pulled in by sleep.target through this module's drop-in on it, never enabled: a one-shot waiting +# for a sleep is not a service whose state the mesh can declare. +[Unit] +Description=Every module's code before sleep (mesh power) +Before=sleep.target + +[Service] +Type=oneshot +ExecStart=/usr/local/lib/mesh-power/bin/power-moment before-sleep diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-supply.service b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-supply.service new file mode 100644 index 0000000..5096a00 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-supply.service @@ -0,0 +1,10 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# Started by the module's udev rule when a power supply changes; runs on-mains or on-battery once per +# change of source. +[Unit] +Description=Every module's code for the power source (mesh power) + +[Service] +Type=oneshot +ExecStart=/usr/local/lib/mesh-power/bin/power-moment supply diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/sleep.target.d/mesh-power.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/sleep.target.d/mesh-power.conf new file mode 100644 index 0000000..096f793 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/sleep.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-before-sleep.service diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf new file mode 100644 index 0000000..81223d3 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-after-wake.service diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend.target.d/mesh-power.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend.target.d/mesh-power.conf new file mode 100644 index 0000000..81223d3 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-after-wake.service diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/udev/rules.d/90-mesh-power.rules b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/udev/rules.d/90-mesh-power.rules new file mode 100644 index 0000000..d0a75bb --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/power/templates/udev/rules.d/90-mesh-power.rules @@ -0,0 +1,5 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# Only the charger's change can switch the machine between mains and battery. A battery reports its +# level many times a minute, and USB-C supplies come and go; matching them started the supply unit +# every second or two on the laptop. +SUBSYSTEM=="power_supply", ACTION=="change", ATTR{type}=="Mains", RUN+="/usr/bin/systemctl --no-block start mesh-power-supply.service" diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/powerlevel10k/files/zsh-normal b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/powerlevel10k/files/zsh-normal new file mode 100644 index 0000000..ba4fc1d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/powerlevel10k/files/zsh-normal @@ -0,0 +1,3 @@ +# The prompt: powerlevel10k v1.20.0, pinned in this module (novox/hq ADR 0205), and its configuration. +[[ ! -f ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme ]] || source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme +[[ ! -f ~/.config/powerlevel10k/p10k.zsh ]] || source ~/.config/powerlevel10k/p10k.zsh diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/powerlevel10k/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/powerlevel10k/module.json.captured new file mode 100644 index 0000000..da1bd4d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/records/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/records/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/records/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/records/files/origin b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/records/files/origin new file mode 100644 index 0000000..8c829dc --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/records/files/origin @@ -0,0 +1 @@ +${bound:git:scheme}://${bound:git:at}:${bound:git:port} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/records/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/records/module.json.captured new file mode 100644 index 0000000..db6f7e5 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/redis/files/redis.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/redis/files/redis.conf new file mode 100644 index 0000000..ac27331 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/redis/files/redis.conf @@ -0,0 +1,3 @@ +requirepass ${secret:secret} +appendonly yes +dir /data diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/redis/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/redis/module.json.captured new file mode 100644 index 0000000..58e1106 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/restic/files/backups.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/restic/files/backups.conf new file mode 100644 index 0000000..2f23f90 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/restic/files/data.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/restic/files/data.conf new file mode 100644 index 0000000..f19e849 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/restic/files/retention.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/restic/files/retention.conf new file mode 100644 index 0000000..cd7954c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/restic/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/restic/module.json.captured new file mode 100644 index 0000000..18ee811 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/bin/dmenu b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/bin/dmenu new file mode 100755 index 0000000..b7460e9 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/bin/dmenu @@ -0,0 +1,26 @@ +#!/bin/sh +# dmenu, as this node's launcher answers it (module rofi, novox/hq ADR 0208). The node-launcher +# seat's dmenu-compatible command: choices on standard input, the chosen one on standard output, +# exit 1 when nothing was chosen. A script calls `dmenu` and works whichever module holds the seat. +# +# rofi understands dmenu's -p, -l and -i. dmenu's look options (-fn, -nb, -nf, -sb, -sf, -m, -w) +# take a value rofi would misread, so they are dropped with it; -b and -f are dropped alone. The +# look is the launcher's theme. +set -u +n=$# +while [ "$n" -gt 0 ]; do + arg=$1 + shift + n=$((n - 1)) + case $arg in + -fn | -nb | -nf | -sb | -sf | -m | -w) + if [ "$n" -gt 0 ]; then + shift + n=$((n - 1)) + fi + ;; + -b | -f) ;; + *) set -- "$@" "$arg" ;; + esac +done +exec rofi -dmenu "$@" diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-launch b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-launch new file mode 100755 index 0000000..788f23c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-launch @@ -0,0 +1,39 @@ +#!/bin/sh +# rofi-launch drun|run|window|filebrowser|sudo (module rofi, novox/hq ADR 0208): what the launcher's +# key bindings run. +# +# After a resume the keyboard grab can fail for a moment, and rofi then exits at once: a key press +# that opens nothing. So a failure within half a second is tried again, up to five times. A person +# closing the menu takes longer than that, and is never shown it a second time. +set -u +mode=${1:-drun} + +now_ms() { date +%s%3N; } + +attempt() { + i=0 + while [ "$i" -lt 5 ]; do + start=$(now_ms) + "$@" && return 0 + [ $(($(now_ms) - start)) -ge 500 ] && return 1 + i=$((i + 1)) + sleep 0.1 + done + return 1 +} + +case $mode in +drun | run | window | filebrowser) + attempt rofi -show "$mode" + ;; +sudo) + # A command line, run with sudo in the node's terminal. + command=$(attempt rofi -dmenu -p sudo &2 + exit 2 + ;; +esac diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-powermenu b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-powermenu new file mode 100755 index 0000000..2bdf64c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-powermenu @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# rofi-powermenu (module rofi, novox/hq ADR 0208): lock, suspend, log out, reboot, shut down. +# +# It belongs to the session, and it is the launcher's: its whole face is rofi's. Every action is a +# verb of logind or the service manager, so nothing here names a window manager or a locker: +# - lock asks logind to lock the session, which the holder of node-lock-screen answers; +# - log out ends this login session, whichever window manager runs in it. +# Adopted from the workstations' power menu of 2026-10-04 (after adi1090x's powermenu type-2). +set -u + +theme=mesh-powermenu +uptime="$(uptime -p | sed -e 's/^up //')" + +shutdown='󰤂' +reboot='󰜉' +lock='󰌾' +suspend='󰤄' +logout='󰍃' +yes='󰄲' +no='󰅖' + +menu() { + rofi -dmenu -p "Uptime: $uptime" -mesg "Uptime: $uptime" -theme "$theme" +} + +confirm() { + printf '%s\n%s\n' "$yes" "$no" | rofi -dmenu -p 'Confirmation' -mesg 'Are you sure?' -theme "$theme" \ + -theme-str 'window {location: center; anchor: center; fullscreen: false; width: 350px;}' \ + -theme-str 'mainbox {children: [ "message", "listview" ];}' \ + -theme-str 'listview {columns: 2; lines: 1;}' \ + -theme-str 'element-text {horizontal-align: 0.5;}' \ + -theme-str 'textbox {horizontal-align: 0.5;}' +} + +session() { + # The login session this menu runs in: the session's own id, else logind's answer for this process. + if [ -n "${XDG_SESSION_ID:-}" ]; then + echo "$XDG_SESSION_ID" + else + loginctl show-session auto -p Id --value 2>/dev/null + fi +} + +confirmed() { [ "$(confirm)" = "$yes" ]; } + +chosen="$(printf '%s\n' "$lock" "$suspend" "$logout" "$reboot" "$shutdown" | menu)" || exit 0 +case $chosen in +"$lock") loginctl lock-session "$(session)" ;; +"$suspend") confirmed && systemctl suspend ;; +"$logout") confirmed && loginctl terminate-session "$(session)" ;; +"$reboot") confirmed && systemctl reboot ;; +"$shutdown") confirmed && systemctl poweroff ;; +esac +exit 0 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/config.rasi b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/config.rasi new file mode 100644 index 0000000..d123371 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/config.rasi @@ -0,0 +1,54 @@ +/* rofi, the launcher (module rofi, novox/hq ADR 0208). Owned by the mesh: this file is replaced at + * every push. Adopted from the two workstations' file of 2026-10-04: the settings that differ from + * rofi's defaults, nothing else. Its look is the theme "mesh", in themes/ beside this file. */ + +configuration { + modi: "drun,run,window,filebrowser"; + show-icons: true; + case-sensitive: false; + normalize-match: true; + matching: "normal"; + tokenize: true; + cycle: true; + steal-focus: false; + click-to-exit: true; + sort: false; + + /* Applications: the user's and the system's desktop entries, read fresh each time so a + * program installed a minute ago is there. */ + drun-match-fields: "name,generic,exec,categories,keywords"; + drun-display-format: "{name} [({generic})]"; + drun-show-actions: false; + drun-url-launcher: "xdg-open"; + drun-use-desktop-cache: false; + drun-reload-desktop-cache: false; + run,drun { + fallback-icon: "application-x-addon"; + } + + /* A command run from `run` opens in the node's terminal when it asks for one. */ + terminal: "rofi-sensible-terminal"; + run-shell-command: "{terminal} -e {cmd}"; + ssh-command: "{terminal} -e {ssh-client} {host} [-p {port}]"; + parse-hosts: true; + parse-known-hosts: true; + + window-match-fields: "title,class,role,name,desktop"; + window-format: "{w} - {c} - {t:0}"; + + disable-history: false; + max-history-size: 25; + + filebrowser { + directories-first: true; + sorting-method: "name"; + } + + display-window: "Windows"; + display-run: "Run"; + display-ssh: "SSH"; + display-drun: "Apps"; + display-filebrowser: "Files"; +} + +@theme "mesh" diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/i3/50-rofi.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/i3/50-rofi.conf new file mode 100644 index 0000000..1395d2c --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/i3/50-rofi.conf @@ -0,0 +1,8 @@ +# The launcher's key bindings (module rofi, novox/hq ADR 0208). Owned by the mesh: replaced at every +# push. i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`, after +# the variables it sets, so $mod is i3's. +bindsym $mod+d exec --no-startup-id ~/.local/bin/rofi-launch drun +bindsym $mod+t exec --no-startup-id ~/.local/bin/rofi-launch run +bindsym $mod+Shift+t exec --no-startup-id ~/.local/bin/rofi-launch sudo +bindsym $mod+Shift+w exec --no-startup-id ~/.local/bin/rofi-launch window +bindsym $mod+Escape exec --no-startup-id ~/.local/bin/rofi-powermenu diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh-powermenu.rasi b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh-powermenu.rasi new file mode 100644 index 0000000..5b837f4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh-powermenu.rasi @@ -0,0 +1,172 @@ +/** + * The theme "mesh-powermenu" (module rofi, novox/hq ADR 0208): the power menu's five buttons. + * Owned by the mesh; replaced at every push. Adopted from the workstations' power menu theme of + * 2026-10-04 (after adi1090x's powermenu type-2, style-1), its colours inlined. + **/ + +/*****----- Configuration -----*****/ +configuration { + show-icons: false; +} + +/*****----- Global Properties -----*****/ +* { + background: #000000FF; + background-alt: #282B31FF; + foreground: #FFFFFFFF; + selected: #DE5200FF; + active: #DE5200FF; + urgent: #CC0000FF; + + font: "JetBrainsMono Nerd Font 11"; +} + + +/*****----- Main Window -----*****/ +window { + /* properties for window widget */ + transparency: "real"; + location: center; + anchor: center; + fullscreen: false; + width: 800px; + x-offset: 0px; + y-offset: 0px; + + /* properties for all widgets */ + enabled: true; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + cursor: "default"; + background-color: @background; +} + +/*****----- Main Box -----*****/ +mainbox { + enabled: true; + spacing: 15px; + margin: 0px; + padding: 30px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: transparent; + children: [ "inputbar", "listview" ]; +} + +/*****----- Inputbar -----*****/ +inputbar { + enabled: true; + spacing: 15px; + margin: 0px; + padding: 0px; + border: 0px; + border-radius: 0px; + border-color: @selected; + background-color: transparent; + text-color: @foreground; + children: [ "textbox-prompt-colon", "prompt"]; +} + +dummy { + background-color: transparent; +} + +textbox-prompt-colon { + enabled: true; + expand: false; + str: ""; + padding: 12px 16px; + border-radius: 0px; + background-color: @urgent; + text-color: @background; +} +prompt { + enabled: true; + padding: 12px; + border-radius: 0px; + background-color: @active; + text-color: @background; +} + +/*****----- Message -----*****/ +message { + enabled: true; + margin: 0px; + padding: 12px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: @background-alt; + text-color: @foreground; +} +textbox { + background-color: inherit; + text-color: inherit; + vertical-align: 0.5; + horizontal-align: 0.5; + placeholder-color: @foreground; + blink: true; + markup: true; +} +error-message { + padding: 12px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: @background; + text-color: @foreground; +} + +/*****----- Listview -----*****/ +listview { + enabled: true; + columns: 5; + lines: 1; + cycle: true; + dynamic: true; + scrollbar: false; + layout: vertical; + reverse: false; + fixed-height: true; + fixed-columns: true; + + spacing: 15px; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: transparent; + text-color: @foreground; + cursor: "default"; +} + +/*****----- Elements -----*****/ +element { + enabled: true; + spacing: 0px; + margin: 0px; + padding: 40px 10px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: @background-alt; + text-color: @foreground; + cursor: pointer; +} +element-text { + font: "JetBrainsMono Nerd Font Bold 32"; + background-color: transparent; + text-color: inherit; + cursor: inherit; + vertical-align: 0.5; + horizontal-align: 0.5; +} +element selected.normal { + background-color: var(selected); + text-color: var(background); +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh.rasi b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh.rasi new file mode 100644 index 0000000..8be0f55 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh.rasi @@ -0,0 +1,263 @@ +/** + * The theme "mesh" (module rofi, novox/hq ADR 0208): every rofi window unless a caller names + * another. Owned by the mesh; replaced at every push. Adopted from the workstations' theme of + * 2026-10-04 (after adi1090x's launcher type-4, style-1), its colour file inlined so the theme is + * one file, and its face the monospace one every desktop module names. + **/ + +/*****----- Global Properties -----*****/ +* { + background: #000000FF; + background-alt: #282B31FF; + foreground: #FFFFFFFF; + selected: #DE5200FF; + active: #DE5200FF; + urgent: #CC0000FF; + + font: "JetBrainsMono Nerd Font 11"; + + border-colour: var(selected); + handle-colour: var(selected); + background-colour: var(background); + foreground-colour: var(foreground); + alternate-background: var(background-alt); + normal-background: var(background); + normal-foreground: var(foreground); + urgent-background: var(urgent); + urgent-foreground: var(background); + active-background: var(active); + active-foreground: var(background); + selected-normal-background: var(selected); + selected-normal-foreground: var(background); + selected-urgent-background: var(active); + selected-urgent-foreground: var(background); + selected-active-background: var(urgent); + selected-active-foreground: var(background); + alternate-normal-background: var(background); + alternate-normal-foreground: var(foreground); + alternate-urgent-background: var(urgent); + alternate-urgent-foreground: var(background); + alternate-active-background: var(active); + alternate-active-foreground: var(background); +} + +/*****----- Main Window -----*****/ +window { + transparency: "real"; + location: center; + anchor: center; + fullscreen: false; + width: 600px; + x-offset: 0px; + y-offset: 0px; + enabled: true; + margin: 0px; + padding: 0px; + border: 1px solid; + border-radius: 0px; + border-color: @border-colour; + cursor: "default"; + background-color: @background-colour; +} + +/*****----- Main Box -----*****/ +mainbox { + enabled: true; + spacing: 10px; + margin: 0px; + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + children: [ "inputbar", "message", "listview", "mode-switcher" ]; +} + +/*****----- Inputbar -----*****/ +inputbar { + enabled: true; + spacing: 10px; + margin: 0px; + padding: 10px; + border: 0px 0px 1px 0px; + border-radius: 0px; + border-color: @border-colour; + background-color: @alternate-background; + text-color: @foreground-colour; + children: [ "prompt", "entry" ]; +} + +prompt { + enabled: true; + background-color: inherit; + text-color: inherit; +} +textbox-prompt-colon { + enabled: true; + expand: false; + str: "::"; + background-color: inherit; + text-color: inherit; +} +entry { + enabled: true; + background-color: inherit; + text-color: inherit; + cursor: text; + placeholder: "Search..."; + placeholder-color: inherit; +} + +/*****----- Listview -----*****/ +listview { + enabled: true; + columns: 1; + lines: 8; + cycle: true; + dynamic: true; + scrollbar: false; + layout: vertical; + reverse: false; + fixed-height: true; + fixed-columns: true; + spacing: 0px; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + text-color: @foreground-colour; + cursor: "default"; +} +scrollbar { + handle-width: 5px; + handle-color: @handle-colour; + border-radius: 0px; + background-color: @alternate-background; +} + +/*****----- Elements -----*****/ +element { + enabled: true; + spacing: 10px; + margin: 0px; + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + text-color: @foreground-colour; + cursor: pointer; +} +element normal.normal { + background-color: var(normal-background); + text-color: var(normal-foreground); +} +element normal.urgent { + background-color: var(urgent-background); + text-color: var(urgent-foreground); +} +element normal.active { + background-color: var(active-background); + text-color: var(active-foreground); +} +element selected.normal { + background-color: var(selected-normal-background); + text-color: var(selected-normal-foreground); +} +element selected.urgent { + background-color: var(selected-urgent-background); + text-color: var(selected-urgent-foreground); +} +element selected.active { + background-color: var(selected-active-background); + text-color: var(selected-active-foreground); +} +element alternate.normal { + background-color: var(alternate-normal-background); + text-color: var(alternate-normal-foreground); +} +element alternate.urgent { + background-color: var(alternate-urgent-background); + text-color: var(alternate-urgent-foreground); +} +element alternate.active { + background-color: var(alternate-active-background); + text-color: var(alternate-active-foreground); +} +element-icon { + background-color: transparent; + text-color: inherit; + size: 24px; + cursor: inherit; +} +element-text { + background-color: transparent; + text-color: inherit; + highlight: inherit; + cursor: inherit; + vertical-align: 0.5; + horizontal-align: 0.0; +} + +/*****----- Mode Switcher -----*****/ +mode-switcher { + enabled: true; + spacing: 10px; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + text-color: @foreground-colour; +} +button { + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: @alternate-background; + text-color: inherit; + cursor: pointer; +} +button selected { + background-color: var(selected-normal-background); + text-color: var(selected-normal-foreground); +} + +/*****----- Message -----*****/ +message { + enabled: true; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + text-color: @foreground-colour; +} +textbox { + font: "Inter 11"; + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: @alternate-background; + text-color: @foreground-colour; + vertical-align: 0.5; + horizontal-align: 0.0; + highlight: none; + placeholder-color: @foreground-colour; + blink: true; + markup: true; +} +error-message { + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: @background-colour; + text-color: @foreground-colour; +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/rofi/module.json.captured new file mode 100644 index 0000000..fbd061d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/route-adapter/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/route-adapter/files/config.json new file mode 100644 index 0000000..1d8d8c2 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/route-adapter/files/config.json @@ -0,0 +1,6 @@ +{ + "dynamic": "/services/traefik/dynamic", + "entrypoint": "websecure", + "certificate-resolver": "le", + "machine": "host.docker.internal" +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/route-adapter/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/route-adapter/module.json.captured new file mode 100644 index 0000000..5234c78 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/screen-lock/files/bin/screen-lock b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/screen-lock/files/bin/screen-lock new file mode 100755 index 0000000..18008bd --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/screen-lock/files/bin/screen-lock @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# screen-lock (module screen-lock, novox/hq ADR 0208): the locker. xss-lock runs it on idle, before +# suspend and on logind's Lock, and node-lock-screen's `lock` ends in it. +# +# The operator's look, adopted from the predecessor's my-i3lock: the screen as it was, blurred, with +# an orange ring, the time and the date. That needs i3lock-color, from the distribution's user +# repository, kept as found until the mesh carries such software (novox/hq research 027, question 1). +# On a machine without it the distribution's i3lock shows the same blurred screen, taken here, with +# its own plain ring; failing that, black. +# +# Before a suspend, xss-lock hands this script a lock on the sleep (XSS_SLEEP_LOCK_FD) and suspends +# once it is released. The locker must not inherit it, or the machine would wait for the unlock +# before sleeping; it is released once i3lock is up, so the machine never sleeps unlocked. This is +# xss-lock's own documented pattern for i3lock. +set -u + +# One locker: a second press of the key, or a lock while locked, changes nothing. +if pgrep -xu "$EUID" i3lock >/dev/null; then + exit 0 +fi + +# The colour build numbers its versions .c. (2.13.c.5); its version line never says "color". +if i3lock --version 2>&1 | grep -qE '[0-9]\.c\.[0-9]'; then + blank='#00000000' clear='#ffffff22' accent='#ca4a00' wrong='#880000bb' verifying='#bb00bbbb' + options=( + --insidever-color="$clear" --ringver-color="$verifying" + --insidewrong-color="$clear" --ringwrong-color="$wrong" + --inside-color="$blank" --ring-color="$accent" --line-color="$blank" --separator-color="$accent" + --verif-color="$accent" --wrong-color="$accent" --time-color="$accent" --date-color="$accent" + --layout-color="$accent" --keyhl-color="$wrong" --bshl-color="$wrong" + --screen 1 --blur 5 --ring-width=7.0 --clock --indicator + --time-str="%H:%M:%S" --date-str="%A, %Y-%m-%d" + --time-font=sans-serif --date-font=sans-serif --verif-font=sans-serif + --wrong-font=sans-serif --layout-font=sans-serif --keylayout 1 + --show-failed-attempts --ignore-empty-password + ) +else + options=(--color=000000 --show-failed-attempts --ignore-empty-password) + shot="${XDG_RUNTIME_DIR:-/tmp}/screen-lock.png" + if command -v magick >/dev/null && magick import -window root -resize 25% -blur 0x3 -resize 400% "$shot" 2>/dev/null; then + options+=(--image="$shot") + fi +fi + +if [[ -e /dev/fd/${XSS_SLEEP_LOCK_FD:--1} ]]; then + kill_i3lock() { pkill -xu "$EUID" "$@" i3lock; } + trap kill_i3lock TERM INT + i3lock "${options[@]}" {XSS_SLEEP_LOCK_FD}<&- + exec {XSS_SLEEP_LOCK_FD}<&- + while kill_i3lock -0; do + sleep 0.5 + done +else + trap 'kill %%' TERM INT + i3lock --nofork "${options[@]}" & + wait +fi diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/screen-lock/files/xinitrc-normal b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/screen-lock/files/xinitrc-normal new file mode 100644 index 0000000..f7f7123 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/screen-lock/files/xinitrc-normal @@ -0,0 +1,8 @@ +# The lock screen (module screen-lock, novox/hq ADR 0208): the session locks after 30 minutes idle, +# the displays go to standby and suspend then and off after an hour, and xss-lock runs the locker on +# idle, before suspend and on logind's Lock. xss-lock needs this login session, so it starts here and +# not as a unit. It is started again if it exits, for as long as this session lasts ($$ is the +# session's own process, which becomes the window manager). +xset s 1800 1800 +xset dpms 1800 1800 3600 +(while kill -0 $$ 2>/dev/null; do xss-lock --transfer-sleep-lock -- "$HOME/.local/bin/screen-lock"; sleep 2; done) & diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/screen-lock/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/screen-lock/module.json.captured new file mode 100644 index 0000000..1791c79 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/searxng/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/searxng/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/searxng/files/config.json @@ -0,0 +1 @@ +{} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/searxng/files/settings.yml b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/searxng/files/settings.yml new file mode 100644 index 0000000..6d7248e --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/searxng/files/settings.yml @@ -0,0 +1,16 @@ +{ + "use_default_settings": true, + "server": { + "secret_key": "${secret:secret}", + "base_url": false, + "limiter": false, + "image_proxy": false, + "public_instance": false + }, + "search": { + "formats": ["html", "json"] + }, + "valkey": { + "url": "valkey://valkey:6379/0" + } +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/searxng/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/searxng/module.json.captured new file mode 100644 index 0000000..cee18e4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sensors/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sensors/module.json.captured new file mode 100644 index 0000000..3df6783 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/showcase/files/README.md b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/showcase/files/README.md new file mode 100644 index 0000000..5c369cc --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/showcase/files/README.md @@ -0,0 +1,6 @@ +# showcase's packed files + +Packed as an `archive` artifact and unpacked onto the machine by an `archive` resource. + +This exists to exercise the case inlining cannot serve: a tree of files that belongs on a machine +and would make a declaration enormous if it were carried inside one. diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/showcase/files/marker.txt b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/showcase/files/marker.txt new file mode 100644 index 0000000..0246772 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/showcase/files/marker.txt @@ -0,0 +1 @@ +showcase diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/showcase/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/showcase/module.json.captured new file mode 100644 index 0000000..e657e2a --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/showcase/templates/showcase.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/showcase/templates/showcase.env new file mode 100644 index 0000000..8ea4a90 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/showcase/templates/showcase.env @@ -0,0 +1,5 @@ +SHOWCASE_GREETING=hello +SHOWCASE_EVERY_SECONDS=30 +SHOWCASE_STATE=/var/lib/showcase +SHOWCASE_DATABASE=${bound:postgres-database:at} +SHOWCASE_LISTEN=${port:8080} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/slack/files/display-session b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/slack/files/display-session new file mode 100644 index 0000000..01660f8 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/slack/files/display-session @@ -0,0 +1,11 @@ +# Slack (module slack, novox/hq ADR 0208, ADR 0212). Owned by the mesh: replaced at every push. +# Its one start: at login, to the tray (-s), with the arguments of the package's own desktop entry. +# No XDG autostart entry starts it as well; slack_check names one if it appears. +exec --no-startup-id /usr/bin/slack --gtk-version=3 -s +# The chat window. Slack owns four X windows, and the only one i3 manages has the class "slack" in +# lower case; the three of class "Slack" (the packaged entry's StartupWMClass) are its unmanaged +# helpers and its tray icon, so a rule on class="Slack" matches nothing. window_role keeps a call or +# screen-share window out of the rules. $ws3 is i3's, in scope here. +for_window [class="(?i)^slack$" window_role="browser-window"] move to workspace $ws3 +for_window [class="(?i)^slack$" window_role="browser-window"] floating disable +for_window [class="(?i)^slack$" window_role="browser-window"] border pixel 2 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/slack/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/slack/module.json.captured new file mode 100644 index 0000000..9feeedb --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/snapd/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/snapd/module.json.captured new file mode 100644 index 0000000..93ad730 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/files/config b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/files/config new file mode 100644 index 0000000..dac07fb --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/files/config @@ -0,0 +1,6 @@ +# The mesh's region (module ssh-client, novox/hq research 027/03). It comes first because ssh +# takes the first value it finds for each option. It brings in ~/.ssh/config.d/ in name order: +# 00-mesh, the mesh's Host per machine, then each file another module places there. Replaced at +# every push. Everything below it is yours, kept as you wrote it, and applies to every host the +# files above leave unsettled. +Include ~/.ssh/config.d/* diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/files/forge b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/files/forge new file mode 100644 index 0000000..6bbf4e2 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/files/forge @@ -0,0 +1,7 @@ +Host ${bound:forge-account:ssh_alias} + HostName ${bound:forge-account:ssh_host} + Port ${bound:forge-account:ssh_port} + HostKeyAlias ${bound:forge-account:ssh_alias} + User git + IdentityFile ~/.ssh/mesh_ed25519 + IdentitiesOnly yes diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/files/gitconfig b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/files/gitconfig new file mode 100644 index 0000000..0525230 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/files/gitconfig @@ -0,0 +1,2 @@ +[url "ssh://${bound:forge-account:ssh_alias}/"] + insteadOf = ${bound:forge-account:https_base} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/files/known_hosts b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/files/known_hosts new file mode 100644 index 0000000..e0df6d8 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/files/known_hosts @@ -0,0 +1 @@ +${bound:forge-account:ssh_alias} ${bound:forge-account:host_key} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/ssh-client/module.json.captured new file mode 100644 index 0000000..a696544 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sshd/files/10-mesh.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sshd/files/10-mesh.conf new file mode 100644 index 0000000..86cd883 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sshd/files/10-mesh.conf @@ -0,0 +1,10 @@ +# Managed by the mesh (module sshd). Replaced on every push; edit the catalogue instead. +Port 22 +PermitRootLogin no +PasswordAuthentication no +PubkeyAuthentication yes +KbdInteractiveAuthentication no +UsePAM yes +X11Forwarding no +PrintMotd no +AcceptEnv LANG LC_* diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sshd/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sshd/module.json.captured new file mode 100644 index 0000000..066e058 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/step-ca/files/config.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/step-ca/files/config.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/step-ca/files/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/step-ca/files/init.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/step-ca/files/init.env new file mode 100644 index 0000000..31d0896 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/step-ca/files/init.env @@ -0,0 +1,2 @@ +DOCKER_STEPCA_INIT_PASSWORD=${secret:password} +DOCKER_STEPCA_INIT_DNS_NAMES=${machine:at},${machine:name},localhost,127.0.0.1 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/step-ca/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/step-ca/module.json.captured new file mode 100644 index 0000000..b11d2b9 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sudo/files/10-mesh-operator b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sudo/files/10-mesh-operator new file mode 100644 index 0000000..997a49a --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sudo/files/10-mesh-operator @@ -0,0 +1,9 @@ +# The mesh's (module sudo, novox/hq to-be 42, research 027): the operator account escalates +# without a prompt. The mesh's tools that act as root run `sudo -n` as this account and rely on it; +# until this file, every machine said so only in a line set by hand in /etc/sudoers. +# Written whole at every push: an edit here is overwritten. A file of this directory whose name +# holds a dot or ends in ~ is not read by sudo; this name holds neither. +# Every command run through sudo gets a terminal of its own (novox/hq ADR 0266): an agent the operator +# starts as the agent account through sudo cannot push keystrokes into the operator's shell (TIOCSTI). +Defaults use_pty +${machine:account} ALL=(ALL:ALL) NOPASSWD: ALL diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sudo/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/sudo/module.json.captured new file mode 100644 index 0000000..364ce5f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/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/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/analytics-env.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/analytics-env.env new file mode 100644 index 0000000..b1b877f --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/analytics-env.env @@ -0,0 +1,14 @@ +LOGFLARE_NODE_HOST=127.0.0.1 +DB_USERNAME=supabase_admin +DB_DATABASE=_supabase +DB_HOSTNAME=supabase-db +DB_PORT=5432 +DB_PASSWORD=${secret:postgres} +DB_SCHEMA=_analytics +LOGFLARE_API_KEY=${secret:logflare} +LOGFLARE_SINGLE_TENANT=true +LOGFLARE_SUPABASE_MODE=true +LOGFLARE_MIN_CLUSTER_SIZE=1 +POSTGRES_BACKEND_URL=postgresql://supabase_admin:${secret:postgres}@supabase-db:5432/_supabase +POSTGRES_BACKEND_SCHEMA=_analytics +LOGFLARE_FEATURE_FLAG_OVERRIDE=multibackend=true diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/analytics-reconcile.exs b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/analytics-reconcile.exs new file mode 100644 index 0000000..179776e --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/analytics-reconcile.exs @@ -0,0 +1,86 @@ +# Generated by the mesh. Do not edit: module supabase writes this file and replaces it at every push. +# +# logflare 1.4.0 copies two of its settings into its own database once and never reads them again: +# LOGFLARE_API_KEY becomes the default user's api_key when that user is created, and +# POSTGRES_BACKEND_URL becomes each source's backend when that source is created. A changed value +# reaches neither: a rotated key is refused, and a moved database is never written to (every +# source here pointed at db:5432, a name that resolves nowhere, so nothing was stored). +# +# Run by the start script before logflare starts, through logflare's own code (its Repo, its Users +# and Backends contexts and their changesets): it makes both copies say what the environment says. +# It changes nothing when they already do, says what it changed, never prints the key or a URL's +# password, and stops the start when it cannot finish. +fail = fn why -> + IO.puts(:stderr, "analytics: #{why}; not starting") + System.halt(1) +end + +say = fn what -> IO.puts(:stderr, "analytics: #{what}") end + +key = System.get_env("LOGFLARE_API_KEY") +url = System.get_env("POSTGRES_BACKEND_URL") +schema = System.get_env("POSTGRES_BACKEND_SCHEMA") + +if key in [nil, ""], do: fail.("LOGFLARE_API_KEY is not set, so the key logflare must accept is unknown") +if url in [nil, ""], do: fail.("POSTGRES_BACKEND_URL is not set, so where logflare must store logs is unknown") + +where = fn u -> + case URI.parse(to_string(u)) do + %URI{host: host, port: port, path: path} when is_binary(host) -> "#{host}:#{port}#{path}" + _ -> "an unreadable URL" + end +end + +Application.ensure_all_started(:ssl) + +outcome = + Ecto.Migrator.with_repo(Logflare.Repo, fn _repo -> + # Backends.update_source_backend_config restarts the source's pipeline after saving, and looks + # it up in this registry to do so; logflare is not running yet, so the registry is empty and + # there is nothing to restart. + {:ok, _} = Registry.start_link(keys: :unique, name: Logflare.Backends.SourceRegistry) + + case Logflare.SingleTenant.get_default_user() do + nil -> + say.("no default user yet; logflare creates it from LOGFLARE_API_KEY and POSTGRES_BACKEND_URL as it starts") + :ok + + user -> + if user.api_key == key and is_nil(user.old_api_key) do + say.("the default user's API key is LOGFLARE_API_KEY already") + else + # old_api_key is cleared too: logflare's UI can swap it back in, and the key replaced + # here may be one that leaked. + case Logflare.Users.update_user_all_fields(user, %{api_key: key, old_api_key: nil}) do + {:ok, _} -> say.("the default user's API key was replaced with LOGFLARE_API_KEY") + {:error, changeset} -> fail.("the default user's API key could not be replaced: #{inspect(changeset.errors)}") + end + end + + results = + for source <- Logflare.Sources.list_sources_by_user(user), + backend <- Logflare.Backends.list_source_backends(source), + backend.type == :postgres do + if backend.config.url == url and backend.config.schema == schema do + :same + else + case Logflare.Backends.update_source_backend_config(backend, %{url: url, schema: schema}) do + {:ok, _} -> + say.("source #{source.name} stored logs at #{where.(backend.config.url)} schema #{inspect(backend.config.schema)}; now at #{where.(url)} schema #{inspect(schema)}") + + {:error, changeset} -> + fail.("source #{source.name}'s backend could not be pointed at #{where.(url)}: #{inspect(changeset.errors)}") + end + end + end + + say.("#{Enum.count(results, &(&1 == :same))} source backend(s) already at #{where.(url)}") + + :ok + end + end) + +case outcome do + {:ok, :ok, _} -> :ok + other -> fail.("logflare's database could not be reconciled: #{inspect(other)}") +end diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/analytics-start.sh b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/analytics-start.sh new file mode 100644 index 0000000..bf39892 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/analytics-start.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Generated by the mesh. Do not edit: module supabase writes this file and replaces it at every push. +# +# Two things logflare 1.4.0 does not do for itself, done here before it starts; either failing +# stops the start, with the reason as the last line of this container's log. +# +# 1. The API key never travels in a URL. logflare prints the whole URL of every request that fails, +# query string included, in the error report Plug.Cowboy writes ("Request: POST +# /api/logs?source_name=...&api_key=..."). That is an error, so no log level hides it while errors +# are logged at all (novox/hq issue 268). logflare reads the key from the x-api-key header as well, +# and the mesh's caller, vector, sends it there; this refuses to start logflare while the vector +# config it is given still passes the key in a URL: an analytics that does not start says why +# here, and one that leaks says nothing. +# +# 2. Its database says what its environment says. logflare copies LOGFLARE_API_KEY into its default +# user and POSTGRES_BACKEND_URL into every source's backend once, when it creates them, and never +# again; reconcile.exs makes both copies match the environment through logflare's own code, after +# its migrations and before it starts. That is what lets the mesh rotate the key by starting +# logflare again, and what moves the sources' backend when the database's address or password +# changes. +set -e +conf=/run/logflare/vector.yml +if [ ! -r "$conf" ]; then + echo "analytics: $conf is not there to check, so whether the API key travels in a URL is unknown; not starting (novox/hq issue 268)" >&2 + exit 1 +fi +if grep -v '^[[:space:]]*#' "$conf" | grep -q 'api_key='; then + echo "analytics: $conf passes the API key in a URL, and logflare prints a failed request's URL; not starting (novox/hq issue 268)" >&2 + exit 1 +fi +reconcile=/run/logflare/reconcile.exs +if [ ! -r "$reconcile" ]; then + echo "analytics: $reconcile is not there, so whether logflare's stored key and backends match its environment is unknown; not starting" >&2 + exit 1 +fi +cd /opt/app/rel/logflare/bin +# The tables reconcile.exs reads exist only once the migrations have run; run.sh runs them again, +# which finds nothing left to do. +./logflare eval Logflare.Release.migrate +./logflare eval 'Code.eval_file("/run/logflare/reconcile.exs")' +exec sh run.sh diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/jwt.sql b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/jwt.sql new file mode 100644 index 0000000..90d8822 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/jwt.sql @@ -0,0 +1,2 @@ +ALTER DATABASE postgres SET "app.settings.jwt_secret" TO '${secret:jwt}'; +ALTER DATABASE postgres SET "app.settings.jwt_exp" TO '3600'; diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/logs.sql b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/logs.sql new file mode 100644 index 0000000..255c0f4 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/logs.sql @@ -0,0 +1,6 @@ +\set pguser `echo "$POSTGRES_USER"` + +\c _supabase +create schema if not exists _analytics; +alter schema _analytics owner to :pguser; +\c postgres diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/pooler.sql b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/pooler.sql new file mode 100644 index 0000000..162c5b9 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/pooler.sql @@ -0,0 +1,6 @@ +\set pguser `echo "$POSTGRES_USER"` + +\c _supabase +create schema if not exists _supavisor; +alter schema _supavisor owner to :pguser; +\c postgres diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/realtime.sql b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/realtime.sql new file mode 100644 index 0000000..4d4b9ff --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/realtime.sql @@ -0,0 +1,4 @@ +\set pguser `echo "$POSTGRES_USER"` + +create schema if not exists _realtime; +alter schema _realtime owner to :pguser; diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/roles.sql b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/roles.sql new file mode 100644 index 0000000..2a05c0b --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/roles.sql @@ -0,0 +1,7 @@ +\set pgpass `echo "$POSTGRES_PASSWORD"` + +ALTER USER authenticator WITH PASSWORD :'pgpass'; +ALTER USER pgbouncer WITH PASSWORD :'pgpass'; +ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass'; diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/supabase.sql b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/supabase.sql new file mode 100644 index 0000000..6236ae1 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/supabase.sql @@ -0,0 +1,3 @@ +\set pguser `echo "$POSTGRES_USER"` + +CREATE DATABASE _supabase WITH OWNER :pguser; diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/webhooks.sql b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/webhooks.sql new file mode 100644 index 0000000..5837b86 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/db-init/webhooks.sql @@ -0,0 +1,208 @@ +BEGIN; + -- Create pg_net extension + CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; + -- Create supabase_functions schema + CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; + GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role; + -- supabase_functions.migrations definition + CREATE TABLE supabase_functions.migrations ( + version text PRIMARY KEY, + inserted_at timestamptz NOT NULL DEFAULT NOW() + ); + -- Initial supabase_functions migration + INSERT INTO supabase_functions.migrations (version) VALUES ('initial'); + -- supabase_functions.hooks definition + CREATE TABLE supabase_functions.hooks ( + id bigserial PRIMARY KEY, + hook_table_id integer NOT NULL, + hook_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + request_id bigint + ); + CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id); + CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name); + COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; + CREATE FUNCTION supabase_functions.http_request() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + request_id bigint; + payload jsonb; + url text := TG_ARGV[0]::text; + method text := TG_ARGV[1]::text; + headers jsonb DEFAULT '{}'::jsonb; + params jsonb DEFAULT '{}'::jsonb; + timeout_ms integer DEFAULT 1000; + BEGIN + IF url IS NULL OR url = 'null' THEN + RAISE EXCEPTION 'url argument is missing'; + END IF; + + IF method IS NULL OR method = 'null' THEN + RAISE EXCEPTION 'method argument is missing'; + END IF; + + IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN + headers = '{"Content-Type": "application/json"}'::jsonb; + ELSE + headers = TG_ARGV[2]::jsonb; + END IF; + + IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN + params = '{}'::jsonb; + ELSE + params = TG_ARGV[3]::jsonb; + END IF; + + IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN + timeout_ms = 1000; + ELSE + timeout_ms = TG_ARGV[4]::integer; + END IF; + + CASE + WHEN method = 'GET' THEN + SELECT http_get INTO request_id FROM net.http_get( + url, + params, + headers, + timeout_ms + ); + WHEN method = 'POST' THEN + payload = jsonb_build_object( + 'old_record', OLD, + 'record', NEW, + 'type', TG_OP, + 'table', TG_TABLE_NAME, + 'schema', TG_TABLE_SCHEMA + ); + + SELECT http_post INTO request_id FROM net.http_post( + url, + payload, + params, + headers, + timeout_ms + ); + ELSE + RAISE EXCEPTION 'method argument % is invalid', method; + END CASE; + + INSERT INTO supabase_functions.hooks + (hook_table_id, hook_name, request_id) + VALUES + (TG_RELID, TG_NAME, request_id); + + RETURN NEW; + END + $function$; + -- Supabase super admin + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_functions_admin' + ) + THEN + CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION; + END IF; + END + $$; + GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin; + ALTER USER supabase_functions_admin SET search_path = "supabase_functions"; + ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin; + ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin; + ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin; + GRANT supabase_functions_admin TO postgres; + -- Remove unused supabase_pg_net_admin role + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_pg_net_admin' + ) + THEN + REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin; + DROP OWNED BY supabase_pg_net_admin; + DROP ROLE supabase_pg_net_admin; + END IF; + END + $$; + -- pg_net grants when extension is already enabled + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_extension + WHERE extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END + $$; + -- Event trigger for pg_net + CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() + RETURNS event_trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_event_trigger_ddl_commands() AS ev + JOIN pg_extension AS ext + ON ev.objid = ext.oid + WHERE ext.extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END; + $$; + COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net'; + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_event_trigger + WHERE evtname = 'issue_pg_net_access' + ) THEN + CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION') + EXECUTE PROCEDURE extensions.grant_pg_net_access(); + END IF; + END + $$; + INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants'); + ALTER function supabase_functions.http_request() SECURITY DEFINER; + ALTER function supabase_functions.http_request() SET search_path = supabase_functions; + REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC; + GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role; +COMMIT; diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/functions-env.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/functions-env.env new file mode 100644 index 0000000..5eade5e --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/functions-env.env @@ -0,0 +1,6 @@ +JWT_SECRET=${secret:jwt} +SUPABASE_URL=http://supabase-kong:8000 +SUPABASE_ANON_KEY=${secret:anon-key} +SUPABASE_SERVICE_ROLE_KEY=${secret:service-role-key} +SUPABASE_DB_URL=postgresql://postgres:${secret:postgres}@supabase-db:5432/postgres +VERIFY_JWT=false diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/functions-main/index.ts b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/functions-main/index.ts new file mode 100644 index 0000000..a094010 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/functions-main/index.ts @@ -0,0 +1,94 @@ +import { serve } from 'https://deno.land/std@0.131.0/http/server.ts' +import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts' + +console.log('main function started') + +const JWT_SECRET = Deno.env.get('JWT_SECRET') +const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true' + +function getAuthToken(req: Request) { + const authHeader = req.headers.get('authorization') + if (!authHeader) { + throw new Error('Missing authorization header') + } + const [bearer, token] = authHeader.split(' ') + if (bearer !== 'Bearer') { + throw new Error(`Auth header is not 'Bearer {token}'`) + } + return token +} + +async function verifyJWT(jwt: string): Promise { + const encoder = new TextEncoder() + const secretKey = encoder.encode(JWT_SECRET) + try { + await jose.jwtVerify(jwt, secretKey) + } catch (err) { + console.error(err) + return false + } + return true +} + +serve(async (req: Request) => { + if (req.method !== 'OPTIONS' && VERIFY_JWT) { + try { + const token = getAuthToken(req) + const isValidJWT = await verifyJWT(token) + + if (!isValidJWT) { + return new Response(JSON.stringify({ msg: 'Invalid JWT' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } catch (e) { + console.error(e) + return new Response(JSON.stringify({ msg: e.toString() }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } + + const url = new URL(req.url) + const { pathname } = url + const path_parts = pathname.split('/') + const service_name = path_parts[1] + + if (!service_name || service_name === '') { + const error = { msg: 'missing function name in request' } + return new Response(JSON.stringify(error), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const servicePath = `/home/deno/functions/${service_name}` + console.error(`serving the request with ${servicePath}`) + + const memoryLimitMb = 150 + const workerTimeoutMs = 1 * 60 * 1000 + const noModuleCache = false + const importMapPath = null + const envVarsObj = Deno.env.toObject() + const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]) + + try { + const worker = await EdgeRuntime.userWorkers.create({ + servicePath, + memoryLimitMb, + workerTimeoutMs, + noModuleCache, + importMapPath, + envVars, + }) + return await worker.fetch(req) + } catch (e) { + const error = { msg: e.toString() } + return new Response(JSON.stringify(error), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } +}) diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/gotrue.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/gotrue.env new file mode 100644 index 0000000..f29f778 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/gotrue.env @@ -0,0 +1,28 @@ +GOTRUE_API_HOST=0.0.0.0 +GOTRUE_API_PORT=9999 +API_EXTERNAL_URL=https://${bound:route:name} +GOTRUE_DB_DRIVER=postgres +GOTRUE_DB_DATABASE_URL=postgres://supabase_auth_admin:${secret:postgres}@supabase-db:5432/postgres +GOTRUE_SITE_URL=https://${bound:route:name} +GOTRUE_URI_ALLOW_LIST= +GOTRUE_DISABLE_SIGNUP=true +GOTRUE_JWT_ADMIN_ROLES=service_role +GOTRUE_JWT_AUD=authenticated +GOTRUE_JWT_DEFAULT_GROUP_NAME=authenticated +GOTRUE_JWT_EXP=3600 +GOTRUE_JWT_SECRET=${secret:jwt} +GOTRUE_EXTERNAL_EMAIL_ENABLED=false +GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false +GOTRUE_MAILER_AUTOCONFIRM=false +GOTRUE_SMTP_ADMIN_EMAIL=admin@example.com +GOTRUE_SMTP_HOST=supabase-mail +GOTRUE_SMTP_PORT=2500 +GOTRUE_SMTP_USER=fake_mail_user +GOTRUE_SMTP_PASS=fake_mail_password +GOTRUE_SMTP_SENDER_NAME=fake_sender +GOTRUE_MAILER_URLPATHS_INVITE=/auth/v1/verify +GOTRUE_MAILER_URLPATHS_CONFIRMATION=/auth/v1/verify +GOTRUE_MAILER_URLPATHS_RECOVERY=/auth/v1/verify +GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=/auth/v1/verify +GOTRUE_EXTERNAL_PHONE_ENABLED=false +GOTRUE_SMS_AUTOCONFIRM=true diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/kong.yml b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/kong.yml new file mode 100644 index 0000000..e13bdb2 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/kong.yml @@ -0,0 +1,242 @@ +# Written by the mesh (modules/supabase): the gateway's declarative config, credentials rendered in. +_format_version: '2.1' +_transform: true + +### +### Consumers / Users +### +consumers: + - username: DASHBOARD + - username: anon + keyauth_credentials: + - key: "${secret:anon-key}" + - username: service_role + keyauth_credentials: + - key: "${secret:service-role-key}" + +### +### Access Control List +### +acls: + - consumer: anon + group: anon + - consumer: service_role + group: admin + +### +### Dashboard credentials +### +basicauth_credentials: + - consumer: DASHBOARD + username: "${secret:dashboard-user}" + password: "${secret:dashboard}" + +### +### API Routes +### +services: + ## Open Auth routes + - name: auth-v1-open + url: http://supabase-auth:9999/verify + routes: + - name: auth-v1-open + strip_path: true + paths: + - /auth/v1/verify + plugins: + - name: cors + - name: auth-v1-open-callback + url: http://supabase-auth:9999/callback + routes: + - name: auth-v1-open-callback + strip_path: true + paths: + - /auth/v1/callback + plugins: + - name: cors + - name: auth-v1-open-authorize + url: http://supabase-auth:9999/authorize + routes: + - name: auth-v1-open-authorize + strip_path: true + paths: + - /auth/v1/authorize + plugins: + - name: cors + + ## Secure Auth routes + - name: auth-v1 + _comment: 'GoTrue: /auth/v1/* -> http://auth:9999/*' + url: http://supabase-auth:9999/ + routes: + - name: auth-v1-all + strip_path: true + paths: + - /auth/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure REST routes + - name: rest-v1 + _comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*' + url: http://supabase-rest:3000/ + routes: + - name: rest-v1-all + strip_path: true + paths: + - /rest/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: true + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure GraphQL routes + - name: graphql-v1 + _comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql' + url: http://supabase-rest:3000/rpc/graphql + routes: + - name: graphql-v1-all + strip_path: true + paths: + - /graphql/v1 + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: true + - name: request-transformer + config: + add: + headers: + - Content-Profile:graphql_public + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure Realtime routes + - name: realtime-v1-ws + _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' + url: http://realtime-dev.supabase-realtime:4000/socket + protocol: ws + routes: + - name: realtime-v1-ws + strip_path: true + paths: + - /realtime/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + - name: realtime-v1-rest + _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' + url: http://realtime-dev.supabase-realtime:4000/api + protocol: http + routes: + - name: realtime-v1-rest + strip_path: true + paths: + - /realtime/v1/api + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + ## Storage routes: the storage server manages its own auth + - name: storage-v1 + _comment: 'Storage: /storage/v1/* -> http://storage:5000/*' + url: http://supabase-storage:5000/ + routes: + - name: storage-v1-all + strip_path: true + paths: + - /storage/v1/ + plugins: + - name: cors + + ## Edge Functions routes + - name: functions-v1 + _comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*' + url: http://supabase-edge-functions:9000/ + routes: + - name: functions-v1-all + strip_path: true + paths: + - /functions/v1/ + plugins: + - name: cors + + ## Analytics routes + - name: analytics-v1 + _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*' + url: http://supabase-analytics:4000/ + routes: + - name: analytics-v1-all + strip_path: true + paths: + - /analytics/v1/ + + ## Secure Database routes + - name: meta + _comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*' + url: http://supabase-meta:8080/ + routes: + - name: meta-all + strip_path: true + paths: + - /pg/ + plugins: + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + + ## Protected Dashboard - catch all remaining routes + - name: dashboard + _comment: 'Studio: /* -> http://studio:3000/*' + url: http://supabase-studio:3000/ + routes: + - name: dashboard-all + strip_path: true + paths: + - / + plugins: + - name: cors + - name: basic-auth + config: + hide_credentials: true diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/meta-env.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/meta-env.env new file mode 100644 index 0000000..c9d2509 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/meta-env.env @@ -0,0 +1,6 @@ +PG_META_PORT=8080 +PG_META_DB_HOST=supabase-db +PG_META_DB_PORT=5432 +PG_META_DB_NAME=postgres +PG_META_DB_USER=supabase_admin +PG_META_DB_PASSWORD=${secret:postgres} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/pooler-env.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/pooler-env.env new file mode 100644 index 0000000..f02c7cd --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/pooler-env.env @@ -0,0 +1,14 @@ +PORT=4000 +POSTGRES_PORT=5432 +POSTGRES_DB=postgres +POSTGRES_PASSWORD=${secret:postgres} +DATABASE_URL=ecto://supabase_admin:${secret:postgres}@supabase-db:5432/_supabase +CLUSTER_POSTGRES=true +SECRET_KEY_BASE=${secret:key-base-a}${secret:key-base-b} +VAULT_ENC_KEY=${secret:pooler-vault} +API_JWT_SECRET=${secret:jwt} +METRICS_JWT_SECRET=${secret:jwt} +REGION=local +ERL_AFLAGS=-proto_dist inet_tcp +RELEASE_NODE=supavisor@127.0.0.1 +POOLER_POOL_MODE=transaction diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/pooler.exs b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/pooler.exs new file mode 100644 index 0000000..61b5ba8 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/pooler.exs @@ -0,0 +1,43 @@ +{:ok, _} = Application.ensure_all_started(:supavisor) + +# Written by the mesh (modules/supabase). Per-machine values come from settings.json (the module's +# one merge:json file); secrets come from the environment the image reads. +settings = "/etc/pooler/settings.json" |> File.read!() |> Jason.decode!() |> Map.get("pooler", %{}) + +{:ok, version} = + case Supavisor.Repo.query!("select version()") do + %{rows: [[ver]]} -> Supavisor.Helpers.parse_pg_version(ver) + _ -> nil + end + +params = %{ + "external_id" => to_string(settings["tenant"] || "default"), + "db_host" => "supabase-db", + "db_port" => System.get_env("POSTGRES_PORT"), + "db_database" => System.get_env("POSTGRES_DB"), + "require_user" => false, + "auth_query" => "SELECT * FROM pgbouncer.get_auth($1)", + "default_max_clients" => settings["max-client-connections"] || 100, + "default_pool_size" => settings["pool-size"] || 20, + "default_parameter_status" => %{"server_version" => version}, + "users" => [%{ + "db_user" => "pgbouncer", + "db_password" => System.get_env("POSTGRES_PASSWORD"), + "mode_type" => System.get_env("POOLER_POOL_MODE"), + "pool_size" => settings["pool-size"] || 20, + "is_manager" => true + }] +} + +case Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do + nil -> + {:ok, _} = Supavisor.Tenants.create_tenant(params) + + # A tenant made elsewhere (HAL named the database host "db") is pointed at this module's + # database container; nothing else it holds is changed. + %{db_host: host} = tenant when host != "supabase-db" -> + {:ok, _} = Supavisor.Tenants.update_tenant(tenant, %{"db_host" => "supabase-db"}) + + _ -> + :ok +end diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/postgrest.conf b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/postgrest.conf new file mode 100644 index 0000000..1755753 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/postgrest.conf @@ -0,0 +1,7 @@ +db-uri = "postgres://authenticator:${secret:postgres}@supabase-db:5432/postgres" +db-schemas = "public,storage,graphql_public" +db-anon-role = "anon" +jwt-secret = "${secret:jwt}" +db-use-legacy-gucs = false +app.settings.jwt_secret = "${secret:jwt}" +app.settings.jwt_exp = "3600" diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/realtime-env.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/realtime-env.env new file mode 100644 index 0000000..75e2136 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/realtime-env.env @@ -0,0 +1,16 @@ +PORT=4000 +DB_HOST=supabase-db +DB_PORT=5432 +DB_USER=supabase_admin +DB_PASSWORD=${secret:postgres} +DB_NAME=postgres +DB_AFTER_CONNECT_QUERY=SET search_path TO _realtime +DB_ENC_KEY=supabaserealtime +API_JWT_SECRET=${secret:jwt} +SECRET_KEY_BASE=${secret:key-base-a}${secret:key-base-b} +ERL_AFLAGS=-proto_dist inet_tcp +DNS_NODES='' +RLIMIT_NOFILE=10000 +APP_NAME=realtime +SEED_SELF_HOST=true +RUN_JANITOR=true diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/settings.json b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/settings.json new file mode 100644 index 0000000..8ab8cfa --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/settings.json @@ -0,0 +1,7 @@ +{ + "pooler": { + "tenant": "default", + "pool-size": 20, + "max-client-connections": 100 + } +} diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/storage-env.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/storage-env.env new file mode 100644 index 0000000..fd5da86 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/storage-env.env @@ -0,0 +1,13 @@ +ANON_KEY=${secret:anon-key} +SERVICE_KEY=${secret:service-role-key} +POSTGREST_URL=http://supabase-rest:3000 +PGRST_JWT_SECRET=${secret:jwt} +DATABASE_URL=postgres://supabase_storage_admin:${secret:postgres}@supabase-db:5432/postgres +FILE_SIZE_LIMIT=52428800 +STORAGE_BACKEND=file +FILE_STORAGE_BACKEND_PATH=/var/lib/storage +TENANT_ID=stub +REGION=stub +GLOBAL_S3_BUCKET=stub +ENABLE_IMAGE_TRANSFORMATION=true +IMGPROXY_URL=http://supabase-imgproxy:5001 diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/studio-env.env b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/studio-env.env new file mode 100644 index 0000000..303dbc5 --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/studio-env.env @@ -0,0 +1,18 @@ +# Next.js listens on the address HOSTNAME names, and docker sets HOSTNAME to the container's id, +# so studio answered only on its network address while its image's health check asks localhost: +# unhealthy for ever while working. Every address, as the upstream compose file has it. +HOSTNAME=:: +STUDIO_PG_META_URL=http://supabase-meta:8080 +POSTGRES_PASSWORD=${secret:postgres} +DEFAULT_ORGANIZATION_NAME=Default Organization +DEFAULT_PROJECT_NAME=Default Project +OPENAI_API_KEY=${secret:openai} +SUPABASE_URL=http://supabase-kong:8000 +SUPABASE_PUBLIC_URL=https://${bound:route:name} +SUPABASE_ANON_KEY=${secret:anon-key} +SUPABASE_SERVICE_KEY=${secret:service-role-key} +AUTH_JWT_SECRET=${secret:jwt} +LOGFLARE_API_KEY=${secret:logflare} +LOGFLARE_URL=http://supabase-analytics:4000 +NEXT_PUBLIC_ENABLE_LOGS=true +NEXT_ANALYTICS_BACKEND_PROVIDER=postgres diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/vector.yml b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/vector.yml new file mode 100644 index 0000000..fb0f03d --- /dev/null +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/supabase/files/vector.yml @@ -0,0 +1,258 @@ +# Written by the mesh (modules/supabase). +# +# Every sink hands logflare its API key in the x-api-key header, never as an api_key query parameter: +# logflare prints a failed request's whole URL, query string included, in its error report, so a +# key in the URL is a key in its log (novox/hq issue 268). supabase-analytics refuses to start +# while this file still puts the key in a URL. +api: + enabled: true + address: 0.0.0.0:9001 + +sources: + docker_host: + type: docker_logs + include_containers: + - supabase-kong + - supabase-auth + - supabase-rest + - realtime-dev.supabase-realtime + - supabase-storage + - supabase-edge-functions + - supabase-db + +transforms: + project_logs: + type: remap + inputs: + - docker_host + source: |- + .project = "default" + .event_message = del(.message) + .appname = del(.container_name) + del(.container_created_at) + del(.container_id) + del(.source_type) + del(.stream) + del(.label) + del(.image) + del(.host) + del(.stream) + router: + type: route + inputs: + - project_logs + route: + kong: '.appname == "supabase-kong"' + auth: '.appname == "supabase-auth"' + rest: '.appname == "supabase-rest"' + realtime: '.appname == "realtime-dev.supabase-realtime"' + storage: '.appname == "supabase-storage"' + functions: '.appname == "supabase-edge-functions"' + db: '.appname == "supabase-db"' + # Ignores non nginx errors since they are related with kong booting up + kong_logs: + type: remap + inputs: + - router.kong + source: |- + req, err = parse_nginx_log(.event_message, "combined") + if err == null { + .timestamp = req.timestamp + .metadata.request.headers.referer = req.referer + .metadata.request.headers.user_agent = req.agent + .metadata.request.headers.cf_connecting_ip = req.client + .metadata.request.method = req.method + .metadata.request.path = req.path + .metadata.request.protocol = req.protocol + .metadata.response.status_code = req.status + } + if err != null { + abort + } + # Ignores non nginx errors since they are related with kong booting up + kong_err: + type: remap + inputs: + - router.kong + source: |- + .metadata.request.method = "GET" + .metadata.response.status_code = 200 + parsed, err = parse_nginx_log(.event_message, "error") + if err == null { + .timestamp = parsed.timestamp + .severity = parsed.severity + .metadata.request.host = parsed.host + .metadata.request.headers.cf_connecting_ip = parsed.client + url, err = split(parsed.request, " ") + if err == null { + .metadata.request.method = url[0] + .metadata.request.path = url[1] + .metadata.request.protocol = url[2] + } + } + if err != null { + abort + } + # Gotrue logs are structured json strings which frontend parses directly. But we keep metadata for consistency. + auth_logs: + type: remap + inputs: + - router.auth + source: |- + parsed, err = parse_json(.event_message) + if err == null { + .metadata.timestamp = parsed.time + .metadata = merge!(.metadata, parsed) + } + # PostgREST logs are structured so we separate timestamp from message using regex + rest_logs: + type: remap + inputs: + - router.rest + source: |- + parsed, err = parse_regex(.event_message, r'^(?P