Compare commits

..

2 Commits

Author SHA1 Message Date
boxswapper
364e59343f after migration 2026-11-13 13:01:22 -07:00
boxswapper
6d90e131b2 created new role 2026-11-13 12:26:24 -07:00
47 changed files with 425 additions and 136 deletions

View File

@@ -33,18 +33,35 @@ does.
## Layout ## Layout
``` ```
playbook.yml Ansible deployment (hosts: webservers) playbook.yml Example play using the role (hosts: webservers)
inventory.ini Inventory (localhost + commented remote example) inventory.ini Inventory (localhost + commented remote example)
trade-hub/ teardown.yml Removes this project's containers and data
roles/trade_hub/
README.md Role usage and variables
defaults/main.yml Configurable defaults
tasks/main.yml Stage source, build images, run containers
files/trade-hub/
podman-compose.yml Local three-service stack (db + backend + frontend) podman-compose.yml Local three-service stack (db + backend + frontend)
backend/ FastAPI app, tests, data fetch script, Dockerfile backend/ FastAPI app, tests, data fetch script, Dockerfile
frontend/ nginx config + static inventory UI frontend/ nginx config + static inventory UI
``` ```
The Ansible deployment is a self-contained role. To deploy it from an existing
playbook, copy `roles/trade_hub` into that repository's `roles/` directory and
add the role to the play:
```yaml
- role: trade_hub
tags: [trade_hub]
```
See `roles/trade_hub/README.md` for the variables. The play-level `become: true`
used by a typical server play makes podman run as root.
## Run locally with Podman Compose ## Run locally with Podman Compose
```bash ```bash
cd trade-hub cd roles/trade_hub/files/trade-hub
podman-compose up -d --build podman-compose up -d --build
# or: podman compose up -d --build # or: podman compose up -d --build
``` ```
@@ -60,7 +77,8 @@ POSTGRES_PASSWORD='choose-something-strong' podman-compose up -d --build
## Deploy with Ansible ## Deploy with Ansible
Deploys to every host in the `webservers` group (defaults to localhost): The example play deploys the role to every host in the `webservers` group
(defaults to localhost):
```bash ```bash
POSTGRES_PASSWORD='choose-something-strong' ansible-playbook playbook.yml POSTGRES_PASSWORD='choose-something-strong' ansible-playbook playbook.yml
@@ -73,16 +91,22 @@ To deploy to a remote server, edit `inventory.ini`:
server1 ansible_host=203.0.113.10 ansible_user=deploy server1 ansible_host=203.0.113.10 ansible_user=deploy
``` ```
Then run the same command from this directory. The playbook: Then run the same command from this directory. The role:
1. installs podman if missing, 1. installs podman if missing (only when `trade_hub_install_podman: true`),
2. removes the legacy `my-apache-server` container if present, 2. optionally removes the legacy `my-apache-server` container
3. builds `sc-inventory-backend` / `sc-inventory-frontend` images (skipped when (`trade_hub_remove_legacy_apache: true`),
they already exist; set `-e force_rebuild=true` to rebuild), 3. stages the application source in `/opt/trade-hub` and builds the
`sc-inventory-backend` / `sc-inventory-frontend` images (skipped when the
source is unchanged and the images already exist; set
`-e trade_hub_force_rebuild=true` to force it),
4. creates the `sc-inventory` podman network and the `sc-inventory-data` volume, 4. creates the `sc-inventory` podman network and the `sc-inventory-data` volume,
5. runs PostgreSQL, the backend, and the frontend containers (only the frontend 5. runs PostgreSQL, the backend, and the frontend containers (only the frontend
publishes a port: 8080), publishes a port: 8080),
6. verifies `http://localhost:8080/healthz` returns 200. 6. verifies `http://127.0.0.1:8080/healthz` returns 200.
The old standalone playbook's variables (`force_rebuild`, `cookie_secure`, …)
became `trade_hub_*` variables; see `roles/trade_hub/README.md`.
## Reset for testing (remove everything) ## Reset for testing (remove everything)
@@ -165,7 +189,8 @@ The stack publishes plain HTTP on port 8080. Session cookies therefore travel
in cleartext, and `COOKIE_SECURE` defaults to `false` so local development works. in cleartext, and `COOKIE_SECURE` defaults to `false` so local development works.
Before putting this on the open internet, terminate TLS in front of the Before putting this on the open internet, terminate TLS in front of the
frontend (Caddy, nginx with certificates, or a load balancer) and set frontend (Caddy, nginx with certificates, or a load balancer) and set
`COOKIE_SECURE=true` (compose env var) or `-e cookie_secure=true` (Ansible). `COOKIE_SECURE=true` (compose env var) or
`-e trade_hub_cookie_secure=true` (Ansible).
Also change `POSTGRES_PASSWORD` from its development default. Also change `POSTGRES_PASSWORD` from its development default.
## Verify ## Verify
@@ -195,7 +220,7 @@ curl -fsS -b cookies.txt http://localhost:8080/api/v1/me/inventory
## Backend development ## Backend development
```bash ```bash
cd trade-hub/backend cd roles/trade_hub/files/trade-hub/backend
python3 -m venv .venv python3 -m venv .venv
.venv/bin/pip install -r requirements.txt -r requirements-dev.txt .venv/bin/pip install -r requirements.txt -r requirements-dev.txt
.venv/bin/python -m pytest tests/ -q # 83 tests, no database needed .venv/bin/python -m pytest tests/ -q # 83 tests, no database needed

View File

@@ -1,126 +1,23 @@
--- ---
# Example play that exercises the trade_hub role against the local inventory.
# To use the role in an existing playbook, copy roles/trade_hub into that
# repository's roles/ directory and add:
#
# roles:
# - role: trade_hub
# tags: [trade_hub]
#
- name: Deploy Star Citizen Inventory (trade-hub) - name: Deploy Star Citizen Inventory (trade-hub)
hosts: webservers hosts: webservers
vars: vars:
backend_image: sc-inventory-backend # The standalone play used to install podman; in a larger playbook the
frontend_image: sc-inventory-frontend # "packages" role normally provides it instead.
postgres_image: docker.io/library/postgres:16-alpine trade_hub_install_podman: true
image_tag: latest trade_hub_postgres_password: "{{ lookup('env', 'POSTGRES_PASSWORD') | default('sc_inventory_dev', true) }}"
network_name: sc-inventory trade_hub_cookie_secure: "false"
frontend_port: "8080:80" trade_hub_force_rebuild: false
postgres_volume: sc-inventory-data
postgres_password: "{{ lookup('env', 'POSTGRES_PASSWORD') | default('sc_inventory_dev', true) }}"
database_url: "postgresql+psycopg://sc_inventory:{{ postgres_password }}@db:5432/sc_inventory"
# Set to true once the site is served over TLS.
cookie_secure: "false"
# Set to true to force image rebuilds even when the images already exist
force_rebuild: false
tasks: roles:
- name: Check whether podman is installed - role: trade_hub
ansible.builtin.command: podman --version tags: [trade_hub]
register: podman_check
failed_when: false
changed_when: false
- name: Install podman if missing
become: true
ansible.builtin.package:
name: podman
state: present
when: podman_check.rc != 0
- name: Remove legacy Apache container if present
containers.podman.podman_container:
name: my-apache-server
state: absent
- name: Check if backend image exists
ansible.builtin.command: podman image exists {{ backend_image }}:{{ image_tag }}
register: backend_image_check
failed_when: false
changed_when: false
- name: Check if frontend image exists
ansible.builtin.command: podman image exists {{ frontend_image }}:{{ image_tag }}
register: frontend_image_check
failed_when: false
changed_when: false
- name: Build backend image
ansible.builtin.command: >
podman build -t {{ backend_image }}:{{ image_tag }}
{{ playbook_dir }}/trade-hub/backend
when: backend_image_check.rc != 0 or force_rebuild | bool
- name: Build frontend image
ansible.builtin.command: >
podman build -t {{ frontend_image }}:{{ image_tag }}
{{ playbook_dir }}/trade-hub/frontend
when: frontend_image_check.rc != 0 or force_rebuild | bool
- name: Create podman network
containers.podman.podman_network:
name: "{{ network_name }}"
state: present
- name: Create PostgreSQL data volume
containers.podman.podman_volume:
name: "{{ postgres_volume }}"
state: present
- name: Run PostgreSQL container (internal only)
containers.podman.podman_container:
name: sc-inventory-db
image: "{{ postgres_image }}"
state: started
network: "{{ network_name }}"
network_aliases:
- db
env:
POSTGRES_USER: sc_inventory
POSTGRES_PASSWORD: "{{ postgres_password }}"
POSTGRES_DB: sc_inventory
volumes:
- "{{ postgres_volume }}:/var/lib/postgresql/data:Z"
restart_policy: unless-stopped
- name: Run backend container (internal only)
containers.podman.podman_container:
name: sc-inventory-backend
image: "{{ backend_image }}:{{ image_tag }}"
state: started
# podman does not recreate a container when its image tag resolves to
# a new ID, so without this a rebuilt image would be ignored.
recreate: true
network: "{{ network_name }}"
# Network alias "backend" matches the compose service name, so the
# nginx upstream (http://backend:8000) resolves in both deployments.
network_aliases:
- backend
env:
DATABASE_URL: "{{ database_url }}"
DB_WAIT_SECONDS: "60"
COOKIE_SECURE: "{{ cookie_secure }}"
restart_policy: unless-stopped
- name: Run frontend container (published port)
containers.podman.podman_container:
name: sc-inventory-frontend
image: "{{ frontend_image }}:{{ image_tag }}"
state: started
recreate: true
network: "{{ network_name }}"
ports:
- "{{ frontend_port }}"
restart_policy: unless-stopped
- name: Verify the stack responds
ansible.builtin.uri:
url: http://localhost:8080/healthz
status_code: 200
register: health_result
until: health_result.status == 200
retries: 8
delay: 10

85
roles/trade_hub/README.md Normal file
View File

@@ -0,0 +1,85 @@
# trade_hub
Deploys the Free People of the Verse trade hub: a FastAPI backend, an nginx
frontend and PostgreSQL, all run by podman on the target host. The role stages
its own copy of the application source from `files/trade-hub/` and builds the
backend and frontend images locally, so it does not need a registry.
## Usage
Add the role to an existing play. It is safe to run after a `packages` role that
already installs podman:
```yaml
- hosts: internal-apps
become: true
vars_files:
- ./vars.yml
roles:
- role: packages
tags: [packages]
# ...
- role: trade_hub
tags: [trade_hub]
```
The play-level `become: true` makes podman run as root. If you run the role
without become, podman runs rootless and the containers belong to the connecting
user.
The playbook's `Pull images` task is unrelated to this role: the role builds its
own backend/frontend images from the staged source. The PostgreSQL image is
pulled by the role if it is not already present.
## Required variables
Set a real database password in `vars.yml` (or the environment of the control
node):
```yaml
trade_hub_postgres_password: "choose-something-strong"
```
If it is omitted, the role falls back to the play variable `POSTGRES_PASSWORD`,
then to `POSTGRES_PASSWORD` from the control node environment, then to the
development default `sc_inventory_dev`. If the password contains characters
that are significant in a URL (`@`, `:`, `/`, …), set `trade_hub_database_url`
explicitly as well.
## Common variables
| Variable | Default | Purpose |
| --- | --- | --- |
| `trade_hub_app_dir` | `/opt/trade-hub` | Where the build context is staged |
| `trade_hub_install_podman` | `false` | Install podman if it is missing |
| `trade_hub_build_images` | `true` | Build backend/frontend images on the target |
| `trade_hub_force_rebuild` | `false` | Rebuild images and restart containers |
| `trade_hub_image_tag` | `latest` | Tag applied to built images |
| `trade_hub_backend_image` | `localhost/sc-inventory-backend` | Backend image name |
| `trade_hub_frontend_image` | `localhost/sc-inventory-frontend` | Frontend image name |
| `trade_hub_postgres_image` | `docker.io/library/postgres:16-alpine` | Database image |
| `trade_hub_postgres_user` | `sc_inventory` | Database user |
| `trade_hub_postgres_db` | `sc_inventory` | Database name |
| `trade_hub_postgres_password` | play var/env `POSTGRES_PASSWORD` or `sc_inventory_dev` | Database password |
| `trade_hub_network` | `sc-inventory` | Podman network name |
| `trade_hub_volume` | `sc-inventory-data` | PostgreSQL data volume |
| `trade_hub_frontend_bind` | `0.0.0.0` | Address the frontend publishes on |
| `trade_hub_frontend_port` | `8080` | Host port for the frontend |
| `trade_hub_cookie_secure` | `false` | Mark session cookies `Secure` (enable behind TLS) |
| `trade_hub_healthcheck_url` | `http://127.0.0.1:<port>/healthz` | Post-deploy check |
| `trade_hub_remove_legacy_apache` | `false` | Remove a container named `my-apache-server` |
| `trade_hub_enable_restart_service` | `false` | Enable `podman-restart.service` |
The container names also default to `sc-inventory-db`, `sc-inventory-backend`
and `sc-inventory-frontend`, matching the previous standalone playbook so an
existing installation is upgraded in place.
## Example deployment
```bash
ansible-playbook site.yml --tags trade_hub -e trade_hub_postgres_password='...'
```
The role verifies `http://127.0.0.1:8080/healthz` after starting the containers.
Only the frontend publishes a port; the database and backend stay on the
internal podman network.

View File

@@ -0,0 +1,51 @@
---
# Where the application build context is staged on the target host.
trade_hub_app_dir: /opt/trade-hub
# Install podman as part of this role. Leave false when another role (for
# example "packages") already provides podman.
trade_hub_install_podman: false
# Images. These are built locally by default; point them at a registry if you
# prefer to pull prebuilt images.
trade_hub_image_tag: latest
trade_hub_backend_image: localhost/sc-inventory-backend
trade_hub_frontend_image: localhost/sc-inventory-frontend
trade_hub_postgres_image: docker.io/library/postgres:16-alpine
trade_hub_build_images: true
trade_hub_force_rebuild: false
# Container names. Kept compatible with the previous standalone playbook so an
# existing installation is upgraded in place.
trade_hub_db_container: sc-inventory-db
trade_hub_backend_container: sc-inventory-backend
trade_hub_frontend_container: sc-inventory-frontend
# Database.
trade_hub_postgres_user: sc_inventory
trade_hub_postgres_db: sc_inventory
trade_hub_postgres_password: "{{ POSTGRES_PASSWORD | default(lookup('env', 'POSTGRES_PASSWORD'), true) | default('sc_inventory_dev', true) }}"
trade_hub_database_url: "postgresql+psycopg://{{ trade_hub_postgres_user }}:{{ trade_hub_postgres_password }}@db:5432/{{ trade_hub_postgres_db }}"
trade_hub_db_wait_seconds: 60
# Networking.
trade_hub_network: sc-inventory
trade_hub_volume: sc-inventory-data
trade_hub_frontend_bind: 0.0.0.0
trade_hub_frontend_port: 8080
# Set to true (or "true") once the site is served over TLS.
trade_hub_cookie_secure: "false"
# Post-deploy health check.
trade_hub_healthcheck_url: "http://127.0.0.1:{{ trade_hub_frontend_port }}/healthz"
trade_hub_healthcheck_retries: 12
trade_hub_healthcheck_delay: 5
# The original standalone playbook removed an ad-hoc Apache container that
# used the same host. Disabled by default so the role never touches unrelated
# containers.
trade_hub_remove_legacy_apache: false
trade_hub_legacy_apache_container: my-apache-server
# Enable podman-restart.service so the containers come back after a reboot.
trade_hub_enable_restart_service: false

View File

@@ -0,0 +1,16 @@
---
galaxy_info:
role_name: trade_hub
author: boxswapper
description: Deploy the Free People of the Verse trade hub (FastAPI, nginx, PostgreSQL) with Podman.
license: MIT
min_ansible_version: "2.15"
platforms:
- name: GenericLinux
versions:
- all
galaxy_tags:
- podman
- fastapi
- postgresql
dependencies: []

View File

@@ -0,0 +1,215 @@
---
- name: Check whether podman is installed
ansible.builtin.command: podman --version
register: trade_hub_podman_check
changed_when: false
failed_when: false
check_mode: false
- name: Install podman when requested
ansible.builtin.package:
name: podman
state: present
become: true
when:
- trade_hub_podman_check.rc != 0
- trade_hub_install_podman | bool
- name: Re-check podman after installation
ansible.builtin.command: podman --version
register: trade_hub_podman_recheck
changed_when: false
failed_when: false
check_mode: false
when: trade_hub_podman_check.rc != 0
- name: Require podman to be available
ansible.builtin.assert:
that:
- trade_hub_podman_check.rc == 0 or (trade_hub_podman_recheck.rc | default(1)) == 0
fail_msg: >-
podman is not installed on {{ inventory_hostname }}. Install it first
(for example with the "packages" role) or run this role with
-e trade_hub_install_podman=true.
- name: Remove the legacy Apache container when requested
containers.podman.podman_container:
name: "{{ trade_hub_legacy_apache_container }}"
state: absent
when: trade_hub_remove_legacy_apache | bool
- name: Create the application directory
ansible.builtin.file:
path: "{{ trade_hub_app_dir }}"
state: directory
mode: "0755"
- name: Stage the application build context
ansible.builtin.copy:
src: "{{ role_path }}/files/trade-hub/{{ item.src }}"
dest: "{{ trade_hub_app_dir }}/{{ item.dest }}"
mode: preserve
loop:
# Only the files needed by the two Dockerfiles are staged. Copying the
# whole tree would also ship local development artefacts such as .venv.
- { src: "backend/Dockerfile", dest: "backend/Dockerfile" }
- { src: "backend/requirements.txt", dest: "backend/requirements.txt" }
- { src: "backend/main.py", dest: "backend/main.py" }
- { src: "backend/app/", dest: "backend/app/" }
- { src: "backend/scripts/", dest: "backend/scripts/" }
- { src: "frontend/Dockerfile", dest: "frontend/Dockerfile" }
- { src: "frontend/nginx.conf", dest: "frontend/nginx.conf" }
- { src: "frontend/src/", dest: "frontend/src/" }
register: trade_hub_source
- name: Check whether the backend image exists
ansible.builtin.command:
argv:
- podman
- image
- exists
- "{{ trade_hub_backend_image }}:{{ trade_hub_image_tag }}"
register: trade_hub_backend_image_check
changed_when: false
failed_when: false
check_mode: false
- name: Check whether the frontend image exists
ansible.builtin.command:
argv:
- podman
- image
- exists
- "{{ trade_hub_frontend_image }}:{{ trade_hub_image_tag }}"
register: trade_hub_frontend_image_check
changed_when: false
failed_when: false
check_mode: false
- name: Build the backend image
ansible.builtin.command:
argv:
- podman
- build
- --tag
- "{{ trade_hub_backend_image }}:{{ trade_hub_image_tag }}"
- "{{ trade_hub_app_dir }}/backend"
when:
- trade_hub_build_images | bool
- trade_hub_force_rebuild | bool or trade_hub_source.changed or trade_hub_backend_image_check.rc != 0
register: trade_hub_backend_build
- name: Build the frontend image
ansible.builtin.command:
argv:
- podman
- build
- --tag
- "{{ trade_hub_frontend_image }}:{{ trade_hub_image_tag }}"
- "{{ trade_hub_app_dir }}/frontend"
when:
- trade_hub_build_images | bool
- trade_hub_force_rebuild | bool or trade_hub_source.changed or trade_hub_frontend_image_check.rc != 0
register: trade_hub_frontend_build
- name: Require prebuilt images when image builds are disabled
ansible.builtin.assert:
that:
- trade_hub_backend_image_check.rc == 0
- trade_hub_frontend_image_check.rc == 0
fail_msg: >-
trade_hub_build_images is false but {{ trade_hub_backend_image }}:{{
trade_hub_image_tag }} and/or {{ trade_hub_frontend_image }}:{{
trade_hub_image_tag }} are missing on {{ inventory_hostname }}.
when: not trade_hub_build_images | bool
- name: Record whether the application containers need to be recreated
ansible.builtin.set_fact:
trade_hub_recreate_containers: >-
{{ (trade_hub_force_rebuild | bool)
or ((trade_hub_build_images | bool) and (trade_hub_source.changed | default(false)))
or (trade_hub_backend_build.changed | default(false))
or (trade_hub_frontend_build.changed | default(false)) }}
- name: Ensure the PostgreSQL image is present
containers.podman.podman_image:
name: "{{ trade_hub_postgres_image }}"
state: present
when: not ansible_check_mode
- name: Create the podman network
containers.podman.podman_network:
name: "{{ trade_hub_network }}"
state: present
- name: Create the PostgreSQL data volume
containers.podman.podman_volume:
name: "{{ trade_hub_volume }}"
state: present
- name: Run the PostgreSQL container
containers.podman.podman_container:
name: "{{ trade_hub_db_container }}"
image: "{{ trade_hub_postgres_image }}"
state: started
recreate: "{{ trade_hub_force_rebuild | bool }}"
network: "{{ trade_hub_network }}"
network_aliases:
- db
env:
POSTGRES_USER: "{{ trade_hub_postgres_user }}"
POSTGRES_PASSWORD: "{{ trade_hub_postgres_password }}"
POSTGRES_DB: "{{ trade_hub_postgres_db }}"
volumes:
- "{{ trade_hub_volume }}:/var/lib/postgresql/data:Z"
restart_policy: unless-stopped
when: not ansible_check_mode
- name: Run the backend container
containers.podman.podman_container:
name: "{{ trade_hub_backend_container }}"
image: "{{ trade_hub_backend_image }}:{{ trade_hub_image_tag }}"
state: started
# podman does not recreate a container when its image tag resolves to a new
# ID, so recreate whenever the image was rebuilt or the app changed.
recreate: "{{ trade_hub_recreate_containers | bool }}"
network: "{{ trade_hub_network }}"
# The nginx upstream is http://backend:8000, so this alias must match.
network_aliases:
- backend
env:
DATABASE_URL: "{{ trade_hub_database_url }}"
DB_WAIT_SECONDS: "{{ trade_hub_db_wait_seconds }}"
COOKIE_SECURE: "{{ trade_hub_cookie_secure | bool | lower }}"
restart_policy: unless-stopped
when: not ansible_check_mode
- name: Run the frontend container
containers.podman.podman_container:
name: "{{ trade_hub_frontend_container }}"
image: "{{ trade_hub_frontend_image }}:{{ trade_hub_image_tag }}"
state: started
recreate: "{{ trade_hub_recreate_containers | bool }}"
network: "{{ trade_hub_network }}"
ports:
- "{{ trade_hub_frontend_bind }}:{{ trade_hub_frontend_port }}:80"
restart_policy: unless-stopped
when: not ansible_check_mode
- name: Enable podman-restart so containers survive a reboot
ansible.builtin.systemd:
name: podman-restart.service
enabled: true
state: started
when: trade_hub_enable_restart_service | bool
- name: Wait for the application to answer health checks
ansible.builtin.uri:
url: "{{ trade_hub_healthcheck_url }}"
status_code: 200
register: trade_hub_health
until: trade_hub_health.status == 200
retries: "{{ trade_hub_healthcheck_retries }}"
delay: "{{ trade_hub_healthcheck_delay }}"
changed_when: false
when: not ansible_check_mode