From 2e6b099ff09015438ea5102d481acf35b6a7bd27 Mon Sep 17 00:00:00 2001 From: Daniil Anfimov Date: Mon, 26 Oct 2026 00:54:23 +0200 Subject: [PATCH] Add Pulp deployment role, playbook and inventory Port the pulp role from pulp-deploy (Docker Compose or Podman/Quadlet, custom image with vendored pulpcore patches, OpenTelemetry collector) with playbooks/pulp.yml and an inventories/pulp inventory for the dev, staging and prod environments. The original prod DB-fields encryption key and token keypair are committed vault-encrypted; plaintext keys, secrets/, local prod inventories and the vault password file are gitignored. --- .gitignore | 20 +- README.md | 16 + inventories/pulp/group_vars/all.yml | 26 + inventories/pulp/group_vars/dev.yml | 23 + inventories/pulp/group_vars/prod.yml | 217 ++++++ inventories/pulp/group_vars/staging.yml | 81 +++ inventories/pulp/hosts.yml | 29 + playbooks/pulp.yml | 15 + roles/pulp/defaults/main.yml | 500 ++++++++++++++ roles/pulp/files/README.md | 78 +++ .../files/database_fields.symmetric.key.vault | 7 + roles/pulp/files/datarepair.py | 201 ++++++ roles/pulp/files/export.py | 619 ++++++++++++++++++ roles/pulp/files/nginx.sh | 28 + roles/pulp/files/passwd | 20 + roles/pulp/files/token_private_key.pem.vault | 17 + roles/pulp/files/token_public_key.pem.vault | 14 + roles/pulp/handlers/main.yml | 122 ++++ roles/pulp/tasks/build_image.yml | 62 ++ roles/pulp/tasks/deploy.yml | 197 ++++++ roles/pulp/tasks/install_engine.yml | 192 ++++++ roles/pulp/tasks/journald.yml | 17 + roles/pulp/tasks/main.yml | 133 ++++ roles/pulp/tasks/quadlet.yml | 204 ++++++ roles/pulp/tasks/tls.yml | 59 ++ roles/pulp/templates/Containerfile.j2 | 35 + roles/pulp/templates/compose.yml.j2 | 330 ++++++++++ roles/pulp/templates/journald-pulp.conf.j2 | 10 + roles/pulp/templates/nginx.conf.template.j2 | 128 ++++ .../templates/otel-collector-config.yaml.j2 | 77 +++ .../templates/podman-restart-mounts.conf.j2 | 7 + .../quadlet/otel-collector.container.j2 | 56 ++ .../templates/quadlet/pulp-api.container.j2 | 50 ++ .../quadlet/pulp-content.container.j2 | 46 ++ .../quadlet/pulp-init-password.container.j2 | 36 + .../quadlet/pulp-migration.container.j2 | 48 ++ .../templates/quadlet/pulp-pg-data.volume.j2 | 15 + .../quadlet/pulp-postgres.container.j2 | 57 ++ .../quadlet/pulp-redis-data.volume.j2 | 11 + .../templates/quadlet/pulp-redis.container.j2 | 40 ++ .../quadlet/pulp-signing.container.j2 | 31 + .../templates/quadlet/pulp-storage.volume.j2 | 12 + .../quadlet/pulp-worker.container.j2 | 46 ++ roles/pulp/templates/quadlet/pulp.network.j2 | 14 + roles/pulp/templates/settings.py.j2 | 64 ++ roles/pulp/vars/main.yml | 103 +++ 46 files changed, 4112 insertions(+), 1 deletion(-) create mode 100644 inventories/pulp/group_vars/all.yml create mode 100644 inventories/pulp/group_vars/dev.yml create mode 100644 inventories/pulp/group_vars/prod.yml create mode 100644 inventories/pulp/group_vars/staging.yml create mode 100644 inventories/pulp/hosts.yml create mode 100644 playbooks/pulp.yml create mode 100644 roles/pulp/defaults/main.yml create mode 100644 roles/pulp/files/README.md create mode 100644 roles/pulp/files/database_fields.symmetric.key.vault create mode 100644 roles/pulp/files/datarepair.py create mode 100644 roles/pulp/files/export.py create mode 100644 roles/pulp/files/nginx.sh create mode 100644 roles/pulp/files/passwd create mode 100644 roles/pulp/files/token_private_key.pem.vault create mode 100644 roles/pulp/files/token_public_key.pem.vault create mode 100644 roles/pulp/handlers/main.yml create mode 100644 roles/pulp/tasks/build_image.yml create mode 100644 roles/pulp/tasks/deploy.yml create mode 100644 roles/pulp/tasks/install_engine.yml create mode 100644 roles/pulp/tasks/journald.yml create mode 100644 roles/pulp/tasks/main.yml create mode 100644 roles/pulp/tasks/quadlet.yml create mode 100644 roles/pulp/tasks/tls.yml create mode 100644 roles/pulp/templates/Containerfile.j2 create mode 100644 roles/pulp/templates/compose.yml.j2 create mode 100644 roles/pulp/templates/journald-pulp.conf.j2 create mode 100644 roles/pulp/templates/nginx.conf.template.j2 create mode 100644 roles/pulp/templates/otel-collector-config.yaml.j2 create mode 100644 roles/pulp/templates/podman-restart-mounts.conf.j2 create mode 100644 roles/pulp/templates/quadlet/otel-collector.container.j2 create mode 100644 roles/pulp/templates/quadlet/pulp-api.container.j2 create mode 100644 roles/pulp/templates/quadlet/pulp-content.container.j2 create mode 100644 roles/pulp/templates/quadlet/pulp-init-password.container.j2 create mode 100644 roles/pulp/templates/quadlet/pulp-migration.container.j2 create mode 100644 roles/pulp/templates/quadlet/pulp-pg-data.volume.j2 create mode 100644 roles/pulp/templates/quadlet/pulp-postgres.container.j2 create mode 100644 roles/pulp/templates/quadlet/pulp-redis-data.volume.j2 create mode 100644 roles/pulp/templates/quadlet/pulp-redis.container.j2 create mode 100644 roles/pulp/templates/quadlet/pulp-signing.container.j2 create mode 100644 roles/pulp/templates/quadlet/pulp-storage.volume.j2 create mode 100644 roles/pulp/templates/quadlet/pulp-worker.container.j2 create mode 100644 roles/pulp/templates/quadlet/pulp.network.j2 create mode 100644 roles/pulp/templates/settings.py.j2 create mode 100644 roles/pulp/vars/main.yml diff --git a/.gitignore b/.gitignore index 3fe20bd..61af98d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,22 @@ vars.yml roles/ezamriy.fail2ban roles/geerlingguy.docker roles/geerlingguy.redis -.vscode/* \ No newline at end of file +.vscode/* + +# Secrets — never commit +.vault_pass +.vault-password +*vault-pass* +secrets/ +prod-secrets.yml +sign_vars.yml +inventories/pulp/prod_hosts.* +roles/pulp/files/*.key +roles/pulp/files/*.pem +roles/pulp/files/*.crt + +# Caches +.mypy_cache/ +__pycache__/ +*.py[cod] +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 11782f4..9490ba9 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,22 @@ ansible-playbook -i inventories/alts --ask-vault-pass playbooks/alts_scheduler.y ansible-playbook -i inventories/alts --ask-vault-pass playbooks/alts_celery.yml ``` +## Pulp (standalone deployment) +Pulp is deployed by `playbooks/pulp.yml` (role `roles/pulp`) using the +`inventories/pulp` inventory, which has `dev`, `staging` and `prod` groups. +Put your hosts into `hosts.yml` and adjust `group_vars/*.yml`; all tunables +are documented in `roles/pulp/defaults/main.yml`. + +Secrets are never committed. Provide `pulp_postgres_password` and +`pulp_secret_key` (and `pulp_admin_password` where used) via +`ansible-vault encrypt_string` or a separate vault file passed with `-e @...`. +For an existing deployment, drop the original `database_fields.symmetric.key` +and `token_{public,private}_key.pem` into `roles/pulp/files/` (gitignored). +```bash +ansible-playbook -i inventories/pulp playbooks/pulp.yml --limit dev +ansible-playbook -i inventories/pulp playbooks/pulp.yml --limit prod --ask-vault-pass +``` + ## Contributing to albs-deploy Any question? Found a bug? File an [issue](https://github.com/AlmaLinux/build-system/issues). Do you want to contribute with source code? diff --git a/inventories/pulp/group_vars/all.yml b/inventories/pulp/group_vars/all.yml new file mode 100644 index 0000000..f9a2ffb --- /dev/null +++ b/inventories/pulp/group_vars/all.yml @@ -0,0 +1,26 @@ +--- +# ───────────────────────────────────────────────────────────────────────── +# Settings shared by ALL environments. Environment-specific values (Postgres +# location, HTTPS, secrets) live in group_vars/dev.yml and group_vars/prod.yml. +# Full list of knobs: roles/pulp/defaults/main.yml +# ───────────────────────────────────────────────────────────────────────── + +# Where the compose project and its mounted assets live on the target host. +pulp_project_dir: /opt/pulp + +# Horizontal scaling (the whole point of the multi-container topology). +pulp_api_replicas: 2 +pulp_content_replicas: 2 +pulp_worker_replicas: 2 + +# ── Plugins ─────────────────────────────────────────────────────────────── +# pulp/pulp-minimal bundles a set of plugins that varies by image tag (see +# pulp_image_tag in roles/pulp/defaults/main.yml — currently 3.114.1). The 3.49.x +# line bundled: pulp-rpm, pulp-deb, pulp-container, pulp-python, pulp-file, +# pulp-ansible, pulp-maven, pulp-ostree, pulp-certguard; later tags add more +# (pulp-gem/npm/hugging-face among them). Confirm against the exact tag you +# deploy rather than trusting this list: +# docker run --rm pulp/pulp-minimal:3.114.1 pip list | grep pulp +# List ONLY plugins NOT in that set here; a non-empty list triggers a custom +# image build (FROM pulp/pulp-minimal) that pip-installs them. +pulp_extra_plugins: [] diff --git a/inventories/pulp/group_vars/dev.yml b/inventories/pulp/group_vars/dev.yml new file mode 100644 index 0000000..ff28798 --- /dev/null +++ b/inventories/pulp/group_vars/dev.yml @@ -0,0 +1,23 @@ +--- +# ── Dev environment ──────────────────────────────────────────────────────── +# PostgreSQL runs in a Docker container alongside the rest of the stack. +pulp_postgres_external: false +pulp_postgres_password: "dev-postgres" + +# Plain HTTP is fine for local dev. +pulp_https: false +pulp_http_port: 8080 + +# Dev secrets (override or vault as you see fit). +pulp_admin_password: "password" +pulp_secret_key: "dev-only-not-secret-change-for-prod" + +# ── OpenTelemetry ────────────────────────────────────────────────────────── +# Local collector with the debug exporter: metrics/traces land in the +# collector's log and go nowhere else. Read them with +# docker compose -f /opt/pulp/compose.yml logs -f otel-collector +# OTLP ports published on the host so you can push test data in from outside. +pulp_otel_enabled: true +pulp_otel_collector_enabled: true +pulp_otel_collector_expose_ports: true +pulp_otel_collector_debug_verbosity: detailed diff --git a/inventories/pulp/group_vars/prod.yml b/inventories/pulp/group_vars/prod.yml new file mode 100644 index 0000000..277d661 --- /dev/null +++ b/inventories/pulp/group_vars/prod.yml @@ -0,0 +1,217 @@ +--- +# ── Prod environment ─────────────────────────────────────────────────────── +# Container engine: prod runs on Podman (daemonless, rootful via the play's +# sudo). dev/staging stay on Docker (see roles/pulp/defaults/main.yml). +pulp_container_engine: podman + +# Orchestrator: one systemd unit per container via Quadlet instead of +# podman-compose. Prod already satisfies the prerequisites — pulp_web_enabled is +# false (external nginx) and pulp_postgres_external is true (RDS) — so no +# pulp_web/postgres units are needed, and api/content are single-instance behind +# published host ports, which keeps nginx service-name DNS out of the picture. +# +# What this replaces: podman-restart.service + its RequiresMountsFor drop-in +# (each unit now carries WantedBy=multi-user.target and its own +# RequiresMountsFor=/data/pulp_root), and the whole-stack force-recreate on any +# config change (now a targeted restart of api/content/workers). +# +# Set back to "compose" to fall back — the compose rendering is still maintained. +# NOTE: switching is a cutover, not a rolling change (container names and the +# network change). Stop the compose stack before the first Quadlet run: +# cd /opt/pulp && podman-compose -f compose.yml down +# Data is safe across it: the content store is the /data/pulp_root bind mount and +# the database is RDS. +pulp_orchestrator: quadlet + +# ── Pulp version ───────────────────────────────────────────────────────────── +# Pin the runtime image for prod. WARNING: raising this above the currently +# deployed version makes migration_service run `pulpcore-manager migrate` +# against the RDS on the next bring-up — a real, FORWARD-ONLY schema upgrade +# (you cannot roll back to an older image afterward). Snapshot RDS first, and +# expect the migration to take a while on a large database. +pulp_image_tag: "3.114.1" + +# Apply the vendored pulpcore source patches (roles/pulp/files/*.py, listed in +# pulp_source_patches): export.py fixes package file timestamps on export and +# lets streamed file:// content be exported without a downloaded Artifact; +# datarepair.py makes the datarepair-7272/7465 repair tasks stream content ids +# one version at a time instead of OOMing on large repositories. This forces a +# custom-image build (localhost/pulp-custom) layered on pulp-minimal. The set is +# pinned to pulpcore pulp_source_patches_pulpcore_version (3.114.1) and the build +# fails if that drifts from pulp_image_tag above — so when you raise the tag, +# re-derive every patched file from the new upstream and bump the version var +# (see roles/pulp/files/README.md). +pulp_source_patches_enabled: true + +# Use an external PostgreSQL on AWS RDS — the bundled postgres container is NOT +# deployed. Point the host at your RDS endpoint and provide the credentials. +pulp_postgres_external: true +# TODO(prod): replace with the real RDS endpoint. +pulp_postgres_host: "pulp-prod.xxxxxxxx.eu-central-1.rds.amazonaws.com" +pulp_postgres_port: 5432 +pulp_postgres_db: pulp +pulp_postgres_user: pulp +# sslmode left undefined -> omitted from settings.py; libpq defaults to "prefer" +# (still negotiates TLS with RDS, just not client-enforced). Uncomment to pin it: +# pulp_postgres_sslmode: require # or "verify-full" + CA for strict checking +# pulp_postgres_password: ... # SET VIA ANSIBLE-VAULT (see below) + +# TODO(prod): replace pulp.example.com everywhere below with the real public FQDN. +# Public hostname clients use to reach Pulp. +pulp_content_origin: "https://pulp.example.com" +# Public URLs Pulp advertises for its own API/content (pulp_ansible_* clients, +# pulp_container token server). Default to compose-internal service names; prod +# fronts them behind the public FQDN. +pulp_ansible_api_hostname: "https://pulp.example.com" +pulp_ansible_content_hostname: "https://pulp.example.com/pulp/content" +pulp_token_server: "https://pulp.example.com/token/" + +# ── Reverse proxy / TLS ────────────────────────────────────────────────────── +# TLS and routing are handled by the EXTERNAL nginx (separate host) that proxies +# to the published api/content ports below — so the bundled pulp_web is disabled +# and Pulp speaks plain HTTP internally. CONTENT_ORIGIN (pulp_content_origin +# above) must be the public https URL so Pulp emits correct links even though it +# sees http. The external nginx must route /pulp/content/ -> content port and +# everything else -> api port (matching its `pulp` / `pulp-content` upstreams). +pulp_web_enabled: false +pulp_https: false +pulp_api_host_port: 8081 # external nginx upstream "pulp" (0.0.0.0:8081) +pulp_content_host_port: 24816 # external nginx upstream "pulp-content" (0.0.0.0:24816) + +# One gunicorn instance per role (mirrors the old systemd pulpcore-api/-content +# units); scale via gunicorn workers, not replicas (a published host port can't +# fan out). Task workers have no port, so they scale by replica count. +pulp_api_replicas: 1 +pulp_content_replicas: 1 +pulp_api_workers: 10 # old: pulpcore-api --workers 10 +pulp_content_workers: 20 # old: pulpcore-content --workers 20 +pulp_gunicorn_timeout: 300 # old: --timeout 300 +pulp_worker_replicas: 16 # old: pulpcore-worker@1..16 + +# ── Logging ────────────────────────────────────────────────────────────────── +# Services log to the host journal (default pulp_log_driver=journald). Manage +# journald retention here so rotation is codified with the deployment. Budget is +# GLOBAL to the journal (all services + replicas share it) — tune for the host's +# root/journal filesystem, which is separate from the EFS content mount at /data. +pulp_journald_configure: true +pulp_journald_system_max_use: "4G" +pulp_journald_max_retention_sec: "1month" + +# ── Metrics (OpenTelemetry) ────────────────────────────────────────────────── +# Pulp's processes expose no HTTP endpoint, so the collector is the only thing +# that can be scraped. It runs as otel-collector.container on the pulp network +# and serves Prometheus on :8889 (collector self-metrics on :8888): +# scrape_configs: +# - job_name: pulp-prod +# static_configs: +# - targets: [':8889'] +# +# On 3.114.x this yields api.request_duration (ms histogram) from pulp-api's +# DjangoMetricsMiddleware. artifacts.size.counter additionally needs +# DOMAIN_ENABLED, which this role does not set, so pulp-content and the workers +# emit nothing today — their OTel env is inert but kept for uniformity. +# +# SECURITY: :8889 and :8888 are published on 0.0.0.0 and UNAUTHENTICATED. +# Restrict both to the Prometheus host in the security group. To avoid publishing +# them, use pulp_otel_collector_metrics_endpoint to push over OTLP instead — +# which needs Prometheus's OTLP receiver enabled. +# +# WARNING: turning this on re-renders the api/content/worker units, so the +# handler restarts api, content and all 16 workers, and re-runs pulp-migration +# (a no-op on an unchanged image). api/content are single-instance behind the +# external nginx, so this is a real interruption — do it in a window. +pulp_otel_enabled: true +pulp_otel_collector_enabled: true + +# ── Data ─────────────────────────────────────────────────────────────────── +# Content storage. The whole EFS tree /data/pulp_root (an NFS mount on the host) +# is bind-mounted into the containers at the SAME path, /data/pulp_root, and the +# Pulp paths below are repointed under it: the real artifact store stays at +# /data/pulp_root/pulp (its media/ + tmp/ subdirs) and PulpExport writes to the +# sibling /data/pulp_root/exports. +# +# Why one shared-parent mount instead of the intuitive two (store -> /var/lib/pulp +# + exports -> /data/pulp_root): PulpExport hardlinks (os.link) each artifact from +# MEDIA_ROOT into the export dir. A hard link cannot cross filesystems. With two +# separate bind mounts of the EFS export, the NFS client sees TWO mounts (separate +# superblocks) inside the container — even though the host sees one filesystem — +# and every export dies with "[Errno 18] Invalid cross-device link". Worse, both +# mounts report the SAME st_dev, so the export.py patch's same-filesystem guard is +# fooled and never falls back to copy. Mounting the shared parent ONCE puts +# MEDIA_ROOT and the export dir under a single mount/superblock, so hardlinks work. +# Postgres data is managed by RDS, so only the content store mounts. +pulp_storage_dir: /data/pulp_root +pulp_storage_mount: /data/pulp_root # mount the store at its own host path +pulp_deploy_root_host: /data/pulp_root/pulp # host path of DEPLOY_ROOT (subdir base) + +# Pulp filesystem layout, repointed into the shared mount. MEDIA_ROOT stays the +# physical artifact store (paths are stored relative to it, so the existing data +# resolves unchanged); the rest live alongside it under /data/pulp_root/pulp. +pulp_deploy_root: /data/pulp_root/pulp +pulp_media_root: /data/pulp_root/pulp/media +pulp_working_directory: /data/pulp_root/pulp/tmp +pulp_file_upload_temp_dir: /data/pulp_root/pulp/tmp +pulp_static_root: /data/pulp_root/pulp/assets + +# The existing data is owned by uid 1001 (host pulp:pulp-exports), while the +# image's pulp user is uid 700. Run the services as the data owner so they have +# native rwx without chowning the 49 TB tree, and align the role-managed +# assets/certs ownership (settings, DB-encryption + token keys) to the same uid +# so this user can still read them. The data tree itself is left untouched. +pulp_run_user: "1001:1002" +pulp_storage_owner: "1001" +pulp_storage_group: "1002" + +# PulpExport/PulpImport. No SEPARATE exports bind mount (pulp_exports_dir empty): +# the export dirs live under the single /data/pulp_root store mount above, which +# is exactly what keeps them on the same filesystem as MEDIA_ROOT for hardlinks. +# The whitelisted paths still have to be declared explicitly. +pulp_exports_dir: "" +pulp_export_path: /data/pulp_root/exports +pulp_allowed_export_paths: + - /data/pulp_root/exports + - /data/pulp_root/test-exports +pulp_allowed_import_paths: + - /data/pulp_root/exports + - /data/pulp_root/test-exports + +# SELinux is Permissive on the prod hosts, so the ":z"/":Z" relabel flags are +# dead work — disable them on ALL bind mounts via the global toggle. This also +# covers the EFS content store at /data: NFS can't store per-file SELinux labels +# anyway, so the relabel would fail (EOPNOTSUPP) and recursively walk the whole +# 49 TB tree on every start. If the hosts are ever switched to Enforcing, set +# this back true and label the EFS mount itself instead, e.g. mount /data with +# context="system_u:object_r:container_file_t:s0". +pulp_selinux_relabel: false + +# ── Existing deployment ────────────────────────────────────────────────────── +# Prod migrates an existing Pulp (RDS data + an admin user already present), so +# do NOT run set_init_password_service — it would reset the current admin +# password. (Consequently pulp_admin_password is unused and not required below.) +pulp_set_init_password: false + +# Reuse the ORIGINAL DB-field encryption key from the existing deployment. A +# freshly generated key would make already-encrypted RDS fields unreadable, so +# generation is disabled and the key is copied in from the control node. +# The original key is committed vault-encrypted (the copy module decrypts it +# on the fly); the plaintext name is gitignored. +pulp_generate_db_encryption_key: false +pulp_db_encryption_key_src: files/database_fields.symmetric.key.vault + +# Bring the existing pulp_container token-auth ES256 keypair over too (settings +# references these paths). Token auth is disabled in prod, so they're not +# strictly required, but copying the originals matches the current deployment. +# Committed vault-encrypted, like the DB key above. Leave both empty if you +# don't have/need them. +pulp_token_public_key_src: files/token_public_key.pem.vault +pulp_token_private_key_src: files/token_private_key.pem.vault + +# ── Secrets ──────────────────────────────────────────────────────────────── +# DO NOT commit these in plaintext. Encrypt each value inline and paste here: +# ansible-vault encrypt_string '' --name pulp_postgres_password +# ansible-vault encrypt_string "$(openssl rand -base64 50)" --name pulp_secret_key +# pulp_postgres_password: !vault | ... +# pulp_secret_key: !vault | ... +# (pulp_admin_password not needed — pulp_set_init_password is false.) +# Then run with --ask-vault-pass (or --vault-password-file). +# (Alternatively keep a separate encrypted file and pass it with -e @secrets/prod.yml.) diff --git a/inventories/pulp/group_vars/staging.yml b/inventories/pulp/group_vars/staging.yml new file mode 100644 index 0000000..ffb5186 --- /dev/null +++ b/inventories/pulp/group_vars/staging.yml @@ -0,0 +1,81 @@ +--- +# ── Staging environment ────────────────────────────────────────────────────── +# Self-contained staging: PostgreSQL runs in a Docker container (like dev), so +# no external RDS is needed. To mirror prod with an external DB instead, set +# pulp_postgres_external: true and provide the connection details + password. +pulp_postgres_external: false +# Creds MUST match albs-deploy (one_vm inventory) so the ALBS stack it deployed +# can talk to this Pulp: Pulp's postgres superuser is postgres/password, db pulp; +# Pulp admin is admin/password (albs-deploy's pulp_password). Keep in sync with +# the ALBS side (albs-web-server's vars.env). +pulp_postgres_user: postgres +pulp_postgres_password: password +pulp_postgres_db: pulp +pulp_admin_password: password + +# Origin ALBS uses to reach Pulp content (in-network, via the shared pulp net). +# Use the host "pulp" (pulp_web's hostname), NOT "pulp_web": Django rejects the +# underscore in the Host header (RFC 1034/1035) with DisallowedHost. +pulp_content_origin: "http://pulp" + +# ── HTTPS ────────────────────────────────────────────────────────────────── +# TLS disabled on staging — plain HTTP only. +pulp_https: false +pulp_http_port: 80 +# pulp_web listens on 80 internally too, so the whole ALBS stack can reach Pulp +# at plain "http://pulp" (port 80) — the all-in-one convention its configs use. +pulp_web_http_listen: 80 +# pulp_https_port: 443 +# pulp_tls_cert_src: files/pulp_webserver.crt # PEM cert (+ intermediate chain) +# pulp_tls_key_src: files/pulp_webserver.key # PEM private key + +# ── Data ─────────────────────────────────────────────────────────────────── +# Content + export stores bind-mounted on the host. Postgres runs in a +# container; its data sits in a Docker named volume unless you also set +# pulp_postgres_data_dir to a host path. +pulp_storage_dir: /srv/pulp_storage +pulp_exports_dir: /srv/pulp_exports # enable PulpExport/Import +# pulp_postgres_data_dir: /srv/pulp_pgdata + +# ── Secrets ──────────────────────────────────────────────────────────────── +# DO NOT commit these in plaintext. Encrypt each value inline and paste here: +# ansible-vault encrypt_string '' --name pulp_postgres_password +# ansible-vault encrypt_string '' --name pulp_admin_password +# ansible-vault encrypt_string "$(openssl rand -base64 50)" --name pulp_secret_key +# pulp_postgres_password: !vault | ... +# pulp_admin_password: !vault | ... +# pulp_secret_key: !vault | ... +# Then run with --ask-vault-pass (or --vault-password-file). +# (Alternatively keep a separate encrypted file and pass it with -e @secrets/staging.yml.) + +# ── Orchestrator: Quadlet ─────────────────────────────────────────────────── +# One systemd unit per container instead of podman-compose. Podman only, and no +# pulp_web unit is rendered — front api (8081) and content (24816) with your own +# nginx. PostgreSQL still runs in a container (pulp_postgres_external is false +# above), as pulp-postgres.container + the pg_datadata volume. +# api/content are single-instance because their host ports are fixed; scale them +# with pulp_api_workers / pulp_content_workers instead. Workers scale by replica +# count as usual. +pulp_container_engine: podman +pulp_orchestrator: quadlet +pulp_web_enabled: false +pulp_api_replicas: 1 +pulp_content_replicas: 1 + +# ── OpenTelemetry ────────────────────────────────────────────────────────── +# Collector runs as otel-collector.container on the pulp network; Pulp reaches +# it at the pulp_otel_exporter_endpoint default (http://otel-collector:4318). +# +# Metrics are SCRAPED off the collector (pulp_otel_collector_prometheus_exporter +# is on by default), so point Prometheus at :8889 — no OTLP +# receiver needs enabling on the Prometheus side: +# scrape_configs: +# - job_name: pulp-staging +# static_configs: +# - targets: [':8889'] +# Collector self-metrics are on :8888 for debugging the pipeline. +# +# pulp_otel_collector_metrics_endpoint additionally PUSHES to an OTLP endpoint; +# not needed when scraping. +pulp_otel_enabled: true +pulp_otel_collector_enabled: true diff --git a/inventories/pulp/hosts.yml b/inventories/pulp/hosts.yml new file mode 100644 index 0000000..38cd79b --- /dev/null +++ b/inventories/pulp/hosts.yml @@ -0,0 +1,29 @@ +--- +# Three environments. Run one at a time with --limit, e.g.: +# ansible-playbook -i inventories/pulp playbooks/pulp.yml --limit dev +# ansible-playbook -i inventories/pulp playbooks/pulp.yml --limit staging --ask-vault-pass +# ansible-playbook -i inventories/pulp playbooks/pulp.yml --limit prod --ask-vault-pass +# +# Replace the placeholder hosts below with your own (SSH user needs +# passwordless sudo, or run with --ask-become-pass). +# +# All environments share the `pulp` role/play. Group is pulp_all, not pulp: a +# host named `pulp` would make a pattern matching both resolve to the HOST, +# silently reducing any --limit to that one machine. +all: + children: + pulp_all: + children: + # Dev — PostgreSQL runs in a container (see group_vars/dev.yml). + dev: + hosts: + localhost: + ansible_connection: local + # Staging — self-contained, containerized PostgreSQL (see group_vars/staging.yml). + staging: + hosts: + pulp-staging01: + # Prod — external PostgreSQL on AWS RDS (see group_vars/prod.yml). + prod: + hosts: + pulp-prod01: diff --git a/playbooks/pulp.yml b/playbooks/pulp.yml new file mode 100644 index 0000000..b46d73f --- /dev/null +++ b/playbooks/pulp.yml @@ -0,0 +1,15 @@ +--- +# Deploy Pulp via Docker Compose or Podman (pulp_container_engine), following +# the upstream pulp-oci-images quickstart (scalable multi-container topology), +# with optional custom-image builds to add plugins beyond the bundled set. +# +# Usage: +# ansible-playbook -i inventories/pulp playbooks/pulp.yml --limit dev +# ansible-playbook -i inventories/pulp playbooks/pulp.yml --limit prod --ask-vault-pass +# +- name: Deploy Pulp + hosts: pulp_all + gather_facts: true + become: true + roles: + - role: pulp diff --git a/roles/pulp/defaults/main.yml b/roles/pulp/defaults/main.yml new file mode 100644 index 0000000..54bee60 --- /dev/null +++ b/roles/pulp/defaults/main.yml @@ -0,0 +1,500 @@ +--- +# ── Layout ────────────────────────────────────────────────────────────── +pulp_project_dir: /opt/pulp +pulp_assets_dir: "{{ pulp_project_dir }}/assets" + +# ── Container engine ────────────────────────────────────────────────────── +# Which engine runs the stack: "docker" or "podman". docker uses the daemon + +# "docker compose"; podman is daemonless and uses "podman-compose". Podman runs +# rootful here (the play escalates via sudo), matching the old root Docker +# daemon and keeping privileged ports (80/443) working without extra tuning. +# Overridden per-environment in group_vars (prod uses podman). +pulp_container_engine: docker +pulp_container_cli: "{{ 'podman' if pulp_container_engine == 'podman' else 'docker' }}" +pulp_compose_cmd: "{{ 'podman-compose' if pulp_container_engine == 'podman' else 'docker compose' }}" + +# ── Orchestrator ────────────────────────────────────────────────────────── +# How the stack is described and supervised: +# +# compose (default) — one rendered compose.yml driven by "docker compose" / +# "podman-compose". Supports the FULL topology: bundled pulp_web, containerised +# PostgreSQL, multi-replica api/content behind pulp_web's DNS load balancing. +# Under podman, boot persistence relies on podman-restart.service (+ the +# RequiresMountsFor drop-in) because nothing supervises the containers. +# +# quadlet — one systemd unit per container under {{ pulp_quadlet_dir }} (see +# vars/main.yml), generated by podman-system-generator. Podman ONLY. Boot +# persistence, dependency ordering, per-service mount requirements and +# restart policy all become native systemd, and a config change restarts just +# the affected services instead of force-recreating every container. +# +# Covers every service except pulp_web, so it requires (asserted in +# tasks/main.yml): +# * pulp_container_engine: podman +# * pulp_web_enabled: false — no pulp_web unit; use an external proxy +# Consequently api/content stay single-instance (published host ports) and no +# nginx service-name DNS load balancing is involved. Workers still scale by +# replica count: one rendered unit per pulp_worker_replicas. PostgreSQL +# follows pulp_postgres_external either way: false renders a pulp-postgres +# unit (+ pg data volume), true expects a managed database. +pulp_orchestrator: compose + +# Startup budget for the one-shot migration unit (Quadlet only). `pulpcore-manager +# migrate` on a large database can run for a long time and systemd's default +# TimeoutStartSec would kill it mid-schema-change. +pulp_quadlet_migration_timeout: 3600 + +# Enable podman-restart.service so restart=always containers come back after a +# host reboot (podman is daemonless — nothing else re-applies the policy on +# boot). Podman engine + compose orchestrator only; ignored under docker (whose +# daemon handles this) and under quadlet (where each unit is WantedBy a target). +# Set false where an external supervisor (a project systemd unit) owns the +# stack lifecycle. +pulp_podman_restart_service: true + +# Host bind-mount source paths the restart-managed containers depend on (any of +# the *_dir stores below that are set; empty ones drop out). podman-restart.service +# only orders itself After=network-online.target, so on a host whose store lives +# on an NFS/EFS bind mount it can start containers before that mount is ready. +# These paths are fed to a RequiresMountsFor drop-in so the service waits for +# (and requires) whatever .mount unit provides them. Empty list => no drop-in. +pulp_podman_bind_mount_paths: "{{ [pulp_storage_dir, pulp_exports_dir, pulp_postgres_data_dir, pulp_redis_data_dir] | select | list }}" + +# ── Images ────────────────────────────────────────────────────────────── +# Base images. pulp-minimal carries pulpcore + bundled plugins; pulp-web is +# the nginx front end. Both images are pinned to the same pulpcore release for +# reproducible deployments. +# 3.114.1 across all environments — prod pins the same value explicitly in +# group_vars/prod.yml (kept there deliberately, so a change to this default can +# never move prod by accident). +# +# WARNING: raising this runs `pulpcore-manager migrate` on the next bring-up — a +# real, FORWARD-ONLY schema upgrade you cannot roll back by putting the old image +# back. Snapshot the database first. The bundled plugin set also varies by tag; +# check what a tag ships before deploying it: +# {{ pulp_container_cli }} run --rm {{ pulp_minimal_image }} pip list | grep pulp +pulp_image_tag: "3.114.1" +pulp_minimal_image: "docker.io/pulp/pulp-minimal:{{ pulp_image_tag }}" +pulp_web_image: "docker.io/pulp/pulp-web:{{ pulp_image_tag }}" +pulp_postgres_image: "docker.io/library/postgres:16" +pulp_redis_image: "docker.io/library/redis:latest" + +# ── Plugins / custom image ───────────────────────────────────────────── +pulp_extra_plugins: [] +# Build a custom image automatically whenever extra plugins are requested OR the +# vendored source patches are enabled (see below). +pulp_build_custom_image: "{{ (pulp_extra_plugins | length) > 0 or (pulp_source_patches_enabled | bool) }}" +pulp_custom_image_name: "localhost/pulp-custom:latest" + +# ── Vendored source patches ────────────────────────────────────────────── +# Overlay locally-patched pulpcore modules onto the image. Each entry in +# pulp_source_patches is a file under roles/pulp/files/ (src) and its +# destination inside the image (dest). Currently carried: +# export.py — fixes package file timestamps on export and lets streamed +# file:// content be exported without a downloaded Artifact. +# datarepair.py — makes repair_7272 / repair_7465 (pulpcore-manager +# datarepair-*) load one RepositoryVersion at a time and +# stream content ids, so they no longer OOM on repositories +# with many versions / huge content_ids arrays. +# +# Each file vendors the WHOLE module, so the set is tightly coupled to ONE +# pulpcore version: on an image bump the upstream files may have changed and +# every copy must be re-derived. pulp_source_patches_pulpcore_version records +# the version the current files match; build_image.yml asserts it equals +# pulp_image_tag and fails the build if they drift — forcing a conscious +# re-check on every version bump. +# Off by default. pulp_source_patches_pulpcore_version matches pulp_image_tag, +# so this can be switched on per-environment (prod does); dev/staging leave it +# off and run plain pulp-minimal. +pulp_source_patches_enabled: false +pulp_source_patches_pulpcore_version: "3.114.1" +# Destination directory inside the image. Interpreter-version pinned — verify +# on a bump: +# {{ pulp_container_cli }} run --rm {{ pulp_minimal_image }} \ +# python3 -c "import pulpcore.app.tasks.export as m; print(m.__file__)" +pulp_source_patches_site_packages: /usr/local/lib/python3.11/site-packages +pulp_source_patches: + - src: export.py + dest: "{{ pulp_source_patches_site_packages }}/pulpcore/app/tasks/export.py" + - src: datarepair.py + dest: "{{ pulp_source_patches_site_packages }}/pulpcore/app/tasks/datarepair.py" +# Image the pulp-minimal-based services (api/content/worker/etc.) actually run. +pulp_runtime_image: "{{ pulp_custom_image_name if (pulp_build_custom_image | bool) else pulp_minimal_image }}" + +# ── Scaling ───────────────────────────────────────────────────────────── +# api/content replicas are SEPARATE containers load-balanced by pulp_web. When +# pulp_web is disabled (external reverse proxy, see below) their gunicorn ports +# are published on fixed host ports, which cannot fan out to >1 replica — pin +# these to 1 in that mode and scale via pulp_api_workers / pulp_content_workers +# instead. pulp_worker has no port, so it always scales freely by replica count. +pulp_api_replicas: 2 +pulp_content_replicas: 2 +pulp_worker_replicas: 2 + +# Gunicorn tuning inside each api/content container (image env knobs). Defaults +# match the pulp-oci-images defaults; raise workers to scale a single instance. +pulp_api_workers: 2 +pulp_content_workers: 2 +pulp_gunicorn_timeout: 90 + +# ── Container logging ───────────────────────────────────────────────────── +# Long-running services log via the journald driver: container stdout/stderr +# lands in the host systemd journal, so both "{{ pulp_container_cli }} logs +# " and journalctl work, and retention is governed centrally by journald +# rather than per-container files. This is the right fit for Podman: its +# json-file/k8s-file driver ignores max-file, so a rotated file keeps only the +# latest slice and older history is lost — journald keeps real history. +# +# Each service gets a STATIC syslog tag (the service name), so scaled replicas +# (pulp_api / pulp_content / pulp_worker → pulp_content_1, _2, …) aggregate +# under one identifier — `journalctl -t pulp_content` — while journald's own +# CONTAINER_NAME field still separates individual replicas. One-shot services +# (migration / password / signing) are left unconfigured; they just exit. +pulp_log_driver: journald + +# Optionally manage /etc/systemd/journald.conf so the retention policy is +# codified with the deployment. Off by default — leave the host's existing +# journald config untouched. Only applied when pulp_log_driver == "journald". +# NOTE: these limits are GLOBAL to the whole journal (shared by every service +# and replica), not per-container — size the budget accordingly. +pulp_journald_configure: false +pulp_journald_storage: persistent # keep logs across reboots (/var/log/journal) +pulp_journald_system_max_use: "2G" # total on-disk budget for the whole journal +pulp_journald_max_retention_sec: "1month" +pulp_journald_compress: "yes" + +# ── OpenTelemetry metrics (optional) ───────────────────────────────────────── +# Enable Pulp's native OpenTelemetry instrumentation. Pulp EXPORTS metrics over +# OTLP to a collector — it does NOT expose a Prometheus scrape endpoint — so +# point pulp_otel_exporter_endpoint at an OpenTelemetry Collector (or an +# OTLP-capable Prometheus). +# +# Mechanism (verified against pulp-minimal:3.114.1): pulpcore settings are +# Dynaconf with ENVVAR_PREFIX "PULP", so PULP_OTEL_ENABLED=true sets +# settings.OTEL_ENABLED, which installs pulpcore.middleware.DjangoMetricsMiddleware. +# The image entrypoints do NOT wrap anything with opentelemetry-instrument — that +# tool is not even in the 3.114.x image; the middleware replaces it. +# +# What you get on 3.114.x: +# * api.request_duration (ms histogram, service.name "pulp-api") — from +# OTEL_ENABLED alone. Bucket bounds via the OTEL_PULP_API_HISTOGRAM_BUCKETS +# setting; skip paths with OTEL_PYTHON_EXCLUDED_URLS. +# * artifacts.size.counter (Bytes, service.name "pulp-content") — ALSO requires +# settings.DOMAIN_ENABLED, which this role does not set. +# First export lands after OTEL_METRIC_EXPORT_INTERVAL (default 60s). +# +# Only the OTLP *http* exporter is installed in the image, so +# pulp_otel_exporter_protocol must stay http/protobuf — grpc cannot work. +# NOTE: enabling this without a reachable endpoint just produces exporter +# connection-retry log noise — wire the collector first. +pulp_otel_enabled: false +pulp_otel_exporter_endpoint: "http://otel-collector:4318" +# Keep http/protobuf: the image ships only opentelemetry-exporter-otlp-proto-http. +pulp_otel_exporter_protocol: "http/protobuf" + +# ── OpenTelemetry collector (optional, runs alongside the stack) ───────────── +# Render and run a collector next to Pulp, so pulp_otel_exporter_endpoint has +# something to talk to. Rendered for BOTH orchestrators: a compose service under +# compose, an otel-collector.container unit under quadlet. +# +# Nothing in the Pulp stack depends on it — a broken collector must never keep +# Pulp down, and the OTLP exporters retry on their own. Consequently the unit is +# NOT in pulp_quadlet_runtime_after; api/content/worker carry no Requires= on it. +pulp_otel_collector_enabled: false +# Pin the tag. ":latest" breaks configs across collector releases (the "logging" +# exporter was dropped in favour of "debug", for one). +pulp_otel_collector_image: "docker.io/otel/opentelemetry-collector-contrib:0.115.1" +# Container name == the DNS name on the shared network, so it MUST match the host +# part of pulp_otel_exporter_endpoint above. Hyphen, not underscore: underscores +# are invalid in hostnames (RFC 1034/1035) and bite in Host headers. +pulp_otel_collector_name: otel-collector +pulp_otel_collector_otlp_http_port: 4318 +pulp_otel_collector_otlp_grpc_port: 4317 +# Publish the OTLP ports on the host. Useful on dev to push test spans in from +# outside the container network; leave false elsewhere. +pulp_otel_collector_expose_ports: false +# Serve metrics as a Prometheus SCRAPE endpoint on the collector. This is the +# primary path: Pulp's own processes expose no HTTP endpoint, so the collector is +# the only thing that can be scraped — and pulling this way means Prometheus needs +# NO OTLP receiver enabled (no --web.enable-otlp-receiver, no version floor). +# The port is published on the host whenever this is on, since a scrape endpoint +# only reachable inside the container network is useless. It is unauthenticated: +# restrict it at the firewall/security-group level. +pulp_otel_collector_prometheus_exporter: true +pulp_otel_collector_prometheus_port: 8889 + +# The collector's OWN telemetry (otelcol_receiver_accepted_metric_points, +# otelcol_exporter_sent_metric_points, otelcol_exporter_send_failed_metric_points). +# Cheap, and the fastest way to tell "Pulp sent nothing" from "the collector +# dropped it" — leave it on. Also published on the host. +pulp_otel_collector_telemetry_enabled: true +pulp_otel_collector_telemetry_port: 8888 + +# Additionally PUSH metrics to an OTLP endpoint. Independent of the scrape +# exporter above — set both to do both. Empty => push nowhere, i.e. +# metrics are only served for scraping. With neither exporter configured they +# fall back to the debug exporter (printed to the collector's log). +# For an OTLP-capable Prometheus use the BASE OTLP path — the exporter appends +# /v1/metrics itself: +# http://prometheus.example.com:9090/api/v1/otlp +# Prometheus needs its OTLP receiver switched on (2.47+: +# --enable-feature=otlp-write-receiver, 3.x: --web.enable-otlp-receiver). +pulp_otel_collector_metrics_endpoint: "" +# Traces always terminate at the debug exporter: Prometheus cannot accept them, +# and every collector pipeline needs at least one exporter. Absorbing them here +# is what stops Pulp retrying failed trace exports forever. +pulp_otel_collector_debug_verbosity: normal # normal | detailed + +# ── Reverse proxy (pulp_web) ────────────────────────────────────────────── +# The bundled pulp_web (nginx) container fronts the api/content services: it +# terminates TLS, routes /pulp/content/ -> pulp_content and everything else -> +# pulp_api, load-balances replicas, and serves static/UI. Keep it enabled for a +# self-contained stack. +# +# Set false to run your OWN reverse proxy (e.g. nginx on a separate host) +# straight against the api/content gunicorn ports. When disabled: +# * the pulp_web container, its nginx assets, and local TLS provisioning are +# dropped; +# * pulp_api / pulp_content publish the host ports below for your proxy; +# * api/content must be replicas: 1 (fixed host port); scale via workers; +# * your proxy owns TLS (set pulp_https: false) and MUST route /pulp/content/ +# to the content port and the rest to the api port. Because it terminates +# TLS, set CONTENT_ORIGIN (pulp_content_origin) to the public https URL so +# Pulp emits correct links. +pulp_web_enabled: true +# Host ports published for an external reverse proxy (only when pulp_web is +# disabled). Match your proxy's upstream targets. +pulp_api_host_port: 8081 # -> container 24817 (gunicorn pulp-api) +pulp_content_host_port: 24816 # -> container 24816 (gunicorn pulp-content) + +# ── Networking ────────────────────────────────────────────────────────── +# Host-published ports for pulp_web. For production behind privileged ports, +# set these to 80 / 443 (the container listens internally on pulp_web_*_listen +# below). Unused when pulp_web is disabled. +pulp_http_port: 8080 +pulp_https_port: 8443 +# Internal ports nginx listens on inside the pulp_web container. +pulp_web_http_listen: 8080 +pulp_web_https_listen: 8443 +# Expose postgres/redis on the host? Off by default (kept inside the compose network). +pulp_expose_postgres: false +pulp_expose_redis: false + +# ── HTTPS / TLS ─────────────────────────────────────────────────────────── +pulp_https: false +# When HTTPS is on, also serve a plain-HTTP vhost that 301-redirects to HTTPS. +pulp_web_http_redirect: true +# Certificate source (control-node paths). If set, they are copied into place. +pulp_tls_cert_src: "" +pulp_tls_key_src: "" +# If no cert is provided, optionally generate a self-signed one (NON-PRODUCTION). +pulp_tls_generate_self_signed: false +pulp_tls_cn: "{{ ansible_fqdn }}" +# Where the webserver cert/key live on the host (mounted into pulp_web only). +pulp_web_certs_dir: "{{ pulp_assets_dir }}/web/certs" + +# ── SELinux relabeling ─────────────────────────────────────────────────────── +# Master switch for the ":z"/":Z" relabel flags on ALL bind mounts (config +# assets, certs, and the postgres/redis/storage/exports data mounts). The flag +# makes the engine chcon the host path to a container-accessible label. +# +# Keep true on a LOCAL filesystem under ENFORCING SELinux. Set false when: +# * SELinux is Permissive/Disabled — labels aren't enforced, so the relabel is +# dead work (and still recursively walks/chcons the tree on every start), or +# * the host doesn't use SELinux at all. +# Named-volume mounts never get a relabel flag regardless of this setting. +# NOTE: if the host is later switched back to Enforcing, containers will be +# denied access to unlabeled bind mounts — flip this back true and re-run. +pulp_selinux_relabel: true + +# ── Data mount points ────────────────────────────────────────────────────── +# Each store defaults to a managed Docker named volume (empty string). Set a +# host path to bind-mount existing data instead — this is the migration path +# from the systemd deployment (point these at your current data directories). +# e.g. pulp_storage_dir: /srv/pulp (current /var/lib/pulp content) +# pulp_postgres_data_dir: /var/lib/pgsql/data +pulp_storage_dir: "" # -> pulp_storage_mount (else named volume "pulp") +pulp_postgres_data_dir: "" # -> postgres PGDATA (else named volume "pg_data") +pulp_redis_data_dir: "" # -> /data (else named volume "redis_data") +# Container path the storage store is mounted at. Defaults to /var/lib/pulp (the +# image's built-in DEPLOY_ROOT), so the DEPLOY_ROOT/MEDIA_ROOT/... defaults below +# line up with it. Override together with those paths to mount a SHARED PARENT +# once and place DEPLOY_ROOT in a subdir — needed when PulpExport hardlinks the +# artifacts into an export dir that is a SIBLING of the store: both must sit under +# ONE mount, or os.link() fails cross-device (EXDEV). This bites on NFS/EFS, where +# two separate bind mounts of the same export become two mounts (separate +# superblocks) inside the container even though the host sees one filesystem. See +# group_vars/prod.yml for the shared-parent layout. +pulp_storage_mount: /var/lib/pulp +# Host directory under which the DEPLOY_ROOT subdirs (media/tmp/assets/scripts) +# are ensured to exist with the pulp uid/gid. Defaults to the storage bind source, +# which equals DEPLOY_ROOT's host path in the default layout (mount == DEPLOY_ROOT). +# Override when the storage mount root differs from DEPLOY_ROOT (shared-parent +# layout: the store is mounted at a parent and DEPLOY_ROOT lives in a subdir). +pulp_deploy_root_host: "{{ pulp_storage_dir }}" +# Optionally chown the pulp storage bind-mount to the in-container pulp uid/gid. +# Leave false when migrating real data that already has correct ownership. +pulp_storage_set_owner: false +pulp_storage_owner: "700" +pulp_storage_group: "700" +# UID[:GID] the pulp runtime services (api/content/worker + one-shots) run as. +# Defaults to the image's built-in "pulp" user (uid 700). When bind-mounting +# EXISTING data owned by a different uid, set this to that owner so the process +# has native access without chowning the data — and set pulp_storage_owner / +# pulp_storage_group to the same, so the role-managed assets/certs (settings, +# encryption/token keys) remain readable by this user. +pulp_run_user: pulp +# SELinux relabel for the storage bind mount specifically. Defaults to the +# global pulp_selinux_relabel, but override it independently for the common case +# where the host is Enforcing (global stays true) yet storage is on NFS/EFS: +# NFS can't hold per-file SELinux xattrs, so the relabel fails (EOPNOTSUPP) AND +# recursively walks the whole tree on every start. For NFS/EFS, set this false +# and label the mount itself instead, e.g. mount with: +# context="system_u:object_r:container_file_t:s0" +# (Ignored in named-volume mode; only applies when pulp_storage_dir is set.) +pulp_storage_selinux_relabel: "{{ pulp_selinux_relabel }}" + +# ── PulpExport / PulpImport ───────────────────────────────────────────────── +# Filesystem export/import (pulpcore-manager / PulpExport tasks). Disabled when +# pulp_exports_dir is empty. Set it to a host path (e.g. an NFS/EBS mount) to +# enable: the dir is bind-mounted into the api+worker services at +# pulp_export_path, chowned to the in-container pulp uid/gid, and whitelisted in +# settings (ALLOWED_EXPORT_PATHS / ALLOWED_IMPORT_PATHS). +pulp_exports_dir: "" # host path; "" = export/import not mounted +pulp_export_path: /srv/exports # path inside the containers +# Same SELinux rule as pulp_storage_selinux_relabel: defaults to the global +# toggle; set false independently when the exports dir is on NFS/EFS and label +# the mount via a context= mount option instead. +pulp_exports_selinux_relabel: "{{ pulp_selinux_relabel }}" +# Whitelisted PulpExport / PulpImport paths, rendered verbatim into settings.py +# (ALLOWED_EXPORT_PATHS / ALLOWED_IMPORT_PATHS). Defaults preserve the previous +# behavior: the single mounted pulp_export_path (when pulp_exports_dir is set) +# plus /tmp, else just /tmp. Override with an explicit list to allow multiple or +# custom paths — every path MUST be readable/writable inside the api+worker +# containers (i.e. backed by a bind mount), or exports/imports fail at runtime. +pulp_allowed_export_paths: "{{ ([pulp_export_path, '/tmp'] if pulp_exports_dir else ['/tmp']) }}" +pulp_allowed_import_paths: "{{ ([pulp_export_path, '/tmp'] if pulp_exports_dir else ['/tmp']) }}" + +# ── Package signing (optional) ────────────────────────────────────────────── +# Run a Pulp signing service (signs published metadata/packages). Off by +# default. When enabled you MUST also provide pulp_signing_key_fingerprint and +# import the corresponding GPG key into the pulp keyring; otherwise the +# signing_key_service container fails and the stack never comes up. +pulp_signing_enabled: false +pulp_signing_key_fingerprint: "" + +# ── Database / cache ───────────────────────────────────────────────────── +# When true, use an external PostgreSQL (e.g. AWS RDS) and do NOT run the +# bundled postgres container. When false (dev default), Postgres runs in Docker. +pulp_postgres_external: false +# Connection details. For dev the host is the compose service name "postgres". +# For external/RDS, override pulp_postgres_host with the endpoint and tighten ssl. +pulp_postgres_host: postgres +pulp_postgres_port: 5432 +# Optional: when set, rendered as DATABASES.default.OPTIONS.sslmode; when left +# undefined the key is omitted and libpq's own default ("prefer") applies. +# For external/RDS set "require" (or "verify-full" with a CA). +# pulp_postgres_sslmode: prefer +pulp_postgres_user: pulp +pulp_postgres_db: pulp +pulp_postgres_password: "changeme-postgres" + +# ── App settings ───────────────────────────────────────────────────────── +pulp_secret_key: "CHANGE-ME" +pulp_content_origin: >- + {{ 'https' if pulp_https | bool else 'http' }}://{{ ansible_fqdn }}:{{ pulp_https_port if pulp_https | bool else pulp_http_port }} +pulp_admin_password: "changeme-please" +# Run the one-shot set_init_password_service, which sets the Pulp admin +# (superuser) password from pulp_admin_password on bring-up. Keep true for a +# fresh install to seed the initial admin. Set false for an EXISTING deployment +# (e.g. migrating with pre-existing RDS data) — the admin already exists and you +# don't want its password reset. When false, pulp_admin_password is unused. +pulp_set_init_password: true + +# ── settings.py knobs ──────────────────────────────────────────────────── +# Every line rendered into assets/settings.py is driven from here. Defaults +# mirror the production reference; override per-environment in group_vars. +# +# NOTE: the *_host / *_hostname / token_server values below default to the +# compose-internal service names (redis, pulp_api, pulp_content) rather than +# prod's public FQDNs — the multi-container stack talks to itself over the +# compose network. In prod, set pulp_ansible_api_hostname / _content_hostname / +# pulp_token_server to the public https URLs (see group_vars/prod.yml). +pulp_cache_enabled: true +pulp_redis_host: redis +pulp_redis_port: 6379 +pulp_redis_password: "" + +# Fernet key for symmetrically-encrypted DB fields (path inside the container, +# from the mounted assets/certs dir). +pulp_db_encryption_key_path: /etc/pulp/certs/database_fields.symmetric.key +# How the key file is provisioned on the host: +# * pulp_db_encryption_key_src set -> copy that control-node file into place +# (use this to bring the ORIGINAL key when migrating an existing deployment); +# * else pulp_generate_db_encryption_key true -> generate a fresh random key +# (correct only for a NEW deployment with no pre-existing encrypted data). +# A NEW/WRONG key makes already-encrypted DB fields permanently unreadable, so +# for an existing deployment provide the original key and disable generation. +# When generation is off and no key is present, the play fails fast. +pulp_generate_db_encryption_key: true +pulp_db_encryption_key_src: "" +# Optional: persistent DB connection lifetime (seconds). When set, rendered as +# DATABASES.default.CONN_MAX_AGE; when undefined the key is omitted and Django's +# default (0 = close the connection after each request) applies. +# pulp_postgres_conn_max_age: 0 + +# Internal service endpoints (compose network). Public FQDNs go in group_vars. +pulp_ansible_api_hostname: "http://pulp_api:24817" +pulp_ansible_content_hostname: "http://pulp_content:24816/pulp/content" + +# Filesystem layout (container-internal paths under the pulp storage mount). +pulp_deploy_root: /var/lib/pulp +pulp_media_root: /var/lib/pulp/media +pulp_working_directory: /var/lib/pulp/tmp +pulp_file_upload_temp_dir: /var/lib/pulp/tmp +pulp_static_root: /var/lib/pulp/assets +pulp_static_url: /assets/ +pulp_content_path_prefix: /pulp/content/ + +# Content / upload protection windows (minutes) and API app TTL (seconds). +pulp_upload_protection_time: 1440 +pulp_tmpfile_protection_time: 1440 +pulp_api_app_ttl: 350 + +pulp_allowed_content_checksums: + - sha256 +pulp_authentication_backends: + - django.contrib.auth.backends.ModelBackend + - pulpcore.backends.ObjectRolePermissionBackend + +# pulp_container token auth. Prod runs with token auth DISABLED, so the key +# paths below are unused unless you flip pulp_token_auth_disabled to false — in +# which case you must provide the ES256 keypair at these paths. +pulp_token_server: "http://pulp_api:24817/token/" +pulp_token_auth_disabled: true +pulp_token_signature_algorithm: ES256 +# Container-internal paths (from the mounted assets/certs dir) that settings.py +# points at. +pulp_token_public_key_path: /etc/pulp/certs/token_public_key.pem +pulp_token_private_key_path: /etc/pulp/certs/token_private_key.pem +# Optional control-node source files for the ES256 keypair. When set, they are +# copied into assets/certs (like the DB encryption key) — use this to bring the +# ORIGINAL keypair when migrating an existing deployment. Left empty, no key +# files are laid down (fine when token auth is disabled). +pulp_token_public_key_src: "" +pulp_token_private_key_src: "" + +pulp_analytics: false + +# Install the selected container engine (pulp_container_engine) on the host. +# Engine version pinning is left to the distro repo (latest stable). Set false +# if the engine + compose tool are already present. +pulp_install_engine: true + +# Bring the container stack up as part of the run. When false, the play still +# installs the engine, renders compose.yml + all assets/settings and (optionally) +# builds the custom image, but does NOT run "compose up", skips the API readiness +# wait, and suppresses the restart handler — so no containers are started or +# recreated. Use it to stage/refresh config on the host and start manually later +# (cd {{ pulp_project_dir }} && {{ pulp_compose_cmd }} -f compose.yml up -d). +pulp_start_stack: true diff --git a/roles/pulp/files/README.md b/roles/pulp/files/README.md new file mode 100644 index 0000000..c53d634 --- /dev/null +++ b/roles/pulp/files/README.md @@ -0,0 +1,78 @@ +# TLS certificate drop-in (prod / any HTTPS environment) + +`roles/pulp/tasks/tls.yml` copies the webserver cert + key from here into the +`pulp_web` container when `pulp_https: true` and `pulp_tls_cert_src` / +`pulp_tls_key_src` point at these filenames (the prod default). + +Place your PEM files here — they are **gitignored** and must never be committed: + + roles/pulp/files/pulp_webserver.crt # server cert + intermediate chain + roles/pulp/files/pulp_webserver.key # private key + +If both are absent while HTTPS is on, the play fails fast (no self-signed +fallback in prod). For a non-production self-signed cert instead, leave these +unset and set `pulp_tls_generate_self_signed: true`. + +## Database fields encryption key (existing-deployment migration) + +When `pulp_db_encryption_key_src` points here (prod default), drop the +**original** Fernet key from the deployment you're migrating: + + roles/pulp/files/database_fields.symmetric.key + +Also gitignored. This key decrypts already-encrypted DB fields (remote +credentials, etc.) — a new or wrong key makes them permanently unreadable, so it +must be the exact key from the source deployment. For a brand-new deployment +instead, leave `pulp_db_encryption_key_src` empty and keep +`pulp_generate_db_encryption_key: true` to have the role generate one. + +## Vendored pulpcore source patches (`export.py`, `datarepair.py`) + +These are locally-patched copies of whole pulpcore modules. Unlike the secrets +above they **are committed** — source we carry until the fixes land upstream. +When `pulp_source_patches_enabled: true`, `build_image.yml` stages every entry +of `pulp_source_patches` (`{src, dest}`) into the image build context and +`Containerfile.j2` `COPY`s each one over the image's copy at its `dest`. + +| File | Upstream module | What it changes | +|-----------------|------------------------------------|-----------------| +| `export.py` | `pulpcore/app/tasks/export.py` | Package file timestamps on export; export streamed `file://` content without a downloaded Artifact. | +| `datarepair.py` | `pulpcore/app/tasks/datarepair.py` | `repair_7272` / `repair_7465` (`pulpcore-manager datarepair-7272` / `-7465`) load one RepositoryVersion at a time, compare `content_ids` by count first and then stream relationship ids, and in 7465 only load versions whose cache is missing — so the repair no longer OOMs on repositories with many versions or huge `content_ids` arrays. | + +Each file vendors the whole module, so the set is pinned to ONE pulpcore version +(`pulp_source_patches_pulpcore_version`, currently `3.114.1`). The build asserts +that version equals `pulp_image_tag` and fails if they drift — so on any image +bump you must re-derive every file from the new upstream and update the version +var (or disable the patches). + +To add another patch: drop the patched module here and append +`{src: , dest: }` to `pulp_source_patches` in +`roles/pulp/defaults/main.yml`. + +### Filesystem exporter and hardlinks (cross-device gotcha) + +The filesystem exporter hardlinks (`os.link`) each artifact from `MEDIA_ROOT` +into the export directory. A hard link cannot cross filesystems, so the artifact +store and the export dir **must be on the same mounted filesystem inside the +container**, or every export fails with `[Errno 18] Invalid cross-device link`. + +`export.py` has a `st_dev` guard that falls back to copy when the two are on +different devices — but it is **defeated on NFS/EFS**: two separate bind mounts +of the same NFS export become two mounts (separate superblocks) inside the +container yet report the SAME `st_dev`, so the guard thinks they match and still +attempts the hardlink. The fix is at the mount layer: bind-mount the shared +parent ONCE and place `DEPLOY_ROOT` (hence `MEDIA_ROOT`) and the export dir under +it — see the shared-parent layout in `group_vars/prod.yml` +(`pulp_storage_mount` / `pulp_deploy_root_host`). + +## pulp_container token-auth keypair (optional) + +When `pulp_token_public_key_src` / `pulp_token_private_key_src` point here (prod +default), drop the existing ES256 keypair: + + roles/pulp/files/token_public_key.pem + roles/pulp/files/token_private_key.pem + +Gitignored (`*.pem`). Only needed if token auth is enabled, or to mirror an +existing deployment. If you don't have or need them, set both +`pulp_token_*_key_src` vars to `""`. diff --git a/roles/pulp/files/database_fields.symmetric.key.vault b/roles/pulp/files/database_fields.symmetric.key.vault new file mode 100644 index 0000000..a9e9bad --- /dev/null +++ b/roles/pulp/files/database_fields.symmetric.key.vault @@ -0,0 +1,7 @@ +$ANSIBLE_VAULT;1.1;AES256 +37623739313764366333346237333031656262633366313737326162366437343136306131386663 +6332383537343433396436646265633539396333633763620a396262666566643136323662646431 +31363664666265356332363835313738323936643831353633356539316336306266363736316362 +3934663631623538350a376361653963393739356162636332376663386266333036623233636334 +66383237626338306136666430643133383666653034633636636336656664313633363462636239 +3263383735616535353464636132356639666563663065623965 diff --git a/roles/pulp/files/datarepair.py b/roles/pulp/files/datarepair.py new file mode 100644 index 0000000..43575f4 --- /dev/null +++ b/roles/pulp/files/datarepair.py @@ -0,0 +1,201 @@ +from logging import getLogger + +from django.db import transaction + +from pulpcore.app import models +from pulpcore.app.models import ProgressReport +from pulpcore.app.util import get_domain + +log = getLogger(__name__) + + +def repair_7272(dry_run=False): + """ + Repair repository version content_ids cache and content count mismatches (Issue #7272). + + This task fixes two types of data corruption within the current domain: + 1. Mismatch between RepositoryVersion.content_ids cache and actual RepositoryContent + relationships + 2. Mismatch between RepositoryVersionContentDetails count and actual RepositoryContent count + + Args: + dry_run (bool): If True, only report issues without fixing them. Defaults to False. + """ + number_broken = 0 + + domain = get_domain() + + log.info(f'Performing datarepair for issue #7272 for domain "{domain.name}"') + + repos = models.Repository.objects.filter(pulp_domain=domain) + total_versions = models.RepositoryVersion.objects.filter(repository__in=repos).count() + + with ( + ProgressReport( + message="Repositories checked", + code="repair.7272.repos_checked", + total=repos.count(), + ) as repos_progress, + ProgressReport( + message="Repository versions checked", + code="repair.7272.versions_checked", + total=total_versions, + ) as versions_progress, + ProgressReport( + message="Repository versions fixed", + code="repair.7272.versions_fixed", + ) as fixed_progress, + ): + for repo in repos: + # Fetch only the version PKs up front (small, no content_ids arrays) and load + # one full version at a time, so at most a single version's content_ids array + # lives in memory rather than every version's array at once. + version_ids = list( + models.RepositoryVersion.objects.filter(repository=repo).values_list( + "pk", flat=True + ) + ) + for rv_id in version_ids: + rv = models.RepositoryVersion.objects.get(pk=rv_id) + needs_fix = False + versions_progress.increment() + + relationships = rv._content_relationships() + repositorycontent_id_count = relationships.count() + + if rv.content_ids is not None: + cache_mismatch = False + # Cheap size check first; only fall back to an element comparison when + # the counts match, and stream the relationship ids so we never hold a + # second full id set in memory alongside the cached one. + if len(rv.content_ids) != repositorycontent_id_count: + cache_mismatch = True + else: + cached_id_set = set(rv.content_ids) + for content_id in relationships.values_list( + "content_id", flat=True + ).iterator(): + if content_id not in cached_id_set: + cache_mismatch = True + break + del cached_id_set + if cache_mismatch: + log.warning( + f'Repository "{repo.name}" (type "{repo.pulp_type}") version ' + f'{rv.number} in domain "{domain.name}" has a mismatch between the ' + "RepositoryContent and the cached ID set" + ) + needs_fix = True + + if repositorycontent_id_count == 0: + continue + rv_count_details = models.RepositoryVersionContentDetails.objects.filter( + repository_version=rv, + count_type=models.RepositoryVersionContentDetails.PRESENT, + ) + + # need to sum across all content types + total_count = sum(rvcd.count for rvcd in rv_count_details) + + if total_count != repositorycontent_id_count: + log.warning( + f'Repository "{repo.name}" (type "{repo.pulp_type}") version {rv.number} ' + f'in domain "{domain.name}" has a mismatch between the RepositoryContent ' + "and RepositoryVersionContentDetails" + ) + needs_fix = True + + if needs_fix: + number_broken += 1 + + if not dry_run: + with transaction.atomic(): + rv.content_ids = list( + relationships.values_list("content_id", flat=True).iterator() + ) + rv.save() + rv._compute_counts() + fixed_progress.increment() + + repos_progress.increment() + + fixed_progress.total = number_broken + + if not number_broken: + log.info(f'Data repair operation for issue #7272 for domain "{domain.name}" finished. (OK)') + else: + if dry_run: + log.info( + f"Data repair operation for issue #7272 dry run finished. ({number_broken} " + f'repository versions need fixing in domain "{domain.name}")' + ) + else: + log.info( + f"Data repair operation for issue #7272 finished. ({number_broken} " + f'repository versions fixed in domain "{domain.name}")' + ) + + +def repair_7465(dry_run=False): + """ + Populates the content_ids cache for all repository versions. + """ + number_missing = 0 + domain = get_domain() + + log.info(f'Performing datarepair for issue #7465 for domain "{domain.name}"') + + repos = models.Repository.objects.filter(pulp_domain=domain) + total_versions = models.RepositoryVersion.objects.filter(repository__in=repos).count() + + with ( + ProgressReport( + message="Repositories checked", + code="repair.7465.repos_checked", + total=repos.count(), + ) as repos_progress, + ProgressReport( + message="Repository versions checked", + code="repair.7465.versions_checked", + total=total_versions, + ) as versions_progress, + ProgressReport( + message="Repository versions fixed", + code="repair.7465.versions_fixed", + ) as fixed_progress, + ): + for repo in repos: + repo_versions = models.RepositoryVersion.objects.filter(repository=repo) + # All versions count as "checked", but we only need to load the ones whose + # cache is missing. Filtering on content_ids__isnull avoids pulling the large + # content_ids arrays for the (already-populated) majority of versions. + versions_progress.increase_by(repo_versions.count()) + null_version_ids = list( + repo_versions.filter(content_ids__isnull=True).values_list("pk", flat=True) + ) + for rv_id in null_version_ids: + rv = models.RepositoryVersion.objects.get(pk=rv_id) + number_missing += 1 + if not dry_run: + rv.content_ids = list( + rv._content_relationships().values_list("content_id", flat=True).iterator() + ) + rv.save() + fixed_progress.increment() + repos_progress.increment() + + fixed_progress.total = number_missing + + if not number_missing: + log.info(f'Data repair operation for issue #7465 for domain "{domain.name}" finished. (OK)') + else: + if dry_run: + log.info( + f"Data repair operation for issue #7465 dry run finished. ({number_missing} " + f'repository versions need fixing in domain "{domain.name}")' + ) + else: + log.info( + f"Data repair operation for issue #7465 finished. ({number_missing} " + f'repository versions fixed in domain "{domain.name}")' + ) diff --git a/roles/pulp/files/export.py b/roles/pulp/files/export.py new file mode 100644 index 0000000..cd364ef --- /dev/null +++ b/roles/pulp/files/export.py @@ -0,0 +1,619 @@ +import json +import logging +import os +import os.path +import shutil +import tarfile +from gettext import gettext as _ +from glob import glob +from pathlib import Path +from urllib.parse import unquote, urlparse + +from django.conf import settings + +from pulpcore.app.apps import get_plugin_config +from pulpcore.app.importexport import ( + export_artifacts, + export_content, + export_versions, +) +from pulpcore.app.models import ( + CreatedResource, + ExportedResource, + Exporter, + FilesystemExport, + Publication, + PulpExport, + PulpExporter, + Repository, + RepositoryVersion, + Task, +) +from pulpcore.app.models.content import ContentArtifact, RemoteArtifact +from pulpcore.app.serializers import PulpExportSerializer +from pulpcore.app.util import Crc32Hasher, HashingFileWriter, compute_file_hash +from pulpcore.constants import FS_EXPORT_METHODS +from pulp_rpm.app.models.package import Package + +log = logging.getLogger(__name__) + + +class UnexportableArtifactException(RuntimeError): + """Exception for artifacts that are unavailable for export.""" + + def __init__(self): + super().__init__(_("Cannot export artifacts that haven't been downloaded.")) + + +def _ensure_bool(value): + if isinstance(value, bool): + return value + if isinstance(value, str): + if value.lower() in ["yes", "y", "true", "t", "on", "1"]: + return True + if value.lower() in ["no", "n", "false", "f", "off", "0"]: + return False + if value == 1: + return True + if value == 0: + return False + raise ValueError("Value {value:r} does not describe a boolean.") + + +def _validate_fs_export(content_artifacts): + """ + Args: + content_artifacts (django.db.models.QuerySet): Set of ContentArtifacts to export + + Raises: + RuntimeError: If Artifacts are not downloaded or when trying to link non-fs files + """ + missing_ca_qs = content_artifacts.filter(artifact=None) + if not missing_ca_qs.exists(): + return + + # Allow streamed content whose remote URL is a local file:// path (e.g. an rsync'd + # local mirror). Those artifacts can be exported directly even though they were + # never downloaded into Pulp storage. + missing_pks = set(missing_ca_qs.values_list("pk", flat=True)) + + # Reject if any missing artifact has a non-file:// remote (truly needs downloading). + if ( + RemoteArtifact.objects.filter(content_artifact__pk__in=missing_pks) + .exclude(url__startswith="file://") + .exists() + ): + raise UnexportableArtifactException() + + # Reject if any missing artifact has no remote at all (nothing to fall back to). + ca_pks_with_remote = set( + RemoteArtifact.objects.filter(content_artifact__pk__in=missing_pks).values_list( + "content_artifact_id", flat=True + ) + ) + if missing_pks - ca_pks_with_remote: + raise UnexportableArtifactException() + + +def _local_path_from_file_url(url): + """Convert a file:// URL to an absolute local filesystem path.""" + parsed = urlparse(url) + return os.path.abspath(os.path.join(parsed.netloc, unquote(parsed.path))) + + +def _file_url_local_paths(content_artifacts): + """Map ContentArtifact pk -> local path for missing artifacts backed by file:// remotes. + + Returns an empty dict when there is no such content. Lets streamed content whose + bytes live on the local filesystem be exported without a downloaded Artifact. + """ + missing_ca_pks = set(content_artifacts.filter(artifact=None).values_list("pk", flat=True)) + if not missing_ca_pks: + return {} + + ca_pk_to_local_path = {} + for ra in RemoteArtifact.objects.filter( + content_artifact__pk__in=missing_ca_pks, + url__startswith="file://", + ).values("content_artifact_id", "url"): + ca_pk_to_local_path[ra["content_artifact_id"]] = _local_path_from_file_url(ra["url"]) + return ca_pk_to_local_path + + +def _export_local_to_file_system( + path, relative_paths_to_local_paths, method=FS_EXPORT_METHODS.WRITE +): + """ + Export artifacts backed by file:// remotes directly from their local path. + + Used for streamed content that has no downloaded Artifact but whose bytes are + available locally. The base export directory must already exist (created by + _export_to_file_system). + + Args: + path (str): The already-created export root directory. + relative_paths_to_local_paths: A dict with {relative_path: local_fs_path} mapping. + method: FS_EXPORT_METHODS constant (WRITE, SYMLINK, or HARDLINK). + """ + for relative_path, src_path in relative_paths_to_local_paths.items(): + dest = os.path.join(path, relative_path) + os.makedirs(os.path.split(dest)[0], exist_ok=True) + + if method == FS_EXPORT_METHODS.SYMLINK: + os.path.lexists(dest) and os.unlink(dest) + os.symlink(src_path, dest) + elif method == FS_EXPORT_METHODS.HARDLINK: + os.path.lexists(dest) and os.unlink(dest) + try: + os.link(src_path, dest) + except OSError: + log.info(_("Hard link cannot be created, file will be copied.")) + shutil.copy2(src_path, dest) + elif method == FS_EXPORT_METHODS.WRITE: + shutil.copy2(src_path, dest) + else: + raise RuntimeError(_("Unsupported export method '{}'.").format(method)) + + +def _export_to_file_system(path, relative_paths_to_artifacts, method=FS_EXPORT_METHODS.WRITE): + """ + Export a set of artifacts to the filesystem. + + Args: + path (str): A path to export the ContentArtifacts to + relative_paths_to_artifacts: A dict with {relative_path: artifact} mapping + + Raises: + ValidationError: When path is not in the ALLOWED_EXPORT_PATHS setting + """ + using_filesystem_storage = ( + settings.STORAGES["default"]["BACKEND"] == "pulpcore.app.models.storage.FileSystem" + ) + + if method != FS_EXPORT_METHODS.WRITE and not using_filesystem_storage: + raise RuntimeError(_("Only write is supported for non-filesystem storage.")) + + os.makedirs(path, exist_ok=True) + export_not_on_same_filesystem = ( + using_filesystem_storage and os.stat(settings.MEDIA_ROOT).st_dev != os.stat(path).st_dev + ) + + if method == FS_EXPORT_METHODS.HARDLINK and export_not_on_same_filesystem: + log.info(_("Hard link cannot be created, file will be copied.")) + method = FS_EXPORT_METHODS.WRITE + + pkg_checksum_path_mapping = {} + + for relative_path, artifact in relative_paths_to_artifacts.items(): + dest = os.path.join(path, relative_path) + os.makedirs(os.path.split(dest)[0], exist_ok=True) + + if method == FS_EXPORT_METHODS.SYMLINK: + src = os.path.join(settings.MEDIA_ROOT, artifact.file.name) + os.path.lexists(dest) and os.unlink(dest) + os.symlink(src, dest) + elif method == FS_EXPORT_METHODS.HARDLINK: + src = os.path.join(settings.MEDIA_ROOT, artifact.file.name) + os.path.lexists(dest) and os.unlink(dest) + os.link(src, dest) + elif method == FS_EXPORT_METHODS.WRITE: + with open(dest, "wb") as f, artifact.file as af: + for chunk in af.chunks(1024 * 1024): + f.write(chunk) + else: + raise RuntimeError(_("Unsupported export method '{}'.").format(method)) + pkg_checksum_path_mapping[artifact.sha256] = dest + + log.info(_('Fixing packages timestamps')) + packages = Package.objects.only('pkgId', 'time_file').filter(pkgId__in=list(pkg_checksum_path_mapping.keys())) + log.info(_('Finished querying database for timestamps')) + for pkg in packages: + pkg_path = pkg_checksum_path_mapping[pkg.pkgId] + os.utime(pkg_path, (pkg.time_file, pkg.time_file)) + log.info(_('Timestamps fixing is finished')) + + +def _export_publication_to_file_system( + path, publication, start_repo_version=None, method=FS_EXPORT_METHODS.WRITE, allow_missing=False +): + """ + Export a publication to the file system. + + Args: + path (str): Path to place the exported data + publication_pk (str): Publication pk + """ + content_artifacts = ContentArtifact.objects.filter( + pk__in=publication.published_artifact.values_list("content_artifact__pk", flat=True) + ) + if start_repo_version: + start_version_content_artifacts = ContentArtifact.objects.filter( + artifact__in=start_repo_version.artifacts + ) + + if publication.pass_through: + content_artifacts |= ContentArtifact.objects.filter( + content__in=publication.repository_version.content + ) + + if not allow_missing: + # In some cases we may want to disable this validation + _validate_fs_export(content_artifacts) + + difference_content_artifacts = [] + if start_repo_version: + difference_content_artifacts = set( + content_artifacts.difference(start_version_content_artifacts).values_list( + "pk", flat=True + ) + ) + + # Map ContentArtifact pk -> local path for any streamed file:// content, so it can + # be exported directly even though it has no downloaded Artifact. + ca_pk_to_local_path = _file_url_local_paths(content_artifacts) + + relative_path_to_artifacts = {} + relative_path_to_local_paths = {} + if publication.pass_through: + for ca in content_artifacts.select_related("artifact").iterator(): + if (start_repo_version is None) or (ca.pk in difference_content_artifacts): + if ca.artifact: + relative_path_to_artifacts[ca.relative_path] = ca.artifact + elif ca.pk in ca_pk_to_local_path: + relative_path_to_local_paths[ca.relative_path] = ca_pk_to_local_path[ca.pk] + + publication_metadata_paths = set( + publication.published_metadata.values_list("relative_path", flat=True) + ) + for pa in publication.published_artifact.select_related( + "content_artifact", "content_artifact__artifact" + ).iterator(): + if ( + start_repo_version is None + or pa.relative_path in publication_metadata_paths + or pa.content_artifact.pk in difference_content_artifacts + ): + # Artifact isn't guaranteed to be present + if pa.content_artifact.artifact: + relative_path_to_artifacts[pa.relative_path] = pa.content_artifact.artifact + elif pa.content_artifact.pk in ca_pk_to_local_path: + relative_path_to_local_paths[pa.relative_path] = ca_pk_to_local_path[ + pa.content_artifact.pk + ] + + _export_to_file_system(path, relative_path_to_artifacts, method) + if relative_path_to_local_paths: + _export_local_to_file_system(path, relative_path_to_local_paths, method) + + +def _export_location_is_clean(path): + """ + Returns whether the provided path is valid to use as an export location. + + Args: + path (str): + """ + if os.path.exists(path): + if not os.path.isdir(path): + return False + + with os.scandir(path) as it: + if any(it): + return False + return True + + +def fs_publication_export(exporter_pk, publication_pk, start_repo_version_pk=None): + """ + Export a publication to the file system using an exporter. + + Args: + exporter_pk (str): FilesystemExporter pk + publication_pk (str): Publication pk + """ + exporter = Exporter.objects.get(pk=exporter_pk).cast() + publication = Publication.objects.get(pk=publication_pk).cast() + + start_repo_version = None + if start_repo_version_pk: + start_repo_version = RepositoryVersion.objects.get(pk=start_repo_version_pk) + + params = {"publication": publication_pk} + if start_repo_version: + params["start_repository_version"] = start_repo_version_pk + + export = FilesystemExport.objects.create( + exporter=exporter, + params=params, + task=Task.current(), + ) + ExportedResource.objects.create(export=export, content_object=publication) + CreatedResource.objects.create(content_object=export) + log.info( + "Exporting: file_system_exporter={exporter}, publication={publication}, " + "start_repo_version={start_repo_version}, path={path}".format( + exporter=exporter.name, + publication=publication.pk, + start_repo_version=start_repo_version_pk, + path=exporter.path, + ) + ) + + if not _export_location_is_clean(exporter.path): + raise RuntimeError(_("Cannot export to directories that contain existing data.")) + + _export_publication_to_file_system( + exporter.path, publication, start_repo_version=start_repo_version, method=exporter.method + ) + + +def fs_repo_version_export(exporter_pk, repo_version_pk, start_repo_version_pk=None): + """ + Export a repository version to the file system using an exporter. + + Args: + exporter_pk (str): FilesystemExporter pk + repo_version_pk (str): RepositoryVersion pk + """ + exporter = Exporter.objects.get(pk=exporter_pk).cast() + repo_version = RepositoryVersion.objects.get(pk=repo_version_pk) + start_repo_version = None + if start_repo_version_pk: + start_repo_version = RepositoryVersion.objects.get(pk=start_repo_version_pk) + + params = {"repository_version": repo_version_pk} + if start_repo_version: + params["start_repository_version"] = start_repo_version_pk + + export = FilesystemExport.objects.create( + exporter=exporter, + params=params, + task=Task.current(), + ) + ExportedResource.objects.create(export=export, content_object=repo_version) + CreatedResource.objects.create(content_object=export) + + log.info( + "Exporting: file_system_exporter={exporter}, repo_version={repo_version}, " + "start_repo_version={start_repo_version}, path={path}".format( + exporter=exporter.name, + repo_version=repo_version.pk, + start_repo_version=start_repo_version_pk, + path=exporter.path, + ) + ) + + content_artifacts = ContentArtifact.objects.filter(content__in=repo_version.content) + _validate_fs_export(content_artifacts) + difference_content_artifacts = [] + if start_repo_version: + start_version_content_artifacts = ContentArtifact.objects.filter( + artifact__in=start_repo_version.artifacts + ) + difference_content_artifacts = set( + content_artifacts.difference(start_version_content_artifacts).values_list( + "pk", flat=True + ) + ) + + # Map ContentArtifact pk -> local path for any streamed file:// content, so it can + # be exported directly even though it has no downloaded Artifact. + ca_pk_to_local_path = _file_url_local_paths(content_artifacts) + + relative_path_to_artifacts = {} + relative_path_to_local_paths = {} + for ca in content_artifacts.select_related("artifact").iterator(): + if start_repo_version is None or ca.pk in difference_content_artifacts: + if ca.artifact: + relative_path_to_artifacts[ca.relative_path] = ca.artifact + elif ca.pk in ca_pk_to_local_path: + relative_path_to_local_paths[ca.relative_path] = ca_pk_to_local_path[ca.pk] + + _export_to_file_system(exporter.path, relative_path_to_artifacts, exporter.method) + if relative_path_to_local_paths: + _export_local_to_file_system(exporter.path, relative_path_to_local_paths, exporter.method) + + +def _get_versions_to_export(the_exporter, the_export): + """ + Return repo-versions to be exported. + + versions is based on exporter-repositories and how the export-cmd was + invoked. + """ + repositories = the_exporter.repositories.all() + # Figure out which RepositoryVersions we're going to be exporting + # Use repo.latest unless versions= was specified + if the_export.validated_versions is not None: + versions = the_export.validated_versions + else: + versions = [r.latest_version() for r in repositories] + return versions + + +def _get_starting_versions(do_incremental, the_exporter, the_export): + # list-of-previous-versions, or None + if do_incremental: + if the_export.validated_start_versions: + prev_versions = the_export.validated_start_versions + else: + prev_versions = [ + er.content_object + for er in ExportedResource.objects.filter(export=the_exporter.last_export).all() + ] + else: + prev_versions = None + return prev_versions + + +def _get_versions_info(the_exporter): + """ + Return plugin-version-info based on plugins are responsible for exporter-repositories. + """ + # extract plugin-version-info based on the repositories we're exporting from + repositories = the_exporter.repositories.all() + repo_types = {r.pulp_type for r in repositories} + app_labels = {Repository.get_model_for_pulp_type(rt)._meta.app_label for rt in repo_types} + app_labels.add("core") + vers_info = [(app_label, get_plugin_config(app_label).version) for app_label in app_labels] + return vers_info + + +def _version_match(curr_versions, prev_versions): + """ + Match list of repo-versions, to a prev-set, based on belonging to the same repository. + + We need this in order to be able to know how to 'diff' versions for incremental exports. + + :param curr_versions ([model.RepositoryVersion]): versions we want to export + :param prev_versions ([model.RepositoryVersion]): last set of versions we exported (if any) + :return: { : , or None if no match or no prev_versions } + """ + curr_to_repo = {v.repository: v for v in curr_versions} + if prev_versions is None: + return {curr_to_repo[repo]: None for repo in curr_to_repo} + else: + prev_to_repo = {v.repository: v for v in prev_versions} + return {curr_to_repo[repo]: prev_to_repo[repo] for repo in curr_to_repo} + + +def _incremental_requested(the_export): + """Figure out that a) an incremental is requested, and b) it's possible.""" + the_exporter = the_export.exporter + full = _ensure_bool(the_export.params.get("full", True)) + starting_versions_provided = len(the_export.params.get("start_versions", [])) > 0 + last_exists = the_exporter.last_export + return (starting_versions_provided or last_exists) and not full + + +def pulp_export(exporter_pk, params): + """ + Create a PulpExport to export pulp_exporter.repositories. + + 1) Spit out all Artifacts, ArtifactResource.json, and RepositoryResource.json + 2) Spit out all *resource JSONs in per-repo-version directories + 3) Compute and store the sha256 and filename of the resulting tar/chunks + + Args: + exporter_pk (str): PulpExporter + params (dict): request data + + Raises: + ValidationError: When path is not in the ALLOWED_EXPORT_PATHS setting, + OR path exists and is not a directory + """ + pulp_exporter = PulpExporter.objects.get(pk=exporter_pk) + serializer = PulpExportSerializer(data=params, context={"exporter": pulp_exporter}) + serializer.is_valid(raise_exception=True) + the_export = PulpExport.objects.create(exporter=pulp_exporter, params=params) + the_export.validated_versions = serializer.validated_data.get("versions", None) + the_export.validated_start_versions = serializer.validated_data.get("start_versions", None) + the_export.validated_chunk_size = serializer.validated_data.get("chunk_size", None) + + meta = serializer.validated_data.get("meta", {}) + + hasher = Crc32Hasher + checksum_type = "crc32" + try: + the_export.task = Task.current() + + tarfile_fp = the_export.export_tarfile_path() + + path = Path(pulp_exporter.path) + if not path.is_dir(): + path.mkdir(mode=0o775, parents=True) + + writer = HashingFileWriter( + base_path=tarfile_fp, + chunk_size=the_export.validated_chunk_size or 0, + hasher_cls=hasher, + ) + + try: + with writer: + with tarfile.open(fileobj=writer, mode="w|") as tar: + _do_export(pulp_exporter, tar, the_export) + except Exception: + # no matter what went wrong, we can't trust the files we (may have) created. + # Delete the ones we can find and pass the problem up. + for pathname in glob(tarfile_fp + ".*"): + os.remove(pathname) + raise + + rslts = writer.results + + # store the outputfile/hash info + the_export.output_file_info = rslts + + # write outputfile/hash info to a file 'next to' the output file(s) + output_file_info_path = tarfile_fp.replace(".tar", "-toc.json") + with open(output_file_info_path, "w") as outfile: + table_of_contents = { + "meta": { + **meta, + "checksum_type": checksum_type, + }, + "files": {}, + } + + if the_export.validated_chunk_size: + table_of_contents["meta"]["chunk_size"] = the_export.validated_chunk_size + + # Build a toc with just filenames (not the path on the exporter-machine) + for a_path in rslts.keys(): + table_of_contents["files"][os.path.basename(a_path)] = rslts[a_path] + json.dump(table_of_contents, outfile) + + # store toc info + toc_hash = compute_file_hash(output_file_info_path) + the_export.output_file_info[output_file_info_path] = toc_hash + the_export.toc_info = {"file": output_file_info_path, "sha256": toc_hash, "meta": meta} + finally: + # whatever may have happened, make sure we save the export + the_export.save() + + # If an exception was thrown, we'll never get here - which is good, because we don't want a + # 'failed' export to be the last_export we derive the next incremental from + # mark it as 'last' + pulp_exporter.last_export = the_export + # save the exporter + pulp_exporter.save() + + +def _do_export(pulp_exporter, tar, the_export): + the_export.tarfile = tar + CreatedResource.objects.create(content_object=the_export) + plugin_version_info = _get_versions_info(pulp_exporter) + + # export plugin-version-info + export_versions(the_export, plugin_version_info) + + ending_versions = _get_versions_to_export(pulp_exporter, the_export) + do_incremental = _incremental_requested(the_export) + starting_versions = _get_starting_versions(do_incremental, pulp_exporter, the_export) + vers_match = _version_match(ending_versions, starting_versions) + + # Gather up versions and artifacts + artifact_pks = set() + for version in ending_versions: + # Check version-content to make sure we're not being asked to export + # an on_demand repo + content_artifacts = ContentArtifact.objects.filter(content__in=version.content) + if content_artifacts.filter(artifact=None).exists(): + raise RuntimeError(_("Remote artifacts cannot be exported.")) + + if do_incremental: + vers_artifacts = version.artifacts.difference(vers_match[version].artifacts) + else: + vers_artifacts = version.artifacts + artifact_pks.update(vers_artifacts.values_list("pk", flat=True)) + + # Export the top-level entities (artifacts and repositories) + # Note: we've already handled "what about incrementals" when building the 'artifacts' list + export_artifacts(the_export, list(artifact_pks)) + del artifact_pks + + # Export the repository-version data, per-version + for version in ending_versions: + export_content(the_export, version) + ExportedResource.objects.create(export=the_export, content_object=version) diff --git a/roles/pulp/files/nginx.sh b/roles/pulp/files/nginx.sh new file mode 100644 index 0000000..8a5a79e --- /dev/null +++ b/roles/pulp/files/nginx.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# This logic enables us to have multiple servers, and check to see +# if they are scaled every 10 seconds. +# https://serverfault.com/a/821625/189494 +# https://www.nginx.com/blog/dns-service-discovery-nginx-plus#domain-name-variable + +set -e + +if [ "$container" == "podman" ]; then + # the nameserver list under podman is unreliable. + # It will look like "10.89.1.1 192.168.1.1 192.168.1.1", but only the 1st IP works. + # This doesn't mess up `nslookup`, but it messes up `getent hosts` and nginx. + export NAMESERVER=`cat /etc/resolv.conf | grep "nameserver" | awk '{print $2}' | head -n1` +else + export NAMESERVER=`cat /etc/resolv.conf | grep "nameserver" | awk '{print $2}' | tr '\n' ' '` +fi + +echo "Nameserver is: $NAMESERVER" + +echo "Generating nginx config" +envsubst '$NAMESERVER' < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf + +# Note: the nginx.conf.template already proxies to $pulp_api:24817 / +# $pulp_content:24816 via the resolver, so no per-file rewrite of +# /etc/nginx/pulp/*.conf is needed (and that dir is empty in pulp-web). + +echo "Starting nginx" +exec nginx -g "daemon off;" diff --git a/roles/pulp/files/passwd b/roles/pulp/files/passwd new file mode 100644 index 0000000..0f9afc2 --- /dev/null +++ b/roles/pulp/files/passwd @@ -0,0 +1,20 @@ +root:x:0:0:root:/root:/bin/bash +daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin +bin:x:2:2:bin:/bin:/usr/sbin/nologin +sys:x:3:3:sys:/dev:/usr/sbin/nologin +sync:x:4:65534:sync:/bin:/bin/sync +games:x:5:60:games:/usr/games:/usr/sbin/nologin +man:x:6:12:man:/var/cache/man:/usr/sbin/nologin +lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin +mail:x:8:8:mail:/var/mail:/usr/sbin/nologin +news:x:9:9:news:/var/spool/news:/usr/sbin/nologin +uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin +proxy:x:13:13:proxy:/bin:/usr/sbin/nologin +www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin +backup:x:34:34:backup:/var/backups:/usr/sbin/nologin +list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin +irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin +gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin +nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin +_apt:x:100:65534::/nonexistent:/usr/sbin/nologin +postgres:x:26:26::/var/lib/postgresql:/bin/bash diff --git a/roles/pulp/files/token_private_key.pem.vault b/roles/pulp/files/token_private_key.pem.vault new file mode 100644 index 0000000..d85ffd9 --- /dev/null +++ b/roles/pulp/files/token_private_key.pem.vault @@ -0,0 +1,17 @@ +$ANSIBLE_VAULT;1.1;AES256 +64376562336531646534356665666336373234356639313931373532366332373761393430316431 +3339336536306362376235646665633665623834653636360a343063646265316336663563323266 +66316234313931393537326262636534333463333634376535373265346532396263396436306337 +6362316532663730380a616631343664323965646133666461306139363534306263383834623636 +62386132306137356233343435386231333464643333663535363030666130633436306462656238 +32323363323461393764633263303864376562393163613361313930626135383565393234333338 +66356538313335316665623134333230653035373361306633363835326538333639306463356133 +31316632313736316436356231616263393765303533386333633165356637663336376362663731 +63363134343238323533613635653032343934663563333165343130666334393634373130383233 +66633734626661656365336465393036636434613839616261623464656161393630363364656237 +30376161323862626139363237326239363332666362323834373333333735653535643861353139 +66393034643061326563313735333835613830666633653234346263376564393534613961626264 +30643830623336343636373932626238316437343864643866323261616433303863373136616130 +38346530333966323766653963633636616330643532326139323463626664663462323231366163 +39666263353430303361313466353935303134326337346238656432373737356163326631323734 +37336462336533373830 diff --git a/roles/pulp/files/token_public_key.pem.vault b/roles/pulp/files/token_public_key.pem.vault new file mode 100644 index 0000000..86e41ef --- /dev/null +++ b/roles/pulp/files/token_public_key.pem.vault @@ -0,0 +1,14 @@ +$ANSIBLE_VAULT;1.1;AES256 +38316638333663663037346439336265616665636636386632633731393761346531323366313331 +3438323937626131383630623632613863616236316237330a643663306664333735633839383139 +61326537663166323364376537353239666265323839353330333863643436333361656535643731 +6535646439303631640a393239656430353136626238616130366135373365343761636337353166 +61373632633563333137343734666466366236343538393263326166333233313263323239633032 +62353032623033356235613938663663373539396561626266616663653766613035633364623931 +36663738636363656434316264626564633434613063366430306362633932343333646136373163 +36646263656433346361666636333366653738376535663038633561366433363633333166316332 +62666666396237326135633232336633633631643239646534393936313165346630316431646361 +66386639653739666230633237343561623737643839326430396539333266626664626432376631 +31333366326239343732326361616533333463643265636633333965616132356435626564656434 +64303865303232346132393831633532363165633865303239353834366566636532393538326663 +39653633326465356461396535336435303663646635326138336232356265323666 diff --git a/roles/pulp/handlers/main.yml b/roles/pulp/handlers/main.yml new file mode 100644 index 0000000..47491bf --- /dev/null +++ b/roles/pulp/handlers/main.yml @@ -0,0 +1,122 @@ +--- +# Recreate/restart containers so changed config (settings.py, nginx, compose file +# or Quadlet units) and freshly built images are picked up. Mounted files are only +# re-read on container restart. +# +# Every asset task notifies "Restart Pulp stack"; the handlers below all listen on +# that name and are guarded by pulp_orchestrator, so exactly one path runs. Handler +# order is definition order, which is why the daemon-reload comes before the unit +# restart — a newly rendered .container file does not exist as a systemd unit until +# the generator has run. + +# ── Quadlet ──────────────────────────────────────────────────────────────── +- name: Reload systemd so Quadlet regenerates the units + ansible.builtin.systemd: + daemon_reload: true + listen: + - Restart Pulp stack + - Restart Pulp data stores + - Restart Pulp collector + when: pulp_orchestrator == 'quadlet' + +# Data-store units (postgres/redis, their volumes, the shared network) mount none +# of the rendered config assets, so a settings.py change deliberately leaves them +# alone. But a change to one of THEIR unit files — image bump, exposed port, +# volume swap — has to be applied, and a daemon-reload alone does not restart a +# running service. Only the render tasks for those units notify this topic. +- name: Restart the Pulp data-store services + ansible.builtin.systemd: + name: "{{ item }}" + state: restarted + loop: "{{ pulp_quadlet_datastore_units }}" + listen: Restart Pulp data stores + when: + - pulp_orchestrator == 'quadlet' + - pulp_start_stack | bool + +# ── Compose ──────────────────────────────────────────────────────────────── +- name: Restart Pulp stack + ansible.builtin.command: + cmd: >- + {{ pulp_compose_cmd }} -f compose.yml up -d --force-recreate --remove-orphans + --scale pulp_api={{ pulp_api_replicas }} + --scale pulp_content={{ pulp_content_replicas }} + --scale pulp_worker={{ pulp_worker_replicas }} + chdir: "{{ pulp_project_dir }}" + changed_when: true + when: + - pulp_orchestrator == 'compose' + - pulp_start_stack | bool + +# Re-run the schema migration BEFORE the services that depend on it restart. +# +# pulp-migration is Type=oneshot + RemainAfterExit=yes, so it stays +# "active (exited)" and the `state: started` task in main.yml is a no-op on it. +# Without this handler an image-tag bump would restart api/content/worker onto the +# NEW code against the OLD schema — the compose path avoids that because +# `up -d --force-recreate` recreates migration_service. `migrate` with nothing +# pending is a cheap no-op, so running it on every config change is safe. +# +# Deliberately NOT restarting pulp-init-password: re-running that resets the +# admin password. Signing registration is left alone for the same +# don't-touch-it-unless-asked reason. +# +# Ordering matters and is why this sits above the runtime restart: api/content/ +# worker carry Requires=pulp-migration.service, so if systemd propagates the +# deactivation to them, the next handler brings them straight back up. +- name: Re-run the Pulp database migration + ansible.builtin.systemd: + name: pulp-migration.service + state: restarted + listen: Restart Pulp stack + when: + - pulp_orchestrator == 'quadlet' + - pulp_start_stack | bool + +# Restart only the services that mount the changed config — not the whole stack. +# Redis mounts none of it and the one-shots have already completed, so leaving +# them alone avoids a needless cache flush and a re-run of `migrate` on every +# config tweak. (This granularity is the main day-2 win over the compose path, +# which force-recreates every container including the data stores.) +- name: Restart the Pulp Quadlet services + ansible.builtin.systemd: + name: "{{ item }}" + state: restarted + loop: "{{ pulp_quadlet_runtime_units }}" + listen: Restart Pulp stack + when: + - pulp_orchestrator == 'quadlet' + - pulp_start_stack | bool + +# ── Telemetry ────────────────────────────────────────────────────────────── +# The collector config is mounted into the collector only, so restart just that +# container. Both orchestrator paths are guarded, so exactly one runs. +- name: Restart the OpenTelemetry collector (compose) + ansible.builtin.command: + cmd: >- + {{ pulp_compose_cmd }} -f compose.yml up -d --force-recreate + {{ pulp_otel_collector_name }} + chdir: "{{ pulp_project_dir }}" + changed_when: true + listen: Restart Pulp collector + when: + - pulp_orchestrator == 'compose' + - pulp_otel_collector_enabled | bool + - pulp_start_stack | bool + +- name: Restart the OpenTelemetry collector (Quadlet) + ansible.builtin.systemd: + name: otel-collector.service + state: restarted + listen: Restart Pulp collector + when: + - pulp_orchestrator == 'quadlet' + - pulp_otel_collector_enabled | bool + - pulp_start_stack | bool + +# journald re-reads its config on restart. Safe: it hands sockets off across the +# restart, so no log entries are dropped. +- name: Restart systemd-journald + ansible.builtin.systemd: + name: systemd-journald + state: restarted diff --git a/roles/pulp/tasks/build_image.yml b/roles/pulp/tasks/build_image.yml new file mode 100644 index 0000000..f572787 --- /dev/null +++ b/roles/pulp/tasks/build_image.yml @@ -0,0 +1,62 @@ +--- +- name: Assert the vendored source patches match the image pulpcore version + ansible.builtin.assert: + that: pulp_image_tag == pulp_source_patches_pulpcore_version + fail_msg: >- + The vendored pulpcore source patches + ({{ pulp_source_patches | map(attribute='src') | join(', ') }} under + roles/pulp/files/) are pinned to pulpcore + {{ pulp_source_patches_pulpcore_version }} but the image tag is + {{ pulp_image_tag }}. Each patch vendors a WHOLE module, so they are very + likely stale against the new version. Re-derive every file from the + {{ pulp_image_tag }} upstream, set pulp_source_patches_pulpcore_version to + {{ pulp_image_tag }}, or disable pulp_source_patches_enabled. + when: pulp_source_patches_enabled | bool + +- name: Ensure the patches directory exists in the build context + ansible.builtin.file: + path: "{{ pulp_project_dir }}/patches" + state: directory + mode: "0755" + when: pulp_source_patches_enabled | bool + +- name: Stage the vendored source patches into the build context + ansible.builtin.copy: + src: "{{ item.src }}" + dest: "{{ pulp_project_dir }}/patches/{{ item.src }}" + mode: "0644" + loop: "{{ pulp_source_patches }}" + loop_control: + label: "{{ item.src }}" + register: pulp_source_patches_staged + when: pulp_source_patches_enabled | bool + +- name: Render the custom-image Containerfile + ansible.builtin.template: + src: Containerfile.j2 + dest: "{{ pulp_project_dir }}/Containerfile" + mode: "0644" + register: pulp_containerfile + +- name: Check whether the custom image already exists + ansible.builtin.command: + cmd: "{{ pulp_container_cli }} image inspect {{ pulp_custom_image_name }}" + register: pulp_image_present + changed_when: false + failed_when: false + +- name: Pull the latest pulp-minimal base image + ansible.builtin.command: + cmd: "{{ pulp_container_cli }} pull {{ pulp_minimal_image }}" + register: pulp_base_pull + changed_when: "'Downloaded newer image' in pulp_base_pull.stdout" + when: pulp_containerfile is changed or pulp_image_present.rc != 0 or pulp_source_patches_staged is changed + +- name: Build the custom Pulp image + ansible.builtin.command: + cmd: "{{ pulp_container_cli }} build --pull -t {{ pulp_custom_image_name }} -f Containerfile ." + chdir: "{{ pulp_project_dir }}" + register: pulp_image_build + changed_when: true + when: pulp_containerfile is changed or pulp_image_present.rc != 0 or pulp_source_patches_staged is changed + notify: Restart Pulp stack diff --git a/roles/pulp/tasks/deploy.yml b/roles/pulp/tasks/deploy.yml new file mode 100644 index 0000000..b968709 --- /dev/null +++ b/roles/pulp/tasks/deploy.yml @@ -0,0 +1,197 @@ +--- +- name: Create the compose project directory tree + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ pulp_project_dir }}" + - "{{ pulp_assets_dir }}" + - "{{ pulp_assets_dir }}/bin" + - "{{ pulp_assets_dir }}/nginx" + - "{{ pulp_assets_dir }}/postgres" + - "{{ pulp_assets_dir }}/certs" + - "{{ pulp_assets_dir }}/otel" + +- name: Install the pulp-web nginx launcher script + ansible.builtin.copy: + src: nginx.sh + dest: "{{ pulp_assets_dir }}/bin/nginx.sh" + mode: "0755" + notify: Restart Pulp stack + when: pulp_web_enabled | bool + +- name: Render the nginx config template + ansible.builtin.template: + src: nginx.conf.template.j2 + dest: "{{ pulp_assets_dir }}/nginx/nginx.conf.template" + mode: "0644" + notify: Restart Pulp stack + when: pulp_web_enabled | bool + +# Mounted into the collector only, so a change here restarts just the collector +# — not the Pulp services, which never read it. +- name: Render the OpenTelemetry collector config + ansible.builtin.template: + src: otel-collector-config.yaml.j2 + dest: "{{ pulp_assets_dir }}/otel/config.yaml" + mode: "0644" + notify: Restart Pulp collector + when: pulp_otel_collector_enabled | bool + +- name: Install the postgres passwd file + ansible.builtin.copy: + src: passwd + dest: "{{ pulp_assets_dir }}/postgres/passwd" + mode: "0644" + +# ── Bind-mount data directories (migration from systemd) ──────────────────── +- name: Ensure host data directories exist for bind mounts + ansible.builtin.file: + path: "{{ item }}" + state: directory + loop: "{{ [pulp_storage_dir, pulp_postgres_data_dir, pulp_redis_data_dir, pulp_exports_dir] | select | list }}" + +- name: Set ownership on the exports bind mount (in-container pulp uid/gid) + ansible.builtin.file: + path: "{{ pulp_exports_dir }}" + owner: "{{ pulp_storage_owner }}" + group: "{{ pulp_storage_group }}" + when: pulp_exports_dir | length > 0 + +# A bind-mounted store shadows the subdirectories the image pre-creates under +# DEPLOY_ROOT, so pulpcore's startup check fails (files.E001: /tmp missing). +# Recreate them with the in-container pulp uid/gid. pulp_deploy_root_host is the +# host path of DEPLOY_ROOT (defaults to the storage bind source; differs in the +# shared-parent layout). (Named-volume mode is fine.) +- name: Ensure pulp storage subdirectories exist on the bind mount + ansible.builtin.file: + path: "{{ pulp_deploy_root_host }}/{{ item }}" + state: directory + owner: "{{ pulp_storage_owner }}" + group: "{{ pulp_storage_group }}" + mode: "2775" + loop: + - tmp + - media + - assets + - scripts + when: pulp_storage_dir | length > 0 + +- name: Set ownership on the pulp storage bind mount (in-container pulp uid/gid) + ansible.builtin.file: + path: "{{ pulp_storage_dir }}" + owner: "{{ pulp_storage_owner }}" + group: "{{ pulp_storage_group }}" + recurse: true + when: + - pulp_storage_dir | length > 0 + - pulp_storage_set_owner | bool + +# ── TLS material ───────────────────────────────────────────────────────────── +- name: Provision TLS certificate + ansible.builtin.include_tasks: tls.yml + when: pulp_https | bool + +# ── Database fields encryption (Fernet) key ───────────────────────────────── +# Provided key wins (migration: reuse the ORIGINAL key). Otherwise generate one +# only for a new deployment. A missing key with generation disabled is fatal. +- name: Install the provided database fields encryption key + ansible.builtin.copy: + src: "{{ pulp_db_encryption_key_src }}" + dest: "{{ pulp_assets_dir }}/certs/database_fields.symmetric.key" + mode: "0640" + force: false + when: pulp_db_encryption_key_src | length > 0 + notify: Restart Pulp stack + +# Write the Fernet key straight to the file (only when absent), so its content +# is never the command's stdout/skip message. 32 random bytes, base64-encoded. +- name: Generate the database fields encryption key + ansible.builtin.shell: + cmd: "openssl rand -base64 32 > {{ pulp_assets_dir }}/certs/database_fields.symmetric.key" + creates: "{{ pulp_assets_dir }}/certs/database_fields.symmetric.key" + when: + - pulp_generate_db_encryption_key | bool + - pulp_db_encryption_key_src | length == 0 + notify: Restart Pulp stack + +- name: Check the database fields encryption key is present + ansible.builtin.stat: + path: "{{ pulp_assets_dir }}/certs/database_fields.symmetric.key" + register: pulp_db_key_stat + +- name: Fail when the database fields encryption key is missing + ansible.builtin.assert: + that: pulp_db_key_stat.stat.exists + fail_msg: >- + No database fields encryption key at + {{ pulp_assets_dir }}/certs/database_fields.symmetric.key and generation is + disabled. For an existing deployment provide the ORIGINAL key via + pulp_db_encryption_key_src (or pre-place it on the host) — a new/wrong key + makes already-encrypted DB fields permanently unreadable. + +- name: Enforce permissions on the database fields encryption key + ansible.builtin.file: + path: "{{ pulp_assets_dir }}/certs/database_fields.symmetric.key" + owner: "{{ pulp_storage_owner }}" + group: "{{ pulp_storage_group }}" + mode: "0640" + +# ── pulp_container token auth ES256 keypair ───────────────────────────────── +# Copied in only when a source is provided (e.g. migrating an existing +# deployment). Unused when token auth is disabled. +- name: Install the token auth public key + ansible.builtin.copy: + src: "{{ pulp_token_public_key_src }}" + dest: "{{ pulp_assets_dir }}/certs/token_public_key.pem" + owner: "{{ pulp_storage_owner }}" + group: "{{ pulp_storage_group }}" + mode: "0644" + force: false + when: pulp_token_public_key_src | length > 0 + notify: Restart Pulp stack + +- name: Install the token auth private key + ansible.builtin.copy: + src: "{{ pulp_token_private_key_src }}" + dest: "{{ pulp_assets_dir }}/certs/token_private_key.pem" + owner: "{{ pulp_storage_owner }}" + group: "{{ pulp_storage_group }}" + mode: "0640" + force: false + when: pulp_token_private_key_src | length > 0 + notify: Restart Pulp stack + +- name: Check the token auth keypair is present (when token auth enabled) + ansible.builtin.stat: + path: "{{ pulp_assets_dir }}/certs/{{ item }}" + loop: + - token_public_key.pem + - token_private_key.pem + register: pulp_token_key_stat + when: not (pulp_token_auth_disabled | bool) + +- name: Fail when token auth is enabled but the keypair is missing + ansible.builtin.assert: + that: pulp_token_key_stat.results | map(attribute='stat.exists') | select | list | length == 2 + fail_msg: >- + pulp_token_auth_disabled is false but the ES256 keypair is missing. Provide + it via pulp_token_public_key_src / pulp_token_private_key_src (or pre-place + token_{public,private}_key.pem in {{ pulp_assets_dir }}/certs). + when: not (pulp_token_auth_disabled | bool) + +- name: Render Pulp settings.py + ansible.builtin.template: + src: settings.py.j2 + dest: "{{ pulp_assets_dir }}/settings.py" + mode: "0644" + notify: Restart Pulp stack + +- name: Render the compose file + ansible.builtin.template: + src: compose.yml.j2 + dest: "{{ pulp_project_dir }}/compose.yml" + mode: "0644" + when: pulp_orchestrator == 'compose' + notify: Restart Pulp stack diff --git a/roles/pulp/tasks/install_engine.yml b/roles/pulp/tasks/install_engine.yml new file mode 100644 index 0000000..d006554 --- /dev/null +++ b/roles/pulp/tasks/install_engine.yml @@ -0,0 +1,192 @@ +--- +# Installs the selected container engine (pulp_container_engine) and its compose +# tool for RHEL- and Debian-family hosts. +# docker -> Docker CE + the compose v2 plugin (daemon-based) +# podman -> podman + podman-compose (daemonless, run rootful via the play's sudo) + +# ═══════════════════════════════════════════════════════════════════════════ +# Docker +# ═══════════════════════════════════════════════════════════════════════════ +- name: Install Docker + when: pulp_container_engine == 'docker' + block: + # ── RHEL family (AlmaLinux / RHEL / Rocky / CentOS Stream / Fedora) ────── + - name: Add the Docker CE yum repository + ansible.builtin.yum_repository: + name: docker-ce-stable + description: Docker CE Stable - $basearch + baseurl: "https://download.docker.com/linux/centos/$releasever/$basearch/stable" + gpgcheck: true + gpgkey: https://download.docker.com/linux/centos/gpg + enabled: true + when: ansible_os_family == "RedHat" + + - name: Install Docker CE (RHEL family) + ansible.builtin.dnf: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + state: present + when: ansible_os_family == "RedHat" + + # ── Debian family (Debian / Ubuntu) ───────────────────────────────────── + - name: Install prerequisites (Debian family) + ansible.builtin.apt: + name: + - ca-certificates + - curl + - gnupg + state: present + update_cache: true + when: ansible_os_family == "Debian" + + - name: Add Docker's APT GPG key (Debian family) + ansible.builtin.get_url: + url: "https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg" + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + when: ansible_os_family == "Debian" + + - name: Add the Docker CE APT repository (Debian family) + ansible.builtin.apt_repository: + repo: >- + deb [arch={{ 'amd64' if ansible_architecture == 'x86_64' else ansible_architecture }} + signed-by=/etc/apt/keyrings/docker.asc] + https://download.docker.com/linux/{{ ansible_distribution | lower }} + {{ ansible_distribution_release }} stable + filename: docker + state: present + when: ansible_os_family == "Debian" + + - name: Install Docker CE (Debian family) + ansible.builtin.apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + state: present + update_cache: true + when: ansible_os_family == "Debian" + + # ── Common ─────────────────────────────────────────────────────────────── + - name: Ensure Docker is enabled and running + ansible.builtin.service: + name: docker + state: started + enabled: true + +# ═══════════════════════════════════════════════════════════════════════════ +# Podman (rootful, daemonless). No engine daemon to supervise the stack, so +# restart:always is enforced only by conmon while the host stays up — it does +# NOT survive a reboot. podman-restart.service (ships with podman) closes that +# gap: on boot it re-starts every container whose policy is restart=always, +# which is all our long-lived services. Docker doesn't need this — its enabled +# daemon re-applies restart policies itself. +# ═══════════════════════════════════════════════════════════════════════════ +- name: Install Podman + when: pulp_container_engine == 'podman' + block: + # ── RHEL family ────────────────────────────────────────────────────────── + # podman ships in AppStream; podman-compose comes from EPEL. Enable EPEL so + # podman-compose is resolvable on a minimal host. + # podman-compose is only needed by the compose orchestrator; Quadlet ships + # with podman itself, so the EPEL dependency drops out entirely there. + - name: Enable EPEL (RHEL family, for podman-compose) + ansible.builtin.dnf: + name: epel-release + state: present + when: + - ansible_os_family == "RedHat" + - pulp_orchestrator == 'compose' + + - name: Install Podman + podman-compose (RHEL family) + ansible.builtin.dnf: + name: "{{ ['podman'] + (['podman-compose'] if pulp_orchestrator == 'compose' else []) }}" + state: present + when: ansible_os_family == "RedHat" + + # ── Debian family ──────────────────────────────────────────────────────── + - name: Install Podman + podman-compose (Debian family) + ansible.builtin.apt: + name: "{{ ['podman'] + (['podman-compose'] if pulp_orchestrator == 'compose' else []) }}" + state: present + update_cache: true + when: ansible_os_family == "Debian" + + # ── Quadlet availability ──────────────────────────────────────────────── + # Quadlet template-free rendering needs Notify=healthy (Podman 5.0+). Older + # podman silently ignores unknown keys, which would leave dependents starting + # before Redis is ready — so check rather than trust the distro. + # check_mode: false so this runs during --check too. A command task is skipped + # in check mode but still registers, leaving stdout empty — and the assert + # below then dies on "Input version value cannot be empty" rather than telling + # you anything about the host. Reading a version is read-only, so running it + # is safe in a dry run. + - name: Read the installed Podman version + ansible.builtin.command: podman version --format '{{ "{{" }}.Client.Version{{ "}}" }}' + register: pulp_podman_version + changed_when: false + check_mode: false + when: pulp_orchestrator == 'quadlet' + + - name: Require Podman 5.0+ for the Quadlet orchestrator + ansible.builtin.assert: + that: pulp_podman_version.stdout | trim is version('5.0', '>=') + fail_msg: >- + pulp_orchestrator is "quadlet" but this host has Podman + {{ pulp_podman_version.stdout | trim }}. The units use Notify=healthy, + which needs Podman 5.0 or newer; older versions ignore it and start + api/content/worker before Redis is ready. Upgrade Podman or use + pulp_orchestrator: compose. + when: pulp_orchestrator == 'quadlet' + + # ── Boot persistence ───────────────────────────────────────────────────── + # Restart restart=always containers after a host reboot. Without this the + # daemonless stack stays down until the next `podman-compose up` (or Ansible + # run). Guarded so it can be turned off where an external supervisor owns the + # stack lifecycle. + # Not needed under Quadlet: each unit carries [Install] WantedBy=multi-user.target + # and its own RequiresMountsFor=, so systemd owns boot ordering per service + # instead of one host-wide policy replay. + - name: Enable podman-restart.service (restore the stack after reboot) + ansible.builtin.systemd: + name: podman-restart.service + enabled: true + when: + - pulp_orchestrator == 'compose' + - pulp_podman_restart_service | bool + + # ── Boot ordering for host bind-mount stores ───────────────────────────── + # podman-restart.service orders itself only After=network-online.target, so + # on a host whose storage/exports live on an NFS/EFS bind mount it can start + # containers before that mount is ready (empty store, or start failure). + # A RequiresMountsFor drop-in makes it wait for — and require — the backing + # mount(s). Only meaningful when at least one store is a host bind mount. + - name: Order podman-restart.service after the host bind-mount stores + when: + - pulp_orchestrator == 'compose' + - pulp_podman_restart_service | bool + - pulp_podman_bind_mount_paths | length > 0 + block: + - name: Create the podman-restart.service drop-in directory + ansible.builtin.file: + path: /etc/systemd/system/podman-restart.service.d + state: directory + mode: "0755" + + - name: Write the RequiresMountsFor drop-in + ansible.builtin.template: + src: podman-restart-mounts.conf.j2 + dest: /etc/systemd/system/podman-restart.service.d/10-wait-for-storage.conf + mode: "0644" + register: pulp_podman_restart_dropin + + - name: Reload systemd so the drop-in takes effect + ansible.builtin.systemd: + daemon_reload: true + when: pulp_podman_restart_dropin is changed diff --git a/roles/pulp/tasks/journald.yml b/roles/pulp/tasks/journald.yml new file mode 100644 index 0000000..0aa0542 --- /dev/null +++ b/roles/pulp/tasks/journald.yml @@ -0,0 +1,17 @@ +--- +# Codify journald retention for the Pulp stack so log rotation lives with the +# deployment. Written as a drop-in so the distro's base journald.conf is left +# intact. Only included when pulp_log_driver == "journald" and the operator +# opts in via pulp_journald_configure. +- name: Ensure the journald drop-in directory exists + ansible.builtin.file: + path: /etc/systemd/journald.conf.d + state: directory + mode: "0755" + +- name: Configure journald retention for the Pulp stack + ansible.builtin.template: + src: journald-pulp.conf.j2 + dest: /etc/systemd/journald.conf.d/pulp.conf + mode: "0644" + notify: Restart systemd-journald diff --git a/roles/pulp/tasks/main.yml b/roles/pulp/tasks/main.yml new file mode 100644 index 0000000..593a151 --- /dev/null +++ b/roles/pulp/tasks/main.yml @@ -0,0 +1,133 @@ +--- +- name: Validate external PostgreSQL configuration + ansible.builtin.assert: + that: + - pulp_postgres_host | length > 0 + - pulp_postgres_host != 'postgres' + - pulp_postgres_password | length > 0 + fail_msg: >- + pulp_postgres_external is true but the connection is not configured. Set + pulp_postgres_host to your RDS endpoint (not the default 'postgres') and + provide pulp_postgres_password (ideally via ansible-vault). + when: pulp_postgres_external | bool + +- name: Validate reverse-proxy / replica configuration + ansible.builtin.assert: + that: + - (pulp_api_replicas | int) == 1 + - (pulp_content_replicas | int) == 1 + fail_msg: >- + pulp_web is disabled, so the api/content gunicorn ports are published on + fixed host ports (pulp_api_host_port / pulp_content_host_port) and cannot + fan out to multiple replicas. Set pulp_api_replicas and + pulp_content_replicas to 1 and scale a single instance via + pulp_api_workers / pulp_content_workers instead. + when: not (pulp_web_enabled | bool) + +# ── Orchestrator validation ───────────────────────────────────────────────── +# The Quadlet rendering covers every service except pulp_web, which has no +# equivalent for its service-name DNS load balancing. Fail loudly rather than +# silently bringing up a partial stack. +- name: Validate the orchestrator selection + ansible.builtin.assert: + that: pulp_orchestrator in ['compose', 'quadlet'] + fail_msg: >- + pulp_orchestrator must be "compose" or "quadlet" (got + "{{ pulp_orchestrator }}"). + +- name: Validate the Quadlet orchestrator prerequisites + ansible.builtin.assert: + that: + - pulp_container_engine == 'podman' + - not (pulp_web_enabled | bool) + fail_msg: >- + pulp_orchestrator is "quadlet", which requires: + * pulp_container_engine: podman — Quadlet is a Podman feature and has no + Docker equivalent (got "{{ pulp_container_engine }}"); + * pulp_web_enabled: false — no pulp_web Quadlet unit is rendered; + run an external reverse proxy against the published api/content ports. + PostgreSQL works either way: pulp_postgres_external: true points at a + managed database, false renders a pulp-postgres unit + its data volume. + Use pulp_orchestrator: compose for the bundled pulp_web. + when: pulp_orchestrator == 'quadlet' + +- name: Install the container engine and its compose tool + ansible.builtin.include_tasks: install_engine.yml + when: pulp_install_engine | bool + +- name: Configure journald log retention + ansible.builtin.include_tasks: journald.yml + when: + - pulp_log_driver == 'journald' + - pulp_journald_configure | bool + +- name: Lay down the project assets (settings, certs, keys, data dirs) + ansible.builtin.include_tasks: deploy.yml + +- name: Build the custom Pulp image (extra plugins / vendored patches) + ansible.builtin.include_tasks: build_image.yml + when: pulp_build_custom_image | bool + +# Rendered after build_image.yml: the units reference pulp_runtime_image, which +# is the locally built localhost/pulp-custom tag when a custom build is enabled, +# so the image must exist before a unit tries to start from it. +- name: Render the Quadlet units + ansible.builtin.include_tasks: quadlet.yml + when: pulp_orchestrator == 'quadlet' + +# Apply any config/image changes now (force-recreate) so the readiness check +# below runs against the final container state, not a stale one. +- name: Apply pending config/image changes + ansible.builtin.meta: flush_handlers + +- name: Ensure the Pulp stack is up (compose) + ansible.builtin.command: + cmd: >- + {{ pulp_compose_cmd }} -f compose.yml up -d --remove-orphans + --scale pulp_api={{ pulp_api_replicas }} + --scale pulp_content={{ pulp_content_replicas }} + --scale pulp_worker={{ pulp_worker_replicas }} + chdir: "{{ pulp_project_dir }}" + register: pulp_compose_up + changed_when: >- + ('Creating' in pulp_compose_up.stderr) or + ('Recreating' in pulp_compose_up.stderr) or + ('Started' in pulp_compose_up.stderr) or + ('Created' in pulp_compose_up.stderr) + when: + - pulp_orchestrator == 'compose' + - pulp_start_stack | bool + +# Quadlet units cannot be `systemctl enable`d — the generator writes them into +# /run/systemd/generator, so there is nothing to symlink. Boot persistence comes +# from [Install] WantedBy=multi-user.target inside each .container file, which the +# generator materialises on daemon-reload; this task only starts them for the +# current run. daemon_reload covers the first run, where the units were rendered +# but no handler fired. +- name: Ensure the Pulp stack is up (Quadlet) + ansible.builtin.systemd: + name: "{{ item }}" + state: started + daemon_reload: true + loop: "{{ pulp_quadlet_all_units }}" + when: + - pulp_orchestrator == 'quadlet' + - pulp_start_stack | bool + +- name: Wait for the Pulp API to report ready + ansible.builtin.uri: + url: "{{ _pulp_status_base }}/pulp/api/v3/status/" + status_code: 200 + validate_certs: false + register: pulp_status + retries: 30 + delay: 10 + until: pulp_status.status == 200 + when: pulp_start_stack | bool + vars: + # With pulp_web the probe hits the published web port; without it, the + # api gunicorn port published for the external reverse proxy. + _pulp_status_base: >- + {{ ('http://127.0.0.1:' ~ pulp_api_host_port) if not (pulp_web_enabled | bool) + else ('https://127.0.0.1:' ~ pulp_https_port if pulp_https | bool + else 'http://127.0.0.1:' ~ pulp_http_port) }} diff --git a/roles/pulp/tasks/quadlet.yml b/roles/pulp/tasks/quadlet.yml new file mode 100644 index 0000000..640b34b --- /dev/null +++ b/roles/pulp/tasks/quadlet.yml @@ -0,0 +1,204 @@ +--- +# Render the Pulp stack as Quadlet units under {{ pulp_quadlet_dir }}. +# podman-system-generator turns each file into a systemd service on +# daemon-reload and at boot; [Install] WantedBy=multi-user.target in each unit is +# what makes the stack come back after a reboot, replacing the compose path's +# podman-restart.service + RequiresMountsFor drop-in. +# +# Only the services this mode supports are rendered: no postgres unit (external +# PostgreSQL) and no pulp_web unit (external reverse proxy). main.yml asserts both +# before this file is included. + +- name: Ensure the Quadlet unit directory exists + ansible.builtin.file: + path: "{{ pulp_quadlet_dir }}" + state: directory + mode: "0755" + +# ── Network ───────────────────────────────────────────────────────────────── +- name: Render the Pulp network unit + ansible.builtin.template: + src: quadlet/pulp.network.j2 + dest: "{{ pulp_quadlet_dir }}/pulp.network" + mode: "0644" + notify: + - Restart Pulp stack + - Restart Pulp data stores + +# ── Podman-managed volumes (only for stores without a host bind mount) ────── +- name: Render the content storage volume unit + ansible.builtin.template: + src: quadlet/pulp-storage.volume.j2 + dest: "{{ pulp_quadlet_dir }}/pulp-storage.volume" + mode: "0644" + when: pulp_storage_dir | length == 0 + notify: Restart Pulp stack + +- name: Render the Redis data volume unit + ansible.builtin.template: + src: quadlet/pulp-redis-data.volume.j2 + dest: "{{ pulp_quadlet_dir }}/pulp-redis-data.volume" + mode: "0644" + when: pulp_redis_data_dir | length == 0 + notify: + - Restart Pulp stack + - Restart Pulp data stores + +- name: Render the PostgreSQL data volume unit + ansible.builtin.template: + src: quadlet/pulp-pg-data.volume.j2 + dest: "{{ pulp_quadlet_dir }}/pulp-pg-data.volume" + mode: "0644" + when: + - not (pulp_postgres_external | bool) + - pulp_postgres_data_dir | length == 0 + notify: + - Restart Pulp stack + - Restart Pulp data stores + +# ── Containers ────────────────────────────────────────────────────────────── +# 0600: carries POSTGRES_PASSWORD in an Environment= line. +- name: Render the PostgreSQL unit + ansible.builtin.template: + src: quadlet/pulp-postgres.container.j2 + dest: "{{ pulp_quadlet_dir }}/pulp-postgres.container" + mode: "0600" + when: not (pulp_postgres_external | bool) + notify: + - Restart Pulp stack + - Restart Pulp data stores + +- name: Render the Redis unit + ansible.builtin.template: + src: quadlet/pulp-redis.container.j2 + dest: "{{ pulp_quadlet_dir }}/pulp-redis.container" + mode: "0644" + notify: + - Restart Pulp stack + - Restart Pulp data stores + +- name: Render the migration unit + ansible.builtin.template: + src: quadlet/pulp-migration.container.j2 + dest: "{{ pulp_quadlet_dir }}/pulp-migration.container" + mode: "0644" + notify: Restart Pulp stack + +- name: Render the signing service unit + ansible.builtin.template: + src: quadlet/pulp-signing.container.j2 + dest: "{{ pulp_quadlet_dir }}/pulp-signing.container" + mode: "0644" + when: pulp_signing_enabled | bool + notify: Restart Pulp stack + +- name: Render the initial admin password unit + ansible.builtin.template: + src: quadlet/pulp-init-password.container.j2 + dest: "{{ pulp_quadlet_dir }}/pulp-init-password.container" + mode: "0600" + when: pulp_set_init_password | bool + notify: Restart Pulp stack + +- name: Render the API unit + ansible.builtin.template: + src: quadlet/pulp-api.container.j2 + dest: "{{ pulp_quadlet_dir }}/pulp-api.container" + mode: "0644" + notify: Restart Pulp stack + +- name: Render the content unit + ansible.builtin.template: + src: quadlet/pulp-content.container.j2 + dest: "{{ pulp_quadlet_dir }}/pulp-content.container" + mode: "0644" + notify: Restart Pulp stack + +- name: Render one unit per task worker replica + ansible.builtin.template: + src: quadlet/pulp-worker.container.j2 + dest: "{{ pulp_quadlet_dir }}/pulp-worker-{{ item }}.container" + mode: "0644" + loop: "{{ range(1, (pulp_worker_replicas | int) + 1) | list }}" + vars: + worker_index: "{{ item }}" + notify: Restart Pulp stack + +# ── Telemetry ─────────────────────────────────────────────────────────────── +# Notifies only the collector topic: a change here must not restart Pulp, and +# the daemon-reload handler listens on this topic too. +- name: Render the OpenTelemetry collector unit + ansible.builtin.template: + src: quadlet/otel-collector.container.j2 + dest: "{{ pulp_quadlet_dir }}/otel-collector.container" + mode: "0644" + when: pulp_otel_collector_enabled | bool + notify: Restart Pulp collector + +# ── Reconcile: drop units this run did not render ─────────────────────────── +# Lowering pulp_worker_replicas, or turning signing / init-password off, leaves +# a stale unit file behind. systemd would keep starting it (it still has an +# [Install] symlink), so the container would linger — the compose path got this +# for free via --remove-orphans. Stop the unit BEFORE deleting the file, or +# daemon-reload forgets the unit while its container keeps running. +- name: Build the list of units this run manages + ansible.builtin.set_fact: + pulp_quadlet_expected: >- + {{ ['pulp.network', 'pulp-redis.container', 'pulp-migration.container', + 'pulp-api.container', 'pulp-content.container'] + + (['pulp-postgres.container'] if not (pulp_postgres_external | bool) else []) + + (['pulp-storage.volume'] if pulp_storage_dir | length == 0 else []) + + (['pulp-redis-data.volume'] if pulp_redis_data_dir | length == 0 else []) + + (['pulp-pg-data.volume'] + if not (pulp_postgres_external | bool) and pulp_postgres_data_dir | length == 0 + else []) + + (['pulp-signing.container'] if pulp_signing_enabled | bool else []) + + (['pulp-init-password.container'] if pulp_set_init_password | bool else []) + + (['otel-collector.container'] if pulp_otel_collector_enabled | bool else []) + + (range(1, (pulp_worker_replicas | int) + 1) + | map('regex_replace', '^(.*)$', 'pulp-worker-\1.container') | list) }} + +- name: Find Quadlet units currently on the host + ansible.builtin.find: + paths: "{{ pulp_quadlet_dir }}" + patterns: + - "pulp*.container" + - "pulp*.volume" + - "pulp*.network" + # Not pulp-prefixed, so it needs its own pattern — without this, turning + # pulp_otel_collector_enabled back off would leave the unit running. + - "otel-collector.container" + file_type: file + register: pulp_quadlet_present + +- name: Identify orphaned units + ansible.builtin.set_fact: + pulp_quadlet_orphans: >- + {{ pulp_quadlet_present.files | map(attribute='path') | map('basename') + | reject('in', pulp_quadlet_expected) | list }} + +- name: Report orphaned units being removed + ansible.builtin.debug: + msg: >- + Removing Quadlet units no longer part of the desired state: + {{ pulp_quadlet_orphans | join(', ') }} + when: pulp_quadlet_orphans | length > 0 + +# .container files map to .service; .volume/.network map to +# -volume.service / -network.service. Only container units have a +# running process worth stopping — the volume/network units are pulled in as +# dependencies and go away with their consumers. +- name: Stop orphaned container units + ansible.builtin.systemd: + name: "{{ item | regex_replace('\\.container$', '.service') }}" + state: stopped + loop: "{{ pulp_quadlet_orphans | select('search', '\\.container$') | list }}" + failed_when: false + when: pulp_start_stack | bool + +- name: Remove orphaned unit files + ansible.builtin.file: + path: "{{ pulp_quadlet_dir }}/{{ item }}" + state: absent + loop: "{{ pulp_quadlet_orphans }}" + notify: Restart Pulp stack diff --git a/roles/pulp/tasks/tls.yml b/roles/pulp/tasks/tls.yml new file mode 100644 index 0000000..615a5fc --- /dev/null +++ b/roles/pulp/tasks/tls.yml @@ -0,0 +1,59 @@ +--- +# Provision the pulp-web TLS certificate + key at {{ pulp_web_certs_dir }}. +# Precedence: explicit cert/key files > self-signed (non-prod) > fail. + +- name: Create the webserver certs directory + ansible.builtin.file: + path: "{{ pulp_web_certs_dir }}" + state: directory + mode: "0750" + +- name: Fail when HTTPS is enabled but no certificate source is configured + ansible.builtin.fail: + msg: >- + pulp_https is true but no certificate is available. Set pulp_tls_cert_src + and pulp_tls_key_src to your PEM files, or set + pulp_tls_generate_self_signed: true for a non-production self-signed cert. + when: + - not (pulp_tls_cert_src and pulp_tls_key_src) + - not (pulp_tls_generate_self_signed | bool) + +# ── Provided certificate ──────────────────────────────────────────────────── +- name: Install the provided TLS certificate + ansible.builtin.copy: + src: "{{ pulp_tls_cert_src }}" + dest: "{{ pulp_web_certs_dir }}/pulp_webserver.crt" + mode: "0644" + when: pulp_tls_cert_src and pulp_tls_key_src + notify: Restart Pulp stack + +- name: Install the provided TLS private key + ansible.builtin.copy: + src: "{{ pulp_tls_key_src }}" + dest: "{{ pulp_web_certs_dir }}/pulp_webserver.key" + mode: "0640" + when: pulp_tls_cert_src and pulp_tls_key_src + notify: Restart Pulp stack + +# ── Self-signed fallback (NON-PRODUCTION) ─────────────────────────────────── +- name: Generate a self-signed TLS certificate (non-production) + ansible.builtin.command: + cmd: >- + openssl req -x509 -nodes -newkey rsa:4096 -days 3650 + -keyout {{ pulp_web_certs_dir }}/pulp_webserver.key + -out {{ pulp_web_certs_dir }}/pulp_webserver.crt + -subj "/CN={{ pulp_tls_cn }}" + -addext "subjectAltName=DNS:{{ pulp_tls_cn }}" + creates: "{{ pulp_web_certs_dir }}/pulp_webserver.crt" + when: + - not (pulp_tls_cert_src and pulp_tls_key_src) + - pulp_tls_generate_self_signed | bool + notify: Restart Pulp stack + +- name: Tighten self-signed key permissions + ansible.builtin.file: + path: "{{ pulp_web_certs_dir }}/pulp_webserver.key" + mode: "0640" + when: + - not (pulp_tls_cert_src and pulp_tls_key_src) + - pulp_tls_generate_self_signed | bool diff --git a/roles/pulp/templates/Containerfile.j2 b/roles/pulp/templates/Containerfile.j2 new file mode 100644 index 0000000..e9b1c58 --- /dev/null +++ b/roles/pulp/templates/Containerfile.j2 @@ -0,0 +1,35 @@ +# {{ ansible_managed }} +# Custom Pulp image: layers local changes onto the upstream pulp-minimal image. +# Built (roles/pulp/tasks/build_image.yml) whenever extra plugins are requested +# OR vendored source patches are enabled. +FROM {{ pulp_minimal_image }} + +USER root:root +{% if pulp_extra_plugins %} + +# Install extra plugins that are NOT bundled in the upstream image. +# /constraints.txt and uv ship in the base image; we reuse them so transitive +# deps stay aligned with the bundled pulpcore. +RUN uv pip install --no-cache \ +{% for plugin in pulp_extra_plugins %} + "{{ plugin }}"{{ " \\" }} +{% endfor %} + -c /constraints.txt +{% endif %} +{% if pulp_source_patches_enabled | bool %} + +# Overlay the vendored pulpcore source patches. Pinned to pulpcore +# {{ pulp_source_patches_pulpcore_version }} (asserted against pulp_image_tag at +# build time); re-derive every roles/pulp/files/*.py patch on any version bump. +{% for patch in pulp_source_patches %} +COPY patches/{{ patch.src }} {{ patch.dest }} +{% endfor %} +{% endif %} +{% if pulp_extra_plugins %} + +# Re-collect static assets so any UI/static files the new plugins ship are served. +USER pulp:pulp +RUN PULP_STATIC_ROOT=/var/lib/operator/static/ PULP_CONTENT_ORIGIN=localhost \ + /usr/local/bin/pulpcore-manager collectstatic --clear --noinput --link +USER root:root +{% endif %} diff --git a/roles/pulp/templates/compose.yml.j2 b/roles/pulp/templates/compose.yml.j2 new file mode 100644 index 0000000..d5a492d --- /dev/null +++ b/roles/pulp/templates/compose.yml.j2 @@ -0,0 +1,330 @@ +# {{ ansible_managed }} +# Scalable multi-container Pulp stack (based on pulp-oci-images images/compose). +# +# Data stores use Docker named volumes by default, or host bind-mounts when the +# corresponding *_dir variable is set (migration from the systemd deployment). +# SELinux relabel flags (":z" shared / ":Z" private). Correct on a local FS +# under Enforcing, but dead work under Permissive and broken on NFS/EFS. The +# global pulp_selinux_relabel toggles them on ALL bind mounts; storage/exports +# additionally honor their own per-mount toggles (for the Enforcing+NFS case). +# Label the mount with a context= option instead when relabel is off. See +# roles/pulp/defaults/main.yml. +{% set z = ':z' if (pulp_selinux_relabel | bool) else '' %} +{% set Z = ':Z' if (pulp_selinux_relabel | bool) else '' %} +{% set storage_relabel = ':z' if (pulp_storage_selinux_relabel | bool) else '' %} +{% set exports_relabel = ':z' if (pulp_exports_selinux_relabel | bool) else '' %} +{% set pulp_storage_volume = (pulp_storage_dir ~ ':' ~ pulp_storage_mount ~ storage_relabel) if pulp_storage_dir else ('pulp:' ~ pulp_storage_mount) %} +{% set pg_data_volume = (pulp_postgres_data_dir ~ ':/var/lib/postgresql/data' ~ Z) if pulp_postgres_data_dir else 'pg_data:/var/lib/postgresql/data' %} +{% set redis_volume = (pulp_redis_data_dir ~ ':/data' ~ Z) if pulp_redis_data_dir else 'redis_data:/data' %} +{% set exports_volume = (pulp_exports_dir ~ ':' ~ pulp_export_path ~ exports_relabel) if pulp_exports_dir else '' %} +{% set pg_local = not (pulp_postgres_external | bool) %} +{#- Per-service logging block. Static tag => scaled replicas aggregate under one + journalctl -t while CONTAINER_NAME still separates them. For journald + only the tag is emitted; other drivers get their driver line as-is. -#} +{% macro logging(tag) %} + logging: + driver: "{{ pulp_log_driver }}" +{% if pulp_log_driver == 'journald' %} + options: + tag: "{{ tag }}" +{%- endif %} +{%- endmacro %} +version: '3' +services: +{% if pg_local %} + postgres: + image: "{{ pulp_postgres_image }}" +{% if pulp_expose_postgres | bool %} + ports: + - "5432:5432" +{% endif %} + environment: + POSTGRES_USER: {{ pulp_postgres_user }} + POSTGRES_PASSWORD: {{ pulp_postgres_password }} + POSTGRES_DB: {{ pulp_postgres_db }} + POSTGRES_INITDB_ARGS: '--auth-host=scram-sha-256' + POSTGRES_HOST_AUTH_METHOD: 'scram-sha-256' + volumes: + - "{{ pg_data_volume }}" + - "./assets/postgres/passwd:/etc/passwd{{ Z }}" + restart: always +{{ logging('postgres') }} + healthcheck: + test: pg_isready -U {{ pulp_postgres_user }} + interval: 10s + timeout: 5s + retries: 5 + +{% endif %} + redis: + image: "{{ pulp_redis_image }}" +{% if pulp_expose_redis | bool %} + ports: + - "6379:6379" +{% endif %} + volumes: + - "{{ redis_volume }}" + restart: always +{{ logging('redis') }} + healthcheck: + test: redis-cli ping + interval: 10s + timeout: 5s + retries: 5 + +{% if pulp_otel_collector_enabled | bool %} + # Receives OTLP from pulp_api / pulp_content / pulp_worker. Service name is + # the DNS name, so it must match the host part of pulp_otel_exporter_endpoint + # — hyphen, not underscore (underscores are invalid in hostnames). + # + # No depends_on from the Pulp services, deliberately: telemetry is best-effort + # and a broken collector must not hold the stack down. The exporters retry. + {{ pulp_otel_collector_name }}: + image: "{{ pulp_otel_collector_image }}" +{% if (pulp_otel_collector_expose_ports | bool) or (pulp_otel_collector_prometheus_exporter | bool) or (pulp_otel_collector_telemetry_enabled | bool) %} + ports: +{% if pulp_otel_collector_expose_ports | bool %} + # OTLP ingest, for pushing data in from outside the container network. + - "{{ pulp_otel_collector_otlp_http_port }}:{{ pulp_otel_collector_otlp_http_port }}" + - "{{ pulp_otel_collector_otlp_grpc_port }}:{{ pulp_otel_collector_otlp_grpc_port }}" +{% endif %} +{% if pulp_otel_collector_prometheus_exporter | bool %} + # Scrape endpoint — Prometheus is outside the container network. + - "{{ pulp_otel_collector_prometheus_port }}:{{ pulp_otel_collector_prometheus_port }}" +{% endif %} +{% if pulp_otel_collector_telemetry_enabled | bool %} + - "{{ pulp_otel_collector_telemetry_port }}:{{ pulp_otel_collector_telemetry_port }}" +{% endif %} +{% endif %} + volumes: + # -contrib image path; the core image uses /etc/otelcol/config.yaml. + # Options are comma-separated: ":ro:z" would be an invalid fourth field. + - "./assets/otel/config.yaml:/etc/otelcol-contrib/config.yaml:ro{{ z | replace(':', ',') }}" + restart: always +{{ logging('otel_collector') }} + +{% endif %} + migration_service: + image: "{{ pulp_runtime_image }}" +{% if pg_local %} + depends_on: + postgres: + condition: service_healthy +{% endif %} + command: pulpcore-manager migrate --noinput + user: "{{ pulp_run_user }}" + volumes: + - "./assets/settings.py:/etc/pulp/settings.py{{ z }}" + - "./assets/certs:/etc/pulp/certs{{ z }}" + - "{{ pulp_storage_volume }}" +{% if pulp_signing_enabled | bool %} + + signing_key_service: + image: "{{ pulp_runtime_image }}" + command: sh -c "add_signing_service.sh" + depends_on: +{% if pg_local %} + postgres: + condition: service_healthy +{% endif %} + migration_service: + condition: service_completed_successfully + user: "{{ pulp_run_user }}" + environment: + PULP_SIGNING_KEY_FINGERPRINT: {{ pulp_signing_key_fingerprint }} + volumes: + - "./assets/settings.py:/etc/pulp/settings.py{{ z }}" + - "./assets/certs:/etc/pulp/certs{{ z }}" + - "{{ pulp_storage_volume }}" +{% endif %} +{% if pulp_set_init_password | bool %} + + set_init_password_service: + image: "{{ pulp_runtime_image }}" + command: set_init_password.sh +{% if pg_local %} + depends_on: + postgres: + condition: service_healthy +{% endif %} + user: "{{ pulp_run_user }}" + environment: + PULP_DEFAULT_ADMIN_PASSWORD: {{ pulp_admin_password }} + volumes: + - "./assets/settings.py:/etc/pulp/settings.py{{ z }}" + - "./assets/certs:/etc/pulp/certs{{ z }}" + - "{{ pulp_storage_volume }}" +{% endif %} +{% if pulp_web_enabled | bool %} + + pulp_web: + image: "{{ pulp_web_image }}" + command: ['/usr/bin/nginx.sh'] + depends_on: + pulp_api: + condition: service_healthy + pulp_content: + condition: service_healthy + ports: +{% if pulp_https | bool %} + - "{{ pulp_https_port }}:{{ pulp_web_https_listen }}" +{% if pulp_web_http_redirect | bool %} + - "{{ pulp_http_port }}:{{ pulp_web_http_listen }}" +{% endif %} +{% else %} + - "{{ pulp_http_port }}:{{ pulp_web_http_listen }}" +{% endif %} + hostname: pulp + user: root + volumes: + - "./assets/bin/nginx.sh:/usr/bin/nginx.sh{{ Z }}" + - "./assets/nginx/nginx.conf.template:/etc/nginx/nginx.conf.template{{ Z }}" +{% if pulp_https | bool %} + - "./assets/web/certs:/etc/pulp/certs{{ z }}" +{% endif %} + restart: always +{{ logging('pulp_web') }} +{% endif %} + + pulp_api: + image: "{{ pulp_runtime_image }}" + deploy: + replicas: {{ pulp_api_replicas }} + command: ['pulp-api'] + environment: + PULP_API_WORKERS: "{{ pulp_api_workers }}" + PULP_GUNICORN_TIMEOUT: "{{ pulp_gunicorn_timeout }}" +{% if pulp_otel_enabled | bool %} + PULP_OTEL_ENABLED: "true" + OTEL_EXPORTER_OTLP_ENDPOINT: "{{ pulp_otel_exporter_endpoint }}" + OTEL_EXPORTER_OTLP_PROTOCOL: "{{ pulp_otel_exporter_protocol }}" + OTEL_SERVICE_NAME: "pulp-api" +{% endif %} +{% if not (pulp_web_enabled | bool) %} + ports: + - "{{ pulp_api_host_port }}:24817" +{% endif %} + depends_on: + redis: + condition: service_healthy +{% if pg_local %} + postgres: + condition: service_healthy +{% endif %} + migration_service: + condition: service_completed_successfully +{% if pulp_set_init_password | bool %} + set_init_password_service: + condition: service_completed_successfully +{% endif %} +{% if pulp_signing_enabled | bool %} + signing_key_service: + condition: service_completed_successfully +{% endif %} + hostname: pulp-api + user: "{{ pulp_run_user }}" + volumes: + - "./assets/settings.py:/etc/pulp/settings.py{{ z }}" + - "./assets/certs:/etc/pulp/certs{{ z }}" + - "{{ pulp_storage_volume }}" +{% if pulp_exports_dir %} + - "{{ exports_volume }}" +{% endif %} + restart: always +{{ logging('pulp_api') }} + healthcheck: + test: readyz.py /pulp/api/v3/status/ + interval: 10s + timeout: 5s + retries: 5 + start_period: 90s + + pulp_content: + image: "{{ pulp_runtime_image }}" + deploy: + replicas: {{ pulp_content_replicas }} + command: ['pulp-content'] + environment: + PULP_CONTENT_WORKERS: "{{ pulp_content_workers }}" + PULP_GUNICORN_TIMEOUT: "{{ pulp_gunicorn_timeout }}" +{% if pulp_otel_enabled | bool %} + PULP_OTEL_ENABLED: "true" + OTEL_EXPORTER_OTLP_ENDPOINT: "{{ pulp_otel_exporter_endpoint }}" + OTEL_EXPORTER_OTLP_PROTOCOL: "{{ pulp_otel_exporter_protocol }}" + OTEL_SERVICE_NAME: "pulp-content" +{% endif %} +{% if not (pulp_web_enabled | bool) %} + ports: + - "{{ pulp_content_host_port }}:24816" +{% endif %} + depends_on: + redis: + condition: service_healthy +{% if pg_local %} + postgres: + condition: service_healthy +{% endif %} + migration_service: + condition: service_completed_successfully + hostname: pulp-content + user: "{{ pulp_run_user }}" + volumes: + - "./assets/settings.py:/etc/pulp/settings.py{{ z }}" + - "./assets/certs:/etc/pulp/certs{{ z }}" + - "{{ pulp_storage_volume }}" + restart: always +{{ logging('pulp_content') }} + healthcheck: + test: readyz.py /pulp/content/ + interval: 10s + timeout: 5s + retries: 5 + start_period: 90s + + pulp_worker: + image: "{{ pulp_runtime_image }}" + deploy: + replicas: {{ pulp_worker_replicas }} + command: ['pulp-worker'] +{% if pulp_otel_enabled | bool %} + environment: + PULP_OTEL_ENABLED: "true" + OTEL_EXPORTER_OTLP_ENDPOINT: "{{ pulp_otel_exporter_endpoint }}" + OTEL_EXPORTER_OTLP_PROTOCOL: "{{ pulp_otel_exporter_protocol }}" + OTEL_SERVICE_NAME: "pulp-worker" +{% endif %} + depends_on: + redis: + condition: service_healthy +{% if pg_local %} + postgres: + condition: service_healthy +{% endif %} + migration_service: + condition: service_completed_successfully + user: "{{ pulp_run_user }}" + volumes: + - "./assets/settings.py:/etc/pulp/settings.py{{ z }}" + - "./assets/certs:/etc/pulp/certs{{ z }}" + - "{{ pulp_storage_volume }}" +{% if pulp_exports_dir %} + - "{{ exports_volume }}" +{% endif %} + restart: always +{{ logging('pulp_worker') }} +{% if (not pulp_storage_dir) or (pg_local and not pulp_postgres_data_dir) or (not pulp_redis_data_dir) %} + +volumes: +{% if not pulp_storage_dir %} + pulp: + name: pulp${DEV_VOLUME_SUFFIX:-data} +{% endif %} +{% if pg_local and not pulp_postgres_data_dir %} + pg_data: + name: pg_data${DEV_VOLUME_SUFFIX:-data} +{% endif %} +{% if not pulp_redis_data_dir %} + redis_data: + name: redis_data${DEV_VOLUME_SUFFIX:-data} +{% endif %} +{% endif %} diff --git a/roles/pulp/templates/journald-pulp.conf.j2 b/roles/pulp/templates/journald-pulp.conf.j2 new file mode 100644 index 0000000..c604746 --- /dev/null +++ b/roles/pulp/templates/journald-pulp.conf.j2 @@ -0,0 +1,10 @@ +# {{ ansible_managed }} +# Journald retention for the Pulp container stack (pulp_log_driver=journald). +# Drop-in overriding /etc/systemd/journald.conf. These limits are GLOBAL to the +# whole journal — shared by every Pulp service AND every scaled replica (and any +# other journald user on the host) — not per-container. Size accordingly. +[Journal] +Storage={{ pulp_journald_storage }} +Compress={{ pulp_journald_compress }} +SystemMaxUse={{ pulp_journald_system_max_use }} +MaxRetentionSec={{ pulp_journald_max_retention_sec }} diff --git a/roles/pulp/templates/nginx.conf.template.j2 b/roles/pulp/templates/nginx.conf.template.j2 new file mode 100644 index 0000000..8e2280d --- /dev/null +++ b/roles/pulp/templates/nginx.conf.template.j2 @@ -0,0 +1,128 @@ +# {{ ansible_managed }} +# pulp-web nginx config template. $NAMESERVER is substituted by nginx.sh at +# container start; $pulp_api / $pulp_content stay as nginx variables so the +# resolver can pick up scaled replicas every 10s. +error_log /dev/stdout info; +worker_processes 1; +events { + worker_connections 1024; # increase if you have lots of clients + accept_mutex off; # set to 'on' if nginx worker_processes > 1 +} + +http { + access_log /dev/stdout; + include mime.types; + # fallback in case we can't determine a type + default_type application/octet-stream; + sendfile on; + + # If left at the default of 1024, nginx emits a warning about being unable + # to build optimal hash types. + types_hash_max_size 4096; + + server { + # This logic enables us to have multiple servers, and check to see + # if they are scaled every 10 seconds. + resolver $NAMESERVER valid=10s; + set $pulp_api pulp_api; + set $pulp_content pulp_content; + +{% if pulp_https | bool %} + # Gunicorn docs suggest the use of the "deferred" directive on Linux. + listen {{ pulp_web_https_listen }} default_server deferred ssl; + listen [::]:{{ pulp_web_https_listen }} default_server deferred ssl; + + ssl_certificate /etc/pulp/certs/pulp_webserver.crt; + ssl_certificate_key /etc/pulp/certs/pulp_webserver.key; + ssl_session_cache shared:SSL:50m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + # intermediate configuration + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256'; + ssl_prefer_server_ciphers on; + + # HSTS (15768000 seconds = 6 months) + add_header Strict-Transport-Security max-age=15768000; +{% else %} + # Gunicorn docs suggest the use of the "deferred" directive on Linux. + listen {{ pulp_web_http_listen }} default_server deferred; + listen [::]:{{ pulp_web_http_listen }} default_server deferred; +{% endif %} + + # If you have a domain name, this is where to add it + server_name $hostname; + + # The default client_max_body_size is 1m. Clients uploading + # files larger than this will need to chunk said files. + client_max_body_size 10m; + + # Gunicorn docs suggest this value. + keepalive_timeout 5; + + # static files that can change dynamically, or are needed for TLS + # purposes are served through the webserver. + root /opt/app-root/src; + + location /pulp/content/ { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; + # we don't want nginx trying to do something clever with + # redirects, we set the Host: header above already. + proxy_redirect off; + proxy_pass http://$pulp_content:24816; + } + + location /pulp/api/v3/ { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; + # we don't want nginx trying to do something clever with + # redirects, we set the Host: header above already. + proxy_redirect off; + proxy_pass http://$pulp_api:24817; + } + + location /auth/login/ { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; + # we don't want nginx trying to do something clever with + # redirects, we set the Host: header above already. + proxy_redirect off; + proxy_pass http://$pulp_api:24817; + } + + include /etc/nginx/pulp/*.conf; + + location / { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; + # we don't want nginx trying to do something clever with + # redirects, we set the Host: header above already. + proxy_redirect off; + proxy_pass http://$pulp_api:24817; + # static files are served through whitenoise - http://whitenoise.evans.io/en/stable/ + } +{% if pulp_https | bool %} + + # ACME http-01 tokens, i.e. for Let's Encrypt + location /.well-known/ { + try_files $uri $uri/ =404; + } +{% endif %} + } +{% if pulp_https | bool and pulp_web_http_redirect | bool %} + + # Redirect plain HTTP to HTTPS. + server { + listen {{ pulp_web_http_listen }} default_server deferred; + listen [::]:{{ pulp_web_http_listen }} default_server deferred; + server_name _; + return 301 https://$host$request_uri; + } +{% endif %} +} diff --git a/roles/pulp/templates/otel-collector-config.yaml.j2 b/roles/pulp/templates/otel-collector-config.yaml.j2 new file mode 100644 index 0000000..5f52545 --- /dev/null +++ b/roles/pulp/templates/otel-collector-config.yaml.j2 @@ -0,0 +1,77 @@ +# {{ ansible_managed }} +# OpenTelemetry Collector config for the Pulp stack. Shared by both +# orchestrators — the compose service and the Quadlet unit mount this same file. +# +# Pulp PUSHES OTLP here (its processes expose no HTTP endpoint of their own); +# this file decides where it goes next. Every exporter named in a pipeline below +# MUST also appear under `exporters:` — a dangling name is a fatal config error +# and the collector refuses to start. +{% set metric_exporters = [] %} +{% if pulp_otel_collector_prometheus_exporter | bool %} +{% set _ = metric_exporters.append('prometheus') %} +{% endif %} +{% if pulp_otel_collector_metrics_endpoint %} +{% set _ = metric_exporters.append('otlphttp/metrics') %} +{% endif %} +{% if not metric_exporters %} +{% set _ = metric_exporters.append('debug') %} +{% endif %} +receivers: + otlp: + protocols: + http: + endpoint: "0.0.0.0:{{ pulp_otel_collector_otlp_http_port }}" + grpc: + endpoint: "0.0.0.0:{{ pulp_otel_collector_otlp_grpc_port }}" + +processors: + # Shed load rather than OOM the collector when a backend is slow or down. + memory_limiter: + check_interval: 5s + limit_percentage: 75 + spike_limit_percentage: 25 + # One batched stream out instead of one request per exporting process. Pulp's + # api runs pulp_api_workers gunicorn processes, each exporting on its own. + batch: {} + +exporters: + # Always defined: the traces pipeline uses it, and it is the fallback when no + # metrics exporter is configured. Writes to the collector's own log — + # `journalctl -u otel-collector` (quadlet) / `docker compose logs` (compose). + debug: + verbosity: {{ pulp_otel_collector_debug_verbosity }} +{% if pulp_otel_collector_prometheus_exporter | bool %} + # Serves a Prometheus /metrics scrape endpoint. NOTE: this is the "prometheus" + # exporter (pull), not "prometheusremotewrite" (push). Metric names arrive + # translated — OTel's api.request_duration becomes api_request_duration_* with + # a unit suffix and _bucket/_sum/_count series. + prometheus: + endpoint: "0.0.0.0:{{ pulp_otel_collector_prometheus_port }}" +{% endif %} +{% if pulp_otel_collector_metrics_endpoint %} + otlphttp/metrics: + endpoint: "{{ pulp_otel_collector_metrics_endpoint }}" +{% endif %} + +service: +{% if pulp_otel_collector_telemetry_enabled | bool %} + # The collector's own metrics, for debugging the pipeline itself: + # otelcol_receiver_accepted_metric_points -> did Pulp send anything + # otelcol_exporter_sent_metric_points -> did it go out + # otelcol_exporter_send_failed_metric_points + telemetry: + metrics: + address: "0.0.0.0:{{ pulp_otel_collector_telemetry_port }}" +{% endif %} + pipelines: + metrics: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [{{ metric_exporters | join(', ') }}] + # Traces terminate at debug. Prometheus cannot ingest them, and a pipeline + # with no exporter is a config error — so the collector absorbs them instead + # of leaving Pulp's exporter retrying against an endpoint that rejects them. + traces: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [debug] diff --git a/roles/pulp/templates/podman-restart-mounts.conf.j2 b/roles/pulp/templates/podman-restart-mounts.conf.j2 new file mode 100644 index 0000000..0cda6b8 --- /dev/null +++ b/roles/pulp/templates/podman-restart-mounts.conf.j2 @@ -0,0 +1,7 @@ +# {{ ansible_managed }} +# Order podman-restart.service after the host bind-mount stores so the stack is +# not started on boot before an NFS/EFS-backed store is mounted. RequiresMountsFor +# resolves each path to the .mount unit that provides it and adds both After= and +# Requires= on it — so the service waits for the mount and won't run if it fails. +[Unit] +RequiresMountsFor={{ pulp_podman_bind_mount_paths | join(' ') }} diff --git a/roles/pulp/templates/quadlet/otel-collector.container.j2 b/roles/pulp/templates/quadlet/otel-collector.container.j2 new file mode 100644 index 0000000..95709c7 --- /dev/null +++ b/roles/pulp/templates/quadlet/otel-collector.container.j2 @@ -0,0 +1,56 @@ +# {{ ansible_managed }} +# OpenTelemetry Collector for the Pulp stack. Receives OTLP from +# pulp-api / pulp-content / pulp-worker-N and forwards per +# {{ pulp_assets_dir }}/otel/config.yaml. +# +# ContainerName is the DNS name aardvark-dns serves on the pulp network, and it +# must match the host part of pulp_otel_exporter_endpoint. Network= is therefore +# mandatory: on podman's built-in network DNS is disabled and Pulp could not +# resolve this name. +# +# NOTHING orders itself after this unit, deliberately. Telemetry is best-effort: +# a broken collector must not keep Pulp down, and the OTLP exporters retry by +# themselves. Hence no entry in pulp_quadlet_runtime_after and no Requires= on +# this service anywhere. +[Unit] +Description=OpenTelemetry Collector for Pulp + +[Container] +ContainerName={{ pulp_otel_collector_name }} +Image={{ pulp_otel_collector_image }} +Network={{ pulp_quadlet_network_name }}.network +{# The -contrib image reads /etc/otelcol-contrib/config.yaml; the core + (non-contrib) image reads /etc/otelcol/config.yaml. Pinned together with + pulp_otel_collector_image — change both or the collector starts with its + built-in default config and silently drops everything. #} +{# Mount options are COMMA-separated after the third colon, so the relabel flag + has to join "ro" with a comma — ":ro:z" is a fourth field and is rejected. #} +Volume={{ pulp_assets_dir }}/otel/config.yaml:/etc/otelcol-contrib/config.yaml:ro{{ _pulp_z | replace(':', ',') }} +{% if pulp_otel_collector_expose_ports | bool %} +{# OTLP ingest. Only needed to push data in from OUTSIDE the container network — + Pulp itself reaches the collector by DNS on the shared network. #} +PublishPort={{ pulp_otel_collector_otlp_http_port }}:{{ pulp_otel_collector_otlp_http_port }} +PublishPort={{ pulp_otel_collector_otlp_grpc_port }}:{{ pulp_otel_collector_otlp_grpc_port }} +{% endif %} +{% if pulp_otel_collector_prometheus_exporter | bool %} +{# Scrape endpoint. Published unconditionally when the exporter is on: Prometheus + lives outside the container network, so an unpublished port is useless. #} +PublishPort={{ pulp_otel_collector_prometheus_port }}:{{ pulp_otel_collector_prometheus_port }} +{% endif %} +{% if pulp_otel_collector_telemetry_enabled | bool %} +PublishPort={{ pulp_otel_collector_telemetry_port }}:{{ pulp_otel_collector_telemetry_port }} +{% endif %} +LogDriver={{ pulp_log_driver }} +{% if pulp_log_driver == 'journald' %} +PodmanArgs=--log-opt tag=otel_collector +{% endif %} +# No HealthCmd / Notify=healthy: the collector image ships no shell or curl, so +# there is nothing to probe with — and since nothing orders itself after this +# unit, there is no dependent whose start would need gating. + +[Service] +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/roles/pulp/templates/quadlet/pulp-api.container.j2 b/roles/pulp/templates/quadlet/pulp-api.container.j2 new file mode 100644 index 0000000..8423470 --- /dev/null +++ b/roles/pulp/templates/quadlet/pulp-api.container.j2 @@ -0,0 +1,50 @@ +# {{ ansible_managed }} +# pulp-api gunicorn. In Quadlet mode the bundled pulp_web is disabled, so this +# publishes its gunicorn port directly for the EXTERNAL reverse proxy. That fixed +# host port is also why this service is single-instance (scale via +# pulp_api_workers, not replicas). +[Unit] +Description=Pulp API service +Requires={{ pulp_quadlet_runtime_after | join(' ') }} +After={{ pulp_quadlet_runtime_after | join(' ') }} +{% if pulp_quadlet_runtime_mounts %} +RequiresMountsFor={{ pulp_quadlet_runtime_mounts | join(' ') }} +{% endif %} + +[Container] +ContainerName=pulp-api +Image={{ pulp_runtime_image }} +Network={{ pulp_quadlet_network_name }}.network +HostName=pulp-api +Exec=pulp-api +PublishPort={{ pulp_api_host_port }}:24817 +Environment=PULP_API_WORKERS={{ pulp_api_workers }} +Environment=PULP_GUNICORN_TIMEOUT={{ pulp_gunicorn_timeout }} +{% if pulp_otel_enabled | bool %} +Environment=PULP_OTEL_ENABLED=true +Environment=OTEL_EXPORTER_OTLP_ENDPOINT={{ pulp_otel_exporter_endpoint }} +Environment=OTEL_EXPORTER_OTLP_PROTOCOL={{ pulp_otel_exporter_protocol }} +Environment=OTEL_SERVICE_NAME=pulp-api +{% endif %} +{% for vol in pulp_quadlet_runtime_volumes %} +Volume={{ vol }} +{% endfor %} +LogDriver={{ pulp_log_driver }} +PodmanArgs=--user {{ pulp_run_user }}{% if pulp_log_driver == 'journald' %} --log-opt tag=pulp_api{% endif %} + +# Healthcheck is informational here (visible in `podman ps` / `podman +# healthcheck run`). Deliberately NO Notify=healthy: nothing orders itself after +# api in this topology (pulp_web is disabled), so gating unit startup on the +# probe would only add a way for a slow first boot to mark the unit failed. +HealthCmd=readyz.py /pulp/api/v3/status/ +HealthInterval=10s +HealthTimeout=5s +HealthRetries=5 +HealthStartPeriod=90s + +[Service] +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/roles/pulp/templates/quadlet/pulp-content.container.j2 b/roles/pulp/templates/quadlet/pulp-content.container.j2 new file mode 100644 index 0000000..5ba3e11 --- /dev/null +++ b/roles/pulp/templates/quadlet/pulp-content.container.j2 @@ -0,0 +1,46 @@ +# {{ ansible_managed }} +# pulp-content gunicorn. Publishes its gunicorn port for the external reverse +# proxy, which MUST route /pulp/content/ here and everything else to the api port. +# Single-instance for the same fixed-host-port reason as pulp-api. +[Unit] +Description=Pulp content service +Requires={{ pulp_quadlet_runtime_after | join(' ') }} +After={{ pulp_quadlet_runtime_after | join(' ') }} +{% if pulp_quadlet_runtime_mounts %} +RequiresMountsFor={{ pulp_quadlet_runtime_mounts | join(' ') }} +{% endif %} + +[Container] +ContainerName=pulp-content +Image={{ pulp_runtime_image }} +Network={{ pulp_quadlet_network_name }}.network +HostName=pulp-content +Exec=pulp-content +PublishPort={{ pulp_content_host_port }}:24816 +Environment=PULP_CONTENT_WORKERS={{ pulp_content_workers }} +Environment=PULP_GUNICORN_TIMEOUT={{ pulp_gunicorn_timeout }} +{% if pulp_otel_enabled | bool %} +Environment=PULP_OTEL_ENABLED=true +Environment=OTEL_EXPORTER_OTLP_ENDPOINT={{ pulp_otel_exporter_endpoint }} +Environment=OTEL_EXPORTER_OTLP_PROTOCOL={{ pulp_otel_exporter_protocol }} +Environment=OTEL_SERVICE_NAME=pulp-content +{% endif %} +{% for vol in pulp_quadlet_runtime_volumes %} +Volume={{ vol }} +{% endfor %} +LogDriver={{ pulp_log_driver }} +PodmanArgs=--user {{ pulp_run_user }}{% if pulp_log_driver == 'journald' %} --log-opt tag=pulp_content{% endif %} + +# Informational only — see the note in pulp-api.container.j2. +HealthCmd=readyz.py /pulp/content/ +HealthInterval=10s +HealthTimeout=5s +HealthRetries=5 +HealthStartPeriod=90s + +[Service] +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/roles/pulp/templates/quadlet/pulp-init-password.container.j2 b/roles/pulp/templates/quadlet/pulp-init-password.container.j2 new file mode 100644 index 0000000..578d330 --- /dev/null +++ b/roles/pulp/templates/quadlet/pulp-init-password.container.j2 @@ -0,0 +1,36 @@ +# {{ ansible_managed }} +# One-shot: seed the Pulp admin (superuser) password on a FRESH install. +# +# Ordered After=pulp-migration because the auth tables must exist before a +# password can be set — the compose rendering only gated this on postgres being +# healthy, which is a latent race on a first bring-up. +# +# Not rendered when pulp_set_init_password is false (an existing deployment, +# where running this would reset the current admin's password). +[Unit] +Description=Pulp initial admin password (one-shot) +Requires=pulp-migration.service +After=pulp-migration.service +{% if pulp_quadlet_runtime_mounts %} +RequiresMountsFor={{ pulp_quadlet_runtime_mounts | join(' ') }} +{% endif %} + +[Container] +ContainerName=pulp-init-password +Image={{ pulp_runtime_image }} +Network={{ pulp_quadlet_network_name }}.network +Exec=set_init_password.sh +Environment=PULP_DEFAULT_ADMIN_PASSWORD={{ pulp_admin_password }} +{% for vol in pulp_quadlet_runtime_volumes %} +Volume={{ vol }} +{% endfor %} +LogDriver={{ pulp_log_driver }} +PodmanArgs=--user {{ pulp_run_user }}{% if pulp_log_driver == 'journald' %} --log-opt tag=pulp_init_password{% endif %} + + +[Service] +Type=oneshot +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/roles/pulp/templates/quadlet/pulp-migration.container.j2 b/roles/pulp/templates/quadlet/pulp-migration.container.j2 new file mode 100644 index 0000000..2e26cfc --- /dev/null +++ b/roles/pulp/templates/quadlet/pulp-migration.container.j2 @@ -0,0 +1,48 @@ +# {{ ansible_managed }} +# One-shot database migration. Runs `pulpcore-manager migrate` against the +# external PostgreSQL before any long-lived service starts. +# +# Type=oneshot + RemainAfterExit=yes makes Quadlet run the container in the +# foreground and keeps the unit "active (exited)" after success, so an After= on +# this unit means "after the migration COMPLETED" — compose's +# condition: service_completed_successfully. +# +# WARNING: on an image-tag bump this performs a real, FORWARD-ONLY schema +# upgrade. Snapshot the database first. +[Unit] +Description=Pulp database migration (one-shot) +{% if not (pulp_postgres_external | bool) %} +# Local database: wait for it to be ACCEPTING CONNECTIONS, not merely started. +# pulp-postgres carries Notify=healthy (pg_isready), so this After= covers initdb +# on a first bring-up. With an external database there is no unit to order +# against — the DB is assumed up, exactly as in the compose rendering. +Requires=pulp-postgres.service +After=pulp-postgres.service +{% endif %} +{% if pulp_quadlet_runtime_mounts %} +RequiresMountsFor={{ pulp_quadlet_runtime_mounts | join(' ') }} +{% endif %} + +[Container] +ContainerName=pulp-migration +Image={{ pulp_runtime_image }} +Network={{ pulp_quadlet_network_name }}.network +Exec=pulpcore-manager migrate --noinput +{% for vol in pulp_quadlet_runtime_volumes %} +Volume={{ vol }} +{% endfor %} +LogDriver={{ pulp_log_driver }} +{# Quadlet's User=/Group= keys take numeric ids only; pulp_run_user may be the + image's "pulp" name OR "uid:gid" (prod runs as the data owner). Pass it + through verbatim so both forms work. #} +PodmanArgs=--user {{ pulp_run_user }}{% if pulp_log_driver == 'journald' %} --log-opt tag=pulp_migration{% endif %} + + +[Service] +Type=oneshot +RemainAfterExit=yes +# A large database can take a long while; don't let systemd kill it mid-migration. +TimeoutStartSec={{ pulp_quadlet_migration_timeout }} + +[Install] +WantedBy=multi-user.target diff --git a/roles/pulp/templates/quadlet/pulp-pg-data.volume.j2 b/roles/pulp/templates/quadlet/pulp-pg-data.volume.j2 new file mode 100644 index 0000000..02d443c --- /dev/null +++ b/roles/pulp/templates/quadlet/pulp-pg-data.volume.j2 @@ -0,0 +1,15 @@ +# {{ ansible_managed }} +# Podman-managed volume for the containerised PostgreSQL data directory. Only +# rendered when the database runs locally (pulp_postgres_external is false) AND +# pulp_postgres_data_dir is empty (no host bind mount). +[Unit] +Description=Pulp PostgreSQL data volume + +[Volume] +# Name matches what compose.yml.j2 produces ("pg_data${DEV_VOLUME_SUFFIX:-data}" +# => pg_datadata), so a podman-compose -> Quadlet switch REUSES the existing +# volume. NOTE: this only holds when the old stack was podman-compose. Docker +# keeps its volumes in a separate store (/var/lib/docker/volumes), so a +# docker-compose -> Quadlet move starts from an EMPTY database and needs a +# pg_dump/pg_restore instead. +VolumeName=pg_datadata diff --git a/roles/pulp/templates/quadlet/pulp-postgres.container.j2 b/roles/pulp/templates/quadlet/pulp-postgres.container.j2 new file mode 100644 index 0000000..a11def9 --- /dev/null +++ b/roles/pulp/templates/quadlet/pulp-postgres.container.j2 @@ -0,0 +1,57 @@ +# {{ ansible_managed }} +# PostgreSQL — Pulp's database. Only rendered when pulp_postgres_external is +# false; with a managed database (RDS) there is nothing to run here. +# +# ContainerName=postgres is load-bearing: it is the DNS name aardvark-dns serves +# on the pulp network, and settings.py points DATABASES.HOST at +# pulp_postgres_host, which defaults to "postgres". Renaming one without the +# other breaks every service with a connection error. +# +# The /etc/passwd bind mount is the upstream pulp-oci-images workaround: the +# image's entrypoint needs a passwd entry for the uid it runs as, which is not +# guaranteed under an arbitrary uid mapping. +[Unit] +Description=Pulp PostgreSQL database +{% if pulp_quadlet_pg_mounts %} +RequiresMountsFor={{ pulp_quadlet_pg_mounts | join(' ') }} +{% endif %} + +[Container] +ContainerName=postgres +Image={{ pulp_postgres_image }} +Network={{ pulp_quadlet_network_name }}.network +Environment=POSTGRES_USER={{ pulp_postgres_user }} +Environment=POSTGRES_PASSWORD={{ pulp_postgres_password }} +Environment=POSTGRES_DB={{ pulp_postgres_db }} +Environment=POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 +Environment=POSTGRES_HOST_AUTH_METHOD=scram-sha-256 +Volume={{ _pulp_pg_data_volume | trim }} +Volume={{ pulp_assets_dir }}/postgres/passwd:/etc/passwd{{ _pulp_Z }} +{% if pulp_expose_postgres | bool %} +PublishPort=5432:5432 +{% endif %} +LogDriver={{ pulp_log_driver }} +{% if pulp_log_driver == 'journald' %} +PodmanArgs=--log-opt tag=postgres +{% endif %} +# Static syslog tag above keeps `journalctl -t postgres` working, matching the +# compose rendering's per-service tags. +# +# No --user here, deliberately: the postgres image manages its own uid switch +# from root (initdb refuses to run as an arbitrary uid), unlike the pulp-minimal +# services which run as pulp_run_user. +HealthCmd=pg_isready -U {{ pulp_postgres_user }} +HealthInterval=10s +HealthTimeout=5s +HealthRetries=5 +# Gate dependents on the healthcheck, not merely on the process starting. This +# is what makes After=pulp-postgres.service mean "accepting connections" — +# without it pulp-migration races initdb on a first bring-up, which is the exact +# failure compose avoided with condition: service_healthy. +Notify=healthy + +[Service] +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/roles/pulp/templates/quadlet/pulp-redis-data.volume.j2 b/roles/pulp/templates/quadlet/pulp-redis-data.volume.j2 new file mode 100644 index 0000000..d66b9cd --- /dev/null +++ b/roles/pulp/templates/quadlet/pulp-redis-data.volume.j2 @@ -0,0 +1,11 @@ +# {{ ansible_managed }} +# Podman-managed volume for the Redis append-only/RDB data. Only rendered when +# pulp_redis_data_dir is empty (no host bind mount). +[Unit] +Description=Pulp Redis data volume + +[Volume] +# Name matches what compose.yml.j2 produces ("redis_data${DEV_VOLUME_SUFFIX:-data}" +# => redis_datadata), so switching orchestrators REUSES the existing volume +# instead of silently starting from an empty one. +VolumeName=redis_datadata diff --git a/roles/pulp/templates/quadlet/pulp-redis.container.j2 b/roles/pulp/templates/quadlet/pulp-redis.container.j2 new file mode 100644 index 0000000..44aeddc --- /dev/null +++ b/roles/pulp/templates/quadlet/pulp-redis.container.j2 @@ -0,0 +1,40 @@ +# {{ ansible_managed }} +# Redis — Pulp's cache and task broker. The only data-store container in the +# Quadlet rendering (PostgreSQL is external/RDS in this mode). +# +# ContainerName=redis is load-bearing: it is the DNS name aardvark-dns serves on +# the pulp network, and settings.py points REDIS_HOST at pulp_redis_host, which +# defaults to "redis". Renaming one without the other breaks the cache silently. +[Unit] +Description=Pulp Redis cache and task broker +{% if pulp_quadlet_redis_mounts %} +RequiresMountsFor={{ pulp_quadlet_redis_mounts | join(' ') }} +{% endif %} + +[Container] +ContainerName=redis +Image={{ pulp_redis_image }} +Network={{ pulp_quadlet_network_name }}.network +Volume={{ _pulp_redis_volume | trim }} +{% if pulp_expose_redis | bool %} +PublishPort=6379:6379 +{% endif %} +LogDriver={{ pulp_log_driver }} +{% if pulp_log_driver == 'journald' %} +PodmanArgs=--log-opt tag=redis +{% endif %} +# Static syslog tag above keeps `journalctl -t redis` working, matching the +# compose rendering's per-service tags. +HealthCmd=redis-cli ping +HealthInterval=10s +HealthTimeout=5s +HealthRetries=5 +# Gate dependents on the healthcheck, not merely on the process starting — the +# Quadlet equivalent of compose's depends_on: condition: service_healthy. +Notify=healthy + +[Service] +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/roles/pulp/templates/quadlet/pulp-signing.container.j2 b/roles/pulp/templates/quadlet/pulp-signing.container.j2 new file mode 100644 index 0000000..4085024 --- /dev/null +++ b/roles/pulp/templates/quadlet/pulp-signing.container.j2 @@ -0,0 +1,31 @@ +# {{ ansible_managed }} +# One-shot: register the Pulp signing service. Requires the GPG key identified by +# pulp_signing_key_fingerprint to already be in the pulp keyring — otherwise this +# unit fails and, because api/content/worker order After= it, the stack stays down. +[Unit] +Description=Pulp signing service registration (one-shot) +Requires=pulp-migration.service +After=pulp-migration.service +{% if pulp_quadlet_runtime_mounts %} +RequiresMountsFor={{ pulp_quadlet_runtime_mounts | join(' ') }} +{% endif %} + +[Container] +ContainerName=pulp-signing +Image={{ pulp_runtime_image }} +Network={{ pulp_quadlet_network_name }}.network +Exec=sh -c "add_signing_service.sh" +Environment=PULP_SIGNING_KEY_FINGERPRINT={{ pulp_signing_key_fingerprint }} +{% for vol in pulp_quadlet_runtime_volumes %} +Volume={{ vol }} +{% endfor %} +LogDriver={{ pulp_log_driver }} +PodmanArgs=--user {{ pulp_run_user }}{% if pulp_log_driver == 'journald' %} --log-opt tag=pulp_signing{% endif %} + + +[Service] +Type=oneshot +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/roles/pulp/templates/quadlet/pulp-storage.volume.j2 b/roles/pulp/templates/quadlet/pulp-storage.volume.j2 new file mode 100644 index 0000000..4f87057 --- /dev/null +++ b/roles/pulp/templates/quadlet/pulp-storage.volume.j2 @@ -0,0 +1,12 @@ +# {{ ansible_managed }} +# Podman-managed volume for the Pulp content store. Only rendered when +# pulp_storage_dir is empty (no host bind mount). Prod bind-mounts the EFS tree +# instead, so this file is absent there. +[Unit] +Description=Pulp content storage volume + +[Volume] +# Name matches what compose.yml.j2 produces ("pulp${DEV_VOLUME_SUFFIX:-data}" => +# pulpdata). Critical: a different name here would mount an EMPTY store on an +# orchestrator switch, and Pulp would report every artifact as missing. +VolumeName=pulpdata diff --git a/roles/pulp/templates/quadlet/pulp-worker.container.j2 b/roles/pulp/templates/quadlet/pulp-worker.container.j2 new file mode 100644 index 0000000..b6de474 --- /dev/null +++ b/roles/pulp/templates/quadlet/pulp-worker.container.j2 @@ -0,0 +1,46 @@ +# {{ ansible_managed }} +# Pulp task worker {{ worker_index }} of {{ pulp_worker_replicas }}. +# +# One concrete unit per replica rather than a systemd template unit +# (pulp-worker@.container): Quadlet-generated units live in the systemd generator +# directory, so `systemctl enable pulp-worker@N` has nothing to symlink and boot +# persistence has to come from [Install] WantedBy= inside the file — which a +# template cannot express per instance. Rendering N files keeps enablement +# declarative; quadlet.yml prunes units above the current replica count. +# +# Workers publish no port, so they scale freely by replica count (unlike +# api/content). This replaces the old pulpcore-worker@1..N systemd units. +[Unit] +Description=Pulp task worker {{ worker_index }} +Requires={{ pulp_quadlet_runtime_after | join(' ') }} +After={{ pulp_quadlet_runtime_after | join(' ') }} +{% if pulp_quadlet_runtime_mounts %} +RequiresMountsFor={{ pulp_quadlet_runtime_mounts | join(' ') }} +{% endif %} + +[Container] +ContainerName=pulp-worker-{{ worker_index }} +Image={{ pulp_runtime_image }} +Network={{ pulp_quadlet_network_name }}.network +Exec=pulp-worker +{% if pulp_otel_enabled | bool %} +Environment=PULP_OTEL_ENABLED=true +Environment=OTEL_EXPORTER_OTLP_ENDPOINT={{ pulp_otel_exporter_endpoint }} +Environment=OTEL_EXPORTER_OTLP_PROTOCOL={{ pulp_otel_exporter_protocol }} +Environment=OTEL_SERVICE_NAME=pulp-worker +{% endif %} +{% for vol in pulp_quadlet_runtime_volumes %} +Volume={{ vol }} +{% endfor %} +LogDriver={{ pulp_log_driver }} +{# Static tag shared by every replica, so `journalctl -t pulp_worker` aggregates + all of them while journald's CONTAINER_NAME still separates each one. #} +PodmanArgs=--user {{ pulp_run_user }}{% if pulp_log_driver == 'journald' %} --log-opt tag=pulp_worker{% endif %} + + +[Service] +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/roles/pulp/templates/quadlet/pulp.network.j2 b/roles/pulp/templates/quadlet/pulp.network.j2 new file mode 100644 index 0000000..299f83c --- /dev/null +++ b/roles/pulp/templates/quadlet/pulp.network.j2 @@ -0,0 +1,14 @@ +# {{ ansible_managed }} +# Dedicated bridge network for the Pulp stack. +# +# This unit is MANDATORY, not cosmetic. Podman's built-in "podman" network has +# DNS DISABLED — a container that omits Network= lands there, cannot resolve +# "redis", and pulp-api/content/worker fail to reach the cache/broker. compose +# hid this by always creating a project network. Quadlet-created networks have +# DNS (aardvark-dns) on by default, which is what makes ContainerName= usable as +# a hostname across the stack. +[Unit] +Description=Pulp container network + +[Network] +NetworkName={{ pulp_quadlet_network_name }} diff --git a/roles/pulp/templates/settings.py.j2 b/roles/pulp/templates/settings.py.j2 new file mode 100644 index 0000000..ec58936 --- /dev/null +++ b/roles/pulp/templates/settings.py.j2 @@ -0,0 +1,64 @@ +# {{ ansible_managed }} +# Pulp settings — mounted at /etc/pulp/settings.py in every pulp service. +# Every value here is driven from role variables (see roles/pulp/defaults/main.yml). +# Reference: https://pulpproject.org/pulpcore/docs/admin/reference/settings/ + +SECRET_KEY = "{{ pulp_secret_key }}" +CONTENT_ORIGIN = "{{ pulp_content_origin }}" + +DATABASES = { + "default": { + "HOST": "{{ pulp_postgres_host }}", + "ENGINE": "django.db.backends.postgresql", + "NAME": "{{ pulp_postgres_db }}", + "USER": "{{ pulp_postgres_user }}", + "PASSWORD": "{{ pulp_postgres_password }}", + "PORT": "{{ pulp_postgres_port }}", +{% if pulp_postgres_conn_max_age is defined %} + "CONN_MAX_AGE": {{ pulp_postgres_conn_max_age }}, +{% endif %} +{% if pulp_postgres_sslmode is defined %} + "OPTIONS": {"sslmode": "{{ pulp_postgres_sslmode }}"}, +{% endif %} + } +} + +DB_ENCRYPTION_KEY = "{{ pulp_db_encryption_key_path }}" + +CACHE_ENABLED = {{ pulp_cache_enabled | bool }} +REDIS_HOST = "{{ pulp_redis_host }}" +REDIS_PORT = {{ pulp_redis_port }} +REDIS_PASSWORD = "{{ pulp_redis_password }}" + +ANSIBLE_API_HOSTNAME = "{{ pulp_ansible_api_hostname }}" +ANSIBLE_CONTENT_HOSTNAME = "{{ pulp_ansible_content_hostname }}" + +# ── Filesystem layout (container-internal paths under the pulp storage mount) ─ +DEPLOY_ROOT = "{{ pulp_deploy_root }}" +MEDIA_ROOT = "{{ pulp_media_root }}" +WORKING_DIRECTORY = "{{ pulp_working_directory }}" +FILE_UPLOAD_TEMP_DIR = "{{ pulp_file_upload_temp_dir }}" +STATIC_ROOT = "{{ pulp_static_root }}" +STATIC_URL = "{{ pulp_static_url }}" +CONTENT_PATH_PREFIX = "{{ pulp_content_path_prefix }}" + +ALLOWED_IMPORT_PATHS = {{ pulp_allowed_import_paths | to_json }} +ALLOWED_EXPORT_PATHS = {{ pulp_allowed_export_paths | to_json }} + +# ── Content / upload protection windows (minutes) and app TTL (seconds) ────── +UPLOAD_PROTECTION_TIME = {{ pulp_upload_protection_time }} +TMPFILE_PROTECTION_TIME = {{ pulp_tmpfile_protection_time }} +API_APP_TTL = {{ pulp_api_app_ttl }} + +ALLOWED_CONTENT_CHECKSUMS = {{ pulp_allowed_content_checksums | to_json }} + +AUTHENTICATION_BACKENDS = {{ pulp_authentication_backends | to_json }} + +# ── pulp_container token auth ─────────────────────────────────────────────── +TOKEN_SERVER = "{{ pulp_token_server }}" +TOKEN_AUTH_DISABLED = {{ pulp_token_auth_disabled | bool }} +TOKEN_SIGNATURE_ALGORITHM = "{{ pulp_token_signature_algorithm }}" +PUBLIC_KEY_PATH = "{{ pulp_token_public_key_path }}" +PRIVATE_KEY_PATH = "{{ pulp_token_private_key_path }}" + +ANALYTICS = {{ pulp_analytics | bool }} diff --git a/roles/pulp/vars/main.yml b/roles/pulp/vars/main.yml new file mode 100644 index 0000000..e6d3916 --- /dev/null +++ b/roles/pulp/vars/main.yml @@ -0,0 +1,103 @@ +--- +# Derived values shared by the compose and Quadlet renderings. These are NOT +# meant to be overridden per-environment (role vars outrank group_vars) — tune +# the inputs in defaults/main.yml instead. + +# ── SELinux relabel suffixes ──────────────────────────────────────────────── +# Same semantics as the {% set %} preamble in compose.yml.j2: ":z" shared / +# ":Z" private, empty when relabeling is off. Named-volume mounts never get one. +_pulp_z: "{{ ':z' if (pulp_selinux_relabel | bool) else '' }}" +_pulp_Z: "{{ ':Z' if (pulp_selinux_relabel | bool) else '' }}" +_pulp_storage_z: "{{ ':z' if (pulp_storage_selinux_relabel | bool) else '' }}" +_pulp_exports_z: "{{ ':z' if (pulp_exports_selinux_relabel | bool) else '' }}" + +# ── Quadlet layout ────────────────────────────────────────────────────────── +# Rootful Quadlet search path. Files dropped here are turned into systemd units +# by podman-system-generator on daemon-reload and at boot. +pulp_quadlet_dir: /etc/containers/systemd + +# Name of the podman network the stack shares. Also the basename of the +# .network unit, which every .container references as ".network" — Quadlet +# resolves that to the generated pulp-network.service and adds the ordering. +pulp_quadlet_network_name: pulp + +# ── Volume lines for the pulp-minimal based services ──────────────────────── +# A bind mount is "host:container[:opts]"; a named volume references the .volume +# unit ("pulp-storage.volume:/var/lib/pulp"), which Quadlet resolves to the +# podman volume it manages. +_pulp_storage_volume: >- + {{ (pulp_storage_dir ~ ':' ~ pulp_storage_mount ~ _pulp_storage_z) + if pulp_storage_dir else ('pulp-storage.volume:' ~ pulp_storage_mount) }} +_pulp_exports_volume: >- + {{ (pulp_exports_dir ~ ':' ~ pulp_export_path ~ _pulp_exports_z) + if pulp_exports_dir else '' }} +_pulp_redis_volume: >- + {{ (pulp_redis_data_dir ~ ':/data' ~ _pulp_Z) + if pulp_redis_data_dir else 'pulp-redis-data.volume:/data' }} +_pulp_pg_data_volume: >- + {{ (pulp_postgres_data_dir ~ ':/var/lib/postgresql/data' ~ _pulp_Z) + if pulp_postgres_data_dir else 'pulp-pg-data.volume:/var/lib/postgresql/data' }} + +# Mounts shared by api / content / worker / the one-shots: the rendered config +# assets, the certs dir (settings.py, DB-fields key, token keypair), the content +# store, and — only when it is a separate bind mount — the exports dir. +pulp_quadlet_runtime_volumes: >- + {{ [pulp_assets_dir ~ '/settings.py:/etc/pulp/settings.py' ~ _pulp_z, + pulp_assets_dir ~ '/certs:/etc/pulp/certs' ~ _pulp_z, + _pulp_storage_volume | trim] + + ([_pulp_exports_volume | trim] if pulp_exports_dir else []) }} + +# Host bind-mount sources the runtime services depend on. Fed to RequiresMountsFor +# so a unit waits for (and requires) the .mount unit providing an NFS/EFS-backed +# store instead of starting against an empty directory. Quadlet replaces the +# host-wide podman-restart.service drop-in with this per-unit ordering. +pulp_quadlet_runtime_mounts: "{{ [pulp_storage_dir, pulp_exports_dir] | select | list }}" +pulp_quadlet_redis_mounts: "{{ [pulp_redis_data_dir] | select | list }}" +pulp_quadlet_pg_mounts: "{{ [pulp_postgres_data_dir] | select | list }}" + +# The postgres unit, as a list so it folds into the dependency lists below and +# vanishes entirely when the database is external (RDS) and nothing is rendered. +_pulp_quadlet_pg_unit: >- + {{ ['pulp-postgres.service'] if not (pulp_postgres_external | bool) else [] }} + +# Services api/content/worker must wait on. The one-shots are Type=oneshot + +# RemainAfterExit=yes, so After= on them means "after it completed successfully" +# — the Quadlet equivalent of compose's condition: service_completed_successfully. +pulp_quadlet_runtime_after: >- + {{ _pulp_quadlet_pg_unit + + ['pulp-redis.service', 'pulp-migration.service'] + + (['pulp-signing.service'] if pulp_signing_enabled | bool else []) + + (['pulp-init-password.service'] if pulp_set_init_password | bool else []) }} + +# The long-lived services that mount the rendered config assets. These are what +# a settings.py / certs change has to restart — redis mounts none of it and the +# one-shots have already exited. +pulp_quadlet_runtime_units: >- + {{ ['pulp-api.service', 'pulp-content.service'] + + (range(1, (pulp_worker_replicas | int) + 1) + | map('regex_replace', '^(.*)$', 'pulp-worker-\1.service') | list) }} + +# The data stores. They mount none of the rendered config assets, so a +# settings.py change must NOT restart them — but a change to their own unit file +# (image bump, exposed port, volume swap) has to be applied somehow, which is +# what the "Restart Pulp data stores" handler topic uses this for. +pulp_quadlet_datastore_units: >- + {{ _pulp_quadlet_pg_unit + ['pulp-redis.service'] }} + +# The collector, when enabled. Started by the playbook and WantedBy the boot +# target, but deliberately absent from pulp_quadlet_runtime_after — nothing in +# the Pulp stack requires it. +pulp_quadlet_collector_units: >- + {{ ['otel-collector.service'] if pulp_otel_collector_enabled | bool else [] }} + +# Everything to bring up, in dependency order. systemd would resolve this from +# the units' own Requires=/After=, but starting them explicitly in order surfaces +# a failing one-shot as that unit's failure rather than as an opaque dependency +# error on pulp-api. +pulp_quadlet_all_units: >- + {{ _pulp_quadlet_pg_unit + + ['pulp-redis.service', 'pulp-migration.service'] + + (['pulp-signing.service'] if pulp_signing_enabled | bool else []) + + (['pulp-init-password.service'] if pulp_set_init_password | bool else []) + + pulp_quadlet_runtime_units + + pulp_quadlet_collector_units }}