diff --git a/.github/workflows/ansible.yml b/.github/workflows/ansible.yml index dd945e1..8fe8a5c 100644 --- a/.github/workflows/ansible.yml +++ b/.github/workflows/ansible.yml @@ -1,31 +1,79 @@ --- +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Ansible validation on: - pull_request: true + pull_request: {} push: branches: - main +permissions: + contents: read + jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - python-version: '3.x' + python-version: '3.13' - name: Install Ansible tooling - run: python -m pip install --requirement requirements.txt molecule molecule-docker + run: python -m pip install --requirement requirements.txt - name: Install Ansible collections run: ansible-galaxy collection install --requirements-file requirements.yml - name: Lint YAML run: yamllint . - name: Lint Ansible run: ansible-lint - - name: Syntax check Dawarich playbook - run: ansible-playbook --syntax-check playbooks/dawarich.yml + - name: Syntax check playbooks + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + playbooks=(playbooks/*.yml playbooks/*.yaml) + if (( ${#playbooks[@]} == 0 )); then + echo "No playbooks found." >&2 + exit 1 + fi + for playbook in "${playbooks[@]}"; do + ansible-playbook --syntax-check --inventory 'localhost,' "$playbook" + done - name: Test Dawarich role env: ANSIBLE_ALLOW_BROKEN_CONDITIONALS: 'true' run: molecule test --scenario-name dawarich + + validate-vault: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: validate + runs-on: ubuntu-latest + environment: ansible-validation + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.13' + - name: Install Ansible tooling + run: python -m pip install --requirement requirements.txt + - name: Create temporary Vault password file + shell: bash + env: + ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }} + run: | + set -euo pipefail + if [[ -z "$ANSIBLE_VAULT_PASSWORD" ]]; then + echo "ANSIBLE_VAULT_PASSWORD is not configured for the ansible-validation environment." >&2 + exit 1 + fi + umask 077 + printf '%s' "$ANSIBLE_VAULT_PASSWORD" > "$RUNNER_TEMP/ansible-vault-password" + - name: Validate Vault decryption + env: + ANSIBLE_VAULT_PASSWORD_FILE: ${{ runner.temp }}/ansible-vault-password + run: python scripts/validate_vault.py + - name: Remove temporary Vault password file + if: always() + shell: bash + run: rm -f "$RUNNER_TEMP/ansible-vault-password" diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml new file mode 100644 index 0000000..db71e2b --- /dev/null +++ b/.github/workflows/workflow-lint.yml @@ -0,0 +1,26 @@ +--- +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +name: GitHub workflow validation + +on: + pull_request: + paths: + - .github/workflows/** + push: + branches: + - main + paths: + - .github/workflows/** + +permissions: + contents: read + +jobs: + actionlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Validate GitHub Actions workflows + uses: docker://rhysd/actionlint@sha256:ef8299f97635c4c30e2298f48f30763ab782a4ad2c95b744649439a039421e36 + with: + args: -color diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..bc20e04 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +--- +repos: + - repo: https://github.com/rhysd/actionlint + rev: v1.7.10 + hooks: + - id: actionlint diff --git a/AGENTS.md b/AGENTS.md index f40935d..7c55439 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,15 +3,15 @@ ## Persona And Workflow - Act as an expert DevOps engineer focused on Ansible roles that deploy and manage Docker containers. Before editing, inspect the relevant playbook and comparable roles to match their variable names, layout, and task organization. -- Never commit directly to `main` or `master`. Branch from `master` using descriptive names such as `feature/add-nginx-role` or `fix/db-container-restart`; use Conventional Commits and submit all changes through a pull or merge request with a concise review summary. +- Never commit directly to `main`. Branch from `main` using descriptive names such as `feature/add-nginx-role` or `fix/db-container-restart`; use Conventional Commits and submit all changes through a pull or merge request with a concise review summary. ## Automation Boundary - Never execute code against the live environment. Do not run `ansible-playbook` with the external inventory, including `--check`, and do not use credentials or SSH to access managed hosts. - Write and modify roles, playbooks, CI pipelines, and tests only. Live execution and deployment are the operator's responsibility. -- Run Ansible tooling from the repository root when performing local-only validation: `ansible.cfg` resolves `roles_path = ./roles` and `vault_password_file = ./vault_pass.sh` relatively. +- Run Ansible tooling from the repository root when performing local-only validation: `ansible.cfg` resolves `roles_path = ./roles` relatively. Lint, syntax checks, and Molecule must not require Vault decryption. - Inventory is external: `../netbox-ansible/netbox.yml`; this repository does not contain a local inventory. -- The vault password script requires the 1Password CLI and the `Ansible Vault Password` item. Do not retrieve it. Keep existing `!vault` values encrypted; create new values with `ansible-vault encrypt_string` only when the operator provides the required vault access. +- The vault password script requires the 1Password CLI and the `Ansible Vault Password` item. Operators must explicitly pass `--vault-password-file ./vault_pass.sh` when decrypting or modifying Vault values. Do not retrieve it. Keep existing `!vault` values encrypted; create new values with `ansible-vault encrypt_string` only when the operator provides the required vault access. ## Layout And Safety @@ -19,7 +19,7 @@ - Tags are the intended way to select roles and role components. `common.yml` requires `--ask-become-pass --ask-pass` for an initial host run. - `patch.yml` deliberately rolls Debian hosts out at 30%, 60%, then 100%, and Proxmox one host at a time; preserve that rollout behavior. - Firewall changes are high impact: the role validates `/etc/nftables.conf` with `nft --check` before applying it only when templates changed. -- No repository-defined CI or automated test suite exists. `ansible-lint` and `yamllint` are available through `requirements.txt`, but no project-specific invocation is configured. +- GitHub Actions runs secret-free lint, syntax, and Molecule validation for pull requests. The `ansible-validation` environment must restrict deployments to `main` and supply `ANSIBLE_VAULT_PASSWORD` only to trusted pushes for decryption validation. It must not require approval because CI is non-interactive. ## Roles And Containers @@ -35,6 +35,5 @@ - Molecule scenarios must pause after convergence so the agent can inspect the deployed containers' logs for runtime errors and warnings. A container being running or reporting healthy is not sufficient; investigate and address warnings as well as failures before considering the scenario successful. - Where possible, Molecule scenarios must include basic application-level tests in addition to idempotency and container configuration checks. - During local Molecule debugging, temporarily override container `state: healthy` with `state: started` so convergence returns quickly and container logs can be inspected before adapting the role. Restore health-state assertions before merging. -- Run `yamllint` and `ansible-lint` for changed Ansible and YAML files; use two-space YAML indentation and no trailing whitespace. -- CI changes must lint modified roles, run `ansible-playbook --syntax-check` for playbooks using the modified role, and run `molecule test` in an ephemeral environment. Require a green pipeline before merging to `master`. -- Since no CI configuration exists, add the required pipeline configuration as part of feature work that introduces or changes a role. +- Run `pre-commit run --all-files`, `yamllint`, and `ansible-lint` for changed files; use two-space YAML indentation and no trailing whitespace. +- CI changes must lint modified roles, run `ansible-playbook --syntax-check` for playbooks using the modified role, and run `molecule test` in an ephemeral environment. Require a green pipeline before merging to `main`. diff --git a/ansible.cfg b/ansible.cfg index 4ce260e..509aa2c 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -1,6 +1,5 @@ -[defaults] -vault_password_file = ./vault_pass.sh -roles_path = ./roles +[defaults] +roles_path = ./roles interpreter_python = auto_silent diff --git a/molecule/dawarich/molecule.yml b/molecule/dawarich/molecule.yml index 1a9d24c..346483a 100644 --- a/molecule/dawarich/molecule.yml +++ b/molecule/dawarich/molecule.yml @@ -2,6 +2,11 @@ driver: name: docker +ansible: + cfg: + defaults: + roles_path: ${MOLECULE_PROJECT_DIRECTORY}/roles + scenario: test_sequence: - destroy diff --git a/readme.md b/readme.md index f46222a..8879c40 100644 --- a/readme.md +++ b/readme.md @@ -22,12 +22,18 @@ As a network engineer I prefer to segregate my network a bit more than normally ### Running a playbook ``eval `ssh-agent` `` `ssh-add ` -`ansible-playbook -i ../netbox-ansible/netbox.yml playbooks/.yml --tags --diff <--check>` (vault pass is handled by 1password cli) +`ansible-playbook -i ../netbox-ansible/netbox.yml playbooks/.yml --tags --diff --vault-password-file ./vault_pass.sh [--check]` (vault pass is handled by the 1Password CLI) ### Encrypting passwords -`ansible-vault encrypt_string` +`ansible-vault encrypt_string --vault-password-file ./vault_pass.sh` Next enter your variable to encrypt and press ctrl+d twice. +### Validating GitHub Actions workflows +GitHub Actions workflow files use the GitHub workflow schema in VS Code and are checked by a dedicated actionlint workflow. Install the repository hook once with `pre-commit install`, then run all local checks with `pre-commit run --all-files`. Actionlint can also run directly with `docker run --rm --volume "$PWD:/repo" --workdir /repo rhysd/actionlint:1.7.10`. + +### GitHub Actions Vault validation +Create a GitHub environment named `ansible-validation`, restrict its deployment branches to `main`, and add an environment secret named `ANSIBLE_VAULT_PASSWORD` containing only the raw Ansible Vault password. Do not add an approval rule if validation must remain non-interactive. Trusted pushes to `main` run `python scripts/validate_vault.py` after lint, syntax, and Molecule checks succeed. Pull requests never receive the Vault password. + ## Servicer Specific Info ### DNS @@ -41,5 +47,3 @@ DNS is running in multiple stages. ### Matrix Setup - nginx serves frai.se => /.well-known/matrix/server redirects to https://matrix.frai.se:443 - matrix.frai.se directs directly towards the matrix server - - diff --git a/requirements.txt b/requirements.txt index 128929a..83a6f67 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,14 @@ -ansible -ansible-lint -yamllint -passlib # needed for macos hashing and encryption -pymongo # needed to create databases -psycopg2-binary # needed to create postgres database -pywinrm # needed to manage windows hosts -netaddr # needed for ansible.utils.ipaddr -bcrypt # needed for adguard password hash -proxmoxer # needed for proxmox modules -jmespath # json_query -molecule -molecule-docker +ansible==13.6.0 +ansible-lint==26.4.0 +yamllint==1.38.0 +passlib==1.7.4 # needed for macos hashing and encryption +pymongo==4.17.0 # needed to create databases +psycopg2-binary==2.9.12 # needed to create postgres database +pywinrm==0.5.0 # needed to manage windows hosts +netaddr==1.3.0 # needed for ansible.utils.ipaddr +bcrypt==4.0.1 # needed for adguard password hash +proxmoxer==2.3.0 # needed for proxmox modules +jmespath==1.1.0 # json_query +molecule==26.6.0 +molecule-docker==2.1.0 +pre-commit==4.6.0 diff --git a/requirements.yml b/requirements.yml index 44a6229..4bee992 100644 --- a/requirements.yml +++ b/requirements.yml @@ -1,6 +1,10 @@ --- collections: - name: community.docker + version: 5.2.0 - name: community.general + version: 12.6.0 - name: ansible.posix + version: 2.1.0 - name: ansible.utils + version: 6.0.2 diff --git a/scripts/validate_vault.py b/scripts/validate_vault.py new file mode 100644 index 0000000..4334722 --- /dev/null +++ b/scripts/validate_vault.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Validate every Ansible Vault payload without templating variable values.""" + +from __future__ import annotations + +import argparse +import os +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path + +from ansible.parsing.dataloader import DataLoader +from ansible.parsing.vault import VaultLib, VaultSecret, is_encrypted + + +VAULT_HEADER = b"$ANSIBLE_VAULT;" +YAML_SUFFIXES = {".yaml", ".yml"} +EXCLUDED_PARTS = {".ansible", ".git", ".venv"} + + +def encrypted_payloads(value: object): + """Yield ciphertext from parsed Vault-tagged scalars without rendering Jinja.""" + ciphertext = getattr(value, "_ciphertext", None) + if ciphertext is not None and is_encrypted(ciphertext): + yield ciphertext + return + + if isinstance(value, Mapping): + for key, item in value.items(): + yield from encrypted_payloads(key) + yield from encrypted_payloads(item) + elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + for item in value: + yield from encrypted_payloads(item) + + +def candidate_files(root: Path): + for path in root.rglob("*"): + if not path.is_file() or EXCLUDED_PARTS.intersection(path.parts): + continue + + contents = path.read_bytes() + if contents.lstrip().startswith(VAULT_HEADER) or ( + path.suffix.lower() in YAML_SUFFIXES and VAULT_HEADER in contents + ): + yield path, contents + + +def validate_file(path: Path, contents: bytes, vault: VaultLib) -> int: + expected = contents.count(VAULT_HEADER) + if contents.lstrip().startswith(VAULT_HEADER): + vault.decrypt(contents.strip()) + return 1 + + parsed = DataLoader().load_from_file(str(path)) + payloads = list(encrypted_payloads(parsed)) + if len(payloads) != expected: + raise ValueError( + f"found {expected} Vault header(s), but parsed {len(payloads)} encrypted value(s)" + ) + + for payload in payloads: + vault.decrypt(payload) + return len(payloads) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument( + "--password-file", + type=Path, + default=os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE"), + ) + args = parser.parse_args() + + if args.password_file is None: + parser.error("--password-file or ANSIBLE_VAULT_PASSWORD_FILE is required") + + password = args.password_file.read_bytes().rstrip(b"\r\n") + if not password: + parser.error("the Vault password file is empty") + + vault = VaultLib([("default", VaultSecret(password))]) + files = list(candidate_files(args.root.resolve())) + if not files: + print("No Ansible Vault payloads found.", file=sys.stderr) + return 1 + + decrypted = 0 + failures = 0 + for path, contents in files: + try: + decrypted += validate_file(path, contents, vault) + except Exception as error: # Ansible exposes several version-specific Vault errors. + failures += 1 + print(f"{path.relative_to(args.root.resolve())}: {error}", file=sys.stderr) + + if failures: + return 1 + + print(f"Validated {decrypted} Vault value(s) in {len(files)} file(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())