A platform for sharing and reproducing FABRIC research artifacts. Built with Django 6.0, Django REST Framework, and drf-spectacular (OpenAPI docs). Provides both a REST API and a Bootstrap 5 web UI.
DISCLAIMER: The code herein may not be up to date nor compliant with the most recent package and/or security notices. The frequency at which this code is reviewed and updated is based solely on the lifecycle of the project for which it was written to support, and is not actively maintained outside of that scope. Use at your own risk.
- Configuration
- Deploy
- Logging
- Web UI
- REST API
- Publishing releases from GitHub
- Backup and Restore
- References
cp env.template .env
# Edit .env with appropriate values
source .env
uv sync # creates .venv and installs dependenciesFor Docker deployments, also configure:
cp vouch/config.template vouch/config
# Choose a compose template:
# compose/docker-compose.yml.prod-ssl — production with Nginx SSL
# compose/docker-compose.yml.local-ssl — local development with Nginx SSL
cp compose/docker-compose.yml.prod-ssl docker-compose.ymlSee env.template for the full list of environment variables.
Everything that varies between deployments is read from .env, so the tracked files stay
generic and a checkout can be brought forward with git pull without losing local changes.
Do not hand-edit docker-compose.yml, nginx/default.conf.template or settings.py for a
single environment — set these instead:
| Variable | Purpose |
|---|---|
DJANGO_ALLOWED_HOSTS |
Comma-separated hosts this deployment answers on. Loopback is always allowed. |
DJANGO_CORS_ALLOWED_ORIGINS |
Comma-separated origins permitted to call the API cross-site. |
DJANGO_ADMIN_ENABLED |
Expose the Django admin at /admin to FABRIC users holding ARTIFACT_MANAGER_ADMINS_ROLE; default false (404 for everyone). |
HOST_ARTIFACT_STORAGE |
Host directory holding artifact bundles. |
HOST_ARTIFACT_BACKUPS |
Host directory holding database backups. |
HOST_DB_DATA |
Host directory backing the PostgreSQL data volume. |
HOST_METRICS_LOGS |
Host directory holding the metrics log — see Logging. |
NGINX_HTTP_PORT / NGINX_HTTPS_PORT |
Host ports published by the Nginx container. |
NGINX_SSL_CERTS_DIR |
Host directory mounted read-only at /etc/ssl. |
AMGR_SSL_CERT / AMGR_SSL_CERT_KEY |
Certificate filenames within that directory. |
AMGR_HTTPS_PORT |
Public HTTPS port used in redirects. |
AMGR_SERVER_NAMES |
Required. Space-separated names Nginx serves (as DJANGO_ALLOWED_HOSTS, IPv6 in brackets); any other or missing Host is closed with 444. |
AMGR_CLIENT_MAX_BODY_SIZE |
Largest artifact bundle accepted on upload. |
USE_X_ACCEL_REDIRECT |
Delegate artifact downloads to Nginx — see Artifact downloads. |
GITHUB_OIDC_AUDIENCE |
Public site URL expected in GitHub Actions tokens; empty disables Actions publishing. |
GITHUB_IMPORT_MAX_BYTES |
Maximum compressed source archive size; default 78643200 (75 MiB). |
GITHUB_IMPORTS_PER_DAY |
New GitHub versions allowed per artifact per UTC day; default 10. |
GITHUB_API_TOKEN |
Optional server-side GitHub API token; recommended to increase the rate limit. |
CRAWLER_USER_AGENT_MARKERS |
Comma-separated substrings matched case-insensitively against User-Agent; matching requests are served but not counted as views or downloads. curl, wget and python-requests count as real. |
nginx/default.conf.template is rendered at container start by the official Nginx image's
envsubst entrypoint. Only ${AMGR_*} placeholders are substituted, so Nginx's own runtime
variables ($host, $request_uri, ...) pass through untouched.
Django serves /robots.txt itself in every run mode, so no Nginx change is needed. It keeps
well-behaved crawlers off the API, the Django admin, the authentication paths and the create and
update forms while leaving the artifact listing and detail pages indexable. Crawlers that ignore it
are still served, but requests whose User-Agent matches CRAWLER_USER_AGENT_MARKERS do not add
to an artifact's view or download counts.
source .env
./run_server.sh --run-mode local-dev --make-migrations --load-fixturessource .env
UWSGI_UID=$(id -u) UWSGI_GID=$(id -g) ./run_server.sh --run-mode local-ssl --make-migrations --load-fixturesMAKE_MIGRATIONS=1 LOAD_FIXTURES=1 docker compose up -dBundles reach several hundred megabytes, so a download is never buffered in memory. Two
strategies, selected by USE_X_ACCEL_REDIRECT:
false(default) — Django streams the file withFileResponse. Correct in every run mode, and the only option for--run-mode local-dev. A uWSGI worker stays occupied for the length of the transfer.true— Django authorizes the request, records the download and returns headers only, naming an internal Nginx location through theX-Accel-Redirectheader. Nginx sends the bytes withsendfile(), so the worker is released immediately and byte-range and conditional requests are handled by Nginx. Requires the bundled Nginx withHOST_ARTIFACT_STORAGEmounted, which the supplied compose files do.
Permission checks are identical either way: the Nginx location is marked internal, so it
cannot be requested directly and every download still passes through
validate_contents_download().
Two loggers, configured by the LOGGING dictConfig in artifactmgr/server/settings.py:
consoleLogger— operational output to stdout, wheredocker compose logs djangopicks it up. Django's own records share this stream, sodjango.request4xx warnings and 5xx tracebacks are visible in production rather than swallowed.metricsLogger— the audit event stream. It writes to its own file and never propagates into stdout, so the event record stays clean enough to grep and parse.
All timestamps are UTC, in both streams, regardless of the host timezone.
Levels are set per logger in .env, each variable gating exactly one logger:
| Variable | Purpose |
|---|---|
DJANGO_LOG_LEVEL |
Django's own loggers. Ships as INFO; DEBUG is very chatty. |
ROOT_LOG_LEVEL |
Everything not covered by a more specific logger, third-party libraries included. |
DJANGO_DB_LOG_LEVEL |
Pinned separately so DJANGO_LOG_LEVEL=DEBUG cannot also switch on per-query SQL logging. |
CONSOLE_LOG_LEVEL |
First-party operational output — consoleLogger and the artifactmgr package logger. |
METRICS_LOG_LEVEL |
The metrics event stream. |
METRICS_LOG_FILE |
Path to the metrics log. A relative path resolves against the project root, /code under Docker. |
METRICS_LOG_MAX_BYTES |
0 (default) uses WatchedFileHandler with external rotation. See the warning below. |
METRICS_LOG_BACKUP_COUNT |
Backup generations kept when METRICS_LOG_MAX_BYTES > 0. |
METRICS_LOG_FILE defaults to ./logs/metrics/metrics.log, and under Docker the containing
directory is the bind mount named by HOST_METRICS_LOGS (default ./logs/metrics, which works
out of the box for local development; point it at durable storage in production).
The handler opens the file at process startup rather than at first write. That is deliberate —
a bad path fails loudly at boot instead of silently discarding every audit record — but it means
the directory must already exist and be writable by the uWSGI worker uid, or Django will not
start. Under Docker, docker-entrypoint.sh creates it and chowns it to
UWSGI_UID/UWSGI_GID while still running as root; the chown is a warning rather than a fatal
error, because it is a no-op on Docker Desktop's virtiofs and on NFS mounts. For a deployment
whose HOST_METRICS_LOGS lives outside the checkout, pre-create the host directory and chown it
to the same uid before the first docker compose up.
Rotation is external. Install the supplied logrotate fragment, editing the path and the
create uid/gid to match this deployment's .env:
sudo cp logrotate/artifact-manager-metrics /etc/logrotate.d/artifact-manager-metrics
sudo logrotate -d /etc/logrotate.d/artifact-manager-metrics # dry run, verify the pathsNo signal or restart is needed afterwards: WatchedFileHandler notices the inode change and
reopens the file on its own. The fragment deliberately does not use copytruncate, which would
keep the inode and lose every record written between the copy and the truncate.
Setting METRICS_LOG_MAX_BYTES above 0 switches to an in-process RotatingFileHandler
instead. That is local development only, and only for a single process: artifactmgr.ini
runs four uWSGI workers with no lazy-apps, so all four inherit one file description, all four
see the same offset, and they stampede the rollover — destroying backup generations and losing
exactly the records an audit is later asked to produce.
Container stdout is capped by the compose logging: block on the django service (10 MB per
file, 5 files) so that raising DJANGO_LOG_LEVEL to DEBUG cannot fill the host disk.
The web UI provides the following pages:
| Page | URL | Description |
|---|---|---|
| Artifacts | /artifacts/ |
Paginated list of all artifacts with search by title, tag, or project name |
| Artifacts by Author | /artifacts/authors/ |
Authors table with per-author artifact counts |
| Author Detail | /artifacts/authors/<uuid> |
Paginated artifacts by a specific author with search by title, tag, or project name |
| Artifacts by Project | /artifacts/projects/ |
Projects table with per-project artifact counts and search |
| Project Detail | /artifacts/projects/<uuid> |
Paginated artifacts for a specific FABRIC project with search by title, tag, or project name |
| Artifact Detail | /artifacts/<uuid> |
Artifact metadata, versions, and file management |
| Create Artifact | /artifacts/create/ |
Form to create a new artifact (authenticated users) |
| Update Artifact | /artifacts/<uuid>/update |
Form to edit an artifact (authors only) |
All list views enforce visibility-based authorization:
- Public artifacts are visible to everyone
- Project artifacts are visible to project members and authors
- Author artifacts are visible only to their authors
Interactive API documentation is available at:
/api/swagger/— Swagger UI/api/redoc/— ReDoc UI/api/schema/— OpenAPI 3 schema (JSON)
| Endpoint | Query Parameters | Description |
|---|---|---|
GET /api/artifacts |
search, page |
List all visible artifacts with search by title, tag, or project name |
POST /api/artifacts |
Create a new artifact | |
GET /api/artifacts/{uuid} |
Retrieve a specific artifact | |
PUT /api/artifacts/{uuid} |
Update a specific artifact | |
PATCH /api/artifacts/{uuid} |
Partially update a specific artifact | |
DELETE /api/artifacts/{uuid} |
Delete a specific artifact (permanent; removes all versions and stored files) | |
GET /api/artifacts/{uuid}/repo |
Show the linked repository, subject to artifact visibility and author redaction | |
PUT /api/artifacts/{uuid}/repo |
Author: link or re-confirm with JSON {"repository": "owner/repo"} |
|
DELETE /api/artifacts/{uuid}/repo |
Author: unlink; existing versions are retained | |
GET /api/artifacts/{uuid}/repo/releases |
Author: list published releases from up to 500 GitHub releases, marked when imported | |
POST /api/artifacts/{uuid}/repo/import |
Author: import with JSON {"tag": "v1.0"} and FABRIC authentication |
|
POST /api/artifacts/{uuid}/repo/publish |
GitHub Actions: import with JSON {"tag": "v1.0"} and an OIDC bearer token |
|
GET /api/artifacts/by-author/{uuid} |
search, page |
List artifacts by a specific author with search by title, tag, or project name |
GET /api/artifacts/by-project/{uuid} |
search, page |
List artifacts for a specific project with search by title, tag, or project name |
| Endpoint | Description |
|---|---|
GET /api/authors |
List all artifact authors |
GET /api/authors/{uuid} |
Retrieve a specific author |
GET /api/contents |
List artifact versions/content |
GET /api/contents/{uuid} |
Retrieve a specific version |
GET /api/contents/download/{urn} |
Download an artifact version by URN |
GET /api/meta/tags |
List all artifact tags |
List endpoints enforce visibility-based authorization. The GitHub release list returns one
bounded list; other list endpoints support pagination. Repository write actions accept JSON,
with Content-Type: application/json; the existing contents upload endpoint still accepts only
uploaded fabric bundles.
Version responses include storage_type and source. For a GitHub version, version is its tag
and source records the repository, tag, full commit, release URL/name/date, trigger and pre-release
flag; uploaded versions have source: null. When authors are hidden, non-authors receive
repository: null, source: null, and a date-based version label in both artifact and
/api/contents responses.
The site's own usage guide at /help/ covers this for artifact authors, including the GitHub
Actions setup; this section is the operator-side summary.
An artifact author can link a public GitHub repository from the artifact detail page. Enter
owner/repo or https://github.com/owner/repo, then choose Link repository. Anyone with write
access to that repository can then publish versions of this artifact through GitHub Actions.
Choose Show releases and tags and Import to copy a published release, or a git tag that has
no release, manually; the equivalent API call is POST /api/artifacts/{uuid}/repo/import with
{"tag": "v1.0"} and the author's FABRIC token. A tag without a published release is imported with
its commit, a tree/<tag> link and the commit date, but no release name or pre-release flag.
The source tarball is copied into FABRIC storage at the resolved commit. GitHub and uploaded versions can coexist, and downloads follow the artifact's visibility even after the repository is unlinked. Each GitHub version shows its tag, commit, release link and pre-release badge. Hiding authors also hides the repository and GitHub provenance from non-authors.
On the artifact page, expand Publish automatically with GitHub Actions and copy the workflow
into .github/workflows/publish-artifact.yml in the linked repository. The page fills in the site
URL and artifact UUID; for the example below, replace both values. The site administrator must
set GITHUB_OIDC_AUDIENCE to that exact public site URL to enable Actions publishing.
name: Publish to FABRIC Artifact Manager
# Runs for a published release and for a pushed tag. When one action does both, the
# second run finds the version already imported and succeeds without a second copy.
on:
release:
types: [published]
push:
tags: ['*']
permissions:
id-token: write
contents: read
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Publish ${{ github.event.release.tag_name || github.ref_name }}
env:
AMGR_URL: "https://artifacts.example.org"
ARTIFACT_UUID: "replace-with-artifact-uuid"
TAG: ${{ github.event.release.tag_name || github.ref_name }}
run: |
set -euo pipefail
OIDC_TOKEN=$(curl -sSf --get --data-urlencode "audience=$AMGR_URL" \
-H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL" | jq -r .value)
curl -sS --fail-with-body --retry 5 --retry-delay 15 --retry-all-errors -X POST \
"${AMGR_URL%/}/api/artifacts/$ARTIFACT_UUID/repo/publish" \
-H "Authorization: Bearer $OIDC_TOKEN" -H 'Content-Type: application/json' \
--data "$(jq -cn --arg tag "$TAG" '{tag: $tag}')"GitHub supplies a short-lived OIDC token for each run, so no stored Artifact Manager secret is needed. The audience request is URL-encoded; a trailing slash in the configured audience is preserved for token verification and removed only when constructing the publish endpoint URL.
The publish endpoint verifies an RS256 signature from https://token.actions.githubusercontent.com,
token expiry and the exact configured audience, and requires the linked public repository and
owner IDs to match the token. Imports are attributed to the author who confirmed the link and
require that person to remain an author; pull_request and pull_request_target events are
refused, while other events (including release and workflow_dispatch) are allowed.
If the confirming author is removed, the repository card shows Publishing paused until a current author chooses Re-confirm. A repository transfer also needs author re-confirmation. Unlink stops future publishing and preserves previously imported versions.
- Only public repositories on GitHub.com are supported. GitHub Enterprise OIDC issuers are unsupported. Published releases (including pre-releases) and git tags without a published release can be imported; a draft release is treated as a bare tag. Attached release assets are not imported.
- Source archives are limited to 75 MiB (
78643200bytes) by default, configurable throughGITHUB_IMPORT_MAX_BYTES. Downloads have a 50-second budget shared with metadata requests; stalled reads time out after at most 15 seconds. - The default cap is 10 new imports per artifact per UTC day (
GITHUB_IMPORTS_PER_DAY), shared by web, API and Actions publishing. Re-importing the same repository ID and tag returns the existing version (HTTP 200; a new version returns 201), even when the cap has been reached. - Listing reads at most five pages of 100 GitHub releases and five pages of 100 tags. Older tags can still be
imported by name through the API. Two imports can run concurrently across the site; busy,
rate-limited or timed-out requests can be retried.
GITHUB_API_TOKENis recommended for production to raise GitHub's API rate limit. - There is no
jtireplay tracking. A captured token remains valid for its few minutes, including the verification leeway, and can only import already-published tags of the linked repository.
The dumpdata.sh script creates a timestamped backup under ./backups/ containing:
- Django JSON fixtures for each app (
apiuser,artifacts) with--natural-foreign --natural-primaryfor portability - A copy of the artifact storage directory (uploaded
.tgzfiles) - A copy of the media directory (if present)
- A
manifest.jsonwith timestamp, app list, and Django version
A ./backups/latest symlink always points to the most recent backup. Fixtures are also copied to ./dumpdata/ for use with --load-fixtures.
Local:
source .env
uv run ./dumpdata.shPreview commands without writing anything:
source .env
uv run ./dumpdata.sh --dry-runDocker:
docker exec amgr-django /bin/bash -c "source .env; uv run ./dumpdata.sh"
docker cp amgr-django:/code/dumpdata/. dumpdata/Local:
cp dumpdata/apiuser.json artifactmgr/apps/apiuser/fixtures/
cp dumpdata/artifacts.json artifactmgr/apps/artifacts/fixtures/
# first run, make migrations, load fixtures
UWSGI_UID=$(id -u) UWSGI_GID=$(id -g) ./run_server.sh --run-mode local-ssl --load-fixtures --make-migrations
# subsequent runs, no need to load fixtures
UWSGI_UID=$(id -u) UWSGI_GID=$(id -g) ./run_server.sh --run-mode local-ssl --make-migrationsDocker:
cp dumpdata/apiuser.json artifactmgr/apps/apiuser/fixtures/
cp dumpdata/artifacts.json artifactmgr/apps/artifacts/fixtures/
docker compose build
MAKE_MIGRATIONS=1 LOAD_FIXTURES=1 docker compose up -d