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..ecbdf89 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -241,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 diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index 34aaecd..12934fe 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: "{{ omit }}" + _dcm_file_group: "{{ omit }}" + _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/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..27c0d2d 100644 --- a/roles/dcm_deploy/tasks/prerequisites.yml +++ b/roles/dcm_deploy/tasks/prerequisites.yml @@ -10,10 +10,15 @@ ansible.builtin.dnf: name: - "@container-management" - - firewalld - git state: present +- name: Install firewalld + ansible.builtin.dnf: + name: + - firewalld + state: present + - name: Enable and start firewalld ansible.builtin.systemd_service: name: firewalld @@ -36,10 +41,24 @@ state: enabled notify: Reload firewalld +- 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..39f3884 --- /dev/null +++ b/roles/dcm_deploy/tasks/resolve_rootless_vars.yml @@ -0,0 +1,106 @@ +--- +# Phase A: Rootless setup (only when dcm_rootless is true) + +- 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 + + - 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_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" + + - 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" + + - 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 }}" + + - 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 + + - 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) + +- 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 }}