feat: package dark deployment runtime
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 54s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
This commit is contained in:
tegwick 2026-08-22 13:59:24 +02:00
parent e408651545
commit 0941a2e5f4
9 changed files with 134 additions and 15 deletions

View file

@ -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}"

View file

@ -1,8 +1,9 @@
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app 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 src /app/src
COPY migrations /app/migrations
RUN pip install --no-cache-dir . RUN pip install --no-cache-dir .

View file

@ -19,7 +19,9 @@ uv run sbom-nexus serve --reload
The default API listens on `http://127.0.0.1:8010`. Local development uses The default API listens on `http://127.0.0.1:8010`. Local development uses
SQLite through `SBOM_NEXUS_DATABASE_PATH`; production 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 ## Initial API surface

View file

@ -18,6 +18,11 @@ make migrate
make run 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 PostgreSQL never auto-creates tables unless `SBOM_NEXUS_AUTO_CREATE=1` is set
explicitly. Normal production operation must use Alembic. explicitly. Normal production operation must use Alembic.

View file

@ -1,11 +1,11 @@
from __future__ import annotations from __future__ import annotations
import os
from logging.config import fileConfig from logging.config import fileConfig
from alembic import context from alembic import context
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
from sbom_nexus.config import database_target
from sbom_nexus.database import database_url, metadata from sbom_nexus.database import database_url, metadata
config = context.config config = context.config
@ -15,14 +15,11 @@ if config.config_file_name is not None:
target_metadata = metadata target_metadata = metadata
configured_target = os.getenv("SBOM_NEXUS_DATABASE_URL") or os.getenv( configured_target = database_target(config.get_main_option("sqlalchemy.url"))
"SBOM_NEXUS_DATABASE_PATH" 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: def run_migrations_offline() -> None:

View file

@ -9,12 +9,10 @@ from typing import Any, Literal
from fastapi import FastAPI, HTTPException, Query, Request from fastapi import FastAPI, HTTPException, Query, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sbom_nexus.config import database_target
from sbom_nexus.scanner import VALID_ECOSYSTEMS, scan_repository from sbom_nexus.scanner import VALID_ECOSYSTEMS, scan_repository
from sbom_nexus.storage import Store 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")) 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", version="0.1.0",
description="SBOM capture, history, evaluation, and bounded catch-up service", 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): if _auto_create(application.state.store):
application.state.store.init_schema() application.state.store.init_schema()

35
src/sbom_nexus/config.py Normal file
View file

@ -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

25
tests/test_config.py Normal file
View file

@ -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()

View file

@ -35,7 +35,7 @@ bounded daily catch-up before retiring State Hub SBOM ownership.
```task ```task
id: SBOM-WP-0002-T01 id: SBOM-WP-0002-T01
status: todo status: progress
priority: high priority: high
state_hub_task_id: "95a520d4-30c2-5c87-8054-6bfe549c2686" state_hub_task_id: "95a520d4-30c2-5c87-8054-6bfe549c2686"
``` ```