DevOps Server Onboarding
- Users are created and updated on the DevOps server, with SSH keys, from
resources/users.json. - Devpi (pypi mirror) users are created and updated from the same
resources/users.json. - Generic binary artifacts are downloaded from upstream and uploaded to the
<project_code>package registry. - Container images are built with Docker buildx bake and pushed to the
<project_code>container registry. - OpenAPI Python clients are generated from OpenAPI schemas and published to the
<project_code>PyPI registry. - GitHub repositories are mirrored as pull mirrors onto a dedicated organization, from
resources/public-mirros.json.
Prerequisites
- uv
- Docker with buildx (container builds)
- Java (OpenAPI generator)
CS_PROJECT_CODEenvironment variable holding the project code (in CI it is provided by theCS_PROJECT_CODEActions variable on the<project_code>organization)DEVOPS_SERVER_INVENTORY_HOSTNAMEenvironment variable holding the DevOps server host's inventory hostname, used to build every per-host Vault path. Every command fails immediately when it is missing or empty, before logging or Vault is set up- Vault access via environment variables:
VAULT_API_ENDPOINT,VAULT_API_KEY,VAULT_CA_CERT_BASE64,VAULT_CLIENT_CERT_BASE64,VAULT_CLIENT_KEY_BASE64(the*_BASE64variables hold base64-encoded PEM contents;devops_server_onboardingdecodes them to temporary files) - The
<project_code>organization must already exist on the DevOps server (importingdevops_server_onboardingchecks this and fails if it's missing)
Setup
uv sync
uv --offline run --no-sync --no-progress update-devops-server-org
devops-server-onboarding is an installable package (src/devops_server_onboarding/) whose commands are
exposed as console scripts ([project.scripts] in pyproject.toml). Importing the package
reads the project code from the CS_PROJECT_CODE environment variable, the DevOps server host from the
DEVOPS_SERVER_INVENTORY_HOSTNAME environment variable, and the VAULT_* environment variables, fetches the DevOps server
details from Vault (bucket <project_code>, key devops-server/hosts/<DEVOPS_SERVER_INVENTORY_HOSTNAME>/apps/scm/generated)
and the root CA (bucket <project_code>, key certificate-authority/root-ca), checks that the <project_code> organization already exists on the DevOps server, and exposes CS_PROJECT_CODE,
DEVOPS_SERVER_INVENTORY_HOSTNAME, DEVOPS_SERVER_LOCAL_URL, DEVOPS_SERVER_DOMAIN, DEVOPS_SERVER_PORT,
DEVOPS_SERVER_ADMIN_USER, DEVOPS_SERVER_ADMIN_API_TOKEN, ROOT_CA_CERT_FILE (a temporary file holding
the root CA), and VAULT_* values as package constants. A .env file in the working directory is loaded when present; no .env
file is written. Every command triggers this bootstrap as a side effect of its first import, so a
broken Vault connection or a missing organization surfaces immediately.
update-devops-server-org reads the resolved config plus the raw VAULT_* environment variables,
then pushes the Vault credentials as Actions secrets on the <project_code> organization
(VAULT_API_ENDPOINT, VAULT_API_KEY, VAULT_CA_CERT_BASE64, VAULT_CLIENT_CERT_BASE64,
VAULT_CLIENT_KEY_BASE64), so Actions workflows running in that organization can reach Vault.
It also sets the project code as the CS_PROJECT_CODE Actions variable and the DevOps server host's inventory
hostname as the DEVOPS_SERVER_INVENTORY_HOSTNAME Actions variable (neither is a secret) on the
<project_code> organization: each variable is created if absent, updated if its stored value differs, and left
untouched if it already matches. Workflows read them as ${{ vars.CS_PROJECT_CODE }} and
${{ vars.DEVOPS_SERVER_INVENTORY_HOSTNAME }}.
Publish artifacts
All commands are run from the repo root.
The users, devpi-users, generic-artifacts, containers, openapi-client, and github-public-mirror
commands process each declared item independently. A failing item (a user, artifact, build target, package,
or mirror) logs a warning and the run continues with the next one. At the end the command logs a table with
one row per item and its status (SUCCESS, SKIPPED, or FAILED). If any item failed, the command exits
with a non-zero code after the table.
Each JSON declaration under resources/ is validated against a Pydantic model when the command starts, so an
unknown or misspelled field, a missing required field (such as a user's email), or an invalid visibility
stops the command before anything is changed on a server.
Users
uv --offline run --no-sync --no-progress users
Users are declared in resources/users.json as "<username>": {settings}. A user
missing on the server is created with a freshly generated random password (a password is mandatory at
creation time). Every field present in the JSON entry is then compared against the user's current value on
the server, and an edit call is only made, and printed, for fields that actually differ; fields absent from
the JSON entry are left untouched. Any declared SSH key not already on the account is added.
{
"admin": {
"email": "admin@localhost",
"full_name": "Admin",
"admin": true,
"active": true,
"allow_create_organization": false,
"allow_git_hook": false,
"allow_import_local": false,
"prohibit_login": false,
"visibility": "private",
"ssh_keys": []
}
}
Only email is required; every other key is optional. send_notification (default false) controls whether
Gitea emails the user on creation.
Devpi (pypi mirror) users
uv --offline run --no-sync --no-progress devpi-users
Reads the same resources/users.json declarations as users, but only the email
field applies; every Gitea-specific field (admin, full_name, ssh_keys, visibility, ...) is ignored.
A user missing on the mirror is created with a freshly generated random password (a password is mandatory
at creation time, and devpi has no way to reset one without knowing the old one). An existing user's email
is patched only if it differs from the declared value. Implemented by calling devpi-client's own Python
API (devpi.user.user_create/user_modify, driven through a devpi.main.Hub) directly, never the devpi
CLI executable.
Beyond the package-level Vault bootstrap, this command makes its own additional read of
{project_code}/devops-server/hosts/{DEVOPS_SERVER_INVENTORY_HOSTNAME}/apps/pypi-mirror/generated for endpoint
and root_password (shared with devops-server-deployer, whose pypi_mirror_* tags provision the
mirror and write that secret on every run), then logs into the mirror as root at that endpoint.
Generic binary artifacts
uv --offline run --no-sync --no-progress generic-artifacts
Artifacts are declared in resources/generic-artifacts.json as
"<package>/<version>/<filename>": {"uri": ..., "checksum": "<algorithm>:<hexdigest>"}. Downloads are cached in
generic_artifacts/ (override with DEVOPS_SERVER_BINARY_ARTIFACTS_DIRECTORY), checksum-verified, and uploaded to the
<project_code> organization. Already published files are skipped.
Container images
uv --offline run --no-sync --no-progress containers
Installs the internal root CA for the Docker daemon (restarting docker through systemctl when it isn't running
in CI), logs into the registry, registers the qemu binfmt handlers for cross-platform builds, and creates a buildx
builder trusting the root CA if one doesn't exist yet. It then builds and pushes every target in the default group of resources/containers/docker-bake.hcl
(Dockerfiles under resources/containers/dockerfiles/) to the <project_code> organization. Image versions are
pinned in the bake file, most of them as *_VERSION variables. Every image is pushed under its pinned version tag
and one floating alias tag: latest for most images, trixie for the redis, node, rust, python, httpd,
postgres, and uv images, and latest-jre or latest-jdk for the two eclipse-temurin targets, which share
one image name. Requires docker with buildx on PATH and password-less sudo for the root CA installation.
Set CONTAINERS_NO_CACHE=1 (also true or yes) to pass --no-cache to every bake target; it defaults to false.
The Onboard workflow exposes it as the no_cache input.
OpenAPI Python clients
uv --offline run --no-sync --no-progress openapi-client
Schemas live in resources/openapi-schemas/ named <package>-<version>.json; the file name is the source of
truth for the package name and version. For each schema not yet published, a Python client is generated with
openapi-generator-cli (jar downloaded on demand) and published with Poetry to the <project_code>
organization. Already published versions are skipped.
GitHub public mirrors
uv --offline run --no-sync --no-progress github-public-mirror
Mirrors are declared in resources/public-mirros.json:
{
"github": {
"organization": "<organization>",
"mirrors": {
"<repo in Gitea>": {
"source": "<github owner>/<github repository>",
"visibility": "public"
}
}
}
}
Only source is required; visibility ("public" or "private") is optional per mirror.
Before writing anything, the declared GitHub API token (managed-services/github Vault secret,
key GH_PROD_API_TOKEN) is validated against https://api.github.com/user, and every declared
source repo is checked to exist and be readable with that token; the command stops before touching
Gitea if any source repo is missing or inaccessible. The visibility applied to each mirror is its
declared visibility when present, else the source repo's actual current visibility on GitHub.
The declared organization is created (public) on the DevOps server if it doesn't exist yet, else
made public if it isn't already, regardless of any individual mirror's visibility. A mirror repo
missing on the server is created as a pull mirror of its source with an 8-hour sync interval and
the resolved visibility above. An existing mirror repo has its Clone From URL checked against the
declared source first; since Gitea's API has no way to repoint an existing mirror's remote, a
mismatch is fixed by deleting and recreating the repo against the declared source. A matching
mirror instead has its sync interval, source token, and resolved visibility re-applied on every
run, so a rotated GitHub token, a visibility change in the JSON, or a visibility change on the
source repo propagates automatically. Actions (CI/CD) are always disabled on every mirror repo.
Registry package cleanup
cleanup-artifacts deletes packages from the <project_code> package registry. It connects to Vault for
credentials and the server's REST API to list and remove packages. It is interactive and requires a TTY: it
prompts with an arrow-key select for the package type to delete, then asks for an optional comma-separated
list of package names to restrict deletion to (leave empty to remove every package of the selected type).
Passing the package type as an argument (container, generic, pypi or terraform) skips the type prompt
and needs no TTY for it; any other value fails with a usage error.
uv --offline run --no-sync --no-progress cleanup-artifacts
With the package type given, the type prompt is skipped:
uv --offline run --no-sync --no-progress cleanup-artifacts container
uv --offline run --no-sync --no-progress cleanup-artifacts generic
uv --offline run --no-sync --no-progress cleanup-artifacts pypi
uv --offline run --no-sync --no-progress cleanup-artifacts terraform
--filter <name> restricts deletion to the named packages and skips the names prompt. Repeat it for several
names. With both the type and --filter given, the command runs without any prompt:
uv --offline run --no-sync --no-progress cleanup-artifacts pypi --filter my-client
uv --offline run --no-sync --no-progress cleanup-artifacts container --filter app-api --filter app-worker
--all removes every package of the type without asking for names, so <type> --all also runs without any
prompt. It cannot be combined with --filter:
uv --offline run --no-sync --no-progress cleanup-artifacts generic --all
CI
- .gitea/workflows/onboard.yml: manually triggered (
workflow_dispatch) deployment pipeline with parallel jobs, one per command: users, devpi users, generic binaries, container images, OpenAPI Python clients, and GitHub public mirrors. Each job authenticates to Vault via theVAULT_*Actions secrets and reads theCS_PROJECT_CODEandDEVOPS_SERVER_INVENTORY_HOSTNAMEorganization Actions variables. - .gitea/workflows/lint.yml: runs the Python linters and type checkers, yamllint, and prettier.
- .gitea/workflows/gitleaks.yml: secret scanning on push, pull request, manual
trigger (
workflow_dispatch), and a daily schedule.
Development
See DEVELOPMENT.md for git hooks setup and the lint commands.