asset-engine: scaffold deploy stack on ana-docker
Mirrors task-board's build-on-host pattern: elway playbook clones vh/asset-engine into /opt/docker/build/, docker build, install compose + seed .env, up -d, verify /health. No registry. Internal-only tool — LAN port 8200 (bind 0.0.0.0) is primary; Traefik labels additionally route asset-engine.phasefinal.com with TLS via the anaprod cert resolver. DB and outputs are separate bind-mounts under /opt/docker/conf/asset-engine/ so outputs/ can move volumes later without touching DB state. INFERENCE_HOST defaults to 10.100.79.3 (irv-ml1 over WG). OIDC env seam is pre-allocated empty for v2.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
# Deploy asset-engine (https://gitea.phasefinal.com/vh/asset-engine) to a
|
||||
# Docker host following the PFI /opt/docker/ convention (ana-docker by
|
||||
# default, but the playbook works against any host with Docker +
|
||||
# traefik-net in place).
|
||||
#
|
||||
# Idempotent: rerunning is safe. Creates-gates + conditional when:
|
||||
# checks skip work that's already done; `docker compose up -d` is itself
|
||||
# idempotent (no restart unless compose content or env changed).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/elway ana-docker --playbook playbooks/deploy-asset-engine.yaml
|
||||
# scripts/elway ana-docker --playbook playbooks/deploy-asset-engine.yaml --var ref=v0.1.0
|
||||
#
|
||||
# Prereqs on the target host:
|
||||
# - Docker + docker compose plugin
|
||||
# - `traefik-net` docker network (external)
|
||||
# - Target user (lkraven) has git SSH access to gitea.phasefinal.com
|
||||
# — either SSH key authorized in gitea, or the repo is HTTPS-reachable
|
||||
# if you swap `repo_url` below.
|
||||
# - Target user is in the `docker` group.
|
||||
|
||||
vars:
|
||||
repo_url: git@gitea.phasefinal.com:vh/asset-engine.git
|
||||
ref: main
|
||||
build_dir: /opt/docker/build/asset-engine
|
||||
image_tag: asset-engine:local
|
||||
compose_dir: /opt/docker/compose/asset-engine
|
||||
db_dir: /opt/docker/conf/asset-engine/db
|
||||
outputs_dir: /opt/docker/conf/asset-engine/outputs
|
||||
host_port: "8200"
|
||||
|
||||
steps:
|
||||
# ── host-side directory prep ─────────────────────────────────────────
|
||||
- name: Ensure /opt/docker/build parent exists
|
||||
shell: mkdir -p /opt/docker/build
|
||||
sudo: true
|
||||
creates: /opt/docker/build
|
||||
|
||||
- name: Chown /opt/docker/build to lkraven (only if mkdir'd by root above)
|
||||
shell: chown lkraven:lkraven /opt/docker/build
|
||||
sudo: true
|
||||
when: '[ "$(stat -c %U /opt/docker/build)" != lkraven ]'
|
||||
|
||||
# ── fetch / sync source ─────────────────────────────────────────────
|
||||
- name: Clone asset-engine repo if absent
|
||||
# Auto-accept the first-run host key so the playbook doesn't hang
|
||||
# prompting for yes/no.
|
||||
shell: GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new" git clone {{ repo_url }} {{ build_dir }}
|
||||
creates: "{{ build_dir }}/.git"
|
||||
|
||||
- name: Fetch from origin
|
||||
shell: cd {{ build_dir }} && git fetch --quiet origin
|
||||
|
||||
- name: Reset working tree to {{ ref }}
|
||||
# Accept either a branch name (resolves via origin/<ref>) or a
|
||||
# full/short SHA (resolves directly). CI passes the triggering
|
||||
# commit SHA via --var ref=${{ github.sha }}; manual runs pass
|
||||
# branch names like main / v0.1.0.
|
||||
shell: |
|
||||
cd {{ build_dir }}
|
||||
if sha=$(git rev-parse --verify --quiet "origin/{{ ref }}^{commit}"); then :;
|
||||
elif sha=$(git rev-parse --verify --quiet "{{ ref }}^{commit}"); then :;
|
||||
else echo "elway: ref not found: {{ ref }}" >&2; exit 1; fi
|
||||
git reset --hard "$sha"
|
||||
# Report ok (no-change) when the tree was already at the requested
|
||||
# ref — saves a noisy CHANGED status line on no-op reruns.
|
||||
changed_when: '[ "$(cd {{ build_dir }} && git rev-parse HEAD)" != "$(cd {{ build_dir }} && (git rev-parse --verify --quiet "origin/{{ ref }}^{commit}" || git rev-parse --verify --quiet "{{ ref }}^{commit}"))" ]'
|
||||
|
||||
# ── image build ─────────────────────────────────────────────────────
|
||||
- name: Build image {{ image_tag }}
|
||||
shell: cd {{ build_dir }} && docker build -t {{ image_tag }} .
|
||||
# Docker build reuses layer cache and is fast on reruns, but it
|
||||
# always runs — we can't cheaply know up-front whether anything
|
||||
# downstream has changed. Leave it in the always-run lane; Docker
|
||||
# itself handles the no-op efficiently.
|
||||
|
||||
# ── compose + state dirs ────────────────────────────────────────────
|
||||
- name: Ensure compose dir exists
|
||||
shell: mkdir -p {{ compose_dir }}
|
||||
creates: "{{ compose_dir }}"
|
||||
|
||||
- name: Ensure DB dir exists
|
||||
# Created as lkraven (uid 1000 on these hosts), matching the
|
||||
# container's app user — no chown dance needed.
|
||||
shell: mkdir -p {{ db_dir }}
|
||||
creates: "{{ db_dir }}"
|
||||
|
||||
- name: Ensure outputs dir exists
|
||||
# Separate from db_dir so outputs/ can later move to a bigger
|
||||
# volume without touching DB state.
|
||||
shell: mkdir -p {{ outputs_dir }}
|
||||
creates: "{{ outputs_dir }}"
|
||||
|
||||
# ── deploy compose files ────────────────────────────────────────────
|
||||
- name: Upload compose.yaml
|
||||
upload:
|
||||
src: stacks/asset-engine/compose.yaml
|
||||
dest: "{{ compose_dir }}/compose.yaml"
|
||||
mode: "0644"
|
||||
|
||||
- name: Seed .env from template (only if absent)
|
||||
upload:
|
||||
src: stacks/asset-engine/.env.example
|
||||
dest: "{{ compose_dir }}/.env"
|
||||
mode: "0644"
|
||||
when: "[ ! -f {{ compose_dir }}/.env ]"
|
||||
|
||||
# ── bring up + wait for ready ───────────────────────────────────────
|
||||
- name: docker compose up -d
|
||||
shell: cd {{ compose_dir }} && docker compose up -d
|
||||
|
||||
- name: Wait for /health to respond
|
||||
# Short retry loop — docker compose up returns before healthcheck
|
||||
# stabilizes; we want verify: to run against a live server.
|
||||
shell: |
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf -o /dev/null http://localhost:{{ host_port }}/health && exit 0
|
||||
sleep 1
|
||||
done
|
||||
exit 1
|
||||
changed_when: "false"
|
||||
|
||||
verify:
|
||||
- name: /health returns 200
|
||||
shell: curl -sf -o /dev/null http://localhost:{{ host_port }}/health
|
||||
changed_when: "false"
|
||||
|
||||
- name: /health body reports status=ok
|
||||
shell: curl -sf http://localhost:{{ host_port }}/health | grep -q '"status"[[:space:]]*:[[:space:]]*"ok"'
|
||||
changed_when: "false"
|
||||
|
||||
- name: Container is in the traefik-net network
|
||||
# `traefik-net` has a dash, so it's not accessible via Go template dot
|
||||
# syntax — JSON-encode the networks map and grep for the key instead.
|
||||
shell: docker inspect asset-engine --format '{{json .NetworkSettings.Networks}}' | grep -q traefik-net
|
||||
changed_when: "false"
|
||||
@@ -0,0 +1,37 @@
|
||||
# asset-engine stack tunables. Copy to `.env` on ana-docker before deploying.
|
||||
#
|
||||
# The deploy playbook seeds `.env` from this template on first run only —
|
||||
# it won't clobber an existing `.env`.
|
||||
|
||||
# Image tag. Built locally from the asset-engine git repo by the playbook.
|
||||
ASSET_ENGINE_IMAGE=asset-engine:local
|
||||
|
||||
# Host port exposing the FastAPI app (container listens on 8000 internally).
|
||||
# Internal-only tool — LAN access on this port is the primary entry; the
|
||||
# Traefik labels in compose.yaml additionally route the public hostname.
|
||||
ASSET_ENGINE_PORT=8200
|
||||
|
||||
# Bind address for the host port. 0.0.0.0 = LAN-reachable (default for an
|
||||
# internal-only tool). Flip to 127.0.0.1 only if you want to force all
|
||||
# traffic through traefik.
|
||||
ASSET_ENGINE_BIND=0.0.0.0
|
||||
|
||||
# Host paths for state. Container runs as uid 1000 — paths must be writable
|
||||
# by that uid (mkdir'd by the playbook without sudo, so lkraven-owned when
|
||||
# lkraven is uid 1000 on the host).
|
||||
#
|
||||
# DB lives separately from outputs so we can grow outputs/ onto a different
|
||||
# volume later without restoring DB state on top of it.
|
||||
ASSET_ENGINE_DB_DIR=/opt/docker/conf/asset-engine/db
|
||||
ASSET_ENGINE_OUTPUTS_DIR=/opt/docker/conf/asset-engine/outputs
|
||||
|
||||
# Inference target. Default is irv-ml1 over WG. Override if the fleet's
|
||||
# inference host moves.
|
||||
INFERENCE_HOST=10.100.79.3
|
||||
|
||||
# OIDC seam — empty in v1 (auth is no-op). Populate when v2 forward-auth
|
||||
# lands. Pre-allocated here so the surface is visible in the config file
|
||||
# before code reads it.
|
||||
OIDC_ISSUER=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
@@ -0,0 +1,85 @@
|
||||
# asset-engine
|
||||
|
||||
Control plane over the PFI inference fleet — FastAPI + HTMX/Shoelace
|
||||
UI that exposes the catalog at [`docs/asset-engine/services.yaml`](../../docs/asset-engine/services.yaml)
|
||||
as a web app, routes generation requests to inference hosts (irv-ml1
|
||||
over WG by default), and persists generated assets to a local SQLite
|
||||
DB + content-addressed blob store.
|
||||
|
||||
**Server:** ana-docker
|
||||
**Hostname (TLS):** `asset-engine.phasefinal.com` (TLS via Traefik / `anaprod` cert resolver)
|
||||
**LAN port:** `10.250.50.70:8200` (configurable via `.env`)
|
||||
**Upstream repo:** [vh/asset-engine](https://gitea.phasefinal.com/vh/asset-engine)
|
||||
**Image:** `asset-engine:local` — built on the host from the git repo by
|
||||
the deploy playbook. Not pulled from a registry.
|
||||
|
||||
## Deploy
|
||||
|
||||
Two paths — automated (preferred) and manual (escape hatch / first-time).
|
||||
|
||||
### Automated (Gitea Actions, push-to-main)
|
||||
|
||||
The asset-engine repo ships `.gitea/workflows/{ci,deploy}.yaml`. CI runs
|
||||
on PRs (uv sync, pytest, catalog drift check against this repo's
|
||||
`docs/asset-engine/services.yaml`); the deploy workflow runs on push to
|
||||
main and just calls the elway playbook below pinned to the triggering
|
||||
commit SHA. Drift check is a BLOCKING gate — a PR that vendors a
|
||||
services.yaml mismatched against this repo fails CI and can't merge.
|
||||
|
||||
A reference copy of the deploy workflow lives next to this README at
|
||||
[`gitea-workflow-deploy.yaml.example`](gitea-workflow-deploy.yaml.example);
|
||||
the canonical source is in the asset-engine repo. The example header
|
||||
lists the two repo secrets required (`DEPLOY_SSH_KEY`, `MGMT_REPO_TOKEN`).
|
||||
|
||||
### Manual (elway from a workstation)
|
||||
|
||||
The playbook owns the full flow: clone/update the source repo,
|
||||
`docker build`, install compose + seed .env, bring up, verify health.
|
||||
|
||||
```bash
|
||||
# First deploy (or update to latest main)
|
||||
scripts/elway ana-docker --playbook playbooks/deploy-asset-engine.yaml
|
||||
|
||||
# Pin to a specific ref (tag, branch, or commit SHA)
|
||||
scripts/elway ana-docker --playbook playbooks/deploy-asset-engine.yaml --var ref=v0.1.0
|
||||
```
|
||||
|
||||
## Path layout (on ana-docker)
|
||||
|
||||
| Host path | Container path | Purpose | Restic? |
|
||||
|---|---|---|---|
|
||||
| `/opt/docker/build/asset-engine/` | — | git checkout used as docker build context | excluded |
|
||||
| `/opt/docker/compose/asset-engine/` | — | compose.yaml + .env | included (via `/opt/docker`) |
|
||||
| `/opt/docker/conf/asset-engine/db/` | `/app/runtime/db` | SQLite (`asset_engine.db` + WAL) | **included** |
|
||||
| `/opt/docker/conf/asset-engine/outputs/` | `/app/runtime/outputs` | content-addressed blob store | **included** |
|
||||
|
||||
## Network model
|
||||
|
||||
Internal-only tool, two entry points:
|
||||
|
||||
- **LAN**, default: `http://10.250.50.70:8200` — container port 8000
|
||||
published on the host, bound to 0.0.0.0 (configurable via
|
||||
`ASSET_ENGINE_BIND` / `ASSET_ENGINE_PORT`).
|
||||
- **TLS hostname**: `https://asset-engine.phasefinal.com` — Traefik on
|
||||
ana-docker terminates TLS and forwards to the container over the
|
||||
`traefik-net` docker network on port 8000.
|
||||
|
||||
`INFERENCE_HOST` defaults to `10.100.79.3` (irv-ml1 over WG). Override
|
||||
in `.env` if the fleet's inference topology moves.
|
||||
|
||||
## Catalog drift
|
||||
|
||||
`docs/asset-engine/services.yaml` in this repo is the canonical
|
||||
catalog. The asset-engine repo vendors a copy at `data/services.yaml`
|
||||
and re-vendors via `uv run scripts/sync_catalog.py` after upstream
|
||||
changes; CI fails any PR where the vendored copy diverges from this
|
||||
one. Pattern is: edit catalog here → asset-engine re-vendors → both
|
||||
sides commit on the same merge window.
|
||||
|
||||
## Outputs directory growth
|
||||
|
||||
`outputs/` grows unbounded in v1 — `Asset.retention` exists in the
|
||||
schema but the GC sweep isn't wired yet. The plan: Beszel alert when
|
||||
`du -sh /opt/docker/conf/asset-engine/outputs` crosses ~50 GB,
|
||||
revisit the threshold once we have real growth data. Tracking issue
|
||||
in the asset-engine repo.
|
||||
@@ -0,0 +1,66 @@
|
||||
# asset-engine — control plane over PFI's inference fleet.
|
||||
#
|
||||
# FastAPI + HTMX/Shoelace UI. Reads the catalog at services.yaml (baked
|
||||
# into the image at build time, drift-checked in CI against the copy
|
||||
# at docs/asset-engine/services.yaml in this repo), routes requests to
|
||||
# inference hosts (irv-ml1 over WG by default), persists Assets to a
|
||||
# local SQLite WAL DB, stores generated blobs content-addressed under
|
||||
# runtime/outputs/.
|
||||
#
|
||||
# Image is built on the host from the asset-engine git repo by the
|
||||
# deploy playbook (`playbooks/deploy-asset-engine.yaml`), which clones
|
||||
# into /opt/docker/build/asset-engine and runs `docker build -t
|
||||
# asset-engine:local .` before installing this compose and bringing
|
||||
# it up. No registry.
|
||||
#
|
||||
# State persists under /opt/docker/conf/asset-engine/{db,outputs} on
|
||||
# the host — separate bind-mounts so outputs/ can move to a bigger
|
||||
# volume later without touching DB state.
|
||||
#
|
||||
# All tunables live in .env — edit that, not this file.
|
||||
|
||||
services:
|
||||
asset-engine:
|
||||
image: ${ASSET_ENGINE_IMAGE}
|
||||
container_name: asset-engine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${ASSET_ENGINE_BIND:-0.0.0.0}:${ASSET_ENGINE_PORT}:8000"
|
||||
environment:
|
||||
# App always listens on 8000 internally; host port is the only knob.
|
||||
- RUNTIME_DIR=/app/runtime
|
||||
- DB_URL=sqlite:////app/runtime/db/asset_engine.db
|
||||
- INFERENCE_HOST=${INFERENCE_HOST:-10.100.79.3}
|
||||
# OIDC seam is empty in v1; populated in v2 when forward-auth lands.
|
||||
- OIDC_ISSUER=${OIDC_ISSUER:-}
|
||||
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
|
||||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
|
||||
volumes:
|
||||
- ${ASSET_ENGINE_DB_DIR}:/app/runtime/db
|
||||
- ${ASSET_ENGINE_OUTPUTS_DIR}:/app/runtime/outputs
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c 'import urllib.request,sys; r=urllib.request.urlopen(\"http://127.0.0.1:8000/health\",timeout=3); sys.exit(0 if r.status==200 else 1)' || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.asset-engine.rule=Host(`asset-engine.phasefinal.com`)
|
||||
- traefik.http.routers.asset-engine.entrypoints=websecure
|
||||
- traefik.http.routers.asset-engine.tls=true
|
||||
- traefik.http.routers.asset-engine.tls.certresolver=anaprod
|
||||
- traefik.http.routers.asset-engine.middlewares=crowdsec@file
|
||||
- traefik.http.services.asset-engine.loadbalancer.server.port=8000
|
||||
- homepage.group=AI Systems
|
||||
- homepage.name=Asset Engine
|
||||
- homepage.icon=mdi-tools
|
||||
- homepage.description=Control plane over the PFI inference fleet
|
||||
- homepage.href=https://asset-engine.phasefinal.com
|
||||
|
||||
networks:
|
||||
tnet:
|
||||
name: traefik-net
|
||||
external: true
|
||||
@@ -0,0 +1,91 @@
|
||||
# Gitea Actions workflow for asset-engine.
|
||||
#
|
||||
# THIS FILE LIVES IN THE ASSET-ENGINE REPO, NOT HERE.
|
||||
# Copy to vh/asset-engine:.gitea/workflows/deploy.yaml and commit.
|
||||
# (The canonical copy lives in the asset-engine repo; this file is a
|
||||
# reference for what shape the workflow takes.)
|
||||
#
|
||||
# What it does on every push to main (and on manual workflow_dispatch):
|
||||
# 1. Checks out asset-engine itself (the triggering repo).
|
||||
# 2. Checks out vh/esh-pfi-infrastructure to pick up the elway
|
||||
# playbook and helper scripts.
|
||||
# 3. Configures SSH so elway can reach ana-docker.
|
||||
# 4. Runs `scripts/elway ana-docker --playbook playbooks/deploy-asset-engine.yaml`
|
||||
# pinning to the commit SHA that triggered the workflow.
|
||||
#
|
||||
# Required Actions secrets (configure under
|
||||
# https://gitea.phasefinal.com/vh/asset-engine/settings/actions/secrets,
|
||||
# or org-level for reuse across repos):
|
||||
#
|
||||
# DEPLOY_SSH_KEY Private SSH key whose pubkey is in
|
||||
# ~lkraven/.ssh/authorized_keys on ana-docker.
|
||||
# Used by the runner to invoke the elway playbook.
|
||||
# Generate fresh; don't reuse a personal key.
|
||||
#
|
||||
# MGMT_REPO_TOKEN Gitea PAT (read:repository scope) on
|
||||
# vh/esh-pfi-infrastructure, used to clone the
|
||||
# management repo. Generate at
|
||||
# https://gitea.phasefinal.com/-/user/settings/applications.
|
||||
|
||||
name: Deploy asset-engine
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# `pfi-fleet` matches the central runner on ana-docker. Pin to
|
||||
# `ana-docker` instead if you want to refuse running on a future
|
||||
# site-local runner. The runner's label embeds a default image
|
||||
# (node:20-bookworm-slim) — has node + git out of the box, so
|
||||
# actions/checkout@v4 (a JS action) works without a custom
|
||||
# container. We just apt-install python3 + pyyaml for elway.
|
||||
runs-on: pfi-fleet
|
||||
|
||||
steps:
|
||||
- name: Install playbook prerequisites
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends \
|
||||
python3 python3-yaml openssh-client
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
- name: Checkout asset-engine (triggering repo)
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout management repo (eshpfi-management)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: vh/esh-pfi-infrastructure
|
||||
token: ${{ secrets.MGMT_REPO_TOKEN }}
|
||||
path: _mgmt
|
||||
|
||||
- name: Configure SSH to ana-docker
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
# The DEPLOY_SSH_KEY secret is the full private key contents,
|
||||
# newline-terminated. ssh refuses keys that aren't 0600.
|
||||
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
|
||||
# ssh_config alias so elway resolves "ana-docker" the same
|
||||
# way it would on a workstation. accept-new is fine for a
|
||||
# fresh job container — host key gets cached for the lifetime
|
||||
# of this job only.
|
||||
cat > ~/.ssh/config <<'EOF'
|
||||
Host ana-docker
|
||||
HostName 10.250.50.70
|
||||
User lkraven
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
StrictHostKeyChecking accept-new
|
||||
EOF
|
||||
chmod 600 ~/.ssh/config
|
||||
|
||||
- name: Deploy asset-engine (elway playbook, pinned to this commit)
|
||||
working-directory: _mgmt
|
||||
run: |
|
||||
scripts/elway ana-docker \
|
||||
--playbook playbooks/deploy-asset-engine.yaml \
|
||||
--var ref=${{ github.sha }}
|
||||
Reference in New Issue
Block a user