this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

spindle wf

+939
+242
.tangled/workflows/build.yml
··· 1 + when: 2 + - event: ["push", "manual"] 3 + branch: ["develop"] 4 + - event: ["push"] 5 + tag: ["v*"] 6 + 7 + engine: "nixery" 8 + 9 + clone: 10 + depth: 0 11 + 12 + dependencies: 13 + nixpkgs: 14 + - python313 15 + - gcc 16 + - gnumake 17 + - pkg-config 18 + - zlib 19 + - libjpeg 20 + - gmp 21 + - gettext 22 + - curl 23 + - wget 24 + - git 25 + - jq 26 + - skopeo 27 + - nix 28 + - postgresql_15 29 + - libffi 30 + - openjpeg 31 + - pango 32 + - harfbuzz 33 + - fontconfig 34 + - pipenv 35 + - cacert 36 + 37 + environment: 38 + PIPENV_VENV_IN_PROJECT: "1" 39 + PIPENV_CACHE_DIR: "/tmp/pipenv-cache" 40 + PIP_CACHE_DIR: "/tmp/pip-cache" 41 + NIX_SSL_CERT_FILE: "/nix/var/nix/profiles/default/etc/ssl/certs/ca-bundle.crt" 42 + SSL_CERT_FILE: "/nix/var/nix/profiles/default/etc/ssl/certs/ca-bundle.crt" 43 + 44 + steps: 45 + - name: "Print build info" 46 + command: | 47 + echo "=== CARE Build Pipeline ===" 48 + echo "Date: $(date -u +%Y-%m-%dT%H:%M:%SZ)" 49 + echo "SHA: ${TANGLED_SHA:-unknown}" 50 + echo "Ref: ${TANGLED_REF:-unknown}" 51 + echo "Ref Name: ${TANGLED_REF_NAME:-unknown}" 52 + echo "Ref Type: ${TANGLED_REF_TYPE:-unknown}" 53 + echo "Repo: ${TANGLED_REPO_NAME:-unknown}" 54 + echo "" 55 + echo "=== Tool versions ===" 56 + python3 --version 57 + pipenv --version 58 + nix --version 59 + skopeo --version 60 + git --version 61 + gcc --version | head -1 62 + echo "" 63 + echo "=== Workspace ===" 64 + pwd 65 + ls -la 66 + 67 + - name: "Create virtualenv and install dependencies" 68 + command: | 69 + echo "=== Creating virtualenv ===" 70 + python3 -m venv .venv 71 + echo "=== Installing production dependencies ===" 72 + pipenv install --deploy --categories "packages" 73 + echo "=== Virtualenv contents ===" 74 + .venv/bin/python --version 75 + .venv/bin/pip list --format=columns | head -20 76 + echo "... (truncated)" 77 + echo "=== Total packages ===" 78 + .venv/bin/pip list --format=columns | tail -n +3 | wc -l 79 + 80 + - name: "Install plugins" 81 + command: | 82 + echo "=== Installing plugins ===" 83 + echo "ADDITIONAL_PLUGS=${ADDITIONAL_PLUGS:-<not set>}" 84 + .venv/bin/python3 install_plugins.py 85 + echo "=== Plugin installation complete ===" 86 + 87 + - name: "Compute image tags" 88 + command: | 89 + echo "=== Computing image tags ===" 90 + 91 + TAGS="" 92 + SHA="${TANGLED_SHA:-unknown}" 93 + SHORT_SHA="${SHA:0:7}" 94 + REF="${TANGLED_REF:-}" 95 + REF_NAME="${TANGLED_REF_NAME:-}" 96 + REF_TYPE="${TANGLED_REF_TYPE:-}" 97 + DATE=$(date -u +%Y%m%d) 98 + 99 + if [ "$REF_TYPE" = "tag" ]; then 100 + # Tag push (e.g. v25.28.0) 101 + # Produce: production-latest, production-latest-{date}-{sha}, and the version tag 102 + VERSION="${REF_NAME}" 103 + TAGS="production-latest" 104 + TAGS="${TAGS} production-latest-${DATE}-${SHORT_SHA}" 105 + TAGS="${TAGS} ${VERSION}" 106 + echo "Tag push detected: ${VERSION}" 107 + 108 + elif [ "$REF_TYPE" = "branch" ]; then 109 + case "$REF_NAME" in 110 + develop) 111 + TAGS="latest" 112 + TAGS="${TAGS} latest-${DATE}-${SHORT_SHA}" 113 + echo "Develop branch push detected" 114 + ;; 115 + staging) 116 + TAGS="staging-latest" 117 + TAGS="${TAGS} staging-latest-${DATE}-${SHORT_SHA}" 118 + echo "Staging branch push detected" 119 + ;; 120 + production) 121 + TAGS="production-latest" 122 + TAGS="${TAGS} production-latest-${DATE}-${SHORT_SHA}" 123 + echo "Production branch push detected" 124 + ;; 125 + *) 126 + TAGS="branch-${REF_NAME}-${SHORT_SHA}" 127 + echo "Other branch push detected: ${REF_NAME}" 128 + ;; 129 + esac 130 + 131 + else 132 + # Fallback for manual triggers or unexpected ref types 133 + TAGS="dev-${SHORT_SHA}" 134 + echo "Manual or unknown trigger" 135 + fi 136 + 137 + echo "" 138 + echo "Computed tags:" 139 + for TAG in $TAGS; do 140 + echo " - ${TAG}" 141 + done 142 + 143 + # Persist tags for later steps — /tmp is NOT shared between Spindle 144 + # steps, only the workspace directory (/tangled/workspace) is. 145 + # Since the repo is cloned into the workspace, we can write files 146 + # here and they'll be available in subsequent steps. 147 + mkdir -p .build-meta 148 + echo "${TAGS}" > .build-meta/image-tags.txt 149 + echo "${SHA}" > .build-meta/image-sha.txt 150 + 151 + - name: "Build OCI image with Nix" 152 + command: | 153 + echo "=== Building OCI image ===" 154 + 155 + SHA=$(cat .build-meta/image-sha.txt) 156 + SHORT_SHA="${SHA:0:7}" 157 + 158 + echo "App version: ${SHORT_SHA}" 159 + echo "Source dir: $(pwd)" 160 + echo "Venv dir: $(pwd)/.venv" 161 + echo "" 162 + 163 + # Build the image using the standalone nix expression. 164 + # We override the default arguments so it picks up the CI-built venv 165 + # and the checked-out source tree rather than the flake's defaults. 166 + # 167 + # --arg expects a Nix expression. Absolute paths (starting with /) 168 + # are valid Nix path literals — Nix will copy them into the store. 169 + # We resolve $(pwd) to get the absolute workspace path. 170 + WORKSPACE="$(pwd)" 171 + 172 + nix-build nix/docker-image.nix \ 173 + --arg pkgs 'import <nixpkgs> {}' \ 174 + --argstr appVersion "${SHORT_SHA}" \ 175 + --arg venvPath "${WORKSPACE}/.venv" \ 176 + --arg appSrc "${WORKSPACE}" \ 177 + --out-link result \ 178 + --show-trace \ 179 + --option sandbox false 180 + 181 + echo "" 182 + echo "=== Build complete ===" 183 + ls -lh result 184 + echo "" 185 + 186 + # Inspect the image 187 + echo "=== Image layers ===" 188 + if command -v skopeo &> /dev/null; then 189 + skopeo inspect docker-archive:result | jq '{Layers: .Layers | length, Digest: .Digest, Created: .Created}' 2>/dev/null || echo "(inspection skipped)" 190 + fi 191 + 192 + - name: "Push image to registry" 193 + command: | 194 + echo "=== Push step ===" 195 + 196 + TAGS=$(cat .build-meta/image-tags.txt) 197 + SHA=$(cat .build-meta/image-sha.txt) 198 + 199 + # Registry configuration — expects secrets to be set via Tangled repo settings: 200 + # REGISTRY_URL - e.g. ghcr.io or a custom registry 201 + # REGISTRY_IMAGE - e.g. ohcnetwork/care 202 + # REGISTRY_USERNAME - auth username 203 + # REGISTRY_TOKEN - auth token/password 204 + REGISTRY_URL="${REGISTRY_URL:-}" 205 + REGISTRY_IMAGE="${REGISTRY_IMAGE:-}" 206 + REGISTRY_USERNAME="${REGISTRY_USERNAME:-}" 207 + REGISTRY_TOKEN="${REGISTRY_TOKEN:-}" 208 + 209 + if [ -z "$REGISTRY_URL" ] || [ -z "$REGISTRY_IMAGE" ] || [ -z "$REGISTRY_TOKEN" ]; then 210 + echo "Registry credentials not configured. Skipping push." 211 + echo "" 212 + echo "To enable pushing, set these secrets in your Tangled repo settings:" 213 + echo " REGISTRY_URL (e.g. ghcr.io)" 214 + echo " REGISTRY_IMAGE (e.g. ohcnetwork/care)" 215 + echo " REGISTRY_USERNAME (e.g. your-username)" 216 + echo " REGISTRY_TOKEN (e.g. your-token)" 217 + echo "" 218 + echo "The following tags would have been pushed:" 219 + for TAG in $TAGS; do 220 + echo " - ${REGISTRY_URL:-<registry>}/${REGISTRY_IMAGE:-<image>}:${TAG}" 221 + done 222 + exit 0 223 + fi 224 + 225 + echo "Logging in to ${REGISTRY_URL}..." 226 + echo "${REGISTRY_TOKEN}" | skopeo login "${REGISTRY_URL}" \ 227 + --username "${REGISTRY_USERNAME}" \ 228 + --password-stdin 229 + 230 + echo "" 231 + echo "Pushing image..." 232 + for TAG in $TAGS; do 233 + DEST="${REGISTRY_URL}/${REGISTRY_IMAGE}:${TAG}" 234 + echo " -> ${DEST}" 235 + skopeo copy \ 236 + "docker-archive:result" \ 237 + "docker://${DEST}" \ 238 + --retry-times 3 239 + done 240 + 241 + echo "" 242 + echo "=== All tags pushed successfully ==="
+470
care/CLAUDE.md
··· 1 + # CLAUDE.md — Tangled Spindle Build Workflow Context 2 + 3 + This document captures all the important context about the CARE project's build infrastructure, specifically for implementing the Tangled Spindle CI/CD pipeline that produces OCI container images using Nixery/Nix `dockerTools`. 4 + 5 + ## Project Overview 6 + 7 + CARE (Care is a Digital Public Good) is a Django application enabling TeleICU & Decentralised Administration of Healthcare Capacity. It uses Python 3.13, Django 6.0, PostgreSQL, Redis, Celery, and is deployed as a Docker container running gunicorn on port 9000. 8 + 9 + The repository is hosted on Tangled at `tangled.org` and also mirrored on GitHub at `ohcnetwork/care`. 10 + 11 + --- 12 + 13 + ## Repository Structure (Key Paths) 14 + 15 + ``` 16 + care/ 17 + ├── .tangled/workflows/ # Tangled Spindle CI workflows 18 + │ ├── build.yml # THE BUILD WORKFLOW (to be implemented) 19 + │ └── test.yml # Existing test workflow (basic health check currently) 20 + ├── .github/workflows/ # Existing GitHub Actions (reference implementation) 21 + │ ├── deploy.yml # Current build+push pipeline (GHCR, multi-arch) 22 + │ ├── release.yml # Tag creation on production branch (vYY.WW.MINOR) 23 + │ ├── reusable-test.yml # Reusable test job 24 + │ └── ... 25 + ├── docker/ 26 + │ ├── prod.Dockerfile # Current production Dockerfile (THE REFERENCE) 27 + │ └── dev.Dockerfile # Dev Dockerfile 28 + ├── scripts/ 29 + │ ├── start.sh # Entrypoint: gunicorn on 0.0.0.0:9000 30 + │ ├── healthcheck.sh # Reads /tmp/container-role, curls /ping/ for api 31 + │ ├── celery_worker.sh # Celery worker entrypoint 32 + │ ├── celery_beat.sh # Celery beat entrypoint 33 + │ ├── wait_for_db.sh # Waits for PostgreSQL 34 + │ ├── wait_for_redis.sh # Waits for Redis 35 + │ └── ... 36 + ├── plugs/ # Plugin system 37 + │ ├── __init__.py 38 + │ ├── manager.py # PlugManager: installs additional pip packages 39 + │ └── plug.py # Plug dataclass 40 + ├── install_plugins.py # Entry point: `from plug_config import manager; manager.install()` 41 + ├── plug_config.py # Defines plugs list and creates PlugManager 42 + ├── Pipfile # Python dependencies (pipenv) 43 + ├── Pipfile.lock # Locked dependencies 44 + ├── flake.nix # Nix flake for dev environment (NOT yet for Docker image) 45 + ├── flake.lock # Pinned nixpkgs (nixos-unstable) 46 + ├── config/ # Django config (settings, wsgi, celery_app, gunicorn) 47 + └── manage.py # Django management 48 + ``` 49 + 50 + --- 51 + 52 + ## Current Production Dockerfile (`docker/prod.Dockerfile`) 53 + 54 + This is the golden reference for what the Nix-built OCI image must replicate. 55 + 56 + ### Build stage (`builder`): 57 + - Base: `python:3.13-slim-bookworm` 58 + - Installs build deps: `build-essential libjpeg-dev zlib1g-dev libgmp-dev libpq-dev git wget libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 libharfbuzz-subset0 libffi-dev libopenjp2-7-dev` 59 + - Installs `pipenv==2025.1.1` 60 + - Creates venv at `/app/.venv` 61 + - Runs `pipenv install --deploy --categories "packages"` 62 + - Copies `plugs/` and `install_plugins.py`, `plug_config.py` 63 + - Accepts `ADDITIONAL_PLUGS` build arg (JSON array of extra plugin specs) 64 + - Runs `python3 install_plugins.py` (which pip-installs any plugins) 65 + 66 + ### Runtime stage (`runtime`): 67 + - Base: `python:3.13-slim-bookworm` 68 + - Creates `django` user/group 69 + - Runtime deps: `libpq-dev libgmp-dev libpangoft2-1.0-0 gettext wget curl gnupg` 70 + - Copies `.venv` from builder 71 + - Sets `APP_VERSION` env var (git SHA) 72 + - Copies `scripts/*.sh` with execute perms 73 + - Copies full repo source to `/app` 74 + - Runs as user `django` 75 + - Healthcheck: `./healthcheck.sh` (30s interval, 5s timeout, 10s start, 12 retries) 76 + - Exposes port 9000 77 + 78 + ### Key environment variables in the image: 79 + - `BUILD_ENVIRONMENT=production` 80 + - `PYTHONUNBUFFERED=1` 81 + - `PYTHONDONTWRITEBYTECODE=1` 82 + - `PIPENV_VENV_IN_PROJECT=1` 83 + - `PATH=/app/.venv/bin:$PATH` 84 + - `HOME=/app` 85 + 86 + --- 87 + 88 + ## Current GitHub Actions Deploy Pipeline (`.github/workflows/deploy.yml`) 89 + 90 + ### Trigger: 91 + - Push to `develop` branch 92 + - Push of `v*` tags 93 + - Manual `workflow_dispatch` 94 + - Ignores `docs/**` changes 95 + 96 + ### Jobs: 97 + 1. **test** — runs reusable test workflow 98 + 2. **build** — matrix build for `linux/amd64` and `linux/arm64` (separate runners), pushes digests to GHCR 99 + 3. **merge-manifests** — creates multi-arch manifest, pushes tagged images to GHCR, creates Sentry release 100 + 4. **notify-release** — (for tag pushes only) placeholder notification 101 + 102 + ### Image tags (from `docker/metadata-action`): 103 + - `production-latest` + `production-latest-{run}-{date}-{sha}` — for tag pushes (`v*`) 104 + - `staging-latest` + `staging-latest-{run}-{date}-{sha}` — for `staging` branch 105 + - `latest` + `latest-{run}` — for `develop` branch 106 + - Semver `{{version}}` — for tag pushes 107 + 108 + ### Registry: `ghcr.io/ohcnetwork/care` 109 + 110 + ### Build args passed: 111 + - `APP_VERSION=${{ github.sha }}` 112 + - `ADDITIONAL_PLUGS=${{ env.ADDITIONAL_PLUGS }}` 113 + 114 + --- 115 + 116 + ## Release Tagging (`.github/workflows/release.yml`) 117 + 118 + On push to `production` branch: 119 + - Calculates next tag as `vYY.WW.MINOR` (year.week.patch, auto-incrementing) 120 + - Creates and pushes git tag 121 + - Creates draft GitHub release with auto-generated notes 122 + 123 + --- 124 + 125 + ## Plugin System 126 + 127 + - `plug_config.py` defines `plugs = []` (empty list by default) and creates `manager = PlugManager(plugs)` 128 + - `PlugManager.__init__` also reads `ADDITIONAL_PLUGS` env var (JSON array of plug dicts) 129 + - Each `Plug` has: `name`, `package_name`, `version` (default `@main`), `configs` 130 + - `manager.install()` runs `pip install` for each plug's `package_name + version` 131 + - `install_plugins.py` just calls `manager.install()` 132 + 133 + --- 134 + 135 + ## Existing Nix Setup (`flake.nix`) 136 + 137 + ### Inputs: 138 + - `nixpkgs` — `github:NixOS/nixpkgs/nixos-unstable` 139 + - `flake-utils` — `github:numtide/flake-utils` 140 + 141 + ### What exists today: 142 + - `devShells.default` — full dev environment with PostgreSQL 15, Redis, MinIO, Python 3.13, ruff, and many helper scripts 143 + - `packages.default` — a trivial `writeShellApplication` that just prints a message 144 + 145 + ### What does NOT exist yet: 146 + - `packages.dockerImage` — the Nix expression to build an OCI image (TO BE CREATED) 147 + - `nix/docker-image.nix` — standalone Nix expression for the image (TO BE CREATED) 148 + 149 + ### Key nixpkgs packages used in dev shell (reference for build deps): 150 + - `python313`, `postgresql_15`, `libpq`, `redis`, `pkg-config`, `zlib`, `libjpeg`, `gmp`, `gettext`, `curl`, `wget`, `git`, `gcc`, `gnumake` 151 + 152 + ### Important env vars set in dev shell: 153 + - `PG_CONFIG = "${pkgs.postgresql_15}/bin/pg_config"` 154 + - `LDFLAGS = "-L${pkgs.postgresql_15}/lib"` 155 + - `CPPFLAGS = "-I${pkgs.postgresql_15}/include"` 156 + 157 + --- 158 + 159 + ## Tangled Spindle Workflow System 160 + 161 + ### Workflow location: `.tangled/workflows/*.yml` 162 + 163 + ### Workflow YAML schema: 164 + 165 + ```yaml 166 + when: # REQUIRED - trigger conditions 167 + - event: ["push", "pull_request", "manual"] 168 + branch: ["main", "develop"] # glob patterns supported 169 + tag: ["v*"] # for push events 170 + 171 + engine: "nixery" # REQUIRED - only "nixery" supported currently 172 + 173 + clone: # OPTIONAL 174 + skip: false # default false 175 + depth: 1 # default 1 (shallow) 176 + submodules: false # default false 177 + 178 + dependencies: # OPTIONAL - nix packages 179 + nixpkgs: # registry key 180 + - package1 181 + - package2 182 + nixpkgs/nixpkgs-unstable: # can specify channel 183 + - package3 184 + git+https://tangled.org/@user/repo: # custom flake registries 185 + - my_pkg 186 + 187 + environment: # OPTIONAL - global env vars (PUBLIC, not for secrets) 188 + KEY: "value" 189 + 190 + steps: # OPTIONAL - list of steps 191 + - name: "Step name" 192 + command: | # runs in bash shell 193 + echo "hello" 194 + environment: # OPTIONAL - per-step env vars (also PUBLIC) 195 + KEY: "value" 196 + ``` 197 + 198 + ### Built-in environment variables (always available): 199 + - `CI=true` 200 + - `TANGLED_PIPELINE_ID` — AT URI of the pipeline 201 + - `TANGLED_REPO_KNOT` — repository's knot hostname 202 + - `TANGLED_REPO_DID` — DID of repo owner 203 + - `TANGLED_REPO_NAME` — repository name 204 + - `TANGLED_REPO_DEFAULT_BRANCH` — default branch 205 + - `TANGLED_REPO_URL` — full URL to the repository 206 + 207 + ### Push-only environment variables: 208 + - `TANGLED_REF` — full git ref (e.g., `refs/heads/main`) 209 + - `TANGLED_REF_NAME` — short name (e.g., `main`) 210 + - `TANGLED_REF_TYPE` — `branch` or `tag` 211 + - `TANGLED_SHA` / `TANGLED_COMMIT_SHA` — commit SHA 212 + 213 + ### PR-only environment variables: 214 + - `TANGLED_PR_SOURCE_BRANCH`, `TANGLED_PR_TARGET_BRANCH`, `TANGLED_PR_SOURCE_SHA` 215 + 216 + ### Secrets: 217 + - Secrets are managed via the repository settings on Tangled's web UI 218 + - Backend can be SQLite or OpenBao (Hashicorp Vault fork) 219 + - **Secrets are NOT defined in the workflow YAML** — they're injected as env vars at runtime 220 + - Do NOT put secrets in `environment:` blocks (those are public/visible) 221 + 222 + ### Execution model: 223 + - Spindle uses Docker/Podman under the hood 224 + - Each step runs in a fresh container 225 + - State persists across steps in `/tangled/workspace` directory 226 + - Base image is constructed on-the-fly using Nixery based on `dependencies` 227 + - Default workflow timeout: 5 minutes (configurable on spindle) 228 + 229 + ### Existing test.yml: 230 + ```yaml 231 + when: 232 + - event: ["push", "manual"] 233 + branch: ["develop"] 234 + 235 + engine: "nixery" 236 + 237 + dependencies: 238 + nixpkgs: 239 + - coreutils 240 + 241 + steps: 242 + - name: "Spindle health check" 243 + command: | 244 + echo "=== Spindle is working! ===" 245 + # ... prints env vars, date, hostname, etc. 246 + ``` 247 + 248 + --- 249 + 250 + ## Implemented Files 251 + 252 + ### 1. `nix/docker-image.nix` — Nix Expression for OCI Image (CREATED) 253 + 254 + Standalone Nix expression using `pkgs.dockerTools.buildLayeredImage`. Can be 255 + invoked from the flake (`nix build .#dockerImage`) or directly with `nix-build`. 256 + 257 + **Arguments:** 258 + | Arg | Type | Default | Description | 259 + |---|---|---|---| 260 + | `pkgs` | attrset | `import <nixpkgs> {}` | nixpkgs package set | 261 + | `appVersion` | string | `"unknown"` | Git SHA or version tag, becomes `APP_VERSION` env var | 262 + | `venvPath` | path | `./../.venv` | Pre-built Python virtualenv | 263 + | `appSrc` | path | `./..` | Full application source tree | 264 + 265 + **Runtime packages included in image:** 266 + | Package | Why | 267 + |---|---| 268 + | `bash`, `coreutils`, `findutils`, `gnugrep`, `gnused` | Scripts use `#!/bin/bash`, standard utils | 269 + | `libpq` | PostgreSQL client library (`psycopg`) | 270 + | `gmp` | GNU MP (cryptography deps) | 271 + | `pango`, `harfbuzz`, `fontconfig`, `freetype` | WeasyPrint / PDF rendering | 272 + | `gettext` | Django `compilemessages` | 273 + | `curl`, `wget` | Healthcheck + general HTTP | 274 + | `cacert` | TLS certificates (set via `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, etc.) | 275 + | `procps` | `celery inspect ping` in healthcheck | 276 + | `libjpeg`, `openjpeg` | Pillow runtime | 277 + | `libffi` | cffi runtime | 278 + | `zlib` | Various packages runtime | 279 + 280 + **Image assembly (`appDir` derivation):** 281 + - Uses `rsync` to copy source, excluding `.venv`, `.git`, `.nix-data`, `__pycache__`, `.pyc`, `.ruff_cache`, `result`, `node_modules`, `.build-meta` 282 + - Copies `.venv` separately from explicit `venvPath` arg (avoids staleness when venv is rebuilt in CI) 283 + - Sets execute permissions on `scripts/*.sh` and top-level `*.sh` files 284 + 285 + **User setup (`etcDir` derivation):** 286 + - Creates `/etc/passwd` and `/etc/group` with `django` user (UID 1000, GID 1000) 287 + - Creates world-writable `/tmp` with container-role placeholder 288 + 289 + **Image config:** 290 + | Key | Value | 291 + |---|---| 292 + | `WorkingDir` | `/app` | 293 + | `Env.BUILD_ENVIRONMENT` | `production` | 294 + | `Env.PYTHONUNBUFFERED` | `1` | 295 + | `Env.PYTHONDONTWRITEBYTECODE` | `1` | 296 + | `Env.PIPENV_VENV_IN_PROJECT` | `1` | 297 + | `Env.APP_VERSION` | `${appVersion}` | 298 + | `Env.HOME` | `/app` | 299 + | `Env.PATH` | `/app/.venv/bin:/bin:/usr/bin:/sbin:/usr/sbin` | 300 + | `Env.SSL_CERT_FILE` | Nix store path to `ca-bundle.crt` | 301 + | `Env.FONTCONFIG_PATH` | Nix store path to fontconfig config | 302 + | `ExposedPorts` | `9000/tcp` | 303 + | `User` | `django` | 304 + | `Healthcheck` | `CMD /app/scripts/healthcheck.sh` (30s interval, 5s timeout, 10s start, 12 retries) | 305 + 306 + **Layering:** `maxLayers = 100`, with `enableFakechroot = true` for `fakeRootCommands`. 307 + 308 + ### 2. `flake.nix` — Added `packages.dockerImage` Output (MODIFIED) 309 + 310 + Added after `packages.default`: 311 + 312 + ```nix 313 + packages.dockerImage = import ./nix/docker-image.nix { 314 + inherit pkgs; 315 + appVersion = "dev"; 316 + venvPath = ./.venv; 317 + appSrc = ./.; 318 + }; 319 + ``` 320 + 321 + Local usage: `nix build .#dockerImage` (requires a `.venv` at repo root). 322 + 323 + In CI the image is built via `nix-build nix/docker-image.nix` with `--arg` / `--argstr` overrides pointing at the workspace-built venv and source. 324 + 325 + ### 3. `.tangled/workflows/build.yml` — Build Workflow (CREATED) 326 + 327 + **Triggers:** 328 + - Push to `develop` branch + manual trigger 329 + - Push of `v*` tags 330 + 331 + **Engine:** `nixery` 332 + 333 + **Clone depth:** `0` (full clone — needed for tag computation) 334 + 335 + **Dependencies (nixpkgs):** `python313`, `gcc`, `gnumake`, `pkg-config`, `zlib`, `libjpeg`, `gmp`, `gettext`, `curl`, `wget`, `git`, `jq`, `skopeo`, `nix`, `postgresql_15`, `libffi`, `openjpeg`, `pango`, `harfbuzz`, `fontconfig`, `pipenv`, `cacert` 336 + 337 + **Global environment:** 338 + - `PIPENV_VENV_IN_PROJECT=1` 339 + - `PIPENV_CACHE_DIR=/tmp/pipenv-cache` 340 + - `PIP_CACHE_DIR=/tmp/pip-cache` 341 + - `NIX_SSL_CERT_FILE` and `SSL_CERT_FILE` — point to Nix profile cert bundle 342 + 343 + **Steps (6 steps):** 344 + 345 + 1. **Print build info** — Echoes SHA, ref, ref type, tool versions, workspace listing 346 + 2. **Create virtualenv and install dependencies** — `python3 -m venv .venv` then `pipenv install --deploy --categories "packages"`. Prints package count. 347 + 3. **Install plugins** — Runs `.venv/bin/python3 install_plugins.py`. Respects `ADDITIONAL_PLUGS` env var (set as secret if needed). 348 + 4. **Compute image tags** — Shell logic based on `TANGLED_REF_TYPE` and `TANGLED_REF_NAME`: 349 + - Tag push (`v*`): `production-latest`, `production-latest-{date}-{sha}`, `{version}` 350 + - `develop` branch: `latest`, `latest-{date}-{sha}` 351 + - `staging` branch: `staging-latest`, `staging-latest-{date}-{sha}` 352 + - `production` branch: `production-latest`, `production-latest-{date}-{sha}` 353 + - Other branches: `branch-{name}-{sha}` 354 + - Manual/fallback: `dev-{sha}` 355 + - Tags persisted to `.build-meta/image-tags.txt` and `.build-meta/image-sha.txt` (workspace-relative, survives across Spindle steps) 356 + 5. **Build OCI image with Nix** — `nix-build nix/docker-image.nix` with `--arg pkgs`, `--argstr appVersion`, `--arg venvPath`, `--arg appSrc`, `--option sandbox false`. Output is `./result` (symlink to tarball). Inspects with `skopeo inspect docker-archive:result`. 357 + 6. **Push image to registry** — Reads `REGISTRY_URL`, `REGISTRY_IMAGE`, `REGISTRY_USERNAME`, `REGISTRY_TOKEN` from env (secrets). If not set, prints what would have been pushed and exits 0 (graceful skip). Otherwise: `skopeo login`, then `skopeo copy docker-archive:result docker://{dest}` for each tag with `--retry-times 3`. 358 + 359 + --- 360 + 361 + ## Secrets Required (to be set in Tangled repo settings) 362 + 363 + | Secret | Used for | 364 + |---|---| 365 + | `REGISTRY_URL` | Registry hostname (e.g. `ghcr.io`) | 366 + | `REGISTRY_IMAGE` | Image path (e.g. `ohcnetwork/care`) | 367 + | `REGISTRY_USERNAME` | `skopeo login` username | 368 + | `REGISTRY_TOKEN` | `skopeo login` password/token | 369 + | `ADDITIONAL_PLUGS` | (Optional) JSON array of extra plugin specs | 370 + 371 + Sentry is explicitly out of scope for this workflow. 372 + 373 + **The push step gracefully skips if registry secrets are not configured.** This means the workflow can be tested/iterated on without any secrets set — it will build the image and report what tags would have been pushed. 374 + 375 + --- 376 + 377 + ## What's Deferred 378 + 379 + | Feature | Why | 380 + |---|---| 381 + | **Multi-arch** (arm64) | Spindle runners are single-arch; add when matrix support exists | 382 + | **Sentry release** | Explicitly excluded per project decision | 383 + | **Test job dependency** | Tangled may not support cross-workflow dependencies yet | 384 + | **ECS deploy** | Already commented out in GitHub Actions; out of scope | 385 + 386 + --- 387 + 388 + ## Key Technical Notes 389 + 390 + ### Pipenv in CI: 391 + - `PIPENV_VENV_IN_PROJECT=1` makes pipenv create `.venv` inside the project dir 392 + - `pipenv install --deploy --categories "packages"` installs only production deps 393 + - The `Pipfile.lock` must exist and match (--deploy enforces this) 394 + 395 + ### Plugin installation: 396 + - Happens AFTER pipenv install 397 + - Reads `ADDITIONAL_PLUGS` env var (JSON array) 398 + - Runs `pip install` for each plugin into the existing venv 399 + - Default `plug_config.py` has an empty `plugs = []` list 400 + 401 + ### Nix `dockerTools.buildLayeredImage`: 402 + - Produces a tarball (not a running container) 403 + - Automatically splits Nix store paths into Docker layers 404 + - System libraries → shared/cacheable layers 405 + - App source + venv → top layers (change frequently) 406 + - The tarball can be loaded with `docker load` or pushed with `skopeo` 407 + - `skopeo copy docker-archive:./result docker://registry/image:tag` 408 + 409 + ### Workspace persistence in Spindle: 410 + - Each Spindle step runs in a fresh container 411 + - State persists **only** in the workspace directory (where the repo is cloned) 412 + - `/tmp` is NOT shared between steps 413 + - Cross-step state (tags, SHA) is persisted to `.build-meta/` inside the workspace 414 + - The `.venv` built in step 2 is available in step 5 because it's inside the workspace 415 + 416 + ### nix-build path arguments: 417 + - `--arg` evaluates its value as a Nix expression 418 + - Absolute paths (starting with `/`) are valid Nix path literals 419 + - Nix copies them into the store automatically 420 + - `--option sandbox false` is needed because the workspace paths are impure 421 + - `$(pwd)` is resolved to get the absolute workspace path for `venvPath` and `appSrc` 422 + 423 + ### Source filtering in the image: 424 + - `rsync` with `--exclude` filters prevents build artifacts, `.git`, `__pycache__`, etc. from entering the image 425 + - `.venv` is excluded from the source copy and added separately from `venvPath` — this avoids double-copying and ensures the CI-built venv is always used 426 + 427 + ### nixpkgs version: 428 + - Flake is pinned to `nixos-unstable` (rev `5e2a59a5b1a82f89f2c7e598302a9cacebb72a67`) 429 + - Python 3.13 is available in this channel 430 + - All packages referenced in the dev shell are available 431 + - In CI, `nix-build` uses `<nixpkgs>` from the Nixery environment (which also uses nixpkgs) 432 + 433 + --- 434 + 435 + ## Reference: Tangled Core's Own build.yml 436 + 437 + The Tangled project itself uses this simple build workflow (for reference on syntax/conventions): 438 + 439 + ```yaml 440 + when: 441 + - event: ["push", "pull_request"] 442 + branch: master 443 + 444 + engine: nixery 445 + 446 + dependencies: 447 + nixpkgs: 448 + - go 449 + - gcc 450 + 451 + environment: 452 + CGO_ENABLED: 1 453 + 454 + steps: 455 + - name: patch static dir 456 + command: | 457 + mkdir -p appview/pages/static; touch appview/pages/static/x 458 + 459 + - name: build appview 460 + command: | 461 + go build -o appview.out ./cmd/appview 462 + 463 + - name: build knot 464 + command: | 465 + go build -o knot.out ./cmd/knot 466 + 467 + - name: build spindle 468 + command: | 469 + go build -o spindle.out ./cmd/spindle 470 + ```
+15
flake.nix
··· 559 559 echo "Use 'nix develop' to enter the development shell" 560 560 ''; 561 561 }; 562 + 563 + # Production OCI image built with dockerTools. 564 + # 565 + # In CI the image is built via nix-build with --argstr overrides so 566 + # that the pre-built .venv and checked-out source are injected. 567 + # 568 + # Locally you can test with: 569 + # nix build .#dockerImage 570 + # (requires a .venv to exist at the repo root) 571 + packages.dockerImage = import ./nix/docker-image.nix { 572 + inherit pkgs; 573 + appVersion = "dev"; 574 + venvPath = ./.venv; 575 + appSrc = ./.; 576 + }; 562 577 } 563 578 ); 564 579 }
+212
nix/docker-image.nix
··· 1 + # nix/docker-image.nix 2 + # 3 + # Builds a layered OCI image for CARE that is functionally equivalent to 4 + # docker/prod.Dockerfile. The image is intended to be built in CI where a 5 + # pre-built virtualenv (.venv) and the full application source tree are passed 6 + # in as arguments. 7 + # 8 + # Usage (standalone): 9 + # nix-build nix/docker-image.nix \ 10 + # --arg pkgs 'import <nixpkgs> {}' \ 11 + # --argstr appVersion "abc1234" \ 12 + # --argstr venvPath /path/to/.venv \ 13 + # --argstr appSrc /path/to/repo 14 + # 15 + # Usage (from flake.nix): 16 + # Imported and called with the appropriate arguments. 17 + 18 + { pkgs ? import <nixpkgs> { } 19 + , appVersion ? "unknown" 20 + , venvPath ? ./../.venv 21 + , appSrc ? ./.. 22 + }: 23 + 24 + let 25 + # ------------------------------------------------------------------------- 26 + # Runtime dependencies — mirrors the `apt-get install` in the runtime stage 27 + # of docker/prod.Dockerfile: 28 + # libpq-dev libgmp-dev libpangoft2-1.0-0 gettext wget curl gnupg 29 + # 30 + # Plus essentials the scripts and Python runtime need. 31 + # ------------------------------------------------------------------------- 32 + runtimeDeps = with pkgs; [ 33 + # Core utilities — scripts use #!/bin/bash, cat, ls, touch, etc. 34 + bash 35 + coreutils 36 + findutils 37 + gnugrep 38 + gnused 39 + 40 + # PostgreSQL client library (psycopg at runtime) 41 + libpq 42 + 43 + # GNU MP — required by cryptography / gmpy2 at runtime 44 + gmp 45 + 46 + # WeasyPrint / PDF rendering runtime 47 + pango 48 + harfbuzz 49 + fontconfig 50 + freetype 51 + 52 + # Django compilemessages 53 + gettext 54 + 55 + # Healthcheck + general HTTP 56 + curl 57 + wget 58 + 59 + # TLS root certificates 60 + cacert 61 + 62 + # Used by celery inspect in healthcheck.sh 63 + procps 64 + 65 + # libjpeg + openjpeg — Pillow runtime 66 + libjpeg 67 + openjpeg 68 + 69 + # libffi — cffi runtime 70 + libffi 71 + 72 + # zlib — runtime dependency for various packages 73 + zlib 74 + ]; 75 + 76 + # ------------------------------------------------------------------------- 77 + # Construct the image contents. 78 + # We create a derivation that assembles the /app directory exactly as the 79 + # Dockerfile COPY steps would, with correct permissions. 80 + # ------------------------------------------------------------------------- 81 + appDir = pkgs.runCommand "care-app" { 82 + nativeBuildInputs = [ pkgs.rsync ]; 83 + } '' 84 + mkdir -p $out/app 85 + 86 + # Copy the application source, excluding directories that should not 87 + # end up in the production image (build artifacts, dev tooling, the 88 + # venv itself — we copy that separately to avoid staleness issues 89 + # when venvPath differs from appSrc/.venv). 90 + rsync -a --chmod=D755,F644 \ 91 + --exclude '.venv' \ 92 + --exclude '.nix-data' \ 93 + --exclude '.git' \ 94 + --exclude '.build-meta' \ 95 + --exclude '__pycache__' \ 96 + --exclude '*.pyc' \ 97 + --exclude '.ruff_cache' \ 98 + --exclude 'result' \ 99 + --exclude 'node_modules' \ 100 + ${appSrc}/ $out/app/ 101 + 102 + # Copy the pre-built virtualenv (always from the explicit venvPath 103 + # argument so CI can point it at the freshly-built venv even if it 104 + # lives inside the workspace / appSrc). 105 + cp -r ${venvPath} $out/app/.venv 106 + 107 + # Make all scripts executable (mirrors --chmod=0755 in Dockerfile) 108 + if [ -d "$out/app/scripts" ]; then 109 + chmod -R +x $out/app/scripts/ 110 + fi 111 + 112 + # Make top-level shell scripts executable (healthcheck.sh etc. are 113 + # copied to /app in the Dockerfile) 114 + for f in $out/app/*.sh; do 115 + [ -f "$f" ] && chmod +x "$f" 116 + done 117 + ''; 118 + 119 + # ------------------------------------------------------------------------- 120 + # Create /etc/passwd and /etc/group entries for the django user. 121 + # Mirrors: addgroup --system django && adduser --system --ingroup django django 122 + # ------------------------------------------------------------------------- 123 + passwdContents = '' 124 + root:x:0:0:root:/root:/bin/bash 125 + nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin 126 + django:x:1000:1000:django:/app:/bin/bash 127 + ''; 128 + 129 + groupContents = '' 130 + root:x:0: 131 + nogroup:x:65534: 132 + django:x:1000:django 133 + ''; 134 + 135 + etcDir = pkgs.runCommand "care-etc" { } '' 136 + mkdir -p $out/etc 137 + echo '${passwdContents}' > $out/etc/passwd 138 + echo '${groupContents}' > $out/etc/group 139 + mkdir -p $out/tmp 140 + chmod 1777 $out/tmp 141 + ''; 142 + 143 + in 144 + pkgs.dockerTools.buildLayeredImage { 145 + name = "care"; 146 + tag = appVersion; 147 + 148 + # Maximum number of layers. The default is 100 which is fine — nix will 149 + # automatically bin-pack store paths into layers. System libraries land in 150 + # shared / cacheable layers; the app + venv go into the top layers. 151 + maxLayers = 100; 152 + 153 + contents = runtimeDeps ++ [ 154 + appDir 155 + etcDir 156 + ]; 157 + 158 + # fakeRootCommands runs inside a fakeroot environment during image 159 + # construction — we use it to set ownership on /app to the django user 160 + # and create runtime directories. 161 + fakeRootCommands = '' 162 + # Ensure /tmp exists and is world-writable 163 + mkdir -p ./tmp 164 + chmod 1777 ./tmp 165 + 166 + # Create /tmp/container-role placeholder (used by healthcheck.sh) 167 + touch ./tmp/container-role 168 + chmod 666 ./tmp/container-role 169 + ''; 170 + 171 + # Enable fakeRootCommands 172 + enableFakechroot = true; 173 + 174 + config = { 175 + WorkingDir = "/app"; 176 + 177 + Env = [ 178 + "BUILD_ENVIRONMENT=production" 179 + "PYTHONUNBUFFERED=1" 180 + "PYTHONDONTWRITEBYTECODE=1" 181 + "PIPENV_VENV_IN_PROJECT=1" 182 + "APP_VERSION=${appVersion}" 183 + "HOME=/app" 184 + 185 + # PATH: venv first, then nix profile bins, then standard paths 186 + "PATH=/app/.venv/bin:/bin:/usr/bin:/sbin:/usr/sbin" 187 + 188 + # TLS certificates — required for any outbound HTTPS (boto3, requests, etc.) 189 + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" 190 + "NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" 191 + "CURL_CA_BUNDLE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" 192 + "REQUESTS_CA_BUNDLE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" 193 + 194 + # Fontconfig — needed by WeasyPrint / pango at runtime 195 + "FONTCONFIG_PATH=${pkgs.fontconfig.out}/etc/fonts" 196 + ]; 197 + 198 + ExposedPorts = { 199 + "9000/tcp" = { }; 200 + }; 201 + 202 + User = "django"; 203 + 204 + Healthcheck = { 205 + Test = [ "CMD" "/app/scripts/healthcheck.sh" ]; 206 + Interval = 30000000000; # 30s in nanoseconds 207 + Timeout = 5000000000; # 5s 208 + StartPeriod = 10000000000; # 10s 209 + Retries = 12; 210 + }; 211 + }; 212 + }