From d68e241724e1b116cabe2184cbdc1ea8a102c59f Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 19:15:55 +0100 Subject: [PATCH] Take the catalogue's composition rules into the node-engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0593 §7: the composer with its refusals leaves mesh-sdk, so mesh-sdk carries no composition rule. The catalogue functions only the node-engine's composition reaches (Resolve, Resolution's Compose, Declaration, DataItems, LeftOut, Rules and the rest, the settings, placeholder and template refusals, the rendering helpers) are copied with their tests into internal/compose/resolution; a method of a mesh-sdk shape becomes a function taking it first. The composition calls its own copies, and the captured catalogue its tests read lives here now, shared by mesh-compose's tests. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- cmd/mesh-compose/main_test.go | 33 +- internal/beside/beside.go | 134 + internal/beside/reads-outside | 1 + internal/compose/composed.go | 13 +- internal/compose/composed_test.go | 5 +- internal/compose/credentials.go | 13 +- internal/compose/frombus/answer.go | 3 +- internal/compose/frombus/answer_rows_test.go | 5 +- internal/compose/frombus/frombus.go | 7 +- internal/compose/frombus/held_rows_test.go | 7 +- internal/compose/frombus/states.go | 4 +- internal/compose/network.go | 3 +- internal/compose/node.go | 11 +- internal/compose/rendering.go | 13 +- .../resolution/a_service_process_test.go | 10 + internal/compose/resolution/accesses_test.go | 80 + internal/compose/resolution/adoption.go | 177 ++ internal/compose/resolution/adoption_test.go | 629 ++++ .../compose/resolution/agent_account_test.go | 83 + .../resolution/artifact_store_seat_test.go | 40 + .../compose/resolution/artifact_sweep_seat.go | 43 + internal/compose/resolution/artifacts.go | 65 + internal/compose/resolution/artifacts_test.go | 102 + .../compose/resolution/backup_placement.go | 46 + .../resolution/backup_placement_test.go | 114 + internal/compose/resolution/backup_test.go | 37 + internal/compose/resolution/bound.go | 117 + .../compose/resolution/bound_into_files.go | 176 ++ .../resolution/bound_into_files_test.go | 246 ++ internal/compose/resolution/bound_test.go | 219 ++ .../compose/resolution/broker_seat_test.go | 49 + internal/compose/resolution/brokered_test.go | 255 ++ internal/compose/resolution/bucket_shares.go | 48 + .../compose/resolution/bucket_shares_test.go | 105 + internal/compose/resolution/build.go | 20 + internal/compose/resolution/busy.go | 20 + internal/compose/resolution/busy_test.go | 54 + .../resolution/catalogue_check_test.go | 46 + .../compose/resolution/catalogue_read_test.go | 46 + .../resolution/catrust_manifest_test.go | 70 + internal/compose/resolution/checker_seat.go | 33 + internal/compose/resolution/chosen.go | 86 + .../compose/resolution/co_located_test.go | 306 ++ internal/compose/resolution/collections.go | 312 ++ .../resolution/collections_bar_test.go | 110 + .../compose/resolution/collections_l2_test.go | 57 + .../compose/resolution/collections_l3_test.go | 36 + .../compose/resolution/collections_test.go | 13 + internal/compose/resolution/computed_test.go | 184 ++ internal/compose/resolution/config_test.go | 181 ++ .../resolution/consumer_into_serves.go | 267 ++ internal/compose/resolution/consumers.go | 100 + .../compose/resolution/contributes_test.go | 311 ++ .../contribution_dependencies_test.go | 58 + .../resolution/control_records_seats.go | 52 + .../compose/resolution/core_credentials.go | 19 + .../resolution/core_credentials_test.go | 24 + internal/compose/resolution/data.go | 77 + internal/compose/resolution/data_placed.go | 31 + internal/compose/resolution/data_test.go | 16 + internal/compose/resolution/declaration.go | 2522 +++++++++++++++++ .../compose/resolution/declaration_test.go | 84 + .../resolution/decode_for_test_test.go | 9 + internal/compose/resolution/delivery_seat.go | 74 + .../resolution/derived_for_consumer_test.go | 312 ++ internal/compose/resolution/dir_into.go | 239 ++ internal/compose/resolution/dir_into_test.go | 271 ++ internal/compose/resolution/doc.go | 7 + internal/compose/resolution/domain_test.go | 119 + internal/compose/resolution/endpoint_test.go | 143 + .../resolution/endpoints_setting_test.go | 98 + internal/compose/resolution/engine_seat.go | 11 + internal/compose/resolution/enrolment_seat.go | 67 + .../compose/resolution/environment_into.go | 554 ++++ .../resolution/environment_into_test.go | 238 ++ .../resolution/environment_items_test.go | 19 + internal/compose/resolution/files.go | 152 + internal/compose/resolution/files_test.go | 75 + internal/compose/resolution/filtering.go | 894 ++++++ internal/compose/resolution/filtering_test.go | 379 +++ .../resolution/foundation_manifests_test.go | 270 ++ .../resolution/generated_collision_test.go | 81 + .../resolution/grant_secret_owner_test.go | 105 + .../compose/resolution/graphical_session.go | 228 ++ .../resolution/graphical_session_test.go | 74 + internal/compose/resolution/groups_test.go | 127 + internal/compose/resolution/handed.go | 665 +++++ .../compose/resolution/handed_bound_test.go | 146 + .../resolution/handed_consumers_test.go | 48 + .../resolution/handed_providers_test.go | 111 + .../resolution/handed_pulled_in_test.go | 55 + .../compose/resolution/handed_secrets_test.go | 78 + internal/compose/resolution/handed_test.go | 57 + internal/compose/resolution/health.go | 54 + internal/compose/resolution/health_seats.go | 104 + internal/compose/resolution/health_test.go | 104 + internal/compose/resolution/holdings_test.go | 110 + .../compose/resolution/hostname_seat_test.go | 53 + internal/compose/resolution/jails_into.go | 66 + .../compose/resolution/jails_into_test.go | 74 + internal/compose/resolution/kept_test.go | 59 + internal/compose/resolution/keyed.go | 14 + internal/compose/resolution/keyed_test.go | 142 + internal/compose/resolution/kinded_test.go | 25 + internal/compose/resolution/laid_values.go | 38 + internal/compose/resolution/licence_test.go | 259 ++ internal/compose/resolution/localnetwork.go | 117 + .../compose/resolution/localnetwork_test.go | 183 ++ .../compose/resolution/machine_into_files.go | 153 + .../resolution/machine_into_files_test.go | 122 + internal/compose/resolution/manifest.go | 23 + internal/compose/resolution/names_test.go | 56 + internal/compose/resolution/needs_test.go | 79 + .../compose/resolution/no_subjects_test.go | 41 + internal/compose/resolution/node_resolver.go | 97 + .../compose/resolution/node_resolver_test.go | 184 ++ .../resolution/operators_machine_test.go | 33 + .../resolution/optional_requirements.go | 75 + .../resolution/optional_requirements_test.go | 168 ++ .../resolution/outside_not_given_test.go | 104 + internal/compose/resolution/own_name_test.go | 128 + .../resolution/own_secret_owner_test.go | 95 + .../resolution/pin_names_module_test.go | 104 + .../compose/resolution/placed_paths_test.go | 182 ++ internal/compose/resolution/placement.go | 275 ++ internal/compose/resolution/placement_seat.go | 59 + internal/compose/resolution/port_into.go | 133 + internal/compose/resolution/port_into_test.go | 172 ++ internal/compose/resolution/process_bus.go | 16 + .../compose/resolution/process_bus_test.go | 103 + .../compose/resolution/process_env_test.go | 82 + internal/compose/resolution/reach_test.go | 163 ++ internal/compose/resolution/reads-outside | 1 + internal/compose/resolution/reads.go | 173 ++ internal/compose/resolution/reads_test.go | 81 + .../resolution/received_on_the_bus_test.go | 68 + .../resolution/reflects_renamed_test.go | 32 + internal/compose/resolution/replay166_test.go | 43 + internal/compose/resolution/replay279_test.go | 40 + internal/compose/resolution/resolve.go | 1187 ++++++++ .../resolution/resolve_loopback_test.go | 54 + internal/compose/resolution/resolve_test.go | 484 ++++ .../compose/resolution/resolved_path_test.go | 32 + .../resolver_file_is_the_uplinks_test.go | 57 + .../resolution/resolver_manifests_test.go | 232 ++ internal/compose/resolution/roster.go | 226 ++ internal/compose/resolution/routing_test.go | 250 ++ internal/compose/resolution/runonce_test.go | 54 + internal/compose/resolution/runtime.go | 410 +++ .../compose/resolution/runtime_health_test.go | 57 + internal/compose/resolution/runtime_test.go | 421 +++ internal/compose/resolution/schedule_test.go | 34 + .../compose/resolution/seat_contributions.go | 200 ++ .../resolution/seat_contributions_test.go | 62 + internal/compose/resolution/seat_data.go | 230 ++ internal/compose/resolution/seat_data_test.go | 108 + .../compose/resolution/seat_dependencies.go | 219 ++ .../resolution/seat_dependencies_test.go | 97 + internal/compose/resolution/seat_into.go | 193 ++ internal/compose/resolution/seat_into_test.go | 162 ++ internal/compose/resolution/seat_pass.go | 74 + internal/compose/resolution/seat_pass_test.go | 80 + internal/compose/resolution/seat_paths.go | 125 + .../compose/resolution/seat_paths_test.go | 56 + internal/compose/resolution/seats.go | 651 +++++ internal/compose/resolution/seats_test.go | 72 + .../compose/resolution/secret_family_test.go | 44 + .../compose/resolution/secret_restart_test.go | 90 + .../compose/resolution/secrets_into_files.go | 214 ++ .../resolution/secrets_into_files_test.go | 245 ++ .../compose/resolution/served_own_names.go | 89 + .../resolution/served_own_names_test.go | 117 + .../compose/resolution/setting_defaults.go | 64 + .../resolution/setting_defaults_test.go | 188 ++ .../resolution/setting_in_owner_test.go | 107 + .../resolution/setting_in_unit_test.go | 115 + internal/compose/resolution/setting_into.go | 260 ++ .../compose/resolution/setting_into_test.go | 42 + internal/compose/resolution/setting_takes.go | 216 ++ internal/compose/resolution/settings.go | 517 ++++ internal/compose/resolution/settings_test.go | 106 + .../resolution/several_contributions_test.go | 117 + .../resolution/several_secrets_test.go | 144 + .../compose/resolution/shared_account_test.go | 71 + .../resolution/shared_credential_test.go | 48 + internal/compose/resolution/shares.go | 78 + .../short_form_port_protocol_test.go | 26 + .../compose/resolution/ssh_client_test.go | 117 + internal/compose/resolution/template.go | 31 + internal/compose/resolution/template_test.go | 73 + .../resolution/terminal_settings_test.go | 77 + .../resolution/testdata/bars/bar-battery | 9 + .../resolution/testdata/bars/bar-power-draw | 6 + .../testdata/bars/battery.bottom-bar.toml | 119 + .../testdata/bars/battery.top-bar.toml | 37 + .../testdata/bars/both.bottom-bar.toml | 126 + .../testdata/bars/both.top-bar.toml | 37 + .../resolution/testdata/bars/bottom-bar.toml | 109 + .../testdata/bars/none.bottom-bar.toml | 109 + .../testdata/bars/none.top-bar.toml | 37 + .../testdata/bars/power-meter.bottom-bar.toml | 116 + .../testdata/bars/power-meter.top-bar.toml | 37 + .../resolution/testdata/bars/top-bar.toml | 37 + ...catalogue-835bf22b-claude-code.module.json | 249 ++ .../catalogue-835bf22b-dunst.module.json | 0 .../catalogue-835bf22b-xterm.module.json | 236 ++ .../resolution/testdata/environment/all.posix | 26 + .../testdata/environment/all.systemd | 23 + .../resolution/testdata/environment/zsh.posix | 9 + .../testdata/environment/zsh.systemd | 6 + .../grafana-ace.json | 35 + .../grafana-unlayered.json | 35 + ...yrus-g14.contribution-node-hotkeys-trigger | 10 + .../asus-zephyrus-g14.shell-after-wake-normal | 7 + .../items/claude-code.shell-zsh-normal | 12 + .../l2-moved/items/mesh-cli.shell-zsh-normal | 3 + .../items/powerlevel10k.shell-zsh-normal | 3 + .../zsh-autosuggestions.shell-zsh-normal | 2 + .../zsh-syntax-highlighting.shell-zsh-last | 3 + .../testdata/l2-moved/templates/power | 4 + .../testdata/l2-moved/templates/triggerhappy | 4 + .../testdata/l2-moved/templates/zsh | 60 + .../resolution/testdata/l2-moved/want/power | 11 + .../testdata/l2-moved/want/triggerhappy | 14 + .../resolution/testdata/l2-moved/want/zsh | 88 + .../resolution/toolchain_manifests_test.go | 71 + .../compose/resolution/two_consumers_test.go | 128 + .../compose/resolution/two_resolvers_test.go | 160 ++ .../resolution/unconsumed_placeholder.go | 146 + .../resolution/unconsumed_placeholder_test.go | 111 + internal/compose/resolution/unknown_field.go | 36 + internal/compose/resolution/verbs.go | 59 + .../compose/resolution/while_stopped_test.go | 64 + internal/compose/resolution/yields.go | 69 + internal/compose/resolution/zones.go | 63 + internal/compose/resolution/zones_test.go | 47 + internal/compose/resolve.go | 5 +- internal/compose/resolve_test.go | 3 +- internal/compose/rest.go | 15 +- internal/compose/rest_test.go | 3 +- internal/selfcompose/selfcompose.go | 5 +- testdata/beside/CAPTURED | 27 + .../modules/adwaita/files/cursor-index.theme | 5 + .../modules/adwaita/files/env-gtk-theme | 1 + .../modules/adwaita/files/env-gtk2-rc-files | 1 + .../adwaita/files/env-qt-qpa-platformtheme | 1 + .../modules/adwaita/files/env-qt-select | 1 + .../adwaita/files/env-qt-style-override | 1 + .../modules/adwaita/files/env-xcursor-size | 1 + .../modules/adwaita/files/env-xcursor-theme | 1 + .../modules/adwaita/files/gtk-settings.ini | 9 + .../modules/adwaita/files/portals.conf | 11 + .../modules/adwaita/files/qt6ct.conf | 29 + .../modules/adwaita/files/xinitrc-normal | 10 + .../modules/adwaita/files/xresources-normal | 3 + .../modules/adwaita/module.json.captured | 0 .../artifact-store-tools/files/store-names | 4 + .../artifact-store-tools/module.json.captured | 0 .../files/bin/zephyrus-backlight | 33 + .../files/bin/zephyrus-display | 41 + .../files/bin/zephyrus-kbd-notify | 20 + .../files/bin/zephyrus-media | 27 + .../files/bin/zephyrus-notify | 12 + .../files/bin/zephyrus-session | 46 + .../files/bin/zephyrus-touchpad | 29 + .../asus-zephyrus-g14/module.json.captured | 0 .../UPower.conf.d/50-asus-zephyrus-g14.conf | 11 + .../30-asus-zephyrus-g14-touchpad.conf | 12 + .../asus-zephyrus-g14/templates/after-wake | 7 + .../templates/display-session | 30 + .../templates/hotkeys-triggers | 10 + .../modprobe.d/g14-nvidia-power.conf | 16 + .../modprobe.d/video-brightness-switch.conf | 6 + .../asus-zephyrus-g14.conf | 7 + .../asus-zephyrus-g14-nvidia.conf | 7 + .../asus-zephyrus-g14-nvidia.conf | 7 + .../asus-zephyrus-g14-nvidia.conf | 7 + .../templates/udev/rules.d/90-backlight.rules | 5 + .../modules/audit-logger/module.json.captured | 0 .../modules/avahi/module.json.captured | 0 .../modules/baserow/files/config.json | 4 + .../modules/baserow/files/server.env | 7 + .../modules/baserow/module.json.captured | 0 .../modules/betterbird/files/accounts.json | 3 + .../modules/betterbird/files/betterbird | 10 + .../betterbird/files/configuration.json | 6 + .../files/eu.betterbird.Betterbird.desktop | 25 + .../modules/betterbird/files/prefs.json | 3 + .../modules/betterbird/files/tags.json | 3 + .../modules/betterbird/module.json.captured | 0 .../modules/blueman/module.json.captured | 0 .../modules/bluetooth/module.json.captured | 0 .../modules/brother/files/settings.json | 9 + .../modules/brother/files/store.json | 9 + .../modules/brother/module.json.captured | 0 .../modules/ca-trust/files/anchor | 58 + .../ca-trust/files/mesh-ca-trust.service | 16 + .../modules/ca-trust/module.json.captured | 0 .../modules/claude-code/files/agent-account | 1 + .../modules/claude-code/files/claude-agent | 13 + .../modules/claude-code/files/facts.json | 4 + .../modules/claude-code/files/settings.json | 10 + .../modules/claude-code/files/zsh-normal | 12 + .../modules/claude-code/module.json.captured | 0 .../claude-licence-manager/files/database.url | 1 + .../files/settings.json | 7 + .../module.json.captured | 0 .../clipmenu/files/bin/clipmenud-session | 41 + .../modules/clipmenu/files/bin/xsel | 20 + .../modules/clipmenu/files/env-cm-histlength | 1 + .../modules/clipmenu/files/env-cm-max-clips | 1 + .../modules/clipmenu/files/env-cm-selections | 1 + .../clipmenu/files/i3/50-clipmenu.conf | 5 + .../files/systemd/clipmenud-mesh.conf | 14 + .../modules/clipmenu/module.json.captured | 0 .../modules/confluence/files/config.json | 1 + .../modules/confluence/module.json.captured | 0 .../modules/cups/module.json.captured | 0 .../modules/dbus/module.json.captured | 0 .../modules/desk-channel/files/settings.json | 3 + .../modules/desk-channel/module.json.captured | 0 .../modules/dev-tools/module.json.captured | 0 .../modules/disk-load/module.json.captured | 0 .../modules/distribution/module.json.captured | 0 .../modules/dmenu/module.json.captured | 0 .../modules/dnsmasq/files/dnsmasq.conf | 87 + .../modules/dnsmasq/files/mesh.conf | 14 + .../modules/dnsmasq/module.json.captured | 0 .../docker-compose/module.json.captured | 0 .../modules/docker/files/daemon.json | 1 + .../modules/docker/files/docker-prune.service | 15 + .../modules/docker/files/docker-prune.timer | 11 + .../modules/docker/files/log-rotation.conf | 5 + .../modules/docker/module.json.captured | 0 .../mesh-catalog/modules/dunst/files/dunstrc | 110 + .../modules/dunst/files/i3/50-dunst.conf | 4 + .../modules/dunst/module.json.captured | 123 + .../modules/fail2ban/files/fail2ban | 7 + .../files/iptables-allports-dualchain.conf | 40 + .../modules/fail2ban/files/jail.local | 48 + .../fail2ban/files/mesh-own-addresses.json | 3 + .../modules/fail2ban/files/own-addresses | 0 .../modules/fail2ban/files/recidive.conf | 9 + .../modules/fail2ban/files/sshd.conf | 8 + .../modules/fail2ban/module.json.captured | 0 .../mesh-catalog/modules/feh/files/fehbg | 6 + .../modules/feh/files/i3/50-feh.conf | 3 + .../modules/feh/files/xinitrc-normal | 2 + .../modules/feh/module.json.captured | 0 .../modules/flatpak/module.json.captured | 0 .../modules/fonts/files/50-mesh-fonts.conf | 40 + .../modules/fonts/module.json.captured | 0 .../modules/forticlient/module.json.captured | 0 .../modules/gitea/files/agent-teams.json | 1 + .../modules/gitea/files/config.json | 1 + .../modules/gitea/files/consumers.json | 1 + .../mesh-catalog/modules/gitea/files/origins | 1 + .../modules/gitea/files/repositories.json | 1 + .../modules/gitea/files/server.env | 7 + .../modules/gitea/module.json.captured | 0 .../modules/gitlab/files/config.json | 1 + .../modules/gitlab/module.json.captured | 0 .../modules/gnome-keyring/files/pam/login | 4 + .../modules/gnome-keyring/files/pam/passwd | 3 + .../modules/gnome-keyring/files/xinitrc-first | 5 + .../gnome-keyring/module.json.captured | 0 .../modules/go/module.json.captured | 0 .../modules/grafana/files/admin.secret | 1 + .../modules/grafana/files/config.json | 4 + .../modules/grafana/files/influxdb-api | 1 + .../modules/grafana/files/mesh-influxdb.yaml | 19 + .../modules/grafana/files/oidc-client.secret | 1 + .../modules/grafana/files/oidc.env | 13 + .../modules/grafana/module.json.captured | 0 .../modules/hello-web/files/index.html | 1 + .../modules/hello-web/module.json.captured | 0 .../modules/home-assistant/files/config.json | 1 + .../home-assistant/files/provisions.env | 4 + .../home-assistant/module.json.captured | 0 .../modules/hostname/files/hostname | 1 + .../mesh-catalog/modules/hostname/files/hosts | 7 + .../modules/hostname/module.json.captured | 0 .../mesh-catalog/modules/i3/files/config | 201 ++ .../modules/i3/files/env-xdg-current-desktop | 1 + .../modules/i3/files/env-xdg-session-desktop | 1 + .../mesh-catalog/modules/i3/files/wms/i3 | 9 + .../modules/i3/files/xinitrc-last | 2 + .../modules/i3/module.json.captured | 0 .../i3status-rust/files/bin/i3bar-watchdog | 64 + .../i3status-rust/files/bin/i3status-updates | 63 + .../i3status-rust/files/bin/i3status-value | 13 + .../i3status-rust/files/bottom-bar.toml | 109 + .../files/i3/60-i3status-rust.conf | 34 + .../files/icons/custom-icons.toml | 135 + .../modules/i3status-rust/files/top-bar.toml | 37 + .../i3status-rust/files/xinitrc-normal | 3 + .../i3status-rust/module.json.captured | 0 .../modules/icecast/files/config.json | 1 + .../modules/icecast/files/icecast.xml | 56 + .../modules/icecast/module.json.captured | 0 .../modules/influxdb/files/config.json | 1 + .../modules/influxdb/files/server.env | 6 + .../modules/influxdb/module.json.captured | 0 .../modules/invoicing/files/api.env | 9 + .../modules/invoicing/module.json.captured | 0 .../modules/jira/files/config.json | 1 + .../modules/jira/module.json.captured | 0 .../modules/kernel/files/huge-pages.sh | 84 + .../files/mesh-kernel-huge-pages.service | 25 + .../modules/kernel/module.json.captured | 0 .../modules/keycloak/files/admin.env | 2 + .../modules/keycloak/files/config.json | 1 + .../modules/keycloak/files/consumers.json | 1 + .../modules/keycloak/files/database.env | 3 + .../modules/keycloak/files/hostname.env | 1 + .../modules/keycloak/module.json.captured | 0 .../mesh-catalog/modules/lab/files/lab.env | 3 + .../modules/lab/module.json.captured | 0 .../modules/lemurs/files/config.toml | 369 +++ .../mesh-catalog/modules/lemurs/files/xserver | 8 + .../modules/lemurs/module.json.captured | 0 .../modules/letta/files/config.json | 3 + .../mesh-catalog/modules/letta/files/pgpass | 1 + .../modules/letta/files/server.env | 6 + .../mesh-catalog/modules/letta/files/start.sh | 23 + .../modules/letta/module.json.captured | 0 .../local-model-consumer/files/openai.env | 3 + .../local-model-consumer/module.json.captured | 0 .../modules/localization/files/locale.conf | 3 + .../modules/localization/files/vconsole.conf | 3 + .../modules/localization/module.json.captured | 0 .../modules/logrotate/files/logrotate.conf | 32 + .../modules/logrotate/module.json.captured | 0 .../modules/mailu/files/admin.env | 2 + .../modules/mailu/files/config.json | 1 + .../modules/mailu/files/database.env | 5 + .../modules/mailu/files/mailu.env | 78 + .../modules/mailu/files/secret.env | 1 + .../modules/mailu/module.json.captured | 0 .../modules/marrytts/module.json.captured | 0 .../modules/matrix/files/conduit.toml | 15 + .../modules/matrix/files/element.json | 53 + .../modules/matrix/module.json.captured | 0 .../modules/memory-pressure/files/oomd.conf | 8 + .../memory-pressure/files/root-slice.conf | 5 + .../modules/memory-pressure/files/sysctl.conf | 9 + .../memory-pressure/files/user-manager.conf | 7 + .../memory-pressure/files/zram-generator.conf | 11 + .../memory-pressure/module.json.captured | 0 .../modules/mesh-build-go/files/forge | 7 + .../modules/mesh-build-go/files/gitconfig | 2 + .../modules/mesh-build-go/files/known_hosts | 1 + .../mesh-build-go/module.json.captured | 0 .../modules/mesh-build-node/files/forge | 7 + .../modules/mesh-build-node/files/gitconfig | 2 + .../modules/mesh-build-node/files/known_hosts | 1 + .../mesh-build-node/module.json.captured | 0 .../modules/mesh-catalog/files/database.url | 1 + .../modules/mesh-catalog/module.json.captured | 0 .../modules/mesh-cli/files/zsh-normal | 3 + .../modules/mesh-cli/module.json.captured | 0 .../modules/mesh-config/module.json.captured | 0 .../mesh-delivery-gate/module.json.captured | 0 .../modules/mesh-files/module.json.captured | 0 .../modules/mesh-issues/files/settings.json | 147 + .../modules/mesh-issues/module.json.captured | 0 .../modules/mesh-knowledge/files/database.url | 1 + .../mesh-knowledge/files/settings.json | 5 + .../mesh-knowledge/module.json.captured | 0 .../mesh-mail-notifier/module.json.captured | 0 .../mesh-mail-reader/files/callers.json | 3 + .../mesh-mail-reader/files/database-url | 1 + .../mesh-mail-reader/module.json.captured | 0 .../modules/mesh-mail-web/files/oidc.json | 5 + .../mesh-mail-web/files/operators.json | 3 + .../mesh-mail-web/module.json.captured | 0 .../modules/mesh-mail/files/accounts.json | 3 + .../modules/mesh-mail/files/database-url | 1 + .../modules/mesh-mail/files/grants.json | 3 + .../modules/mesh-mail/files/store.json | 9 + .../modules/mesh-mail/module.json.captured | 0 .../module.json.captured | 0 .../mesh-provisioner/module.json.captured | 0 .../modules/mesh-reads/module.json.captured | 0 .../modules/mesh-vault/module.json.captured | 0 .../modules/mesh-watcher/files/settings.json | 3 + .../modules/mesh-watcher/module.json.captured | 0 .../modules/messenger/files/settings.json | 5 + .../modules/messenger/module.json.captured | 0 .../mesh-catalog/modules/minio/files/root.env | 2 + .../modules/minio/module.json.captured | 0 .../modules/model-usage/files/database.url | 1 + .../modules/model-usage/module.json.captured | 0 .../modules/mongodb/files/server-root.secret | 1 + .../modules/mongodb/module.json.captured | 0 .../modules/mosquitto/files/bootstrap.env | 7 + .../modules/mosquitto/files/mosquitto.conf | 20 + .../modules/mosquitto/module.json.captured | 0 .../modules/mounts/files/mounts.conf | 11 + .../modules/mounts/module.json.captured | 0 .../modules/mssql/files/backup.sql | 14 + .../mesh-catalog/modules/mssql/files/sa.env | 2 + .../modules/mssql/module.json.captured | 0 .../modules/n8n/files/n8n-database.secret | 1 + .../mesh-catalog/modules/n8n/files/server.env | 12 + .../modules/n8n/module.json.captured | 0 .../mesh-catalog/modules/nats/files/nats.conf | 46 + .../modules/nats/module.json.captured | 0 .../modules/netcheck/module.json.captured | 0 .../modules/networkmanager/files/50-mesh.conf | 63 + .../networkmanager/module.json.captured | 0 .../nextcloud-client/module.json.captured | 0 .../modules/nextcloud/files/config.json | 1 + .../modules/nextcloud/files/server.env | 14 + .../modules/nextcloud/module.json.captured | 0 .../modules/nfs-server/files/50-mesh.conf | 12 + .../modules/nfs-server/files/shares.conf | 10 + .../modules/nfs-server/module.json.captured | 0 .../nftables/files/mesh-filter.service | 14 + .../modules/nftables/files/mesh.conf | 5 + .../modules/nftables/module.json.captured | 0 .../modules/nm-applet/module.json.captured | 0 .../modules/node-env/files/50-mesh.conf | 4 + .../modules/node-env/files/environment.sh | 5 + .../modules/node-env/module.json.captured | 0 .../modules/nodejs/module.json.captured | 0 .../modules/nodered/files/config.json | 3 + .../modules/nodered/files/mqtt.env | 4 + .../modules/nodered/files/settings.js | 48 + .../modules/nodered/files/settings.json | 14 + .../modules/nodered/module.json.captured | 0 .../modules/ollama/module.json.captured | 0 .../modules/only-office/files/server.env | 5 + .../modules/only-office/module.json.captured | 0 .../openai-consumer/module.json.captured | 0 .../modules/openrazer/module.json.captured | 0 .../files/mesh-pacman-weekly-upgrade.service | 12 + .../files/mesh-pacman-weekly-upgrade.timer | 11 + .../modules/pacman/files/pacman.conf | 29 + .../modules/pacman/files/reflector.conf | 8 + .../modules/pacman/module.json.captured | 0 .../modules/photos-eef/module.json.captured | 0 .../modules/photos-filip/module.json.captured | 0 .../modules/picom/files/picom.conf | 38 + .../modules/picom/module.json.captured | 0 .../polychromatic/files/display-session | 6 + .../polychromatic/module.json.captured | 0 .../modules/postgres/files/consumers.json | 1 + .../modules/postgres/module.json.captured | 0 .../modules/power/files/bin/power-moment | 90 + .../modules/power/module.json.captured | 0 .../modules/power/templates/bar-battery | 9 + .../modules/power/templates/bar-power-draw | 6 + .../templates/mesh-power/moments/after-boot | 4 + .../templates/mesh-power/moments/after-wake | 4 + .../mesh-power/moments/before-shutdown | 4 + .../templates/mesh-power/moments/before-sleep | 4 + .../templates/mesh-power/moments/on-battery | 4 + .../templates/mesh-power/moments/on-mains | 4 + .../systemd/logind.conf.d/power.conf | 8 + .../system/hibernate.target.d/mesh-power.conf | 3 + .../hybrid-sleep.target.d/mesh-power.conf | 3 + .../system/mesh-power-after-boot.service | 13 + .../system/mesh-power-after-wake.service | 11 + .../system/mesh-power-before-shutdown.service | 18 + .../system/mesh-power-before-sleep.service | 11 + .../systemd/system/mesh-power-supply.service | 10 + .../system/sleep.target.d/mesh-power.conf | 3 + .../mesh-power.conf | 3 + .../system/suspend.target.d/mesh-power.conf | 3 + .../udev/rules.d/90-mesh-power.rules | 5 + .../modules/powerlevel10k/files/zsh-normal | 3 + .../powerlevel10k/module.json.captured | 0 .../modules/records/files/config.json | 1 + .../mesh-catalog/modules/records/files/origin | 1 + .../modules/records/module.json.captured | 0 .../modules/redis/files/redis.conf | 3 + .../modules/redis/module.json.captured | 0 .../modules/restic/files/backups.conf | 2 + .../modules/restic/files/data.conf | 2 + .../modules/restic/files/retention.conf | 6 + .../modules/restic/module.json.captured | 0 .../mesh-catalog/modules/rofi/files/bin/dmenu | 26 + .../modules/rofi/files/bin/rofi-launch | 39 + .../modules/rofi/files/bin/rofi-powermenu | 54 + .../modules/rofi/files/config.rasi | 54 + .../modules/rofi/files/i3/50-rofi.conf | 8 + .../rofi/files/themes/mesh-powermenu.rasi | 172 ++ .../modules/rofi/files/themes/mesh.rasi | 263 ++ .../modules/rofi/module.json.captured | 0 .../modules/route-adapter/files/config.json | 6 + .../route-adapter/module.json.captured | 0 .../modules/screen-lock/files/bin/screen-lock | 57 + .../modules/screen-lock/files/xinitrc-normal | 8 + .../modules/screen-lock/module.json.captured | 0 .../modules/searxng/files/config.json | 1 + .../modules/searxng/files/settings.yml | 16 + .../modules/searxng/module.json.captured | 0 .../modules/sensors/module.json.captured | 0 .../modules/showcase/files/README.md | 6 + .../modules/showcase/files/marker.txt | 1 + .../modules/showcase/module.json.captured | 0 .../modules/showcase/templates/showcase.env | 5 + .../modules/slack/files/display-session | 11 + .../modules/slack/module.json.captured | 0 .../modules/snapd/module.json.captured | 0 .../modules/ssh-client/files/config | 6 + .../modules/ssh-client/files/forge | 7 + .../modules/ssh-client/files/gitconfig | 2 + .../modules/ssh-client/files/known_hosts | 1 + .../modules/ssh-client/module.json.captured | 0 .../modules/sshd/files/10-mesh.conf | 10 + .../modules/sshd/module.json.captured | 0 .../modules/step-ca/files/config.json | 1 + .../modules/step-ca/files/init.env | 2 + .../modules/step-ca/module.json.captured | 0 .../modules/sudo/files/10-mesh-operator | 9 + .../modules/sudo/module.json.captured | 0 .../modules/supabase/files/analytics-env.env | 14 + .../supabase/files/analytics-reconcile.exs | 86 + .../modules/supabase/files/analytics-start.sh | 41 + .../modules/supabase/files/db-init/jwt.sql | 2 + .../modules/supabase/files/db-init/logs.sql | 6 + .../modules/supabase/files/db-init/pooler.sql | 6 + .../supabase/files/db-init/realtime.sql | 4 + .../modules/supabase/files/db-init/roles.sql | 7 + .../supabase/files/db-init/supabase.sql | 3 + .../supabase/files/db-init/webhooks.sql | 208 ++ .../modules/supabase/files/functions-env.env | 6 + .../supabase/files/functions-main/index.ts | 94 + .../modules/supabase/files/gotrue.env | 28 + .../modules/supabase/files/kong.yml | 242 ++ .../modules/supabase/files/meta-env.env | 6 + .../modules/supabase/files/pooler-env.env | 14 + .../modules/supabase/files/pooler.exs | 43 + .../modules/supabase/files/postgrest.conf | 7 + .../modules/supabase/files/realtime-env.env | 16 + .../modules/supabase/files/settings.json | 7 + .../modules/supabase/files/storage-env.env | 13 + .../modules/supabase/files/studio-env.env | 18 + .../modules/supabase/files/vector.yml | 258 ++ .../modules/supabase/module.json.captured | 0 .../systemd-networkd/files/00-mesh0.network | 35 + .../systemd-networkd/module.json.captured | 0 .../systemd-resolved/module.json.captured | 0 .../modules/systemd/module.json.captured | 0 .../modules/telegram/module.json.captured | 0 .../modules/time-sync/files/50-mesh.conf | 6 + .../modules/time-sync/module.json.captured | 0 .../modules/triggerhappy/files/service.conf | 8 + .../modules/triggerhappy/files/triggers.conf | 4 + .../modules/triggerhappy/module.json.captured | 0 .../modules/umami/files/server.env | 3 + .../modules/umami/module.json.captured | 0 .../modules/unifi/files/config.json | 4 + .../modules/unifi/module.json.captured | 0 .../modules/website/module.json.captured | 0 .../modules/xclip/module.json.captured | 0 .../modules/xdg/files/mimeapps.list | 29 + .../modules/xdg/files/user-dirs.conf | 9 + .../modules/xdg/files/user-dirs.dirs | 17 + .../modules/xdg/files/user-dirs.locale | 1 + .../modules/xdg/module.json.captured | 0 .../mesh-catalog/modules/xorg/files/xinitrc | 51 + .../modules/xorg/files/xresources | 13 + .../modules/xorg/module.json.captured | 0 .../modules/xterm/files/env-terminal | 1 + .../modules/xterm/files/xresources-normal | 31 + .../modules/xterm/module.json.captured | 0 .../mesh-catalog/modules/zfs/files/scrub.conf | 6 + .../modules/zfs/module.json.captured | 0 .../zsh-autosuggestions/files/zsh-normal | 2 + .../zsh-autosuggestions/module.json.captured | 0 .../zsh-syntax-highlighting/files/zsh-last | 3 + .../module.json.captured | 0 .../mesh-catalog/modules/zsh/files/env-editor | 1 + .../modules/zsh/files/env-path-local-bin | 1 + .../modules/zsh/files/env-path-scripts | 1 + .../modules/zsh/files/env-path-scripts-bin | 1 + .../mesh-catalog/modules/zsh/files/env-visual | 1 + .../modules/zsh/files/env-xdg-config-home | 1 + .../mesh-catalog/modules/zsh/files/tools.conf | 3 + .../mesh-catalog/modules/zsh/files/zshenv | 9 + .../mesh-catalog/modules/zsh/files/zshrc | 60 + .../modules/zsh/module.json.captured | 0 testdata/beside/mesh-catalog/predefined.json | 13 + 686 files changed, 35703 insertions(+), 80 deletions(-) create mode 100644 internal/beside/beside.go create mode 100644 internal/beside/reads-outside create mode 100644 internal/compose/resolution/a_service_process_test.go create mode 100644 internal/compose/resolution/accesses_test.go create mode 100644 internal/compose/resolution/adoption.go create mode 100644 internal/compose/resolution/adoption_test.go create mode 100644 internal/compose/resolution/agent_account_test.go create mode 100644 internal/compose/resolution/artifact_store_seat_test.go create mode 100644 internal/compose/resolution/artifact_sweep_seat.go create mode 100644 internal/compose/resolution/artifacts.go create mode 100644 internal/compose/resolution/artifacts_test.go create mode 100644 internal/compose/resolution/backup_placement.go create mode 100644 internal/compose/resolution/backup_placement_test.go create mode 100644 internal/compose/resolution/backup_test.go create mode 100644 internal/compose/resolution/bound.go create mode 100644 internal/compose/resolution/bound_into_files.go create mode 100644 internal/compose/resolution/bound_into_files_test.go create mode 100644 internal/compose/resolution/bound_test.go create mode 100644 internal/compose/resolution/broker_seat_test.go create mode 100644 internal/compose/resolution/brokered_test.go create mode 100644 internal/compose/resolution/bucket_shares.go create mode 100644 internal/compose/resolution/bucket_shares_test.go create mode 100644 internal/compose/resolution/build.go create mode 100644 internal/compose/resolution/busy.go create mode 100644 internal/compose/resolution/busy_test.go create mode 100644 internal/compose/resolution/catalogue_check_test.go create mode 100644 internal/compose/resolution/catalogue_read_test.go create mode 100644 internal/compose/resolution/catrust_manifest_test.go create mode 100644 internal/compose/resolution/checker_seat.go create mode 100644 internal/compose/resolution/chosen.go create mode 100644 internal/compose/resolution/co_located_test.go create mode 100644 internal/compose/resolution/collections.go create mode 100644 internal/compose/resolution/collections_bar_test.go create mode 100644 internal/compose/resolution/collections_l2_test.go create mode 100644 internal/compose/resolution/collections_l3_test.go create mode 100644 internal/compose/resolution/collections_test.go create mode 100644 internal/compose/resolution/computed_test.go create mode 100644 internal/compose/resolution/config_test.go create mode 100644 internal/compose/resolution/consumer_into_serves.go create mode 100644 internal/compose/resolution/consumers.go create mode 100644 internal/compose/resolution/contributes_test.go create mode 100644 internal/compose/resolution/contribution_dependencies_test.go create mode 100644 internal/compose/resolution/control_records_seats.go create mode 100644 internal/compose/resolution/core_credentials.go create mode 100644 internal/compose/resolution/core_credentials_test.go create mode 100644 internal/compose/resolution/data.go create mode 100644 internal/compose/resolution/data_placed.go create mode 100644 internal/compose/resolution/data_test.go create mode 100644 internal/compose/resolution/declaration.go create mode 100644 internal/compose/resolution/declaration_test.go create mode 100644 internal/compose/resolution/decode_for_test_test.go create mode 100644 internal/compose/resolution/delivery_seat.go create mode 100644 internal/compose/resolution/derived_for_consumer_test.go create mode 100644 internal/compose/resolution/dir_into.go create mode 100644 internal/compose/resolution/dir_into_test.go create mode 100644 internal/compose/resolution/doc.go create mode 100644 internal/compose/resolution/domain_test.go create mode 100644 internal/compose/resolution/endpoint_test.go create mode 100644 internal/compose/resolution/endpoints_setting_test.go create mode 100644 internal/compose/resolution/engine_seat.go create mode 100644 internal/compose/resolution/enrolment_seat.go create mode 100644 internal/compose/resolution/environment_into.go create mode 100644 internal/compose/resolution/environment_into_test.go create mode 100644 internal/compose/resolution/environment_items_test.go create mode 100644 internal/compose/resolution/files.go create mode 100644 internal/compose/resolution/files_test.go create mode 100644 internal/compose/resolution/filtering.go create mode 100644 internal/compose/resolution/filtering_test.go create mode 100644 internal/compose/resolution/foundation_manifests_test.go create mode 100644 internal/compose/resolution/generated_collision_test.go create mode 100644 internal/compose/resolution/grant_secret_owner_test.go create mode 100644 internal/compose/resolution/graphical_session.go create mode 100644 internal/compose/resolution/graphical_session_test.go create mode 100644 internal/compose/resolution/groups_test.go create mode 100644 internal/compose/resolution/handed.go create mode 100644 internal/compose/resolution/handed_bound_test.go create mode 100644 internal/compose/resolution/handed_consumers_test.go create mode 100644 internal/compose/resolution/handed_providers_test.go create mode 100644 internal/compose/resolution/handed_pulled_in_test.go create mode 100644 internal/compose/resolution/handed_secrets_test.go create mode 100644 internal/compose/resolution/handed_test.go create mode 100644 internal/compose/resolution/health.go create mode 100644 internal/compose/resolution/health_seats.go create mode 100644 internal/compose/resolution/health_test.go create mode 100644 internal/compose/resolution/holdings_test.go create mode 100644 internal/compose/resolution/hostname_seat_test.go create mode 100644 internal/compose/resolution/jails_into.go create mode 100644 internal/compose/resolution/jails_into_test.go create mode 100644 internal/compose/resolution/kept_test.go create mode 100644 internal/compose/resolution/keyed.go create mode 100644 internal/compose/resolution/keyed_test.go create mode 100644 internal/compose/resolution/kinded_test.go create mode 100644 internal/compose/resolution/laid_values.go create mode 100644 internal/compose/resolution/licence_test.go create mode 100644 internal/compose/resolution/localnetwork.go create mode 100644 internal/compose/resolution/localnetwork_test.go create mode 100644 internal/compose/resolution/machine_into_files.go create mode 100644 internal/compose/resolution/machine_into_files_test.go create mode 100644 internal/compose/resolution/manifest.go create mode 100644 internal/compose/resolution/names_test.go create mode 100644 internal/compose/resolution/needs_test.go create mode 100644 internal/compose/resolution/no_subjects_test.go create mode 100644 internal/compose/resolution/node_resolver.go create mode 100644 internal/compose/resolution/node_resolver_test.go create mode 100644 internal/compose/resolution/operators_machine_test.go create mode 100644 internal/compose/resolution/optional_requirements.go create mode 100644 internal/compose/resolution/optional_requirements_test.go create mode 100644 internal/compose/resolution/outside_not_given_test.go create mode 100644 internal/compose/resolution/own_name_test.go create mode 100644 internal/compose/resolution/own_secret_owner_test.go create mode 100644 internal/compose/resolution/pin_names_module_test.go create mode 100644 internal/compose/resolution/placed_paths_test.go create mode 100644 internal/compose/resolution/placement.go create mode 100644 internal/compose/resolution/placement_seat.go create mode 100644 internal/compose/resolution/port_into.go create mode 100644 internal/compose/resolution/port_into_test.go create mode 100644 internal/compose/resolution/process_bus.go create mode 100644 internal/compose/resolution/process_bus_test.go create mode 100644 internal/compose/resolution/process_env_test.go create mode 100644 internal/compose/resolution/reach_test.go create mode 100644 internal/compose/resolution/reads-outside create mode 100644 internal/compose/resolution/reads.go create mode 100644 internal/compose/resolution/reads_test.go create mode 100644 internal/compose/resolution/received_on_the_bus_test.go create mode 100644 internal/compose/resolution/reflects_renamed_test.go create mode 100644 internal/compose/resolution/replay166_test.go create mode 100644 internal/compose/resolution/replay279_test.go create mode 100644 internal/compose/resolution/resolve.go create mode 100644 internal/compose/resolution/resolve_loopback_test.go create mode 100644 internal/compose/resolution/resolve_test.go create mode 100644 internal/compose/resolution/resolved_path_test.go create mode 100644 internal/compose/resolution/resolver_file_is_the_uplinks_test.go create mode 100644 internal/compose/resolution/resolver_manifests_test.go create mode 100644 internal/compose/resolution/roster.go create mode 100644 internal/compose/resolution/routing_test.go create mode 100644 internal/compose/resolution/runonce_test.go create mode 100644 internal/compose/resolution/runtime.go create mode 100644 internal/compose/resolution/runtime_health_test.go create mode 100644 internal/compose/resolution/runtime_test.go create mode 100644 internal/compose/resolution/schedule_test.go create mode 100644 internal/compose/resolution/seat_contributions.go create mode 100644 internal/compose/resolution/seat_contributions_test.go create mode 100644 internal/compose/resolution/seat_data.go create mode 100644 internal/compose/resolution/seat_data_test.go create mode 100644 internal/compose/resolution/seat_dependencies.go create mode 100644 internal/compose/resolution/seat_dependencies_test.go create mode 100644 internal/compose/resolution/seat_into.go create mode 100644 internal/compose/resolution/seat_into_test.go create mode 100644 internal/compose/resolution/seat_pass.go create mode 100644 internal/compose/resolution/seat_pass_test.go create mode 100644 internal/compose/resolution/seat_paths.go create mode 100644 internal/compose/resolution/seat_paths_test.go create mode 100644 internal/compose/resolution/seats.go create mode 100644 internal/compose/resolution/seats_test.go create mode 100644 internal/compose/resolution/secret_family_test.go create mode 100644 internal/compose/resolution/secret_restart_test.go create mode 100644 internal/compose/resolution/secrets_into_files.go create mode 100644 internal/compose/resolution/secrets_into_files_test.go create mode 100644 internal/compose/resolution/served_own_names.go create mode 100644 internal/compose/resolution/served_own_names_test.go create mode 100644 internal/compose/resolution/setting_defaults.go create mode 100644 internal/compose/resolution/setting_defaults_test.go create mode 100644 internal/compose/resolution/setting_in_owner_test.go create mode 100644 internal/compose/resolution/setting_in_unit_test.go create mode 100644 internal/compose/resolution/setting_into.go create mode 100644 internal/compose/resolution/setting_into_test.go create mode 100644 internal/compose/resolution/setting_takes.go create mode 100644 internal/compose/resolution/settings.go create mode 100644 internal/compose/resolution/settings_test.go create mode 100644 internal/compose/resolution/several_contributions_test.go create mode 100644 internal/compose/resolution/several_secrets_test.go create mode 100644 internal/compose/resolution/shared_account_test.go create mode 100644 internal/compose/resolution/shared_credential_test.go create mode 100644 internal/compose/resolution/shares.go create mode 100644 internal/compose/resolution/short_form_port_protocol_test.go create mode 100644 internal/compose/resolution/ssh_client_test.go create mode 100644 internal/compose/resolution/template.go create mode 100644 internal/compose/resolution/template_test.go create mode 100644 internal/compose/resolution/terminal_settings_test.go create mode 100644 internal/compose/resolution/testdata/bars/bar-battery create mode 100644 internal/compose/resolution/testdata/bars/bar-power-draw create mode 100644 internal/compose/resolution/testdata/bars/battery.bottom-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/battery.top-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/both.bottom-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/both.top-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/bottom-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/none.bottom-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/none.top-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/power-meter.bottom-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/power-meter.top-bar.toml create mode 100644 internal/compose/resolution/testdata/bars/top-bar.toml create mode 100644 internal/compose/resolution/testdata/catalogue-835bf22b-claude-code.module.json rename cmd/mesh-compose/testdata/mesh-catalog/modules/dunst/module.json.captured => internal/compose/resolution/testdata/catalogue-835bf22b-dunst.module.json (100%) create mode 100644 internal/compose/resolution/testdata/catalogue-835bf22b-xterm.module.json create mode 100644 internal/compose/resolution/testdata/environment/all.posix create mode 100644 internal/compose/resolution/testdata/environment/all.systemd create mode 100644 internal/compose/resolution/testdata/environment/zsh.posix create mode 100644 internal/compose/resolution/testdata/environment/zsh.systemd create mode 100644 internal/compose/resolution/testdata/influxdb-served-by-setting/grafana-ace.json create mode 100644 internal/compose/resolution/testdata/influxdb-served-by-setting/grafana-unlayered.json create mode 100644 internal/compose/resolution/testdata/l2-moved/items/asus-zephyrus-g14.contribution-node-hotkeys-trigger create mode 100644 internal/compose/resolution/testdata/l2-moved/items/asus-zephyrus-g14.shell-after-wake-normal create mode 100644 internal/compose/resolution/testdata/l2-moved/items/claude-code.shell-zsh-normal create mode 100644 internal/compose/resolution/testdata/l2-moved/items/mesh-cli.shell-zsh-normal create mode 100644 internal/compose/resolution/testdata/l2-moved/items/powerlevel10k.shell-zsh-normal create mode 100644 internal/compose/resolution/testdata/l2-moved/items/zsh-autosuggestions.shell-zsh-normal create mode 100644 internal/compose/resolution/testdata/l2-moved/items/zsh-syntax-highlighting.shell-zsh-last create mode 100644 internal/compose/resolution/testdata/l2-moved/templates/power create mode 100644 internal/compose/resolution/testdata/l2-moved/templates/triggerhappy create mode 100644 internal/compose/resolution/testdata/l2-moved/templates/zsh create mode 100644 internal/compose/resolution/testdata/l2-moved/want/power create mode 100644 internal/compose/resolution/testdata/l2-moved/want/triggerhappy create mode 100644 internal/compose/resolution/testdata/l2-moved/want/zsh create mode 100644 internal/compose/resolution/toolchain_manifests_test.go create mode 100644 internal/compose/resolution/two_consumers_test.go create mode 100644 internal/compose/resolution/two_resolvers_test.go create mode 100644 internal/compose/resolution/unconsumed_placeholder.go create mode 100644 internal/compose/resolution/unconsumed_placeholder_test.go create mode 100644 internal/compose/resolution/unknown_field.go create mode 100644 internal/compose/resolution/verbs.go create mode 100644 internal/compose/resolution/while_stopped_test.go create mode 100644 internal/compose/resolution/yields.go create mode 100644 internal/compose/resolution/zones.go create mode 100644 internal/compose/resolution/zones_test.go create mode 100644 testdata/beside/CAPTURED create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/cursor-index.theme create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk-theme create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk2-rc-files create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-qpa-platformtheme create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-select create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-style-override create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-size create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-theme create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/gtk-settings.ini create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/portals.conf create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/qt6ct.conf create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/xinitrc-normal create mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/xresources-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/adwaita/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/artifact-store-tools/files/store-names rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/artifact-store-tools/module.json.captured (100%) create mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-backlight create mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-display create mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-kbd-notify create mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-media create mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-notify create mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-session create mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-touchpad rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/after-wake create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/display-session create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/hotkeys-triggers create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/video-brightness-switch.conf create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf create mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/udev/rules.d/90-backlight.rules rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/audit-logger/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/avahi/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/baserow/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/baserow/files/server.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/baserow/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/betterbird/files/accounts.json create mode 100755 testdata/beside/mesh-catalog/modules/betterbird/files/betterbird create mode 100644 testdata/beside/mesh-catalog/modules/betterbird/files/configuration.json create mode 100644 testdata/beside/mesh-catalog/modules/betterbird/files/eu.betterbird.Betterbird.desktop create mode 100644 testdata/beside/mesh-catalog/modules/betterbird/files/prefs.json create mode 100644 testdata/beside/mesh-catalog/modules/betterbird/files/tags.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/betterbird/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/blueman/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/bluetooth/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/brother/files/settings.json create mode 100644 testdata/beside/mesh-catalog/modules/brother/files/store.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/brother/module.json.captured (100%) create mode 100755 testdata/beside/mesh-catalog/modules/ca-trust/files/anchor create mode 100644 testdata/beside/mesh-catalog/modules/ca-trust/files/mesh-ca-trust.service rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/ca-trust/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/claude-code/files/agent-account create mode 100755 testdata/beside/mesh-catalog/modules/claude-code/files/claude-agent create mode 100644 testdata/beside/mesh-catalog/modules/claude-code/files/facts.json create mode 100644 testdata/beside/mesh-catalog/modules/claude-code/files/settings.json create mode 100644 testdata/beside/mesh-catalog/modules/claude-code/files/zsh-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/claude-code/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/claude-licence-manager/files/database.url create mode 100644 testdata/beside/mesh-catalog/modules/claude-licence-manager/files/settings.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/claude-licence-manager/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/bin/clipmenud-session create mode 100755 testdata/beside/mesh-catalog/modules/clipmenu/files/bin/xsel create mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-histlength create mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-max-clips create mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-selections create mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/i3/50-clipmenu.conf create mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/systemd/clipmenud-mesh.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/clipmenu/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/confluence/files/config.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/confluence/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/cups/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/dbus/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/desk-channel/files/settings.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/desk-channel/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/dev-tools/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/disk-load/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/distribution/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/dmenu/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/dnsmasq/files/dnsmasq.conf create mode 100644 testdata/beside/mesh-catalog/modules/dnsmasq/files/mesh.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/dnsmasq/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/docker-compose/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/docker/files/daemon.json create mode 100644 testdata/beside/mesh-catalog/modules/docker/files/docker-prune.service create mode 100644 testdata/beside/mesh-catalog/modules/docker/files/docker-prune.timer create mode 100644 testdata/beside/mesh-catalog/modules/docker/files/log-rotation.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/docker/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/dunst/files/dunstrc create mode 100644 testdata/beside/mesh-catalog/modules/dunst/files/i3/50-dunst.conf create mode 100644 testdata/beside/mesh-catalog/modules/dunst/module.json.captured create mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/fail2ban create mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/iptables-allports-dualchain.conf create mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/jail.local create mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/mesh-own-addresses.json create mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/own-addresses create mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/recidive.conf create mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/sshd.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/fail2ban/module.json.captured (100%) create mode 100755 testdata/beside/mesh-catalog/modules/feh/files/fehbg create mode 100644 testdata/beside/mesh-catalog/modules/feh/files/i3/50-feh.conf create mode 100644 testdata/beside/mesh-catalog/modules/feh/files/xinitrc-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/feh/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/flatpak/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/fonts/files/50-mesh-fonts.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/fonts/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/forticlient/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/agent-teams.json create mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/consumers.json create mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/origins create mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/repositories.json create mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/server.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/gitea/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/gitlab/files/config.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/gitlab/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/login create mode 100644 testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/passwd create mode 100644 testdata/beside/mesh-catalog/modules/gnome-keyring/files/xinitrc-first rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/gnome-keyring/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/go/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/admin.secret create mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/influxdb-api create mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/mesh-influxdb.yaml create mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/oidc-client.secret create mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/oidc.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/grafana/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/hello-web/files/index.html rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/hello-web/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/home-assistant/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/home-assistant/files/provisions.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/home-assistant/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/hostname/files/hostname create mode 100644 testdata/beside/mesh-catalog/modules/hostname/files/hosts rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/hostname/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/i3/files/config create mode 100644 testdata/beside/mesh-catalog/modules/i3/files/env-xdg-current-desktop create mode 100644 testdata/beside/mesh-catalog/modules/i3/files/env-xdg-session-desktop create mode 100755 testdata/beside/mesh-catalog/modules/i3/files/wms/i3 create mode 100644 testdata/beside/mesh-catalog/modules/i3/files/xinitrc-last rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/i3/module.json.captured (100%) create mode 100755 testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3bar-watchdog create mode 100755 testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-updates create mode 100755 testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-value create mode 100644 testdata/beside/mesh-catalog/modules/i3status-rust/files/bottom-bar.toml create mode 100644 testdata/beside/mesh-catalog/modules/i3status-rust/files/i3/60-i3status-rust.conf create mode 100644 testdata/beside/mesh-catalog/modules/i3status-rust/files/icons/custom-icons.toml create mode 100644 testdata/beside/mesh-catalog/modules/i3status-rust/files/top-bar.toml create mode 100644 testdata/beside/mesh-catalog/modules/i3status-rust/files/xinitrc-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/i3status-rust/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/icecast/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/icecast/files/icecast.xml rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/icecast/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/influxdb/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/influxdb/files/server.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/influxdb/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/invoicing/files/api.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/invoicing/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/jira/files/config.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/jira/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/kernel/files/huge-pages.sh create mode 100644 testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-huge-pages.service rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/kernel/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/keycloak/files/admin.env create mode 100644 testdata/beside/mesh-catalog/modules/keycloak/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/keycloak/files/consumers.json create mode 100644 testdata/beside/mesh-catalog/modules/keycloak/files/database.env create mode 100644 testdata/beside/mesh-catalog/modules/keycloak/files/hostname.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/keycloak/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/lab/files/lab.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/lab/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/lemurs/files/config.toml create mode 100755 testdata/beside/mesh-catalog/modules/lemurs/files/xserver rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/lemurs/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/letta/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/letta/files/pgpass create mode 100644 testdata/beside/mesh-catalog/modules/letta/files/server.env create mode 100644 testdata/beside/mesh-catalog/modules/letta/files/start.sh rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/letta/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/local-model-consumer/files/openai.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/local-model-consumer/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/localization/files/locale.conf create mode 100644 testdata/beside/mesh-catalog/modules/localization/files/vconsole.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/localization/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/logrotate/files/logrotate.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/logrotate/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mailu/files/admin.env create mode 100644 testdata/beside/mesh-catalog/modules/mailu/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/mailu/files/database.env create mode 100644 testdata/beside/mesh-catalog/modules/mailu/files/mailu.env create mode 100644 testdata/beside/mesh-catalog/modules/mailu/files/secret.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mailu/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/marrytts/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/matrix/files/conduit.toml create mode 100644 testdata/beside/mesh-catalog/modules/matrix/files/element.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/matrix/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/memory-pressure/files/oomd.conf create mode 100644 testdata/beside/mesh-catalog/modules/memory-pressure/files/root-slice.conf create mode 100644 testdata/beside/mesh-catalog/modules/memory-pressure/files/sysctl.conf create mode 100644 testdata/beside/mesh-catalog/modules/memory-pressure/files/user-manager.conf create mode 100644 testdata/beside/mesh-catalog/modules/memory-pressure/files/zram-generator.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/memory-pressure/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-go/files/forge create mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-go/files/gitconfig create mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-go/files/known_hosts rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-build-go/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-node/files/forge create mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-node/files/gitconfig create mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-node/files/known_hosts rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-build-node/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-catalog/files/database.url rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-catalog/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-cli/files/zsh-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-cli/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-config/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-delivery-gate/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-files/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-issues/files/settings.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-issues/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-knowledge/files/database.url create mode 100644 testdata/beside/mesh-catalog/modules/mesh-knowledge/files/settings.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-knowledge/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-mail-notifier/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/callers.json create mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/database-url rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-mail-reader/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail-web/files/oidc.json create mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail-web/files/operators.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-mail-web/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail/files/accounts.json create mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail/files/database-url create mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail/files/grants.json create mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail/files/store.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-mail/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-provisioner-client/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-provisioner/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-reads/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-vault/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mesh-watcher/files/settings.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mesh-watcher/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/messenger/files/settings.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/messenger/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/minio/files/root.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/minio/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/model-usage/files/database.url rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/model-usage/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mongodb/files/server-root.secret rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mongodb/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mosquitto/files/bootstrap.env create mode 100644 testdata/beside/mesh-catalog/modules/mosquitto/files/mosquitto.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mosquitto/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mounts/files/mounts.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mounts/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/mssql/files/backup.sql create mode 100644 testdata/beside/mesh-catalog/modules/mssql/files/sa.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/mssql/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/n8n/files/n8n-database.secret create mode 100644 testdata/beside/mesh-catalog/modules/n8n/files/server.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/n8n/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/nats/files/nats.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/nats/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/netcheck/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/networkmanager/files/50-mesh.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/networkmanager/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/nextcloud-client/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/nextcloud/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/nextcloud/files/server.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/nextcloud/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/nfs-server/files/50-mesh.conf create mode 100644 testdata/beside/mesh-catalog/modules/nfs-server/files/shares.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/nfs-server/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/nftables/files/mesh-filter.service create mode 100644 testdata/beside/mesh-catalog/modules/nftables/files/mesh.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/nftables/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/nm-applet/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/node-env/files/50-mesh.conf create mode 100644 testdata/beside/mesh-catalog/modules/node-env/files/environment.sh rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/node-env/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/nodejs/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/nodered/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/nodered/files/mqtt.env create mode 100644 testdata/beside/mesh-catalog/modules/nodered/files/settings.js create mode 100644 testdata/beside/mesh-catalog/modules/nodered/files/settings.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/nodered/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/ollama/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/only-office/files/server.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/only-office/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/openai-consumer/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/openrazer/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/pacman/files/mesh-pacman-weekly-upgrade.service create mode 100644 testdata/beside/mesh-catalog/modules/pacman/files/mesh-pacman-weekly-upgrade.timer create mode 100644 testdata/beside/mesh-catalog/modules/pacman/files/pacman.conf create mode 100644 testdata/beside/mesh-catalog/modules/pacman/files/reflector.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/pacman/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/photos-eef/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/photos-filip/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/picom/files/picom.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/picom/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/polychromatic/files/display-session rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/polychromatic/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/postgres/files/consumers.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/postgres/module.json.captured (100%) create mode 100755 testdata/beside/mesh-catalog/modules/power/files/bin/power-moment rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/power/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/bar-battery create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/bar-power-draw create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-boot create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-wake create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-shutdown create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-sleep create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-battery create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-mains create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/logind.conf.d/power.conf create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hibernate.target.d/mesh-power.conf create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-boot.service create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-wake.service create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-shutdown.service create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-sleep.service create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-supply.service create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/sleep.target.d/mesh-power.conf create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend.target.d/mesh-power.conf create mode 100644 testdata/beside/mesh-catalog/modules/power/templates/udev/rules.d/90-mesh-power.rules create mode 100644 testdata/beside/mesh-catalog/modules/powerlevel10k/files/zsh-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/powerlevel10k/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/records/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/records/files/origin rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/records/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/redis/files/redis.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/redis/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/restic/files/backups.conf create mode 100644 testdata/beside/mesh-catalog/modules/restic/files/data.conf create mode 100644 testdata/beside/mesh-catalog/modules/restic/files/retention.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/restic/module.json.captured (100%) create mode 100755 testdata/beside/mesh-catalog/modules/rofi/files/bin/dmenu create mode 100755 testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-launch create mode 100755 testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-powermenu create mode 100644 testdata/beside/mesh-catalog/modules/rofi/files/config.rasi create mode 100644 testdata/beside/mesh-catalog/modules/rofi/files/i3/50-rofi.conf create mode 100644 testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh-powermenu.rasi create mode 100644 testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh.rasi rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/rofi/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/route-adapter/files/config.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/route-adapter/module.json.captured (100%) create mode 100755 testdata/beside/mesh-catalog/modules/screen-lock/files/bin/screen-lock create mode 100644 testdata/beside/mesh-catalog/modules/screen-lock/files/xinitrc-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/screen-lock/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/searxng/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/searxng/files/settings.yml rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/searxng/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/sensors/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/showcase/files/README.md create mode 100644 testdata/beside/mesh-catalog/modules/showcase/files/marker.txt rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/showcase/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/showcase/templates/showcase.env create mode 100644 testdata/beside/mesh-catalog/modules/slack/files/display-session rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/slack/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/snapd/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/ssh-client/files/config create mode 100644 testdata/beside/mesh-catalog/modules/ssh-client/files/forge create mode 100644 testdata/beside/mesh-catalog/modules/ssh-client/files/gitconfig create mode 100644 testdata/beside/mesh-catalog/modules/ssh-client/files/known_hosts rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/ssh-client/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/sshd/files/10-mesh.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/sshd/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/step-ca/files/config.json create mode 100644 testdata/beside/mesh-catalog/modules/step-ca/files/init.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/step-ca/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/sudo/files/10-mesh-operator rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/sudo/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/analytics-env.env create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/analytics-reconcile.exs create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/analytics-start.sh create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/jwt.sql create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/logs.sql create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/pooler.sql create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/realtime.sql create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/roles.sql create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/supabase.sql create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/webhooks.sql create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/functions-env.env create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/functions-main/index.ts create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/gotrue.env create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/kong.yml create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/meta-env.env create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/pooler-env.env create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/pooler.exs create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/postgrest.conf create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/realtime-env.env create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/settings.json create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/storage-env.env create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/studio-env.env create mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/vector.yml rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/supabase/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/systemd-networkd/files/00-mesh0.network rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/systemd-networkd/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/systemd-resolved/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/systemd/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/telegram/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/time-sync/files/50-mesh.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/time-sync/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/triggerhappy/files/service.conf create mode 100644 testdata/beside/mesh-catalog/modules/triggerhappy/files/triggers.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/triggerhappy/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/umami/files/server.env rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/umami/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/unifi/files/config.json rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/unifi/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/website/module.json.captured (100%) rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/xclip/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/xdg/files/mimeapps.list create mode 100644 testdata/beside/mesh-catalog/modules/xdg/files/user-dirs.conf create mode 100644 testdata/beside/mesh-catalog/modules/xdg/files/user-dirs.dirs create mode 100644 testdata/beside/mesh-catalog/modules/xdg/files/user-dirs.locale rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/xdg/module.json.captured (100%) create mode 100755 testdata/beside/mesh-catalog/modules/xorg/files/xinitrc create mode 100644 testdata/beside/mesh-catalog/modules/xorg/files/xresources rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/xorg/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/xterm/files/env-terminal create mode 100644 testdata/beside/mesh-catalog/modules/xterm/files/xresources-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/xterm/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/zfs/files/scrub.conf rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/zfs/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/zsh-autosuggestions/files/zsh-normal rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/zsh-autosuggestions/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/zsh-syntax-highlighting/files/zsh-last rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/zsh-syntax-highlighting/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-editor create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-path-local-bin create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-path-scripts create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-path-scripts-bin create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-visual create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-xdg-config-home create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/tools.conf create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/zshenv create mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/zshrc rename {cmd/mesh-compose/testdata => testdata/beside}/mesh-catalog/modules/zsh/module.json.captured (100%) create mode 100644 testdata/beside/mesh-catalog/predefined.json diff --git a/cmd/mesh-compose/main_test.go b/cmd/mesh-compose/main_test.go index dbb62af..83a5faf 100644 --- a/cmd/mesh-compose/main_test.go +++ b/cmd/mesh-compose/main_test.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "go/build" - "io/fs" "os" "os/exec" "path/filepath" @@ -16,41 +15,15 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/beside" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/composer" ) -// captured is every manifest of the catalogue, raw, by module name: the clone beside a merge check (MESH_CHECK_BESIDE), -// or the copy captured in testdata/mesh-catalog from mesh-sdk's testdata/beside (mesh-catalog b5dbfbb7, its -// module.json files alone), each kept as module.json.captured so this repository holds no module of the catalogue. +// captured is every manifest of the catalogue captured in testdata/beside (internal/beside), raw, by module name. func captured(t *testing.T) map[string]composer.Manifest { t.Helper() - dir := "" - if root := os.Getenv("MESH_CHECK_BESIDE"); root != "" { - dir = filepath.Join(root, "mesh-catalog") - } else { - dir = t.TempDir() - from, _ := filepath.Abs("testdata/mesh-catalog") - err := filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - rel, _ := filepath.Rel(from, path) - into := filepath.Join(dir, strings.TrimSuffix(rel, ".captured")) - raw, err := os.ReadFile(path) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(into), 0o755); err != nil { - return err - } - return os.WriteFile(into, raw, 0o644) - }) - if err != nil { - t.Fatal(err) - } - } - read, err := readCatalogue(dir) + read, err := readCatalogue(filepath.Dir(beside.Catalogue(t))) if err != nil { t.Fatal(err) } diff --git a/internal/beside/beside.go b/internal/beside/beside.go new file mode 100644 index 0000000..d31ec1d --- /dev/null +++ b/internal/beside/beside.go @@ -0,0 +1,134 @@ +// Package beside is where a test finds another repository of the mesh it reads: the catalogue's manifests, +// the node-engine's genesis template (novox/hq issue 432), the SDK's conformance fixtures (issue 449). +// +// Copied from mesh-controller internal/beside at commit 6c2a8d62 (R4b, issue 1379); the one change is that Dir +// lays out the captured copy of a repository once per process, not once per test (see laidOut). In mesh-sdk the +// catalogue's tests read mesh-catalog and mesh-host only; the captured copy holds just what they read. +// +// A test used to read the checkout beside this one, `../../../mesh-catalog`, so its verdict depended on +// whatever sat on the machine running it: a stale or dirty checkout failed it on a desktop, and where none +// was beside it skipped and said nothing. Now there are two inputs, both named, and no third: +// +// - In a merge check, the build seat clones each core repository beside the one checked (the catalogue +// at its main, the node-engine at the commit the mesh runs) and says where in MESH_CHECK_BESIDE. A +// test judges against those clones, so agreement with the other repository is checked where +// `mesh/repo-check` runs; a repository missing there fails the test, never skips it. Which clones a +// check gets is chosen from the inventory: mesh-catalog by the source of the `nats` module, mesh-host +// by the source of `mesh-host`, and mesh-sdk as the controller's sibling at the commit the controller's +// go.mod pins. Each repository's tests read these siblings, and the checker places exactly these +// (mesh-delivery's checker, besideRules, follows this list): mesh-controller's read mesh-catalog, +// mesh-host and mesh-sdk; mesh-host's read mesh-catalog, mesh-sdk and mesh-controller (its step-nine +// test reads the controller's own manifest, novox/hq issue 1295); mesh-sdk's read mesh-catalog and +// mesh-host; mesh-catalog's read mesh-sdk and mesh-controller; mesh-delivery's read mesh-sdk and +// mesh-catalog. In a mesh where either module has no source repository nothing is +// cloned, and these tests fail loudly with "not beside this check": a cause in the setup, not in the +// change. And in a delivery group that holds a mesh-catalog pull request, these tests read the +// catalogue's main, not the group's head. +// - Anywhere else, the test judges against the copy captured in this repository's testdata/beside, at the +// commit testdata/beside/CAPTURED names. Never against a developer's own checkout: to judge one, set +// MESH_CHECK_BESIDE to the directory holding it, as the check does. +// +// The captured manifests are named module.json.captured, and put back as module.json in a directory of +// the test's own: a module.json anywhere in this repository is a module of it to the forge's announcer and +// the planner, and a merge would build and register a hundred copies of the catalogue's. +package beside + +import ( + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +// Env is where a merge check says the repositories cloned beside the one checked are (internal/builder's +// EnvBeside, repeated here so a test does not import the builder). +const Env = "MESH_CHECK_BESIDE" + +// CapturedSuffix is what a captured file's name carries that the repository's own does not. +const CapturedSuffix = ".captured" + +// Dir is the named repository a test reads — the clone beside a merge check, or the captured copy — and +// says which in the test's log. +func Dir(t testing.TB, repository string) string { + t.Helper() + if root := os.Getenv(Env); root != "" { + dir := filepath.Join(root, repository) + if _, err := os.Stat(dir); err != nil { + t.Fatalf("%s is not beside this check in %s=%s, so its agreement with this repository cannot be "+ + "judged here: %v", repository, Env, root, err) + } + t.Logf("judged against %s as cloned beside this check", dir) + return dir + } + from := filepath.Join(Captured(), repository) + if _, err := os.Stat(from); err != nil { + t.Fatalf("no captured copy of %s at %s: %v", repository, from, err) + } + dir, err := layOut(repository, from) + if err != nil { + t.Fatalf("the captured copy of %s could not be laid out: %v", repository, err) + } + t.Logf("judged against the copy of %s captured in testdata/beside (see CAPTURED); a merge check "+ + "judges it against the repository's own clone", repository) + return dir +} + +// laidOut is the captured copies already put back in place in this process, by repository. A copy is +// read-only to every test, so it is laid out once per process and not once per test: with a copy per test +// the catalogue's tests alone took 2.14 s, over mesh-sdk's unit bound of 2 s, and with one 0.81 s (measured +// 2026-10-20). The directories are in the temp directory, which the system clears; a process cannot remove +// them while another test may still read them. +var ( + laidOutMu sync.Mutex + laidOut = map[string]string{} +) + +func layOut(repository, from string) (string, error) { + laidOutMu.Lock() + defer laidOutMu.Unlock() + if dir, ok := laidOut[repository]; ok { + return dir, nil + } + root, err := os.MkdirTemp("", "beside-") + if err != nil { + return "", err + } + dir := filepath.Join(root, repository) + err = filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(from, path) + into := filepath.Join(dir, strings.TrimSuffix(rel, CapturedSuffix)) + if d.IsDir() { + return os.MkdirAll(into, 0o755) + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(into, raw, 0o644) + }) + if err != nil { + os.RemoveAll(root) + return "", err + } + laidOut[repository] = dir + return dir, nil +} + +// Catalogue is the catalogue's modules directory, as Dir finds the catalogue. +func Catalogue(t testing.TB) string { + t.Helper() + return filepath.Join(Dir(t, "mesh-catalog"), "modules") +} + +// Captured is this repository's testdata/beside, found from this file rather than from the working +// directory, so a test in any package reaches it. +func Captured() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "..", "..", "testdata", "beside") +} diff --git a/internal/beside/reads-outside b/internal/beside/reads-outside new file mode 100644 index 0000000..f6b2d87 --- /dev/null +++ b/internal/beside/reads-outside @@ -0,0 +1 @@ +These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935). diff --git a/internal/compose/composed.go b/internal/compose/composed.go index 9dc317d..fffd0b8 100644 --- a/internal/compose/composed.go +++ b/internal/compose/composed.go @@ -11,6 +11,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/overlay" ) @@ -315,7 +316,7 @@ func RunsAnyOf(runs *delivery.Composed, module string) bool { // nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build // (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process. func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle { - runtimeID := catalogue.RuntimeModule + "." + catalogue.RuntimeProcessID() + runtimeID := catalogue.RuntimeModule + "." + resolution.RuntimeProcessID() type resource struct { ID string `json:"id"` Module string `json:"module"` @@ -340,9 +341,9 @@ func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle { switch { case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule: env = r.Env - case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+catalogue.BundleID("")): - name := strings.TrimPrefix(r.ID, owner+"."+catalogue.BundleID("")) - if r.Path == catalogue.BundlePath(owner, name) { + case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+resolution.BundleID("")): + name := strings.TrimPrefix(r.ID, owner+"."+resolution.BundleID("")) + if r.Path == resolution.BundlePath(owner, name) { out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest}) } } @@ -396,7 +397,7 @@ func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue } } if man.Build != nil { - run := catalogue.RunByAResource(man) + run := resolution.RunByAResource(man) for _, a := range man.Build.Artifacts { if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name { b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a) @@ -431,7 +432,7 @@ func NamesBuild(m catalogue.Manifest) bool { func Stub(m catalogue.Manifest) catalogue.Manifest { // The paths it owns stay its manifest's, as the controller tells the holder of node-files them (issue 1126, seed-8). if m.OwnsPaths == nil { - m.OwnsPaths = catalogue.DeclaredPaths(m) + m.OwnsPaths = resolution.DeclaredPaths(m) } m.Build = nil // What it hands the provisioner is left out as it runs, not refused for want of a plan (catalogue.Manifest.Stubbed). diff --git a/internal/compose/composed_test.go b/internal/compose/composed_test.go index c5373f4..35091c1 100644 --- a/internal/compose/composed_test.go +++ b/internal/compose/composed_test.go @@ -8,6 +8,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/overlay" ) @@ -118,7 +119,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { if err != nil { t.Fatal(err) } - served := "lamp=" + catalogue.BundlePath("lamp", "tools") + "/lamp-tools" + served := "lamp=" + resolution.BundlePath("lamp", "tools") + "/lamp-tools" runtimeRuns := func(dropped bool) delivery.Composed { env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"` if dropped { @@ -128,7 +129,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { `{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}} } lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') + - `","digest":"` + dg('b') + `","path":"` + catalogue.BundlePath("lamp", "tools") + `"}` + `","digest":"` + dg('b') + `","path":"` + resolution.BundlePath("lamp", "tools") + `"}` for _, row := range []struct { name string set func(n *Node) delivery.Composed // and what runs of lamp diff --git a/internal/compose/credentials.go b/internal/compose/credentials.go index 480c988..374302d 100644 --- a/internal/compose/credentials.go +++ b/internal/compose/credentials.go @@ -11,6 +11,7 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/compose/resolution" ) // Mode says what a composition does about what a send would make. Both modes read what the Input holds and make nothing; they @@ -64,7 +65,7 @@ func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overfl return nil, nil, nil, nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Provision, err) } plan := resolved.Plan - values, asks, err := plan.ContributionsFrom(s.Provision, s.ConsumerModule, resolved.Settings) + values, asks, err := resolution.ContributionsFrom(plan, s.Provision, s.ConsumerModule, resolved.Settings) if err != nil { return nil, nil, nil, nil, err } @@ -73,13 +74,13 @@ func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overfl if err != nil { return nil, nil, nil, nil, err } - values = catalogue.AtPublishedPort(values, s.ConsumerModule, consumer.Ports[s.ConsumerModule]) + values = resolution.AtPublishedPort(values, s.ConsumerModule, consumer.Ports[s.ConsumerModule]) from := s.ConsumerModule if !asks { // That module no longer wants this: left empty, which is how a login is withdrawn rather than kept working for ever. from = "" } - if bound := plan.BindsFrom(s.Provision, s.ConsumerModule, s.Local); from != "" && !slices.Contains(bound, node) { + if bound := resolution.BindsFrom(plan, s.Provision, s.ConsumerModule, s.Local); from != "" && !slices.Contains(bound, node) { // It still asks, and not of this node: withdrawn exactly as a credential nobody asks for, and said. from = "" unbound = append(unbound, catalogue.Unbound{Provision: s.Provision, Provider: node, @@ -294,7 +295,7 @@ func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing c foreseen[module][name] = true } for _, m := range plan.Modules { - if handing.Handing(m) { + if resolution.Handing(handing, m) { continue } // A secret family is never made (novox/hq ADR 0283): each member a person gave on this machine is placed, and one not @@ -391,7 +392,7 @@ type CoreBoxWanted struct { func coreModulesOf(modules []catalogue.Manifest, handed func(catalogue.Manifest) bool) map[string][]string { out := map[string][]string{} for _, m := range modules { - if !catalogue.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) { + if !resolution.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) { continue } var names []string @@ -424,7 +425,7 @@ func sealingKeyValid(raw string) error { // sealing key, or one whose key is not a key, leaves its module out, said. func (c *composer) coreBoxesFor(node Node, modules []catalogue.Manifest, handing catalogue.Rendering) ( sealed map[string]map[string]json.RawMessage, notPlaced map[string]string, wanted []CoreBoxWanted) { - core := coreModulesOf(modules, func(m catalogue.Manifest) bool { return handing.Handing(m) }) + core := coreModulesOf(modules, func(m catalogue.Manifest) bool { return resolution.Handing(handing, m) }) sealed, notPlaced = map[string]map[string]json.RawMessage{}, map[string]string{} mods := make([]string, 0, len(core)) for m := range core { diff --git a/internal/compose/frombus/answer.go b/internal/compose/frombus/answer.go index f5a5e9e..d2b0162 100644 --- a/internal/compose/frombus/answer.go +++ b/internal/compose/frombus/answer.go @@ -13,6 +13,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" ) // Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with @@ -410,7 +411,7 @@ func blames(m string, again, err error) bool { if m == catalogue.RuntimeModule { // Or where its stub fails only for the modules of their own account it would run (issue 774): they are held // after it, each for its own words, and the node composes the rest. - var none *catalogue.NoRuntimeToRunError + var none *resolution.NoRuntimeToRunError return again == nil || errors.As(again, &none) && none.Held } return again == nil || again.Error() != err.Error() diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go index a6c9c1c..daee654 100644 --- a/internal/compose/frombus/answer_rows_test.go +++ b/internal/compose/frombus/answer_rows_test.go @@ -13,6 +13,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/overlay" ) @@ -260,7 +261,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { switch r.ID { case "nft." + catalogue.FilteringID(): rules = r.Content - case "vault." + catalogue.KeptID(): + case "vault." + resolution.KeptID(): keptComposed = raw } } @@ -291,7 +292,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`), json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`), json.RawMessage(`{"id":"f1-circuits.server","module":"f1-circuits","type":"container","name":"f1-circuits","image":"registry.example/f1@sha256:` + strings.Repeat("c", 64) + `"}`)}} - if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) { + if got, want := resolution.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), resolution.DeclaredPaths(f1); !reflect.DeepEqual(got, want) { t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want) } // Issue 1126: a held module hands no template its build kept for a file it no longer carries (novox's node: diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index c5795c2..f063f49 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -45,6 +45,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/overlay" "github.com/novox/mesh-host/internal/takein" ) @@ -666,7 +667,7 @@ func plansIn(in compose.Input, node string, mode compose.Mode) (map[string]catal } with := rendered.Rendering with.Generators = gens - composed, err := resolved.Plan.Compose(with) + composed, err := resolution.Compose(resolved.Plan, with) if err != nil { return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err) } @@ -1095,7 +1096,7 @@ func storeSought(in compose.Input) string { func keptOf(c delivery.Composed) map[string]any { for _, raw := range c.Resources { var r map[string]any - if json.Unmarshal(raw, &r) == nil && r["id"] == c.Module+"."+catalogue.KeptID() { + if json.Unmarshal(raw, &r) == nil && r["id"] == c.Module+"."+resolution.KeptID() { return r } } @@ -1110,7 +1111,7 @@ func membershipOf(own delivery.Composed) string { ID string `json:"id"` Sealed string `json:"sealed"` } - if json.Unmarshal(raw, &r) == nil && r.ID == catalogue.BusMembershipID() { + if json.Unmarshal(raw, &r) == nil && r.ID == resolution.BusMembershipID() { return r.Sealed } } diff --git a/internal/compose/frombus/held_rows_test.go b/internal/compose/frombus/held_rows_test.go index 7bde353..bc8fae7 100644 --- a/internal/compose/frombus/held_rows_test.go +++ b/internal/compose/frombus/held_rows_test.go @@ -11,6 +11,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" ) // aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given. @@ -101,8 +102,8 @@ func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) { {"lamp", errors.New("lamp refuses"), false}, {catalogue.RuntimeModule, nil, true}, {catalogue.RuntimeModule, errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false}, - {catalogue.RuntimeModule, fmt.Errorf("compose: %w", &catalogue.NoRuntimeToRunError{Held: true}), true}, - {catalogue.RuntimeModule, &catalogue.NoRuntimeToRunError{}, false}, + {catalogue.RuntimeModule, fmt.Errorf("compose: %w", &resolution.NoRuntimeToRunError{Held: true}), true}, + {catalogue.RuntimeModule, &resolution.NoRuntimeToRunError{}, false}, } { if got := blames(row.module, row.again, err); got != row.blamed { t.Errorf("%s with %v: blamed %v, want %v", row.module, row.again, got, row.blamed) @@ -125,7 +126,7 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) { running := []delivery.Composed{{Node: "n1", Module: catalogue.RuntimeModule, FP: old, Resources: []json.RawMessage{ json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`), json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` + - dg("1") + `","digest":"` + dg("1") + `","path":"` + catalogue.BundlePath("node-tools", "runtime") + `"}`)}}} + dg("1") + `","digest":"` + dg("1") + `","path":"` + resolution.BundlePath("node-tools", "runtime") + `"}`)}}} for _, row := range []struct { name string want string // the build node-tools' want names diff --git a/internal/compose/frombus/states.go b/internal/compose/frombus/states.go index fbf92b0..ca932f3 100644 --- a/internal/compose/frombus/states.go +++ b/internal/compose/frombus/states.go @@ -15,9 +15,9 @@ import ( "strings" "time" - "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" ) // The states a module names in its manifest's `reads` to compose through StatesReader. @@ -135,7 +135,7 @@ func (r statesReader) Values(_ context.Context, node, module string) (map[string if !ok || strings.Contains(name, ".") { return } - if value, laid := catalogue.LaidValue(v); laid { + if value, laid := resolution.LaidValue(v); laid { if out == nil { out = map[string]any{} } diff --git a/internal/compose/network.go b/internal/compose/network.go index 2438efa..4e11abf 100644 --- a/internal/compose/network.go +++ b/internal/compose/network.go @@ -9,6 +9,7 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/overlay" ) @@ -282,7 +283,7 @@ func (c *composer) onTheNetworkNow() []Node { if len(n.Assigned) == 0 { continue } - got, err := catalogue.Resolve(c.in.Catalogue, n.Assigned, + got, err := resolution.Resolve(c.in.Catalogue, n.Assigned, catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities}, catalogue.World{Unchecked: true, Holdings: c.in.Holdings}) if err != nil { diff --git a/internal/compose/node.go b/internal/compose/node.go index 550b572..0aff1dc 100644 --- a/internal/compose/node.go +++ b/internal/compose/node.go @@ -7,6 +7,7 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/validate" ) @@ -178,13 +179,13 @@ func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) { with := rendered.Rendering with.Generators = gens plan := resolved.Plan - composed, err := plan.Compose(with) + composed, err := resolution.Compose(plan, with) if err != nil { return Declared{}, err } // Which ports the filter admits from the local network, and from which networks (novox/hq ADR 0340 clause 8): the same rules the // composition rendered, never sent. - rules, err := plan.Rules(with) + rules, err := resolution.Rules(plan, with) if err != nil { return Declared{}, err } @@ -198,7 +199,7 @@ func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) { Withheld: rendered.Withheld, Unbound: rendered.Unbound, Foreseen: composed.Foreseen, StoodIn: rendered.Foreseen, ForeseenPorts: rendered.ForeseenPorts, Unplaced: composed.Unplaced, HandedOnly: composed.HandedOnly, Modules: namedModules(plan, composed.LeftOut), - Filled: composed.Filled, LocalNetwork: catalogue.LocalNetworkPorts(rules, with.LocalNetworks), + Filled: composed.Filled, LocalNetwork: resolution.LocalNetworkPorts(rules, with.LocalNetworks), CoreBoxesWanted: rendered.CoreBoxesWanted, Said: rendered.Said, Sequence: node.Sequence, Epoch: node.Epoch, Generation: node.Generation} if len(composed.Filled) > 0 { @@ -206,7 +207,7 @@ func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) { } // A handed module only pulled in by another keeps what requires it, for its value of its own in the assignments mirror (issue 765). for module := range composed.Plans { - if by := plan.RequiredBy(module); by != "" { + if by := resolution.RequiredBy(plan, module); by != "" { if out.RequiredBy == nil { out.RequiredBy = map[string]string{} } @@ -347,5 +348,5 @@ func DataItems(in Input, node string) (map[string][]catalogue.PlacedDataItem, [] if err != nil { return nil, nil, err } - return resolved.Plan.DataItems(rendered.Rendering) + return resolution.DataItems(resolved.Plan, rendered.Rendering) } diff --git a/internal/compose/rendering.go b/internal/compose/rendering.go index 646ac86..c8db3e0 100644 --- a/internal/compose/rendering.go +++ b/internal/compose/rendering.go @@ -7,6 +7,7 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/overlay" ) @@ -156,7 +157,7 @@ func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) { // Which of this machine's links face outside (novox/hq ADR 0140), and which of its networks count as its local network // (ADR 0340). r.OutwardLinks = node.OutwardLinks - r.LocalNetworks = catalogue.CountedLocalNetworks(localNetworksOf(node)) + r.LocalNetworks = resolution.CountedLocalNetworks(localNetworksOf(node)) // Where this machine reaches each mesh seat's holder, for ${seat::reach} (novox/hq ADR 0222). if store != "" { @@ -205,7 +206,7 @@ func (c *composer) portsFor(node Node, plan catalogue.Resolution, settings catal given := map[string]map[int]int{} for _, m := range plan.Modules { - g, err := catalogue.GivenPorts(m, settings[m.Module]) + g, err := resolution.GivenPorts(m, settings[m.Module]) if err != nil { // A given port its definition no longer publishes: the module is left out of the declaration, by name, when it is // composed (novox/hq ADR 0163, rule 6) -- never the machine refused here for it. @@ -403,14 +404,14 @@ func (c *composer) zonesInTheMesh() ([]catalogue.ZoneAt, error) { if err != nil { return nil, fmt.Errorf("the zone %s declares on %s cannot be read: %w", m.Module, n.Name, err) } - z, err := catalogue.ZoneOn(m, layers, published, n.Name, at) + z, err := resolution.ZoneOn(m, layers, published, n.Name, at) if err != nil { return nil, err } zones = append(zones, *z) } } - if problems := catalogue.ZonesProblems(zones, c.suffix(), public); len(problems) > 0 { + if problems := resolution.ZonesProblems(zones, c.suffix(), public); len(problems) > 0 { return nil, fmt.Errorf("the mesh's zones cannot be forwarded:\n - %s", strings.Join(problems, "\n - ")) } return zones, nil @@ -511,7 +512,7 @@ func (c *composer) seatsOn(node Node, inSet []catalogue.Manifest, adopted bool, if adopted && !taken[name] { layers := node.Settings[m.Module] ports = map[int]int{} - if given, err := catalogue.GivenPorts(m, layers); err == nil { + if given, err := resolution.GivenPorts(m, layers); err == nil { ports = given } } @@ -539,5 +540,5 @@ func localNetworksOf(n Node) []catalogue.LocalNetwork { if !n.NetworksSaid { return nil } - return catalogue.JudgeLocalNetworks(n.Networks, n.OutwardLinks, overlay.Interface) + return resolution.JudgeLocalNetworks(n.Networks, n.OutwardLinks, overlay.Interface) } diff --git a/internal/compose/resolution/a_service_process_test.go b/internal/compose/resolution/a_service_process_test.go new file mode 100644 index 0000000..de7b3ac --- /dev/null +++ b/internal/compose/resolution/a_service_process_test.go @@ -0,0 +1,10 @@ +package resolution + +func indexOf(out []map[string]any, id string) int { + for i, r := range out { + if r["id"] == id { + return i + } + } + return -1 +} diff --git a/internal/compose/resolution/accesses_test.go b/internal/compose/resolution/accesses_test.go new file mode 100644 index 0000000..5bfc9f3 --- /dev/null +++ b/internal/compose/resolution/accesses_test.go @@ -0,0 +1,80 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The fault 04-ISSUES/036 records: a media stack is several modules — a library server, the +// acquisition managers, a download client — that must share directories on one machine. Written +// as owned `directory` resources, two of them on one node are refused as rivalrous owners, which +// is right in general and wrong here: sharing those directories is the whole point of the stack. +// +// Declared as accesses to an operator-owned path (novox/hq ADR 0051), they co-resolve. An access +// is not a resource and never enters the duplicate-owner map, so this is the exact case the +// resolver refuses above when the same path is owned — and does not, when it is merely accessed. +func TestTwoModulesAccessingOnePathCoResolve(t *testing.T) { + a := mod("a", nil, nil, nil) + a.Accesses = []mcat.Access{{Path: "/services/media/downloads", Mode: mcat.AccessReadWrite}} + b := mod("b", nil, nil, nil) + b.Accesses = []mcat.Access{{Path: "/services/media/downloads", Mode: mcat.AccessRead}} + + got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), mcat.World{}) + if err != nil { + t.Fatalf("two modules accessing one operator-owned path were refused: %v", err) + } + + // And each accessing module's grant reaches the machine as its own `access` resource, so the + // host can find the path present or refuse — both naming the one operator-owned path. + var accesses int + for _, r := range mustDeclare(t, got) { + if r["type"] == "access" && r["path"] == "/services/media/downloads" { + accesses++ + } + } + if accesses != 2 { + t.Errorf("expected both modules' accesses in the declaration, got %d", accesses) + } +} + +// The mirror of the case above, so the sharing vocabulary does not weaken the rule it sits beside: +// two modules OWNING one path is still refused, exactly as before accesses existed. +func TestTwoModulesOwningOnePathAreStillRefused(t *testing.T) { + a := mod("a", nil, nil, nil) + a.Resources = []map[string]any{ + {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}} + b := mod("b", nil, nil, nil) + b.Resources = []map[string]any{ + {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}} + + _, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), mcat.World{}) + if err == nil { + t.Fatal("two modules owning the same directory were both assigned") + } + if !strings.Contains(err.Error(), "/services/media/movies") { + t.Errorf("the refusal does not name the path: %v", err) + } +} + +// Shared data is the operator's, owned by no module (novox/hq ADR 0051). A module owning the path +// another module was told to expect the operator to provide would create and chown it — and the +// two intentions cannot both hold, so it is refused by name. This is what keeps the distinction +// machine-checkable rather than a convention nobody enforces. +func TestAModuleMayNotOwnWhatAnotherAccesses(t *testing.T) { + owns := mod("owns", nil, nil, nil) + owns.Resources = []map[string]any{ + {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}} + uses := mod("uses", nil, nil, nil) + uses.Accesses = []mcat.Access{{Path: "/services/media/movies", Mode: mcat.AccessRead}} + + _, err := Resolve(shelf(owns, uses), []string{"owns", "uses"}, workstation(), mcat.World{}) + if err == nil { + t.Fatal("a module owning a path another accesses was allowed") + } + if !strings.Contains(err.Error(), "/services/media/movies") || + !strings.Contains(err.Error(), "the operator's") { + t.Errorf("the refusal does not explain the ownership conflict: %v", err) + } +} diff --git a/internal/compose/resolution/adoption.go b/internal/compose/resolution/adoption.go new file mode 100644 index 0000000..5017ab9 --- /dev/null +++ b/internal/compose/resolution/adoption.go @@ -0,0 +1,177 @@ +package resolution + +import ( + "fmt" + "sort" + "strconv" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// GuardID, GuardUnitID and GuardRunningID are the guard's resource identities. The installer +// raises the same three on an adopted genesis, so the first push finds them already there. +func GuardID() string { return mcat.AdoptionPrefix + "guard" } + +func GuardUnitID() string { return mcat.AdoptionPrefix + "guard-unit" } + +func GuardRunningID() string { return mcat.AdoptionPrefix + "guard-running" } + +// GuardPackageID is the tool that loads the guard, declared first: a node joining adopted has +// no filter module and may have no nft at all, and a table nothing can load guards nothing. +func GuardPackageID() string { return mcat.AdoptionPrefix + "guard-package" } + +// OpeningID is an opening's resource identity: its protocol, port and path say what it is. +func OpeningID(protocol string, port int, path string) string { + return fmt.Sprintf("%sopening-%s-%d-%s", mcat.AdoptionPrefix, protocol, port, path) +} + +// Openings are what the mesh needs reachable on an adopted node, from the same inputs as the +// filter it would load were the node converged, each from where that filter would admit it. +// +// `rules` is Filtering's answer — every module's listens, the hub's port, the per-node exposure — +// and `foundation` is the ports the mesh itself needs, from everywhere. A rule for this machine +// only opens nothing. `published` maps a machine port a container publishes to the container's +// port: a published port is forwarded, not received, so its opening names the forwarded path and +// the port the packet is forwarded to. +func Openings(rules []mcat.Rule, foundation []int, published map[string]map[int]int) []map[string]any { + type key struct { + protocol string + port int + } + from := map[key]string{} + var order []key + widen := func(k key, f string) { + was, seen := from[k] + if !seen { + order = append(order, k) + } + if !seen || was != mcat.OpeningFromEverywhere { + from[k] = f + } + } + for _, rule := range rules { + switch rule.From { + case mcat.FromEverywhere: + widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromEverywhere) + case mcat.FromMesh: + widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromMesh) + case mcat.FromLocalNetwork: + // The private network's part only (novox/hq ADR 0340). An adopted node's found firewall + // stays, and it is what admits the machine's local network; an opening has no word for a + // local network, and widening it to everywhere would open the port to the internet. + widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromMesh) + } + } + for _, port := range foundation { + widen(key{"tcp", port}, mcat.OpeningFromEverywhere) + } + sort.Slice(order, func(a, b int) bool { + if order[a].port != order[b].port { + return order[a].port < order[b].port + } + return order[a].protocol < order[b].protocol + }) + out := make([]map[string]any, 0, len(order)) + for _, k := range order { + opening := map[string]any{"type": "opening", "port": k.port, "protocol": k.protocol, + "from": from[k]} + if to, forwarded := published[k.protocol][k.port]; forwarded { + opening["id"] = OpeningID(k.protocol, k.port, mcat.PathForwarded) + opening["path"] = mcat.PathForwarded + opening["to"] = to + } else { + opening["id"] = OpeningID(k.protocol, k.port, mcat.PathIncoming) + opening["path"] = mcat.PathIncoming + } + out = append(out, opening) + } + return out +} + +// AsGuard renders the mesh's refusal-only table for the given machine ports. +// +// It passes everything by default and holds nothing but a refusal, so it cannot close anything +// the machine serves; and it is the mesh's own table, so the found firewall reloading does not +// touch it. It refuses only packets addressed to this machine, and the ports except from the +// machine itself — its loopback and the container runtime's own networks — and from the private +// network, known by the interface a packet arrives on and never by its source address. At +// prerouting, ahead of the runtime's destination translation, so it matches the port the packet +// was sent to; in the inet family, so both address families. +// +// **The machine's own interfaces are named, where the derived filter names address ranges.** The +// filter accepts the container runtime's networks by CIDR; this excludes its bridges by name — lo, +// docker0, the br-* a compose network gets, and the mesh's own mesh0. A runtime whose bridge is +// named anything else (a podman or libvirt bridge, or a docker network created with a fixed name) +// would have its containers' traffic to a guarded port refused, which reads as the port being +// down. Names rather than addresses is deliberate: a source address can be claimed by whoever +// sends the packet, and this table exists to refuse what the found firewall never sees. Widening +// it means adding names here and in the installer's copy together, which the golden test holds to +// one text. +// +// The same text the installer raises on an adopted genesis; a test holds both to it. +func AsGuard(ports []int) string { + sorted := append([]int{}, ports...) + sort.Ints(sorted) + listed := make([]string, len(sorted)) + for i, p := range sorted { + listed[i] = strconv.Itoa(p) + } + var b strings.Builder + b.WriteString("table inet mesh_guard {}\n") + b.WriteString("delete table inet mesh_guard\n") + b.WriteString("table inet mesh_guard {\n") + b.WriteString("\tchain prerouting {\n") + b.WriteString("\t\ttype filter hook prerouting priority raw; policy accept;\n") + // Only packets addressed to this machine: traffic it routes for others — a predecessor's hub, + // say — is never the guard's business (novox/hq ADR 0103). + fmt.Fprintf(&b, "\t\tfib daddr type local iifname != \"lo\" iifname != \"docker0\" "+ + "iifname != \"br-*\" iifname != \"mesh0\" tcp dport { %s } drop\n", + strings.Join(listed, ", ")) + b.WriteString("\t}\n") + b.WriteString("}\n") + return b.String() +} + +// GuardUnitText is the unit that loads the guard. Stopping it deletes only its own table: never +// a flush, which would take the container runtime's rules and the found firewall with it. +func GuardUnitText() string { + return "[Unit]\n" + + "Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)\n" + + // Early, before the network is up, and without the default dependencies that would + // order it after the network; stopped at shutdown like any unit. + "DefaultDependencies=no\n" + + "Wants=network-pre.target\n" + + "Before=network-pre.target shutdown.target\n" + + "Conflicts=shutdown.target\n" + + "\n" + + "[Service]\n" + + "Type=oneshot\n" + + "RemainAfterExit=yes\n" + + "ExecStart=nft -f " + mcat.GuardPath + "\n" + + "ExecReload=nft -f " + mcat.GuardPath + "\n" + + "ExecStop=nft delete table inet mesh_guard\n" + + "\n" + + "[Install]\n" + + "WantedBy=multi-user.target\n" +} + +// GuardResources are the guard as four resources of the existing kinds: the tool that loads it, +// the table, the unit, and the unit running — reloaded when the table changes, so the new table +// replaces the old in one `nft -f` through the unit's ExecReload with no moment unguarded, and +// restarted only when the unit itself changes. Nothing when there is nothing to guard: an empty +// set is not a table nft loads. +func GuardResources(ports []int) []map[string]any { + if len(ports) == 0 { + return nil + } + return []map[string]any{ + {"id": GuardPackageID(), "type": "package", "package": mcat.GuardPackage}, + {"id": GuardID(), "type": "file", "path": mcat.GuardPath, "content": AsGuard(ports), + "mode": "0644"}, + {"id": GuardUnitID(), "type": "file", "path": mcat.GuardUnitPath, "content": GuardUnitText(), + "mode": "0644"}, + {"id": GuardRunningID(), "type": "service", "unit": mcat.GuardUnit, "state": "running", + "boot": "enabled", "restart-on": []any{GuardUnitID()}, "reload-on": []any{GuardID()}}, + } +} diff --git a/internal/compose/resolution/adoption_test.go b/internal/compose/resolution/adoption_test.go new file mode 100644 index 0000000..d704884 --- /dev/null +++ b/internal/compose/resolution/adoption_test.go @@ -0,0 +1,629 @@ +package resolution + +import ( + "encoding/json" + "reflect" + "slices" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// hub is the private network's generator on the hub: it opens the hub's port from anywhere. +type hub struct{} + +func (hub) Resources(string) ([]map[string]any, bool, error) { + return []map[string]any{{"id": "config", "type": "file", "path": "/etc/wireguard/mesh0.conf", + "content": "[Interface]\n"}}, true, nil +} + +func (hub) Listens(string) ([]mcat.Listening, error) { + return []mcat.Listening{{Port: 51820, Protocol: "udp", From: mcat.FromEverywhere}}, nil +} + +// anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network, +// a served module and the filter module. +func anAdoptedAnchor() mcat.Resolution { + return mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{ + {Module: "network", Computed: "overlay"}, + {Module: "postgres", Guards: []int{5432}, + Listens: []mcat.Listening{{Port: 5432, From: mcat.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store", + "ports": []any{"5432:5432"}}}}, + {Module: "lavinmq", Guards: []int{15672}, + Listens: []mcat.Listening{{Port: 5671, From: mcat.FromMesh}, {Port: 5672, From: mcat.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker", + "ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}}}}, + {Module: "distribution", + Listens: []mcat.Listening{{Port: 5000, From: mcat.FromMesh}}, + Resources: []map[string]any{{"id": "store", "type": "container", "name": "registry", + "ports": []any{"5000"}}}}, + {Module: "hello-web", + Listens: []mcat.Listening{{Port: 8080, From: mcat.FromEverywhere}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web", + "ports": []any{"8080"}}}}, + {Module: "helper", Listens: []mcat.Listening{{Port: 9000, From: mcat.FromMachine}}}, + {Module: "nftables", Filtering: &mcat.Filtering{Into: "/etc/nftables.conf"}, + Resources: []map[string]any{{"id": "load", "type": "service", "unit": "mesh-filter.service", + "state": "running", "restart-on": []any{"filtering"}}}}, + }} +} + +func anchorRendering(adopted bool) mcat.Rendering { + return mcat.Rendering{ + Generators: map[string]mcat.Generator{"overlay": hub{}}, + Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}}, + Settings: mcat.SettingsBy{"distribution": {{From: "node anchor", + Placement: map[string]any{mcat.ExposeSetting: map[string]any{"5000": mcat.FromEverywhere}}}}}, + Mesh: []string{"10.42.0.1"}, + Foundation: []int{5671}, + // What the machine reported faces outside, which every rule in the filter is written + // around (novox/hq ADR 0140). + OutwardLinks: []string{"eth0"}, + TunnelInterface: "mesh0", + Adopted: adopted, + // Genesis takes the foundation's modules. + Taken: map[string]bool{"postgres": true, "lavinmq": true}, + } +} + +func byID(resources []map[string]any) map[string]map[string]any { + out := map[string]map[string]any{} + for _, r := range resources { + out[r["id"].(string)] = r + } + return out +} + +func TestAnAdoptedNodeIsDeclaredOpeningsFromTheSameInputsAsTheFilter(t *testing.T) { + composed, err := Compose(anAdoptedAnchor(), anchorRendering(true)) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + want := map[string]map[string]any{ + // The hub's port, from anywhere, received. + "adoption.opening-udp-51820-incoming": {"port": 51820, "protocol": "udp", + "from": "everywhere", "path": "incoming"}, + // The store's port from the private network only, and forwarded: a container publishes it. + "adoption.opening-tcp-5432-forwarded": {"port": 5432, "protocol": "tcp", "from": "mesh", + "path": "forwarded", "to": 5432}, + // The bus from anywhere: a node enrols over it before it has a private address. + "adoption.opening-tcp-5671-forwarded": {"port": 5671, "protocol": "tcp", + "from": "everywhere", "path": "forwarded", "to": 5671}, + "adoption.opening-tcp-5672-forwarded": {"port": 5672, "protocol": "tcp", "from": "mesh", + "path": "forwarded", "to": 5672}, + // The registry from anywhere, by its node's exposure setting. + "adoption.opening-tcp-5000-forwarded": {"port": 5000, "protocol": "tcp", + "from": "everywhere", "path": "forwarded", "to": 5000}, + // A published port names the machine port and the container port it is forwarded to. + "adoption.opening-tcp-20001-forwarded": {"port": 20001, "protocol": "tcp", + "from": "everywhere", "path": "forwarded", "to": 8080}, + } + for id, fields := range want { + opening, ok := got[id] + if !ok { + t.Errorf("no %s among %v", id, keys(got)) + continue + } + if opening["type"] != "opening" { + t.Errorf("%s is a %v", id, opening["type"]) + } + for k, v := range fields { + if opening[k] != v { + t.Errorf("%s: %s is %v, want %v", id, k, opening[k], v) + } + } + } + for id := range got { + if strings.HasPrefix(id, "adoption.opening-") && want[id] == nil { + t.Errorf("an opening nothing asked for: %s", id) + } + } + // A port for this machine only opens nothing, and the management port is not opened at all. + for id := range got { + if strings.Contains(id, "-9000-") || strings.Contains(id, "-15672-") { + t.Errorf("%s is opened", id) + } + } + + // And openings come first, in the order the machine applies them. + if !strings.HasPrefix(composed.Resources[0]["id"].(string), "adoption.opening-") { + t.Errorf("openings are not first: %v", composed.Resources[0]["id"]) + } +} + +func TestAnAdoptedNodeLoadsNoFilterOfTheMeshs(t *testing.T) { + composed, err := Compose(anAdoptedAnchor(), anchorRendering(true)) + if err != nil { + t.Fatal(err) + } + for _, r := range composed.Resources { + if r["path"] == "/etc/nftables.conf" || strings.HasPrefix(r["id"].(string), "nftables.") { + t.Fatalf("an adopted node is declared the filter module's %v", r["id"]) + } + if content, _ := r["content"].(string); strings.Contains(content, "policy drop") { + t.Fatalf("an adopted node is declared a table that drops by default: %v", r["id"]) + } + // Nothing but refusals: the only accept in the guard is its policy. + if content, _ := r["content"].(string); r["id"] == GuardID() && + strings.Count(content, "accept") != 1 { + t.Fatalf("the guard holds an accept:\n%s", content) + } + } + got := byID(composed.Resources) + guard := got[GuardID()] + if guard == nil || got[GuardUnitID()] == nil || got[GuardRunningID()] == nil { + t.Fatalf("no guard: %v", keys(got)) + } + if guard["content"] != AsGuard([]int{5432, 5672, 15672}) { + t.Fatalf("the guard does not guard the store, the broker and its management port:\n%s", + guard["content"]) + } + // The tool that loads it comes first, and the table after it: a node joining adopted has no + // filter module and may have no nft. + pkg, table := -1, -1 + for i, r := range composed.Resources { + switch r["id"] { + case GuardPackageID(): + pkg = i + if r["type"] != "package" || r["package"] != "nftables" { + t.Fatalf("the guard's package is %v", r) + } + case GuardID(): + table = i + } + } + if pkg < 0 || pkg > table { + t.Fatalf("nftables is not declared before the guard's table (%d, %d)", pkg, table) + } + // A changed table is reloaded — one `nft -f`, atomic — never restarted, which would delete the + // table and leave the ports unguarded until it is loaded again. Only a changed unit restarts. + if !reflect.DeepEqual(got[GuardRunningID()]["reload-on"], []any{GuardID()}) || + !reflect.DeepEqual(got[GuardRunningID()]["restart-on"], []any{GuardUnitID()}) { + t.Fatalf("the guard is not reloaded on its table and restarted on its unit: %v", + got[GuardRunningID()]) + } + // Nothing of the mesh's own is anybody's to hold. + for id, module := range composed.Owner { + if strings.HasPrefix(id, mcat.AdoptionPrefix) { + t.Fatalf("%s is owned by %s", id, module) + } + } +} + +func TestAConvergedNodeIsDeclaredItsFilterAndNoOpenings(t *testing.T) { + composed, err := Compose(anAdoptedAnchor(), anchorRendering(false)) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + if got["nftables.filtering"] == nil || got["nftables.load"] == nil { + t.Fatalf("a converged node lost its filter: %v", keys(got)) + } + for id := range got { + if strings.HasPrefix(id, mcat.AdoptionPrefix) { + t.Fatalf("a converged node is declared %s", id) + } + } + plain, err := Declaration(anAdoptedAnchor(), anchorRendering(false)) + if err != nil { + t.Fatal(err) + } + a, _ := json.Marshal(plain) + b, _ := json.Marshal(composed.Resources) + if string(a) != string(b) { + t.Fatal("Compose and Declaration disagree on a converged node") + } +} + +// The table the installer raises and the controller declares, character for character. +func TestTheGuardIsExactlyThisTable(t *testing.T) { + const golden = `table inet mesh_guard {} +delete table inet mesh_guard +table inet mesh_guard { + chain prerouting { + type filter hook prerouting priority raw; policy accept; + fib daddr type local iifname != "lo" iifname != "docker0" iifname != "br-*" iifname != "mesh0" tcp dport { 5432, 15672 } drop + } +} +` + if got := AsGuard([]int{15672, 5432}); got != golden { + t.Fatalf("the guard changed:\n%s", got) + } + const unit = `[Unit] +Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100) +DefaultDependencies=no +Wants=network-pre.target +Before=network-pre.target shutdown.target +Conflicts=shutdown.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=nft -f /etc/mesh/guard.nft +ExecReload=nft -f /etc/mesh/guard.nft +ExecStop=nft delete table inet mesh_guard + +[Install] +WantedBy=multi-user.target +` + if got := GuardUnitText(); got != unit { + t.Fatalf("the guard's unit changed:\n%s", got) + } + if GuardResources(nil) != nil { + t.Fatal("a guard with nothing to guard is an empty set nft refuses to load") + } +} + +func keys[V any](m map[string]V) []string { + return sortedKeys(m) +} + +// novox/hq ADR 0100: the foundation's ports are the node's. Given 5433 for the store, every place +// that uses the port reads it from there: the container, the filter, the openings, the guard. +func TestAGivenPortIsUsedEverywhereThePortIs(t *testing.T) { + given := map[string]map[int]int{"postgres": {5432: 5433}, "lavinmq": {15672: 15673}} + for _, adopted := range []bool{true, false} { + with := anchorRendering(adopted) + with.Given = given + with.Ports["postgres"] = map[int]int{5432: 5433} + composed, err := Compose(anAdoptedAnchor(), with) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + if ports := got["postgres.server"]["ports"]; !reflect.DeepEqual(ports, []any{"5433:5432"}) { + t.Fatalf("the store's container publishes %v", ports) + } + if ports := got["lavinmq.server"]["ports"]; !reflect.DeepEqual(ports, + []any{"5671:5671", "5672:5672", "127.0.0.1:15673:15672"}) { + t.Fatalf("the broker's container publishes %v", ports) + } + if !adopted { + filter, _ := got["nftables.filtering"]["content"].(string) + if !strings.Contains(filter, "tcp dport 5433 accept") || strings.Contains(filter, "5432") { + t.Fatalf("the filter does not use the given port:\n%s", filter) + } + continue + } + if o := got["adoption.opening-tcp-5433-forwarded"]; o == nil || o["to"] != 5432 { + t.Fatalf("no opening for the given port: %v", keys(got)) + } + if guard := got[GuardID()]["content"]; guard != AsGuard([]int{5433, 5672, 15673}) { + t.Fatalf("the guard does not guard the given ports:\n%s", guard) + } + } +} + +// novox/hq ADR 0103: the guard is derived, and from taken modules only — every machine port a taken +// module publishes that the filter admits from the private network only, and the ports its +// manifest guards. A module assigned but not taken is not guarded: its port may still be the +// predecessor's. +func TestTheGuardIsDerivedFromTakenModulesOnly(t *testing.T) { + guardOf := func(with mcat.Rendering) string { + t.Helper() + composed, err := Compose(anAdoptedAnchor(), with) + if err != nil { + t.Fatal(err) + } + content, _ := byID(composed.Resources)[GuardID()]["content"].(string) + return content + } + + // The broker taken, the store not: the broker's plain port follows from its listens (from + // the mesh, published), its management port from its manifest; the store is not guarded, and + // neither is the bus, which the mesh needs from everywhere. + with := anchorRendering(true) + with.Taken = map[string]bool{"lavinmq": true} + if got := guardOf(with); got != AsGuard([]int{5672, 15672}) { + t.Fatalf("the guard is not the taken broker's ports:\n%s", got) + } + + // A taken module publishing a port admitted from everywhere is not guarded; one admitted from + // the mesh is. The registry is exposed everywhere on this node, and hello-web listens from + // everywhere. + with.Taken = map[string]bool{"distribution": true, "hello-web": true} + if got := guardOf(with); got != "" { + t.Fatalf("a port admitted from everywhere is guarded:\n%s", got) + } + with.Settings = nil + if got := guardOf(with); got != AsGuard([]int{5000}) { + t.Fatalf("the registry, from the mesh only, is not guarded:\n%s", got) + } + + // Nothing taken, nothing guarded — and no guard at all rather than an empty set. + with = anchorRendering(true) + with.Taken = nil + if got := guardOf(with); got != "" { + t.Fatalf("an untaken store is guarded:\n%s", got) + } + + // A given port is followed: where the machine put it is what is refused. + with = anchorRendering(true) + with.Given = map[string]map[int]int{"lavinmq": {5672: 5682, 15672: 15673}} + with.Ports["lavinmq"] = map[int]int{5671: 5671, 5672: 5682} + if got := guardOf(with); got != AsGuard([]int{5432, 5682, 15673}) { + t.Fatalf("the guard does not follow the given ports:\n%s", got) + } +} + +// novox/hq ADR 0038: only a published port is the mesh's to move. A module that binds the machine +// itself listens where its software was told to, so giving it a machine port is refused. +func TestAGivenPortIsRefusedForAPortNoContainerPublishes(t *testing.T) { + onTheMachine := mcat.Manifest{Module: "daemon", + Listens: []mcat.Listening{{Port: 9000, From: mcat.FromMesh}}, Guards: []int{9000}} + layers := []mcat.Layer{{From: "node anchor", + Placement: map[string]any{mcat.PortsSetting: map[string]any{"9000": float64(9100)}}}} + _, err := GivenPorts(onTheMachine, layers) + if err == nil || !strings.Contains(err.Error(), "does not publish") { + t.Fatalf("a port no container publishes was given: %v", err) + } +} + +// novox/hq ADR 0103: a guarded port this node is told to open to everyone is opened, not guarded. +// An opening from everywhere beside a guard dropping it is one statement refusing the other. +func TestAGuardedPortOpenedToEveryoneIsNotGuarded(t *testing.T) { + with := anchorRendering(true) + with.Settings["postgres"] = []mcat.Layer{{From: "node anchor", + Placement: map[string]any{mcat.ExposeSetting: map[string]any{"5432": mcat.FromEverywhere}}}} + composed, err := Compose(anAdoptedAnchor(), with) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + if o := got["adoption.opening-tcp-5432-forwarded"]; o == nil || o["from"] != mcat.OpeningFromEverywhere { + t.Fatalf("the store's port is not opened to everyone: %v", o) + } + if guard, _ := got[GuardID()]["content"].(string); guard != AsGuard([]int{5672, 15672}) { + t.Fatalf("a port opened to everyone is still guarded:\n%s", guard) + } +} + +// A module that publishes its ssh port the long way — `2222:22`, because the machine's own daemon +// holds 22 — and says it listens on the machine side of that mapping, which is what anything +// reaching it dials. +func aForge() mcat.Manifest { + return mcat.Manifest{Module: "forge", + Provides: []mcat.Offer{{Name: "git-over-ssh", Scope: mcat.ScopeMesh}}, + Serves: map[string]map[string]any{"git-over-ssh": {"port": 2222}}, + Listens: []mcat.Listening{{Port: 3000, From: mcat.FromMesh}, {Port: 2222, From: mcat.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "forge", + "ports": []any{"3000", "2222:22"}}}} +} + +// portsAsThePlanWould is where this machine puts each of a module's ports, derived the way +// cmd/mesh-controller/plan.go derives it: a given port first, looked up by the port the module +// says it listens on, and otherwise wherever the manifest's own mapping already put it. Written +// here because everything below — the filter, the openings, the guard, what a consumer is told — +// reads that map, and a given port that the lookup does not find moves the container's mapping +// and nothing else. +// +// It stands in for the plan only where the plan does not allocate: a port the manifest already +// placed, or one a node was given. For a short form with no given port the real plan asks the +// inventory for a machine port and may come back with one from the pool, which needs a store and +// is what cmd/mesh-controller's own tests exercise. So an assertion here about such a port asserts +// this helper, not the mesh; keep the assertions to the ports under test. +func portsAsThePlanWould(m mcat.Manifest, given map[int]int) map[int]int { + out := map[int]int{} + for _, l := range m.Listens { + if at, is := given[l.Port]; is { + out[l.Port] = at + continue + } + at, _ := m.MachineSide(l.Port) + out[l.Port] = at + } + return out +} + +// novox/hq ADR 0100 and 0038: the machine side of a long-form mapping is a port the module +// publishes, so a node may move it — and a module may name a mapping by either end. +func TestAGivenPortNamesEitherEndOfWhatTheModulePublishes(t *testing.T) { + forge := aForge() + node := func(v any) []mcat.Layer { + return []mcat.Layer{{From: "node anchor", Placement: map[string]any{mcat.PortsSetting: v}}} + } + + // The machine side — the number this module says it listens on, and the one the predecessor + // had somewhere else. Answered under 2222, the end the module itself names, which is what + // every reader of this map asks for. One entry, not two: a second key for the same answer is + // an entry another mapping's reader could find instead. + given, err := GivenPorts(forge, node(map[string]any{"2222": float64(222)})) + if err != nil { + t.Fatalf("the machine side of a mapping cannot be given a port: %v", err) + } + if want := map[int]int{2222: 222}; !reflect.DeepEqual(given, want) { + t.Fatalf("the forge was given %v; the mapping it names is filed under %v", given, want) + } + + // The container's own port names the same mapping and means the same thing — and is filed + // under the same name, because the module's name for it has not changed. + if inside, err := GivenPorts(forge, node(map[string]any{"22": float64(222)})); err != nil || + !reflect.DeepEqual(inside, map[int]int{2222: 222}) { + t.Fatalf("the container's end of the mapping was given %v: %v", inside, err) + } + + // A short form is published on the number it names, and is unchanged by any of this. + if short, err := GivenPorts(forge, node(map[string]any{"3000": float64(2999)})); err != nil || + short[3000] != 2999 { + t.Fatalf("the short form was given %v: %v", short, err) + } + + // A port no container publishes is still refused, in the same words. + if _, err := GivenPorts(forge, node(map[string]any{"9000": float64(9100)})); err == nil || + !strings.Contains(err.Error(), "does not publish") { + t.Fatalf("a port the forge does not publish was given: %v", err) + } + + // And the two ends of one mapping given two different numbers is one setting contradicting + // the other: the machine publishes it once. + if _, err := GivenPorts(forge, node(map[string]any{ + "2222": float64(222), "22": float64(300)})); err == nil || + !strings.Contains(err.Error(), "one mapping") { + t.Fatalf("the two ends of one mapping were given different ports: %v", err) + } + // Said at both ends with the same number, it is still said twice, and refused where every + // other repeated machine port is — as the inventory refuses it when the setting is stored, + // which is the layer that sees it first. + if _, err := GivenPorts(forge, node(map[string]any{ + "2222": float64(222), "22": float64(222)})); err == nil || + !strings.Contains(err.Error(), "to both its 22 and its 2222") { + t.Fatalf("one mapping given one machine port at both ends: %v", err) + } + // A number that names two different mappings names neither: which one to move is not said. + twice := aForge() + twice.Resources[0]["ports"] = []any{"22", "2222:22"} + if _, err := GivenPorts(twice, node(map[string]any{"22": float64(222)})); err == nil || + !strings.Contains(err.Error(), "twice") { + t.Fatalf("a number naming two of the module's mappings was accepted: %v", err) + } + + // And the same refusal when the number naming two mappings is not the one the setting used + // but the one the answer would be filed under. Here `80` is the module's own name for a + // mapping, and two mappings wear it; filing an answer there is one container's port standing + // where the other's is read, and both containers then publish it. + shared := mcat.Manifest{Module: "gallery", + Listens: []mcat.Listening{{Port: 80, From: mcat.FromMesh}}, + Resources: []map[string]any{ + {"id": "a", "type": "container", "name": "a", "ports": []any{"4001:80"}}, + {"id": "b", "type": "container", "name": "b", "ports": []any{"4002:80"}}}} + if _, err := GivenPorts(shared, node(map[string]any{"4001": float64(1234)})); err == nil || + !strings.Contains(err.Error(), "twice") { + t.Fatalf("two containers were put on one machine port: %v", err) + } + + // A module whose mappings chain — one's machine side is another's container port — keeps them + // apart, because each is filed under the port the module names it by and neither name is + // shared. Given both, each moves on its own and neither overwrites the other. + chained := mcat.Manifest{Module: "chain", + Listens: []mcat.Listening{{Port: 80, From: mcat.FromMesh}, {Port: 9090, From: mcat.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "chain", + "ports": []any{"8080:80", "9090:8080"}}}} + both, err := GivenPorts(chained, node(map[string]any{"80": float64(1234), "9090": float64(5678)})) + if err != nil || !reflect.DeepEqual(both, map[int]int{80: 1234, 9090: 5678}) { + t.Fatalf("chained mappings were given %v: %v", both, err) + } + if moved := givenOuter("8080:80", both); moved != "1234:80" { + t.Fatalf("the first mapping moved to %q, and it was given 1234", moved) + } + if moved := givenOuter("9090:8080", both); moved != "5678:8080" { + t.Fatalf("the second mapping moved to %q, and it was given 5678", moved) + } +} + +// And the number reaches everything derived from it. The fault this is written against moved the +// container's mapping alone: the filter opened the port the software had left, the adopted node's +// opening named it too, the guard refused it, and a consumer was sent to it. +func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T) { + forge := aForge() + given, err := GivenPorts(forge, []mcat.Layer{{From: "node anchor", + Placement: map[string]any{mcat.PortsSetting: map[string]any{"2222": float64(222)}}}}) + if err != nil { + t.Fatalf("the machine side of a mapping cannot be given a port: %v", err) + } + r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{forge}} + with := mcat.Rendering{ + Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)}, + Given: map[string]map[int]int{"forge": given}, + Mesh: []string{"10.77.0.1"}, + Adopted: true, + OutwardLinks: []string{"eth0"}, + TunnelInterface: "mesh0", + Taken: map[string]bool{"forge": true}, + } + + // What the runtime is handed: the machine's own port on the outside, the container's within. + composed, err := Compose(r, with) + if err != nil { + t.Fatalf("the forge does not compose: %v", err) + } + got := byID(composed.Resources) + if ports := got["forge.server"]["ports"]; !reflect.DeepEqual(ports, []any{"3000:3000", "222:22"}) { + t.Fatalf("the forge's container publishes %v", ports) + } + + // What the filter would open, were the node converged. + rules, err := Rules(r, with) + if err != nil { + t.Fatal(err) + } + var opened []int + for _, rule := range rules { + opened = append(opened, rule.Port) + } + // Only the moved port is asserted: the forge's other port is a short form the real plan would + // allocate rather than read off the manifest, so its number here is portsAsThePlanWould's and + // not the mesh's. + if !slices.Contains(opened, 222) || slices.Contains(opened, 2222) { + t.Fatalf("the filter opens %v, not where the machine puts the forge", opened) + } + + // And what it is declared instead, adopted: an opening on the machine's port, naming the + // container's port on the forwarded path — a published port is forwarded, never received. + opening := got[OpeningID("tcp", 222, mcat.PathForwarded)] + if opening == nil || opening["to"] != 22 || opening["from"] != mcat.OpeningFromMesh { + t.Fatalf("no opening for the port this node gave the forge: %v", keys(got)) + } + for id := range got { + if strings.HasPrefix(id, "adoption.opening-tcp-2222-") { + t.Errorf("an opening for the port the forge was moved off: %s", id) + } + } + + // The guard refuses it where the machine put it, and nothing where it used to be. + guard, _ := got[GuardID()]["content"].(string) + if !strings.Contains(guard, "222") || strings.Contains(guard, "2222") { + t.Fatalf("the guard does not follow the given port:\n%s", guard) + } + + // And a consumer is sent to the same number, which is read from what the module serves. + if told := ServedOn(forge, "git-over-ssh", with.Ports["forge"])["port"]; told != 222 { + t.Fatalf("a consumer is told the forge answers on %v", told) + } +} + +// And the mapping itself moves under either name, because Rendering.Given is a map anybody +// composing a declaration hands in: keyed by the machine side, which is what a module declaring +// 2222 calls its port, only the outside moves and the container's own port stays as written. +func TestAMappingIsMovedUnderEitherOfItsNames(t *testing.T) { + for _, c := range []struct { + written string + given map[int]int + want string + }{ + {"2222:22", map[int]int{2222: 222}, "222:22"}, + {"2222:22", map[int]int{22: 222}, "222:22"}, + {"127.0.0.1:15672:15672/tcp", map[int]int{15672: 15673}, "127.0.0.1:15673:15672/tcp"}, + {"2222:22", map[int]int{3000: 2999}, "2222:22"}, + {"2222:22", nil, "2222:22"}, + } { + if got := givenOuter(c.written, c.given); got != c.want { + t.Errorf("%s given %v is published as %s, want %s", c.written, c.given, got, c.want) + } + } +} + +// The same on a node that was given nothing, which is where the derivation was never at fault: +// a long-form mapping is published where the manifest put it, and an adopted node opens that port +// on the forwarded path like any other. What broke it was the number reaching only the mapping. +func TestALongFormPortIsOpenedWhereTheManifestPublishesIt(t *testing.T) { + forge := aForge() + r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{forge}} + composed, err := Compose(r, mcat.Rendering{ + Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)}, + Mesh: []string{"10.77.0.1"}, + Adopted: true, + OutwardLinks: []string{"eth0"}, + TunnelInterface: "mesh0", + }) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + opening := got[OpeningID("tcp", 2222, mcat.PathForwarded)] + if opening == nil || opening["to"] != 22 { + t.Fatalf("no opening for the forge's published ssh port: %v", keys(got)) + } +} diff --git a/internal/compose/resolution/agent_account_test.go b/internal/compose/resolution/agent_account_test.go new file mode 100644 index 0000000..a54e628 --- /dev/null +++ b/internal/compose/resolution/agent_account_test.go @@ -0,0 +1,83 @@ +package resolution + +import ( + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) { + facts := machineFacts(mcat.Resolution{Node: "anchor", Account: "ops"}, nil, "") + if facts["agent-account"] != "ops" || facts["agent-home"] != "/home/ops" || facts["agent-root"] != "" { + t.Errorf("with no agent account named, agents run as the operator: %v", facts) + } + facts = machineFacts(mcat.Resolution{Node: "anchor", Account: "ops", AccountHome: "/srv/ops"}, nil, "") + if facts["agent-home"] != "/srv/ops" { + t.Errorf("the operator's stated home is the agent's home when they are one account: %v", facts) + } + facts = machineFacts(mcat.Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "") + if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != mcat.RootNever { + t.Errorf("a named agent account is the agents', never root: %v", facts) + } + if facts["account"] != "ops" { + t.Errorf("the operator account is still the operator's: %v", facts) + } + facts = machineFacts(mcat.Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "") + if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != mcat.RootNever { + t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts) + } + if _, has := machineFacts(mcat.Resolution{Node: "anchor"}, nil, "")["agent-account"]; has { + t.Error("a machine with no account at all names an agent account") + } +} + +// The agent's module, in the shape the catalogue's declares it: the account, never root where it is its +// own; its directory under that home, owned by it. +const agentModule = `{"module": "agent", "version": "1", "resources": [ + {"id": "account", "type": "user", "name": "${machine:agent-account}", "home": "${machine:agent-home}", + "root": "${machine:agent-root}"}, + {"id": "home", "type": "directory", "path": "${machine:agent-home}/.agent", "mode": "0700", + "owner": "${machine:agent-account}"} +]}` + +func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) { + m, err := decodeForTest([]byte(agentModule)) + if err != nil { + t.Fatal(err) + } + compose := func(r mcat.Resolution, with mcat.Rendering) (user, home map[string]any) { + t.Helper() + r.Node, r.Modules = "anchor", []mcat.Manifest{m} + out, err := Declaration(r, with) + if err != nil { + t.Fatal(err) + } + return fileNamed(out, "agent.account"), fileNamed(out, "agent.home") + } + + user, home := compose(mcat.Resolution{Account: "ops", AgentAccount: "agent"}, mcat.Rendering{JudgesRoot: true}) + if user["name"] != "agent" || user[mcat.RootField] != mcat.RootNever || user["home"] != "/home/agent" { + t.Errorf("an engine that judges root is sent the agent account never to become root: %v", user) + } + if home["path"] != "/home/agent/.agent" || home["owner"] != "agent" { + t.Errorf("the agent's directory is under its own home, its own: %v", home) + } + + user, _ = compose(mcat.Resolution{Account: "ops", AgentAccount: "agent", AgentAccountHome: "/srv/agent"}, mcat.Rendering{JudgesRoot: true}) + if user["home"] != "/srv/agent" { + t.Errorf("an agent account named with a home of its own is made there: %v", user) + } + + user, _ = compose(mcat.Resolution{Account: "ops", AgentAccount: "agent"}, mcat.Rendering{}) + if _, sent := user[mcat.RootField]; sent || user["name"] != "agent" { + t.Errorf("an older engine, which parses strictly, is sent root: %v", user) + } + + user, home = compose(mcat.Resolution{Account: "ops"}, mcat.Rendering{JudgesRoot: true}) + if _, sent := user[mcat.RootField]; sent || user["name"] != "ops" { + t.Errorf("where agents run as the operator, root asserts nothing and is not sent: %v", user) + } + if home["path"] != "/home/ops/.agent" || home["owner"] != "ops" { + t.Errorf("with no agent account, the agent's directory is the operator's: %v", home) + } +} diff --git a/internal/compose/resolution/artifact_store_seat_test.go b/internal/compose/resolution/artifact_store_seat_test.go new file mode 100644 index 0000000..ebe2ce2 --- /dev/null +++ b/internal/compose/resolution/artifact_store_seat_test.go @@ -0,0 +1,40 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The artifact store's seat is one per mesh, read from the catalogue beside this checkout. +// +// **A second store anywhere is refused by name, not discovered as a consumer failure.** The seat +// was node-scoped, so a second `distribution` on another machine resolved cleanly there — and a +// node-scoped requirement with one candidate installs that candidate on the node, so anything that +// required the store's presence beside it would have raised a fresh, empty store on the wrong +// machine. Only afterwards did the mesh notice: `artifact-store` offered by two nodes, and every +// consumer elsewhere refusing to choose. The claim says it first, where the second store is +// assigned. +func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) { + store := catalogueManifest(t, "distribution") + + // The first store resolves as it always has. + if _, err := Resolve(shelf(store), []string{"distribution"}, workstation(), mcat.World{}); err != nil { + t.Fatalf("the store alone does not resolve: %v", err) + } + + // A second one, on any other machine, is refused — and the refusal names the seat. + elsewhere := mcat.World{Held: []mcat.Held{{Claim: "mesh-artifact-store", Scope: mcat.ScopeMesh, + Node: "anchor", Module: "distribution"}}} + other := workstation() + other.Name = "laptop" + _, err := Resolve(shelf(store), []string{"distribution"}, other, elsewhere) + if err == nil { + t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " + + "second time and every consumer elsewhere would refuse to choose") + } + if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") { + t.Fatalf("refused without naming the seat: %v", err) + } +} diff --git a/internal/compose/resolution/artifact_sweep_seat.go b/internal/compose/resolution/artifact_sweep_seat.go new file mode 100644 index 0000000..98888e7 --- /dev/null +++ b/internal/compose/resolution/artifact_sweep_seat.go @@ -0,0 +1,43 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks. +func artifactSweepVerbs() []mcat.Verb { + return []mcat.Verb{ + {Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " + + "repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " + + "newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " + + "unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.", + Input: schema(map[string]string{ + "repository": "only this repository's references, as / (the counts stay whole)", + "collected": "\"true\": list the collected references too (counted always)", + }, nil, "collected")}, + {Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " + + "held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " + + "Reads only.", + Input: listed(schema(map[string]string{ + "references": "the artifacts' references, artifact-store:////blobs/sha256:: a list, " + + "or one text separated by commas", + }, []string{"references"}), "references")}, + {Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " + + "dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " + + "then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " + + "names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " + + "seconds; what is left is said. Never touches a digest the mesh did not make.", + Input: schema(map[string]string{ + "confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done", + "why": "with confirm: why, required and recorded", + "most": "let go of at most this many (500 by default, at most 5000)", + }, nil, "confirm")}, + {Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " + + "it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " + + "it: never removed), by what the builder's log says each build stood on. Records nothing by hand.", + Input: schema(map[string]string{}, nil)}, + {Name: "images", Description: "Every container image one machine's want and have name, each with the resources " + + "naming it: what the machine keeps when it prunes its images. Reads only.", + Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})}, + } +} diff --git a/internal/compose/resolution/artifacts.go b/internal/compose/resolution/artifacts.go new file mode 100644 index 0000000..559ac1b --- /dev/null +++ b/internal/compose/resolution/artifacts.go @@ -0,0 +1,65 @@ +package resolution + +import ( + "fmt" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// artifactsInto composes the artifact store's address into a resource's `image` and `source`. +// +// **Composed here, at the last moment before a machine, and stored nowhere.** A kept reference is +// routed through the store as this network reaches it now. A reference recorded with an address +// before references were kept without one is re-routed the same way — but only when the mesh +// built it (`with.Built` names every `/` it has), because a module may run an +// image from a public registry under its own name and that one is exactly where it says. +// +// A kept reference with no store to route it through is refused: sent as it is, the runtime would +// refuse the scheme on the machine, one push away from the reason. +// +// **What this does not reach: the images genesis pinned.** The installer builds the control plane +// and the builder before the mesh exists, pushes them itself and pins their manifests to +// `:/mesh-controller@…` and `:/mesh-builder@…` — single-segment +// repositories with no build record, so `with.Built` does not name them and they are left as +// written until each is rebuilt through the mesh, which records it by digest and path. Until then +// a registry that moves strands exactly those two on a recreate, and the control plane's is the +// one that cannot be repaired through the mesh. Rebuild both through `build` before moving the +// store (novox/hq 04-ISSUES/102, finding F4). +func artifactsInto(resource map[string]any, module string, with mcat.Rendering) error { + for _, key := range []string{"image", "source"} { + written, ok := resource[key].(string) + if !ok { + continue + } + if _, kept := mcat.InArtifactStore(written); kept { + if with.ArtifactStore == "" { + return fmt.Errorf( + "%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+ + "artifact store on its network to fetch it from — nothing assigned offers "+ + "%s, or the machine offering it is not on the private network", + module, resource["id"], written, mcat.ArtifactStoreProvision) + } + resource[key] = mcat.Routed(written, with.ArtifactStore) + continue + } + if with.ArtifactStore == "" { + continue + } + recorded := mcat.Recorded(written) + if recorded == written { + continue + } + path, _ := mcat.InArtifactStore(recorded) + repository := path + if at := strings.IndexAny(path, "@"); at >= 0 { + repository = path[:at] + } else if blobs := strings.Index(path, "/blobs/"); blobs >= 0 { + repository = path[:blobs] + } + if with.Built[repository] { + resource[key] = mcat.Routed(recorded, with.ArtifactStore) + } + } + return nil +} diff --git a/internal/compose/resolution/artifacts_test.go b/internal/compose/resolution/artifacts_test.go new file mode 100644 index 0000000..e9ab076 --- /dev/null +++ b/internal/compose/resolution/artifacts_test.go @@ -0,0 +1,102 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +var digest = "sha256:" + strings.Repeat("d", 64) + +// **The address is composed into a declaration, and the record never carries it.** +func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) { + m := mcat.Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "mesh-gitea", "artifact": "server"}, + {"id": "config", "type": "archive", "path": "/etc/gitea", "artifact": "config"}, + {"id": "cache", "type": "container", "name": "mesh-gitea-cache", "image": "valkey/valkey@" + digest}, + }, Build: &mcat.Build{Artifacts: []mcat.Artifact{ + {Name: "server", Kind: mcat.ArtifactImage, From: "Dockerfile"}, + {Name: "config", Kind: mcat.ArtifactArchive, From: "config"}, + }}} + resolved, err := m.Resolve([]mcat.Built{ + {Name: "server", Kind: mcat.ArtifactImage, Reference: mcat.ArtifactStoreScheme + "gitea/server@" + digest}, + {Name: "config", Kind: mcat.ArtifactArchive, Reference: mcat.ArtifactStoreScheme + "gitea/config/blobs/" + digest, Digest: digest}, + }) + if err != nil { + t.Fatal(err) + } + r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{resolved}} + + out, err := Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101"}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "gitea.server")["image"]; got != "anchor.internal:5101/gitea/server@"+digest { + t.Errorf("the image the mesh built is fetched as %v", got) + } + if got := fileNamed(out, "gitea.config")["source"]; got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest { + t.Errorf("the archive the mesh built is fetched from %v", got) + } + if got := fileNamed(out, "gitea.cache")["image"]; got != "valkey/valkey@"+digest { + t.Errorf("an image from a public registry was routed through the store: %v", got) + } + // The manifest the mesh holds still says what it is, not where it was fetched from. + if got := resolved.Resources[0]["image"]; got != mcat.ArtifactStoreScheme+"gitea/server@"+digest { + t.Errorf("composing wrote the address into the catalogue's copy: %v", got) + } + + // And the store moves: the same record, another address, without a rebuild. + out, err = Declaration(r, mcat.Rendering{ArtifactStore: "laptop.internal:5000"}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "gitea.server")["image"]; got != "laptop.internal:5000/gitea/server@"+digest { + t.Errorf("after the store moved, the image is still fetched as %v", got) + } +} + +func TestAnImageInTheStoreWithNoStoreToFetchItFromIsRefused(t *testing.T) { + m := mcat.Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "mesh-gitea", + "image": mcat.ArtifactStoreScheme + "gitea/server@" + digest}, + }} + _, err := Declaration(mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}}, mcat.Rendering{}) + if err == nil || !strings.Contains(err.Error(), "no artifact store") { + t.Fatalf("a reference nothing can fetch was sent to a machine: %v", err) + } +} + +// **A reference recorded with an address before this follows the store too** — when the mesh +// built it. A module running an image straight from a public registry under its own name is left +// exactly where it says: `quay.io/keycloak/keycloak` is not the mesh's, whatever it is called. +func TestAReferenceRecordedWithAnAddressFollowsTheStoreWhenTheMeshBuiltIt(t *testing.T) { + m := mcat.Manifest{Module: "keycloak", Version: "1", Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "mesh-keycloak", + "image": "anchor.internal:5100/keycloak/server@" + digest}, + {"id": "upstream", "type": "container", "name": "mesh-keycloak-upstream", + "image": "quay.io/keycloak/keycloak@" + digest}, + }} + r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}} + out, err := Declaration(r, mcat.Rendering{ + ArtifactStore: "anchor.internal:5101", + Built: map[string]bool{"keycloak/server": true}, + }) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5101/keycloak/server@"+digest { + t.Errorf("an image the mesh built, recorded with the old address, is fetched as %v", got) + } + if got := fileNamed(out, "keycloak.upstream")["image"]; got != "quay.io/keycloak/keycloak@"+digest { + t.Errorf("a public image was re-routed through the store: %v", got) + } + // Nothing known to be built: nothing re-routed, nothing refused. + out, err = Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101"}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5100/keycloak/server@"+digest { + t.Errorf("with no build record, a reference was rewritten: %v", got) + } +} diff --git a/internal/compose/resolution/backup_placement.go b/internal/compose/resolution/backup_placement.go new file mode 100644 index 0000000..46156a9 --- /dev/null +++ b/internal/compose/resolution/backup_placement.go @@ -0,0 +1,46 @@ +package resolution + +import ( + "fmt" + "regexp" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// ofBackup is where a holder places them: ${backup:}. Loose inside the braces, so a misspelt key is found and +// refused rather than written out as text. +var ofBackup = regexp.MustCompile(`\$\{backup:([^}]*)\}`) + +// backupKinds is the contribution kind each key of the backup placement renders. +var backupKinds = map[string]string{"lines": mcat.BackupKindBackup, "data": mcat.BackupKindData} + +// backupPlacer is the machine's modules the backup placement is composed over, as the old contributions are. +type backupPlacer struct { + modules []mcat.Manifest + with mcat.Rendering + facts map[string]string + caps map[string]bool + unplaced *[]string +} + +// placed is the text of one key for a holder, or what is wrong with naming it. +func (b backupPlacer) placed(holder mcat.Manifest, key string, item bool) (string, string) { + kind, known := backupKinds[key] + switch { + case item: + return "", "an item is kept per item; the machine's backup lines are placed only in a file" + case !known: + return "", fmt.Sprintf("the backup placement has `lines` and `data`, not %q", key) + case !holder.ClaimsSeat(mcat.BackupSeat): + return "", fmt.Sprintf("%s does not claim %s; the machine's backup lines are placed by its holder alone "+ + "(novox/hq ADR 0212)", holder.Module, mcat.BackupSeat) + } + text, left, err := seatContributions(b.modules, holder, mcat.BackupSeat+":"+kind, b.with, b.facts, b.caps) + if b.unplaced != nil { + *b.unplaced = append(*b.unplaced, left...) + } + if err != nil { + return "", err.Error() + } + return text, "" +} diff --git a/internal/compose/resolution/backup_placement_test.go b/internal/compose/resolution/backup_placement_test.go new file mode 100644 index 0000000..9035fc1 --- /dev/null +++ b/internal/compose/resolution/backup_placement_test.go @@ -0,0 +1,114 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +const oldBackupText = "# lines\n${contribution:node-backup:backup}" + +const oldDataText = "# data\n${contribution:node-backup:data}" + +const placedBackupTxt = "# lines\n${backup:lines}" + +const placedDataTxt = "# data\n${backup:data}" + +const backupHolder = `{"module":"restic","version":"1","moved":true,"claims":[{"name":"node-backup","scope":"node"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"declared","type":"file","path":"/etc/backups.conf","template":"files/backups"}, + {"id":"data-declared","type":"file","path":"/etc/data.conf","template":"files/data"}]}` + +func dataModule(name string) string { + return `{"module":"` + name + `","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}], + "data":{"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable","why":"the ` + name + ` data"}]}}` +} + +// backupsOn is a machine of a holder whose files say these texts and the modules that declare data. +func backupsOn(t *testing.T, lines, data string, modules ...string) mcat.Resolution { + t.Helper() + all := []mcat.Manifest{held(t, backupHolder, builtWith("restic", map[string]string{"backups": lines, "data": data}))} + names := []string{"restic"} + for _, name := range modules { + all = append(all, mustParse(t, dataModule(name))) + names = append(names, name) + } + r, err := Resolve(shelf(all...), names, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + return r +} + +var backupModules = []string{"alpha", "beta", "gamma"} + +// gammaLeftOut is a machine whose gamma does not place: its lines are kept, best effort. +func gammaLeftOut(with mcat.Rendering) mcat.Rendering { + with.NotPlaced = map[string]string{"gamma": "its placement setting does not read"} + return with +} + +func TestTheBackupPlacementIsTheTextTheOldPlaceholdersRender(t *testing.T) { + // The old form is an unmoved holder: its placeholders are the controller's alone. + oldHolder := strings.Replace(backupHolder, `"moved":true,`, "", 1) + all := []mcat.Manifest{held(t, oldHolder, builtWith("restic", map[string]string{"backups": oldBackupText, "data": oldDataText}))} + for _, name := range backupModules { + all = append(all, mustParse(t, dataModule(name))) + } + old, err := Resolve(shelf(all...), append([]string{"restic"}, backupModules...), workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + old.Account = "op" + wantLines, wantData := fileOf(t, old, gammaLeftOut(mcat.Rendering{}), "restic.declared"), fileOf(t, old, gammaLeftOut(mcat.Rendering{}), "restic.data-declared") + if !strings.Contains(wantLines, "# alpha\npath ") || !strings.Contains(wantLines, "# gamma\n") || !strings.Contains(wantData, "item d") { + t.Fatalf("the old form renders\n%s\n%s", wantLines, wantData) + } + moved := backupsOn(t, placedBackupTxt, placedDataTxt, backupModules...) + // Not handed: the controller fills the new placeholders with the old text. + if got := fileOf(t, moved, gammaLeftOut(mcat.Rendering{}), "restic.declared"); got != wantLines { + t.Errorf("not handed, the lines are\n%s\nwant\n%s", got, wantLines) + } + if got := fileOf(t, moved, gammaLeftOut(mcat.Rendering{}), "restic.data-declared"); got != wantData { + t.Errorf("not handed, the data is\n%s\nwant\n%s", got, wantData) + } + // Handed: the plan carries the same text as placement, and the files are the client's. + c, _ := composeWith(t, moved, gammaLeftOut(handingTo("store:5000"))) + p := c.Plans["restic"].Placement + if got := "# lines\n" + p["backup:lines"]; got != wantLines { + t.Errorf("handed, the plan's lines are\n%s\nwant\n%s", got, wantLines) + } + if got := "# data\n" + p["backup:data"]; got != wantData { + t.Errorf("handed, the plan's data is\n%s\nwant\n%s", got, wantData) + } + if len(p) != 2 { + t.Errorf("the plan's placement is %v", p) + } +} + +func TestAnUnknownBackupKeyIsRefused(t *testing.T) { + r := backupsOn(t, "${backup:rows}", placedDataTxt, backupModules...) + if _, err := Compose(r, handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "backup:rows") { + t.Errorf("handed, an unknown key is %v", err) + } + if _, err := Declaration(r, mcat.Rendering{}); err == nil || !strings.Contains(err.Error(), "backup:rows") { + t.Errorf("not handed, an unknown key is %v", err) + } +} + +func fileOf(t *testing.T, r mcat.Resolution, with mcat.Rendering, id string) string { + t.Helper() + out, err := Declaration(r, with) + if err != nil { + t.Fatal(err) + } + for _, res := range out { + if res["id"] == id { + return res["content"].(string) + } + } + t.Fatalf("%s was not composed", id) + return "" +} diff --git a/internal/compose/resolution/backup_test.go b/internal/compose/resolution/backup_test.go new file mode 100644 index 0000000..6583285 --- /dev/null +++ b/internal/compose/resolution/backup_test.go @@ -0,0 +1,37 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// A module on the shelf from before ADR 0233, which writes its backup lines by hand and declares no +// data, is still backed up: its lines are placed as written, each module's under a comment naming it. +func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) { + pg, err := decodeForTest([]byte(`{"module":"pg","version":"1", + "resources":[{"id":"dumps","type":"directory","path":"/srv/pg/dumps","mode":"0700"}], + "contributions":[{"seat":"node-backup","kind":"backup","content":"run pg-dump-all\npath ${dir:dumps}"}]}`)) + if err != nil { + t.Fatal(err) + } + mail, err := decodeForTest([]byte(`{"module":"mail","version":"1", + "resources":[{"id":"spool","type":"directory","mode":"0700"}], + "contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:spool}"}]}`)) + if err != nil { + t.Fatal(err) + } + placed, _, err := seatContributions([]mcat.Manifest{pg, mail}, mcat.Manifest{}, mcat.BackupSeat+":"+"backup", mcat.Rendering{}, nil, nil) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"# pg\nrun pg-dump-all\npath /srv/pg/dumps\n", "# mail\npath /var/lib/mail/spool\n"} { + if !strings.Contains(placed, want) { + t.Errorf("placed contributions lack %q:\n%s", want, placed) + } + } + if strings.Contains(placed, "${dir:") { + t.Errorf("a directory reached the holder unfilled:\n%s", placed) + } +} diff --git a/internal/compose/resolution/bound.go b/internal/compose/resolution/bound.go new file mode 100644 index 0000000..bf7b620 --- /dev/null +++ b/internal/compose/resolution/bound.go @@ -0,0 +1,117 @@ +package resolution + +import ( + "fmt" + "slices" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// keepBound holds every need for a provision that keeps its consumers' data at the provider its +// consumer was bound to, where the resolution chose another. +// +// A need with no record is a binding being made, and is left as resolved: it is recorded when it is +// first sent. A pin naming the provider the resolution chose is a person moving it, and is left too. +// Otherwise the recorded provider answers, if it still provides the provision — beside the consumer, +// or offered from elsewhere — and the move is returned as kept. **One that no longer does is refused, +// never answered by the provider chosen**: answering it there is exactly the silent move this exists +// to stop, and the consumer's data is still wherever it was. +func keepBound(needs []mcat.Needed, catalogue map[string]mcat.Manifest, node mcat.Node, world mcat.World, + keeps map[string]bool, here func(string) bool) ([]mcat.Needed, []mcat.KeptBinding, []string) { + var kept []mcat.KeptBinding + var problems []string + refused := map[[2]string]bool{} + out := make([]mcat.Needed, 0, len(needs)) + for _, n := range needs { + if n.ByRecord || !keeps[n.Name] { + out = append(out, n) + continue + } + n.KeepsData = true + bound, recorded := world.Bound[n.For][n.Name] + chose := mcat.Chosen{Node: n.From, Module: n.Module} + if !recorded || sameProvider(bound, chose) { + out = append(out, n) + continue + } + if pin, pinned := world.Pinned[n.Name]; pinned && chosenMatches(pin, mcat.Provider{Node: chose.Node, Module: chose.Module}) { + out = append(out, n) + continue + } + held, ok, why := boundNeed(n, bound, catalogue, node, world, here) + if !ok { + if key := [2]string{n.For, n.Name}; !refused[key] { + refused[key] = true + problems = append(problems, fmt.Sprintf( + "%s on %s is bound to %s for %q, which keeps its data, and %s — it would move to %s, "+ + "which holds none of it. Move its data and say so with `pin %s %s %s %s`, or give "+ + "%s back what it provided", + n.For, node.Name, bound, n.Name, why, chose, node.Name, n.Name, chose.Node, chose.Module, + bound.Node)) + } + continue + } + out = append(out, held) + kept = append(kept, mcat.KeptBinding{Machine: node.Name, Consumer: n.For, Provision: n.Name, Bound: bound, Would: chose}) + } + return out, kept, problems +} + +// sameProvider is whether a recorded provider is the one chosen. A record naming no module (none +// is written without one, but a person's hand might) matches any module on its node. +func sameProvider(bound, chose mcat.Chosen) bool { + return bound.Node == chose.Node && (bound.Module == "" || bound.Module == chose.Module) +} + +// boundNeed is the need answered by the recorded provider, or why it cannot be. +func boundNeed(n mcat.Needed, bound mcat.Chosen, catalogue map[string]mcat.Manifest, node mcat.Node, world mcat.World, + here func(string) bool) (mcat.Needed, bool, string) { + held := mcat.Needed{Name: n.Name, For: n.For, Local: n.Local, KeepsData: true} + if bound.Node == node.Name { + m, known := catalogue[bound.Module] + if !known || !here(bound.Module) || !providesAt(m, n.Name, mcat.ScopeMesh) { + return mcat.Needed{}, false, fmt.Sprintf("%s no longer runs on %s", bound.Module, node.Name) + } + at := node.At + if at == "" { + at = "127.0.0.1" + } + held.From, held.At, held.Module = node.Name, at, m.Module + held.Serves, held.SharedOwn, held.Identity = servedByOne(m, n.Name), sharedByOne(m, n.Name), m.IdentityBoundOf(n.Name) + held.CredentialForm, held.CredentialTool = m.CredentialFormOf(n.Name), m.CredentialToolOf(n.Name) + return held, true, "" + } + matching := chosenAmong(bound, world.Offered[n.Name]) + if len(matching) != 1 { + return mcat.Needed{}, false, fmt.Sprintf("%s no longer provides it", bound) + } + p := matching[0] + if node.At == "" || p.At == "" { + return mcat.Needed{}, false, fmt.Sprintf("%s and %s are not both on the private network", node.Name, p.Node) + } + identity := mcat.DefaultIdentityBound + if pm, known := catalogue[p.Module]; known { + held.SharedOwn, _ = pm.SharedCredentialOf(n.Name) + held.CredentialForm, held.CredentialTool = pm.CredentialFormOf(n.Name), pm.CredentialToolOf(n.Name) + identity = pm.IdentityBoundOf(n.Name) + } + held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity + held.LeftOut = p.LeftOut + return held, true, "" +} + +// BindsFrom is the providers this resolution binds a pair credential's need to — the provision, the +// module that requires it and the credential's local name — answered by a machine rather than by a +// record. None means this machine states no such binding. +func BindsFrom(r mcat.Resolution, provision, module, local string) []string { + var from []string + for _, n := range r.Needs { + if n.ByRecord || n.Name != provision || n.For != module || n.Local != local { + continue + } + if !slices.Contains(from, n.From) { + from = append(from, n.From) + } + } + return from +} diff --git a/internal/compose/resolution/bound_into_files.go b/internal/compose/resolution/bound_into_files.go new file mode 100644 index 0000000..d27401e --- /dev/null +++ b/internal/compose/resolution/bound_into_files.go @@ -0,0 +1,176 @@ +package resolution + +import ( + "fmt" + "regexp" + "sort" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// bound is where a module says a value from one of its bindings belongs: +// ${bound::}, and for an optional requirement the value it takes while absent, +// ${bound::|default:} (novox/hq ADR 0330 §4). A default is never empty: an +// absent requirement is never rendered as an empty string. +var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)(?:\|default:([^}\n]+))?\}`) + +// knownFor is everything a module may name from one of its bindings. +// +// Three facts the mesh states about any provision, plus whatever the provider said it serves. A +// module may not reach a binding it does not have — the same boundary as a secret, for the same +// reason. +func knownFor(m mcat.Manifest, needs []mcat.Needed, node string) (map[string]map[string]string, error) { + out := map[string]map[string]string{} + for _, want := range m.Wants() { + for i := range needs { + n := needs[i] + if n.Name != want || n.For != m.Module { + continue + } + as := mcat.ConsumerIdentity(node, mcat.IdentitySource(m.Slug, m.Module)) + values := map[string]string{ + "at": n.At, + "from": n.From, + "as": as, + } + // What the provider derives for this consumer rather than for all of them + // (novox/hq ADR 0202). Filled here, the one place a provision and the module + // requiring it are both in hand. + served, err := ServedTo(n.Serves, as) + if err != nil { + return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err) + } + for key, value := range served { + // The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000, + // which is what a float would write and what a connection string would refuse. + values[key] = plainly(value) + } + out[want] = values + } + } + return out, nil +} + +// withOwnNames adds a module's own composed names to what it may name from one binding: +// `${bound::name}` and `:internal-name`, and for several contributions to one requirement +// `:name-` / `:internal-name-`. Set over anything the provider serves under those keys: +// what the module is called is the mesh's statement, not the provider's. +func withOwnNames(values map[string]string, own map[string]any) { + for _, key := range []string{"name", "internal-name"} { + if v, ok := own[key].(string); ok { + values[key] = v + } + } + many, _ := own["names"].(map[string]any) + for local, raw := range many { + names, _ := raw.(map[string]any) + for _, key := range []string{"name", "internal-name"} { + if v, ok := names[key].(string); ok { + values[key+"-"+local] = v + } + } + } +} + +// plainly renders a served value as a program would expect to read it. +func plainly(value any) string { + switch v := value.(type) { + case string: + return v + case float64: + if v == float64(int64(v)) { + return fmt.Sprintf("%d", int64(v)) + } + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", v), "0"), ".") + case bool: + return fmt.Sprintf("%t", v) + case nil: + return "" + default: + return fmt.Sprint(v) + } +} + +// boundInto replaces a file's ${bound:…} placeholders with what the mesh knows. +// +// A placeholder naming something the module does not require, or a key the provider does not +// serve, is refused. Left as it was, the literal `${bound:x:y}` would be written into a +// configuration file and read as a value — a connection to a host called `${bound:x:y}`, failing +// somewhere that names neither the module nor the mesh. +// +// **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR +// 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses +// that already; this is the same rule where the file is made, for a manifest registered before it. +func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error { + if fmt.Sprint(resource["type"]) != "file" { + return nil + } + content, ok := resource["content"].(string) + if !ok { + return nil + } + var refused error + filled := bound.ReplaceAllStringFunc(content, func(token string) string { + if refused != nil { + return token + } + m := bound.FindStringSubmatch(token) + provision, key, fallback := m[1], m[2], m[3] + values, has := known[provision] + if !has { + if absent[provision] { + if fallback != "" { + return fallback + } + refused = fmt.Errorf( + "%s has a file that says ${bound:%s:%s}, and %q is optional and absent here: it names no "+ + "value for then (${bound:%s:%s|default:}), so it would be written empty "+ + "(novox/hq ADR 0330 §4)", module, provision, key, provision, provision, key) + return token + } + refused = fmt.Errorf( + "%s has a file that says ${bound:%s:%s}, and %s does not require %q. It may name %s", + module, provision, key, module, provision, orNothing(namesOfBindings(known))) + return token + } + value, said := values[key] + if !said { + refused = fmt.Errorf( + "%s asks its %s binding for %q, and what answers it says %s", + module, provision, key, orNothing(namesOfKeys(values))) + return token + } + return value + }) + if refused != nil { + return refused + } + resource["content"] = filled + return nil +} + +func namesOfBindings(known map[string]map[string]string) []string { + var names []string + for name := range known { + names = append(names, fmt.Sprintf("%q", name)) + } + sort.Strings(names) + return names +} + +func namesOfKeys(values map[string]string) []string { + var names []string + for key := range values { + names = append(names, fmt.Sprintf("%q", key)) + } + sort.Strings(names) + return names +} + +func orNothing(names []string) string { + if len(names) == 0 { + return "nothing" + } + return join(names) +} diff --git a/internal/compose/resolution/bound_into_files_test.go b/internal/compose/resolution/bound_into_files_test.go new file mode 100644 index 0000000..0da5176 --- /dev/null +++ b/internal/compose/resolution/bound_into_files_test.go @@ -0,0 +1,246 @@ +package resolution + +import ( + "fmt" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// A consumer writes its own connection string (novox/hq 04-ISSUES/023). +// +// Everything needed for one now reaches the file that needs it: the address and port from what the +// provider serves, the name to present from what the mesh decided, the database name from what the +// module itself contributed, and the password through the sealed hole the host fills. +func TestAConsumerCanWriteAConnectionString(t *testing.T) { + r := mcat.Resolution{ + Node: "workstation", + Modules: []mcat.Manifest{{ + Module: "keycloak", + Requires: []string{"postgres-database"}, + Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/db.secret"}, + Resources: []map[string]any{{ + "id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env", + "mode": "0600", + "content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" + + "${bound:postgres-database:port}/keycloak\n" + + "KC_DB_USERNAME=${bound:postgres-database:as}\n" + + "KC_DB_PASSWORD=${secret:postgres-database}\n", + }}, + }}, + Needs: []mcat.Needed{{ + Name: "postgres-database", From: "anchor", At: "anchor.internal", For: "keycloak", + Sealed: "sealed-db", Serves: map[string]any{"port": float64(5432)}, + }}, + } + out, err := Declaration(r, mcat.Rendering{}) + if err != nil { + t.Fatal(err) + } + file := fileNamed(out, "keycloak.dbenv") + if file == nil { + t.Fatalf("no file in %v", out) + } + got := file["content"].(string) + + want := "KC_DB_URL=jdbc:postgresql://anchor.internal:5432/keycloak\n" + + "KC_DB_USERNAME=mesh_workstation_keycloak\n" + + "KC_DB_PASSWORD=${secret:postgres-database}\n" + if got != want { + t.Errorf("the consumer cannot connect with what it was given:\n got %q\nwant %q", got, want) + } + // The password is still a hole. Only the host may fill that one, and only on the machine. + if sealed, _ := file["secrets"].(map[string]any); sealed["postgres-database"] != "sealed-db" { + t.Errorf("the credential did not travel with the file: %v", file) + } +} + +// A provider answered on its consumer's own machine is announced at the port the machine PUBLISHES +// it on, not the port the module declared (novox/hq 04-ISSUES/038). +// +// Issue 018 made the same-node provider announced at all; this is the promise after it — that the +// port a co-located consumer is told is the one actually listening at the address it is handed. The +// mesh assigns a bare `ports` mapping a host port (here the container's 5432 becomes 15432 on this +// machine), and it must announce 15432, or the consumer dials workstation.internal:5432 where the +// provider is not published. Cross-node consumers already got this (the plan re-derives the served +// facts with the provider's assignment); the same-node need was settled before the assignment and +// carried the declared port until now. +func TestASameNodeProviderIsAnnouncedAtThePortItIsPublishedOn(t *testing.T) { + provider := mcat.Manifest{ + Module: "postgres", Version: "1", + Provides: mcat.FromAnywhere("postgres-database"), + Listens: []mcat.Listening{{Port: 5432, Protocol: "tcp", From: "mesh"}}, + Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "postgres", + "ports": []any{"5432"}, + }}, + } + consumer := mcat.Manifest{ + Module: "keycloak", Version: "1", Requires: []string{"postgres-database"}, + Resources: []map[string]any{{ + "id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env", "mode": "0600", + "content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" + + "${bound:postgres-database:port}/keycloak\n", + }}, + } + r, err := Resolve(shelf(provider, consumer), + []string{"postgres", "keycloak"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + // The mesh assigned the container's 5432 to 15432 on this machine. + out, err := Declaration(r, mcat.Rendering{Ports: map[string]map[int]int{"postgres": {5432: 15432}}}) + if err != nil { + t.Fatal(err) + } + + // Where the provider is actually published: the assigned host port, on the machine at large. + server := fileNamed(out, "postgres.server") + if server == nil { + t.Fatalf("the provider container is not in the declaration: %v", out) + } + if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "15432:5432") { + t.Fatalf("the provider was not published on the assigned port: %v", server["ports"]) + } + + // What the consumer was told to dial: the SAME port, at the announced address — not the 5432 the + // module declared, which is closed on that address. + dbenv := fileNamed(out, "keycloak.dbenv") + if dbenv == nil { + t.Fatalf("the consumer's binding file is not in the declaration: %v", out) + } + got := dbenv["content"].(string) + want := "KC_DB_URL=jdbc:postgresql://workstation.internal:15432/keycloak\n" + if got != want { + t.Errorf("the consumer dials a port the provider does not listen on:\n got %q\nwant %q", + got, want) + } +} + +// A port is 5432, not 5432.000000 — which is what a number decoded from JSON would write, and what +// every connection string in the world would refuse. +func TestAPortIsWrittenAsAPort(t *testing.T) { + if got := plainly(float64(5432)); got != "5432" { + t.Errorf("a port was rendered as %q", got) + } + if got := plainly("postgres"); got != "postgres" { + t.Errorf("a string was rendered as %q", got) + } +} + +// The name the provider will create is the name the consumer is told to present. One derivation, +// so the two agree by construction rather than by two conventions written on the same day. +func TestTheProviderAndTheConsumerAgreeOnTheName(t *testing.T) { + provider, err := Resolve(shelf(mcat.Manifest{ + Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database"), + Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"}, + Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"}, + }), []string{"postgres"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(provider, mcat.Rendering{Grants: []mcat.Grant{{ + Provision: "postgres-database", Consumer: "workstation", From: "keycloak", + Values: map[string]any{"name": "keycloak"}, Sealed: "sealed", + }}}) + if err != nil { + t.Fatal(err) + } + var told string + for _, res := range out { + if res["path"] == "/var/lib/postgres/grants/mesh.json" { + told = res["content"].(string) + } + } + if !strings.Contains(told, `"as": "mesh_workstation_keycloak"`) { + t.Errorf("the provider was not told what to call the login: %s", told) + } + if told == "" { + t.Fatal("the provider was told nothing at all") + } + // Which is exactly what the consumer, on its own machine, is told to present. + if mcat.ConsumerIdentity("workstation", "keycloak") != "mesh_workstation_keycloak" { + t.Errorf("got %q", mcat.ConsumerIdentity("workstation", "keycloak")) + } +} + +// A binding the module does not have is a typo, refused where it was written. +func TestAFileCannotNameABindingTheModuleDoesNotHave(t *testing.T) { + r := mcat.Resolution{ + Node: "workstation", + Modules: []mcat.Manifest{{ + Module: "keycloak", Requires: []string{"postgres-database"}, + Resources: []map[string]any{{ + "id": "conf", "type": "file", "path": "/etc/keycloak.conf", + "content": "host=${bound:s3-bucket:at}\n", + }}, + }}, + Needs: []mcat.Needed{{Name: "postgres-database", From: "anchor", At: "a", For: "keycloak"}}, + } + _, err := Declaration(r, mcat.Rendering{}) + if err == nil { + t.Fatal("a module read a binding it does not have") + } + if !strings.Contains(err.Error(), "postgres-database") { + t.Errorf("the refusal does not say what it could have named: %v", err) + } +} + +// A key the provider does not serve is refused too. Written through, the literal placeholder +// becomes a hostname, and the failure names neither the module nor the mesh. +func TestAKeyTheProviderDoesNotServeIsRefused(t *testing.T) { + r := mcat.Resolution{ + Node: "workstation", + Modules: []mcat.Manifest{{ + Module: "keycloak", Requires: []string{"postgres-database"}, + Resources: []map[string]any{{ + "id": "conf", "type": "file", "path": "/etc/keycloak.conf", + "content": "sock=${bound:postgres-database:socket}\n", + }}, + }}, + Needs: []mcat.Needed{{ + Name: "postgres-database", From: "anchor", At: "a", For: "keycloak", + Serves: map[string]any{"port": float64(5432)}, + }}, + } + _, err := Declaration(r, mcat.Rendering{}) + if err == nil { + t.Fatal("a module read a key nothing serves, and the placeholder became a value") + } + if !strings.Contains(err.Error(), `"port"`) { + t.Errorf("the refusal does not say what is served: %v", err) + } +} + +// A machine's resolver configuration must name its resolver by address — it cannot resolve the name +// of the thing it resolves names with (novox/hq ADR 0194). So a binding offers the providing +// machine's private address beside its name, and only when the machine has one. +func TestABindingOffersTheProvidersAddress(t *testing.T) { + consumer := func() mcat.Resolution { + return mcat.Resolution{ + Node: "workstation", + Modules: []mcat.Manifest{{ + Module: "resolv-conf", + Requires: []string{"wildcard-resolution"}, + Resources: []map[string]any{{ + "id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644", + "content": "nameserver ${bound:wildcard-resolution:address}\n", + }}, + }}, + Needs: []mcat.Needed{{Name: "wildcard-resolution", From: "anchor", At: "anchor.internal", For: "resolv-conf"}}, + } + } + out, err := Declaration(consumer(), mcat.Rendering{Machines: map[string]string{"anchor.internal": "10.77.0.1"}}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "resolv-conf.resolv")["content"]; got != "nameserver 10.77.0.1\n" { + t.Fatalf("the resolver is not named by its address: %q", got) + } + // A machine with no address yet: refused, never written with a blank where the address belongs. + if _, err := Declaration(consumer(), mcat.Rendering{}); err == nil { + t.Fatal("a file naming an address the mesh does not have was composed") + } +} diff --git a/internal/compose/resolution/bound_test.go b/internal/compose/resolution/bound_test.go new file mode 100644 index 0000000..98df9cb --- /dev/null +++ b/internal/compose/resolution/bound_test.go @@ -0,0 +1,219 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +var incidentConsumers = []string{"board", "listings", "workflows", "agents", "game"} + +func storeMesh() map[string]mcat.Manifest { + shelf := map[string]mcat.Manifest{ + "store": {Module: "store", Version: "1", + Provides: []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh}}, + Claims: []mcat.Claim{{Name: "mesh-store", Scope: mcat.ScopeMesh}}, + Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, + Grants: map[string]string{"postgres-database": "/grants"}}, + "resolver": {Module: "resolver", Version: "1", + Provides: []mcat.Offer{{Name: "wildcard-resolution", Scope: mcat.ScopeMesh}}, + Claims: []mcat.Claim{{Name: "mesh-dns-resolver", Scope: mcat.ScopeMesh}}}, + "network": {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}}, + } + for _, c := range incidentConsumers { + shelf[c] = mcat.Manifest{Module: c, Version: "1", Requires: []string{"postgres-database"}} + } + return shelf +} + +// storeWorld is the rest of the mesh as the home server's plan sees it: the anchor runs a store and a +// resolver and holds both seats; the home server runs its own of each. +func storeWorld() mcat.World { + w := mcat.World{Offered: map[string][]mcat.Provider{ + "postgres-database": { + {Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}}, + {Node: "home", At: "home.internal", Module: "store", Serves: map[string]any{"port": 5434}}, + }, + "wildcard-resolution": { + {Node: "anchor", At: "anchor.internal", Module: "resolver"}, + {Node: "home", At: "home.internal", Module: "resolver"}, + }, + }} + w.Held = []mcat.Held{ + {Claim: "mesh-store", Scope: mcat.ScopeMesh, Node: "anchor", Module: "store"}, + {Claim: "mesh-dns-resolver", Scope: mcat.ScopeMesh, Node: "anchor", Module: "resolver"}, + } + w.Holdings = w.Held + return w +} + +var home = mcat.Node{Name: "home", At: "home.internal"} + +func homeAssigned() []string { + return append([]string{"store", "resolver", "network"}, incidentConsumers...) +} + +func boundFrom(t *testing.T, r mcat.Resolution, consumer, provision string) mcat.Needed { + t.Helper() + for _, n := range r.Needs { + if n.For == consumer && n.Name == provision { + return n + } + } + t.Fatalf("no binding of %s for %s: %+v", consumer, provision, r.Needs) + return mcat.Needed{} +} + +func TestTheIncidentAMachinesOwnStoreKeepsItsConsumersWhenTheSeatIsHeldElsewhere(t *testing.T) { + got, err := Resolve(storeMesh(), homeAssigned(), home, storeWorld()) + if err != nil { + t.Fatal(err) + } + for _, c := range incidentConsumers { + if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" || n.Module != "store" { + t.Errorf("%s bound to %s/%s; its data is on the store beside it (issue 273)", c, n.From, n.Module) + } + } + // And the resolver, which keeps nothing of anybody's, still answers from the seat's holder (258). + if n := boundFrom(t, got, "network", "wildcard-resolution"); n.From != "anchor" { + t.Errorf("the resolver bound to %s; the seat is held on anchor (issue 258)", n.From) + } + if len(got.Kept) != 0 { + t.Errorf("nothing was moved, and %d kept: %+v", len(got.Kept), got.Kept) + } +} + +func TestTheIncidentWithEveryConsumerOnRecordStillMovesNothing(t *testing.T) { + w := storeWorld() + w.Bound = map[string]map[string]mcat.Chosen{} + for _, c := range incidentConsumers { + w.Bound[c] = map[string]mcat.Chosen{"postgres-database": {Node: "home", Module: "store"}} + } + got, err := Resolve(storeMesh(), homeAssigned(), home, w) + if err != nil { + t.Fatal(err) + } + for _, c := range incidentConsumers { + if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" { + t.Errorf("%s bound to %s", c, n.From) + } + } + if len(got.Kept) != 0 { + t.Errorf("kept %+v", got.Kept) + } +} + +func TestAPinElsewhereStillMovesAStoresConsumers(t *testing.T) { + w := storeWorld() + w.Pinned = map[string]mcat.Chosen{"postgres-database": {Node: "anchor", Module: "store"}} + w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + got, err := Resolve(storeMesh(), homeAssigned(), home, w) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" || n.Module != "store" { + t.Errorf("bound to %s/%s; a person pinned it to anchor", n.From, n.Module) + } + if len(got.Kept) != 0 { + t.Errorf("a pin is a person's move, not one to keep: %+v", got.Kept) + } +} + +// A consumer on a machine with no store of its own, bound to one store, when the seat moves to +// another: the holder would answer, and the binding stays. +func laptopWorld(holder string) mcat.World { + w := storeWorld() + w.Held = []mcat.Held{{Claim: "mesh-store", Scope: mcat.ScopeMesh, Node: holder, Module: "store"}} + w.Holdings = w.Held + return w +} + +var laptop = mcat.Node{Name: "laptop", At: "laptop.internal"} + +func TestABoundConsumerStaysWhenTheSeatsHolderChanges(t *testing.T) { + w := laptopWorld("home") + w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "anchor", Module: "store"}}} + got, err := Resolve(storeMesh(), []string{"board"}, laptop, w) + if err != nil { + t.Fatal(err) + } + n := boundFrom(t, got, "board", "postgres-database") + if n.From != "anchor" || n.At != "anchor.internal" || n.Serves["port"] != 5432 { + t.Fatalf("bound to %s at %s %v; its data is on anchor", n.From, n.At, n.Serves) + } + if len(got.Kept) != 1 { + t.Fatalf("the move was not said: %+v", got.Kept) + } + k := got.Kept[0] + if k.Bound != (mcat.Chosen{Node: "anchor", Module: "store"}) || k.Would != (mcat.Chosen{Node: "home", Module: "store"}) || k.Consumer != "board" { + t.Fatalf("kept %+v", k) + } + for _, want := range []string{"would move board's postgres-database from anchor/store to home/store", + "its data is on anchor/store", "pin laptop postgres-database home store", "move the data first"} { + if !strings.Contains(k.String(), want) { + t.Errorf("%q does not say %q", k.String(), want) + } + } + // Without a record it is a binding being made, and the holder answers it as before. + w.Bound = nil + got, err = Resolve(storeMesh(), []string{"board"}, laptop, w) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, got, "board", "postgres-database"); n.From != "home" { + t.Errorf("a new consumer bound to %s; the seat is held on home", n.From) + } +} + +func TestABoundConsumerWhoseProviderIsGoneIsRefusedNotMoved(t *testing.T) { + w := laptopWorld("anchor") + w.Offered["postgres-database"] = w.Offered["postgres-database"][:1] // only anchor's store is left + w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + _, err := Resolve(storeMesh(), []string{"board"}, laptop, w) + if err == nil { + t.Fatal("bound to home's store, which is gone, and answered by anchor's — the silent move") + } + for _, want := range []string{"board on laptop is bound to home/store", "home/store no longer provides it", + "pin laptop postgres-database anchor store"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not say %q", err, want) + } + } +} + +func TestAMachinesOwnStoreUnassignedRefusesItsBoundConsumers(t *testing.T) { + w := storeWorld() + w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + _, err := Resolve(storeMesh(), []string{"board"}, home, w) + if err == nil || !strings.Contains(err.Error(), "store no longer runs on home") { + t.Fatalf("got %v", err) + } +} + +// The first pass asks only what a machine offers, and is never refused by a binding. +func TestTheFirstPassKeepsNoBinding(t *testing.T) { + w := storeWorld() + w.Unchecked = true + w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "gone", Module: "store"}}} + if _, err := Resolve(storeMesh(), []string{"board"}, laptop, w); err != nil { + t.Fatal(err) + } +} + +// An offer saying it keeps nothing is answered by the seat's holder as the resolver is, even where it +// grants a credential. +func TestAStoreSayingItKeepsNothingFollowsTheSeat(t *testing.T) { + shelf := storeMesh() + no := false + s := shelf["store"] + s.Provides = []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh, KeepsConsumerData: &no}} + shelf["store"] = s + got, err := Resolve(shelf, homeAssigned(), home, storeWorld()) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" { + t.Errorf("bound to %s; it keeps nothing, and the seat is held on anchor", n.From) + } +} diff --git a/internal/compose/resolution/broker_seat_test.go b/internal/compose/resolution/broker_seat_test.go new file mode 100644 index 0000000..524f390 --- /dev/null +++ b/internal/compose/resolution/broker_seat_test.go @@ -0,0 +1,49 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The mesh's bus is one per mesh, read from the catalogue beside this checkout. +// +// **This is step 2's claim, and it is checked here rather than in a bed** (novox/hq ADR 0116): +// adoption puts the NATS server into the `mesh-broker` seat on a mesh that is already running, +// and the property that matters is that a second one anywhere is refused *when it is assigned*, +// not discovered later as two servers holding different halves of the mesh's traffic. A second +// bus is not a degraded mesh; it is two meshes that both believe they are the one. +func TestASecondMeshBusAnywhereIsRefusedByName(t *testing.T) { + nats := catalogueManifest(t, "nats") + + if _, err := Resolve(shelf(nats), []string{"nats"}, workstation(), mcat.World{}); err != nil { + t.Fatalf("the bus alone does not resolve: %v", err) + } + + elsewhere := mcat.World{Held: []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh, + Node: "anchor", Module: "nats"}}} + other := workstation() + other.Name = "laptop" + _, err := Resolve(shelf(nats), []string{"nats"}, other, elsewhere) + if err == nil { + t.Fatal("a second bus was accepted on another machine") + } + if !strings.Contains(err.Error(), "mesh-broker") || !strings.Contains(err.Error(), "one per mesh") { + t.Fatalf("refused without naming the seat: %v", err) + } +} + +// The seat is the server's role, not the product's name (novox/hq ADR 0079). A different +// implementation of the bus claims the same seat, and the mesh refuses it for the same reason — +// which is the property that lets the bus be replaced at all. +func TestTheSeatRefusesADifferentBusToo(t *testing.T) { + nats := catalogueManifest(t, "nats") + held := mcat.World{Held: []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh, + Node: "anchor", Module: "some-other-broker"}}} + other := workstation() + other.Name = "laptop" + if _, err := Resolve(shelf(nats), []string{"nats"}, other, held); err == nil { + t.Fatal("the seat admitted a second holder because the module's name differed") + } +} diff --git a/internal/compose/resolution/brokered_test.go b/internal/compose/resolution/brokered_test.go new file mode 100644 index 0000000..2932a08 --- /dev/null +++ b/internal/compose/resolution/brokered_test.go @@ -0,0 +1,255 @@ +package resolution + +import ( + "encoding/json" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// onNetwork is a set of providers, all of them reachable. Written as a helper because a machine +// that cannot reach the one answering its requirement is its own case, tested separately. +// reachable is this machine, on the private network. +func reachable() mcat.Node { + n := workstation() + n.At = "workstation.internal" + return n +} + +func onNetwork(nodes ...string) map[string][]mcat.Provider { + out := make([]mcat.Provider, 0, len(nodes)) + for _, n := range nodes { + out = append(out, mcat.Provider{Node: n, At: n + ".internal", Module: "postgres"}) + } + return map[string][]mcat.Provider{"postgres-database": out} +} + +func brokeredShelf() map[string]mcat.Manifest { + return shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database")}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}}) +} + +func TestADatabaseIsNotInstalledOnEveryMachineThatUsesOne(t *testing.T) { + // The fault this whole distinction exists for. + got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor")}) + if err != nil { + t.Fatal(err) + } + if have := strings.Join(names(got), " "); strings.Contains(have, "postgres") { + t.Fatalf("using a database installed one here: %s", have) + } +} + +func TestWhatAMachineTakesFromElsewhereIsRecorded(t *testing.T) { + // It is the only part of a node's set that stops working when a *different* machine goes + // away, and it is where a credential will have to be handed back. + got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor")}) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 { + t.Fatalf("got %v", got.Needs) + } + if got.Needs[0].Name != "postgres-database" || got.Needs[0].From != "anchor" { + t.Fatalf("got %v", got.Needs[0]) + } + if got.Needs[0].For != "meshboard" { + t.Fatalf("it does not say what wanted it: %v", got.Needs[0]) + } +} + +func TestNothingInTheMeshProvidingItIsRefusedWithSomewhereToPutIt(t *testing.T) { + // Refused rather than installed here. Choosing a machine to put a database on is a decision + // with consequences, and nothing resolving a web application should make it silently. + _, err := Resolve(brokeredShelf(), []string{"meshboard"}, workstation(), mcat.World{}) + if err == nil { + t.Fatal("a database was found in a mesh that has none") + } + if !strings.Contains(err.Error(), "assign") || !strings.Contains(err.Error(), "postgres") { + t.Fatalf("the refusal does not say what to do: %v", err) + } +} + +func TestTwoNodesProvidingItIsRefusedRatherThanPicked(t *testing.T) { + // Same rule as everywhere else. Picking one would be a guess about which database a person + // meant, and the wrong guess is somebody's data in the wrong place. + _, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor", "archive")}) + if err == nil { + t.Fatal("one of two databases was picked silently") + } + for _, want := range []string{"anchor", "archive", "pin"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal does not name %s: %v", want, err) + } + } +} + +func TestSayingWhichOneSettlesIt(t *testing.T) { + got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{ + Offered: onNetwork("anchor", "archive"), + Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "archive", Module: "postgres"}}, + }) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 || got.Needs[0].From != "archive" { + t.Fatalf("the choice was not taken: %v", got.Needs) + } +} + +func TestBeingPointedAtAMachineThatDoesNotProvideItIsRefused(t *testing.T) { + // Rather than falling back to one that does. A fallback would quietly move somebody's data to + // a machine they did not choose, which is the whole reason the question is asked. + _, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{ + Offered: onNetwork("anchor", "archive"), + Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "somewhere-else", Module: "postgres"}}, + }) + if err == nil { + t.Fatal("a machine was silently given a different database from the one chosen") + } + if !strings.Contains(err.Error(), "somewhere-else") { + t.Fatalf("the refusal does not say what was chosen: %v", err) + } +} + +func TestOneProviderDoesNotOverruleAChoice(t *testing.T) { + // A single answer is normally taken silently. Not when somebody said they wanted a different + // one -- that is the mesh overruling a person, which it does nowhere else. + _, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{ + Offered: onNetwork("anchor"), + Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "archive", Module: "postgres"}}, + }) + if err == nil { + t.Fatal("the only database was used although another was chosen") + } + if !strings.Contains(err.Error(), "only anchor/postgres provides it") { + t.Fatalf("the refusal does not say what is available: %v", err) + } +} + +func TestACatalogueThatDisagreesAboutScopeIsRefused(t *testing.T) { + // If one module says a database is local and another says it is anywhere, the same + // requirement means two things depending on which one happens to answer it. + _, err := Resolve(shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database")}, mcat.Manifest{Module: "sqlite", Version: "1", Provides: mcat.Offers("postgres-database")}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}}), []string{"meshboard"}, workstation(), mcat.World{}) + if err == nil { + t.Fatal("a catalogue that disagrees about where a database lives was accepted") + } + if !strings.Contains(err.Error(), "two things") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +func TestALocalRequirementIsStillAnsweredLocally(t *testing.T) { + // The change must not have made everything brokered. A shell is still installed here. + got, err := Resolve(shelf(mcat.Manifest{Module: "zsh", Version: "1", Provides: mcat.Offers("shell")}, mcat.Manifest{Module: "tools", Version: "1", Requires: []string{"shell"}}), []string{"tools"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + if have := strings.Join(names(got), " "); !strings.Contains(have, "zsh") { + t.Fatalf("a shell was not installed on the machine that needs one: %s", have) + } +} + +func boundShelf() map[string]mcat.Manifest { + return shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database"), + Serves: map[string]map[string]any{"postgres-database": {"port": 5432, "driver": "postgres"}}}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}, + Binds: map[string]string{"postgres-database": "/etc/meshboard/database.json"}}, + ) +} + +func binding(t *testing.T, out []map[string]any) map[string]any { + t.Helper() + for _, r := range out { + if r["path"] != "/etc/meshboard/database.json" { + continue + } + var parsed map[string]any + if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil { + t.Fatalf("what the app is told is not readable: %v", err) + } + return parsed + } + t.Fatalf("the app was told nothing: %v", out) + return nil +} + +func TestAnAppIsToldWhereItsDatabaseIs(t *testing.T) { + // Knowing it needs the anchor's database is useless to the program that needs it unless the + // program is told. This is the whole point of the field. + got, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal", + Serves: map[string]any{"port": 5432, "driver": "postgres"}}}}}) + if err != nil { + t.Fatal(err) + } + told := binding(t, mustDeclare(t, got)) + if told["from"] != "anchor" || told["at"] != "anchor.internal" { + t.Fatalf("it was not told where: %v", told) + } + serves, _ := told["serves"].(map[string]any) + if serves["port"] != float64(5432) || serves["driver"] != "postgres" { + t.Fatalf("it was not told how: %v", serves) + } +} + +func TestItSaysWhereTheCredentialIsInstead(t *testing.T) { + // A missing field looks like a bug; a stated absence looks like a boundary. Somebody wiring + // this up must not spend an afternoon looking for the password field. + // + // It used to say only that the mesh had no way to issue one, which stopped being true when + // 021 was fixed. A stated absence is only useful while it is accurate — once it is not, it + // sends the reader somewhere there is nothing to find. + got, _ := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}}) + told := binding(t, mustDeclare(t, got)) + note, _ := told["generated"].(string) + if !strings.Contains(note, "not here") || !strings.Contains(note, "secrets") { + t.Fatalf("the file does not say where the credential is instead: %v", note) + } + for key := range told { + if strings.Contains(key, "password") || strings.Contains(key, "secret") { + t.Fatalf("something that looks like a credential appeared: %q", key) + } + } +} + +func TestTwoMachinesThatCannotReachEachOtherAreRefused(t *testing.T) { + // An app on one machine and a database on another that share no private network is a mesh + // that reports itself configured and does not work. Said here rather than discovered as a + // connection timing out. + _, err := Resolve(boundShelf(), []string{"meshboard"}, workstation(), mcat. // not on the network + World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}}) + if err == nil { + t.Fatal("an app was pointed at a database it has no path to") + } + if !strings.Contains(err.Error(), "private network") { + t.Fatalf("the refusal does not say what is wrong: %v", err) + } + if !strings.Contains(err.Error(), meshNetwork) { + t.Fatalf("the refusal does not say what to assign: %v", err) + } +} + +func TestTheProviderBeingOffTheNetworkIsAlsoRefused(t *testing.T) { + // Both directions, because the failure is identical from either end and the remedy differs. + _, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor"}}}}) + if err == nil { + t.Fatal("an app was pointed at a database that is not on the private network") + } + if !strings.Contains(err.Error(), "anchor") { + t.Fatalf("the refusal does not name the unreachable end: %v", err) + } +} + +func TestBindingSomethingAnsweredHereWritesNothing(t *testing.T) { + // A file saying "it is on this node" is a fact nobody needs and one more thing to keep true. + got, err := Resolve(shelf(mcat.Manifest{Module: "zsh", Version: "1", Provides: mcat.Offers("shell")}, mcat.Manifest{Module: "tools", Version: "1", Requires: []string{"shell"}, + Binds: map[string]string{"shell": "/etc/tools/shell.json"}}, + ), []string{"tools"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + for _, r := range mustDeclare(t, got) { + if r["path"] == "/etc/tools/shell.json" { + t.Fatalf("a binding was written for something on this machine: %v", r) + } + } +} diff --git a/internal/compose/resolution/bucket_shares.go b/internal/compose/resolution/bucket_shares.go new file mode 100644 index 0000000..0ada52a --- /dev/null +++ b/internal/compose/resolution/bucket_shares.go @@ -0,0 +1,48 @@ +package resolution + +import ( + "fmt" + "sort" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// sortedShares is a requirement's shares as they are handed to the provider: sorted by `to` (a file whose lines move +// looks changed), each entry's methods sorted, and a copy so the manifest is not reordered. +func sortedShares(shares []mcat.BucketShare) []mcat.BucketShare { + out := make([]mcat.BucketShare, 0, len(shares)) + for _, s := range shares { + methods := append([]string(nil), s.Methods...) + sort.Strings(methods) + out = append(out, mcat.BucketShare{To: s.To, Methods: methods}) + } + sort.Slice(out, func(i, j int) bool { return out[i].To < out[j].To }) + return out +} + +// withShares adds this module's shares of the requirement to the values it gives the provider. A module whose own +// `contributes` already names `shares` is refused: the field is the only way to say it (ADR 0468 §8). +func withShares(m mcat.Manifest, requirement string, values map[string]any) (map[string]any, error) { + shares := m.RequireShares[requirement] + if _, named := values[mcat.SharesValue]; named && requirement == mcat.BucketRequirement { + return nil, fmt.Errorf("%s contributing to %s names `shares`; write it on its requirement instead (novox/hq ADR 0468 §8)", + m.Module, requirement) + } + if len(shares) == 0 { + return values, nil + } + out := make(map[string]any, len(values)+1) + for k, v := range values { + out[k] = v + } + listed := []any{} + for _, s := range sortedShares(shares) { + methods := []any{} + for _, method := range s.Methods { + methods = append(methods, method) + } + listed = append(listed, map[string]any{"to": s.To, "methods": methods}) + } + out[mcat.SharesValue] = listed + return out, nil +} diff --git a/internal/compose/resolution/bucket_shares_test.go b/internal/compose/resolution/bucket_shares_test.go new file mode 100644 index 0000000..4c5a2cc --- /dev/null +++ b/internal/compose/resolution/bucket_shares_test.go @@ -0,0 +1,105 @@ +package resolution + +import ( + "encoding/json" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +const filesWithShares = `{"module":"files","version":"1","requires":["shell", + {"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["get","put"]}]}]}` + +func minioLike() mcat.Manifest { + return mcat.Manifest{Module: "minio", Version: "1", Provides: mcat.Offers("s3-bucket"), + Receives: map[string]string{"s3-bucket": "/grants/mesh.json"}} +} + +func givenToMinio(t *testing.T, out []map[string]any) []mcat.Contribution { + t.Helper() + for _, r := range out { + if r["path"] != "/grants/mesh.json" { + continue + } + var parsed struct { + Given []mcat.Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil { + t.Fatal(err) + } + return parsed.Given + } + t.Fatalf("minio was given no file: %v", out) + return nil +} + +func sharesOf(t *testing.T, values map[string]any) string { + t.Helper() + raw, err := json.Marshal(values["shares"]) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +const sortedSharesJSON = `[{"methods":["get","put"],"to":"print-and-scan"},{"methods":["put"],"to":"seat:node-files"}]` + +// 1591/AC3: the values a consumer gives carry the shares sorted by `to`, remote (grant) and co-located alike. +func TestTheGrantCarriesTheSharesSorted(t *testing.T) { + m := mustParse(t, filesWithShares) + remote := mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}} + values, asks, err := ContributionsFrom(remote, "s3-bucket", "files", mcat.SettingsBy{}) + if err != nil || !asks { + t.Fatalf("asks %v, err %v", asks, err) + } + if got := sharesOf(t, values); got != sortedSharesJSON { + t.Fatalf("the grant values carry %s", got) + } + // As the provider's file shows it, through a grant. + provider, err := Resolve(shelf(minioLike()), []string{"minio"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(provider, mcat.Rendering{Grants: []mcat.Grant{{Provision: "s3-bucket", Consumer: "laptop", From: "files", Values: values}}}) + if err != nil { + t.Fatal(err) + } + given := givenToMinio(t, out) + if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON { + t.Fatalf("the remote consumer's shares did not reach the file: %+v", given) + } + + // Co-located: the consumer contributes to s3-bucket too, and no grant is passed. + co := mustParse(t, `{"module":"files","version":"1","contributes":{"s3-bucket":{"name":"files"}}, + "requires":[{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["put","get"]}]}]}`) + both, err := Resolve(shelf(minioLike(), co), []string{"files"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err = Declaration(both, mcat.Rendering{}) + if err != nil { + t.Fatal(err) + } + given = givenToMinio(t, out) + if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON || given[0].Values["name"] != "files" { + t.Fatalf("the co-located consumer's shares did not reach the file: %+v", given) + } +} + +// 1591/AC5: a consumer whose contributes to s3-bucket names `shares` itself is refused. +func TestAConsumerCannotContributeSharesItself(t *testing.T) { + _, err := decodeForTest([]byte(`{"module":"files","version":"1", + "contributes":{"s3-bucket":{"shares":"x"}}, + "requires":[{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]}]}]}`)) + if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") { + t.Errorf("both written, accepted: %v", err) + } + // Without the field the grant is still refused, so the provider is never told something the manifest did not say. + m := mcat.Manifest{Module: "files", Requires: []string{"s3-bucket"}, + Contributes: map[string]map[string]any{"s3-bucket": {"shares": "x"}}} + r := mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}} + if _, _, err := ContributionsFrom(r, "s3-bucket", "files", mcat.SettingsBy{}); err == nil { + t.Error("a contributed `shares` reached the grant") + } +} diff --git a/internal/compose/resolution/build.go b/internal/compose/resolution/build.go new file mode 100644 index 0000000..08bfe76 --- /dev/null +++ b/internal/compose/resolution/build.go @@ -0,0 +1,20 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// runByAResource is the artifacts one of a module's own resources names — a process that runs it, +// a step, an archive that unpacks it — by name. +func runByAResource(m mcat.Manifest) map[string]bool { + named := map[string]bool{} + for _, r := range m.Resources { + if a, ok := r["artifact"].(string); ok && a != "" { + named[a] = true + } + } + return named +} + +// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools). +func RunByAResource(m mcat.Manifest) map[string]bool { return runByAResource(m) } diff --git a/internal/compose/resolution/busy.go b/internal/compose/resolution/busy.go new file mode 100644 index 0000000..d27ba08 --- /dev/null +++ b/internal/compose/resolution/busy.go @@ -0,0 +1,20 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that +// reads it, the command and its timeout written out; for one that does not, the field is not sent, and the +// container is replaced at once, as before. +func busyInto(resource map[string]any, with mcat.Rendering) { + if _, has := resource[mcat.BusyField]; !has { + return + } + if !with.ReadsBusy { + delete(resource, mcat.BusyField) + return + } + b, _, _ := mcat.ReadBusy(resource) + resource[mcat.BusyField] = map[string]any{"command": b.Command, "timeout": b.Timeout.String()} +} diff --git a/internal/compose/resolution/busy_test.go b/internal/compose/resolution/busy_test.go new file mode 100644 index 0000000..8940b78 --- /dev/null +++ b/internal/compose/resolution/busy_test.go @@ -0,0 +1,54 @@ +package resolution + +import ( + "encoding/json" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func busyManifest(t *testing.T, on string, busy any) []byte { + t.Helper() + extra := map[string]any{"id": "agent", "type": "container", "name": "app-agent", + "image": "registry.example/app" + healthDigest} + var m map[string]any + if err := json.Unmarshal(healthManifest(t, "", nil, extra), &m); err != nil { + t.Fatal(err) + } + for _, r := range m["resources"].([]any) { + if r := r.(map[string]any); r["id"] == on { + r[mcat.BusyField] = busy + } + } + out, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) { + m, err := decodeForTest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})) + if err != nil { + t.Fatal(err) + } + compose := func(with mcat.Rendering) []map[string]any { + out, err := Declaration(mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}, with) + if err != nil { + t.Fatal(err) + } + return out + } + older := compose(mcat.Rendering{}) + if b, sent := older[indexOfID(older, "app.agent")][mcat.BusyField]; sent { + t.Fatalf("busy was sent to an engine that does not read it: %v", b) + } + newer := compose(mcat.Rendering{ReadsBusy: true}) + got, _ := newer[indexOfID(newer, "app.agent")][mcat.BusyField].(map[string]any) + if got["command"] != "mesh-builder busy" || got["timeout"] != "10s" { + t.Fatalf("busy composed as %v", got) + } + if _, has := m.Resources[len(m.Resources)-1][mcat.BusyField].(map[string]any)["timeout"]; has { + t.Error("composing wrote the timeout into the catalogue's own manifest") + } +} diff --git a/internal/compose/resolution/catalogue_check_test.go b/internal/compose/resolution/catalogue_check_test.go new file mode 100644 index 0000000..3e75f08 --- /dev/null +++ b/internal/compose/resolution/catalogue_check_test.go @@ -0,0 +1,46 @@ +package resolution + +import ( + "path/filepath" + "testing" + + "github.com/novox/mesh-host/internal/beside" +) + +// TestEveryCatalogueManifestParses runs the real catalogue through the real gate. +// +// Not a fixture: the point is whether the manifests as written are accepted by the control plane that +// will read them, and a copy of one manifest proves nothing about the other seventy-one. +// catalogueRoot is the catalogue these checks run over: in a merge check the clone the build seat put +// beside this one, elsewhere the copy captured in testdata/beside (internal/beside). A check that only +// ran when somebody remembered a variable was a check nobody ran (novox/hq issue 134), and one that read +// whatever checkout sat beside judged the machine, not the change (novox/hq issue 432): it never skips. +func catalogueRoot(t *testing.T) string { + t.Helper() + return beside.Dir(t, "mesh-catalog") +} + +func TestEveryCatalogueManifestParses(t *testing.T) { + _, found := catalogueManifests(t) + named, routed := 0, 0 + for _, r := range found { + m, err := r.manifest, r.err + if err != nil { + t.Errorf("%s: %v", filepath.Base(filepath.Dir(r.path)), err) + continue + } + for _, l := range m.Listens { + if l.Name != "" { + named++ + } + } + for port := range RoutedPorts(m) { + _ = port + routed++ + } + } + t.Logf("%d manifests, %d named endpoints, %d routed endpoints resolved", len(found), named, routed) + if named == 0 { + t.Fatal("no endpoint in the catalogue is named, so this proved nothing") + } +} diff --git a/internal/compose/resolution/catalogue_read_test.go b/internal/compose/resolution/catalogue_read_test.go new file mode 100644 index 0000000..08bd7c2 --- /dev/null +++ b/internal/compose/resolution/catalogue_read_test.go @@ -0,0 +1,46 @@ +package resolution + +import ( + "path/filepath" + "sync" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// readManifest is one manifest of the catalogue as ReadTreeManifest read it. +type readManifest struct { + path string // the module.json + manifest mcat.Manifest + err error +} + +var readOnce sync.Once + +var readAll []readManifest + +var readRoot string + +// catalogueManifests is every manifest of the catalogue (catalogueRoot), read through ReadTreeManifest once +// per test binary and not once per test: the checks over the real catalogue each read and parsed every +// manifest again, and together took 1.5 s of mesh-sdk's unit bound of 2 s (issue 1756). What it returns is +// shared and read-only: a test that changes a manifest copies it first. +func catalogueManifests(t *testing.T) (string, []readManifest) { + t.Helper() + root := catalogueRoot(t) + readOnce.Do(func() { + readRoot = root + found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil { + return + } + for _, p := range found { + m, err := mcat.ReadTreeManifest(root, filepath.Dir(p)) + readAll = append(readAll, readManifest{path: p, manifest: m, err: err}) + } + }) + if len(readAll) == 0 { + t.Fatalf("no manifests under %s", root) + } + return readRoot, readAll +} diff --git a/internal/compose/resolution/catrust_manifest_test.go b/internal/compose/resolution/catrust_manifest_test.go new file mode 100644 index 0000000..67a3a8c --- /dev/null +++ b/internal/compose/resolution/catrust_manifest_test.go @@ -0,0 +1,70 @@ +package resolution + +import ( + "path/filepath" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/beside" +) + +// **A machine trusts the mesh's authority because a module put its root there** (novox/hq ADR +// 0147, issue 129). The module carries a shell script and a unit, and both are worthless unless +// the mesh fills in where the authority is — which is the one thing about it the manifest cannot +// state, because the authority's address is a fact about the mesh and not about the module. +// +// So what is checked here is the rendering, not the parsing: the script the machine will run +// names the authority it was bound to, and the unit runs that script both ways. The verification +// itself — a plain client trusting an internal name on a machine holding this, and failing on one +// that does not — is the lab's, and cannot be had here. +func TestCaTrustRendersTheAuthorityItWasBoundTo(t *testing.T) { + m, err := mcat.ReadTreeManifest(beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), "ca-trust")) + if err != nil { + t.Fatalf("the trust module does not parse:\n%v", err) + } + + r := mcat.Resolution{ + Node: "workstation", + Modules: []mcat.Manifest{m}, + Needs: []mcat.Needed{{ + Name: "internal-acme-ca", From: "anchor", At: "anchor.internal", For: "ca-trust", + Serves: map[string]any{ + "port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem", + }, + }}, + } + out, err := Declaration(r, mcat.Rendering{}) + if err != nil { + t.Fatalf("the trust module could not be composed for a machine: %v", err) + } + + script := fileNamed(out, "ca-trust.anchor") + if script == nil { + t.Fatalf("nothing writes the script the unit runs: %v", out) + } + body, _ := script["content"].(string) + if !strings.Contains(body, "https://anchor.internal:9000/roots.pem") { + t.Errorf("the script does not fetch from the authority it was bound to:\n%s", body) + } + if script["mode"] != "0755" { + t.Errorf("the script is written %v, which systemd cannot execute", script["mode"]) + } + + unit := fileNamed(out, "ca-trust.unit") + if unit == nil { + t.Fatalf("no unit: %v", out) + } + text, _ := unit["content"].(string) + // Both halves. A unit that only installs the anchor leaves a machine trusting an authority + // nobody assigned it to any more, which is the half issue 129 asked for by name. + for _, want := range []string{ + "ExecStart=" + script["path"].(string) + " install", + "ExecStop=" + script["path"].(string) + " remove", + "RemainAfterExit=yes", + } { + if !strings.Contains(text, want) { + t.Errorf("the unit does not say %q:\n%s", want, text) + } + } +} diff --git a/internal/compose/resolution/checker_seat.go b/internal/compose/resolution/checker_seat.go new file mode 100644 index 0000000..337ffa9 --- /dev/null +++ b/internal/compose/resolution/checker_seat.go @@ -0,0 +1,33 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them +// everywhere. +func checkerVerbs() []mcat.Verb { + return []mcat.Verb{ + {Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " + + "judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration " + + "levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read " + + "reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most " + + "4 wait or run at once, mesh-wide.", + Input: schema(map[string]string{"repository": "/", + "head": "the full commit to try, 40 or 64 hex digits", + "trunk": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent", + "tests": "a go test -run pattern: only those tests run, and nothing is kept", + "level": "unit or integration: only this level; both when absent"}, + []string{"repository", "head"}), + Replaces: []string{"sh merge-check.sh", "docker run postgres"}}, + {Name: "try-read", Optional: true, Description: "Where a try stands, as stored: queued, running, or its end " + + "(pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit " + + "it was merged onto, the time it took and what failed first. Read-only.", + Input: schema(map[string]string{"id": "the try's id, try-"}, []string{"id"})}, + {Name: "try-end", Optional: true, Description: "End a try that waits or runs, as error, by hand: say why (kept in " + + "what it says). Its place among the tries is free at once and the next try of its head asks anew with a new " + + "id. A try that ended already is refused.", + Input: schema(map[string]string{"id": "the try's id, try-", "why": "why it is ended, in words"}, + []string{"id", "why"})}, + } +} diff --git a/internal/compose/resolution/chosen.go b/internal/compose/resolution/chosen.go new file mode 100644 index 0000000..fffdd19 --- /dev/null +++ b/internal/compose/resolution/chosen.go @@ -0,0 +1,86 @@ +package resolution + +import ( + "sort" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// matches is whether this provider is the one chosen. +func chosenMatches(c mcat.Chosen, p mcat.Provider) bool { + return p.Node == c.Node && (c.Module == "" || p.Module == c.Module) +} + +// among is every offered provider the choice names — one, when the choice is whole. +func chosenAmong(c mcat.Chosen, where []mcat.Provider) []mcat.Provider { + var out []mcat.Provider + for _, p := range where { + if chosenMatches(c, p) { + out = append(out, p) + } + } + return out +} + +// nameOf is how a refusal names a provider: the node and the module on it. +func nameOf(p mcat.Provider) string { + return mcat.Chosen{Node: p.Node, Module: p.Module}.String() +} + +// providerNames is every provider named, sorted, for a refusal to list. +func providerNames(where []mcat.Provider) []string { + out := make([]string, 0, len(where)) + for _, p := range where { + out = append(out, nameOf(p)) + } + sort.Strings(out) + return out +} + +// providersHere is which modules in this node's own set offer a provision, sorted. +func providersHere(catalogue map[string]mcat.Manifest, here func(string) bool, want string) []string { + var out []string + for name, m := range catalogue { + if !here(name) { + continue + } + for _, o := range m.Offers() { + if o == want { + out = append(out, name) + break + } + } + } + sort.Strings(out) + return out +} + +// servedByOne is what one provider beside the consumer says a consumer needs to know, or nothing. +// +// Serving is *whether* a need is created at all when the provider is on this same machine (novox/hq +// 04-ISSUES/038's sibling): a need never created is a binding the consumer never gets. The manifest +// alone answers that; the values are settled later, with the node's settings. +func servedByOne(m mcat.Manifest, want string) map[string]any { + if _, ok := m.Serves[want]; ok { + return ServedOn(m, want, nil) + } + return nil +} + +// sharedByOne is the own secret that provider names as its credential (ADR 0158), or "" when it +// gives each consumer its own. +func sharedByOne(m mcat.Manifest, want string) string { + if own, shared := m.SharedCredentialOf(want); shared { + return own + } + return "" +} + +func oneOf(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} diff --git a/internal/compose/resolution/co_located_test.go b/internal/compose/resolution/co_located_test.go new file mode 100644 index 0000000..0b39a17 --- /dev/null +++ b/internal/compose/resolution/co_located_test.go @@ -0,0 +1,306 @@ +package resolution + +import ( + "encoding/json" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// A root certificate, as a certificate authority serves one: a real, self-signed one made for these tests (its key +// thrown away), because the one setting that may hold lines must parse as a certificate (novox/hq issue 339). +const servedRoot = `-----BEGIN CERTIFICATE----- +MIIBPjCB8aADAgECAhRZG3p93hUUB5bz00uxhYo/nJnTQjAFBgMrZXAwFDESMBAG +A1UEAwwJdGVzdCByb290MCAXDTI2MTAwODIyMjE0NloYDzIxMjYwOTE0MjIyMTQ2 +WjAUMRIwEAYDVQQDDAl0ZXN0IHJvb3QwKjAFBgMrZXADIQA0pt/ld+W0MXwBhPfO +cuAt56kIW6Qcn+4vqWpuvHTiqaNTMFEwHQYDVR0OBBYEFIjgaLk4OVGIOeZQiqnN +p9vhciFjMB8GA1UdIwQYMBaAFIjgaLk4OVGIOeZQiqnNp9vhciFjMA8GA1UdEwEB +/wQFMAMBAf8wBQYDK2VwA0EAl9uWeSM2XAV8u0reyV3BLRxNVik+4FCRO1QKPs2k +IlB1rK9oAOYManH+VFuBMI/JJ31ajSti81q4E0CSw8r5DQ== +-----END CERTIFICATE----- +` + +// stepCA is an internal ACME authority: it answers `acme-ca` from anywhere in the mesh, and what a +// consumer must know is the directory URL and the root to trust. **The root is not knowable when +// the module is written** — it exists only once the CA has been initialised — so the manifest +// declares the key and the operator supplies the value as a setting, which is exactly the shape the +// cross-node path settles and the same-node path did not. +func stepCA() mcat.Manifest { + return mcat.Manifest{ + Module: "step-ca", Version: "1", + Provides: mcat.FromAnywhere("acme-ca"), + Listens: []mcat.Listening{{Port: 9000, Protocol: "tcp", From: mcat.FromMesh}}, + Serves: map[string]map[string]any{"acme-ca": { + "directory": "https://anchor.internal/acme/acme/directory", + // Declared empty: a manifest is the same on every mesh, and this mesh's root is not. + "root": "", + }}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "step-ca", "ports": []any{"9000"}, + }}, + } +} + +// stepCAWithItsRoot is the authority whose root the operator supplies: the served key asks for it as +// `${setting:root}` (ADR 0155), the only way a setting reaches what a provider serves since a layer +// stopped replacing a served key (novox/hq issue 696; before it, the empty `root` was replaced whole). +func stepCAWithItsRoot() mcat.Manifest { + m := stepCA() + m.Serves["acme-ca"]["root"] = "${setting:root}" + return m +} + +// routeProxy issues from that authority, so it must be told the root to verify it with. +func routeProxy() mcat.Manifest { + return mcat.Manifest{ + Module: "route-proxy", Version: "1", + Requires: []string{"acme-ca"}, + Provides: mcat.FromAnywhere("route"), + Binds: map[string]string{"acme-ca": "/var/lib/route-proxy/acme-ca.json"}, + Receives: map[string]string{"route": "/var/lib/route-proxy/routes.json"}, + Resources: []map[string]any{{ + "id": "bundle", "type": "file", "path": "/var/lib/route-proxy/ca.pem", "mode": "0644", + "content": "${bound:acme-ca:root}", + }}, + } +} + +// A co-located provider's served VALUES reach its consumer, not just its served keys. +// +// The mesh walks a provider on ANOTHER machine and settles what it serves with that node's settings +// layers before offering it (cmd/mesh-controller plan.go, theRestOfTheMesh). A provider on the +// consumer's own machine was never settled at all: resolve.go's servedHere and declaration.go's +// here() both read the manifest and stop there. So a served value the operator supplied — the one +// kind of value a manifest cannot carry, because it is different on every mesh — arrived as the +// manifest's empty default. +// +// The consequence is silent and total: route-proxy wrote an empty CA bundle, fell back to the +// system trust store, could not verify the internal authority, and no certificate was ever issued. +func TestACoLocatedProvidersServedValuesReachItsConsumer(t *testing.T) { + r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()), + []string{"step-ca", "route-proxy"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + + // What the operator set on the provider, on this node — the CA's root, which only exists once + // the CA has been initialised. + out, err := Declaration(r, mcat.Rendering{Settings: mcat.SettingsBy{ + "step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}}, + }}) + if err != nil { + t.Fatal(err) + } + + bundle := fileNamed(out, "route-proxy.bundle") + if bundle == nil { + t.Fatalf("the consumer was given no bundle at all: %v", out) + } + if got := bundle["content"]; got != servedRoot { + t.Errorf("the co-located consumer was not told the root it must trust:\n got %q\nwant %q", + got, servedRoot) + } +} + +// And the binding file says the same thing, for a consumer that reads the binding rather than a +// substituted placeholder. The two are one fact and must not be able to disagree. +func TestACoLocatedConsumersBindingCarriesTheSettledValues(t *testing.T) { + r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()), + []string{"step-ca", "route-proxy"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(r, mcat.Rendering{ + Settings: mcat.SettingsBy{ + "step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}}, + }, + // And the machine moved the provider's port while it was at it, so both halves of the + // cross-node derivation are exercised at once. + Ports: map[string]map[int]int{"step-ca": {9000: 19000}}, + }) + if err != nil { + t.Fatal(err) + } + + binding := fileNamed(out, "route-proxy.bound-acme-ca") + if binding == nil { + t.Fatalf("the consumer was given no binding at all: %v", out) + } + var said struct { + Serves map[string]any `json:"serves"` + } + if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil { + t.Fatal(err) + } + if said.Serves["root"] != servedRoot { + t.Errorf("the binding does not carry the settled root: %q", said.Serves["root"]) + } + if port, _ := asPort(said.Serves["port"]); port != 19000 { + t.Errorf("the binding does not carry the port the machine publishes: %v", said.Serves["port"]) + } +} + +// A provider PULLED IN rather than assigned is settled the same way. +// +// The resolver's same-node need is built during the walk, from whichever modules had been chosen by +// the time the requirement came up — so which path a co-located binding took depended on the order +// a person happened to assign things in. Settling after the closure is known removes that: both +// orders now produce the same file. +func TestACoLocatedProviderIsSettledWhicheverWayItWasPulledIn(t *testing.T) { + for _, assigned := range [][]string{ + {"step-ca", "route-proxy"}, + {"route-proxy", "step-ca"}, + } { + r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()), assigned, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(r, mcat.Rendering{Settings: mcat.SettingsBy{ + "step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}}, + }}) + if err != nil { + t.Fatal(err) + } + bundle := fileNamed(out, "route-proxy.bundle") + if bundle == nil || bundle["content"] != servedRoot { + t.Errorf("assigned %v: the consumer was not told the root", assigned) + } + } +} + +// A same-node contribution names the port the MACHINE publishes, not the one the module declared. +// +// 04-ISSUES/038 fixed this for what a consumer is TOLD about a provider. This is the other +// direction: what a workload TELLS the provider about itself. gitea declares a bare container port +// 3000, the mesh publishes it as 20000:3000 — and gitea's route contribution still said 3000, so +// the proxy beside it dialled a port nothing listens on and answered 502 for every request. +// +// The redirect uses the CONTRIBUTING module's assignment: the port belongs to the workload, and +// using the provider's map would move it to wherever the proxy happens to be published. +func TestASameNodeContributionNamesThePortTheMachinePublishes(t *testing.T) { + gitea := mcat.Manifest{ + Module: "gitea", Version: "1", + Listens: []mcat.Listening{{Port: 3000, Protocol: "tcp", From: mcat.FromMesh}}, + Contributes: map[string]map[string]any{"route": {"label": "git", "port": 3000}}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "gitea", "ports": []any{"3000"}, + }}, + } + r, err := Resolve(shelf(gitea, routeProxy(), stepCA()), + []string{"gitea", "route-proxy", "step-ca"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(r, mcat.Rendering{ + // The machine put gitea's 3000 on 20000, and published it that way. + Ports: map[string]map[int]int{"gitea": {3000: 20000}}, + }) + if err != nil { + t.Fatal(err) + } + + // Where the workload is actually published. + server := fileNamed(out, "gitea.server") + if published := strings.Join(asStrings(server["ports"]), ","); published != "20000:3000" { + t.Fatalf("the workload was not published on the assigned port: %v", server["ports"]) + } + + // What the proxy beside it was told to dial: the SAME port. + routes := fileNamed(out, "route-proxy.received-route") + if routes == nil { + t.Fatalf("the proxy was given no routes file at all: %v", out) + } + var given struct { + Given []mcat.Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil { + t.Fatal(err) + } + if len(given.Given) != 1 { + t.Fatalf("expected one route, got %v", given.Given) + } + port, ok := asPort(given.Given[0].Values["port"]) + if !ok || port != 20000 { + t.Errorf("the proxy was told to dial a port nothing listens on: %v", + given.Given[0].Values["port"]) + } +} + +// A contribution from ANOTHER machine is left exactly as it was. +// +// Its port belongs to that machine's assignment, which this node's map knows nothing about — +// applying this node's map to it would move a remote workload's port to wherever a local module of +// the same name happens to be published, which is worse than the fault being fixed. +func TestAContributionFromAnotherMachineKeepsItsOwnPort(t *testing.T) { + r, err := Resolve(shelf(routeProxy(), stepCA()), + []string{"route-proxy", "step-ca"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(r, mcat.Rendering{ + // A same-named module on this machine, moved. The grant below is a laptop's, and must not + // be dragged along with it. + Ports: map[string]map[int]int{"gitea": {3000: 20000}}, + Grants: []mcat.Grant{{ + Provision: "route", Consumer: "laptop", From: "gitea", At: "laptop.internal", + Values: map[string]any{"name": "git.example", "port": 3000}, + }}, + }) + if err != nil { + t.Fatal(err) + } + routes := fileNamed(out, "route-proxy.received-route") + var given struct { + Given []mcat.Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil { + t.Fatal(err) + } + if len(given.Given) != 1 { + t.Fatalf("expected one route, got %v", given.Given) + } + if port, _ := asPort(given.Given[0].Values["port"]); port != 3000 { + t.Errorf("another machine's contribution was rewritten with this machine's ports: %v", + given.Given[0].Values["port"]) + } +} + +// The cross-node half of the same fault: a grant assembled on the providing machine carries the +// consumer's declared port until the CONSUMER's machine's assignments are applied to it. The +// declaration layer cannot do it — those assignments are not this machine's, which is the line the +// test above holds — so the grant layer is handed them and does it there. +func TestAContributionIsRedirectedToWhereItsOwnMachinePublishedIt(t *testing.T) { + published := map[int]int{3000: 20000} + + got := AtPublishedPort(map[string]any{"name": "git.example.tld", "port": 3000}, "forge", published) + if port, _ := asPort(got["port"]); port != 20000 { + t.Errorf("the proxy would dial a port that machine never published: %v", got["port"]) + } + if got["name"] != "git.example.tld" { + t.Errorf("redirecting the port disturbed the rest of the contribution: %v", got) + } + + // Idempotent, and silent about a port nobody moved: a module that pinned its own mapping has no + // assignment, and must come back exactly as it was written. + again := AtPublishedPort(got, "forge", published) + if port, _ := asPort(again["port"]); port != 20000 { + t.Errorf("applying it twice moved the port again: %v", again["port"]) + } + pinned := AtPublishedPort(map[string]any{"port": 9070}, "office", published) + if port, _ := asPort(pinned["port"]); port != 9070 { + t.Errorf("a port the mesh never assigned was rewritten: %v", pinned["port"]) + } +} + +func asStrings(v any) []string { + listed, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(listed)) + for _, one := range listed { + out = append(out, strings.TrimSpace(plainly(one))) + } + return out +} diff --git a/internal/compose/resolution/collections.go b/internal/compose/resolution/collections.go new file mode 100644 index 0000000..472fc46 --- /dev/null +++ b/internal/compose/resolution/collections.go @@ -0,0 +1,312 @@ +package resolution + +import ( + "fmt" + "regexp" + "slices" + "sort" + "strconv" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func itemKey(it mcat.Item) string { return mcat.ItemKey(it.Collection, it.Name) } + +// order is the item's place among the items of one placeholder: its `order` attribute, or OrderDefault. +func itemOrder(it mcat.Item) int { + if n, err := strconv.Atoi(it.Attributes[mcat.OrderAttribute]); err == nil && n >= 0 && n <= 99 { + return n + } + return mcat.OrderDefault +} + +// matches is whether the item's attributes carry every pair of the selector. +func itemMatches(it mcat.Item, where map[string]string) bool { + for k, v := range where { + if got, has := it.Attributes[k]; !has || got != v { + return false + } + } + return true +} + +// itemsInOrder is the module's items by order, then name; the order the old form placed a module's pieces in. +func itemsInOrder(items []mcat.Item) []mcat.Item { + out := append([]mcat.Item(nil), items...) + sort.SliceStable(out, func(a, b int) bool { + if oa, ob := itemOrder(out[a]), itemOrder(out[b]); oa != ob { + return oa < ob + } + return out[a].Name < out[b].Name + }) + return out +} + +// oldCollectionNamespaces are the namespaces read as collections of their own name. +var oldCollectionNamespaces = []string{"shell", "environment", "contribution"} + +// ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused. +var ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`) + +var attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + +var attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`) + +// splitSelector is a placeholder's name without its selector, and the selector: `widgets:bar=top,place=left` is +// `widgets` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon +// before the first one. +func splitSelector(name string) (collection, selector string) { + eq := strings.Index(name, "=") + if eq < 0 { + return name, "" + } + colon := strings.LastIndex(name[:eq], ":") + if colon < 0 { + return name, "" + } + return name[:colon], name[colon+1:] +} + +// selectorOf is a selector's pairs, or why it is none. +func selectorOf(selector string) (map[string]string, error) { + out := map[string]string{} + if selector == "" { + return out, nil + } + for _, pair := range strings.Split(selector, ",") { + k, v, ok := strings.Cut(pair, "=") + switch { + case !ok || !attributeKey.MatchString(k) || !attributeValue.MatchString(v): + return nil, fmt.Errorf("%q in %q is not =", pair, selector) + case out[k] != "": + return nil, fmt.Errorf("%q names %s twice", selector, k) + } + out[k] = v + } + return out, nil +} + +// collectionOfPlaceholder is the collection a placeholder's namespace and key name, and whether they name one. +func collectionOfPlaceholder(namespace, key string) (string, bool) { + key, _ = splitSelector(key) + switch { + case namespace == mcat.CollectedNamespace && strings.HasPrefix(key, mcat.EnvironmentCollection+":"): + // The environment is one collection, and the rest of the name picks the rendering (issue 795). + return mcat.EnvironmentCollection, true + case namespace == mcat.CollectedNamespace: + return key, true + case namespace == "environment": + return mcat.EnvironmentCollection, true + case slices.Contains(oldCollectionNamespaces, namespace): + return namespace + ":" + key, true + } + return "", false +} + +// oldCollection is an old namespace's collection by its name: its comment, or why no item may be contributed to it. +// ok is false for a name that is not an old namespace's. +// +// forItem is whether an item is to be contributed to it: an item may be contributed to a kind received as data, +// which a collector may place as a collection only where its shape says its pieces are text (design 57 L3). +func oldCollection(name string, forItem bool) (comment string, old bool, wrong error) { + namespace, key, _ := strings.Cut(name, ":") + if !slices.Contains(oldCollectionNamespaces, namespace) { + return "", false, nil + } + switch namespace { + case "shell": + target, slot, two := strings.Cut(key, ":") + if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) { + return "", true, fmt.Errorf("%s is no slot: shell code is shell::, the shell one of %s, "+ + "the session's file one of %s or the power moment one of %s, and the slot one of %s", name, + strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "), + strings.Join(knownSlots, ", ")) + } + // shellCode's line before each module's code. + return "#", true, nil + case "environment": + if key == "" { + // The collection itself, which an item is contributed to; a placeholder names a rendering. + return "#", true, nil + } + if key != mcat.EnvironmentPOSIX && key != mcat.EnvironmentSystemd { + return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s", + name, mcat.EnvironmentPOSIX, mcat.EnvironmentSystemd) + } + return "#", true, nil + } + seat, kind, _ := strings.Cut(key, ":") + s, r, ok := receivable(seat, kind) + switch { + case !ok && s.Name == "": + return "", true, fmt.Errorf("%s names the seat %q, which the mesh does not define", name, seat) + case !ok: + return "", true, fmt.Errorf("%s: %s; %s", name, s.Name, kindsOf(s)) + case r.Shape != nil && (forItem || collectableAsText(r)): + return r.Comment, true, nil + case r.Shape != nil: + return "", true, fmt.Errorf("%s is received by %s as data in its own shape, which no item of text is "+ + "(novox/hq ADR 0255)", name, s.Name) + case r.Dirs: + return "", true, fmt.Errorf("%s names its contributors' directories, which the holder reads as paths "+ + "the controller fills; it is not yet a collection (design 57 L1)", name) + case s.Name != canonicalSeat(seat): + return "", true, fmt.Errorf("%s names the seat by another name; a collection names it %s", name, s.Name) + } + return r.Comment, true, nil +} + +// isOld is whether a collection's name is an old namespace's. +func isOld(name string) bool { + _, old, _ := oldCollection(name, true) + return old +} + +// collectedPlaceholderProblems is what is wrong with one resource's ${collected:…}: placed only in a file's content, +// and naming a collection the module declares it collects. +func collectedPlaceholderProblems(m mcat.Manifest, r map[string]any) []string { + var problems []string + for _, field := range sortedKeys(r) { + v, ok := r[field].(string) + if !ok { + continue + } + for _, f := range ofCollected.FindAllStringSubmatch(v, -1) { + base, selector := splitSelector(f[1]) + if _, err := selectorOf(selector); err != nil { + problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err)) + continue + } + switch { + case field != "content": + problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a collection is placed "+ + "only in a file's content", m.Module, r["id"], f[0], field)) + case isOld(base): + if _, _, wrong := oldCollection(base, false); wrong != nil { + problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], wrong)) + } + case !slices.ContainsFunc(m.Collections, func(c mcat.Collection) bool { return c.Name == base }): + problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not collect %q: "+ + "declare it under collections (novox/hq ADR 0349 §13)", m.Module, r["id"], f[0], m.Module, base)) + } + } + } + return problems +} + +// collectionComment is the comment of a collection a module's file names: declared, or the old namespace's. +func collectionComment(m mcat.Manifest, name string) (string, error) { + if comment, old, wrong := oldCollection(name, false); old { + return comment, wrong + } + for _, c := range m.Collections { + if c.Name == name { + return c.Comment, nil + } + } + return "", fmt.Errorf("%s names ${collected:%s} and does not collect it", m.Module, name) +} + +// itemsLeftOutFor is itemsLeftOut for a placeholder that selects by attributes (where), on a machine with these +// capabilities: an item the selector does not take, or whose capability the machine lacks, is not left out but not +// wanted here. +func itemsLeftOutFor(modules []mcat.Manifest, holder mcat.Manifest, collection string, where map[string]string, + facts map[string]string, caps map[string]bool) []string { + var out []string + for _, m := range inModuleOrder(modules) { + for _, it := range itemsInOrder(m.Items) { + if it.Collection != collection || !itemMatches(it, where) || (it.IfCapability != "" && !caps[it.IfCapability]) { + continue + } + if _, rendered := heldText(it, facts); rendered && renderedOldWay(collection) { + continue + } + out = append(out, fmt.Sprintf("%s's item of %s is not placed: %s is filled the old way on this "+ + "machine, from contributions, and an item only by the provisioner's client (design 57 L1)", + m.Module, itemKey(it), holder.Module)) + } + } + return out +} + +// oldFormsLeftOut says each piece of an old form a handed collector's collections would have held: the collector is +// filled from items alone, and the piece's module contributes none (ADR 0349 clause 17). +func oldFormsLeftOut(modules []mcat.Manifest, holder mcat.Manifest, collections []mcat.PlanCollection) []string { + var out []string + for _, c := range collections { + namespace, key, _ := strings.Cut(c.Name, ":") + for _, m := range inModuleOrder(modules) { + old := false + switch namespace { + case "shell": + target, slot, _ := strings.Cut(key, ":") + old = slices.ContainsFunc(m.Shell, func(s mcat.ShellCode) bool { return s.For == target && s.Slot == slot }) + case "environment": + old = m.Environment != nil && (len(m.Environment.Variables) > 0 || len(m.Environment.Path) > 0) + case "contribution": + seat, kind, _ := strings.Cut(key, ":") + old = slices.ContainsFunc(manifestAllContributions(m), func(s mcat.SeatContribution) bool { + return canonicalSeat(s.Seat) == canonicalSeat(seat) && s.Kind == kind + }) + } + if old { + out = append(out, fmt.Sprintf("%s's %s is not placed: %s is filled by the provisioner's client on "+ + "this machine, from items, and %s contributes it the old way (design 57 L1)", m.Module, c.Name, + holder.Module, m.Module)) + } + } + } + return out +} + +// renderedOldWay is whether the controller renders a collection's items into a collector not handed. +func renderedOldWay(collection string) bool { + namespace, _, _ := strings.Cut(collection, ":") + return namespace == "shell" || namespace == "contribution" || namespace == mcat.EnvironmentCollection +} + +// heldItem is one item of a module rendered the old way. +type heldItem struct { + item mcat.Item + text string +} + +// heldItems is a module's items of the collection that the selector takes and the machine's capabilities allow, by +// order then name, each rendered as heldText does; the ones it cannot render are left out (itemsLeftOutFor says so). +func heldItems(m mcat.Manifest, collection string, where map[string]string, facts map[string]string, + caps map[string]bool) []heldItem { + if !renderedOldWay(collection) { + return nil + } + var out []heldItem + for _, it := range itemsInOrder(m.Items) { + if it.Collection != collection || !itemMatches(it, where) || (it.IfCapability != "" && !caps[it.IfCapability]) { + continue + } + if text, ok := heldText(it, facts); ok { + out = append(out, heldItem{item: it, text: text}) + } + } + return out +} + +// heldText is an item's text with the machine's facts filled, ending its line; false for an item whose text the mesh +// does not hold or which names anything but a machine fact this machine has. +func heldText(it mcat.Item, facts map[string]string) (string, bool) { + if it.Content == "" { + return "", false + } + text := it.Content + for _, f := range templatePlaceholder.FindAllStringSubmatch(text, -1) { + value, has := facts[f[2]] + if f[1] != "machine" || !has { + return "", false + } + text = strings.ReplaceAll(text, f[0], value) + } + if !strings.HasSuffix(text, "\n") { + text += "\n" + } + return text, true +} diff --git a/internal/compose/resolution/collections_bar_test.go b/internal/compose/resolution/collections_bar_test.go new file mode 100644 index 0000000..c00debd --- /dev/null +++ b/internal/compose/resolution/collections_bar_test.go @@ -0,0 +1,110 @@ +package resolution + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +const collectedBarsText = "top\n${collected:contribution:node-bar:block:bar=top,place=status}" + + "bottom\n${collected:contribution:node-bar:block:bar=bottom,place=status}" + +const collectedBars = `{"module":"bars","version":"1","moved":true, + "claims":[{"name":"node-bar","scope":"node","renders":{"block":"{{.shows}}"}}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}` + +const itemsOfPower = `{"module":"power","version":"1","moved":true, + "items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"}, + {"collection":"contribution:node-bar:block","name":"draw","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` + +func collectedBarsOn(t *testing.T, caps map[string]bool) mcat.Resolution { + t.Helper() + bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText})) + power := held(t, itemsOfPower, builtWith("power", map[string]string{"battery": "B\n", "draw": "D ${machine:name}\n"})) + r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + r.Account, r.Capabilities = "op", caps + return r +} + +func TestACollectorMayNameTheBarsBlocksAsACollectionAndNoOtherDataKind(t *testing.T) { + bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText})) + if _, err := Compose(collectedBarsOn(t, nil), handingTo("store:5000")); err != nil { + t.Fatalf("the handed collector is refused: %v", err) + } + if got := collectedPlaceholderProblems(bars, map[string]any{"id": "x", + "content": "${collected:contribution:node-bar:block:bar=top,place=status}"}); len(got) != 0 { + t.Fatalf("the check refuses it: %v", got) + } + // A data kind whose shape does not say its items are text stays refused. + _, plain, _ := receivable(mcat.BarSeat, mcat.BarKindBlock) + plain.Shape = &mcat.Shape{} + if collectableAsText(plain) { + t.Fatal("a shape not marked as text is collectable") + } + if _, _, err := oldCollection("contribution:node-bar:block", false); err != nil { + t.Fatal(err) + } +} + +// The bars of the catalogue's i3status-rust, with the blocks of power's items, compose to the files the old shaped +// contribution made (issue 781). testdata/bars holds the catalogue's bars and block texts as of the move and the bars as +// composed before it, for a machine with neither capability, the battery, the power meter and both. Where the +// collector is not handed the controller fills the files; where it is, it names the items for the client, which +// fills the same files (mesh-catalog's provisioner client holds that half against the same golden files). +func TestTheBarsOfTheCatalogueAreTheSameHandedOrNot(t *testing.T) { + read := func(name string) string { + b, err := os.ReadFile(filepath.Join("testdata", "bars", name)) + if err != nil { + t.Fatal(err) + } + return string(b) + } + collector := `{"module":"bars","version":"1","moved":true,"claims":[{"name":"node-bar","scope":"node"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"top","type":"file","path":"/t","template":"files/top"}, + {"id":"bottom","type":"file","path":"/b","template":"files/bottom"}]}` + power := `{"module":"power","version":"1","moved":true, + "items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"}, + {"collection":"contribution:node-bar:block","name":"power-draw","if-capability":"power-meter","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` + for name, caps := range map[string]map[string]bool{"none": nil, "battery": {"battery": true}, + "power-meter": {"power-meter": true}, "both": {"battery": true, "power-meter": true}} { + bars := held(t, collector, builtWith("bars", map[string]string{"top": read("top-bar.toml"), "bottom": read("bottom-bar.toml")})) + blocks := held(t, power, builtWith("power", map[string]string{"battery": read("bar-battery"), "draw": read("bar-power-draw")})) + r, err := Resolve(shelf(bars, blocks), []string{"bars", "power"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + r.Account, r.Capabilities = "op", caps + // Not handed: the controller fills them. + for id, which := range map[string]string{"bars.top": "top", "bars.bottom": "bottom"} { + if got := composedFile(t, r, id); got != read(name+"."+which+"-bar.toml") { + t.Errorf("%s, %s bar, not handed:\n%s\nwant\n%s", name, which, got, read(name+"."+which+"-bar.toml")) + } + } + // Handed: the plan names the items the machine's capabilities allow, for the client to fill. + c, _ := composeWith(t, r, handingTo("store:5000")) + var names []string + for _, it := range c.Plans["power"].Items { + names = append(names, it.Name) + } + want := []string{} + if caps["battery"] { + want = append(want, "battery") + } + if caps["power-meter"] { + want = append(want, "power-draw") + } + if !reflect.DeepEqual(append([]string{}, names...), want) { + t.Errorf("%s: the plan names %v, want %v", name, names, want) + } + } +} diff --git a/internal/compose/resolution/collections_l2_test.go b/internal/compose/resolution/collections_l2_test.go new file mode 100644 index 0000000..cda4d55 --- /dev/null +++ b/internal/compose/resolution/collections_l2_test.go @@ -0,0 +1,57 @@ +package resolution + +import ( + "os" + "path/filepath" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func l2Moved(t *testing.T, parts ...string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(append([]string{"testdata", "l2-moved"}, parts...)...)) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func TestTheCollectorsL2MovedComposeAsBeforeWhereNothingIsHanded(t *testing.T) { + // Each moved contributor, its items read back from its templates archive as the mesh holds them. + items := map[string][]string{ + "asus-zephyrus-g14": {"shell:after-wake:normal", "contribution:node-hotkeys:trigger"}, + "claude-code": {"shell:zsh:normal"}, + "mesh-cli": {"shell:zsh:normal"}, + "powerlevel10k": {"shell:zsh:normal"}, + "zsh-autosuggestions": {"shell:zsh:normal"}, + "zsh-syntax-highlighting": {"shell:zsh:last"}, + } + var modules []mcat.Manifest + for _, module := range sortedKeys(items) { + m := mcat.Manifest{Module: module, Moved: true} + for _, collection := range items[module] { + m.Items = append(m.Items, mcat.Item{Collection: collection, + Content: l2Moved(t, "items", module+"."+strings.ReplaceAll(collection, ":", "-"))}) + } + modules = append(modules, m) + } + for _, c := range []struct{ module, seat string }{{"zsh", mcat.LoginShellSeat}, {"triggerhappy", mcat.HotkeysSeat}, + {"power", mcat.PowerSeat}} { + holder := mcat.Manifest{Module: c.module, Moved: true, Claims: []mcat.Claim{{Name: c.seat}}} + resource := map[string]any{"id": "file", "type": "file", "path": "/etc/" + c.module, + "content": l2Moved(t, "templates", c.module)} + var unplaced []string + if err := contributionsInto(resource, holder, append([]mcat.Manifest{holder}, modules...), + map[string]string{"name": "g14"}, mcat.Rendering{}, map[string]bool{}, &unplaced); err != nil { + t.Fatalf("%s: %v", c.module, err) + } + if got, want := resource["content"], l2Moved(t, "want", c.module); got != want { + t.Errorf("%s's file is not what the old forms made:\n%s\nwant:\n%s", c.module, got, want) + } + if len(unplaced) != 0 { + t.Errorf("%s: unplaced %v", c.module, unplaced) + } + } +} diff --git a/internal/compose/resolution/collections_l3_test.go b/internal/compose/resolution/collections_l3_test.go new file mode 100644 index 0000000..8b66048 --- /dev/null +++ b/internal/compose/resolution/collections_l3_test.go @@ -0,0 +1,36 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +const barCollector = `{"module":"bars","version":"1","moved":true, + "collections":[{"name":"blocks","comment":"#"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}` + +func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testing.T) { + for placeholder, want := range map[string]string{ + "${collected:blocks:bar}": "", // no equals sign: a collection named blocks:bar + "${collected:blocks:bar=top,bar=bottom}": "names bar twice", + "${collected:blocks:bar=}": "is not =", + "${collected:other:bar=top}": `does not collect "other"`, + } { + raw := strings.Replace(barCollector, `"template":"files/bars"`, `"content":"`+placeholder+`"`, 1) + raw = strings.Replace(raw, `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1) + raw = strings.Replace(raw, `"moved":true,`, "", 1) + m, err := decodeForTest([]byte(raw)) + if err == nil { + _, err = Declaration((mcat.Resolution{Node: "n", Modules: []mcat.Manifest{m}}), mcat.Rendering{}) + } + if want == "" { + continue + } + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: %v, want %q", placeholder, err, want) + } + } +} diff --git a/internal/compose/resolution/collections_test.go b/internal/compose/resolution/collections_test.go new file mode 100644 index 0000000..006624d --- /dev/null +++ b/internal/compose/resolution/collections_test.go @@ -0,0 +1,13 @@ +package resolution + +import ( + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func builtWith(module string, texts map[string]string) []mcat.Built { + return []mcat.Built{{Name: "files", Kind: mcat.ArtifactArchive, + Reference: mcat.ArtifactStoreScheme + module + "/files/blobs/sha256:" + strings.Repeat("e", 64), + Digest: "sha256:" + strings.Repeat("e", 64), Texts: texts}} +} diff --git a/internal/compose/resolution/computed_test.go b/internal/compose/resolution/computed_test.go new file mode 100644 index 0000000..ed7e4f7 --- /dev/null +++ b/internal/compose/resolution/computed_test.go @@ -0,0 +1,184 @@ +package resolution + +import ( + "fmt" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +type fake struct { + on map[string]bool + asked []string + err error +} + +func (f *fake) Resources(node string) ([]map[string]any, bool, error) { + f.asked = append(f.asked, node) + if f.err != nil { + return nil, false, f.err + } + if !f.on[node] { + return nil, false, nil + } + return []map[string]any{{"id": "peers", "type": "file", "path": "/etc/x.conf", + "merge": mcat.MergeJSON, "content": `{"peer":"` + node + `"}`}}, true, nil +} + +func computedShelf() map[string]mcat.Manifest { + return shelf(mcat.Manifest{Module: "mesh-network", Computed: "mesh-network", + Provides: mcat.Offers("private-network")}) +} + +func TestAComputedModuleIsAskedAboutTheNodeItIsFor(t *testing.T) { + // The generator gets a node name, not a plan. Everything it needs is the whole mesh, which + // it was built with — this is the one thing a node could never work out for itself. + got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + gen := &fake{on: map[string]bool{"workstation": true}} + out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}}) + if err != nil { + t.Fatal(err) + } + if len(gen.asked) != 1 || gen.asked[0] != "workstation" { + t.Fatalf("asked about %v, wanted workstation once", gen.asked) + } + if len(out) != 1 || !strings.Contains(out[0]["content"].(string), `"peer": "workstation"`) { + t.Fatalf("got %v", out) + } +} + +func TestAMachineNobodyGaveItGetsNothing(t *testing.T) { + // The whole point of making the network a module. Before this, every machine with an address + // was on the private network and there was no way to say one should stay off. + got, err := Resolve(computedShelf(), nil, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + gen := &fake{on: map[string]bool{"workstation": true}} + out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}}) + if err != nil { + t.Fatal(err) + } + if len(out) != 0 { + t.Fatalf("a machine that was assigned nothing got %d resource(s)", len(out)) + } + if len(gen.asked) != 0 { + t.Fatalf("the generator was asked about %v, and nobody had asked for it", gen.asked) + } +} + +func TestAssignedAndNotYetPartOfItIsNotAFailure(t *testing.T) { + // A machine given the network module before it has a place on it. Brief and ordinary — the + // answer is "nothing yet", and treating it as an error would make an ordering a fault. + got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{}) + gen := &fake{on: map[string]bool{}} + out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}}) + if err != nil { + t.Fatalf("refused a node that is not on the network yet: %v", err) + } + if len(out) != 0 { + t.Fatalf("got %v", out) + } +} + +func TestAGeneratorThisControlPlaneDoesNotHaveIsRefused(t *testing.T) { + // Sending a machine a module with no files would look like it worked. Named in the message, + // because the only fix is a control plane that has it. + got, _ := Resolve(shelf(mcat.Manifest{Module: "weather", Computed: "the-weather"}), []string{"weather"}, workstation(), mcat.World{}) + + _, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": &fake{}}}) + if err == nil { + t.Fatal("a module computed by nothing was accepted") + } + if !strings.Contains(err.Error(), "the-weather") { + t.Fatalf("the refusal does not name what is missing: %v", err) + } +} + +func TestSettingsApplyToAComputedModuleToo(t *testing.T) { + // Being computed is about where the files come from, not about whether they are configurable. + // A line drawn there would be arbitrary and nobody could predict it. + got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{}) + gen := &fake{on: map[string]bool{"workstation": true}} + out, err := Declaration(got, mcat.Rendering{ + Generators: map[string]mcat.Generator{"mesh-network": gen}, + Settings: mcat.SettingsBy{"mesh-network": {{From: "the mesh", + Values: map[string]any{"keepalive": 25}}}}, + }) + if err != nil { + t.Fatal(err) + } + content := out[0]["content"].(string) + if !strings.Contains(content, `"keepalive": 25`) { + t.Fatalf("the setting did not reach a computed file: %s", content) + } + if !strings.Contains(content, `"peer": "workstation"`) { + t.Fatalf("the setting replaced what the generator computed: %s", content) + } +} + +func TestTwoWaysToBeOnAPrivateNetworkRefuseAndNameBoth(t *testing.T) { + // WireGuard is one way. The refusing rule is what makes a second one safe to add: assigning + // both is caught rather than producing a machine on two networks that each half-work. + _, err := Resolve(shelf(mcat.Manifest{Module: "mesh-network", Computed: "mesh-network", + Provides: mcat.Offers("private-network")}, mcat.Manifest{Module: "tailscale", Provides: mcat.Offers("private-network")}, mcat.Manifest{Module: "backups", Requires: []string{"private-network"}}, + ), []string{"backups"}, workstation(), mcat.World{}) + + if err == nil { + t.Fatal("two answers to one requirement were taken silently") + } + for _, want := range []string{"mesh-network", "tailscale", "private-network"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal does not name %s: %v", want, err) + } + } +} + +// reloading answers the runtime's trust as the networking module once did (novox/hq ADR 0102; it no +// longer does, ADR 0222 — and beside the runtime's module it is refused, generated_collision_test): a file +// written into, and the runtime reloaded on it. +type reloading struct{} + +func (reloading) Resources(node string) ([]map[string]any, bool, error) { + return []map[string]any{ + {"id": "registry-trust", "type": "file", "path": "/etc/docker/daemon.json", + "merge": mcat.MergeJSON, "into": "json", "content": `{"insecure-registries":["r:5000"]}`}, + {"id": "registry-trust-reload", "type": "service", "unit": "docker.service", + "state": "running", "reload-on": []string{"registry-trust"}}, + }, true, nil +} + +func TestWhatAServiceIsReloadedOnIsNamedAsTheHostWillSeeIt(t *testing.T) { + // Ids are prefixed with their module on the way out. An unprefixed reload-on would name a + // resource the host never sees, and the runtime would never be reloaded for its trust. + got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": reloading{}}}) + if err != nil { + t.Fatal(err) + } + var file, service map[string]any + for _, r := range out { + switch r["type"] { + case "file": + file = r + case "service": + service = r + } + } + if file == nil || service == nil { + t.Fatalf("got %v", out) + } + if file["into"] != "json" { + t.Errorf("into did not reach the host: %v", file) + } + if want := "[" + file["id"].(string) + "]"; fmt.Sprint(service["reload-on"]) != want { + t.Errorf("reload-on names %v, the file is %v", service["reload-on"], file["id"]) + } +} diff --git a/internal/compose/resolution/config_test.go b/internal/compose/resolution/config_test.go new file mode 100644 index 0000000..d731c9d --- /dev/null +++ b/internal/compose/resolution/config_test.go @@ -0,0 +1,181 @@ +package resolution + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// inConfigForm is a module in the config form that registers: every value typed, described, defaulted or +// required, placed in a file or read at runtime, and its mergeable file naming its keys. +func inConfigForm() map[string]any { + return map[string]any{ + "module": "notifier", + "version": "1", + "config": map[string]any{ + "font-size": map[string]any{"type": "number", "description": "the notification's font size, in points", + "minimum": 6, "maximum": 40, "default": 10}, + "width": map[string]any{"type": "number", "description": "the notification's width, in pixels", + "default": 250}, + "position": map[string]any{"type": "string", "description": "the corner it shows in, a word", + "enum": []any{"top-right", "top-left"}, "default": "top-right"}, + "theme": map[string]any{"type": "string", "description": "the colour theme, a lower-case name", + "pattern": "[a-z]+", "default": "dark"}, + "history": map[string]any{"type": "number", "description": "how many notifications it keeps, a count", + "default": 20, "runtime": true}, + "sender": map[string]any{"type": "string", "description": "the address it mails a digest from", + "required": true}, + }, + "resources": []any{ + map[string]any{"id": "configuration", "type": "file", "path": "/x/notifierrc", + "content": "font = Inter ${config:font-size}\nwidth = ${config:width}\nfrom = ${config:sender}\n"}, + map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", + "takes-config": []any{"position", "theme"}, "content": "{\"position\": \"top-right\"}\n"}, + map[string]any{"id": "notifier", "type": "service", "unit": "notifier.service", + "reload-on": []any{"configuration"}, "restart-on": []any{"rules"}}, + }, + } +} + +func parseRaw(t *testing.T, m map[string]any) (mcat.Manifest, error) { + t.Helper() + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return decodeForTest(raw) +} + +// settingsDunst is the dunst module as it is written today; configDunst the same module rewritten from settings to +// config. +const settingsDunst = `{"module": "dunst", "version": "1", + "settings": { + "font-size": {"kind": "preference", "default": 10, "why": "10 points of Inter reads well on a screen of about 100 DPI"}, + "width": {"kind": "preference", "default": 250, "why": "250 pixels fits a title of about forty characters at 10 points"} + }, + "resources": [ + {"id": "package", "type": "package", "package": "dunst"}, + {"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644", + "content": "[global]\n font = Inter ${setting:font-size}\n width = ${setting:width}\n"}, + {"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json", + "takes-settings": [], "content": "{\"urgency\": \"low\"}\n"}, + {"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]} + ]}` + +const configDunst = `{"module": "dunst", "version": "1", + "config": { + "font-size": {"type": "number", "description": "the font size of a notification, in points", "minimum": 6, + "maximum": 40, "default": 10}, + "width": {"type": "number", "description": "the width of a notification, in pixels", "minimum": 100, + "default": 250} + }, + "resources": [ + {"id": "package", "type": "package", "package": "dunst"}, + {"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644", + "content": "[global]\n font = Inter ${config:font-size}\n width = ${config:width}\n"}, + {"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json", + "takes-config": [], "content": "{\"urgency\": \"low\"}\n"}, + {"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]} + ]}` + +// ${config:} fills exactly as ${setting:} does: the module rewritten from settings to config composes +// to the same bytes, with no layer, with the node's, and with the mesh's under the node's. +func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) { + composed := func(manifest string, layers []mcat.Layer) []byte { + t.Helper() + m, err := decodeForTest([]byte(manifest)) + if err != nil { + t.Fatal(err) + } + r := anAdoptedAnchor() + r.Modules = append(r.Modules, m) + with := anchorRendering(false) + if layers != nil { + with.Settings["dunst"] = layers + } + c, err := Compose(r, with) + if err != nil { + t.Fatal(err) + } + if why, left := c.LeftOut["dunst"]; left { + t.Fatalf("dunst was left out: %s", why) + } + raw, err := json.Marshal(c) + if err != nil { + t.Fatal(err) + } + return raw + } + for _, layers := range [][]mcat.Layer{ + nil, + {{From: "anchor", Values: map[string]any{"font-size": float64(13)}}}, + {{From: mcat.MeshWideLayer, Values: map[string]any{"width": float64(300)}}, + {From: "anchor", Values: map[string]any{"font-size": float64(12), "width": float64(320)}}}, + } { + was, is := composed(settingsDunst, layers), composed(configDunst, layers) + if !bytes.Equal(was, is) { + t.Fatalf("composed differently with %v:\nsettings: %s\nconfig: %s", layers, was, is) + } + if !strings.Contains(string(is), "font = Inter 1") || strings.Contains(string(is), "${config:") { + t.Fatalf("not filled: %s", is) + } + } +} + +// A required value nothing sets is refused at composition in the config form's words: ${config:} and the +// config verb, never `settings set` (review of mesh-controller#320). The settings form's words are unchanged. +func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) { + m, err := parseRaw(t, inConfigForm()) + if err != nil { + t.Fatal(err) + } + r := anAdoptedAnchor() + r.Modules = append(r.Modules, m) + composed, err := Compose(r, anchorRendering(false)) + if err != nil { + t.Fatal(err) + } + why, left := composed.LeftOut["notifier"] + if !left { + t.Fatal("a module whose required value nothing sets was not left out") + } + for _, said := range []string{"${config:sender}", `required config value "sender"`, "config verb"} { + if !strings.Contains(why, said) { + t.Fatalf("left out without %q: %s", said, why) + } + } + for _, unsaid := range []string{"${setting:", "settings set"} { + if strings.Contains(why, unsaid) { + t.Fatalf("left out in the settings form's words (%q): %s", unsaid, why) + } + } + var unset *UnsetSettingError + if err := unsetInConfigWords(settingInto(map[string]any{"type": "file", "content": "${setting:sender}"}, nil, + "notifier"), m); !errors.As(err, &unset) || unset.Setting != "sender" { + t.Fatalf("not typed as a value nobody gave: %v", err) + } + + // A contribution's value too (issue 1126, seed-8: de-spiegel's route names were refused with ${setting:…}). + withRoute := inConfigForm() + withRoute["contributes"] = map[string]any{"route": map[string]any{"site": map[string]any{"name": "${config:sender}", "port": 80}}} + routed, err := parseRaw(t, withRoute) + if err != nil { + t.Fatal(err) + } + _, err = resolutionComposed(mcat.Resolution{}, routed, "route", routed.ContributesMany["route"]["site"], nil, "notifier contributing site to route") + if !errors.As(err, &unset) || unset.Module != "notifier" || !strings.Contains(err.Error(), + `notifier has a contribution site to route, in "name", that says ${config:sender}`) || strings.Contains(err.Error(), "${setting:") { + t.Fatalf("a contribution's unset value is not said in the config form's words: %v", err) + } + + settingsForm := notifier() + settingsForm.Resources[0]["content"] = "from = ${setting:domain}\n" + err = unsetInConfigWords(settingInto(settingsForm.Resources[0], nil, "notifier"), settingsForm) + if err == nil || !strings.Contains(err.Error(), "`settings set notifier `") { + t.Fatalf("the settings form's refusal changed: %v", err) + } +} diff --git a/internal/compose/resolution/consumer_into_serves.go b/internal/compose/resolution/consumer_into_serves.go new file mode 100644 index 0000000..abaaf55 --- /dev/null +++ b/internal/compose/resolution/consumer_into_serves.go @@ -0,0 +1,267 @@ +package resolution + +import ( + "fmt" + "regexp" + "sort" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// consumerFact is `${consumer:}` or `${consumer::}`. +var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`) + +// consumerFacts are what a served value may name about the consumer it is being derived for. +// One entry, deliberately: the identity is the one thing about a consumer the mesh itself chose, +// so it is the one thing the mesh can hand to a provider without either end guessing. +var consumerFacts = []string{"as"} + +// consumerAlphabets are the ways the mesh will write that identity. `dns` is the mesh's own +// identifier with its separator written `-` instead of `_` — the whole of the difference between +// the alphabet the mesh mints in and the one buckets, vhosts and hostnames accept. +var consumerAlphabets = []string{"dns"} + +// ServedTo fills a provider's served values for one consumer. +// +// `as` is the identity the mesh minted for that consumer — the same string it is told to present +// as a login. Values with no placeholder are returned exactly as they were, and a block with no +// placeholder at all is returned unchanged, so this costs nothing for the providers that derive +// nothing. +// +// Only strings carry placeholders. A number, a boolean or a nested object is a value the provider +// stated outright, and is left alone. +func ServedTo(serves map[string]any, as string) (map[string]any, error) { + if len(serves) == 0 { + return serves, nil + } + var out map[string]any + for _, key := range sortedAnyKeys(serves) { + text, ok := serves[key].(string) + if !ok || !strings.Contains(text, "${consumer:") { + continue + } + filled, err := consumerInto(text, as) + if err != nil { + return nil, fmt.Errorf("the value served as %q: %w", key, err) + } + if out == nil { + // Copied only once something actually changes: the caller's map is the manifest's, + // and a provider that derives nothing must not have it rewritten underneath it. + out = make(map[string]any, len(serves)) + for k, v := range serves { + out[k] = v + } + } + out[key] = filled + } + if out == nil { + return serves, nil + } + return out, nil +} + +// consumerInto replaces every `${consumer:…}` in one value. +// +// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through, +// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name, +// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}` +// is refused by (boundInto). +func consumerInto(value, as string) (string, error) { + var failed error + out := consumerFact.ReplaceAllStringFunc(value, func(match string) string { + parts := consumerFact.FindStringSubmatch(match) + fact, alphabet := parts[1], parts[2] + if fact != "as" { + if failed == nil { + failed = fmt.Errorf( + "says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts)) + } + return match + } + switch alphabet { + case "": + return as + case "dns": + return asDNSLabel(as) + default: + if failed == nil { + failed = fmt.Errorf( + "says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets)) + } + return match + } + }) + if failed != nil { + return "", failed + } + return out, nil +} + +// asDNSLabel writes a minted identity as a DNS label. +// +// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and +// already inside the bound of the provision serving them: a provider that serves one as a DNS label +// bounds it at 63 or less (CheckServes, novox/hq ADR 0225), and one that serves it at all without +// saying is held to twenty (IdentityBoundOf). So +// this is the separator and nothing else — no lower-casing of what is already lower case, no +// truncation to a limit the identity is already inside, no padding of a name that is already long +// enough. Each of those would be the mesh guessing at a rule it has not been given. +func asDNSLabel(as string) string { + return strings.ReplaceAll(as, "_", "-") +} + +func sortedAnyKeys(values map[string]any) []string { + out := make([]string, 0, len(values)) + for k := range values { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// derivedFor is what the provider on this machine derives for one consumer of one provision +// (novox/hq ADR 0202). +// +// Settled first, then derived: an operator may set a prefix on what the provider serves and the +// mesh still fills the consumer's half of it ([ADR 0174]). Only the keys that actually name the +// consumer are returned — the rest of a `serves` block is the same for every consumer and is +// already in the provider's own definition, so repeating it here would be a second copy to go +// stale. +// +// The first module in the resolved order that says it serves the provision answers, which is the +// choice servedOnThisMachine makes for the consumer's half. Nothing serving it on this machine is +// not an error: a contribution can reach a machine whose provider is a record or an adapter, and +// then there is nothing derived to tell. +func resolutionDerivedFor(r mcat.Resolution, provision, as, consumer, local string, settings mcat.SettingsBy) (map[string]any, error) { + for _, m := range r.Modules { + serves, said := m.Serves[provision] + if !said { + continue + } + var names map[string]any + for key, value := range serves { + if text, ok := value.(string); ok && strings.Contains(text, "${consumer:") { + if names == nil { + names = map[string]any{} + } + names[key] = value + } + } + if names == nil { + return nil, nil + } + // **A consumer that keeps several holders of this provision is refused** — this is issue + // 124's own failure one case to the side, and it would be just as quiet. + // + // Each holder gets its own login, `…_` (ADR 0094), and a provider derives from the + // login, so it would make one resource per holder. The consumer's side has no such + // dimension: one binding file per provision, one `${bound::}`, both + // derived from the un-suffixed identity. So the provider would create the holder's + // resource and the consumer would be configured against a name nothing made — it would + // authenticate successfully and be refused on every object, which reads like a credential + // fault and is not one. + // + // Lifting this means giving the consumer's side a local dimension. That is a decision, + // not an omission, and until it is taken the mesh says so rather than guessing. + if local != "" { + return nil, fmt.Errorf( + "%s keeps several holders of %s (this one is %q), and %s derives %s for each "+ + "consumer from the login the mesh minted. Each holder has its own login, and a "+ + "consumer is told one value per requirement — so the two ends would name "+ + "different things and nothing would compare them (novox/hq ADR 0202)", + consumer, local, provision, m.Module, orNothing(sortedAnyKeys(names))) + } + settled, err := Settle(names, WithDefaults(m, settings[m.Module])) + if err != nil { + return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err) + } + derived, err := ServedTo(settled, as) + if err != nil { + return nil, fmt.Errorf("%s serving %s to %s: %w", m.Module, provision, as, err) + } + return derived, nil + } + return nil, nil +} + +// notTranscribed refuses a consumer's file that writes out the value its provider derives for it, +// instead of asking for it (novox/hq ADR 0202, issue 124). +// +// **What would have caught the one wrong instance.** The object store's three consumers each wrote +// their bucket into their own configuration by hand. One of them named a predecessor's bucket, and +// nothing compared it to what the provider would actually create: the module would have +// authenticated successfully and been refused on every object, which reads like a credential fault +// and is not one. It looked authoritative for months. +// +// The test is exact and costs one string search: a definition whose file already contains the +// value the mesh is about to derive for it has written down somebody else's rule. It cannot be a +// coincidence — a derived value carries the identity the mesh minted for this very consumer on +// this very machine, which nothing else would spell out — and it cannot be checked afterwards, +// because after substitution every consumer's file contains it legitimately. +// +// Only values that actually name the consumer are judged. A provider that serves a constant under +// the same key serves the same constant to everyone, and a consumer repeating it is redundant +// rather than wrong. +func notTranscribed(resource map[string]any, known map[string]map[string]string, module string) error { + if fmt.Sprint(resource["type"]) != "file" { + return nil + } + content, ok := resource["content"].(string) + if !ok || content == "" { + return nil + } + for _, provision := range sortedKnown(known) { + values := known[provision] + identity := values["as"] + if identity == "" { + continue + } + for _, key := range sortedStringKeys(values) { + if key == "as" { + // The login is not derived from itself, and a consumer that must present it in a + // connection string legitimately has it from `${bound:…}` — which is what it will + // be after substitution, so this would judge the substitution, not the module. + continue + } + value := values[key] + if value == "" || !namesTheConsumer(value, identity) { + continue + } + if !strings.Contains(content, value) { + continue + } + return fmt.Errorf( + "%s writes %q into %v, and that is exactly what %s derives for it — a definition "+ + "keeping its own copy of somebody else's naming rule is one that can disagree "+ + "with it, silently. Say ${bound:%s:%s} and be told", + module, value, resource["id"], provision, provision, key) + } + } + return nil +} + +// namesTheConsumer is whether a derived value was built from this consumer's identity — in the +// alphabet it was minted in, or as a DNS label. A value that does not contain it was not derived +// from it, whatever else it may be. +func namesTheConsumer(value, identity string) bool { + return strings.Contains(value, identity) || strings.Contains(value, asDNSLabel(identity)) +} + +func sortedKnown(known map[string]map[string]string) []string { + out := make([]string, 0, len(known)) + for k := range known { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func sortedStringKeys(values map[string]string) []string { + out := make([]string, 0, len(values)) + for k := range values { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/compose/resolution/consumers.go b/internal/compose/resolution/consumers.go new file mode 100644 index 0000000..b74e74f --- /dev/null +++ b/internal/compose/resolution/consumers.go @@ -0,0 +1,100 @@ +package resolution + +import ( + "fmt" + "regexp" + "slices" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// ofConsumers is where a provider places its consumers' credentials. Loose inside the braces, so a misspelt name is +// refused rather than passed over. +var ofConsumers = regexp.MustCompile(`\$\{consumers:([^}]*)\}`) + +// consumersPlaceholderProblems is what is wrong with one resource's ${consumers:…}: placed only in a file's content, +// for a provision the module provides, in a file only the provider reads and the client writes back. +func consumersPlaceholderProblems(m mcat.Manifest, r map[string]any) []string { + var problems []string + for _, field := range sortedKeys(r) { + v, ok := r[field].(string) + if !ok { + continue + } + for _, f := range ofConsumers.FindAllStringSubmatch(v, -1) { + switch { + case field != "content" || fmt.Sprint(r["type"]) != "file": + problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a provider's consumers "+ + "are placed only in a file's content (novox/hq ADR 0373)", m.Module, r["id"], f[0], field)) + continue + case !slices.ContainsFunc(m.Provides, func(o mcat.Offer) bool { return o.Name == f[1] }): + problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not provide %q: only a "+ + "provider's template holds its consumers' credentials (novox/hq ADR 0373)", m.Module, r["id"], f[0], + m.Module, f[1])) + } + if mode := fmt.Sprint(r["mode"]); mode != "0600" && mode != "0400" { + problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) with "+ + "mode %q: the provider alone reads it, 0600 or 0400 (novox/hq ADR 0373)", m.Module, r["id"], f[0], + r["mode"])) + } + if owner := ownershipOf(r); owner != mcat.RuntimeOwned { + problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) and is "+ + "%s at runtime: it is owned, written back whenever it differs (novox/hq ADR 0373)", m.Module, + r["id"], f[0], owner)) + } + } + } + return problems +} + +// consumersHandedOnly says what a file naming ${consumers:…} is where it would be written the old way: the provisioner's +// client alone fills it, so on a machine that does not hand the module it is **left out** of the declaration, never +// written as that text or as an empty list the provider would read as no consumers, and never the reason the module is +// refused: a provider not handed there keeps working as it did, from its grant files (novox/hq ADR 0373, issue 780). +// A moved module judged from its repository's tree (WithTemplatesFrom: `module check`), on a machine +// that would hand it, has no templates archive to hand and is judged as it composes; there the placeholder is judged +// and taken out of the text, since what is composed so is never sent (judged answers whether the file is so judged). +func consumersHandedOnly(m mcat.Manifest, r map[string]any, with mcat.Rendering) (judged, leftOut bool) { + content, _ := r["content"].(string) + switch { + case ofConsumers.FindString(content) == "": + return false, false + case m.Moved && m.Handed == nil && with.Hands && with.Provisioner != "": + return true, false + } + return false, true +} + +// consumersLeftOutLine is the plan's one line for a file left out because the module is not handed. +func consumersLeftOutLine(m mcat.Manifest, r map[string]any) string { + return fmt.Sprintf("%s's %v names ${consumers:…}, which the provisioner's client alone fills: left out, handed only "+ + "(novox/hq ADR 0373)", m.Module, r["id"]) +} + +// consumersNotHanded is consumersHandedOnly as a refusal, for a caller that has no way to leave the file out. +func consumersNotHanded(m mcat.Manifest, r map[string]any, with mcat.Rendering) (judged bool, err error) { + judged, left := consumersHandedOnly(m, r, with) + if left { + return false, fmt.Errorf("%s's resource %v names %s, which the provisioner's client alone fills, and %s is not "+ + "handed to it on this machine: it would be written as that text, or empty, and the provider would read no "+ + "consumers (novox/hq ADR 0373)", m.Module, r["id"], ofConsumers.FindString(fmt.Sprint(r["content"])), m.Module) + } + return judged, nil +} + +// consumersOfTemplate is what is wrong with a handed file's template naming ${consumers:…}, judged on the file's +// resource as if its text were inline. +func consumersOfTemplate(m mcat.Manifest, id, text string) []string { + for _, r := range m.Resources { + if fmt.Sprint(r["id"]) != id { + continue + } + withText := map[string]any{} + for k, v := range r { + withText[k] = v + } + withText["content"] = text + return consumersPlaceholderProblems(m, withText) + } + return nil +} diff --git a/internal/compose/resolution/contributes_test.go b/internal/compose/resolution/contributes_test.go new file mode 100644 index 0000000..44f9b9c --- /dev/null +++ b/internal/compose/resolution/contributes_test.go @@ -0,0 +1,311 @@ +package resolution + +import ( + "encoding/json" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func published(module, host string, port int) mcat.Manifest { + return mcat.Manifest{Module: module, Version: "1", + Contributes: map[string]map[string]any{ + "reverse-proxy": {"host": host, "port": port}, + }} +} + +func proxy() mcat.Manifest { + return mcat.Manifest{Module: "traefik", Version: "1", + Provides: mcat.Offers("reverse-proxy"), + Receives: map[string]string{"reverse-proxy": "/etc/traefik/mesh.json"}, + Resources: []map[string]any{ + {"id": "up", "type": "service", "unit": "traefik", "state": "running", + "restart-on": []any{mcat.ReceivedID("reverse-proxy")}}, + }} +} + +// received digs the delivered contributions back out of a declaration. +func received(t *testing.T, out []map[string]any) []mcat.Contribution { + t.Helper() + for _, r := range out { + if r["path"] != "/etc/traefik/mesh.json" { + continue + } + body := r["content"].(string) + var parsed struct { + Requirement string `json:"requirement"` + Given []mcat.Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(body), &parsed); err != nil { + t.Fatalf("the file the proxy is given is not readable: %v\n%s", err, body) + } + if parsed.Requirement != "reverse-proxy" { + t.Fatalf("the file does not say what it is about: %q", parsed.Requirement) + } + return parsed.Given + } + t.Fatalf("the provider was given no file at all: %v", out) + return nil +} + +func TestTheFileTheProviderGetsIsMachineReadable(t *testing.T) { + // It is written for a program, and the first version put a `//` header above the JSON — a + // file that says "do not edit" to a person and fails to parse for the thing meant to read it. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + for _, r := range mustDeclare(t, got) { + if r["path"] != "/etc/traefik/mesh.json" { + continue + } + var any map[string]any + if err := json.Unmarshal([]byte(r["content"].(string)), &any); err != nil { + t.Fatalf("the file is not parseable: %v\n%s", err, r["content"]) + } + if note, _ := any["generated"].(string); !strings.Contains(note, "do not edit") { + // Inside the document rather than above it, so it reaches a person without + // breaking the parse. + t.Fatalf("the file does not say it is generated: %v", any["generated"]) + } + return + } + t.Fatal("no received file") +} + +func TestAModuleTellsItsProviderWhatItNeeds(t *testing.T) { + got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + if err != nil { + t.Fatal(err) + } + out, err := Declaration(got, mcat.Rendering{}) + if err != nil { + t.Fatal(err) + } + given := received(t, out) + if len(given) != 1 || given[0].From != "board" { + t.Fatalf("the proxy was not told about board: %v", given) + } + if given[0].Values["host"] != "board" || given[0].Values["port"] != float64(8080) { + t.Fatalf("the contribution did not survive: %v", given[0].Values) + } +} + +func TestContributingToSomethingIsRequiringIt(t *testing.T) { + // Asking to be published means a publisher must exist. A module that had to say both would + // eventually say one, and the failure would be a machine where nothing serves the route. + got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(names(got), " "), "traefik") { + t.Fatalf("contributing to reverse-proxy did not bring one in: %v", names(got)) + } +} + +func TestNothingToContributeToIsRefused(t *testing.T) { + _, err := Resolve(shelf(published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + if err == nil { + t.Fatal("a module was published through a proxy that does not exist") + } + if !strings.Contains(err.Error(), "reverse-proxy") { + t.Fatalf("the refusal does not name what is missing: %v", err) + } +} + +func TestEveryPublisherOnTheMachineIsInOneFile(t *testing.T) { + got, err := Resolve(shelf(proxy(), + published("board", "board", 8080), + published("archive", "archive", 9000), + ), []string{"board", "archive"}, workstation(), mcat.World{}) + + if err != nil { + t.Fatal(err) + } + given := received(t, mustDeclare(t, got)) + if len(given) != 2 { + t.Fatalf("the proxy was told about %d of 2: %v", len(given), given) + } + // Ordered by module, because this becomes a file and a file whose lines move about looks + // changed when nothing changed — which would restart the proxy for ever. + if given[0].From != "archive" || given[1].From != "board" { + t.Fatalf("the order is not stable: %v", given) + } +} + +func TestAProviderWithNoConsumersStillGetsTheFile(t *testing.T) { + // Empty rather than absent. A provider that finds no file cannot tell "nothing asked for me" + // from "the mesh never wrote it", and those want completely different responses — the same + // rule the host follows about a service that does not exist. + got, err := Resolve(shelf(proxy()), []string{"traefik"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + if given := received(t, mustDeclare(t, got)); len(given) != 0 { + t.Fatalf("got %v", given) + } +} + +func TestTheProviderCanReloadWhenTheRoutesChange(t *testing.T) { + // A proxy that got a new route and did not reload is a route that silently does not work. + // The same fault the private network had when a peer list changed under a running interface, + // which is why the received file has a name a module can point at. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + out := mustDeclare(t, got) + for _, r := range out { + if r["type"] != "service" { + continue + } + reflects, ok := r["restart-on"].([]any) + if !ok || len(reflects) != 1 { + t.Fatalf("the proxy does not reflect anything: %v", r) + } + if reflects[0] != "traefik."+mcat.ReceivedID("reverse-proxy") { + t.Fatalf("it reflects %v, which is not the file it was given", reflects[0]) + } + return + } + t.Fatal("no service in the declaration") +} + +func TestARouteCanBeSetPerMesh(t *testing.T) { + // The hostname is exactly the kind of thing that differs between one mesh and the next. A + // module whose route could not be set would have to be edited to be reused anywhere. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + out, err := Declaration(got, mcat.Rendering{Settings: mcat.SettingsBy{ + "board": {{From: "the mesh", Values: map[string]any{"host": "dashboard"}}}, + }}) + if err != nil { + t.Fatal(err) + } + given := received(t, out) + if given[0].Values["host"] != "dashboard" { + t.Fatalf("the setting did not reach the route: %v", given[0].Values) + } + if given[0].Values["port"] != float64(8080) { + t.Fatalf("setting the host dropped the port: %v", given[0].Values) + } +} + +func TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) { + // novox/hq 04-ISSUES/173: the mail module's site name, set so its environment file could read + // it, arrived in every route it contributed. A setting overrides a key the contribution + // declares and adds none — the provider reads the contribution as a contract. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + out, err := Declaration(got, mcat.Rendering{Settings: mcat.SettingsBy{ + "board": {{From: "the mesh", Values: map[string]any{"host": "dashboard", "sitename": "Board"}}}, + }}) + if err != nil { + t.Fatal(err) + } + given := received(t, out) + if given[0].Values["host"] != "dashboard" { + t.Fatalf("the setting did not override the route's host: %v", given[0].Values) + } + if _, leaked := given[0].Values["sitename"]; leaked { + t.Fatalf("a setting the route never declared reached the proxy: %v", given[0].Values) + } +} + +func provider() mcat.Manifest { + return mcat.Manifest{Module: "postgres", Version: "1", + Provides: mcat.FromAnywhere("postgres-database"), + Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"}, + Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"}, + } +} + +func grantedTo(t *testing.T, out []map[string]any) []mcat.Contribution { + t.Helper() + for _, r := range out { + if r["path"] != "/var/lib/postgres/grants/mesh.json" { + continue + } + var parsed struct { + Given []mcat.Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil { + t.Fatal(err) + } + return parsed.Given + } + t.Fatalf("the provider was given no manifest: %v", out) + return nil +} + +func TestAConsumersOwnContributionsAreStillThere(t *testing.T) { + // Cross-node grants are merged in with this machine's own, because from the provider's side + // they are the same thing — somebody wanting something — and a provider that had to read two + // lists would read one of them. + got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), + []string{"board"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + given := received(t, mustDeclare(t, got)) + if len(given) != 1 || given[0].Node != "" { + t.Fatalf("a local contribution grew a node: %v", given) + } +} + +func TestARequireOnlyConsumerOfAParameterlessProvisionStillAsks(t *testing.T) { + // A consumer that requires a parameterless provision (one whose serves names no consumer key — + // redis-cache, amqp) contributes no payload, but it still ASKS for the provision and must be + // granted a credential. Keying "asks" on contributions alone gave its grant From="" — read as + // withdrawn — so the provider never created the account and the consumer authenticated nowhere. + r := mcat.Resolution{ + Node: "laptop", + Modules: []mcat.Manifest{{ + Module: "amqp-ping", + Requires: []string{"amqp"}, + }}, + } + values, asks, err := ContributionsFrom(r, "amqp", "amqp-ping", mcat.SettingsBy{}) + if err != nil { + t.Fatal(err) + } + if !asks { + t.Fatal("a require-only consumer was treated as not asking; its grant would be withdrawn and it would never be provisioned") + } + if values == nil { + t.Fatalf("asks is true but values is nil; expected an empty contribution, got %v", values) + } +} + +func TestAConsumerThatStoppedAskingIsWithdrawn(t *testing.T) { + // Withdrawal is how a credential is taken away. The provisioner removes what nobody asks for, + // and it can only do that if the mesh stops asking — a consumer that was unassigned would + // otherwise keep a working login for ever, and nothing would say so. + // + // A grant with no asking module is exactly that state: the mesh still holds the secret, + // because it is sealed and unusable to the mesh anyway, and the machine no longer wants it. + got, err := Resolve(shelf(provider()), []string{"postgres"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(got, mcat.Rendering{Grants: []mcat.Grant{ + {Provision: "postgres-database", Consumer: "still-here", From: "meshboard", + Values: map[string]any{"name": "meshboard"}, Sealed: "c2VhbGVk"}, + {Provision: "postgres-database", Consumer: "gone-away", Sealed: "c3RhbGU="}, + }}) + if err != nil { + t.Fatal(err) + } + + given := grantedTo(t, out) + if len(given) != 1 || given[0].Node != "still-here" { + t.Fatalf("the provider is still told about a machine that stopped asking: %v", given) + } + // And no credential is written for it either, or the provisioner would find a file for a + // consumer its manifest does not mention and have to guess what that means. + for _, r := range out { + if path, _ := r["path"].(string); strings.Contains(path, "gone-away") { + t.Fatalf("a withdrawn consumer's credential is still delivered: %v", r) + } + } +} diff --git a/internal/compose/resolution/contribution_dependencies_test.go b/internal/compose/resolution/contribution_dependencies_test.go new file mode 100644 index 0000000..10fd91d --- /dev/null +++ b/internal/compose/resolution/contribution_dependencies_test.go @@ -0,0 +1,58 @@ +package resolution + +import ( + "reflect" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) { + env := mod("theme", nil, nil, nil) + env.Environment = &mcat.Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}} + path := mod("toolchain", nil, nil, nil) + path.Environment = &mcat.Environment{Path: []mcat.PathEntry{{Entry: "/opt/x/bin"}}} + shell := mod("prompt", nil, nil, nil) + shell.Shell = []mcat.ShellCode{{For: "zsh", Slot: "first", Code: "true"}} + session := mod("wallpaper", nil, nil, nil) + session.Shell = []mcat.ShellCode{{For: "xinitrc", Slot: "normal", Code: "true"}} + resources := mod("bar", nil, nil, nil) + resources.Shell = []mcat.ShellCode{{For: "xresources", Slot: "normal", Code: "x: y"}} + empty := mod("nothing", nil, nil, nil) + empty.Environment = &mcat.Environment{} + + cases := map[string]struct { + m mcat.Manifest + want []string + }{ + "a variable": {env, []string{mcat.EnvironmentSeat}}, + "a path entry": {path, []string{mcat.EnvironmentSeat}}, + "shell code": {shell, []string{mcat.LoginShellSeat}}, + "the session's start": {session, []string{mcat.DisplayServerSeat}}, + "the session's X resources": {resources, []string{mcat.DisplayServerSeat}}, + "an empty environment": {empty, nil}, + } + for name, c := range cases { + got := DependsOn(nil, c.m) + if len(got) == 0 { + got = nil + } + if !reflect.DeepEqual(got, c.want) { + t.Errorf("%s depends on %v, want %v", name, got, c.want) + } + } +} + +func TestAHolderMeetsItsOwnContribution(t *testing.T) { + // The display server's module contributes nothing to its own seat today, but the zsh module's + // environment contributions do go to another seat: a claim meets only the seat it names. + zsh := mod("zsh", nil, nil, nil, mcat.Claim{Name: mcat.LoginShellSeat}) + zsh.Shell = []mcat.ShellCode{{For: "zsh", Slot: "normal", Code: "true"}} + zsh.Environment = &mcat.Environment{Variables: map[string]string{"EDITOR": "vim"}} + catalogue := map[string]mcat.Manifest{"zsh": zsh, + "node-env": mod("node-env", nil, nil, nil, mcat.Claim{Name: mcat.EnvironmentSeat})} + unheld := UnheldDependencies(catalogue, "laptop", []mcat.Manifest{zsh}, nil) + if len(unheld) != 1 || unheld[0].Seat != mcat.EnvironmentSeat { + t.Errorf("zsh alone: unheld %v, want only %s (its shell code is its own seat's)", unheld, mcat.EnvironmentSeat) + } +} diff --git a/internal/compose/resolution/control_records_seats.go b/internal/compose/resolution/control_records_seats.go new file mode 100644 index 0000000..d3b53f1 --- /dev/null +++ b/internal/compose/resolution/control_records_seats.go @@ -0,0 +1,52 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// controlRecordsDecision is the record that made the two seats. +const controlRecordsDecision = "novox/hq ADR 0565" + +// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15). +func wantsVerbs() []mcat.Verb { + return []mcat.Verb{ + {Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " + + "and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " + + "rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " + + "with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.", + Input: schema(map[string]string{ + "module": "one module", + "policy": "with module: roll, together, record, or default to take a person's choice back", + "why": "with policy: why, required and kept", + "compare": "\"true\": each node's own composition, by its digest, and the assigner's findings", + }, nil, "compare")}, + {Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " + + "indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " + + "the step itself is the put on the operator's warrant.", + Input: schema(map[string]string{}, nil)}, + } +} + +// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them. +func handActsVerbs() []mcat.Verb { + return []mcat.Verb{ + {Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " + + "(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " + + "runtime proves, never said by the caller. Said as act-recorded.", + Input: schema(map[string]string{ + "what": "what was done, in a line", + "why": "why it had to be done by hand", + "cause": "the cause in a word, or a condition's kind", + "condition": "the key of the condition it addressed, if any (optional)", + "verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own", + }, []string{"what", "why"})}, + {Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " + + "cause of each, and the causes recorded twice within a fortnight (a healer is wanted).", + Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause", + "who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")}, + {Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " + + "with which proofs and which answer read from the router's own record of that module's ask, never from the " + + "caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.", + Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})}, + } +} diff --git a/internal/compose/resolution/core_credentials.go b/internal/compose/resolution/core_credentials.go new file mode 100644 index 0000000..6b667f3 --- /dev/null +++ b/internal/compose/resolution/core_credentials.go @@ -0,0 +1,19 @@ +package resolution + +import ( + "encoding/json" + "slices" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// CarriesVaultSealed says whether module's own secrets are the core's credentials. +func CarriesVaultSealed(module string) bool { + return slices.Contains(mcat.CoreCredentialModules, module) +} + +// vaultSealedOf is the vault's box for one of module's own secrets, if the rendering was given one. +func renderingVaultSealedOf(with mcat.Rendering, module, name string) (json.RawMessage, bool) { + box, ok := with.VaultSealed[module][name] + return box, ok && len(box) > 0 +} diff --git a/internal/compose/resolution/core_credentials_test.go b/internal/compose/resolution/core_credentials_test.go new file mode 100644 index 0000000..6c7ffd2 --- /dev/null +++ b/internal/compose/resolution/core_credentials_test.go @@ -0,0 +1,24 @@ +package resolution + +import ( + "reflect" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func TestTheCoresOwnCredentialsAreTheBusTheProvisionerAndItsClient(t *testing.T) { + if want := []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}; !reflect.DeepEqual(mcat.CoreCredentialModules, want) { + t.Fatalf("the core's modules are %v, want %v (mesh-vault's CoreModules is the same list)", mcat.CoreCredentialModules, want) + } + for _, m := range []string{"nats", "mesh-provisioner", "mesh-provisioner-client"} { + if !CarriesVaultSealed(m) { + t.Errorf("%s is not on the list", m) + } + } + for _, m := range []string{"postgres", "mesh-vault", "mesh-controller", ""} { + if CarriesVaultSealed(m) { + t.Errorf("%s is on the list", m) + } + } +} diff --git a/internal/compose/resolution/data.go b/internal/compose/resolution/data.go new file mode 100644 index 0000000..1d81163 --- /dev/null +++ b/internal/compose/resolution/data.go @@ -0,0 +1,77 @@ +package resolution + +import ( + "fmt" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// derivedContributions is what a module's data section gives the backup holder: its backup lines and +// its items. Every item is listed, so a valuable one on a machine is measured whether or not it is +// copied; a cache is listed and not measured. +func manifestDerivedContributions(m mcat.Manifest) []mcat.SeatContribution { + items := m.DataItems() + if len(items) == 0 { + return nil + } + var lines []string + kept := map[string]bool{} + keep := func(path string) { + if !kept[path] { + kept[path] = true + lines = append(lines, "path "+path) + } + } + for _, it := range items { + if !it.BackedUp() { + continue + } + if it.Backup.IsDump() { + lines = append(lines, "run "+strings.TrimSpace(it.Backup.Dump)) + if into, ok := m.DataItem(it.Backup.Into); ok { + keep(into.Path) + } + continue + } + keep(it.Path) + } + var described []string + for _, it := range items { + covered := "-" + switch { + case it.Backup.IsDump(): + if into, ok := m.DataItem(it.Backup.Into); ok { + covered = into.Path + } + case it.BackedUp(): + covered = it.Path + } + described = append(described, fmt.Sprintf("item %s %s %s %s %s %s", it.ID, it.Class, it.Path, covered, + it.Protection(), it.MeasuredBy())) + } + var out []mcat.SeatContribution + if len(lines) > 0 { + out = append(out, mcat.SeatContribution{Seat: mcat.BackupSeat, Kind: mcat.BackupKindBackup, Content: strings.Join(lines, "\n") + "\n"}) + } + return append(out, mcat.SeatContribution{Seat: mcat.BackupSeat, Kind: mcat.BackupKindData, Content: strings.Join(described, "\n") + "\n"}) +} + +// allContributions is every contribution a module makes to a seat's holder: what it wrote, and what +// its data section derives. **One list**: a module that declares its data has its backup lines +// composed from it, and a backup line it still writes by hand is not placed — the catalogue check +// refuses it — so the holder never copies two lists that disagree. +func manifestAllContributions(m mcat.Manifest) []mcat.SeatContribution { + if m.Data == nil { + return m.Contributions + } + derived := manifestDerivedContributions(m) + out := make([]mcat.SeatContribution, 0, len(m.Contributions)+len(derived)) + for _, c := range m.Contributions { + if s, known := mcat.SeatNamed(c.Seat); known && s.Name == mcat.BackupSeat { + continue + } + out = append(out, c) + } + return append(out, derived...) +} diff --git a/internal/compose/resolution/data_placed.go b/internal/compose/resolution/data_placed.go new file mode 100644 index 0000000..d1a17ee --- /dev/null +++ b/internal/compose/resolution/data_placed.go @@ -0,0 +1,31 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// DataItems is every data item the resolution's modules declare, placed on its node with the rendering given: the text +// the node-backup holder is handed, read. A module left out of the node's declaration keeps its items, as its data is +// still on the node (ADR 0262); an item that cannot be placed is said in unplaced and left out. +func DataItems(r mcat.Resolution, with mcat.Rendering) (items map[string][]mcat.PlacedDataItem, unplaced []string, err error) { + left := LeftOut(r, with.Settings, with.Adopted) + for module, why := range with.NotPlaced { + if _, already := left[module]; !already { + left[module] = why + } + } + modules := make([]mcat.Manifest, 0, len(r.Modules)) + for _, m := range r.Modules { + if _, isLeft := left[m.Module]; isLeft { + m = backupView(m) + } + modules = append(modules, m) + } + text, unplaced, err := seatContributions(modules, mcat.Manifest{}, mcat.BackupSeat+":"+mcat.BackupKindData, with, + machineFacts(r, with.Names, with.MeshRange), r.Capabilities) + if err != nil { + return nil, unplaced, err + } + items, err = mcat.ParseDataItems(text) + return items, unplaced, err +} diff --git a/internal/compose/resolution/data_test.go b/internal/compose/resolution/data_test.go new file mode 100644 index 0000000..29731f7 --- /dev/null +++ b/internal/compose/resolution/data_test.go @@ -0,0 +1,16 @@ +package resolution + +import ( + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func mustParse(t *testing.T, raw string) mcat.Manifest { + t.Helper() + m, err := decodeForTest([]byte(raw)) + if err != nil { + t.Fatalf("refused: %v", err) + } + return m +} diff --git a/internal/compose/resolution/declaration.go b/internal/compose/resolution/declaration.go new file mode 100644 index 0000000..3a774e3 --- /dev/null +++ b/internal/compose/resolution/declaration.go @@ -0,0 +1,2522 @@ +package resolution + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// rootInto composes a user's `root` for the node-engine: taken away when it asserts nothing (empty — a +// machine where agents run as the operator) or when the engine is older than the field and parses +// strictly; kept as "never" otherwise. +func rootInto(resource map[string]any, with mcat.Rendering) { + if resource["type"] != "user" { + return + } + value, has := resource[mcat.RootField] + if !has { + return + } + if s, _ := value.(string); s == "" || !with.JudgesRoot { + delete(resource, mcat.RootField) + } +} + +// machinePort is where a module's port lives on this machine, or the port itself when the mesh has +// not been asked. Unassigned is not an error here: a module with no `listens` never needed one, +// and a caller composing a declaration without a store still gets something coherent. +func renderingMachinePort(r mcat.Rendering, module string, wanted int) int { + if at, given := r.Given[module][wanted]; given { + return at + } + if at, known := r.Ports[module][wanted]; known { + return at + } + return wanted +} + +// Declaration is everything the resolved modules put on the node, with settings applied. +// +// Resource identities are prefixed with the module they came from. Two modules may reasonably +// both call something "config", and without this the second would silently replace the first — +// the node applying one of them and reporting success. +func Declaration(r mcat.Resolution, with mcat.Rendering) ([]map[string]any, error) { + composed, err := Compose(r, with) + if err != nil { + return nil, err + } + return composed.Resources, nil +} + +// NotMadeError is a module's own secret the composition was given no value for (novox/hq issue 275): +// typed, so that a caller can tell "a send would make this, and none has yet" from a composition that +// fails for any other reason without reading the words. +type NotMadeError struct { + Module, Name string +} + +func (e *NotMadeError) Error() string { + return fmt.Sprintf("%s needs a secret called %q and none was made for it", e.Module, e.Name) +} + +// LeftOut is which of this machine's modules a declaration composed with these settings leaves +// out, and why (novox/hq ADR 0163, rule 6): each whose stored settings its definition can no longer +// compose. Empty when every module composes. The same judgement SetSettings makes before storing. +func LeftOut(r mcat.Resolution, settings mcat.SettingsBy, adopted bool) map[string]string { + out := map[string]string{} + for module, why := range LeftOutBecause(r, settings, adopted) { + out[module] = why.Error() + } + return out +} + +// LeftOutBecause is LeftOut with each reason as the error it was, so a reader can tell a setting nobody +// gave (an *UnsetSettingError) from any other cause and say it as the operator's to give (novox/hq issue +// 380). An UnreadManifestError for a stored manifest this controller cannot read whole. +func LeftOutBecause(r mcat.Resolution, settings mcat.SettingsBy, adopted bool) map[string]error { + out := map[string]error{} + for _, m := range r.Modules { + if why := UnknownFieldReason(m); why != "" { + out[m.Module] = &UnreadManifestError{Module: m.Module, said: why} + continue + } + if err := JudgeSettings(m, settings[m.Module], adopted); err != nil { + out[m.Module] = err + } + } + return out +} + +// UnreadManifestError is a module left out because its stored manifest has a key this controller does not know +// (novox/hq ADR 0262): said once for the whole mesh, by the catalogue's own condition, not per machine. +type UnreadManifestError struct { + Module string + said string +} + +func (e *UnreadManifestError) Error() string { return e.said } + +// Compose is Declaration with the owner of every resource said. +func Compose(r mcat.Resolution, with mcat.Rendering) (mcat.Composed, error) { + owner := map[string]string{} + received := map[string]map[string][]mcat.Contribution{} + leftOut := map[string]string{} + var foreseen, unplaced, handedOnly []string + handed := &handedFiles{plans: map[string]mcat.Plan{}} + resources, err := resolutionCompose(r, with, owner, received, leftOut, &foreseen, &unplaced, &handedOnly, handed) + if err != nil { + return mcat.Composed{}, err + } + sort.Strings(foreseen) + if with.BusMembership != "" { + // The machine's own, not any module's: how it reaches the mesh from now on. Sealed like a + // secret and placed where the host looks for exactly this (design 28, task 5.2). + resources = append(resources, map[string]any{ + "id": BusMembershipID(), "type": "file", "path": mcat.BusMembershipPath, + "sealed": with.BusMembership, "mode": "0600", + }) + } + sort.Strings(unplaced) + sort.Strings(handedOnly) + out := mcat.Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut, + Foreseen: foreseen, Unplaced: unplaced, HandedOnly: handedOnly, Filled: handed.paths} + if plans := handed.finished(r.Needs); len(plans) > 0 { + out.Plans = plans + } + return out, nil +} + +// BusMembershipID names the resource carrying a machine's membership for the new bus, and +// BusMembershipPath is where the host reads it — the same constant on both sides. +func BusMembershipID() string { return "bus-membership" } + +func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string]string, + received map[string]map[string][]mcat.Contribution, leftOut map[string]string, + foreseen, unplaced, handedOnly *[]string, handed *handedFiles) ([]map[string]any, error) { + // **A setting is judged where it is stored, and an impossible one costs a module, not a + // machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes + // this module uncomposable; it is left out of the declaration — its held things kept, its + // containers untouched, the machine told so by name — and the machine is told everything else. + // Before placing, because a placement is a setting too. + left := LeftOut(r, with.Settings, with.Adopted) + kept := make([]mcat.Manifest, 0, len(r.Modules)) + var stillBackedUp []mcat.Manifest + for module, why := range with.NotPlaced { + if _, already := left[module]; !already { + left[module] = why + } + } + for _, m := range r.Modules { + if why, isLeft := left[m.Module]; isLeft { + if leftOut != nil { + leftOut[m.Module] = why + } + // **Its data is still copied** (novox/hq ADR 0262): a module left out runs nothing new, and + // the data it already holds on the machine is the reason to keep copying it. Only its data, + // as the backup holder's lines are derived from it, and the directories they name. + stillBackedUp = append(stillBackedUp, backupView(m)) + continue + } + kept = append(kept, m) + } + r.Modules = kept + + // Every manifest is placed first (novox/hq ADR 0112): the maps naming where its bindings, + // credentials and contributions land are resolved against this node's directories, so every + // reader below — the binding files, the sealed secrets, the grant paths a contribution + // names — sees a concrete place and none learns the vocabulary. + // Into a fresh slice, never the caller's: one resolution may compose for many nodes, and a + // slice element written in place would carry the first node's places into the second's. + placed := make([]mcat.Manifest, len(r.Modules)) + for i, m := range r.Modules { + var err error + if placed[i], err = placedManifest(m, with); err != nil { + return nil, err + } + } + r.Modules = placed + + // Where each provision's credentials land, so a contribution can name the file rather than + // carry a value the mesh does not have. + directories := map[string]string{} + for _, m := range r.Modules { + for provision, where := range m.Grants { + directories[provision] = where + } + } + given, err := resolutionContributions(r, with.Settings, with.Grants, directories) + if err != nil { + return nil, err + } + + // A workload on THIS machine contributes the port it declared, and the machine may have + // published it somewhere else (novox/hq ADR 0066). The mirror of the redirect below: 038 fixed + // what a consumer is TOLD about a provider, and this is what a workload TELLS the provider about + // itself — gitea declaring 3000, published as 20000:3000, and the co-located proxy dialling 3000, + // where nothing listens, for every request. + // + // **The contributing module's assignment, never the provider's.** The port belongs to the + // workload; using the map of whatever answers the requirement would move a route to wherever the + // proxy happens to be published. A contribution carried here from another machine is left exactly + // as it is — its port is that machine's to assign, and this map knows nothing about it. + for provision := range given { + for i := range given[provision] { + said := &given[provision][i] + if said.Node != "" && said.Node != r.Node { + continue + } + said.Values = atMachinePort(said.Values, said.From, with.Ports) + } + } + + // A provider answered on this same machine never passed through the walk that works out what a + // provider on ANOTHER machine serves (novox/hq 04-ISSUES/038, ADR 0066). That walk does two + // things — it derives the served facts with the provider node's port assignments, and it settles + // them with that node's settings layers — and the same-node paths (resolve.go's servedHere, and + // here() below) did neither, because both run while resolving, before either is known. + // + // The port was the first half to be noticed (038). The second half is worse and quieter: a served + // value the OPERATOR supplied — a certificate authority's root, which is the one kind of value a + // manifest cannot carry, because it differs on every mesh — arrived as the manifest's empty + // default. A proxy then wrote an empty CA bundle, fell back to the system trust store, could not + // verify the internal authority, and stopped issuing with nothing saying why. + // + // So it is re-derived here, in full, exactly as the cross-node path derives it. **After the + // closure is known**, which also removes an order dependence: the resolver built these needs + // mid-walk from whichever modules had been chosen by then, so what a co-located binding carried + // depended on the order somebody happened to assign things in. + for i := range r.Needs { + if r.Needs[i].ByRecord || r.Needs[i].From != r.Node { + continue + } + serves, answered, err := resolutionServedOnThisMachine(r, r.Needs[i].Name, with) + if err != nil { + return nil, err + } + if answered { + r.Needs[i].Serves = serves + } + } + + // Once, from every module's listens -- not per module. A module receiving only its own ports + // would write a rule set that closed every other module on the machine. Each module's per-node + // exposure settings override its listens' source first (novox/hq ADR 0046). + rules, err := Rules(r, with) + if err != nil { + return nil, err + } + // **A machine that has not said which links face outside is sent no filter** (novox/hq ADR + // 0140). The whole chain is written around those links: with none, the rule that lets this + // machine's own guests keep working would name an empty set, which nftables refuses, and a rule + // set that does not load is a machine filtering nothing while its unit reports success. Refused + // here, where a person reads it, rather than on the machine — and the machine keeps the filter + // it already has. + if filters := resolutionFiltersHere(r); filters != "" && len(with.OutwardLinks) == 0 { + return nil, fmt.Errorf( + "%s cannot be sent a filter: it has not reported which of its links face outside, and "+ + "every rule in the chain is written around them. It reports that on each apply; "+ + "`node show %s` says whether it has. Until then %s is not sent, and the machine "+ + "keeps the filter it has", r.Node, r.Node, filters) + } + filtering := AsNftablesWithLocal(rules, with.Mesh, r.PublicDomain != "", with.Foundation, + with.OutwardLinks, with.TunnelInterface, with.LocalNetworks) + + // **A variable two modules set is refused whether or not anything places it** (novox/hq ADR + // 0203 §5): the account has one environment, and a machine whose holder arrives later should not + // be the moment two modules are found to disagree about it. + if err := variablesSetOnce(r.Modules); err != nil { + return nil, err + } + + generated, err := resolutionGeneratedHere(r, with) + if err != nil { + return nil, err + } + + var out []map[string]any + for _, m := range r.Modules { + if with.Adopted && m.Filtering != nil { + // Nothing of a module that loads a filter, on an adopted node: its table would drop + // by default and hold accepts, and the found firewall stays in force. Every resource, + // not only the rule set — its service must not run, and a node returned to adopted + // stops it by the ordinary removal of what is no longer declared. + continue + } + resources := m.Resources + + // What the mesh computes for this module goes FIRST, before the module's own resources. + // + // **Order is stated, not derived — the host does not sort** (novox/hq ADR 0005), so + // whatever the mesh writes down is the order a machine applies. A module's service or + // container routinely depends on one of these files; nothing here ever depends on a + // module's resources, because none of it is computed from them. + // + // Appended, this was wrong in a way that only showed on the first apply and then healed: + // the service started before its certificate or its rule set existed, failed, and the next + // reconcile fixed it. A fault that repairs itself on the second attempt is worse than one + // that does not, because what gets remembered is that it works. + var first []map[string]any + if f := m.Filtering; f != nil { + first = append(first, map[string]any{ + "id": mcat.FilteringID(), "type": "file", "path": f.Into, + "content": filtering, "mode": "0600", + }) + } + // The node's fail2ban jails, composed from every module it runs (novox/hq to-be 31), written + // where the intrusion-prevention holder owns them. Like the rule set above: gathered from all + // modules, written by the one that holds the role. + if j := m.Jailing; j != nil { + first = append(first, jailsInto(r.Modules, j)...) + } + if c := m.Certificate; c != nil { + if with.Certificate == "" { + // Asked for and not issued. Refused rather than skipped: a module that serves TLS + // with no certificate does not start, and the reason is somewhere else entirely. + return nil, fmt.Errorf( + "%s wants a certificate for this machine and none was issued", m.Module) + } + first = append(first, map[string]any{ + "id": mcat.CertificateID(), "type": "file", "path": c.Into, + // Public. It travels in the open like any other file, because it is a statement + // about a key rather than the key. + "content": with.Certificate, "mode": "0644", + }) + if c.Authority != "" { + first = append(first, map[string]any{ + "id": mcat.AuthorityID(), "type": "file", "path": c.Authority, + "content": with.Authority, "mode": "0644", + }) + } + } + if m.BusUsers != "" { + // **The claim authorises it, not the field.** This file holds every user's password + // hash, so a module that could ask for it could read every credential on the bus. + // Checked from this manifest alone, which is the cheapest check there is: whether some + // other module also claims the seat is resolution's business elsewhere, and one holder + // mesh-wide is already guaranteed. + if !m.ClaimsSeat("mesh-broker") { + return nil, fmt.Errorf( + "%s asks for the mesh's user list and does not claim mesh-broker. That file "+ + "holds every user's password hash, so the seat is what authorises it", + m.Module) + } + if with.BusUsers == "" { + // Asked for and not composed. Refused rather than skipped, for the reason a + // certificate is: a bus with no user list refuses every connection in the mesh, and + // an empty file would look like a configuration problem on the machine. + return nil, fmt.Errorf( + "%s holds mesh-broker and the mesh composed no user list, so the bus would "+ + "refuse every connection", m.Module) + } + first = append(first, map[string]any{ + "id": mcat.BusUsersID(), "type": "file", "path": m.BusUsers, + "content": with.BusUsers, + // Readable by the server and nothing else. Hashes rather than passwords, so this is + // not a set of working credentials — but a list of every user in the mesh is worth + // keeping to the one process that needs it. + "mode": "0600", + }) + } + // **A secret family's members, as given** (novox/hq ADR 0283): one file each at the family's path, and + // nothing for a member not given — the mesh never makes one, and the module says it waits for it. + for _, name := range sortedKeys(with.Needed[m.Module]) { + own, family, ok := m.OwnSecrets.Lookup(name) + if !ok || family == "" || with.Needed[m.Module][name] == "" { + continue + } + // A per-secret owner (novox/hq ADR 0436 §1) stands in for the module's secrets-owner, when + // the manifest named one for this secret (or the family it belongs to). + owner := m.SecretsOwner + if own.Owner != "" { + owner = own.Owner + } + first = append(first, ownedBy(owner, map[string]any{ + "id": mcat.NeedID(name), "type": "file", "path": own.Path, "sealed": with.Needed[m.Module][name], + })) + } + // **A handed module's own secrets are the vault's** (design 57 V4, issue 647): each file holding one is + // declared placed and without a value, filled by the machine's provisioner client from the vault; the + // controller makes and seals none of them here. + handsSecrets := Handing(with, m) + for _, name := range sortedKeys(m.OwnSecrets.Plain()) { + if handsSecrets { + owner := resolutionOwnSecretOwner(r, m, name) + first = append(first, ownedBy(owner, map[string]any{ + "id": mcat.NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, mcat.HandedSecretField: name, + })) + continue + } + // **A core credential is the vault's box** (ADR 0349 §16, issue 755): carried as the vault sealed it to this + // machine, which the controller cannot open; the controller's own sealed value is not sent with it. + if box, ok := renderingVaultSealedOf(with, m.Module, name); ok { + if string(box) == string(mcat.ForeseenVaultBox) { + *foreseen = append(*foreseen, m.Module+"/"+name) + } + owner := resolutionOwnSecretOwner(r, m, name) + first = append(first, ownedBy(owner, map[string]any{ + "id": mcat.NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, mcat.VaultSealedField: box, + })) + continue + } + sealed := with.Needed[m.Module][name] + if sealed == "" && with.Foreseen[m.Module][name] { + // Not made, and the next send makes it: composed with a stand-in so that whatever + // else this composition would refuse is still found (novox/hq issue 275). + sealed = mcat.ForeseenSealed + *foreseen = append(*foreseen, m.Module+"/"+name) + } + if sealed == "" && m.OwnSecrets[name].IssuedBy == mcat.IssuedOutside { + // **Not given, and the mesh never makes it** (novox/hq issue 401, ADR 0228): no file, as for a + // family's member (ADR 0283), so the module reads its absence as not given. + continue + } + if sealed == "" { + // Declared and not made. Refused rather than skipped: a module whose own + // credential is silently absent starts, fails to authenticate, and the reason is + // three layers away from the machine reporting it. + return nil, &NotMadeError{Module: m.Module, Name: name} + } + // The runtime's credential belongs to the account the runtime runs as (novox/hq ADR 0175, + // to-be 38 WP3): its process is composed `user: ` where the node has one, and a + // root-owned 0600 file is one that process cannot read. Composed here rather than said in + // the manifest, because a manifest cannot say ${machine:account} safely — a node with no + // account has nothing to resolve it to, and then the runtime runs as root and the file + // stays root's. + first = append(first, ownedBy(resolutionOwnSecretOwner(r, m, name), map[string]any{ + "id": mcat.NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed, + })) + } + // This module's tools bundles, where the machine runs the node's tool runtime (novox/hq + // ADR 0175, to-be 38 WP2). Mesh-computed like everything above it, and before the module's + // own resources for the same reason: the runtime's process names the files inside these + // and is restarted when one changes, so they are on the machine before it is. + if resolutionRuntimeHere(r) { + first = append(first, bundleArchives(m)...) + } + // Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before + // the module's own resources, and so before the container that mounts them: the host must + // find each present — refusing clearly if the operator has not provided it — before it + // starts anything that depends on it. The mesh creates, chowns and reconciles none of it; + // an `access` resource says only *this path must exist, and this module reaches it*. + // Where each is on THIS machine is the assignment's (novox/hq issue 153): placed by id + // where the operator said, the definition's default otherwise, refused where neither. + accesses, accessPaths, err := accessesFor(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } + for _, a := range accesses { + first = append(first, map[string]any{ + "id": mcat.AccessID(a.Path), "type": "access", "path": a.Path, "mode": a.Mode, + }) + } + for _, to := range m.SecretRequirements() { + for _, file := range m.SecretFiles(to) { + var found *mcat.Needed + for i, n := range r.Needs { + // **This module's need, not the provision's** (novox/hq 04-ISSUES/022). Matching + // on the name alone, every consumer of a provision took whichever credential + // happened to be last in the list — so on a node with two of them, one module + // would be given the other's password and fail to authenticate with a valid + // credential belonging to somebody else. And this file's local name, where the + // module keeps several (ADR 0094). + if n.Name == to && n.For == m.Module && n.Local == file.Local { + found = &r.Needs[i] + } + } + // **A credential the vault keeps for this holder alone** (issue 757): where the module is handed, its + // file is declared placed and without a value, and the machine's client fills it from the vault. + if found != nil && found.IssuedToHolder && Handing(with, m) { + first = append(first, ownedBy(m.SecretsOwner, map[string]any{ + "id": mcat.SecretID(mcat.SecretLocal(to, file.Local)), "type": "file", "path": file.Path, mcat.HandedProvisionField: mcat.PlanProvision{Name: mcat.SecretLocal(to, file.Local), Provision: to, + Local: file.Local, Provider: found.From, ProviderModule: found.Module}, + })) + continue + } + if found != nil && found.ByRecord && found.Sealed == "" && !found.Manager { + // Answered by a record whose key has not been supplied since this consumer was + // put on it. **Refused, not skipped.** The mesh discarded the plaintext when the + // key was accepted and cannot seal another, so a machine that resolved cleanly + // would receive no file at all and fail at whatever tried to read it — which is + // the outcome ADR 0024 exists to avoid, arrived at politely. + // + // The manager holder is the one exception (novox/hq ADR 0050): an empty refresh token + // is a licence whose manager has not adopted one yet, a real waiting state rather than + // a lost key. It falls through to the skip below — its bound facts (carrying the + // manager's public key) are still delivered, which is what adoption needs to seal the + // first refresh token. + return nil, fmt.Errorf( + "%s on this machine uses the licence %q and no key has been sealed to it. "+ + "The mesh cannot make one; supply it again with `licence key %s`", + m.Module, found.From, found.From) + } + if found == nil || found.Sealed == "" { + // Answered on this machine, or answered by a node the mesh could not seal to. + // Nothing to write either way, and writing an empty credential file would be + // worse than none: something would read it and fail authenticating. + continue + } + first = append(first, ownedBy(m.SecretsOwner, map[string]any{ + "id": mcat.SecretID(mcat.SecretLocal(to, file.Local)), "type": "file", "path": file.Path, + "sealed": found.Sealed, + })) + } + } + for _, to := range sortedKeys(m.Grants) { + for _, g := range with.Grants { + if g.Provision != to { + continue + } + if g.From == "" { + // Nothing on that machine asks for this any more. Skipped here rather than + // where grants are gathered, so the rule holds whoever gathers them. + // + // **This is how a credential is withdrawn.** The provisioner removes what + // nobody asks for, and it can only do that if the mesh stops asking — a + // consumer that was unassigned would otherwise keep a working login for ever, + // and nothing would say so. + continue + } + if g.Sealed == "" { + // Its offer presents no credential (form none, issue 1126): nothing to write, and an empty credential + // file would be read as one that failed. Who asked still reaches the provider in its contributions. + continue + } + first = append(first, ownedBy(resolutionProvisionsAs(r, m), map[string]any{ + // One file per holder — the consumer's module with its local name after it + // where it keeps several (ADR 0094); the lab found two files with one id. + "id": mcat.GrantID(to, g.Consumer+"."+holderAs(g.From, g.Local)), + "type": "file", + "path": grantPath(m.Grants[to], g.Consumer, holderAs(g.From, g.Local)), + "sealed": g.Sealed, + })) + } + } + for _, to := range sortedKeys(m.Binds) { + var found *mcat.Needed + for i, n := range r.Needs { + if n.Name == to && n.For == m.Module { + found = &r.Needs[i] + } + } + if found == nil { + // Answered on this same machine rather than from elsewhere in the mesh. + // + // **Still written.** It used to be skipped, reasoning that "it is on this node" + // is a fact nobody needs — and that is true of the *location* and false of + // everything beside it. A binding also carries what the provider said a consumer + // must know, which is the port; a consumer cannot invent that, and got an absent + // file with no explanation. A build machine sharing a node with the registry it + // pushes to sat in a loop saying it could not read its own binding. + here, err := here(r, to, with) + if err != nil { + return nil, err + } + if here == nil { + // Nothing in this node's set offers it either, so there is genuinely nothing + // to say. Resolution has already refused anything unanswerable, so this is a + // requirement met by the module itself. + continue + } + found = here + } + own, err := resolutionOwnNames(r, m, to, with.Settings[m.Module]) + if err != nil { + return nil, err + } + as := mcat.ConsumerIdentity(r.Node, mcat.IdentitySource(m.Slug, m.Module)) + // What the provider derives for THIS consumer, filled here where the consumer is + // known (novox/hq ADR 0202). The same fill knownFor does below, so the binding file + // and the module's `${bound:…}` substitutions cannot say different things. + told := *found + told.Serves, err = ServedTo(told.Serves, as) + if err != nil { + return nil, fmt.Errorf("%s is told about %s: %w", m.Module, to, err) + } + file, err := boundFile(told, m.Binds[to], as, own) + if err != nil { + return nil, err + } + first = append(first, file) + } + for _, to := range sortedKeys(m.Receives) { + file, err := receivedFile(to, m.Receives[to], given[to]) + if err != nil { + return nil, err + } + first = append(first, file) + if received[m.Module] == nil { + received[m.Module] = map[string][]mcat.Contribution{} + } + // Empty rather than absent when nobody contributed, for the reason the file is + // written empty: "nothing asked" and "never told" want different responses. + received[m.Module][to] = append([]mcat.Contribution{}, given[to]...) + } + if m.Keeps != "" && with.Kept != nil { + file, err := keptFile(m.Keeps, with.Kept) + if err != nil { + return nil, err + } + // Read by the module as its secrets' owner when it names one: the vault, as its own account (issue 723). + first = append(first, ownedBy(m.SecretsOwner, file)) + } else if runs, ok := with.KeptRuns[m.Module]; m.Keeps != "" && ok { + file := make(map[string]any, len(runs)) + for k, v := range runs { + file[k] = v + } + file["id"] = KeptID() + delete(file, "module") + first = append(first, file) + } + if m.Computed != "" { + mine, part := generated[m.Module] + if !part { + // Assigned, and not yet part of what this generates. Nothing to put on the + // machine, which is different from an error: a node given the network module + // before it has an address is in exactly that state, briefly. + continue + } + resources = mine + } + + // Now, and not before: a module whose resources are computed replaces them wholesale, and + // merging earlier would throw away the files it still needs. + // + // **Except the module's own accounts, which go before even those** (novox/hq issue 213). What + // the mesh computes may belong to one: a module whose code runs as an account it declares has + // its secrets written owned by that account, and a file given to a user the machine does not + // have yet fails — so on the first apply the secrets were refused, the process started without + // them, and the second apply healed it, which is the fault the paragraph above describes. + // An account depends on nothing the mesh computes. + accounts, rest := accountsFirst(resources) + resources = append(append(accounts, first...), rest...) + // **A file delivered only while its optional requirement is bound is left out while it is + // absent** (novox/hq ADR 0330 §5), and the field is dropped from the rest: the node-engine + // parses strictly and knows no such field. + absent := AbsentFor(r, m.Module) + resources = withoutAbsent(resources, absent) + + // No container is given the mesh's names (novox/hq ADR 0148). It used to be: every + // container got the whole roster as `--add-host` entries at creation, and a name that + // moved afterwards was wrong inside it for as long as it ran (issues 109, 135) — and once + // the roster was made part of a container's identity so that could be caught, one name + // moving anywhere replaced every container in the mesh (issue 151). A container resolves a + // mesh name through its machine's resolver at the moment it asks, which the runtime is + // told once per machine, as a file, by the resolver's own module. The names a module + // declares for itself are its own and stay exactly as written: they are part of what the + // module is, and the mesh does not know what they mean. + // What this module may name from inside one of its own files. Gathered once per module + // rather than per file, because it is a fact about the module. + sealed, err := sealedFor(m, r.Needs, with) + if err != nil { + return nil, err + } + // And what its bindings say, for the half of a connection that is not secret. + known, err := knownFor(m, r.Needs, r.Node) + if err != nil { + return nil, err + } + // A requirement answered on this same machine is not in r.Needs — its binding file is + // written from `here` (above) — and so `${bound:…}` could not name it, though the file + // beside it said the same facts. Filled from the same answer, so the two cannot disagree. + for _, want := range m.Wants() { + if _, has := known[want]; has { + continue + } + answered, err := here(r, want, with) + if err != nil { + return nil, err + } + if answered == nil { + continue + } + local := *answered + local.For = m.Module + here, err := knownFor(m, []mcat.Needed{local}, r.Node) + if err != nil { + return nil, err + } + for provision, values := range here { + known[provision] = values + } + } + // And the providing machine's private address, beside its name (novox/hq ADR 0194). A name is + // what nearly every consumer wants; the one that cannot use it is a machine's resolver + // configuration, which must reach the resolver before it can resolve anything — the resolver's + // own name included. Absent when the machine has no address yet, so a file naming it is refused + // rather than written with a blank where an address belongs. + for _, values := range known { + if address := with.Machines[values["at"]]; address != "" { + values["address"] = address + } + } + // And what the module is called through each requirement it contributes to (novox/hq + // 04-ISSUES/122) — the same composition its binding file carries. + for provision, values := range known { + own, err := resolutionOwnNames(r, m, provision, with.Settings[m.Module]) + if err != nil { + return nil, err + } + withOwnNames(values, own) + } + // And where this node places the directories the module declared without a path + // (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource. + // — and, on an adopted machine, where the assignment says they already are, with the + // owner the data already has (novox/hq issue 153). Malformed placements are refused here. + placed, err := mcat.Places(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } + dirs := dirsFor(m, with) + // And the machine underneath, which no binding of its own can tell it. + thisMachine := machineFacts(r, with.Names, with.MeshRange) + + // **A definition that already holds the answer transcribed it** (novox/hq ADR 0202). + // Judged over what the module itself declares, and before anything is substituted: the + // mesh's own generated files — the binding, the contributions — legitimately carry the + // derived value, and after substitution so does every consumer's file, so this is the one + // moment the two can be told apart. + for _, own := range m.Resources { + if err := notTranscribed(own, known, m.Module); err != nil { + return nil, err + } + } + // **And every placeholder no pass fills where it stands is refused** (novox/hq issue 231): + // judged here, over the definition as written and before any pass, by the function the + // catalogue check runs — so a manifest registered by an older binary is judged too, and + // what a setting or a binding later puts into a file is its software's text, never swept. + for _, own := range m.Resources { + if problems := unconsumedPlaceholders(m.Module, own); len(problems) > 0 { + return nil, fmt.Errorf("%s", problems[0]) + } + } + + // Which of this module's files carry a secret, for the rule that a container may not read + // one of them as its environment without saying so (ADR 0086, issue 041). + secretFiles := secretFilesOf(resources) + + // Which of this module's resources its preparation runs before, if it prepares anything. + prepareBefore := preparationTarget(m) + + // Which found networks this machine's setting keeps for each of its containers (novox/hq + // ADR 0163, rule 4); judged above, so an invalid one is not here. + keptNetworks, _ := KeptNetworks(m, with.Settings[m.Module], with.Adopted) + + // **A moved module's templated files are the provisioner's** (novox/hq ADR 0349 §4, §6, design 57 P6): on a + // machine that can take them, each is declared placed and without its text, and no pass below fills it. + handing, err := handed.begin(r.Node, m, dirs, accessPaths, thisMachine, with, r.Capabilities, r.Needs, + backupPlacer{append(append([]mcat.Manifest(nil), r.Modules...), stillBackedUp...), with, thisMachine, r.Capabilities, + unplaced}) + if err != nil { + return nil, err + } + // **A plan for what the seat keys of it all the same** (catalogue.Keyed, issue 774): a module not handed whose + // provider's helper answers a provision of it, its route the commonest, has that provision in a plan of its own, + // so the seat keys it and the helper hears it. + keyedOnly := handing == nil && handed.keyed(r.Node, m, r.Needs) + if _, ok := handed.plans[m.Module]; ok && (handing != nil || keyedOnly) { + for _, to := range requirementsContributedTo(m) { + own, err := resolutionOwnNames(r, m, to, with.Settings[m.Module]) + if err != nil { + return nil, err + } + handed.name(m.Module, to, own) + contributed, err := resolutionContributedOf(r, m, to, with) + if err != nil { + return nil, err + } + handed.contribute(m.Module, to, contributed) + } + } + // A handed collector is filled from items alone: each old form its collections would have held is said + // (design 57 L1). + if plan, ok := handed.plans[m.Module]; ok && handing != nil && len(plan.Collections) > 0 { + *unplaced = append(*unplaced, oldFormsLeftOut(append(append([]mcat.Manifest(nil), r.Modules...), + stillBackedUp...), m, plan.Collections)...) + } + for _, unsettled := range resources { + if pr, ok := unsettled[mcat.HandedProvisionField].(mcat.PlanProvision); ok { + if handing == nil && m.Stubbed { + continue // held: its credential file is what runs of it, left out (issue 1126) + } + if handing == nil { + return nil, fmt.Errorf("%s: its credential of %s is handed to the provisioner and the module is "+ + "not: nothing would fill it", m.Module, pr.Provision) + } + marked := map[string]any{} + for k, v := range unsettled { + marked[k] = v + } + delete(marked, mcat.HandedProvisionField) + marked[mcat.HandedSecretField] = pr.Name + file, sec, err := handedSecretFile(marked, m, dirs, accessPaths, thisMachine, placed) + if err != nil { + return nil, err + } + file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"]) + owner[fmt.Sprint(file["id"])] = m.Module + pr.Path = sec.Path + if err := handed.addProvisionFile(m.Module, pr); err != nil { + return nil, err + } + out = append(out, file) + continue + } + if unsettled[mcat.HandedSecretField] != nil { + if handing == nil && m.Stubbed { + continue // held: its own secret's file is what runs of it, left out (issue 1126) + } + if handing == nil { + return nil, fmt.Errorf("%s: its own secret %v is handed to the provisioner and the module is "+ + "not: nothing would fill it", m.Module, unsettled[mcat.HandedSecretField]) + } + file, sec, err := handedSecretFile(unsettled, m, dirs, accessPaths, thisMachine, placed) + if err != nil { + return nil, err + } + file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"]) + owner[fmt.Sprint(file["id"])] = m.Module + if err := handed.addSecretFile(m.Module, sec); err != nil { + return nil, err + } + out = append(out, file) + continue + } + if handing != nil && fmt.Sprint(unsettled["type"]) == "file" { + if text, _ := unsettled["content"].(string); len(secretsUsed(text)) > 0 && + !handing[fmt.Sprint(unsettled["id"])] { + return nil, fmt.Errorf("%s: its file %v names a secret in its text, and a handed module's "+ + "secrets reach only the files its provisioner client fills: keep it in a template "+ + "(design 57 V4)", m.Module, unsettled["id"]) + } + } + if handing[fmt.Sprint(unsettled["id"])] { + file, line, err := handedFile(unsettled, m, dirs, accessPaths, thisMachine, placed, with) + if err != nil { + return nil, err + } + file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"]) + owner[fmt.Sprint(file["id"])] = m.Module + if err := handed.add(m.Module, line); err != nil { + return nil, err + } + out = append(out, file) + continue + } + if fmt.Sprint(unsettled["type"]) == "file" { + if _, left := consumersHandedOnly(m, unsettled, with); left { + *handedOnly = append(*handedOnly, consumersLeftOutLine(m, unsettled)) + continue + } + } + resource, err := ApplySettings(unsettled, with.Settings[m.Module]) + if err != nil { + return nil, err + } + copied := map[string]any{} + for k, v := range resource { + copied[k] = v + } + if networks, keeps := keptNetworks[fmt.Sprint(copied["id"])]; keeps { + // The container also joins the found network the setting names, so a neighbour + // that resolves it there keeps resolving it. Passed to the host as its own field, + // which it joins after the container is made. + joins := make([]any, 0, len(networks)) + for _, n := range networks { + joins = append(joins, n) + } + copied["networks"] = joins + } + if err := refuseSecretsInEnvironment(copied, secretFiles, m.Module); err != nil { + return nil, err + } + // Said in the catalogue, not on the machine: the host parses strictly and knows no + // such field, and the reason is for a reader of the manifest. + delete(copied, mcat.SecretsInEnvironment) + delete(copied, mcat.NamesOnPurpose) + delete(copied, mcat.TrustedField) + // A process's bus stays controller-side (novox/hq ADR 0436 §1): never composed onto the + // node-engine's process resource, whose wire declaration is mesh-host's own, vendored, with a + // strict decoder that refuses an unknown key. + delete(copied, mcat.ProcessBusField) + // **An operator's value, from the assignment** (novox/hq ADR 0112, ADR 0155): what a + // definition may not carry because it is true of one installation only. Filled from + // the same layers a mergeable file takes, and refused when no layer set it. + if err := unsetInConfigWords(settingInto(copied, WithDefaults(m, with.Settings[m.Module]), m.Module), m); err != nil { + return nil, err + } + // **Placed before anything reads a path.** A pathless directory receives the path + // this node resolves for it, and every ${dir:…} — in paths, mounts, content and + // environment — becomes that path, so what follows sees only concrete places + // (novox/hq ADR 0112). The host receives paths exactly as it always has. + if err := dirInto(copied, dirs, m.Module); err != nil { + return nil, err + } + // The operator's data the same way: ${access:…} becomes where this node keeps it, + // and a placed directory takes the owner the assignment said (issue 153). + if err := accessInto(copied, accessPaths, m.Module); err != nil { + return nil, err + } + ownerInto(copied, placed) + // **After settings, and that is the whole reason it is here.** A module's file + // content is where a setting lands, so a placeholder may only exist once the setting + // has been put in — filling secrets first would look at content that is not yet what + // the machine receives. + if err := intoFile(copied, sealed, outsideNotGiven(m, with.Needed[m.Module]), m.Module); err != nil { + return nil, err + } + // **Substituted here, not on the machine.** A bound value is not secret — the mesh + // holds it in the clear — so there is nothing for the host to be the only witness of, + // and sending it already filled in means the host learns no new field. + if err := boundInto(copied, known, m.Module, absent); err != nil { + return nil, err + } + // And what the module could not have written: the machine it turned out to be + // assigned to. Beside the bound values because it is the same kind of fact — the + // mesh's own, held in the clear — and because a module that must name itself to + // something else has no binding to learn it from (novox/hq ADR 0066). + // Which port this machine gave it, for a module that binds directly rather than + // through a runtime that can remap (ADR 0038). Applied before the machine's facts so + // a refusal names the port rather than whatever came after it. + if err := portInto(copied, m.Module, m.Listens, with); err != nil { + return nil, err + } + // And where this machine put the foundation's servers, for the one module that + // reaches them by seat rather than by binding (novox/hq 04-ISSUES/102). + if err := seatInto(copied, m.Module, with); err != nil { + return nil, err + } + if err := machineInto(copied, thisMachine, m.Module); err != nil { + return nil, err + } + if err := pinned(copied, m.Module); err != nil { + return nil, err + } + // And the other half of the same question: an image nobody has published yet is named + // by an artifact rather than by a placeholder digest, and is just as unrunnable. + if err := built(copied, m.Module); err != nil { + return nil, err + } + // What the mesh built is kept by digest and path; the store's address is this + // network's now, composed here and never recorded (novox/hq 04-ISSUES/102). + if err := artifactsInto(copied, m.Module, with); err != nil { + return nil, err + } + publishedOn(copied, m.Module, with) + // How it is ready, in the node-engine's words: its endpoint as the port this machine + // published it on — or not sent at all to an engine older than the field (ADR 0240). + healthInto(copied, m, with) + // And whether it is busy (novox/hq ADR 0335): sent only to an engine that asks it. + busyInto(copied, with) + // And a user's `root` (novox/hq ADR 0266): sent only when it asserts something, to an engine + // that judges it. + rootInto(copied, with) + // The account's environment and every module's shell code, where this module holds the + // seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on + // the node, as the jails are, and **last of every placeholder pass**: shell code is a + // shell's own syntax, full of `${…}` no pass above should ever be shown. + contributing := append(append([]mcat.Manifest(nil), r.Modules...), stillBackedUp...) + if err := contributionsInto(copied, m, contributing, thisMachine, with, r.Capabilities, unplaced); err != nil { + return nil, err + } + copied["id"] = m.Module + "." + fmt.Sprint(resource["id"]) + // A service saying what it reflects names resources within its own module, so those + // are prefixed too or they would point at nothing. + // + // **Unless it already names one.** A module may reflect a file another module put on + // the machine — the case this exists for is a resolver restarting when the mesh + // rewrites the names, which are computed by the mesh and belong to its module, not to + // the daemon's. Written `.`, and a dot is what marks it as already + // answered: prefixing it again would point at nothing, silently, and the daemon would + // serve the old names for ever while everything reported success. + if renamed := reflectsRenamed(m.Module, resource["restart-on"]); renamed != nil { + copied["restart-on"] = renamed + } + // And what it is reloaded on, by the same rule (novox/hq ADR 0102): an id left + // unprefixed matches nothing, and the service is never reloaded. + if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil { + copied["reload-on"] = renamed + } + // And which of its module's containers a scheduled step holds still (novox/hq ADR 0189). + // **The loudest of the three when it is missed.** An unprefixed `restart-on` matches + // nothing and a service quietly never restarts; an unprefixed `while-stopped` names a + // container the declaration does not contain, and the host refuses the whole + // declaration — so the machine takes nothing at all, for every push, until this is + // right. That is what it did on the control node (2026-10-04). + if renamed := reflectsRenamed(m.Module, resource[mcat.WhileStopped]); renamed != nil { + copied[mcat.WhileStopped] = renamed + } + // And what a process replaces (novox/hq issue 213): a resource of this module's that it + // no longer declares, named as the host recorded it, or the host hands nothing over and + // removes it first. + if renamed := reflectsRenamed(m.Module, resource["replaces"]); renamed != nil { + copied["replaces"] = renamed + } + // **What reads one of this module's own secrets is restarted when it changes** (novox/hq + // issue 203, issue 206). A credential is re-issued by the mesh, and a container that + // mounted the old file keeps the old one open: the build machine ran for an hour on a + // credential the mesh had replaced, because its manifest restarted it on its + // environment file and nobody had thought to name the credential too. Composed here so + // no manifest has to say it, for a container or a daemon that names the secret's path. + if reads := secretsReadBy(copied, m, with.Needed[m.Module]); len(reads) > 0 { + copied["restart-on"] = withRestartOn(copied["restart-on"], reads) + } + // **A version prepares its state before it runs** (novox/hq ADR 0135). Derived from the + // module's own resource rather than declared beside it: what prepares the state is the + // module's own code, so what it is given has to be what that code is given — and a + // second resource written by hand is a second copy to drift from the first. Placed + // immediately before it, because a run-once step stops everything the declaration + // places after it (ADR 0052), which is how a version whose preparation failed does not + // serve. + if prepareBefore != "" && fmt.Sprint(resource["id"]) == prepareBefore { + step := prepared(copied) + owner[fmt.Sprint(step["id"])] = m.Module + out = append(out, step) + } + owner[fmt.Sprint(copied["id"])] = m.Module + out = append(out, copied) + } + + // **What only the mesh knows, where this module asked for it.** The graph is the control + // plane's; making a name resolve is the module's software. Emitted as ordinary files under + // this module's name, so they are applied, reported and removed exactly as anything else + // it declares. + // The resolver file is the node-resolver holder's where one is held here, and the uplink's + // holder steps back from it (novox/hq ADR 0247). + given, err := FactsFrom(stepsBack(m, r.Modules), r, with) + if err != nil { + return nil, err + } + for _, fact := range given { + fact["id"] = m.Module + "." + fmt.Sprint(fact["id"]) + owner[fmt.Sprint(fact["id"])] = m.Module + out = append(out, fact) + } + } + // The node's tool runtime, last (novox/hq ADR 0175, to-be 38 WP2.3): one process loading every + // bundle delivered above and holding the credential sealed above, so both exist before it starts + // — the order written here is the order the machine applies. + if resolutionRuntimeHere(r) { + process, err := resolutionRuntimeProcess(r, with) + if err != nil { + return nil, err + } + if process != nil { // none for a held runtime's stand-in: what runs of it is carried + owner[fmt.Sprint(process["id"])] = mcat.RuntimeModule + out = append(out, process) + } + // And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8). + owns, err := resolutionOwnRuntimes(r, with) + if err != nil { + return nil, err + } + for _, p := range owns { + id := fmt.Sprint(p["id"]) + owner[id] = strings.TrimSuffix(id, "."+mcat.OwnRuntimeID()) + out = append(out, p) + } + // What each module's bundles are given is read as the account the runtime runs as — not what a + // module of its own account is given, which its own account reads. + words := map[string]map[string]string{} + for _, m := range r.Modules { + if m.RunsAs != "" { + continue + } + w, err := bundleWords(m, with) + if err != nil { + return nil, err + } + words[m.Module] = w + } + // And a file a module's words name is one the runtime is restarted for when it changes. + if named := givenTo(out, owner, words, r.Account); len(named) > 0 && process != nil { + restarts, _ := process["restart-on"].([]any) + seen := map[string]bool{} + for _, id := range restarts { + seen[fmt.Sprint(id)] = true + } + for _, id := range named { + if !seen[id] { + restarts = append(restarts, id) + seen[id] = true + } + } + process["restart-on"] = restarts + } + // **And what a module of its own account is given, its own account reads** (issue 723): the files and + // directories its words name that say no owner, given to the account it runs as, never the operator's. + for _, m := range r.Modules { + if m.RunsAs == "" { + continue + } + w, err := bundleWords(m, with) + if err != nil { + return nil, err + } + givenTo(out, owner, map[string]map[string]string{m.Module: w}, m.RunsAs) + } + } + if with.Adopted { + // First, before anything a module declares: what the mesh needs reachable, then its guard. + // The order a machine applies is the order written here. + ours := Openings(rules, with.Foundation, mcat.Published(out)) + ours = append(ours, GuardResources(resolutionGuarded(r, out, owner, rules, with))...) + out = append(ours, out...) + } + return out, nil +} + +// guarded is what the mesh's guard refuses on an adopted node (novox/hq ADR 0103): derived, and +// for taken modules only. +// +// For each taken module, every machine port its containers publish that the filter would admit +// from the private network only — a published port is forwarded, not received, so a found +// firewall filtering only what it receives never sees it — together with the ports the module's +// manifest guards explicitly (the store's port, the broker's management port), wherever the +// machine put them. A port of a module assigned but not taken is not guarded: it may still be the +// predecessor's, serving the predecessor's other machines. The foundation's ports are admitted +// from everywhere and are never guarded. +func resolutionGuarded(r mcat.Resolution, out []map[string]any, owner map[string]string, rules []mcat.Rule, + with mcat.Rendering) []int { + meshOnly := map[int]bool{} + fromEverywhere := map[int]bool{} + for _, rule := range rules { + if rule.Protocol != "tcp" { + continue + } + switch rule.From { + case mcat.FromMesh: + meshOnly[rule.Port] = true + case mcat.FromEverywhere: + fromEverywhere[rule.Port] = true + } + } + for _, port := range with.Foundation { + delete(meshOnly, port) + fromEverywhere[port] = true + } + seen := map[int]bool{} + var ports []int + guard := func(at int) { + if !seen[at] { + seen[at] = true + ports = append(ports, at) + } + } + for _, m := range r.Modules { + if !with.Taken[m.Module] { + continue + } + var mine []map[string]any + for _, resource := range out { + if owner[fmt.Sprint(resource["id"])] == m.Module { + mine = append(mine, resource) + } + } + for outer := range mcat.Published(mine)["tcp"] { + if meshOnly[outer] { + guard(outer) + } + } + for _, want := range m.Guards { + at := renderingMachinePort(with, m.Module, want) + for _, resource := range mine { + if fmt.Sprint(resource["type"]) != "container" { + continue + } + listed, _ := resource["ports"].([]any) + for _, entry := range listed { + outer, inner, _, ok := mapping(fmt.Sprint(entry)) + if ok && inner == want { + at = outer + } + } + } + // Not what this node is told to open to everyone: a per-node exposure setting that + // widens a guarded port is the operator saying so, and declaring an opening for it + // and a guard dropping it would be one statement refusing the other. + if !fromEverywhere[at] { + guard(at) + } + } + } + sort.Ints(ports) + return ports +} + +// mapping reads a container's port mapping — `[address:]outer:inner[/protocol]`, the address +// possibly an IPv6 one in brackets — indexing from the end, so an address's own colons never +// shift the ports. Not ok for a short form or anything that is not a mapping. +func mapping(written string) (outer, inner int, address string, ok bool) { + written = strings.TrimSpace(written) + if cut := strings.LastIndex(written, "/"); cut >= 0 { + written = written[:cut] + } + parts := strings.Split(written, ":") + if len(parts) < 2 { + return 0, 0, "", false + } + inner, err := strconv.Atoi(parts[len(parts)-1]) + if err != nil { + return 0, 0, "", false + } + outer, err = strconv.Atoi(parts[len(parts)-2]) + if err != nil { + return 0, 0, "", false + } + return outer, inner, strings.Join(parts[:len(parts)-2], ":"), true +} + +// filtersHere is the module on this node that loads the machine's packet filter, or empty when none +// does. Named rather than counted: a refusal that says which module is one step from acted on. +func resolutionFiltersHere(r mcat.Resolution) string { + for _, m := range r.Modules { + if m.Filtering != nil { + return m.Module + } + } + return "" +} + +// Rules is the rule set this node's filter is derived from: every module's listens, what was +// computed for this machine, and each module's per-node exposure. The same answer whether the node +// is adopted or converged — the one loads it as a filter, the other declares it as openings. +func Rules(r mcat.Resolution, with mcat.Rendering) ([]mcat.Rule, error) { + // A module whose settings cannot compose is left out of the declaration (novox/hq ADR 0163, + // rule 6), and out of the filter with it: nothing of it is declared, so nothing of it is let + // through. + left := LeftOut(r, with.Settings, with.Adopted) + exposure := map[string]map[int]string{} + for _, m := range r.Modules { + if _, isLeft := left[m.Module]; isLeft { + continue + } + e, err := Exposure(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } + // And how far each endpoint reaches, which says the same thing to the filter and more + // besides (novox/hq ADR 0138). Folded in here rather than beside: the filter has one + // question — from where — and a reach answers it, so giving it two inputs would let them + // disagree. Reaches refuses a port that both name, so this cannot silently prefer one. + reaches, err := Reaches(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } + // **Only for an endpoint the proxy does not serve.** A routed endpoint's port is how the + // proxy reaches it and nothing else (ADR 0045), so `public` there asks for a public name and + // says nothing about the port — opening it to the world as well would undo the arrangement + // the proxy exists for, and would silently reopen a port an operator had narrowed. + // + // Found by trying to express a real module: one whose routed name must be public and whose + // machine-side port must not be. Under one value for both, there was no way to say it. + routed := RoutedPorts(m) + for port, reach := range reaches { + if routed[port] { + continue + } + source, ok := FilterSource(reach) + if !ok { + return nil, fmt.Errorf("%s: %q is not a reach the filter can read", m.Module, reach) + } + if e == nil { + e = map[int]string{} + } + e[port] = source + } + if e != nil { + exposure[m.Module] = e + } + } + return Filtering(r, with.Generators, with.Ports, exposure) +} + +// provisionsAs is the account that reads what the mesh writes for this provider: the one secret +// per consumer it must open to set that consumer's password (novox/hq issue 225). +// +// **A root-owned 0600 file is one that process cannot read**, which is the same sentence already +// written above for a module's own secrets — and the grant secret is the other kind of secret +// the mesh writes for a module, so it is the same rule. +// +// Which account depends on where the module's code runs. A module whose code is a bundle is run +// by the node's tool runtime, as the node's account ([ADR 0198](0198)); one still in a container +// is whatever it declares as its secrets owner. Nothing names these paths, so the rule that +// claims a bundle's other files by the words that name them (givenTo) cannot reach them: the +// harness composes a grant secret's path from the contributions file, not from a word. +// +// Empty is root, which is what it was and what a module with no bundle and no declared owner +// still wants. +func resolutionProvisionsAs(r mcat.Resolution, m mcat.Manifest) string { + // A module of its own account reads them as that account (ADR 0259 §8, issue 723). + if m.RunsAs != "" { + return m.RunsAs + } + if len(m.Bundles) > 0 && r.Account != "" { + return r.Account + } + return m.SecretsOwner +} + +// ownSecretOwner is who one of a module's own secret files belongs to: the account the manifest names for it, else the +// runtime's account for the runtime's own credential and for a tools bundle's bus account (ToolsBusSecret; novox/hq ADR +// 0564 Limits 4), which the runtime runs that bundle as, else the module's secrets-owner (root when none). +func resolutionOwnSecretOwner(r mcat.Resolution, m mcat.Manifest, name string) string { + if o := m.OwnSecrets[name].Owner; o != "" { + return o + } + if r.Account != "" && (m.Module == mcat.RuntimeModule || ToolsBusSecret(m, name)) { + return r.Account + } + return m.SecretsOwner +} + +// grantPath is where one consumer's sealed credential lands on the providing machine. +// +// Suffixed, so the directory can also hold whatever the module writing it keeps there and so a +// node named like something else in that directory cannot collide with it. +// One file per consumer, and a consumer is a module on a machine (novox/hq 04-ISSUES/022). +// +// Named after both. Named after the machine alone, two modules on one node wrote to one path: the +// second overwrote the first, and the provisioner — reading a directory — saw one consumer where +// there were two. +// reflectsRenamed is a resource's restart-on list under the module's prefix, or nil when it has +// none. It reads both the shape JSON parsing produces ([]any) and the shape code composing +// resources natively produces ([]string): a reference that was skipped because its list arrived +// in the other shape would point at nothing — silently, with the service never restarting and +// every check passing, which is how a runtime kept serving without the registry trust its +// daemon file already carried. +func reflectsRenamed(module string, reflects any) []any { + var names []string + switch v := reflects.(type) { + case []any: + for _, id := range v { + names = append(names, fmt.Sprint(id)) + } + case []string: + names = v + } + if names == nil { + return nil + } + var renamed []any + for _, named := range names { + if strings.Contains(named, ".") { + renamed = append(renamed, named) + continue + } + renamed = append(renamed, module+"."+named) + } + return renamed +} + +func grantPath(directory, consumer, module string) string { + return strings.TrimRight(directory, "/") + "/" + consumer + "." + module + ".secret" +} + +// holderAs is a consumer's name at the provider with a local name after it, where it keeps several +// credentials for one provision (ADR 0094); the name alone otherwise. +func holderAs(as, local string) string { + if local == "" { + return as + } + return as + "_" + local +} + +// contributions collects what every module in this set contributes, by requirement. +// +// Ordered by contributing module, because the result becomes a file on a machine and a file whose +// lines move about is a file that looks changed when nothing changed. +func resolutionContributions(r mcat.Resolution, settings mcat.SettingsBy, grants []mcat.Grant, + directories map[string]string) (map[string][]mcat.Contribution, error) { + out := map[string][]mcat.Contribution{} + modules := append([]mcat.Manifest{}, r.Modules...) + sort.Slice(modules, func(i, j int) bool { return modules[i].Module < modules[j].Module }) + + // What consumers on other machines asked for. Merged in with this machine's own, because from + // the provider's side they are the same thing — somebody wanting something — and a provider + // that had to read two lists would be a provider that reads one of them. + sorted := append([]mcat.Grant{}, grants...) + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Provision != sorted[j].Provision { + return sorted[i].Provision < sorted[j].Provision + } + if sorted[i].Consumer != sorted[j].Consumer { + return sorted[i].Consumer < sorted[j].Consumer + } + if sorted[i].From != sorted[j].From { + return sorted[i].From < sorted[j].From + } + return sorted[i].Local < sorted[j].Local + }) + // A consumer already carried by the grants loop, keyed (provision, module). When provider and + // consumer are co-located, `grantsFor` enumerates the same-node consumer too, so without this the + // module would be emitted a second time by the m.Contributes loop below — once full (with the + // grant's secret/as) and once partial — which is the duplicate seen in a co-located mesh.json. + granted := map[string]map[string]bool{} + for _, g := range sorted { + if g.From == "" { + // As above: nothing on that machine asks for this any more, so the provider is not + // told about it and withdraws the login on its next pass. + continue + } + as := holderAs(mcat.ConsumerIdentity(g.Consumer, mcat.IdentitySource(g.Slug, g.From)), g.Local) + derived, err := resolutionDerivedFor(r, g.Provision, as, g.From, g.Local, settings) + if err != nil { + return nil, err + } + out[g.Provision] = append(out[g.Provision], mcat.Contribution{ + From: g.From, Node: g.Consumer, At: g.At, Values: g.Values, Derived: derived, + // One holder per local name: the identity the consumer is known by, and the local name + // after it where the module keeps several (ADR 0094). Not a login any backend checks — + // a secret is not a login — so the identity limit does not apply to the suffix. + As: as, + Secret: grantPath(directories[g.Provision], g.Consumer, holderAs(g.From, g.Local)), + }) + if granted[g.Provision] == nil { + granted[g.Provision] = map[string]bool{} + } + granted[g.Provision][g.From] = true + } + for _, m := range modules { + for _, to := range sortedKeys(m.Contributes) { + // The grants loop already emitted this module's contribution to this provision, with its + // minted secret — emitting the partial copy again would duplicate it. A contribution with + // no grant (a reverse-proxy route names a host, not a credential) is not in `granted`, so + // it still reaches the provider from here. + if granted[to][m.Module] { + continue + } + // Settings reach a contribution the same way they reach a file. A route's hostname is + // exactly the kind of thing that differs between one mesh and the next, and a module + // that could not have it set would have to be edited to be reused. + values, err := resolutionComposed(r, m, to, m.Contributes[to], settings[m.Module], + m.Module+" contributing to "+to) + if err != nil { + return nil, err + } + // What the module shares of its bucket reaches the provider with the rest (novox/hq ADR 0468 §8). + if values, err = withShares(m, to, values); err != nil { + return nil, err + } + out[to] = append(out[to], mcat.Contribution{From: m.Module, Values: values}) + } + // Several contributions to one requirement (ADR 0094's sibling for `contributes`): an + // object store's data API and its console are two different public names from one module, + // not one. Never in `granted` — a route names a host, not a credential — so every local + // name always reaches the provider from here. + for _, to := range sortedKeys(m.ContributesMany) { + for _, local := range sortedKeys(m.ContributesMany[to]) { + values, err := resolutionComposed(r, m, to, m.ContributesMany[to][local], settings[m.Module], + m.Module+" contributing "+local+" to "+to) + if err != nil { + return nil, err + } + out[to] = append(out[to], mcat.Contribution{From: m.Module, Values: values}) + } + } + } + return out, nil +} + +// contributedOf is each contribution m makes to one requirement, composed as its provider receives it in a received +// file (contributions: the one of `contributes` with what the module shares of its bucket, then each of several by +// local name), with where m's machine is, encoded for PlanProvision.Contributed (issue 774): one composition, so a +// plan and a file cannot say different things. Nil where m contributes nothing to it. +// +// **Its port is the one the machine published** (atAssignedPort): a plan is read on whatever machine its provider runs, +// which cannot redirect the number as a received file on this machine is redirected (atMachinePort), so the plan +// carries the machine port already. route-proxy dialled gitea's 3000 for git (published at 20000) and grafana's 3000 +// on another machine (published at 20012) until it did (2026-10-31). +func resolutionContributedOf(r mcat.Resolution, m mcat.Manifest, to string, with mcat.Rendering) (json.RawMessage, error) { + layers := with.Settings[m.Module] + form := mcat.ContributedForm{At: r.At} + add := func(local string, values map[string]any) error { + values, err := resolutionAtAssignedPort(r, m, to, values, with) + if err != nil { + return err + } + raw, err := json.Marshal(values) + if err != nil { + return fmt.Errorf("%s contributing to %s: %w", m.Module, to, err) + } + form.Each = append(form.Each, mcat.ContributedOne{Local: local, Values: raw}) + return nil + } + if raw, ok := m.Contributes[to]; ok { + values, err := resolutionComposed(r, m, to, raw, layers, m.Module+" contributing to "+to) + if err != nil { + return nil, err + } + if values, err = withShares(m, to, values); err != nil { + return nil, err + } + if err := add("", values); err != nil { + return nil, err + } + } + for _, local := range sortedKeys(m.ContributesMany[to]) { + values, err := resolutionComposed(r, m, to, m.ContributesMany[to][local], layers, m.Module+" contributing "+local+" to "+to) + if err != nil { + return nil, err + } + if err := add(local, values); err != nil { + return nil, err + } + } + if len(form.Each) == 0 { + return nil, nil + } + return EncodeContributed(form) +} + +// atAssignedPort is a contribution of m with its port where this machine publishes it for m: the number +// `${port:}` gives m itself and its declaration publishes (machinePort, port_into.go), the placement's +// assignment where it holds one and the software's own number where it holds none (a bare port not yet moved, a fixed +// mapping, the host's network). What its provider dials, on this machine over loopback and from another at m's address. +// +// **Refused where a plan carries a port m does not listen on** (with.Planned): no machine port stands for it, so the +// provider would dial a number nothing on the machine is known to publish. Elsewhere (a declaration, a composition +// with no ports) such a port is left as it is, as machinePort leaves it. +func resolutionAtAssignedPort(r mcat.Resolution, m mcat.Manifest, to string, values map[string]any, with mcat.Rendering) (map[string]any, error) { + number, ok := asPort(values["port"]) + if !ok { + return values, nil + } + _, given := with.Given[m.Module][number] + _, placed := with.Ports[m.Module][number] + if !given && !placed { + if with.Planned { + return nil, fmt.Errorf("%s contributing to %s: its port %d on %s is none of the ports it listens on, so no "+ + "machine port stands for it", m.Module, to, number, r.Node) + } + return values, nil + } + return atMachinePort(values, m.Module, map[string]map[int]int{m.Module: {number: renderingMachinePort(with, m.Module, number)}}), nil +} + +// composed is one contribution as its provider receives it: settled with this node's settings, its +// endpoint's port filled in, and its names composed from the label. +// +// **One function, because two readers must agree.** The provider is told the names in its received +// file; the contributing module is told the same names in its own binding (novox/hq 04-ISSUES/122). +// Composing them twice, in two places, is how the proxy would come to serve one name while the +// module wrote another into its configuration. +func resolutionComposed(r mcat.Resolution, m mcat.Manifest, to string, raw map[string]any, layers []mcat.Layer, what string) ( + map[string]any, error) { + // Overridden, not merged: a setting changes a key the contribution declares and adds none. + // The provider reads the contribution as a contract, and a setting made for one of this + // module's files is no part of it (novox/hq 04-ISSUES/173). + values, err := overridden(raw, WithDefaults(m, layers), what) + if err != nil { + // A required value nothing sets is said in the words of the module's form, as a file's, a unit's and an owner's + // are (issue 1126, seed-8: de-spiegel in the config form was refused with ${setting:…} and `settings set`). + var unset *UnsetSettingError + if errors.As(err, &unset) && unset.Module == "" { + unset.Module = m.Module + unset.where = fmt.Sprintf("a contribution%s, in %q,", strings.TrimPrefix(what, m.Module+" contributing"), unset.field) + } + return nil, unsetInConfigWords(fmt.Errorf("%s: %w", what, err), m) + } + reaches, err := Reaches(m, layers) + if err != nil { + return nil, fmt.Errorf("%s: %w", what, err) + } + blocks, err := mcat.Endpoints(m, layers) + if err != nil { + return nil, fmt.Errorf("%s: %w", what, err) + } + portOfEndpoint(values, endpointPorts(m)) + composeName(values, r.PublicDomain, servingAt(r, to), reaches, endpointPorts(m), blocks) + return values, nil +} + +// ownNames is what a module is known by through what it contributes to one requirement — the names +// the mesh composed for it, and nothing else of the contribution. +// +// **The half a module could not learn** (novox/hq 04-ISSUES/122). A module contributes a label, the +// mesh joins it with this node's domains, and the provider serves the result — and the module itself +// was never told. Software that must know its own address (a login redirect, a canonical URL, an +// issuer) had it written into the manifest as a literal, which is a domain in a definition and wrong +// on every other machine. `${bound::name}` is the answer, from the same composition the +// provider receives. +// +// Several contributions to one requirement are keyed by their local name under `names`. +func resolutionOwnNames(r mcat.Resolution, m mcat.Manifest, to string, layers []mcat.Layer) (map[string]any, error) { + pick := func(values map[string]any) map[string]any { + names := map[string]any{} + for _, key := range []string{"name", "internal-name"} { + if v, ok := values[key].(string); ok && v != "" { + names[key] = v + } + } + return names + } + out := map[string]any{} + if raw, ok := m.Contributes[to]; ok { + values, err := resolutionComposed(r, m, to, raw, layers, m.Module+" contributing to "+to) + if err != nil { + return nil, err + } + for k, v := range pick(values) { + out[k] = v + } + } + if locals := m.ContributesMany[to]; len(locals) > 0 { + many := map[string]any{} + for _, local := range sortedKeys(locals) { + values, err := resolutionComposed(r, m, to, locals[local], layers, + m.Module+" contributing "+local+" to "+to) + if err != nil { + return nil, err + } + if names := pick(values); len(names) > 0 { + many[local] = names + } + } + if len(many) > 0 { + out["names"] = many + } + } + return out, nil +} + +// composeName joins a contribution's label with a node's public domain, and separately with its +// private one, in place (novox/hq ADR 0056). +// +// **The whole of what the mesh does with a route's name: join two given strings — twice.** A +// contribution carries a `label` — the subdomain its operator chose — and the node carries its +// public domain and its own private-network address; the granted names are `