Skip to content
Merged
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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@ jobs:
- name: Test
run: make test

images:
name: build + verify role images
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

# Reading a Dockerfile cannot tell you an image builds, that its entrypoint
# serves, or that sqlalchemy is genuinely absent from the engine rather than
# merely unmentioned. tests/test_deploy_invariants.py does the cheap textual
# half in the unit suite; this job does the half that needs a Docker daemon,
# so the ADR 0002 boundary is re-proved against the artefact on every PR (#81).
- name: Build both images and assert their deployment invariants
run: make images-verify

secret-scan:
name: secret scan (gitleaks)
runs-on: ubuntu-latest
Expand Down
17 changes: 15 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ COMPOSE ?= docker compose -f deploy/compose/docker-compose.yml --env-file .env
# Engine replica count for `make scale`.
N ?= 2

.PHONY: help sync hooks lint format type test check up down destroy migrate seed logs ps scale init-env secrets
.PHONY: help sync hooks lint format type test check images images-verify up down destroy \
migrate seed logs ps scale init-env secrets

help: ## List the available targets
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
Expand Down Expand Up @@ -37,6 +38,18 @@ test: ## pytest across all workspace members

check: lint type test ## Everything CI runs

# ─── Images ───────────────────────────────────────────────────────────────────

images: ## Build both role images
docker build -f deploy/docker/api.Dockerfile -t icebergsst/api:verify .
docker build -f deploy/docker/engine.Dockerfile -t icebergsst/engine:verify .

# Properties of the built artefact rather than of the Dockerfile text: both
# entrypoints serve, both run non-root, and no database package is importable in
# the engine image (ADR 0002). CI runs this on every PR (#81).
images-verify: ## Build both images and assert their deployment invariants
./deploy/docker/verify-images.sh

# ─── Local stack ──────────────────────────────────────────────────────────────
# `up` waits for every healthcheck before migrating, so what it hands back is a
# stack that is ready rather than one that is merely started.
Expand All @@ -52,7 +65,7 @@ destroy: ## Stop the stack and delete its data volume
$(COMPOSE) down --volumes

migrate: ## Apply migrations (api role only — it owns the schema)
$(COMPOSE) run --rm api alembic -c apps/api/alembic.ini upgrade head
$(COMPOSE) run --rm api python -m iceberg_api migrate

seed: ## Load development fixtures (refuses to run in prod)
$(COMPOSE) run --rm api python -m iceberg_api.seed
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ The console is then at <http://localhost:8000/> and the OpenAPI docs at `/docs`.
OIDC configured in `.env`; the first person to sign in lands as a viewer unless
`ICEBERG_BOOTSTRAP_ADMIN_SUBJECT` names them.

`make check` runs what CI runs: `ruff`, `mypy`, and `pytest`. `make help` lists every target.
`make check` runs what CI runs: `ruff`, `mypy`, and `pytest`. `make images-verify` builds both role
images and asserts the properties they must have — both entrypoints serve, both run non-root, and
no database package is importable in the engine image (ADR 0002). `make help` lists every target.

## Repository layout

Expand Down
9 changes: 8 additions & 1 deletion apps/api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@ dependencies = [
"alembic>=1.18.5",
# Cron expression parsing for the schedule model and scheduler tick.
"croniter>=6.0.0",
# iceberg_api.dispatch enqueues task ids on the broker (ADR 0009). Declared
# here because the api image installs this package alone: leaning on the
# workspace having iceberg-engine installed builds an image that imports
# dramatiq and does not have it.
"dramatiq[redis]>=2.2.0",
"fastapi>=0.139.2",
"iceberg-core",
# [db] pulls in the ORM: the api role is the only one that owns the schema,
# so it is the only one that declares a dependency on it (ADR 0002).
"iceberg-core[db]",
# The M3 web UI's template engine. Imported directly by iceberg_api.web —
# declared rather than leaned on transitively through fastapi's extras.
"jinja2>=3.1.6",
Expand Down
21 changes: 17 additions & 4 deletions apps/api/alembic.ini → apps/api/src/iceberg_api/alembic.ini
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
# Alembic configuration for the api role — the only role that owns the schema
# (docs/deployment.md § Migrations). Run from the repo root:
# (docs/deployment.md § Migrations). Normally reached through the operator CLI,
# which finds this file inside the installed package:
#
# uv run alembic -c apps/api/alembic.ini upgrade head
# uv run python -m iceberg_api migrate # dev
# python -m iceberg_api migrate # in the api container
#
# It lives *inside* the package rather than beside pyproject.toml so that it
# travels in the wheel with the migrations it points at. The runtime image
# installs the package and copies no source tree (deploy/docker/api.Dockerfile),
# so a config file outside src/ would simply not be there. %(here)s resolves
# against this file, which makes the path correct in a source checkout and in
# site-packages alike.
#
# Authoring a revision still goes through alembic directly, since that reads the
# source tree by nature:
#
# uv run alembic -c apps/api/src/iceberg_api/alembic.ini revision --autogenerate -m "add x"
#
# The database URL is not set here: env.py takes it from ICEBERG_DATABASE_URL via
# ApiSettings, so migrations and the running API can never disagree about which
# database they mean (and a URL with a password stays out of version control).

[alembic]
script_location = %(here)s/src/iceberg_api/migrations
prepend_sys_path = .
script_location = %(here)s/migrations
path_separator = os
timezone = UTC

Expand Down
34 changes: 34 additions & 0 deletions apps/api/src/iceberg_api/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
import uuid
from collections.abc import Sequence
from datetime import UTC, datetime
from pathlib import Path

import structlog
from alembic import command
from alembic.config import Config
from iceberg_core.config import get_api_settings
from iceberg_core.db import session_scope
from iceberg_core.logging import configure_logging
Expand Down Expand Up @@ -55,9 +58,37 @@ def _build_parser() -> argparse.ArgumentParser:

commands.add_parser("reclaim", help="return expired-lease tasks to the queue")
commands.add_parser("scheduler-tick", help="run one scheduler round now")

migrate_parser = commands.add_parser("migrate", help="apply migrations up to a revision")
migrate_parser.add_argument(
"--revision",
default="head",
help="target revision (default: head; accepts e.g. -1 to step back)",
)
return parser


#: The packaged alembic config, resolved against this module rather than the
#: working directory. The api image installs the package and copies no source
#: tree, so a repo-relative path would be correct in a checkout and wrong in
#: every container.
ALEMBIC_INI = Path(__file__).resolve().parent / "alembic.ini"


def alembic_config() -> Config:
"""The alembic config as every role should reach it.

One loader for the compose `make migrate`, the Helm pre-upgrade Job, and the
migration tests, so none of them can drift onto a different script location.
"""
return Config(str(ALEMBIC_INI))


def migrate(revision: str = "head") -> None:
"""Apply migrations. The api role owns the schema; nothing else runs this."""
command.upgrade(alembic_config(), revision)


def mint_engine_token(name: str, version: str | None) -> tuple[uuid.UUID, str]:
"""Register or rotate ``name``; return its id and new token.

Expand Down Expand Up @@ -127,6 +158,9 @@ def main(argv: Sequence[str] | None = None) -> int:
f"skipped={len(result.skipped)}",
file=sys.stderr,
)
case "migrate":
migrate(args.revision)
print(f"migrated to {args.revision}", file=sys.stderr)
return 0


Expand Down
8 changes: 5 additions & 3 deletions apps/api/tests/test_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,22 @@
import pytest
from alembic import command
from alembic.config import Config
from iceberg_api.cli import alembic_config
from iceberg_core.enums import ScanStatus, ScanTrigger, SourceType
from iceberg_core.models import Scan, Source, metadata
from sqlalchemy import Engine, create_engine, inspect
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select

ALEMBIC_INI = Path(__file__).resolve().parents[1] / "alembic.ini"


@pytest.fixture(name="migrated_engine")
def migrated_engine_fixture(tmp_path: Path) -> Iterator[tuple[Engine, Config]]:
"""A file-backed SQLite database with migrations applied to head."""
url = f"sqlite:///{tmp_path / 'migrations.db'}"
config = Config(str(ALEMBIC_INI))
# Deliberately the same loader the operator CLI and the Helm Job use: these
# tests are only evidence about production migrations if they run the same
# config, from the same place.
config = alembic_config()
config.attributes["sqlalchemy.url"] = url
# The suite configures its own logging; let Alembic keep its hands off it.
config.attributes["skip_logging_config"] = True
Expand Down
16 changes: 13 additions & 3 deletions deploy/docker/api.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never

# The builder works at the same path the runtime stage will use. A venv is not
# relocatable: uv writes `#!/app/.venv/bin/python` into every console script, so
# building at /src and copying to /app leaves `uvicorn` with a shebang pointing at
# a directory that does not exist — "exec: no such file or directory" at start.
WORKDIR /app

# Dependencies first, from the lock file and manifests alone: editing application
Expand All @@ -27,11 +31,15 @@ COPY apps/engine/pyproject.toml apps/engine/
COPY packages/core/pyproject.toml packages/core/
COPY packages/detect/pyproject.toml packages/detect/
COPY packages/connectors/pyproject.toml packages/connectors/
RUN uv sync --locked --no-dev --no-install-workspace --package iceberg-api
RUN uv sync --locked --no-dev --no-editable --no-install-workspace --package iceberg-api

COPY apps/ apps/
COPY packages/ packages/
RUN uv sync --locked --no-dev --package iceberg-api
# --no-editable installs the workspace packages into the venv proper rather than
# linking back at /src, so the venv is self-contained and the runtime stage can
# take it alone. Templates, static assets, migrations and alembic.ini all live
# under src/iceberg_api/ and travel in the wheel with it.
RUN uv sync --locked --no-dev --no-editable --package iceberg-api


FROM python:3.14-slim AS runtime
Expand All @@ -44,7 +52,9 @@ ENV PYTHONUNBUFFERED=1 \
RUN useradd --create-home --uid 10001 iceberg

WORKDIR /app
COPY --from=builder --chown=iceberg:iceberg /app /app
# The venv and nothing else: no source tree, no uv, no build dependencies. What
# is not in the image cannot be imported by accident or read by an attacker.
COPY --from=builder --chown=iceberg:iceberg /app/.venv /app/.venv

USER iceberg
EXPOSE 8000
Expand Down
14 changes: 10 additions & 4 deletions deploy/docker/engine.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,24 @@ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never

# Same path as the runtime stage — a venv is not relocatable. See api.Dockerfile.
WORKDIR /app

# Dependency layer first — see api.Dockerfile for why. --package iceberg-engine
# means the api's dependencies (and its Postgres driver) are not installed here.
# means the api's dependencies are not installed here: no Postgres driver, no
# alembic, and no ORM, because iceberg-core carries sqlmodel in its `db` extra
# and only apps/api asks for it.
COPY pyproject.toml uv.lock ./
COPY apps/api/pyproject.toml apps/api/
COPY apps/engine/pyproject.toml apps/engine/
COPY packages/core/pyproject.toml packages/core/
COPY packages/detect/pyproject.toml packages/detect/
COPY packages/connectors/pyproject.toml packages/connectors/
RUN uv sync --locked --no-dev --no-install-workspace --package iceberg-engine
RUN uv sync --locked --no-dev --no-editable --no-install-workspace --package iceberg-engine

COPY apps/ apps/
COPY packages/ packages/
RUN uv sync --locked --no-dev --package iceberg-engine
RUN uv sync --locked --no-dev --no-editable --package iceberg-engine


FROM python:3.14-slim AS runtime
Expand All @@ -44,7 +47,10 @@ ENV PYTHONUNBUFFERED=1 \
RUN useradd --create-home --uid 10001 iceberg

WORKDIR /app
COPY --from=builder --chown=iceberg:iceberg /app /app
# The venv alone — see api.Dockerfile. Here it also carries the ADR 0002
# boundary: deploy/docker/verify-images.sh proves no database package is
# importable in this image, which is only true because nothing copies one in.
COPY --from=builder --chown=iceberg:iceberg /app/.venv /app/.venv

USER iceberg
EXPOSE 9191
Expand Down
Loading
Loading