Capture current Ansible control plane state

Commit the accumulated infrastructure work that was living only in the
working tree: monitoring stack, emergency access/bot, gyro allocator,
grimmory, adguard, backup audit and the OpenCode agent definitions.

Also ignore Python bytecode, local archives and Nix/direnv artifacts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
This commit is contained in:
Dmitry
2026-08-26 21:39:28 +03:00
co-authored by Claude Opus 5
parent 4bafa7d09e
commit c676be81ec
126 changed files with 10583 additions and 44 deletions
+28
View File
@@ -0,0 +1,28 @@
---
gyro_user: gyro
gyro_group: gyro
gyro_home: /home/gyro
gyro_app_dir: /opt/gyro/app
gyro_config_dir: /etc/gyro
gyro_cache_dir: /var/cache/gyro
gyro_uv_venv: /opt/uv
gyro_uv_version: 0.12.5
gyro_python_min_version: "3.13"
gyro_timezone: Europe/Moscow
gyro_repo_url: ""
gyro_repo_version: main
gyro_git_known_hosts_name: ""
gyro_git_host_key: ""
gyro_deploy_enabled: false
gyro_tinvest_token: ""
gyro_tinvest_account_id: ""
gyro_telegram_bot_token: ""
gyro_telegram_user_id: ""
gyro_telegram_proxy: ""
gyro_dry_run_override: "true"
gyro_secrets_configured: false
gyro_timer_enabled: false
gyro_timer_on_calendar: "Mon..Fri *-*-* 11:00:00 Europe/Moscow"
@@ -0,0 +1,37 @@
#!/usr/bin/python3
import os
import socket
import sys
import urllib.parse
import urllib.request
def main() -> int:
token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
user_id = os.environ.get("TELEGRAM_USER_ID", "").strip()
if not token or not user_id:
print("Gyro failure notification skipped: Telegram credentials are absent")
return 1
proxy = os.environ.get("TELEGRAM_PROXY", "").strip()
handlers = [urllib.request.ProxyHandler({"http": proxy, "https": proxy})] if proxy else []
opener = urllib.request.build_opener(*handlers)
failed_unit = sys.argv[1] if len(sys.argv) > 1 else "gyro.service"
message = f"Gyro job failed on {socket.gethostname()}: {failed_unit}. Check journalctl -u gyro.service."
body = urllib.parse.urlencode({"chat_id": user_id, "text": message}).encode()
request = urllib.request.Request(
f"https://api.telegram.org/bot{token}/sendMessage",
data=body,
method="POST",
)
try:
with opener.open(request, timeout=20) as response:
return 0 if response.status == 200 else 1
except Exception as exc:
print(f"Gyro failure notification failed: {type(exc).__name__}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+4
View File
@@ -0,0 +1,4 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
+365
View File
@@ -0,0 +1,365 @@
---
- name: Install Gyro runtime packages
ansible.builtin.apt:
name:
- ca-certificates
- git
- openssh-client
- python3
- python3-packaging
- python3-venv
- sudo
- ufw
state: present
update_cache: true
- name: Configure Gyro timezone
community.general.timezone:
name: "{{ gyro_timezone }}"
- name: Create Gyro service group
ansible.builtin.group:
name: "{{ gyro_group }}"
system: true
- name: Create Gyro service user
ansible.builtin.user:
name: "{{ gyro_user }}"
group: "{{ gyro_group }}"
home: "{{ gyro_home }}"
shell: /bin/bash
system: true
create_home: true
- name: Create Gyro directories
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner }}"
group: "{{ item.group }}"
mode: "{{ item.mode }}"
loop:
- { path: "{{ gyro_app_dir }}", owner: "{{ gyro_user }}", group: "{{ gyro_group }}", mode: "0750" }
- { path: "{{ gyro_config_dir }}", owner: root, group: root, mode: "0700" }
- { path: "{{ gyro_cache_dir }}", owner: "{{ gyro_user }}", group: "{{ gyro_group }}", mode: "0700" }
- { path: "{{ gyro_home }}/.ssh", owner: "{{ gyro_user }}", group: "{{ gyro_group }}", mode: "0700" }
- name: Read system Python version
ansible.builtin.command: python3 -c "import platform; print(platform.python_version())"
register: gyro_python_version
changed_when: false
- name: Require Python 3.13 or newer
ansible.builtin.assert:
that:
- gyro_python_version.stdout is version(gyro_python_min_version, '>=')
fail_msg: "Gyro requires Python {{ gyro_python_min_version }} or newer; found {{ gyro_python_version.stdout }}."
- name: Create isolated uv installation environment
ansible.builtin.command:
argv:
- python3
- -m
- venv
- "{{ gyro_uv_venv }}"
creates: "{{ gyro_uv_venv }}/bin/pip"
- name: Install pinned uv version
ansible.builtin.pip:
name: "uv=={{ gyro_uv_version }}"
executable: "{{ gyro_uv_venv }}/bin/pip"
- name: Link uv into the system path
ansible.builtin.file:
src: "{{ gyro_uv_venv }}/bin/uv"
dest: /usr/local/bin/uv
state: link
- name: Generate Git deploy key on the container
ansible.builtin.command:
argv:
- ssh-keygen
- -q
- -t
- ed25519
- -N
- ""
- -C
- gyro@homelab
- -f
- "{{ gyro_home }}/.ssh/id_ed25519_gitea"
creates: "{{ gyro_home }}/.ssh/id_ed25519_gitea"
become: true
become_user: "{{ gyro_user }}"
vars:
ansible_become: true
- name: Secure Git deploy key ownership
ansible.builtin.file:
path: "{{ item.path }}"
owner: "{{ gyro_user }}"
group: "{{ gyro_group }}"
mode: "{{ item.mode }}"
loop:
- { path: "{{ gyro_home }}/.ssh/id_ed25519_gitea", mode: "0600" }
- { path: "{{ gyro_home }}/.ssh/id_ed25519_gitea.pub", mode: "0644" }
- name: Read Git deploy public key
ansible.builtin.slurp:
src: "{{ gyro_home }}/.ssh/id_ed25519_gitea.pub"
register: gyro_deploy_public_key
- name: Show Git deploy public key
ansible.builtin.debug:
msg: "{{ gyro_deploy_public_key.content | b64decode | trim }}"
- name: Allow SSH from the HomeLab LAN
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ homelab_lan_cidr }}"
- name: Allow SSH from the OpenVPN network
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ openvpn_network_cidr }}"
- name: Remove obsolete outbound Gitea SSH allowance
community.general.ufw:
rule: allow
delete: true
direction: out
dest: 192.168.1.25
port: "2222"
proto: tcp
- name: Allow outbound Telegram proxy access
community.general.ufw:
rule: allow
direction: out
dest: 192.168.1.27
port: "7890"
proto: tcp
- name: Deny other outbound HomeLab LAN access
community.general.ufw:
rule: deny
direction: out
dest: "{{ homelab_lan_cidr }}"
- name: Enable restrictive Gyro firewall
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: Mask mount units already provided by unprivileged LXC
ansible.builtin.systemd:
name: "{{ item }}"
enabled: false
masked: true
state: stopped
loop:
- dev-mqueue.mount
- run-lock.mount
- tmp.mount
- name: Clear stale failures from masked LXC mount units
ansible.builtin.command: >-
systemctl reset-failed dev-mqueue.mount run-lock.mount tmp.mount
changed_when: false
- name: Create locked placeholder environment file
ansible.builtin.copy:
dest: "{{ gyro_config_dir }}/gyro.env"
owner: root
group: root
mode: "0600"
force: false
content: |
# Managed by Ansible after gyro_secrets_configured is enabled.
DRY_RUN_OVERRIDE=true
- name: Validate deployment inputs
ansible.builtin.assert:
that:
- gyro_repo_url | length > 0
- gyro_git_known_hosts_name | length > 0
- gyro_git_host_key | length > 0
fail_msg: Set the repository URL and verified SSH host key before enabling deployment.
when: gyro_deploy_enabled | bool
- name: Remove obsolete Git SSH alias
ansible.builtin.file:
path: "{{ gyro_home }}/.ssh/config"
state: absent
when: gyro_deploy_enabled | bool
- name: Remove obsolete Gitea known host
ansible.builtin.known_hosts:
path: "{{ gyro_home }}/.ssh/known_hosts"
name: "[192.168.1.25]:2222"
state: absent
when: gyro_deploy_enabled | bool
- name: Pin verified Git host key
ansible.builtin.known_hosts:
path: "{{ gyro_home }}/.ssh/known_hosts"
name: "{{ gyro_git_known_hosts_name }}"
key: "{{ gyro_git_host_key }}"
when: gyro_deploy_enabled | bool
- name: Secure Git known hosts file
ansible.builtin.file:
path: "{{ gyro_home }}/.ssh/known_hosts"
owner: "{{ gyro_user }}"
group: "{{ gyro_group }}"
mode: "0600"
when: gyro_deploy_enabled | bool
- name: Ensure Gyro checkout belongs to the service user
ansible.builtin.file:
path: "{{ gyro_app_dir }}"
owner: "{{ gyro_user }}"
group: "{{ gyro_group }}"
recurse: true
when: gyro_deploy_enabled | bool
- name: Clone Gyro from Git remote
ansible.builtin.git:
repo: "{{ gyro_repo_url }}"
dest: "{{ gyro_app_dir }}"
version: "{{ gyro_repo_version }}"
key_file: "{{ gyro_home }}/.ssh/id_ed25519_gitea"
accept_hostkey: false
ssh_opts: >-
-o UserKnownHostsFile={{ gyro_home }}/.ssh/known_hosts
-o StrictHostKeyChecking=yes
-o IdentitiesOnly=yes
update: true
become: true
become_user: "{{ gyro_user }}"
vars:
ansible_become: true
when: gyro_deploy_enabled | bool
- name: Synchronize locked Gyro dependencies
ansible.builtin.command:
argv:
- /usr/local/bin/uv
- sync
- --frozen
args:
chdir: "{{ gyro_app_dir }}"
environment:
UV_CACHE_DIR: "{{ gyro_cache_dir }}/uv"
become: true
become_user: "{{ gyro_user }}"
vars:
ansible_become: true
register: gyro_uv_sync
changed_when: "'Installed' in gyro_uv_sync.stderr or 'Uninstalled' in gyro_uv_sync.stderr"
when: gyro_deploy_enabled | bool
- name: Verify bundled T-Invest CA file
ansible.builtin.stat:
path: "{{ gyro_app_dir }}/config/certs/russian_ca.pem"
register: gyro_ca_bundle
when: gyro_deploy_enabled | bool
- name: Require complete Gyro checkout
ansible.builtin.assert:
that:
- gyro_ca_bundle.stat.exists
- gyro_ca_bundle.stat.isreg | default(false)
fail_msg: The Git checkout does not contain config/certs/russian_ca.pem.
when: gyro_deploy_enabled | bool
- name: Run Gyro unit tests
ansible.builtin.command:
argv:
- /usr/local/bin/uv
- run
- --frozen
- --no-sync
- python
- -m
- unittest
- discover
- -s
- tests
args:
chdir: "{{ gyro_app_dir }}"
environment:
UV_CACHE_DIR: "{{ gyro_cache_dir }}/uv"
become: true
become_user: "{{ gyro_user }}"
vars:
ansible_become: true
changed_when: false
when: gyro_deploy_enabled | bool
- name: Validate Gyro Vault secrets
ansible.builtin.assert:
that:
- gyro_tinvest_token | length > 0
- gyro_tinvest_account_id | length > 0
- gyro_telegram_bot_token | length > 0
- (gyro_telegram_user_id | string | length) > 0
- gyro_dry_run_override in ['true', 'false']
fail_msg: Populate and encrypt inventory/host_vars/gyro/vault.yml before enabling secrets.
no_log: true
when: gyro_secrets_configured | bool
- name: Install Gyro environment file from Vault
ansible.builtin.template:
src: gyro.env.j2
dest: "{{ gyro_config_dir }}/gyro.env"
owner: root
group: root
mode: "0600"
no_log: true
when: gyro_secrets_configured | bool
- name: Install Gyro failure notifier
ansible.builtin.copy:
src: gyro-failure-notify.py
dest: /usr/local/libexec/gyro-failure-notify
owner: root
group: root
mode: "0755"
- name: Install Gyro systemd units
ansible.builtin.template:
src: "{{ item.src }}"
dest: "/etc/systemd/system/{{ item.dest }}"
owner: root
group: root
mode: "0644"
loop:
- { src: gyro.service.j2, dest: gyro.service }
- { src: gyro.timer.j2, dest: gyro.timer }
- { src: gyro-failure@.service.j2, dest: "gyro-failure@.service" }
notify: Reload systemd
- name: Apply systemd unit changes
ansible.builtin.meta: flush_handlers
- name: Enable Gyro timer only after deployment and secret setup
ansible.builtin.systemd:
name: gyro.timer
enabled: "{{ gyro_timer_ready }}"
state: "{{ 'started' if gyro_timer_ready else 'stopped' }}"
vars:
gyro_timer_ready: "{{ gyro_timer_enabled | bool and gyro_deploy_enabled | bool and gyro_secrets_configured | bool }}"
- name: Verify Gyro unit definitions
ansible.builtin.command: >-
systemd-analyze verify
/etc/systemd/system/gyro.service
/etc/systemd/system/gyro.timer
/etc/systemd/system/gyro-failure@.service
changed_when: false
@@ -0,0 +1,15 @@
[Unit]
Description=Notify Telegram about failed Gyro unit %i
[Service]
Type=oneshot
User={{ gyro_user }}
Group={{ gyro_group }}
EnvironmentFile={{ gyro_config_dir }}/gyro.env
ExecStart=/usr/local/libexec/gyro-failure-notify %i
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
+6
View File
@@ -0,0 +1,6 @@
TINVEST_TOKEN={{ gyro_tinvest_token | string | to_json }}
TINVEST_ACCOUNT_ID={{ gyro_tinvest_account_id | string | to_json }}
TELEGRAM_BOT_TOKEN={{ gyro_telegram_bot_token | string | to_json }}
TELEGRAM_USER_ID={{ gyro_telegram_user_id | string | to_json }}
TELEGRAM_PROXY={{ gyro_telegram_proxy | string | to_json }}
DRY_RUN_OVERRIDE={{ gyro_dry_run_override | string | to_json }}
@@ -0,0 +1,31 @@
[Unit]
Description=Gyro investment allocator
Wants=network-online.target
After=network-online.target
OnFailure=gyro-failure@%n.service
[Service]
Type=oneshot
User={{ gyro_user }}
Group={{ gyro_group }}
WorkingDirectory={{ gyro_app_dir }}
EnvironmentFile={{ gyro_config_dir }}/gyro.env
Environment=UV_CACHE_DIR={{ gyro_cache_dir }}/uv
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStart=/usr/local/bin/uv run --frozen --no-sync python main.py
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectClock=true
ProtectControlGroups=true
ProtectHome=true
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectSystem=strict
ReadWritePaths={{ gyro_cache_dir }}
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
@@ -0,0 +1,10 @@
[Unit]
Description=Run Gyro investment allocator on weekdays
[Timer]
OnCalendar={{ gyro_timer_on_calendar }}
Persistent=true
Unit=gyro.service
[Install]
WantedBy=timers.target