Skip to content
28 changes: 24 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
python-version: "3.13"

- name: Install dependencies
run: pip install yamllint 'ansible-core>=2.15' ansible-lint
run: pip install yamllint 'ansible-core>=2.16' ansible-lint

- name: Install required collections
run: ansible-galaxy collection install ansible.posix
Expand All @@ -46,7 +46,7 @@ jobs:
python-version: "3.13"

- name: Install dependencies
run: pip install 'ansible-core>=2.15'
run: pip install 'ansible-core>=2.16'

- name: Install required collections
run: ansible-galaxy collection install ansible.posix
Expand All @@ -69,7 +69,7 @@ jobs:
python-version: "3.13"

- name: Install dependencies
run: pip install 'ansible-core>=2.15'
run: pip install 'ansible-core>=2.16'

- name: Verify quadlet templates match upstream compose
run: ansible-playbook verify_compose_alignment.yml
Expand All @@ -86,10 +86,30 @@ jobs:
python-version: "3.13"

- name: Install dependencies
run: pip install 'ansible-core>=2.15' 'molecule>=26.0.0,<27'
run: pip install 'ansible-core>=2.16' 'molecule>=26.0.0,<27'

- name: Install required collections
run: ansible-galaxy collection install ansible.posix

- name: Run Molecule tests
run: molecule test

molecule-rootless:
name: Quadlet template rendering (rootless)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install dependencies
run: pip install 'ansible-core>=2.16' 'molecule>=26.0.0,<27'

- name: Install required collections
run: ansible-galaxy collection install ansible.posix

- name: Run Molecule rootless tests
run: molecule test -s rootless
21 changes: 19 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ The `pull_secret` must be a valid `.dockerconfigjson` JSON string, base64-encode

**Playbook entry points:** `main.yml` runs the full role. `run_role.yml`, `run_role_task.yml`, and `run_task.yml` are helpers for running individual roles or task phases. All four load variables from `vault_path`/`vault_dir` and `vars_path`/`vars_dir` extra-vars before executing.

**The role** (`roles/dcm_deploy/`) deploys 12 containers in six sequential phases defined in `tasks/main.yml`: prerequisites → generate_configs → deploy_quadlet_files → initialize_database → start_services → validate_deployment.
**The role** (`roles/dcm_deploy/`) deploys 12 containers in six sequential phases defined in `tasks/main.yml`: prerequisites → generate_configs → deploy_quadlet_files → initialize_database → start_services → validate_deployment. An optional `resolve_rootless_vars` phase runs first (when `dcm_rootless: true`) to set internal facts that adapt all paths, ownership, and systemd scope for rootless Podman deployment.

**Config source of truth:** Traefik config and PostgreSQL init SQL are NOT templated — they're copied from a clone of the upstream [api-gateway](https://github.com/dcm-project/api-gateway) repo at deploy time. Only `dcm.env.j2` and the quadlet unit files are Jinja2 templates.

Expand Down Expand Up @@ -115,6 +115,20 @@ Optional providers (kubevirt, k8s-container, acm-cluster, three-tier-demo) are g

The three-tier-demo provider requires `dcm_provider_k8s_container` to be enabled; it shares the `dcm_k8s_container_sp_kubeconfig` path.

## Rootless Support

When `dcm_rootless: true`, the `resolve_rootless_vars` phase sets internal facts that the rest of the role consumes transparently:

- `_dcm_systemd_scope` — `user` (vs `system`)
- `_dcm_wanted_by` — `default.target` (vs `multi-user.target`)
- `_dcm_file_owner` / `_dcm_file_group` — the rootless user/group
- `_dcm_become_user` — the user to `become` for systemd and podman operations
- `_dcm_rootless_env` — environment dict with `XDG_RUNTIME_DIR` for user-scoped dbus

The `dcm_quadlet_dir` and `dcm_config_dir` variables are overridden via `set_fact` to point at user-scoped paths. Existing template and task references work unchanged because they use these variable names.

Rootless and rootful are mutually exclusive on the same host. There is no migration path between them — data volumes are stored in different Podman storage locations.

## Syncing with Upstream api-gateway

The upstream compose.yaml at `https://github.com/dcm-project/api-gateway` is the source of truth. When it changes, this role must be updated to match. Run `ansible-playbook verify_compose_alignment.yml` first — it checks template file existence, environment variable coverage, dependency ordering (`depends_on` vs `After=`/`Requires=`), port mappings, and image name alignment. If it passes, the templates are in sync with compose.
Expand Down Expand Up @@ -154,18 +168,21 @@ These are copied at deploy time from the cloned repo — usually no action neede

## CI

GitHub Actions runs five jobs on every PR targeting `main`:
GitHub Actions runs six jobs on every PR targeting `main`:

- **check-clean-commits** — shared workflow that checks for merge commits, fixup/squash/WIP markers, vague commit messages
- **Lint** — `yamllint` + `ansible-lint`
- **Syntax check** — `ansible-playbook --syntax-check` on all playbooks
- **Compose-to-quadlet alignment** — runs `verify_compose_alignment.yml` against live upstream compose.yaml
- **Quadlet template rendering** — `molecule test` renders all core quadlet templates locally and asserts naming conventions, dependency symmetry, SELinux suffixes, Pull policy, env file references, and network membership
- **Quadlet template rendering (rootless)** — `molecule test -s rootless` renders templates with `dcm_rootless: true` and asserts rootless-specific paths, `WantedBy=default.target`, and systemd scope

### Molecule scenario

The `molecule/default/` scenario uses a delegated driver (no containers) and only runs `deploy_quadlet_files` with `ANSIBLE_SKIP_TAGS: systemd` to avoid systemd calls. It tests template rendering correctness, not deployment. Assertions are in `molecule/default/verify.yml`. When adding a new service, update the container lists and assertion groups there.

The `molecule/rootless/` scenario is identical in structure but sets `dcm_rootless: true` in its converge variables. It verifies that templates render with user-scoped paths and `WantedBy=default.target`. File ownership is not asserted because the delegated driver runs as the test user, not a real `dcm` service account. Assertions are in `molecule/rootless/verify.yml`.

### Task tags

Each phase in `tasks/main.yml` is tagged (`prerequisites`, `generate_configs`, `deploy_quadlet_files`, `initialize_database`, `start_services`, `validate_deployment`). The two `systemd_service` tasks in `deploy_quadlet_files.yml` are tagged `systemd` — this is how Molecule skips them in CI.
56 changes: 51 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,16 @@ Configuration files (Traefik routes, PostgreSQL init SQL) are sourced from the [
## Prerequisites

- RHEL 9 or Fedora target host with Podman 4.4+ (quadlet support)
- Ansible 2.15+ on the control node
- Ansible 2.16+ on the control node (rootless mode requires `systemd_service` `scope` parameter)
- `ansible.posix` collection: `ansible-galaxy collection install ansible.posix`
- Network access to pull container images from `docker.io` and `quay.io`
- Dedicated host (container names like `postgres` and `nats` assume no collisions)

## Deployment Phases

The `dcm_deploy` role executes in six phases:
The `dcm_deploy` role executes in six phases (seven when `dcm_rootless: true`):

1. **Resolve rootless vars** *(optional, rootless only)* — creates the service user, configures subuid/subgid, enables lingering, starts the user systemd instance, and sets internal facts for user-scoped paths and systemd
1. **Prerequisites** — installs container tools, firewalld, and git; opens the gateway and UI ports; creates config directories
2. **Generate configs** — clones the api-gateway repo, copies Traefik config and PostgreSQL init SQL, templates the shared environment file, then cleans up the clone
3. **Deploy quadlet files** — places `.container`, `.network`, and `.volume` unit files into `/etc/containers/systemd/` and reloads systemd
Expand Down Expand Up @@ -224,6 +225,45 @@ Note: three-tier-demo shares `dcm_k8s_container_sp_kubeconfig` — set `dcm_prov
|----------|---------|-------------|
| `dcm_firewall_zone` | `public` | Firewalld zone for published ports |

### Rootless Deployment

| Variable | Default | Description |
|----------|---------|-------------|
| `dcm_rootless` | `false` | Enable rootless Podman deployment |
| `dcm_rootless_user` | `dcm` | User account to run containers as |
| `dcm_rootless_create_user` | `true` | Whether the role should create the user |
| `dcm_rootless_home` | `/home/{{ dcm_rootless_user }}` | Home directory path for the rootless user |

## Rootless Deployment

Set `dcm_rootless: true` to run all containers under a dedicated unprivileged user instead of root. This uses user-scoped systemd and rootless Podman — no root privileges are needed at runtime.

```yaml
dcm_rootless: true
dcm_rootless_user: dcm # default
```

Set `dcm_rootless_create_user: false` if user management is handled externally (LDAP, IPA, etc.). The `ansible.builtin.user` module is idempotent, so you do not need to disable it just because the user already exists locally.

### Path differences

| Resource | Rootful (default) | Rootless |
|----------|-------------------|----------|
| Quadlet directory | `/etc/containers/systemd` | `~dcm/.config/containers/systemd` |
| Config directory | `/srv/containers/dcm/config` | `~dcm/.local/share/dcm/config` |
| Systemd target | `multi-user.target` | `default.target` |
| Systemd scope | `system` | `user` |

### Requirements

- `ansible-core >= 2.16` on the control node (required for the `scope` parameter on the `systemd_service` module)
- `become_method: sudo` configured for the target host (the role uses nested privilege dropping to the rootless user)

### Limitations

- Rootless and rootful deployments are **mutually exclusive** on the same host. Do not enable both.
- There is **no migration path** from rootful to rootless. Data volumes live in different storage paths per Podman's storage model.

## Verification

After deployment, verify the stack is healthy:
Expand All @@ -241,14 +281,20 @@ curl http://<host>:9080/api/v1alpha1/health/placement
# DCM UI accessible
curl http://<host>:7007

# All systemd units active
# All systemd units active (rootful)
systemctl status dcm-*.service
# For rootless, run as the service user:
# sudo -u dcm XDG_RUNTIME_DIR=/run/user/$(id -u dcm) systemctl --user status dcm-*.service

# All containers running
# All containers running (rootful)
podman ps
# For rootless:
# sudo -u dcm podman ps

# Databases created
# Databases created (rootful)
podman exec postgres psql -U admin -l
# For rootless:
# sudo -u dcm podman exec postgres psql -U admin -l
```

## Compose Alignment
Expand Down
8 changes: 8 additions & 0 deletions molecule/default/converge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@
dcm_kubevirt_kubeconfig: /tmp/molecule-dcm/kubeconfig-kubevirt
dcm_k8s_container_sp_kubeconfig: /tmp/molecule-dcm/kubeconfig-k8s
dcm_acm_cluster_sp_kubeconfig: /tmp/molecule-dcm/kubeconfig-acm
# Rootful-mode internal facts (normally set by resolve_rootless_vars.yml)
dcm_rootless: false
_dcm_systemd_scope: system
_dcm_wanted_by: multi-user.target
_dcm_file_owner: "{{ omit }}"
_dcm_file_group: "{{ omit }}"
_dcm_become_user: root
_dcm_rootless_env: {}
Comment thread
chadcrum marked this conversation as resolved.
tasks:
- name: Deploy quadlet files
ansible.builtin.include_role:
Expand Down
25 changes: 25 additions & 0 deletions molecule/default/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,28 @@
loop: "{{ core_slurp.results }}"
loop_control:
label: "{{ item.item }}"

# --- WantedBy matches rootful mode (multi-user.target) ---
- name: Assert WantedBy=multi-user.target in core containers
ansible.builtin.assert:
that: >-
'WantedBy=multi-user.target' in (item.content | b64decode)
fail_msg: "{{ item.item }} should have WantedBy=multi-user.target in rootful mode"
loop: "{{ core_slurp.results }}"
loop_control:
label: "{{ item.item }}"

- name: Slurp network file
ansible.builtin.slurp:
src: "{{ quadlet_dir }}/{{ item }}"
register: network_slurp
loop: "{{ network_files }}"

- name: Assert WantedBy=multi-user.target in network file
ansible.builtin.assert:
that: >-
'WantedBy=multi-user.target' in (item.content | b64decode)
fail_msg: "{{ item.item }} should have WantedBy=multi-user.target in rootful mode"
loop: "{{ network_slurp.results }}"
loop_control:
label: "{{ item.item }}"
34 changes: 34 additions & 0 deletions molecule/rootless/converge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
- name: Converge — render quadlet templates (rootless)
hosts: all
gather_facts: false
vars:
dcm_quadlet_dir: /tmp/molecule-dcm-rootless/.config/containers/systemd
dcm_config_dir: /tmp/molecule-dcm-rootless/.local/share/dcm/config
Comment thread
chadcrum marked this conversation as resolved.
dcm_kubevirt_kubeconfig: /tmp/molecule-dcm-rootless/kubeconfig-kubevirt
dcm_k8s_container_sp_kubeconfig: /tmp/molecule-dcm-rootless/kubeconfig-k8s
dcm_acm_cluster_sp_kubeconfig: /tmp/molecule-dcm-rootless/kubeconfig-acm
# Rootless-mode internal facts (normally set by resolve_rootless_vars.yml)
dcm_rootless: true
dcm_rootless_user: "molecule-dcm"
dcm_rootless_home: "/tmp/molecule-dcm-rootless"
_dcm_systemd_scope: user
_dcm_wanted_by: default.target
_dcm_file_owner: "{{ omit }}"
_dcm_file_group: "{{ omit }}"
_dcm_become_user: "{{ lookup('env', 'USER') }}"
_dcm_rootless_env: {}
Comment thread
chadcrum marked this conversation as resolved.
pre_tasks:
- name: Create rootless directory structure
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ dcm_quadlet_dir }}"
- "{{ dcm_config_dir }}"
tasks:
- name: Deploy quadlet files (rootless)
Comment thread
chadcrum marked this conversation as resolved.
ansible.builtin.include_role:
name: dcm_deploy
tasks_from: deploy_quadlet_files
9 changes: 9 additions & 0 deletions molecule/rootless/destroy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
- name: Destroy test environment
hosts: all
gather_facts: false
tasks:
- name: Remove molecule working directory
ansible.builtin.file:
path: /tmp/molecule-dcm-rootless
state: absent
19 changes: 19 additions & 0 deletions molecule/rootless/molecule.yml
Comment thread
chadcrum marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
driver:
name: default

provisioner:
name: ansible
env:
ANSIBLE_SKIP_TAGS: systemd
ANSIBLE_ROLES_PATH: ${MOLECULE_PROJECT_DIRECTORY}/roles
inventory:
hosts:
all:
hosts:
localhost:
ansible_host: 127.0.0.1
ansible_connection: local

verifier:
name: ansible
Loading
Loading