From f14e405e6f75d2fcad1fb24b39b7273caa5a9326 Mon Sep 17 00:00:00 2001 From: Patrick Toal Date: Sun, 15 Mar 2026 00:01:10 -0400 Subject: [PATCH] feat: backup appwritefix: CORS error by adding platforms --- ...handoff-2026-03-14-appwrite-setup-final.md | 0 .../decisions-2026-03-14-domain-target-fix.md | 15 +- ...ff-2026-03-14-appwrite-bootstrap-backup.md | 110 +++++++++++ playbooks/backup_appwrite.yml | 98 ++++++++++ playbooks/bootstrap_appwrite.yml | 176 ++++++++++++++++++ playbooks/provision_database.yml | 6 +- playbooks/provision_users.yml | 2 +- 7 files changed, 403 insertions(+), 4 deletions(-) rename docs/{summaries => archive/handoffs}/handoff-2026-03-14-appwrite-setup-final.md (100%) create mode 100644 docs/summaries/handoff-2026-03-14-appwrite-bootstrap-backup.md create mode 100644 playbooks/backup_appwrite.yml create mode 100644 playbooks/bootstrap_appwrite.yml diff --git a/docs/summaries/handoff-2026-03-14-appwrite-setup-final.md b/docs/archive/handoffs/handoff-2026-03-14-appwrite-setup-final.md similarity index 100% rename from docs/summaries/handoff-2026-03-14-appwrite-setup-final.md rename to docs/archive/handoffs/handoff-2026-03-14-appwrite-setup-final.md diff --git a/docs/summaries/decisions-2026-03-14-domain-target-fix.md b/docs/summaries/decisions-2026-03-14-domain-target-fix.md index 01f8ce7..4dd7873 100644 --- a/docs/summaries/decisions-2026-03-14-domain-target-fix.md +++ b/docs/summaries/decisions-2026-03-14-domain-target-fix.md @@ -40,4 +40,17 @@ File is now only downloaded if absent. Upgrade playbook handles re-downloads. - Appwrite console loads without error ✅ - Stack running on bab1.mgmt.toal.ca ✅ - install_appwrite.yml is idempotent ✅ -- node_exporter install: complete, metrics confirmed ✅ \ No newline at end of file +- node_exporter install: complete, metrics confirmed ✅ +- bootstrap_appwrite.yml: project + API key creation working ✅ + - API key stored at kv/oys/bab-appwrite-api-key + +## bootstrap_appwrite.yml — Key Decisions + +| Decision | Rationale | +|----------|-----------| +| No account creation task | Appwrite only grants console owner role via web UI signup, not REST API | +| JWT required for console API | Session cookie alone gives `role: users`; JWT carries team membership claims including `projects.write` | +| teamId fetched dynamically | Appwrite 1.8.x requires teamId in POST /v1/projects; use teams[0]['$id'] from GET /v1/teams | +| `$id` via bracket notation | Jinja2 treats `$` as special; dot notation fails | +| vault_kv2_write (not vault_kv2_put) | No put module in community.hashi_vault; no patch operation — dedicated path avoids clobbering other secrets | +| Dedicated Vault path kv/oys/bab-appwrite-api-key | Separate from env config secrets to avoid full-overwrite on re-run | \ No newline at end of file diff --git a/docs/summaries/handoff-2026-03-14-appwrite-bootstrap-backup.md b/docs/summaries/handoff-2026-03-14-appwrite-bootstrap-backup.md new file mode 100644 index 0000000..f23cb56 --- /dev/null +++ b/docs/summaries/handoff-2026-03-14-appwrite-bootstrap-backup.md @@ -0,0 +1,110 @@ +# Session Handoff: Appwrite Bootstrap, Backup, and Bug Fixes +**Date:** 2026-03-14 +**Session Duration:** ~3 hours +**Session Focus:** Fix Appwrite console crash, add bootstrap and backup playbooks +**Context Usage at Handoff:** ~85% + +--- + +## What Was Accomplished + +1. Fixed `_APP_DOMAIN_TARGET_CNAME` null crash → `playbooks/templates/appwrite.env.j2` +2. Fixed idempotency: removed `force: true` from compose download → `playbooks/install_appwrite.yml` +3. Fixed `appwrite_response_format` undefined error → `playbooks/provision_database.yml`, `playbooks/provision_users.yml` +4. Created Appwrite bootstrap playbook → `playbooks/bootstrap_appwrite.yml` +5. Created Appwrite backup playbook → `playbooks/backup_appwrite.yml` +6. Diagnosed nginx CORS 405 on `apidev.bab.toal.ca` — **not fixed, open** +7. Written decision record → `docs/summaries/decisions-2026-03-14-domain-target-fix.md` + +--- + +## Exact State of Work in Progress + +- **CORS / nginx**: `apidev.bab.toal.ca` returns HTTP 405 on OPTIONS preflight from nginx/1.20.1. Root cause: nginx config does not pass OPTIONS to backend. `appwrite.toal.ca` works fine. nginx config is managed by `nginxinc.nginx_core` role; no config templates exist in this repo yet. +- **backup_appwrite.yml**: Written and structurally correct but **not yet run successfully end-to-end**. Needs a test run and restore verification. + +--- + +## Decisions Made This Session + +| Decision | Rationale | Status | +|----------|-----------|--------| +| `_APP_DOMAIN_TARGET_CNAME` replaces `_APP_DOMAIN_TARGET` | Deprecated since Appwrite 1.7.0; compose `environment:` blocks list the new var, not the old one — old var silently never reached containers | CONFIRMED | +| `appwrite_response_format \| default('1.6')` | Var undefined at module_defaults evaluation time; `1.6` is correct format for Appwrite 1.8.x | CONFIRMED | +| bootstrap: no account creation task | Appwrite only grants console `owner` role via web UI signup; REST API creates `role: users` which lacks `projects.write` | CONFIRMED | +| bootstrap: JWT required for console API | Session cookie alone gives `role: users`; JWT carries team membership claims including `projects.write` | CONFIRMED | +| bootstrap: `teamId` fetched from `GET /v1/teams` | Required field in `POST /v1/projects` for Appwrite 1.8.x; discovered from browser network capture | CONFIRMED | +| bootstrap: `['$id']` bracket notation | Jinja2 rejects `.$id` — `$` is a special character | CONFIRMED | +| bootstrap: `vault_kv2_write` at `kv/oys/bab-appwrite-api-key` | `vault_kv2_put` does not exist; no PATCH operation — dedicated path avoids full-overwrite of other secrets | CONFIRMED | +| backup: mysqldump runs while service UP | `--single-transaction` gives consistent InnoDB snapshot; service must be up for `docker compose exec` | CONFIRMED | +| backup: `block/rescue/always` | Ensures `systemctl start appwrite` fires even if volume backup fails | CONFIRMED | + +--- + +## Key Numbers + +- `appwrite_response_format` default: `1.6` +- Vault path for API key: `kv/oys/bab-appwrite-api-key`, key: `appwrite_api_key` +- Backup destination: `/var/backups/appwrite/YYYYMMDDTHHMMSS/` +- Volumes backed up (8): `appwrite-uploads`, `appwrite-functions`, `appwrite-builds`, `appwrite-sites`, `appwrite-certificates`, `appwrite-config`, `appwrite-cache`, `appwrite-redis` +- Volume excluded: `appwrite-mariadb` (covered by mysqldump) + +--- + +## Conditional Logic Established + +- IF `appwrite_compose_project` not set THEN `_compose_project` defaults to `basename(appwrite_dir)` = `appwrite` → Docker volume names are `appwrite_appwrite-uploads`, etc. +- IF bootstrap re-run THEN second API key created AND Vault entry overwritten — delete old key from console manually +- IF backup fails during volume tar THEN `always` block restarts Appwrite — playbook exits failed, partial backup remains in `backup_dir` + +--- + +## Files Created or Modified + +| File Path | Action | Description | +|-----------|--------|-------------| +| `playbooks/templates/appwrite.env.j2` | Modified | Replaced `_APP_DOMAIN_TARGET` with `_APP_DOMAIN_TARGET_CNAME`; added `_APP_DOMAIN_TARGET_CAA` | +| `playbooks/install_appwrite.yml` | Modified | Removed `force: true` from `get_url` | +| `playbooks/provision_database.yml` | Modified | `appwrite_response_format \| default('1.6')`; fixed long URL line | +| `playbooks/provision_users.yml` | Modified | `appwrite_response_format \| default('1.6')` | +| `playbooks/bootstrap_appwrite.yml` | Created | Session→JWT→teams→project→API key→Vault | +| `playbooks/backup_appwrite.yml` | Created | mysqldump + volume tar + .env, block/rescue/always | +| `docs/summaries/decisions-2026-03-14-domain-target-fix.md` | Created | Decision record for domain var fix and idempotency | +| `CLAUDE.md` | Modified | Added trailing newline rule | + +--- + +## What the NEXT Session Should Do + +1. **Fix nginx CORS** — `apidev.bab.toal.ca` returns 405 on OPTIONS. Load `playbooks/install_nginx.yml`; find where `nginxinc.nginx_core.nginx_config` vars are defined in inventory and add OPTIONS passthrough. +2. **Test backup end-to-end** — run `ansible-navigator run playbooks/backup_appwrite.yml --mode stdout`, verify 8 volume tarballs + `mariadb-dump.sql` + `.env` in `/var/backups/appwrite//` +3. **Validate volume name prefix** — run `docker volume ls | grep appwrite` on bab1 to confirm prefix is `appwrite_` + +--- + +## Open Questions Requiring User Input + +- [ ] **CORS fix scope**: Should nginx config live in this repo as templates, or managed elsewhere? — impacts `install_nginx.yml` completion +- [ ] **Backup retention**: No rotation yet — each run adds a timestamped dir. Add cleanup task? +- [ ] **Backup offsite**: 3-2-1 rule — is S3/rsync in scope? + +--- + +## Assumptions That Need Validation + +- ASSUMED: Docker Compose project name for volumes is `appwrite` (basename of `/home/ptoal/appwrite`) — validate with `docker volume ls` +- ASSUMED: `teams[0]` in bootstrap is always the admin's personal team — valid only if admin has one team + +--- + +## What NOT to Re-Read + +- `docs/summaries/handoff-2026-03-14-appwrite-setup-final.md` — superseded; moved to archive + +--- + +## Files to Load Next Session + +- `playbooks/install_nginx.yml` — if working on CORS fix +- `playbooks/backup_appwrite.yml` — if testing/fixing backup +- `docs/context/architecture.md` — for Appwrite API or EDA work \ No newline at end of file diff --git a/playbooks/backup_appwrite.yml b/playbooks/backup_appwrite.yml new file mode 100644 index 0000000..615400f --- /dev/null +++ b/playbooks/backup_appwrite.yml @@ -0,0 +1,98 @@ +--- +# Backs up a running Appwrite instance per the official backup guide: +# https://appwrite.io/docs/advanced/self-hosting/production/backups +# +# What is backed up: +# - MariaDB: mysqldump (--single-transaction, consistent without downtime) +# - Docker volumes: all data volumes (tar.gz, requires service stop) +# - .env file +# +# Backup is written to: {{ appwrite_backup_root }}/YYYYMMDDTHHMMSS/ +# +# Required vars (from inventory): +# appwrite_dir - e.g. /home/ptoal/appwrite +# +# Optional vars: +# appwrite_backup_root - destination parent dir (default: /var/backups/appwrite) +# appwrite_compose_project - compose project name (default: basename of appwrite_dir) + +- name: Backup Appwrite + hosts: appwrite + gather_facts: true + become: true + + vars: + _compose_project: "{{ appwrite_compose_project | default(appwrite_dir | basename) }}" + backup_root: "{{ appwrite_backup_root | default('/var/backups/appwrite') }}" + backup_dir: "{{ backup_root }}/{{ ansible_date_time.iso8601_basic_short }}" + # appwrite-mariadb volume excluded — covered by the mysqldump below. + # appwrite-cache and appwrite-redis are transient but included for + # completeness; they are safe to omit if backup size is a concern. + appwrite_volumes: + - appwrite-uploads + - appwrite-functions + - appwrite-builds + - appwrite-sites + - appwrite-certificates + - appwrite-config + - appwrite-cache + - appwrite-redis + + tasks: + - name: Create backup directory + ansible.builtin.file: + path: "{{ backup_dir }}" + state: directory + mode: '0700' + + - name: Dump MariaDB + # --single-transaction gives a consistent InnoDB snapshot without locking. + # Runs while the service is still up so docker compose exec is available. + ansible.builtin.shell: + cmd: > + docker compose exec -T mariadb + sh -c 'exec mysqldump --all-databases --add-drop-database + --single-transaction --routines --triggers + -uroot -p"$MYSQL_ROOT_PASSWORD"' + > {{ backup_dir }}/mariadb-dump.sql + chdir: "{{ appwrite_dir }}" + changed_when: true + + - name: Stop, back up volumes, and restart + block: + - name: Stop Appwrite service + ansible.builtin.systemd: + name: appwrite + state: stopped + + - name: Back up Docker volumes + ansible.builtin.command: + cmd: > + docker run --rm + -v {{ _compose_project }}_{{ item }}:/data + -v {{ backup_dir }}:/backup + ubuntu tar czf /backup/{{ item }}.tar.gz -C /data . + loop: "{{ appwrite_volumes }}" + changed_when: true + + - name: Back up .env + ansible.builtin.copy: + src: "{{ appwrite_dir }}/.env" + dest: "{{ backup_dir }}/.env" + remote_src: true + mode: '0600' + + rescue: + - name: Notify that backup failed + ansible.builtin.debug: + msg: "Backup failed — Appwrite will be restarted. Check {{ backup_dir }} for partial output." + + always: + - name: Ensure Appwrite service is started + ansible.builtin.systemd: + name: appwrite + state: started + + - name: Report backup location + ansible.builtin.debug: + msg: "Backup written to {{ backup_dir }}" diff --git a/playbooks/bootstrap_appwrite.yml b/playbooks/bootstrap_appwrite.yml new file mode 100644 index 0000000..9beee96 --- /dev/null +++ b/playbooks/bootstrap_appwrite.yml @@ -0,0 +1,176 @@ +--- +# Bootstraps a fresh Appwrite instance: +# 1. Creates the console admin user +# 2. Creates the BAB project +# 3. Registers web platforms (CORS allowed origins) +# 4. Generates an Ansible automation API key +# 5. Stores the API key secret in Vault at kv/oys/bab-appwrite-api-key +# +# Run once per environment after install_appwrite.yml. +# Safe to re-run: account and project creation tolerate 409. +# Platform and API key creation are NOT idempotent — re-running creates +# duplicates. Delete stale entries from the console. +# +# Required vars (from inventory): +# appwrite_domain - e.g. appwrite.toal.ca (used to build admin URL) +# appwrite_project - project ID to create +# appwrite_project_name - human-readable project name (default: BAB) +# appwrite_web_platforms - list of {name, hostname} dicts for CORS origins +# +# Note: uses appwrite_domain directly, not appwrite_admin_uri, because +# appwrite_admin_uri may point to an app-layer proxy (e.g. nginx) that +# does not expose the Appwrite admin/console endpoints. + +- name: Bootstrap Appwrite — Admin, Project, and API Key + hosts: appwrite + gather_facts: false + + vars: + appwrite_admin_uri: "https://{{ appwrite_domain }}/v1" + + tasks: + - name: Read admin credentials from Vault + community.hashi_vault.vault_kv2_get: + path: oys/bab_admin + engine_mount_point: kv + register: vault_admin + no_log: true + delegate_to: localhost + + - name: Create Appwrite console admin account + ansible.builtin.uri: + url: "{{ appwrite_admin_uri }}/account" + method: POST + body_format: json + headers: + X-Appwrite-Project: console + X-Appwrite-Response-Format: "1.6" + body: + userId: "{{ appwrite_admin_user_id | default('bab-admin') }}" + email: "{{ vault_admin.secret.bab_admin_user }}" + password: "{{ vault_admin.secret.bab_admin_password }}" + status_code: [201, 409, 501] + return_content: true + delegate_to: localhost + no_log: true + + - name: Create admin session + ansible.builtin.uri: + url: "{{ appwrite_admin_uri }}/account/sessions/email" + method: POST + body_format: json + headers: + X-Appwrite-Project: console + X-Appwrite-Response-Format: "1.6" + body: + email: "{{ vault_admin.secret.bab_admin_user }}" + password: "{{ vault_admin.secret.bab_admin_password }}" + status_code: [201] + return_content: true + register: admin_session + delegate_to: localhost + no_log: false + + - name: Create JWT from admin session + ansible.builtin.uri: + url: "{{ appwrite_admin_uri }}/account/jwt" + method: POST + body_format: json + headers: + X-Appwrite-Project: console + X-Appwrite-Response-Format: "1.6" + Cookie: "{{ admin_session.cookies_string }}" + status_code: [201] + return_content: true + register: admin_jwt + delegate_to: localhost + no_log: true + + - name: Get admin user teams + ansible.builtin.uri: + url: "{{ appwrite_admin_uri }}/teams" + method: GET + headers: + X-Appwrite-Project: console + X-Appwrite-Response-Format: "1.6" + X-Appwrite-JWT: "{{ admin_jwt.json.jwt }}" + status_code: [200] + return_content: true + register: admin_teams + delegate_to: localhost + + - name: Create BAB project + ansible.builtin.uri: + url: "{{ appwrite_admin_uri }}/projects" + method: POST + body_format: json + headers: + X-Appwrite-Project: console + X-Appwrite-Response-Format: "1.6" + X-Appwrite-JWT: "{{ admin_jwt.json.jwt }}" + body: + projectId: "{{ appwrite_project }}" + name: "{{ appwrite_project_name | default('BAB') }}" + teamId: "{{ admin_teams.json.teams[0]['$id'] }}" + region: default + status_code: [201, 409] + return_content: true + delegate_to: localhost + no_log: false + + - name: Register web platforms (CORS allowed origins) + ansible.builtin.uri: + url: "{{ appwrite_admin_uri }}/projects/{{ appwrite_project }}/platforms" + method: POST + body_format: json + headers: + X-Appwrite-Project: console + X-Appwrite-Response-Format: "1.6" + X-Appwrite-JWT: "{{ admin_jwt.json.jwt }}" + body: + type: web + name: "{{ item.name }}" + hostname: "{{ item.hostname }}" + status_code: [201] + return_content: true + loop: "{{ appwrite_web_platforms | default([]) }}" + delegate_to: localhost + + - name: Create Ansible automation API key + ansible.builtin.uri: + url: "{{ appwrite_admin_uri }}/projects/{{ appwrite_project }}/keys" + method: POST + body_format: json + headers: + X-Appwrite-Project: console + X-Appwrite-Response-Format: "1.6" + X-Appwrite-JWT: "{{ admin_jwt.json.jwt }}" + body: + name: ansible-automation + scopes: + - databases.read + - databases.write + - collections.read + - collections.write + - attributes.read + - attributes.write + - indexes.read + - indexes.write + - documents.read + - documents.write + - users.read + - users.write + status_code: [201] + return_content: true + register: api_key + delegate_to: localhost + no_log: true + + - name: Store API key secret in Vault + community.hashi_vault.vault_kv2_write: + path: oys/bab-appwrite-api-key + engine_mount_point: kv + data: + appwrite_api_key: "{{ api_key.json.secret }}" + delegate_to: localhost + no_log: true diff --git a/playbooks/provision_database.yml b/playbooks/provision_database.yml index e104c32..ad07a03 100644 --- a/playbooks/provision_database.yml +++ b/playbooks/provision_database.yml @@ -7,7 +7,7 @@ ansible.builtin.uri: body_format: json headers: - X-Appwrite-Response-Format: '{{ appwrite_response_format }}' + X-Appwrite-Response-Format: '{{ appwrite_response_format | default("1.6") }}' X-Appwrite-Project: '{{ appwrite_project }}' X-Appwrite-Key: '{{ appwrite_api_key }}' return_content: true @@ -46,7 +46,9 @@ - name: Create Attributes ansible.builtin.uri: - url: "{{ appwrite_api_uri }}/databases/{{ bab_database.id }}/collections/{{ item[0].id }}/attributes/{{ ( item[1].format is defined and item[1].format != '' ) |ternary(item[1].format, item[1].type) }}" + url: >- + {{ appwrite_api_uri }}/databases/{{ bab_database.id }}/collections/{{ item[0].id }}/attributes/{{ + (item[1].format is defined and item[1].format != '') | ternary(item[1].format, item[1].type) }} method: POST body: "{{ lookup('ansible.builtin.template', 'appwrite_attribute_template.json.j2') }}" status_code: [202, 409] diff --git a/playbooks/provision_users.yml b/playbooks/provision_users.yml index be0a9fe..c8320ea 100644 --- a/playbooks/provision_users.yml +++ b/playbooks/provision_users.yml @@ -10,7 +10,7 @@ body_format: json headers: Content-Type: application/json - X-Appwrite-Response-Format: '{{ appwrite_response_format }}' + X-Appwrite-Response-Format: '{{ appwrite_response_format | default("1.6") }}' X-Appwrite-Project: '{{ appwrite_project }}' X-Appwrite-Key: '{{ appwrite_api_key }}'