From 589462e6d0eaca6d9d91be177820d72840dff65d Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Wed, 8 Jul 2026 05:59:11 -0500 Subject: [PATCH] updates --- IMPLEMENTATION.md | 181 ++++++++++++------ README.md | 18 +- ansible.cfg | 3 +- inventory/group_vars/all.yml | 54 ++++++ inventory/host_vars/adama.yml | 13 ++ inventory/host_vars/ai-server-4080.yml | 16 ++ inventory/host_vars/desktop.yml | 6 - inventory/host_vars/roslin.yml | 10 + inventory/hosts.yml | 3 +- playbooks/deploy-apps.yml | 13 +- playbooks/site.yml | 3 + roles/app-deploy/tasks/django.yml | 88 +++++++++ roles/app-deploy/tasks/main.yml | 59 +++++- roles/app-deploy/tasks/node_static.yml | 53 +++++ roles/common/tasks/main.yml | 7 + roles/docker/tasks/main.yml | 25 +-- roles/gitea-key/defaults/main.yml | 6 + roles/gitea-key/tasks/main.yml | 74 +++++++ roles/nodejs/defaults/main.yml | 3 + roles/nodejs/tasks/main.yml | 52 +++++ roles/tianji/tasks/main.yml | 6 + roles/web-static/defaults/main.yml | 3 + roles/web-static/tasks/main.yml | 57 ++++++ .../templates/docker-compose.yml.j2 | 12 ++ roles/web-static/templates/nginx.conf.j2 | 19 ++ scripts/deploy.sh | 31 ++- scripts/provision.sh | 58 +++++- 27 files changed, 771 insertions(+), 102 deletions(-) create mode 100644 inventory/host_vars/adama.yml create mode 100644 inventory/host_vars/ai-server-4080.yml delete mode 100644 inventory/host_vars/desktop.yml create mode 100644 inventory/host_vars/roslin.yml create mode 100644 roles/app-deploy/tasks/django.yml create mode 100644 roles/app-deploy/tasks/node_static.yml create mode 100644 roles/gitea-key/defaults/main.yml create mode 100644 roles/gitea-key/tasks/main.yml create mode 100644 roles/nodejs/defaults/main.yml create mode 100644 roles/nodejs/tasks/main.yml create mode 100644 roles/tianji/tasks/main.yml create mode 100644 roles/web-static/defaults/main.yml create mode 100644 roles/web-static/tasks/main.yml create mode 100644 roles/web-static/templates/docker-compose.yml.j2 create mode 100644 roles/web-static/templates/nginx.conf.j2 diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index db9f582..0253595 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -7,10 +7,9 @@ Ansible-based provisioning and deployment for homelab web servers. ```mermaid flowchart TB subgraph provision ["Provisioning (manual / rare)"] - Desktop1["Ubuntu Desktop\n(control node)"] - Desktop1 -->|ansible-playbook site.yml| Adama - Desktop1 -->|ansible-playbook site.yml| Roslin - Desktop1 -->|ansible-playbook site.yml| DesktopTarget + Control1["ai-server-4080\n(control node)"] + Control1 -->|ansible-playbook site.yml| Adama + Control1 -->|ansible-playbook site.yml| Roslin end subgraph cicd ["CI/CD (every merge to master)"] @@ -20,14 +19,13 @@ flowchart TB Deploy --> AnsibleDeploy["ansible-playbook deploy-apps.yml"] AnsibleDeploy --> Adama2["adama"] AnsibleDeploy --> Roslin2["roslin"] - AnsibleDeploy --> Desktop2["desktop"] end ``` | Pipeline | When | Playbook | Where it runs | |----------|------|----------|---------------| -| **Provision** | New VM, OS change, firewall, Docker install | `site.yml` | Desktop — run manually | -| **Deploy** | Green unit tests on `master` | `deploy-apps.yml` | Gitea Act runner on desktop | +| **Provision** | New VM, OS change, firewall, Docker install | `site.yml` | ai-server-4080 — run manually | +| **Deploy** | Green unit tests on `master` | `deploy-apps.yml` | Gitea Act runner on ai-server-4080 | Both pipelines share the same inventory (`inventory/hosts.yml`). @@ -35,9 +33,9 @@ Both pipelines share the same inventory (`inventory/hosts.yml`). | Name | IP | Role | |------|-----|------| -| adama | 10.0.0.77 | Ubuntu Server VM (Proxmox) | -| roslin | 10.0.0.176 | Ubuntu Server VM (Proxmox) | -| desktop | *see `host_vars/desktop.yml`* | Ubuntu Desktop — control node + deployment target | +| adama | 10.0.0.77 | Ubuntu Server VM (Proxmox) — app host | +| roslin | 10.0.0.176 | Ubuntu Server VM (Proxmox) — app host | +| ai-server-4080 | 10.0.0.128 | Control node + Gitea act runner (no app workloads) | Hostname on this machine: `ryan-development-1` @@ -52,18 +50,23 @@ server-infra/ ├── inventory/ │ ├── hosts.yml │ ├── group_vars/ -│ │ ├── all.yml -│ │ └── webservers.yml +│ │ └── all.yml # vars + app_catalog │ └── host_vars/ -│ └── desktop.yml +│ ├── adama.yml # host_apps (django + dta_webapp) +│ ├── roslin.yml # host_apps (mirrors adama) +│ └── ai-server-4080.yml # control node / act runner, no workloads ├── playbooks/ │ ├── site.yml # Phase 1: provision -│ └── deploy-apps.yml # Phase 2: CI deploy (stub) +│ └── deploy-apps.yml # Phase 2: CI deploy ├── roles/ │ ├── common/ # Base packages │ ├── ufw/ # Firewall │ ├── docker/ # Docker CE + compose plugin -│ └── app-deploy/ # App deploy (stub for Phase 2) +│ ├── nodejs/ # Node.js + npm + npx (NodeSource) +│ ├── gitea-key/ # per-server SSH key + Gitea access probe +│ ├── tianji/ # Monitoring reporter +│ ├── app-deploy/ # django (docker) + node-static deploy +│ └── web-static/ # nginx container serving /var/www builds └── scripts/ ├── provision.sh # Wrapper with --limit support └── deploy.sh # Wrapper for deploy playbook @@ -74,7 +77,7 @@ server-infra/ Ansible needs SSH + sudo on each target before playbooks work. 1. Create `westfarn` on each VM with sudo membership. -2. Copy your SSH public key from the desktop: +2. Copy your SSH public key from the control node (ai-server-4080): ```bash ssh-copy-id westfarn@10.0.0.77 ssh-copy-id westfarn@10.0.0.176 @@ -84,7 +87,7 @@ Ansible needs SSH + sudo on each target before playbooks work. ssh westfarn@10.0.0.77 ssh westfarn@10.0.0.176 ``` -4. On the desktop (control node), install Ansible: +4. On ai-server-4080 (control node), install Ansible: ```bash sudo apt update && sudo apt install -y ansible # or: pip install ansible @@ -94,7 +97,7 @@ Ansible needs SSH + sudo on each target before playbooks work. cd ~/Documents/repos/server-infra ansible-galaxy collection install -r requirements.yml ``` -6. Update `inventory/host_vars/desktop.yml` with this machine's LAN IP. +6. Update `inventory/host_vars/ai-server-4080.yml` with this machine's LAN IP (`ansible_host`). ## Testing on a Single Server @@ -118,7 +121,7 @@ ansible adama -m ping # Same for other hosts ./scripts/provision.sh roslin -./scripts/provision.sh desktop +./scripts/provision.sh ai-server-4080 ``` ### Provision all hosts @@ -145,6 +148,9 @@ Applies roles in order to the `webservers` group: | `common` | apt update, git, python3, pip, curl, ca-certificates | | `ufw` | Firewall: SSH from LAN only, HTTP/HTTPS public | | `docker` | Docker CE, compose plugin, add `westfarn` to `docker` group | +| `nodejs` | Node.js + npm + npx (NodeSource) for `dta_webapp` builds | +| `gitea-key` | Per-server SSH key + Gitea access probe | +| `tianji` | Monitoring reporter | ### UFW rules @@ -161,56 +167,121 @@ After Docker install, re-SSH so the `docker` group membership takes effect. ## Phase 2: CI Deploy (`deploy-apps.yml`) -Not fully implemented yet. Planned flow: +### Apps -1. Gitea push triggers unit tests. -2. On success, Act runner on desktop runs `deploy-apps.yml`. -3. Ansible fans out to all `webservers` hosts. +| App | Type | Hosts | Notes | +|-----|------|-------|-------| +| `company_site` | django (docker) | adama + roslin | active/active behind NPM | +| `dta_service` | django (docker) | adama + roslin | active/active behind NPM | +| `dta_webapp` | node/vite static | adama + roslin | active/active; built to `/var/www/_dta_webapp`, served by web-static nginx | -### Planned `company_site` workflow change +Both environments (`beta`, `prod`) are deployed. Django apps use a **shared external +Postgres** (via `DATABASE_URL` in each host's env file) so active/active replicas +share one database. -```yaml -# company_site/.gitea/workflows/deploy.yml (future) -jobs: - deploy: - runs-on: self-hosted - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ gitea.event.workflow_run.head_sha }} +### Data model - - name: Deploy to all webservers - run: | - ~/Documents/repos/server-infra/scripts/deploy.sh \ - --extra-vars "app_ref=${{ gitea.event.workflow_run.head_sha }}" -``` +- `app_catalog` (`group_vars/all.yml`) — how each app is built (repo, type, compose file, migrate cmd). +- `host_apps` (`host_vars/.yml`) — which app+env+port runs on that host. +- Django app = one compose project per env: project name `_`, host port from `host_apps`. + Ports match across adama/roslin so NPM can balance `adama:PORT` + `roslin:PORT`. -### Planned `app-deploy` role (post-dockerize) +### Ports -Per host: +| App | beta | prod | +|-----|------|------| +| company_site | 8010 | 8000 | +| dta_service | 8011 | 8001 | +| dta_webapp (nginx) | 8081 | 8080 | -1. Clone or pull app repo at pinned SHA. -2. `docker compose pull && docker compose up -d`. -3. Optional health check. +### Flow -Pre-dockerize interim: role can rsync/systemd like current `company_site/scripts/deploy.sh`. +1. Gitea push to `master` → repo's `.gitea/workflows` runs tests. +2. On green, deploy job on the self-hosted runner calls: + ```bash + ~/Documents/repos/server-infra/scripts/deploy.sh \ + --app company_site --env prod --ref "${{ gitea.sha }}" + ``` +3. `deploy-apps.yml` runs against `webservers`; each host deploys only the + matching app+env from its `host_apps`. + +### `app-deploy` role behavior + +- **django**: push per-app secret from control node `{{ secrets_dir }}//_.env` + to host `{{ apps_env_dir }}` → git checkout at ref → copy `.env` into checkout → + `docker compose build` → `up -d` → migrate (run once, shared DB). +- **node-static**: git checkout at ref → `npm ci` → `npm run build:` + (writes to `/var/www/_dta_webapp`). +- **web-static** role: one nginx container per app host (adama + roslin) serving + the static roots on their ports; NPM balances across both. + +### Reverse proxy / load balancing (NPM at 10.0.0.230) + +Ansible does **not** manage NPM. It only guarantees stable host ports. In NPM you +point each domain at the backend(s): + +- Single host: standard Proxy Host → `adama:PORT`. +- Active/active: jc21 NPM's UI Proxy Host is single-target. To balance + adama+roslin you need the **Advanced** tab with a custom `upstream {}` block + (or a real LB). Confirm this before relying on active/active. + +### Required changes IN each app repo (owned separately) + +- [ ] `docker-compose.prod.yml`: drop the bundled `db` service; `web` reads + `DATABASE_URL` / `DB_HOST` pointing at the shared external Postgres. +- [ ] Each app has its own database + user on the shared Postgres. +- [ ] `.gitea/workflows/deploy.yml`: replace the local `scripts/deploy.sh` step + with a call to `server-infra/scripts/deploy.sh --app --env --ref ` + (keep the test/docker jobs). +- [ ] `dta_webapp`: `npm run build:beta` / `build:prod` output to + `/var/www/beta_dta_webapp` / `/var/www/prod_dta_webapp`. + +### Shared Postgres (10.0.0.230, same box as NPM) + +One shared instance; each app+env gets its own database (beta and prod MUST NOT +share a DB — active/active replicas of the same env share one DB, different envs +do not). + +| app | env | database | DATABASE_URL | +|-----|-----|----------|--------------| +| company_site | prod | `company_site` | `postgres://westfarn:@10.0.0.230:5432/company_site` | +| company_site | beta | `company_site_beta` | `postgres://westfarn:@10.0.0.230:5432/company_site_beta` | +| dta_service | prod | `dta_service` | `postgres://westfarn:@10.0.0.230:5432/dta_service` | +| dta_service | beta | `dta_service_beta` | `postgres://westfarn:@10.0.0.230:5432/dta_service_beta` | + +Server prereqs on 10.0.0.230: create the 4 DBs + grant `westfarn`; +`listen_addresses` covers LAN; `pg_hba.conf` allows `10.0.0.0/24`; firewall opens +5432 to `10.0.0.0/24` only. + +### One-time host bootstrap (per target) + +- [x] Gitea SSH key: the `gitea-key` role (in `site.yml`) generates a key per + server, configures SSH for port 30009, probes access, and — if the server + can't reach Gitea yet — prints the public key to add and stops. Add the key + (Gitea user SSH keys, or repo Deploy Keys) and re-run provisioning. +- [ ] Create control-node secrets `{{ secrets_dir }}//_.env` + (default `~/Documents/secrets//_.env`) with `DATABASE_URL` + (see table), `DJANGO_ENV`, `DJANGO_SECRET_KEY`, `WEB_PORT` (matching the port + table). Deploy pushes these to `/opt/apps/env/_.env` (mode 600) on + adama + roslin. Never committed to git. +- [x] Node.js/npm/npx for the `dta_webapp` build — installed by the `nodejs` + role in `site.yml` (NodeSource, `node_major` default 20). ## Gitea Act Runner -**Recommended:** Single self-hosted runner on the desktop. +**Recommended:** Single self-hosted runner on ai-server-4080. -- One build artifact, one orchestration point. -- VMs only run containers; no runner needed on them for deploy fan-out. +- One orchestration point. +- App hosts (adama/roslin) run the workloads; no runner needed on them for deploy fan-out. - Runner needs: Ansible, this repo checked out, SSH key to all hosts, vault password (later). -### Runner requirements on desktop +### Runner requirements on ai-server-4080 | Requirement | Why | |-------------|-----| | Ansible | Run `deploy-apps.yml` | | `server-infra` checkout | Playbooks + inventory | -| SSH key to all hosts | Including loopback to desktop | -| Docker | Build images before push to hosts (Phase 2) | +| SSH key to adama + roslin | Deploy fan-out | ## SSH Keys for CI Deploy @@ -237,7 +308,7 @@ Store vault password for CI in a file readable only by the Act runner (e.g. `~/. | # | Task | Status | |---|------|--------| | 1 | Create `server-infra` repo | Done | -| 2 | Inventory with all 3 hosts | Done — update desktop IP | +| 2 | Inventory with all 3 hosts | Done | | 3 | Bootstrap SSH to adama + roslin | Manual | | 4 | `site.yml` → common, ufw, docker | Done | | 5 | Verify `ansible webservers -m ping` | Manual | @@ -250,11 +321,9 @@ Store vault password for CI in a file readable only by the Act runner (e.g. `~/. ## Open Decisions -1. **Desktop LAN IP** — set in `inventory/host_vars/desktop.yml`. -2. **Same app on all three?** — prod mirror vs adama=prod / roslin=staging / desktop=dev. -3. **Deploy user** — `westfarn` vs dedicated `deploy` for CI. -4. **Gitea URL** — for clone URLs in `app-deploy` role. -5. **Reverse proxy** — Caddy/nginx on host before containers? Affects Phase 2. +1. **Deploy user** — `westfarn` vs dedicated `deploy` for CI. +2. **NPM load balancing** — confirm jc21 NPM can express adama+roslin upstreams (Advanced tab), else active/active is just two independent instances. +3. **Secrets** — Ansible Vault vs per-host env files (currently per-host `/opt/apps/env/*.env`). ## Adding a New VM diff --git a/README.md b/README.md index a6cef82..ee96a0a 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,17 @@ Ansible provisioning and deployment for homelab web servers. # Install collections (once) ansible-galaxy collection install -r requirements.yml +# Bootstrap SSH key to each host (one-time, before Ansible) +ssh-copy-id westfarn@10.0.0.77 + # Test connectivity to one host ansible adama -m ping # Provision one host (dry run first) -./scripts/provision.sh adama --check +./scripts/provision.sh adama --check --ask-become-pass +./scripts/provision.sh adama --ask-become-pass # first run; sudo password once + +# Later runs (after common role sets passwordless sudo) ./scripts/provision.sh adama # Provision all hosts @@ -23,8 +29,8 @@ See [IMPLEMENTATION.md](IMPLEMENTATION.md) for full architecture, CI/CD plan, an ## Servers -| Host | IP | -|------|-----| -| adama | 10.0.0.77 | -| roslin | 10.0.0.176 | -| desktop | see `inventory/host_vars/desktop.yml` | +| Host | IP | Role | +|------|-----|------| +| adama | 10.0.0.77 | app host | +| roslin | 10.0.0.176 | app host | +| ai-server-4080 | 10.0.0.128 | control node + act runner | diff --git a/ansible.cfg b/ansible.cfg index 0e64992..3346da2 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -3,7 +3,8 @@ inventory = inventory/hosts.yml roles_path = roles host_key_checking = False retry_files_enabled = False -stdout_callback = yaml +stdout_callback = ansible.builtin.default +callback_result_format = yaml interpreter_python = auto_silent [privilege_escalation] diff --git a/inventory/group_vars/all.yml b/inventory/group_vars/all.yml index fb739f2..548cad1 100644 --- a/inventory/group_vars/all.yml +++ b/inventory/group_vars/all.yml @@ -14,3 +14,57 @@ ufw_allowed_tcp_ports: # Docker docker_users: - "{{ admin_user }}" + +# Tianji monitoring +tianji_server_url: https://tianji.aimloperations.com +tianji_workspace_id: cm7w8087y020lddswyhamadj2 +tianji_install_script_url: "https://tianji.aimloperations.com/serverStatus/{{ tianji_workspace_id }}/install.sh?url={{ tianji_server_url }}" + +# --------------------------------------------------------------------------- +# App deployment (Phase 2) +# --------------------------------------------------------------------------- +git_base_url: "ssh://git@git.aimloperations.com:30009" + +# Where app checkouts and per-app env files live on each target host. +apps_base_dir: /opt/apps +apps_env_dir: "{{ apps_base_dir }}/env" # persistent .env files, never in git +apps_src_dir: "{{ apps_base_dir }}/src" # git checkouts + +# Control-node directory holding per-app .env secrets, pushed to hosts at +# deploy time. Layout: //_.env (never in git). +secrets_dir: "{{ lookup('ansible.builtin.env', 'HOME') }}/Documents/secrets" + +# Static site document root base (dta_webapp writes build output here). +web_static_root: /var/www + +# Catalog of deployable apps. host_apps (per host_vars) references these by name. +app_catalog: + company_site: + type: django + repo: "{{ git_base_url }}/ai_ml_operations/company_site.git" + default_branch: master + compose_file: docker-compose.prod.yml + web_service: web + migrate_cmd: "uv run python manage.py migrate --noinput" + dta_service: + type: django + repo: "{{ git_base_url }}/Ditch_The_Agent/dta_service.git" + default_branch: main + compose_file: docker-compose.prod.yml + web_service: web + migrate_cmd: "uv run python manage.py migrate --noinput" + dta_webapp: + type: node-static + repo: "{{ git_base_url }}/Ditch_The_Agent/dta_webapp.git" + default_branch: main + # package.json lives in this subdir of the checkout (npm runs here). + subdir: ditch-the-agent + # Document root served by nginx and written by `npm run build:`. + # {env} is replaced with the entry's env (beta/prod). + webroot_pattern: "/var/www/{env}.app.ditchtheagent/html" + # deploy runs `npm ci` then `npm run build:`; that script writes to + # {{ web_static_root }}/_dta_webapp (beta/prod), served by web-static. + +# Deploy filter vars. CI passes these; manual runs may leave them undefined +# to (re)deploy every app listed in the host's host_apps. +# app: app_env: beta|prod app_ref: diff --git a/inventory/host_vars/adama.yml b/inventory/host_vars/adama.yml new file mode 100644 index 0000000..143c3ee --- /dev/null +++ b/inventory/host_vars/adama.yml @@ -0,0 +1,13 @@ +--- +# Django services run active/active here and on roslin (shared external DB). +# dta_webapp static also runs active/active (built into /var/www, served by +# the web-static nginx container). Ports MUST match roslin so NPM can balance. +# Each entry is one workload: django -> compose project _ on port; +# node-static -> /var/www/_dta_webapp served on port. +host_apps: + - { name: company_site, env: prod, port: 8000 } + - { name: company_site, env: beta, port: 8010 } + - { name: dta_service, env: prod, port: 8001 } + - { name: dta_service, env: beta, port: 8011 } + - { name: dta_webapp, env: prod, port: 8080 } + - { name: dta_webapp, env: beta, port: 8081 } diff --git a/inventory/host_vars/ai-server-4080.yml b/inventory/host_vars/ai-server-4080.yml new file mode 100644 index 0000000..b1652d5 --- /dev/null +++ b/inventory/host_vars/ai-server-4080.yml @@ -0,0 +1,16 @@ +--- +# ai-server-4080 — control node + Gitea act runner. Update IP with: +# ip -4 addr show scope global +ansible_host: 10.0.0.128 + +ansible_control_node: true +act_runner_enabled: true + +# This host's pre-existing ~/.ssh/id_ed25519 is a personal key WITH a passphrase, +# which hangs the (non-BatchMode) gitea access probe. Use a dedicated, +# passphrase-less deploy key here instead. +gitea_key_path: "/home/{{ admin_user }}/.ssh/gitea_deploy" + +# company_site prod runs here for side testing. +host_apps: + - { name: company_site, env: prod, port: 8000 } diff --git a/inventory/host_vars/desktop.yml b/inventory/host_vars/desktop.yml deleted file mode 100644 index 023832b..0000000 --- a/inventory/host_vars/desktop.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -# Update with this machine's LAN IP: ip -4 addr show scope global -ansible_host: 10.0.0.1 # FIXME: set your desktop IP - -ansible_control_node: true -act_runner_enabled: true diff --git a/inventory/host_vars/roslin.yml b/inventory/host_vars/roslin.yml new file mode 100644 index 0000000..74ce546 --- /dev/null +++ b/inventory/host_vars/roslin.yml @@ -0,0 +1,10 @@ +--- +# Mirrors adama for active/active. Ports MUST match adama so NPM upstreams +# can balance adama:PORT and roslin:PORT for the same workload. +host_apps: + - { name: company_site, env: prod, port: 8000 } + - { name: company_site, env: beta, port: 8010 } + - { name: dta_service, env: prod, port: 8001 } + - { name: dta_service, env: beta, port: 8011 } + - { name: dta_webapp, env: prod, port: 8080 } + - { name: dta_webapp, env: beta, port: 8081 } diff --git a/inventory/hosts.yml b/inventory/hosts.yml index c1fbe73..bbd8055 100644 --- a/inventory/hosts.yml +++ b/inventory/hosts.yml @@ -7,4 +7,5 @@ all: ansible_host: 10.0.0.77 roslin: ansible_host: 10.0.0.176 - desktop: + ai-server-4080: + ansible_host: 10.0.0.128 diff --git a/playbooks/deploy-apps.yml b/playbooks/deploy-apps.yml index 212e981..9c81255 100644 --- a/playbooks/deploy-apps.yml +++ b/playbooks/deploy-apps.yml @@ -1,7 +1,16 @@ --- -# Phase 2: CI-triggered app deployment (stub) -- name: Deploy application to webservers +# Phase 2: CI-triggered app deployment. +# +# Runs against all webservers; each host deploys only the apps listed in its +# host_apps. Deploy exactly one app+env at a pinned ref via extra-vars: +# +# ansible-playbook playbooks/deploy-apps.yml \ +# -e app=company_site -e app_env=prod -e app_ref= +# +# Or redeploy everything on master by omitting app/app_env/app_ref. +- name: Deploy applications hosts: webservers become: true roles: - app-deploy + - web-static diff --git a/playbooks/site.yml b/playbooks/site.yml index 19684ba..d628409 100644 --- a/playbooks/site.yml +++ b/playbooks/site.yml @@ -6,3 +6,6 @@ - common - ufw - docker + - nodejs + - gitea-key + - tianji diff --git a/roles/app-deploy/tasks/django.yml b/roles/app-deploy/tasks/django.yml new file mode 100644 index 0000000..a14dc53 --- /dev/null +++ b/roles/app-deploy/tasks/django.yml @@ -0,0 +1,88 @@ +--- +# Deploy one Django app+env as its own docker compose project. +# Called per item with loop_var app_item = { name, env, port }. + +- name: "django[{{ app_item.name }}/{{ app_item.env }}] locals" + ansible.builtin.set_fact: + _spec: "{{ app_catalog[app_item.name] }}" + _src: "{{ apps_src_dir }}/{{ app_item.name }}_{{ app_item.env }}" + _envfile: "{{ apps_env_dir }}/{{ app_item.name }}_{{ app_item.env }}.env" + _secret_src: "{{ secrets_dir }}/{{ app_item.name }}/{{ app_item.name }}_{{ app_item.env }}.env" + _project: "{{ app_item.name }}_{{ app_item.env }}" + _ref: "{{ app_ref | default(app_catalog[app_item.name].default_branch) }}" + +- name: "django[{{ _project }}] ensure base dirs" + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ admin_user }}" + group: "{{ admin_user }}" + mode: "0750" + loop: + - "{{ apps_src_dir }}" + - "{{ apps_env_dir }}" + +- name: "django[{{ _project }}] check local secret exists" + ansible.builtin.stat: + path: "{{ _secret_src }}" + register: _secret_stat + delegate_to: localhost + become: false + +- name: "django[{{ _project }}] fail when local secret missing" + ansible.builtin.fail: + msg: >- + Missing local secret {{ _secret_src }} on the control node. + Create it (DATABASE_URL to the shared external Postgres, DJANGO_SECRET_KEY, + WEB_PORT={{ app_item.port }}, etc.) before deploying. It is never committed + to git. + when: not _secret_stat.stat.exists + +- name: "django[{{ _project }}] push secret to {{ _envfile }}" + ansible.builtin.copy: + src: "{{ _secret_src }}" + dest: "{{ _envfile }}" + owner: "{{ admin_user }}" + group: "{{ admin_user }}" + mode: "0600" + +- name: "django[{{ _project }}] checkout {{ _ref }}" + ansible.builtin.git: + repo: "{{ _spec.repo }}" + dest: "{{ _src }}" + version: "{{ _ref }}" + force: true + accept_hostkey: true + become: true + become_user: "{{ admin_user }}" + +- name: "django[{{ _project }}] install .env into checkout" + ansible.builtin.copy: + src: "{{ _envfile }}" + dest: "{{ _src }}/.env" + remote_src: true + owner: "{{ admin_user }}" + group: "{{ admin_user }}" + mode: "0600" + +- name: "django[{{ _project }}] build images" + ansible.builtin.command: + cmd: "docker compose -f {{ _spec.compose_file }} --env-file .env build" + chdir: "{{ _src }}" + environment: + COMPOSE_PROJECT_NAME: "{{ _project }}" + WEB_PORT: "{{ app_item.port }}" + become: true + become_user: "{{ admin_user }}" + changed_when: true + +- name: "django[{{ _project }}] start containers" + ansible.builtin.command: + cmd: "docker compose -f {{ _spec.compose_file }} --env-file .env up -d --remove-orphans" + chdir: "{{ _src }}" + environment: + COMPOSE_PROJECT_NAME: "{{ _project }}" + WEB_PORT: "{{ app_item.port }}" + become: true + become_user: "{{ admin_user }}" + changed_when: true diff --git a/roles/app-deploy/tasks/main.yml b/roles/app-deploy/tasks/main.yml index faf9dc4..e8a87b3 100644 --- a/roles/app-deploy/tasks/main.yml +++ b/roles/app-deploy/tasks/main.yml @@ -1,9 +1,56 @@ --- -# Stub — implement after company_site is dockerized. -# Interim: can rsync/systemd like company_site/scripts/deploy.sh +# Generic app deployment. +# +# CI passes: app= app_env= app_ref= +# Manual run with none of those redeploys every app in this host's host_apps +# at branch master. +# +# host_apps (host_vars) lists what runs on THIS host; app_catalog (group_vars) +# describes how each app is built. -- name: App deploy not yet implemented +- name: Classify catalog by type + ansible.builtin.set_fact: + django_names: "{{ app_catalog | dict2items | selectattr('value.type', 'equalto', 'django') | map(attribute='key') | list }}" + node_names: "{{ app_catalog | dict2items | selectattr('value.type', 'equalto', 'node-static') | map(attribute='key') | list }}" + +- name: Resolve deploy targets for {{ inventory_hostname }} + ansible.builtin.set_fact: + deploy_targets: >- + {{ (host_apps | default([]) | selectattr('name', 'equalto', app | default('')) | selectattr('env', 'equalto', app_env | default('')) | list) + if (app is defined and app_env is defined) + else (host_apps | default([])) }} + +- name: Show deploy targets ansible.builtin.debug: - msg: >- - app-deploy role is a stub. Set app_ref={{ app_ref | default('unset') }}. - Implement git pull / docker compose after dockerize ticket. + msg: "ref={{ app_ref | default('(per-app default branch)') }} targets={{ deploy_targets | map(attribute='name') | zip(deploy_targets | map(attribute='env')) | list }}" + +- name: Deploy Django (docker compose) apps + ansible.builtin.include_tasks: django.yml + loop: "{{ deploy_targets | selectattr('name', 'in', django_names) | list }}" + loop_control: + loop_var: app_item + label: "{{ app_item.name }}/{{ app_item.env }}" + +- name: Run Django migrations once (shared external DB) + ansible.builtin.command: + cmd: >- + docker compose -f {{ app_catalog[item.name].compose_file }} --env-file .env + exec -T {{ app_catalog[item.name].web_service }} + {{ app_catalog[item.name].migrate_cmd }} + chdir: "{{ apps_src_dir }}/{{ item.name }}_{{ item.env }}" + environment: + COMPOSE_PROJECT_NAME: "{{ item.name }}_{{ item.env }}" + loop: "{{ deploy_targets | selectattr('name', 'in', django_names) | list }}" + loop_control: + label: "{{ item.name }}/{{ item.env }}" + become: true + become_user: "{{ admin_user }}" + run_once: true + changed_when: true + +- name: Deploy node static apps + ansible.builtin.include_tasks: node_static.yml + loop: "{{ deploy_targets | selectattr('name', 'in', node_names) | list }}" + loop_control: + loop_var: app_item + label: "{{ app_item.name }}/{{ app_item.env }}" diff --git a/roles/app-deploy/tasks/node_static.yml b/roles/app-deploy/tasks/node_static.yml new file mode 100644 index 0000000..735957d --- /dev/null +++ b/roles/app-deploy/tasks/node_static.yml @@ -0,0 +1,53 @@ +--- +# Build one node/vite static site into {{ web_static_root }}/_. +# Called per item with loop_var app_item = { name, env, port }. +# The app's own `npm run build:` script is expected to output to +# {{ web_static_root }}/_dta_webapp. + +- name: "static[{{ app_item.name }}/{{ app_item.env }}] locals" + ansible.builtin.set_fact: + _spec: "{{ app_catalog[app_item.name] }}" + _src: "{{ apps_src_dir }}/{{ app_item.name }}_{{ app_item.env }}" + _webroot: "{{ (app_catalog[app_item.name].webroot_pattern | default(web_static_root ~ '/{env}_' ~ app_item.name)) | replace('{env}', app_item.env) }}" + _ref: "{{ app_ref | default(app_catalog[app_item.name].default_branch) }}" + +- name: "static[{{ app_item.name }}/{{ app_item.env }}] workdir" + ansible.builtin.set_fact: + _workdir: "{{ (_src ~ '/' ~ _spec.subdir) if (_spec.subdir | default('')) else _src }}" + +- name: "static[{{ app_item.name }}/{{ app_item.env }}] ensure dirs" + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ admin_user }}" + group: "{{ admin_user }}" + mode: "0755" + loop: + - "{{ apps_src_dir }}" + - "{{ _webroot }}" + +- name: "static[{{ app_item.name }}/{{ app_item.env }}] checkout {{ _ref }}" + ansible.builtin.git: + repo: "{{ _spec.repo }}" + dest: "{{ _src }}" + version: "{{ _ref }}" + force: true + accept_hostkey: true + become: true + become_user: "{{ admin_user }}" + +- name: "static[{{ app_item.name }}/{{ app_item.env }}] npm ci" + ansible.builtin.command: + cmd: npm ci + chdir: "{{ _workdir }}" + become: true + become_user: "{{ admin_user }}" + changed_when: true + +- name: "static[{{ app_item.name }}/{{ app_item.env }}] build ({{ app_item.env }})" + ansible.builtin.command: + cmd: "npm run build:{{ app_item.env }}" + chdir: "{{ _workdir }}" + become: true + become_user: "{{ admin_user }}" + changed_when: true diff --git a/roles/common/tasks/main.yml b/roles/common/tasks/main.yml index cd33e53..01eab35 100644 --- a/roles/common/tasks/main.yml +++ b/roles/common/tasks/main.yml @@ -1,4 +1,11 @@ --- +- name: Allow admin user passwordless sudo for Ansible + ansible.builtin.copy: + dest: "/etc/sudoers.d/{{ admin_user }}" + content: "{{ admin_user }} ALL=(ALL) NOPASSWD:ALL\n" + mode: "0440" + validate: visudo -cf %s + - name: Update apt cache ansible.builtin.apt: update_cache: true diff --git a/roles/docker/tasks/main.yml b/roles/docker/tasks/main.yml index 62997e4..bbb007f 100644 --- a/roles/docker/tasks/main.yml +++ b/roles/docker/tasks/main.yml @@ -1,23 +1,14 @@ --- -- name: Create keyrings directory - ansible.builtin.file: - path: /etc/apt/keyrings - state: directory - mode: "0755" - -- name: Add Docker GPG key - ansible.builtin.get_url: - url: https://download.docker.com/linux/ubuntu/gpg - dest: /etc/apt/keyrings/docker.asc - mode: "0644" - - name: Add Docker apt repository - ansible.builtin.apt_repository: - repo: "deb [arch={{ docker_apt_arch }} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable" + ansible.builtin.deb822_repository: + name: docker + types: deb + uris: "https://download.docker.com/linux/{{ ansible_distribution | lower }}" + suites: "{{ ansible_distribution_release }}" + components: stable + architectures: "{{ 'arm64' if ansible_architecture == 'aarch64' else 'amd64' }}" + signed_by: "https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg" state: present - filename: docker - vars: - docker_apt_arch: "{{ 'arm64' if ansible_architecture == 'aarch64' else 'amd64' }}" - name: Install Docker packages ansible.builtin.apt: diff --git a/roles/gitea-key/defaults/main.yml b/roles/gitea-key/defaults/main.yml new file mode 100644 index 0000000..337a5a9 --- /dev/null +++ b/roles/gitea-key/defaults/main.yml @@ -0,0 +1,6 @@ +--- +gitea_ssh_host: git.aimloperations.com +gitea_ssh_port: 30009 +gitea_key_path: "/home/{{ admin_user }}/.ssh/id_ed25519" +# Any repo the deploy user should be able to read; used only as an access probe. +gitea_test_repo: "ai_ml_operations/company_site.git" diff --git a/roles/gitea-key/tasks/main.yml b/roles/gitea-key/tasks/main.yml new file mode 100644 index 0000000..74e7d5b --- /dev/null +++ b/roles/gitea-key/tasks/main.yml @@ -0,0 +1,74 @@ +--- +# Per-server SSH key for cloning from Gitea. +# 1. Generate an ed25519 key for the deploy user (if absent). +# 2. Configure SSH for the Gitea host (port + identity). +# 3. Test access first; only surface the "add this key" step when it's missing. + +- name: gitea-key | ensure .ssh dir + ansible.builtin.file: + path: "/home/{{ admin_user }}/.ssh" + state: directory + owner: "{{ admin_user }}" + group: "{{ admin_user }}" + mode: "0700" + +- name: gitea-key | generate deploy key + become_user: "{{ admin_user }}" + ansible.builtin.command: + cmd: "ssh-keygen -t ed25519 -N '' -f {{ gitea_key_path }} -C '{{ admin_user }}@{{ inventory_hostname }}-gitea'" + creates: "{{ gitea_key_path }}" + +- name: gitea-key | configure SSH for Gitea host + become_user: "{{ admin_user }}" + ansible.builtin.blockinfile: + path: "/home/{{ admin_user }}/.ssh/config" + create: true + owner: "{{ admin_user }}" + group: "{{ admin_user }}" + mode: "0600" + marker: "# {mark} ANSIBLE MANAGED gitea" + block: | + Host {{ gitea_ssh_host }} + User git + Port {{ gitea_ssh_port }} + IdentityFile {{ gitea_key_path }} + IdentitiesOnly yes + StrictHostKeyChecking accept-new + +- name: gitea-key | read public key + ansible.builtin.slurp: + src: "{{ gitea_key_path }}.pub" + register: _gitea_pubkey + +- name: gitea-key | check Gitea access (permission probe) + become_user: "{{ admin_user }}" + ansible.builtin.command: + cmd: "git ls-remote {{ git_base_url }}/{{ gitea_test_repo }}" + register: _gitea_access + failed_when: false + changed_when: false + +- name: gitea-key | access OK + ansible.builtin.debug: + msg: "{{ inventory_hostname }} already has Gitea access; nothing to add." + when: _gitea_access.rc == 0 + +- name: gitea-key | ADD THIS KEY to Gitea (access missing) + ansible.builtin.debug: + msg: | + ================= ACTION REQUIRED on {{ inventory_hostname }} ================= + This server cannot reach Gitea yet. Add its public key: + + {{ _gitea_pubkey.content | b64decode | trim }} + + Where (either works): + * User key : Gitea > Settings > SSH / GPG Keys > Add Key + * Deploy key: repo > Settings > Deploy Keys (per repo, read-only) + Then re-run provisioning to continue. + ============================================================================== + when: _gitea_access.rc != 0 + +- name: gitea-key | fail until key is added + ansible.builtin.fail: + msg: "No Gitea access from {{ inventory_hostname }}. Add the key shown above, then re-run." + when: _gitea_access.rc != 0 diff --git a/roles/nodejs/defaults/main.yml b/roles/nodejs/defaults/main.yml new file mode 100644 index 0000000..1e2b6c9 --- /dev/null +++ b/roles/nodejs/defaults/main.yml @@ -0,0 +1,3 @@ +--- +# Node.js major version to install from NodeSource (provides npm + npx). +node_major: 22 diff --git a/roles/nodejs/tasks/main.yml b/roles/nodejs/tasks/main.yml new file mode 100644 index 0000000..3564c64 --- /dev/null +++ b/roles/nodejs/tasks/main.yml @@ -0,0 +1,52 @@ +--- +- name: Add NodeSource apt repository + ansible.builtin.deb822_repository: + name: nodesource + types: deb + uris: "https://deb.nodesource.com/node_{{ node_major }}.x" + suites: nodistro + components: main + signed_by: "https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key" + state: present + register: _nodesource_repo + +- name: Force NodeSource over the distro nodejs/npm + ansible.builtin.copy: + dest: /etc/apt/preferences.d/nodesource.pref + owner: root + group: root + mode: "0644" + content: | + Package: * + Pin: origin "deb.nodesource.com" + Pin-Priority: 1001 + register: _nodesource_pin + +- name: Refresh apt cache after NodeSource changes + ansible.builtin.apt: + update_cache: true + when: _nodesource_repo is changed or _nodesource_pin is changed + +- name: Remove distro nodejs/npm (NodeSource nodejs bundles its own npm + npx) + ansible.builtin.apt: + name: + - npm + - nodejs + state: absent + purge: true + +- name: Install Node.js from NodeSource (includes npm + npx) + ansible.builtin.apt: + name: nodejs + state: latest + allow_downgrade: true + update_cache: true + +- name: Verify node / npm / npx available + ansible.builtin.command: + cmd: "{{ item }} --version" + loop: + - node + - npm + - npx + changed_when: false diff --git a/roles/tianji/tasks/main.yml b/roles/tianji/tasks/main.yml new file mode 100644 index 0000000..23cd188 --- /dev/null +++ b/roles/tianji/tasks/main.yml @@ -0,0 +1,6 @@ +--- +- name: Install Tianji monitoring reporter + ansible.builtin.shell: | + curl -o- "{{ tianji_install_script_url }}" | bash + args: + creates: /usr/lib/systemd/system/tianji-reporter.service diff --git a/roles/web-static/defaults/main.yml b/roles/web-static/defaults/main.yml new file mode 100644 index 0000000..c393510 --- /dev/null +++ b/roles/web-static/defaults/main.yml @@ -0,0 +1,3 @@ +--- +web_static_dir: "{{ apps_base_dir }}/web-static" +web_static_image: nginx:alpine diff --git a/roles/web-static/tasks/main.yml b/roles/web-static/tasks/main.yml new file mode 100644 index 0000000..a30ee35 --- /dev/null +++ b/roles/web-static/tasks/main.yml @@ -0,0 +1,57 @@ +--- +# Serves node/vite static builds (dta_webapp beta/prod) via a single nginx +# container. Skips itself on hosts that have no node-static apps. + +- name: web-static | node-static app names + ansible.builtin.set_fact: + _node_names: "{{ app_catalog | dict2items | selectattr('value.type', 'equalto', 'node-static') | map(attribute='key') | list }}" + +- name: web-static | this host's static apps + ansible.builtin.set_fact: + _static_apps: "{{ host_apps | default([]) | selectattr('name', 'in', _node_names) | list }}" + +- name: web-static | configure and run + when: _static_apps | length > 0 + block: + - name: web-static | ensure project dir + ansible.builtin.file: + path: "{{ web_static_dir }}" + state: directory + owner: "{{ admin_user }}" + group: "{{ admin_user }}" + mode: "0750" + + - name: web-static | nginx config + ansible.builtin.template: + src: nginx.conf.j2 + dest: "{{ web_static_dir }}/nginx.conf" + owner: "{{ admin_user }}" + group: "{{ admin_user }}" + mode: "0644" + register: _nginx_conf + + - name: web-static | compose file + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ web_static_dir }}/docker-compose.yml" + owner: "{{ admin_user }}" + group: "{{ admin_user }}" + mode: "0644" + register: _compose_file + + - name: web-static | start container + ansible.builtin.command: + cmd: "docker compose up -d --remove-orphans{{ ' --force-recreate' if (_compose_file is changed) else '' }}" + chdir: "{{ web_static_dir }}" + become: true + become_user: "{{ admin_user }}" + changed_when: true + + - name: web-static | reload nginx on config change + ansible.builtin.command: + cmd: "docker compose exec -T web-static nginx -s reload" + chdir: "{{ web_static_dir }}" + become: true + become_user: "{{ admin_user }}" + when: _nginx_conf is changed and _compose_file is not changed + changed_when: true diff --git a/roles/web-static/templates/docker-compose.yml.j2 b/roles/web-static/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..65a605b --- /dev/null +++ b/roles/web-static/templates/docker-compose.yml.j2 @@ -0,0 +1,12 @@ +# Managed by Ansible (roles/web-static). Do not edit by hand. +services: + web-static: + image: {{ web_static_image }} + restart: unless-stopped + ports: +{% for a in _static_apps %} + - "{{ a.port }}:{{ a.port }}" +{% endfor %} + volumes: + - {{ web_static_root }}:{{ web_static_root }}:ro + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro diff --git a/roles/web-static/templates/nginx.conf.j2 b/roles/web-static/templates/nginx.conf.j2 new file mode 100644 index 0000000..c5ceff1 --- /dev/null +++ b/roles/web-static/templates/nginx.conf.j2 @@ -0,0 +1,19 @@ +# Managed by Ansible (roles/web-static). Do not edit by hand. +{% for a in _static_apps %} +server { + listen {{ a.port }}; + server_name _; + + root {{ (app_catalog[a.name].webroot_pattern | default(web_static_root ~ '/{env}_' ~ a.name)) | replace('{env}', a.env) }}; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(?:js|css|woff2?|png|jpg|jpeg|gif|svg|ico)$ { + expires 7d; + add_header Cache-Control "public"; + } +} +{% endfor %} diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 3f18f46..987018f 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -12,19 +12,28 @@ usage() { cat < SSH key already exists: $key" + return 0 + fi + + echo "==> Generating SSH key: $key" + ssh-keygen -t ed25519 -N "" -f "$key" -C "$(whoami)@$(hostname)" +} + +ensure_gitea_ssh_config() { + local ssh_dir="${HOME}/.ssh" + local config="${ssh_dir}/config" + + mkdir -p "$ssh_dir" + chmod 700 "$ssh_dir" + + if [[ -f "$config" ]] && grep -qE '^Host[[:space:]]+git\.aimloperations\.com$' "$config"; then + echo "==> SSH config already has git.aimloperations.com" + return 0 + fi + + if [[ -f "$config" && -s "$config" ]]; then + printf '\n' >>"$config" + fi + + cat >>"$config" <<'EOF' +Host git.aimloperations.com + User git + Port 30009 + AddressFamily inet +EOF + + chmod 600 "$config" + echo "==> Added git.aimloperations.com to ${config}" +} + +ensure_ssh_key +ensure_gitea_ssh_config + CMD=(ansible-playbook playbooks/site.yml "${EXTRA_ARGS[@]}") if [[ -n "$LIMIT" ]]; then CMD+=(--limit "$LIMIT")