diff --git a/.forgejo/workflows/image.yaml b/.forgejo/workflows/image.yaml new file mode 100644 index 0000000..05caa42 --- /dev/null +++ b/.forgejo/workflows/image.yaml @@ -0,0 +1,54 @@ +name: Build and Publish Container Image + +on: + push: + branches: + - main + paths: + - ".forgejo/workflows/image.yaml" + - "Containerfile" + - "alembic.ini" + - "migrations/**" + - "src/**" + - "pyproject.toml" + - "uv.lock" + - "README.md" + workflow_dispatch: + +env: + REGISTRY: forgejo.coulomb.social + IMAGE_NAME: coulomb/sbom-nexus + DOCKER_HOST: tcp://127.0.0.1:2375 + +jobs: + build-and-push: + runs-on: container-build + steps: + - name: Build and push image + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -eu + REF="${GITHUB_SHA:-main}" + SHORT="${REF:0:7}" + mkdir -p buildctx "${HOME}/bin" + wget -qO /tmp/repo.tar.gz \ + "https://forgejo.coulomb.social/${GITHUB_REPOSITORY}/archive/${SHORT}.tar.gz" + tar xzf /tmp/repo.tar.gz -C buildctx --strip-components=1 + wget -qO- https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \ + | tar xz --strip-components=1 -C "${HOME}/bin" docker/docker + export PATH="${HOME}/bin:${PATH}" + echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin + IMAGE="${REGISTRY}/${IMAGE_NAME}" + docker build -f buildctx/Containerfile -t "${IMAGE}:latest" -t "${IMAGE}:main-${SHORT}" buildctx + docker push "${IMAGE}:latest" + docker push "${IMAGE}:main-${SHORT}" + + - name: Report immutable digest + run: | + set -eu + export PATH="${HOME}/bin:${PATH}" + IMAGE="${REGISTRY}/${IMAGE_NAME}" + SHORT="${GITHUB_SHA:0:7}" + docker inspect --format='{{index .RepoDigests 0}}' "${IMAGE}:main-${SHORT}" diff --git a/Containerfile b/Containerfile index e973ec1..4a1d9d5 100644 --- a/Containerfile +++ b/Containerfile @@ -1,8 +1,9 @@ FROM python:3.12-slim WORKDIR /app -COPY pyproject.toml uv.lock README.md /app/ +COPY pyproject.toml uv.lock README.md alembic.ini /app/ COPY src /app/src +COPY migrations /app/migrations RUN pip install --no-cache-dir . diff --git a/README.md b/README.md index ae1ec0c..5518bc2 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,9 @@ uv run sbom-nexus serve --reload The default API listens on `http://127.0.0.1:8010`. Local development uses SQLite through `SBOM_NEXUS_DATABASE_PATH`; production uses -`SBOM_NEXUS_DATABASE_URL=postgresql+psycopg://...` and `make migrate`. +`SBOM_NEXUS_DATABASE_URL_FILE=/var/run/secrets/.../url` and `make migrate`. +The direct `SBOM_NEXUS_DATABASE_URL` variable remains available for disposable +development environments; mounted secret files are preferred for production. ## Initial API surface diff --git a/docs/operator-guide.md b/docs/operator-guide.md index a4cff89..eb4275f 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -18,6 +18,11 @@ make migrate make run ``` +Production deployments should mount a Secret and set +`SBOM_NEXUS_DATABASE_URL_FILE` to its `url` file rather than exposing the DSN +in a manifest or command argument. The same file setting is consumed by both +Alembic and the API process. + PostgreSQL never auto-creates tables unless `SBOM_NEXUS_AUTO_CREATE=1` is set explicitly. Normal production operation must use Alembic. diff --git a/migrations/env.py b/migrations/env.py index 1cfc6f5..a35b63a 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -1,11 +1,11 @@ from __future__ import annotations -import os from logging.config import fileConfig from alembic import context from sqlalchemy import engine_from_config, pool +from sbom_nexus.config import database_target from sbom_nexus.database import database_url, metadata config = context.config @@ -15,14 +15,11 @@ if config.config_file_name is not None: target_metadata = metadata -configured_target = os.getenv("SBOM_NEXUS_DATABASE_URL") or os.getenv( - "SBOM_NEXUS_DATABASE_PATH" +configured_target = database_target(config.get_main_option("sqlalchemy.url")) +config.set_main_option( + "sqlalchemy.url", + database_url(configured_target).replace("%", "%%"), ) -if configured_target: - config.set_main_option( - "sqlalchemy.url", - database_url(configured_target).replace("%", "%%"), - ) def run_migrations_offline() -> None: diff --git a/src/sbom_nexus/api.py b/src/sbom_nexus/api.py index 2da9251..2d0c206 100644 --- a/src/sbom_nexus/api.py +++ b/src/sbom_nexus/api.py @@ -9,12 +9,10 @@ from typing import Any, Literal from fastapi import FastAPI, HTTPException, Query, Request from pydantic import BaseModel, Field +from sbom_nexus.config import database_target from sbom_nexus.scanner import VALID_ECOSYSTEMS, scan_repository from sbom_nexus.storage import Store -DEFAULT_DATABASE_TARGET = os.getenv("SBOM_NEXUS_DATABASE_URL") or os.getenv( - "SBOM_NEXUS_DATABASE_PATH", "sbom-nexus.db" -) DEFAULT_STALE_DAYS = int(os.getenv("SBOM_NEXUS_STALE_DAYS", "30")) @@ -89,7 +87,9 @@ def create_app(database_path: str | Path | None = None) -> FastAPI: version="0.1.0", description="SBOM capture, history, evaluation, and bounded catch-up service", ) - application.state.store = Store(database_path or DEFAULT_DATABASE_TARGET) + application.state.store = Store( + database_path if database_path is not None else database_target("sbom-nexus.db") + ) if _auto_create(application.state.store): application.state.store.init_schema() diff --git a/src/sbom_nexus/config.py b/src/sbom_nexus/config.py new file mode 100644 index 0000000..e1e84b4 --- /dev/null +++ b/src/sbom_nexus/config.py @@ -0,0 +1,35 @@ +"""Runtime configuration helpers that keep secret values out of manifests.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def database_target(default: str | Path | None = None) -> str | Path: + """Return the configured database target, preferring a mounted secret file.""" + url_file = os.getenv("SBOM_NEXUS_DATABASE_URL_FILE") + if url_file: + path = Path(url_file) + try: + value = path.read_text(encoding="utf-8").strip() + except OSError as exc: + raise RuntimeError(f"Unable to read SBOM_NEXUS_DATABASE_URL_FILE: {path}") from exc + if not value: + raise RuntimeError(f"SBOM_NEXUS_DATABASE_URL_FILE is empty: {path}") + return value + + url = os.getenv("SBOM_NEXUS_DATABASE_URL") + if url: + return url + + path = os.getenv("SBOM_NEXUS_DATABASE_PATH") + if path: + return path + + if default is None: + raise RuntimeError( + "Configure SBOM_NEXUS_DATABASE_URL_FILE, SBOM_NEXUS_DATABASE_URL, " + "or SBOM_NEXUS_DATABASE_PATH" + ) + return default diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..e926ca3 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from sbom_nexus.config import database_target + + +def test_database_target_prefers_secret_file(monkeypatch, tmp_path: Path) -> None: + secret = tmp_path / "url" + secret.write_text("postgresql://mounted-secret\n", encoding="utf-8") + monkeypatch.setenv("SBOM_NEXUS_DATABASE_URL_FILE", str(secret)) + monkeypatch.setenv("SBOM_NEXUS_DATABASE_URL", "postgresql://environment") + + assert database_target() == "postgresql://mounted-secret" + + +def test_database_target_rejects_empty_secret_file(monkeypatch, tmp_path: Path) -> None: + secret = tmp_path / "url" + secret.write_text("\n", encoding="utf-8") + monkeypatch.setenv("SBOM_NEXUS_DATABASE_URL_FILE", str(secret)) + + with pytest.raises(RuntimeError, match="is empty"): + database_target() diff --git a/workplans/SBOM-WP-0002-production-cutover.md b/workplans/SBOM-WP-0002-production-cutover.md index 7ecb088..bd09e72 100644 --- a/workplans/SBOM-WP-0002-production-cutover.md +++ b/workplans/SBOM-WP-0002-production-cutover.md @@ -35,7 +35,7 @@ bounded daily catch-up before retiring State Hub SBOM ownership. ```task id: SBOM-WP-0002-T01 -status: todo +status: progress priority: high state_hub_task_id: "95a520d4-30c2-5c87-8054-6bfe549c2686" ```