This commit is contained in:
2026-07-08 05:59:11 -05:00
parent f848420d8f
commit 589462e6d0
27 changed files with 771 additions and 102 deletions
+125 -56
View File
@@ -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/<env>_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/<host>.yml`) — which app+env+port runs on that host.
- Django app = one compose project per env: project name `<app>_<env>`, 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 }}/<app>/<app>_<env>.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:<env>`
(writes to `/var/www/<env>_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 <name> --env <env> --ref <sha>`
(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:<pw>@10.0.0.230:5432/company_site` |
| company_site | beta | `company_site_beta` | `postgres://westfarn:<pw>@10.0.0.230:5432/company_site_beta` |
| dta_service | prod | `dta_service` | `postgres://westfarn:<pw>@10.0.0.230:5432/dta_service` |
| dta_service | beta | `dta_service_beta` | `postgres://westfarn:<pw>@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 }}/<app>/<app>_<env>.env`
(default `~/Documents/secrets/<app>/<app>_<env>.env`) with `DATABASE_URL`
(see table), `DJANGO_ENV`, `DJANGO_SECRET_KEY`, `WEB_PORT` (matching the port
table). Deploy pushes these to `/opt/apps/env/<app>_<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
+12 -6
View File
@@ -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 |
+2 -1
View File
@@ -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]
+54
View File
@@ -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: <secrets_dir>/<app>/<app>_<env>.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>`.
# {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:<env>`; that script writes to
# {{ web_static_root }}/<env>_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: <name> app_env: beta|prod app_ref: <git sha or branch, default master>
+13
View File
@@ -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 <name>_<env> on port;
# node-static -> /var/www/<env>_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 }
+16
View File
@@ -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 }
-6
View File
@@ -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
+10
View File
@@ -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 }
+2 -1
View File
@@ -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
+11 -2
View File
@@ -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=<sha>
#
# Or redeploy everything on master by omitting app/app_env/app_ref.
- name: Deploy applications
hosts: webservers
become: true
roles:
- app-deploy
- web-static
+3
View File
@@ -6,3 +6,6 @@
- common
- ufw
- docker
- nodejs
- gitea-key
- tianji
+88
View File
@@ -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
+53 -6
View File
@@ -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=<name> app_env=<beta|prod> app_ref=<git sha>
# 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 }}"
+53
View File
@@ -0,0 +1,53 @@
---
# Build one node/vite static site into {{ web_static_root }}/<env>_<app>.
# Called per item with loop_var app_item = { name, env, port }.
# The app's own `npm run build:<env>` script is expected to output to
# {{ web_static_root }}/<env>_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
+7
View File
@@ -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
+8 -17
View File
@@ -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:
+6
View File
@@ -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"
+74
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
---
# Node.js major version to install from NodeSource (provides npm + npx).
node_major: 22
+52
View File
@@ -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
+6
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
---
web_static_dir: "{{ apps_base_dir }}/web-static"
web_static_image: nginx:alpine
+57
View File
@@ -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
@@ -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
+19
View File
@@ -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 %}
+26 -5
View File
@@ -12,19 +12,28 @@ usage() {
cat <<EOF
Usage: $(basename "$0") [HOST] [OPTIONS]
Deploy application with deploy-apps.yml (Phase 2 stub).
Deploy applications with deploy-apps.yml.
HOST Optional. Limit to one host: adama, roslin, or desktop.
HOST Optional. Limit to one host: adama, roslin, or ai-server-4080.
Options:
--app NAME App to deploy (company_site, dta_service, dta_webapp)
--env ENV Environment: beta or prod
--ref REF Git ref/sha to deploy (default: master)
--check Dry run
--diff Show diffs
--extra-vars V Pass extra vars (e.g. app_ref=abc123)
--extra-vars V Pass raw extra vars
-h, --help Show this help
Examples:
# CI: deploy one app+env at a pinned sha
$(basename "$0") --app company_site --env prod --ref abc123
# Redeploy everything on master (all hosts)
$(basename "$0")
# Dry run for one host
$(basename "$0") adama --check
$(basename "$0") --extra-vars app_ref=master
EOF
}
@@ -34,6 +43,18 @@ while [[ $# -gt 0 ]]; do
usage
exit 0
;;
--app)
EXTRA_VARS+=(-e "app=$2")
shift 2
;;
--env)
EXTRA_VARS+=(-e "app_env=$2")
shift 2
;;
--ref)
EXTRA_VARS+=(-e "app_ref=$2")
shift 2
;;
--check)
EXTRA_ARGS+=(--check)
shift
@@ -46,7 +67,7 @@ while [[ $# -gt 0 ]]; do
EXTRA_VARS+=(-e "$2")
shift 2
;;
adama|roslin|desktop)
adama|roslin|ai-server-4080)
LIMIT="$1"
shift
;;
+56 -2
View File
@@ -13,17 +13,21 @@ Usage: $(basename "$0") [HOST] [OPTIONS]
Provision server(s) with site.yml.
HOST Optional. Limit to one host: adama, roslin, or desktop.
HOST Optional. Limit to one host: adama, roslin, or ai-server-4080.
Omit to run against all webservers.
Options:
--check Dry run (ansible --check)
--diff Show diffs
--ask-pass Prompt for SSH password (bootstrap before ssh-copy-id)
--ask-become-pass Prompt for sudo password
-h, --help Show this help
Examples:
$(basename "$0") adama --check # dry run on adama only
$(basename "$0") adama # provision adama
$(basename "$0") adama --ask-pass --ask-become-pass # first run, password auth
$(basename "$0") ai-server-4080 # provision the control node
$(basename "$0") # provision all hosts
EOF
}
@@ -42,7 +46,15 @@ while [[ $# -gt 0 ]]; do
EXTRA_ARGS+=(--diff)
shift
;;
adama|roslin|desktop)
--ask-pass)
EXTRA_ARGS+=(--ask-pass)
shift
;;
--ask-become-pass)
EXTRA_ARGS+=(--ask-become-pass)
shift
;;
adama|roslin|ai-server-4080)
LIMIT="$1"
shift
;;
@@ -54,6 +66,48 @@ while [[ $# -gt 0 ]]; do
esac
done
ensure_ssh_key() {
local key="${HOME}/.ssh/id_ed25519"
if [[ -f "$key" ]]; then
echo "==> 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")