diff --git a/infra/ansible/roles/veza_app/tasks/artifact.yml b/infra/ansible/roles/veza_app/tasks/artifact.yml index af7fb2dc7..dd264a30a 100644 --- a/infra/ansible/roles/veza_app/tasks/artifact.yml +++ b/infra/ansible/roles/veza_app/tasks/artifact.yml @@ -1,8 +1,83 @@ -# Stub — filled by the next commit. -# Will: get_url + checksum on the .tar.zst from Forgejo Registry, -# unarchive into veza_app_release_dir, atomically swap the -# `current` symlink only after a successful extraction. +# Pull the release tarball from the Forgejo Package Registry and +# extract it under /opt/veza///. Atomic via the +# `current` symlink: nothing visible to the running service until +# the symlink swap at the end. Idempotent: re-running this task with +# the same SHA is a no-op once VERSION exists. --- -- name: Artifact fetch + extract (placeholder) - ansible.builtin.debug: - msg: "TODO: get_url {{ veza_app_artifact_url }} → {{ veza_app_release_dir }}" +- name: Ensure veza_app system user exists + ansible.builtin.user: + name: "{{ veza_app_user }}" + system: true + shell: /usr/sbin/nologin + home: "{{ veza_app_install_dir }}" + create_home: false + tags: [veza_app, artifact] + +- name: Ensure install + log directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ veza_app_user }}" + group: "{{ veza_app_group }}" + mode: "0755" + loop: + - "{{ veza_app_install_dir }}" + - "{{ veza_app_release_dir }}" + - "{{ veza_log_root }}" + tags: [veza_app, artifact] + +- name: Fetch release tarball into /tmp + ansible.builtin.get_url: + url: "{{ veza_app_artifact_url }}" + dest: "/tmp/veza-{{ veza_component }}-{{ veza_release_sha }}.tar.zst" + mode: "0600" + headers: + Authorization: "token {{ vault_forgejo_registry_token | default('') }}" + timeout: 60 + force: false # don't re-download if file already present (idempotency on retries) + tags: [veza_app, artifact] + +- name: Extract tarball into the per-SHA release dir + ansible.builtin.unarchive: + src: "/tmp/veza-{{ veza_component }}-{{ veza_release_sha }}.tar.zst" + dest: "{{ veza_app_release_dir }}" + remote_src: true + owner: "{{ veza_app_user }}" + group: "{{ veza_app_group }}" + creates: "{{ veza_app_release_dir }}/VERSION" + tags: [veza_app, artifact] + +- name: Verify the binary landed (kind=binary only) + ansible.builtin.stat: + path: "{{ veza_app_release_dir }}/{{ veza_app_binary_name }}" + register: binary_stat + when: veza_app_kind == 'binary' + tags: [veza_app, artifact] + +- name: Fail fast if the binary is missing or not executable + ansible.builtin.assert: + that: + - binary_stat.stat.exists + - binary_stat.stat.executable + fail_msg: >- + Tarball {{ veza_app_artifact_url }} extracted but + {{ veza_app_binary_name }} is missing or not executable at + {{ veza_app_release_dir }}. Tarball-build job is broken. + when: veza_app_kind == 'binary' + tags: [veza_app, artifact] + +- name: Atomically swap the `current` symlink + ansible.builtin.file: + path: "{{ veza_app_current_link }}" + src: "{{ veza_app_release_dir }}" + state: link + force: true + owner: "{{ veza_app_user }}" + group: "{{ veza_app_group }}" + tags: [veza_app, artifact] + +- name: Cleanup downloaded tarball + ansible.builtin.file: + path: "/tmp/veza-{{ veza_component }}-{{ veza_release_sha }}.tar.zst" + state: absent + tags: [veza_app, artifact] diff --git a/infra/ansible/roles/veza_app/tasks/config_binary.yml b/infra/ansible/roles/veza_app/tasks/config_binary.yml index 6bd61ee00..a8a89d38d 100644 --- a/infra/ansible/roles/veza_app/tasks/config_binary.yml +++ b/infra/ansible/roles/veza_app/tasks/config_binary.yml @@ -1,8 +1,74 @@ -# Stub — filled by the next commit. -# Will: render veza_app_env_template → veza_app_env_file (mode 0640), -# render veza_app_service_template → /etc/systemd/system/.service, -# install secret files from Vault, daemon-reload, enable+start the unit. +# Render env file + secret files + systemd unit, then start the +# service. Used for kind=binary (backend, stream); the static-kind +# equivalent is config_static.yml. --- -- name: Binary component config (placeholder) - ansible.builtin.debug: - msg: "TODO: render env={{ veza_app_env_file }}, unit=veza-{{ veza_component }}.service" +- name: Ensure /etc/veza exists for env + secret files + ansible.builtin.file: + path: "{{ veza_config_root }}" + state: directory + owner: root + group: "{{ veza_app_group }}" + mode: "0750" + tags: [veza_app, config] + +- name: Ensure /etc/veza/secrets exists (mode 0700) + ansible.builtin.file: + path: "{{ veza_config_root }}/secrets" + state: directory + owner: root + group: "{{ veza_app_group }}" + mode: "0750" + tags: [veza_app, config] + +- name: Render component env file from Vault + ansible.builtin.template: + src: "{{ veza_app_env_template }}" + dest: "{{ veza_app_env_file }}" + owner: root + group: "{{ veza_app_group }}" + mode: "{{ veza_app_file_mode }}" + notify: "veza-app restart" + tags: [veza_app, config] + +# Render each secret file from Vault. `loop_control.label` masks the +# value in playbook output even though `no_log: true` is set, defense +# in depth. +- name: Install secret files from Vault + ansible.builtin.copy: + content: >- + {{ (lookup('vars', item.var) | b64decode) + if item.decode | default('') == 'base64' + else lookup('vars', item.var) }} + dest: "{{ item.path }}" + owner: "{{ veza_app_user }}" + group: "{{ veza_app_group }}" + mode: "{{ item.mode }}" + loop: "{{ veza_app_secret_files }}" + loop_control: + label: "{{ item.path }}" + no_log: true + notify: "veza-app restart" + tags: [veza_app, config, secrets] + +- name: Render systemd unit + ansible.builtin.template: + src: "{{ veza_app_service_template }}" + dest: "/etc/systemd/system/{{ veza_app_service_name }}.service" + owner: root + group: root + mode: "0644" + notify: + - "veza-app daemon-reload" + - "veza-app restart" + tags: [veza_app, config, service] + +- name: Flush handlers so daemon-reload + restart happen before probe + ansible.builtin.meta: flush_handlers + tags: [veza_app, config, service] + +- name: Enable + start the service + ansible.builtin.systemd: + name: "{{ veza_app_service_name }}" + state: started + enabled: true + tags: [veza_app, service] diff --git a/infra/ansible/roles/veza_app/tasks/container.yml b/infra/ansible/roles/veza_app/tasks/container.yml index 8504c4dc4..2799eefd1 100644 --- a/infra/ansible/roles/veza_app/tasks/container.yml +++ b/infra/ansible/roles/veza_app/tasks/container.yml @@ -1,8 +1,24 @@ -# Stub — filled by the next commit. -# Will: incus delete --force ; incus launch images:debian/13 -# --profile veza-app --profile veza-net; wait until -# `incus exec -- true` succeeds (container ready). +# Reachability guard. The container is created (or destroyed-and- +# recreated) by playbooks/deploy_app.yml ON THE INCUS HOST before the +# role is invoked — by the time we run, the container exists and the +# `community.general.incus` connection plugin is wired in inventory. +# This task just smoke-tests the connection so a misconfigured run +# fails on the first task instead of on apt halfway through. --- -- name: Container recreate (placeholder) - ansible.builtin.debug: - msg: "TODO: implement Incus delete-then-launch for {{ veza_app_container_name }}" +- name: Verify the container is reachable via the connection plugin + ansible.builtin.command: /bin/true + changed_when: false + tags: [veza_app, container] + +- name: Record the SHA + color we are about to land + ansible.builtin.copy: + dest: "{{ veza_state_root }}/release.txt" + content: | + component={{ veza_component }} + color={{ veza_target_color }} + sha={{ veza_release_sha }} + deployed_at={{ ansible_date_time.iso8601 }} + owner: root + group: root + mode: "0644" + tags: [veza_app, container] diff --git a/infra/ansible/roles/veza_app/tasks/os_deps.yml b/infra/ansible/roles/veza_app/tasks/os_deps.yml index 90649d092..c20ffba06 100644 --- a/infra/ansible/roles/veza_app/tasks/os_deps.yml +++ b/infra/ansible/roles/veza_app/tasks/os_deps.yml @@ -1,7 +1,42 @@ -# Stub — filled by the next commit. -# Will: apt-get update + install veza_common_os_packages + -# veza_app_extra_packages inside the freshly-launched container. +# Install OS deps inside the freshly-created container. Wait briefly +# for cloud-init / debootstrap to finish first — apt locks held by +# `unattended-upgrades` on first boot would race a parallel +# `apt-get update`. --- -- name: OS deps install (placeholder) - ansible.builtin.debug: - msg: "TODO: apt install {{ veza_common_os_packages + veza_app_extra_packages }}" +- name: Ensure /var/lib/veza state dir exists + ansible.builtin.file: + path: "{{ veza_state_root }}" + state: directory + owner: root + group: root + mode: "0755" + tags: [veza_app, packages] + +- name: Wait for any first-boot apt lock to clear + ansible.builtin.shell: | + set -e + for i in $(seq 1 30); do + if ! fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 && \ + ! fuser /var/lib/apt/lists/lock >/dev/null 2>&1; then + exit 0 + fi + sleep 2 + done + echo "apt locks still held after 60s" + exit 1 + args: + executable: /bin/bash + changed_when: false + tags: [veza_app, packages] + +- name: Refresh apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 60 + tags: [veza_app, packages] + +- name: Install OS packages (common + component-specific) + ansible.builtin.apt: + name: "{{ veza_common_os_packages + veza_app_extra_packages }}" + state: present + tags: [veza_app, packages] diff --git a/infra/ansible/roles/veza_app/tasks/probe.yml b/infra/ansible/roles/veza_app/tasks/probe.yml index d032bf584..65ba82f0e 100644 --- a/infra/ansible/roles/veza_app/tasks/probe.yml +++ b/infra/ansible/roles/veza_app/tasks/probe.yml @@ -1,9 +1,33 @@ -# Stub — filled by the next commit. -# Will: curl http://: with retry until -# 200 or veza_healthcheck_retries × veza_healthcheck_delay_seconds -# elapses. Failure here makes the playbook fail fast — Phase E.fail -# in deploy_app.yml. +# Hammer the component's health endpoint until 200 or we exhaust the +# retry budget. This runs INSIDE the container (curl-to-localhost), +# which means we're proving the systemd unit is up and the process +# is bound — not the Incus DNS / network path. Phase D in +# playbooks/deploy_app.yml does the cross-container probe via curl +# from the runner. --- -- name: Health probe (placeholder) - ansible.builtin.debug: - msg: "TODO: probe http://{{ veza_app_container_name }}.{{ veza_incus_dns_suffix }}:{{ veza_app_listen_port }}{{ veza_app_health_path }}" +- name: Wait for {{ veza_app_service_name }} to answer on :{{ veza_app_listen_port }}{{ veza_app_health_path }} + ansible.builtin.uri: + url: "http://127.0.0.1:{{ veza_app_listen_port }}{{ veza_app_health_path }}" + method: GET + status_code: [200] + return_content: false + timeout: 5 + register: veza_app_probe + retries: "{{ veza_healthcheck_retries }}" + delay: "{{ veza_healthcheck_delay_seconds }}" + until: veza_app_probe.status == 200 + changed_when: false + tags: [veza_app, probe] + +- name: Record probe success + ansible.builtin.copy: + dest: "{{ veza_state_root }}/last-probe.txt" + content: | + probed_at={{ ansible_date_time.iso8601 }} + url=http://127.0.0.1:{{ veza_app_listen_port }}{{ veza_app_health_path }} + sha={{ veza_release_sha }} + result=ok + owner: root + group: root + mode: "0644" + tags: [veza_app, probe] diff --git a/infra/ansible/roles/veza_app/templates/backend.env.j2 b/infra/ansible/roles/veza_app/templates/backend.env.j2 new file mode 100644 index 000000000..3938fe988 --- /dev/null +++ b/infra/ansible/roles/veza_app/templates/backend.env.j2 @@ -0,0 +1,86 @@ +# Managed by Ansible — do not edit by hand. veza_app role, +# templates/backend.env.j2 ; rendered fresh on every deploy. +# Sourced by /etc/systemd/system/veza-backend.service via EnvironmentFile=. + +# --- Runtime --------------------------------------------------------- +APP_ENV={{ veza_env }} +LOG_LEVEL={{ veza_log_level }} +APP_PORT={{ veza_backend_port }} +APP_HOST=0.0.0.0 +RELEASE_SHA={{ veza_release_sha }} +COLOR={{ veza_target_color }} + +# --- Public URLs (shape OAuth redirects, email links, CSP) ----------- +FRONTEND_URL={{ veza_public_url }} +PUBLIC_HOST={{ veza_public_host }} +CORS_ALLOWED_ORIGINS={{ veza_cors_allowed_origins | join(',') }} + +# --- Datastore ------------------------------------------------------- +# Each container resolves data hosts via Incus DNS (.lxd suffix). +# postgres-primary is the writable side ; pgbouncer fronts it. +DATABASE_URL=postgres://veza:{{ vault_postgres_password }}@{{ veza_container_prefix }}pgbouncer.{{ veza_incus_dns_suffix }}:6432/veza?sslmode=require +DB_HOST={{ veza_container_prefix }}pgbouncer.{{ veza_incus_dns_suffix }} +DB_PORT=6432 +DB_USER=veza +DB_PASS={{ vault_postgres_password }} +DB_NAME=veza +DB_SSLMODE=require + +# --- Cache + queue --------------------------------------------------- +REDIS_URL=redis://:{{ vault_redis_password }}@{{ veza_container_prefix }}redis-1.{{ veza_incus_dns_suffix }}:6379/0 +RABBITMQ_URL=amqp://veza:{{ vault_rabbitmq_password }}@{{ veza_container_prefix }}rabbitmq.{{ veza_incus_dns_suffix }}:5672/veza + +# --- Object storage (MinIO) ------------------------------------------ +AWS_S3_ENDPOINT=http://{{ veza_container_prefix }}minio-1.{{ veza_incus_dns_suffix }}:9000 +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID={{ vault_minio_access_key }} +AWS_SECRET_ACCESS_KEY={{ vault_minio_secret_key }} +S3_BUCKET=veza-{{ veza_env }} + +# --- JWT (RS256) ----------------------------------------------------- +JWT_PRIVATE_KEY_PATH={{ veza_config_root }}/secrets/jwt-private.pem +JWT_PUBLIC_KEY_PATH={{ veza_config_root }}/secrets/jwt-public.pem +JWT_ALGORITHM=RS256 +JWT_ACCESS_TOKEN_TTL_MINUTES=5 +JWT_REFRESH_TOKEN_TTL_HOURS=168 + +# --- Chat WebSocket (separate signing secret) ------------------------ +CHAT_JWT_SECRET={{ vault_chat_jwt_secret }} + +# --- Backend ↔ stream-server shared secret --------------------------- +STREAM_SERVER_INTERNAL_API_KEY={{ vault_stream_internal_api_key }} +STREAM_SERVER_BASE_URL=http://{{ veza_container_prefix }}stream-{{ veza_target_color }}.{{ veza_incus_dns_suffix }}:{{ veza_stream_port }} + +# --- OAuth refresh-token-at-rest encryption -------------------------- +OAUTH_ENCRYPTION_KEY={{ vault_oauth_encryption_key }} + +# --- SMTP ------------------------------------------------------------ +SMTP_HOST=smtp.veza.fr +SMTP_PORT=587 +SMTP_USER=ops@veza.fr +SMTP_PASSWORD={{ vault_smtp_password }} +SMTP_FROM=noreply@veza.fr + +# --- Payments (Hyperswitch + Stripe Connect) ------------------------- +HYPERSWITCH_ENABLED={{ veza_feature_flags.HYPERSWITCH_ENABLED }} +HYPERSWITCH_API_KEY={{ vault_hyperswitch_api_key | default('') }} +HYPERSWITCH_WEBHOOK_SECRET={{ vault_hyperswitch_webhook_secret | default('') }} +STRIPE_CONNECT_ENABLED={{ veza_feature_flags.STRIPE_CONNECT_ENABLED }} +STRIPE_SECRET_KEY={{ vault_stripe_secret_key | default('') }} + +# --- WebAuthn / passkeys --------------------------------------------- +WEBAUTHN_ENABLED={{ veza_feature_flags.WEBAUTHN_ENABLED }} +WEBAUTHN_RP_ID={{ veza_public_host }} +WEBAUTHN_RP_NAME=Veza + +# --- Observability --------------------------------------------------- +SENTRY_DSN={{ vault_sentry_dsn | default('') }} +OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.{{ veza_incus_dns_suffix }}:4317 +OTEL_SERVICE_NAME=veza-backend +OTEL_TRACES_SAMPLER=parentbased_traceidratio +OTEL_TRACES_SAMPLER_ARG={{ veza_otel_sample_rate }} + +# --- Migrations ------------------------------------------------------ +# Backend auto-migrates on boot. Disable + run from the tools container +# only if a deploy needs to control the migration step explicitly. +RUN_MIGRATIONS_ON_BOOT=true diff --git a/infra/ansible/roles/veza_app/templates/veza-backend.service.j2 b/infra/ansible/roles/veza_app/templates/veza-backend.service.j2 new file mode 100644 index 000000000..6f8eb1ab9 --- /dev/null +++ b/infra/ansible/roles/veza_app/templates/veza-backend.service.j2 @@ -0,0 +1,33 @@ +# Managed by Ansible — do not edit by hand. +# veza_app role, templates/veza-backend.service.j2. +# Released SHA: {{ veza_release_sha }} ; color: {{ veza_target_color }} +[Unit] +Description=Veza backend API (Go) — color {{ veza_target_color }}, sha {{ veza_release_sha[:12] }} +Documentation=https://veza.fr/docs +After=network-online.target +Wants=network-online.target +AssertPathExists={{ veza_app_current_link }}/{{ veza_app_binary_name }} + +[Service] +Type=simple +User={{ veza_app_user }} +Group={{ veza_app_group }} +EnvironmentFile=-{{ veza_app_env_file }} +WorkingDirectory={{ veza_app_current_link }} +ExecStart={{ veza_app_current_link }}/{{ veza_app_binary_name }} +Restart=on-failure +RestartSec=5s +LimitNOFILE=65535 + +# Hardening — same baseline as the other Ansible-managed daemons. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths={{ veza_app_install_dir }} {{ veza_log_root }} {{ veza_state_root }} +PrivateTmp=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true + +[Install] +WantedBy=multi-user.target