From a5e8cd39c5672d733cf69209bfa8d6163c4b6d1f Mon Sep 17 00:00:00 2001 From: Chad Crum Date: Thu, 28 May 2026 14:45:35 -0400 Subject: [PATCH 1/8] feat(dcm_deploy): add optional rootless Podman deployment support Co-Authored-By: Claude Opus 4.6 Signed-off-by: Chad Crum --- .github/workflows/ci.yml | 28 +++- CLAUDE.md | 21 ++- README.md | 41 +++++- molecule/default/converge.yml | 8 ++ molecule/default/verify.yml | 25 ++++ molecule/rootless/converge.yml | 34 +++++ molecule/rootless/destroy.yml | 9 ++ molecule/rootless/molecule.yml | 19 +++ molecule/rootless/verify.yml | 134 ++++++++++++++++++ roles/dcm_deploy/defaults/main.yml | 6 + roles/dcm_deploy/handlers/main.yml | 2 + .../dcm_deploy/tasks/deploy_quadlet_files.yml | 26 ++++ roles/dcm_deploy/tasks/generate_configs.yml | 24 ++++ .../dcm_deploy/tasks/initialize_database.yml | 16 +++ roles/dcm_deploy/tasks/main.yml | 4 + roles/dcm_deploy/tasks/prerequisites.yml | 25 +++- .../tasks/resolve_rootless_vars.yml | 119 ++++++++++++++++ roles/dcm_deploy/tasks/start_services.yml | 35 +++++ .../dcm_deploy/tasks/validate_deployment.yml | 12 ++ ...-acm-cluster-service-provider.container.j2 | 2 +- .../dcm-catalog-manager.container.j2 | 2 +- .../templates/dcm-gateway.container.j2 | 2 +- ...8s-container-service-provider.container.j2 | 2 +- ...dcm-kubevirt-service-provider.container.j2 | 2 +- .../templates/dcm-nats.container.j2 | 2 +- .../templates/dcm-network.network.j2 | 2 +- .../dcm-placement-manager.container.j2 | 2 +- .../templates/dcm-policy-manager.container.j2 | 2 +- .../templates/dcm-postgres.container.j2 | 2 +- .../dcm-service-provider-manager.container.j2 | 2 +- ...ee-tier-demo-service-provider.container.j2 | 2 +- .../dcm_deploy/templates/dcm-ui.container.j2 | 2 +- 32 files changed, 593 insertions(+), 21 deletions(-) create mode 100644 molecule/rootless/converge.yml create mode 100644 molecule/rootless/destroy.yml create mode 100644 molecule/rootless/molecule.yml create mode 100644 molecule/rootless/verify.yml create mode 100644 roles/dcm_deploy/tasks/resolve_rootless_vars.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdd694d..362ff00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 142213e..88de426 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. @@ -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. @@ -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. diff --git a/README.md b/README.md index c64add9..ff2f00f 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ 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) @@ -224,6 +224,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 +dcm_rootless_create_user: true # default — set false if user already exists +``` + +### 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. +- Firewalld rules are **skipped** in rootless mode. Rootless Podman uses user-space networking (slirp4netns/pasta), so all published ports are > 1024 and do not require `CAP_NET_BIND_SERVICE`. + ## Verification After deployment, verify the stack is healthy: diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index 34aaecd..56874f6 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -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: root + _dcm_file_group: root + _dcm_become_user: root + _dcm_rootless_env: {} tasks: - name: Deploy quadlet files ansible.builtin.include_role: diff --git a/molecule/default/verify.yml b/molecule/default/verify.yml index 9f27711..621d3d6 100644 --- a/molecule/default/verify.yml +++ b/molecule/default/verify.yml @@ -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 }}" diff --git a/molecule/rootless/converge.yml b/molecule/rootless/converge.yml new file mode 100644 index 0000000..3e6819e --- /dev/null +++ b/molecule/rootless/converge.yml @@ -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 + 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: {} + 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) + ansible.builtin.include_role: + name: dcm_deploy + tasks_from: deploy_quadlet_files diff --git a/molecule/rootless/destroy.yml b/molecule/rootless/destroy.yml new file mode 100644 index 0000000..5f939c2 --- /dev/null +++ b/molecule/rootless/destroy.yml @@ -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 diff --git a/molecule/rootless/molecule.yml b/molecule/rootless/molecule.yml new file mode 100644 index 0000000..2edc705 --- /dev/null +++ b/molecule/rootless/molecule.yml @@ -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 diff --git a/molecule/rootless/verify.yml b/molecule/rootless/verify.yml new file mode 100644 index 0000000..6376950 --- /dev/null +++ b/molecule/rootless/verify.yml @@ -0,0 +1,134 @@ +--- +- name: Verify rendered quadlet files (rootless) + hosts: all + gather_facts: false + vars: + quadlet_dir: /tmp/molecule-dcm-rootless/.config/containers/systemd + core_containers: + - dcm-postgres.container + - dcm-nats.container + - dcm-service-provider-manager.container + - dcm-catalog-manager.container + - dcm-policy-manager.container + - dcm-placement-manager.container + - dcm-gateway.container + - dcm-ui.container + network_files: + - dcm-network.network + volume_files: + - dcm-postgres-data.volume + - dcm-nats-data.volume + # Containers that use EnvironmentFile for shared env vars + env_file_containers: + - dcm-postgres.container + - dcm-service-provider-manager.container + - dcm-catalog-manager.container + - dcm-policy-manager.container + - dcm-placement-manager.container + tasks: + # --- File existence --- + - name: Stat core container files + ansible.builtin.stat: + path: "{{ quadlet_dir }}/{{ item }}" + register: core_stat + loop: "{{ core_containers }}" + + - name: Assert core container files exist + ansible.builtin.assert: + that: item.stat.exists + fail_msg: "Missing core template: {{ item.item }}" + loop: "{{ core_stat.results }}" + loop_control: + label: "{{ item.item }}" + + - name: Stat network file + ansible.builtin.stat: + path: "{{ quadlet_dir }}/{{ item }}" + register: network_stat + loop: "{{ network_files }}" + + - name: Assert network file exists + ansible.builtin.assert: + that: item.stat.exists + fail_msg: "Missing network file: {{ item.item }}" + loop: "{{ network_stat.results }}" + loop_control: + label: "{{ item.item }}" + + - name: Stat volume files + ansible.builtin.stat: + path: "{{ quadlet_dir }}/{{ item }}" + register: volume_stat + loop: "{{ volume_files }}" + + - name: Assert volume files exist + ansible.builtin.assert: + that: item.stat.exists + fail_msg: "Missing volume file: {{ item.item }}" + loop: "{{ volume_stat.results }}" + loop_control: + label: "{{ item.item }}" + + # --- Read all core container files for content assertions --- + - name: Slurp core container files + ansible.builtin.slurp: + src: "{{ quadlet_dir }}/{{ item }}" + register: core_slurp + loop: "{{ core_containers }}" + + # --- WantedBy matches rootless mode (default.target) --- + - name: Assert WantedBy=default.target in core containers + ansible.builtin.assert: + that: >- + 'WantedBy=default.target' in (item.content | b64decode) + fail_msg: "{{ item.item }} should have WantedBy=default.target in rootless 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=default.target in network file + ansible.builtin.assert: + that: >- + 'WantedBy=default.target' in (item.content | b64decode) + fail_msg: "{{ item.item }} should have WantedBy=default.target in rootless mode" + loop: "{{ network_slurp.results }}" + loop_control: + label: "{{ item.item }}" + + # --- No hardcoded rootful paths in rendered templates --- + - name: Assert no hardcoded rootful paths + ansible.builtin.assert: + that: + - "'/etc/containers/systemd' not in (item.content | b64decode)" + - "'/srv/containers/dcm' not in (item.content | b64decode)" + fail_msg: "{{ item.item }} contains hardcoded rootful paths" + loop: "{{ core_slurp.results }}" + loop_control: + label: "{{ item.item }}" + + # --- EnvironmentFile points to rootless quadlet dir --- + - name: Assert EnvironmentFile points to rootless quadlet dir + ansible.builtin.assert: + that: >- + (item.content | b64decode) is regex('EnvironmentFile=.*/\.config/containers/systemd/dcm\.env') + fail_msg: "{{ item.item }} EnvironmentFile should reference rootless quadlet dir" + loop: "{{ core_slurp.results }}" + loop_control: + label: "{{ item.item }}" + when: item.item in env_file_containers + + # --- All containers use Network=dcm-network.network --- + - name: Assert all core containers use dcm-network.network + ansible.builtin.assert: + that: >- + 'Network=dcm-network.network' in (item.content | b64decode) + fail_msg: "{{ item.item }} is missing Network=dcm-network.network" + loop: "{{ core_slurp.results }}" + loop_control: + label: "{{ item.item }}" diff --git a/roles/dcm_deploy/defaults/main.yml b/roles/dcm_deploy/defaults/main.yml index 3c8e87f..e3a7eae 100644 --- a/roles/dcm_deploy/defaults/main.yml +++ b/roles/dcm_deploy/defaults/main.yml @@ -85,3 +85,9 @@ dcm_three_tier_demo_sp_namespace: "default" # Firewall dcm_firewall_zone: "public" + +# Rootless deployment +dcm_rootless: false +dcm_rootless_user: "dcm" +dcm_rootless_create_user: true +dcm_rootless_home: "/home/{{ dcm_rootless_user }}" diff --git a/roles/dcm_deploy/handlers/main.yml b/roles/dcm_deploy/handlers/main.yml index b959497..03aa0ef 100644 --- a/roles/dcm_deploy/handlers/main.yml +++ b/roles/dcm_deploy/handlers/main.yml @@ -2,3 +2,5 @@ - name: Reload firewalld ansible.builtin.command: firewall-cmd --reload changed_when: true + failed_when: false + when: not dcm_rootless diff --git a/roles/dcm_deploy/tasks/deploy_quadlet_files.yml b/roles/dcm_deploy/tasks/deploy_quadlet_files.yml index df74537..12cee00 100644 --- a/roles/dcm_deploy/tasks/deploy_quadlet_files.yml +++ b/roles/dcm_deploy/tasks/deploy_quadlet_files.yml @@ -51,6 +51,8 @@ ansible.builtin.find: paths: "{{ dcm_quadlet_dir }}" patterns: "dcm-*.container" + become: true + become_user: "{{ _dcm_become_user }}" register: existing_quadlets - name: Identify stale quadlet files @@ -62,6 +64,10 @@ name: "{{ item | replace('.container', '.service') }}" state: stopped enabled: false + scope: "{{ _dcm_systemd_scope }}" + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" loop: "{{ dcm_stale_quadlets }}" ignore_errors: true tags: [systemd] @@ -70,6 +76,8 @@ ansible.builtin.file: path: "{{ dcm_quadlet_dir }}/{{ item }}" state: absent + become: true + become_user: "{{ _dcm_become_user }}" loop: "{{ dcm_stale_quadlets }}" - name: Deploy DCM network quadlet @@ -77,12 +85,16 @@ src: dcm-network.network.j2 dest: "{{ dcm_quadlet_dir }}/dcm-network.network" mode: "0644" + owner: "{{ _dcm_file_owner }}" + group: "{{ _dcm_file_group }}" - name: Deploy DCM volume quadlets ansible.builtin.template: src: "{{ item }}.j2" dest: "{{ dcm_quadlet_dir }}/{{ item }}" mode: "0644" + owner: "{{ _dcm_file_owner }}" + group: "{{ _dcm_file_group }}" loop: - dcm-postgres-data.volume - dcm-nats-data.volume @@ -92,6 +104,8 @@ src: "{{ item }}.j2" dest: "{{ dcm_quadlet_dir }}/{{ item }}" mode: "0644" + owner: "{{ _dcm_file_owner }}" + group: "{{ _dcm_file_group }}" loop: - dcm-postgres.container - dcm-nats.container @@ -107,6 +121,8 @@ src: dcm-kubevirt-service-provider.container.j2 dest: "{{ dcm_quadlet_dir }}/dcm-kubevirt-service-provider.container" mode: "0644" + owner: "{{ _dcm_file_owner }}" + group: "{{ _dcm_file_group }}" when: dcm_provider_kubevirt - name: Deploy K8s container service provider quadlet @@ -114,6 +130,8 @@ src: dcm-k8s-container-service-provider.container.j2 dest: "{{ dcm_quadlet_dir }}/dcm-k8s-container-service-provider.container" mode: "0644" + owner: "{{ _dcm_file_owner }}" + group: "{{ _dcm_file_group }}" when: dcm_provider_k8s_container - name: Deploy ACM cluster service provider quadlet @@ -121,6 +139,8 @@ src: dcm-acm-cluster-service-provider.container.j2 dest: "{{ dcm_quadlet_dir }}/dcm-acm-cluster-service-provider.container" mode: "0644" + owner: "{{ _dcm_file_owner }}" + group: "{{ _dcm_file_group }}" when: dcm_provider_acm_cluster - name: Deploy three-tier demo service provider quadlet @@ -128,9 +148,15 @@ src: dcm-three-tier-demo-service-provider.container.j2 dest: "{{ dcm_quadlet_dir }}/dcm-three-tier-demo-service-provider.container" mode: "0644" + owner: "{{ _dcm_file_owner }}" + group: "{{ _dcm_file_group }}" when: dcm_provider_three_tier_demo - name: Reload systemd daemon ansible.builtin.systemd_service: daemon_reload: true + scope: "{{ _dcm_systemd_scope }}" + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" tags: [systemd] diff --git a/roles/dcm_deploy/tasks/generate_configs.yml b/roles/dcm_deploy/tasks/generate_configs.yml index 016eeb6..88eb9c5 100644 --- a/roles/dcm_deploy/tasks/generate_configs.yml +++ b/roles/dcm_deploy/tasks/generate_configs.yml @@ -3,7 +3,11 @@ ansible.builtin.template: src: dcm.env.j2 dest: "{{ dcm_quadlet_dir }}/dcm.env" + owner: "{{ _dcm_file_owner }}" + group: "{{ _dcm_file_group }}" mode: "0600" + become: true + become_user: "{{ _dcm_become_user }}" - name: Clone api-gateway repo, copy config files, and clean up block: @@ -13,38 +17,58 @@ version: "{{ dcm_api_gateway_version }}" dest: "{{ dcm_api_gateway_clone_dir }}" force: true + become: true + become_user: "{{ _dcm_become_user }}" - name: Copy Traefik static configuration ansible.builtin.copy: src: "{{ dcm_api_gateway_clone_dir }}/config/traefik.yml" dest: "{{ dcm_config_dir }}/traefik/traefik.yml" + owner: "{{ _dcm_file_owner }}" + group: "{{ _dcm_file_group }}" mode: "0644" remote_src: true + become: true + become_user: "{{ _dcm_become_user }}" - name: Copy Traefik dynamic routes ansible.builtin.copy: src: "{{ dcm_api_gateway_clone_dir }}/config/dynamic/routes.yml" dest: "{{ dcm_config_dir }}/traefik/dynamic/routes.yml" + owner: "{{ _dcm_file_owner }}" + group: "{{ _dcm_file_group }}" mode: "0644" remote_src: true + become: true + become_user: "{{ _dcm_become_user }}" - name: Copy PostgreSQL init SQL ansible.builtin.copy: src: "{{ dcm_api_gateway_clone_dir }}/hack/postgres-init/01-create-databases.sql" dest: "{{ dcm_config_dir }}/postgres-init/01-create-databases.sql" + owner: "{{ _dcm_file_owner }}" + group: "{{ _dcm_file_group }}" mode: "0644" remote_src: true + become: true + become_user: "{{ _dcm_become_user }}" - name: Template ACM cluster provider environment file ansible.builtin.template: src: dcm-acm-cluster.env.j2 dest: "{{ dcm_quadlet_dir }}/dcm-acm-cluster.env" + owner: "{{ _dcm_file_owner }}" + group: "{{ _dcm_file_group }}" mode: "0600" no_log: true when: dcm_provider_acm_cluster + become: true + become_user: "{{ _dcm_become_user }}" always: - name: Remove cloned api-gateway repository ansible.builtin.file: path: "{{ dcm_api_gateway_clone_dir }}" state: absent + become: true + become_user: "{{ _dcm_become_user }}" diff --git a/roles/dcm_deploy/tasks/initialize_database.yml b/roles/dcm_deploy/tasks/initialize_database.yml index c0b0569..08ac834 100644 --- a/roles/dcm_deploy/tasks/initialize_database.yml +++ b/roles/dcm_deploy/tasks/initialize_database.yml @@ -4,9 +4,16 @@ name: dcm-postgres.service state: started enabled: true + scope: "{{ _dcm_systemd_scope }}" + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" - name: Check if core databases exist ansible.builtin.command: podman exec postgres psql -U {{ dcm_db_user }} -d postgres -tc "SELECT 1 FROM pg_database WHERE datname='{{ item }}'" + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" register: db_check loop: - service-provider @@ -17,6 +24,9 @@ - name: Create missing core databases ansible.builtin.command: podman exec postgres createdb -U {{ dcm_db_user }} {{ item.item }} + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" loop: "{{ db_check.results }}" when: item.stdout | trim != "1" register: db_create @@ -27,12 +37,18 @@ - name: Check if three-tier-sp database exists ansible.builtin.command: podman exec postgres psql -U {{ dcm_db_user }} -d postgres -tc "SELECT 1 FROM pg_database WHERE datname='three-tier-sp'" + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" register: three_tier_db_check changed_when: false when: dcm_provider_three_tier_demo - name: Create three-tier-sp database ansible.builtin.command: podman exec postgres createdb -U {{ dcm_db_user }} three-tier-sp + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" when: - dcm_provider_three_tier_demo - three_tier_db_check.stdout | default('') | trim != "1" diff --git a/roles/dcm_deploy/tasks/main.yml b/roles/dcm_deploy/tasks/main.yml index ca9e5c6..2125105 100644 --- a/roles/dcm_deploy/tasks/main.yml +++ b/roles/dcm_deploy/tasks/main.yml @@ -1,4 +1,8 @@ --- +- name: "Resolve rootless variables" + ansible.builtin.include_tasks: "resolve_rootless_vars.yml" + tags: [resolve_rootless_vars] + - name: "Ensure prerequisites" ansible.builtin.include_tasks: "prerequisites.yml" tags: [prerequisites] diff --git a/roles/dcm_deploy/tasks/prerequisites.yml b/roles/dcm_deploy/tasks/prerequisites.yml index cec9bdf..8754886 100644 --- a/roles/dcm_deploy/tasks/prerequisites.yml +++ b/roles/dcm_deploy/tasks/prerequisites.yml @@ -10,15 +10,22 @@ ansible.builtin.dnf: name: - "@container-management" - - firewalld - git state: present +- name: Install firewalld + ansible.builtin.dnf: + name: + - firewalld + state: present + when: not dcm_rootless + - name: Enable and start firewalld ansible.builtin.systemd_service: name: firewalld state: started enabled: true + when: not dcm_rootless - name: Open DCM gateway port in firewall ansible.posix.firewalld: @@ -27,6 +34,7 @@ permanent: true state: enabled notify: Reload firewalld + when: not dcm_rootless - name: Open DCM UI port in firewall ansible.posix.firewalld: @@ -35,11 +43,26 @@ permanent: true state: enabled notify: Reload firewalld + when: not dcm_rootless + +- name: Create rootless XDG directory structure + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ dcm_rootless_user }}" + group: "{{ dcm_rootless_user }}" + mode: "0755" + loop: + - "{{ dcm_rootless_home }}/.config/containers/systemd" + - "{{ dcm_rootless_home }}/.local/share/dcm/config" + when: dcm_rootless - name: Create configuration directories ansible.builtin.file: path: "{{ item }}" state: directory + owner: "{{ _dcm_file_owner }}" + group: "{{ _dcm_file_group }}" mode: "0755" loop: - "{{ dcm_config_dir }}/traefik/dynamic" diff --git a/roles/dcm_deploy/tasks/resolve_rootless_vars.yml b/roles/dcm_deploy/tasks/resolve_rootless_vars.yml new file mode 100644 index 0000000..5add65d --- /dev/null +++ b/roles/dcm_deploy/tasks/resolve_rootless_vars.yml @@ -0,0 +1,119 @@ +--- +# Phase A: Rootless setup (only when dcm_rootless is true) + +- name: Assert sudo become method for rootless + ansible.builtin.assert: + that: + - ansible_become_method | default('sudo') == 'sudo' + fail_msg: "Rootless deployment requires become_method=sudo for nested privilege dropping" + when: dcm_rootless + +- name: Check for existing rootful deployment + ansible.builtin.find: + paths: /etc/containers/systemd + patterns: "dcm-*.container" + register: _rootful_quadlets + when: dcm_rootless + +- name: Fail if rootful quadlets exist + ansible.builtin.assert: + that: + - _rootful_quadlets.files | length == 0 + fail_msg: >- + Existing rootful quadlet files found in /etc/containers/systemd. + Rootless and rootful deployments are mutually exclusive on the same host. + Remove existing rootful deployment before enabling dcm_rootless. + when: dcm_rootless and _rootful_quadlets.files is defined + +- name: Create DCM service user + ansible.builtin.user: + name: "{{ dcm_rootless_user }}" + system: true + create_home: true + home: "{{ dcm_rootless_home }}" + shell: /sbin/nologin + state: present + when: dcm_rootless and dcm_rootless_create_user + +- name: Ensure subordinate UID range for rootless podman + ansible.builtin.lineinfile: + path: /etc/subuid + regexp: "^{{ dcm_rootless_user }}:" + line: "{{ dcm_rootless_user }}:231072:65536" + create: true + mode: "0644" + when: dcm_rootless + +- name: Ensure subordinate GID range for rootless podman + ansible.builtin.lineinfile: + path: /etc/subgid + regexp: "^{{ dcm_rootless_user }}:" + line: "{{ dcm_rootless_user }}:231072:65536" + create: true + mode: "0644" + when: dcm_rootless + +- name: Set DCM user home directory permissions + ansible.builtin.file: + path: "{{ dcm_rootless_home }}" + state: directory + owner: "{{ dcm_rootless_user }}" + group: "{{ dcm_rootless_user }}" + mode: "0700" + when: dcm_rootless + +- name: Check if lingering is enabled + ansible.builtin.stat: + path: "/var/lib/systemd/linger/{{ dcm_rootless_user }}" + register: _linger_check + when: dcm_rootless + +- name: Enable lingering for DCM user + ansible.builtin.command: + cmd: loginctl enable-linger {{ dcm_rootless_user }} + changed_when: true + when: dcm_rootless and not _linger_check.stat.exists + +- name: Look up DCM user UID + ansible.builtin.getent: + database: passwd + key: "{{ dcm_rootless_user }}" + when: dcm_rootless + +- name: Start user systemd instance + ansible.builtin.systemd_service: + name: "user@{{ getent_passwd[dcm_rootless_user][1] }}.service" + state: started + when: dcm_rootless + +- name: Wait for XDG_RUNTIME_DIR to exist + ansible.builtin.wait_for: + path: "/run/user/{{ getent_passwd[dcm_rootless_user][1] }}" + timeout: 30 + when: dcm_rootless + +- name: Set rootless internal facts + ansible.builtin.set_fact: + dcm_quadlet_dir: "{{ dcm_rootless_home }}/.config/containers/systemd" + dcm_config_dir: "{{ dcm_rootless_home }}/.local/share/dcm/config" + _dcm_systemd_scope: user + _dcm_wanted_by: default.target + _dcm_file_owner: "{{ dcm_rootless_user }}" + _dcm_file_group: "{{ dcm_rootless_user }}" + _dcm_become_user: "{{ dcm_rootless_user }}" + _dcm_rootless_env: + XDG_RUNTIME_DIR: "/run/user/{{ getent_passwd[dcm_rootless_user][1] }}" + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ getent_passwd[dcm_rootless_user][1] }}/bus" + when: dcm_rootless + +# Phase B: Rootful defaults (always runs, rootless set_fact above overrides) + +- name: Set rootful internal facts + ansible.builtin.set_fact: + _dcm_systemd_scope: system + _dcm_wanted_by: multi-user.target + _dcm_file_owner: root + _dcm_file_group: root + _dcm_become_user: root + _dcm_rootless_env: {} + when: not dcm_rootless diff --git a/roles/dcm_deploy/tasks/start_services.yml b/roles/dcm_deploy/tasks/start_services.yml index 9a2c214..d4b2fca 100644 --- a/roles/dcm_deploy/tasks/start_services.yml +++ b/roles/dcm_deploy/tasks/start_services.yml @@ -4,6 +4,10 @@ name: dcm-nats.service state: started enabled: true + scope: "{{ _dcm_systemd_scope }}" + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" - name: Wait for NATS to be ready ansible.builtin.wait_for: @@ -16,6 +20,10 @@ name: "{{ item }}" state: started enabled: true + scope: "{{ _dcm_systemd_scope }}" + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" loop: - dcm-service-provider-manager.service - dcm-catalog-manager.service @@ -24,6 +32,9 @@ - name: Wait for manager containers to be healthy ansible.builtin.command: podman inspect --format {% raw %}'{{.State.Health.Status}}'{% endraw %} {{ item }} + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" register: manager_health until: manager_health.stdout | trim == "healthy" retries: 30 @@ -40,18 +51,30 @@ name: dcm-gateway.service state: started enabled: true + scope: "{{ _dcm_systemd_scope }}" + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" - name: Start DCM UI service ansible.builtin.systemd_service: name: dcm-ui.service state: started enabled: true + scope: "{{ _dcm_systemd_scope }}" + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" - name: Start KubeVirt service provider ansible.builtin.systemd_service: name: dcm-kubevirt-service-provider.service state: started enabled: true + scope: "{{ _dcm_systemd_scope }}" + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" when: dcm_provider_kubevirt - name: Start K8s container service provider @@ -59,6 +82,10 @@ name: dcm-k8s-container-service-provider.service state: started enabled: true + scope: "{{ _dcm_systemd_scope }}" + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" when: dcm_provider_k8s_container - name: Start ACM cluster service provider @@ -66,6 +93,10 @@ name: dcm-acm-cluster-service-provider.service state: started enabled: true + scope: "{{ _dcm_systemd_scope }}" + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" when: dcm_provider_acm_cluster - name: Start three-tier demo service provider @@ -73,4 +104,8 @@ name: dcm-three-tier-demo-service-provider.service state: started enabled: true + scope: "{{ _dcm_systemd_scope }}" + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" when: dcm_provider_three_tier_demo diff --git a/roles/dcm_deploy/tasks/validate_deployment.yml b/roles/dcm_deploy/tasks/validate_deployment.yml index 77f2eb5..261ce03 100644 --- a/roles/dcm_deploy/tasks/validate_deployment.yml +++ b/roles/dcm_deploy/tasks/validate_deployment.yml @@ -24,6 +24,9 @@ - name: Get running container names ansible.builtin.command: podman ps --format {% raw %}'{{.Names}}'{% endraw %} + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" register: running_containers changed_when: false @@ -67,15 +70,24 @@ - name: Verify KubeVirt provider health endpoint ansible.builtin.command: podman exec kubevirt-service-provider bash -c 'echo > /dev/tcp/localhost/8081' + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" changed_when: false when: dcm_provider_kubevirt - name: Verify K8s container provider health endpoint ansible.builtin.command: podman exec k8s-container-service-provider bash -c 'echo > /dev/tcp/localhost/8080' + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" changed_when: false when: dcm_provider_k8s_container - name: Verify ACM cluster provider health endpoint ansible.builtin.command: podman exec acm-cluster-service-provider bash -c 'echo > /dev/tcp/localhost/8080' + become: true + become_user: "{{ _dcm_become_user }}" + environment: "{{ _dcm_rootless_env }}" changed_when: false when: dcm_provider_acm_cluster diff --git a/roles/dcm_deploy/templates/dcm-acm-cluster-service-provider.container.j2 b/roles/dcm_deploy/templates/dcm-acm-cluster-service-provider.container.j2 index 8d864f9..12316ed 100644 --- a/roles/dcm_deploy/templates/dcm-acm-cluster-service-provider.container.j2 +++ b/roles/dcm_deploy/templates/dcm-acm-cluster-service-provider.container.j2 @@ -25,4 +25,4 @@ Restart=always TimeoutStartSec=300 [Install] -WantedBy=multi-user.target +WantedBy={{ _dcm_wanted_by }} diff --git a/roles/dcm_deploy/templates/dcm-catalog-manager.container.j2 b/roles/dcm_deploy/templates/dcm-catalog-manager.container.j2 index bfd6ed9..37fe299 100644 --- a/roles/dcm_deploy/templates/dcm-catalog-manager.container.j2 +++ b/roles/dcm_deploy/templates/dcm-catalog-manager.container.j2 @@ -23,4 +23,4 @@ Restart=always TimeoutStartSec=300 [Install] -WantedBy=multi-user.target +WantedBy={{ _dcm_wanted_by }} diff --git a/roles/dcm_deploy/templates/dcm-gateway.container.j2 b/roles/dcm_deploy/templates/dcm-gateway.container.j2 index c3356c7..1d75103 100644 --- a/roles/dcm_deploy/templates/dcm-gateway.container.j2 +++ b/roles/dcm_deploy/templates/dcm-gateway.container.j2 @@ -17,4 +17,4 @@ Restart=always TimeoutStartSec=300 [Install] -WantedBy=multi-user.target +WantedBy={{ _dcm_wanted_by }} diff --git a/roles/dcm_deploy/templates/dcm-k8s-container-service-provider.container.j2 b/roles/dcm_deploy/templates/dcm-k8s-container-service-provider.container.j2 index da0b3bf..16971da 100644 --- a/roles/dcm_deploy/templates/dcm-k8s-container-service-provider.container.j2 +++ b/roles/dcm_deploy/templates/dcm-k8s-container-service-provider.container.j2 @@ -22,4 +22,4 @@ Restart=always TimeoutStartSec=300 [Install] -WantedBy=multi-user.target +WantedBy={{ _dcm_wanted_by }} diff --git a/roles/dcm_deploy/templates/dcm-kubevirt-service-provider.container.j2 b/roles/dcm_deploy/templates/dcm-kubevirt-service-provider.container.j2 index 800fdd6..3d834bc 100644 --- a/roles/dcm_deploy/templates/dcm-kubevirt-service-provider.container.j2 +++ b/roles/dcm_deploy/templates/dcm-kubevirt-service-provider.container.j2 @@ -21,4 +21,4 @@ Restart=always TimeoutStartSec=300 [Install] -WantedBy=multi-user.target +WantedBy={{ _dcm_wanted_by }} diff --git a/roles/dcm_deploy/templates/dcm-nats.container.j2 b/roles/dcm_deploy/templates/dcm-nats.container.j2 index 9e3afcf..97fcae7 100644 --- a/roles/dcm_deploy/templates/dcm-nats.container.j2 +++ b/roles/dcm_deploy/templates/dcm-nats.container.j2 @@ -15,4 +15,4 @@ Restart=always TimeoutStartSec=300 [Install] -WantedBy=multi-user.target +WantedBy={{ _dcm_wanted_by }} diff --git a/roles/dcm_deploy/templates/dcm-network.network.j2 b/roles/dcm_deploy/templates/dcm-network.network.j2 index 897fb1c..229c58b 100644 --- a/roles/dcm_deploy/templates/dcm-network.network.j2 +++ b/roles/dcm_deploy/templates/dcm-network.network.j2 @@ -7,4 +7,4 @@ Driver=bridge Internal=false [Install] -WantedBy=multi-user.target +WantedBy={{ _dcm_wanted_by }} diff --git a/roles/dcm_deploy/templates/dcm-placement-manager.container.j2 b/roles/dcm_deploy/templates/dcm-placement-manager.container.j2 index ed415de..ec0d916 100644 --- a/roles/dcm_deploy/templates/dcm-placement-manager.container.j2 +++ b/roles/dcm_deploy/templates/dcm-placement-manager.container.j2 @@ -24,4 +24,4 @@ Restart=always TimeoutStartSec=300 [Install] -WantedBy=multi-user.target +WantedBy={{ _dcm_wanted_by }} diff --git a/roles/dcm_deploy/templates/dcm-policy-manager.container.j2 b/roles/dcm_deploy/templates/dcm-policy-manager.container.j2 index f1fdcfd..271ee4b 100644 --- a/roles/dcm_deploy/templates/dcm-policy-manager.container.j2 +++ b/roles/dcm_deploy/templates/dcm-policy-manager.container.j2 @@ -23,4 +23,4 @@ Restart=always TimeoutStartSec=300 [Install] -WantedBy=multi-user.target +WantedBy={{ _dcm_wanted_by }} diff --git a/roles/dcm_deploy/templates/dcm-postgres.container.j2 b/roles/dcm_deploy/templates/dcm-postgres.container.j2 index 3229008..2a972e8 100644 --- a/roles/dcm_deploy/templates/dcm-postgres.container.j2 +++ b/roles/dcm_deploy/templates/dcm-postgres.container.j2 @@ -23,4 +23,4 @@ Restart=always TimeoutStartSec=300 [Install] -WantedBy=multi-user.target +WantedBy={{ _dcm_wanted_by }} diff --git a/roles/dcm_deploy/templates/dcm-service-provider-manager.container.j2 b/roles/dcm_deploy/templates/dcm-service-provider-manager.container.j2 index 454e061..558e08d 100644 --- a/roles/dcm_deploy/templates/dcm-service-provider-manager.container.j2 +++ b/roles/dcm_deploy/templates/dcm-service-provider-manager.container.j2 @@ -23,4 +23,4 @@ Restart=always TimeoutStartSec=300 [Install] -WantedBy=multi-user.target +WantedBy={{ _dcm_wanted_by }} diff --git a/roles/dcm_deploy/templates/dcm-three-tier-demo-service-provider.container.j2 b/roles/dcm_deploy/templates/dcm-three-tier-demo-service-provider.container.j2 index e208d90..bd29c0a 100644 --- a/roles/dcm_deploy/templates/dcm-three-tier-demo-service-provider.container.j2 +++ b/roles/dcm_deploy/templates/dcm-three-tier-demo-service-provider.container.j2 @@ -24,4 +24,4 @@ Restart=always TimeoutStartSec=300 [Install] -WantedBy=multi-user.target +WantedBy={{ _dcm_wanted_by }} diff --git a/roles/dcm_deploy/templates/dcm-ui.container.j2 b/roles/dcm_deploy/templates/dcm-ui.container.j2 index 97106da..1ad04f5 100644 --- a/roles/dcm_deploy/templates/dcm-ui.container.j2 +++ b/roles/dcm_deploy/templates/dcm-ui.container.j2 @@ -17,4 +17,4 @@ Restart=always TimeoutStartSec=300 [Install] -WantedBy=multi-user.target +WantedBy={{ _dcm_wanted_by }} From e394c8efc44f652d5cc06f3996fa96352f7381f7 Mon Sep 17 00:00:00 2001 From: Chad Crum Date: Thu, 28 May 2026 16:34:12 -0400 Subject: [PATCH 2/8] fix(handlers): remove rootless guard from firewalld reload handler The handler should reload firewalld unconditionally when notified. Rootless deployments simply won't trigger it since firewall tasks are skipped in that path. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Chad Crum --- roles/dcm_deploy/handlers/main.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/roles/dcm_deploy/handlers/main.yml b/roles/dcm_deploy/handlers/main.yml index 03aa0ef..b959497 100644 --- a/roles/dcm_deploy/handlers/main.yml +++ b/roles/dcm_deploy/handlers/main.yml @@ -2,5 +2,3 @@ - name: Reload firewalld ansible.builtin.command: firewall-cmd --reload changed_when: true - failed_when: false - when: not dcm_rootless From d70bc9914f91cae6f307a662c0b5e8079c973118 Mon Sep 17 00:00:00 2001 From: Chad Crum Date: Thu, 28 May 2026 16:52:29 -0400 Subject: [PATCH 3/8] refactor(rootless): drop redundant linger stat check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loginctl enable-linger is idempotent — no need to check first. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Chad Crum --- roles/dcm_deploy/tasks/resolve_rootless_vars.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/roles/dcm_deploy/tasks/resolve_rootless_vars.yml b/roles/dcm_deploy/tasks/resolve_rootless_vars.yml index 5add65d..fdb6ea1 100644 --- a/roles/dcm_deploy/tasks/resolve_rootless_vars.yml +++ b/roles/dcm_deploy/tasks/resolve_rootless_vars.yml @@ -62,17 +62,11 @@ mode: "0700" when: dcm_rootless -- name: Check if lingering is enabled - ansible.builtin.stat: - path: "/var/lib/systemd/linger/{{ dcm_rootless_user }}" - register: _linger_check - when: dcm_rootless - - name: Enable lingering for DCM user ansible.builtin.command: cmd: loginctl enable-linger {{ dcm_rootless_user }} changed_when: true - when: dcm_rootless and not _linger_check.stat.exists + when: dcm_rootless - name: Look up DCM user UID ansible.builtin.getent: From 7979cdf7da4ec40f7882abed5a2cfb2f8f746ee5 Mon Sep 17 00:00:00 2001 From: Chad Crum Date: Thu, 28 May 2026 16:55:11 -0400 Subject: [PATCH 4/8] refactor(rootless): wrap Phase A tasks in block Reduces repeated `when: dcm_rootless` guards by using a single block-level condition. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Chad Crum --- .../tasks/resolve_rootless_vars.yml | 165 +++++++++--------- 1 file changed, 79 insertions(+), 86 deletions(-) diff --git a/roles/dcm_deploy/tasks/resolve_rootless_vars.yml b/roles/dcm_deploy/tasks/resolve_rootless_vars.yml index fdb6ea1..39f3884 100644 --- a/roles/dcm_deploy/tasks/resolve_rootless_vars.yml +++ b/roles/dcm_deploy/tasks/resolve_rootless_vars.yml @@ -1,104 +1,97 @@ --- # Phase A: Rootless setup (only when dcm_rootless is true) -- name: Assert sudo become method for rootless - ansible.builtin.assert: - that: - - ansible_become_method | default('sudo') == 'sudo' - fail_msg: "Rootless deployment requires become_method=sudo for nested privilege dropping" +- name: Configure rootless deployment when: dcm_rootless + block: + - name: Assert sudo become method for rootless + ansible.builtin.assert: + that: + - ansible_become_method | default('sudo') == 'sudo' + fail_msg: "Rootless deployment requires become_method=sudo for nested privilege dropping" -- name: Check for existing rootful deployment - ansible.builtin.find: - paths: /etc/containers/systemd - patterns: "dcm-*.container" - register: _rootful_quadlets - when: dcm_rootless + - name: Check for existing rootful deployment + ansible.builtin.find: + paths: /etc/containers/systemd + patterns: "dcm-*.container" + register: _rootful_quadlets -- name: Fail if rootful quadlets exist - ansible.builtin.assert: - that: - - _rootful_quadlets.files | length == 0 - fail_msg: >- - Existing rootful quadlet files found in /etc/containers/systemd. - Rootless and rootful deployments are mutually exclusive on the same host. - Remove existing rootful deployment before enabling dcm_rootless. - when: dcm_rootless and _rootful_quadlets.files is defined + - name: Fail if rootful quadlets exist + ansible.builtin.assert: + that: + - _rootful_quadlets.files | length == 0 + fail_msg: >- + Existing rootful quadlet files found in /etc/containers/systemd. + Rootless and rootful deployments are mutually exclusive on the same host. + Remove existing rootful deployment before enabling dcm_rootless. + when: _rootful_quadlets.files is defined -- name: Create DCM service user - ansible.builtin.user: - name: "{{ dcm_rootless_user }}" - system: true - create_home: true - home: "{{ dcm_rootless_home }}" - shell: /sbin/nologin - state: present - when: dcm_rootless and dcm_rootless_create_user + - name: Create DCM service user + ansible.builtin.user: + name: "{{ dcm_rootless_user }}" + system: true + create_home: true + home: "{{ dcm_rootless_home }}" + shell: /sbin/nologin + state: present + when: dcm_rootless_create_user -- name: Ensure subordinate UID range for rootless podman - ansible.builtin.lineinfile: - path: /etc/subuid - regexp: "^{{ dcm_rootless_user }}:" - line: "{{ dcm_rootless_user }}:231072:65536" - create: true - mode: "0644" - when: dcm_rootless + - name: Ensure subordinate UID range for rootless podman + ansible.builtin.lineinfile: + path: /etc/subuid + regexp: "^{{ dcm_rootless_user }}:" + line: "{{ dcm_rootless_user }}:231072:65536" + create: true + mode: "0644" -- name: Ensure subordinate GID range for rootless podman - ansible.builtin.lineinfile: - path: /etc/subgid - regexp: "^{{ dcm_rootless_user }}:" - line: "{{ dcm_rootless_user }}:231072:65536" - create: true - mode: "0644" - when: dcm_rootless + - name: Ensure subordinate GID range for rootless podman + ansible.builtin.lineinfile: + path: /etc/subgid + regexp: "^{{ dcm_rootless_user }}:" + line: "{{ dcm_rootless_user }}:231072:65536" + create: true + mode: "0644" -- name: Set DCM user home directory permissions - ansible.builtin.file: - path: "{{ dcm_rootless_home }}" - state: directory - owner: "{{ dcm_rootless_user }}" - group: "{{ dcm_rootless_user }}" - mode: "0700" - when: dcm_rootless + - name: Set DCM user home directory permissions + ansible.builtin.file: + path: "{{ dcm_rootless_home }}" + state: directory + owner: "{{ dcm_rootless_user }}" + group: "{{ dcm_rootless_user }}" + mode: "0700" -- name: Enable lingering for DCM user - ansible.builtin.command: - cmd: loginctl enable-linger {{ dcm_rootless_user }} - changed_when: true - when: dcm_rootless + - name: Enable lingering for DCM user + ansible.builtin.command: + cmd: loginctl enable-linger {{ dcm_rootless_user }} + changed_when: true -- name: Look up DCM user UID - ansible.builtin.getent: - database: passwd - key: "{{ dcm_rootless_user }}" - when: dcm_rootless + - name: Look up DCM user UID + ansible.builtin.getent: + database: passwd + key: "{{ dcm_rootless_user }}" -- name: Start user systemd instance - ansible.builtin.systemd_service: - name: "user@{{ getent_passwd[dcm_rootless_user][1] }}.service" - state: started - when: dcm_rootless + - name: Start user systemd instance + ansible.builtin.systemd_service: + name: "user@{{ getent_passwd[dcm_rootless_user][1] }}.service" + state: started -- name: Wait for XDG_RUNTIME_DIR to exist - ansible.builtin.wait_for: - path: "/run/user/{{ getent_passwd[dcm_rootless_user][1] }}" - timeout: 30 - when: dcm_rootless + - name: Wait for XDG_RUNTIME_DIR to exist + ansible.builtin.wait_for: + path: "/run/user/{{ getent_passwd[dcm_rootless_user][1] }}" + timeout: 30 -- name: Set rootless internal facts - ansible.builtin.set_fact: - dcm_quadlet_dir: "{{ dcm_rootless_home }}/.config/containers/systemd" - dcm_config_dir: "{{ dcm_rootless_home }}/.local/share/dcm/config" - _dcm_systemd_scope: user - _dcm_wanted_by: default.target - _dcm_file_owner: "{{ dcm_rootless_user }}" - _dcm_file_group: "{{ dcm_rootless_user }}" - _dcm_become_user: "{{ dcm_rootless_user }}" - _dcm_rootless_env: - XDG_RUNTIME_DIR: "/run/user/{{ getent_passwd[dcm_rootless_user][1] }}" - DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ getent_passwd[dcm_rootless_user][1] }}/bus" - when: dcm_rootless + - name: Set rootless internal facts + ansible.builtin.set_fact: + dcm_quadlet_dir: "{{ dcm_rootless_home }}/.config/containers/systemd" + dcm_config_dir: "{{ dcm_rootless_home }}/.local/share/dcm/config" + _dcm_systemd_scope: user + _dcm_wanted_by: default.target + _dcm_file_owner: "{{ dcm_rootless_user }}" + _dcm_file_group: "{{ dcm_rootless_user }}" + _dcm_become_user: "{{ dcm_rootless_user }}" + _dcm_rootless_env: + XDG_RUNTIME_DIR: "/run/user/{{ getent_passwd[dcm_rootless_user][1] }}" + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ getent_passwd[dcm_rootless_user][1] }}/bus" # Phase B: Rootful defaults (always runs, rootless set_fact above overrides) From fbbbe41f5f8b4b9417abbd7817748a649d2526c3 Mon Sep 17 00:00:00 2001 From: Chad Crum Date: Thu, 28 May 2026 16:56:04 -0400 Subject: [PATCH 5/8] docs: clarify dcm_rootless_create_user usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user module is idempotent — the flag is only needed when user management is handled externally (LDAP, IPA). Co-Authored-By: Claude Opus 4.6 Signed-off-by: Chad Crum --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ff2f00f..94fa383 100644 --- a/README.md +++ b/README.md @@ -240,9 +240,10 @@ Set `dcm_rootless: true` to run all containers under a dedicated unprivileged us ```yaml dcm_rootless: true dcm_rootless_user: dcm # default -dcm_rootless_create_user: true # default — set false if user already exists ``` +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 | From f967fb0287856493669a07d550ee89b19851ebf3 Mon Sep 17 00:00:00 2001 From: Chad Crum Date: Thu, 28 May 2026 16:58:50 -0400 Subject: [PATCH 6/8] fix(molecule): use omit sentinel for file ownership in default converge The omit sentinel works correctly in playbook vars blocks (unlike set_fact) and prevents chown failures when running as a non-root user in CI. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Chad Crum --- molecule/default/converge.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index 56874f6..12934fe 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -12,8 +12,8 @@ dcm_rootless: false _dcm_systemd_scope: system _dcm_wanted_by: multi-user.target - _dcm_file_owner: root - _dcm_file_group: root + _dcm_file_owner: "{{ omit }}" + _dcm_file_group: "{{ omit }}" _dcm_become_user: root _dcm_rootless_env: {} tasks: From 4b8d51dcc0ce0171ff7c4db0dc6a64f06b6273eb Mon Sep 17 00:00:00 2001 From: Chad Crum Date: Fri, 29 May 2026 15:11:12 -0400 Subject: [PATCH 7/8] fix(prerequisites): apply firewalld rules for both rootless and rootful Rootless Podman ports still need firewall rules for external access when firewalld is active. Remove the rootless guards so firewalld is managed regardless of deployment mode. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Chad Crum --- README.md | 1 - roles/dcm_deploy/tasks/prerequisites.yml | 4 ---- 2 files changed, 5 deletions(-) diff --git a/README.md b/README.md index 94fa383..27e8c2c 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,6 @@ Set `dcm_rootless_create_user: false` if user management is handled externally ( - 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. -- Firewalld rules are **skipped** in rootless mode. Rootless Podman uses user-space networking (slirp4netns/pasta), so all published ports are > 1024 and do not require `CAP_NET_BIND_SERVICE`. ## Verification diff --git a/roles/dcm_deploy/tasks/prerequisites.yml b/roles/dcm_deploy/tasks/prerequisites.yml index 8754886..27c0d2d 100644 --- a/roles/dcm_deploy/tasks/prerequisites.yml +++ b/roles/dcm_deploy/tasks/prerequisites.yml @@ -18,14 +18,12 @@ name: - firewalld state: present - when: not dcm_rootless - name: Enable and start firewalld ansible.builtin.systemd_service: name: firewalld state: started enabled: true - when: not dcm_rootless - name: Open DCM gateway port in firewall ansible.posix.firewalld: @@ -34,7 +32,6 @@ permanent: true state: enabled notify: Reload firewalld - when: not dcm_rootless - name: Open DCM UI port in firewall ansible.posix.firewalld: @@ -43,7 +40,6 @@ permanent: true state: enabled notify: Reload firewalld - when: not dcm_rootless - name: Create rootless XDG directory structure ansible.builtin.file: From 3833adf07f1e694009ec1708ba64eaeade149687 Mon Sep 17 00:00:00 2001 From: Chad Crum Date: Fri, 29 May 2026 15:12:07 -0400 Subject: [PATCH 8/8] docs: add rootless phase to deployment list and verification commands Add resolve_rootless_vars as an optional phase in the deployment phases list. Add rootless equivalents for systemctl, podman, and psql verification commands. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Chad Crum --- README.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 27e8c2c..ecbdf89 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,9 @@ Configuration files (Traefik routes, PostgreSQL init SQL) are sourced from the [ ## 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 @@ -280,14 +281,20 @@ curl http://:9080/api/v1alpha1/health/placement # DCM UI accessible curl http://: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