Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 55 additions & 7 deletions .github/workflows/ansible.yml
Original file line number Diff line number Diff line change
@@ -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"
26 changes: 26 additions & 0 deletions .github/workflows/workflow-lint.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
repos:
- repo: https://github.com/rhysd/actionlint
rev: v1.7.10
hooks:
- id: actionlint
13 changes: 6 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,23 @@
## 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

- `playbooks/` contains runnable entrypoints; `roles/` contains reusable service configuration. Inventory-derived variables are in `playbooks/group_vars/` and `playbooks/host_vars/`.
- 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

Expand All @@ -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`.
5 changes: 2 additions & 3 deletions ansible.cfg
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
[defaults]
vault_password_file = ./vault_pass.sh
roles_path = ./roles
[defaults]
roles_path = ./roles

interpreter_python = auto_silent

Expand Down
5 changes: 5 additions & 0 deletions molecule/dawarich/molecule.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
driver:
name: docker

ansible:
cfg:
defaults:
roles_path: ${MOLECULE_PROJECT_DIRECTORY}/roles

scenario:
test_sequence:
- destroy
Expand Down
12 changes: 8 additions & 4 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ssh-key here>`
`ansible-playbook -i ../netbox-ansible/netbox.yml playbooks/<playbook>.yml --tags <tags> --diff <--check>` (vault pass is handled by 1password cli)
`ansible-playbook -i ../netbox-ansible/netbox.yml playbooks/<playbook>.yml --tags <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
Expand All @@ -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


27 changes: 14 additions & 13 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions requirements.yml
Original file line number Diff line number Diff line change
@@ -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
107 changes: 107 additions & 0 deletions scripts/validate_vault.py
Original file line number Diff line number Diff line change
@@ -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())
Loading