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:
@@ -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())
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user