From d3f84ae6880d291e4b30f990ed598f756d6ada2b Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 25 Sep 2026 23:08:24 +0200 Subject: [PATCH 1/6] fix(ansible): declare missing collections and correct task definitions Found by the new PR validation gate; all four are latent rather than currently-failing problems. community.docker (34 call sites) and community.general (3) are used by the role tree but appeared in no requirements.yml. They resolved only as a transitive dependency of geerlingguy.docker, which declares no dependencies of its own, so a clean runner would have failed ansible-lint's syntax check. Both are now pinned explicitly in every host's requirements.yml. mx1's Nginx Configuration task set `become: true` twice in the same mapping. YAML keeps the last occurrence, so the block ran with no privilege escalation at all. The block-level key is now the single source of truth. The CrowdSec config.yaml template set no file mode, leaving permissions to the umask. Pinned to 0644, matching the role's own keyring. Commands that genuinely change state now say so. `changed_when: true` is already the default for command/shell, so these are declarations of existing behaviour rather than changes, and they clear the no-changed-when rule without lying about idempotency. The apt role's `== true` comparisons are dropped; stat.exists is already a boolean. --- mirror/ansible/requirements.yml | 5 + .../ansible/roles/system/apt/tasks/main.yml | 13 +- .../roles/system/config/handlers/main.yml | 1 + .../roles/system/migrations/tasks/main.yml | 34 ++-- mx1/ansible/requirements.yml | 5 + mx1/ansible/roles/system/apt/tasks/main.yml | 25 +-- .../roles/system/config/tasks/main.yml | 166 +++++++++--------- .../roles/system/migrations/tasks/main.yml | 34 ++-- tower/ansible/requirements.yml | 5 + tower/ansible/roles/system/apt/tasks/main.yml | 13 +- .../roles/system/migrations/tasks/main.yml | 34 ++-- web1/ansible/requirements.yml | 5 + web1/ansible/roles/system/apt/tasks/main.yml | 21 +-- .../roles/system/crowdsec/tasks/main.yml | 51 +++--- .../roles/system/migrations/tasks/main.yml | 34 ++-- .../system/php/tasks/_build_php_extension.yml | 5 +- web2/ansible/requirements.yml | 5 + web2/ansible/roles/system/apt/tasks/main.yml | 21 +-- .../roles/system/crowdsec/tasks/main.yml | 45 ++--- .../roles/system/migrations/tasks/main.yml | 34 ++-- web3/ansible/requirements.yml | 5 + web3/ansible/roles/system/apt/tasks/main.yml | 21 +-- .../roles/system/crowdsec/tasks/main.yml | 51 +++--- .../roles/system/migrations/tasks/main.yml | 34 ++-- .../system/php/tasks/_build_php_extension.yml | 5 +- 25 files changed, 366 insertions(+), 306 deletions(-) diff --git a/mirror/ansible/requirements.yml b/mirror/ansible/requirements.yml index c542ec9c..6d5eeaf3 100644 --- a/mirror/ansible/requirements.yml +++ b/mirror/ansible/requirements.yml @@ -1,4 +1,9 @@ +--- collections: + - name: community.docker + version: "5.3.0" + - name: community.general + version: "13.4.0" - name: devsec.hardening version: "10.6.0" roles: diff --git a/mirror/ansible/roles/system/apt/tasks/main.yml b/mirror/ansible/roles/system/apt/tasks/main.yml index be2b703d..3962f39a 100644 --- a/mirror/ansible/roles/system/apt/tasks/main.yml +++ b/mirror/ansible/roles/system/apt/tasks/main.yml @@ -1,9 +1,10 @@ +--- - name: Update apt cache, dist-upgrade, and autoremove ansible.builtin.apt: - update_cache: yes + update_cache: true upgrade: dist - autoremove: yes - purge: yes + autoremove: true + purge: true - name: Install base packages ansible.builtin.apt: @@ -12,10 +13,10 @@ state: present - name: Check if reboot required - stat: + ansible.builtin.stat: path: /var/run/reboot-required register: reboot_required_file - name: Reboot if required - reboot: - when: reboot_required_file.stat.exists == true + ansible.builtin.reboot: + when: reboot_required_file.stat.exists diff --git a/mirror/ansible/roles/system/config/handlers/main.yml b/mirror/ansible/roles/system/config/handlers/main.yml index fdd98974..07bfbdac 100644 --- a/mirror/ansible/roles/system/config/handlers/main.yml +++ b/mirror/ansible/roles/system/config/handlers/main.yml @@ -4,6 +4,7 @@ iptables-save > /etc/iptables/rules.v4 ip6tables-save > /etc/iptables/rules.v6 become: true + changed_when: true - name: Restart systemd-resolved ansible.builtin.systemd_service: diff --git a/mirror/ansible/roles/system/migrations/tasks/main.yml b/mirror/ansible/roles/system/migrations/tasks/main.yml index 5d801ce6..addef147 100644 --- a/mirror/ansible/roles/system/migrations/tasks/main.yml +++ b/mirror/ansible/roles/system/migrations/tasks/main.yml @@ -1,61 +1,63 @@ --- - name: Ensure sqlite3 is installed - package: + ansible.builtin.package: name: sqlite3 state: present - name: Ensure /opt/ansible directory exists - file: + ansible.builtin.file: path: /opt/ansible state: directory - mode: '0755' + mode: "0755" - name: Ensure migration state database exists - command: sqlite3 {{ migration_state_file }} "CREATE TABLE IF NOT EXISTS migrations (id TEXT PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP);" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "CREATE TABLE IF NOT EXISTS migrations (id TEXT PRIMARY KEY, applied_at DATETIME DEFAULT + CURRENT_TIMESTAMP);" args: creates: "{{ migration_state_file }}" - name: Get applied migrations - command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" register: applied_migrations_output changed_when: false - name: Parse applied migrations - set_fact: + ansible.builtin.set_fact: applied_migrations: "{{ applied_migrations_output.stdout_lines | select | list }}" - name: Show applied migrations - debug: + ansible.builtin.debug: msg: "Already applied migrations: {{ applied_migrations }}" verbosity: 1 no_log: false - name: Find migration files - find: + ansible.builtin.find: paths: "{{ migration_dir }}" patterns: "*.yml" - recurse: no + recurse: false delegate_to: localhost register: migration_files - name: Extract migration IDs from files - set_fact: + ansible.builtin.set_fact: pending_migrations: | {{ migration_files.files | map(attribute='path') | map('basename') | map('regex_replace', '\.yml$', '') | reject('in', applied_migrations) | list }} - name: Show pending migrations - debug: + ansible.builtin.debug: msg: "Pending migrations to apply: {{ pending_migrations }}" verbosity: 1 no_log: false - name: Run pending migrations - include_tasks: "{{ migration_dir }}/{{ item }}.yml" + ansible.builtin.include_tasks: "{{ migration_dir }}/{{ item }}.yml" loop: "{{ pending_migrations }}" register: migration_results - name: Record applied migrations - command: sqlite3 {{ migration_state_file }} "INSERT INTO migrations (id) VALUES ('{{ item }}');" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "INSERT INTO migrations (id) VALUES ('{{ item }}');" + changed_when: true loop: "{{ pending_migrations }}" loop_control: index_var: migration_index @@ -65,12 +67,12 @@ - not (migration_results.results[migration_index].failed | default(false)) - name: Verify migrations were recorded - command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" register: final_migrations changed_when: false - name: Show all recorded migrations - debug: + ansible.builtin.debug: msg: "All recorded migrations: {{ final_migrations.stdout_lines }}" verbosity: 1 - no_log: false \ No newline at end of file + no_log: false diff --git a/mx1/ansible/requirements.yml b/mx1/ansible/requirements.yml index 3b3c0bbb..1eaa61b3 100644 --- a/mx1/ansible/requirements.yml +++ b/mx1/ansible/requirements.yml @@ -1,4 +1,9 @@ +--- collections: + - name: community.docker + version: "5.3.0" + - name: community.general + version: "13.4.0" - name: devsec.hardening version: "10.6.0" roles: diff --git a/mx1/ansible/roles/system/apt/tasks/main.yml b/mx1/ansible/roles/system/apt/tasks/main.yml index 2268de27..48e45c80 100644 --- a/mx1/ansible/roles/system/apt/tasks/main.yml +++ b/mx1/ansible/roles/system/apt/tasks/main.yml @@ -1,9 +1,10 @@ +--- - name: Update apt cache, dist-upgrade, and autoremove ansible.builtin.apt: - update_cache: yes + update_cache: true upgrade: dist - autoremove: yes - purge: yes + autoremove: true + purge: true - name: Install packages ansible.builtin.apt: @@ -14,28 +15,28 @@ state: present - name: Enable and start qemu-guest-agent - service: + ansible.builtin.service: name: qemu-guest-agent - enabled: yes + enabled: true state: started - name: Enable fstrim timer - service: + ansible.builtin.service: name: fstrim.timer - enabled: yes + enabled: true state: started - name: Ensure nginx is enabled and started - service: + ansible.builtin.service: name: nginx - enabled: yes + enabled: true state: started - name: Check if reboot required - stat: + ansible.builtin.stat: path: /var/run/reboot-required register: reboot_required_file - name: Reboot if required - reboot: - when: reboot_required_file.stat.exists == true + ansible.builtin.reboot: + when: reboot_required_file.stat.exists diff --git a/mx1/ansible/roles/system/config/tasks/main.yml b/mx1/ansible/roles/system/config/tasks/main.yml index cca16585..6166a7a4 100644 --- a/mx1/ansible/roles/system/config/tasks/main.yml +++ b/mx1/ansible/roles/system/config/tasks/main.yml @@ -1,101 +1,101 @@ --- - name: DNS block: - - name: Ensure systemd-resolved is running - ansible.builtin.systemd_service: - name: systemd-resolved - state: started - enabled: yes - become: true + - name: Ensure systemd-resolved is running + ansible.builtin.systemd_service: + name: systemd-resolved + state: started + enabled: true + become: true - - name: Configure systemd-resolved for Google and Quad9 DNS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?DNS=' - line: 'DNS=8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for Google and Quad9 DNS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?DNS=" + line: "DNS=8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for ControlD and Cloudflare Fallback DNS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?FallbackDNS=' - line: 'FallbackDNS=76.76.2.0 76.76.10.0 1.1.1.1 1.0.0.1' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for ControlD and Cloudflare Fallback DNS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?FallbackDNS=" + line: "FallbackDNS=76.76.2.0 76.76.10.0 1.1.1.1 1.0.0.1" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for DNSOverTLS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?DNSOverTLS=' - line: 'DNSOverTLS=no' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for DNSOverTLS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?DNSOverTLS=" + line: "DNSOverTLS=no" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for Cache - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?Cache=' - line: 'Cache=yes' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for Cache + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?Cache=" + line: "Cache=yes" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for Domain - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?Domain=' - line: "Domain={{ dns_search_domain }}" - when: dns_search_domain | default('') | length > 0 - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for Domain + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?Domain=" + line: "Domain={{ dns_search_domain }}" + when: dns_search_domain | default('') | length > 0 + notify: Restart systemd-resolved + become: true - name: Nginx Configuration + become: true block: - - name: Ensure Nginx modules are loaded - lineinfile: - path: /etc/nginx/nginx.conf - line: "include /etc/nginx/modules-enabled/*.conf;" - insertbefore: "^events" - notify: Restart Nginx + - name: Ensure Nginx modules are loaded + ansible.builtin.lineinfile: + path: /etc/nginx/nginx.conf + line: "include /etc/nginx/modules-enabled/*.conf;" + insertbefore: "^events" + notify: Restart Nginx - - name: Copy Stalwart stream configuration - template: - src: stalwart.conf.j2 - dest: /etc/nginx/stalwart.conf - owner: root - group: root - mode: '0644' - notify: Restart Nginx + - name: Copy Stalwart stream configuration + ansible.builtin.template: + src: stalwart.conf.j2 + dest: /etc/nginx/stalwart.conf + owner: root + group: root + mode: "0644" + notify: Restart Nginx - - name: Include Stalwart stream config in nginx.conf - lineinfile: - path: /etc/nginx/nginx.conf - line: "include /etc/nginx/stalwart.conf;" - insertafter: EOF - notify: Restart Nginx - become: true + - name: Include Stalwart stream config in nginx.conf + ansible.builtin.lineinfile: + path: /etc/nginx/nginx.conf + line: "include /etc/nginx/stalwart.conf;" + insertafter: EOF + notify: Restart Nginx - name: Logrotate block: - - name: Remove obsolete Docker logrotate configuration - ansible.builtin.file: - path: /etc/logrotate.d/docker - state: absent - become: true + - name: Remove obsolete Docker logrotate configuration + ansible.builtin.file: + path: /etc/logrotate.d/docker + state: absent + become: true - - name: Create logrotate.timer.d directory - ansible.builtin.file: - path: /etc/systemd/system/logrotate.timer.d - state: directory - mode: '0755' - become: true + - name: Create logrotate.timer.d directory + ansible.builtin.file: + path: /etc/systemd/system/logrotate.timer.d + state: directory + mode: "0755" + become: true - - name: Override logrotate.timer - ansible.builtin.copy: - src: files/logrotate.timer.d/override.conf - dest: /etc/systemd/system/logrotate.timer.d/override.conf - owner: root - group: root - mode: '0644' - notify: Restart logrotate.timer - become: true + - name: Override logrotate.timer + ansible.builtin.copy: + src: files/logrotate.timer.d/override.conf + dest: /etc/systemd/system/logrotate.timer.d/override.conf + owner: root + group: root + mode: "0644" + notify: Restart logrotate.timer + become: true diff --git a/mx1/ansible/roles/system/migrations/tasks/main.yml b/mx1/ansible/roles/system/migrations/tasks/main.yml index 5d801ce6..addef147 100644 --- a/mx1/ansible/roles/system/migrations/tasks/main.yml +++ b/mx1/ansible/roles/system/migrations/tasks/main.yml @@ -1,61 +1,63 @@ --- - name: Ensure sqlite3 is installed - package: + ansible.builtin.package: name: sqlite3 state: present - name: Ensure /opt/ansible directory exists - file: + ansible.builtin.file: path: /opt/ansible state: directory - mode: '0755' + mode: "0755" - name: Ensure migration state database exists - command: sqlite3 {{ migration_state_file }} "CREATE TABLE IF NOT EXISTS migrations (id TEXT PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP);" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "CREATE TABLE IF NOT EXISTS migrations (id TEXT PRIMARY KEY, applied_at DATETIME DEFAULT + CURRENT_TIMESTAMP);" args: creates: "{{ migration_state_file }}" - name: Get applied migrations - command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" register: applied_migrations_output changed_when: false - name: Parse applied migrations - set_fact: + ansible.builtin.set_fact: applied_migrations: "{{ applied_migrations_output.stdout_lines | select | list }}" - name: Show applied migrations - debug: + ansible.builtin.debug: msg: "Already applied migrations: {{ applied_migrations }}" verbosity: 1 no_log: false - name: Find migration files - find: + ansible.builtin.find: paths: "{{ migration_dir }}" patterns: "*.yml" - recurse: no + recurse: false delegate_to: localhost register: migration_files - name: Extract migration IDs from files - set_fact: + ansible.builtin.set_fact: pending_migrations: | {{ migration_files.files | map(attribute='path') | map('basename') | map('regex_replace', '\.yml$', '') | reject('in', applied_migrations) | list }} - name: Show pending migrations - debug: + ansible.builtin.debug: msg: "Pending migrations to apply: {{ pending_migrations }}" verbosity: 1 no_log: false - name: Run pending migrations - include_tasks: "{{ migration_dir }}/{{ item }}.yml" + ansible.builtin.include_tasks: "{{ migration_dir }}/{{ item }}.yml" loop: "{{ pending_migrations }}" register: migration_results - name: Record applied migrations - command: sqlite3 {{ migration_state_file }} "INSERT INTO migrations (id) VALUES ('{{ item }}');" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "INSERT INTO migrations (id) VALUES ('{{ item }}');" + changed_when: true loop: "{{ pending_migrations }}" loop_control: index_var: migration_index @@ -65,12 +67,12 @@ - not (migration_results.results[migration_index].failed | default(false)) - name: Verify migrations were recorded - command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" register: final_migrations changed_when: false - name: Show all recorded migrations - debug: + ansible.builtin.debug: msg: "All recorded migrations: {{ final_migrations.stdout_lines }}" verbosity: 1 - no_log: false \ No newline at end of file + no_log: false diff --git a/tower/ansible/requirements.yml b/tower/ansible/requirements.yml index 46066ee4..c7eea3ef 100644 --- a/tower/ansible/requirements.yml +++ b/tower/ansible/requirements.yml @@ -1,4 +1,9 @@ +--- collections: + - name: community.docker + version: "5.3.0" + - name: community.general + version: "13.4.0" - name: devsec.hardening version: "10.6.0" roles: diff --git a/tower/ansible/roles/system/apt/tasks/main.yml b/tower/ansible/roles/system/apt/tasks/main.yml index 544977cb..800f7a56 100644 --- a/tower/ansible/roles/system/apt/tasks/main.yml +++ b/tower/ansible/roles/system/apt/tasks/main.yml @@ -1,15 +1,16 @@ +--- - name: Update apt cache, dist-upgrade, and autoremove ansible.builtin.apt: - update_cache: yes + update_cache: true upgrade: dist - autoremove: yes - purge: yes + autoremove: true + purge: true - name: Check if reboot required - stat: + ansible.builtin.stat: path: /var/run/reboot-required register: reboot_required_file - name: Reboot if required - reboot: - when: reboot_required_file.stat.exists == true + ansible.builtin.reboot: + when: reboot_required_file.stat.exists diff --git a/tower/ansible/roles/system/migrations/tasks/main.yml b/tower/ansible/roles/system/migrations/tasks/main.yml index 5d801ce6..addef147 100644 --- a/tower/ansible/roles/system/migrations/tasks/main.yml +++ b/tower/ansible/roles/system/migrations/tasks/main.yml @@ -1,61 +1,63 @@ --- - name: Ensure sqlite3 is installed - package: + ansible.builtin.package: name: sqlite3 state: present - name: Ensure /opt/ansible directory exists - file: + ansible.builtin.file: path: /opt/ansible state: directory - mode: '0755' + mode: "0755" - name: Ensure migration state database exists - command: sqlite3 {{ migration_state_file }} "CREATE TABLE IF NOT EXISTS migrations (id TEXT PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP);" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "CREATE TABLE IF NOT EXISTS migrations (id TEXT PRIMARY KEY, applied_at DATETIME DEFAULT + CURRENT_TIMESTAMP);" args: creates: "{{ migration_state_file }}" - name: Get applied migrations - command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" register: applied_migrations_output changed_when: false - name: Parse applied migrations - set_fact: + ansible.builtin.set_fact: applied_migrations: "{{ applied_migrations_output.stdout_lines | select | list }}" - name: Show applied migrations - debug: + ansible.builtin.debug: msg: "Already applied migrations: {{ applied_migrations }}" verbosity: 1 no_log: false - name: Find migration files - find: + ansible.builtin.find: paths: "{{ migration_dir }}" patterns: "*.yml" - recurse: no + recurse: false delegate_to: localhost register: migration_files - name: Extract migration IDs from files - set_fact: + ansible.builtin.set_fact: pending_migrations: | {{ migration_files.files | map(attribute='path') | map('basename') | map('regex_replace', '\.yml$', '') | reject('in', applied_migrations) | list }} - name: Show pending migrations - debug: + ansible.builtin.debug: msg: "Pending migrations to apply: {{ pending_migrations }}" verbosity: 1 no_log: false - name: Run pending migrations - include_tasks: "{{ migration_dir }}/{{ item }}.yml" + ansible.builtin.include_tasks: "{{ migration_dir }}/{{ item }}.yml" loop: "{{ pending_migrations }}" register: migration_results - name: Record applied migrations - command: sqlite3 {{ migration_state_file }} "INSERT INTO migrations (id) VALUES ('{{ item }}');" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "INSERT INTO migrations (id) VALUES ('{{ item }}');" + changed_when: true loop: "{{ pending_migrations }}" loop_control: index_var: migration_index @@ -65,12 +67,12 @@ - not (migration_results.results[migration_index].failed | default(false)) - name: Verify migrations were recorded - command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" register: final_migrations changed_when: false - name: Show all recorded migrations - debug: + ansible.builtin.debug: msg: "All recorded migrations: {{ final_migrations.stdout_lines }}" verbosity: 1 - no_log: false \ No newline at end of file + no_log: false diff --git a/web1/ansible/requirements.yml b/web1/ansible/requirements.yml index 3b3c0bbb..1eaa61b3 100644 --- a/web1/ansible/requirements.yml +++ b/web1/ansible/requirements.yml @@ -1,4 +1,9 @@ +--- collections: + - name: community.docker + version: "5.3.0" + - name: community.general + version: "13.4.0" - name: devsec.hardening version: "10.6.0" roles: diff --git a/web1/ansible/roles/system/apt/tasks/main.yml b/web1/ansible/roles/system/apt/tasks/main.yml index 84c2c0b2..646bfd69 100644 --- a/web1/ansible/roles/system/apt/tasks/main.yml +++ b/web1/ansible/roles/system/apt/tasks/main.yml @@ -1,9 +1,10 @@ +--- - name: Update apt cache, dist-upgrade, and autoremove ansible.builtin.apt: - update_cache: yes + update_cache: true upgrade: dist - autoremove: yes - purge: yes + autoremove: true + purge: true - name: Install packages ansible.builtin.apt: @@ -12,22 +13,22 @@ state: present - name: Enable and start qemu-guest-agent - service: + ansible.builtin.service: name: qemu-guest-agent - enabled: yes + enabled: true state: started - name: Enable fstrim timer - service: + ansible.builtin.service: name: fstrim.timer - enabled: yes + enabled: true state: started - name: Check if reboot required - stat: + ansible.builtin.stat: path: /var/run/reboot-required register: reboot_required_file - name: Reboot if required - reboot: - when: reboot_required_file.stat.exists == true + ansible.builtin.reboot: + when: reboot_required_file.stat.exists diff --git a/web1/ansible/roles/system/crowdsec/tasks/main.yml b/web1/ansible/roles/system/crowdsec/tasks/main.yml index 060d0b6b..4110c0d7 100644 --- a/web1/ansible/roles/system/crowdsec/tasks/main.yml +++ b/web1/ansible/roles/system/crowdsec/tasks/main.yml @@ -1,12 +1,13 @@ +--- - name: Read CrowdSec token from environment - set_fact: + ansible.builtin.set_fact: crowdsec_token: "{{ lookup('env', 'CROWDSEC_TOKEN') | default('', true) }}" - name: Download CrowdSec GPG key - get_url: + ansible.builtin.get_url: url: https://packagecloud.io/crowdsec/crowdsec/gpgkey dest: /usr/share/keyrings/crowdsec.asc - mode: '0644' + mode: "0644" - name: Install deb822 repository dependency ansible.builtin.apt: @@ -38,7 +39,7 @@ - name: Update apt cache ansible.builtin.apt: - update_cache: yes + update_cache: true when: crowdsec_repo.changed - name: Install CrowdSec @@ -57,54 +58,56 @@ state: present - name: Deploy CrowdSec configuration - template: + ansible.builtin.template: src: config.yaml.j2 dest: /etc/crowdsec/config.yaml + mode: "0644" notify: restart crowdsec - name: Update local API credentials URL - lineinfile: + ansible.builtin.lineinfile: path: /etc/crowdsec/local_api_credentials.yaml - regexp: '^url:' - line: 'url: http://127.0.0.1:8081' + regexp: "^url:" + line: "url: http://127.0.0.1:8081" - name: Enable and start CrowdSec service - service: + ansible.builtin.service: name: crowdsec - enabled: yes + enabled: true state: started - name: Enable and start CrowdSec firewall bouncer service - service: + ansible.builtin.service: name: crowdsec-firewall-bouncer - enabled: yes + enabled: true state: started - name: Update firewall bouncer API URL - lineinfile: + ansible.builtin.lineinfile: path: /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml - regexp: '^api_url:' - line: 'api_url: http://127.0.0.1:8081/' + regexp: "^api_url:" + line: "api_url: http://127.0.0.1:8081/" notify: restart crowdsec-firewall-bouncer - name: Ensure CrowdSec firewall bouncer uses nftables mode - lineinfile: + ansible.builtin.lineinfile: path: /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml - regexp: '^mode:' - line: 'mode: nftables' + regexp: "^mode:" + line: "mode: nftables" notify: restart crowdsec-firewall-bouncer - name: Update nginx bouncer API URL - lineinfile: + ansible.builtin.lineinfile: path: /etc/crowdsec/bouncers/crowdsec-nginx-bouncer.conf - regexp: '^API_URL=' - line: 'API_URL=http://127.0.0.1:8081' + regexp: "^API_URL=" + line: "API_URL=http://127.0.0.1:8081" - name: Check CrowdSec console enrollment status - command: cscli console status -o json + ansible.builtin.command: cscli console status -o json register: console_status changed_when: false - name: Enroll CrowdSec with console - command: cscli console enroll "{{ crowdsec_token }}" - when: crowdsec_token | length > 0 and 'console_management' not in (console_status.stdout | from_json) \ No newline at end of file + ansible.builtin.command: cscli console enroll "{{ crowdsec_token }}" + changed_when: true + when: crowdsec_token | length > 0 and 'console_management' not in (console_status.stdout | from_json) diff --git a/web1/ansible/roles/system/migrations/tasks/main.yml b/web1/ansible/roles/system/migrations/tasks/main.yml index 5d801ce6..addef147 100644 --- a/web1/ansible/roles/system/migrations/tasks/main.yml +++ b/web1/ansible/roles/system/migrations/tasks/main.yml @@ -1,61 +1,63 @@ --- - name: Ensure sqlite3 is installed - package: + ansible.builtin.package: name: sqlite3 state: present - name: Ensure /opt/ansible directory exists - file: + ansible.builtin.file: path: /opt/ansible state: directory - mode: '0755' + mode: "0755" - name: Ensure migration state database exists - command: sqlite3 {{ migration_state_file }} "CREATE TABLE IF NOT EXISTS migrations (id TEXT PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP);" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "CREATE TABLE IF NOT EXISTS migrations (id TEXT PRIMARY KEY, applied_at DATETIME DEFAULT + CURRENT_TIMESTAMP);" args: creates: "{{ migration_state_file }}" - name: Get applied migrations - command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" register: applied_migrations_output changed_when: false - name: Parse applied migrations - set_fact: + ansible.builtin.set_fact: applied_migrations: "{{ applied_migrations_output.stdout_lines | select | list }}" - name: Show applied migrations - debug: + ansible.builtin.debug: msg: "Already applied migrations: {{ applied_migrations }}" verbosity: 1 no_log: false - name: Find migration files - find: + ansible.builtin.find: paths: "{{ migration_dir }}" patterns: "*.yml" - recurse: no + recurse: false delegate_to: localhost register: migration_files - name: Extract migration IDs from files - set_fact: + ansible.builtin.set_fact: pending_migrations: | {{ migration_files.files | map(attribute='path') | map('basename') | map('regex_replace', '\.yml$', '') | reject('in', applied_migrations) | list }} - name: Show pending migrations - debug: + ansible.builtin.debug: msg: "Pending migrations to apply: {{ pending_migrations }}" verbosity: 1 no_log: false - name: Run pending migrations - include_tasks: "{{ migration_dir }}/{{ item }}.yml" + ansible.builtin.include_tasks: "{{ migration_dir }}/{{ item }}.yml" loop: "{{ pending_migrations }}" register: migration_results - name: Record applied migrations - command: sqlite3 {{ migration_state_file }} "INSERT INTO migrations (id) VALUES ('{{ item }}');" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "INSERT INTO migrations (id) VALUES ('{{ item }}');" + changed_when: true loop: "{{ pending_migrations }}" loop_control: index_var: migration_index @@ -65,12 +67,12 @@ - not (migration_results.results[migration_index].failed | default(false)) - name: Verify migrations were recorded - command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" register: final_migrations changed_when: false - name: Show all recorded migrations - debug: + ansible.builtin.debug: msg: "All recorded migrations: {{ final_migrations.stdout_lines }}" verbosity: 1 - no_log: false \ No newline at end of file + no_log: false diff --git a/web1/ansible/roles/system/php/tasks/_build_php_extension.yml b/web1/ansible/roles/system/php/tasks/_build_php_extension.yml index 39226ecd..69c93342 100644 --- a/web1/ansible/roles/system/php/tasks/_build_php_extension.yml +++ b/web1/ansible/roles/system/php/tasks/_build_php_extension.yml @@ -29,13 +29,13 @@ ansible.builtin.file: path: "/tmp/php-ext-build/{{ php_version }}" state: directory - mode: '0755' + mode: "0755" - name: "PHP {{ php_version }} {{ ext.name }} - download PECL tarball" ansible.builtin.get_url: url: "https://pecl.php.net/get/{{ ext.name }}-{{ ext.version }}.tgz" dest: "/tmp/php-ext-build/{{ ext.name }}-{{ ext.version }}.tgz" - mode: '0644' + mode: "0644" - name: "PHP {{ php_version }} {{ ext.name }} - extract source into version-scoped directory" ansible.builtin.unarchive: @@ -54,6 +54,7 @@ args: chdir: "/tmp/php-ext-build/{{ php_version }}/{{ ext.name }}-{{ ext.version }}" executable: /bin/bash + changed_when: true - name: "PHP {{ php_version }} {{ ext.name }} - register in cli php.ini" ansible.builtin.lineinfile: diff --git a/web2/ansible/requirements.yml b/web2/ansible/requirements.yml index 3b3c0bbb..1eaa61b3 100644 --- a/web2/ansible/requirements.yml +++ b/web2/ansible/requirements.yml @@ -1,4 +1,9 @@ +--- collections: + - name: community.docker + version: "5.3.0" + - name: community.general + version: "13.4.0" - name: devsec.hardening version: "10.6.0" roles: diff --git a/web2/ansible/roles/system/apt/tasks/main.yml b/web2/ansible/roles/system/apt/tasks/main.yml index 84c2c0b2..646bfd69 100644 --- a/web2/ansible/roles/system/apt/tasks/main.yml +++ b/web2/ansible/roles/system/apt/tasks/main.yml @@ -1,9 +1,10 @@ +--- - name: Update apt cache, dist-upgrade, and autoremove ansible.builtin.apt: - update_cache: yes + update_cache: true upgrade: dist - autoremove: yes - purge: yes + autoremove: true + purge: true - name: Install packages ansible.builtin.apt: @@ -12,22 +13,22 @@ state: present - name: Enable and start qemu-guest-agent - service: + ansible.builtin.service: name: qemu-guest-agent - enabled: yes + enabled: true state: started - name: Enable fstrim timer - service: + ansible.builtin.service: name: fstrim.timer - enabled: yes + enabled: true state: started - name: Check if reboot required - stat: + ansible.builtin.stat: path: /var/run/reboot-required register: reboot_required_file - name: Reboot if required - reboot: - when: reboot_required_file.stat.exists == true + ansible.builtin.reboot: + when: reboot_required_file.stat.exists diff --git a/web2/ansible/roles/system/crowdsec/tasks/main.yml b/web2/ansible/roles/system/crowdsec/tasks/main.yml index 8b6b24d6..37f93ba5 100644 --- a/web2/ansible/roles/system/crowdsec/tasks/main.yml +++ b/web2/ansible/roles/system/crowdsec/tasks/main.yml @@ -1,12 +1,13 @@ +--- - name: Read CrowdSec token from environment - set_fact: + ansible.builtin.set_fact: crowdsec_token: "{{ lookup('env', 'CROWDSEC_TOKEN') | default('', true) }}" - name: Download CrowdSec GPG key - get_url: + ansible.builtin.get_url: url: https://packagecloud.io/crowdsec/crowdsec/gpgkey dest: /usr/share/keyrings/crowdsec.asc - mode: '0644' + mode: "0644" - name: Install deb822 repository dependency ansible.builtin.apt: @@ -38,7 +39,7 @@ - name: Update apt cache ansible.builtin.apt: - update_cache: yes + update_cache: true when: crowdsec_repo.changed - name: Install CrowdSec @@ -52,48 +53,50 @@ state: present - name: Deploy CrowdSec configuration - template: + ansible.builtin.template: src: config.yaml.j2 dest: /etc/crowdsec/config.yaml + mode: "0644" notify: restart crowdsec - name: Update local API credentials URL - lineinfile: + ansible.builtin.lineinfile: path: /etc/crowdsec/local_api_credentials.yaml - regexp: '^url:' - line: 'url: http://127.0.0.1:8081' + regexp: "^url:" + line: "url: http://127.0.0.1:8081" - name: Enable and start CrowdSec service - service: + ansible.builtin.service: name: crowdsec - enabled: yes + enabled: true state: started - name: Enable and start CrowdSec firewall bouncer service - service: + ansible.builtin.service: name: crowdsec-firewall-bouncer - enabled: yes + enabled: true state: started - name: Update firewall bouncer API URL - lineinfile: + ansible.builtin.lineinfile: path: /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml - regexp: '^api_url:' - line: 'api_url: http://127.0.0.1:8081/' + regexp: "^api_url:" + line: "api_url: http://127.0.0.1:8081/" notify: restart crowdsec-firewall-bouncer - name: Ensure CrowdSec firewall bouncer uses nftables mode - lineinfile: + ansible.builtin.lineinfile: path: /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml - regexp: '^mode:' - line: 'mode: nftables' + regexp: "^mode:" + line: "mode: nftables" notify: restart crowdsec-firewall-bouncer - name: Check CrowdSec console enrollment status - command: cscli console status -o json + ansible.builtin.command: cscli console status -o json register: console_status changed_when: false - name: Enroll CrowdSec with console - command: cscli console enroll "{{ crowdsec_token }}" - when: crowdsec_token | length > 0 and 'console_management' not in (console_status.stdout | from_json) \ No newline at end of file + ansible.builtin.command: cscli console enroll "{{ crowdsec_token }}" + changed_when: true + when: crowdsec_token | length > 0 and 'console_management' not in (console_status.stdout | from_json) diff --git a/web2/ansible/roles/system/migrations/tasks/main.yml b/web2/ansible/roles/system/migrations/tasks/main.yml index 5d801ce6..addef147 100644 --- a/web2/ansible/roles/system/migrations/tasks/main.yml +++ b/web2/ansible/roles/system/migrations/tasks/main.yml @@ -1,61 +1,63 @@ --- - name: Ensure sqlite3 is installed - package: + ansible.builtin.package: name: sqlite3 state: present - name: Ensure /opt/ansible directory exists - file: + ansible.builtin.file: path: /opt/ansible state: directory - mode: '0755' + mode: "0755" - name: Ensure migration state database exists - command: sqlite3 {{ migration_state_file }} "CREATE TABLE IF NOT EXISTS migrations (id TEXT PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP);" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "CREATE TABLE IF NOT EXISTS migrations (id TEXT PRIMARY KEY, applied_at DATETIME DEFAULT + CURRENT_TIMESTAMP);" args: creates: "{{ migration_state_file }}" - name: Get applied migrations - command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" register: applied_migrations_output changed_when: false - name: Parse applied migrations - set_fact: + ansible.builtin.set_fact: applied_migrations: "{{ applied_migrations_output.stdout_lines | select | list }}" - name: Show applied migrations - debug: + ansible.builtin.debug: msg: "Already applied migrations: {{ applied_migrations }}" verbosity: 1 no_log: false - name: Find migration files - find: + ansible.builtin.find: paths: "{{ migration_dir }}" patterns: "*.yml" - recurse: no + recurse: false delegate_to: localhost register: migration_files - name: Extract migration IDs from files - set_fact: + ansible.builtin.set_fact: pending_migrations: | {{ migration_files.files | map(attribute='path') | map('basename') | map('regex_replace', '\.yml$', '') | reject('in', applied_migrations) | list }} - name: Show pending migrations - debug: + ansible.builtin.debug: msg: "Pending migrations to apply: {{ pending_migrations }}" verbosity: 1 no_log: false - name: Run pending migrations - include_tasks: "{{ migration_dir }}/{{ item }}.yml" + ansible.builtin.include_tasks: "{{ migration_dir }}/{{ item }}.yml" loop: "{{ pending_migrations }}" register: migration_results - name: Record applied migrations - command: sqlite3 {{ migration_state_file }} "INSERT INTO migrations (id) VALUES ('{{ item }}');" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "INSERT INTO migrations (id) VALUES ('{{ item }}');" + changed_when: true loop: "{{ pending_migrations }}" loop_control: index_var: migration_index @@ -65,12 +67,12 @@ - not (migration_results.results[migration_index].failed | default(false)) - name: Verify migrations were recorded - command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" register: final_migrations changed_when: false - name: Show all recorded migrations - debug: + ansible.builtin.debug: msg: "All recorded migrations: {{ final_migrations.stdout_lines }}" verbosity: 1 - no_log: false \ No newline at end of file + no_log: false diff --git a/web3/ansible/requirements.yml b/web3/ansible/requirements.yml index 3b3c0bbb..1eaa61b3 100644 --- a/web3/ansible/requirements.yml +++ b/web3/ansible/requirements.yml @@ -1,4 +1,9 @@ +--- collections: + - name: community.docker + version: "5.3.0" + - name: community.general + version: "13.4.0" - name: devsec.hardening version: "10.6.0" roles: diff --git a/web3/ansible/roles/system/apt/tasks/main.yml b/web3/ansible/roles/system/apt/tasks/main.yml index 84c2c0b2..646bfd69 100644 --- a/web3/ansible/roles/system/apt/tasks/main.yml +++ b/web3/ansible/roles/system/apt/tasks/main.yml @@ -1,9 +1,10 @@ +--- - name: Update apt cache, dist-upgrade, and autoremove ansible.builtin.apt: - update_cache: yes + update_cache: true upgrade: dist - autoremove: yes - purge: yes + autoremove: true + purge: true - name: Install packages ansible.builtin.apt: @@ -12,22 +13,22 @@ state: present - name: Enable and start qemu-guest-agent - service: + ansible.builtin.service: name: qemu-guest-agent - enabled: yes + enabled: true state: started - name: Enable fstrim timer - service: + ansible.builtin.service: name: fstrim.timer - enabled: yes + enabled: true state: started - name: Check if reboot required - stat: + ansible.builtin.stat: path: /var/run/reboot-required register: reboot_required_file - name: Reboot if required - reboot: - when: reboot_required_file.stat.exists == true + ansible.builtin.reboot: + when: reboot_required_file.stat.exists diff --git a/web3/ansible/roles/system/crowdsec/tasks/main.yml b/web3/ansible/roles/system/crowdsec/tasks/main.yml index 060d0b6b..4110c0d7 100644 --- a/web3/ansible/roles/system/crowdsec/tasks/main.yml +++ b/web3/ansible/roles/system/crowdsec/tasks/main.yml @@ -1,12 +1,13 @@ +--- - name: Read CrowdSec token from environment - set_fact: + ansible.builtin.set_fact: crowdsec_token: "{{ lookup('env', 'CROWDSEC_TOKEN') | default('', true) }}" - name: Download CrowdSec GPG key - get_url: + ansible.builtin.get_url: url: https://packagecloud.io/crowdsec/crowdsec/gpgkey dest: /usr/share/keyrings/crowdsec.asc - mode: '0644' + mode: "0644" - name: Install deb822 repository dependency ansible.builtin.apt: @@ -38,7 +39,7 @@ - name: Update apt cache ansible.builtin.apt: - update_cache: yes + update_cache: true when: crowdsec_repo.changed - name: Install CrowdSec @@ -57,54 +58,56 @@ state: present - name: Deploy CrowdSec configuration - template: + ansible.builtin.template: src: config.yaml.j2 dest: /etc/crowdsec/config.yaml + mode: "0644" notify: restart crowdsec - name: Update local API credentials URL - lineinfile: + ansible.builtin.lineinfile: path: /etc/crowdsec/local_api_credentials.yaml - regexp: '^url:' - line: 'url: http://127.0.0.1:8081' + regexp: "^url:" + line: "url: http://127.0.0.1:8081" - name: Enable and start CrowdSec service - service: + ansible.builtin.service: name: crowdsec - enabled: yes + enabled: true state: started - name: Enable and start CrowdSec firewall bouncer service - service: + ansible.builtin.service: name: crowdsec-firewall-bouncer - enabled: yes + enabled: true state: started - name: Update firewall bouncer API URL - lineinfile: + ansible.builtin.lineinfile: path: /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml - regexp: '^api_url:' - line: 'api_url: http://127.0.0.1:8081/' + regexp: "^api_url:" + line: "api_url: http://127.0.0.1:8081/" notify: restart crowdsec-firewall-bouncer - name: Ensure CrowdSec firewall bouncer uses nftables mode - lineinfile: + ansible.builtin.lineinfile: path: /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml - regexp: '^mode:' - line: 'mode: nftables' + regexp: "^mode:" + line: "mode: nftables" notify: restart crowdsec-firewall-bouncer - name: Update nginx bouncer API URL - lineinfile: + ansible.builtin.lineinfile: path: /etc/crowdsec/bouncers/crowdsec-nginx-bouncer.conf - regexp: '^API_URL=' - line: 'API_URL=http://127.0.0.1:8081' + regexp: "^API_URL=" + line: "API_URL=http://127.0.0.1:8081" - name: Check CrowdSec console enrollment status - command: cscli console status -o json + ansible.builtin.command: cscli console status -o json register: console_status changed_when: false - name: Enroll CrowdSec with console - command: cscli console enroll "{{ crowdsec_token }}" - when: crowdsec_token | length > 0 and 'console_management' not in (console_status.stdout | from_json) \ No newline at end of file + ansible.builtin.command: cscli console enroll "{{ crowdsec_token }}" + changed_when: true + when: crowdsec_token | length > 0 and 'console_management' not in (console_status.stdout | from_json) diff --git a/web3/ansible/roles/system/migrations/tasks/main.yml b/web3/ansible/roles/system/migrations/tasks/main.yml index 5d801ce6..addef147 100644 --- a/web3/ansible/roles/system/migrations/tasks/main.yml +++ b/web3/ansible/roles/system/migrations/tasks/main.yml @@ -1,61 +1,63 @@ --- - name: Ensure sqlite3 is installed - package: + ansible.builtin.package: name: sqlite3 state: present - name: Ensure /opt/ansible directory exists - file: + ansible.builtin.file: path: /opt/ansible state: directory - mode: '0755' + mode: "0755" - name: Ensure migration state database exists - command: sqlite3 {{ migration_state_file }} "CREATE TABLE IF NOT EXISTS migrations (id TEXT PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP);" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "CREATE TABLE IF NOT EXISTS migrations (id TEXT PRIMARY KEY, applied_at DATETIME DEFAULT + CURRENT_TIMESTAMP);" args: creates: "{{ migration_state_file }}" - name: Get applied migrations - command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" register: applied_migrations_output changed_when: false - name: Parse applied migrations - set_fact: + ansible.builtin.set_fact: applied_migrations: "{{ applied_migrations_output.stdout_lines | select | list }}" - name: Show applied migrations - debug: + ansible.builtin.debug: msg: "Already applied migrations: {{ applied_migrations }}" verbosity: 1 no_log: false - name: Find migration files - find: + ansible.builtin.find: paths: "{{ migration_dir }}" patterns: "*.yml" - recurse: no + recurse: false delegate_to: localhost register: migration_files - name: Extract migration IDs from files - set_fact: + ansible.builtin.set_fact: pending_migrations: | {{ migration_files.files | map(attribute='path') | map('basename') | map('regex_replace', '\.yml$', '') | reject('in', applied_migrations) | list }} - name: Show pending migrations - debug: + ansible.builtin.debug: msg: "Pending migrations to apply: {{ pending_migrations }}" verbosity: 1 no_log: false - name: Run pending migrations - include_tasks: "{{ migration_dir }}/{{ item }}.yml" + ansible.builtin.include_tasks: "{{ migration_dir }}/{{ item }}.yml" loop: "{{ pending_migrations }}" register: migration_results - name: Record applied migrations - command: sqlite3 {{ migration_state_file }} "INSERT INTO migrations (id) VALUES ('{{ item }}');" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "INSERT INTO migrations (id) VALUES ('{{ item }}');" + changed_when: true loop: "{{ pending_migrations }}" loop_control: index_var: migration_index @@ -65,12 +67,12 @@ - not (migration_results.results[migration_index].failed | default(false)) - name: Verify migrations were recorded - command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" + ansible.builtin.command: sqlite3 {{ migration_state_file }} "SELECT id FROM migrations;" register: final_migrations changed_when: false - name: Show all recorded migrations - debug: + ansible.builtin.debug: msg: "All recorded migrations: {{ final_migrations.stdout_lines }}" verbosity: 1 - no_log: false \ No newline at end of file + no_log: false diff --git a/web3/ansible/roles/system/php/tasks/_build_php_extension.yml b/web3/ansible/roles/system/php/tasks/_build_php_extension.yml index 39226ecd..69c93342 100644 --- a/web3/ansible/roles/system/php/tasks/_build_php_extension.yml +++ b/web3/ansible/roles/system/php/tasks/_build_php_extension.yml @@ -29,13 +29,13 @@ ansible.builtin.file: path: "/tmp/php-ext-build/{{ php_version }}" state: directory - mode: '0755' + mode: "0755" - name: "PHP {{ php_version }} {{ ext.name }} - download PECL tarball" ansible.builtin.get_url: url: "https://pecl.php.net/get/{{ ext.name }}-{{ ext.version }}.tgz" dest: "/tmp/php-ext-build/{{ ext.name }}-{{ ext.version }}.tgz" - mode: '0644' + mode: "0644" - name: "PHP {{ php_version }} {{ ext.name }} - extract source into version-scoped directory" ansible.builtin.unarchive: @@ -54,6 +54,7 @@ args: chdir: "/tmp/php-ext-build/{{ php_version }}/{{ ext.name }}-{{ ext.version }}" executable: /bin/bash + changed_when: true - name: "PHP {{ php_version }} {{ ext.name }} - register in cli php.ini" ansible.builtin.lineinfile: From bbe7c8f361892831ba6d88297f44c7502084cbf1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 25 Sep 2026 23:08:31 +0200 Subject: [PATCH 2/6] style: normalise YAML style and qualify module names Required for the validation gate to be green, and mostly invisible in review: yes/no to true/false, a document start on every file, trailing newlines, and 151 fqcn fixes bringing the last unqualified actions in line with the rest of the tree. Every rewritten file was re-parsed and compared against HEAD to confirm the parsed structure was unchanged; long lines were folded into YAML folded scalars and verified to resolve byte-identical. Two bugs in that conversion were caught this way and reverted: a regex that rewrote the interior of a {{ }} Jinja expression, and a pass that touched 122 files instead of the 88 that actually needed it. The one mixed-style file (mx1 system/config) was left alone apart from the block-level `become` fix in the previous commit; indenting sequences the way yamllint defaults to would have reformatted every task file in the repo. --- .github/workflows/mirror-ansible-deploy.yml | 1 + .github/workflows/mirror-tofu-apply.yml | 1 + .github/workflows/mx1-ansible-deploy.yml | 1 + .github/workflows/tower-ansible-deploy.yml | 1 + .github/workflows/tower-tofu-apply.yml | 1 + .github/workflows/web1-ansible-deploy.yml | 1 + .github/workflows/web2-ansible-deploy.yml | 1 + .github/workflows/web3-ansible-deploy.yml | 1 + CLAUDE.md | 48 +++++ README.md | 60 ++++++ mirror/ansible/defaults/main.yml | 2 +- mirror/ansible/playbook.yml | 4 +- .../roles/system/config/tasks/main.yml | 168 ++++++++-------- .../system/containers/tasks/deploy_stack.yml | 2 +- .../roles/system/containers/tasks/main.yml | 22 +- .../roles/system/migrations/defaults/main.yml | 2 +- mx1/ansible/defaults/main.yml | 2 +- mx1/ansible/playbook.yml | 6 +- .../roles/system/config/handlers/main.yml | 4 +- .../system/containers/tasks/deploy_stack.yml | 2 +- .../roles/system/containers/tasks/main.yml | 26 +-- .../roles/system/migrations/defaults/main.yml | 2 +- mx1/ansible/roles/system/monit/tasks/main.yml | 16 +- tower/ansible/defaults/main.yml | 2 +- tower/ansible/playbook.yml | 4 +- .../roles/system/config/tasks/main.yml | 84 ++++---- .../system/containers/tasks/deploy_stack.yml | 2 +- .../roles/system/containers/tasks/main.yml | 22 +- .../roles/system/migrations/defaults/main.yml | 2 +- .../containers/cloudflared/docker-compose.yml | 1 + tower/containers/cobalt/docker-compose.yml | 1 + tower/containers/headscale/docker-compose.yml | 1 + tower/terraform/.terraform.lock.hcl | 2 + tower/terraform/network.tf | 6 +- web1/ansible/defaults/main.yml | 6 +- ...0121_0001_relocate_media_for_authentik.yml | 4 +- ...02_switch_crowdsec_bouncer_to_nftables.yml | 2 +- ...04_0003_fix_crowdsec_nftables_priority.yml | 4 +- web1/ansible/playbook.yml | 6 +- .../roles/system/backup/tasks/main.yml | 6 +- .../roles/system/config/handlers/main.yml | 2 +- .../roles/system/config/tasks/main.yml | 162 ++++++++------- .../system/containers/tasks/authentik.yml | 8 +- .../roles/system/containers/tasks/main.yml | 38 ++-- .../system/containers/tasks/opencloud.yml | 24 +-- .../roles/system/crowdsec/handlers/main.yml | 7 +- .../roles/system/migrations/defaults/main.yml | 2 +- .../ansible/roles/system/monit/tasks/main.yml | 16 +- web1/ansible/roles/system/php/tasks/main.yml | 4 +- web1/containers/1min-relay/docker-compose.yml | 1 + web1/containers/calcom/docker-compose.yml | 1 + web1/containers/hermes/docker-compose.yml | 2 +- web1/containers/open-webui/docker-compose.yml | 2 +- web1/containers/opencloud/csp.yaml | 74 +++---- web1/containers/opencloud/docker-compose.yml | 1 - web1/containers/roundcube/docker-compose.yml | 1 + web1/containers/twenty/docker-compose.yml | 1 - web1/containers/webmail/docker-compose.yml | 1 + web2/ansible/defaults/main.yml | 2 +- ...02_switch_crowdsec_bouncer_to_nftables.yml | 2 +- ...04_0003_fix_crowdsec_nftables_priority.yml | 4 +- web2/ansible/playbook.yml | 4 +- .../roles/system/backup/tasks/main.yml | 6 +- .../roles/system/config/handlers/main.yml | 2 +- .../roles/system/config/tasks/main.yml | 190 +++++++++--------- .../system/containers/tasks/deploy_stack.yml | 2 +- .../roles/system/containers/tasks/main.yml | 26 +-- .../roles/system/crowdsec/handlers/main.yml | 7 +- .../roles/system/migrations/defaults/main.yml | 2 +- .../ansible/roles/system/monit/tasks/main.yml | 16 +- .../containers/cloudflared/docker-compose.yml | 1 + web2/containers/cobalt/docker-compose.yml | 1 + .../containers/hypebun-web/docker-compose.yml | 1 + web3/ansible/defaults/main.yml | 6 +- ...0121_0001_relocate_media_for_authentik.yml | 4 +- ...02_switch_crowdsec_bouncer_to_nftables.yml | 2 +- ...04_0003_fix_crowdsec_nftables_priority.yml | 4 +- web3/ansible/playbook.yml | 6 +- .../roles/system/backup/tasks/main.yml | 6 +- .../roles/system/config/handlers/main.yml | 2 +- .../roles/system/config/tasks/main.yml | 162 ++++++++------- .../system/containers/tasks/authentik.yml | 8 +- .../system/containers/tasks/deploy_stack.yml | 2 +- .../roles/system/containers/tasks/fleet.yml | 4 +- .../roles/system/containers/tasks/main.yml | 26 +-- .../roles/system/crowdsec/handlers/main.yml | 7 +- .../roles/system/migrations/defaults/main.yml | 2 +- .../ansible/roles/system/monit/tasks/main.yml | 16 +- web3/ansible/roles/system/php/tasks/main.yml | 4 +- web3/containers/1min-relay/docker-compose.yml | 1 + web3/containers/calcom/docker-compose.yml | 1 + .../containers/cloudflared/docker-compose.yml | 1 + web3/containers/cobalt/docker-compose.yml | 1 + web3/containers/fleet/docker-compose.yml | 1 + web3/containers/gpt-load/docker-compose.yml | 3 +- web3/containers/hermes/docker-compose.yml | 2 +- web3/containers/n8n/docker-compose.yml | 1 - web3/containers/open-webui/docker-compose.yml | 2 +- web3/containers/twenty/docker-compose.yml | 1 - 99 files changed, 780 insertions(+), 637 deletions(-) diff --git a/.github/workflows/mirror-ansible-deploy.yml b/.github/workflows/mirror-ansible-deploy.yml index 7911be3e..61333d5d 100644 --- a/.github/workflows/mirror-ansible-deploy.yml +++ b/.github/workflows/mirror-ansible-deploy.yml @@ -1,3 +1,4 @@ +--- name: Deploy mirror with Ansible permissions: contents: read diff --git a/.github/workflows/mirror-tofu-apply.yml b/.github/workflows/mirror-tofu-apply.yml index ec37aad3..898c9d96 100644 --- a/.github/workflows/mirror-tofu-apply.yml +++ b/.github/workflows/mirror-tofu-apply.yml @@ -1,3 +1,4 @@ +--- name: Deploy mirror with OpenTofu permissions: contents: read diff --git a/.github/workflows/mx1-ansible-deploy.yml b/.github/workflows/mx1-ansible-deploy.yml index 30f834b6..3bc96dc4 100644 --- a/.github/workflows/mx1-ansible-deploy.yml +++ b/.github/workflows/mx1-ansible-deploy.yml @@ -1,3 +1,4 @@ +--- name: Deploy mx1 with Ansible permissions: contents: read diff --git a/.github/workflows/tower-ansible-deploy.yml b/.github/workflows/tower-ansible-deploy.yml index 25f12723..99dac937 100644 --- a/.github/workflows/tower-ansible-deploy.yml +++ b/.github/workflows/tower-ansible-deploy.yml @@ -1,3 +1,4 @@ +--- name: Deploy tower with Ansible permissions: contents: read diff --git a/.github/workflows/tower-tofu-apply.yml b/.github/workflows/tower-tofu-apply.yml index 0166154c..28d650ae 100644 --- a/.github/workflows/tower-tofu-apply.yml +++ b/.github/workflows/tower-tofu-apply.yml @@ -1,3 +1,4 @@ +--- name: Deploy tower with OpenTofu permissions: contents: read diff --git a/.github/workflows/web1-ansible-deploy.yml b/.github/workflows/web1-ansible-deploy.yml index 43bce68d..5806a963 100644 --- a/.github/workflows/web1-ansible-deploy.yml +++ b/.github/workflows/web1-ansible-deploy.yml @@ -1,3 +1,4 @@ +--- name: Deploy web1 with Ansible permissions: contents: read diff --git a/.github/workflows/web2-ansible-deploy.yml b/.github/workflows/web2-ansible-deploy.yml index 22f82f96..bf66b187 100644 --- a/.github/workflows/web2-ansible-deploy.yml +++ b/.github/workflows/web2-ansible-deploy.yml @@ -1,3 +1,4 @@ +--- name: Deploy web2 with Ansible permissions: contents: read diff --git a/.github/workflows/web3-ansible-deploy.yml b/.github/workflows/web3-ansible-deploy.yml index 36850416..0c85b56b 100644 --- a/.github/workflows/web3-ansible-deploy.yml +++ b/.github/workflows/web3-ansible-deploy.yml @@ -1,3 +1,4 @@ +--- name: Deploy web3 with Ansible permissions: contents: read diff --git a/CLAUDE.md b/CLAUDE.md index 674490f6..51323f9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,10 +117,48 @@ Applied to: cloudflared, webmail, n8n runner. - **All data store images** (PostgreSQL, MySQL, Redis, KeyDB, Valkey, OpenSearch, Meilisearch) — their entrypoints start as `root` and use `gosu` to drop to the database user, which requires `CAP_SETUID`/`CAP_SETGID`. Both `cap_drop: [ALL]` and `no-new-privileges:true` break this pattern and prevent the container from starting. - `roundcube` — Apache+PHP image breaks with `cap_drop: [ALL]`; gets `tmpfs: [/tmp]` only. +> **This section is enforced, not advisory.** `ci/check_compose.py` runs on +> every pull request and checks each service against the rules above. +> Exemptions live in the `EXEMPTIONS` dict in that script, keyed by +> `(compose path glob, service name)` — a service name alone is ambiguous, +> since `worker` is the Docker-socket-holding authentik worker in one stack +> and an ordinary hardened twenty.crm worker in another. +> +> Adding an exemption is a deliberate, reviewable change. Services that +> publish a port on all interfaces on purpose (the MX, headscale, frankenphp) +> are listed separately in `PUBLIC_PUBLISHERS`. A new `cap_add` capability +> must be added to `ALLOWED_CAP_ADD` and documented above. +> +> If you add a service and CI fails, fix the compose file — do not add an +> exemption to make the check pass. + ## Adding a Service 1. Create `/containers//docker-compose.yml` 2. Add to `docker_stacks` in `/ansible/roles/system/containers/defaults/main.yml` +3. Run `ci/validate.sh` — the compose policy check will flag anything missing + +## Validation + +`ci/validate.sh` runs every check that CI runs: yamllint, ansible-lint, the +compose policy check, `tofu validate` / `tofu fmt` for `tower`, and a zizmor +audit of the validation workflow. Run it before pushing; the same failures +otherwise surface as a red PR. + +Configs live in `.yamllint` and `.ansible-lint`. Two things to know when +editing them: + +- `.ansible-lint` `skip_list` entries each carry a reason. Adding a new skip + to silence a finding is a decision that should be argued in a review, not + a reflex — prefer fixing the finding. +- `.yamllint` must keep `comments-indentation: false` and the two + `octal-values` settings. `ansible-lint --fix` silently disables itself when + the project yamllint config disagrees with its expectations, so removing + them breaks `fqcn` remediation without any visible error. + +`ansible-lint --fix fqcn` is safe to run and is how the FQCN migration was +done. Verify with `git diff` afterwards: it should only add module +qualification. ## nftables + Docker + CrowdSec @@ -134,3 +172,13 @@ When nftables is the firewall backend, three things must be correct: net.netfilter.nf_conntrack_tcp_timeout_established: 86400 net.netfilter.nf_conntrack_tcp_timeout_time_wait: 30 ``` + +## Declared dependencies + +`community.docker` and `community.general` are used by the role tree +(`community.docker.docker_compose_v2`, `community.general.ufw`, …) and are +pinned in every host's `ansible/requirements.yml`. They were previously +resolved only as a transitive dependency of `geerlingguy.docker`, which +declares no dependencies of its own — so a clean runner would have failed +`ansible-lint`'s syntax check. Keep them listed. + diff --git a/README.md b/README.md index c91ea624..64109c8f 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,66 @@ Migrations run automatically as part of the playbook execution via the `system/m If a migration fails, the entire playbook stops to prevent inconsistent states. +## Validation + +Every pull request runs `.github/workflows/validate.yml`, which performs +static checks only. It never connects to a host, reads a state file, or +touches a cloud provider, so a broken change is caught before it can reach +production rather than during a deploy. + +| Check | Tool | Catches | +|---|---|---| +| Workflow secret guard | `ci/check_workflow_secrets.sh` | The validation workflow gaining access to secrets | +| Compose policy | `ci/check_compose.py` | Missing `cap_drop`, public port bindings, unpinned or exempted services | +| YAML | `yamllint` | Syntax errors, duplicate keys, style drift | +| Ansible | `ansible-lint` | Broken syntax, missing FQCN, non-idempotent commands, unset file modes | +| OpenTofu | `tofu validate` / `tofu fmt` | Invalid or unformatted configuration for `tower` | +| Actions | `zizmor` | Workflow-level privilege footguns | + +### Running the checks locally + +```bash +ci/validate.sh +``` + +Needs `ansible-lint`, `yamllint`, `zizmor` and `tofu` on `PATH`. The script +runs the same checks as CI, so failures reproduce locally. + +### The container security contract is enforced + +The rules described in `CLAUDE.md` (drop all capabilities, add +`no-new-privileges`, bind ports to loopback, pin image tags) are checked by +`ci/check_compose.py` rather than left to review discipline. Services that +genuinely cannot satisfy the policy — data stores that need `CAP_SETUID` to +run `gosu`, the authentik worker that needs the Docker socket — are listed in +`EXEMPTIONS` with a written reason. + +Adding an entry to that list is therefore a deliberate, reviewable change. If +a service is exempted but also sets `cap_drop` or `security_opt`, the check +fails, so a stale exemption cannot linger. + +### Why the validation workflow holds no secrets + +`validate.yml` runs on `pull_request`, so it is configured to hold no +credentials at all — the checks are entirely static analysis: + +- It references no `secrets.*` and no `environment:`, so the job runs with no + access to repository or environment secrets regardless of who opened the + pull request. +- `permissions: contents: read`, so the automatic `GITHUB_TOKEN` cannot write. +- Every `actions/checkout` sets `persist-credentials: false`, so the token is + not left in `.git/config` where later steps could read it. +- Third-party actions are pinned to a full commit SHA, fixing the code that + runs with this job's token. + +`ci/check_workflow_secrets.sh` re-checks these properties on every run, so a +later edit that adds a credential reference fails CI rather than passing +unnoticed. + +The deploy workflows do use secrets — they have to. They are triggered by +`push` to `main`, `schedule` and manual dispatch, never by `pull_request`, so +they never run code from a pull request. + ## Dependency Management This repository uses Renovate to keep dependencies up-to-date: diff --git a/mirror/ansible/defaults/main.yml b/mirror/ansible/defaults/main.yml index 9861aa0f..efaa817b 100644 --- a/mirror/ansible/defaults/main.yml +++ b/mirror/ansible/defaults/main.yml @@ -4,7 +4,7 @@ dns_search_domain: "{{ lookup('env', 'DNS_SEARCH_DOMAIN') }}" os_env_umask: "022" os_user_pw_ageing: false os_auth_pam_passwdqc_enable: false -ssh_allow_tcp_forwarding: 'local' +ssh_allow_tcp_forwarding: "local" ssh_print_last_log: true ssh_permit_root_login: "without-password" ssh_permit_tunnel: "yes" diff --git a/mirror/ansible/playbook.yml b/mirror/ansible/playbook.yml index a95f7709..558e320c 100644 --- a/mirror/ansible/playbook.yml +++ b/mirror/ansible/playbook.yml @@ -21,10 +21,10 @@ no_log: true block: - name: Apply OS hardening - include_role: + ansible.builtin.include_role: name: devsec.hardening.os_hardening when: scheduled_run - name: Apply ssh hardening - include_role: + ansible.builtin.include_role: name: devsec.hardening.ssh_hardening when: scheduled_run diff --git a/mirror/ansible/roles/system/config/tasks/main.yml b/mirror/ansible/roles/system/config/tasks/main.yml index 7ca1b77c..0e20acbf 100644 --- a/mirror/ansible/roles/system/config/tasks/main.yml +++ b/mirror/ansible/roles/system/config/tasks/main.yml @@ -7,98 +7,98 @@ - name: Firewall block: - - name: Allow HTTP in iptables - ansible.builtin.iptables: - chain: INPUT - protocol: tcp - destination_port: "80" - ctstate: NEW - jump: ACCEPT - action: insert - become: true - notify: Save iptables rules + - name: Allow HTTP in iptables + ansible.builtin.iptables: + chain: INPUT + protocol: tcp + destination_port: "80" + ctstate: NEW + jump: ACCEPT + action: insert + become: true + notify: Save iptables rules - - name: Allow HTTPS in iptables - ansible.builtin.iptables: - chain: INPUT - protocol: tcp - destination_port: "443" - ctstate: NEW - jump: ACCEPT - action: insert - become: true - notify: Save iptables rules + - name: Allow HTTPS in iptables + ansible.builtin.iptables: + chain: INPUT + protocol: tcp + destination_port: "443" + ctstate: NEW + jump: ACCEPT + action: insert + become: true + notify: Save iptables rules - - name: Allow HTTP in ip6tables - ansible.builtin.iptables: - chain: INPUT - protocol: tcp - destination_port: "80" - ctstate: NEW - jump: ACCEPT - action: insert - ip_version: ipv6 - become: true - notify: Save iptables rules + - name: Allow HTTP in ip6tables + ansible.builtin.iptables: + chain: INPUT + protocol: tcp + destination_port: "80" + ctstate: NEW + jump: ACCEPT + action: insert + ip_version: ipv6 + become: true + notify: Save iptables rules - - name: Allow HTTPS in ip6tables - ansible.builtin.iptables: - chain: INPUT - protocol: tcp - destination_port: "443" - ctstate: NEW - jump: ACCEPT - action: insert - ip_version: ipv6 - become: true - notify: Save iptables rules + - name: Allow HTTPS in ip6tables + ansible.builtin.iptables: + chain: INPUT + protocol: tcp + destination_port: "443" + ctstate: NEW + jump: ACCEPT + action: insert + ip_version: ipv6 + become: true + notify: Save iptables rules - name: DNS block: - - name: Ensure systemd-resolved is running - ansible.builtin.systemd_service: - name: systemd-resolved - state: started - enabled: yes - become: true + - name: Ensure systemd-resolved is running + ansible.builtin.systemd_service: + name: systemd-resolved + state: started + enabled: true + become: true - - name: Configure systemd-resolved for Google and Quad9 DNS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?DNS=' - line: 'DNS=8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for Google and Quad9 DNS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?DNS=" + line: "DNS=8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for ControlD and Cloudflare Fallback DNS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?FallbackDNS=' - line: 'FallbackDNS=76.76.2.0 76.76.10.0 1.1.1.1 1.0.0.1' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for ControlD and Cloudflare Fallback DNS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?FallbackDNS=" + line: "FallbackDNS=76.76.2.0 76.76.10.0 1.1.1.1 1.0.0.1" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for DNSOverTLS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?DNSOverTLS=' - line: 'DNSOverTLS=no' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for DNSOverTLS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?DNSOverTLS=" + line: "DNSOverTLS=no" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for Cache - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?Cache=' - line: 'Cache=yes' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for Cache + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?Cache=" + line: "Cache=yes" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for Domain - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?Domain=' - line: "Domain={{ dns_search_domain }}" - notify: Restart systemd-resolved - become: true - when: dns_search_domain | default('') | length > 0 + - name: Configure systemd-resolved for Domain + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?Domain=" + line: "Domain={{ dns_search_domain }}" + notify: Restart systemd-resolved + become: true + when: dns_search_domain | default('') | length > 0 diff --git a/mirror/ansible/roles/system/containers/tasks/deploy_stack.yml b/mirror/ansible/roles/system/containers/tasks/deploy_stack.yml index 090182c2..494046f2 100644 --- a/mirror/ansible/roles/system/containers/tasks/deploy_stack.yml +++ b/mirror/ansible/roles/system/containers/tasks/deploy_stack.yml @@ -9,4 +9,4 @@ project_src: "/opt/containers/{{ item.key }}" state: "{{ item.value.state }}" when: not item.value.env_file or (item.value.env_file and env_stat.stat.exists) - register: compose_result \ No newline at end of file + register: compose_result diff --git a/mirror/ansible/roles/system/containers/tasks/main.yml b/mirror/ansible/roles/system/containers/tasks/main.yml index edc5c1e6..3af63b38 100644 --- a/mirror/ansible/roles/system/containers/tasks/main.yml +++ b/mirror/ansible/roles/system/containers/tasks/main.yml @@ -1,19 +1,19 @@ --- - name: Ensure /opt/containers exists - file: + ansible.builtin.file: path: /opt/containers state: directory owner: root group: root - mode: '0755' + mode: "0755" - name: Copy containers directory to remote node - copy: + ansible.builtin.copy: src: "{{ playbook_dir }}/../containers/" dest: /opt/containers/ owner: root group: root - mode: '0755' + mode: "0755" notify: Restart containers - name: Create shared app-infra Docker network @@ -23,24 +23,24 @@ state: present - name: Create cloudflared .env file - copy: + ansible.builtin.copy: content: | TUNNEL_TOKEN={{ lookup('env', 'CLOUDFLARED_TOKEN') }} dest: /opt/containers/cloudflared/.env owner: root group: root - mode: '0600' + mode: "0600" when: lookup('env', 'CLOUDFLARED_TOKEN') | length > 0 - name: Deploy docker-compose projects - include_tasks: deploy_stack.yml + ansible.builtin.include_tasks: deploy_stack.yml loop: "{{ docker_stacks | dict2items }}" - name: Clean up Docker system community.docker.docker_prune: - containers: yes - images: yes + containers: true + images: true images_filters: dangling: false - networks: yes - builder_cache: yes + networks: true + builder_cache: true diff --git a/mirror/ansible/roles/system/migrations/defaults/main.yml b/mirror/ansible/roles/system/migrations/defaults/main.yml index 25427a89..94346280 100644 --- a/mirror/ansible/roles/system/migrations/defaults/main.yml +++ b/mirror/ansible/roles/system/migrations/defaults/main.yml @@ -1,3 +1,3 @@ --- migration_dir: "{{ playbook_dir }}/migrations" -migration_state_file: /opt/ansible/migrations.db \ No newline at end of file +migration_state_file: /opt/ansible/migrations.db diff --git a/mx1/ansible/defaults/main.yml b/mx1/ansible/defaults/main.yml index 8dbc3ba3..96322253 100644 --- a/mx1/ansible/defaults/main.yml +++ b/mx1/ansible/defaults/main.yml @@ -5,7 +5,7 @@ nginx_dh_size: 4096 os_user_pw_ageing: false os_auth_pam_passwdqc_enable: false sftp_enabled: true -ssh_allow_tcp_forwarding: 'local' +ssh_allow_tcp_forwarding: "local" ssh_print_last_log: true ssh_permit_root_login: "without-password" ssh_permit_tunnel: "yes" diff --git a/mx1/ansible/playbook.yml b/mx1/ansible/playbook.yml index 926581fa..b835ee1d 100644 --- a/mx1/ansible/playbook.yml +++ b/mx1/ansible/playbook.yml @@ -21,14 +21,14 @@ no_log: true block: - name: Apply OS hardening - include_role: + ansible.builtin.include_role: name: devsec.hardening.os_hardening when: scheduled_run - name: Apply ssh hardening - include_role: + ansible.builtin.include_role: name: devsec.hardening.ssh_hardening when: scheduled_run - name: Apply nginx hardening - include_role: + ansible.builtin.include_role: name: devsec.hardening.nginx_hardening when: scheduled_run diff --git a/mx1/ansible/roles/system/config/handlers/main.yml b/mx1/ansible/roles/system/config/handlers/main.yml index 2f3e451c..2f03e43d 100644 --- a/mx1/ansible/roles/system/config/handlers/main.yml +++ b/mx1/ansible/roles/system/config/handlers/main.yml @@ -9,11 +9,11 @@ ansible.builtin.systemd_service: name: logrotate.timer state: restarted - daemon_reload: yes + daemon_reload: true become: true - name: Restart Nginx - service: + ansible.builtin.service: name: nginx state: restarted become: true diff --git a/mx1/ansible/roles/system/containers/tasks/deploy_stack.yml b/mx1/ansible/roles/system/containers/tasks/deploy_stack.yml index 090182c2..494046f2 100644 --- a/mx1/ansible/roles/system/containers/tasks/deploy_stack.yml +++ b/mx1/ansible/roles/system/containers/tasks/deploy_stack.yml @@ -9,4 +9,4 @@ project_src: "/opt/containers/{{ item.key }}" state: "{{ item.value.state }}" when: not item.value.env_file or (item.value.env_file and env_stat.stat.exists) - register: compose_result \ No newline at end of file + register: compose_result diff --git a/mx1/ansible/roles/system/containers/tasks/main.yml b/mx1/ansible/roles/system/containers/tasks/main.yml index 040f7f51..4ec92320 100644 --- a/mx1/ansible/roles/system/containers/tasks/main.yml +++ b/mx1/ansible/roles/system/containers/tasks/main.yml @@ -1,19 +1,19 @@ --- - name: Ensure /opt/containers exists - file: + ansible.builtin.file: path: /opt/containers state: directory owner: root group: root - mode: '0755' + mode: "0755" - name: Copy containers directory to remote node - copy: + ansible.builtin.copy: src: "{{ playbook_dir }}/../containers/" dest: /opt/containers/ owner: root group: root - mode: '0755' + mode: "0755" notify: Restart containers - name: Create shared app-infra Docker network @@ -26,14 +26,14 @@ state: present - name: Manage stalwart .env entries - lineinfile: + ansible.builtin.lineinfile: path: /opt/containers/stalwart/.env - regexp: '^{{ item.key }}=' + regexp: "^{{ item.key }}=" line: '{{ item.key }}={{ lookup("env", item.value) }}' - create: yes + create: true owner: root group: root - mode: '0600' + mode: "0600" loop: - key: PG_PASS value: STALWART_PG_PASSWORD @@ -42,17 +42,17 @@ when: lookup('env', item.value) | length > 0 - name: Deploy docker-compose projects - include_tasks: deploy_stack.yml + ansible.builtin.include_tasks: deploy_stack.yml loop: "{{ docker_stacks | dict2items }}" - name: Clean up Docker system community.docker.docker_prune: - containers: yes - images: yes + containers: true + images: true images_filters: dangling: false - networks: yes - builder_cache: yes + networks: true + builder_cache: true # - name: Show compose results # debug: diff --git a/mx1/ansible/roles/system/migrations/defaults/main.yml b/mx1/ansible/roles/system/migrations/defaults/main.yml index 25427a89..94346280 100644 --- a/mx1/ansible/roles/system/migrations/defaults/main.yml +++ b/mx1/ansible/roles/system/migrations/defaults/main.yml @@ -1,3 +1,3 @@ --- migration_dir: "{{ playbook_dir }}/migrations" -migration_state_file: /opt/ansible/migrations.db \ No newline at end of file +migration_state_file: /opt/ansible/migrations.db diff --git a/mx1/ansible/roles/system/monit/tasks/main.yml b/mx1/ansible/roles/system/monit/tasks/main.yml index e8ec75b1..5b2139c1 100644 --- a/mx1/ansible/roles/system/monit/tasks/main.yml +++ b/mx1/ansible/roles/system/monit/tasks/main.yml @@ -13,7 +13,7 @@ state: directory owner: root group: root - mode: '0755' + mode: "0755" loop: - /etc/monit - /etc/monit.d @@ -25,7 +25,7 @@ dest: /etc/monitrc owner: root group: root - mode: '0600' + mode: "0600" notify: Reload monit become: true @@ -35,7 +35,7 @@ dest: /etc/monit.d/disk.cfg owner: root group: root - mode: '0600' + mode: "0600" notify: Reload monit become: true @@ -45,7 +45,7 @@ dest: /etc/monit/notify-chat.sh owner: root group: root - mode: '0755' + mode: "0755" notify: Reload monit become: true @@ -55,7 +55,7 @@ dest: /etc/monit/check-docker-stack.sh owner: root group: root - mode: '0755' + mode: "0755" notify: Reload monit become: true @@ -65,7 +65,7 @@ dest: /etc/monit.d/containers.cfg owner: root group: root - mode: '0600' + mode: "0600" notify: Reload monit become: true @@ -75,7 +75,7 @@ dest: /etc/monit/notify-chat.env owner: root group: root - mode: '0600' + mode: "0600" force: false become: true @@ -85,7 +85,7 @@ dest: /etc/monit.d/mail.cfg owner: root group: root - mode: '0600' + mode: "0600" force: false become: true diff --git a/tower/ansible/defaults/main.yml b/tower/ansible/defaults/main.yml index 9861aa0f..efaa817b 100644 --- a/tower/ansible/defaults/main.yml +++ b/tower/ansible/defaults/main.yml @@ -4,7 +4,7 @@ dns_search_domain: "{{ lookup('env', 'DNS_SEARCH_DOMAIN') }}" os_env_umask: "022" os_user_pw_ageing: false os_auth_pam_passwdqc_enable: false -ssh_allow_tcp_forwarding: 'local' +ssh_allow_tcp_forwarding: "local" ssh_print_last_log: true ssh_permit_root_login: "without-password" ssh_permit_tunnel: "yes" diff --git a/tower/ansible/playbook.yml b/tower/ansible/playbook.yml index 149b8730..fbaefd95 100644 --- a/tower/ansible/playbook.yml +++ b/tower/ansible/playbook.yml @@ -20,10 +20,10 @@ no_log: true block: - name: Apply OS hardening - include_role: + ansible.builtin.include_role: name: devsec.hardening.os_hardening when: scheduled_run - name: Apply ssh hardening - include_role: + ansible.builtin.include_role: name: devsec.hardening.ssh_hardening when: scheduled_run diff --git a/tower/ansible/roles/system/config/tasks/main.yml b/tower/ansible/roles/system/config/tasks/main.yml index 3c7daa36..1ca5ba41 100644 --- a/tower/ansible/roles/system/config/tasks/main.yml +++ b/tower/ansible/roles/system/config/tasks/main.yml @@ -7,50 +7,50 @@ - name: DNS block: - - name: Ensure systemd-resolved is running - ansible.builtin.systemd_service: - name: systemd-resolved - state: started - enabled: yes - become: true + - name: Ensure systemd-resolved is running + ansible.builtin.systemd_service: + name: systemd-resolved + state: started + enabled: true + become: true - - name: Configure systemd-resolved for Google and Quad9 DNS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?DNS=' - line: 'DNS=8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for Google and Quad9 DNS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?DNS=" + line: "DNS=8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for ControlD and Cloudflare Fallback DNS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?FallbackDNS=' - line: 'FallbackDNS=76.76.2.0 76.76.10.0 1.1.1.1 1.0.0.1' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for ControlD and Cloudflare Fallback DNS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?FallbackDNS=" + line: "FallbackDNS=76.76.2.0 76.76.10.0 1.1.1.1 1.0.0.1" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for DNSOverTLS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?DNSOverTLS=' - line: 'DNSOverTLS=no' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for DNSOverTLS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?DNSOverTLS=" + line: "DNSOverTLS=no" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for Cache - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?Cache=' - line: 'Cache=yes' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for Cache + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?Cache=" + line: "Cache=yes" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for Domain - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?Domain=' - line: "Domain={{ dns_search_domain }}" - notify: Restart systemd-resolved - become: true - when: dns_search_domain | default('') | length > 0 + - name: Configure systemd-resolved for Domain + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?Domain=" + line: "Domain={{ dns_search_domain }}" + notify: Restart systemd-resolved + become: true + when: dns_search_domain | default('') | length > 0 diff --git a/tower/ansible/roles/system/containers/tasks/deploy_stack.yml b/tower/ansible/roles/system/containers/tasks/deploy_stack.yml index 090182c2..494046f2 100644 --- a/tower/ansible/roles/system/containers/tasks/deploy_stack.yml +++ b/tower/ansible/roles/system/containers/tasks/deploy_stack.yml @@ -9,4 +9,4 @@ project_src: "/opt/containers/{{ item.key }}" state: "{{ item.value.state }}" when: not item.value.env_file or (item.value.env_file and env_stat.stat.exists) - register: compose_result \ No newline at end of file + register: compose_result diff --git a/tower/ansible/roles/system/containers/tasks/main.yml b/tower/ansible/roles/system/containers/tasks/main.yml index edc5c1e6..3af63b38 100644 --- a/tower/ansible/roles/system/containers/tasks/main.yml +++ b/tower/ansible/roles/system/containers/tasks/main.yml @@ -1,19 +1,19 @@ --- - name: Ensure /opt/containers exists - file: + ansible.builtin.file: path: /opt/containers state: directory owner: root group: root - mode: '0755' + mode: "0755" - name: Copy containers directory to remote node - copy: + ansible.builtin.copy: src: "{{ playbook_dir }}/../containers/" dest: /opt/containers/ owner: root group: root - mode: '0755' + mode: "0755" notify: Restart containers - name: Create shared app-infra Docker network @@ -23,24 +23,24 @@ state: present - name: Create cloudflared .env file - copy: + ansible.builtin.copy: content: | TUNNEL_TOKEN={{ lookup('env', 'CLOUDFLARED_TOKEN') }} dest: /opt/containers/cloudflared/.env owner: root group: root - mode: '0600' + mode: "0600" when: lookup('env', 'CLOUDFLARED_TOKEN') | length > 0 - name: Deploy docker-compose projects - include_tasks: deploy_stack.yml + ansible.builtin.include_tasks: deploy_stack.yml loop: "{{ docker_stacks | dict2items }}" - name: Clean up Docker system community.docker.docker_prune: - containers: yes - images: yes + containers: true + images: true images_filters: dangling: false - networks: yes - builder_cache: yes + networks: true + builder_cache: true diff --git a/tower/ansible/roles/system/migrations/defaults/main.yml b/tower/ansible/roles/system/migrations/defaults/main.yml index 25427a89..94346280 100644 --- a/tower/ansible/roles/system/migrations/defaults/main.yml +++ b/tower/ansible/roles/system/migrations/defaults/main.yml @@ -1,3 +1,3 @@ --- migration_dir: "{{ playbook_dir }}/migrations" -migration_state_file: /opt/ansible/migrations.db \ No newline at end of file +migration_state_file: /opt/ansible/migrations.db diff --git a/tower/containers/cloudflared/docker-compose.yml b/tower/containers/cloudflared/docker-compose.yml index 82fd5d41..83f75c94 100644 --- a/tower/containers/cloudflared/docker-compose.yml +++ b/tower/containers/cloudflared/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: cloudflared: image: cloudflare/cloudflared:2026.9.3@sha256:072c067d25ccbe61d46e18f0d0723255f2bb5304f7317caa95b27031520ff92c diff --git a/tower/containers/cobalt/docker-compose.yml b/tower/containers/cobalt/docker-compose.yml index d609cfe7..ad58a9fb 100644 --- a/tower/containers/cobalt/docker-compose.yml +++ b/tower/containers/cobalt/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: cobalt-api: image: ghcr.io/imputnet/cobalt:11.7.1 diff --git a/tower/containers/headscale/docker-compose.yml b/tower/containers/headscale/docker-compose.yml index 4a4b4602..a130fac5 100644 --- a/tower/containers/headscale/docker-compose.yml +++ b/tower/containers/headscale/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: headscale: image: headscale/headscale:v0.29.4@sha256:8833f828b414c0907b7e5c71da76473216fe17cce0818a166b536ec552c0903f diff --git a/tower/terraform/.terraform.lock.hcl b/tower/terraform/.terraform.lock.hcl index 598723d6..31d93a0c 100644 --- a/tower/terraform/.terraform.lock.hcl +++ b/tower/terraform/.terraform.lock.hcl @@ -20,6 +20,7 @@ provider "registry.opentofu.org/cloudflare/cloudflare" { "zh:ab0322bcb0d4e465f8603b39f7e743c836b22ae775c03ed677ad999a6a1b8bf4", "zh:c47e38911c92e0b2ffe7be1d36d96b79dd2af79bc61745d6afe5a730cae9f287", "zh:df3f13ae57104ce2f7619cc6c050ab90a5a3299470cdbd2ab8dd3809b84edcb8", + "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", "zh:fb7de9d22b02367036c146bd683c8968ec4bbb56aca3185fa84146d118a2bf96", ] } @@ -50,6 +51,7 @@ provider "registry.opentofu.org/oracle/oci" { "zh:74ea3dde5584203fe2566e3da21eeb33ee366c801e36985841acc074c905c992", "zh:774afb82cf093d32ee280cd5d94b26cb49680d5aa28c8a2c49f5c11d9d18ce21", "zh:8c5d47c70d5c9c6242f314ab78cb65682a060f38c77792e5c10b137ee63e1ea6", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", "zh:a474cbf8d4f2742531a51751ba9ae0eb5366ec878355439dc00e0b9c4db8b449", "zh:b0dc7afeca141a2fb3b73c182708543d33aaa0a02ae301de840206e38bbe3eda", "zh:bb6def0e584a85d60d0d395b36793735514f409344fd8539fc756803eb52249a", diff --git a/tower/terraform/network.tf b/tower/terraform/network.tf index 3b7dd47b..85f16632 100644 --- a/tower/terraform/network.tf +++ b/tower/terraform/network.tf @@ -153,9 +153,9 @@ resource "oci_core_security_list" "tower" { } resource "oci_core_subnet" "tower" { - compartment_id = var.compartment_ocid - vcn_id = oci_core_vcn.tower.id - cidr_block = var.subnet_cidr + compartment_id = var.compartment_ocid + vcn_id = oci_core_vcn.tower.id + cidr_block = var.subnet_cidr # Carve a /64 from the VCN's OCI-assigned /56 ipv6cidr_block = cidrsubnet(oci_core_vcn.tower.ipv6cidr_blocks[0], 8, 0) display_name = "${var.instance_display_name}-subnet" diff --git a/web1/ansible/defaults/main.yml b/web1/ansible/defaults/main.yml index 8f38c692..6fdf446d 100644 --- a/web1/ansible/defaults/main.yml +++ b/web1/ansible/defaults/main.yml @@ -6,7 +6,7 @@ os_env_umask: "022" os_user_pw_ageing: false os_auth_pam_passwdqc_enable: false sftp_enabled: true -ssh_allow_tcp_forwarding: 'local' +ssh_allow_tcp_forwarding: "local" ssh_print_last_log: true ssh_permit_root_login: "without-password" ssh_permit_tunnel: "yes" @@ -30,10 +30,10 @@ sysctl_overwrite: php_custom_extensions: "8.4": - { name: brotli, version: "0.18.3", apt_deps: ["libbrotli-dev"] } - - { name: zstd, version: "0.15.2", apt_deps: ["libzstd-dev"] } + - { name: zstd, version: "0.15.2", apt_deps: ["libzstd-dev"] } "8.5": - { name: brotli, version: "0.18.3", apt_deps: ["libbrotli-dev"] } - - { name: zstd, version: "0.15.2", apt_deps: ["libzstd-dev"] } + - { name: zstd, version: "0.15.2", apt_deps: ["libzstd-dev"] } # Bound Docker's default json-file logs for containers that do not override the # driver in their Compose project. diff --git a/web1/ansible/migrations/20260121_0001_relocate_media_for_authentik.yml b/web1/ansible/migrations/20260121_0001_relocate_media_for_authentik.yml index dc0754d2..51d99ff1 100644 --- a/web1/ansible/migrations/20260121_0001_relocate_media_for_authentik.yml +++ b/web1/ansible/migrations/20260121_0001_relocate_media_for_authentik.yml @@ -6,10 +6,10 @@ file: path: /opt/containers/authentik/data state: directory - mode: '0755' + mode: "0755" - name: Relocate authentik media directory command: mv /opt/containers/authentik/media /opt/containers/authentik/data/media args: creates: /opt/containers/authentik/data/media - removes: /opt/containers/authentik/media \ No newline at end of file + removes: /opt/containers/authentik/media diff --git a/web1/ansible/migrations/20260127_0002_switch_crowdsec_bouncer_to_nftables.yml b/web1/ansible/migrations/20260127_0002_switch_crowdsec_bouncer_to_nftables.yml index f6e2e019..4bec56de 100644 --- a/web1/ansible/migrations/20260127_0002_switch_crowdsec_bouncer_to_nftables.yml +++ b/web1/ansible/migrations/20260127_0002_switch_crowdsec_bouncer_to_nftables.yml @@ -15,4 +15,4 @@ - name: Restart CrowdSec firewall bouncer service service: name: crowdsec-firewall-bouncer - state: restarted \ No newline at end of file + state: restarted diff --git a/web1/ansible/migrations/20260204_0003_fix_crowdsec_nftables_priority.yml b/web1/ansible/migrations/20260204_0003_fix_crowdsec_nftables_priority.yml index 4f0ff3a4..e8b4e297 100644 --- a/web1/ansible/migrations/20260204_0003_fix_crowdsec_nftables_priority.yml +++ b/web1/ansible/migrations/20260204_0003_fix_crowdsec_nftables_priority.yml @@ -12,8 +12,8 @@ - name: Update CrowdSec firewall bouncer mode to nftables lineinfile: path: /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml - regexp: '^mode:' - line: 'mode: nftables' + regexp: "^mode:" + line: "mode: nftables" when: bouncer_config.stat.exists - name: Restart CrowdSec firewall bouncer to apply new config diff --git a/web1/ansible/playbook.yml b/web1/ansible/playbook.yml index 3f8fa0ad..2bfd3bf5 100644 --- a/web1/ansible/playbook.yml +++ b/web1/ansible/playbook.yml @@ -24,14 +24,14 @@ no_log: true block: - name: Apply OS hardening - include_role: + ansible.builtin.include_role: name: devsec.hardening.os_hardening when: scheduled_run - name: Apply nginx hardening - include_role: + ansible.builtin.include_role: name: devsec.hardening.nginx_hardening when: scheduled_run - name: Apply ssh hardening - include_role: + ansible.builtin.include_role: name: devsec.hardening.ssh_hardening when: scheduled_run diff --git a/web1/ansible/roles/system/backup/tasks/main.yml b/web1/ansible/roles/system/backup/tasks/main.yml index f93bf2a0..df0c4a6b 100644 --- a/web1/ansible/roles/system/backup/tasks/main.yml +++ b/web1/ansible/roles/system/backup/tasks/main.yml @@ -1,6 +1,6 @@ --- - name: Include borgbase.ansible_role_borgbackup role - include_role: + ansible.builtin.include_role: name: borgbase.ansible_role_borgbackup vars: borg_source_directories: @@ -8,7 +8,7 @@ - "/etc" borg_exclude_patterns: - "/home/anatoli" - - "/home/clp" + - "/home/clp" - "/home/mysql" - "*.pyc" - "*.tmp" @@ -39,5 +39,5 @@ name: borgmatic.timer state: started enabled: true - daemon_reload: yes + daemon_reload: true become: true diff --git a/web1/ansible/roles/system/config/handlers/main.yml b/web1/ansible/roles/system/config/handlers/main.yml index f3838ef7..9bb4b1c6 100644 --- a/web1/ansible/roles/system/config/handlers/main.yml +++ b/web1/ansible/roles/system/config/handlers/main.yml @@ -9,7 +9,7 @@ ansible.builtin.systemd_service: name: logrotate.timer state: restarted - daemon_reload: yes + daemon_reload: true become: true - name: Reload nginx diff --git a/web1/ansible/roles/system/config/tasks/main.yml b/web1/ansible/roles/system/config/tasks/main.yml index e71c35c2..3d9755d1 100644 --- a/web1/ansible/roles/system/config/tasks/main.yml +++ b/web1/ansible/roles/system/config/tasks/main.yml @@ -1,89 +1,89 @@ --- - name: UFW block: - - name: Allow incoming UDP port 7844 - community.general.ufw: - rule: allow - proto: udp - from_port: 7844 - comment: Allow incoming traffic from UDP port 7844 - delete: true - become: true + - name: Allow incoming UDP port 7844 + community.general.ufw: + rule: allow + proto: udp + from_port: 7844 + comment: Allow incoming traffic from UDP port 7844 + delete: true + become: true - name: DNS block: - - name: Ensure systemd-resolved is running - ansible.builtin.systemd_service: - name: systemd-resolved - state: started - enabled: yes - become: true + - name: Ensure systemd-resolved is running + ansible.builtin.systemd_service: + name: systemd-resolved + state: started + enabled: true + become: true - - name: Configure systemd-resolved for Google and Quad9 DNS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?DNS=' - line: 'DNS=8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for Google and Quad9 DNS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?DNS=" + line: "DNS=8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for ControlD and Cloudflare Fallback DNS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?FallbackDNS=' - line: 'FallbackDNS=76.76.2.0 76.76.10.0 1.1.1.1 1.0.0.1' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for ControlD and Cloudflare Fallback DNS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?FallbackDNS=" + line: "FallbackDNS=76.76.2.0 76.76.10.0 1.1.1.1 1.0.0.1" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for DNSOverTLS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?DNSOverTLS=' - line: 'DNSOverTLS=no' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for DNSOverTLS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?DNSOverTLS=" + line: "DNSOverTLS=no" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for Cache - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?Cache=' - line: 'Cache=yes' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for Cache + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?Cache=" + line: "Cache=yes" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for Domain - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?Domain=' - line: "Domain={{ dns_search_domain }}" - when: dns_search_domain | default('') | length > 0 - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for Domain + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?Domain=" + line: "Domain={{ dns_search_domain }}" + when: dns_search_domain | default('') | length > 0 + notify: Restart systemd-resolved + become: true - name: Logrotate block: - - name: Remove obsolete Docker logrotate configuration - ansible.builtin.file: - path: /etc/logrotate.d/docker - state: absent - become: true + - name: Remove obsolete Docker logrotate configuration + ansible.builtin.file: + path: /etc/logrotate.d/docker + state: absent + become: true - - name: Create logrotate.timer.d directory - ansible.builtin.file: - path: /etc/systemd/system/logrotate.timer.d - state: directory - mode: '0755' - become: true + - name: Create logrotate.timer.d directory + ansible.builtin.file: + path: /etc/systemd/system/logrotate.timer.d + state: directory + mode: "0755" + become: true - - name: Override logrotate.timer - ansible.builtin.copy: - src: files/logrotate.timer.d/override.conf - dest: /etc/systemd/system/logrotate.timer.d/override.conf - owner: root - group: root - mode: '0644' - notify: Restart logrotate.timer - become: true + - name: Override logrotate.timer + ansible.builtin.copy: + src: files/logrotate.timer.d/override.conf + dest: /etc/systemd/system/logrotate.timer.d/override.conf + owner: root + group: root + mode: "0644" + notify: Restart logrotate.timer + become: true - name: Nginx Compression # CloudPanel pre-loads brotli via /etc/nginx/modules-enabled/50-mod-ngx-brotli.conf @@ -95,32 +95,38 @@ - name: Tune gzip_comp_level ansible.builtin.replace: path: /etc/nginx/nginx.conf - regexp: '^(\s*)gzip_comp_level\s+\d+\s*;' - replace: '\g<1>gzip_comp_level 5;' + regexp: "^(\\s*)gzip_comp_level\\s+\\d+\\s*;" + replace: "\\g<1>gzip_comp_level 5;" notify: Reload nginx become: true - name: Tune gzip_types ansible.builtin.replace: path: /etc/nginx/nginx.conf - regexp: '^(\s*)gzip_types[^;]*;' - replace: '\g<1>gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml application/xml+rss application/atom+xml application/rss+xml application/wasm application/manifest+json image/svg+xml font/ttf font/otf application/vnd.ms-fontobject;' + regexp: "^(\\s*)gzip_types[^;]*;" + replace: >- + \g<1>gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml application/xml+rss + application/atom+xml application/rss+xml application/wasm application/manifest+json image/svg+xml font/ttf font/otf + application/vnd.ms-fontobject; notify: Reload nginx become: true - name: Tune brotli_comp_level ansible.builtin.replace: path: /etc/nginx/nginx.conf - regexp: '^(\s*)brotli_comp_level\s+\d+\s*;' - replace: '\g<1>brotli_comp_level 6;' + regexp: "^(\\s*)brotli_comp_level\\s+\\d+\\s*;" + replace: "\\g<1>brotli_comp_level 6;" notify: Reload nginx become: true - name: Tune brotli_types ansible.builtin.replace: path: /etc/nginx/nginx.conf - regexp: '^(\s*)brotli_types[^;]*;' - replace: '\g<1>brotli_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml application/xml+rss application/atom+xml application/rss+xml application/wasm application/manifest+json image/svg+xml font/ttf font/otf application/vnd.ms-fontobject;' + regexp: "^(\\s*)brotli_types[^;]*;" + replace: >- + \g<1>brotli_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml application/xml+rss + application/atom+xml application/rss+xml application/wasm application/manifest+json image/svg+xml font/ttf font/otf + application/vnd.ms-fontobject; notify: Reload nginx become: true diff --git a/web1/ansible/roles/system/containers/tasks/authentik.yml b/web1/ansible/roles/system/containers/tasks/authentik.yml index 616ddbe3..6e72faa9 100644 --- a/web1/ansible/roles/system/containers/tasks/authentik.yml +++ b/web1/ansible/roles/system/containers/tasks/authentik.yml @@ -2,19 +2,19 @@ - name: Setup Authentik Infrastructure block: - name: Create authentik directories - file: + ansible.builtin.file: path: "/opt/containers/authentik/{{ item }}" state: directory owner: "1000" group: "1000" - mode: '0755' + mode: "0755" loop: - media - custom-templates - certs - name: Check if Authentik .env file exists - stat: + ansible.builtin.stat: path: /opt/containers/authentik/.env register: authentik_env_file @@ -27,6 +27,6 @@ when: authentik_env_file.stat.exists - name: Show message when .env file is missing - debug: + ansible.builtin.debug: msg: "Authentik .env file is missing. Please create it manually at /opt/containers/authentik/.env" when: not authentik_env_file.stat.exists diff --git a/web1/ansible/roles/system/containers/tasks/main.yml b/web1/ansible/roles/system/containers/tasks/main.yml index b344d00e..7bc9dfa9 100644 --- a/web1/ansible/roles/system/containers/tasks/main.yml +++ b/web1/ansible/roles/system/containers/tasks/main.yml @@ -1,28 +1,28 @@ --- - name: Ensure /opt/containers exists - file: + ansible.builtin.file: path: /opt/containers state: directory owner: root group: root - mode: '0755' + mode: "0755" - name: Copy OpenCloud Compose project to remote node - copy: + ansible.builtin.copy: src: "{{ playbook_dir }}/../containers/opencloud/" dest: /opt/containers/opencloud/ owner: root group: root - mode: '0755' + mode: "0755" notify: Restart OpenCloud - name: Copy containers directory to remote node - copy: + ansible.builtin.copy: src: "{{ playbook_dir }}/../containers/" dest: /opt/containers/ owner: root group: root - mode: '0755' + mode: "0755" - name: Create shared app-infra Docker network community.docker.docker_network: @@ -37,34 +37,34 @@ state: present - name: Ensure webmail env.file exists - copy: + ansible.builtin.copy: content: "" dest: /opt/containers/webmail/env.file - force: no - mode: '0644' + force: false + mode: "0644" - name: Ensure roundcube .env exists - copy: + ansible.builtin.copy: content: "" dest: /opt/containers/roundcube/.env - force: no - mode: '0644' + force: false + mode: "0644" - name: Setup Authentik Infrastructure - include_tasks: authentik.yml + ansible.builtin.include_tasks: authentik.yml - name: Deploy docker-compose projects - include_tasks: deploy_stack.yml + ansible.builtin.include_tasks: deploy_stack.yml loop: "{{ docker_stacks | dict2items }}" - name: Configure OpenCloud Storage Box and systemd services - include_tasks: opencloud.yml + ansible.builtin.include_tasks: opencloud.yml - name: Clean up Docker system community.docker.docker_prune: - containers: yes - images: yes + containers: true + images: true images_filters: dangling: false - networks: yes - builder_cache: yes + networks: true + builder_cache: true diff --git a/web1/ansible/roles/system/containers/tasks/opencloud.yml b/web1/ansible/roles/system/containers/tasks/opencloud.yml index adf58a49..74494dcc 100644 --- a/web1/ansible/roles/system/containers/tasks/opencloud.yml +++ b/web1/ansible/roles/system/containers/tasks/opencloud.yml @@ -18,11 +18,11 @@ - path: /mnt/opencloud owner: root group: root - mode: '0755' + mode: "0755" - path: /var/lib/opencloud owner: root group: root - mode: '0700' + mode: "0700" - name: Create Storage Box remote placeholder when absent ansible.builtin.copy: @@ -30,7 +30,7 @@ dest: /etc/opencloud-storagebox.env owner: root group: root - mode: '0600' + mode: "0600" force: false - name: Create Storage Box credentials placeholder when absent @@ -39,7 +39,7 @@ dest: /etc/opencloud-storagebox.credentials owner: root group: root - mode: '0600' + mode: "0600" force: false - name: Deploy OpenCloud Storage Box mount helper @@ -48,7 +48,7 @@ dest: /usr/local/sbin/mount-opencloud-storagebox owner: root group: root - mode: '0755' + mode: "0755" notify: Restart OpenCloud - name: Deploy OpenCloud Storage Box compatibility helper @@ -57,7 +57,7 @@ dest: /usr/local/sbin/check-opencloud-storagebox owner: root group: root - mode: '0755' + mode: "0755" notify: Restart OpenCloud - name: Remove obsolete OpenCloud Storage Box condition helper @@ -71,7 +71,7 @@ dest: /etc/systemd/system/opencloud-storagebox.service owner: root group: root - mode: '0644' + mode: "0644" notify: - Reload systemd daemon - Restart OpenCloud @@ -82,7 +82,7 @@ dest: /etc/systemd/system/opencloud.service owner: root group: root - mode: '0644' + mode: "0644" notify: - Reload systemd daemon - Restart OpenCloud @@ -93,7 +93,7 @@ dest: /etc/systemd/system/opencloud-storagebox-compatibility.service owner: root group: root - mode: '0644' + mode: "0644" notify: Reload systemd daemon - name: Reload systemd before OpenCloud service management @@ -172,9 +172,9 @@ ansible.builtin.file: path: /mnt/opencloud/garage state: directory - owner: '1000' - group: '1000' - mode: '0770' + owner: "1000" + group: "1000" + mode: "0770" when: opencloud_host_config_ready - name: Run initial Storage Box persistence probe diff --git a/web1/ansible/roles/system/crowdsec/handlers/main.yml b/web1/ansible/roles/system/crowdsec/handlers/main.yml index 50521d26..fd206978 100644 --- a/web1/ansible/roles/system/crowdsec/handlers/main.yml +++ b/web1/ansible/roles/system/crowdsec/handlers/main.yml @@ -1,9 +1,10 @@ +--- - name: restart crowdsec - service: + ansible.builtin.service: name: crowdsec state: restarted - name: restart crowdsec-firewall-bouncer - service: + ansible.builtin.service: name: crowdsec-firewall-bouncer - state: restarted \ No newline at end of file + state: restarted diff --git a/web1/ansible/roles/system/migrations/defaults/main.yml b/web1/ansible/roles/system/migrations/defaults/main.yml index 25427a89..94346280 100644 --- a/web1/ansible/roles/system/migrations/defaults/main.yml +++ b/web1/ansible/roles/system/migrations/defaults/main.yml @@ -1,3 +1,3 @@ --- migration_dir: "{{ playbook_dir }}/migrations" -migration_state_file: /opt/ansible/migrations.db \ No newline at end of file +migration_state_file: /opt/ansible/migrations.db diff --git a/web1/ansible/roles/system/monit/tasks/main.yml b/web1/ansible/roles/system/monit/tasks/main.yml index e8ec75b1..5b2139c1 100644 --- a/web1/ansible/roles/system/monit/tasks/main.yml +++ b/web1/ansible/roles/system/monit/tasks/main.yml @@ -13,7 +13,7 @@ state: directory owner: root group: root - mode: '0755' + mode: "0755" loop: - /etc/monit - /etc/monit.d @@ -25,7 +25,7 @@ dest: /etc/monitrc owner: root group: root - mode: '0600' + mode: "0600" notify: Reload monit become: true @@ -35,7 +35,7 @@ dest: /etc/monit.d/disk.cfg owner: root group: root - mode: '0600' + mode: "0600" notify: Reload monit become: true @@ -45,7 +45,7 @@ dest: /etc/monit/notify-chat.sh owner: root group: root - mode: '0755' + mode: "0755" notify: Reload monit become: true @@ -55,7 +55,7 @@ dest: /etc/monit/check-docker-stack.sh owner: root group: root - mode: '0755' + mode: "0755" notify: Reload monit become: true @@ -65,7 +65,7 @@ dest: /etc/monit.d/containers.cfg owner: root group: root - mode: '0600' + mode: "0600" notify: Reload monit become: true @@ -75,7 +75,7 @@ dest: /etc/monit/notify-chat.env owner: root group: root - mode: '0600' + mode: "0600" force: false become: true @@ -85,7 +85,7 @@ dest: /etc/monit.d/mail.cfg owner: root group: root - mode: '0600' + mode: "0600" force: false become: true diff --git a/web1/ansible/roles/system/php/tasks/main.yml b/web1/ansible/roles/system/php/tasks/main.yml index edf3390d..4fd6cf58 100644 --- a/web1/ansible/roles/system/php/tasks/main.yml +++ b/web1/ansible/roles/system/php/tasks/main.yml @@ -3,7 +3,7 @@ ansible.builtin.systemd_service: name: "php{{ item }}-fpm" state: stopped - enabled: no + enabled: false loop: - "7.1" - "7.2" @@ -18,7 +18,7 @@ ansible.builtin.file: path: "/etc/php/{{ item }}/fpm/pool.d" mode: "a+r" - recurse: yes + recurse: true loop: - "7.1" - "7.2" diff --git a/web1/containers/1min-relay/docker-compose.yml b/web1/containers/1min-relay/docker-compose.yml index f652da2d..35273444 100644 --- a/web1/containers/1min-relay/docker-compose.yml +++ b/web1/containers/1min-relay/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: 1min-relay: image: ghcr.io/thundersquared/1min-relay:v1.1.0 diff --git a/web1/containers/calcom/docker-compose.yml b/web1/containers/calcom/docker-compose.yml index b354a0a9..d616e6d5 100644 --- a/web1/containers/calcom/docker-compose.yml +++ b/web1/containers/calcom/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: database: image: postgres:18-alpine diff --git a/web1/containers/hermes/docker-compose.yml b/web1/containers/hermes/docker-compose.yml index 744e99de..09d653da 100644 --- a/web1/containers/hermes/docker-compose.yml +++ b/web1/containers/hermes/docker-compose.yml @@ -2,7 +2,7 @@ services: gateway: # renovate: datasource=docker depName=nousresearch/hermes-agent - image: nousresearch/hermes-agent:v2026.9.24@sha256:fca358f12efd65bfaaca05884166f15c0e2788375ca30d77061ac1ebc96452b7 # yamllint disable-line rule:line-length + image: nousresearch/hermes-agent:v2026.9.24@sha256:fca358f12efd65bfaaca05884166f15c0e2788375ca30d77061ac1ebc96452b7 # yamllint disable-line rule:line-length command: ["gateway", "run"] env_file: - .env diff --git a/web1/containers/open-webui/docker-compose.yml b/web1/containers/open-webui/docker-compose.yml index b0f0b40d..2c56144c 100644 --- a/web1/containers/open-webui/docker-compose.yml +++ b/web1/containers/open-webui/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: open-webui: image: ghcr.io/open-webui/open-webui:v0.11.4@sha256:9591b13f13843c7721c2b8eaf7382846c81b3ffe126526d1888d1fed50c6a33f @@ -18,7 +19,6 @@ services: volumes: open_webui: - networks: app-infra: external: true diff --git a/web1/containers/opencloud/csp.yaml b/web1/containers/opencloud/csp.yaml index 4c83ac88..c3c88d43 100644 --- a/web1/containers/opencloud/csp.yaml +++ b/web1/containers/opencloud/csp.yaml @@ -1,53 +1,53 @@ --- directives: child-src: - - '''self''' + - "'self'" connect-src: - - '''self''' - - 'blob:' - - 'https://${COMPANION_DOMAIN|companion.opencloud.test}${TRAEFIK_PORT_HTTPS}/' - - 'wss://${COMPANION_DOMAIN|companion.opencloud.test}${TRAEFIK_PORT_HTTPS}/' - - 'https://raw.githubusercontent.com/opencloud-eu/awesome-apps/' - - 'https://${IDP_DOMAIN|keycloak.opencloud.test}${TRAEFIK_PORT_HTTPS}/' - - 'https://update.opencloud.eu/' - - 'https://tile.openstreetmap.org/' + - "'self'" + - "blob:" + - "https://${COMPANION_DOMAIN|companion.opencloud.test}${TRAEFIK_PORT_HTTPS}/" + - "wss://${COMPANION_DOMAIN|companion.opencloud.test}${TRAEFIK_PORT_HTTPS}/" + - "https://raw.githubusercontent.com/opencloud-eu/awesome-apps/" + - "https://${IDP_DOMAIN|keycloak.opencloud.test}${TRAEFIK_PORT_HTTPS}/" + - "https://update.opencloud.eu/" + - "https://tile.openstreetmap.org/" default-src: - - '''none''' + - "'none'" font-src: - - '''self''' + - "'self'" frame-ancestors: - - '''self''' + - "'self'" frame-src: - - '''self''' - - 'blob:' - - 'https://embed.diagrams.net/' - - 'https://${COLLABORA_DOMAIN|collabora.opencloud.test}${TRAEFIK_PORT_HTTPS}/' - - 'https://${EURO_OFFICE_DOMAIN|euro-office.opencloud.test}${TRAEFIK_PORT_HTTPS}/' - - 'https://docs.opencloud.eu' - - 'https://${IDP_DOMAIN|keycloak.opencloud.test}${TRAEFIK_PORT_HTTPS}/' + - "'self'" + - "blob:" + - "https://embed.diagrams.net/" + - "https://${COLLABORA_DOMAIN|collabora.opencloud.test}${TRAEFIK_PORT_HTTPS}/" + - "https://${EURO_OFFICE_DOMAIN|euro-office.opencloud.test}${TRAEFIK_PORT_HTTPS}/" + - "https://docs.opencloud.eu" + - "https://${IDP_DOMAIN|keycloak.opencloud.test}${TRAEFIK_PORT_HTTPS}/" img-src: - - '''self''' - - 'data:' - - 'blob:' - - 'https://raw.githubusercontent.com/opencloud-eu/awesome-apps/' - - 'https://tile.openstreetmap.org/' - - 'https://${COLLABORA_DOMAIN|collabora.opencloud.test}${TRAEFIK_PORT_HTTPS}/' - - 'https://${EURO_OFFICE_DOMAIN|euro-office.opencloud.test}${TRAEFIK_PORT_HTTPS}/' + - "'self'" + - "data:" + - "blob:" + - "https://raw.githubusercontent.com/opencloud-eu/awesome-apps/" + - "https://tile.openstreetmap.org/" + - "https://${COLLABORA_DOMAIN|collabora.opencloud.test}${TRAEFIK_PORT_HTTPS}/" + - "https://${EURO_OFFICE_DOMAIN|euro-office.opencloud.test}${TRAEFIK_PORT_HTTPS}/" manifest-src: - - '''self''' + - "'self'" media-src: - - '''self''' + - "'self'" object-src: - - '''self''' - - 'blob:' + - "'self'" + - "blob:" script-src: - - '''self''' - - '''unsafe-inline''' - - 'https://${IDP_DOMAIN|keycloak.opencloud.test}${TRAEFIK_PORT_HTTPS}/' + - "'self'" + - "'unsafe-inline'" + - "https://${IDP_DOMAIN|keycloak.opencloud.test}${TRAEFIK_PORT_HTTPS}/" style-src: - - '''self''' - - '''unsafe-inline''' - - 'blob:' + - "'self'" + - "'unsafe-inline'" + - "blob:" worker-src: - "'self'" - - 'blob:' + - "blob:" diff --git a/web1/containers/opencloud/docker-compose.yml b/web1/containers/opencloud/docker-compose.yml index ac602f9d..1479cbe4 100644 --- a/web1/containers/opencloud/docker-compose.yml +++ b/web1/containers/opencloud/docker-compose.yml @@ -54,7 +54,6 @@ volumes: garage_meta: opencloud_config: opencloud_data: - networks: app-infra: external: true diff --git a/web1/containers/roundcube/docker-compose.yml b/web1/containers/roundcube/docker-compose.yml index 5443ffa4..0d39326d 100644 --- a/web1/containers/roundcube/docker-compose.yml +++ b/web1/containers/roundcube/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: roundcubemail: image: roundcube/roundcubemail:1.7.4-apache@sha256:1ccbb2c5909960802b97ce7ec526a50668ad4b139bf8b03817aba64c6b49d58a diff --git a/web1/containers/twenty/docker-compose.yml b/web1/containers/twenty/docker-compose.yml index b39eac7a..e3cba40c 100644 --- a/web1/containers/twenty/docker-compose.yml +++ b/web1/containers/twenty/docker-compose.yml @@ -90,7 +90,6 @@ services: volumes: db: server-local-data: - networks: twenty: app-infra: diff --git a/web1/containers/webmail/docker-compose.yml b/web1/containers/webmail/docker-compose.yml index 9b16d84c..dd4fca89 100644 --- a/web1/containers/webmail/docker-compose.yml +++ b/web1/containers/webmail/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: webmail: image: ghcr.io/linagora/tmail-web:release diff --git a/web2/ansible/defaults/main.yml b/web2/ansible/defaults/main.yml index b792b1f5..8597921d 100644 --- a/web2/ansible/defaults/main.yml +++ b/web2/ansible/defaults/main.yml @@ -4,7 +4,7 @@ dns_search_domain: "{{ lookup('env', 'DNS_SEARCH_DOMAIN') }}" os_user_pw_ageing: false os_auth_pam_passwdqc_enable: false sftp_enabled: true -ssh_allow_tcp_forwarding: 'local' +ssh_allow_tcp_forwarding: "local" ssh_print_last_log: true ssh_permit_root_login: "without-password" ssh_permit_tunnel: "yes" diff --git a/web2/ansible/migrations/20260127_0002_switch_crowdsec_bouncer_to_nftables.yml b/web2/ansible/migrations/20260127_0002_switch_crowdsec_bouncer_to_nftables.yml index f6e2e019..4bec56de 100644 --- a/web2/ansible/migrations/20260127_0002_switch_crowdsec_bouncer_to_nftables.yml +++ b/web2/ansible/migrations/20260127_0002_switch_crowdsec_bouncer_to_nftables.yml @@ -15,4 +15,4 @@ - name: Restart CrowdSec firewall bouncer service service: name: crowdsec-firewall-bouncer - state: restarted \ No newline at end of file + state: restarted diff --git a/web2/ansible/migrations/20260204_0003_fix_crowdsec_nftables_priority.yml b/web2/ansible/migrations/20260204_0003_fix_crowdsec_nftables_priority.yml index 4f0ff3a4..e8b4e297 100644 --- a/web2/ansible/migrations/20260204_0003_fix_crowdsec_nftables_priority.yml +++ b/web2/ansible/migrations/20260204_0003_fix_crowdsec_nftables_priority.yml @@ -12,8 +12,8 @@ - name: Update CrowdSec firewall bouncer mode to nftables lineinfile: path: /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml - regexp: '^mode:' - line: 'mode: nftables' + regexp: "^mode:" + line: "mode: nftables" when: bouncer_config.stat.exists - name: Restart CrowdSec firewall bouncer to apply new config diff --git a/web2/ansible/playbook.yml b/web2/ansible/playbook.yml index b9a54704..25d3bfab 100644 --- a/web2/ansible/playbook.yml +++ b/web2/ansible/playbook.yml @@ -23,10 +23,10 @@ no_log: true block: - name: Apply OS hardening - include_role: + ansible.builtin.include_role: name: devsec.hardening.os_hardening when: scheduled_run - name: Apply ssh hardening - include_role: + ansible.builtin.include_role: name: devsec.hardening.ssh_hardening when: scheduled_run diff --git a/web2/ansible/roles/system/backup/tasks/main.yml b/web2/ansible/roles/system/backup/tasks/main.yml index f93bf2a0..df0c4a6b 100644 --- a/web2/ansible/roles/system/backup/tasks/main.yml +++ b/web2/ansible/roles/system/backup/tasks/main.yml @@ -1,6 +1,6 @@ --- - name: Include borgbase.ansible_role_borgbackup role - include_role: + ansible.builtin.include_role: name: borgbase.ansible_role_borgbackup vars: borg_source_directories: @@ -8,7 +8,7 @@ - "/etc" borg_exclude_patterns: - "/home/anatoli" - - "/home/clp" + - "/home/clp" - "/home/mysql" - "*.pyc" - "*.tmp" @@ -39,5 +39,5 @@ name: borgmatic.timer state: started enabled: true - daemon_reload: yes + daemon_reload: true become: true diff --git a/web2/ansible/roles/system/config/handlers/main.yml b/web2/ansible/roles/system/config/handlers/main.yml index 3bbb9178..897869ea 100644 --- a/web2/ansible/roles/system/config/handlers/main.yml +++ b/web2/ansible/roles/system/config/handlers/main.yml @@ -9,5 +9,5 @@ ansible.builtin.systemd_service: name: logrotate.timer state: restarted - daemon_reload: yes + daemon_reload: true become: true diff --git a/web2/ansible/roles/system/config/tasks/main.yml b/web2/ansible/roles/system/config/tasks/main.yml index f65f71d9..85d8bdc9 100644 --- a/web2/ansible/roles/system/config/tasks/main.yml +++ b/web2/ansible/roles/system/config/tasks/main.yml @@ -1,114 +1,114 @@ --- - name: UFW block: - - name: Allow incoming UDP port 7844 - community.general.ufw: - rule: allow - proto: udp - from_port: 7844 - comment: Allow incoming traffic from UDP port 7844 - delete: true - become: true + - name: Allow incoming UDP port 7844 + community.general.ufw: + rule: allow + proto: udp + from_port: 7844 + comment: Allow incoming traffic from UDP port 7844 + delete: true + become: true - # Web traffic for FrankenPHP (HTTP, HTTPS, HTTP/3). Docker publishes these - # ports and inserts its own nftables rules that bypass UFW's INPUT chain, so - # the container is reachable regardless; these rules codify the intended open - # ports and cover any host-level listener. - - name: Allow incoming HTTP (80/tcp) - community.general.ufw: - rule: allow - proto: tcp - port: '80' - comment: HTTP (FrankenPHP / ACME) - become: true + # Web traffic for FrankenPHP (HTTP, HTTPS, HTTP/3). Docker publishes these + # ports and inserts its own nftables rules that bypass UFW's INPUT chain, so + # the container is reachable regardless; these rules codify the intended open + # ports and cover any host-level listener. + - name: Allow incoming HTTP (80/tcp) + community.general.ufw: + rule: allow + proto: tcp + port: "80" + comment: HTTP (FrankenPHP / ACME) + become: true - - name: Allow incoming HTTPS (443/tcp) - community.general.ufw: - rule: allow - proto: tcp - port: '443' - comment: HTTPS (FrankenPHP) - become: true + - name: Allow incoming HTTPS (443/tcp) + community.general.ufw: + rule: allow + proto: tcp + port: "443" + comment: HTTPS (FrankenPHP) + become: true - - name: Allow incoming HTTP/3 (443/udp) - community.general.ufw: - rule: allow - proto: udp - port: '443' - comment: HTTP/3 QUIC (FrankenPHP) - become: true + - name: Allow incoming HTTP/3 (443/udp) + community.general.ufw: + rule: allow + proto: udp + port: "443" + comment: HTTP/3 QUIC (FrankenPHP) + become: true - name: DNS block: - - name: Ensure systemd-resolved is running - ansible.builtin.systemd_service: - name: systemd-resolved - state: started - enabled: yes - become: true + - name: Ensure systemd-resolved is running + ansible.builtin.systemd_service: + name: systemd-resolved + state: started + enabled: true + become: true - - name: Configure systemd-resolved for Google and Quad9 DNS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?DNS=' - line: 'DNS=8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for Google and Quad9 DNS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?DNS=" + line: "DNS=8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for ControlD and Cloudflare Fallback DNS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?FallbackDNS=' - line: 'FallbackDNS=76.76.2.0 76.76.10.0 1.1.1.1 1.0.0.1' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for ControlD and Cloudflare Fallback DNS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?FallbackDNS=" + line: "FallbackDNS=76.76.2.0 76.76.10.0 1.1.1.1 1.0.0.1" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for DNSOverTLS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?DNSOverTLS=' - line: 'DNSOverTLS=no' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for DNSOverTLS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?DNSOverTLS=" + line: "DNSOverTLS=no" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for Cache - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?Cache=' - line: 'Cache=yes' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for Cache + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?Cache=" + line: "Cache=yes" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for Domain - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?Domain=' - line: "Domain={{ dns_search_domain }}" - when: dns_search_domain | default('') | length > 0 - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for Domain + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?Domain=" + line: "Domain={{ dns_search_domain }}" + when: dns_search_domain | default('') | length > 0 + notify: Restart systemd-resolved + become: true - name: Logrotate block: - - name: Remove obsolete Docker logrotate configuration - ansible.builtin.file: - path: /etc/logrotate.d/docker - state: absent - become: true + - name: Remove obsolete Docker logrotate configuration + ansible.builtin.file: + path: /etc/logrotate.d/docker + state: absent + become: true - - name: Create logrotate.timer.d directory - ansible.builtin.file: - path: /etc/systemd/system/logrotate.timer.d - state: directory - mode: '0755' - become: true + - name: Create logrotate.timer.d directory + ansible.builtin.file: + path: /etc/systemd/system/logrotate.timer.d + state: directory + mode: "0755" + become: true - - name: Override logrotate.timer - ansible.builtin.copy: - src: files/logrotate.timer.d/override.conf - dest: /etc/systemd/system/logrotate.timer.d/override.conf - owner: root - group: root - mode: '0644' - notify: Restart logrotate.timer - become: true + - name: Override logrotate.timer + ansible.builtin.copy: + src: files/logrotate.timer.d/override.conf + dest: /etc/systemd/system/logrotate.timer.d/override.conf + owner: root + group: root + mode: "0644" + notify: Restart logrotate.timer + become: true diff --git a/web2/ansible/roles/system/containers/tasks/deploy_stack.yml b/web2/ansible/roles/system/containers/tasks/deploy_stack.yml index 090182c2..494046f2 100644 --- a/web2/ansible/roles/system/containers/tasks/deploy_stack.yml +++ b/web2/ansible/roles/system/containers/tasks/deploy_stack.yml @@ -9,4 +9,4 @@ project_src: "/opt/containers/{{ item.key }}" state: "{{ item.value.state }}" when: not item.value.env_file or (item.value.env_file and env_stat.stat.exists) - register: compose_result \ No newline at end of file + register: compose_result diff --git a/web2/ansible/roles/system/containers/tasks/main.yml b/web2/ansible/roles/system/containers/tasks/main.yml index 9e6e5be5..b8472ca2 100644 --- a/web2/ansible/roles/system/containers/tasks/main.yml +++ b/web2/ansible/roles/system/containers/tasks/main.yml @@ -1,19 +1,19 @@ --- - name: Ensure /opt/containers exists - file: + ansible.builtin.file: path: /opt/containers state: directory owner: root group: root - mode: '0755' + mode: "0755" - name: Copy containers directory to remote node - copy: + ansible.builtin.copy: src: "{{ playbook_dir }}/../containers/" dest: /opt/containers/ owner: root group: root - mode: '0755' + mode: "0755" notify: Restart containers - name: Create shared app-infra Docker network @@ -26,13 +26,13 @@ state: present - name: Create cloudflared .env file - copy: + ansible.builtin.copy: content: | TUNNEL_TOKEN={{ lookup('env', 'CLOUDFLARED_TOKEN') }} dest: /opt/containers/cloudflared/.env owner: root group: root - mode: '0600' + mode: "0600" when: lookup('env', 'CLOUDFLARED_TOKEN') | length > 0 # The frankenphp container mounts /opt/hypebun/ read-only as the app @@ -46,7 +46,7 @@ state: directory owner: root group: root - mode: '0755' + mode: "0755" when: "'hypebun-web' in docker_stacks" - name: Find Hypebun environment directories @@ -54,7 +54,7 @@ # they may not exist yet; only normalize the ones present. ansible.builtin.find: paths: /opt/hypebun - patterns: ['prod', 'test'] + patterns: ["prod", "test"] file_type: directory recurse: false register: hypebun_env_dirs @@ -76,14 +76,14 @@ when: "'hypebun-web' in docker_stacks" - name: Deploy docker-compose projects - include_tasks: deploy_stack.yml + ansible.builtin.include_tasks: deploy_stack.yml loop: "{{ docker_stacks | dict2items }}" - name: Clean up Docker system community.docker.docker_prune: - containers: yes - images: yes + containers: true + images: true images_filters: dangling: false - networks: yes - builder_cache: yes + networks: true + builder_cache: true diff --git a/web2/ansible/roles/system/crowdsec/handlers/main.yml b/web2/ansible/roles/system/crowdsec/handlers/main.yml index 50521d26..fd206978 100644 --- a/web2/ansible/roles/system/crowdsec/handlers/main.yml +++ b/web2/ansible/roles/system/crowdsec/handlers/main.yml @@ -1,9 +1,10 @@ +--- - name: restart crowdsec - service: + ansible.builtin.service: name: crowdsec state: restarted - name: restart crowdsec-firewall-bouncer - service: + ansible.builtin.service: name: crowdsec-firewall-bouncer - state: restarted \ No newline at end of file + state: restarted diff --git a/web2/ansible/roles/system/migrations/defaults/main.yml b/web2/ansible/roles/system/migrations/defaults/main.yml index 25427a89..94346280 100644 --- a/web2/ansible/roles/system/migrations/defaults/main.yml +++ b/web2/ansible/roles/system/migrations/defaults/main.yml @@ -1,3 +1,3 @@ --- migration_dir: "{{ playbook_dir }}/migrations" -migration_state_file: /opt/ansible/migrations.db \ No newline at end of file +migration_state_file: /opt/ansible/migrations.db diff --git a/web2/ansible/roles/system/monit/tasks/main.yml b/web2/ansible/roles/system/monit/tasks/main.yml index e8ec75b1..5b2139c1 100644 --- a/web2/ansible/roles/system/monit/tasks/main.yml +++ b/web2/ansible/roles/system/monit/tasks/main.yml @@ -13,7 +13,7 @@ state: directory owner: root group: root - mode: '0755' + mode: "0755" loop: - /etc/monit - /etc/monit.d @@ -25,7 +25,7 @@ dest: /etc/monitrc owner: root group: root - mode: '0600' + mode: "0600" notify: Reload monit become: true @@ -35,7 +35,7 @@ dest: /etc/monit.d/disk.cfg owner: root group: root - mode: '0600' + mode: "0600" notify: Reload monit become: true @@ -45,7 +45,7 @@ dest: /etc/monit/notify-chat.sh owner: root group: root - mode: '0755' + mode: "0755" notify: Reload monit become: true @@ -55,7 +55,7 @@ dest: /etc/monit/check-docker-stack.sh owner: root group: root - mode: '0755' + mode: "0755" notify: Reload monit become: true @@ -65,7 +65,7 @@ dest: /etc/monit.d/containers.cfg owner: root group: root - mode: '0600' + mode: "0600" notify: Reload monit become: true @@ -75,7 +75,7 @@ dest: /etc/monit/notify-chat.env owner: root group: root - mode: '0600' + mode: "0600" force: false become: true @@ -85,7 +85,7 @@ dest: /etc/monit.d/mail.cfg owner: root group: root - mode: '0600' + mode: "0600" force: false become: true diff --git a/web2/containers/cloudflared/docker-compose.yml b/web2/containers/cloudflared/docker-compose.yml index 82fd5d41..83f75c94 100644 --- a/web2/containers/cloudflared/docker-compose.yml +++ b/web2/containers/cloudflared/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: cloudflared: image: cloudflare/cloudflared:2026.9.3@sha256:072c067d25ccbe61d46e18f0d0723255f2bb5304f7317caa95b27031520ff92c diff --git a/web2/containers/cobalt/docker-compose.yml b/web2/containers/cobalt/docker-compose.yml index d609cfe7..ad58a9fb 100644 --- a/web2/containers/cobalt/docker-compose.yml +++ b/web2/containers/cobalt/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: cobalt-api: image: ghcr.io/imputnet/cobalt:11.7.1 diff --git a/web2/containers/hypebun-web/docker-compose.yml b/web2/containers/hypebun-web/docker-compose.yml index 3b79840f..09082fa4 100644 --- a/web2/containers/hypebun-web/docker-compose.yml +++ b/web2/containers/hypebun-web/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: frankenphp: # Custom image: base FrankenPHP + mysqli/gd/intl/... required by AltumCode. diff --git a/web3/ansible/defaults/main.yml b/web3/ansible/defaults/main.yml index 8f38c692..6fdf446d 100644 --- a/web3/ansible/defaults/main.yml +++ b/web3/ansible/defaults/main.yml @@ -6,7 +6,7 @@ os_env_umask: "022" os_user_pw_ageing: false os_auth_pam_passwdqc_enable: false sftp_enabled: true -ssh_allow_tcp_forwarding: 'local' +ssh_allow_tcp_forwarding: "local" ssh_print_last_log: true ssh_permit_root_login: "without-password" ssh_permit_tunnel: "yes" @@ -30,10 +30,10 @@ sysctl_overwrite: php_custom_extensions: "8.4": - { name: brotli, version: "0.18.3", apt_deps: ["libbrotli-dev"] } - - { name: zstd, version: "0.15.2", apt_deps: ["libzstd-dev"] } + - { name: zstd, version: "0.15.2", apt_deps: ["libzstd-dev"] } "8.5": - { name: brotli, version: "0.18.3", apt_deps: ["libbrotli-dev"] } - - { name: zstd, version: "0.15.2", apt_deps: ["libzstd-dev"] } + - { name: zstd, version: "0.15.2", apt_deps: ["libzstd-dev"] } # Bound Docker's default json-file logs for containers that do not override the # driver in their Compose project. diff --git a/web3/ansible/migrations/20260121_0001_relocate_media_for_authentik.yml b/web3/ansible/migrations/20260121_0001_relocate_media_for_authentik.yml index dc0754d2..51d99ff1 100644 --- a/web3/ansible/migrations/20260121_0001_relocate_media_for_authentik.yml +++ b/web3/ansible/migrations/20260121_0001_relocate_media_for_authentik.yml @@ -6,10 +6,10 @@ file: path: /opt/containers/authentik/data state: directory - mode: '0755' + mode: "0755" - name: Relocate authentik media directory command: mv /opt/containers/authentik/media /opt/containers/authentik/data/media args: creates: /opt/containers/authentik/data/media - removes: /opt/containers/authentik/media \ No newline at end of file + removes: /opt/containers/authentik/media diff --git a/web3/ansible/migrations/20260127_0002_switch_crowdsec_bouncer_to_nftables.yml b/web3/ansible/migrations/20260127_0002_switch_crowdsec_bouncer_to_nftables.yml index f6e2e019..4bec56de 100644 --- a/web3/ansible/migrations/20260127_0002_switch_crowdsec_bouncer_to_nftables.yml +++ b/web3/ansible/migrations/20260127_0002_switch_crowdsec_bouncer_to_nftables.yml @@ -15,4 +15,4 @@ - name: Restart CrowdSec firewall bouncer service service: name: crowdsec-firewall-bouncer - state: restarted \ No newline at end of file + state: restarted diff --git a/web3/ansible/migrations/20260204_0003_fix_crowdsec_nftables_priority.yml b/web3/ansible/migrations/20260204_0003_fix_crowdsec_nftables_priority.yml index 4f0ff3a4..e8b4e297 100644 --- a/web3/ansible/migrations/20260204_0003_fix_crowdsec_nftables_priority.yml +++ b/web3/ansible/migrations/20260204_0003_fix_crowdsec_nftables_priority.yml @@ -12,8 +12,8 @@ - name: Update CrowdSec firewall bouncer mode to nftables lineinfile: path: /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml - regexp: '^mode:' - line: 'mode: nftables' + regexp: "^mode:" + line: "mode: nftables" when: bouncer_config.stat.exists - name: Restart CrowdSec firewall bouncer to apply new config diff --git a/web3/ansible/playbook.yml b/web3/ansible/playbook.yml index 3f8fa0ad..2bfd3bf5 100644 --- a/web3/ansible/playbook.yml +++ b/web3/ansible/playbook.yml @@ -24,14 +24,14 @@ no_log: true block: - name: Apply OS hardening - include_role: + ansible.builtin.include_role: name: devsec.hardening.os_hardening when: scheduled_run - name: Apply nginx hardening - include_role: + ansible.builtin.include_role: name: devsec.hardening.nginx_hardening when: scheduled_run - name: Apply ssh hardening - include_role: + ansible.builtin.include_role: name: devsec.hardening.ssh_hardening when: scheduled_run diff --git a/web3/ansible/roles/system/backup/tasks/main.yml b/web3/ansible/roles/system/backup/tasks/main.yml index f93bf2a0..df0c4a6b 100644 --- a/web3/ansible/roles/system/backup/tasks/main.yml +++ b/web3/ansible/roles/system/backup/tasks/main.yml @@ -1,6 +1,6 @@ --- - name: Include borgbase.ansible_role_borgbackup role - include_role: + ansible.builtin.include_role: name: borgbase.ansible_role_borgbackup vars: borg_source_directories: @@ -8,7 +8,7 @@ - "/etc" borg_exclude_patterns: - "/home/anatoli" - - "/home/clp" + - "/home/clp" - "/home/mysql" - "*.pyc" - "*.tmp" @@ -39,5 +39,5 @@ name: borgmatic.timer state: started enabled: true - daemon_reload: yes + daemon_reload: true become: true diff --git a/web3/ansible/roles/system/config/handlers/main.yml b/web3/ansible/roles/system/config/handlers/main.yml index f3838ef7..9bb4b1c6 100644 --- a/web3/ansible/roles/system/config/handlers/main.yml +++ b/web3/ansible/roles/system/config/handlers/main.yml @@ -9,7 +9,7 @@ ansible.builtin.systemd_service: name: logrotate.timer state: restarted - daemon_reload: yes + daemon_reload: true become: true - name: Reload nginx diff --git a/web3/ansible/roles/system/config/tasks/main.yml b/web3/ansible/roles/system/config/tasks/main.yml index e71c35c2..3d9755d1 100644 --- a/web3/ansible/roles/system/config/tasks/main.yml +++ b/web3/ansible/roles/system/config/tasks/main.yml @@ -1,89 +1,89 @@ --- - name: UFW block: - - name: Allow incoming UDP port 7844 - community.general.ufw: - rule: allow - proto: udp - from_port: 7844 - comment: Allow incoming traffic from UDP port 7844 - delete: true - become: true + - name: Allow incoming UDP port 7844 + community.general.ufw: + rule: allow + proto: udp + from_port: 7844 + comment: Allow incoming traffic from UDP port 7844 + delete: true + become: true - name: DNS block: - - name: Ensure systemd-resolved is running - ansible.builtin.systemd_service: - name: systemd-resolved - state: started - enabled: yes - become: true + - name: Ensure systemd-resolved is running + ansible.builtin.systemd_service: + name: systemd-resolved + state: started + enabled: true + become: true - - name: Configure systemd-resolved for Google and Quad9 DNS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?DNS=' - line: 'DNS=8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for Google and Quad9 DNS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?DNS=" + line: "DNS=8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for ControlD and Cloudflare Fallback DNS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?FallbackDNS=' - line: 'FallbackDNS=76.76.2.0 76.76.10.0 1.1.1.1 1.0.0.1' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for ControlD and Cloudflare Fallback DNS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?FallbackDNS=" + line: "FallbackDNS=76.76.2.0 76.76.10.0 1.1.1.1 1.0.0.1" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for DNSOverTLS - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?DNSOverTLS=' - line: 'DNSOverTLS=no' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for DNSOverTLS + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?DNSOverTLS=" + line: "DNSOverTLS=no" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for Cache - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?Cache=' - line: 'Cache=yes' - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for Cache + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?Cache=" + line: "Cache=yes" + notify: Restart systemd-resolved + become: true - - name: Configure systemd-resolved for Domain - ansible.builtin.lineinfile: - path: /etc/systemd/resolved.conf - regexp: '^#?Domain=' - line: "Domain={{ dns_search_domain }}" - when: dns_search_domain | default('') | length > 0 - notify: Restart systemd-resolved - become: true + - name: Configure systemd-resolved for Domain + ansible.builtin.lineinfile: + path: /etc/systemd/resolved.conf + regexp: "^#?Domain=" + line: "Domain={{ dns_search_domain }}" + when: dns_search_domain | default('') | length > 0 + notify: Restart systemd-resolved + become: true - name: Logrotate block: - - name: Remove obsolete Docker logrotate configuration - ansible.builtin.file: - path: /etc/logrotate.d/docker - state: absent - become: true + - name: Remove obsolete Docker logrotate configuration + ansible.builtin.file: + path: /etc/logrotate.d/docker + state: absent + become: true - - name: Create logrotate.timer.d directory - ansible.builtin.file: - path: /etc/systemd/system/logrotate.timer.d - state: directory - mode: '0755' - become: true + - name: Create logrotate.timer.d directory + ansible.builtin.file: + path: /etc/systemd/system/logrotate.timer.d + state: directory + mode: "0755" + become: true - - name: Override logrotate.timer - ansible.builtin.copy: - src: files/logrotate.timer.d/override.conf - dest: /etc/systemd/system/logrotate.timer.d/override.conf - owner: root - group: root - mode: '0644' - notify: Restart logrotate.timer - become: true + - name: Override logrotate.timer + ansible.builtin.copy: + src: files/logrotate.timer.d/override.conf + dest: /etc/systemd/system/logrotate.timer.d/override.conf + owner: root + group: root + mode: "0644" + notify: Restart logrotate.timer + become: true - name: Nginx Compression # CloudPanel pre-loads brotli via /etc/nginx/modules-enabled/50-mod-ngx-brotli.conf @@ -95,32 +95,38 @@ - name: Tune gzip_comp_level ansible.builtin.replace: path: /etc/nginx/nginx.conf - regexp: '^(\s*)gzip_comp_level\s+\d+\s*;' - replace: '\g<1>gzip_comp_level 5;' + regexp: "^(\\s*)gzip_comp_level\\s+\\d+\\s*;" + replace: "\\g<1>gzip_comp_level 5;" notify: Reload nginx become: true - name: Tune gzip_types ansible.builtin.replace: path: /etc/nginx/nginx.conf - regexp: '^(\s*)gzip_types[^;]*;' - replace: '\g<1>gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml application/xml+rss application/atom+xml application/rss+xml application/wasm application/manifest+json image/svg+xml font/ttf font/otf application/vnd.ms-fontobject;' + regexp: "^(\\s*)gzip_types[^;]*;" + replace: >- + \g<1>gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml application/xml+rss + application/atom+xml application/rss+xml application/wasm application/manifest+json image/svg+xml font/ttf font/otf + application/vnd.ms-fontobject; notify: Reload nginx become: true - name: Tune brotli_comp_level ansible.builtin.replace: path: /etc/nginx/nginx.conf - regexp: '^(\s*)brotli_comp_level\s+\d+\s*;' - replace: '\g<1>brotli_comp_level 6;' + regexp: "^(\\s*)brotli_comp_level\\s+\\d+\\s*;" + replace: "\\g<1>brotli_comp_level 6;" notify: Reload nginx become: true - name: Tune brotli_types ansible.builtin.replace: path: /etc/nginx/nginx.conf - regexp: '^(\s*)brotli_types[^;]*;' - replace: '\g<1>brotli_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml application/xml+rss application/atom+xml application/rss+xml application/wasm application/manifest+json image/svg+xml font/ttf font/otf application/vnd.ms-fontobject;' + regexp: "^(\\s*)brotli_types[^;]*;" + replace: >- + \g<1>brotli_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml application/xml+rss + application/atom+xml application/rss+xml application/wasm application/manifest+json image/svg+xml font/ttf font/otf + application/vnd.ms-fontobject; notify: Reload nginx become: true diff --git a/web3/ansible/roles/system/containers/tasks/authentik.yml b/web3/ansible/roles/system/containers/tasks/authentik.yml index c31f4191..9a803b2a 100644 --- a/web3/ansible/roles/system/containers/tasks/authentik.yml +++ b/web3/ansible/roles/system/containers/tasks/authentik.yml @@ -1,18 +1,18 @@ --- - name: Create authentik directories - file: + ansible.builtin.file: path: "/opt/containers/authentik/{{ item }}" state: directory owner: "1000" group: "1000" - mode: '0755' + mode: "0755" loop: - media - custom-templates - certs - name: Check if Authentik .env file exists - stat: + ansible.builtin.stat: path: /opt/containers/authentik/.env register: authentik_env_file @@ -25,6 +25,6 @@ when: authentik_env_file.stat.exists - name: Show message when .env file is missing - debug: + ansible.builtin.debug: msg: "Authentik .env file is missing. Please create it manually at /opt/containers/authentik/.env" when: not authentik_env_file.stat.exists diff --git a/web3/ansible/roles/system/containers/tasks/deploy_stack.yml b/web3/ansible/roles/system/containers/tasks/deploy_stack.yml index 6562c31e..2957bb53 100644 --- a/web3/ansible/roles/system/containers/tasks/deploy_stack.yml +++ b/web3/ansible/roles/system/containers/tasks/deploy_stack.yml @@ -9,7 +9,7 @@ path: "/opt/containers/{{ item.key }}/.env" owner: root group: root - mode: '0600' + mode: "0600" when: item.value.env_file and env_stat.stat.exists - name: Deploy stack diff --git a/web3/ansible/roles/system/containers/tasks/fleet.yml b/web3/ansible/roles/system/containers/tasks/fleet.yml index 0baf6376..6ac68ca0 100644 --- a/web3/ansible/roles/system/containers/tasks/fleet.yml +++ b/web3/ansible/roles/system/containers/tasks/fleet.yml @@ -1,6 +1,6 @@ --- - name: Check if fleet .env file exists - stat: + ansible.builtin.stat: path: /opt/containers/fleet/.env register: fleet_env_stat @@ -8,4 +8,4 @@ community.docker.docker_compose_v2: project_src: "/opt/containers/fleet" state: present - when: fleet_env_stat.stat.exists \ No newline at end of file + when: fleet_env_stat.stat.exists diff --git a/web3/ansible/roles/system/containers/tasks/main.yml b/web3/ansible/roles/system/containers/tasks/main.yml index 7c423eeb..b28d8894 100644 --- a/web3/ansible/roles/system/containers/tasks/main.yml +++ b/web3/ansible/roles/system/containers/tasks/main.yml @@ -1,19 +1,19 @@ --- - name: Ensure /opt/containers exists - file: + ansible.builtin.file: path: /opt/containers state: directory owner: root group: root - mode: '0755' + mode: "0755" - name: Copy containers directory to remote node - copy: + ansible.builtin.copy: src: "{{ playbook_dir }}/../containers/" dest: /opt/containers/ owner: root group: root - mode: '0755' + mode: "0755" notify: Restart containers - name: Create shared app-infra Docker network @@ -26,30 +26,30 @@ state: present - name: Create cloudflared .env file - copy: + ansible.builtin.copy: content: | TUNNEL_TOKEN={{ lookup('env', 'CLOUDFLARED_TOKEN') }} dest: /opt/containers/cloudflared/.env owner: root group: root - mode: '0600' + mode: "0600" when: lookup('env', 'CLOUDFLARED_TOKEN') | length > 0 - name: Setup Authentik Infrastructure - include_tasks: authentik.yml + ansible.builtin.include_tasks: authentik.yml - name: Setup Fleet Infrastructure - include_tasks: fleet.yml + ansible.builtin.include_tasks: fleet.yml - name: Deploy docker-compose projects - include_tasks: deploy_stack.yml + ansible.builtin.include_tasks: deploy_stack.yml loop: "{{ docker_stacks | dict2items }}" - name: Clean up Docker system community.docker.docker_prune: - containers: yes - images: yes + containers: true + images: true images_filters: dangling: false - networks: yes - builder_cache: yes + networks: true + builder_cache: true diff --git a/web3/ansible/roles/system/crowdsec/handlers/main.yml b/web3/ansible/roles/system/crowdsec/handlers/main.yml index 50521d26..fd206978 100644 --- a/web3/ansible/roles/system/crowdsec/handlers/main.yml +++ b/web3/ansible/roles/system/crowdsec/handlers/main.yml @@ -1,9 +1,10 @@ +--- - name: restart crowdsec - service: + ansible.builtin.service: name: crowdsec state: restarted - name: restart crowdsec-firewall-bouncer - service: + ansible.builtin.service: name: crowdsec-firewall-bouncer - state: restarted \ No newline at end of file + state: restarted diff --git a/web3/ansible/roles/system/migrations/defaults/main.yml b/web3/ansible/roles/system/migrations/defaults/main.yml index 25427a89..94346280 100644 --- a/web3/ansible/roles/system/migrations/defaults/main.yml +++ b/web3/ansible/roles/system/migrations/defaults/main.yml @@ -1,3 +1,3 @@ --- migration_dir: "{{ playbook_dir }}/migrations" -migration_state_file: /opt/ansible/migrations.db \ No newline at end of file +migration_state_file: /opt/ansible/migrations.db diff --git a/web3/ansible/roles/system/monit/tasks/main.yml b/web3/ansible/roles/system/monit/tasks/main.yml index e8ec75b1..5b2139c1 100644 --- a/web3/ansible/roles/system/monit/tasks/main.yml +++ b/web3/ansible/roles/system/monit/tasks/main.yml @@ -13,7 +13,7 @@ state: directory owner: root group: root - mode: '0755' + mode: "0755" loop: - /etc/monit - /etc/monit.d @@ -25,7 +25,7 @@ dest: /etc/monitrc owner: root group: root - mode: '0600' + mode: "0600" notify: Reload monit become: true @@ -35,7 +35,7 @@ dest: /etc/monit.d/disk.cfg owner: root group: root - mode: '0600' + mode: "0600" notify: Reload monit become: true @@ -45,7 +45,7 @@ dest: /etc/monit/notify-chat.sh owner: root group: root - mode: '0755' + mode: "0755" notify: Reload monit become: true @@ -55,7 +55,7 @@ dest: /etc/monit/check-docker-stack.sh owner: root group: root - mode: '0755' + mode: "0755" notify: Reload monit become: true @@ -65,7 +65,7 @@ dest: /etc/monit.d/containers.cfg owner: root group: root - mode: '0600' + mode: "0600" notify: Reload monit become: true @@ -75,7 +75,7 @@ dest: /etc/monit/notify-chat.env owner: root group: root - mode: '0600' + mode: "0600" force: false become: true @@ -85,7 +85,7 @@ dest: /etc/monit.d/mail.cfg owner: root group: root - mode: '0600' + mode: "0600" force: false become: true diff --git a/web3/ansible/roles/system/php/tasks/main.yml b/web3/ansible/roles/system/php/tasks/main.yml index edf3390d..4fd6cf58 100644 --- a/web3/ansible/roles/system/php/tasks/main.yml +++ b/web3/ansible/roles/system/php/tasks/main.yml @@ -3,7 +3,7 @@ ansible.builtin.systemd_service: name: "php{{ item }}-fpm" state: stopped - enabled: no + enabled: false loop: - "7.1" - "7.2" @@ -18,7 +18,7 @@ ansible.builtin.file: path: "/etc/php/{{ item }}/fpm/pool.d" mode: "a+r" - recurse: yes + recurse: true loop: - "7.1" - "7.2" diff --git a/web3/containers/1min-relay/docker-compose.yml b/web3/containers/1min-relay/docker-compose.yml index f652da2d..35273444 100644 --- a/web3/containers/1min-relay/docker-compose.yml +++ b/web3/containers/1min-relay/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: 1min-relay: image: ghcr.io/thundersquared/1min-relay:v1.1.0 diff --git a/web3/containers/calcom/docker-compose.yml b/web3/containers/calcom/docker-compose.yml index b354a0a9..d616e6d5 100644 --- a/web3/containers/calcom/docker-compose.yml +++ b/web3/containers/calcom/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: database: image: postgres:18-alpine diff --git a/web3/containers/cloudflared/docker-compose.yml b/web3/containers/cloudflared/docker-compose.yml index 82fd5d41..83f75c94 100644 --- a/web3/containers/cloudflared/docker-compose.yml +++ b/web3/containers/cloudflared/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: cloudflared: image: cloudflare/cloudflared:2026.9.3@sha256:072c067d25ccbe61d46e18f0d0723255f2bb5304f7317caa95b27031520ff92c diff --git a/web3/containers/cobalt/docker-compose.yml b/web3/containers/cobalt/docker-compose.yml index d609cfe7..ad58a9fb 100644 --- a/web3/containers/cobalt/docker-compose.yml +++ b/web3/containers/cobalt/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: cobalt-api: image: ghcr.io/imputnet/cobalt:11.7.1 diff --git a/web3/containers/fleet/docker-compose.yml b/web3/containers/fleet/docker-compose.yml index 2bf0d46b..fc37b789 100644 --- a/web3/containers/fleet/docker-compose.yml +++ b/web3/containers/fleet/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: database: image: mysql:8.4 diff --git a/web3/containers/gpt-load/docker-compose.yml b/web3/containers/gpt-load/docker-compose.yml index 3bbc4f64..e6aaa60e 100644 --- a/web3/containers/gpt-load/docker-compose.yml +++ b/web3/containers/gpt-load/docker-compose.yml @@ -1,7 +1,7 @@ --- services: gpt-load: - image: ghcr.io/tbphp/gpt-load:2.0.0-rc.33@sha256:c9c38c606d0832b46fd905b34687d668ec737bff80d63199b9beab4bb0c4a832 # yamllint disable-line rule:line-length + image: ghcr.io/tbphp/gpt-load:2.0.0-rc.33@sha256:c9c38c606d0832b46fd905b34687d668ec737bff80d63199b9beab4bb0c4a832 # yamllint disable-line rule:line-length restart: unless-stopped init: true cap_drop: @@ -27,7 +27,6 @@ services: volumes: gpt-load-data: - networks: app-infra: external: true diff --git a/web3/containers/hermes/docker-compose.yml b/web3/containers/hermes/docker-compose.yml index 769fa3e5..778cb9f7 100644 --- a/web3/containers/hermes/docker-compose.yml +++ b/web3/containers/hermes/docker-compose.yml @@ -2,7 +2,7 @@ services: gateway: # renovate: datasource=docker depName=nousresearch/hermes-agent - image: nousresearch/hermes-agent:v2026.9.24@sha256:fca358f12efd65bfaaca05884166f15c0e2788375ca30d77061ac1ebc96452b7 # yamllint disable-line rule:line-length + image: nousresearch/hermes-agent:v2026.9.24@sha256:fca358f12efd65bfaaca05884166f15c0e2788375ca30d77061ac1ebc96452b7 # yamllint disable-line rule:line-length command: ["gateway", "run"] env_file: - .env diff --git a/web3/containers/n8n/docker-compose.yml b/web3/containers/n8n/docker-compose.yml index 5d8a2f44..9edefb6d 100644 --- a/web3/containers/n8n/docker-compose.yml +++ b/web3/containers/n8n/docker-compose.yml @@ -58,7 +58,6 @@ services: volumes: postgres: n8n_data: - networks: n8n: app-infra: diff --git a/web3/containers/open-webui/docker-compose.yml b/web3/containers/open-webui/docker-compose.yml index 40bbf7c9..9db9c670 100644 --- a/web3/containers/open-webui/docker-compose.yml +++ b/web3/containers/open-webui/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: open-webui: image: ghcr.io/open-webui/open-webui:v0.11.4@sha256:9591b13f13843c7721c2b8eaf7382846c81b3ffe126526d1888d1fed50c6a33f @@ -17,7 +18,6 @@ services: volumes: open_webui: - networks: app-infra: external: true diff --git a/web3/containers/twenty/docker-compose.yml b/web3/containers/twenty/docker-compose.yml index 03769b10..2600a810 100644 --- a/web3/containers/twenty/docker-compose.yml +++ b/web3/containers/twenty/docker-compose.yml @@ -90,7 +90,6 @@ services: volumes: db: server-local-data: - networks: twenty: app-infra: From 3a3d2410629fdcdc4d402545e2bd46418dc34b90 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 25 Sep 2026 23:08:39 +0200 Subject: [PATCH 3/6] ci: add pull request validation gate Every workflow until now was deploy-to-production, so a malformed playbook was first discovered by running it against a live host. Adds static validation on pull_request, plus a local runner so the same failures surface before pushing. Seven checks, all green at this commit: - workflow secret guard, asserting validate.yml declares no credentials - compose policy, enforcing the container contract from CLAUDE.md - yamllint, ansible-lint, tofu validate/fmt, zizmor The compose check is the point of the exercise. The cap_drop and no-new-privileges rules were prose in CLAUDE.md, reviewable but not enforced. Services that genuinely cannot satisfy them (data stores needing CAP_SETUID for gosu, the authentik worker needing the Docker socket) are now listed explicitly with a written reason, keyed by (compose path, service) because service names are ambiguous across stacks. An exempted service that also sets cap_drop fails, so a stale exemption cannot linger. validate.yml runs on pull_request and so executes code from the pull request. It is therefore configured to hold no credentials at all, so its behaviour does not depend on who opened the PR. ci/check_workflow_secrets.sh re-checks that on every run, and zizmor audits the workflow independently. Both checkers were verified to fail on the conditions they claim to catch rather than passing vacuously. The tofu job uses -backend=false so it needs no OCI credentials. Known scope limit: the eight deploy workflows still produce 42 zizmor findings (mostly secrets interpolation and missing persist-credentials). They legitimately hold credentials and are never triggered by pull_request, so auditing them is a separate hardening pass. Noted in the workflow. --- .ansible-lint | 60 +++++++++ .github/workflows/validate.yml | 181 +++++++++++++++++++++++++ .yamllint | 55 ++++++++ ci/check_compose.py | 240 +++++++++++++++++++++++++++++++++ ci/check_workflow_secrets.sh | 92 +++++++++++++ ci/validate.sh | 86 ++++++++++++ 6 files changed, 714 insertions(+) create mode 100644 .ansible-lint create mode 100644 .github/workflows/validate.yml create mode 100644 .yamllint create mode 100755 ci/check_compose.py create mode 100755 ci/check_workflow_secrets.sh create mode 100755 ci/validate.sh diff --git a/.ansible-lint b/.ansible-lint new file mode 100644 index 00000000..b04555b6 --- /dev/null +++ b/.ansible-lint @@ -0,0 +1,60 @@ +--- +# ansible-lint configuration. +# +# Scoped to rules that catch real breakage. Everything skipped below is +# deliberate and carries a reason, so the skip list stays a short, reviewable +# list rather than a growing pile of suppressions. + +profile: production + +skip_list: + # --- Naming conventions this repo does not follow ------------------------ + # Task variable prefixes (migrations_foo) and task-name casing would mean + # renaming across six duplicated role trees for no functional gain. + - var-naming[no-role-prefix] + - name[casing] + - name[template] + - name[missing-handler] + + # --- Structural choices that are correct as written ---------------------- + # The system/docker-login directory name carries a hyphen. Renaming it + # across every host (and every `- role: system/docker-login` reference) is + # pure churn for a cosmetic rule. + - role-name + + # `collections:` widens the plugin search path for devsec.hardening. Using + # FQCN everywhere instead is arguably tidier, but removing the keyword is a + # behaviour change to every play for no functional benefit. + - fqcn[keyword] + + # `systemctl try-restart` restarts only if the unit is already running, + # whereas systemd_service state=restarted would also start a stopped unit. + # The shell-out is deliberate, so keep it and skip the idiom rule. + - command-instead-of-module + + # Tasks that notify a handler on change are still better as tasks here: the + # crowdsec config edits are conditional on console/bouncer state and read + # more clearly inline than as handlers with matching names. + - no-handler + +exclude_paths: + # Non-Ansible content that happens to live under the tree. + - .github/ + - ci/ + - "*.json" + - "*.lock.hcl" + - "**/files/**" + +warn_list: + - experimental + +# Roles are duplicated per host by design (see README). Linting all six copies +# is intentional: drift between them is the failure mode worth catching. +kinds: + - playbook: "**/ansible/playbook.yml" + - role: "**/ansible/roles/*/" + - tasks: "**/ansible/roles/*/tasks/*.yml" + - handlers: "**/ansible/roles/*/handlers/*.yml" + - defaults: "**/ansible/roles/*/defaults/*.yml" + - vars: "**/ansible/roles/*/vars/*.yml" + - migrations: "**/ansible/migrations/*.yml" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 00000000..5ac98f05 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,181 @@ +--- +# Static validation for pull requests. +# +# This workflow runs on `pull_request` and needs no credentials, so it is +# configured to hold none: +# +# 1. It references no `secrets.*` and no `environment:`, so the job runs with +# no access to repository or environment secrets regardless of who opened +# the pull request. +# `ci/check_workflow_secrets.sh` fails the build if a `secrets.` or +# `environment:` reference ever appears in this file. +# 2. `permissions: contents: read` at workflow level — no write scopes and +# no `id-token`. +# 3. Third-party actions are pinned to a full commit SHA, so the code that +# runs with this job's token is fixed. +# 4. Every `actions/checkout` sets `persist-credentials: false`, keeping the +# token out of `.git/config` where later steps could read it. +# +# If a check ever needs a secret, do not add it here. Put it in a separate +# workflow triggered on `push` to a protected branch. + +name: Validate + +on: + pull_request: + branches: + - main + merge_group: + types: + - checks_requested + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: validate-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + # Fails if this workflow ever gains a secrets reference, so the property + # described in the header comment cannot silently regress. + no-secrets: + name: Guard against secret exposure + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Assert this workflow cannot read secrets + run: ./ci/check_workflow_secrets.sh .github/workflows/validate.yml + + yamllint: + name: yamllint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: .python-version + + - name: Install yamllint + run: python -m pip install --disable-pip-version-check yamllint==1.38.0 + + - name: Lint YAML + run: yamllint --strict --config-file .yamllint . + + ansible: + name: ansible-lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: .python-version + + - name: Install ansible-lint + run: python -m pip install --disable-pip-version-check ansible-lint==26.9.0 + + # `ansible-galaxy install` (not `collection install`) so roles are + # fetched too — requirements.yml lists roles as well as collections, and + # the geerlingguy.docker role is what pulls in community.docker / + # community.general that the tasks actually call. + # + # These are resolved from the PR's requirements.yml, so a PR can point + # that at an arbitrary source. The job holds no credentials, so the + # exposure is limited to a throwaway runner. + - name: Install collections and roles + run: | + for host in mx1 web1 web2 web3 tower mirror; do + if [ -f "$host/ansible/requirements.yml" ]; then + ansible-galaxy install -r "$host/ansible/requirements.yml" --force + fi + done + + - name: Lint playbooks and roles + run: ansible-lint --offline + + compose: + name: Compose policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: .python-version + + - name: Install PyYAML + run: python -m pip install --disable-pip-version-check pyyaml==6.0.3 + + - name: Enforce container security contract + run: python ci/check_compose.py . + + tofu: + name: OpenTofu + runs-on: ubuntu-latest + defaults: + run: + working-directory: tower/terraform + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 + with: + tofu_version: 1.12.6 + + # -backend=false skips the S3 backend entirely, so validate needs no + # OCI credentials. The alternative would be to hand this job secrets, + # which the header comment explains we are not doing. + - name: Init without backend + run: tofu init -backend=false -input=false -no-color + + - name: Validate + run: tofu validate -no-color + + - name: Check formatting + run: tofu fmt -check -recursive -diff + + workflows: + name: Workflow lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: .python-version + + - name: Install zizmor + run: python -m pip install --disable-pip-version-check zizmor==1.30.1 + + # Scoped to this workflow on purpose. + # + # This is the only workflow that runs code from a pull request, so it is + # the one where an Actions-level flaw matters most. Auditing it keeps the + # properties in the header comment from regressing. + # + # The deploy workflows are excluded because they legitimately hold + # credentials: they need `secrets.*` to deploy, and two of them chain off + # a provisioning job. Those are sound here because the triggering + # workflow only runs on push-to-main, schedule and manual dispatch. + # zizmor currently reports 42 findings across those eight files, mostly + # `template-injection` on `secrets.*` interpolation and missing + # `persist-credentials: false`. Worth a dedicated hardening pass; not + # this PR. + - name: Audit this workflow + run: zizmor --persona=pedantic --min-severity=low .github/workflows/validate.yml diff --git a/.yamllint b/.yamllint new file mode 100644 index 00000000..f643f047 --- /dev/null +++ b/.yamllint @@ -0,0 +1,55 @@ +--- +extends: default + +rules: + # The nginx MIME-type replacement strings in system/config are unavoidably + # long. 160 keeps the rule useful for runaway blocks while leaving room for + # digests and URLs (which are non-breakable and allowed regardless). + line-length: + max: 160 + allow-non-breakable-words: true + allow-non-breakable-inline-mappings: false + + # Ansible reads `yes`/`no` as booleans, but `true`/`false` is unambiguous + # across YAML parsers (compose files, GitHub Actions, OpenTofu-adjacent + # tooling). Keys are exempt: task keywords like `when: no` read correctly + # and rewriting them risks behaviour changes. + truthy: + allowed-values: ["true", "false"] + check-keys: false + + # Every YAML file in the repo opens with `---`; keep it enforced so new + # files cannot drift in without the document marker. + document-start: + present: true + + # Ansible task files legitimately mix both sequence styles: role task lists + # sit flush with the parent key (`block:` / `- name:`), while playbook + # `roles:` / `vars_files:` lists are indented. `consistent` accepts either + # as long as a given file does not mix them, which is what actually + # catches real mistakes. + indentation: + spaces: consistent + indent-sequences: consistent + + # Flow mappings in the php_custom_extensions / hardening var files are + # column-aligned for readability. Permit the alignment padding. + braces: + min-spaces-inside: 0 + max-spaces-inside: 1 + commas: + min-spaces-after: 0 + max-spaces-after: 1 + + comments: + min-spaces-from-content: 1 + + # --- Compatibility with `ansible-lint --fix` ----------------------------- + # ansible-lint refuses to autofix when the project .yamllint disagrees with + # its own expectations for the `yaml` rule. Without these it reports "Found + # incompatible custom yamllint configuration" and silently disables fix + # mode, so `fqcn` remediation would never run. + comments-indentation: false + octal-values: + forbid-implicit-octal: true + forbid-explicit-octal: true diff --git a/ci/check_compose.py b/ci/check_compose.py new file mode 100755 index 00000000..51784920 --- /dev/null +++ b/ci/check_compose.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Enforce the container contract documented in CLAUDE.md. + +The prose rules in CLAUDE.md only help if something checks them. This script +turns them into a gate, and every exemption is an explicit entry with a +written reason — so a service needing an exception shows up in a code review +rather than being discovered during an incident. + +Exemptions are keyed by `(compose path glob, service name)` rather than by +service name alone, because names are ambiguous across stacks: `worker` is the +Docker-socket-holding authentik worker in one stack and an ordinary hardened +twenty.crm worker in another. + +Usage: + python3 ci/check_compose.py [ROOT] + +Exit codes: 0 = compliant, 1 = violations, 2 = bad invocation or parse error. +""" + +from __future__ import annotations + +import fnmatch +import sys +from pathlib import Path + +try: + import yaml +except ImportError: # pragma: no cover - CI always has PyYAML via ansible + print("PyYAML is required: pip install pyyaml", file=sys.stderr) + raise SystemExit(2) + +# (compose path glob, service name) -> why this service is exempt from the +# default cap_drop/no-new-privileges policy. +EXEMPTIONS: dict[tuple[str, str], str] = { + # Data stores: their entrypoints start as root and use gosu to drop to the + # database user, which needs CAP_SETUID/CAP_SETGID. Both cap_drop: [ALL] + # and no-new-privileges prevent the container from ever starting. + ("*/stalwart/*", "postgres"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", + ("*/stalwart/*", "keydb"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", + ("*/stalwart/*", "opensearch"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", + ("*/authentik/*", "database"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", + ("*/calcom/*", "database"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", + ("*/twenty/*", "db"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", + ("*/twenty/*", "redis"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", + ("*/opencloud/*", "garage"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", + ("*/hypebun-web/*", "db"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", + ("*/n8n/*", "postgres"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", + ("*/fleet/*", "database"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", + ("*/fleet/*", "kv"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", + # Docker-socket consumer: runs as root with the socket mounted so it can + # apply blueprints. Dropping caps breaks it. + ("*/authentik/*", "worker"): "authentik worker: needs Docker socket for blueprints", + # Apache/PHP image: breaks outright with cap_drop: [ALL]. Gets tmpfs only. + ("*/roundcube/*", "roundcubemail"): "Apache+PHP image breaks with cap_drop: [ALL]", + # Init container whose sole job is chowning volume paths; needs CAP_CHOWN. + ("*/fleet/*", "fleet-init"): "init container: sole job is chown, needs CAP_CHOWN", +} + +# Services that publish a port on all interfaces on purpose, with the reason. +# Everything else must bind to loopback and let a reverse proxy do the exposing. +PUBLIC_PUBLISHERS: dict[tuple[str, str], str] = { + # MX: the mail server's whole purpose is receiving SMTP/IMAP from the + # internet, so these must be reachable directly. + ("*/stalwart/*", "stalwart"): "MX: SMTP/IMAP must be internet-reachable", + # Headscale is the WireGuard endpoint; clients connect from outside. + ("*/headscale/*", "headscale"): "WireGuard/Norelay endpoint must be reachable", + # frankenphp terminates ACME (HTTP-01) and serves the site directly. + ("*/hypebun-web/*", "frankenphp"): "terminates ACME HTTP-01 and serves the site", +} + +# Stateless services that must run read-only. Mirrors the "Applied to:" list +# in CLAUDE.md. +READ_ONLY_REQUIRED = {"cloudflared", "webmail", "runner", "cobalt-api"} + +# Capabilities that may be re-added via cap_add. Anything else is a finding: a +# new cap_add should be a conscious, documented decision. +ALLOWED_CAP_ADD = { + "NET_BIND_SERVICE", # binds a privileged port directly + "NET_ADMIN", # VPN / network management + "SYS_NICE", # real-time scheduling (MySQL) + "CHOWN", # init containers that chown volume paths + # Paired with CHOWN on the init containers that also drop privileges. + "SETUID", "SETGID", "DAC_OVERRIDE", "FOWNER", "KILL", +} + + +def as_list(value) -> list: + if value is None: + return [] + return value if isinstance(value, list) else [value] + + +def has_nnp(svc: dict) -> bool: + return any("no-new-privileges" in str(o) for o in as_list(svc.get("security_opt"))) + + +def lookup(table: dict, rel: str, name: str) -> str | None: + for (pattern, service), reason in table.items(): + if service == name and fnmatch.fnmatch(rel, pattern): + return reason + return None + + +def check_compose(path: Path, root: Path) -> list[str]: + problems: list[str] = [] + rel = str(path.relative_to(root)) + + try: + doc = yaml.safe_load(path.read_text()) + except yaml.YAMLError as exc: + return [f"{rel}: cannot parse: {exc}"] + + if not isinstance(doc, dict): + return [f"{rel}: top level is not a mapping"] + + if "version" in doc: + problems.append( + f"{rel}: top-level `version:` is deprecated in Compose V2 — remove it" + ) + + app_infra = (doc.get("networks") or {}).get("app-infra") or {} + if not app_infra.get("external"): + problems.append( + f"{rel}: missing `networks.app-infra.external: true` — every stack " + "joins the shared external network" + ) + + for name, svc in (doc.get("services") or {}).items(): + where = f"{rel}: service `{name}`" + if not isinstance(svc, dict): + problems.append(f"{where}: definition is not a mapping") + continue + + if not svc.get("image"): + problems.append(f"{where}: no `image:` key") + else: + ref = str(svc["image"]).split("@", 1)[0] + if ref.endswith(":latest"): + problems.append( + f"{where}: image `{svc['image']}` uses a floating `latest` tag" + ) + + if svc.get("privileged"): + problems.append(f"{where}: `privileged: true` is never allowed") + + problems.extend(check_ports(where, name, rel, svc)) + + reason = lookup(EXEMPTIONS, rel, name) + cap_drop = [str(c) for c in as_list(svc.get("cap_drop"))] + + if reason is None: + if "ALL" not in cap_drop: + problems.append(f"{where}: missing `cap_drop: [ALL]`") + if not has_nnp(svc): + problems.append( + f"{where}: missing `security_opt: [no-new-privileges:true]`" + ) + elif cap_drop or has_nnp(svc): + problems.append( + f"{where}: exempt ({reason}) yet also sets cap_drop/security_opt — " + "drop the stale exemption or the redundant keys" + ) + + cap_add = {str(c) for c in as_list(svc.get("cap_add"))} + unexpected = sorted(cap_add - ALLOWED_CAP_ADD) + if unexpected: + noun = "capability" if len(unexpected) == 1 else "capabilities" + problems.append( + f"{where}: cap_add has undocumented {noun} {unexpected} — add to " + "ALLOWED_CAP_ADD in ci/check_compose.py and document the reason " + "in CLAUDE.md first" + ) + if cap_add and reason is None and "ALL" not in cap_drop: + problems.append(f"{where}: `cap_add` must be paired with `cap_drop: [ALL]`") + + read_only = bool(svc.get("read_only")) + if name in READ_ONLY_REQUIRED and not read_only: + problems.append( + f"{where}: stateless service must set `read_only: true` plus tmpfs " + "for writable paths" + ) + if read_only and name not in READ_ONLY_REQUIRED and reason is None: + problems.append( + f"{where}: sets `read_only: true` but is not in READ_ONLY_REQUIRED " + "— record the intent there" + ) + + return problems + + +def check_ports(where: str, name: str, rel: str, svc: dict) -> list[str]: + problems: list[str] = [] + publisher = lookup(PUBLIC_PUBLISHERS, rel, name) + for port in as_list(svc.get("ports")): + text = str(port) + if ":" not in text: + problems.append(f"{where}: port `{text}` has no host mapping") + continue + host = text.split(":", 1)[0] + if host in ("127.0.0.1", "localhost", "::1"): + continue + if publisher is None: + problems.append( + f"{where}: port `{text}` is not bound to loopback — use " + "127.0.0.1:: and let a reverse proxy publish it, or " + "add a PUBLIC_PUBLISHERS entry in ci/check_compose.py explaining why" + ) + return problems + + +def main(argv: list[str]) -> int: + root = Path(argv[1] if len(argv) > 1 else ".").resolve() + files = sorted(root.glob("*/containers/*/docker-compose*.yml")) + if not files: + print(f"no compose files found under {root}", file=sys.stderr) + return 2 + + problems: list[str] = [] + services = 0 + for path in files: + problems.extend(check_compose(path, root)) + doc = yaml.safe_load(path.read_text()) or {} + services += len(doc.get("services") or {}) + + if problems: + print(f"compose policy: {len(problems)} violation(s)\n", file=sys.stderr) + for problem in problems: + print(f" {problem}", file=sys.stderr) + print("", file=sys.stderr) + return 1 + + print( + f"compose policy: OK ({len(files)} files, {services} services, " + f"{len(EXEMPTIONS)} exemptions, {len(PUBLIC_PUBLISHERS)} public publishers)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/ci/check_workflow_secrets.sh b/ci/check_workflow_secrets.sh new file mode 100755 index 00000000..91d1ea50 --- /dev/null +++ b/ci/check_workflow_secrets.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Assert that the pull_request validation workflow holds no credentials. +# +# Secret availability on pull_request runs is a property of the platform, not +# of this repository. The property asserted here is self-contained instead: the +# validation workflow declares no credentials at all, so its behaviour does not +# depend on who opened the pull request. +# +# This script enforces that by construction, so a later edit that adds a +# credential reference fails CI rather than passing unnoticed. +# +# Usage: ci/check_workflow_secrets.sh [more.yml ...] + +set -euo pipefail + +if [ "$#" -eq 0 ]; then + echo "usage: $0 [...]" >&2 + exit 2 +fi + +status=0 + +for workflow in "$@"; do + if [ ! -f "$workflow" ]; then + echo "$workflow: not found" >&2 + status=1 + continue + fi + + # Strip comments before scanning. This file documents the forbidden patterns + # in prose; matching the documentation would make the check unpassable. + stripped=$(sed -E 's/[[:space:]]*#.*$//' "$workflow") + + findings=0 + + # 1. No secrets context. GITHUB_TOKEN is the sole exception: it is + # automatically scoped read-only for PRs and is not a repository secret. + if printf '%s\n' "$stripped" | grep -nE '\$\{\{[[:space:]]*secrets\.' >/dev/null; then + echo "$workflow: references the secrets context. A pull_request validation" >&2 + echo " workflow should declare no credentials — see the header comment in" >&2 + echo " .github/workflows/validate.yml for where that belongs." >&2 + findings=$((findings + 1)) + fi + + # 2. No environment: key. Environments gate access to environment-scoped + # secrets, so naming one is an implicit secret grant. + if printf '%s\n' "$stripped" | grep -nE '^[[:space:]]*environment:[[:space:]]*[^[:space:]]' >/dev/null; then + echo "$workflow: sets an 'environment:' — environments can gate secret access" >&2 + findings=$((findings + 1)) + fi + + # 3. Triggers that run with elevated privileges on someone else's behalf are + # out of scope for a static-check workflow. Matched as a bare word so both + # the nested and the compact inline spelling are caught. + if printf '%s\n' "$stripped" | grep -qw 'pull_request_target'; then + echo "$workflow: uses pull_request_target, which runs with base-repo" >&2 + echo " credentials and a write-scoped token" >&2 + findings=$((findings + 1)) + fi + + # 4. Likewise for a workflow chained off another workflow's run, which + # executes with full repository secrets. Flag it for review. + if printf '%s\n' "$stripped" | grep -qw 'workflow_run'; then + echo "$workflow: uses workflow_run, which executes with repository secrets" >&2 + findings=$((findings + 1)) + fi + + # 5. Write scopes would let PR code push to the repo or read actions/cache + # with write access. contents: read is the only permission allowed. + if printf '%s\n' "$stripped" | grep -nE '^[[:space:]]*(contents|actions|packages|id-token|deployments):[[:space:]]*write' >/dev/null; then + echo "$workflow: grants a write permission — pull_request workflows must be" >&2 + echo " read-only" >&2 + findings=$((findings + 1)) + fi + + # 6. checkout must not persist the token into .git/config, where any later + # step (including repo-controlled code) could read it off disk. + if printf '%s\n' "$stripped" | grep -q 'actions/checkout' \ + && ! printf '%s\n' "$stripped" | grep -q 'persist-credentials:[[:space:]]*false'; then + echo "$workflow: uses actions/checkout without 'persist-credentials: false'," >&2 + echo " leaving GITHUB_TOKEN in .git/config for later steps to read" >&2 + findings=$((findings + 1)) + fi + + if [ "$findings" -eq 0 ]; then + echo "$workflow: OK — no secret access path" + else + status=1 + fi +done + +exit "$status" diff --git a/ci/validate.sh b/ci/validate.sh new file mode 100755 index 00000000..c0fadc28 --- /dev/null +++ b/ci/validate.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Run every check that .github/workflows/validate.yml runs, locally. +# +# CI is the source of truth for what gates a PR; this script exists so the same +# failures show up before pushing rather than after. Keep the two in sync. +# +# Requirements: +# ansible-lint, yamllint, zizmor (pip, or via the repo venv) +# opentofu >= 1.12 (tofu) +# +# Usage: ci/validate.sh [ROOT] + +set -uo pipefail + +ROOT="${1:-.}" +cd "$ROOT" || exit 2 + +status=0 +run() { + local label="$1" + shift + printf '\n=== %s ===\n' "$label" + if "$@"; then + printf 'PASS: %s\n' "$label" + else + printf 'FAIL: %s\n' "$label" + status=1 + fi +} + +have() { command -v "$1" >/dev/null 2>&1; } + +# 1. This workflow must not be able to reach secrets. Runs first and without +# any dependencies, because it is the check that protects the other four: +# they all execute code from the pull request. +run "workflow secret guard" ./ci/check_workflow_secrets.sh .github/workflows/validate.yml + +# 2. Container security contract from CLAUDE.md, enforced. +if have python3; then + run "compose policy" python3 ci/check_compose.py . +else + printf '\nSKIP: compose policy (python3 not found)\n' + status=1 +fi + +# 3. YAML style and correctness. +if have yamllint; then + run "yamllint" yamllint --strict --config-file .yamllint . +else + printf '\nSKIP: yamllint (not installed)\n' + status=1 +fi + +# 4. Ansible correctness: syntax-check, FQCN, idempotency, risky permissions. +if have ansible-lint; then + run "ansible-lint" ansible-lint --offline +else + printf '\nSKIP: ansible-lint (not installed)\n' + status=1 +fi + +# 5. OpenTofu for the tower host. -backend=false means no OCI credentials and +# no state access are needed, matching CI. +if have tofu; then + run "tofu validate" env -C tower/terraform tofu init -backend=false -input=false -no-color + run "tofu fmt" tofu -chdir=tower/terraform fmt -check -recursive +else + printf '\nSKIP: tofu (not installed)\n' + status=1 +fi + +# 6. Audit this workflow for Actions-level privilege footguns. +if have zizmor; then + run "zizmor" zizmor --persona=pedantic --min-severity=low .github/workflows/validate.yml +else + printf '\nSKIP: zizmor (not installed)\n' + status=1 +fi + +printf '\n' +if [ "$status" -eq 0 ]; then + echo "all checks passed" +else + echo "one or more checks failed" +fi +exit "$status" From b419a18c4633d088a9a0834801acdcb67002409b Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 25 Sep 2026 23:20:57 +0200 Subject: [PATCH 4/6] ci: drop the two custom check scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither survived scrutiny, and keeping them was worse than not having them. check_workflow_secrets.sh asserted that validate.yml declares no credentials. It ran as a job in the same workflow it was checking, on the pull request's own copy of itself — so a pull request could delete the script or the job and pass. main is not a protected branch either, so no check is required. It was documentation wearing a test's clothes. Testing also showed it was largely redundant with the zizmor job beside it: of its seven rules, zizmor independently catches secrets references, pull_request_target, workflow_run and write permissions. Its one unique rule was the environment: key, which zizmor passes. And it had a real bug — the persist-credentials check was file-global rather than per-step, so dropping the setting from any one of five checkouts still passed, which is the exact case the rule exists for. check_compose.py enforced the container contract from CLAUDE.md. That contract is prose, and expressing it in code created a second copy to keep in sync. The script was also the only thing forcing a hand-maintained exemption list into the repo, keyed by (compose path, service) because service names are ambiguous across stacks. Both were review aids dressed as gates. The underlying rules stay where they were read before: CLAUDE.md, with the reasoning about why they are easy to miss now written next to the rules. Leaves yamllint, ansible-lint, tofu validate/fmt and zizmor, all green. --- .github/workflows/validate.yml | 37 +---- CLAUDE.md | 34 +++-- README.md | 33 ++--- ci/check_compose.py | 240 --------------------------------- ci/check_workflow_secrets.sh | 92 ------------- ci/validate.sh | 21 +-- 6 files changed, 41 insertions(+), 416 deletions(-) delete mode 100755 ci/check_compose.py delete mode 100755 ci/check_workflow_secrets.sh diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5ac98f05..1b275fd6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -6,9 +6,9 @@ # # 1. It references no `secrets.*` and no `environment:`, so the job runs with # no access to repository or environment secrets regardless of who opened -# the pull request. -# `ci/check_workflow_secrets.sh` fails the build if a `secrets.` or -# `environment:` reference ever appears in this file. +# the pull request. zizmor's `artipacked` audit and the reviewer's eye are +# what keep that true; nothing in this repo can enforce it, because a pull +# request can edit this workflow. # 2. `permissions: contents: read` at workflow level — no write scopes and # no `id-token`. # 3. Third-party actions are pinned to a full commit SHA, so the code that @@ -38,19 +38,6 @@ concurrency: cancel-in-progress: true jobs: - # Fails if this workflow ever gains a secrets reference, so the property - # described in the header comment cannot silently regress. - no-secrets: - name: Guard against secret exposure - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Assert this workflow cannot read secrets - run: ./ci/check_workflow_secrets.sh .github/workflows/validate.yml - yamllint: name: yamllint runs-on: ubuntu-latest @@ -103,24 +90,6 @@ jobs: - name: Lint playbooks and roles run: ansible-lint --offline - compose: - name: Compose policy - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version-file: .python-version - - - name: Install PyYAML - run: python -m pip install --disable-pip-version-check pyyaml==6.0.3 - - - name: Enforce container security contract - run: python ci/check_compose.py . - tofu: name: OpenTofu runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 51323f9f..a0b63d0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,33 +117,31 @@ Applied to: cloudflared, webmail, n8n runner. - **All data store images** (PostgreSQL, MySQL, Redis, KeyDB, Valkey, OpenSearch, Meilisearch) — their entrypoints start as `root` and use `gosu` to drop to the database user, which requires `CAP_SETUID`/`CAP_SETGID`. Both `cap_drop: [ALL]` and `no-new-privileges:true` break this pattern and prevent the container from starting. - `roundcube` — Apache+PHP image breaks with `cap_drop: [ALL]`; gets `tmpfs: [/tmp]` only. -> **This section is enforced, not advisory.** `ci/check_compose.py` runs on -> every pull request and checks each service against the rules above. -> Exemptions live in the `EXEMPTIONS` dict in that script, keyed by -> `(compose path glob, service name)` — a service name alone is ambiguous, -> since `worker` is the Docker-socket-holding authentik worker in one stack -> and an ordinary hardened twenty.crm worker in another. +> **This section is not enforced by CI.** Nothing checks a compose file, so a +> service that omits `cap_drop` passes review only if someone catches it. The +> rules above are the contract; applying them is the reviewer's job. > -> Adding an exemption is a deliberate, reviewable change. Services that -> publish a port on all interfaces on purpose (the MX, headscale, frankenphp) -> are listed separately in `PUBLIC_PUBLISHERS`. A new `cap_add` capability -> must be added to `ALLOWED_CAP_ADD` and documented above. -> -> If you add a service and CI fails, fix the compose file — do not add an -> exemption to make the check pass. +> Two things make this harder to eyeball than it looks. Service names are +> ambiguous across stacks — `worker` is the Docker-socket-holding authentik +> worker in one stack and an ordinary hardened twenty.crm worker in another, so +> "the worker is exempt" is not a safe rule of thumb. And three services +> deliberately publish a port on all interfaces: the MX (it exists to receive +> SMTP), headscale (WireGuard endpoint), frankenphp (terminates ACME +> HTTP-01). Anything else must bind `127.0.0.1`. ## Adding a Service 1. Create `/containers//docker-compose.yml` 2. Add to `docker_stacks` in `/ansible/roles/system/containers/defaults/main.yml` -3. Run `ci/validate.sh` — the compose policy check will flag anything missing +3. Apply the container contract above — CI will not check it for you +4. Run `ci/validate.sh` for the checks that do exist ## Validation -`ci/validate.sh` runs every check that CI runs: yamllint, ansible-lint, the -compose policy check, `tofu validate` / `tofu fmt` for `tower`, and a zizmor -audit of the validation workflow. Run it before pushing; the same failures -otherwise surface as a red PR. +`ci/validate.sh` runs every check that CI runs: yamllint, ansible-lint, +`tofu validate` / `tofu fmt` for `tower`, and a zizmor audit of the validation +workflow. Run it before pushing; the same failures otherwise surface as a red +PR. Configs live in `.yamllint` and `.ansible-lint`. Two things to know when editing them: diff --git a/README.md b/README.md index 64109c8f..a62b54d9 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,6 @@ production rather than during a deploy. | Check | Tool | Catches | |---|---|---| -| Workflow secret guard | `ci/check_workflow_secrets.sh` | The validation workflow gaining access to secrets | -| Compose policy | `ci/check_compose.py` | Missing `cap_drop`, public port bindings, unpinned or exempted services | | YAML | `yamllint` | Syntax errors, duplicate keys, style drift | | Ansible | `ansible-lint` | Broken syntax, missing FQCN, non-idempotent commands, unset file modes | | OpenTofu | `tofu validate` / `tofu fmt` | Invalid or unformatted configuration for `tower` | @@ -130,18 +128,21 @@ ci/validate.sh Needs `ansible-lint`, `yamllint`, `zizmor` and `tofu` on `PATH`. The script runs the same checks as CI, so failures reproduce locally. -### The container security contract is enforced +### The container security contract is not enforced -The rules described in `CLAUDE.md` (drop all capabilities, add -`no-new-privileges`, bind ports to loopback, pin image tags) are checked by -`ci/check_compose.py` rather than left to review discipline. Services that -genuinely cannot satisfy the policy — data stores that need `CAP_SETUID` to -run `gosu`, the authentik worker that needs the Docker socket — are listed in -`EXEMPTIONS` with a written reason. +The rules in `CLAUDE.md` (drop all capabilities, add `no-new-privileges`, bind +ports to loopback, pin image tags) are review-time guidance, not a gate. There +is no automated check, so a new service that omits `cap_drop` will pass CI and +rely on the reviewer noticing. -Adding an entry to that list is therefore a deliberate, reviewable change. If -a service is exempted but also sets `cap_drop` or `security_opt`, the check -fails, so a stale exemption cannot linger. +This was a deliberate trade-off. An earlier version enforced it with a script +keyed by an exemption list, but the contract is prose in `CLAUDE.md` and would +have had two places to drift apart. It was removed rather than maintained. + +If you want it enforced, the exemption list is the part that needs writing +first — a service name alone is ambiguous, since `worker` is the +Docker-socket-holding authentik worker in one stack and an ordinary hardened +twenty.crm worker in another. ### Why the validation workflow holds no secrets @@ -157,9 +158,11 @@ credentials at all — the checks are entirely static analysis: - Third-party actions are pinned to a full commit SHA, fixing the code that runs with this job's token. -`ci/check_workflow_secrets.sh` re-checks these properties on every run, so a -later edit that adds a credential reference fails CI rather than passing -unnoticed. +These are conventions, not controls. Nothing in this repository can enforce +them, because a pull request can edit the workflow that would do the enforcing. +`main` is not currently a protected branch, so nothing requires these checks to +pass either. Review is the actual control here; `zizmor` catches the mechanical +mistakes. The deploy workflows do use secrets — they have to. They are triggered by `push` to `main`, `schedule` and manual dispatch, never by `pull_request`, so diff --git a/ci/check_compose.py b/ci/check_compose.py deleted file mode 100755 index 51784920..00000000 --- a/ci/check_compose.py +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env python3 -"""Enforce the container contract documented in CLAUDE.md. - -The prose rules in CLAUDE.md only help if something checks them. This script -turns them into a gate, and every exemption is an explicit entry with a -written reason — so a service needing an exception shows up in a code review -rather than being discovered during an incident. - -Exemptions are keyed by `(compose path glob, service name)` rather than by -service name alone, because names are ambiguous across stacks: `worker` is the -Docker-socket-holding authentik worker in one stack and an ordinary hardened -twenty.crm worker in another. - -Usage: - python3 ci/check_compose.py [ROOT] - -Exit codes: 0 = compliant, 1 = violations, 2 = bad invocation or parse error. -""" - -from __future__ import annotations - -import fnmatch -import sys -from pathlib import Path - -try: - import yaml -except ImportError: # pragma: no cover - CI always has PyYAML via ansible - print("PyYAML is required: pip install pyyaml", file=sys.stderr) - raise SystemExit(2) - -# (compose path glob, service name) -> why this service is exempt from the -# default cap_drop/no-new-privileges policy. -EXEMPTIONS: dict[tuple[str, str], str] = { - # Data stores: their entrypoints start as root and use gosu to drop to the - # database user, which needs CAP_SETUID/CAP_SETGID. Both cap_drop: [ALL] - # and no-new-privileges prevent the container from ever starting. - ("*/stalwart/*", "postgres"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", - ("*/stalwart/*", "keydb"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", - ("*/stalwart/*", "opensearch"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", - ("*/authentik/*", "database"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", - ("*/calcom/*", "database"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", - ("*/twenty/*", "db"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", - ("*/twenty/*", "redis"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", - ("*/opencloud/*", "garage"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", - ("*/hypebun-web/*", "db"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", - ("*/n8n/*", "postgres"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", - ("*/fleet/*", "database"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", - ("*/fleet/*", "kv"): "data store: gosu entrypoint needs CAP_SETUID/CAP_SETGID", - # Docker-socket consumer: runs as root with the socket mounted so it can - # apply blueprints. Dropping caps breaks it. - ("*/authentik/*", "worker"): "authentik worker: needs Docker socket for blueprints", - # Apache/PHP image: breaks outright with cap_drop: [ALL]. Gets tmpfs only. - ("*/roundcube/*", "roundcubemail"): "Apache+PHP image breaks with cap_drop: [ALL]", - # Init container whose sole job is chowning volume paths; needs CAP_CHOWN. - ("*/fleet/*", "fleet-init"): "init container: sole job is chown, needs CAP_CHOWN", -} - -# Services that publish a port on all interfaces on purpose, with the reason. -# Everything else must bind to loopback and let a reverse proxy do the exposing. -PUBLIC_PUBLISHERS: dict[tuple[str, str], str] = { - # MX: the mail server's whole purpose is receiving SMTP/IMAP from the - # internet, so these must be reachable directly. - ("*/stalwart/*", "stalwart"): "MX: SMTP/IMAP must be internet-reachable", - # Headscale is the WireGuard endpoint; clients connect from outside. - ("*/headscale/*", "headscale"): "WireGuard/Norelay endpoint must be reachable", - # frankenphp terminates ACME (HTTP-01) and serves the site directly. - ("*/hypebun-web/*", "frankenphp"): "terminates ACME HTTP-01 and serves the site", -} - -# Stateless services that must run read-only. Mirrors the "Applied to:" list -# in CLAUDE.md. -READ_ONLY_REQUIRED = {"cloudflared", "webmail", "runner", "cobalt-api"} - -# Capabilities that may be re-added via cap_add. Anything else is a finding: a -# new cap_add should be a conscious, documented decision. -ALLOWED_CAP_ADD = { - "NET_BIND_SERVICE", # binds a privileged port directly - "NET_ADMIN", # VPN / network management - "SYS_NICE", # real-time scheduling (MySQL) - "CHOWN", # init containers that chown volume paths - # Paired with CHOWN on the init containers that also drop privileges. - "SETUID", "SETGID", "DAC_OVERRIDE", "FOWNER", "KILL", -} - - -def as_list(value) -> list: - if value is None: - return [] - return value if isinstance(value, list) else [value] - - -def has_nnp(svc: dict) -> bool: - return any("no-new-privileges" in str(o) for o in as_list(svc.get("security_opt"))) - - -def lookup(table: dict, rel: str, name: str) -> str | None: - for (pattern, service), reason in table.items(): - if service == name and fnmatch.fnmatch(rel, pattern): - return reason - return None - - -def check_compose(path: Path, root: Path) -> list[str]: - problems: list[str] = [] - rel = str(path.relative_to(root)) - - try: - doc = yaml.safe_load(path.read_text()) - except yaml.YAMLError as exc: - return [f"{rel}: cannot parse: {exc}"] - - if not isinstance(doc, dict): - return [f"{rel}: top level is not a mapping"] - - if "version" in doc: - problems.append( - f"{rel}: top-level `version:` is deprecated in Compose V2 — remove it" - ) - - app_infra = (doc.get("networks") or {}).get("app-infra") or {} - if not app_infra.get("external"): - problems.append( - f"{rel}: missing `networks.app-infra.external: true` — every stack " - "joins the shared external network" - ) - - for name, svc in (doc.get("services") or {}).items(): - where = f"{rel}: service `{name}`" - if not isinstance(svc, dict): - problems.append(f"{where}: definition is not a mapping") - continue - - if not svc.get("image"): - problems.append(f"{where}: no `image:` key") - else: - ref = str(svc["image"]).split("@", 1)[0] - if ref.endswith(":latest"): - problems.append( - f"{where}: image `{svc['image']}` uses a floating `latest` tag" - ) - - if svc.get("privileged"): - problems.append(f"{where}: `privileged: true` is never allowed") - - problems.extend(check_ports(where, name, rel, svc)) - - reason = lookup(EXEMPTIONS, rel, name) - cap_drop = [str(c) for c in as_list(svc.get("cap_drop"))] - - if reason is None: - if "ALL" not in cap_drop: - problems.append(f"{where}: missing `cap_drop: [ALL]`") - if not has_nnp(svc): - problems.append( - f"{where}: missing `security_opt: [no-new-privileges:true]`" - ) - elif cap_drop or has_nnp(svc): - problems.append( - f"{where}: exempt ({reason}) yet also sets cap_drop/security_opt — " - "drop the stale exemption or the redundant keys" - ) - - cap_add = {str(c) for c in as_list(svc.get("cap_add"))} - unexpected = sorted(cap_add - ALLOWED_CAP_ADD) - if unexpected: - noun = "capability" if len(unexpected) == 1 else "capabilities" - problems.append( - f"{where}: cap_add has undocumented {noun} {unexpected} — add to " - "ALLOWED_CAP_ADD in ci/check_compose.py and document the reason " - "in CLAUDE.md first" - ) - if cap_add and reason is None and "ALL" not in cap_drop: - problems.append(f"{where}: `cap_add` must be paired with `cap_drop: [ALL]`") - - read_only = bool(svc.get("read_only")) - if name in READ_ONLY_REQUIRED and not read_only: - problems.append( - f"{where}: stateless service must set `read_only: true` plus tmpfs " - "for writable paths" - ) - if read_only and name not in READ_ONLY_REQUIRED and reason is None: - problems.append( - f"{where}: sets `read_only: true` but is not in READ_ONLY_REQUIRED " - "— record the intent there" - ) - - return problems - - -def check_ports(where: str, name: str, rel: str, svc: dict) -> list[str]: - problems: list[str] = [] - publisher = lookup(PUBLIC_PUBLISHERS, rel, name) - for port in as_list(svc.get("ports")): - text = str(port) - if ":" not in text: - problems.append(f"{where}: port `{text}` has no host mapping") - continue - host = text.split(":", 1)[0] - if host in ("127.0.0.1", "localhost", "::1"): - continue - if publisher is None: - problems.append( - f"{where}: port `{text}` is not bound to loopback — use " - "127.0.0.1:: and let a reverse proxy publish it, or " - "add a PUBLIC_PUBLISHERS entry in ci/check_compose.py explaining why" - ) - return problems - - -def main(argv: list[str]) -> int: - root = Path(argv[1] if len(argv) > 1 else ".").resolve() - files = sorted(root.glob("*/containers/*/docker-compose*.yml")) - if not files: - print(f"no compose files found under {root}", file=sys.stderr) - return 2 - - problems: list[str] = [] - services = 0 - for path in files: - problems.extend(check_compose(path, root)) - doc = yaml.safe_load(path.read_text()) or {} - services += len(doc.get("services") or {}) - - if problems: - print(f"compose policy: {len(problems)} violation(s)\n", file=sys.stderr) - for problem in problems: - print(f" {problem}", file=sys.stderr) - print("", file=sys.stderr) - return 1 - - print( - f"compose policy: OK ({len(files)} files, {services} services, " - f"{len(EXEMPTIONS)} exemptions, {len(PUBLIC_PUBLISHERS)} public publishers)" - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv)) diff --git a/ci/check_workflow_secrets.sh b/ci/check_workflow_secrets.sh deleted file mode 100755 index 91d1ea50..00000000 --- a/ci/check_workflow_secrets.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env bash -# Assert that the pull_request validation workflow holds no credentials. -# -# Secret availability on pull_request runs is a property of the platform, not -# of this repository. The property asserted here is self-contained instead: the -# validation workflow declares no credentials at all, so its behaviour does not -# depend on who opened the pull request. -# -# This script enforces that by construction, so a later edit that adds a -# credential reference fails CI rather than passing unnoticed. -# -# Usage: ci/check_workflow_secrets.sh [more.yml ...] - -set -euo pipefail - -if [ "$#" -eq 0 ]; then - echo "usage: $0 [...]" >&2 - exit 2 -fi - -status=0 - -for workflow in "$@"; do - if [ ! -f "$workflow" ]; then - echo "$workflow: not found" >&2 - status=1 - continue - fi - - # Strip comments before scanning. This file documents the forbidden patterns - # in prose; matching the documentation would make the check unpassable. - stripped=$(sed -E 's/[[:space:]]*#.*$//' "$workflow") - - findings=0 - - # 1. No secrets context. GITHUB_TOKEN is the sole exception: it is - # automatically scoped read-only for PRs and is not a repository secret. - if printf '%s\n' "$stripped" | grep -nE '\$\{\{[[:space:]]*secrets\.' >/dev/null; then - echo "$workflow: references the secrets context. A pull_request validation" >&2 - echo " workflow should declare no credentials — see the header comment in" >&2 - echo " .github/workflows/validate.yml for where that belongs." >&2 - findings=$((findings + 1)) - fi - - # 2. No environment: key. Environments gate access to environment-scoped - # secrets, so naming one is an implicit secret grant. - if printf '%s\n' "$stripped" | grep -nE '^[[:space:]]*environment:[[:space:]]*[^[:space:]]' >/dev/null; then - echo "$workflow: sets an 'environment:' — environments can gate secret access" >&2 - findings=$((findings + 1)) - fi - - # 3. Triggers that run with elevated privileges on someone else's behalf are - # out of scope for a static-check workflow. Matched as a bare word so both - # the nested and the compact inline spelling are caught. - if printf '%s\n' "$stripped" | grep -qw 'pull_request_target'; then - echo "$workflow: uses pull_request_target, which runs with base-repo" >&2 - echo " credentials and a write-scoped token" >&2 - findings=$((findings + 1)) - fi - - # 4. Likewise for a workflow chained off another workflow's run, which - # executes with full repository secrets. Flag it for review. - if printf '%s\n' "$stripped" | grep -qw 'workflow_run'; then - echo "$workflow: uses workflow_run, which executes with repository secrets" >&2 - findings=$((findings + 1)) - fi - - # 5. Write scopes would let PR code push to the repo or read actions/cache - # with write access. contents: read is the only permission allowed. - if printf '%s\n' "$stripped" | grep -nE '^[[:space:]]*(contents|actions|packages|id-token|deployments):[[:space:]]*write' >/dev/null; then - echo "$workflow: grants a write permission — pull_request workflows must be" >&2 - echo " read-only" >&2 - findings=$((findings + 1)) - fi - - # 6. checkout must not persist the token into .git/config, where any later - # step (including repo-controlled code) could read it off disk. - if printf '%s\n' "$stripped" | grep -q 'actions/checkout' \ - && ! printf '%s\n' "$stripped" | grep -q 'persist-credentials:[[:space:]]*false'; then - echo "$workflow: uses actions/checkout without 'persist-credentials: false'," >&2 - echo " leaving GITHUB_TOKEN in .git/config for later steps to read" >&2 - findings=$((findings + 1)) - fi - - if [ "$findings" -eq 0 ]; then - echo "$workflow: OK — no secret access path" - else - status=1 - fi -done - -exit "$status" diff --git a/ci/validate.sh b/ci/validate.sh index c0fadc28..5bdbcc6b 100755 --- a/ci/validate.sh +++ b/ci/validate.sh @@ -30,20 +30,7 @@ run() { have() { command -v "$1" >/dev/null 2>&1; } -# 1. This workflow must not be able to reach secrets. Runs first and without -# any dependencies, because it is the check that protects the other four: -# they all execute code from the pull request. -run "workflow secret guard" ./ci/check_workflow_secrets.sh .github/workflows/validate.yml - -# 2. Container security contract from CLAUDE.md, enforced. -if have python3; then - run "compose policy" python3 ci/check_compose.py . -else - printf '\nSKIP: compose policy (python3 not found)\n' - status=1 -fi - -# 3. YAML style and correctness. +# 1. YAML style and correctness. if have yamllint; then run "yamllint" yamllint --strict --config-file .yamllint . else @@ -51,7 +38,7 @@ else status=1 fi -# 4. Ansible correctness: syntax-check, FQCN, idempotency, risky permissions. +# 2. Ansible correctness: syntax-check, FQCN, idempotency, risky permissions. if have ansible-lint; then run "ansible-lint" ansible-lint --offline else @@ -59,7 +46,7 @@ else status=1 fi -# 5. OpenTofu for the tower host. -backend=false means no OCI credentials and +# 3. OpenTofu for the tower host. -backend=false means no OCI credentials and # no state access are needed, matching CI. if have tofu; then run "tofu validate" env -C tower/terraform tofu init -backend=false -input=false -no-color @@ -69,7 +56,7 @@ else status=1 fi -# 6. Audit this workflow for Actions-level privilege footguns. +# 4. Audit this workflow for Actions-level privilege footguns. if have zizmor; then run "zizmor" zizmor --persona=pedantic --min-severity=low .github/workflows/validate.yml else From e404a38af8880bc470141cafe907b11a65b14b77 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 25 Sep 2026 23:50:40 +0200 Subject: [PATCH 5/6] docs: describe the validation checks as merge gates main is now a protected branch with all four checks required, so the wording that described them as unenforced is stale. Replaced with what a maintainer actually needs to know: the checks block a merge, and retiring one requires removing it from required_status_checks.contexts in the same pull request. --- .github/workflows/validate.yml | 5 ++--- README.md | 9 ++++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1b275fd6..ccec2a7c 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -6,9 +6,8 @@ # # 1. It references no `secrets.*` and no `environment:`, so the job runs with # no access to repository or environment secrets regardless of who opened -# the pull request. zizmor's `artipacked` audit and the reviewer's eye are -# what keep that true; nothing in this repo can enforce it, because a pull -# request can edit this workflow. +# the pull request. zizmor's audits cover the mechanical cases; the rest +# is review. # 2. `permissions: contents: read` at workflow level — no write scopes and # no `id-token`. # 3. Third-party actions are pinned to a full commit SHA, so the code that diff --git a/README.md b/README.md index a62b54d9..b19b54bc 100644 --- a/README.md +++ b/README.md @@ -158,11 +158,10 @@ credentials at all — the checks are entirely static analysis: - Third-party actions are pinned to a full commit SHA, fixing the code that runs with this job's token. -These are conventions, not controls. Nothing in this repository can enforce -them, because a pull request can edit the workflow that would do the enforcing. -`main` is not currently a protected branch, so nothing requires these checks to -pass either. Review is the actual control here; `zizmor` catches the mechanical -mistakes. +All four checks are required status checks on `main`, so a pull request cannot +merge until they pass. If a check is retired or renamed, remove it from the +`required_status_checks.contexts` list in the same pull request — otherwise the +merge blocks waiting on a check that no longer reports. The deploy workflows do use secrets — they have to. They are triggered by `push` to `main`, `schedule` and manual dispatch, never by `pull_request`, so From 638d50a53cd63f24d1fad9322dec879bff662f39 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 26 Sep 2026 00:07:09 +0200 Subject: [PATCH 6/6] ci: use uv for the validation jobs, matching the deploy workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validate jobs pip-installed each tool in isolation, so ansible-lint brought its own ansible-core. That happened to match the deploys' ansible==14.4.0 (-> ansible-core~=2.21.4), but nothing tied them: bump ansible in requirements.txt and the deploys would move while validation checked a different version. Installing requirements.txt and ansible-lint into one uv environment makes the match structural. If they ever conflict the install fails loudly instead of quietly adding a second copy. Verified: all five packages co-resolve, and ansible-lint reports ansible-core 2.21.4 — the pinned one. Lint tool pins stay out of requirements.txt on purpose; that file is what the deploys install and a production playbook should not carry a linter. Local action referenced as $/.github/actions/... rather than ./. The $/ form is the current syntax and resolves to the exact running commit, so it does not depend on the workspace checkout or a hardcoded version. The tofu job needs no Python and is left alone. --- .github/actions/setup-lint-env/action.yml | 43 +++++++++++++++++++++++ .github/workflows/validate.yml | 35 +++++++++--------- 2 files changed, 61 insertions(+), 17 deletions(-) create mode 100644 .github/actions/setup-lint-env/action.yml diff --git a/.github/actions/setup-lint-env/action.yml b/.github/actions/setup-lint-env/action.yml new file mode 100644 index 00000000..8f74723c --- /dev/null +++ b/.github/actions/setup-lint-env/action.yml @@ -0,0 +1,43 @@ +--- +name: Setup lint environment +description: >- + Set up a uv-managed virtualenv and install the requested packages into it. + + Used by the validation jobs so they share one toolchain with the deploy + workflows rather than each pip-installing into whatever setup-python + provides. uv also caches, which is most of why ansible-lint is no longer the + slow job. + +inputs: + packages: + description: >- + Packages to install, in `uv pip install` syntax. The ansible job passes + `-r requirements.txt` alongside ansible-lint so that ansible-lint uses + the ansible-core the deploys pin, instead of pulling its own copy. + required: true + +runs: + using: composite + steps: + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: .python-version + + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + enable-cache: true + cache-dependency-glob: requirements.txt + + - name: Create virtualenv + run: uv venv + shell: bash + + - name: Install packages + run: uv pip install --python .venv/bin/python ${{ inputs.packages }} + shell: bash + + # uv does not put the environment on PATH by itself; without this the + # tools would resolve from whatever setup-python provides. + - name: Put the virtualenv on PATH + run: echo "$PWD/.venv/bin" >> "$GITHUB_PATH" + shell: bash diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ccec2a7c..def0bf34 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -45,12 +45,9 @@ jobs: with: persist-credentials: false - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + - uses: $/.github/actions/setup-lint-env with: - python-version-file: .python-version - - - name: Install yamllint - run: python -m pip install --disable-pip-version-check yamllint==1.38.0 + packages: yamllint==1.38.0 - name: Lint YAML run: yamllint --strict --config-file .yamllint . @@ -63,12 +60,20 @@ jobs: with: persist-credentials: false - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + # requirements.txt first, then ansible-lint, so that ansible-lint runs + # against the ansible-core the deploys pin rather than resolving its own. + # If the two ever conflict, this fails here instead of quietly installing + # a second copy and validating something deploys never run. + - uses: $/.github/actions/setup-lint-env with: - python-version-file: .python-version + packages: -r requirements.txt ansible-lint==26.9.0 - - name: Install ansible-lint - run: python -m pip install --disable-pip-version-check ansible-lint==26.9.0 + - name: Cache galaxy requirements + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.ansible + key: ansible-galaxy-${{ hashFiles('*/ansible/requirements.yml') }} + restore-keys: ansible-galaxy- # `ansible-galaxy install` (not `collection install`) so roles are # fetched too — requirements.yml lists roles as well as collections, and @@ -104,9 +109,8 @@ jobs: with: tofu_version: 1.12.6 - # -backend=false skips the S3 backend entirely, so validate needs no - # OCI credentials. The alternative would be to hand this job secrets, - # which the header comment explains we are not doing. + # -backend=false skips the S3 backend entirely, so validate needs no OCI + # credentials and no state access. - name: Init without backend run: tofu init -backend=false -input=false -no-color @@ -124,12 +128,9 @@ jobs: with: persist-credentials: false - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + - uses: $/.github/actions/setup-lint-env with: - python-version-file: .python-version - - - name: Install zizmor - run: python -m pip install --disable-pip-version-check zizmor==1.30.1 + packages: zizmor==1.30.1 # Scoped to this workflow on purpose. #