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

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

View file

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

View file

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

View file

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

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
id: SBOM-WP-0002-T01
status: todo
status: progress
priority: high
state_hub_task_id: "95a520d4-30c2-5c87-8054-6bfe549c2686"
```