From 001d17d4730e5c32a1cd51cf18da877c827e55c7 Mon Sep 17 00:00:00 2001 From: Saad Ali Date: Sat, 23 May 2026 16:15:55 +0500 Subject: [PATCH 1/2] fix(docker): harden collection (modules, role, templates, tooling) - Close shell injection in docker_compose_service_check (argv-list run_command + input validation on directory/name/container_id) - Fix multi-tenant image-deletion in docker_image_mgmt_plan (filter against containers.list(all=True), tags + IDs) - Add module params: force_refresh, optional docker_host, tls - Add role-boundary assert block for all operator path vars (NAME, COMPOSE_PATH, SYSTEMD_FILE, MANIFEST.dest, TEMPLATES, FILES, ADDITIONAL_PATHS) plus manifest-path invariant - Hoist Python Docker SDK install above create/delete include - Add file/dir mode defaults (0750/0640/0644); drop recurse:true - Drop ExecStartPre=down; add ExecReload + Timeout(Start|Stop)Sec - Declare community.docker>=3.0.0 in galaxy.yml dependencies - Add meta/runtime.yml (requires_ansible >=2.15.0, action_groups.nixknight_docker) - Add DOCUMENTATION/EXAMPLES/RETURN to both modules - Add .gitignore and .yamllint (repo-wide config) - Add yamllint CI workflow scoped to YAML files changed in a PR --- .github/workflows/yamllint.yml | 75 ++++ .gitignore | 72 ++++ .yamllint | 29 ++ collections/nixknight/docker/galaxy.yml | 2 + collections/nixknight/docker/meta/runtime.yml | 7 + .../modules/docker_compose_service_check.py | 292 +++++++++---- .../plugins/modules/docker_image_mgmt_plan.py | 383 ++++++++++++++---- .../roles/docker-compose-service/README.md | 53 ++- .../docker-compose-service/defaults/main.yml | 29 +- .../docker-compose-service/meta/main.yml | 10 +- .../tasks/create_update_service.yml | 62 ++- .../tasks/delete_service.yml | 10 +- .../tasks/image_mgmt_plan.yml | 5 +- .../docker-compose-service/tasks/main.yml | 99 +++++ .../tasks/pull_remove_images.yml | 7 +- .../templates/docker-compose.yml.j2 | 4 + .../systemd/system/docker-compose.service.j2 | 6 +- 17 files changed, 934 insertions(+), 211 deletions(-) create mode 100644 .github/workflows/yamllint.yml create mode 100644 .gitignore create mode 100644 .yamllint create mode 100644 collections/nixknight/docker/meta/runtime.yml diff --git a/.github/workflows/yamllint.yml b/.github/workflows/yamllint.yml new file mode 100644 index 0000000..ccebc48 --- /dev/null +++ b/.github/workflows/yamllint.yml @@ -0,0 +1,75 @@ +name: YAML Lint + +on: + pull_request: + types: [opened, synchronize, reopened] + +defaults: + run: + shell: bash + +concurrency: + group: yamllint-${{ github.event.pull_request.head.ref }} + cancel-in-progress: true + +jobs: + yamllint: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + + steps: + - name: Checkout Repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Install yamllint + env: + YAMLLINT_VERSION: "1.38.0" + run: | + set -euo pipefail + python3 -m pip install --user "yamllint==${YAMLLINT_VERSION}" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Identify Changed YAML Files + id: changed + env: + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + files=$(git diff --name-only --diff-filter=ACMRT \ + "origin/${BASE_REF}...HEAD" \ + | grep -E '\.(yml|yaml)$' || true) + if [ -z "$files" ]; then + echo "No YAML/YML files changed; skipping lint." + echo "has_files=false" >> "$GITHUB_OUTPUT" + else + echo "Files to lint:" + echo "$files" | sed 's/^/ /' + echo "has_files=true" >> "$GITHUB_OUTPUT" + { + echo "files<> "$GITHUB_OUTPUT" + fi + + - name: Run yamllint + if: steps.changed.outputs.has_files == 'true' + env: + YAML_FILES: ${{ steps.changed.outputs.files }} + run: | + set -euo pipefail + echo "$YAML_FILES" | xargs -d '\n' -r yamllint --config-file .yamllint + + - name: Failure summary + if: failure() + run: | + { + echo "### YAML lint failed"; + echo "- Run: ${{ github.run_id }}"; + echo "- Trigger: ${{ github.event_name }}"; + echo "- Ref: ${{ github.ref }}"; + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ceef90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# Ansible +*.retry +*.log +MANIFEST.json +FILES.json +.ansible/ +collections/*/changelogs/.plugin-cache.yaml +ansible_collections/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environment +venv/ +ENV/ +env/ +.venv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Testing +.pytest_cache/ +.tox/ +.coverage +htmlcov/ +.molecule/ +tests/output/ + +# Collection builds +*.tar.gz +build/*.tar.gz + +# Temporary files +tmp/ +temp/ +*.tmp + +# Secrets +.vault_pass +vault_pass.txt +*.pem +*.key +credentials.yml + +# OS +Thumbs.db diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..e2f149e --- /dev/null +++ b/.yamllint @@ -0,0 +1,29 @@ +--- +extends: default + +rules: + line-length: + max: 160 + level: warning + + indentation: + spaces: 2 + indent-sequences: consistent + + comments: + min-spaces-from-content: 1 + + comments-indentation: {} + + document-start: + present: true + + truthy: + allowed-values: ['true', 'false', 'yes', 'no'] + check-keys: false + +ignore: | + .github/ + build/ + .cache/ + *.tar.gz diff --git a/collections/nixknight/docker/galaxy.yml b/collections/nixknight/docker/galaxy.yml index 2036209..41d9e49 100644 --- a/collections/nixknight/docker/galaxy.yml +++ b/collections/nixknight/docker/galaxy.yml @@ -12,3 +12,5 @@ tags: - docker - containers - containerization +dependencies: + "community.docker": ">=3.0.0" diff --git a/collections/nixknight/docker/meta/runtime.yml b/collections/nixknight/docker/meta/runtime.yml new file mode 100644 index 0000000..51ef07b --- /dev/null +++ b/collections/nixknight/docker/meta/runtime.yml @@ -0,0 +1,7 @@ +--- +requires_ansible: '>=2.15.0' + +action_groups: + nixknight_docker: + - docker_compose_service_check + - docker_image_mgmt_plan diff --git a/collections/nixknight/docker/plugins/modules/docker_compose_service_check.py b/collections/nixknight/docker/plugins/modules/docker_compose_service_check.py index aab7984..c4cb27d 100644 --- a/collections/nixknight/docker/plugins/modules/docker_compose_service_check.py +++ b/collections/nixknight/docker/plugins/modules/docker_compose_service_check.py @@ -1,103 +1,235 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) NIXKnight +# MIT License (see LICENSE) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = r''' +--- +module: docker_compose_service_check +short_description: Report running containers for a Docker Compose project +version_added: "0.1.5" +description: + - Query a Docker Compose project for its currently running containers. + - Uses C(docker compose ps --status running --format json) so only running + containers are returned (in line with the historical C(ps -q) behavior). + - Read-only -- safe in C(check_mode). +options: + project_directory: + description: + - Absolute path to the directory containing the project's + C(docker-compose.yml) (or equivalent compose file). + - Must exist, must be an absolute path, must not contain C(..) segments. + required: true + type: path + project_name: + description: + - Compose project name (passed as C(-p)). + - Must match C(^[a-z0-9][a-z0-9_-]*$) (Compose project-name grammar). + required: true + type: str +author: + - Saad Ali (@NIXKnight) +''' + +EXAMPLES = r''' +- name: Inspect running containers of a compose project + nixknight.docker.docker_compose_service_check: + project_directory: /opt/postgresql + project_name: postgresql + register: pg_check + +- name: Show running container count + ansible.builtin.debug: + msg: "{{ pg_check.container_count }} container(s) running" +''' + +RETURN = r''' +changed: + description: Always C(false). This module is read-only. + type: bool + returned: always +containers: + description: List of running containers for the given project. + type: list + elements: dict + returned: always + contains: + id: + description: Container ID (short or full, as reported by Compose). + type: str + name: + description: Container name with any leading C(/) stripped. + type: str +container_count: + description: Number of running containers reported. + type: int + returned: always +''' + +import json +import os +import pathlib +import re + from ansible.module_utils.basic import AnsibleModule -import subprocess -def run_command(command): - """Run a shell command and return output, capturing both stdout and stderr""" - try: - # Start the process and capture stdout and stderr - process = subprocess.Popen( - command, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - shell=True, - text=True + +PROJECT_NAME_RE = re.compile(r'^[a-z0-9][a-z0-9_-]*$') +CONTAINER_ID_RE = re.compile(r'^[a-f0-9]{12,64}$') + + +def _validate_project_directory(module, project_directory): + """Return a validated absolute project directory or fail the module.""" + if not project_directory: + module.fail_json(msg="project_directory must not be empty") + + if not os.path.isabs(project_directory): + module.fail_json( + msg="project_directory must be an absolute path: %s" % project_directory + ) + + resolved = pathlib.Path(project_directory).resolve() + if ".." in resolved.parts: + module.fail_json( + msg="project_directory must not contain '..' segments: %s" % project_directory + ) + + if not os.path.isdir(project_directory): + module.fail_json( + msg="project_directory does not exist or is not a directory: %s" + % project_directory + ) + + return project_directory + + +def _validate_project_name(module, project_name): + """Validate the Compose project name against the canonical grammar.""" + if not project_name: + module.fail_json(msg="project_name must not be empty") + + if not PROJECT_NAME_RE.match(project_name): + module.fail_json( + msg=( + "project_name does not match ^[a-z0-9][a-z0-9_-]*$ " + "(Compose project-name grammar): %s" + ) + % project_name ) - # Communicate with the process to get results - stdout, stderr = process.communicate() - # Return dictionary containing exit code, stdout, and stderr - return { - 'return_code': process.returncode, - 'stdout': stdout.strip(), - 'stderr': stderr.strip() - } - except Exception as e: - # Return error details if the command fails - return { - 'return_code': 1, - 'stdout': '', - 'stderr': str(e) - } - -def get_running_docker_containers(project_directory, project_name): - """Retrieve a list of running containers for a specific Docker Compose project""" - # Command to change to the specified project directory - cmd_cd = f"cd {project_directory}" - - # Command to list running container IDs for the project - cmd_ps = f"docker compose -p {project_name} ps -q --status running" - - # Combine the two commands with && to execute in sequence - command = f"{cmd_cd} && {cmd_ps}" - result = run_command(command) - - # Check if the command was successful - if result['return_code'] != 0: - # Return an empty list and the error message if there was an error - return [], result['stderr'] - - # Split stdout into individual container IDs if there are any - container_ids = result['stdout'].split('\n') if result['stdout'] else [] - containers = [] - # Loop through each container ID to retrieve its name - for container_id in container_ids: - if not container_id: + return project_name + + +def _parse_compose_ps_json(stdout): + """Parse `docker compose ps --format json` output. + + Compose versions differ: some emit a single JSON array, others emit + newline-delimited JSON objects. Handle both. + """ + stdout = (stdout or "").strip() + if not stdout: + return [] + + # Try a single JSON document first (array or object). + try: + doc = json.loads(stdout) + if isinstance(doc, list): + return doc + if isinstance(doc, dict): + return [doc] + except ValueError: + pass + + # Fall back to NDJSON (one object per line). + containers = [] + for line in stdout.splitlines(): + line = line.strip() + if not line: continue + try: + containers.append(json.loads(line)) + except ValueError as exc: + raise ValueError("failed to parse compose ps JSON line: %s" % exc) + return containers + + +def get_running_docker_containers(module, project_directory, project_name): + """Return (containers, error_message) for the named compose project.""" + argv = [ + "docker", "compose", + "-p", project_name, + "ps", + "--status", "running", + "--format", "json", + ] + + rc, stdout, stderr = module.run_command( + argv, + cwd=project_directory, + check_rc=False, + ) + + if rc != 0: + return [], (stderr or "").strip() or "docker compose ps exited with rc=%d" % rc - # Command to inspect the container and get its name - inspect_cmd = f"docker inspect --format='{{{{.Name}}}}' {container_id}" - inspect_result = run_command(inspect_cmd) + try: + parsed = _parse_compose_ps_json(stdout) + except ValueError as exc: + return [], str(exc) - # If the command succeeded, strip the leading '/' and store the container details - if inspect_result['return_code'] == 0: - container_name = inspect_result['stdout'].strip('/') - containers.append({ - 'id': container_id, - 'name': container_name - }) + containers = [] + for entry in parsed: + # Compose `ps --format json` reports `ID` (full ID), `Name`, `Service`. + # Use whichever ID-bearing key is present. + cid = entry.get("ID") or entry.get("Id") or entry.get("ContainerID") or "" + name = entry.get("Name") or entry.get("Names") or "" + + # Defensive ID validation -- even if the ID came from `docker compose ps` + # we revalidate to keep the contract narrow. + if cid and not CONTAINER_ID_RE.match(cid): + return [], "compose returned a malformed container ID: %s" % cid + + containers.append({ + "id": cid, + "name": (name or "").lstrip("/"), + }) - # Return the list of containers and None (no error) return containers, None + def main(): - # Define arguments that the module will accept module = AnsibleModule( argument_spec=dict( - project_directory=dict(type='str', required=True), - project_name=dict(type='str', required=True) - ) + project_directory=dict(type='path', required=True), + project_name=dict(type='str', required=True), + ), + supports_check_mode=True, ) - # Get arguments from Ansible - project_directory = module.params['project_directory'] - project_name = module.params['project_name'] + project_directory = _validate_project_directory( + module, module.params['project_directory'] + ) + project_name = _validate_project_name( + module, module.params['project_name'] + ) - # Retrieve running containers and any error encountered - containers, error = get_running_docker_containers(project_directory, project_name) + containers, error = get_running_docker_containers( + module, project_directory, project_name + ) - # If there is an error, fail the module with an error message if error: - module.fail_json(msg=f"Error checking containers: {error}") + module.fail_json(msg="Error checking containers: %s" % error) - # Set result with container details and count, without any changes made - result = { - 'changed': False, - 'containers': containers, - 'container_count': len(containers) - } + module.exit_json( + changed=False, + containers=containers, + container_count=len(containers), + ) - # Exit module successfully, returning result data - module.exit_json(**result) if __name__ == '__main__': main() diff --git a/collections/nixknight/docker/plugins/modules/docker_image_mgmt_plan.py b/collections/nixknight/docker/plugins/modules/docker_image_mgmt_plan.py index e408a26..251f087 100644 --- a/collections/nixknight/docker/plugins/modules/docker_image_mgmt_plan.py +++ b/collections/nixknight/docker/plugins/modules/docker_image_mgmt_plan.py @@ -1,133 +1,336 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) NIXKnight +# MIT License (see LICENSE) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = r''' +--- +module: docker_image_mgmt_plan +short_description: Compute a Docker image pull/remove plan for a service +version_added: "0.1.5" +description: + - Inspect the local Docker daemon and produce two lists describing the + intended image lifecycle for a service: images that need to be pulled + and images that may be safely removed. + - Multi-tenant safe -- images referenced by any container on the daemon + (including stopped containers) are protected from removal. + - Supports C(check_mode); the inventory read still runs so the planner + reports an accurate intended plan during dry-runs. +options: + images: + description: + - Service-keyed mapping of desired images. Each value must contain + C(name) and C(tag). + required: true + type: dict + purge: + description: + - When true, all existing tags of the desired image names are added to + the remove plan (subject to the multi-tenant protection above). + - Typically set to true during teardown. + required: false + type: bool + default: false + force_refresh: + description: + - When true, every desired image is added to the pull plan even if its + tag is already present locally. Used for mutable tags (e.g. C(latest)) + to force a re-pull and propagate the resulting C(changed) status to + the role's restart handler. + required: false + type: bool + default: false + docker_host: + description: + - Optional base URL for the Docker daemon (for example + C(unix:///var/run/docker.sock) or C(tcp://docker-host:2376)). + - When omitted, the daemon is discovered from the environment. + required: false + type: str + tls: + description: + - Optional TLS configuration mapping passed straight to + C(docker.tls.TLSConfig). + - Common keys: C(ca_cert), C(client_cert), C(client_key), C(verify), + C(assert_hostname). + required: false + type: dict +author: + - Saad Ali (@NIXKnight) +''' + +EXAMPLES = r''' +- name: Plan image lifecycle for a service + nixknight.docker.docker_image_mgmt_plan: + images: + postgresql: + name: "postgres" + tag: "16.2-bookworm" + redis: + name: "redis" + tag: "7.2.4-bookworm" + register: plan + +- name: Force-refresh mutable tags + nixknight.docker.docker_image_mgmt_plan: + images: + app: + name: "registry.example.com:5000/foo/app" + tag: "latest" + force_refresh: true + register: refresh_plan +''' + +RETURN = r''' +changed: + description: Always C(false). This module is a planner; it does not pull or remove. + type: bool + returned: always +docker_images_to_pull: + description: List of "name:tag" references that should be pulled. + type: list + elements: str + returned: always +docker_images_to_remove: + description: List of "name:tag" references that may be removed. + type: list + elements: str + returned: always +''' + +import re + from ansible.module_utils.basic import AnsibleModule -import docker -from docker.errors import APIError, ImageNotFound -from urllib3.exceptions import HTTPError -def get_existing_docker_images(client, image_name): - """ - Get all tags for a specific image name using Docker API. - Returns a list of "name:tag" strings. +try: + import docker + from docker.errors import APIError, DockerException, ImageNotFound + HAS_DOCKER = True + DOCKER_IMPORT_ERROR = None +except ImportError as imp_exc: + HAS_DOCKER = False + DOCKER_IMPORT_ERROR = imp_exc + +try: + from urllib3.exceptions import HTTPError +except ImportError: + HTTPError = Exception # type: ignore[misc] + + +# Reject empty, NUL byte, ASCII control chars, and any whitespace inside the +# reference. The Docker SDK itself enforces the remainder of the +# distribution-spec grammar (incl. valid registry-host-with-port references +# like `registry.example.com:5000/foo`). +_BAD_IMAGE_CHARS = re.compile(r'[\x00-\x1f\s]') + + +def _sanitize_image_name(module, image_name): + """Minimal sanitation: defer full validation to the Docker SDK.""" + if not isinstance(image_name, str) or not image_name: + module.fail_json(msg="image name must be a non-empty string: %r" % (image_name,)) + if _BAD_IMAGE_CHARS.search(image_name): + module.fail_json( + msg="image name contains control or whitespace characters: %r" + % (image_name,) + ) + return image_name + + +def _sanitize_image_tag(module, image_tag): + """Minimal sanitation for the tag component.""" + if not isinstance(image_tag, str) or not image_tag: + module.fail_json(msg="image tag must be a non-empty string: %r" % (image_tag,)) + if _BAD_IMAGE_CHARS.search(image_tag): + module.fail_json( + msg="image tag contains control or whitespace characters: %r" % (image_tag,) + ) + return image_tag + + +def _build_docker_client(module, docker_host, tls_params): + """Construct a DockerClient honoring optional docker_host / tls settings.""" + if not HAS_DOCKER: + module.fail_json( + msg=( + "The 'docker' Python package is required by docker_image_mgmt_plan. " + "Install it (e.g. via the 'python3-docker' distro package) on the " + "target host. ImportError: %s" + ) + % DOCKER_IMPORT_ERROR + ) - Args: - client: Docker client instance - image_name: Name of the image to check - """ try: - # List all images matching the specified image name - images = client.images.list(name=image_name) - image_tags = [] - for image in images: - # Each image may have multiple tags, add each tag to image_tags list - for tag in image.tags: - image_tags.append(tag) - return image_tags - except APIError as e: - # Handle Docker API errors and raise a descriptive exception - raise Exception(f"Docker API error while getting images: {str(e)}") - -def get_docker_image_management_plan(module, docker_images_config, purge=False): - """ - Determine which images need to be pulled and which should be removed. - Uses Docker API to check image existence. + if docker_host: + tls_config = None + if tls_params: + tls_config = docker.tls.TLSConfig(**tls_params) + client = docker.DockerClient(base_url=docker_host, tls=tls_config) + else: + client = docker.from_env() + client.ping() + except (DockerException, HTTPError) as exc: + module.fail_json(msg="Failed to connect to Docker daemon: %s" % exc) + + return client + - Args: - module: Ansible module instance - docker_images_config: Dictionary of service image configurations - purge: Boolean indicating whether to purge unused images +def _protected_image_references(client): + """Return the set of image references currently in use by any container. + + Multi-tenant safety: filters against C(client.containers.list(all=True)) + so containers in created/running/paused/exited states are all considered. + The protection set is derived from each container's image ID plus its + current tags -- this catches both tag-referenced and digest-referenced + deployments on the same daemon. """ + protected_tags = set() + protected_ids = set() + for container in client.containers.list(all=True): + image = getattr(container, "image", None) + if image is None: + continue + if getattr(image, "id", None): + protected_ids.add(image.id) + for tag in getattr(image, "tags", []) or []: + protected_tags.add(tag) + return protected_tags, protected_ids + + +def _existing_tags_for_image(client, image_name): + """List all locally present tags for the given image name.""" try: - # Initialize Docker client using environment configuration - client = docker.from_env() - # Test Docker daemon connectivity - client.ping() - except (docker.errors.DockerException, HTTPError) as e: - # Fail if unable to connect to Docker daemon - module.fail_json(msg=f"Failed to connect to Docker daemon: {str(e)}") + images = client.images.list(name=image_name) + except APIError as exc: + raise RuntimeError("Docker API error while listing %s: %s" % (image_name, exc)) + + tags = [] + for image in images: + for tag in image.tags or []: + tags.append(tag) + return tags + + +def get_docker_image_management_plan(module, client, images_config, purge, force_refresh): + """Compute the pull / remove plan honoring multi-tenant protections.""" + images_to_pull = [] + images_to_remove = [] - # Lists to store images that need to be pulled or removed - docker_images_to_pull = [] - docker_images_to_remove = [] + protected_tags, protected_ids = _protected_image_references(client) - # Loop through each service and image configuration - for service, config in docker_images_config.items(): - image_name = config['name'] - image_tag = config['tag'] - required_image = f"{image_name}:{image_tag}" + for service, config in images_config.items(): + if not isinstance(config, dict): + module.fail_json( + msg="images[%s] must be a mapping with 'name' and 'tag' keys" % service + ) + + image_name = _sanitize_image_name(module, config.get('name', '')) + image_tag = _sanitize_image_tag(module, config.get('tag', '')) + required_image = "%s:%s" % (image_name, image_tag) try: - # Get all tags for the specified image name - existing_images = get_existing_docker_images(client, image_name) + existing_images = _existing_tags_for_image(client, image_name) + + if force_refresh: + # R9: always schedule a pull so the resulting `changed` + # propagates to the restart handler. + images_to_pull.append(required_image) - # Check if the specified tag exists among existing images if required_image not in existing_images: - # If not found, mark for pulling and remove any other tags - docker_images_to_pull.append(required_image) - docker_images_to_remove.extend(existing_images) - elif purge: - # Remove all tags for this image if purge is enabled - docker_images_to_remove.extend([ - img for img in existing_images + if not force_refresh: + images_to_pull.append(required_image) + # Removal candidates: all other tags of this image name that + # are not protected by an active container reference. + images_to_remove.extend([ + img for img in existing_images if img != required_image ]) + elif purge: + # During purge, every tag of this image name is a removal + # candidate (still subject to the protection filter below). + images_to_remove.extend(existing_images) else: - # Required tag exists; remove any other tags for this image - docker_images_to_remove.extend([ - img for img in existing_images - if img != required_image + # Required tag present: candidates are sibling tags only. + images_to_remove.extend([ + img for img in existing_images if img != required_image ]) except ImageNotFound: - # If image not found, add it to the pull list - docker_images_to_pull.append(required_image) - except Exception as e: - # Handle general errors during image check - module.fail_json(msg=f"Error checking image {image_name}: {str(e)}") + images_to_pull.append(required_image) + except RuntimeError as exc: + module.fail_json(msg=str(exc)) + except Exception as exc: # pylint: disable=broad-except + module.fail_json(msg="Error checking image %s: %s" % (image_name, exc)) - # Remove duplicates from lists while maintaining order - docker_images_to_pull = list(dict.fromkeys(docker_images_to_pull)) - docker_images_to_remove = list(dict.fromkeys(docker_images_to_remove)) + # Multi-tenant safety filter (S2): drop any candidate that is in use by + # any container on this daemon (any project, any state). + filtered_remove = [] + for tag in images_to_remove: + if tag in protected_tags: + continue + # Resolve to the image object so we can check its ID; if the tag is + # gone before we get here we simply skip it. + try: + img = client.images.get(tag) + except (ImageNotFound, APIError): + continue + if getattr(img, "id", None) in protected_ids: + continue + filtered_remove.append(tag) + + images_to_pull = list(dict.fromkeys(images_to_pull)) + images_to_remove = list(dict.fromkeys(filtered_remove)) + + return images_to_pull, images_to_remove - return docker_images_to_pull, docker_images_to_remove def main(): - # Define input arguments for the Ansible module module_args = dict( images=dict(type='dict', required=True), - purge=dict(type='bool', default=False) + purge=dict(type='bool', default=False), + force_refresh=dict(type='bool', default=False), + docker_host=dict(type='str', required=False, default=None), + tls=dict(type='dict', required=False, default=None, no_log=False), ) - # Initialize result dictionary to store results and status result = dict( changed=False, docker_images_to_pull=[], docker_images_to_remove=[], - failed=False ) - # Initialize Ansible module module = AnsibleModule( argument_spec=module_args, - supports_check_mode=True + supports_check_mode=True, + ) + + # S4: the read-only inventory must still run in --check mode so the planner + # reports accurate drift; the pull/remove tasks (in the role) are the + # only things that should be skipped. + client = _build_docker_client( + module, + module.params['docker_host'], + module.params['tls'], ) try: - # If not in check mode, perform the image analysis - if not module.check_mode: - docker_images_to_pull, docker_images_to_remove = get_docker_image_management_plan( - module, - module.params['images'], - module.params['purge'] - ) + images_to_pull, images_to_remove = get_docker_image_management_plan( + module, + client, + module.params['images'], + module.params['purge'], + module.params['force_refresh'], + ) + except Exception as exc: # pylint: disable=broad-except + module.fail_json(msg=str(exc)) - # Update result dictionary with the analysis outcomes - result['docker_images_to_pull'] = docker_images_to_pull - result['docker_images_to_remove'] = docker_images_to_remove + result['docker_images_to_pull'] = images_to_pull + result['docker_images_to_remove'] = images_to_remove - # Exit module with the result as JSON - module.exit_json(**result) + module.exit_json(**result) - except Exception as e: - # Handle any errors that occur during execution - module.fail_json(msg=str(e)) if __name__ == '__main__': main() diff --git a/collections/nixknight/docker/roles/docker-compose-service/README.md b/collections/nixknight/docker/roles/docker-compose-service/README.md index b901d4a..f6dd6f5 100644 --- a/collections/nixknight/docker/roles/docker-compose-service/README.md +++ b/collections/nixknight/docker/roles/docker-compose-service/README.md @@ -6,6 +6,7 @@ An Ansible role for deploying and managing Docker Compose services with systemd - [Requirements](#requirements) - [Role Variables](#role-variables) +- [Operational Notes](#operational-notes) - [Installation](#installation) - [Examples](#examples) - [License](#license) @@ -15,8 +16,8 @@ An Ansible role for deploying and managing Docker Compose services with systemd - Docker installed and running on target hosts - Target host runs systemd -- Ansible 2.9 or higher -- Python docker library on target hosts +- Ansible 2.15 or higher (per `meta/runtime.yml`) +- Python docker library on target hosts (installed by the role by default; see `DOCKER_COMPOSE_SERVICE_INSTALL_PYTHON_DOCKER` below) ## **Role Variables** @@ -24,32 +25,62 @@ An Ansible role for deploying and managing Docker Compose services with systemd | Variable | Description | Type | |----------|-------------|------| -| `DOCKER_COMPOSE_SERVICE_NAME` | Name of the service for systemd | string | -| `DOCKER_COMPOSE_SERVICE_COMPOSE_PATH` | Path where docker-compose.yml will be stored | string | -| `DOCKER_COMPOSE_SERVICE_MANIFEST` | Docker Compose configuration content | dict | +| `DOCKER_COMPOSE_SERVICE_NAME` | Name of the service for systemd (must match `^[a-z0-9][a-z0-9_-]*$`) | string | +| `DOCKER_COMPOSE_SERVICE_COMPOSE_PATH` | Absolute path where `docker-compose.yml` will be stored | string | +| `DOCKER_COMPOSE_SERVICE_MANIFEST` | Docker Compose configuration content; `dest` must equal `/docker-compose.yml` | dict | ### **Optional Variables** | Variable | Default | Description | Type | |----------|---------|-------------|------| -| `DOCKER_COMPOSE_SERVICE_SYSTEMD_DESCRIPTION` | `"{{ DOCKER_COMPOSE_SERVICE_NAME }} Service"` | Systemd service description | string | +| `DOCKER_COMPOSE_SERVICE_SYSTEMD_DESCRIPTION` | `""` | Systemd service description | string | | `DOCKER_COMPOSE_SERVICE_SYSTEMD_REQUIRES` | `docker.service` | Systemd service dependencies | string | | `DOCKER_COMPOSE_SERVICE_SYSTEMD_AFTER` | `docker.service` | Systemd service ordering | string | | `DOCKER_COMPOSE_SERVICE_SYSTEMD_FILE` | `/etc/systemd/system/{{ DOCKER_COMPOSE_SERVICE_NAME }}.service` | Systemd service file path | string | +| `DOCKER_COMPOSE_SERVICE_SYSTEMD_DEFAULT_STATE` | `started` | Default systemd unit state when no change is detected | string | +| `DOCKER_COMPOSE_SERVICE_SET_GROUP_ID` | `false` | Resolve `docker` group GID and expose it as `DOCKER_GID` in the unit's `Environment` | bool | | `DOCKER_COMPOSE_SERVICE_CONFIG_PATH` | `""` | Additional configuration directory path | string | | `DOCKER_COMPOSE_SERVICE_IMAGES` | `{}` | Docker images to manage with tags | dict | | `DOCKER_COMPOSE_SERVICE_FILES` | `[]` | Static files to copy to target | list | | `DOCKER_COMPOSE_SERVICE_TEMPLATES` | `[]` | Template files to render | list | | `DOCKER_COMPOSE_SERVICE_ADDITIONAL_PATHS` | `[]` | Additional directories to create | list | +| `DOCKER_COMPOSE_SERVICE_REMOVE` | `false` | When `true`, run the teardown path instead of create/update | bool | +| `DOCKER_COMPOSE_SERVICE_INSTALL_PYTHON_DOCKER` | `true` | Install the Python Docker SDK package on the target | bool | +| `DOCKER_COMPOSE_SERVICE_PYTHON_DOCKER_PACKAGE` | `python3-docker` | Distro package providing the Python Docker SDK (Debian/bookworm) | string | +| `DOCKER_COMPOSE_SERVICE_DIR_MODE` | `0750` | Mode applied to the compose / config / additional directories created by the role | string | +| `DOCKER_COMPOSE_SERVICE_DIR_OWNER` | `root` | Owner applied to the directories created by the role | string | +| `DOCKER_COMPOSE_SERVICE_DIR_GROUP` | `root` | Group applied to the directories created by the role | string | +| `DOCKER_COMPOSE_SERVICE_COMPOSE_FILE_MODE` | `0640` | Mode for the rendered `docker-compose.yml` (may carry secrets) | string | +| `DOCKER_COMPOSE_SERVICE_COMPOSE_FILE_OWNER` | `root` | Owner for the rendered `docker-compose.yml` | string | +| `DOCKER_COMPOSE_SERVICE_COMPOSE_FILE_GROUP` | `root` | Group for the rendered `docker-compose.yml` | string | +| `DOCKER_COMPOSE_SERVICE_SYSTEMD_FILE_MODE` | `0644` | Mode for the systemd unit file | string | +| `DOCKER_COMPOSE_SERVICE_SYSTEMD_FILE_OWNER` | `root` | Owner for the systemd unit file | string | +| `DOCKER_COMPOSE_SERVICE_SYSTEMD_FILE_GROUP` | `root` | Group for the systemd unit file | string | +| `DOCKER_COMPOSE_SERVICE_TIMEOUT_START` | `300` | `TimeoutStartSec` (seconds) in the rendered systemd unit | int | +| `DOCKER_COMPOSE_SERVICE_TIMEOUT_STOP` | `300` | `TimeoutStopSec` (seconds) in the rendered systemd unit | int | +| `DOCKER_COMPOSE_SERVICE_FORCE_IMAGE_REFRESH` | `false` | Force-pull mutable tags (e.g. `latest`) and propagate `changed` to the restart handler | bool | + +## **Operational Notes** + +The following behaviors are important to understand before adopting this role: + +- **Restrictive defaults applied to managed paths**: the role now applies `0750` to directories, `0640` to the rendered `docker-compose.yml`, and `0644` to the systemd unit, all owned by `root:root`. Operators with different ownership or mode requirements **MUST** override the `*_MODE` / `*_OWNER` / `*_GROUP` defaults. Directory tasks intentionally do **not** use `recurse: true` so existing bind-mount data, secrets, and post-deploy artifacts are left untouched. +- **Registry authentication is out of band**: the role does not bundle a `docker login` step. Use `community.docker.docker_login` (or an equivalent out-of-band mechanism) before invoking the role when pulls require credentials. +- **Mutable-tag refresh requires `DOCKER_COMPOSE_SERVICE_FORCE_IMAGE_REFRESH: true`** (default `false`). Immutable-tag deployments (e.g. pinned semver tags) work without it. When set, the planner schedules a pull even if the local tag is present, and the resulting `changed` status propagates to the service-restart handler. +- **Manual `docker compose` invocations**: always pass `-p ` to avoid creating orphan projects under a different project name than the one used by the role. +- **Untrusted variables**: any user-controlled value fed into `DOCKER_COMPOSE_SERVICE_MANIFEST.content` **MUST** be marked `!unsafe` to disable Ansible's upstream Jinja resolution -- the role's `to_nice_yaml` happens after variable resolution. +- **Pre-provisioned Python SDK**: when `DOCKER_COMPOSE_SERVICE_INSTALL_PYTHON_DOCKER` is set to `false`, the operator **MUST** pre-provision the `python3-docker` distro package on the target. `docker_image_mgmt_plan` imports `docker` at module-load time; without it the module fails before any task runs. ## **Installation** Install the collection containing this role: ```bash -ansible-galaxy collection install git+https://github.com/NIXKnight/Ansible-Collections.git#/collections/nixknight/docker,docker-0.1.3 +ansible-galaxy collection install git+https://github.com/NIXKnight/Ansible-Collections.git#/collections/nixknight/docker ``` +The collection declares `community.docker (>=3.0.0)` as a dependency; `ansible-galaxy collection install` will resolve and install it automatically. + ## **Examples** ### **PostgreSQL Service** @@ -76,7 +107,7 @@ Here is an example of how to use this role to set up a PostgreSQL service. restart: unless-stopped environment: POSTGRES_USER: user - POSTGRES_PASSWORD: password + POSTGRES_PASSWORD: POSTGRES_DB: my_database volumes: - "postgres_data:/var/lib/postgresql/data" @@ -142,7 +173,7 @@ Here is an example of how to use this role to set up a Django service with a cus restart: unless-stopped environment: POSTGRES_USER: django_user - POSTGRES_PASSWORD: django_password + POSTGRES_PASSWORD: POSTGRES_DB: django_db volumes: - "postgres_data:/var/lib/postgresql/data" @@ -189,7 +220,7 @@ Here is an example of how to use this role to set up a Django service with a cus restart: unless-stopped environment: POSTGRES_USER: user - POSTGRES_PASSWORD: password + POSTGRES_PASSWORD: POSTGRES_DB: my_database_pattern volumes: - "postgres_pattern_data:/var/lib/postgresql/data" @@ -271,7 +302,7 @@ Here is an example of how to use this role to set up a Django service with a cus restart: unless-stopped environment: POSTGRES_USER: django_user_pattern - POSTGRES_PASSWORD: django_password_pattern + POSTGRES_PASSWORD: POSTGRES_DB: django_db_pattern volumes: - "postgres_django_pattern_data:/var/lib/postgresql/data" diff --git a/collections/nixknight/docker/roles/docker-compose-service/defaults/main.yml b/collections/nixknight/docker/roles/docker-compose-service/defaults/main.yml index 5166fc8..1b54098 100644 --- a/collections/nixknight/docker/roles/docker-compose-service/defaults/main.yml +++ b/collections/nixknight/docker/roles/docker-compose-service/defaults/main.yml @@ -30,7 +30,7 @@ DOCKER_COMPOSE_SERVICE_MANIFEST: {} # image: "{{ DOCKER_COMPOSE_SERVICE_IMAGES.postgresql.name }}" # environment: # POSTGRES_USER: root - # POSTGRES_PASSWORD: knight + # POSTGRES_PASSWORD: # volumes: # - "postgres_data:/var/lib/postgresql/data" # network_mode: host @@ -57,3 +57,30 @@ DOCKER_COMPOSE_SERVICE_MANIFEST: {} # redis_data: # driver: local DOCKER_COMPOSE_SERVICE_REMOVE: false + +# V1 -- Python Docker SDK provisioning (Debian/bookworm). +# Modules import `docker` at module-load time -- keep this true unless the host +# already has python3-docker installed out-of-band. +DOCKER_COMPOSE_SERVICE_INSTALL_PYTHON_DOCKER: true +DOCKER_COMPOSE_SERVICE_PYTHON_DOCKER_PACKAGE: "python3-docker" + +# V2 -- File / directory mode + ownership defaults applied by the role. +# Override these when operator-managed ownership differs from root:root. +DOCKER_COMPOSE_SERVICE_DIR_MODE: "0750" +DOCKER_COMPOSE_SERVICE_DIR_OWNER: "root" +DOCKER_COMPOSE_SERVICE_DIR_GROUP: "root" +DOCKER_COMPOSE_SERVICE_COMPOSE_FILE_MODE: "0640" +DOCKER_COMPOSE_SERVICE_COMPOSE_FILE_OWNER: "root" +DOCKER_COMPOSE_SERVICE_COMPOSE_FILE_GROUP: "root" +DOCKER_COMPOSE_SERVICE_SYSTEMD_FILE_MODE: "0644" +DOCKER_COMPOSE_SERVICE_SYSTEMD_FILE_OWNER: "root" +DOCKER_COMPOSE_SERVICE_SYSTEMD_FILE_GROUP: "root" + +# V3 -- systemd unit timeouts (seconds). +DOCKER_COMPOSE_SERVICE_TIMEOUT_START: 300 +DOCKER_COMPOSE_SERVICE_TIMEOUT_STOP: 300 + +# V4 -- Force-refresh mutable tags (e.g., `latest`). When true the +# image-management planner schedules a pull even if the local tag is present; +# the resulting `changed` propagates to the service-restart handler. +DOCKER_COMPOSE_SERVICE_FORCE_IMAGE_REFRESH: false diff --git a/collections/nixknight/docker/roles/docker-compose-service/meta/main.yml b/collections/nixknight/docker/roles/docker-compose-service/meta/main.yml index fafe868..8466af3 100644 --- a/collections/nixknight/docker/roles/docker-compose-service/meta/main.yml +++ b/collections/nixknight/docker/roles/docker-compose-service/meta/main.yml @@ -1,13 +1,13 @@ +--- dependencies: [] galaxy_info: author: Saad Ali description: Setup a service with Docker Compose. - license: license (MIT) - min_ansible_version: 8.1 + license: MIT platforms: - - name: Debian - versions: - - bookworm + - name: Debian + versions: + - bookworm galaxy_tags: - docker - compose diff --git a/collections/nixknight/docker/roles/docker-compose-service/tasks/create_update_service.yml b/collections/nixknight/docker/roles/docker-compose-service/tasks/create_update_service.yml index b049ad1..d818e49 100644 --- a/collections/nixknight/docker/roles/docker-compose-service/tasks/create_update_service.yml +++ b/collections/nixknight/docker/roles/docker-compose-service/tasks/create_update_service.yml @@ -1,13 +1,19 @@ --- # tasks file for Ansible-Docker-Compose-Service -- name: Set Docker Group ID - ansible.builtin.shell: "getent group docker | cut -d: -f3" - args: - executable: "/bin/bash" - register: docker_gid +- name: Resolve Docker Group ID + ansible.builtin.command: "getent group docker" + register: docker_group_entry changed_when: false when: DOCKER_COMPOSE_SERVICE_SET_GROUP_ID +- name: Extract Docker GID + ansible.builtin.set_fact: + docker_gid: "{{ docker_group_entry.stdout.split(':')[2] }}" + when: + - DOCKER_COMPOSE_SERVICE_SET_GROUP_ID + - docker_group_entry is defined + - docker_group_entry.stdout | default('') | length > 0 + - name: Check if Service Systemd File Exists ansible.builtin.stat: path: "{{ DOCKER_COMPOSE_SERVICE_SYSTEMD_FILE }}" @@ -22,24 +28,33 @@ ansible.builtin.file: path: "{{ DOCKER_COMPOSE_SERVICE_COMPOSE_PATH }}" state: directory - recurse: true - when: (docker_compose_file.stat.isdir is not defined) and (DOCKER_COMPOSE_SERVICE_COMPOSE_PATH is defined and DOCKER_COMPOSE_SERVICE_COMPOSE_PATH | length > 0) + mode: "{{ DOCKER_COMPOSE_SERVICE_DIR_MODE }}" + owner: "{{ DOCKER_COMPOSE_SERVICE_DIR_OWNER }}" + group: "{{ DOCKER_COMPOSE_SERVICE_DIR_GROUP }}" + when: + - docker_compose_file.stat.isdir is not defined + - DOCKER_COMPOSE_SERVICE_COMPOSE_PATH is defined + - DOCKER_COMPOSE_SERVICE_COMPOSE_PATH | length > 0 - name: Create Service Docker Compose Additional Directories ansible.builtin.file: path: "{{ item.path }}" state: directory - owner: "{{ item.owner }}" - group: "{{ item.group }}" - recurse: true + mode: "{{ item.mode | default(DOCKER_COMPOSE_SERVICE_DIR_MODE) }}" + owner: "{{ item.owner | default(DOCKER_COMPOSE_SERVICE_DIR_OWNER) }}" + group: "{{ item.group | default(DOCKER_COMPOSE_SERVICE_DIR_GROUP) }}" with_items: "{{ DOCKER_COMPOSE_SERVICE_ADDITIONAL_PATHS }}" - name: Create Service Configuration Directory ansible.builtin.file: path: "{{ DOCKER_COMPOSE_SERVICE_CONFIG_PATH }}" state: directory - recurse: true - when: DOCKER_COMPOSE_SERVICE_CONFIG_PATH is defined and DOCKER_COMPOSE_SERVICE_CONFIG_PATH | length > 0 + mode: "{{ DOCKER_COMPOSE_SERVICE_DIR_MODE }}" + owner: "{{ DOCKER_COMPOSE_SERVICE_DIR_OWNER }}" + group: "{{ DOCKER_COMPOSE_SERVICE_DIR_GROUP }}" + when: + - DOCKER_COMPOSE_SERVICE_CONFIG_PATH is defined + - DOCKER_COMPOSE_SERVICE_CONFIG_PATH | length > 0 - name: Create Container Image Management Plan ansible.builtin.include_tasks: @@ -50,18 +65,26 @@ name: "{{ DOCKER_COMPOSE_SERVICE_NAME }}" state: stopped register: service_systemd_stop - when: service_systemd_file.stat.exists and service_needs_to_stop + when: + - service_systemd_file.stat.exists + - service_needs_to_stop - name: Create/Update Service Docker Compose File ansible.builtin.template: src: "templates/docker-compose.yml.j2" dest: "{{ DOCKER_COMPOSE_SERVICE_MANIFEST.dest }}" + mode: "{{ DOCKER_COMPOSE_SERVICE_COMPOSE_FILE_MODE }}" + owner: "{{ DOCKER_COMPOSE_SERVICE_COMPOSE_FILE_OWNER }}" + group: "{{ DOCKER_COMPOSE_SERVICE_COMPOSE_FILE_GROUP }}" register: create_update_docker_compose_file - name: Create/Update Systemd Service File ansible.builtin.template: src: "templates/etc/systemd/system/docker-compose.service.j2" dest: "{{ DOCKER_COMPOSE_SERVICE_SYSTEMD_FILE }}" + mode: "{{ DOCKER_COMPOSE_SERVICE_SYSTEMD_FILE_MODE }}" + owner: "{{ DOCKER_COMPOSE_SERVICE_SYSTEMD_FILE_OWNER }}" + group: "{{ DOCKER_COMPOSE_SERVICE_SYSTEMD_FILE_GROUP }}" register: create_update_systemd_service_file - name: Render Service Template(s) @@ -73,7 +96,9 @@ group: "{{ item.group }}" with_items: "{{ DOCKER_COMPOSE_SERVICE_TEMPLATES }}" register: render_service_templates - when: DOCKER_COMPOSE_SERVICE_TEMPLATES is defined and DOCKER_COMPOSE_SERVICE_TEMPLATES | length > 0 + when: + - DOCKER_COMPOSE_SERVICE_TEMPLATES is defined + - DOCKER_COMPOSE_SERVICE_TEMPLATES | length > 0 - name: Copy/Update Service File(s) ansible.builtin.copy: @@ -84,12 +109,16 @@ group: "{{ item.group }}" with_items: "{{ DOCKER_COMPOSE_SERVICE_FILES }}" register: create_update_service_files - when: DOCKER_COMPOSE_SERVICE_FILES is defined and DOCKER_COMPOSE_SERVICE_FILES | length > 0 + when: + - DOCKER_COMPOSE_SERVICE_FILES is defined + - DOCKER_COMPOSE_SERVICE_FILES | length > 0 - name: Pull/Remove Container Images ansible.builtin.include_tasks: file: pull_remove_images.yml +# R9 -- restart logic now incorporates the pull-task's `changed` result so +# mutable-tag refreshes propagate to the service. - name: Set Service State ansible.builtin.set_fact: service_state: >- @@ -100,7 +129,8 @@ (create_update_docker_compose_file is defined and create_update_docker_compose_file.changed) or (create_update_systemd_service_file is defined and create_update_systemd_service_file.changed) or (render_service_templates is defined and render_service_templates.changed) or - (create_update_service_files is defined and create_update_service_files.changed) + (create_update_service_files is defined and create_update_service_files.changed) or + (pull_container_images is defined and pull_container_images.changed) ) and not service_needs_to_stop ) diff --git a/collections/nixknight/docker/roles/docker-compose-service/tasks/delete_service.yml b/collections/nixknight/docker/roles/docker-compose-service/tasks/delete_service.yml index 9c9c2c7..3c404c4 100644 --- a/collections/nixknight/docker/roles/docker-compose-service/tasks/delete_service.yml +++ b/collections/nixknight/docker/roles/docker-compose-service/tasks/delete_service.yml @@ -4,7 +4,7 @@ file: image_mgmt_plan.yml - name: Stop and Disable Service - ansible.builtin.systemd: + ansible.builtin.systemd_service: name: "{{ DOCKER_COMPOSE_SERVICE_NAME }}" state: "stopped" enabled: false @@ -23,14 +23,18 @@ ansible.builtin.file: path: "{{ DOCKER_COMPOSE_SERVICE_CONFIG_PATH }}" state: absent - when: DOCKER_COMPOSE_SERVICE_CONFIG_PATH is defined and DOCKER_COMPOSE_SERVICE_CONFIG_PATH | length > 0 + when: + - DOCKER_COMPOSE_SERVICE_CONFIG_PATH is defined + - DOCKER_COMPOSE_SERVICE_CONFIG_PATH | length > 0 - name: Remove additional directories for the service ansible.builtin.file: path: "{{ item.path }}" state: absent with_items: "{{ DOCKER_COMPOSE_SERVICE_ADDITIONAL_PATHS }}" - when: DOCKER_COMPOSE_SERVICE_ADDITIONAL_PATHS is defined and DOCKER_COMPOSE_SERVICE_ADDITIONAL_PATHS | length > 0 + when: + - DOCKER_COMPOSE_SERVICE_ADDITIONAL_PATHS is defined + - DOCKER_COMPOSE_SERVICE_ADDITIONAL_PATHS | length > 0 - name: Remove Container Images ansible.builtin.include_tasks: diff --git a/collections/nixknight/docker/roles/docker-compose-service/tasks/image_mgmt_plan.yml b/collections/nixknight/docker/roles/docker-compose-service/tasks/image_mgmt_plan.yml index a376c5f..416d937 100644 --- a/collections/nixknight/docker/roles/docker-compose-service/tasks/image_mgmt_plan.yml +++ b/collections/nixknight/docker/roles/docker-compose-service/tasks/image_mgmt_plan.yml @@ -1,10 +1,11 @@ --- # tasks file for Ansible-Docker-Compose-Service -# This task uses docker_image_mgmt_plan module from https://github.com/NIXKnight/Ansible-Collections.git +# Uses nixknight.docker.docker_image_mgmt_plan from this collection. - name: Create a Docker Container Image Management Plan - docker_image_mgmt_plan: + nixknight.docker.docker_image_mgmt_plan: images: "{{ DOCKER_COMPOSE_SERVICE_IMAGES }}" purge: "{{ DOCKER_COMPOSE_SERVICE_REMOVE }}" + force_refresh: "{{ DOCKER_COMPOSE_SERVICE_FORCE_IMAGE_REFRESH }}" register: container_image_mgmt_plan - name: Set Vars according to the Image Management Plan diff --git a/collections/nixknight/docker/roles/docker-compose-service/tasks/main.yml b/collections/nixknight/docker/roles/docker-compose-service/tasks/main.yml index 2a91048..3e9757e 100644 --- a/collections/nixknight/docker/roles/docker-compose-service/tasks/main.yml +++ b/collections/nixknight/docker/roles/docker-compose-service/tasks/main.yml @@ -1,5 +1,104 @@ --- # tasks file for Ansible-Docker-Compose-Service + +- name: Validate Role Inputs + ansible.builtin.assert: + that: + # Service name: Compose project-name grammar. + - DOCKER_COMPOSE_SERVICE_NAME is string + - DOCKER_COMPOSE_SERVICE_NAME | length > 0 + - DOCKER_COMPOSE_SERVICE_NAME is match('^[a-z0-9][a-z0-9_-]*$') + + # Compose path: non-empty, absolute, no '..' segments. + - DOCKER_COMPOSE_SERVICE_COMPOSE_PATH is string + - DOCKER_COMPOSE_SERVICE_COMPOSE_PATH | length > 0 + - DOCKER_COMPOSE_SERVICE_COMPOSE_PATH is match('^/') + - "'..' not in (DOCKER_COMPOSE_SERVICE_COMPOSE_PATH.split('/'))" + + # Systemd unit path. + - DOCKER_COMPOSE_SERVICE_SYSTEMD_FILE is string + - DOCKER_COMPOSE_SERVICE_SYSTEMD_FILE | length > 0 + - DOCKER_COMPOSE_SERVICE_SYSTEMD_FILE is match('^/') + - "'..' not in (DOCKER_COMPOSE_SERVICE_SYSTEMD_FILE.split('/'))" + + # Config path: when defined and non-empty it must be absolute and clean. + - (DOCKER_COMPOSE_SERVICE_CONFIG_PATH | default('') | length == 0) or + (DOCKER_COMPOSE_SERVICE_CONFIG_PATH is match('^/') and + '..' not in (DOCKER_COMPOSE_SERVICE_CONFIG_PATH.split('/'))) + + # Manifest destination + the manifest/compose-path invariant. + - DOCKER_COMPOSE_SERVICE_MANIFEST is mapping + - DOCKER_COMPOSE_SERVICE_MANIFEST.dest is defined + - DOCKER_COMPOSE_SERVICE_MANIFEST.dest is string + - DOCKER_COMPOSE_SERVICE_MANIFEST.dest | length > 0 + - DOCKER_COMPOSE_SERVICE_MANIFEST.dest is match('^/') + - "'..' not in (DOCKER_COMPOSE_SERVICE_MANIFEST.dest.split('/'))" + - DOCKER_COMPOSE_SERVICE_MANIFEST.dest == + (DOCKER_COMPOSE_SERVICE_COMPOSE_PATH ~ '/docker-compose.yml') + fail_msg: >- + One or more role inputs failed validation. Required: NAME matches + ^[a-z0-9][a-z0-9_-]*$; COMPOSE_PATH, SYSTEMD_FILE, MANIFEST.dest absolute + and free of '..'; MANIFEST.dest must equal + "{{ DOCKER_COMPOSE_SERVICE_COMPOSE_PATH }}/docker-compose.yml". + quiet: true + +- name: Validate Additional Paths + ansible.builtin.assert: + that: + - item.path is defined + - item.path is string + - item.path | length > 0 + - item.path is match('^/') + - "'..' not in (item.path.split('/'))" + fail_msg: >- + DOCKER_COMPOSE_SERVICE_ADDITIONAL_PATHS entry is invalid: path must be a + non-empty absolute string with no '..' segments. Offending entry: {{ item }} + quiet: true + loop: "{{ DOCKER_COMPOSE_SERVICE_ADDITIONAL_PATHS | default([]) }}" + loop_control: + label: "{{ item.path | default(item) }}" + +- name: Validate Template Destinations + ansible.builtin.assert: + that: + - item.dest is defined + - item.dest is string + - item.dest | length > 0 + - item.dest is match('^/') + - "'..' not in (item.dest.split('/'))" + fail_msg: >- + DOCKER_COMPOSE_SERVICE_TEMPLATES entry is invalid: dest must be a + non-empty absolute string with no '..' segments. Offending entry: {{ item }} + quiet: true + loop: "{{ DOCKER_COMPOSE_SERVICE_TEMPLATES | default([]) }}" + loop_control: + label: "{{ item.dest | default(item) }}" + +- name: Validate File Destinations + ansible.builtin.assert: + that: + - item.dest is defined + - item.dest is string + - item.dest | length > 0 + - item.dest is match('^/') + - "'..' not in (item.dest.split('/'))" + fail_msg: >- + DOCKER_COMPOSE_SERVICE_FILES entry is invalid: dest must be a non-empty + absolute string with no '..' segments. Offending entry: {{ item }} + quiet: true + loop: "{{ DOCKER_COMPOSE_SERVICE_FILES | default([]) }}" + loop_control: + label: "{{ item.dest | default(item) }}" + +# R2 -- Hoisted so both create and delete code paths have the Python SDK +# available. The docker_image_mgmt_plan module imports `docker` at module-load +# time; without the SDK the delete path also fails. +- name: Install Python Docker Library + ansible.builtin.package: + name: "{{ DOCKER_COMPOSE_SERVICE_PYTHON_DOCKER_PACKAGE }}" + state: present + when: DOCKER_COMPOSE_SERVICE_INSTALL_PYTHON_DOCKER | bool + - name: Create/Update Service ansible.builtin.include_tasks: file: create_update_service.yml diff --git a/collections/nixknight/docker/roles/docker-compose-service/tasks/pull_remove_images.yml b/collections/nixknight/docker/roles/docker-compose-service/tasks/pull_remove_images.yml index 1b7b604..e3c40aa 100644 --- a/collections/nixknight/docker/roles/docker-compose-service/tasks/pull_remove_images.yml +++ b/collections/nixknight/docker/roles/docker-compose-service/tasks/pull_remove_images.yml @@ -1,10 +1,16 @@ --- +# R9: register `pull_container_images` so the restart logic in +# create_update_service.yml can react to mutable-tag refreshes. `force_source` +# ensures the pull executes when the planner schedules one (e.g. for the +# force-refresh path). - name: Pull New Container Images community.docker.docker_image: name: "{{ item }}" source: pull state: present + force_source: true with_items: "{{ container_images_to_pull }}" + register: pull_container_images when: container_images_to_pull | length > 0 - name: Remove Old Container Images @@ -12,4 +18,3 @@ name: "{{ item }}" state: absent with_items: "{{ container_images_to_remove }}" - when: service_needs_to_stop diff --git a/collections/nixknight/docker/roles/docker-compose-service/templates/docker-compose.yml.j2 b/collections/nixknight/docker/roles/docker-compose-service/templates/docker-compose.yml.j2 index 9bc1b3c..977768b 100644 --- a/collections/nixknight/docker/roles/docker-compose-service/templates/docker-compose.yml.j2 +++ b/collections/nixknight/docker/roles/docker-compose-service/templates/docker-compose.yml.j2 @@ -1,2 +1,6 @@ --- +# WARNING: this file is rendered from DOCKER_COMPOSE_SERVICE_MANIFEST.content via +# `to_nice_yaml`. Operators feeding untrusted values into the manifest MUST use +# Ansible's `!unsafe` markers so the upstream Jinja resolver does not interpret +# them as templates. See the role README, "Operational notes" section. {{ DOCKER_COMPOSE_SERVICE_MANIFEST.content | to_nice_yaml(indent=2, sort_keys=false) }} diff --git a/collections/nixknight/docker/roles/docker-compose-service/templates/etc/systemd/system/docker-compose.service.j2 b/collections/nixknight/docker/roles/docker-compose-service/templates/etc/systemd/system/docker-compose.service.j2 index 03c16da..9a5a119 100644 --- a/collections/nixknight/docker/roles/docker-compose-service/templates/etc/systemd/system/docker-compose.service.j2 +++ b/collections/nixknight/docker/roles/docker-compose-service/templates/etc/systemd/system/docker-compose.service.j2 @@ -7,10 +7,12 @@ After={{ DOCKER_COMPOSE_SERVICE_SYSTEMD_AFTER }} Type=oneshot RemainAfterExit=yes WorkingDirectory={{ DOCKER_COMPOSE_SERVICE_COMPOSE_PATH }} -{% if DOCKER_COMPOSE_SERVICE_SET_GROUP_ID %}Environment="DOCKER_GID={{ docker_gid.stdout }}" +{% if DOCKER_COMPOSE_SERVICE_SET_GROUP_ID %}Environment="DOCKER_GID={{ docker_gid }}" {% endif %} -ExecStartPre=/usr/bin/docker compose -p {{ DOCKER_COMPOSE_SERVICE_NAME }} down +TimeoutStartSec={{ DOCKER_COMPOSE_SERVICE_TIMEOUT_START }} +TimeoutStopSec={{ DOCKER_COMPOSE_SERVICE_TIMEOUT_STOP }} ExecStart=/usr/bin/docker compose -p {{ DOCKER_COMPOSE_SERVICE_NAME }} up -d +ExecReload=/usr/bin/docker compose -p {{ DOCKER_COMPOSE_SERVICE_NAME }} up -d --remove-orphans ExecStop=/usr/bin/docker compose -p {{ DOCKER_COMPOSE_SERVICE_NAME }} down [Install] From 9a1205d6774b2379f95646299a1f1011484d695a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 11:17:36 +0000 Subject: [PATCH 2/2] chore: bump docker collection version to 0.1.5 --- README.md | 2 +- collections/nixknight/docker/galaxy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f694757..8b31a01 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Install individual collections from GitHub: ```bash # Docker collection -ansible-galaxy collection install git+https://github.com/NIXKnight/Ansible-Collections.git#/collections/nixknight/docker,docker-0.1.4 +ansible-galaxy collection install git+https://github.com/NIXKnight/Ansible-Collections.git#/collections/nixknight/docker,docker-0.1.5 # General collection ansible-galaxy collection install git+https://github.com/NIXKnight/Ansible-Collections.git#/collections/nixknight/general,general-0.1.0 diff --git a/collections/nixknight/docker/galaxy.yml b/collections/nixknight/docker/galaxy.yml index 41d9e49..6d34df3 100644 --- a/collections/nixknight/docker/galaxy.yml +++ b/collections/nixknight/docker/galaxy.yml @@ -1,7 +1,7 @@ --- namespace: "nixknight" name: "docker" -version: "0.1.4" +version: "0.1.5" readme: "README.md" authors: - "Saad Ali "