Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ tests/
*.md
!requirements.txt
!requirements.yml
openapi.json
48 changes: 36 additions & 12 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,33 +1,57 @@
FROM python:3.12-slim
# ─── Stage 1: builder ─────────────────────────────────────────────────────────
FROM python:3.13-bookworm AS builder

# Install system deps for ansible and ssh
RUN apt-get update && apt-get install -y --no-install-recommends \
openssh-client \
git \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app
WORKDIR /build

# Install Python deps
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir -r requirements.txt

# Install Ansible collections
COPY requirements.yml .
RUN ansible-galaxy collection install -r requirements.yml -p /usr/share/ansible/collections
RUN /opt/venv/bin/ansible-galaxy collection install \
-r requirements.yml \
-p /usr/share/ansible/collections

# ─── Stage 2: runtime ─────────────────────────────────────────────────────────
FROM python:3.13-slim-bookworm AS runtime

# Match host UID/GID at build time so SSH key volume permissions align.
# Override with: docker build --build-arg UID=$(id -u) --build-arg GID=$(id -g)
ARG UID=1000
ARG GID=1000

RUN apt-get update && apt-get install -y --no-install-recommends \
openssh-client \
git \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -g "${GID}" range42 \
&& useradd -u "${UID}" -g "${GID}" -m -d /home/range42 --no-log-init range42

WORKDIR /app

COPY --from=builder /opt/venv /opt/venv
COPY --from=builder /usr/share/ansible/collections /usr/share/ansible/collections

# Copy application
COPY app/ app/
COPY playbooks/ playbooks/
COPY inventory/ inventory/

# Set env defaults
RUN chown -R range42:range42 /app

ENV PATH="/opt/venv/bin:$PATH"
ENV HOME=/home/range42
ENV PYTHONPATH=/app
ENV PROJECT_ROOT_DIR=/app
ENV API_BACKEND_WWWAPP_PLAYBOOKS_DIR=/app/
ENV API_BACKEND_INVENTORY_DIR=/app/inventory/
ENV HOST=0.0.0.0
ENV PORT=8000
ENV PYTHONPATH=/app
ENV ANSIBLE_COLLECTIONS_PATH=/usr/share/ansible/collections

USER range42

EXPOSE 8000

Expand Down
95 changes: 90 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ FastAPI application that orchestrates Proxmox infrastructure deployments by exec
## Table of Contents

- [Quick Start](#quick-start)
- [Docker: Build & Publish](#docker-build--publish)
- [Configuration](#configuration)
- [API Documentation](#api-documentation)
- [WebSocket API](#websocket-api)
Expand All @@ -23,17 +24,16 @@ FastAPI application that orchestrates Proxmox infrastructure deployments by exec
### Option 1 -- Docker

```bash
docker compose up
# Build and start (uses the built image — no source bind-mounts)
VAULT_PASSWORD=my-secret docker compose up
```

Builds the image, installs dependencies and Ansible collections, and starts the API on port `8000`.
Builds the image from the local source and starts the API on port `8000`. The application code, Ansible playbooks, and inventory are baked into the image at build time.

**Environment variables:** Configured via the host environment or a `.env` file. Required: at least one of `VAULT_PASSWORD_FILE` or `VAULT_PASSWORD` for vault-encrypted operations.

**Volumes:**
- `./app` -- Application source (read-only)
- `./playbooks` -- Ansible playbooks (read-only)
- `./inventory` -- Ansible inventory files (read-only)
- `${SSH_KEY_PATH:-~/.ssh}` -- SSH private keys (read-only) — needed for Ansible over SSH

**Health check:** The container pings `/docs/openapi.json` every 30s (5s timeout, 10s start period, 3 retries).

Expand Down Expand Up @@ -66,6 +66,91 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

---

## Docker: Build & Publish

The Dockerfile is a two-stage build (`builder` → `runtime`) based on **Debian Bookworm** (`python:3.13-bookworm` / `python:3.13-slim-bookworm`):

- **Stage 1 `builder`** — installs Python dependencies into `/opt/venv` and Ansible collections into `/usr/share/ansible/collections`.
- **Stage 2 `runtime`** — copies the virtualenv and collections from the builder; bakes in application code; runs uvicorn.

### Non-root user

The runtime image runs as a non-root user (`range42`, UID/GID 1000 by default). If your SSH keys are owned by a different UID, pass matching build args so the container user can read the mounted keys:

```bash
docker compose build # uses UID/GID 1000
# or match your host user:
UID=$(id -u) GID=$(id -g) docker compose build
```

SSH host key checking is enabled (`ANSIBLE_HOST_KEY_CHECKING=True`). Pre-populate `~/.ssh/known_hosts` on the host before mounting, or the first Ansible connection to an unknown host will fail.

### Build locally

```bash
docker compose build
# or directly:
docker build -t ghcr.io/range42/range42-backend-api:latest .
```

### Run locally (validate the image)

```bash
VAULT_PASSWORD=my-secret docker compose up
# API reachable at http://localhost:8000/docs/swagger
```

### Publish to GHCR

```bash
# 1. Authenticate (one-time per machine)
echo $GITHUB_TOKEN | docker login ghcr.io -u <github-username> --password-stdin

# 2. Build and tag
IMAGE=ghcr.io/range42/range42-backend-api
VERSION=v0.1 # or $(git describe --tags --always)

docker build -t ${IMAGE}:${VERSION} -t ${IMAGE}:latest .

# 3. Push
docker push ${IMAGE}:${VERSION}
docker push ${IMAGE}:latest
```

Or with Compose (sets `IMAGE_NAME` for the service):

```bash
IMAGE_NAME=ghcr.io/range42/range42-backend-api:v0.1 docker compose build
IMAGE_NAME=ghcr.io/range42/range42-backend-api:v0.1 docker compose push
```

### Vault password in production

`VAULT_PASSWORD` passed as an environment variable is visible via `docker inspect` and in `/proc/<pid>/environ` inside the container. For production use `VAULT_PASSWORD_FILE` pointed at a mounted secret file:

```bash
# Create a secret file (outside the repo)
echo "my-vault-password" > /run/secrets/vault_pass
chmod 600 /run/secrets/vault_pass

# Pass the file path, not the password itself
VAULT_PASSWORD_FILE=/run/secrets/vault_pass docker compose up
```

### OpenAPI spec

The committed `openapi.json` at the repository root reflects the current API surface. It is used to bootstrap the Kong API gateway configuration. To regenerate it after adding or modifying routes:

```bash
PYTHONPATH=. python -c "
import json
from app.main import create_app
print(json.dumps(create_app().openapi(), indent=2))
" > openapi.json
```

---

## Configuration

All settings are read from environment variables in `app/core/config.py`. Nothing is hard-coded.
Expand Down
2 changes: 1 addition & 1 deletion app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ class Settings:
cors_origin_regex: str = field(
default_factory=lambda: os.getenv(
"CORS_ORIGIN_REGEX",
r"^https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$",
r"^https?://(localhost|127\.0\.0\.1|\[::1\]|192\.168\.42\.\d{1,3})(:\d+)?$",
)
)

Expand Down
2 changes: 1 addition & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def create_app() -> FastAPI:
openapi_url="/docs/openapi.json",
version="v0.1",
license_info={"name": "GPLv3"},
contact={"email": "info@digisquad.com"},
contact={"email": "info@nc3.lu"},
middleware=middleware,
)

Expand Down
8 changes: 7 additions & 1 deletion app/routes/vms.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,13 @@ def _run_proxmox_action(req, action: str, extravars: dict) -> JSONResponse:

if req.as_json:
result = extract_action_results(events, action)
payload = {"rc": rc, "result": result}
payload: dict[str, Any] = {"rc": rc, "result": result}
# On failure, include error context from Ansible logs
if rc != 0:
lines = log_plain.splitlines()
fatal = next((l for l in lines if "fatal:" in l or "FAILED" in l), None)
payload["error"] = fatal.strip() if fatal else f"Ansible exited with rc={rc}"
payload["log_multiline"] = lines[-10:] # last 10 lines for context
else:
payload = {"rc": rc, "log_multiline": log_plain.splitlines()}

Expand Down
20 changes: 15 additions & 5 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
services:
api:
build: .
image: ${IMAGE_NAME:-ghcr.io/range42/range42-backend-api:latest}
build:
context: .
target: runtime
args:
UID: ${UID:-1000}
GID: ${GID:-1000}
ports:
- "${PORT:-8000}:8000"
volumes:
- ./app:/app/app:ro
- ./playbooks:/app/playbooks:ro
- ./inventory:/app/inventory:ro
- ${SSH_KEY_PATH:-~/.ssh}:/root/.ssh:ro
# SSH keys — must be readable by the container user (UID 1000 by default).
# Set SSH_KEY_PATH to override the source directory.
- ${SSH_KEY_PATH:-~/.ssh}:/home/range42/.ssh:ro
# Inventory contains per-deployment host credentials. Uncomment to
# override the baked-in inventory without rebuilding the image:
# - ${INVENTORY_PATH:-./inventory}:/app/inventory:ro
environment:
- PROJECT_ROOT_DIR=/app
- API_BACKEND_WWWAPP_PLAYBOOKS_DIR=/app/
- API_BACKEND_PUBLIC_PLAYBOOKS_DIR=${API_BACKEND_PUBLIC_PLAYBOOKS_DIR:-/app/}
- API_BACKEND_INVENTORY_DIR=/app/inventory/
- API_BACKEND_VAULT_FILE=${API_BACKEND_VAULT_FILE:-}
# Production: use VAULT_PASSWORD_FILE pointing to a mounted secret file.
# VAULT_PASSWORD is visible via `docker inspect` — dev/testing only.
- VAULT_PASSWORD_FILE=${VAULT_PASSWORD_FILE:-}
- VAULT_PASSWORD=${VAULT_PASSWORD:-}
- CORS_ORIGIN_REGEX=${CORS_ORIGIN_REGEX:-}
Expand Down
Loading