diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdc2f3f..e5aa7a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,8 +35,42 @@ jobs: umask 177 printf 'ci-test-only\n' > .vault_pass + - name: Stage matching provider collection branches + if: github.event_name == 'pull_request' + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + clone_matching_branch() { + local repo="$1" + local dest="$2" + local remote="https://x-access-token:${GITHUB_TOKEN}@github.com/CrownOpsEng/${repo}.git" + + if git ls-remote --exit-code --heads "${remote}" "${GITHUB_HEAD_REF}" >/dev/null 2>&1; then + git clone --depth 1 --branch "${GITHUB_HEAD_REF}" "${remote}" "${dest}" + else + echo "No matching ${GITHUB_HEAD_REF} branch in ${repo}; using the default collection source." + fi + } + + mkdir -p .deps + clone_matching_branch crownops-deploy-base .deps/crownops-deploy-base + clone_matching_branch crownops-deploy-services .deps/crownops-deploy-services + - name: Install collections - run: ./scripts/install-collections.sh + run: | + set -euo pipefail + + if [[ -f "${GITHUB_WORKSPACE}/.deps/crownops-deploy-base/galaxy.yml" ]]; then + export CROWNOPS_BASE_COLLECTION_SOURCE="${GITHUB_WORKSPACE}/.deps/crownops-deploy-base" + fi + + if [[ -f "${GITHUB_WORKSPACE}/.deps/crownops-deploy-services/galaxy.yml" ]]; then + export CROWNOPS_SERVICES_COLLECTION_SOURCE="${GITHUB_WORKSPACE}/.deps/crownops-deploy-services" + fi + + ./scripts/install-collections.sh - name: Syntax check preflight run: ansible-playbook --syntax-check -i inventories/prod/hosts.yml playbooks/preflight.yml @@ -44,15 +78,48 @@ jobs: - name: Smoke test preflight placeholder validation run: bash tests/smoke/test_preflight_placeholder_scan.sh + - name: Smoke test preflight missing nested contract roots + run: bash tests/smoke/test_preflight_missing_contract_roots.sh + - name: Smoke test Tailscale tag validation run: bash tests/smoke/test_preflight_tailscale_tag_validation.sh + - name: Smoke test platform bindings public HTTPS composition + run: bash tests/smoke/test_platform_bindings_public_https.sh + + - name: Smoke test platform bindings private mesh composition + run: bash tests/smoke/test_platform_bindings_private_mesh.sh + + - name: Smoke test platform bindings preserve host extensions + run: bash tests/smoke/test_platform_bindings_host_extensions.sh + + - name: Smoke test host Traefik preflight contract validation + run: bash tests/smoke/test_preflight_host_traefik_contract_validation.sh + - name: Smoke test builder restic target normalization run: bash tests/smoke/test_builder_restic_target_name_normalization.sh + - name: Smoke test builder obsidian setup URI generation + run: bash tests/smoke/test_builder_obsidian_setup_uri_generation.sh + + - name: Smoke test builder restic target keypair consistency + run: bash tests/smoke/test_builder_restic_target_keypair_consistency.sh + - name: Smoke test restic target name preflight validation run: bash tests/smoke/test_preflight_restic_target_name_validation.sh + - name: Smoke test restic cache valid time preflight validation + run: bash tests/smoke/test_preflight_restic_cache_valid_time_validation.sh + + - name: Smoke test broad backup dataset warning + run: bash tests/smoke/test_preflight_restic_broad_path_warning.sh + + - name: Smoke test unsupported feature-owned jobs validation + run: bash tests/smoke/test_preflight_feature_owned_jobs_rejected.sh + + - name: Smoke test Obsidian route name validation + run: bash tests/smoke/test_preflight_obsidian_route_name_validation.sh + - name: Syntax check bootstrap run: ansible-playbook --syntax-check -i inventories/prod/hosts.yml playbooks/bootstrap.yml diff --git a/README.md b/README.md index 5cbc710..4ec3c11 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,10 @@ Configuration model: - `inventories/prod/group_vars/all/main.yml` is the primary non-secret configuration surface - `inventories/prod/group_vars/all/vault.yml` holds secret values only - `.vault_pass` is the repo-local default Ansible Vault password file; keep it local, `0600`, and untracked -- backup policy is expressed as `restic_targets`, `restic_backup_jobs`, and `restic_backup_contributions` +- shared host capabilities live under `host.*` +- feature contracts live under `features.*` +- site-local composition derives shared ingress, backup datasets, and firewall requests before host roles reconcile them +- host-owned `host.traefik.routes`, `host.restic.datasets`, and `host.ufw.requests` remain additive extension points and are preserved when the site layer composes shared inputs Read first: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c6294ac..578b548 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -26,14 +26,34 @@ Current feature set: - Obsidian via the `crownops.deploy_services.obsidian_livesync` role -`playbooks/backup.yml` uses the `crownops.deploy_services.restic_host_backups` role. +Shared host capabilities: -That backup layer is modeled as: +- `crownops.deploy_services.host_traefik` +- `crownops.deploy_services.host_restic` +- `crownops.deploy_base.host_ufw` + +`playbooks/backup.yml` uses the `crownops.deploy_services.host_restic` role. + +This repo owns one site-local composition step through `roles/platform_bindings`. + +That composition layer derives: + +- `platform_ingress_routes` +- `platform_backup_datasets` +- `platform_ufw_requests` + +Then it builds the effective host contracts consumed by the shared roles: + +- `platform_host_traefik` appends composed routes to any host-owned `host.traefik.routes` +- `platform_host_restic` appends composed datasets to any host-owned `host.restic.datasets` +- `platform_host_ufw` appends composed firewall requests to any host-owned `host.ufw.requests` + +The backup layer is modeled as: - targets: backup destinations and transport credentials -- jobs: logical host backup policies with schedule and retention -- contributions: feature-specific paths and consistency hooks merged into named jobs -- converge-time performance policy: no fact gathering for the dedicated backup play, SSH pipelining enabled in the repo Ansible config, and package cache reuse controlled through `restic_apt_cache_valid_time` +- datasets: composed durable backup scopes owned by the site layer and feature/host boundaries +- jobs: logical host-owned backup policy with schedule and retention +- converge-time performance policy: no fact gathering for the dedicated backup play, SSH pipelining enabled in the repo Ansible config, and package cache reuse controlled through `host.restic.apt_cache_valid_time` - restore-first scope policy: back up durable state such as host identity, local markdown workspaces, CouchDB data, and Traefik ACME state, not broad service roots that can be rebuilt from Ansible `playbooks/lockdown.yml` consumes the reusable `crownops.deploy_base.network_lockdown` role so SSH lockdown policy stays consistent across site repos. diff --git a/docs/CONFIG_WIZARD_SPEC.md b/docs/CONFIG_WIZARD_SPEC.md index 5fecb53..90d949d 100644 --- a/docs/CONFIG_WIZARD_SPEC.md +++ b/docs/CONFIG_WIZARD_SPEC.md @@ -174,7 +174,7 @@ label: CouchDB admin password type: secret stage: features required: true -when: feature_obsidian_enabled == true +when: features.obsidian_livesync.enabled == true default: null target: file: inventories/prod/group_vars/all/vault.yml diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 3f096b4..3f17e2d 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -49,6 +49,7 @@ This file states exactly what is done, what is not done, and what gaps remain. 5. Verify whether port 443 should be publicly reachable or source-restricted in your exact model. 6. Join the host to Tailscale with the real auth approach you want. 7. Prepare the backup targets and confirm the job/contribution model matches the host role. +7. Prepare the backup targets and confirm the host job plus composed dataset model matches the host role. 8. Decide how you will handle LiveSync passphrase ownership and recovery expectations. 9. Run and validate the package in a controlled sequence, including the staged SSH lockdown. diff --git a/docs/PREFLIGHT.md b/docs/PREFLIGHT.md index fcd12be..b846e0b 100644 --- a/docs/PREFLIGHT.md +++ b/docs/PREFLIGHT.md @@ -21,15 +21,17 @@ ansible-playbook -i inventories/prod/hosts.yml playbooks/preflight.yml - base and ops domain presence and placeholder values - SSH public key population - Ubuntu release selector validity -- HTTPS firewall exposure for Traefik on TCP 443 when enabled features require it -- Traefik ACME email and DNS provider configuration -- ACME provider env placeholders -- CouchDB admin credentials when Obsidian is enabled -- synced Obsidian account definitions -- duplicate vault names, database names, or users -- placeholder synced account passwords +- nested `features.*` and `host.*` contract presence +- flat legacy inventory variables that should no longer exist +- Obsidian access mode, URL, CouchDB contract, and sync-account uniqueness +- public HTTPS bindings through `host.traefik` +- private mesh planning inputs and public-firewall leakage +- restic target and job structure under `host.restic` +- unsupported `host.restic.feature_owned_jobs` +- composed platform bindings such as ingress routes, datasets, and firewall requests - path collisions between `vault_root` and `exports_root` - restic repository and password placeholders +- broad backup dataset root warnings - Tailscale auth and bootstrap placeholders - placeholder marker sweep across key variables - remote connectivity probe diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 791afdf..b01947e 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -71,11 +71,12 @@ Primary configuration surface: That file should remain the main place where you enable features and define non-secret behavior: -- domains and ingress settings +- top-level deployment identity, paths, and lockdown settings - host bootstrap settings -- synced Obsidian account definitions +- feature config under `features.*` +- shared host capability config under `host.*` - local markdown workspace names -- backup targets, logical jobs, feature contributions, and lockdown behavior +- host backup targets and logical jobs, with backup datasets composed later by the site layer Secrets belong in: @@ -110,9 +111,10 @@ At minimum set: - DNS and ACME values only for `public_https` - a concrete private mesh base URL only for `private_mesh` - keep `5984` off the public firewall in `private_mesh`; that path assumes your VPN or mesh provides reachability -- synced account structure in `all/main.yml` and CouchDB passwords in `all/vault.yml` if Obsidian is enabled +- synced account structure in `features.obsidian_livesync.couchdb.vaults` and CouchDB passwords in `all/vault.yml` if Obsidian is enabled - local markdown workspace names in `all.yml` if you want local-only content directories scaffolded -- backup targets, backup jobs, and contribution wiring +- backup targets and backup jobs under `host.restic`; dataset scope comes from site composition, not from feature-owned schedules +- optional host-owned extensions such as `host.traefik.routes`, `host.restic.datasets`, and `host.ufw.requests` if this site needs extra ingress, backup scope, or firewall rules beyond the composed defaults - Tailscale hostname/tags in `all/main.yml` and optional auth key in `all/vault.yml` Notes: @@ -123,7 +125,7 @@ Notes: - after a successful resumed run, the wizard best-effort securely deletes its own temporary resume-state file - Tailscale join is automated during bootstrap when `tailscale_auth_key` is set - if you intentionally leave `tailscale_auth_key` blank, join manually and then run `./scripts/ssh-lockdown.sh --confirm` after confirming SSH over Tailscale works -- SFTP backup transport supports SSH keys on a per-target basis by storing `ssh_private_key` and `ssh_known_hosts` under each `restic_targets` entry, but the wizard now asks for a local `ssh_private_key_file` path so the key itself does not have to be pasted into the terminal or resume state +- SFTP backup transport supports SSH keys on a per-target basis by storing `ssh_private_key` and `ssh_known_hosts` under each generated `host.restic.targets` entry, but the wizard now asks for a local `ssh_private_key_file` path so the key itself does not have to be pasted into the terminal or resume state - the wizard can guide SFTP backup targets by asking for host, user, path, and port, then deriving the restic repository URL and attempting `ssh-keyscan` automatically - for Linux backup destinations you control, the wizard can still generate a prerequisite setup script that prepares backup users, SSH keys, and repository paths first - staged SSH lockdown is two-phase: `./scripts/ssh-lockdown.sh --phase1-only` validates while preserving public SSH, and `./scripts/ssh-lockdown.sh --confirm` enables the restrictive path diff --git a/docs/RESTORE.md b/docs/RESTORE.md index 163adf7..bf94001 100644 --- a/docs/RESTORE.md +++ b/docs/RESTORE.md @@ -4,8 +4,8 @@ 1. Rebuild host base. 2. Reapply service configuration from Ansible so `docker-compose.yml`, `local.ini`, and routing config are recreated from source of truth. -3. Restore `{{ couchdb_dir }}/data` from restic after stopping CouchDB. -4. If public HTTPS is enabled, restore `{{ traefik_acme_storage }}` so Traefik keeps its ACME account and issued certificates. +3. Restore the `obsidian-couchdb-data` dataset after stopping CouchDB. +4. If public HTTPS is enabled, restore the `traefik-acme` dataset so Traefik keeps its ACME account and issued certificates. 5. Recreate or verify routing and HTTPS. 6. Confirm database security objects and per-vault users. 7. Reconnect device with correct CouchDB credentials. @@ -28,7 +28,7 @@ Back up durable state only: - host identity and operator-managed security config under `/etc/ssh`, `/etc/fail2ban`, and `/etc/ufw` - local markdown workspaces under `{{ vault_root }}/workspaces` -- CouchDB data under `{{ couchdb_dir }}/data` -- Traefik ACME state in `{{ traefik_acme_storage }}` when public HTTPS is enabled +- CouchDB data under `features.obsidian_livesync.couchdb.dir + '/data'` +- Traefik ACME state under `host.traefik.acme_storage_path` when public HTTPS is enabled -Do not treat generated compose files, rendered service config, package caches, or broad parent directories such as `/srv/crownops`, `{{ couchdb_dir }}`, or `{{ traefik_dir }}` as primary backup scope when the deployment is reproducible from Ansible. +Do not treat generated compose files, rendered service config, package caches, or broad parent directories such as `/srv/crownops`, `features.obsidian_livesync.couchdb.dir`, or `host.traefik.layout_root` as primary backup scope when the deployment is reproducible from Ansible. diff --git a/inventories/prod/group_vars/all/main.yml.example b/inventories/prod/group_vars/all/main.yml.example index b6450f1..121f5a0 100644 --- a/inventories/prod/group_vars/all/main.yml.example +++ b/inventories/prod/group_vars/all/main.yml.example @@ -12,46 +12,6 @@ ops_group: deploy ssh_pubkeys: - "ssh-ed25519 REPLACE_ME" -ufw_allowed_tcp_public: [22, 443] -ufw_allowed_udp_public: [] - -feature_obsidian_enabled: true -obsidian_access_mode: public_https -obsidian_base_url: https://notes.ops.example.invalid -obsidian_cors_origins: [] -obsidian_service_subdomain: notes - -traefik_dir: /opt/traefik -traefik_network_name: proxy -internal_network_name: internal -traefik_acme_email: change-me@example.invalid -traefik_certresolver_name: dnsresolver -traefik_acme_storage: /opt/traefik/acme/acme.json -acme_dns_provider: cloudflare -# Example env variable names only. Fill with real provider values. -acme_env: - CF_DNS_API_TOKEN: "REPLACE_ME" - -couchdb_dir: /opt/couchdb -couchdb_container_name: couchdb -couchdb_admin_user: admin -couchdb_admin_password: "{{ vault_couchdb_admin_password }}" -couchdb_bind_host: 127.0.0.1 -couchdb_port: 5984 -couchdb_vaults: - - name: user_a - db_name: vault_user_a - user: vault_user_a_user - password: "{{ vault_couchdb_account_passwords.user_a }}" - - name: user_b - db_name: vault_user_b - user: vault_user_b_user - password: "{{ vault_couchdb_account_passwords.user_b }}" - - name: user_c - db_name: vault_user_c - user: vault_user_c_user - password: "{{ vault_couchdb_account_passwords.user_c }}" - vault_root: /srv/crownops/vaults exports_root: /srv/crownops/exports local_markdown_workspaces: @@ -59,63 +19,116 @@ local_markdown_workspaces: - operations - scratch -restic_enabled: true -restic_apt_cache_valid_time: 86400 -restic_targets: - - name: primary - repository: sftp:backup@primary.example.invalid:/srv/restic/deploy-core - password: "{{ vault_restic_target_secrets.primary.password }}" - ssh_private_key: "{{ vault_restic_target_secrets.primary.ssh_private_key | default('') }}" - ssh_known_hosts: "{{ vault_restic_target_secrets.primary.ssh_known_hosts | default('') }}" - environment: "{{ vault_restic_target_secrets.primary.environment | default({}) }}" - - name: secondary - repository: sftp:backup@secondary.example.invalid:/srv/restic/deploy-core - password: "{{ vault_restic_target_secrets.secondary.password }}" - ssh_private_key: "{{ vault_restic_target_secrets.secondary.ssh_private_key | default('') }}" - ssh_known_hosts: "{{ vault_restic_target_secrets.secondary.ssh_known_hosts | default('') }}" - environment: "{{ vault_restic_target_secrets.secondary.environment | default({}) }}" - -restic_backup_jobs: - - name: host-foundation - paths: - - /etc/ssh - - /etc/fail2ban - - /etc/ufw - target_names: [primary, secondary] - tags: - - profile:stateful-app - - class:host - - name: application-data - # Back up only durable application state. Service configs are rebuilt from Ansible. - paths: - - "{{ vault_root }}/workspaces" - target_names: [primary, secondary] - backup_schedule: "*-*-* 03:30:00" - backup_randomized_delay: 20m - maintenance_schedule: "Sun *-*-* 05:30:00" - maintenance_randomized_delay: 30m - retention_daily: 14 - retention_weekly: 8 - retention_monthly: 6 - tags: - - profile:stateful-app - - class:data +features: + obsidian_livesync: + enabled: true + access_mode: public_https + base_url: https://notes.ops.example.invalid + private_mesh: + url_strategy: tailscale_magicdns + tailnet_name: "" + ingress: + route_name: obsidian-couchdb + couchdb: + dir: /opt/couchdb + container_name: couchdb + internal_network_name: internal + bind_host: 127.0.0.1 + port: 5984 + admin_user: admin + admin_password: "{{ vault_couchdb_admin_password }}" + cors_origins: [] + vaults: + - name: user_a + db_name: vault_user_a + user: vault_user_a_user + password: "{{ vault_couchdb_account_passwords.user_a }}" + - name: user_b + db_name: vault_user_b + user: vault_user_b_user + password: "{{ vault_couchdb_account_passwords.user_b }}" + - name: user_c + db_name: vault_user_c + user: vault_user_c_user + password: "{{ vault_couchdb_account_passwords.user_c }}" -restic_backup_contributions: - - job: host-foundation - paths: - - "{{ traefik_acme_storage }}" - tags: - - feature:edge-proxy - - job: application-data - paths: - - "{{ couchdb_dir }}/data" - pre_commands: - - "docker compose -f /opt/couchdb/docker-compose.yml stop couchdb" - post_commands: - - "docker compose -f /opt/couchdb/docker-compose.yml start couchdb" - tags: - - feature:obsidian-livesync +host: + traefik: + enabled: true + manage_mode: managed + layout_root: /opt/traefik + static_config_path: /opt/traefik/traefik.yml + dynamic_config_root: /opt/traefik/dynamic + dynamic_routes_dir: /opt/traefik/dynamic/routes + acme_storage_path: /opt/traefik/acme/acme.json + proxy_network_name: proxy + container_name: traefik + compose_project_name: traefik + certificate_resolver_name: dnsresolver + acme_email: change-me@example.invalid + dns_provider: cloudflare + dns_env: + CF_DNS_API_TOKEN: "REPLACE_ME" + https_entrypoint_name: websecure + https_port: 443 + log_level: INFO + restic: + enabled: true + install_package: true + package_name: restic + apt_cache_valid_time: 86400 + backup_root: /opt/crownops-backup + targets_dir: /opt/crownops-backup/targets + jobs_dir: /opt/crownops-backup/jobs + passwords_dir: /opt/crownops-backup/passwords + backup_script_path: /usr/local/sbin/crownops-restic-backup + maintain_script_path: /usr/local/sbin/crownops-restic-maintain + ssh_dir: /opt/crownops-backup/ssh + targets: + - name: primary + repository: sftp:backup@primary.example.invalid:/srv/restic/deploy-core + password: "{{ vault_restic_target_secrets.primary.password }}" + ssh_private_key: "{{ vault_restic_target_secrets.primary.ssh_private_key | default('') }}" + ssh_known_hosts: "{{ vault_restic_target_secrets.primary.ssh_known_hosts | default('') }}" + environment: "{{ vault_restic_target_secrets.primary.environment | default({}) }}" + - name: secondary + repository: sftp:backup@secondary.example.invalid:/srv/restic/deploy-core + password: "{{ vault_restic_target_secrets.secondary.password }}" + ssh_private_key: "{{ vault_restic_target_secrets.secondary.ssh_private_key | default('') }}" + ssh_known_hosts: "{{ vault_restic_target_secrets.secondary.ssh_known_hosts | default('') }}" + environment: "{{ vault_restic_target_secrets.secondary.environment | default({}) }}" + jobs: + - name: host-foundation + selector_tags: + - class:host-foundation + target_names: + - primary + - secondary + - name: application-data + selector_tags: + - class:application-data + target_names: + - primary + - secondary + backup_schedule: "*-*-* 03:30:00" + backup_randomized_delay: 20m + maintenance_schedule: "Sun *-*-* 05:30:00" + maintenance_randomized_delay: 30m + retention_daily: 14 + retention_weekly: 8 + retention_monthly: 6 + feature_owned_jobs: [] + ufw: + enabled: true + logging: low + default_incoming_policy: deny + default_outgoing_policy: allow + managed_state_dir: /etc/crownops + managed_state_file: /etc/crownops/host-ufw-rules.json + baseline_tcp_public: + - 22 + baseline_udp_public: [] + requests: [] tailscale_auth_key: "{{ vault_tailscale_auth_key | default('') }}" tailscale_hostname: core-01 diff --git a/playbooks/backup.yml b/playbooks/backup.yml index deeec29..b81212a 100644 --- a/playbooks/backup.yml +++ b/playbooks/backup.yml @@ -4,5 +4,9 @@ become: true gather_facts: false roles: - - role: crownops.deploy_services.restic_host_backups + - role: platform_bindings + tags: [platform, compose] + - role: crownops.deploy_services.host_restic + vars: + host_restic: "{{ platform_host_restic }}" tags: [backup, restic, timers] diff --git a/playbooks/bootstrap.yml b/playbooks/bootstrap.yml index b0c8c1f..403e956 100644 --- a/playbooks/bootstrap.yml +++ b/playbooks/bootstrap.yml @@ -15,8 +15,8 @@ bootstrap_admin_user: "{{ ops_user }}" bootstrap_admin_group: "{{ ops_group }}" bootstrap_admin_authorized_keys: "{{ ssh_pubkeys }}" - bootstrap_ufw_allowed_tcp_public: "{{ ufw_allowed_tcp_public }}" - bootstrap_ufw_allowed_udp_public: "{{ ufw_allowed_udp_public }}" + bootstrap_ufw_allowed_tcp_public: "{{ host.ufw.baseline_tcp_public }}" + bootstrap_ufw_allowed_udp_public: "{{ host.ufw.baseline_udp_public }}" bootstrap_install_docker: true bootstrap_docker_users: - "{{ ops_user }}" diff --git a/playbooks/features/obsidian.yml b/playbooks/features/obsidian.yml index d892c57..af454f5 100644 --- a/playbooks/features/obsidian.yml +++ b/playbooks/features/obsidian.yml @@ -3,6 +3,10 @@ hosts: core_hosts become: true roles: + - role: platform_bindings + tags: [platform, compose] - role: crownops.deploy_services.obsidian_livesync - when: feature_obsidian_enabled | bool + vars: + obsidian_livesync: "{{ platform_obsidian_livesync }}" + when: features.obsidian_livesync.enabled | default(false) | bool tags: [feature, obsidian, services] diff --git a/playbooks/preflight.yml b/playbooks/preflight.yml index 3a39136..cac4562 100644 --- a/playbooks/preflight.yml +++ b/playbooks/preflight.yml @@ -4,6 +4,19 @@ connection: local become: false gather_facts: false + vars: + preflight_inventory_var_files: + - "{{ playbook_dir }}/../inventories/prod/group_vars/all/main.yml" + - "{{ playbook_dir }}/../inventories/prod/group_vars/all/vault.yml" + - "{{ playbook_dir }}/../inventories/prod/group_vars/core_hosts/main.yml" + pre_tasks: + - name: Load inventory group variables into localhost preflight scope + ansible.builtin.include_vars: + file: "{{ item }}" + loop: "{{ preflight_inventory_var_files }}" + tags: [preflight, validate] roles: + - role: platform_bindings + tags: [preflight, validate, platform, compose] - role: preflight_validate tags: [preflight, validate] diff --git a/playbooks/site.yml b/playbooks/site.yml index fe896d2..934a975 100644 --- a/playbooks/site.yml +++ b/playbooks/site.yml @@ -1,2 +1,23 @@ --- -- import_playbook: features/obsidian.yml +- name: Converge site features and shared host capabilities + hosts: core_hosts + become: true + gather_facts: false + roles: + - role: platform_bindings + tags: [platform, compose] + - role: crownops.deploy_services.obsidian_livesync + vars: + obsidian_livesync: "{{ platform_obsidian_livesync }}" + when: features.obsidian_livesync.enabled | default(false) | bool + tags: [feature, obsidian, services] + - role: crownops.deploy_services.host_traefik + vars: + host_traefik: "{{ platform_host_traefik }}" + when: host.traefik.enabled | default(false) | bool + tags: [host, traefik, ingress] + - role: crownops.deploy_base.host_ufw + vars: + host_ufw: "{{ platform_host_ufw }}" + when: host.ufw.enabled | default(false) | bool + tags: [host, ufw, firewall] diff --git a/roles/platform_bindings/meta/main.yml b/roles/platform_bindings/meta/main.yml new file mode 100644 index 0000000..1fb041d --- /dev/null +++ b/roles/platform_bindings/meta/main.yml @@ -0,0 +1,7 @@ +--- +galaxy_info: + author: CrownOps + description: Compose site-local feature and host capability bindings. + license: MIT + min_ansible_version: "2.17" +dependencies: [] diff --git a/roles/platform_bindings/tasks/main.yml b/roles/platform_bindings/tasks/main.yml new file mode 100644 index 0000000..7f647db --- /dev/null +++ b/roles/platform_bindings/tasks/main.yml @@ -0,0 +1,141 @@ +--- +- name: Initialize platform bindings + ansible.builtin.set_fact: + platform_feature_root: "{{ vars.get('features', {}) if (vars.get('features', {}) is mapping) else {} }}" + platform_host_root: "{{ vars.get('host', {}) if (vars.get('host', {}) is mapping) else {} }}" + platform_ingress_routes: [] + platform_backup_datasets: + - name: host-foundation + owner: core + paths: + - /etc/ssh + - /etc/fail2ban + - /etc/ufw + tags: + - class:host-foundation + platform_ufw_requests: [] + +- name: Normalize nested platform inputs + ansible.builtin.set_fact: + platform_obsidian_feature_input: "{{ platform_feature_root.obsidian_livesync | default({}) if ((platform_feature_root.obsidian_livesync | default({})) is mapping) else {} }}" + platform_host_traefik_input: "{{ platform_host_root.traefik | default({}) if ((platform_host_root.traefik | default({})) is mapping) else {} }}" + platform_host_restic_input: "{{ platform_host_root.restic | default({}) if ((platform_host_root.restic | default({})) is mapping) else {} }}" + platform_host_ufw_input: "{{ platform_host_root.ufw | default({}) if ((platform_host_root.ufw | default({})) is mapping) else {} }}" + +- name: Compose workspace backup dataset + ansible.builtin.set_fact: + platform_backup_datasets: "{{ platform_backup_datasets + [platform_workspace_dataset] }}" + vars: + platform_workspace_dataset: + name: workspace-data + owner: core_layout + paths: + - "{{ vault_root }}/workspaces" + tags: + - class:application-data + when: (vault_root | default('') | string | trim) != '' + +- name: Compose effective Obsidian feature contract + ansible.builtin.set_fact: + platform_obsidian_livesync: >- + {{ + platform_obsidian_feature_input + | combine( + { + 'ingress': ( + (platform_obsidian_feature_input.ingress | default({})) + | combine( + { + 'route_name': ( + platform_obsidian_feature_input.ingress.route_name + | default('') + | string + ), + 'shared_network_name': ( + platform_host_traefik_input.proxy_network_name + if ( + (platform_obsidian_feature_input.access_mode | string) == 'public_https' + and (platform_host_traefik_input.enabled | default(false) | bool) + ) + else '' + ) + }, + recursive=True + ) + ) + }, + recursive=True + ) + }} + when: platform_obsidian_feature_input.enabled | default(false) | bool + +- name: Compose Obsidian ingress route + ansible.builtin.set_fact: + platform_ingress_routes: "{{ platform_ingress_routes + [platform_obsidian_route] }}" + vars: + platform_obsidian_route: + name: "{{ platform_obsidian_livesync.ingress.route_name }}" + rule: "Host(`{{ platform_obsidian_livesync.base_url | regex_replace('^https?://([^/:]+).*$','\\1') }}`)" + service_url: "http://{{ platform_obsidian_livesync.couchdb.container_name }}:{{ platform_obsidian_livesync.couchdb.port }}" + when: + - platform_obsidian_feature_input.enabled | default(false) | bool + - (platform_obsidian_feature_input.access_mode | string) == 'public_https' + - platform_host_traefik_input.enabled | default(false) | bool + - (platform_obsidian_livesync.ingress.route_name | string | trim) != '' + +- name: Compose Obsidian backup dataset + ansible.builtin.set_fact: + platform_backup_datasets: "{{ platform_backup_datasets + [platform_obsidian_dataset] }}" + vars: + platform_obsidian_dataset: + name: obsidian-couchdb-data + owner: obsidian_livesync + paths: + - "{{ platform_obsidian_feature_input.couchdb.dir }}/data" + tags: + - class:application-data + - feature:obsidian-livesync + quiesce: + pre_commands: + - "docker compose -f {{ platform_obsidian_feature_input.couchdb.dir }}/docker-compose.yml stop couchdb" + post_commands: + - "docker compose -f {{ platform_obsidian_feature_input.couchdb.dir }}/docker-compose.yml start couchdb" + when: platform_obsidian_feature_input.enabled | default(false) | bool + +- name: Compose Traefik ACME backup dataset + ansible.builtin.set_fact: + platform_backup_datasets: "{{ platform_backup_datasets + [platform_traefik_dataset] }}" + vars: + platform_traefik_dataset: + name: traefik-acme + owner: host_traefik + paths: + - "{{ platform_host_traefik_input.acme_storage_path }}" + tags: + - class:host-foundation + - host:ingress + when: platform_host_traefik_input.enabled | default(false) | bool + +- name: Compose managed public HTTPS firewall request + ansible.builtin.set_fact: + platform_ufw_requests: "{{ platform_ufw_requests + [platform_https_request] }}" + vars: + platform_https_request: + name: https-public + port: "{{ platform_host_traefik_input.https_port | int }}" + proto: tcp + from: any + comment: crownops host ingress https + when: (platform_ingress_routes | length) > 0 + +- name: Compose effective host Traefik contract + ansible.builtin.set_fact: + platform_host_traefik: "{{ platform_host_traefik_input | combine({'routes': (platform_host_traefik_input.routes | default([]) | list) + platform_ingress_routes}, recursive=True) }}" + +- name: Compose effective host restic contract + ansible.builtin.set_fact: + platform_host_restic: "{{ platform_host_restic_input | combine({'datasets': (platform_host_restic_input.datasets | default([]) | list) + platform_backup_datasets}, recursive=True) }}" + +- name: Compose effective host UFW contract + ansible.builtin.set_fact: + platform_host_ufw: "{{ platform_host_ufw_input | combine({'requests': (platform_host_ufw_input.requests | default([]) | list) + platform_ufw_requests}, recursive=True) }}" diff --git a/roles/preflight_validate/tasks/main.yml b/roles/preflight_validate/tasks/main.yml index 1d21b85..e6a9065 100644 --- a/roles/preflight_validate/tasks/main.yml +++ b/roles/preflight_validate/tasks/main.yml @@ -12,11 +12,6 @@ {{ lookup('ansible.builtin.file', item, errors='ignore') | default('', true) }} {% endfor %} -- name: Load inventory group variables into localhost preflight scope - ansible.builtin.include_vars: - file: "{{ item }}" - loop: "{{ preflight_inventory_var_files }}" - - name: Check target group population ansible.builtin.set_fact: preflight_errors: "{{ preflight_errors + ['Inventory group ' ~ preflight_target_group ~ ' is empty. Define at least one deployment target host in inventories/prod/hosts.yml.'] }}" @@ -84,319 +79,431 @@ preflight_errors: "{{ preflight_errors + ['bootstrap_target_ubuntu_release must be jammy or noble. Current value: ' ~ (bootstrap_target_ubuntu_release | string) ~ '.'] }}" when: bootstrap_target_ubuntu_release is not defined or (bootstrap_target_ubuntu_release | string) not in ['jammy', 'noble'] -- name: Check HTTPS firewall path +- name: Check ops user + ansible.builtin.set_fact: + preflight_errors: "{{ preflight_errors + ['ops_user is unset or invalid.'] }}" + when: ops_user is not defined or (ops_user | string | trim) == '' + +- name: Check required nested host and feature config roots exist + ansible.builtin.set_fact: + preflight_errors: "{{ preflight_errors + ['main inventory must define nested features.obsidian_livesync, host.traefik, host.restic, and host.ufw mappings.'] }}" + when: + - features is not defined or features is not mapping + or features.obsidian_livesync is not defined + or host is not defined or host is not mapping + or host.traefik is not defined + or host.restic is not defined + or host.ufw is not defined + +- name: Normalize host and feature roots for validation + ansible.builtin.set_fact: + preflight_feature_root: "{{ vars.get('features', {}) if (vars.get('features', {}) is mapping) else {} }}" + preflight_host_root: "{{ vars.get('host', {}) if (vars.get('host', {}) is mapping) else {} }}" + +- name: Normalize nested public contracts for validation + ansible.builtin.set_fact: + preflight_obsidian_livesync: "{{ preflight_feature_root.obsidian_livesync | default({}) if ((preflight_feature_root.obsidian_livesync | default({})) is mapping) else {} }}" + preflight_host_traefik: "{{ preflight_host_root.traefik | default({}) if ((preflight_host_root.traefik | default({})) is mapping) else {} }}" + preflight_host_restic: "{{ preflight_host_root.restic | default({}) if ((preflight_host_root.restic | default({})) is mapping) else {} }}" + preflight_host_ufw: "{{ preflight_host_root.ufw | default({}) if ((preflight_host_root.ufw | default({})) is mapping) else {} }}" + +- name: Check removed legacy inventory variables are absent + ansible.builtin.set_fact: + preflight_errors: "{{ preflight_errors + ['Legacy variable ' ~ legacy_var ~ ' is still defined. Remove the flat contract and use nested host.* / features.* settings only.'] }}" + loop: + - feature_obsidian_enabled + - obsidian_access_mode + - obsidian_base_url + - obsidian_cors_origins + - obsidian_service_subdomain + - private_mesh_url_strategy + - tailscale_tailnet_name + - ufw_allowed_tcp_public + - ufw_allowed_udp_public + - traefik_dir + - traefik_network_name + - traefik_acme_email + - traefik_certresolver_name + - traefik_acme_storage + - acme_dns_provider + - acme_env + - couchdb_dir + - couchdb_container_name + - couchdb_admin_user + - couchdb_admin_password + - couchdb_bind_host + - couchdb_port + - couchdb_vaults + - internal_network_name + - restic_enabled + - restic_apt_cache_valid_time + - restic_targets + - restic_backup_jobs + - restic_backup_contributions + loop_control: + loop_var: legacy_var + when: legacy_var in vars + +- name: Check vault root + ansible.builtin.set_fact: + preflight_errors: "{{ preflight_errors + ['vault_root is unset or invalid.'] }}" + when: vault_root is not defined or (vault_root | string | trim) == '' + +- name: Check exports root + ansible.builtin.set_fact: + preflight_errors: "{{ preflight_errors + ['exports_root is unset or invalid.'] }}" + when: exports_root is not defined or (exports_root | string | trim) == '' + +- name: Check local markdown workspaces list exists ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['ufw_allowed_tcp_public must include 443 while the Obsidian feature stack is enabled.'] }}" + preflight_errors: "{{ preflight_errors + ['local_markdown_workspaces must be a non-empty list of workspace names.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - (obsidian_access_mode | default('public_https') | string) == 'public_https' - - ufw_allowed_tcp_public is not defined or 443 not in (ufw_allowed_tcp_public | list) + - local_markdown_workspaces is not defined + or local_markdown_workspaces is string + or local_markdown_workspaces is not sequence + or (local_markdown_workspaces | length) == 0 -- name: Check Obsidian access mode +- name: Check local markdown workspace names are unique ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['obsidian_access_mode must be public_https or private_mesh while the Obsidian feature stack is enabled.'] }}" + preflight_errors: "{{ preflight_errors + ['local_markdown_workspaces contains duplicate values.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - (obsidian_access_mode | default('public_https') | string) not in ['public_https', 'private_mesh'] + - local_markdown_workspaces is defined + - ((local_markdown_workspaces | unique | length) != (local_markdown_workspaces | length)) -- name: Check ops user +- name: Check path collisions between vault_root and exports_root ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['ops_user is unset or invalid.'] }}" - when: ops_user is not defined or (ops_user | string | trim) == '' + preflight_errors: "{{ preflight_errors + ['vault_root and exports_root must be different paths.'] }}" + when: + - vault_root is defined + - exports_root is defined + - (vault_root | string) == (exports_root | string) -- name: Check Traefik ACME email +- name: Check Obsidian feature access mode ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['traefik_acme_email is unset or placeholder: ' ~ (traefik_acme_email | string) ~ '.'] }}" + preflight_errors: "{{ preflight_errors + ['features.obsidian_livesync.access_mode must be public_https or private_mesh when the feature is enabled.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - (obsidian_access_mode | default('public_https') | string) == 'public_https' - - traefik_acme_email is not defined or '@' not in (traefik_acme_email | string) or 'example.invalid' in (traefik_acme_email | string) or 'change-me@' in (traefik_acme_email | string) + - preflight_obsidian_livesync.enabled | default(false) | bool + - (preflight_obsidian_livesync.access_mode | default('') | string) not in ['public_https', 'private_mesh'] -- name: Check ACME DNS provider +- name: Check Obsidian base URL ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['acme_dns_provider is unset.'] }}" + preflight_errors: "{{ preflight_errors + ['features.obsidian_livesync.base_url must be a non-placeholder http or https URL when Obsidian is enabled.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - (obsidian_access_mode | default('public_https') | string) == 'public_https' - - acme_dns_provider is not defined or (acme_dns_provider | string | trim) == '' + - preflight_obsidian_livesync.enabled | default(false) | bool + - preflight_obsidian_livesync.base_url is not defined + or (preflight_obsidian_livesync.base_url | string | trim) == '' + or 'example.invalid' in (preflight_obsidian_livesync.base_url | string) + or not ((preflight_obsidian_livesync.base_url | string) is match('^https?://')) -- name: Check ACME env exists +- name: Check Obsidian ingress route name ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['acme_env is empty. DNS-01 certificate issuance cannot work without provider credentials.'] }}" + preflight_errors: "{{ preflight_errors + ['features.obsidian_livesync.ingress.route_name must be a DNS-safe Traefik route identifier when public_https is enabled.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - (obsidian_access_mode | default('public_https') | string) == 'public_https' - - acme_env is not defined or (acme_env | length) == 0 + - preflight_obsidian_livesync.enabled | default(false) | bool + - (preflight_obsidian_livesync.access_mode | string) == 'public_https' + - preflight_obsidian_livesync.ingress is not defined + or (preflight_obsidian_livesync.ingress.route_name | default('') | string | trim) == '' + or not ((preflight_obsidian_livesync.ingress.route_name | string) is match('^[a-z0-9][a-z0-9_-]*$')) -- name: Check ACME env placeholder values +- name: Check Obsidian CouchDB contract completeness ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['acme_env still contains placeholder values. Replace all REPLACE_ME entries with real DNS provider credentials.'] }}" + preflight_errors: "{{ preflight_errors + ['features.obsidian_livesync.couchdb requires dir, container_name, internal_network_name, bind_host, port, admin_user, admin_password, and at least one vault.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - (obsidian_access_mode | default('public_https') | string) == 'public_https' - - acme_env is defined - - (acme_env | to_nice_yaml) is search('REPLACE_ME') + - preflight_obsidian_livesync.enabled | default(false) | bool + - preflight_obsidian_livesync.couchdb is not defined + or (preflight_obsidian_livesync.couchdb.dir | default('') | string | trim) == '' + or (preflight_obsidian_livesync.couchdb.container_name | default('') | string | trim) == '' + or (preflight_obsidian_livesync.couchdb.internal_network_name | default('') | string | trim) == '' + or (preflight_obsidian_livesync.couchdb.bind_host | default('') | string | trim) == '' + or (preflight_obsidian_livesync.couchdb.admin_user | default('') | string | trim) == '' + or (preflight_obsidian_livesync.couchdb.admin_password | default('') | string | trim) == '' + or (preflight_obsidian_livesync.couchdb.vaults | default([]) | length) == 0 -- name: Check private mesh base URL +- name: Check duplicate Obsidian vault names ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['obsidian_base_url must be a non-placeholder http or https URL while Obsidian is enabled in private_mesh mode.'] }}" + preflight_errors: "{{ preflight_errors + ['features.obsidian_livesync.couchdb.vaults contains duplicate name values.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - (obsidian_access_mode | default('public_https') | string) == 'private_mesh' - - obsidian_base_url is not defined or (obsidian_base_url | string | trim) == '' or 'example.invalid' in (obsidian_base_url | string) or not ((obsidian_base_url | string) is match('^https?://')) + - preflight_obsidian_livesync.enabled | default(false) | bool + - ((preflight_obsidian_livesync.couchdb.vaults | default([]) | map(attribute='name') | list | unique | length) != (preflight_obsidian_livesync.couchdb.vaults | default([]) | map(attribute='name') | list | length)) -- name: Check Tailscale MagicDNS planning inputs +- name: Check duplicate Obsidian vault database names ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['tailscale_tailnet_name must be set when private_mesh uses the tailscale_magicdns URL strategy.'] }}" + preflight_errors: "{{ preflight_errors + ['features.obsidian_livesync.couchdb.vaults contains duplicate db_name values.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - (obsidian_access_mode | default('public_https') | string) == 'private_mesh' - - (private_mesh_url_strategy | default('explicit_url') | string) == 'tailscale_magicdns' - - tailscale_tailnet_name is not defined or (tailscale_tailnet_name | string | trim) == '' + - preflight_obsidian_livesync.enabled | default(false) | bool + - ((preflight_obsidian_livesync.couchdb.vaults | default([]) | map(attribute='db_name') | list | unique | length) != (preflight_obsidian_livesync.couchdb.vaults | default([]) | map(attribute='db_name') | list | length)) -- name: Check Tailscale hostname format for MagicDNS planning +- name: Check duplicate Obsidian vault users ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['tailscale_hostname must be a DNS-safe machine name when private_mesh uses tailscale_magicdns. Use only letters, numbers, and hyphens.'] }}" + preflight_errors: "{{ preflight_errors + ['features.obsidian_livesync.couchdb.vaults contains duplicate user values.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - (obsidian_access_mode | default('public_https') | string) == 'private_mesh' - - (private_mesh_url_strategy | default('explicit_url') | string) == 'tailscale_magicdns' - - tailscale_hostname is not defined or not ((tailscale_hostname | string) is match('^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$')) + - preflight_obsidian_livesync.enabled | default(false) | bool + - ((preflight_obsidian_livesync.couchdb.vaults | default([]) | map(attribute='user') | list | unique | length) != (preflight_obsidian_livesync.couchdb.vaults | default([]) | map(attribute='user') | list | length)) -- name: Check Tailscale tailnet input format for MagicDNS planning +- name: Check Obsidian vault placeholders ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['tailscale_tailnet_name must be the tailnet name only, without the .ts.net suffix, when private_mesh uses tailscale_magicdns.'] }}" + preflight_errors: "{{ preflight_errors + ['features.obsidian_livesync.couchdb.vaults still contains placeholder values.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - (obsidian_access_mode | default('public_https') | string) == 'private_mesh' - - (private_mesh_url_strategy | default('explicit_url') | string) == 'tailscale_magicdns' - - tailscale_tailnet_name is defined - - (tailscale_tailnet_name | string | trim) != '' - - (tailscale_tailnet_name | string) is search('\\.ts\\.net$') + - preflight_obsidian_livesync.enabled | default(false) | bool + - (preflight_obsidian_livesync.couchdb.vaults | default([]) | to_nice_yaml) is search('REPLACE_ME') -- name: Check private mesh URL for duplicated ts.net suffix +- name: Check public HTTPS requires host Traefik enablement ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['obsidian_base_url contains a duplicated .ts.net suffix. Review tailscale_hostname and tailscale_tailnet_name planning inputs.'] }}" + preflight_errors: "{{ preflight_errors + ['features.obsidian_livesync.access_mode=public_https requires host.traefik.enabled=true.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - (obsidian_access_mode | default('public_https') | string) == 'private_mesh' - - obsidian_base_url is defined - - (obsidian_base_url | string) is search('\\.ts\\.net\\.ts\\.net') + - preflight_obsidian_livesync.enabled | default(false) | bool + - (preflight_obsidian_livesync.access_mode | string) == 'public_https' + - not (preflight_host_traefik.enabled | default(false) | bool) -- name: Check private mesh does not expose CouchDB publicly +- name: Check public HTTPS ACME email ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['private_mesh mode must not expose CouchDB on the public firewall. Remove 5984 from ufw_allowed_tcp_public or keep CouchDB bound to a non-public interface.'] }}" + preflight_errors: "{{ preflight_errors + ['host.traefik.acme_email is unset or placeholder while public HTTPS ingress is required.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - (obsidian_access_mode | default('public_https') | string) == 'private_mesh' - - ufw_allowed_tcp_public is defined - - 5984 in (ufw_allowed_tcp_public | list) + - preflight_obsidian_livesync.enabled | default(false) | bool + - (preflight_obsidian_livesync.access_mode | string) == 'public_https' + - preflight_host_traefik.acme_email is not defined or '@' not in (preflight_host_traefik.acme_email | string) or 'example.invalid' in (preflight_host_traefik.acme_email | string) or 'change-me@' in (preflight_host_traefik.acme_email | string) -- name: Check CouchDB admin credentials +- name: Check public HTTPS DNS provider ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['couchdb_admin_user or couchdb_admin_password is unset or placeholder.'] }}" + preflight_errors: "{{ preflight_errors + ['host.traefik.dns_provider is unset while public HTTPS ingress is required.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - "'couchdb_admin_user:' not in preflight_config_dump or 'couchdb_admin_password:' not in preflight_config_dump or 'couchdb_admin_password: REPLACE_ME' in preflight_config_dump" + - preflight_obsidian_livesync.enabled | default(false) | bool + - (preflight_obsidian_livesync.access_mode | string) == 'public_https' + - preflight_host_traefik.dns_provider is not defined or (preflight_host_traefik.dns_provider | string | trim) == '' -- name: Check synced vault definitions exist +- name: Check public HTTPS DNS environment ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['couchdb_vaults must contain at least one sync account definition while the Obsidian feature stack is enabled.'] }}" + preflight_errors: "{{ preflight_errors + ['host.traefik.dns_env is empty or still contains placeholder values while public HTTPS ingress is required.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - "'couchdb_vaults:' not in preflight_config_dump or 'couchdb_vaults: []' in preflight_config_dump" + - preflight_obsidian_livesync.enabled | default(false) | bool + - (preflight_obsidian_livesync.access_mode | string) == 'public_https' + - preflight_host_traefik.dns_env is not defined + or (preflight_host_traefik.dns_env | length) == 0 + or (preflight_host_traefik.dns_env | to_nice_yaml) is search('REPLACE_ME') -- name: Check duplicate synced vault names +- name: Check private mesh MagicDNS tailnet input ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['Duplicate sync account names found in couchdb_vaults. Names must be unique.'] }}" + preflight_errors: "{{ preflight_errors + ['features.obsidian_livesync.private_mesh.tailnet_name must be set to the tailnet name only when private_mesh.url_strategy=tailscale_magicdns.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - couchdb_vaults is defined - - ((couchdb_vaults | map(attribute='name') | list | unique | length) != (couchdb_vaults | map(attribute='name') | list | length)) + - preflight_obsidian_livesync.enabled | default(false) | bool + - (preflight_obsidian_livesync.access_mode | string) == 'private_mesh' + - (preflight_obsidian_livesync.private_mesh.url_strategy | default('') | string) == 'tailscale_magicdns' + - preflight_obsidian_livesync.private_mesh.tailnet_name is not defined + or (preflight_obsidian_livesync.private_mesh.tailnet_name | string | trim) == '' + or (preflight_obsidian_livesync.private_mesh.tailnet_name | string) is search('\\.ts\\.net$') -- name: Check duplicate synced vault db names +- name: Check Tailscale hostname format for MagicDNS planning ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['Duplicate CouchDB db_name values found in couchdb_vaults.'] }}" + preflight_errors: "{{ preflight_errors + ['tailscale_hostname must be a DNS-safe machine name when private_mesh uses tailscale_magicdns.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - couchdb_vaults is defined - - ((couchdb_vaults | map(attribute='db_name') | list | unique | length) != (couchdb_vaults | map(attribute='db_name') | list | length)) + - preflight_obsidian_livesync.enabled | default(false) | bool + - (preflight_obsidian_livesync.access_mode | string) == 'private_mesh' + - (preflight_obsidian_livesync.private_mesh.url_strategy | default('') | string) == 'tailscale_magicdns' + - tailscale_hostname is not defined or not ((tailscale_hostname | string) is match('^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$')) -- name: Check duplicate synced vault users +- name: Check private mesh base URL does not duplicate ts.net suffix ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['Duplicate CouchDB user values found in couchdb_vaults.'] }}" + preflight_errors: "{{ preflight_errors + ['features.obsidian_livesync.base_url contains a duplicated .ts.net suffix. Review tailscale_hostname and private_mesh.tailnet_name.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - couchdb_vaults is defined - - ((couchdb_vaults | map(attribute='user') | list | unique | length) != (couchdb_vaults | map(attribute='user') | list | length)) + - preflight_obsidian_livesync.enabled | default(false) | bool + - (preflight_obsidian_livesync.access_mode | string) == 'private_mesh' + - (preflight_obsidian_livesync.base_url | string) is search('\\.ts\\.net\\.ts\\.net') -- name: Check synced vault placeholders +- name: Check host Traefik manage mode ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['One or more couchdb_vaults entries still contains placeholder password values.'] }}" + preflight_errors: "{{ preflight_errors + ['host.traefik.manage_mode must be managed or adopt_managed when Traefik is enabled.'] }}" when: - - feature_obsidian_enabled | default(true) | bool - - couchdb_vaults is defined - - (couchdb_vaults | to_nice_yaml) is search('REPLACE_ME') + - preflight_host_traefik.enabled | default(false) | bool + - (preflight_host_traefik.manage_mode | default('') | string) not in ['managed', 'adopt_managed'] -- name: Check duplicate local workspace names +- name: Check host Traefik required contract fields ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['local_markdown_workspaces contains duplicate values.'] }}" + preflight_errors: "{{ preflight_errors + ['host.traefik.enabled=true requires layout_root, static_config_path, dynamic_config_root, dynamic_routes_dir, acme_storage_path, proxy_network_name, container_name, certificate_resolver_name, acme_email, dns_provider, and dns_env.'] }}" when: - - local_markdown_workspaces is defined - - ((local_markdown_workspaces | unique | length) != (local_markdown_workspaces | length)) + - preflight_host_traefik.enabled | default(false) | bool + - (preflight_host_traefik.layout_root | default('') | string | trim) == '' + or (preflight_host_traefik.static_config_path | default('') | string | trim) == '' + or (preflight_host_traefik.dynamic_config_root | default('') | string | trim) == '' + or (preflight_host_traefik.dynamic_routes_dir | default('') | string | trim) == '' + or (preflight_host_traefik.acme_storage_path | default('') | string | trim) == '' + or (preflight_host_traefik.proxy_network_name | default('') | string | trim) == '' + or (preflight_host_traefik.container_name | default('') | string | trim) == '' + or (preflight_host_traefik.certificate_resolver_name | default('') | string | trim) == '' + or (preflight_host_traefik.acme_email | default('') | string | trim) == '' + or (preflight_host_traefik.dns_provider | default('') | string | trim) == '' + or (preflight_host_traefik.dns_env | default({}) | length) == 0 -- name: Check path collisions between vault_root and exports_root +- name: Check host Traefik route definitions ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['vault_root and exports_root must be different paths.'] }}" + preflight_errors: "{{ preflight_errors + ['Every effective host.traefik route must define a DNS-safe name, a rule, and a service_url. When entrypoints are set explicitly they must be non-empty. Invalid route: ' ~ (item.name | default('unnamed')) ~ '.'] }}" + loop: "{{ platform_host_traefik.routes | default([]) }}" + loop_control: + label: "{{ item.name | default('unnamed') }}" when: - - vault_root is defined - - exports_root is defined - - (vault_root | string) == (exports_root | string) + - preflight_host_traefik.enabled | default(false) | bool + - (item.name | default('') | string | trim) == '' + or not ((item.name | string) is match('^[a-z0-9][a-z0-9_-]*$')) + or (item.rule | default('') | string | trim) == '' + or (item.service_url | default('') | string | trim) == '' + or (item.entrypoints is defined and ((item.entrypoints | list | length) == 0)) -- name: Check restic targets defined when backups are enabled +- name: Check duplicate effective Traefik route names ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['restic_enabled=true but restic_targets is empty. Define at least one enabled backup target.'] }}" + preflight_errors: "{{ preflight_errors + ['platform_bindings produced duplicate Traefik route names across host.traefik.routes and composed feature routes.'] }}" when: - - restic_enabled | default(true) | bool - - "'restic_targets:' not in preflight_config_dump or 'restic_targets: []' in preflight_config_dump" + - preflight_host_traefik.enabled | default(false) | bool + - ((platform_host_traefik.routes | default([]) | map(attribute='name') | list | unique | length) != (platform_host_traefik.routes | default([]) | length)) -- name: Check restic jobs defined when backups are enabled +- name: Check host restic targets are defined when enabled ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['restic_enabled=true but restic_backup_jobs is empty. Define at least one logical backup job.'] }}" + preflight_errors: "{{ preflight_errors + ['host.restic.enabled=true but host.restic.targets is empty. Define at least one enabled backup target.'] }}" when: - - restic_enabled | default(true) | bool - - restic_backup_jobs is not defined or (restic_backup_jobs | length) == 0 + - preflight_host_restic.enabled | default(false) | bool + - (preflight_host_restic.targets | default([]) | length) == 0 -- name: Check restic apt cache reuse window format +- name: Check host restic jobs are defined when enabled ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['restic_apt_cache_valid_time must be an integer greater than or equal to 0 so backup package installation uses a valid apt cache policy.'] }}" + preflight_errors: "{{ preflight_errors + ['host.restic.enabled=true but host.restic.jobs is empty. Define at least one logical backup job.'] }}" when: - - restic_enabled | default(true) | bool - - restic_apt_cache_valid_time is defined - - not ((restic_apt_cache_valid_time | string) is match('^[0-9]+$')) + - preflight_host_restic.enabled | default(false) | bool + - (preflight_host_restic.jobs | default([]) | length) == 0 + +- name: Check host restic apt cache reuse window format + ansible.builtin.set_fact: + preflight_errors: "{{ preflight_errors + ['host.restic.apt_cache_valid_time must be an integer greater than or equal to 0.'] }}" + when: + - preflight_host_restic.enabled | default(false) | bool + - preflight_host_restic.apt_cache_valid_time is not defined or not ((preflight_host_restic.apt_cache_valid_time | string) is match('^[0-9]+$')) - name: Check duplicate restic target names ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['restic_targets contains duplicate target names.'] }}" + preflight_errors: "{{ preflight_errors + ['host.restic.targets contains duplicate target names.'] }}" when: - - restic_enabled | default(true) | bool - - restic_targets is defined - - ((restic_targets | map(attribute='name') | list | unique | length) != (restic_targets | map(attribute='name') | list | length)) + - preflight_host_restic.enabled | default(false) | bool + - ((preflight_host_restic.targets | default([]) | map(attribute='name') | list | unique | length) != (preflight_host_restic.targets | default([]) | map(attribute='name') | list | length)) - name: Check restic target naming format ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['Every restic target name must be lowercase and match ^[a-z0-9][a-z0-9_-]*$. Invalid target names: ' ~ (preflight_invalid_restic_target_names | join(', ')) ~ '.'] }}" + preflight_errors: "{{ preflight_errors + ['Every host.restic target name must be lowercase and match ^[a-z0-9][a-z0-9_-]*$. Invalid target names: ' ~ (preflight_invalid_restic_target_names | join(', ')) ~ '.'] }}" vars: preflight_invalid_restic_target_names: >- {{ - restic_targets - | default([]) + preflight_host_restic.targets | default([]) | map(attribute='name') | reject('match', '^[a-z0-9][a-z0-9_-]*$') | list }} when: - - restic_enabled | default(true) | bool - - restic_targets is defined + - preflight_host_restic.enabled | default(false) | bool - (preflight_invalid_restic_target_names | length) > 0 - name: Check duplicate restic job names ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['restic_backup_jobs contains duplicate job names.'] }}" + preflight_errors: "{{ preflight_errors + ['host.restic.jobs contains duplicate job names.'] }}" when: - - restic_enabled | default(true) | bool - - restic_backup_jobs is defined - - ((restic_backup_jobs | map(attribute='name') | list | unique | length) != (restic_backup_jobs | map(attribute='name') | list | length)) + - preflight_host_restic.enabled | default(false) | bool + - ((preflight_host_restic.jobs | default([]) | map(attribute='name') | list | unique | length) != (preflight_host_restic.jobs | default([]) | map(attribute='name') | list | length)) + +- name: Check duplicate effective restic dataset names + ansible.builtin.set_fact: + preflight_errors: "{{ preflight_errors + ['platform_bindings produced duplicate restic dataset names across host.restic.datasets and composed datasets.'] }}" + when: + - preflight_host_restic.enabled | default(false) | bool + - ((platform_host_restic.datasets | default([]) | map(attribute='name') | list | unique | length) != (platform_host_restic.datasets | default([]) | map(attribute='name') | list | length)) - name: Check restic target placeholders ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['restic_targets still contains placeholder repository or secret references.'] }}" + preflight_errors: "{{ preflight_errors + ['host.restic.targets still contains placeholder repository or secret references.'] }}" when: - - restic_enabled | default(true) | bool - - restic_targets is defined + - preflight_host_restic.enabled | default(false) | bool - > - ('example.invalid' in (restic_targets | to_nice_yaml)) - or ('REPLACE_ME' in (restic_targets | to_nice_yaml)) + ('example.invalid' in (preflight_host_restic.targets | default([]) | to_nice_yaml)) + or ('REPLACE_ME' in (preflight_host_restic.targets | default([]) | to_nice_yaml)) - name: Check restic target passwords ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['Every restic target must resolve to a non-placeholder password in vault_restic_target_secrets.'] }}" + preflight_errors: "{{ preflight_errors + ['Every host.restic target must resolve to a non-placeholder password in vault_restic_target_secrets.'] }}" when: - - restic_enabled | default(true) | bool + - preflight_host_restic.enabled | default(false) | bool - vault_restic_target_secrets is defined - (vault_restic_target_secrets | to_nice_yaml) is search('REPLACE_ME') +- name: Check unsupported feature-owned restic jobs remain empty + ansible.builtin.set_fact: + preflight_errors: "{{ preflight_errors + ['host.restic.feature_owned_jobs is intentionally unsupported in this refactor and must remain empty.'] }}" + when: + - preflight_host_restic.enabled | default(false) | bool + - (preflight_host_restic.feature_owned_jobs | default([]) | length) > 0 + - name: Check restic jobs have valid target_names ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['restic backup job ' ~ (item.name | default('unnamed')) ~ ' must define target_names as a non-empty list of target names.'] }}" - loop: "{{ restic_backup_jobs | default([]) }}" + preflight_errors: "{{ preflight_errors + ['host.restic job ' ~ (item.name | default('unnamed')) ~ ' must define target_names as a non-empty list of target names.'] }}" + loop: "{{ preflight_host_restic.jobs | default([]) }}" loop_control: label: "{{ item.name | default('unnamed') }}" when: - - restic_enabled | default(true) | bool + - preflight_host_restic.enabled | default(false) | bool - item.target_names is not defined or item.target_names is string or item.target_names is not sequence or (item.target_names | length) == 0 - name: Check restic job target references ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['restic backup job ' ~ (item.name | default('unnamed')) ~ ' references unknown target names: ' ~ ((item.target_names | difference(preflight_restic_target_names)) | join(', ')) ~ '.'] }}" - loop: "{{ restic_backup_jobs | default([]) }}" + preflight_errors: "{{ preflight_errors + ['host.restic job ' ~ (item.name | default('unnamed')) ~ ' references unknown target names: ' ~ ((item.target_names | difference(preflight_restic_target_names)) | join(', ')) ~ '.'] }}" + loop: "{{ preflight_host_restic.jobs | default([]) }}" loop_control: label: "{{ item.name | default('unnamed') }}" vars: - preflight_restic_target_names: "{{ restic_targets | default([]) | map(attribute='name') | list }}" + preflight_restic_target_names: "{{ preflight_host_restic.targets | default([]) | map(attribute='name') | list }}" when: - - restic_enabled | default(true) | bool - - restic_targets is defined + - preflight_host_restic.enabled | default(false) | bool - item.target_names is defined - item.target_names is sequence - item.target_names is not string - (item.target_names | length) > 0 - (item.target_names | difference(preflight_restic_target_names) | length) > 0 -- name: Check restic jobs have paths or contributions +- name: Check restic jobs select datasets ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['Each restic backup job must include paths directly or receive paths through restic_backup_contributions.'] }}" - vars: - preflight_restic_job_names_with_contributions: "{{ restic_backup_contributions | default([]) | map(attribute='job') | list }}" + preflight_errors: "{{ preflight_errors + ['host.restic job ' ~ (item.name | default('unnamed')) ~ ' must select datasets via dataset_names and/or selector_tags.'] }}" + loop: "{{ preflight_host_restic.jobs | default([]) }}" + loop_control: + label: "{{ item.name | default('unnamed') }}" when: - - restic_enabled | default(true) | bool - - restic_backup_jobs is defined - - >- - ( - restic_backup_jobs - | rejectattr('paths', 'defined') - | map(attribute='name') - | list - ) | difference(preflight_restic_job_names_with_contributions) | length > 0 + - preflight_host_restic.enabled | default(false) | bool + - (item.dataset_names | default([]) | length) == 0 + - (item.selector_tags | default([]) | length) == 0 -- name: Check restic contribution job references +- name: Check restic jobs resolve at least one effective dataset ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['restic_backup_contributions references one or more undefined jobs.'] }}" - when: - - restic_enabled | default(true) | bool - - restic_backup_contributions is defined - - > - ( - restic_backup_contributions - | map(attribute='job') - | list - | difference(restic_backup_jobs | default([]) | map(attribute='name') | list) - | length - ) > 0 - -- name: Warn on broad restic backup roots - ansible.builtin.set_fact: - preflight_warnings: "{{ preflight_warnings + ['restic backup paths include broad service roots (' ~ (preflight_broad_restic_paths | join(', ')) ~ '). Prefer precise durable state only, such as ' ~ (preflight_restic_precise_examples | join(', ')) ~ ', so backups do not capture rebuildable configs, exports, or package bloat.'] }}" + preflight_errors: "{{ preflight_errors + ['host.restic job ' ~ (item.name | default('unnamed')) ~ ' does not match any effective backup dataset.'] }}" + loop: "{{ preflight_host_restic.jobs | default([]) }}" + loop_control: + label: "{{ item.name | default('unnamed') }}" + vars: + preflight_matching_dataset_names: >- + {%- set matched = [] -%} + {%- for dataset in platform_host_restic.datasets | default([]) -%} + {%- if dataset.name in (item.dataset_names | default([])) + or (((dataset.tags | default([])) | intersect(item.selector_tags | default([]))) | length) > 0 -%} + {%- set _ = matched.append(dataset.name) -%} + {%- endif -%} + {%- endfor -%} + {{ matched }} + when: + - preflight_host_restic.enabled | default(false) | bool + - (item.dataset_names | default([]) | length) > 0 or (item.selector_tags | default([]) | length) > 0 + - (preflight_matching_dataset_names | unique | length) == 0 + +- name: Warn on broad backup dataset roots + ansible.builtin.set_fact: + preflight_warnings: "{{ preflight_warnings + ['Effective backup datasets include broad service roots (' ~ (preflight_broad_restic_paths | join(', ')) ~ '). Prefer precise durable state only, such as ' ~ (preflight_restic_precise_examples | join(', ')) ~ ', so backups do not capture rebuildable configs, exports, or package bloat.'] }}" vars: preflight_broad_restic_path_candidates: >- {{ [ - couchdb_dir | default(''), - traefik_dir | default(''), + preflight_obsidian_livesync.couchdb.dir | default(''), + preflight_host_traefik.layout_root | default(''), vault_root | default(''), - (vault_root | default('')) ~ '/synced', exports_root | default(''), - (exports_root | default('')) ~ '/shared', '/srv/crownops' ] | reject('equalto', '') @@ -406,20 +513,11 @@ preflight_broad_restic_paths: >- {{ ( - ( - restic_backup_jobs | default([]) - | map(attribute='paths') - | select('defined') - | list - | sum(start=[]) - ) - + ( - restic_backup_contributions | default([]) - | map(attribute='paths') - | select('defined') - | list - | sum(start=[]) - ) + platform_host_restic.datasets | default([]) + | map(attribute='paths') + | select('defined') + | list + | sum(start=[]) ) | select('in', preflight_broad_restic_path_candidates) | unique @@ -428,20 +526,20 @@ preflight_restic_precise_examples: >- {{ [ - (couchdb_dir | default('/opt/couchdb')) ~ '/data', - traefik_acme_storage | default('/opt/traefik/acme/acme.json'), + (preflight_obsidian_livesync.couchdb.dir | default('/opt/couchdb')) ~ '/data', + preflight_host_traefik.acme_storage_path | default('/opt/traefik/acme/acme.json'), (vault_root | default('/srv/crownops/vaults')) ~ '/workspaces' ] }} when: - - restic_enabled | default(true) | bool + - preflight_host_restic.enabled | default(false) | bool - (preflight_broad_restic_paths | length) > 0 - name: Check restic SSH known_hosts requirements ansible.builtin.set_fact: - preflight_errors: "{{ preflight_errors + ['Each restic target that uses an SSH private key must also define ssh_known_hosts.'] }}" + preflight_errors: "{{ preflight_errors + ['Each host.restic target that uses an SSH private key must also define ssh_known_hosts.'] }}" when: - - restic_enabled | default(true) | bool + - preflight_host_restic.enabled | default(false) | bool - vault_restic_target_secrets is defined - >- ( @@ -452,6 +550,75 @@ | list ) | length > 0 +- name: Check host UFW baseline port format + ansible.builtin.set_fact: + preflight_errors: "{{ preflight_errors + ['host.ufw baseline public port lists must contain only integers.'] }}" + when: + - preflight_host_ufw.enabled | default(false) | bool + - > + ( + (preflight_host_ufw.baseline_tcp_public | default([]) | map('string') | reject('match', '^[0-9]+$') | list | length) > 0 + ) or ( + (preflight_host_ufw.baseline_udp_public | default([]) | map('string') | reject('match', '^[0-9]+$') | list | length) > 0 + ) + +- name: Check public HTTPS route composition + ansible.builtin.set_fact: + preflight_errors: "{{ preflight_errors + ['platform_bindings did not compose an ingress route for public_https Obsidian.'] }}" + when: + - preflight_obsidian_livesync.enabled | default(false) | bool + - (preflight_obsidian_livesync.access_mode | string) == 'public_https' + - (platform_ingress_routes | default([]) | length) == 0 + +- name: Check private mesh does not compose ingress routes + ansible.builtin.set_fact: + preflight_errors: "{{ preflight_errors + ['platform_bindings must not compose ingress routes for private_mesh Obsidian.'] }}" + when: + - preflight_obsidian_livesync.enabled | default(false) | bool + - (preflight_obsidian_livesync.access_mode | string) == 'private_mesh' + - (platform_ingress_routes | default([]) | length) > 0 + +- name: Check composed Obsidian dataset exists + ansible.builtin.set_fact: + preflight_errors: "{{ preflight_errors + ['platform_bindings did not compose the obsidian-couchdb-data dataset while Obsidian is enabled.'] }}" + when: + - preflight_obsidian_livesync.enabled | default(false) | bool + - "'obsidian-couchdb-data' not in (platform_host_restic.datasets | default([]) | map(attribute='name') | list)" + +- name: Check composed Traefik dataset exists + ansible.builtin.set_fact: + preflight_errors: "{{ preflight_errors + ['platform_bindings did not compose the traefik-acme dataset while host Traefik is enabled.'] }}" + when: + - preflight_host_traefik.enabled | default(false) | bool + - "'traefik-acme' not in (platform_host_restic.datasets | default([]) | map(attribute='name') | list)" + +- name: Check composed HTTPS firewall request exists + ansible.builtin.set_fact: + preflight_errors: "{{ preflight_errors + ['platform_bindings did not compose an https-public UFW request for public HTTPS ingress.'] }}" + when: + - preflight_obsidian_livesync.enabled | default(false) | bool + - (preflight_obsidian_livesync.access_mode | string) == 'public_https' + - "'https-public' not in (platform_ufw_requests | default([]) | map(attribute='name') | list)" + +- name: Check private mesh does not expose CouchDB publicly + ansible.builtin.set_fact: + preflight_errors: "{{ preflight_errors + ['private_mesh mode must not expose CouchDB on the public firewall. Remove any public 5984 rules from host.ufw.'] }}" + vars: + preflight_all_public_ufw_ports: >- + {{ + (preflight_host_ufw.baseline_tcp_public | default([])) + + ( + platform_host_ufw.requests | default([]) + | selectattr('proto', 'equalto', 'tcp') + | map(attribute='port') + | list + ) + }} + when: + - preflight_obsidian_livesync.enabled | default(false) | bool + - (preflight_obsidian_livesync.access_mode | string) == 'private_mesh' + - 5984 in preflight_all_public_ufw_ports + - name: Warn when Tailscale join is manual ansible.builtin.set_fact: preflight_warnings: "{{ preflight_warnings + ['tailscale_auth_key is blank. Bootstrap will install Tailscale but you must join manually before running restrictive lockdown.'] }}" diff --git a/scripts/install-collections.sh b/scripts/install-collections.sh index e2d7114..48d32f4 100755 --- a/scripts/install-collections.sh +++ b/scripts/install-collections.sh @@ -5,6 +5,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORKSPACE_DIR="$(dirname "${ROOT_DIR}")" COLLECTIONS_PATH="${ROOT_DIR}/.ansible/collections" STATE_PATH="${ROOT_DIR}/.ansible/collection-state" +LOCAL_TEMP_PATH="${ROOT_DIR}/.ansible/tmp" PUBLIC_REQUIREMENTS="${ROOT_DIR}/collections/requirements.yml" BASE_COLLECTION_REMOTE_DEFAULT="git+https://github.com/CrownOpsEng/crownops-deploy-base.git" SERVICES_COLLECTION_REMOTE_DEFAULT="git+https://github.com/CrownOpsEng/crownops-deploy-services.git" @@ -204,6 +205,10 @@ command -v ansible-galaxy >/dev/null 2>&1 || die "ansible-galaxy is required on mkdir -p "${COLLECTIONS_PATH}" mkdir -p "${STATE_PATH}" +mkdir -p "${LOCAL_TEMP_PATH}" + +chmod 700 "${LOCAL_TEMP_PATH}" +export ANSIBLE_LOCAL_TEMP="${ANSIBLE_LOCAL_TEMP:-${LOCAL_TEMP_PATH}}" install_requirements install_named_collection 2 "crownops.deploy_base" "${BASE_COLLECTION_SOURCE}" diff --git a/tests/smoke/lib.bash b/tests/smoke/lib.bash new file mode 100644 index 0000000..752537a --- /dev/null +++ b/tests/smoke/lib.bash @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +umask 077 + +create_smoke_tmpdir() { + local root_dir="$1" + local base_tmp="${TMPDIR:-${root_dir}/.tmp}" + local temp_dir + + mkdir -p "${base_tmp}" + temp_dir="$(mktemp -d -p "${base_tmp}" smoke.XXXXXX)" + chmod 700 "${temp_dir}" + printf '%s\n' "${temp_dir}" +} diff --git a/tests/smoke/test_builder_obsidian_setup_uri_generation.sh b/tests/smoke/test_builder_obsidian_setup_uri_generation.sh index fc81b2b..5564694 100644 --- a/tests/smoke/test_builder_obsidian_setup_uri_generation.sh +++ b/tests/smoke/test_builder_obsidian_setup_uri_generation.sh @@ -60,6 +60,9 @@ raw = { first = build_crownops_deploy_core(raw) second = build_crownops_deploy_core(raw) +if "feature_obsidian_enabled" in first or "restic_targets" in first: + raise SystemExit("builder should not expose removed flat inventory keys in rendered context") + if counter["value"] != 2: raise SystemExit(f"expected two generated passphrases, got {counter['value']}") @@ -97,5 +100,13 @@ for key, value in { if settings.get(key) != value: raise SystemExit(f"unexpected secure default {key}: {settings.get(key)!r}") +obsidian_config = first["features"]["obsidian_livesync"] +if not obsidian_config["enabled"]: + raise SystemExit("expected obsidian feature config to stay enabled") +if obsidian_config["private_mesh"]["url_strategy"] != "tailscale_magicdns": + raise SystemExit(f"unexpected private mesh strategy: {obsidian_config['private_mesh']['url_strategy']!r}") +if obsidian_config["base_url"] != "http://core.example.ts.net:5984": + raise SystemExit(f"unexpected nested obsidian base URL: {obsidian_config['base_url']!r}") + print("builder obsidian setup URI generation smoke test passed") PY diff --git a/tests/smoke/test_builder_restic_target_keypair_consistency.sh b/tests/smoke/test_builder_restic_target_keypair_consistency.sh index 4f5b81c..e5a7da0 100644 --- a/tests/smoke/test_builder_restic_target_keypair_consistency.sh +++ b/tests/smoke/test_builder_restic_target_keypair_consistency.sh @@ -63,6 +63,9 @@ raw = { first = build_crownops_deploy_core(raw) second = build_crownops_deploy_core(raw) +if "restic_targets" in first or "restic_backup_jobs" in first: + raise SystemExit("builder should not expose removed flat restic output keys") + if counter["value"] != 1: raise SystemExit(f"expected generated restic keypair to be cached across builder calls, got {counter['value']} generations") @@ -86,5 +89,9 @@ for result in (first, second): if private_key != expected_private_key: raise SystemExit(f"unexpected vault private key: {private_key!r}") + nested_target = result["host"]["restic"]["targets"][0] + if nested_target["name"] != "h4f": + raise SystemExit(f"unexpected nested restic target name: {nested_target['name']!r}") + print("builder restic target keypair consistency smoke test passed") PY diff --git a/tests/smoke/test_builder_restic_target_name_normalization.sh b/tests/smoke/test_builder_restic_target_name_normalization.sh index 0a22553..ccb8e23 100644 --- a/tests/smoke/test_builder_restic_target_name_normalization.sh +++ b/tests/smoke/test_builder_restic_target_name_normalization.sh @@ -55,24 +55,31 @@ result = build_crownops_deploy_core( } ) -target_names = [item["name"] for item in result["restic_targets"]] +if "restic_targets" in result or "restic_backup_jobs" in result or "restic_backup_contributions" in result: + raise SystemExit("builder should not expose removed flat backup keys") + +target_names = [item["name"] for item in result["host"]["restic"]["targets"]] if target_names != ["h4f", "laptop_backup"]: raise SystemExit(f"unexpected normalized restic target names: {target_names!r}") -if result["restic_targets"][0]["sftp_port"] != 2222: - raise SystemExit(f"expected first restic target to preserve sftp_port, got {result['restic_targets'][0]['sftp_port']!r}") +if result["host"]["restic"]["targets"][0]["sftp_port"] != 2222: + raise SystemExit(f"expected first restic target to preserve sftp_port, got {result['host']['restic']['targets'][0]['sftp_port']!r}") -for job in result["restic_backup_jobs"]: +for job in result["host"]["restic"]["jobs"]: if job["target_names"] != ["h4f", "laptop_backup"]: raise SystemExit(f"unexpected backup job target names: {job['target_names']!r}") + if "selector_tags" not in job: + raise SystemExit(f"expected dataset selector_tags in nested restic job, got {job!r}") vault_keys = list(result["vault_restic_target_secrets"].keys()) if vault_keys != ["h4f", "laptop_backup"]: raise SystemExit(f"unexpected vault target secret keys: {vault_keys!r}") -application_job = next(job for job in result["restic_backup_jobs"] if job["name"] == "application-data") -if application_job["paths"] != ["{{ vault_root }}/workspaces"]: - raise SystemExit(f"unexpected application-data paths for non-obsidian deployment: {application_job['paths']!r}") +application_job = next(job for job in result["host"]["restic"]["jobs"] if job["name"] == "application-data") +if application_job["selector_tags"] != ["class:application-data"]: + raise SystemExit(f"unexpected application-data dataset selectors: {application_job['selector_tags']!r}") +if result["host"]["traefik"]["enabled"]: + raise SystemExit("traefik should stay disabled for the non-obsidian builder scenario") obsidian_result = build_crownops_deploy_core( { @@ -88,6 +95,7 @@ obsidian_result = build_crownops_deploy_core( "traefik_acme_email": "ops@example.com", "acme_dns_provider": "cloudflare", "acme_env": {"CF_DNS_API_TOKEN": "token"}, + "ufw_allowed_tcp_public": [22], "restic_enabled": True, "restic_targets_input": [ { @@ -100,11 +108,20 @@ obsidian_result = build_crownops_deploy_core( } ) -contributions = {item["job"]: item["paths"] for item in obsidian_result["restic_backup_contributions"]} -if contributions.get("host-foundation") != ["{{ traefik_acme_storage }}"]: - raise SystemExit(f"unexpected host-foundation contribution paths: {contributions.get('host-foundation')!r}") -if contributions.get("application-data") != ["{{ couchdb_dir }}/data"]: - raise SystemExit(f"unexpected application-data contribution paths: {contributions.get('application-data')!r}") +if not obsidian_result["host"]["traefik"]["enabled"]: + raise SystemExit("expected public_https obsidian deployment to enable nested host traefik config") + +obsidian_job_targets = [job["target_names"] for job in obsidian_result["host"]["restic"]["jobs"]] +if obsidian_job_targets != [["primary"], ["primary"]]: + raise SystemExit(f"unexpected nested restic job targets: {obsidian_job_targets!r}") + +if obsidian_result["features"]["obsidian_livesync"]["ingress"]["route_name"] != "obsidian-couchdb": + raise SystemExit("expected obsidian ingress route name in nested feature contract") + +if obsidian_result["host"]["ufw"]["baseline_tcp_public"] != [22]: + raise SystemExit( + f"expected builder baseline firewall ports to stay host-owned only, got {obsidian_result['host']['ufw']['baseline_tcp_public']!r}" + ) print("builder restic target name normalization smoke test passed") PY diff --git a/tests/smoke/test_platform_bindings_host_extensions.sh b/tests/smoke/test_platform_bindings_host_extensions.sh new file mode 100644 index 0000000..97d0e35 --- /dev/null +++ b/tests/smoke/test_platform_bindings_host_extensions.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "${ROOT_DIR}/tests/smoke/lib.bash" +TMP_DIR="$(create_smoke_tmpdir "${ROOT_DIR}")" +trap 'rm -rf "${TMP_DIR}"' EXIT + +mkdir -p "${TMP_DIR}/inventories/prod/group_vars/all" "${TMP_DIR}/playbooks/roles" +cp -R "${ROOT_DIR}/roles/platform_bindings" "${TMP_DIR}/playbooks/roles/platform_bindings" +cp "${ROOT_DIR}/inventories/prod/group_vars/all/main.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +cp "${ROOT_DIR}/inventories/prod/group_vars/all/vault.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml" + +sed -i "s/example.invalid/example.com/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/change-me@example.com/ops@example.com/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/REPLACE_ME/test-value/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/REPLACE_ME/test-secret/g" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml" +python3 - <<'PY' "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +content = path.read_text() +content = content.replace( + " https_port: 443\n log_level: INFO\n", + " https_port: 443\n log_level: INFO\n routes:\n - name: ops-dashboard\n rule: Host(`ops.example.com`)\n service_url: http://grafana:3000\n", + 1, +) +content = content.replace( + " jobs:\n", + " datasets:\n - name: operator-notes\n owner: operator\n paths:\n - /srv/operator-notes\n tags:\n - class:application-data\n jobs:\n", + 1, +) +content = content.replace( + " requests: []\n", + " requests:\n - name: ssh-public-alt\n port: 2222\n proto: tcp\n from: any\n", + 1, +) +path.write_text(content) +PY + +cat > "${TMP_DIR}/playbooks/platform-bindings.yml" <<'EOF' +--- +- name: Compose extended platform bindings + hosts: localhost + connection: local + gather_facts: false + pre_tasks: + - name: Load inventory variables + ansible.builtin.include_vars: + file: "{{ item }}" + loop: + - "{{ playbook_dir }}/../inventories/prod/group_vars/all/main.yml" + - "{{ playbook_dir }}/../inventories/prod/group_vars/all/vault.yml" + roles: + - role: platform_bindings + tasks: + - name: Show effective Traefik contract + ansible.builtin.debug: + var: platform_host_traefik + - name: Show effective restic contract + ansible.builtin.debug: + var: platform_host_restic + - name: Show effective UFW contract + ansible.builtin.debug: + var: platform_host_ufw +EOF + +OUTPUT="$( + cd "${TMP_DIR}" && + ansible-playbook -i localhost, playbooks/platform-bindings.yml 2>&1 +)" + +for expected in 'ops-dashboard' 'obsidian-couchdb' 'operator-notes' 'workspace-data' 'ssh-public-alt' 'https-public'; do + if [[ "${OUTPUT}" != *"${expected}"* ]]; then + echo "expected merged platform contract output to include ${expected}" >&2 + printf '%s\n' "${OUTPUT}" >&2 + exit 1 + fi +done + +printf 'platform bindings host extension merge smoke test passed\n' diff --git a/tests/smoke/test_platform_bindings_private_mesh.sh b/tests/smoke/test_platform_bindings_private_mesh.sh new file mode 100644 index 0000000..adeff36 --- /dev/null +++ b/tests/smoke/test_platform_bindings_private_mesh.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "${ROOT_DIR}/tests/smoke/lib.bash" +TMP_DIR="$(create_smoke_tmpdir "${ROOT_DIR}")" +trap 'rm -rf "${TMP_DIR}"' EXIT + +mkdir -p "${TMP_DIR}/inventories/prod/group_vars/all" "${TMP_DIR}/playbooks/roles" +cp -R "${ROOT_DIR}/roles/platform_bindings" "${TMP_DIR}/playbooks/roles/platform_bindings" +cp "${ROOT_DIR}/inventories/prod/group_vars/all/main.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +cp "${ROOT_DIR}/inventories/prod/group_vars/all/vault.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml" + +sed -i "s/example.invalid/example.com/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/change-me@example.com/ops@example.com/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/REPLACE_ME/test-value/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/REPLACE_ME/test-secret/g" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml" +python3 - <<'PY' "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +content = path.read_text() +content = content.replace("access_mode: public_https", "access_mode: private_mesh") +content = content.replace("base_url: https://notes.ops.example.com", "base_url: http://core-01.tailnet.ts.net:5984") +content = content.replace("tailnet_name: \"\"", "tailnet_name: tailnet") +content = content.replace(" traefik:\n enabled: true", " traefik:\n enabled: false", 1) +content = content.replace(" bind_host: 127.0.0.1", " bind_host: 0.0.0.0") +path.write_text(content) +PY + +cat > "${TMP_DIR}/playbooks/platform-bindings.yml" <<'EOF' +--- +- name: Compose private mesh platform bindings + hosts: localhost + connection: local + gather_facts: false + pre_tasks: + - name: Load inventory variables + ansible.builtin.include_vars: + file: "{{ item }}" + loop: + - "{{ playbook_dir }}/../inventories/prod/group_vars/all/main.yml" + - "{{ playbook_dir }}/../inventories/prod/group_vars/all/vault.yml" + roles: + - role: platform_bindings + tasks: + - name: Show composed ingress routes + ansible.builtin.debug: + var: platform_ingress_routes + - name: Show composed backup datasets + ansible.builtin.debug: + var: platform_backup_datasets + - name: Show composed ufw requests + ansible.builtin.debug: + var: platform_ufw_requests +EOF + +OUTPUT="$( + cd "${TMP_DIR}" && + ansible-playbook -i localhost, playbooks/platform-bindings.yml 2>&1 +)" + +if [[ "${OUTPUT}" != *"\"platform_ingress_routes\": []"* ]]; then + echo "expected private mesh bindings to skip ingress route composition" >&2 + printf '%s\n' "${OUTPUT}" >&2 + exit 1 +fi + +if [[ "${OUTPUT}" != *"obsidian-couchdb-data"* ]]; then + echo "expected private mesh bindings to keep the obsidian dataset" >&2 + printf '%s\n' "${OUTPUT}" >&2 + exit 1 +fi + +if [[ "${OUTPUT}" != *"\"platform_ufw_requests\": []"* ]]; then + echo "expected private mesh bindings to skip public https ufw requests" >&2 + printf '%s\n' "${OUTPUT}" >&2 + exit 1 +fi + +printf 'platform bindings private_mesh smoke test passed\n' diff --git a/tests/smoke/test_platform_bindings_public_https.sh b/tests/smoke/test_platform_bindings_public_https.sh new file mode 100644 index 0000000..44ab20f --- /dev/null +++ b/tests/smoke/test_platform_bindings_public_https.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "${ROOT_DIR}/tests/smoke/lib.bash" +TMP_DIR="$(create_smoke_tmpdir "${ROOT_DIR}")" +trap 'rm -rf "${TMP_DIR}"' EXIT + +mkdir -p "${TMP_DIR}/inventories/prod/group_vars/all" "${TMP_DIR}/playbooks/roles" +cp -R "${ROOT_DIR}/roles/platform_bindings" "${TMP_DIR}/playbooks/roles/platform_bindings" +cp "${ROOT_DIR}/inventories/prod/group_vars/all/main.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +cp "${ROOT_DIR}/inventories/prod/group_vars/all/vault.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml" + +sed -i "s/example.invalid/example.com/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/change-me@example.com/ops@example.com/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/REPLACE_ME/test-value/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/REPLACE_ME/test-secret/g" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml" + +cat > "${TMP_DIR}/playbooks/platform-bindings.yml" <<'EOF' +--- +- name: Compose public HTTPS platform bindings + hosts: localhost + connection: local + gather_facts: false + pre_tasks: + - name: Load inventory variables + ansible.builtin.include_vars: + file: "{{ item }}" + loop: + - "{{ playbook_dir }}/../inventories/prod/group_vars/all/main.yml" + - "{{ playbook_dir }}/../inventories/prod/group_vars/all/vault.yml" + roles: + - role: platform_bindings + tasks: + - name: Show composed ingress routes + ansible.builtin.debug: + var: platform_ingress_routes + - name: Show composed backup datasets + ansible.builtin.debug: + var: platform_backup_datasets + - name: Show composed ufw requests + ansible.builtin.debug: + var: platform_ufw_requests + - name: Show composed obsidian contract + ansible.builtin.debug: + var: platform_obsidian_livesync +EOF + +OUTPUT="$( + cd "${TMP_DIR}" && + ansible-playbook -i localhost, playbooks/platform-bindings.yml 2>&1 +)" + +if [[ "${OUTPUT}" != *"obsidian-couchdb"* ]]; then + echo "expected public HTTPS bindings to compose the obsidian ingress route" >&2 + printf '%s\n' "${OUTPUT}" >&2 + exit 1 +fi + +if [[ "${OUTPUT}" != *"traefik-acme"* ]]; then + echo "expected public HTTPS bindings to compose the traefik acme dataset" >&2 + printf '%s\n' "${OUTPUT}" >&2 + exit 1 +fi + +if [[ "${OUTPUT}" != *"https-public"* ]]; then + echo "expected public HTTPS bindings to compose the https ufw request" >&2 + printf '%s\n' "${OUTPUT}" >&2 + exit 1 +fi + +if [[ "${OUTPUT}" != *"shared_network_name\": \"proxy"* ]]; then + echo "expected public HTTPS bindings to hand the proxy network to obsidian" >&2 + printf '%s\n' "${OUTPUT}" >&2 + exit 1 +fi + +printf 'platform bindings public_https smoke test passed\n' diff --git a/tests/smoke/test_preflight_feature_owned_jobs_rejected.sh b/tests/smoke/test_preflight_feature_owned_jobs_rejected.sh new file mode 100644 index 0000000..aedde17 --- /dev/null +++ b/tests/smoke/test_preflight_feature_owned_jobs_rejected.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "${ROOT_DIR}/tests/smoke/lib.bash" +TMP_DIR="$(create_smoke_tmpdir "${ROOT_DIR}")" +trap 'rm -rf "${TMP_DIR}"' EXIT + +mkdir -p "${TMP_DIR}/inventories/prod/group_vars/all" "${TMP_DIR}/inventories/prod/group_vars/core_hosts" "${TMP_DIR}/playbooks" "${TMP_DIR}/roles" "${TMP_DIR}/scripts" +cp "${ROOT_DIR}/ansible.cfg" "${TMP_DIR}/ansible.cfg" +cp "${ROOT_DIR}/scripts/init-local-config.sh" "${TMP_DIR}/scripts/init-local-config.sh" +cp "${ROOT_DIR}/playbooks/preflight.yml" "${TMP_DIR}/playbooks/preflight.yml" +cp -R "${ROOT_DIR}/roles/preflight_validate" "${TMP_DIR}/roles/preflight_validate" +cp -R "${ROOT_DIR}/roles/platform_bindings" "${TMP_DIR}/roles/platform_bindings" +cp "${ROOT_DIR}/inventories/prod/hosts.yml.example" "${TMP_DIR}/inventories/prod/hosts.yml.example" +cp "${ROOT_DIR}/inventories/prod/group_vars/all/main.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml.example" +cp "${ROOT_DIR}/inventories/prod/group_vars/all/vault.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml.example" +cp "${ROOT_DIR}/inventories/prod/group_vars/core_hosts/main.yml.example" "${TMP_DIR}/inventories/prod/group_vars/core_hosts/main.yml.example" +printf 'test-only\n' > "${TMP_DIR}/.vault_pass" + +(cd "${TMP_DIR}" && bash ./scripts/init-local-config.sh >/dev/null) + +sed -i "s/203.0.113.10/192.0.2.10/" "${TMP_DIR}/inventories/prod/hosts.yml" +sed -i "s/example.invalid/example.com/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/change-me@example.com/ops@example.com/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/REPLACE_ME/test-value/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/REPLACE_ME/test-secret/g" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml" +python3 - <<'PY' "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +content = path.read_text() +content = content.replace(" feature_owned_jobs: []", " feature_owned_jobs:\n - name: forbidden-job") +path.write_text(content) +PY + +set +e +OUTPUT="$( + cd "${TMP_DIR}" && + ANSIBLE_CONFIG="${TMP_DIR}/ansible.cfg" ansible-playbook -e preflight_validate_remote_connectivity=false -i inventories/prod/hosts.yml playbooks/preflight.yml 2>&1 +)" +STATUS=$? +set -e + +if [[ ${STATUS} -eq 0 ]]; then + echo "expected preflight to fail when feature_owned_jobs is configured" >&2 + exit 1 +fi + +if [[ "${OUTPUT}" != *"host.restic.feature_owned_jobs is intentionally unsupported"* ]]; then + echo "expected unsupported feature_owned_jobs validation error in preflight output" >&2 + printf '%s\n' "${OUTPUT}" >&2 + exit 1 +fi + +printf 'preflight feature_owned_jobs rejection smoke test passed\n' diff --git a/tests/smoke/test_preflight_host_traefik_contract_validation.sh b/tests/smoke/test_preflight_host_traefik_contract_validation.sh new file mode 100755 index 0000000..167f1d3 --- /dev/null +++ b/tests/smoke/test_preflight_host_traefik_contract_validation.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "${ROOT_DIR}/tests/smoke/lib.bash" +TMP_DIR="$(create_smoke_tmpdir "${ROOT_DIR}")" +trap 'rm -rf "${TMP_DIR}"' EXIT + +mkdir -p "${TMP_DIR}/inventories/prod/group_vars/all" "${TMP_DIR}/inventories/prod/group_vars/core_hosts" "${TMP_DIR}/playbooks" "${TMP_DIR}/roles" "${TMP_DIR}/scripts" +cp "${ROOT_DIR}/ansible.cfg" "${TMP_DIR}/ansible.cfg" +cp "${ROOT_DIR}/scripts/init-local-config.sh" "${TMP_DIR}/scripts/init-local-config.sh" +cp "${ROOT_DIR}/playbooks/preflight.yml" "${TMP_DIR}/playbooks/preflight.yml" +cp -R "${ROOT_DIR}/roles/preflight_validate" "${TMP_DIR}/roles/preflight_validate" +cp -R "${ROOT_DIR}/roles/platform_bindings" "${TMP_DIR}/roles/platform_bindings" +cp "${ROOT_DIR}/inventories/prod/hosts.yml.example" "${TMP_DIR}/inventories/prod/hosts.yml.example" +cp "${ROOT_DIR}/inventories/prod/group_vars/all/main.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml.example" +cp "${ROOT_DIR}/inventories/prod/group_vars/all/vault.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml.example" +cp "${ROOT_DIR}/inventories/prod/group_vars/core_hosts/main.yml.example" "${TMP_DIR}/inventories/prod/group_vars/core_hosts/main.yml.example" +printf 'test-only\n' > "${TMP_DIR}/.vault_pass" + +(cd "${TMP_DIR}" && bash ./scripts/init-local-config.sh >/dev/null) + +sed -i "s/203.0.113.10/192.0.2.10/" "${TMP_DIR}/inventories/prod/hosts.yml" +sed -i "s/example.invalid/example.com/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/change-me@example.com/ops@example.com/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/REPLACE_ME/test-value/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/REPLACE_ME/test-secret/g" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml" +python3 - <<'PY' "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +from pathlib import Path +import sys +import yaml + +path = Path(sys.argv[1]) +content = yaml.safe_load(path.read_text()) +content["features"]["obsidian_livesync"]["enabled"] = False +content["features"]["obsidian_livesync"]["access_mode"] = "private_mesh" +content["host"]["traefik"]["enabled"] = True +content["host"]["traefik"]["dns_env"] = {} +content["host"]["traefik"]["routes"] = [ + { + "name": "admin", + "rule": "Host(`admin.example.com`)", + "service_url": "http://admin:8080", + "entrypoints": ["websecure"], + } +] +path.write_text(yaml.safe_dump(content, sort_keys=False)) +PY + +set +e +OUTPUT="$( + cd "${TMP_DIR}" && + ANSIBLE_CONFIG="${TMP_DIR}/ansible.cfg" ansible-playbook -e preflight_validate_remote_connectivity=false -i inventories/prod/hosts.yml playbooks/preflight.yml 2>&1 +)" +STATUS=$? +set -e + +if [[ ${STATUS} -eq 0 ]]; then + echo "expected preflight to fail when enabled host.traefik is missing dns_env" >&2 + exit 1 +fi + +if [[ "${OUTPUT}" != *"host.traefik.enabled=true requires layout_root, static_config_path, dynamic_config_root, dynamic_routes_dir, acme_storage_path, proxy_network_name, container_name, certificate_resolver_name, acme_email, dns_provider, and dns_env."* ]]; then + echo "expected host.traefik contract validation error in preflight output" >&2 + printf '%s\n' "${OUTPUT}" >&2 + exit 1 +fi + +printf 'preflight host.traefik contract validation smoke test passed\n' diff --git a/tests/smoke/test_preflight_missing_contract_roots.sh b/tests/smoke/test_preflight_missing_contract_roots.sh new file mode 100644 index 0000000..72f763a --- /dev/null +++ b/tests/smoke/test_preflight_missing_contract_roots.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "${ROOT_DIR}/tests/smoke/lib.bash" +TMP_DIR="$(create_smoke_tmpdir "${ROOT_DIR}")" +trap 'rm -rf "${TMP_DIR}"' EXIT + +mkdir -p "${TMP_DIR}/inventories/prod/group_vars/all" "${TMP_DIR}/inventories/prod/group_vars/core_hosts" "${TMP_DIR}/playbooks" "${TMP_DIR}/roles" "${TMP_DIR}/scripts" +cp "${ROOT_DIR}/ansible.cfg" "${TMP_DIR}/ansible.cfg" +cp "${ROOT_DIR}/scripts/init-local-config.sh" "${TMP_DIR}/scripts/init-local-config.sh" +cp "${ROOT_DIR}/playbooks/preflight.yml" "${TMP_DIR}/playbooks/preflight.yml" +cp -R "${ROOT_DIR}/roles/preflight_validate" "${TMP_DIR}/roles/preflight_validate" +cp -R "${ROOT_DIR}/roles/platform_bindings" "${TMP_DIR}/roles/platform_bindings" +cp "${ROOT_DIR}/inventories/prod/hosts.yml.example" "${TMP_DIR}/inventories/prod/hosts.yml.example" +cp "${ROOT_DIR}/inventories/prod/group_vars/all/main.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml.example" +cp "${ROOT_DIR}/inventories/prod/group_vars/all/vault.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml.example" +cp "${ROOT_DIR}/inventories/prod/group_vars/core_hosts/main.yml.example" "${TMP_DIR}/inventories/prod/group_vars/core_hosts/main.yml.example" +printf 'test-only\n' > "${TMP_DIR}/.vault_pass" + +(cd "${TMP_DIR}" && bash ./scripts/init-local-config.sh >/dev/null) + +python3 - <<'PY' "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +content = path.read_text() +start = content.index("features:") +end = content.index("tailscale_auth_key:") +path.write_text(content[:start] + content[end:]) +PY + +set +e +OUTPUT="$( + cd "${TMP_DIR}" && + ANSIBLE_CONFIG="${TMP_DIR}/ansible.cfg" ansible-playbook -e preflight_validate_remote_connectivity=false -i inventories/prod/hosts.yml playbooks/preflight.yml 2>&1 +)" +STATUS=$? +set -e + +if [[ ${STATUS} -eq 0 ]]; then + echo "expected preflight to fail when nested contract roots are missing" >&2 + exit 1 +fi + +if [[ "${OUTPUT}" == *"is undefined"* ]]; then + echo "preflight should report missing contract roots without undefined-variable crashes" >&2 + printf '%s\n' "${OUTPUT}" >&2 + exit 1 +fi + +if [[ "${OUTPUT}" != *"main inventory must define nested features.obsidian_livesync, host.traefik, host.restic, and host.ufw mappings."* ]]; then + echo "expected missing nested contract root validation error in preflight output" >&2 + printf '%s\n' "${OUTPUT}" >&2 + exit 1 +fi + +printf 'preflight missing contract roots smoke test passed\n' diff --git a/tests/smoke/test_preflight_obsidian_route_name_validation.sh b/tests/smoke/test_preflight_obsidian_route_name_validation.sh new file mode 100644 index 0000000..913a3ca --- /dev/null +++ b/tests/smoke/test_preflight_obsidian_route_name_validation.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "${ROOT_DIR}/tests/smoke/lib.bash" +TMP_DIR="$(create_smoke_tmpdir "${ROOT_DIR}")" +trap 'rm -rf "${TMP_DIR}"' EXIT + +mkdir -p "${TMP_DIR}/inventories/prod/group_vars/all" "${TMP_DIR}/inventories/prod/group_vars/core_hosts" "${TMP_DIR}/playbooks" "${TMP_DIR}/roles" "${TMP_DIR}/scripts" +cp "${ROOT_DIR}/ansible.cfg" "${TMP_DIR}/ansible.cfg" +cp "${ROOT_DIR}/scripts/init-local-config.sh" "${TMP_DIR}/scripts/init-local-config.sh" +cp "${ROOT_DIR}/playbooks/preflight.yml" "${TMP_DIR}/playbooks/preflight.yml" +cp -R "${ROOT_DIR}/roles/preflight_validate" "${TMP_DIR}/roles/preflight_validate" +cp -R "${ROOT_DIR}/roles/platform_bindings" "${TMP_DIR}/roles/platform_bindings" +cp "${ROOT_DIR}/inventories/prod/hosts.yml.example" "${TMP_DIR}/inventories/prod/hosts.yml.example" +cp "${ROOT_DIR}/inventories/prod/group_vars/all/main.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml.example" +cp "${ROOT_DIR}/inventories/prod/group_vars/all/vault.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml.example" +cp "${ROOT_DIR}/inventories/prod/group_vars/core_hosts/main.yml.example" "${TMP_DIR}/inventories/prod/group_vars/core_hosts/main.yml.example" +printf 'test-only\n' > "${TMP_DIR}/.vault_pass" + +(cd "${TMP_DIR}" && bash ./scripts/init-local-config.sh >/dev/null) + +sed -i "s/203.0.113.10/192.0.2.10/" "${TMP_DIR}/inventories/prod/hosts.yml" +sed -i "s/example.invalid/example.com/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/change-me@example.com/ops@example.com/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/REPLACE_ME/test-value/g" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +sed -i "s/REPLACE_ME/test-secret/g" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml" +python3 - <<'PY' "${TMP_DIR}/inventories/prod/group_vars/all/main.yml" +from pathlib import Path +import sys +import yaml + +path = Path(sys.argv[1]) +content = yaml.safe_load(path.read_text()) +del content["features"]["obsidian_livesync"]["ingress"]["route_name"] +path.write_text(yaml.safe_dump(content, sort_keys=False)) +PY + +set +e +OUTPUT="$( + cd "${TMP_DIR}" && + ANSIBLE_CONFIG="${TMP_DIR}/ansible.cfg" ansible-playbook -e preflight_validate_remote_connectivity=false -i inventories/prod/hosts.yml playbooks/preflight.yml 2>&1 +)" +STATUS=$? +set -e + +if [[ ${STATUS} -eq 0 ]]; then + echo "expected preflight to fail when obsidian ingress.route_name is missing" >&2 + exit 1 +fi + +if [[ "${OUTPUT}" != *"features.obsidian_livesync.ingress.route_name must be a DNS-safe Traefik route identifier when public_https is enabled."* ]]; then + echo "expected obsidian route_name validation error in preflight output" >&2 + printf '%s\n' "${OUTPUT}" >&2 + exit 1 +fi + +if [[ "${OUTPUT}" == *"has no attribute 'route_name'"* ]]; then + echo "platform_bindings should not crash on missing obsidian ingress.route_name" >&2 + printf '%s\n' "${OUTPUT}" >&2 + exit 1 +fi + +printf 'preflight obsidian route_name validation smoke test passed\n' diff --git a/tests/smoke/test_preflight_placeholder_scan.sh b/tests/smoke/test_preflight_placeholder_scan.sh index 9577ec9..ebd6ef8 100644 --- a/tests/smoke/test_preflight_placeholder_scan.sh +++ b/tests/smoke/test_preflight_placeholder_scan.sh @@ -2,7 +2,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -TMP_DIR="$(mktemp -d)" +source "${ROOT_DIR}/tests/smoke/lib.bash" +TMP_DIR="$(create_smoke_tmpdir "${ROOT_DIR}")" trap 'rm -rf "${TMP_DIR}"' EXIT mkdir -p "${TMP_DIR}/inventories/prod/group_vars" "${TMP_DIR}/playbooks" "${TMP_DIR}/roles" "${TMP_DIR}/scripts" @@ -11,6 +12,7 @@ cp "${ROOT_DIR}/ansible.cfg" "${TMP_DIR}/ansible.cfg" cp "${ROOT_DIR}/scripts/init-local-config.sh" "${TMP_DIR}/scripts/init-local-config.sh" cp "${ROOT_DIR}/playbooks/preflight.yml" "${TMP_DIR}/playbooks/preflight.yml" cp -R "${ROOT_DIR}/roles/preflight_validate" "${TMP_DIR}/roles/preflight_validate" +cp -R "${ROOT_DIR}/roles/platform_bindings" "${TMP_DIR}/roles/platform_bindings" cp "${ROOT_DIR}/inventories/prod/hosts.yml.example" "${TMP_DIR}/inventories/prod/hosts.yml.example" cp "${ROOT_DIR}/inventories/prod/group_vars/all/main.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml.example" cp "${ROOT_DIR}/inventories/prod/group_vars/all/vault.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml.example" diff --git a/tests/smoke/test_preflight_restic_broad_path_warning.sh b/tests/smoke/test_preflight_restic_broad_path_warning.sh index 9bffbb0..4002472 100644 --- a/tests/smoke/test_preflight_restic_broad_path_warning.sh +++ b/tests/smoke/test_preflight_restic_broad_path_warning.sh @@ -2,7 +2,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -TMP_DIR="$(mktemp -d)" +source "${ROOT_DIR}/tests/smoke/lib.bash" +TMP_DIR="$(create_smoke_tmpdir "${ROOT_DIR}")" trap 'rm -rf "${TMP_DIR}"' EXIT mkdir -p "${TMP_DIR}/inventories/prod/group_vars/all" "${TMP_DIR}/inventories/prod/group_vars/core_hosts" "${TMP_DIR}/playbooks" "${TMP_DIR}/roles" "${TMP_DIR}/scripts" @@ -10,6 +11,7 @@ cp "${ROOT_DIR}/ansible.cfg" "${TMP_DIR}/ansible.cfg" cp "${ROOT_DIR}/scripts/init-local-config.sh" "${TMP_DIR}/scripts/init-local-config.sh" cp "${ROOT_DIR}/playbooks/preflight.yml" "${TMP_DIR}/playbooks/preflight.yml" cp -R "${ROOT_DIR}/roles/preflight_validate" "${TMP_DIR}/roles/preflight_validate" +cp -R "${ROOT_DIR}/roles/platform_bindings" "${TMP_DIR}/roles/platform_bindings" cp "${ROOT_DIR}/inventories/prod/hosts.yml.example" "${TMP_DIR}/inventories/prod/hosts.yml.example" cp "${ROOT_DIR}/inventories/prod/group_vars/all/main.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml.example" cp "${ROOT_DIR}/inventories/prod/group_vars/all/vault.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml.example" @@ -29,7 +31,17 @@ import sys path = Path(sys.argv[1]) content = path.read_text() -content = content.replace('- "{{ couchdb_dir }}/data"', '- "/opt/couchdb"') +content = content.replace("vault_root: /srv/crownops/vaults", "vault_root: /srv/crownops") +path.write_text(content) +PY + +python3 - <<'PY' "${TMP_DIR}/roles/platform_bindings/tasks/main.yml" +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +content = path.read_text() +content = content.replace('- "{{ vault_root }}/workspaces"', '- "{{ vault_root }}"') path.write_text(content) PY @@ -38,7 +50,7 @@ OUTPUT="$( ANSIBLE_CONFIG="${TMP_DIR}/ansible.cfg" ansible-playbook -e preflight_validate_remote_connectivity=false -i inventories/prod/hosts.yml playbooks/preflight.yml 2>&1 )" -if [[ "${OUTPUT}" != *"restic backup paths include broad service roots"* ]]; then +if [[ "${OUTPUT}" != *"Effective backup datasets include broad service roots"* ]]; then echo "expected preflight to warn on broad backup path roots" >&2 printf '%s\n' "${OUTPUT}" >&2 exit 1 diff --git a/tests/smoke/test_preflight_restic_cache_valid_time_validation.sh b/tests/smoke/test_preflight_restic_cache_valid_time_validation.sh index 756ac14..fb41cb3 100644 --- a/tests/smoke/test_preflight_restic_cache_valid_time_validation.sh +++ b/tests/smoke/test_preflight_restic_cache_valid_time_validation.sh @@ -2,7 +2,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -TMP_DIR="$(mktemp -d)" +source "${ROOT_DIR}/tests/smoke/lib.bash" +TMP_DIR="$(create_smoke_tmpdir "${ROOT_DIR}")" trap 'rm -rf "${TMP_DIR}"' EXIT mkdir -p "${TMP_DIR}/inventories/prod/group_vars/all" "${TMP_DIR}/inventories/prod/group_vars/core_hosts" "${TMP_DIR}/playbooks" "${TMP_DIR}/roles" "${TMP_DIR}/scripts" @@ -10,6 +11,7 @@ cp "${ROOT_DIR}/ansible.cfg" "${TMP_DIR}/ansible.cfg" cp "${ROOT_DIR}/scripts/init-local-config.sh" "${TMP_DIR}/scripts/init-local-config.sh" cp "${ROOT_DIR}/playbooks/preflight.yml" "${TMP_DIR}/playbooks/preflight.yml" cp -R "${ROOT_DIR}/roles/preflight_validate" "${TMP_DIR}/roles/preflight_validate" +cp -R "${ROOT_DIR}/roles/platform_bindings" "${TMP_DIR}/roles/platform_bindings" cp "${ROOT_DIR}/inventories/prod/hosts.yml.example" "${TMP_DIR}/inventories/prod/hosts.yml.example" cp "${ROOT_DIR}/inventories/prod/group_vars/all/main.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml.example" cp "${ROOT_DIR}/inventories/prod/group_vars/all/vault.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml.example" @@ -29,7 +31,7 @@ import sys path = Path(sys.argv[1]) content = path.read_text() -content = content.replace("restic_apt_cache_valid_time: 86400", "restic_apt_cache_valid_time: fast") +content = content.replace("apt_cache_valid_time: 86400", "apt_cache_valid_time: fast") path.write_text(content) PY @@ -46,7 +48,7 @@ if [[ ${STATUS} -eq 0 ]]; then exit 1 fi -if [[ "${OUTPUT}" != *"restic_apt_cache_valid_time must be an integer"* ]]; then +if [[ "${OUTPUT}" != *"host.restic.apt_cache_valid_time must be an integer"* ]]; then echo "expected restic_apt_cache_valid_time validation error in preflight output" >&2 printf '%s\n' "${OUTPUT}" >&2 exit 1 diff --git a/tests/smoke/test_preflight_restic_target_name_validation.sh b/tests/smoke/test_preflight_restic_target_name_validation.sh index 01ba730..c39804b 100644 --- a/tests/smoke/test_preflight_restic_target_name_validation.sh +++ b/tests/smoke/test_preflight_restic_target_name_validation.sh @@ -2,7 +2,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -TMP_DIR="$(mktemp -d)" +source "${ROOT_DIR}/tests/smoke/lib.bash" +TMP_DIR="$(create_smoke_tmpdir "${ROOT_DIR}")" trap 'rm -rf "${TMP_DIR}"' EXIT mkdir -p "${TMP_DIR}/inventories/prod/group_vars/all" "${TMP_DIR}/inventories/prod/group_vars/core_hosts" "${TMP_DIR}/playbooks" "${TMP_DIR}/roles" "${TMP_DIR}/scripts" @@ -10,6 +11,7 @@ cp "${ROOT_DIR}/ansible.cfg" "${TMP_DIR}/ansible.cfg" cp "${ROOT_DIR}/scripts/init-local-config.sh" "${TMP_DIR}/scripts/init-local-config.sh" cp "${ROOT_DIR}/playbooks/preflight.yml" "${TMP_DIR}/playbooks/preflight.yml" cp -R "${ROOT_DIR}/roles/preflight_validate" "${TMP_DIR}/roles/preflight_validate" +cp -R "${ROOT_DIR}/roles/platform_bindings" "${TMP_DIR}/roles/platform_bindings" cp "${ROOT_DIR}/inventories/prod/hosts.yml.example" "${TMP_DIR}/inventories/prod/hosts.yml.example" cp "${ROOT_DIR}/inventories/prod/group_vars/all/main.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml.example" cp "${ROOT_DIR}/inventories/prod/group_vars/all/vault.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml.example" @@ -29,8 +31,8 @@ import sys path = Path(sys.argv[1]) content = path.read_text() -content = content.replace(" - name: primary", " - name: H4F") -content = content.replace("target_names: [primary, secondary]", "target_names: [H4F, secondary]") +content = content.replace(" - name: primary", " - name: H4F", 1) +content = content.replace(" - primary", " - H4F", 1) path.write_text(content) PY @@ -47,7 +49,7 @@ if [[ ${STATUS} -eq 0 ]]; then exit 1 fi -if [[ "${OUTPUT}" != *"Every restic target name must be lowercase"* ]]; then +if [[ "${OUTPUT}" != *"Every host.restic target name must be lowercase"* ]]; then echo "expected restic target name validation error in preflight output" >&2 printf '%s\n' "${OUTPUT}" >&2 exit 1 diff --git a/tests/smoke/test_preflight_tailscale_tag_validation.sh b/tests/smoke/test_preflight_tailscale_tag_validation.sh index 5401dde..94b5a7e 100644 --- a/tests/smoke/test_preflight_tailscale_tag_validation.sh +++ b/tests/smoke/test_preflight_tailscale_tag_validation.sh @@ -2,7 +2,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -TMP_DIR="$(mktemp -d)" +source "${ROOT_DIR}/tests/smoke/lib.bash" +TMP_DIR="$(create_smoke_tmpdir "${ROOT_DIR}")" trap 'rm -rf "${TMP_DIR}"' EXIT mkdir -p "${TMP_DIR}/inventories/prod/group_vars/all" "${TMP_DIR}/inventories/prod/group_vars/core_hosts" "${TMP_DIR}/playbooks" "${TMP_DIR}/roles" "${TMP_DIR}/scripts" @@ -10,6 +11,7 @@ cp "${ROOT_DIR}/ansible.cfg" "${TMP_DIR}/ansible.cfg" cp "${ROOT_DIR}/scripts/init-local-config.sh" "${TMP_DIR}/scripts/init-local-config.sh" cp "${ROOT_DIR}/playbooks/preflight.yml" "${TMP_DIR}/playbooks/preflight.yml" cp -R "${ROOT_DIR}/roles/preflight_validate" "${TMP_DIR}/roles/preflight_validate" +cp -R "${ROOT_DIR}/roles/platform_bindings" "${TMP_DIR}/roles/platform_bindings" cp "${ROOT_DIR}/inventories/prod/hosts.yml.example" "${TMP_DIR}/inventories/prod/hosts.yml.example" cp "${ROOT_DIR}/inventories/prod/group_vars/all/main.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/main.yml.example" cp "${ROOT_DIR}/inventories/prod/group_vars/all/vault.yml.example" "${TMP_DIR}/inventories/prod/group_vars/all/vault.yml.example" diff --git a/wizard_profiles/crownops-deploy-core.yml b/wizard_profiles/crownops-deploy-core.yml index 4e48b77..675a906 100644 --- a/wizard_profiles/crownops-deploy-core.yml +++ b/wizard_profiles/crownops-deploy-core.yml @@ -30,7 +30,6 @@ defaults: tailscale_tags: [] ufw_allowed_tcp_public: - 22 - - 443 ufw_allowed_udp_public: [] traefik_dir: /opt/traefik traefik_network_name: proxy diff --git a/wizard_support/builders.py b/wizard_support/builders.py index 7533cfa..da65a05 100644 --- a/wizard_support/builders.py +++ b/wizard_support/builders.py @@ -131,9 +131,10 @@ def build_crownops_deploy_core(raw: dict[str, Any]) -> dict[str, Any]: data["deploy_command"] = "./scripts/deploy.sh" data["ssh_lockdown_command"] = "./scripts/ssh-lockdown.sh" host_name = data["host_name"] - data["feature_obsidian_enabled"] = bool(data.get("feature_obsidian_enabled", True)) - data["obsidian_access_mode"] = data.get("obsidian_access_mode") or "public_https" - data["private_mesh_url_strategy"] = data.get("private_mesh_url_strategy") or "tailscale_magicdns" + obsidian_enabled = bool(data.get("feature_obsidian_enabled", True)) + obsidian_access_mode = data.get("obsidian_access_mode") or "public_https" + private_mesh_url_strategy = data.get("private_mesh_url_strategy") or "tailscale_magicdns" + restic_enabled = bool(data.get("restic_enabled", True)) data["ops_domain"] = data.get("ops_domain") or f"ops.{data['base_domain']}" data["tailscale_hostname"] = data.get("tailscale_hostname") or host_name @@ -162,14 +163,14 @@ def build_crownops_deploy_core(raw: dict[str, Any]) -> dict[str, Any]: ) data["ssh_pubkeys"] = ssh_pubkeys - if data.get("feature_obsidian_enabled", False): - if data["obsidian_access_mode"] == "public_https": + if obsidian_enabled: + if obsidian_access_mode == "public_https": data["obsidian_base_url"] = data.get("obsidian_base_url") or ( f"https://{data['obsidian_service_subdomain']}.{data['ops_domain']}" ) data["couchdb_bind_host"] = data.get("couchdb_bind_host") or "127.0.0.1" else: - if data["private_mesh_url_strategy"] == "tailscale_magicdns" and data.get("tailscale_tailnet_name"): + if private_mesh_url_strategy == "tailscale_magicdns" and data.get("tailscale_tailnet_name"): data["obsidian_base_url"] = data.get("obsidian_base_url") or ( f"http://{data['tailscale_hostname']}.{data['tailscale_tailnet_name']}.ts.net:{data['couchdb_port']}" ) @@ -313,19 +314,19 @@ def build_crownops_deploy_core(raw: dict[str, Any]) -> dict[str, Any]: data["backup_destination_bootstrap_plans"] = backup_destination_bootstrap_plans target_names = [item["name"] for item in restic_targets] - if data.get("restic_enabled", True) and target_names: - host_foundation_paths = ["/etc/ssh", "/etc/fail2ban", "/etc/ufw"] + host_ufw_baseline_tcp_public = [int(port) for port in copy.deepcopy(data.get("ufw_allowed_tcp_public", []) or [])] - data["restic_backup_jobs"] = [ + host_restic_jobs: list[dict[str, Any]] = [] + if restic_enabled: + host_restic_jobs = [ { "name": "host-foundation", - "paths": host_foundation_paths, + "selector_tags": ["class:host-foundation"], "target_names": target_names, - "tags": ["profile:stateful-app", "class:host"], }, { "name": "application-data", - "paths": ["{{ vault_root }}/workspaces"], + "selector_tags": ["class:application-data"], "target_names": target_names, "backup_schedule": "*-*-* 03:30:00", "backup_randomized_delay": "20m", @@ -334,33 +335,109 @@ def build_crownops_deploy_core(raw: dict[str, Any]) -> dict[str, Any]: "retention_daily": 14, "retention_weekly": 8, "retention_monthly": 6, - "tags": ["profile:stateful-app", "class:data"], }, ] - restic_backup_contributions = [] - if data.get("feature_obsidian_enabled", False) and data.get("obsidian_access_mode") == "public_https": - restic_backup_contributions.append( - { - "job": "host-foundation", - "paths": ["{{ traefik_acme_storage }}"], - "tags": ["feature:edge-proxy"], - } - ) - if data.get("feature_obsidian_enabled", False): - restic_backup_contributions.append( - { - "job": "application-data", - "paths": ["{{ couchdb_dir }}/data"], - "pre_commands": ["docker compose -f /opt/couchdb/docker-compose.yml stop couchdb"], - "post_commands": ["docker compose -f /opt/couchdb/docker-compose.yml start couchdb"], - "tags": ["feature:obsidian-livesync"], - } - ) - data["restic_backup_contributions"] = restic_backup_contributions - else: - data["restic_backup_jobs"] = [] - data["restic_backup_contributions"] = [] - data["restic_enabled"] = False + + data["features"] = { + "obsidian_livesync": { + "enabled": obsidian_enabled, + "access_mode": obsidian_access_mode, + "base_url": data.get("obsidian_base_url", ""), + "private_mesh": { + "url_strategy": private_mesh_url_strategy, + "tailnet_name": data.get("tailscale_tailnet_name", ""), + }, + "ingress": { + "route_name": "obsidian-couchdb", + }, + "couchdb": { + "dir": data.get("couchdb_dir", "/opt/couchdb"), + "container_name": data.get("couchdb_container_name", "couchdb"), + "internal_network_name": data.get("internal_network_name", "internal"), + "bind_host": data.get("couchdb_bind_host", ""), + "port": int(data.get("couchdb_port", 5984)), + "admin_user": data.get("couchdb_admin_user", "admin"), + "admin_password": data["couchdb_admin_password_ref"], + "cors_origins": data.get("obsidian_cors_origins", []), + "vaults": data.get("couchdb_vaults", []), + }, + } + } + data["host"] = { + "traefik": { + "enabled": obsidian_enabled and obsidian_access_mode == "public_https", + "manage_mode": "managed", + "layout_root": data.get("traefik_dir", "/opt/traefik"), + "static_config_path": f"{data.get('traefik_dir', '/opt/traefik')}/traefik.yml", + "dynamic_config_root": f"{data.get('traefik_dir', '/opt/traefik')}/dynamic", + "dynamic_routes_dir": f"{data.get('traefik_dir', '/opt/traefik')}/dynamic/routes", + "acme_storage_path": data.get("traefik_acme_storage", "/opt/traefik/acme/acme.json"), + "proxy_network_name": data.get("traefik_network_name", "proxy"), + "container_name": "traefik", + "compose_project_name": "traefik", + "certificate_resolver_name": data.get("traefik_certresolver_name", "dnsresolver"), + "acme_email": data.get("traefik_acme_email", ""), + "dns_provider": data.get("acme_dns_provider", ""), + "dns_env": copy.deepcopy(data.get("acme_env", {}) or {}), + "https_entrypoint_name": "websecure", + "https_port": 443, + "log_level": "INFO", + }, + "restic": { + "enabled": restic_enabled, + "install_package": True, + "package_name": "restic", + "apt_cache_valid_time": int(data.get("restic_apt_cache_valid_time", 86400)), + "backup_root": "/opt/crownops-backup", + "targets_dir": "/opt/crownops-backup/targets", + "jobs_dir": "/opt/crownops-backup/jobs", + "passwords_dir": "/opt/crownops-backup/passwords", + "backup_script_path": "/usr/local/sbin/crownops-restic-backup", + "maintain_script_path": "/usr/local/sbin/crownops-restic-maintain", + "ssh_dir": "/opt/crownops-backup/ssh", + "targets": restic_targets, + "jobs": host_restic_jobs, + "feature_owned_jobs": [], + }, + "ufw": { + "enabled": True, + "logging": "low", + "default_incoming_policy": "deny", + "default_outgoing_policy": "allow", + "managed_state_dir": "/etc/crownops", + "managed_state_file": "/etc/crownops/host-ufw-rules.json", + "baseline_tcp_public": host_ufw_baseline_tcp_public, + "baseline_udp_public": [int(port) for port in copy.deepcopy(data.get("ufw_allowed_udp_public", []) or [])], + "requests": [], + }, + } + + for legacy_key in [ + "feature_obsidian_enabled", + "obsidian_access_mode", + "private_mesh_url_strategy", + "obsidian_service_subdomain", + "obsidian_cors_origins", + "acme_dns_provider", + "acme_env", + "ufw_allowed_tcp_public", + "ufw_allowed_udp_public", + "traefik_dir", + "traefik_network_name", + "traefik_certresolver_name", + "traefik_acme_storage", + "couchdb_dir", + "couchdb_container_name", + "couchdb_bind_host", + "couchdb_port", + "restic_enabled", + "restic_apt_cache_valid_time", + "restic_targets_input", + "restic_targets", + "restic_backup_jobs", + "restic_backup_contributions", + ]: + data.pop(legacy_key, None) data["generated_secret_fingerprints"] = [] if data.get("vault_couchdb_admin_password"): diff --git a/wizard_templates/crownops-deploy-core/all.yml.j2 b/wizard_templates/crownops-deploy-core/all.yml.j2 index ae16b28..856b4cd 100644 --- a/wizard_templates/crownops-deploy-core/all.yml.j2 +++ b/wizard_templates/crownops-deploy-core/all.yml.j2 @@ -10,106 +10,18 @@ ops_user: {{ ops_user | yaml_value }} ops_group: {{ ops_group | yaml_value }} ssh_pubkeys: -{% for key in ssh_pubkeys %} - - {{ key | yaml_value }} -{% endfor %} - -ufw_allowed_tcp_public: {{ ufw_allowed_tcp_public | yaml_value }} -ufw_allowed_udp_public: {{ ufw_allowed_udp_public | yaml_value }} - -feature_obsidian_enabled: {{ feature_obsidian_enabled | yaml_value }} -{% if feature_obsidian_enabled %} -obsidian_access_mode: {{ obsidian_access_mode | yaml_value }} -obsidian_base_url: {{ obsidian_base_url | yaml_value }} -obsidian_cors_origins: {{ obsidian_cors_origins | yaml_value }} -internal_network_name: {{ internal_network_name | yaml_value }} -{% if obsidian_access_mode == 'private_mesh' %} -private_mesh_url_strategy: {{ private_mesh_url_strategy | yaml_value }} -{% if private_mesh_url_strategy == 'tailscale_magicdns' %} -tailscale_tailnet_name: {{ tailscale_tailnet_name | yaml_value }} -{% endif %} -{% endif %} -{% if obsidian_access_mode == 'public_https' %} -obsidian_service_subdomain: {{ obsidian_service_subdomain | yaml_value }} - -traefik_dir: {{ traefik_dir | yaml_value }} -traefik_network_name: {{ traefik_network_name | yaml_value }} -traefik_acme_email: {{ traefik_acme_email | yaml_value }} -traefik_certresolver_name: {{ traefik_certresolver_name | yaml_value }} -traefik_acme_storage: {{ traefik_acme_storage | yaml_value }} -acme_dns_provider: {{ acme_dns_provider | yaml_value }} -acme_env: -{% for key, value in acme_env.items() %} - {{ key }}: {{ value | yaml_value }} -{% endfor %} -{% endif %} - -couchdb_dir: {{ couchdb_dir | yaml_value }} -couchdb_container_name: {{ couchdb_container_name | yaml_value }} -couchdb_admin_user: {{ couchdb_admin_user | yaml_value }} -couchdb_admin_password: "{{ couchdb_admin_password_ref }}" -couchdb_bind_host: {{ couchdb_bind_host | yaml_value }} -couchdb_port: {{ couchdb_port | yaml_value }} -couchdb_vaults: -{% for account in couchdb_vaults %} - - name: {{ account.name | yaml_value }} - db_name: {{ account.db_name | yaml_value }} - user: {{ account.user | yaml_value }} - password: "{{ account.password_reference }}" -{% endfor %} -{% endif %} +{{ ssh_pubkeys | yaml_block(2) }} vault_root: {{ vault_root | yaml_value }} exports_root: {{ exports_root | yaml_value }} local_markdown_workspaces: -{% for workspace in local_markdown_workspaces %} - - {{ workspace | yaml_value }} -{% endfor %} - -restic_enabled: {{ restic_enabled | yaml_value }} -restic_apt_cache_valid_time: {{ restic_apt_cache_valid_time | yaml_value }} -restic_targets: -{% for target in restic_targets %} - - name: {{ target.name | yaml_value }} - repository: {{ target.repository | yaml_value }} -{% if target.sftp_port is defined %} - sftp_port: {{ target.sftp_port | yaml_value }} -{% endif %} - password: "{{ target.password_reference }}" - ssh_private_key: "{{ target.ssh_private_key_reference }}" - ssh_known_hosts: "{{ target.ssh_known_hosts_reference }}" - environment: "{{ target.environment_reference }}" -{% endfor %} +{{ local_markdown_workspaces | yaml_block(2) }} -restic_backup_jobs: -{% for job in restic_backup_jobs %} - - name: {{ job.name | yaml_value }} - paths: {{ job.paths | yaml_value }} - target_names: {{ job.target_names | yaml_value }} -{% if job.backup_schedule is defined %} - backup_schedule: {{ job.backup_schedule | yaml_value }} - backup_randomized_delay: {{ job.backup_randomized_delay | yaml_value }} - maintenance_schedule: {{ job.maintenance_schedule | yaml_value }} - maintenance_randomized_delay: {{ job.maintenance_randomized_delay | yaml_value }} - retention_daily: {{ job.retention_daily | yaml_value }} - retention_weekly: {{ job.retention_weekly | yaml_value }} - retention_monthly: {{ job.retention_monthly | yaml_value }} -{% endif %} - tags: {{ job.tags | yaml_value }} -{% endfor %} +features: +{{ features | yaml_block(2) }} -restic_backup_contributions: -{% for contribution in restic_backup_contributions %} - - job: {{ contribution.job | yaml_value }} - paths: {{ contribution.paths | yaml_value }} -{% if contribution.pre_commands is defined %} - pre_commands: {{ contribution.pre_commands | yaml_value }} -{% endif %} -{% if contribution.post_commands is defined %} - post_commands: {{ contribution.post_commands | yaml_value }} -{% endif %} - tags: {{ contribution.tags | yaml_value }} -{% endfor %} +host: +{{ host | yaml_block(2) }} tailscale_auth_key: "{{ tailscale_auth_key_ref }}" tailscale_hostname: {{ tailscale_hostname | yaml_value }}