mirror of
https://github.com/fscotto/infra.git
synced 2026-09-27 19:03:47 +00:00
Compare commits
72 Commits
66575e6d80
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa1c8c0b82 | ||
|
|
0b6efc9ad8 | ||
|
|
361ee77d72 | ||
|
|
d4e40d423a | ||
|
|
0a5c2ac1a4 | ||
|
|
48a7f57f7e | ||
|
|
defa98c968 | ||
|
|
21e41f4fc1 | ||
|
|
e10c6694f8 | ||
|
|
4c10af3187 | ||
|
|
3ac732751c | ||
|
|
e837b0059b | ||
|
|
7e498514dd | ||
|
|
e7836ea25f | ||
|
|
de2c24d15c | ||
|
|
bd13cb65cc | ||
|
|
77afdda0a3 | ||
|
|
add75d74e9 | ||
|
|
e46a2b22d7 | ||
|
|
a97c64b08f | ||
|
|
160d63c02d | ||
|
|
73bf2cd62a | ||
|
|
e8d059c8ed | ||
|
|
569e6ef24e | ||
|
|
ab8c51a57d | ||
|
|
7203ac6e2d | ||
|
|
fc8df74050 | ||
|
|
350fc7ac95 | ||
|
|
ab0bbf30ab | ||
|
|
24268938bd | ||
|
|
54e2917062 | ||
|
|
eab66b6d3d | ||
|
|
ae1ca44aa7 | ||
|
|
295f1a62ad | ||
|
|
6da430b9e4 | ||
|
|
e92ff1c729 | ||
|
|
8d3f67885c | ||
|
|
bc9b381525 | ||
|
|
2004b48cf3 | ||
|
|
54e1e88a4e | ||
|
|
cb88e95385 | ||
|
|
764d329a8f | ||
|
|
1517c92866 | ||
|
|
6296925f2d | ||
|
|
45340103d0 | ||
|
|
67b576d9b3 | ||
|
|
5914ae1557 | ||
|
|
671581d717 | ||
|
|
7f6a45f614 | ||
|
|
aadebbebd0 | ||
|
|
bb907febf8 | ||
|
|
2c1f58ce8e | ||
|
|
10bf0ee685 | ||
|
|
17ffa58e19 | ||
|
|
097374f2c5 | ||
|
|
8ed439317a | ||
|
|
bc5a7572a7 | ||
|
|
a6a58245ff | ||
|
|
3f0654a4bd | ||
|
|
8771fe79c8 | ||
|
|
3badf14c60 | ||
|
|
31bc8d07de | ||
|
|
304479a3f9 | ||
|
|
6ba0040192 | ||
|
|
49105a6f91 | ||
|
|
d59913e837 | ||
|
|
51ba397c6a | ||
|
|
474f0082bb | ||
|
|
6589b25f95 | ||
|
|
f2bc1cf768 | ||
|
|
e5a330dab0 | ||
|
|
6e27569dc3 |
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(xargs ls -la)",
|
||||
"Bash(command -v pacman)",
|
||||
"Bash(command -v paclist)",
|
||||
"Bash(command -v pacinfo)",
|
||||
"Bash(git -C /home/fscotto/AnsiblePlaybook add ansible/inventory/group_vars/arch.yml ansible/roles/services_systemd/tasks/main.yml)",
|
||||
"Bash(git -C /home/fscotto/AnsiblePlaybook commit -m ' *)",
|
||||
"Bash(git -C /home/fscotto/AnsiblePlaybook diff --stat)",
|
||||
"Bash(git -C /home/fscotto/AnsiblePlaybook status --short)",
|
||||
"Bash(git -C /home/fscotto/AnsiblePlaybook log --oneline --all -- ansible/inventory/host_vars/nymph.yml ansible/roles/profile_desktop_host/tasks/nymph.yml 'dotfiles/nymph/*')",
|
||||
"Bash(git *)",
|
||||
"Bash(wlogout --help)",
|
||||
"Bash(xbps-query *)",
|
||||
"Bash(xbps-install --dry-run gtklock)"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# DuckDNS runtime files contain a rendered Vault token and must stay on the host.
|
||||
/dotfiles/server/duckdns/
|
||||
275
AGENTS.md
275
AGENTS.md
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md
|
||||
|
||||
Ansible-driven personal infrastructure repo for Fedora and Void desktops, Linux Mint and FreeBSD transition targets, WSL, and an Ubuntu server.
|
||||
Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora IoT, WSL, a Rocky Linux 9 server, and an Atlas NAS.
|
||||
|
||||
## Source Of Truth
|
||||
- Main orchestration: `ansible/site.yml`
|
||||
@@ -14,8 +14,10 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Linux
|
||||
- Current personal desktop: `ikaros = platform_fedora + role_personal_workstation + graphical_desktop + desktop_gnome`
|
||||
- Current laptop: `nymph = platform_fedora + graphical_desktop + desktop_gnome`
|
||||
- Void desktop profile is also the base for other future/reference hosts via `platform_void + graphical_desktop`
|
||||
- Workstation: `deadalus` is Windows + WSL; Ansible target is `deadalus-wsl`
|
||||
- Ubuntu server: `prometheus`
|
||||
- Workstation: `deadalus` is Windows + Fedora WSL.
|
||||
- Rocky server: `prometheus` belongs to `rocky_server`.
|
||||
- NAS: `atlas` (Rocky Linux 9, reached through SSH)
|
||||
- Always-on LAN node: `aegis` (Fedora IoT on Raspberry Pi 4, reached through SSH)
|
||||
- Hosts intentionally belong to multiple groups; trust `ansible/site.yml` over hostname assumptions.
|
||||
- Inventory axes are independent: `platform_*`, `role_*`, and `desktop_*`. Legacy `void` and `desktop` remain compatibility parents.
|
||||
|
||||
@@ -23,7 +25,10 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Linux
|
||||
- Preserve layering `all -> platform -> role -> desktop -> host`.
|
||||
- Keep `ansible/site.yml` small; orchestration belongs there, implementation belongs in roles.
|
||||
- Prefer minimal, targeted edits. Preserve idempotency and existing ordering.
|
||||
- All hosts use `ansible_connection: local`.
|
||||
- Use Git Flow branch prefixes: `feature/` for new functionality, `bugfix/` for non-urgent fixes,
|
||||
`hotfix/` for urgent production fixes, `release/` for release preparation, and `support/` for
|
||||
maintained release lines. Do not use abbreviated prefixes such as `feat/`.
|
||||
- Desktop and WSL hosts use `ansible_connection: local`; remote infrastructure hosts use SSH.
|
||||
- Treat `secrets/` as sensitive. Never print secret values.
|
||||
- Tmux plugins are bootstrapped by TPM on the host; the repo only keeps tmux config and custom helper scripts.
|
||||
- Read the relevant role tasks, templates, vars, and deployed dotfiles before editing.
|
||||
@@ -38,12 +43,37 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Linux
|
||||
- Host-focused dry runs:
|
||||
- Fedora desktop work: `ansible-playbook ansible/site.yml --limit ikaros --check --diff`
|
||||
- Fedora laptop work: `ansible-playbook ansible/site.yml --limit nymph --check --diff`
|
||||
- WSL dev: `ansible-playbook ansible/site.yml --limit deadalus-wsl --check --diff`
|
||||
- WSL workstation dev: `ansible-playbook ansible/site.yml --limit deadalus --check --diff`
|
||||
- Server: `ansible-playbook ansible/site.yml --limit prometheus --check --diff`
|
||||
- Rocky server after activation: `ansible-playbook ansible/site.yml --limit <host> --check --diff`
|
||||
- Atlas NAS: `ansible-playbook ansible/site.yml --limit atlas --check --diff`
|
||||
- Aegis IoT: `ansible-playbook ansible/site.yml --limit aegis --check --diff`
|
||||
- Aegis NFS client layer: `ansible-playbook ansible/site.yml --limit aegis --tags nfs --list-tasks`
|
||||
- Aegis host DNS: `ansible-playbook ansible/site.yml --limit aegis --tags dns --check --diff`
|
||||
- Focused checks:
|
||||
- Emacs is disabled by default; temporary Emacs check: `ansible-playbook ansible/site.yml --limit <host> --tags emacs --check --diff -e emacs_enabled=true`
|
||||
- AI coding agents: `ansible-playbook ansible/site.yml --limit <host> --tags ai_agents --check --diff`
|
||||
- Mail bootstrap: `sh -n scripts/bootstrap_mail.sh` and `shellcheck scripts/bootstrap_mail.sh`
|
||||
- Server compose render: `docker compose -f /opt/docker/server/docker-compose.yml config`
|
||||
- Server compose render: `podman-compose -f /opt/docker/server/docker-compose.yml config` and `systemctl status podman-compose-server`
|
||||
- Atlas media stack:
|
||||
`ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff`
|
||||
- Atlas network/share hardening:
|
||||
`ansible-playbook ansible/site.yml --limit atlas --tags hardening,sharing --check --diff`
|
||||
- Atlas ZFS snapshot retention and scrub timers:
|
||||
`ansible-playbook ansible/site.yml --limit atlas --tags snapshots,scrub --check --diff`
|
||||
- Atlas encrypted Borg backup:
|
||||
`ansible-playbook ansible/site.yml --limit atlas --tags packages,borg --check --diff`
|
||||
- Atlas Borg progress logging only:
|
||||
`ansible-playbook ansible/site.yml --limit atlas --tags borg_logging --check --diff`
|
||||
- Atlas manual offline USB backup and 45Drives Alerts reminder:
|
||||
`ansible-playbook ansible/site.yml --limit atlas --tags usb_backup,usb_reminder --check --diff`
|
||||
- Atlas pool, disk, capacity, temperature, and job monitoring:
|
||||
`ansible-playbook ansible/site.yml --limit atlas --tags monitoring --check --diff`
|
||||
- Atlas explicit post-restore SELinux relabeling:
|
||||
`ansible-playbook ansible/site.yml --limit atlas --tags restorecon --check -e '{"atlas_restorecon_paths":["/zpool/archive"]}'`
|
||||
- Prometheus/Aegis WireGuard gateway:
|
||||
`ansible-playbook ansible/site.yml --limit prometheus,aegis --tags wireguard --check --diff`
|
||||
- DuckDNS config only: `ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff`
|
||||
|
||||
## Conventions
|
||||
- Use FQCN Ansible modules.
|
||||
@@ -54,41 +84,221 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Linux
|
||||
- Use `no_log: true` for secret-bearing task inputs or outputs.
|
||||
|
||||
## Desktop Notes
|
||||
- `desktop_profile` names independently selectable desktop groups such as `desktop_gnome`, `desktop_hyprland`, `desktop_sway`, `desktop_niri`, and `desktop_cinnamon`. Keep platform-specific session bootstrap in platform-specific roles.
|
||||
- `desktop_environment` selects the mutually exclusive `minimal` (default), `gnome`, `xfce`, or `kde` desktop mode. `profile_desktop_common` owns shared Void bootstrap; the minimal mode uses `profile_desktop_sway`, `profile_desktop_hyprland`, and `profile_desktop_niri`, `profile_desktop_xfce` owns the reproducible XFCE setup, `profile_desktop_kde` owns KDE-specific defaults and cleanup, and `profile_desktop_gnome` copies shared desktop dotfiles for Fedora/GNOME without managing GNOME settings. `desktop_sessions_enabled` and `desktop_default_session` apply only to the minimal mode.
|
||||
- Emacs packages and `.emacs.d` deploy are disabled by default via `emacs_enabled: false`; keep the dotfiles in the repo for reversible opt-in only.
|
||||
- `desktop_profile` names independently selectable desktop groups such as `desktop_gnome`, `desktop_sway`, and `desktop_niri`. Keep platform-specific session bootstrap in platform-specific roles.
|
||||
- `desktop_environment` is fixed to `minimal` for Void desktops. `profile_desktop_common` owns shared Void bootstrap; `profile_desktop_sway` and `profile_desktop_niri` manage the enabled sessions, while `profile_desktop_gnome` copies shared desktop dotfiles for Fedora/GNOME without managing GNOME settings. `desktop_sessions_enabled` and `desktop_default_session` apply to the minimal mode.
|
||||
- Emacs has one authoring-oriented `.emacs.d`, deployed by `dotfiles_common` when `emacs_enabled` is true. Fedora/GNOME desktops and workstation profiles enable it; keep platform dependencies in package group vars rather than branching in Emacs Lisp.
|
||||
- NTFS filesystem support is provided by `ntfs-3g` in `ansible/inventory/group_vars/void.yml`.
|
||||
- Void user services are managed by `turnstile` and live under `dotfiles/desktop/.config/service/`.
|
||||
- `ssh-agent` keeps the stable socket `~/.local/state/ssh-agent/socket`.
|
||||
- Critical session entrypoints:
|
||||
- `dotfiles/desktop/.config/sway/config` plus `host.conf` and `session-env` deployed via `host_sway_dotfiles` (sway / Wayland)
|
||||
- `dotfiles/desktop/.config/hypr/hyprland.conf` plus `host.conf` and `session-env` deployed via `host_hyprland_dotfiles` (Hyprland / Wayland)
|
||||
- `dotfiles/desktop/.config/niri/config.kdl` and `session-env` deployed via `desktop_niri_dotfiles` (Niri / Wayland)
|
||||
- Void Niri lives in `profile_desktop_niri`, gated on `'niri' in desktop_sessions_enabled`; it installs the `emptty` `niri.desktop` session, the `/usr/local/bin/start-niri` launcher, and the xdg-desktop-portal config, mirroring `profile_desktop_sway`.
|
||||
- Fedora GNOME (`desktop_gnome`) assumes GNOME comes from the Fedora Workstation base install; Ansible deploys shared desktop dotfiles and git/GPG config for `ikaros` and `nymph`, not GNOME settings.
|
||||
- FreeBSD Niri (dormant; `platform_freebsd` currently has no hosts) must keep FreeBSD-specific package, rc, DBus, seat, portal, and launcher behavior in `profile_desktop_niri_freebsd` or FreeBSD platform vars.
|
||||
- Do not switch or restart a display manager during a playbook run from an active graphical session. Changing among `emptty`, LightDM, and SDDM requires an explicit run from another TTY/SSH session with `desktop_allow_display_manager_switch=true`. Apply managed XFCE XML while logged out of XFCE so `xfconfd` cannot overwrite it from its in-memory state.
|
||||
- Do not switch or restart the display manager during a playbook run from an active graphical session.
|
||||
- `nymph` is the Fedora/GNOME laptop target; keep GNOME settings unmanaged for now and add host-specific tuning only after real use.
|
||||
|
||||
## Void Package And Dotfile Bucket Rules
|
||||
`platform_void` is the reusable Void platform selection. The legacy `void` group remains a compatibility parent so existing `group_vars/void.yml` and `when: "'void' in group_names"` checks keep working during the transition.
|
||||
The Void desktop package lists in `ansible/inventory/group_vars/void.yml` are kept disjoint by role:
|
||||
- `void_packages_base` — system runtime only (init/services, kernel, audio core, networking, filesystem, firewall, hardware daemons, runit logging).
|
||||
- `desktop_common_packages` — GUI infrastructure shared by all desktop modes.
|
||||
- `desktop_minimal_packages` / `desktop_xfce_packages` / `desktop_kde_packages` — mutually exclusive applications, integration components, and display managers. Packages required by more than one exclusive mode may be repeated; cleanup subtracts the selected mode before removing inactive packages.
|
||||
- `desktop_sway_packages` / `desktop_hyprland_packages` — binaries specific to each session. Cross-WM packages used by both (such as `dunst`, `rofi`, and `foot`) are intentionally duplicated in the two lists.
|
||||
`profile_packages` in the same file is cross-distro and is overridden by `group_vars/server.yml` and the workstation group vars; do not move desktop-specific Void entries through it.
|
||||
The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-independent content, `desktop_minimal_dotfiles` carries Thunar, Udiskie, and minimal MIME defaults, and `desktop_xfce_dotfiles` carries XFCE state. KDE uses its own MIME defaults. `desktop_void_dotfiles` remains reserved for files that need the Void runtime.
|
||||
- `desktop_common_packages` — GUI infrastructure shared by the minimal desktop mode.
|
||||
- `desktop_minimal_packages` — applications, integration components, and the `emptty` display manager.
|
||||
- `desktop_sway_packages` — binaries specific to the Sway session.
|
||||
`profile_packages` remains the shared package bucket for Void and Fedora profiles. Rocky uses
|
||||
`rocky_profile_packages` so RPM-specific names do not leak back into the other platforms; do not move
|
||||
desktop-specific Void entries through either bucket.
|
||||
The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-independent content and `desktop_minimal_dotfiles` carries Thunar, Udiskie, and MIME defaults. `desktop_void_dotfiles` remains reserved for files that need the Void runtime.
|
||||
|
||||
## Workstation Notes
|
||||
- `deadalus` is modeled as Windows + WSL; keep Linux dev automation on `deadalus-wsl`.
|
||||
- Native Linux workstation groups remain available for future hosts but have no current host in the main inventory.
|
||||
- `deadalus` is modeled as Windows + Fedora WSL and is the sole workstation target.
|
||||
- Fedora WSL belongs to `platform_fedora`, `workstation_dev_fedora`, and the shared WSL layer. It must not receive Flatpak or Snap runtimes.
|
||||
- Fedora WSL installs Mise from the official `jdxcode/mise` COPR and uses its pinned Temurin Java 11 JDK; update the declared Mise version deliberately.
|
||||
- Windows applications are installed manually and are not managed from the WSL profile.
|
||||
|
||||
## Rocky Server Notes
|
||||
- DuckDNS is rendered by `profile_server` from host-local `server_duckdns_domain` and
|
||||
`vault_duckdns_token`. Keep the rotated token in encrypted Vault or untracked local vars, never in
|
||||
dotfiles. The private `~/duckdns/duck.sh` keeps the existing entrypoint; rendering uses `no_log`
|
||||
and disables diffs. Provisioning does not execute the updater or change its external schedule.
|
||||
- `rocky_server` is a child of both `platform_rocky` and `server`; `prometheus` is its active target.
|
||||
- The target must already provide `server_username` with local sudo access before the profile runs.
|
||||
- The Rocky profile installs Podman and podman-compose, uses firewalld, preserves SELinux enforcement, and renders the
|
||||
existing Nginx Proxy Manager/Gitea Compose stack with a `podman-compose-server` systemd unit. PostgreSQL and
|
||||
Navidrome are no longer part of the desired Prometheus configuration. The role does not stop or remove legacy
|
||||
containers, delete `/opt/postgres/data`, start the Compose stack, update DNS, or cut over traffic.
|
||||
- Firewalld enables SSH, Cockpit (`9090/tcp`), HTTP and HTTPS. Nginx Proxy Manager publishes `80/tcp` and
|
||||
`443/tcp`; bind its administration interface only to `127.0.0.1:81` and use `npm-tunnel` from Ikaros or Nymph.
|
||||
Nextcloud remains disabled; do not provision `/srv/nextcloud` directories.
|
||||
- `scripts/migrate_prometheus_data.sh` is the separate, source-host-run NPM/Gitea migration path. It dry-runs by
|
||||
default and requires explicit source-stack quiescing before copying persistent Docker data with rsync.
|
||||
- Atlas-only OpenZFS, NFS, Samba, and Syncthing stay selected through Atlas host variables and must not
|
||||
leak into `rocky_server`. Cockpit plus its Navigator and Podman extensions are selected explicitly for
|
||||
Prometheus through its host variables.
|
||||
|
||||
## Atlas NAS Notes
|
||||
- `atlas` is a remote Rocky Linux 9 NAS. Keep its connection, LAN, pool and mountpoint values in
|
||||
`host_vars/atlas.yml`. Bootstrap it once with `-e atlas_connection_username=<existing-admin>`;
|
||||
subsequent runs use the dedicated Atlas account.
|
||||
- The pool is normally pre-existing. A one-time bootstrap may create it only when `atlas_create_pool=true`
|
||||
is explicitly supplied and `atlas_zpool_disks` contains exactly four real `/dev/disk/by-id/...` paths.
|
||||
Never partition, force, destroy, roll back, or modify the vdev layout of an existing pool.
|
||||
- `atlas_manage_storage`, `atlas_manage_sharing`, and `atlas_manage_firewall` are enabled in Atlas host vars as
|
||||
the declared steady state; set one false only for a deliberate suspension. `atlas_manage_media_stack` remains false
|
||||
until the future rootful Immich stack has its required Vault inputs and target validation.
|
||||
- Atlas requires `vault_atlas_admin_password_hash` for Cockpit and, while sharing is enabled,
|
||||
`vault_atlas_samba_password`. The future rootful media stack also requires
|
||||
`vault_atlas_immich_db_password`. Never print these values.
|
||||
- Atlas creates the complete declared hierarchy only under the verified existing or explicitly bootstrapped pool: `archive`,
|
||||
`services`, `services/data`, `services/data/navidrome`, `services/data/syncthing`, `media`, `media/music`,
|
||||
`media/photobook`, `backup`, `backup/hosts`, and `backup/hosts/prometheus`. `backup` has a `500G`
|
||||
reservation covering its descendants. `archive` is the SMB-shared raw-data namespace; container state is never beneath it.
|
||||
- The `immich` system account is fixed to UID/GID `1100`, has no login shell or `wheel` membership, and receives only
|
||||
the `video` and `render` supplementary groups. Immich's rootful Quadlets run as `1100:1100`; Server and ML receive
|
||||
`/dev/dri`, while the Photobook external library is read-only at `/external/photobook`.
|
||||
- Atlas applies persistent kernel network hardening: redirects and source routes are rejected, martians logged, reverse-path filtering remains loose for WireGuard, and IPv4 forwarding is disabled. SSH permits only the declared administrator using public-key authentication; root login, passwords, agent and remote forwarding
|
||||
are disabled, while local forwarding remains available for private administrative tunnels. Photobook is exported only to the configured Aegis IP with all access squashed to UID/GID
|
||||
`1100`. Targeted SELinux is enforced persistently; a required reboot is reported but never initiated automatically. The primary LAN interface is assigned explicitly to the managed firewalld zone, and firewall rules are applied before NFS or SMB are started; their service state and TCP listeners are then verified. SMB3 exposes `Archive` to Vault-backed authorized accounts on mandatory encrypted, signed SMB3 over TCP/445 only and admits the configured LAN without host-specific exclusions.
|
||||
- Atlas NPM and Immich share a rootful Podman network. NPM publishes HTTP/HTTPS, but its administration port remains
|
||||
bound to `127.0.0.1:81`; do not expose it directly to the LAN or Internet.
|
||||
- `profile_backend_phase1` temporarily runs rootless Navidrome and Syncthing on Atlas until Uranus replaces
|
||||
them. It binds only to Atlas' LAN IP, never `wg0`; Navidrome and the Syncthing GUI admit only Aegis as
|
||||
the source-NAT gateway, while native Syncthing ports admit the configured LAN. It initializes fresh
|
||||
state only and never migrates or deletes source application data.
|
||||
- `wireguard_overlay` manages `wg0` between Prometheus (`10.0.0.1`) and Aegis (`10.0.0.2`). It persists private
|
||||
keys only on their respective hosts, exchanges only derived public keys through Ansible, and verifies a real peer
|
||||
handshake. Prometheus opens `51820/udp`; Aegis is the LAN gateway. Its persistent IPv4 forwarding, narrowly scoped
|
||||
WireGuard-to-LAN firewalld policy, and source masquerading permit Prometheus to reach LAN services without a static
|
||||
route on the router. Prometheus includes `192.168.178.0/24` in Aegis' peer `AllowedIPs`; add the Uranus VIP there
|
||||
when it is assigned. After a firewalld reload, restore Prometheus' rootful Podman networking with
|
||||
`podman network reload --all` so the existing proxy stack retains container DNS.
|
||||
|
||||
## Atlas NAS TODO
|
||||
Completed validation: the existing RAIDZ2 pool and datasets, SELinux, LAN firewall, SSH, Cockpit with
|
||||
the selected 45Drives plugins, encrypted SMB3 `Archive`, the Aegis-only NFSv4 `photobook` export, and the
|
||||
Prometheus--Aegis WireGuard gateway are operational. The gateway handshake, forwarding, source masquerading,
|
||||
and TCP reachability to Atlas were verified. Temporary Navidrome and Syncthing are available through their
|
||||
manual NPM Proxy Hosts; Syncthing uses `/data/Org` backed by the SMB-shared Archive dataset. Aegis has also
|
||||
validated NFSv4.2 read, write, delete, and `all_squash` mapping to UID/GID `1100` end-to-end. The ZFS
|
||||
snapshot timers are active and the first recursive hourly snapshot completed successfully; the first
|
||||
scheduled retention prune and monthly scrub remain runtime checks.
|
||||
|
||||
### Priority 1 - Data protection
|
||||
- [x] Deploy Ansible-managed recursive ZFS snapshots with 24 hourly, 30 daily, 8 weekly, and 12 monthly
|
||||
generations, plus a monthly scrub on the first Sunday at 03:00. The timers and first hourly snapshot were
|
||||
verified on Atlas. Still observe the first scheduled retention prune and scrub; Cockpit Scheduler is for
|
||||
visibility or manual operations only, and snapshot rollback is never automated.
|
||||
- [x] Activate and validate the encrypted offsite Borg backup to the Hetzner Storage Box. Atlas uses the
|
||||
dedicated SSH identity, pinned ED25519 host key, Vault-backed `repokey` encryption, and a locked
|
||||
non-login `borg` account with no sudo or supplementary groups. The initial snapshot-consistent backup,
|
||||
Borg repository check, and temporary-directory restore completed successfully; the restored `Archive`
|
||||
tree matched the live data, and temporary snapshots and mounts were removed. The exported recovery key
|
||||
was copied offline. Daily backup retries and logging, 30 daily, 8 weekly and 12 monthly archives,
|
||||
compaction, and monthly repository checks are enabled. Future runs report a ZFS-based estimated
|
||||
percentage, and a post-exit helper handles host-namespace temporary snapshot cleanup. The active
|
||||
run predates the new progress logging and still requires an observed final cleanup result.
|
||||
- [x] Populate `/zpool/archive` with the currently available data so offsite and offline backup tests run
|
||||
against a representative load.
|
||||
- [ ] Run and evaluate Borg against the populated pool: duration, repository capacity, deduplication, and
|
||||
a subsequent incremental archive must be observed before considering the offsite path fully validated.
|
||||
- [x] Add the UUID-bound offline USB backup with versioned rsync, locking, capacity checks, verification,
|
||||
safe unmounting and a tested restore procedure; never trigger it for an arbitrary USB disk. The
|
||||
LUKS/ext4 identities were read-only verified; the manual service and 45Drives Alerts reminder timer were
|
||||
deployed on Atlas. Interactive LUKS unlock is part of the manual service; only the reminder is
|
||||
scheduled for the first Saturday of each month at 10:00 Europe/Rome via the existing 45Drives
|
||||
notifier. A manual test produced an Alerts notification, not an email. The first USB attempt failed
|
||||
on a `security.selinux` xattr and was interrupted; the xattr filter is deployed and the temporary
|
||||
recursive snapshot and open LUKS mapper were cleaned up. A later run reported checksum verification
|
||||
and published the USB version, but failed while removing host-namespace ZFS snapshot mounts. Those
|
||||
exact mounts and snapshots were cleaned up. An `ExecStopPost` helper now removes only the named
|
||||
temporary snapshot after the backup process exits. A new full run checksum-verified and published a
|
||||
USB version; the service ended successfully, the mapper closed, no temporary USB snapshot remained,
|
||||
and the pool was healthy. On 2026-09-25 an independent, read-only USB restore test copied one file from
|
||||
the published `atlas/latest` version into `/var/tmp` and matched contents, owner, mode, size, mtime and
|
||||
POSIX ACL. The temporary copy and mount were removed, the mapper closed, and the pool remained healthy.
|
||||
- [x] Test restores independently from a ZFS snapshot, Borg, and the offline USB backup before relying on
|
||||
any backup path. The earlier Borg temporary-directory restore passed. On 2026-09-25 a separate,
|
||||
read-only ZFS snapshot test restored one file to `/var/tmp`, confirmed matching contents, ownership,
|
||||
mode, mtime and ACL, then removed its temporary copy and on-demand mount. This is a file-level smoke
|
||||
test, not full dataset recovery. An independent USB file restore passed on 2026-09-25 with matching
|
||||
content and metadata; full disaster recovery remains a separate Priority 2 task.
|
||||
- [x] Add monitoring and alerting for pool health, scrub/resilver, SMART data, temperatures, free space,
|
||||
snapshot/local-backup growth, Hetzner Storage Box quota, and failed maintenance or backup timers.
|
||||
The half-hourly Atlas health monitor and systemd final-failure hooks are deployed. A live probe
|
||||
found no issues; the service and timer succeeded, and a labelled 45Drives Alerts test notification
|
||||
was submitted. Alerts are deduplicated; email delivery is not claimed. The Storage Box quota probe
|
||||
runs `df -m` over the dedicated pinned-key SSH identity and does not open the Borg repository.
|
||||
Detailed archive size and deduplication remain part of the pending Borg evaluation.
|
||||
|
||||
### Priority 2 - NAS operability and recovery
|
||||
- [ ] Document and test disaster recovery: rebuild Atlas with Ansible, import the existing pool, restore
|
||||
from snapshot/USB/Hetzner, preserve Vault and Borg recovery material offline, and define RPO/RTO.
|
||||
- [ ] Define a controlled Rocky kernel/OpenZFS update and reboot procedure.
|
||||
- [ ] Add the Atlas-initiated least-privilege Prometheus backup pull: Prometheus exposes only prepared
|
||||
read-only dumps through a dedicated account and Atlas retains the private SSH key, pinned host key,
|
||||
atomic pull, verification, retention and systemd service/timer.
|
||||
- [ ] Decide whether a common SMB/NFS namespace is required. `Archive` (SMB) and `photobook` (NFS) are
|
||||
intentionally distinct today; only if a shared namespace is selected, finalize its UID/GID, group,
|
||||
and POSIX ACL model and test the same files through both protocols.
|
||||
|
||||
### Priority 3 - Service expansion
|
||||
- [ ] After data protection and recovery are validated, populate `/zpool/media/music` and validate Navidrome.
|
||||
- [ ] Design and deploy Nextcloud as another explicitly temporary Atlas service before Uranus. Give it
|
||||
separate persistent application, database, and cache storage; keep credentials in Vault; publish it only
|
||||
through NPM over the Prometheus--Aegis gateway; and define backup, upgrade, and eventual Uranus-migration
|
||||
procedures before exposing user data. Do not deploy Nextcloud before the data-protection checklist is complete.
|
||||
- [ ] Keep `atlas_manage_media_stack` disabled until the future Immich deployment has validated `/dev/dri`,
|
||||
container paths, and the required Vault database secret.
|
||||
|
||||
### Priority 4 - Optional workflows
|
||||
- [ ] After data protection is validated, move iCloudPD photo ingestion from Aegis to Atlas as a
|
||||
temporary service until Uranus is ready. Plan to store photos in `/zpool/archive/Pictures` and
|
||||
persistent application/MFA state outside `Archive`; validate permissions, SELinux, backups and
|
||||
recovery before cutover. Keep the current Aegis service and Photobook NFS export unchanged until
|
||||
the Atlas workflow is tested, then retire them explicitly if no longer needed.
|
||||
|
||||
## Cerberus Management Node (Deferred)
|
||||
`cerberus` is postponed until the office in the new house is physically set up. It is not an inventory
|
||||
host and this section is a design and implementation backlog, not authorization to provision it early.
|
||||
|
||||
The planned node is a Lenovo ThinkCentre M700 Tiny with an Intel Core i3-6100T, 8 GB RAM, a 256 GB SSD,
|
||||
and native 1 Gbps Ethernet. It will connect to a multi-input KVM switch using a passive DisplayPort-to-HDMI
|
||||
cable, sharing the monitor and peripherals with Ikaros. Fedora Sericea (immutable Fedora with the Sway
|
||||
Wayland compositor) is the intended OS. Cerberus is an isolated management plane: a dedicated Toolbox
|
||||
environment will run Ansible for future `uranus` cluster provisioning. Rootless Podman will host Grafana,
|
||||
Prometheus, and Loki. The 256 GB local SSD is the hot tier retaining metrics and logs for 30 days; scheduled,
|
||||
validated exports of older historical data will use a dedicated Atlas NFS dataset as cold storage.
|
||||
|
||||
### Implementation plan
|
||||
- [ ] Confirm the office, KVM switch, passive DisplayPort-to-HDMI path, shared monitor/peripherals, and native
|
||||
1 Gbps Ethernet are physically operational before adding Cerberus to inventory.
|
||||
- [ ] Install and update Fedora Sericea with Sway; document the immutable-host lifecycle and keep host changes
|
||||
declarative rather than treating the base OS as a mutable workstation.
|
||||
- [ ] Model Cerberus as its own host with independent platform, role, desktop, network, and storage inputs;
|
||||
do not repurpose Ikaros variables or make it a Uranus cluster member.
|
||||
- [ ] Provision an isolated Toolbox-based Ansible controller with the required collections and a reproducible
|
||||
project checkout; define its least-privilege SSH access, known-host handling, and Vault workflow without
|
||||
storing secrets in the image or repository.
|
||||
- [ ] Define the explicit Uranus provisioning workflow from Cerberus, including inventory boundaries,
|
||||
validation-only runs, and separate approval for any destructive cluster operation.
|
||||
- [ ] Design rootless Podman/Quadlet services for Grafana, Prometheus, and Loki, including persistent local
|
||||
state, service ownership, LAN exposure/authentication, resource limits, updates, and backups.
|
||||
- [ ] Size and enforce a 30-day local hot-retention policy for metrics and logs on the 256 GB SSD; validate
|
||||
actual disk growth and alert before capacity exhaustion.
|
||||
- [ ] Create and validate a dedicated Atlas NFS cold-storage dataset and least-privilege export for Cerberus;
|
||||
do not use a broad existing share or couple it to unrelated Atlas application state.
|
||||
- [ ] Implement scheduled, idempotent exports of data older than 30 days to the Atlas NFS cold tier, with
|
||||
locking, capacity checks, integrity verification, retention rules, failure monitoring, and a tested restore.
|
||||
- [ ] Validate management-plane recovery: rebuild Cerberus, restore observability history from Atlas, and
|
||||
confirm that Uranus provisioning can resume without depending on unreproducible local state.
|
||||
|
||||
## Coding Agent Notes
|
||||
- Shared agent packages live in `ai_agents_npm_packages` in `ansible/inventory/group_vars/all.yml`.
|
||||
- Shared agent definitions and lifecycle flags live in `ai_agents` in `ansible/inventory/group_vars/all.yml`.
|
||||
- Shared agent dotfiles live in `ai_agents_dotfiles`; rendered configs live in `ai_agents_templates`.
|
||||
- Desktop, native workstation, and WSL profiles consume the shared agent package list; do not duplicate package entries in profile-specific vars.
|
||||
- `dotfiles_common` copies common dotfiles plus `ai_agents_dotfiles`, then renders `ai_agents_templates`.
|
||||
- Every `ai_agents.<agent>` entry has independent `install_enabled`, `deploy_enabled`, and `uninstall_enabled` flags. Installation and removal must not both be true for the same agent; the common pre-task fails before changes when they conflict.
|
||||
- Fedora, Void desktop, and WSL workstation profiles consume the shared agent definitions; do not duplicate package entries in profile-specific vars. IBM Bob on the workstation follows its own flags.
|
||||
- `dotfiles_common` deploys `ai_agents_dotfiles` and renders `ai_agents_templates` only when deployment is enabled.
|
||||
- Removal is limited to the managed npm packages and `/usr/local/bin/bob`; never remove agent dotfiles, instructions, credentials, or user data.
|
||||
- Keep `.config/ai/` as the common instruction source; update agent-specific entrypoints to reference it rather than duplicating instruction text.
|
||||
|
||||
## Tooling Notes
|
||||
@@ -102,3 +312,24 @@ The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-i
|
||||
- Keep `README.md` and `AGENTS.md` aligned when workflows materially change.
|
||||
- If you add a new operational area, also add the narrowest validation command for it.
|
||||
- Call out checks you could not run and any follow-up verification needed.
|
||||
|
||||
## Aegis Fedora IoT Notes
|
||||
- `aegis` is a remote Fedora IoT Raspberry Pi 4 node. Bootstrap it once with
|
||||
`ansible/bootstrap/aegis.bu`; the remaining configuration is applied by `profile_aegis` over SSH.
|
||||
- Fedora IoT is immutable. Do not add it to mutable Fedora package or shared dotfile roles.
|
||||
- `profile_aegis` owns the `nfs-utils` and `wireguard-tools` rpm-ostree layers and reports the required reboot
|
||||
without initiating it. `wireguard_overlay` then configures Aegis as the WireGuard LAN gateway with persistent IPv4
|
||||
forwarding, a scoped inter-zone policy, and source masquerading. It also owns rootful Podman Quadlets, persistent container
|
||||
state under `/var/lib`, the Podman auto-update timer, LAN-restricted firewalld rules, and SSH hardening. Keep
|
||||
`aegis_lan_subnet`, `aegis_adguard_web_port`, and `aegis_network_connection_uuid` host-specific;
|
||||
SSH permits only the declared
|
||||
key-authenticated users, never root or password authentication. Keep Apple IDs and other
|
||||
credentials in Vault and use `no_log` for their rendering.
|
||||
- `aegis_adguard_web_port` defaults to `80`. The initial AdGuard Home wizard port `3000` is intentionally unmanaged: open and close it manually only while
|
||||
completing initial setup. Disable the local systemd-resolved stub through `profile_aegis` before
|
||||
AdGuard binds port 53; keep
|
||||
`/etc/resolv.conf` linked to `/run/systemd/resolve/resolv.conf`. LAN clients may use AdGuard, but
|
||||
Aegis must use the independent upstream DNS declared by `aegis_host_dns_servers` so Greenboot does
|
||||
not depend on the AdGuard container during startup.
|
||||
- iCloudPD requires post-deployment interactive MFA initialization; its cookie/configuration state is
|
||||
persisted in `/var/lib/icloudpd/config`.
|
||||
|
||||
70
CLAUDE.md
70
CLAUDE.md
@@ -1,70 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this repo is
|
||||
|
||||
Ansible-driven personal infrastructure as code for Fedora desktops (`ikaros`, `nymph`), WSL development (`deadalus-wsl`), and an Ubuntu server (`prometheus`). See `AGENTS.md` for topology and `README.md` for full profile descriptions.
|
||||
|
||||
## Validation commands
|
||||
|
||||
```bash
|
||||
# Minimum before any change
|
||||
ansible-playbook ansible/site.yml --syntax-check
|
||||
|
||||
# Dry-run per host
|
||||
ansible-playbook ansible/site.yml --limit ikaros --check --diff
|
||||
ansible-playbook ansible/site.yml --limit nymph --check --diff
|
||||
ansible-playbook ansible/site.yml --limit deadalus-wsl --check --diff
|
||||
ansible-playbook ansible/site.yml --limit prometheus --check --diff
|
||||
|
||||
# Linting
|
||||
ansible-lint ansible/site.yml
|
||||
ansible-lint ansible/roles
|
||||
yamllint ansible/
|
||||
|
||||
# Tag-scoped dry-run
|
||||
ansible-playbook ansible/site.yml --limit ikaros --tags <tag> --check --diff
|
||||
|
||||
# Shell scripts
|
||||
sh -n scripts/bootstrap_mail.sh && shellcheck scripts/bootstrap_mail.sh
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Configuration is composed in layers: `all → OS → profile → host`. All hosts use `ansible_connection: local`.
|
||||
|
||||
`ansible/site.yml` is the sole orchestration entry point — keep it thin. Implementation belongs in roles under `ansible/roles/`.
|
||||
|
||||
Variable layering:
|
||||
- `ansible/inventory/group_vars/` — OS and profile defaults
|
||||
- `ansible/inventory/host_vars/` — host-specific overrides (never put host overrides in group_vars)
|
||||
|
||||
Dotfiles under `dotfiles/` mirror the same layer split: `common/`, `desktop/`, `server/`, `workstation/`, then per-host directories (`ikaros/`, `nymph/`).
|
||||
|
||||
## Key conventions
|
||||
|
||||
- FQCN for all Ansible modules.
|
||||
- Prefer declarative modules over `command`/`shell`; when shell is unavoidable, make idempotency explicit.
|
||||
- YAML: start with `---`, 2-space indent, file modes quoted as `"0644"`, booleans as booleans.
|
||||
- `no_log: true` on any task that handles secrets.
|
||||
- Do not restart `emptty` in playbook tasks on active desktop hosts — do it manually via SSH/TTY.
|
||||
|
||||
## Void desktop package buckets (keep disjoint)
|
||||
|
||||
- `void_packages_base` — system runtime (init, kernel, audio core, networking, firewall, hw daemons)
|
||||
- `desktop_common_packages` — WM-agnostic GUI infra (GTK theme, polkit, keyring, portals, flatpak, printing)
|
||||
- `desktop_sway_packages` / `desktop_hyprland_packages` — session-specific binaries; cross-WM packages used by both (`dunst`, `rofi`, `alacritty`) are intentionally duplicated
|
||||
- `profile_packages` — cross-distro; do not move desktop-only Void entries into it
|
||||
|
||||
Dotfile var split follows the same logic: `desktop_common_dotfiles` (WM-agnostic), `desktop_void_dotfiles` (Void runtime — turnstile services, bash fragments).
|
||||
|
||||
## Secrets
|
||||
|
||||
- `secrets/vault.yml` and `secrets/vault.local.yml` are loaded conditionally if present.
|
||||
- `secrets/.vault_pass.gpg` is used automatically if present; falls back to `secrets/.vault_pass`.
|
||||
- Never print vault values; always use `no_log: true` where secrets flow through tasks.
|
||||
|
||||
## AI agent dotfiles
|
||||
|
||||
Shared agent instructions live in `dotfiles/common/.config/ai/`. Agent-specific entrypoints reference that path rather than duplicating content. Shared npm packages are in `ai_agents_npm_packages` in `ansible/inventory/group_vars/all.yml`.
|
||||
850
README.it.md
Normal file
850
README.it.md
Normal file
@@ -0,0 +1,850 @@
|
||||
# Infra — Personal Infrastructure as Code
|
||||
|
||||
> **English version:** [README.md](README.md)
|
||||
|
||||
Questo repository contiene la configurazione **Infrastructure as Code (IaC)** utilizzata per gestire e mantenere allineate diverse macchine personali tramite **Ansible**.
|
||||
|
||||
L'obiettivo è avere **una singola fonte di verità** per:
|
||||
|
||||
- configurazione delle macchine
|
||||
- pacchetti installati
|
||||
- servizi di sistema
|
||||
- configurazioni utente (dotfiles)
|
||||
|
||||
Il repository consente di gestire più sistemi operativi e profili macchina mantenendo una struttura modulare, riproducibile e idempotente.
|
||||
|
||||
---
|
||||
|
||||
# Architettura del progetto
|
||||
|
||||
```text
|
||||
infra/
|
||||
├── ansible/
|
||||
│ ├── ansible.cfg
|
||||
│ ├── site.yml
|
||||
│ ├── inventory/
|
||||
│ │ ├── hosts.yml
|
||||
│ │ ├── group_vars/
|
||||
│ │ └── host_vars/
|
||||
│ ├── templates/
|
||||
│ └── roles/
|
||||
│
|
||||
├── dotfiles/
|
||||
│ ├── common/
|
||||
│ ├── desktop/
|
||||
│ ├── fedora/
|
||||
│ ├── server/
|
||||
│ ├── workstation/
|
||||
│ ├── workstation_dev_wsl/
|
||||
│ └── nymph/
|
||||
│
|
||||
├── scripts/
|
||||
├── secrets/
|
||||
├── README.md
|
||||
└── README.it.md
|
||||
```
|
||||
|
||||
Il repository è diviso in due componenti principali:
|
||||
|
||||
| Componente | Scopo |
|
||||
| ---------- | -------------------------------------- |
|
||||
| ansible | provisioning e configurazione macchine |
|
||||
| dotfiles | configurazioni utente versionate |
|
||||
|
||||
---
|
||||
|
||||
# Macchine gestite
|
||||
|
||||
Il repository modella attualmente host Fedora/GNOME, una workstation Fedora WSL, un server Rocky
|
||||
Linux 9 e un NAS Rocky Linux 9. La composizione resta separata in assi indipendenti:
|
||||
|
||||
```text
|
||||
common user environment
|
||||
+ host-specific platform
|
||||
+ role-specific software
|
||||
+ independently selectable desktop
|
||||
+ host hardware overrides
|
||||
```
|
||||
|
||||
Matrice target:
|
||||
|
||||
| Host | Platform | Role | Desktop |
|
||||
| ------------ | -------- | -------------------- | ------- |
|
||||
| ikaros | Fedora | Personal workstation | GNOME |
|
||||
| nymph | Fedora | Desktop laptop | GNOME |
|
||||
| deadalus | Fedora WSL | Workstation dev | — |
|
||||
| prometheus | Rocky Linux | Server | — |
|
||||
| atlas | Rocky Linux | NAS | — |
|
||||
|
||||
Regola operativa:
|
||||
|
||||
```text
|
||||
ikaros must be boring
|
||||
nymph is allowed to break
|
||||
```
|
||||
|
||||
`ikaros` usa Fedora Workstation/GNOME come desktop personale stabile; `nymph` usa lo stesso
|
||||
target Fedora Workstation/GNOME come laptop. I gruppi legacy `void` e `desktop` restano alias di
|
||||
compatibilita per eventuali host Void futuri mentre i nuovi assi sono
|
||||
`platform_*`, `role_*` e `desktop_*`.
|
||||
|
||||
Nota sullo stato attuale del playbook principale:
|
||||
|
||||
- `ansible/site.yml` applica oggi in automatico Fedora/GNOME su `ikaros` e `nymph`
|
||||
- `ansible/site.yml` applica il profilo Fedora WSL alla workstation `deadalus`
|
||||
- `ansible/site.yml` applica il profilo server Rocky a `prometheus` con DNF, systemd, dotfiles server e firewalld
|
||||
- `ansible/site.yml` applica il profilo NAS Rocky su `atlas` tramite SSH remoto
|
||||
|
||||
## Nodo pianificato e posticipato: Cerberus
|
||||
|
||||
`cerberus` e un nodo di management **posticipato**, in attesa dell'allestimento
|
||||
fisico dell'ufficio nella nuova casa. Non e ancora presente nell'inventory e non
|
||||
esistono ruoli o playbook che lo prendano come target.
|
||||
|
||||
L'hardware previsto e un Lenovo ThinkCentre M700 Tiny (Intel Core i3-6100T,
|
||||
8 GB di RAM e SSD da 256 GB) con Ethernet nativa a 1 Gbps. Condividera monitor
|
||||
e periferiche di Ikaros tramite uno switch KVM a ingressi multipli, usando un
|
||||
cavo passivo DisplayPort-HDMI per il collegamento video. Il sistema operativo
|
||||
previsto e Fedora Sericea, la variante Fedora immutabile con compositor Wayland
|
||||
Sway.
|
||||
|
||||
Cerberus sara un management plane isolato: Ansible verra eseguito in un ambiente
|
||||
Toolbox dedicato per il provisioning del futuro cluster `uranus`, anziche da
|
||||
Ikaros o da un host non gestito. Lo stack di osservabilita rootless Podman
|
||||
eseguira Grafana, Prometheus e Loki. L'SSD locale sara l'hot storage, con
|
||||
metriche e log conservati per 30 giorni; esportazioni programmate trasferiranno
|
||||
i dati storici piu vecchi su un dataset Atlas montato via NFS come cold storage.
|
||||
Il piano di implementazione, con prerequisiti espliciti, e in `AGENTS.md`.
|
||||
|
||||
## Desktop
|
||||
|
||||
Target operativi:
|
||||
|
||||
- `ikaros`: Fedora Workstation + GNOME, desktop personale stabile/floating.
|
||||
- `nymph`: Fedora Workstation + GNOME, laptop desktop con dotfiles desktop condivisi e GNOME lasciato al default Fedora.
|
||||
|
||||
Il profilo Void desktop resta disponibile come modello riutilizzabile per host
|
||||
futuri e usa esclusivamente `desktop_environment: minimal`: Sway e il default,
|
||||
mentre Niri si seleziona con il gruppo `desktop_niri`. GNOME e disponibile solo
|
||||
sui target Fedora tramite `desktop_gnome`.
|
||||
|
||||
Lo stato attuale del profilo desktop include, tra le altre cose:
|
||||
|
||||
- dotfiles comuni e desktop
|
||||
- sessioni Sway e Niri per eventuali host Void in modalita `minimal`
|
||||
- `emptty` con default host-specific in modalita `minimal` e session file Wayland per `sway`
|
||||
- pacchetti Void Linux e servizi runit; le liste pacchetti Void desktop sono separate per criterio:
|
||||
- `void_packages_base` per il runtime sistema (init, kernel, audio core, networking, firewall, hw daemons)
|
||||
- `desktop_common_packages` per l'infrastruttura condivisa
|
||||
- `desktop_minimal_packages` per applicazioni GTK e `emptty`
|
||||
- `desktop_sway_packages` per i binari specifici della sessione Sway
|
||||
- `turnstile` per i servizi utente Void, incluso `ssh-agent`
|
||||
- `ssh-agent` con socket stabile condiviso tra shell e SSH in `~/.local/state/ssh-agent/socket`
|
||||
- Emacs usa una sola configurazione orientata a Org e authoring, condivisa da desktop Fedora/GNOME e workstation; Vim resta l'editor di sviluppo
|
||||
- `tmux` con plugin gestiti da TPM al bootstrap del profilo desktop
|
||||
- Flatpak con remoto Flathub
|
||||
- GNOME Keyring e `udiskie` nella modalita minimale
|
||||
- multi-monitor Void: sotto Sway è gestito da `kanshi`
|
||||
|
||||
---
|
||||
|
||||
## Workstation
|
||||
|
||||
La workstation `deadalus` usa Fedora in WSL sulla macchina Windows omonima, senza runtime Flatpak o Snap.
|
||||
Il profilo è pensato per sviluppo e lavoro.
|
||||
|
||||
Nel modello Ansible usato qui, un singolo inventory host puo appartenere intenzionalmente a piu gruppi e quindi ricevere piu play nello stesso run: l'associazione non e `1 host = 1 play`, ma `host + gruppi = layering finale`.
|
||||
|
||||
Il profilo workstation e agganciato al playbook principale tramite:
|
||||
|
||||
- layer dev Fedora
|
||||
- layer WSL dedicato per sviluppo con `systemd`
|
||||
|
||||
Lo stato attuale del profilo workstation include:
|
||||
|
||||
- installazione pacchetti base Fedora via dnf
|
||||
- installazione e configurazione di Docker dal repository ufficiale
|
||||
- installazione di Mise dal COPR ufficiale con JDK Eclipse Temurin Java 11 fissato
|
||||
- gestione dei dotfiles workstation e rendering dei template dev condivisi
|
||||
- preparazione di Fedora WSL con `systemd` per il toolchain di sviluppo
|
||||
- attivazione del firewall `firewalld` sui target Fedora che dichiarano regole host-specifiche
|
||||
|
||||
Workflow WSL previsto:
|
||||
|
||||
1. avviare Fedora WSL almeno una volta e completare la creazione dell'utente Linux
|
||||
2. installare Ansible dentro la distribuzione WSL
|
||||
3. lanciare il playbook dalla distribuzione su `deadalus` per configurare l'ambiente dev locale
|
||||
4. usare VS Code con le estensioni Remote (`WSL`, `SSH`, `Dev Containers`) dal lato Windows
|
||||
|
||||
Le applicazioni Windows sono installate e gestite manualmente; il profilo WSL non installa componenti di remoting Python per esse.
|
||||
|
||||
---
|
||||
|
||||
## Server
|
||||
|
||||
Sistema operativo:
|
||||
|
||||
- Rocky Linux 9
|
||||
|
||||
Configurazione:
|
||||
|
||||
- nessun ambiente grafico
|
||||
|
||||
Macchina:
|
||||
|
||||
- `prometheus`
|
||||
|
||||
Profilo orientato a servizi server e gestione di dotfiles dedicati.
|
||||
|
||||
Lo stato attuale del profilo server include:
|
||||
|
||||
- installazione pacchetti Rocky via DNF, EPEL e CRB
|
||||
- installazione di Podman e podman-compose
|
||||
- abilitazione dei servizi systemd dichiarati in inventory/group vars
|
||||
- copia dei dotfiles server e rendering del `docker-compose.yml` per Nginx Proxy Manager e Gitea,
|
||||
piu l'unita `podman-compose-server` (attivazione manuale)
|
||||
- attivazione di firewalld con SSH, Cockpit (`9090/tcp`), HTTP e HTTPS abilitati
|
||||
- Syncthing escluso dal profilo server Rocky
|
||||
|
||||
Il Compose desiderato su Prometheus non include piu Navidrome ne il database PostgreSQL obsoleto.
|
||||
Navidrome e Syncthing appartengono ad Atlas; Navidrome ufficiale usa invece SQLite. Il profilo non
|
||||
arresta o rimuove automaticamente eventuali container legacy e non elimina `/opt/postgres/data`.
|
||||
|
||||
Nginx Proxy Manager pubblica solo `80/tcp` e `443/tcp`; la sua interfaccia di amministrazione e
|
||||
associata a `127.0.0.1:81` ed e raggiungibile da Ikaros o Nymph con l'alias Bash `npm-tunnel`.
|
||||
Nextcloud resta disabilitato e il profilo non crea directory `/srv/nextcloud`.
|
||||
|
||||
La fase 1 su Atlas non modifica questo deployment NPM ne i suoi dati persistenti. Dopo aver attivato
|
||||
WireGuard e i servizi Atlas, configurare i proxy host NPM correnti con upstream Navidrome
|
||||
`http://10.0.0.2:4533` e upstream per la GUI Syncthing `http://10.0.0.2:8384`. Solo la GUI web di
|
||||
Syncthing usa NPM; il traffico di sincronizzazione resta sulle porte native pubblicate esplicitamente solo
|
||||
sull'indirizzo WireGuard di Atlas. Configurare l'autenticazione Syncthing e una policy di accesso NPM adeguata prima di pubblicare la GUI.
|
||||
|
||||
### DuckDNS
|
||||
|
||||
`profile_server` genera `~/duckdns/duck.sh` con permessi `0700`, mantenendo il percorso dello
|
||||
script e `duck.log`. Definire `server_duckdns_domain` negli host vars del server e salvare il
|
||||
**nuovo token rigenerato** in `vault_duckdns_token`, nel Vault cifrato `secrets/vault.yml`
|
||||
(`ansible-vault edit secrets/vault.yml`) oppure negli override non versionati `secrets/vault.local.yml`.
|
||||
Non committare lo script generato e non passare il token sulla riga di comando. Il rendering
|
||||
nasconde output e diff sensibili; lo script verifica TLS e passa il token a curl tramite stdin.
|
||||
Il playbook non esegue lo script e non modifica la sua schedulazione esterna.
|
||||
|
||||
```bash
|
||||
ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff
|
||||
ansible-playbook ansible/site.yml --limit prometheus --tags duckdns
|
||||
```
|
||||
|
||||
La cancellazione dalla cronologia non revoca il token: rigenerarlo sul pannello DuckDNS.
|
||||
Dopo la bonifica, riclonare gli altri checkout senza unire nuovamente la vecchia storia;
|
||||
salvare separatamente eventuali modifiche non committate senza copiare segreti.
|
||||
|
||||
### Migrazione dati
|
||||
|
||||
Dopo il provisioning Rocky, eseguire `scripts/migrate_prometheus_data.sh` **sul server Ubuntu
|
||||
sorgente**. Lo script usa rsync, e in dry-run di default; richiede `--quiesce-source --execute` per
|
||||
fermare lo stack sorgente e copiare in modo consistente soltanto i dati di Nginx Proxy Manager e
|
||||
Gitea. Non sposta Navidrome o Syncthing, non avvia container, non cancella dati e non esegue il
|
||||
cutover.
|
||||
|
||||
Utente del profilo server:
|
||||
|
||||
- il profilo usa `server_username`, `server_user_group` e `server_user_home` definiti in `ansible/inventory/group_vars/server.yml`
|
||||
- per default `server_username` eredita `username`, ma puo essere sovrascritto per tutti gli host server via inventory oppure a runtime con extra vars
|
||||
- esempio override da CLI:
|
||||
|
||||
```bash
|
||||
ansible-playbook ansible/site.yml --limit prometheus -e server_username=myuser
|
||||
```
|
||||
|
||||
- se necessario puoi passare anche:
|
||||
|
||||
```bash
|
||||
ansible-playbook ansible/site.yml --limit prometheus -e server_username=myuser -e server_user_group=mygroup -e server_user_home=/srv/myuser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NAS
|
||||
|
||||
`atlas` è un NAS Rocky Linux 9 raggiunto via SSH. Normalmente il pool esiste già e il profilo gestisce
|
||||
solo i dataset figli. La creazione iniziale del RAIDZ2 richiede esplicitamente `atlas_create_pool=true`
|
||||
e quattro percorsi `/dev/disk/by-id/...` verificati in `atlas_zpool_disks`. Il ruolo non partiziona,
|
||||
forza, distrugge, ripristina né modifica il layout vdev di un pool esistente. I client Linux usano NFSv4,
|
||||
quelli Windows/WSL SMB; l'accesso è limitato alla LAN configurata.
|
||||
|
||||
Per il primo avvio servono `vault_atlas_admin_password_hash`, `vault_atlas_samba_password` e
|
||||
`vault_atlas_immich_db_password`; il primo è un hash compatibile con `/etc/shadow`, non una password
|
||||
Cockpit in chiaro. Il bootstrap usa l'amministratore preesistente:
|
||||
|
||||
```bash
|
||||
ansible-playbook ansible/site.yml --limit atlas \
|
||||
-e atlas_connection_username=<existing-admin>
|
||||
```
|
||||
|
||||
Le esecuzioni successive usano `atlas_admin_username`. Storage, condivisioni e firewall LAN sono
|
||||
abilitati; prima dell'applicazione verificare pool, mountpoint, subnet e zona firewalld. La creazione
|
||||
del pool è protetta da un gate esplicito e avviene solo se è assente. Atlas non fa più parte della VPN
|
||||
WireGuard: la vecchia interfaccia è stata ritirata manualmente dopo la verifica del collegamento tra
|
||||
Prometheus e Aegis. Le chiavi SSH autorizzate sono in file separati sotto
|
||||
`~/.ssh/authorized_keys.d/`. `atlas_manage_media_stack` resta disabilitato finché `/dev/dri`, percorsi
|
||||
dei container e segreto del database Immich non sono validati.
|
||||
|
||||
Sotto `zpool` Atlas crea `archive` (SMB), `services/data` con i dataset applicativi
|
||||
`services/data/navidrome` e `services/data/syncthing`, `media`, `media/music`, `media/photobook` e
|
||||
`backup/hosts/prometheus`. Archivio e applicazioni usano `zstd`; media, Syncthing e backup host usano
|
||||
`lz4`. `backup` ha una riserva di `500G` che copre i discendenti. SELinux targeted è persistente;
|
||||
l'eventuale riavvio necessario viene segnalato, non eseguito. Atlas assegna l'interfaccia primaria
|
||||
alla zona firewalld gestita, rifiuta redirect e source route, registra i martian, mantiene il reverse-path
|
||||
filter loose e disabilita il forwarding IPv4. SSH consente soltanto l'amministratore dichiarato con
|
||||
chiave pubblica: root, password, agent forwarding e remote forwarding sono disabilitati, mentre il
|
||||
forwarding locale resta disponibile per i tunnel amministrativi. SMB3 espone `Archive` agli account
|
||||
autorizzati da Vault sulla LAN, solo su TCP/445 con cifratura e firma obbligatorie. NFSv4 espone
|
||||
soltanto `media/photobook` all'IP di Aegis su TCP/2049, con `all_squash` verso UID/GID `1100`.
|
||||
|
||||
L'account di sistema `immich` usa UID/GID `1100`, non ha shell di login né gruppo `wheel` e riceve i
|
||||
gruppi `video` e `render`. Lo stack Immich futuro prevede Quadlet rootful per Server, ML, cache,
|
||||
PostgreSQL e NPM su una rete Podman comune. Immich gira come `1100:1100`, Server e ML ricevono
|
||||
`/dev/dri` e Photobook è montato in sola lettura su `/external/photobook`. NPM pubblica `80` e `443`;
|
||||
l'interfaccia amministrativa resta su `127.0.0.1:81`, raggiungibile via tunnel SSH.
|
||||
|
||||
Atlas ospita temporaneamente Navidrome e Syncthing rootless fino alla sostituzione con Uranus. I
|
||||
servizi sono inizializzati **ex novo**, senza migrare lo stato precedente, rispettivamente sotto
|
||||
`/zpool/services/data/navidrome` e `/zpool/services/data/syncthing`; la musica in
|
||||
`/zpool/media/music` viene popolata separatamente. Sono vincolati all'indirizzo LAN di Atlas
|
||||
(`192.168.178.55`), mai a WireGuard. `wireguard_overlay` collega invece Prometheus (`10.0.0.1`)
|
||||
e Aegis (`10.0.0.2`): le chiavi private restano sui rispettivi host e Ansible scambia solo le pubbliche.
|
||||
Prometheus apre `51820/udp`; Aegis inoltra soltanto il traffico overlay→LAN dichiarato e applica
|
||||
source NAT, evitando interfacce VPN su Atlas/Uranus e route statiche sul router. Navidrome (`4533/tcp`)
|
||||
e la GUI Syncthing (`8384/tcp`) ammettono solo Aegis, mentre le porte native Syncthing sono limitate
|
||||
alla LAN. Dopo la verifica dei servizi, configurare manualmente i Proxy Host NPM verso
|
||||
`http://192.168.178.55:4533` e `http://192.168.178.55:8384`. Il peer Prometheus include la LAN
|
||||
negli `AllowedIPs`; aggiungere la VIP Uranus quando esisterà. Dopo il reload di firewalld, Ansible
|
||||
ricarica le reti Podman rootful di Prometheus per conservare DNS e connettività del proxy.
|
||||
|
||||
Validare il gateway con:
|
||||
|
||||
```bash
|
||||
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
|
||||
ansible-playbook ansible/site.yml --limit prometheus,aegis --tags wireguard --check --diff
|
||||
```
|
||||
|
||||
La prima esecuzione reale WireGuard deve includere entrambi i peer. Se Aegis ha appena installato il
|
||||
layer `wireguard-tools`, riavviarlo manualmente e rieseguire senza `--check`: il ruolo attende un
|
||||
handshake effettivo.
|
||||
|
||||
Gli snapshot ZFS ricorsivi coprono l'intero pool: 24 orari al minuto 05, 30 giornalieri alle 00:15,
|
||||
8 settimanali la domenica alle 01:00 e 12 mensili il primo giorno alle 02:00. La retention elimina
|
||||
solo gli snapshot con prefisso gestito `atlas-auto` e non esegue rollback. Lo scrub OpenZFS mensile è
|
||||
previsto la prima domenica alle 03:00; il timer settimanale incompatibile è disabilitato. Il primo
|
||||
snapshot orario ricorsivo è riuscito; la prima pulizia pianificata e il primo scrub schedulato
|
||||
richiedono ancora una verifica a runtime.
|
||||
|
||||
```bash
|
||||
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
|
||||
ansible-playbook ansible/site.yml --limit atlas --tags snapshots,scrub --check --diff
|
||||
```
|
||||
|
||||
Il backup Borg cifrato usa il sub-account Hetzner `u660064-sub1`, il repository relativo `./borg-data`
|
||||
e Borg remoto 1.4 su SSH porta 23. La chiave ED25519 del server è fissata; una chiave client dedicata
|
||||
appartiene all'account `borg`, bloccato e senza login, sudo o gruppi supplementari. La chiave privata
|
||||
resta in `/etc/atlas-borg`; la passphrase proviene da `vault_atlas_borg_passphrase` ed è resa in un
|
||||
file `0600`. Solo il wrapper root crea snapshot e mount; avvia il client come `borg` con il minimo
|
||||
accesso temporaneo in lettura, senza concedergli gestione ZFS o sudo.
|
||||
|
||||
Il backup giornaliero parte alle 04:30 con un ritardo casuale fino a 30 minuti. Crea uno snapshot ZFS
|
||||
ricorsivo temporaneo e ricostruisce tutti i dataset sotto `/zpool` in un albero di bind mount in sola
|
||||
lettura, per inserirli in un unico archivio coerente. Il wrapper smonta ricorsivamente l'albero privato;
|
||||
un helper `ExecStopPost` mirato rimuove eventuali mount dello snapshot nel namespace host e lo snapshot
|
||||
temporaneo dopo l'uscita del processo. Borg conserva 30 archivi giornalieri, 8 settimanali e 12
|
||||
mensili, poi compatta il repository. Il controllo completo di metadati e repository si svolge il 15
|
||||
di ogni mese alle 06:00. Le operazioni usano un lock comune, journal e retry systemd limitati. Le
|
||||
nuove esecuzioni riportano al massimo una riga di avanzamento al minuto: percentuale **stimata**,
|
||||
dataset, file elaborati e byte originali/compressi/deduplicati. Il denominatore è la somma dei
|
||||
`logicalreferenced` ZFS dello snapshot, non un totale Borg: può superare il 100% e non comprende
|
||||
retention, compattazione o controlli. Le righe di progresso non riportano i nomi dei file; eventuali
|
||||
warning possono farlo. Seguire il job con `sudo journalctl -fu atlas-borg-backup.service`; modifiche
|
||||
all'helper non cambiano un'esecuzione già avviata.
|
||||
|
||||
Attivazione iniziale esplicita:
|
||||
|
||||
1. Inserire una passphrase unica in `secrets/vault.yml` con `ansible-vault edit`.
|
||||
2. Generare e mostrare solo la chiave pubblica con
|
||||
`ansible-playbook ansible/site.yml --limit atlas --tags borg_key`.
|
||||
3. Installarla nel sub-account Hetzner, poi applicare con
|
||||
`ansible-playbook ansible/site.yml --limit atlas --tags packages,borg`.
|
||||
4. Copiare `secrets/recovery/atlas-borg-repokey.export` su un supporto davvero offline: la copia
|
||||
locale ignorata da Git non è di per sé un backup offline.
|
||||
|
||||
Il ruolo inizializza solo un repository `repokey` assente, non accetta password SSH né host key non
|
||||
fissate e non avvia manualmente il primo backup. Validazione:
|
||||
|
||||
```bash
|
||||
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
|
||||
ansible-playbook ansible/site.yml --limit atlas --tags packages,borg --check --diff
|
||||
```
|
||||
|
||||
L'attivazione iniziale è riuscita: backup e controllo del repository, restore completo in una
|
||||
directory temporanea confrontato con l'albero `Archive`, esportazione offline della chiave di recupero
|
||||
e pulizia di snapshot/mount temporanei. Il 2026-09-25 un test separato da snapshot ZFS giornaliero ha
|
||||
copiato un file di `/zpool/archive` in `/var/tmp`, verificando contenuto, proprietario, modalità,
|
||||
mtime e ACL POSIX; copia e mount temporanei sono stati rimossi senza interrompere Borg. Non è un test
|
||||
di ripristino dell'intero dataset.
|
||||
|
||||
Il backup USB offline è distribuito come **servizio solo manuale** (`atlas_manage_usb_backup: true`):
|
||||
Ansible non formatta, sblocca, monta né avvia automaticamente il disco. Il disco esistente è stato
|
||||
verificato in sola lettura il 2026-09-23: UUID LUKS `577b3c43-ea37-4611-81a9-39d555cdfbd4`,
|
||||
UUID ext4 interno `758e2d2e-a427-4797-aad9-39c3a9f17c7e`, mapper `zpool-backup`. All'ispezione
|
||||
era montato in `/mnt/zpool-backup`; il servizio richiede invece che il mapper **non sia montato** prima
|
||||
dell'avvio. Se serve, `systemd-ask-password` chiede interattivamente la passphrase LUKS tramite
|
||||
l'agente di `systemctl start` e la passa direttamente a `cryptsetup`, senza salvarla, esporla negli
|
||||
argomenti o memorizzarla nella cache. Lo script monta il disco privatamente, crea uno snapshot ZFS
|
||||
ricorsivo, copia tutti i dataset in `atlas/snapshots/<timestamp>/` con `rsync --link-dest`, verifica
|
||||
con un dry-run basato sui checksum, aggiorna atomicamente `atlas/latest`, smonta e chiude LUKS. Un
|
||||
errore non sostituisce `latest` né cancella versioni complete precedenti. Borg e USB possono operare
|
||||
contemporaneamente su snapshot distinti, ma la lettura concorrente può ridurre il throughput.
|
||||
|
||||
La copia USB conserva le ACL ma non gli attributi estesi generici, compreso `security.selinux`: la
|
||||
policy della destinazione deve ricreare le etichette dopo un restore. Per un percorso esplicito:
|
||||
|
||||
```bash
|
||||
ansible-playbook ansible/site.yml --limit atlas --tags restorecon \
|
||||
-e '{"atlas_restorecon_paths":["/zpool/archive"]}'
|
||||
```
|
||||
|
||||
Il task accetta solo percorsi sotto la radice del pool Atlas, esegue `restorecon -RFv` solo su quelli
|
||||
indicati ed è altrimenti inattivo; non va lanciato sull'intero pool durante i run ordinari. Le vecchie
|
||||
versioni USB non vengono eliminate automaticamente senza una retention deliberata. Il controllo di
|
||||
capacità include il trasferimento stimato e una riserva libera di 10 GiB. Dopo un backup riuscito,
|
||||
scollegare fisicamente il disco per renderlo davvero offline.
|
||||
|
||||
Validare la configurazione senza avviare il backup e, separatamente, un eventuale relabel pianificato:
|
||||
|
||||
```bash
|
||||
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
|
||||
ansible-playbook ansible/site.yml --limit atlas --tags usb_backup,usb_reminder --check --diff
|
||||
ansible-playbook ansible/site.yml --limit atlas --tags restorecon --check \
|
||||
-e '{"atlas_restorecon_paths":["/zpool/archive"]}'
|
||||
```
|
||||
|
||||
Prima dell'avvio manuale smontare in sicurezza `/mnt/zpool-backup`, se ancora montato. Con il mapper
|
||||
chiuso, `sudo systemctl start atlas-usb-backup.service` chiede la passphrase e avvia il backup; né la
|
||||
password LUKS né un keyfile vanno in Ansible. Seguire con
|
||||
`sudo journalctl -fu atlas-usb-backup.service`. **Non esiste un timer di backup USB.** Soltanto
|
||||
`atlas-usb-reminder.timer` è schedulato il primo sabato del mese alle 10:00 `Europe/Rome`: invia un
|
||||
promemoria al notifier 45Drives Houston, senza avviare il backup. Un test manuale ha prodotto una
|
||||
notifica in 45Drives Alerts, **non un'email**; il log conferma l'invio della notifica, non la consegna
|
||||
di posta. Il primo evento pianificato era il 2026-10-03 alle 10:00 CEST. Controllare timer e risultato
|
||||
con `systemctl list-timers atlas-usb-reminder.timer` e in 45Drives Alerts.
|
||||
|
||||
Il primo tentativo USB del 2026-09-23 fallì su `security.selinux` e, dopo l'interruzione, lasciò
|
||||
snapshot e mapper aperti. Applicato il filtro rsync, furono rimossi lo snapshot fallito, il mapper
|
||||
smontato e lo stato failed; non rimase una copia valida di quel tentativo. Un run del 2026-09-24
|
||||
pubblicò una versione verificata ma fallì nella distruzione dello snapshot a causa di mount
|
||||
`.zfs/snapshot` aperti nel namespace host. Dopo la pulizia non forzata, è stato aggiunto un helper
|
||||
`ExecStopPost` mirato e testato con uno snapshot usa-e-getta. Un run successivo del 2026-09-24 ha
|
||||
verificato i checksum, pubblicato la versione ed è terminato con successo: mapper chiuso, nessuno
|
||||
snapshot USB temporaneo e pool sano. Il 2026-09-25 un test di restore indipendente ha aperto il disco
|
||||
in sola lettura, montato ext4 con `ro,noload`, copiato un file di 5.707.945 byte da `atlas/latest` in
|
||||
una directory vuota sotto `/var/tmp` e confrontato contenuto, proprietario, modalità, dimensione,
|
||||
mtime e ACL POSIX. Il test ha rimosso copia e mount temporanei, chiuso LUKS e lasciato il pool sano
|
||||
mentre Borg continuava. È un test su file, non un esercizio completo di disaster recovery.
|
||||
|
||||
Il monitoraggio Atlas è eseguito ogni 30 minuti da `atlas-health-monitor.timer`. Sonde in sola
|
||||
lettura controllano stato/errori del pool e dei vdev, scrub/resilver, SMART dei quattro dischi del
|
||||
pool e dell'NVMe di sistema, temperature dei dischi e CPU, spazio di sistema/pool/snapshot, crescita
|
||||
di `zpool/backup` e quota Hetzner tramite `df -m` via SSH con l'account `borg` e la chiave fissata.
|
||||
La query remota non apre il repository Borg né il suo lock. Gli alert di crescita richiedono una
|
||||
baseline di circa 24 ore. Sono controllati anche attivazione e freschezza dei timer; hook systemd
|
||||
`OnFailure` segnalano errori di snapshot, scrub, Borg, USB, promemoria e monitoraggio. Il monitor non
|
||||
riavvia Borg; avvisa solo se un run supera 14 giorni. Soglie e percorsi stabili dei dischi sono nelle
|
||||
variabili host. Gli avvisi usano 45Drives Houston con deduplicazione; **la consegna email non è stata
|
||||
verificata**. Il controllo live del 2026-09-25 non ha trovato problemi; la notifica di prova è stata
|
||||
inviata e lo Storage Box risultava occupato al 22%. Dimensione dell'archivio Borg e deduplicazione
|
||||
dettagliata richiedono ancora la fine del backup in corso.
|
||||
|
||||
```bash
|
||||
ansible-playbook ansible/site.yml --limit atlas --tags monitoring --check --diff
|
||||
sudo /usr/local/libexec/atlas-health-monitor --dry-run
|
||||
sudo journalctl -u atlas-health-monitor.service -n 100 --no-pager
|
||||
systemctl list-timers atlas-health-monitor.timer
|
||||
```
|
||||
|
||||
`--dry-run` non invia alert e non modifica lo stato del monitor. Un controllo reale si avvia con
|
||||
`sudo systemctl start atlas-health-monitor.service`, senza avviare servizi di backup. Per una prova
|
||||
etichettata di 45Drives Alerts usare
|
||||
`sudo /usr/local/libexec/atlas-health-monitor --test-notification`.
|
||||
|
||||
### Timer systemd di Atlas
|
||||
|
||||
Tutti i nove timer gestiti sono abilitati. Gli orari sono locali ad Atlas (`Europe/Rome`); Borg e
|
||||
monitoraggio aggiungono il ritardo casuale indicato. Tutti hanno `Persistent=true`: un evento perso
|
||||
viene recuperato quando il timer torna attivo.
|
||||
|
||||
| Timer | Pianificazione (`OnCalendar`) | Azione |
|
||||
| --- | --- | --- |
|
||||
| `atlas-zfs-snapshot-hourly.timer` | `*-*-* *:05:00` — ogni ora al minuto 05 | Snapshot ricorsivo orario e retention |
|
||||
| `atlas-zfs-snapshot-daily.timer` | `*-*-* 00:15:00` — ogni giorno alle 00:15 | Snapshot ricorsivo giornaliero e retention |
|
||||
| `atlas-zfs-snapshot-weekly.timer` | `Sun *-*-* 01:00:00` — domenica alle 01:00 | Snapshot ricorsivo settimanale e retention |
|
||||
| `atlas-zfs-snapshot-monthly.timer` | `*-*-01 02:00:00` — primo giorno del mese alle 02:00 | Snapshot ricorsivo mensile e retention |
|
||||
| `zfs-scrub-monthly@zpool.timer` | `Sun *-*-01..07 03:00:00` — prima domenica alle 03:00 | Scrub ZFS |
|
||||
| `atlas-borg-backup.timer` | `*-*-* 04:30:00` — ogni giorno alle 04:30, più 0–30 min casuali | Backup cifrato offsite |
|
||||
| `atlas-borg-check.timer` | `*-*-15 06:00:00` — giorno 15 alle 06:00, più 0–30 min casuali | Controllo repository Borg |
|
||||
| `atlas-usb-reminder.timer` | `Sat *-*-01..07 10:00:00 Europe/Rome` — primo sabato alle 10:00 | Solo promemoria 45Drives Alerts |
|
||||
| `atlas-health-monitor.timer` | `*:0/30` — ogni mezz'ora, più 0–5 min casuali | Controlli di salute in sola lettura |
|
||||
|
||||
`atlas-usb-backup.service` **non ha timer** e va avviato manualmente. Il timer del fornitore
|
||||
`zfs-scrub-weekly@zpool.timer` è disabilitato a favore dello scrub mensile. Il futuro pull del backup
|
||||
Prometheus non ha ancora un timer, perché non è implementato. Durante un backup Borg attivo,
|
||||
`systemctl list-timers` può mostrare `-` per il prossimo evento senza che il timer sia disabilitato.
|
||||
Per vedere la pianificazione corrente: `systemctl list-timers --all` su Atlas.
|
||||
|
||||
Nextcloud è previsto come servizio temporaneo su Atlas prima di Uranus, ma solo dopo la validazione
|
||||
della protezione dei dati: richiede storage applicativo, database e cache separati, segreti Vault,
|
||||
pubblicazione solo tramite NPM e Aegis, procedure di backup, aggiornamento e migrazione. Non
|
||||
distribuirlo prima di completare la checklist di protezione dei dati.
|
||||
|
||||
La destinazione futura per l'importazione foto iCloud è Atlas, non Aegis. Dopo la validazione dei
|
||||
backup, pianificare una migrazione esplicita di iCloudPD con foto sotto `/zpool/archive/Pictures` e
|
||||
stato applicativo/MFA fuori da `Archive`; testare permessi, SELinux, backup e restore prima del
|
||||
cutover. L'attuale iCloudPD su Aegis e l'export NFS Photobook restano configurati fino
|
||||
all'approvazione e alla verifica di questa migrazione separata. Anche il servizio Atlas sarà
|
||||
temporaneo in attesa di Uranus.
|
||||
|
||||
Il pull dei backup di Prometheus, la valutazione delle dimensioni degli archivi Borg e i test completi
|
||||
di disaster recovery restano da fare. Il backlog prioritizzato è in `AGENTS.md`.
|
||||
|
||||
---
|
||||
|
||||
# Composizione della configurazione
|
||||
|
||||
Emacs è abilitato sui profili Fedora/GNOME e workstation; la configurazione canonica è distribuita da `dotfiles_common`, con Org in `~/Org/`, template versionati e export PDF/HTML/Markdown/DOCX/ODT. Per abilitarlo temporaneamente su un altro profilo:
|
||||
|
||||
```bash
|
||||
ansible-playbook ansible/site.yml --limit <host> --tags emacs -e emacs_enabled=true
|
||||
```
|
||||
|
||||
La configurazione finale di una macchina è ottenuta combinando più livelli.
|
||||
|
||||
```text
|
||||
common configuration
|
||||
+ platform configuration
|
||||
+ role configuration
|
||||
+ desktop configuration
|
||||
+ host overrides
|
||||
```
|
||||
|
||||
Esempi correnti:
|
||||
|
||||
```text
|
||||
ikaros -> common + platform_fedora + role_personal_workstation + graphical_desktop + desktop_gnome + ikaros
|
||||
nymph -> common + platform_fedora + graphical_desktop + desktop_gnome + nymph
|
||||
deadalus -> common + platform_fedora + workstation_dev_fedora + workstation_dev_wsl + deadalus
|
||||
```
|
||||
|
||||
Questo approccio consente di:
|
||||
|
||||
- mantenere configurazioni condivise
|
||||
- applicare override specifici per host
|
||||
- evitare duplicazioni
|
||||
- riutilizzare il profilo Void corrente su un host futuro assegnandolo a
|
||||
`platform_void + graphical_desktop + desktop_sway`
|
||||
|
||||
---
|
||||
|
||||
# Ruoli Ansible
|
||||
|
||||
I principali ruoli attualmente presenti sono:
|
||||
|
||||
| Role | Descrizione |
|
||||
| ------------------------- | ----------------------------------- |
|
||||
| base | configurazione base comune |
|
||||
| packages_void | installazione pacchetti su Void |
|
||||
| packages_fedora | installazione pacchetti su Fedora |
|
||||
| packages_rocky | installazione pacchetti su Rocky Linux 9 |
|
||||
| services_runit | gestione servizi runit |
|
||||
| services_systemd | gestione servizi systemd |
|
||||
| profile_desktop_common | bootstrap desktop Void condiviso |
|
||||
| profile_desktop_gnome | dotfiles desktop condivisi per Fedora/GNOME |
|
||||
| profile_desktop_sway | sessione desktop sway / SwayFX (Wayland) |
|
||||
| profile_desktop_niri | sessione desktop Niri su Void (Wayland) |
|
||||
| profile_desktop_host | override desktop specifici per host |
|
||||
| profile_personal_workstation | layer stabile per workstation personale |
|
||||
| profile_workstation_dev_common | configurazione dev workstation condivisa |
|
||||
| profile_workstation_dev_wsl | configurazione WSL condivisa per sviluppo |
|
||||
| profile_server | configurazione server |
|
||||
| profile_atlas | configurazione NAS Rocky Linux 9 |
|
||||
| profile_backend_phase1 | Navidrome e Syncthing rootless su Atlas |
|
||||
| wireguard_overlay | overlay WireGuard Prometheus/Atlas |
|
||||
| dotfiles_common | distribuzione dotfiles comuni |
|
||||
| dotfiles | distribuzione configurazioni utente |
|
||||
|
||||
---
|
||||
|
||||
# Stato attuale del playbook principale
|
||||
|
||||
Il playbook `ansible/site.yml` e attualmente composto da blocchi per asse:
|
||||
|
||||
```text
|
||||
all -> dotfiles_common
|
||||
platform_void -> packages_void + services_runit
|
||||
platform_void & graphical_desktop -> profile_desktop_common + profile_desktop_sway + profile_desktop_niri + profile_desktop_host
|
||||
platform_fedora -> packages_fedora + services_systemd
|
||||
platform_rocky -> packages_rocky + services_systemd
|
||||
wireguard_overlay -> wireguard_overlay (dopo platform_rocky)
|
||||
atlas -> profile_atlas
|
||||
role_backend_phase1 -> profile_backend_phase1 (dopo atlas)
|
||||
platform_fedora & role_personal_workstation -> profile_personal_workstation
|
||||
platform_fedora & desktop_gnome -> profile_desktop_gnome
|
||||
workstation_dev_fedora -> profile_workstation_dev_common
|
||||
workstation_dev_wsl -> profile_workstation_dev_wsl (dopo platform_fedora + workstation_dev_fedora)
|
||||
rocky_server -> dotfiles_common + profile_server (dopo platform_rocky)
|
||||
```
|
||||
|
||||
Questo significa che, allo stato attuale:
|
||||
|
||||
- `ikaros` riceve Fedora Workstation/GNOME come target desktop personale stabile
|
||||
- `nymph` riceve Fedora Workstation/GNOME come target laptop
|
||||
- il profilo Void resta selezionabile tramite `platform_void + graphical_desktop` per host futuri
|
||||
- `deadalus` riceve il profilo Fedora WSL tramite play dev dedicati
|
||||
- il server Rocky (`prometheus`) e gestito con pacchetti, servizi, dotfiles server e firewalld
|
||||
- il NAS Rocky (`atlas`) usa un pool ZFS gia esistente, condivisioni NFSv4/SMB limitate alla LAN e Cockpit/45Drives
|
||||
- lo stack Compose server include soltanto `gitea` e `nginx-proxy-manager`; Navidrome e Syncthing
|
||||
della fase 1 sono Quadlet rootless su Atlas
|
||||
|
||||
# Dotfiles
|
||||
|
||||
La directory `dotfiles/` contiene le configurazioni utente versionate.
|
||||
|
||||
```text
|
||||
dotfiles/
|
||||
├── common
|
||||
├── desktop
|
||||
├── server
|
||||
├── fedora
|
||||
├── workstation
|
||||
├── workstation_dev_wsl
|
||||
└── nymph
|
||||
```
|
||||
|
||||
Le configurazioni sono applicate tramite Ansible e organizzate per livelli:
|
||||
|
||||
| Livello | Scopo |
|
||||
| ------- | -------------------------------- |
|
||||
| common | configurazioni condivise |
|
||||
| profile | configurazioni per tipo macchina |
|
||||
| host | override specifici |
|
||||
|
||||
---
|
||||
|
||||
# Requisiti
|
||||
|
||||
Per utilizzare il repository sono necessari:
|
||||
|
||||
- Python 3
|
||||
- Ansible
|
||||
- `ansible-lint`
|
||||
- `yamllint`
|
||||
- `shellcheck`
|
||||
- collection definite in `ansible/collections/requirements.yml`
|
||||
- accesso locale o SSH alle macchine target, in base a come e definito l'inventory
|
||||
|
||||
Installazione base:
|
||||
|
||||
```bash
|
||||
python3 -m pip install ansible ansible-lint yamllint shellcheck-py
|
||||
ansible-galaxy collection install -r ansible/collections/requirements.yml
|
||||
```
|
||||
|
||||
Gestione segreti:
|
||||
|
||||
- il repository supporta il caricamento opzionale di `secrets/vault.yml`
|
||||
- il repository supporta anche `secrets/vault.local.yml` per override locali non versionati
|
||||
- `secrets/vault.yml.example` funge da template/esempio
|
||||
- se `secrets/vault.yml` non e presente, il playbook continua comunque senza caricare variabili locali opzionali
|
||||
- se `secrets/.vault_pass.gpg` esiste viene usato automaticamente per sbloccare i vault tramite `gpg`; in alternativa resta supportato `secrets/.vault_pass` come fallback legacy locale; se nessuno dei due file esiste Ansible richiede la password in modo interattivo
|
||||
|
||||
---
|
||||
|
||||
# Utilizzo
|
||||
|
||||
Eseguire il playbook principale:
|
||||
|
||||
```bash
|
||||
ansible-playbook ansible/site.yml
|
||||
```
|
||||
|
||||
Allo stato attuale questo comando:
|
||||
|
||||
- distribuisce i dotfiles comuni a tutti gli host
|
||||
- per `platform_void` applica pacchetti Void e servizi runit
|
||||
- per `platform_void + graphical_desktop` applica bootstrap desktop condiviso, sessioni Sway/Niri e override specifici per host
|
||||
- per `platform_fedora` applica pacchetti Fedora e servizi systemd a `ikaros`, `nymph` e `deadalus`
|
||||
- per `platform_fedora & role_personal_workstation` applica il layer personale a `ikaros`
|
||||
- per `platform_fedora & desktop_gnome` applica il profilo GNOME a `ikaros` e `nymph`
|
||||
- per `workstation_dev_wsl` applica i tweak WSL dopo il layer Fedora a `deadalus`, escludendo Flatpak e Snap
|
||||
- per `platform_rocky` applica pacchetti Rocky e servizi systemd ad `atlas` e `prometheus`; quindi applica il profilo NAS ad `atlas` e il profilo server a `prometheus`
|
||||
- non riavvia automaticamente il display manager
|
||||
- carica `secrets/vault.yml` solo se presente
|
||||
- carica `secrets/vault.local.yml` solo se presente, dopo `vault.yml`, cosi gli override locali hanno precedenza
|
||||
|
||||
Per validare prima di applicare:
|
||||
|
||||
```bash
|
||||
ansible-playbook ansible/site.yml --syntax-check
|
||||
ansible-playbook ansible/site.yml --limit ikaros,nymph --check --diff
|
||||
ansible-playbook ansible/site.yml --limit ikaros --check --diff
|
||||
ansible-playbook ansible/site.yml --limit nymph --check --diff
|
||||
ansible-playbook ansible/site.yml --limit deadalus --check --diff
|
||||
ansible-playbook ansible/site.yml --limit prometheus --check --diff
|
||||
ansible-playbook ansible/site.yml --limit atlas --check --diff
|
||||
ansible-lint ansible/site.yml
|
||||
ansible-lint ansible/roles
|
||||
yamllint ansible/
|
||||
```
|
||||
|
||||
Per testare un override dell'utente server senza modificare l'inventory:
|
||||
|
||||
```bash
|
||||
ansible-playbook ansible/site.yml --limit prometheus --check --diff -e server_username=myuser
|
||||
```
|
||||
|
||||
Per validazioni piu mirate:
|
||||
|
||||
```bash
|
||||
ansible-playbook ansible/site.yml --limit <host> --tags <tag1>,<tag2> --check --diff
|
||||
ansible-playbook ansible/site.yml --limit <host> --start-at-task "<task name>" --check --diff
|
||||
ansible-lint ansible/roles/<role>
|
||||
yamllint ansible/path/to/file.yml
|
||||
podman-compose -f /opt/docker/server/docker-compose.yml config
|
||||
ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff
|
||||
ansible-playbook ansible/site.yml --limit atlas --tags backend_phase1 --check --diff
|
||||
```
|
||||
|
||||
## Tag supportati dal playbook
|
||||
|
||||
Per vedere l'elenco reale aggiornato dei tag disponibili:
|
||||
|
||||
```bash
|
||||
ansible-playbook ansible/site.yml --list-tags
|
||||
```
|
||||
|
||||
Allo stato attuale `ansible/site.yml` espone questi tag:
|
||||
|
||||
| Tag | Scopo | Ambito principale |
|
||||
| --- | --- | --- |
|
||||
| `always` | pre-task sempre eseguiti, inclusi caricamento vault e validazioni preliminari | common |
|
||||
| `ai_agents` | installazione agenti AI condivisi | Fedora, WSL |
|
||||
| `atlas` | account, storage, condivisioni e container Atlas | NAS Atlas |
|
||||
| `backend_phase1` | Quadlet rootless Navidrome e Syncthing | NAS Atlas |
|
||||
| `containers` | Quadlet rootful Atlas | NAS Atlas |
|
||||
| `dotfiles` | distribuzione/configurazione dotfiles | tutti i profili |
|
||||
| `dotfiles:common` | dotfiles comuni condivisi | common, workstation, server |
|
||||
| `dotfiles:desktop` | dotfiles desktop | desktop Void, Fedora/GNOME |
|
||||
| `dotfiles:host` | override host-specifici desktop | desktop Void |
|
||||
| `dotfiles:server` | dotfiles dedicati al profilo server | server |
|
||||
| `dotfiles:workstation` | dotfiles dedicati alle workstation | personal workstation, WSL |
|
||||
| `emptty` | gestione display manager `emptty` | desktop Void |
|
||||
| `display-manager` | gestione del display manager `emptty` | desktop Void |
|
||||
| `emacs` | configurazione Emacs condivisa e dipendenze di authoring | desktop Fedora/GNOME e workstation |
|
||||
| `fonts` | installazione font | Fedora |
|
||||
| `fzf` | configurazione FZF | dotfiles comuni |
|
||||
| `git` | configurazione Git e GPG desktop | Fedora/GNOME, desktop Void |
|
||||
| `gnome` | configurazione host GNOME | Fedora/GNOME desktop |
|
||||
| `immich` | account e Quadlet Immich | NAS Atlas |
|
||||
| `sway` | sessione/configurazione sway / SwayFX (Wayland) | desktop Void |
|
||||
| `niri` | sessione/configurazione Niri (Wayland) | desktop Void |
|
||||
| `npm` | installazione pacchetti npm globali | Fedora/GNOME, desktop Void, WSL |
|
||||
| `nvidia` | componenti NVIDIA desktop | desktop Void |
|
||||
| `packages` | installazione e aggiornamento pacchetti | tutti i profili |
|
||||
| `podman` | integrazione Podman Compose e Quadlet rootless | server |
|
||||
| `portal` | configurazione xdg-desktop-portal | desktop Void |
|
||||
| `services` | gestione servizi runit/systemd | tutti i profili |
|
||||
| `sharing` | condivisioni NFSv4 e SMB3 | NAS Atlas |
|
||||
| `storage` | dataset ZFS figli | NAS Atlas |
|
||||
| `theme` | configurazione del tema GTK/Qt | desktop Void |
|
||||
| `tmux` | configurazione e plugin tmux | desktop Fedora/Void, WSL |
|
||||
| `vim` | configurazione Vim | dotfiles comuni |
|
||||
| `wireguard` | overlay WireGuard Prometheus/Atlas | Prometheus, NAS Atlas |
|
||||
| `wsl` | bootstrap e configurazione WSL | WSL |
|
||||
|
||||
Esempi pratici:
|
||||
|
||||
```bash
|
||||
ansible-playbook ansible/site.yml --limit nymph --tags dotfiles:desktop,gnome --check --diff
|
||||
ansible-playbook ansible/site.yml --limit ikaros --tags gnome --check --diff
|
||||
ansible-playbook ansible/site.yml --limit prometheus --tags services,dotfiles:server --check --diff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Bootstrap di una nuova macchina
|
||||
|
||||
Una nuova macchina può essere inizializzata con i seguenti passaggi:
|
||||
|
||||
```bash
|
||||
git clone <repo>
|
||||
cd <repo-dir>
|
||||
ansible-galaxy collection install -r ansible/collections/requirements.yml
|
||||
ansible-playbook ansible/site.yml
|
||||
```
|
||||
|
||||
Dopo l'esecuzione del playbook la macchina verra configurata secondo il profilo definito e i ruoli attualmente orchestrati.
|
||||
|
||||
Per aggiungere un nuovo host Void che riusa il profilo desktop preservato:
|
||||
|
||||
1. aggiungere l'host a `platform_void`;
|
||||
2. aggiungerlo a `graphical_desktop`;
|
||||
3. usare Sway, oppure aggiungerlo a `desktop_niri` per selezionare Niri;
|
||||
4. lasciare eventuali dettagli hardware in `host_vars/<host>.yml`.
|
||||
|
||||
I gruppi legacy `void` e `desktop` sono parent di compatibilita, quindi un host
|
||||
in `platform_void` e `graphical_desktop` continua a ricevere anche le variabili
|
||||
Void e desktop esistenti.
|
||||
|
||||
Per il flusso mail desktop esiste inoltre uno script dedicato:
|
||||
|
||||
```bash
|
||||
scripts/bootstrap_mail.sh
|
||||
```
|
||||
|
||||
Lo script si occupa del bootstrap dei secret nel keyring, del primo sync con `mbsync` e dell'inizializzazione di `mu` usando la configurazione mail generata dai template.
|
||||
|
||||
Se modifichi questo script, valida almeno con:
|
||||
|
||||
```bash
|
||||
sh -n scripts/bootstrap_mail.sh
|
||||
shellcheck scripts/bootstrap_mail.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Filosofia del progetto
|
||||
|
||||
Il repository segue alcuni principi chiave:
|
||||
|
||||
- Infrastructure as Code
|
||||
- configurazione dichiarativa
|
||||
- idempotenza
|
||||
- ambienti riproducibili
|
||||
- separazione tra configurazione sistema e configurazione utente
|
||||
|
||||
Questo consente di ricreare qualsiasi macchina partendo esclusivamente dal repository.
|
||||
|
||||
---
|
||||
|
||||
# Roadmap
|
||||
|
||||
Possibili evoluzioni future:
|
||||
|
||||
- hardening sicurezza server
|
||||
- configurazione backup
|
||||
- testing automatico playbook
|
||||
- integrazione CI
|
||||
- supporto ad altre distribuzioni Linux
|
||||
|
||||
---
|
||||
|
||||
# Licenza
|
||||
|
||||
Questo progetto è distribuito sotto licenza **LGPL-3.0**.
|
||||
57
ansible/bootstrap/aegis.bu
Normal file
57
ansible/bootstrap/aegis.bu
Normal file
@@ -0,0 +1,57 @@
|
||||
# One-time bootstrap for Fedora IoT on Aegis (Raspberry Pi 4).
|
||||
# Generate only: ./generate-aegis-ign.sh
|
||||
# Generate and write an SD card: ./generate-aegis-ign.sh --write IMAGE DEVICE
|
||||
# The write mode uses arm-image-installer with the RPi4 target and embeds
|
||||
# config.ign; it prompts for Wi-Fi credentials unless supplied through its
|
||||
# WIFI_SSID and WIFI_PASS environment variables.
|
||||
# For WiFi, the UEFI 'System Table Selection' must be DeviceTree (Esc at boot).
|
||||
variant: fiot
|
||||
version: 1.0.0
|
||||
passwd:
|
||||
users:
|
||||
- name: admin
|
||||
password_hash: "$6$bNDsU0XC5BxNJS5U$ENDGpdOUPJM3wcXBgNESCQkOqqQ9gN72/xEQoJsAI6QdsaY6mD4G5DBVIv7nHwHQOP35IH1oGRl.Uy3R2iUYQ1"
|
||||
groups:
|
||||
- wheel
|
||||
ssh_authorized_keys:
|
||||
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros"
|
||||
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEH/7GJfGt0ZVmKeEzceoFkFkeCXFryKK9vAbaip+HCx nymph"
|
||||
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA95wYlzpfN3rjUhpMeP4KHn8I6ZrjQXoDTgwgRIa++b siren"
|
||||
systemd:
|
||||
units:
|
||||
- name: sshd.service
|
||||
enabled: true
|
||||
- name: rpm-ostree-install-wifi.service
|
||||
enabled: true
|
||||
contents: |
|
||||
[Unit]
|
||||
Description=Layer WiFi packages (driver, firmware, NetworkManager-wifi)
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
Before=zincati.service
|
||||
ConditionPathExists=!/var/lib/%N.stamp
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/bin/rpm-ostree install -y --allow-inactive NetworkManager-wifi NetworkManager-wwan wpa_supplicant wireless-regdb iw brcmfmac-firmware
|
||||
ExecStart=/bin/touch /var/lib/%N.stamp
|
||||
ExecStart=/bin/systemctl --no-block reboot
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
storage:
|
||||
files:
|
||||
- path: /etc/hostname
|
||||
mode: 0644
|
||||
contents:
|
||||
inline: |
|
||||
aegis
|
||||
- path: /etc/sysctl.d/99-ip-forward.conf
|
||||
mode: 0644
|
||||
contents:
|
||||
inline: |
|
||||
net.ipv4.ip_forward = 1
|
||||
links:
|
||||
- path: /etc/localtime
|
||||
target: ../usr/share/zoneinfo/Europe/Rome
|
||||
147
ansible/bootstrap/generate-aegis-ign.sh
Executable file
147
ansible/bootstrap/generate-aegis-ign.sh
Executable file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
|
||||
BUTANE_IMAGE=${BUTANE_IMAGE:-quay.io/coreos/butane:release}
|
||||
BUTANE_SOURCE=${BUTANE_SOURCE:-"$SCRIPT_DIR/aegis.bu"}
|
||||
IGNITION_OUTPUT=${IGNITION_OUTPUT:-"$SCRIPT_DIR/config.ign"}
|
||||
SSH_PUBLIC_KEY=${SSH_PUBLIC_KEY:-"$HOME/.ssh/id_ed25519.pub"}
|
||||
WIFI_SECURITY=${WIFI_SECURITY:-wpa-psk}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
generate-aegis-ign.sh
|
||||
generate-aegis-ign.sh --write IMAGE DEVICE
|
||||
|
||||
Environment overrides:
|
||||
BUTANE_IMAGE Butane container image (default: quay.io/coreos/butane:release)
|
||||
BUTANE_SOURCE Butane source path (default: aegis.bu beside this script)
|
||||
IGNITION_OUTPUT Ignition output path (default: config.ign beside this script)
|
||||
SSH_PUBLIC_KEY SSH public key passed to arm-image-installer
|
||||
WIFI_SSID Wi-Fi SSID; prompted if unset in --write mode
|
||||
WIFI_PASS Wi-Fi password; prompted if unset in --write mode
|
||||
WIFI_SECURITY Wi-Fi security type (default: wpa-psk)
|
||||
USAGE
|
||||
}
|
||||
|
||||
require_command() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
printf 'Error: required command not found: %s\n' "$1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
read_required() {
|
||||
prompt=$1
|
||||
value=$2
|
||||
|
||||
if [ -z "$value" ]; then
|
||||
printf '%s' "$prompt" >&2
|
||||
IFS= read -r value
|
||||
fi
|
||||
|
||||
if [ -z "$value" ]; then
|
||||
printf '%s\n' 'Error: a value is required.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
read_secret() {
|
||||
value=$1
|
||||
|
||||
if [ -z "$value" ]; then
|
||||
printf '%s' 'Wi-Fi password: ' >&2
|
||||
stty -echo
|
||||
IFS= read -r value
|
||||
stty echo
|
||||
printf '\n' >&2
|
||||
fi
|
||||
|
||||
if [ -z "$value" ]; then
|
||||
printf '%s\n' 'Error: a value is required.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
write_image=false
|
||||
case $# in
|
||||
0)
|
||||
;;
|
||||
3)
|
||||
if [ "$1" != '--write' ]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
write_image=true
|
||||
IMAGE=$2
|
||||
DEVICE=$3
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
require_command podman
|
||||
|
||||
if [ ! -f "$BUTANE_SOURCE" ]; then
|
||||
printf 'Error: Butane source not found: %s\n' "$BUTANE_SOURCE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUTPUT_DIR=$(dirname "$IGNITION_OUTPUT")
|
||||
if [ ! -d "$OUTPUT_DIR" ]; then
|
||||
printf 'Error: output directory not found: %s\n' "$OUTPUT_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
umask 077
|
||||
TEMP_OUTPUT=$(mktemp "$OUTPUT_DIR/.config.ign.XXXXXX")
|
||||
trap 'rm -f "$TEMP_OUTPUT"' EXIT HUP INT TERM
|
||||
|
||||
podman run --rm -i "$BUTANE_IMAGE" --strict < "$BUTANE_SOURCE" > "$TEMP_OUTPUT"
|
||||
mv "$TEMP_OUTPUT" "$IGNITION_OUTPUT"
|
||||
trap - EXIT HUP INT TERM
|
||||
|
||||
printf 'Generated Ignition config: %s\n' "$IGNITION_OUTPUT"
|
||||
|
||||
if [ "$write_image" = false ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$IMAGE" ]; then
|
||||
printf 'Error: image not found: %s\n' "$IMAGE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -b "$DEVICE" ]; then
|
||||
printf 'Error: target is not a block device: %s\n' "$DEVICE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$SSH_PUBLIC_KEY" ]; then
|
||||
printf 'Error: SSH public key not found: %s\n' "$SSH_PUBLIC_KEY" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_command arm-image-installer
|
||||
WIFI_SSID=$(read_required 'Wi-Fi SSID: ' "${WIFI_SSID:-}")
|
||||
WIFI_PASS=$(read_secret "${WIFI_PASS:-}")
|
||||
|
||||
printf 'Writing %s to %s.\n' "$IMAGE" "$DEVICE" >&2
|
||||
sudo arm-image-installer \
|
||||
--image="$IMAGE" \
|
||||
--target=rpi4 \
|
||||
--media="$DEVICE" \
|
||||
--ignition="$IGNITION_OUTPUT" \
|
||||
--addkey="$SSH_PUBLIC_KEY" \
|
||||
--resizefs \
|
||||
--wifi-ssid="$WIFI_SSID" \
|
||||
--wifi-pass="$WIFI_PASS" \
|
||||
--wifi-security="$WIFI_SECURITY"
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
# Optional inventory overlay for VM tests. It is not loaded by ansible.cfg.
|
||||
all:
|
||||
children:
|
||||
platform_mint:
|
||||
hosts:
|
||||
minttest:
|
||||
ansible_connection: local
|
||||
|
||||
platform_freebsd:
|
||||
hosts:
|
||||
freebsdtest:
|
||||
ansible_connection: local
|
||||
|
||||
graphical_desktop:
|
||||
hosts:
|
||||
minttest:
|
||||
freebsdtest:
|
||||
|
||||
role_personal_workstation:
|
||||
hosts:
|
||||
minttest:
|
||||
|
||||
role_lab:
|
||||
hosts:
|
||||
freebsdtest:
|
||||
|
||||
desktop_cinnamon:
|
||||
hosts:
|
||||
minttest:
|
||||
|
||||
desktop_hyprland:
|
||||
hosts:
|
||||
freebsdtest:
|
||||
@@ -9,6 +9,29 @@ effective_user_group: "{{ user_group }}"
|
||||
effective_user_home: "{{ user_home }}"
|
||||
emacs_enabled: false
|
||||
|
||||
emacs_dotfiles:
|
||||
- name: Bash Emacs client fragment
|
||||
src: .bashrc.d/25-emacs-client-desktop.sh
|
||||
dest: .bashrc.d/25-emacs-client-desktop.sh
|
||||
mode: "0644"
|
||||
- name: Emacs authoring configuration
|
||||
src: .emacs.d/
|
||||
dest: .emacs.d/
|
||||
mode: preserve
|
||||
|
||||
emacs_authoring_directories:
|
||||
- .bashrc.d
|
||||
- Org
|
||||
- Org/documentation
|
||||
- Org/meetings
|
||||
- Org/projects
|
||||
|
||||
emacs_authoring_files:
|
||||
- name: Org HTML stylesheet
|
||||
src: .emacs.d/templates/org/org.css
|
||||
dest: Org/org.css
|
||||
mode: "0644"
|
||||
|
||||
xdg_user_directories:
|
||||
- Desktop
|
||||
- Documents
|
||||
@@ -67,38 +90,82 @@ common_dotfiles:
|
||||
dest: .config/bat/
|
||||
mode: preserve
|
||||
|
||||
ai_agents_npm_packages:
|
||||
- name: "opencode-ai"
|
||||
state: latest
|
||||
- name: "@anthropic-ai/claude-code"
|
||||
state: latest
|
||||
- name: "@openai/codex"
|
||||
state: latest
|
||||
- name: "@google/gemini-cli"
|
||||
state: latest
|
||||
# AI coding-agent lifecycle controls are independent for every agent.
|
||||
# Installation and removal are mutually exclusive per agent; the common
|
||||
# pre-task enforces this before any changes are made.
|
||||
codex_relay_install_enabled: false
|
||||
|
||||
ai_agents_enabled: true
|
||||
ai_agents:
|
||||
opencode:
|
||||
npm_package: opencode-ai
|
||||
install_enabled: true
|
||||
deploy_enabled: true
|
||||
uninstall_enabled: false
|
||||
claude_code:
|
||||
npm_package: "@anthropic-ai/claude-code"
|
||||
install_enabled: false
|
||||
deploy_enabled: false
|
||||
uninstall_enabled: true
|
||||
codex:
|
||||
npm_package: "@openai/codex"
|
||||
install_enabled: true
|
||||
deploy_enabled: true
|
||||
uninstall_enabled: false
|
||||
codex_relay:
|
||||
npm_package: codex-relay
|
||||
install_enabled: "{{ codex_relay_install_enabled }}"
|
||||
deploy_enabled: false
|
||||
uninstall_enabled: false
|
||||
gemini_cli:
|
||||
npm_package: "@google/gemini-cli"
|
||||
install_enabled: true
|
||||
deploy_enabled: true
|
||||
uninstall_enabled: false
|
||||
github_copilot:
|
||||
npm_package: "@github/copilot"
|
||||
install_enabled: false
|
||||
deploy_enabled: false
|
||||
uninstall_enabled: true
|
||||
ibm_bob:
|
||||
install_enabled: true
|
||||
deploy_enabled: false
|
||||
uninstall_enabled: false
|
||||
|
||||
ai_agents_deploy_enabled_names: >-
|
||||
{{ ai_agents | dict2items | selectattr('value.deploy_enabled') | map(attribute='key') | list }}
|
||||
|
||||
ai_agents_dotfiles:
|
||||
- name: AI common config
|
||||
src: .config/ai/
|
||||
dest: .config/ai/
|
||||
mode: preserve
|
||||
agents:
|
||||
- opencode
|
||||
- codex
|
||||
- gemini_cli
|
||||
- name: Gemini CLI config
|
||||
src: .gemini/
|
||||
dest: .gemini/
|
||||
mode: preserve
|
||||
agents:
|
||||
- gemini_cli
|
||||
- name: OpenCode config
|
||||
src: .config/opencode/
|
||||
dest: .config/opencode/
|
||||
mode: preserve
|
||||
agents:
|
||||
- opencode
|
||||
- name: Claude Code memory
|
||||
src: .claude/
|
||||
dest: .claude/
|
||||
mode: preserve
|
||||
agents:
|
||||
- claude_code
|
||||
|
||||
ai_agents_templates:
|
||||
- name: Codex config
|
||||
src: .codex/config.toml.j2
|
||||
dest: .codex/config.toml
|
||||
mode: "0644"
|
||||
agents:
|
||||
- codex
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
---
|
||||
desktop_environment: minimal
|
||||
desktop_allow_display_manager_switch: false
|
||||
|
||||
desktop_display_manager_by_environment:
|
||||
minimal: emptty
|
||||
xfce: lightdm
|
||||
kde: sddm
|
||||
|
||||
desktop_sessions_enabled:
|
||||
- sway
|
||||
- hyprland
|
||||
|
||||
desktop_default_session: hyprland
|
||||
desktop_default_session: sway
|
||||
desktop_default_session_env: wayland
|
||||
desktop_prompt_for_session: true
|
||||
|
||||
@@ -38,8 +31,7 @@ desktop_binary_tools: []
|
||||
|
||||
desktop_npm_packages: >-
|
||||
{{
|
||||
ai_agents_npm_packages
|
||||
+ [
|
||||
[
|
||||
{'name': '@mermaid-js/mermaid-cli', 'state': 'latest'},
|
||||
{'name': 'vscode-langservers-extracted', 'state': 'latest'}
|
||||
]
|
||||
@@ -156,7 +148,6 @@ desktop_minimal_managed_paths:
|
||||
- .config/flameshot
|
||||
- .config/foot
|
||||
- .config/gtklock
|
||||
- .config/hypr
|
||||
- .config/kanshi
|
||||
- .config/rofi
|
||||
- .config/sway
|
||||
@@ -165,16 +156,6 @@ desktop_minimal_managed_paths:
|
||||
- .config/wlogout
|
||||
- .local/bin/udiskie-password
|
||||
|
||||
desktop_emacs_dotfiles:
|
||||
- name: Bash Emacs client fragment
|
||||
src: .bashrc.d/25-emacs-client-desktop.sh
|
||||
dest: .bashrc.d/25-emacs-client-desktop.sh
|
||||
mode: "0644"
|
||||
- name: Emacs config
|
||||
src: .emacs.d/
|
||||
dest: .emacs.d/
|
||||
mode: preserve
|
||||
|
||||
desktop_sway_dotfiles:
|
||||
- name: alacritty config
|
||||
src: .config/alacritty/
|
||||
@@ -213,32 +194,6 @@ desktop_sway_dotfiles:
|
||||
dest: .config/gtklock/
|
||||
mode: preserve
|
||||
|
||||
desktop_hyprland_dotfiles:
|
||||
- name: alacritty config
|
||||
src: .config/alacritty/
|
||||
dest: .config/alacritty/
|
||||
mode: preserve
|
||||
- name: dunst config
|
||||
src: .config/dunst/
|
||||
dest: .config/dunst/
|
||||
mode: preserve
|
||||
- name: rofi config
|
||||
src: .config/rofi/
|
||||
dest: .config/rofi/
|
||||
mode: preserve
|
||||
- name: hyprland config
|
||||
src: .config/hypr/
|
||||
dest: .config/hypr/
|
||||
mode: preserve
|
||||
- name: waybar config
|
||||
src: .config/waybar/
|
||||
dest: .config/waybar/
|
||||
mode: preserve
|
||||
- name: foot config
|
||||
src: .config/foot/
|
||||
dest: .config/foot/
|
||||
mode: preserve
|
||||
|
||||
desktop_niri_dotfiles:
|
||||
- name: foot config
|
||||
src: .config/foot/
|
||||
@@ -261,30 +216,6 @@ desktop_niri_dotfiles:
|
||||
dest: .config/niri/
|
||||
mode: preserve
|
||||
|
||||
desktop_xfce_dotfiles:
|
||||
- name: XFCE configuration
|
||||
src: .config/xfce4/
|
||||
dest: .config/xfce4/
|
||||
mode: preserve
|
||||
- name: XFCE Thunar custom actions
|
||||
src: .config/Thunar/uca.xfce.xml
|
||||
dest: .config/Thunar/uca.xml
|
||||
mode: "0644"
|
||||
- name: XFCE dark desktop background
|
||||
src: .local/share/backgrounds/xfce-dark.svg
|
||||
dest: .local/share/backgrounds/xfce-dark.svg
|
||||
mode: "0644"
|
||||
|
||||
desktop_xfce_managed_paths:
|
||||
- .config/xfce4
|
||||
- .local/share/backgrounds/xfce-dark.svg
|
||||
|
||||
xfce_theme_gtk: Yaru-blue-dark
|
||||
xfce_theme_icons: Papirus-Dark
|
||||
xfce_theme_wm: Greybird-dark
|
||||
xfce_font_ui: Liberation Sans 10
|
||||
xfce_font_monospace: Liberation Mono 11
|
||||
|
||||
desktop_flatpak_packages:
|
||||
- be.alexandervanhee.gradia
|
||||
- ch.protonmail.protonmail-bridge
|
||||
@@ -293,8 +224,8 @@ desktop_flatpak_packages:
|
||||
- com.github.tchx84.Flatseal
|
||||
- com.spotify.Client
|
||||
- io.github.flattool.Warehouse
|
||||
- org.onlyoffice.desktopeditors
|
||||
- org.telegram.desktop
|
||||
- org.mozilla.Thunderbird
|
||||
|
||||
desktop_flatpak_extensions:
|
||||
- org.gtk.Gtk3theme.Yaru-Blue-dark//stable
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
desktop_profile: cinnamon
|
||||
desktop_default_session: cinnamon
|
||||
|
||||
cinnamon_theme_gtk: Mint-Y-Dark-Aqua
|
||||
cinnamon_theme_icons: Mint-Y-Aqua
|
||||
cinnamon_font_ui: Sans 10
|
||||
cinnamon_font_monospace: Monospace 10
|
||||
|
||||
# WM-agnostic desktop dotfiles from desktop_common_dotfiles that do NOT apply to
|
||||
# Cinnamon: the autostart entry is the (removed) Protonmail mail bridge, and the
|
||||
# GTK theme script conflicts with Cinnamon's own dconf-managed theming.
|
||||
cinnamon_desktop_dotfiles_exclude:
|
||||
- XDG autostart entries
|
||||
- GTK theme setup script
|
||||
|
||||
cinnamon_dconf_settings:
|
||||
- key: /org/cinnamon/desktop/interface/gtk-theme
|
||||
value: "'{{ cinnamon_theme_gtk }}'"
|
||||
- key: /org/cinnamon/desktop/interface/icon-theme
|
||||
value: "'{{ cinnamon_theme_icons }}'"
|
||||
- key: /org/cinnamon/desktop/interface/font-name
|
||||
value: "'{{ cinnamon_font_ui }}'"
|
||||
- key: /org/cinnamon/desktop/interface/monospace-font-name
|
||||
value: "'{{ cinnamon_font_monospace }}'"
|
||||
- key: /org/cinnamon/desktop/interface/clock-use-24h
|
||||
value: "true"
|
||||
- key: /org/cinnamon/desktop/wm/preferences/button-layout
|
||||
value: "'menu:minimize,maximize,close'"
|
||||
@@ -11,6 +11,7 @@ fedora_desktop_packages:
|
||||
# Fedora equivalents of the development/tooling packages previously pulled by
|
||||
# the Void desktop profile.
|
||||
- 7zip
|
||||
- arm-image-installer
|
||||
- nodejs-bash-language-server
|
||||
- bluez
|
||||
- bridge-utils
|
||||
@@ -21,6 +22,7 @@ fedora_desktop_packages:
|
||||
- croc
|
||||
- delve
|
||||
- dnsmasq
|
||||
- evolution
|
||||
- fastfetch
|
||||
- poppler-glib
|
||||
- poppler-utils
|
||||
@@ -30,13 +32,12 @@ fedora_desktop_packages:
|
||||
- golang
|
||||
- golangci-lint
|
||||
- gopls
|
||||
- gnome-shell-extension-gsconnect
|
||||
- hunspell
|
||||
- libvirt
|
||||
- libreoffice
|
||||
- liberation-fonts
|
||||
- libasan
|
||||
- libubsan
|
||||
- libvterm-devel
|
||||
- libX11-devel
|
||||
- libXft-devel
|
||||
- meld
|
||||
@@ -51,14 +52,15 @@ fedora_desktop_packages:
|
||||
- qemu-tools
|
||||
- rclone
|
||||
- remmina
|
||||
- rpi-imager
|
||||
- ruff
|
||||
- rustup
|
||||
- rsync
|
||||
- shfmt
|
||||
- seahorse
|
||||
- syncthing
|
||||
- tealdeer
|
||||
- terminus-fonts
|
||||
- texlive
|
||||
- tmux
|
||||
- wl-clipboard
|
||||
- ty
|
||||
@@ -82,7 +84,5 @@ fedora_flatpak_packages:
|
||||
- be.alexandervanhee.gradia
|
||||
- ch.protonmail.protonmail-bridge
|
||||
- com.spotify.Client
|
||||
- org.mozilla.thunderbird
|
||||
- org.onlyoffice.desktopeditors
|
||||
- org.telegram.desktop
|
||||
|
||||
fedora_npm_packages: "{{ ai_agents_npm_packages }}"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
desktop_profile: hyprland
|
||||
desktop_default_session: hyprland
|
||||
@@ -9,18 +9,18 @@ fedora_packages_base:
|
||||
- jq
|
||||
- make
|
||||
- nodejs
|
||||
- openssl
|
||||
- ripgrep
|
||||
|
||||
fedora_manage_docker_repo: true
|
||||
fedora_manage_google_chrome_repo: true
|
||||
fedora_manage_intellij_repo: true
|
||||
fedora_manage_vscode_repo: true
|
||||
fedora_intellij_copr_owner: coder966
|
||||
fedora_intellij_copr_project: intellij-idea-ultimate
|
||||
fedora_intellij_package_name: intellij-idea-ultimate
|
||||
|
||||
fedora_emacs_packages:
|
||||
- emacs-pgtk
|
||||
- pandoc
|
||||
- latexmk
|
||||
- texlive-latex
|
||||
- texlive-luatex
|
||||
- texlive-collection-latexrecommended
|
||||
|
||||
fedora_docker_packages:
|
||||
- docker-ce
|
||||
@@ -48,45 +48,24 @@ fedora_dotfiles:
|
||||
|
||||
workstation_firewall_backend: firewalld
|
||||
|
||||
fedora_workstation_dev_packages:
|
||||
fedora_dev_packages:
|
||||
- croc
|
||||
- distrobox
|
||||
- gnupg
|
||||
- gnupg2-gpg-agent
|
||||
- libfido2
|
||||
- openssh-clients
|
||||
- pandoc
|
||||
- shellcheck
|
||||
|
||||
workstation_host_linux_packages_fedora:
|
||||
- code
|
||||
- croc
|
||||
- firewall-config
|
||||
- gnome-extensions-app
|
||||
- gnome-shell-extension-appindicator
|
||||
- gnome-shell-extension-just-perfection
|
||||
- gnome-shell-extension-no-overview
|
||||
- gnome-tweaks
|
||||
- libreoffice
|
||||
- meld
|
||||
- NetworkManager-openconnect-gnome
|
||||
- pcsc-lite
|
||||
- pinentry-gnome3
|
||||
- podman
|
||||
- podman-compose
|
||||
- rclone
|
||||
- yubikey-manager
|
||||
|
||||
fedora_flatpak_remote_name: flathub
|
||||
fedora_flatpak_remote_url: https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
workstation_flatpak_remote_name: "{{ fedora_flatpak_remote_name }}"
|
||||
workstation_flatpak_remote_url: "{{ fedora_flatpak_remote_url }}"
|
||||
workstation_flatpak_packages:
|
||||
- com.getpostman.Postman
|
||||
- com.spotify.Client
|
||||
- com.github.jeromerobert.pdfarranger
|
||||
- com.github.xournalpp.xournalpp
|
||||
- io.dbeaver.DBeaverCommunity
|
||||
- org.mozilla.Thunderbird
|
||||
- org.telegram.desktop
|
||||
chatgpt_desktop_enabled: false
|
||||
chatgpt_desktop_supported_fedora_versions:
|
||||
- "43"
|
||||
- "44"
|
||||
chatgpt_desktop_supported_architectures:
|
||||
- x86_64
|
||||
- aarch64
|
||||
chatgpt_desktop_rpm_gpg_key: RPM-GPG-KEY-chatgpt-3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4.asc
|
||||
chatgpt_desktop_rpm_repository: https://persistent.oaistatic.com/codex-app-prod/linux/rpm/$basearch
|
||||
|
||||
2
ansible/inventory/group_vars/platform_fedora_iot.yml
Normal file
2
ansible/inventory/group_vars/platform_fedora_iot.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
---
|
||||
# Fedora IoT is immutable: do not attach the mutable Fedora package roles.
|
||||
@@ -1,122 +0,0 @@
|
||||
---
|
||||
platform_profile: freebsd
|
||||
platform_family: freebsd
|
||||
platform_package_manager: pkg
|
||||
platform_service_manager: rc
|
||||
ansible_python_interpreter: /usr/local/bin/python3
|
||||
|
||||
vim_plugin_distro_packages:
|
||||
- fzf
|
||||
vim_plugin_source_plugins:
|
||||
- name: fzf
|
||||
repo: https://github.com/junegunn/fzf.git
|
||||
version: v0.72.0
|
||||
- name: fzf.vim
|
||||
repo: https://github.com/junegunn/fzf.vim.git
|
||||
version: 34a564c81f36047f50e593c1656f4580ff75ccca
|
||||
- name: vim-fugitive
|
||||
repo: https://github.com/tpope/vim-fugitive.git
|
||||
version: 3b753cf8c6a4dcde6edee8827d464ba9b8c4a6f0
|
||||
|
||||
effective_user_group: "{{ user_group }}"
|
||||
effective_user_home: "/home/{{ effective_username }}"
|
||||
user_home: "/home/{{ username }}"
|
||||
|
||||
freebsd_packages_base:
|
||||
- bash
|
||||
- bash-completion
|
||||
- bat
|
||||
- curl
|
||||
- fzf
|
||||
- git
|
||||
- jq
|
||||
- python3
|
||||
- ripgrep
|
||||
- rsync
|
||||
- tmux
|
||||
- tree
|
||||
- unzip
|
||||
- vim
|
||||
- wget
|
||||
- zip
|
||||
- zoxide
|
||||
|
||||
freebsd_development_packages:
|
||||
- clang
|
||||
- cmake
|
||||
- gdb
|
||||
- go
|
||||
- gmake
|
||||
- node
|
||||
- npm
|
||||
- pkgconf
|
||||
- py311-pip
|
||||
- rust
|
||||
- shellcheck
|
||||
|
||||
freebsd_desktop_common_packages:
|
||||
- croc
|
||||
- dbus
|
||||
- dconf
|
||||
- firefox
|
||||
- fontconfig
|
||||
- gnome-keyring
|
||||
- libsecret
|
||||
- mpv
|
||||
- pinentry-gnome
|
||||
- pipewire
|
||||
- polkit
|
||||
- thunderbird
|
||||
- wireplumber
|
||||
- xdg-desktop-portal
|
||||
|
||||
freebsd_hyprland_packages:
|
||||
- alacritty
|
||||
- cliphist
|
||||
- dunst
|
||||
- foot
|
||||
- grim
|
||||
- hypridle
|
||||
- hyprland
|
||||
- hyprlock
|
||||
- hyprpaper
|
||||
- rofi-wayland
|
||||
- seatd
|
||||
- slurp
|
||||
- waybar
|
||||
- wl-clipboard
|
||||
- xdg-desktop-portal-gtk
|
||||
- xdg-desktop-portal-hyprland
|
||||
- xwayland
|
||||
|
||||
freebsd_niri_packages:
|
||||
- cliphist
|
||||
- dunst
|
||||
- foot
|
||||
- grim
|
||||
- niri
|
||||
- rofi-wayland
|
||||
- seatd
|
||||
- slurp
|
||||
- waybar
|
||||
- wl-clipboard
|
||||
- xdg-desktop-portal-gtk
|
||||
|
||||
freebsd_lab_packages:
|
||||
- lldb
|
||||
- llvm
|
||||
- podman
|
||||
- qemu
|
||||
- smartmontools
|
||||
|
||||
freebsd_rc_conf:
|
||||
- name: dbus_enable
|
||||
value: "YES"
|
||||
- name: seatd_enable
|
||||
value: "YES"
|
||||
|
||||
freebsd_enabled_services:
|
||||
- dbus
|
||||
- seatd
|
||||
|
||||
freebsd_xdg_config_dir: /usr/local/etc/xdg
|
||||
@@ -1,107 +0,0 @@
|
||||
---
|
||||
platform_profile: mint
|
||||
platform_family: debian
|
||||
platform_package_manager: apt
|
||||
platform_service_manager: systemd
|
||||
|
||||
# syncthing runs per-user via the packaged system unit template
|
||||
# (Debian/Mint ship syncthing@.service, parametrised by username).
|
||||
enabled_services:
|
||||
- "syncthing@{{ username }}.service"
|
||||
|
||||
mint_manage_flatpak: true
|
||||
mint_flatpak_remote_name: flathub
|
||||
mint_flatpak_remote_url: https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
mint_packages_base:
|
||||
- ca-certificates
|
||||
- curl
|
||||
- flatpak
|
||||
- gpg
|
||||
- htop
|
||||
- make
|
||||
- python3-pip
|
||||
- ripgrep
|
||||
- rsync
|
||||
- shellcheck
|
||||
|
||||
mint_development_packages:
|
||||
- build-essential
|
||||
- clang
|
||||
- clang-format
|
||||
- clang-tools
|
||||
- cmake
|
||||
- gdb
|
||||
- libasan8
|
||||
- libubsan1
|
||||
- golang
|
||||
- nodejs
|
||||
- npm
|
||||
- pkg-config
|
||||
- python3-venv
|
||||
- valgrind
|
||||
|
||||
mint_desktop_common_packages:
|
||||
- croc
|
||||
- dconf-cli
|
||||
- deja-dup
|
||||
- firefox
|
||||
- fonts-liberation
|
||||
- fonts-noto-color-emoji
|
||||
- git-delta
|
||||
- gnome-keyring
|
||||
- libsecret-tools
|
||||
- mpv
|
||||
- pinentry-gnome3
|
||||
- pinentry-gtk2
|
||||
- rclone
|
||||
- syncthing
|
||||
- thunderbird
|
||||
- tmux
|
||||
- xdg-user-dirs
|
||||
- yt-dlp
|
||||
|
||||
mint_emacs_packages:
|
||||
- autoconf
|
||||
- automake
|
||||
- emacs
|
||||
- hunspell
|
||||
- hunspell-it
|
||||
- libfreetype-dev
|
||||
- libpoppler-glib-dev
|
||||
- libpoppler-private-dev
|
||||
- libtool-bin
|
||||
- libvterm-dev
|
||||
- libx11-dev
|
||||
|
||||
mint_cinnamon_packages:
|
||||
- cinnamon
|
||||
- cinnamon-control-center
|
||||
- cinnamon-screensaver
|
||||
- lightdm
|
||||
- slick-greeter
|
||||
- xdg-desktop-portal
|
||||
- xdg-desktop-portal-gtk
|
||||
|
||||
mint_personal_workstation_packages:
|
||||
- libreoffice
|
||||
- meld
|
||||
- pdfarranger
|
||||
- vim
|
||||
|
||||
mint_flatpak_packages:
|
||||
- ch.protonmail.protonmail-bridge
|
||||
- com.calibre_ebook.calibre
|
||||
- com.spotify.Client
|
||||
- org.telegram.desktop
|
||||
|
||||
# Nerd Fonts referenced by the desktop configs deployed on Mint. Debian has no
|
||||
# equivalent of Void's nerd-fonts-ttf package, so they are fetched from the
|
||||
# upstream ryanoasis/nerd-fonts release.
|
||||
mint_nerd_fonts_version: v3.3.0
|
||||
mint_nerd_fonts:
|
||||
- Hack
|
||||
- JetBrainsMono
|
||||
- Iosevka
|
||||
- UbuntuSans
|
||||
- NerdFontsSymbolsOnly
|
||||
55
ansible/inventory/group_vars/platform_rocky.yml
Normal file
55
ansible/inventory/group_vars/platform_rocky.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
platform_profile: rocky
|
||||
platform_family: redhat
|
||||
platform_package_manager: dnf
|
||||
platform_service_manager: systemd
|
||||
|
||||
rocky_openzfs_release_rpm: https://zfsonlinux.org/epel/zfs-release-3-0.el9.noarch.rpm
|
||||
rocky_openzfs_gpg_key_url: >-
|
||||
https://raw.githubusercontent.com/zfsonlinux/zfsonlinux.github.com/master/zfs-release/RPM-GPG-KEY-openzfs-key2
|
||||
rocky_openzfs_gpg_key_fingerprint: 7DC7 299D CF7C 7FD9 CD87 701B A599 FD5E 9DB8 4141
|
||||
rocky_syncthing_version: 2.1.3
|
||||
rocky_syncthing_archive_checksum: sha256:f929eb8e5b72a85543eeeefb2c38f34a68e0c530e70758a2905b78840c76602c
|
||||
rocky_syncthing_archive_url: >-
|
||||
{{
|
||||
'https://github.com/syncthing/syncthing/releases/download/v'
|
||||
~ rocky_syncthing_version
|
||||
~ '/syncthing-linux-amd64-v'
|
||||
~ rocky_syncthing_version
|
||||
~ '.tar.gz'
|
||||
}}
|
||||
|
||||
rocky_manage_openzfs_repo: false
|
||||
rocky_manage_syncthing_binary: false
|
||||
rocky_manage_podman: false
|
||||
rocky_manage_github_cli_repo: false
|
||||
|
||||
rocky_github_cli_repo_url: https://cli.github.com/packages/rpm/gh-cli.repo
|
||||
rocky_github_cli_repo_file: /etc/yum.repos.d/gh-cli.repo
|
||||
|
||||
enabled_services:
|
||||
- firewalld
|
||||
|
||||
rocky_common_packages:
|
||||
- bash-completion
|
||||
- bat
|
||||
- fzf
|
||||
- git
|
||||
- jq
|
||||
- tree
|
||||
- unzip
|
||||
- vim-enhanced
|
||||
- wget
|
||||
- zip
|
||||
- zoxide
|
||||
|
||||
rocky_packages_base:
|
||||
- dnf-plugins-core
|
||||
- firewalld
|
||||
- openssh-server
|
||||
- rsync
|
||||
- selinux-policy-targeted
|
||||
- sudo
|
||||
|
||||
rocky_profile_packages: []
|
||||
rocky_podman_packages: []
|
||||
42
ansible/inventory/group_vars/rocky_server.yml
Normal file
42
ansible/inventory/group_vars/rocky_server.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
rocky_manage_podman: true
|
||||
rocky_manage_github_cli_repo: true
|
||||
|
||||
rocky_profile_packages:
|
||||
- avahi
|
||||
- curl
|
||||
- dmidecode
|
||||
- dosfstools
|
||||
- gcc
|
||||
- gcc-c++
|
||||
- git-delta
|
||||
- gh
|
||||
- gnupg2
|
||||
- htop
|
||||
- make
|
||||
- nmap-ncat
|
||||
- nodejs
|
||||
- parted
|
||||
- pciutils
|
||||
- pinentry
|
||||
- ranger
|
||||
- ripgrep
|
||||
|
||||
rocky_podman_packages:
|
||||
- podman
|
||||
- podman-compose
|
||||
|
||||
enabled_services:
|
||||
- firewalld
|
||||
- podman.socket
|
||||
|
||||
server_firewall_backend: firewalld
|
||||
server_firewalld_zone: public
|
||||
server_firewalld_services:
|
||||
- ssh
|
||||
- cockpit
|
||||
- http
|
||||
- https
|
||||
server_firewalld_ports: []
|
||||
server_sshd_service_name: sshd
|
||||
server_compose_selinux_mount_option: Z
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
machine_role: lab
|
||||
role_profile: lab
|
||||
role_stability: experimental
|
||||
experimental_profile: true
|
||||
|
||||
lab_workspace_directories:
|
||||
- path: "{{ user_home }}/Lab"
|
||||
mode: "0755"
|
||||
- path: "{{ user_home }}/Scratch"
|
||||
mode: "0755"
|
||||
@@ -13,12 +13,4 @@ personal_workstation_directories:
|
||||
- path: "{{ user_home }}/Remotes"
|
||||
mode: "0755"
|
||||
|
||||
personal_workstation_dotfiles:
|
||||
- src: .gitignore_global
|
||||
dest: .gitignore_global
|
||||
mode: "0644"
|
||||
- src: .themes.gitignore
|
||||
dest: .themes.gitignore
|
||||
mode: "0644"
|
||||
|
||||
personal_workstation_flatpak_packages: []
|
||||
|
||||
@@ -6,7 +6,7 @@ effective_username: "{{ server_username }}"
|
||||
effective_user_group: "{{ server_user_group }}"
|
||||
effective_user_home: "{{ server_user_home }}"
|
||||
server_container_stack_dir: /opt/docker/server
|
||||
ai_agents_enabled: false
|
||||
ai_agents: {}
|
||||
vim_plugins_enabled: false
|
||||
|
||||
profile_packages:
|
||||
@@ -25,6 +25,9 @@ profile_packages:
|
||||
- rsync
|
||||
|
||||
server_dotfiles:
|
||||
- src: .bashrc.d/20-editor-server.sh
|
||||
dest: .bashrc.d/20-editor-server.sh
|
||||
mode: "0644"
|
||||
- src: .gnupg/gpg-agent.conf
|
||||
dest: .gnupg/gpg-agent.conf
|
||||
mode: "0600"
|
||||
@@ -34,9 +37,6 @@ server_dotfiles:
|
||||
- src: .themes.gitignore
|
||||
dest: .themes.gitignore
|
||||
mode: "0644"
|
||||
- src: duckdns/
|
||||
dest: duckdns/
|
||||
mode: preserve
|
||||
|
||||
server_templates:
|
||||
- src: server/.gitconfig.j2
|
||||
@@ -50,6 +50,10 @@ server_templates:
|
||||
no_log: true
|
||||
|
||||
server_directories:
|
||||
- path: "{{ server_user_home }}/.bashrc.d"
|
||||
owner: "{{ server_username }}"
|
||||
group: "{{ server_user_group }}"
|
||||
mode: "0755"
|
||||
- path: "{{ server_user_home }}/.gnupg"
|
||||
owner: "{{ server_username }}"
|
||||
group: "{{ server_user_group }}"
|
||||
@@ -58,14 +62,6 @@ server_directories:
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
- path: /opt/navidrome/data
|
||||
owner: "{{ server_username }}"
|
||||
group: "{{ server_user_group }}"
|
||||
mode: "0755"
|
||||
- path: /opt/music
|
||||
owner: "{{ server_username }}"
|
||||
group: "{{ server_user_group }}"
|
||||
mode: "0755"
|
||||
- path: /opt/npm/data
|
||||
owner: root
|
||||
group: root
|
||||
@@ -74,53 +70,15 @@ server_directories:
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
- path: /opt/postgres/data
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
- path: /opt/gitea/data
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
- path: /opt/syncthing/config
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
- path: /srv/syncthing
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
- path: /srv/syncthing/data
|
||||
owner: "1000"
|
||||
group: "1000"
|
||||
mode: "0755"
|
||||
- path: /srv/nextcloud
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
- path: /srv/nextcloud/data
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
server_ufw_rules:
|
||||
- rule: allow
|
||||
name: OpenSSH
|
||||
- rule: allow
|
||||
port: "22000"
|
||||
proto: tcp
|
||||
comment: Syncthing sync traffic
|
||||
- rule: allow
|
||||
port: "22000"
|
||||
proto: udp
|
||||
comment: Syncthing QUIC sync traffic
|
||||
- rule: allow
|
||||
port: "21027"
|
||||
proto: udp
|
||||
comment: Syncthing local discovery
|
||||
|
||||
server_sshd_settings:
|
||||
PermitRootLogin: "no"
|
||||
|
||||
server_sshd_allow_users:
|
||||
- "{{ server_username }}"
|
||||
server_ssh_authorized_keys: []
|
||||
server_ssh_authorized_key_directory: "{{ server_user_home }}/.ssh/authorized_keys.d"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
ubuntu_packages_base:
|
||||
- curl
|
||||
- ufw
|
||||
- htop
|
||||
- build-essential
|
||||
- nodejs
|
||||
- git-delta
|
||||
- jq
|
||||
- ripgrep
|
||||
|
||||
ubuntu_manage_docker_repo: true
|
||||
|
||||
ubuntu_docker_packages:
|
||||
- docker-ce
|
||||
- docker-ce-cli
|
||||
- containerd.io
|
||||
- docker-buildx-plugin
|
||||
- docker-compose-plugin
|
||||
|
||||
enabled_services:
|
||||
- ufw
|
||||
- docker
|
||||
|
||||
ubuntu_dotfiles:
|
||||
- src: .bashrc.d/
|
||||
dest: .bashrc.d/
|
||||
mode: preserve
|
||||
@@ -1,8 +1,4 @@
|
||||
---
|
||||
void_xbps_repositories:
|
||||
- name: hyprland-void
|
||||
url: https://raw.githubusercontent.com/sofijacom/hyprland-void/repository-x86_64-glibc
|
||||
|
||||
void_packages_base:
|
||||
- 7zip
|
||||
- NetworkManager
|
||||
@@ -141,45 +137,6 @@ desktop_minimal_packages:
|
||||
- xfce-polkit
|
||||
- xournalpp
|
||||
|
||||
desktop_kde_packages:
|
||||
- ark
|
||||
- dolphin
|
||||
- ffmpegthumbs
|
||||
- gwenview
|
||||
- kate
|
||||
- kde-plasma
|
||||
- kleopatra
|
||||
- konsole
|
||||
- okular
|
||||
- polkit-kde-agent
|
||||
- print-manager
|
||||
- sddm
|
||||
- skanpage
|
||||
- spectacle
|
||||
|
||||
desktop_xfce_packages:
|
||||
- blueman
|
||||
- gnome-keyring
|
||||
- greybird-themes
|
||||
- gvfs
|
||||
- gvfs-cdda
|
||||
- gvfs-mtp
|
||||
- gvfs-smb
|
||||
- lightdm
|
||||
- lightdm-gtk-greeter
|
||||
- network-manager-applet
|
||||
- papirus-icon-theme
|
||||
- seahorse
|
||||
- thunar-archive-plugin
|
||||
- xarchiver
|
||||
- xdg-desktop-portal-gtk
|
||||
- xfce4
|
||||
- xfce4-clipman-plugin
|
||||
- xfce4-pulseaudio-plugin
|
||||
- xfce4-screenshooter
|
||||
- xfce4-whiskermenu-plugin
|
||||
- xorg
|
||||
|
||||
desktop_sway_packages:
|
||||
- cliphist
|
||||
- dunst
|
||||
@@ -201,27 +158,6 @@ desktop_sway_packages:
|
||||
- xdg-desktop-portal-wlr
|
||||
- xorg-server-xwayland
|
||||
|
||||
desktop_hyprland_packages:
|
||||
- cliphist
|
||||
- dunst
|
||||
- foot
|
||||
- foot-terminfo
|
||||
- grim
|
||||
- hypridle
|
||||
- hyprland
|
||||
- hyprland-guiutils
|
||||
- hyprlock
|
||||
- hyprpolkitagent
|
||||
- hyprpaper
|
||||
- hyprsysteminfo
|
||||
- rofi
|
||||
- slurp
|
||||
- SwayOSD
|
||||
- Waybar
|
||||
- wl-clipboard
|
||||
- xdg-desktop-portal-hyprland
|
||||
- xorg-server-xwayland
|
||||
|
||||
desktop_niri_packages:
|
||||
- cliphist
|
||||
- dunst
|
||||
@@ -238,12 +174,6 @@ desktop_niri_packages:
|
||||
- xdg-desktop-portal-gtk
|
||||
- xorg-server-xwayland
|
||||
|
||||
void_lab_packages:
|
||||
- llvm
|
||||
- podman
|
||||
- qemu
|
||||
- smartmontools
|
||||
|
||||
profile_packages:
|
||||
- bash-language-server
|
||||
- bluez
|
||||
@@ -253,6 +183,7 @@ profile_packages:
|
||||
- deluge-gtk
|
||||
- delve
|
||||
- dnsmasq
|
||||
- evolution
|
||||
- poppler-glib
|
||||
- poppler-utils
|
||||
- fontconfig-devel
|
||||
@@ -263,9 +194,7 @@ profile_packages:
|
||||
- hunspell
|
||||
- libvirt
|
||||
- libspa-bluetooth
|
||||
- libreoffice
|
||||
- liberation-fonts-ttf
|
||||
- libvterm-devel
|
||||
- libX11-devel
|
||||
- libXft-devel
|
||||
- meld
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
---
|
||||
workstation_manage_opencode: true
|
||||
workstation_manage_ibm_bob: true
|
||||
workstation_ibm_bob_install_url: "https://bob.ibm.com/download/bobshell.sh"
|
||||
workstation_npm_packages: "{{ ai_agents_npm_packages + [] }}"
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
---
|
||||
workstation_dev_packages:
|
||||
- distrobox
|
||||
- gnupg
|
||||
- gpg-agent
|
||||
- libfido2-1
|
||||
- openssh-client
|
||||
- pandoc
|
||||
- shellcheck
|
||||
emacs_enabled: true
|
||||
|
||||
workstation_user_directories:
|
||||
- path: "{{ user_home }}/.config"
|
||||
|
||||
@@ -3,27 +3,30 @@ enabled_services:
|
||||
- docker
|
||||
|
||||
workstation_dev_wsl_packages:
|
||||
- lazygit
|
||||
- buildah
|
||||
- helm
|
||||
# Fedora ships the kubectl client binary in the kubernetes-client package.
|
||||
- kubernetes-client
|
||||
- mise
|
||||
- pinentry-curses
|
||||
- python3-pip
|
||||
- tmux
|
||||
workstation_dev_wsl_excluded_packages:
|
||||
- alacritty
|
||||
- pcscd
|
||||
- pinentry-gnome3
|
||||
- podman
|
||||
- podman-compose
|
||||
- ufw
|
||||
- yubikey-manager
|
||||
|
||||
# Java 11, Java 25 and Maven are managed by Mise on this Fedora WSL profile.
|
||||
# Keep their versions pinned; update them deliberately.
|
||||
workstation_mise_java_version: temurin-11.0.31+11
|
||||
workstation_mise_java_25_version: 25.0.2
|
||||
workstation_mise_maven_version: 3.9.16
|
||||
|
||||
workstation_is_wsl: true
|
||||
workstation_manage_google_chrome: false
|
||||
workstation_manage_croc: true
|
||||
workstation_croc_version: v11.0.3
|
||||
workstation_removed_snap_packages: []
|
||||
workstation_snap_packages: []
|
||||
workstation_dev_wsl_python_packages: []
|
||||
workstation_wsl_systemd_enabled: true
|
||||
workstation_dev_wsl_dotfiles:
|
||||
- src: .bashrc.d/30-mise.sh
|
||||
dest: .bashrc.d/30-mise.sh
|
||||
mode: "0644"
|
||||
- src: .config/mise/
|
||||
dest: .config/mise/
|
||||
mode: preserve
|
||||
- src: .gnupg/gpg-agent.conf
|
||||
dest: .gnupg/gpg-agent.conf
|
||||
mode: "0600"
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
enabled_services:
|
||||
- docker
|
||||
|
||||
workstation_dev_wsl_packages:
|
||||
- lazygit
|
||||
- pinentry-curses
|
||||
- python3-pip
|
||||
workstation_dev_wsl_excluded_packages:
|
||||
- alacritty
|
||||
- pcscd
|
||||
- pinentry-gnome3
|
||||
- podman
|
||||
- podman-compose
|
||||
- ufw
|
||||
- yubikey-manager
|
||||
workstation_is_wsl: true
|
||||
workstation_manage_google_chrome: false
|
||||
<<<<<<< HEAD
|
||||
workstation_removed_snap_packages: []
|
||||
workstation_snap_packages:
|
||||
- name: glab
|
||||
classic: true
|
||||
workstation_dev_wsl_python_packages: []
|
||||
||||||| parent of cb83ed1 (Update gitconfig on WSL)
|
||||
workstation_removed_snap_packages: []
|
||||
workstation_snap_packages:
|
||||
- name: glab
|
||||
classic: true
|
||||
workstation_dev_wsl_python_packages:
|
||||
- pypsrp
|
||||
- pyspnego
|
||||
=======
|
||||
workstation_manage_glab: true
|
||||
workstation_glab_version: "1.103.0"
|
||||
workstation_removed_snap_packages:
|
||||
- glab
|
||||
workstation_snap_packages: []
|
||||
workstation_dev_wsl_python_packages:
|
||||
- pypsrp
|
||||
- pyspnego
|
||||
>>>>>>> cb83ed1 (Update gitconfig on WSL)
|
||||
workstation_wsl_systemd_enabled: true
|
||||
workstation_dev_wsl_dotfiles:
|
||||
- src: .gnupg/gpg-agent.conf
|
||||
dest: .gnupg/gpg-agent.conf
|
||||
mode: "0600"
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
workstation_host_linux_packages:
|
||||
- croc
|
||||
- gnome-shell-extension-manager
|
||||
- gnome-shell-extensions
|
||||
- gnome-tweaks
|
||||
- gufw
|
||||
- libreoffice
|
||||
- meld
|
||||
- network-manager-openconnect-gnome
|
||||
- pcscd
|
||||
- pinentry-gnome3
|
||||
- podman
|
||||
- podman-compose
|
||||
- yubikey-manager
|
||||
|
||||
workstation_host_linux_dotfiles:
|
||||
- src: .gnupg/gpg-agent.conf
|
||||
dest: .gnupg/gpg-agent.conf
|
||||
mode: "0600"
|
||||
|
||||
workstation_firewall_backend: ufw
|
||||
workstation_manage_google_chrome: true
|
||||
|
||||
workstation_removed_snap_packages:
|
||||
- firefox
|
||||
|
||||
workstation_snap_packages:
|
||||
- name: intellij-idea-ultimate
|
||||
classic: true
|
||||
channel: latest/stable
|
||||
- name: postman
|
||||
classic: false
|
||||
channel: latest/stable
|
||||
- name: thunderbird
|
||||
classic: false
|
||||
channel: latest/stable
|
||||
- name: code
|
||||
classic: true
|
||||
channel: latest/stable
|
||||
- name: xournalpp
|
||||
classic: false
|
||||
channel: latest/stable
|
||||
- name: pdfarranger
|
||||
classic: false
|
||||
channel: latest/stable
|
||||
- name: spotify
|
||||
classic: false
|
||||
channel: latest/stable
|
||||
- name: telegram-desktop
|
||||
classic: false
|
||||
channel: latest/stable
|
||||
- name: dbeaver-ce
|
||||
classic: true
|
||||
channel: latest/stable
|
||||
|
||||
workstation_gnome_extensions:
|
||||
- id: 9308
|
||||
uuid: bluetooth-battery-monitor@v8v88v8v88.com
|
||||
version_tag: 68559
|
||||
enabled: true
|
||||
- id: 1401
|
||||
uuid: bluetooth-quick-connect@bjarosze.gmail.com
|
||||
version_tag: 65323
|
||||
enabled: true
|
||||
- id: 6099
|
||||
uuid: paperwm@paperwm.github.com
|
||||
version_tag: 68525
|
||||
enabled: true
|
||||
|
||||
workstation_disabled_gnome_extensions:
|
||||
- tiling-assistant@ubuntu.com
|
||||
- ubuntu-dock@ubuntu.com
|
||||
46
ansible/inventory/host_vars/aegis.yml
Normal file
46
ansible/inventory/host_vars/aegis.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
ansible_host: aegis
|
||||
ansible_connection: ssh
|
||||
ansible_user: pi
|
||||
ansible_become: true
|
||||
ansible_python_interpreter: /usr/bin/python3
|
||||
# Avoid PTY framing around module JSON on this remote Fedora IoT host.
|
||||
ansible_ssh_use_tty: false
|
||||
|
||||
aegis_lan_subnet: 192.168.178.0/24
|
||||
aegis_adguard_web_port: 80
|
||||
aegis_network_connection_name: FRITZ!Box 7530 NR
|
||||
aegis_network_connection_uuid: a52fda3d-3eb6-351f-bf04-753edcb76944
|
||||
aegis_host_dns_servers:
|
||||
- 192.168.178.1
|
||||
aegis_host_dns_search_domains:
|
||||
- fritz.box
|
||||
|
||||
# Aegis is the internal WireGuard endpoint and NAT gateway for the LAN.
|
||||
wireguard_overlay_enabled: true
|
||||
wireguard_address: 10.0.0.2/24
|
||||
wireguard_enable_ipv4_forwarding: true
|
||||
wireguard_reload_rootful_podman_networks: true
|
||||
wireguard_forwarding_policies:
|
||||
- name: aegis-wireguard-to-lan
|
||||
ingress_zone: wireguard
|
||||
egress_zone: public
|
||||
source: 10.0.0.0/24
|
||||
destination: "{{ aegis_lan_subnet }}"
|
||||
masquerade: true
|
||||
wireguard_peers:
|
||||
- name: prometheus
|
||||
host: prometheus
|
||||
endpoint: "{{ hostvars['prometheus']['ansible_host'] }}:{{ hostvars['prometheus']['wireguard_listen_port'] }}"
|
||||
allowed_ips:
|
||||
- 10.0.0.0/24
|
||||
persistent_keepalive: 25
|
||||
aegis_ssh_authorized_keys:
|
||||
- name: ikaros
|
||||
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros"
|
||||
- name: nymph
|
||||
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEH/7GJfGt0ZVmKeEzceoFkFkeCXFryKK9vAbaip+HCx nymph"
|
||||
- name: siren
|
||||
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA95wYlzpfN3rjUhpMeP4KHn8I6ZrjQXoDTgwgRIa++b siren"
|
||||
|
||||
aegis_icloudpd_apple_id: "{{ vault_aegis_icloudpd_apple_id | default('') }}"
|
||||
202
ansible/inventory/host_vars/atlas.yml
Normal file
202
ansible/inventory/host_vars/atlas.yml
Normal file
@@ -0,0 +1,202 @@
|
||||
---
|
||||
# Atlas declares its intended steady state; set a feature flag false only for a deliberate suspension.
|
||||
hostname: atlas
|
||||
ansible_host: 192.168.178.55
|
||||
ansible_user: "{{ atlas_connection_username }}"
|
||||
ansible_port: 22
|
||||
ansible_ssh_private_key_file: /home/fscotto/.ssh/id_ed25519
|
||||
|
||||
atlas_admin_username: admin
|
||||
atlas_connection_username: "{{ atlas_admin_username }}"
|
||||
atlas_admin_group: "{{ atlas_admin_username }}"
|
||||
atlas_admin_home: "/home/{{ atlas_admin_username }}"
|
||||
atlas_admin_uid: 1000
|
||||
atlas_admin_gid: 1000
|
||||
atlas_admin_ssh_keys:
|
||||
- name: ikaros
|
||||
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros"
|
||||
- name: nymph
|
||||
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEH/7GJfGt0ZVmKeEzceoFkFkeCXFryKK9vAbaip+HCx nymph"
|
||||
- name: siren
|
||||
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA95wYlzpfN3rjUhpMeP4KHn8I6ZrjQXoDTgwgRIa++b siren"
|
||||
atlas_admin_password_hash: "{{ vault_atlas_admin_password_hash | default('!') }}"
|
||||
atlas_ssh_allow_tcp_forwarding: local
|
||||
|
||||
atlas_lan_subnet: 192.168.178.0/24
|
||||
atlas_aegis_ip: 192.168.178.54
|
||||
atlas_manage_firewall: true
|
||||
atlas_firewalld_zone: public
|
||||
atlas_zfs_pool: zpool
|
||||
# Populate only for the first pool bootstrap with four real persistent disk paths.
|
||||
# Confirmed empty 4 TB IronWolf data disks; the NVMe system disk is intentionally excluded.
|
||||
atlas_zpool_disks:
|
||||
- /dev/disk/by-id/ata-ST4000VN006-3CW104_WW6AC1XM
|
||||
- /dev/disk/by-id/ata-ST4000VN006-3CW104_WW6A73T2
|
||||
- /dev/disk/by-id/ata-ST4000VN006-3CW104_WW6A6VJK
|
||||
- /dev/disk/by-id/ata-ST4000VN006-3CW104_WW6AC1LM
|
||||
atlas_create_pool: false
|
||||
atlas_zfs_dataset_archive: archive
|
||||
atlas_zfs_dataset_services: services
|
||||
atlas_zfs_dataset_app_data: services/data
|
||||
atlas_zfs_dataset_navidrome: services/data/navidrome
|
||||
atlas_zfs_dataset_syncthing: services/data/syncthing
|
||||
atlas_zfs_dataset_media: media
|
||||
atlas_zfs_dataset_music: media/music
|
||||
atlas_zfs_dataset_backup: backup
|
||||
atlas_zfs_dataset_host_backups: backup/hosts
|
||||
atlas_zfs_dataset_backup_prometheus: backup/hosts/prometheus
|
||||
atlas_zfs_backup_reservation: 500G
|
||||
atlas_zfs_dataset_photobook: media/photobook
|
||||
atlas_mount_root: /zpool
|
||||
atlas_manage_storage: true
|
||||
atlas_manage_zfs_snapshots: true
|
||||
atlas_zfs_snapshot_prefix: atlas-auto
|
||||
atlas_zfs_snapshot_policies:
|
||||
- name: hourly
|
||||
calendar: "*-*-* *:05:00"
|
||||
keep: 24
|
||||
- name: daily
|
||||
calendar: "*-*-* 00:15:00"
|
||||
keep: 30
|
||||
- name: weekly
|
||||
calendar: "Sun *-*-* 01:00:00"
|
||||
keep: 8
|
||||
- name: monthly
|
||||
calendar: "*-*-01 02:00:00"
|
||||
keep: 12
|
||||
atlas_manage_zfs_scrub: true
|
||||
atlas_zfs_scrub_calendar: "Sun *-*-01..07 03:00:00"
|
||||
atlas_manage_borg_backup: true
|
||||
atlas_borg_repository_host: u660064-sub1.your-storagebox.de
|
||||
atlas_borg_repository_user: u660064-sub1
|
||||
atlas_borg_repository_port: 23
|
||||
atlas_borg_repository_path: ./borg-data
|
||||
atlas_borg_remote_path: borg-1.4
|
||||
# Verified against Hetzner's published ED25519 fingerprint on 2026-09-17:
|
||||
# SHA256:XqONwb1S0zuj5A1CDxpOSuD2hnAArV1A3wKY7Z3sdgM
|
||||
atlas_borg_host_key: >-
|
||||
[u660064-sub1.your-storagebox.de]:23 ssh-ed25519
|
||||
AAAAC3NzaC1lZDI1NTE5AAAAIICf9svRenC/PLKIL9nk6K/pxQgoiFC41wTNvoIncOxs
|
||||
atlas_borg_backup_calendar: "*-*-* 04:30:00"
|
||||
atlas_borg_check_calendar: "*-*-15 06:00:00"
|
||||
atlas_borg_randomized_delay: 30m
|
||||
atlas_borg_keep_daily: 30
|
||||
atlas_borg_keep_weekly: 8
|
||||
atlas_borg_keep_monthly: 12
|
||||
atlas_manage_usb_backup: true
|
||||
# Read-only lsblk verification on Atlas, 2026-09-23. Never store the LUKS password here.
|
||||
atlas_usb_backup_luks_uuid: 577b3c43-ea37-4611-81a9-39d555cdfbd4
|
||||
atlas_usb_backup_fs_uuid: 758e2d2e-a427-4797-aad9-39c3a9f17c7e
|
||||
atlas_usb_backup_mapper_name: zpool-backup
|
||||
atlas_manage_usb_reminder: true
|
||||
atlas_usb_reminder_calendar: "Sat *-*-01..07 10:00:00 Europe/Rome"
|
||||
atlas_manage_monitoring: true
|
||||
# Physical pool disks and the system NVMe; the disconnected USB disk is intentionally excluded.
|
||||
atlas_monitor_smart_devices:
|
||||
- { name: pool-1, path: "{{ atlas_zpool_disks[0] }}", warning_c: 50, critical_c: 55 }
|
||||
- { name: pool-2, path: "{{ atlas_zpool_disks[1] }}", warning_c: 50, critical_c: 55 }
|
||||
- { name: pool-3, path: "{{ atlas_zpool_disks[2] }}", warning_c: 50, critical_c: 55 }
|
||||
- { name: pool-4, path: "{{ atlas_zpool_disks[3] }}", warning_c: 50, critical_c: 55 }
|
||||
- name: system-nvme
|
||||
path: /dev/disk/by-id/nvme-Patriot_M.2_P320_256GB_P320ADB26011606111
|
||||
warning_c: 70
|
||||
critical_c: 85
|
||||
atlas_monitor_timers:
|
||||
- { name: atlas-zfs-snapshot-hourly.timer, max_age_hours: 3 }
|
||||
- { name: atlas-zfs-snapshot-daily.timer, max_age_hours: 36 }
|
||||
- { name: atlas-zfs-snapshot-weekly.timer, max_age_hours: 216 }
|
||||
- { name: atlas-zfs-snapshot-monthly.timer, max_age_hours: 960 }
|
||||
- { name: zfs-scrub-monthly@zpool.timer, max_age_hours: 960 }
|
||||
- { name: atlas-borg-backup.timer, max_age_hours: 48 }
|
||||
- { name: atlas-borg-check.timer, max_age_hours: 960 }
|
||||
# The first manual USB reminder is not due until October; activation is checked, not age.
|
||||
- { name: atlas-usb-reminder.timer, max_age_hours: 0 }
|
||||
atlas_monitor_failure_units:
|
||||
- atlas-zfs-snapshot@.service
|
||||
- zfs-scrub@zpool.service
|
||||
- atlas-borg-backup.service
|
||||
- atlas-borg-check.service
|
||||
- atlas-usb-backup.service
|
||||
- atlas-usb-reminder.service
|
||||
- atlas-health-monitor.service
|
||||
atlas_monitor_remote_capacity:
|
||||
user: "{{ atlas_borg_repository_user }}"
|
||||
host: "{{ atlas_borg_repository_host }}"
|
||||
run_as: "{{ atlas_borg_username }}"
|
||||
ssh_wrapper: "{{ atlas_borg_ssh_wrapper_path }}"
|
||||
warning_percent: 80
|
||||
critical_percent: 90
|
||||
growth_warning_gib_day: 500
|
||||
atlas_manage_sharing: true
|
||||
atlas_manage_media_stack: false
|
||||
# Planned after data-protection validation: move iCloudPD photo ingestion from
|
||||
# Aegis to Atlas, with photos under /zpool/archive/Pictures and persistent
|
||||
# application/MFA state outside Archive. Do not deploy or cut over yet.
|
||||
|
||||
# WireGuard is retired on Atlas. These rootless services are a temporary home
|
||||
# until Uranus replaces them.
|
||||
backend_phase1_enabled: true
|
||||
backend_phase1_start_services: true
|
||||
backend_phase1_bind_address: "{{ ansible_host }}"
|
||||
backend_phase1_firewalld_zone: "{{ atlas_firewalld_zone }}"
|
||||
backend_phase1_npm_source_ip: "{{ atlas_aegis_ip }}"
|
||||
backend_phase1_syncthing_native_subnet: "{{ atlas_lan_subnet }}"
|
||||
|
||||
rocky_manage_openzfs_repo: true
|
||||
rocky_manage_syncthing_binary: false
|
||||
rocky_manage_podman: true
|
||||
rocky_podman_packages:
|
||||
- podman
|
||||
|
||||
host_packages:
|
||||
- cockpit
|
||||
- cockpit-podman
|
||||
- cockpit-storaged
|
||||
- realmd
|
||||
- pcp
|
||||
- python3-pcp
|
||||
- cryptsetup
|
||||
- nfs-utils
|
||||
- policycoreutils
|
||||
- policycoreutils-python-utils
|
||||
- python3-libselinux
|
||||
- setroubleshoot-server
|
||||
- samba
|
||||
- samba-client
|
||||
- samba-common-tools
|
||||
- borgbackup
|
||||
- zfs
|
||||
|
||||
atlas_nfs_exports:
|
||||
- path: "{{ atlas_photobook_mountpoint }}"
|
||||
client: "{{ atlas_aegis_ip }}"
|
||||
options:
|
||||
- rw
|
||||
- sync
|
||||
- no_subtree_check
|
||||
- all_squash
|
||||
- "anonuid={{ atlas_immich_uid }}"
|
||||
- "anongid={{ atlas_immich_gid }}"
|
||||
|
||||
atlas_samba_share_name: Archive
|
||||
atlas_samba_workgroup: WORKGROUP
|
||||
atlas_samba_encryption: required
|
||||
# Append any pre-existing laptop Unix account here and keep its Samba password in Vault.
|
||||
atlas_samba_accounts:
|
||||
- username: "{{ atlas_admin_username }}"
|
||||
password: "{{ vault_atlas_samba_password | default('') }}"
|
||||
atlas_samba_valid_users: "{{ atlas_samba_accounts | map(attribute='username') | list }}"
|
||||
|
||||
atlas_immich_db_password: "{{ vault_atlas_immich_db_password | default('') }}"
|
||||
|
||||
atlas_firewalld_rich_rules:
|
||||
- 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" port port="22" protocol="tcp" accept'
|
||||
- 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" port port="9090" protocol="tcp" accept'
|
||||
- 'rule family="ipv4" source address="{{ atlas_aegis_ip }}" port port="2049" protocol="tcp" accept'
|
||||
- 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" port port="445" protocol="tcp" accept'
|
||||
|
||||
host_enabled_services:
|
||||
- sshd
|
||||
- cockpit.socket
|
||||
- pmlogger.service
|
||||
- zfs.target
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
hostname: deadalus-wsl
|
||||
|
||||
host_packages: []
|
||||
host_enabled_services: []
|
||||
13
ansible/inventory/host_vars/deadalus.yml
Normal file
13
ansible/inventory/host_vars/deadalus.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
hostname: deadalus
|
||||
workstation_wsl_windows_user_home: /mnt/c/Users/FABIOSCOTTODISANTOLO
|
||||
|
||||
host_packages: []
|
||||
host_enabled_services: []
|
||||
|
||||
# Fedora WSL must not receive alternative application runtimes.
|
||||
fedora_excluded_packages:
|
||||
- flatpak
|
||||
fedora_removed_packages:
|
||||
- flatpak
|
||||
- snapd
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
hostname: ikaros
|
||||
|
||||
codex_relay_install_enabled: true
|
||||
chatgpt_desktop_enabled: true
|
||||
|
||||
host_packages:
|
||||
- openssh-clients
|
||||
- openssh-server
|
||||
@@ -229,19 +232,19 @@ gnome_gsettings_settings:
|
||||
- schema: org.gnome.shell
|
||||
key: app-picker-layout
|
||||
value: >-
|
||||
[{'org.fedoraproject.MediaWriter.desktop': <{'position': <0>}>, 'org.gnome.Contacts.desktop': <{'position': <1>}>, 'org.gnome.Weather.desktop': <{'position': <2>}>, 'org.gnome.clocks.desktop': <{'position': <3>}>, 'org.gnome.Maps.desktop': <{'position': <4>}>, 'org.gnome.SimpleScan.desktop': <{'position': <5>}>, 'org.gnome.Settings.desktop': <{'position': <6>}>, 'org.gnome.Boxes.desktop': <{'position': <7>}>, 'org.gnome.Showtime.desktop': <{'position': <8>}>, 'org.gnome.Snapshot.desktop': <{'position': <9>}>, 'org.gnome.Characters.desktop': <{'position': <10>}>, 'Utilities': <{'position': <11>}>, 'System': <{'position': <12>}>, 'org.gnome.Tour.desktop': <{'position': <13>}>, 'org.gnome.Yelp.desktop': <{'position': <14>}>, 'htop.desktop': <{'position': <15>}>, 'libreoffice-writer.desktop': <{'position': <16>}>, 'libreoffice-calc.desktop': <{'position': <17>}>, 'libreoffice-impress.desktop': <{'position': <18>}>, 'libreoffice-draw.desktop': <{'position': <19>}>, 'libreoffice-math.desktop': <{'position': <20>}>, 'libreoffice-base.desktop': <{'position': <21>}>, 'org.gnome.Meld.desktop': <{'position': <22>}>, 'mpv.desktop': <{'position': <23>}>}, {'com.github.jeromerobert.pdfarranger.desktop': <{'position': <0>}>, 'org.remmina.Remmina.desktop': <{'position': <1>}>, 'ch.protonmail.protonmail-bridge.desktop': <{'position': <2>}>, 'org.gnome.TextEditor.desktop': <{'position': <3>}>, 'org.gnome.Software.desktop': <{'position': <4>}>, 'org.gnome.Calculator.desktop': <{'position': <5>}>, 'org.gnome.Calendar.desktop': <{'position': <6>}>, 'com.mattjakeman.ExtensionManager.desktop': <{'position': <7>}>, 'be.alexandervanhee.gradia.desktop': <{'position': <8>}>, 'syncthing-start.desktop': <{'position': <9>}>, 'syncthing-ui.desktop': <{'position': <10>}>}]
|
||||
[{'System': <{'position': <0>}>, 'Utilities': <{'position': <1>}>, 'org.fedoraproject.MediaWriter.desktop': <{'position': <2>}>, 'org.gnome.Contacts.desktop': <{'position': <3>}>, 'org.gnome.Calendar.desktop': <{'position': <4>}>, 'org.gnome.Calculator.desktop': <{'position': <5>}>, 'org.gnome.Weather.desktop': <{'position': <6>}>, 'org.gnome.clocks.desktop': <{'position': <7>}>, 'org.gnome.Maps.desktop': <{'position': <8>}>, 'org.gnome.SimpleScan.desktop': <{'position': <9>}>, 'org.gnome.TextEditor.desktop': <{'position': <10>}>, 'org.gnome.Settings.desktop': <{'position': <11>}>, 'org.gnome.Boxes.desktop': <{'position': <12>}>, 'org.gnome.Showtime.desktop': <{'position': <13>}>, 'org.gnome.Snapshot.desktop': <{'position': <14>}>, 'org.gnome.Characters.desktop': <{'position': <15>}>, 'org.gnome.Tour.desktop': <{'position': <16>}>, 'org.gnome.Yelp.desktop': <{'position': <17>}>, 'org.gnome.Software.desktop': <{'position': <18>}>, 'be.alexandervanhee.gradia.desktop': <{'position': <19>}>, 'com.github.jeromerobert.pdfarranger.desktop': <{'position': <20>}>, 'org.onlyoffice.desktopeditors.desktop': <{'position': <21>}>, 'org.gnome.Meld.desktop': <{'position': <22>}>, 'com.mattjakeman.ExtensionManager.desktop': <{'position': <23>}>}, {'org.remmina.Remmina.desktop': <{'position': <0>}>, 'ch.protonmail.protonmail-bridge.desktop': <{'position': <1>}>, 'syncthing-start.desktop': <{'position': <2>}>, 'syncthing-ui.desktop': <{'position': <3>}>, 'emacs.desktop': <{'position': <4>}>, 'emacs-client.desktop': <{'position': <5>}>, 'org.gnome.gitlab.somas.Apostrophe.desktop': <{'position': <6>}>, 'mpv.desktop': <{'position': <7>}>, 'htop.desktop': <{'position': <8>}>, 'com.raspberrypi.rpi-imager.desktop': <{'position': <9>}>}]
|
||||
- schema: org.gnome.shell
|
||||
key: disabled-extensions
|
||||
value: >-
|
||||
['window-list@gnome-shell-extensions.gcampax.github.com', 'background-logo@fedorahosted.org', 'apps-menu@gnome-shell-extensions.gcampax.github.com', 'places-menu@gnome-shell-extensions.gcampax.github.com', 'maximize-new-windows@VitalyOstanin']
|
||||
['window-list@gnome-shell-extensions.gcampax.github.com', 'background-logo@fedorahosted.org', 'apps-menu@gnome-shell-extensions.gcampax.github.com', 'places-menu@gnome-shell-extensions.gcampax.github.com']
|
||||
- schema: org.gnome.shell
|
||||
key: enabled-extensions
|
||||
value: >-
|
||||
['status-icons@gnome-shell-extensions.gcampax.github.com', 'gnome-ui-tune@itstime.tech', 'appindicatorsupport@rgcjonas.gmail.com', 'bluetooth-battery-monitor@v8v88v8v88.com', 'clipboard-indicator@tudmotu.com', 'status-area-horizontal-spacing@mathematical.coffee.gmail.com', 'MaximizeWindowIntoNewWorkspace@kyleross.com']
|
||||
['status-icons@gnome-shell-extensions.gcampax.github.com', 'gnome-ui-tune@itstime.tech', 'appindicatorsupport@rgcjonas.gmail.com', 'bluetooth-battery-monitor@v8v88v8v88.com', 'clipboard-indicator@tudmotu.com', 'status-area-horizontal-spacing@mathematical.coffee.gmail.com', 'MaximizeWindowIntoNewWorkspace@kyleross.com', 'maximize-new-windows@VitalyOstanin']
|
||||
- schema: org.gnome.shell
|
||||
key: favorite-apps
|
||||
value: >-
|
||||
['org.gnome.Nautilus.desktop', 'org.mozilla.firefox.desktop', 'com.spotify.Client.desktop', 'org.mozilla.thunderbird.desktop', 'org.telegram.desktop.desktop', 'org.gnome.Ptyxis.desktop', 'virt-manager.desktop']
|
||||
['org.gnome.Nautilus.desktop', 'org.mozilla.firefox.desktop', 'com.spotify.Client.desktop', 'org.gnome.Evolution.desktop', 'org.telegram.desktop.desktop', 'chatgpt.desktop', 'org.gnome.Ptyxis.desktop', 'virt-manager.desktop']
|
||||
- schema: org.gnome.shell
|
||||
key: remember-mount-password
|
||||
value: "true"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
hostname: nymph
|
||||
|
||||
chatgpt_desktop_enabled: true
|
||||
|
||||
# Fedora Workstation/GNOME laptop target. GNOME is provided by the base Fedora
|
||||
# Workstation install; Ansible deploys shared desktop dotfiles but leaves GNOME
|
||||
# settings unmanaged for now.
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
---
|
||||
hostname: prometheus
|
||||
ansible_host: 179.237.102.172
|
||||
ansible_user: rocky
|
||||
ansible_port: 22
|
||||
ansible_ssh_private_key_file: /home/fscotto/.ssh/id_ed25519
|
||||
|
||||
host_packages: []
|
||||
host_enabled_services: []
|
||||
server_username: rocky
|
||||
server_duckdns_domain: fscotto
|
||||
server_ssh_authorized_keys:
|
||||
- name: ikaros
|
||||
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros"
|
||||
- name: nymph
|
||||
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEH/7GJfGt0ZVmKeEzceoFkFkeCXFryKK9vAbaip+HCx nymph"
|
||||
- name: siren
|
||||
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA95wYlzpfN3rjUhpMeP4KHn8I6ZrjQXoDTgwgRIa++b siren"
|
||||
|
||||
wireguard_overlay_enabled: true
|
||||
wireguard_address: 10.0.0.1/24
|
||||
wireguard_listen_port: 51820
|
||||
wireguard_enable_ipv4_forwarding: true
|
||||
wireguard_reload_rootful_podman_networks: true
|
||||
wireguard_peers:
|
||||
- name: aegis
|
||||
host: aegis
|
||||
# TODO: Aggiungere VIP di Uranus in futuro
|
||||
allowed_ips:
|
||||
- 10.0.0.2/32
|
||||
- 192.168.178.0/24
|
||||
|
||||
host_packages:
|
||||
- cockpit
|
||||
- cockpit-navigator
|
||||
- cockpit-podman
|
||||
- cockpit-storaged
|
||||
- realmd
|
||||
- pcp
|
||||
- python3-pcp
|
||||
- setroubleshoot-server
|
||||
|
||||
host_enabled_services:
|
||||
- cockpit.socket
|
||||
- pmlogger.service
|
||||
|
||||
@@ -10,12 +10,18 @@ all:
|
||||
ansible_connection: local
|
||||
nymph:
|
||||
ansible_connection: local
|
||||
deadalus:
|
||||
ansible_connection: local
|
||||
|
||||
platform_mint:
|
||||
hosts: {}
|
||||
platform_fedora_iot:
|
||||
hosts:
|
||||
aegis:
|
||||
|
||||
platform_freebsd:
|
||||
hosts: {}
|
||||
platform_rocky:
|
||||
hosts:
|
||||
atlas:
|
||||
children:
|
||||
rocky_server:
|
||||
|
||||
void:
|
||||
children:
|
||||
@@ -24,6 +30,11 @@ all:
|
||||
fedora:
|
||||
children:
|
||||
platform_fedora:
|
||||
platform_fedora_iot:
|
||||
|
||||
rocky:
|
||||
children:
|
||||
platform_rocky:
|
||||
|
||||
graphical_desktop:
|
||||
hosts:
|
||||
@@ -41,15 +52,13 @@ all:
|
||||
ikaros:
|
||||
ansible_connection: local
|
||||
|
||||
role_lab:
|
||||
hosts: {}
|
||||
role_aegis:
|
||||
hosts:
|
||||
aegis:
|
||||
|
||||
desktop_sway:
|
||||
hosts: {}
|
||||
|
||||
desktop_hyprland:
|
||||
hosts: {}
|
||||
|
||||
desktop_niri:
|
||||
hosts: {}
|
||||
|
||||
@@ -60,23 +69,10 @@ all:
|
||||
nymph:
|
||||
ansible_connection: local
|
||||
|
||||
desktop_cinnamon:
|
||||
hosts: {}
|
||||
|
||||
ubuntu:
|
||||
children:
|
||||
workstation_dev_wsl:
|
||||
ubuntu_server:
|
||||
|
||||
workstation:
|
||||
children:
|
||||
workstation_host:
|
||||
workstation_dev:
|
||||
|
||||
workstation_host:
|
||||
children:
|
||||
workstation_host_linux:
|
||||
|
||||
workstation_dev:
|
||||
children:
|
||||
workstation_dev_fedora:
|
||||
@@ -84,23 +80,27 @@ all:
|
||||
|
||||
server:
|
||||
children:
|
||||
ubuntu_server:
|
||||
|
||||
fedora_workstation:
|
||||
hosts: {}
|
||||
|
||||
workstation_host_linux:
|
||||
hosts: {}
|
||||
rocky_server:
|
||||
|
||||
workstation_dev_fedora:
|
||||
hosts: {}
|
||||
hosts:
|
||||
deadalus:
|
||||
ansible_connection: local
|
||||
|
||||
workstation_dev_wsl:
|
||||
hosts:
|
||||
deadalus-wsl:
|
||||
deadalus:
|
||||
ansible_connection: local
|
||||
|
||||
ubuntu_server:
|
||||
rocky_server:
|
||||
hosts:
|
||||
prometheus:
|
||||
ansible_connection: local
|
||||
|
||||
wireguard_overlay:
|
||||
hosts:
|
||||
prometheus:
|
||||
aegis:
|
||||
|
||||
role_backend_phase1:
|
||||
hosts:
|
||||
atlas:
|
||||
|
||||
@@ -23,8 +23,52 @@
|
||||
dest: "{{ effective_user_home }}/Templates"
|
||||
owner: "{{ effective_username }}"
|
||||
group: "{{ effective_user_group }}"
|
||||
creates: "{{ effective_user_home }}/Templates/code/main.c"
|
||||
when: "'desktop' in group_names"
|
||||
|
||||
- name: Ensure Emacs authoring directories exist
|
||||
tags: [dotfiles, dotfiles:common, emacs]
|
||||
ansible.builtin.file:
|
||||
path: "{{ effective_user_home }}/{{ item }}"
|
||||
state: directory
|
||||
owner: "{{ effective_username }}"
|
||||
group: "{{ effective_user_group }}"
|
||||
mode: "0755"
|
||||
loop: "{{ emacs_authoring_directories | default([]) }}"
|
||||
when:
|
||||
- emacs_enabled | default(false) | bool
|
||||
- not (workstation_is_wsl | default(false) | bool)
|
||||
|
||||
- name: Deploy the shared Emacs authoring configuration
|
||||
tags: [dotfiles, dotfiles:common, emacs]
|
||||
ansible.builtin.copy:
|
||||
src: "{{ playbook_dir }}/../dotfiles/desktop/{{ item.src }}"
|
||||
dest: "{{ effective_user_home }}/{{ item.dest }}"
|
||||
owner: "{{ effective_username }}"
|
||||
group: "{{ effective_user_group }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop: "{{ emacs_dotfiles | default([]) }}"
|
||||
loop_control:
|
||||
label: "{{ item.dest }}"
|
||||
when:
|
||||
- emacs_enabled | default(false) | bool
|
||||
- not (workstation_is_wsl | default(false) | bool)
|
||||
|
||||
- name: Deploy Emacs authoring assets
|
||||
tags: [dotfiles, dotfiles:common, emacs]
|
||||
ansible.builtin.copy:
|
||||
src: "{{ playbook_dir }}/../dotfiles/desktop/{{ item.src }}"
|
||||
dest: "{{ effective_user_home }}/{{ item.dest }}"
|
||||
owner: "{{ effective_username }}"
|
||||
group: "{{ effective_user_group }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop: "{{ emacs_authoring_files | default([]) }}"
|
||||
loop_control:
|
||||
label: "{{ item.dest }}"
|
||||
when:
|
||||
- emacs_enabled | default(false) | bool
|
||||
- not (workstation_is_wsl | default(false) | bool)
|
||||
|
||||
- name: Ensure SSH socket directory exists
|
||||
tags: [dotfiles, dotfiles:common]
|
||||
ansible.builtin.file:
|
||||
@@ -43,13 +87,25 @@
|
||||
group: "{{ effective_user_group }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop: >-
|
||||
{{
|
||||
(common_dotfiles | default([]))
|
||||
+ ((ai_agents_dotfiles | default([])) if (ai_agents_enabled | default(false)) else [])
|
||||
}}
|
||||
{{ common_dotfiles | default([]) }}
|
||||
loop_control:
|
||||
label: "{{ item.dest }}"
|
||||
|
||||
- name: Deploy AI coding-agent dotfiles
|
||||
tags: [dotfiles, dotfiles:common, ai_agents]
|
||||
ansible.builtin.copy:
|
||||
src: "{{ playbook_dir }}/../dotfiles/common/{{ item.src }}"
|
||||
dest: "{{ effective_user_home }}/{{ item.dest }}"
|
||||
owner: "{{ effective_username }}"
|
||||
group: "{{ effective_user_group }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop: "{{ ai_agents_dotfiles | default([]) }}"
|
||||
loop_control:
|
||||
label: "{{ item.dest }}"
|
||||
when:
|
||||
- (item.agents | intersect(ai_agents_deploy_enabled_names)) | length > 0
|
||||
- (ai_agents_dotfiles | default([])) | length > 0
|
||||
|
||||
- name: Install distro packages for Vim plugins
|
||||
tags: [packages, vim, fzf]
|
||||
ansible.builtin.package:
|
||||
@@ -98,7 +154,7 @@
|
||||
- (vim_plugin_source_plugins | default([])) | length > 0
|
||||
|
||||
- name: Ensure AI config directories exist
|
||||
tags: [dotfiles, dotfiles:common]
|
||||
tags: [dotfiles, dotfiles:common, ai_agents]
|
||||
ansible.builtin.file:
|
||||
path: "{{ effective_user_home }}/{{ item }}"
|
||||
state: directory
|
||||
@@ -108,11 +164,11 @@
|
||||
loop:
|
||||
- .codex
|
||||
when:
|
||||
- ai_agents_enabled | default(false)
|
||||
- "'codex' in ai_agents_deploy_enabled_names"
|
||||
- (ai_agents_templates | default([])) | length > 0
|
||||
|
||||
- name: Render AI agent templates
|
||||
tags: [dotfiles, dotfiles:common]
|
||||
- name: Render AI coding-agent templates
|
||||
tags: [dotfiles, dotfiles:common, ai_agents]
|
||||
ansible.builtin.template:
|
||||
src: "{{ playbook_dir }}/../dotfiles/common/{{ item.src }}"
|
||||
dest: "{{ effective_user_home }}/{{ item.dest }}"
|
||||
@@ -122,7 +178,9 @@
|
||||
loop: "{{ ai_agents_templates | default([]) }}"
|
||||
loop_control:
|
||||
label: "{{ item.dest }}"
|
||||
when: ai_agents_enabled | default(false)
|
||||
when:
|
||||
- (item.agents | intersect(ai_agents_deploy_enabled_names)) | length > 0
|
||||
- (ai_agents_templates | default([])) | length > 0
|
||||
|
||||
- name: Refresh bat cache
|
||||
tags: [dotfiles, dotfiles:common]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQINBGpypFUBEACi1Vvzq9pIpA6lj7chbqELuxJtVuzUzxrasa6ZU0yF4yhq7jf8
|
||||
3YkJRHwbezBKeQyzJ5lkX0EhXS8aXxUhMAm3PFpAlwcInfKzmV7atJwvaxIw6Rmd
|
||||
GYe9fBWKjTN/SmPIjtyxrTznZY97+TfD1AeGZpLaJ8fsnhrC+HkiN2TACiTocgpe
|
||||
hFiP0OWK7mWZeTWnY2scpIYXP1Ro7nQv4KacmY4JacTQ7m/HM0Qej/3olhuEv2Cw
|
||||
lMVWw57/oHhmTllfLDQOogFQyIVqaaR98y/Eu6cAabSfcsqAAZ2A8vfHYD27z28J
|
||||
vLO2PZEJd5ThlnX4Zqv0eIpZdBj//8Sl/MSqTshFZ1NDsRoqwdqw284X5MpnOJ4k
|
||||
4Sc2Se8tJxt/nCeibH3dJ504Fb1X/mnOqhCAQ6pVJz4RB5HRlFPSkxVPyag1v1m/
|
||||
7T4vie+OR4eqFQNz6mudrOoMmeVIfyL5fbe4cOr4fk/FyvEE2xMgkFatPqXn7vM9
|
||||
og+zremPCfwRAFpBPyX74VowFY7llcdaj/w8K5T8PzM14Hb3E4ZKizMluKmTvTq9
|
||||
WE1/eSQJLLQqXD5VmtmdUaC/VyE/1ZlIxcA1LWqvEQ327UXREvX/nHsrkKrl956W
|
||||
jzkiHFUTsD1NJ0dMfs+csOt8Furb5jZj+HsMmCm9jLdfz5b/4WKLPbvxIwARAQAB
|
||||
tBZDb2RleCBMaW51eCBSZXBvc2l0b3J5iQJRBBMBCgA7FiEEO/oOSui4zBai2bpo
|
||||
SjtKVmxGYOQFAmpypFUCGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQ
|
||||
SjtKVmxGYORlCQ/9FyikZo8HQcJBP9E/oXVPds/fQnIFB2qJR2z3DrfYEonNt/ev
|
||||
SAySkPPq4/mEOjaI0pFlDDGSaps+FTcJFgoVRTasBIF7JJivvjW9ap8iWEbhhVLe
|
||||
IrFLbMLpUcTRntUx7R4fVMJ/1/cGn+NWZmNwS9ORorzSyCH0IAgCw1Xc3ZrjuMbF
|
||||
VjdToMC1TiXXCEmlYpQakmQ3Ay1cH0FHC2BBNn1MNVkJdPhpZIZCdhaMPHfYFpyo
|
||||
pg8wFvZ5iIcvlbMgyuy8CPJVRWUcYy2dOhEOGnYJnXRPkE3E1hf8YOHNzRlduH89
|
||||
6lT9qcEK2+fpLfrVGoc4zscLZ+Ey+Ko6iQRdVE1j67+wNR3hX8ukue574v1N/xxu
|
||||
i575jumSE19lEj1sH4+P4gFHOtTbF0JhKKzLctbga0IAwTPKhnt3qzj1U5Yj/MZS
|
||||
uEVjrLhdRauOuFBXUclgyVf2w/lE85UUOdlcollsYA6Huq7xDamqf8SslZQGre3E
|
||||
I+lhpqJR1cOwDMUzzcl40uTyhrxXXd/bk4QSlhZbwHR25Pnt+ZMtWavlQWS0eDEV
|
||||
8djuXAURCmx5WOqAFB/TJe1mn5EvyWg4VFzrY/NVNOpzgY5+Xp7J28z7f637r712
|
||||
Eu9j4imVcdPigwS+jf/0f81i2o9b82Y26TN8+EtDLCY841MJ1lrjDrX/dno=
|
||||
=Y+3h
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
@@ -1,9 +1,4 @@
|
||||
---
|
||||
- name: Gather installed package facts
|
||||
tags: [packages]
|
||||
ansible.builtin.package_facts:
|
||||
manager: auto
|
||||
|
||||
- name: Copy Fedora dotfiles
|
||||
tags: [dotfiles, dotfiles:common]
|
||||
ansible.builtin.copy:
|
||||
@@ -30,46 +25,19 @@
|
||||
- "'workstation_dev_fedora' in group_names"
|
||||
- fedora_manage_docker_repo | default(false)
|
||||
|
||||
- name: Configure Google Chrome repository on Fedora
|
||||
tags: [packages]
|
||||
ansible.builtin.yum_repository:
|
||||
name: google-chrome
|
||||
description: Google Chrome
|
||||
baseurl: "https://dl.google.com/linux/chrome/rpm/stable/$basearch"
|
||||
enabled: true
|
||||
gpgcheck: true
|
||||
gpgkey: https://dl.google.com/linux/linux_signing_key.pub
|
||||
- name: Install COPR support on Fedora WSL
|
||||
tags: [packages, mise]
|
||||
ansible.builtin.dnf:
|
||||
name: dnf-plugins-core
|
||||
state: present
|
||||
when:
|
||||
- workstation_manage_google_chrome | default(false)
|
||||
- fedora_manage_google_chrome_repo | default(false)
|
||||
when: "'workstation_dev_wsl' in group_names"
|
||||
|
||||
- name: Configure Visual Studio Code repository on Fedora
|
||||
tags: [packages, vscode]
|
||||
ansible.builtin.yum_repository:
|
||||
name: vscode
|
||||
description: Visual Studio Code
|
||||
baseurl: https://packages.microsoft.com/yumrepos/vscode
|
||||
enabled: true
|
||||
gpgcheck: true
|
||||
gpgkey: https://packages.microsoft.com/keys/microsoft.asc
|
||||
state: present
|
||||
when:
|
||||
- "'workstation_host_linux' in group_names"
|
||||
- fedora_manage_vscode_repo | default(false)
|
||||
|
||||
- name: Configure IntelliJ IDEA COPR repository on Fedora
|
||||
tags: [packages]
|
||||
ansible.builtin.get_url:
|
||||
url: >-
|
||||
https://copr.fedorainfracloud.org/coprs/{{ fedora_intellij_copr_owner }}/{{ fedora_intellij_copr_project }}/repo/fedora-{{ ansible_facts['distribution_major_version'] }}/{{ fedora_intellij_copr_owner }}-{{ fedora_intellij_copr_project }}-fedora-{{ ansible_facts['distribution_major_version'] }}.repo
|
||||
dest: /etc/yum.repos.d/{{ fedora_intellij_copr_owner }}-{{ fedora_intellij_copr_project }}-fedora-{{ ansible_facts['distribution_major_version'] }}.repo
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when:
|
||||
- "'workstation_host_linux' in group_names"
|
||||
- fedora_manage_intellij_repo | default(false)
|
||||
- name: Configure Mise COPR repository on Fedora WSL
|
||||
tags: [packages, mise]
|
||||
community.general.copr:
|
||||
name: jdxcode/mise
|
||||
state: enabled
|
||||
when: "'workstation_dev_wsl' in group_names"
|
||||
|
||||
- name: Refresh dnf package metadata
|
||||
tags: [packages]
|
||||
@@ -89,11 +57,6 @@
|
||||
if 'workstation_dev_fedora' in group_names
|
||||
else []
|
||||
)
|
||||
+ (
|
||||
['google-chrome-stable']
|
||||
if workstation_manage_google_chrome | default(false)
|
||||
else []
|
||||
)
|
||||
+ (profile_packages | default([]))
|
||||
+ (
|
||||
(fedora_desktop_packages | default([]))
|
||||
@@ -103,35 +66,34 @@
|
||||
+ (
|
||||
(fedora_emacs_packages | default([]))
|
||||
if (
|
||||
'graphical_desktop' in group_names
|
||||
and (emacs_enabled | default(false) | bool)
|
||||
emacs_enabled | default(false) | bool
|
||||
)
|
||||
else []
|
||||
)
|
||||
+ (
|
||||
(fedora_workstation_dev_packages | default(workstation_dev_packages | default([])))
|
||||
(fedora_dev_packages | default([]))
|
||||
if 'workstation_dev_fedora' in group_names
|
||||
else []
|
||||
)
|
||||
+ (
|
||||
(workstation_host_linux_packages_fedora | default(workstation_host_linux_packages | default([])))
|
||||
if 'workstation_host_linux' in group_names
|
||||
else []
|
||||
)
|
||||
+ (
|
||||
[fedora_intellij_package_name]
|
||||
if (
|
||||
'workstation_host_linux' in group_names
|
||||
and fedora_manage_intellij_repo | default(false)
|
||||
)
|
||||
(workstation_dev_wsl_packages | default([]))
|
||||
if 'workstation_dev_wsl' in group_names
|
||||
else []
|
||||
)
|
||||
+ (host_packages | default([]))
|
||||
)
|
||||
| difference(fedora_excluded_packages | default([]))
|
||||
| unique
|
||||
}}
|
||||
state: present
|
||||
|
||||
- name: Remove excluded Fedora packages
|
||||
tags: [packages]
|
||||
ansible.builtin.dnf:
|
||||
name: "{{ fedora_removed_packages | default([]) }}"
|
||||
state: absent
|
||||
when: (fedora_removed_packages | default([])) | length > 0
|
||||
|
||||
- name: Add user to docker group
|
||||
tags: [packages]
|
||||
ansible.builtin.user:
|
||||
@@ -142,16 +104,81 @@
|
||||
- "'workstation_dev_fedora' in group_names"
|
||||
- (fedora_docker_packages | default([])) | length > 0
|
||||
|
||||
- name: Install Fedora npm packages
|
||||
- name: Install shared AI coding agents on Fedora
|
||||
tags: [packages, npm, ai_agents]
|
||||
community.general.npm:
|
||||
name: "{{ item.name }}"
|
||||
name: "{{ item.value.npm_package }}"
|
||||
global: true
|
||||
state: "{{ item.state | default('present') }}"
|
||||
loop: "{{ fedora_npm_packages | default([]) }}"
|
||||
when: (fedora_npm_packages | default([])) | length > 0
|
||||
state: latest
|
||||
loop: "{{ ai_agents | dict2items | selectattr('value.npm_package', 'defined') | list }}"
|
||||
when:
|
||||
- item.value.install_enabled | bool
|
||||
- "'workstation_dev_fedora' not in group_names"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
label: "{{ item.key }}"
|
||||
|
||||
- name: Uninstall shared AI coding agents on Fedora
|
||||
tags: [packages, npm, ai_agents]
|
||||
community.general.npm:
|
||||
name: "{{ item.value.npm_package }}"
|
||||
global: true
|
||||
state: absent
|
||||
loop: "{{ ai_agents | dict2items | selectattr('value.npm_package', 'defined') | list }}"
|
||||
when:
|
||||
- item.value.uninstall_enabled | bool
|
||||
- "'workstation_dev_fedora' not in group_names"
|
||||
loop_control:
|
||||
label: "{{ item.key }}"
|
||||
|
||||
- name: Validate ChatGPT desktop architecture on Fedora
|
||||
tags: [packages, chatgpt]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ansible_facts['architecture'] in chatgpt_desktop_supported_architectures
|
||||
- ansible_facts['distribution_major_version'] in chatgpt_desktop_supported_fedora_versions
|
||||
fail_msg: >-
|
||||
ChatGPT desktop supports Fedora {{ chatgpt_desktop_supported_fedora_versions | join(' and ') }}
|
||||
on x86_64 or aarch64; detected Fedora {{ ansible_facts['distribution_major_version'] }}
|
||||
on {{ ansible_facts['architecture'] }}.
|
||||
when: chatgpt_desktop_enabled | bool
|
||||
|
||||
- name: Install ChatGPT desktop RPM signing key
|
||||
tags: [packages, chatgpt]
|
||||
ansible.builtin.copy:
|
||||
src: "{{ chatgpt_desktop_rpm_gpg_key }}"
|
||||
dest: "/etc/pki/rpm-gpg/{{ chatgpt_desktop_rpm_gpg_key }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when: chatgpt_desktop_enabled | bool
|
||||
|
||||
- name: Import ChatGPT desktop RPM signing key
|
||||
tags: [packages, chatgpt]
|
||||
ansible.builtin.rpm_key:
|
||||
state: present
|
||||
key: "/etc/pki/rpm-gpg/{{ chatgpt_desktop_rpm_gpg_key }}"
|
||||
when: chatgpt_desktop_enabled | bool
|
||||
|
||||
- name: Configure ChatGPT desktop RPM repository
|
||||
tags: [packages, chatgpt]
|
||||
ansible.builtin.yum_repository:
|
||||
name: openai-chatgpt
|
||||
description: ChatGPT
|
||||
baseurl: "{{ chatgpt_desktop_rpm_repository }}"
|
||||
enabled: true
|
||||
gpgcheck: true
|
||||
repo_gpgcheck: true
|
||||
gpgkey: "file:///etc/pki/rpm-gpg/{{ chatgpt_desktop_rpm_gpg_key }}"
|
||||
state: present
|
||||
when: chatgpt_desktop_enabled | bool
|
||||
|
||||
- name: Install ChatGPT desktop on selected Fedora hosts
|
||||
tags: [packages, chatgpt]
|
||||
ansible.builtin.dnf:
|
||||
name: chatgpt
|
||||
state: latest
|
||||
update_cache: true
|
||||
when: chatgpt_desktop_enabled | bool
|
||||
|
||||
- name: Define Fedora Flatpak applications
|
||||
tags: [packages]
|
||||
@@ -161,11 +188,6 @@
|
||||
(
|
||||
(fedora_flatpak_packages | default([]))
|
||||
+ (host_flatpak_packages | default([]))
|
||||
+ (
|
||||
(workstation_flatpak_packages | default([]))
|
||||
if 'workstation_host_linux' in group_names
|
||||
else []
|
||||
)
|
||||
)
|
||||
| unique
|
||||
}}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
- name: Install packages on FreeBSD
|
||||
tags: [packages]
|
||||
community.general.pkgng:
|
||||
name: >-
|
||||
{{
|
||||
(
|
||||
(freebsd_packages_base | default([]))
|
||||
+ (freebsd_development_packages | default([]))
|
||||
+ (
|
||||
(freebsd_desktop_common_packages | default([]))
|
||||
if 'graphical_desktop' in group_names
|
||||
else []
|
||||
)
|
||||
+ (
|
||||
(freebsd_hyprland_packages | default([]))
|
||||
if 'desktop_hyprland' in group_names
|
||||
else []
|
||||
)
|
||||
+ (
|
||||
(freebsd_niri_packages | default([]))
|
||||
if 'desktop_niri' in group_names
|
||||
else []
|
||||
)
|
||||
+ (
|
||||
(freebsd_lab_packages | default([]))
|
||||
if 'role_lab' in group_names
|
||||
else []
|
||||
)
|
||||
+ (host_packages | default([]))
|
||||
)
|
||||
| unique
|
||||
}}
|
||||
state: present
|
||||
@@ -1,88 +0,0 @@
|
||||
---
|
||||
- name: Refresh apt package cache on Linux Mint
|
||||
tags: [packages]
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
|
||||
- name: Install packages on Linux Mint
|
||||
tags: [packages]
|
||||
ansible.builtin.apt:
|
||||
name: >-
|
||||
{{
|
||||
(
|
||||
(common_packages | default([]))
|
||||
+ (mint_packages_base | default([]))
|
||||
+ (mint_development_packages | default([]))
|
||||
+ (
|
||||
(mint_desktop_common_packages | default([]))
|
||||
if 'graphical_desktop' in group_names
|
||||
else []
|
||||
)
|
||||
+ (
|
||||
(mint_emacs_packages | default([]))
|
||||
if (
|
||||
'graphical_desktop' in group_names
|
||||
and (emacs_enabled | default(false) | bool)
|
||||
)
|
||||
else []
|
||||
)
|
||||
+ (
|
||||
(mint_cinnamon_packages | default([]))
|
||||
if 'desktop_cinnamon' in group_names
|
||||
else []
|
||||
)
|
||||
+ (
|
||||
(mint_personal_workstation_packages | default([]))
|
||||
if 'role_personal_workstation' in group_names
|
||||
else []
|
||||
)
|
||||
+ (host_packages | default([]))
|
||||
)
|
||||
| unique
|
||||
}}
|
||||
state: present
|
||||
|
||||
- name: Ensure Flathub remote is configured on Linux Mint
|
||||
tags: [packages]
|
||||
community.general.flatpak_remote:
|
||||
name: "{{ mint_flatpak_remote_name | default('flathub') }}"
|
||||
state: present
|
||||
flatpakrepo_url: "{{ mint_flatpak_remote_url | default('https://dl.flathub.org/repo/flathub.flatpakrepo') }}"
|
||||
when:
|
||||
- mint_manage_flatpak | default(true)
|
||||
- >-
|
||||
(
|
||||
(mint_flatpak_packages | default([]))
|
||||
+ (personal_workstation_flatpak_packages | default([]))
|
||||
) | length > 0
|
||||
|
||||
- name: Install Linux Mint Flatpak applications
|
||||
tags: [packages]
|
||||
community.general.flatpak:
|
||||
name: >-
|
||||
{{
|
||||
(
|
||||
(mint_flatpak_packages | default([]))
|
||||
+ (
|
||||
(personal_workstation_flatpak_packages | default([]))
|
||||
if 'role_personal_workstation' in group_names
|
||||
else []
|
||||
)
|
||||
)
|
||||
| unique
|
||||
}}
|
||||
state: present
|
||||
remote: "{{ mint_flatpak_remote_name | default('flathub') }}"
|
||||
method: system
|
||||
when:
|
||||
- mint_manage_flatpak | default(true)
|
||||
- >-
|
||||
(
|
||||
(mint_flatpak_packages | default([]))
|
||||
+ (
|
||||
(personal_workstation_flatpak_packages | default([]))
|
||||
if 'role_personal_workstation' in group_names
|
||||
else []
|
||||
)
|
||||
) | length > 0
|
||||
196
ansible/roles/packages_rocky/tasks/main.yml
Normal file
196
ansible/roles/packages_rocky/tasks/main.yml
Normal file
@@ -0,0 +1,196 @@
|
||||
---
|
||||
- name: Require Rocky Linux 9
|
||||
tags: [packages]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ansible_facts['distribution'] == 'Rocky'
|
||||
- ansible_facts['distribution_major_version'] == '9'
|
||||
fail_msg: This platform role supports Rocky Linux 9 only.
|
||||
|
||||
- name: Install DNF configuration plugin
|
||||
tags: [packages]
|
||||
ansible.builtin.dnf:
|
||||
name: dnf-plugins-core
|
||||
state: present
|
||||
|
||||
- name: Enable Rocky CodeReady Builder repository
|
||||
tags: [packages]
|
||||
community.general.dnf_config_manager:
|
||||
name:
|
||||
- crb
|
||||
state: enabled
|
||||
when: rocky_enable_crb | default(true) | bool
|
||||
|
||||
- name: Install EPEL repository package
|
||||
tags: [packages]
|
||||
ansible.builtin.dnf:
|
||||
name: epel-release
|
||||
state: present
|
||||
|
||||
- name: Import official OpenZFS EL9+ signing key
|
||||
tags: [packages, storage]
|
||||
ansible.builtin.rpm_key:
|
||||
state: present
|
||||
key: "{{ rocky_openzfs_gpg_key_url }}"
|
||||
fingerprint: "{{ rocky_openzfs_gpg_key_fingerprint }}"
|
||||
when: rocky_manage_openzfs_repo | bool
|
||||
|
||||
- name: Install official OpenZFS repository package
|
||||
tags: [packages, storage]
|
||||
ansible.builtin.dnf:
|
||||
name: "{{ rocky_openzfs_release_rpm }}"
|
||||
state: present
|
||||
when: rocky_manage_openzfs_repo | bool
|
||||
|
||||
- name: Disable OpenZFS DKMS repository
|
||||
tags: [packages, storage]
|
||||
community.general.dnf_config_manager:
|
||||
name:
|
||||
- zfs
|
||||
state: disabled
|
||||
when: rocky_manage_openzfs_repo | bool
|
||||
|
||||
- name: Enable OpenZFS kmod repository
|
||||
tags: [packages, storage]
|
||||
community.general.dnf_config_manager:
|
||||
name:
|
||||
- zfs-kmod
|
||||
state: enabled
|
||||
when: rocky_manage_openzfs_repo | bool
|
||||
|
||||
- name: Configure official GitHub CLI RPM repository
|
||||
tags: [packages]
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ rocky_github_cli_repo_url }}"
|
||||
dest: "{{ rocky_github_cli_repo_file }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when: rocky_manage_github_cli_repo | bool
|
||||
|
||||
- name: Refresh Rocky package metadata
|
||||
tags: [packages]
|
||||
ansible.builtin.dnf:
|
||||
update_cache: true
|
||||
|
||||
- name: Install packages on Rocky Linux
|
||||
tags: [packages]
|
||||
ansible.builtin.dnf:
|
||||
name: >-
|
||||
{{
|
||||
(
|
||||
(rocky_common_packages | default([]))
|
||||
+ (rocky_packages_base | default([]))
|
||||
+ (rocky_profile_packages | default([]))
|
||||
+ (rocky_podman_packages | default([]))
|
||||
+ (host_packages | default([]))
|
||||
) | unique
|
||||
}}
|
||||
state: present
|
||||
|
||||
- name: Ensure Podman runtime socket directory exists
|
||||
tags: [packages, podman]
|
||||
ansible.builtin.file:
|
||||
path: /run/podman
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
when: rocky_manage_podman | bool
|
||||
|
||||
- name: Require supported architecture for Syncthing
|
||||
tags: [packages, syncthing]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ansible_facts['architecture'] == 'x86_64'
|
||||
fail_msg: The pinned Atlas Syncthing binary currently supports x86_64 only.
|
||||
when: rocky_manage_syncthing_binary | bool
|
||||
|
||||
- name: Read installed Syncthing version
|
||||
tags: [packages, syncthing]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/local/bin/syncthing
|
||||
- --version
|
||||
register: rocky_syncthing_version_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
when: rocky_manage_syncthing_binary | bool
|
||||
|
||||
- name: Determine whether Syncthing must be installed
|
||||
tags: [packages, syncthing]
|
||||
ansible.builtin.set_fact:
|
||||
rocky_syncthing_install_required: >-
|
||||
{{
|
||||
('syncthing v' ~ rocky_syncthing_version ~ ' ')
|
||||
not in (rocky_syncthing_version_check.stdout | default(''))
|
||||
}}
|
||||
when: rocky_manage_syncthing_binary | bool
|
||||
|
||||
- name: Create temporary Syncthing extraction directory
|
||||
tags: [packages, syncthing]
|
||||
ansible.builtin.file:
|
||||
path: "/tmp/syncthing-{{ rocky_syncthing_version }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
when:
|
||||
- rocky_manage_syncthing_binary | bool
|
||||
- rocky_syncthing_install_required | bool
|
||||
|
||||
- name: Download pinned Syncthing release
|
||||
tags: [packages, syncthing]
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ rocky_syncthing_archive_url }}"
|
||||
dest: "/tmp/syncthing-{{ rocky_syncthing_version }}.tar.gz"
|
||||
checksum: "{{ rocky_syncthing_archive_checksum }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when:
|
||||
- rocky_manage_syncthing_binary | bool
|
||||
- rocky_syncthing_install_required | bool
|
||||
|
||||
- name: Extract pinned Syncthing release
|
||||
tags: [packages, syncthing]
|
||||
ansible.builtin.unarchive:
|
||||
src: "/tmp/syncthing-{{ rocky_syncthing_version }}.tar.gz"
|
||||
dest: "/tmp/syncthing-{{ rocky_syncthing_version }}"
|
||||
remote_src: true
|
||||
when:
|
||||
- rocky_manage_syncthing_binary | bool
|
||||
- rocky_syncthing_install_required | bool
|
||||
|
||||
- name: Install pinned Syncthing binary
|
||||
tags: [packages, syncthing]
|
||||
ansible.builtin.copy:
|
||||
src: >-
|
||||
{{
|
||||
'/tmp/syncthing-' ~ rocky_syncthing_version
|
||||
~ '/syncthing-linux-amd64-v' ~ rocky_syncthing_version
|
||||
~ '/syncthing'
|
||||
}}
|
||||
dest: /usr/local/bin/syncthing
|
||||
remote_src: true
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
when:
|
||||
- rocky_manage_syncthing_binary | bool
|
||||
- rocky_syncthing_install_required | bool
|
||||
|
||||
- name: Remove Syncthing release archive
|
||||
tags: [packages, syncthing]
|
||||
ansible.builtin.file:
|
||||
path: "/tmp/syncthing-{{ rocky_syncthing_version }}.tar.gz"
|
||||
state: absent
|
||||
when: rocky_manage_syncthing_binary | bool
|
||||
|
||||
- name: Remove Syncthing extraction directory
|
||||
tags: [packages, syncthing]
|
||||
ansible.builtin.file:
|
||||
path: "/tmp/syncthing-{{ rocky_syncthing_version }}"
|
||||
state: absent
|
||||
when: rocky_manage_syncthing_binary | bool
|
||||
@@ -1,292 +0,0 @@
|
||||
---
|
||||
- name: Gather installed package facts
|
||||
tags: [packages]
|
||||
ansible.builtin.package_facts:
|
||||
manager: auto
|
||||
|
||||
- name: Copy Ubuntu dotfiles
|
||||
tags: [dotfiles, dotfiles:common]
|
||||
ansible.builtin.copy:
|
||||
src: "{{ playbook_dir }}/../dotfiles/ubuntu/{{ item.src }}"
|
||||
dest: "{{ effective_user_home }}/{{ item.dest }}"
|
||||
owner: "{{ effective_username }}"
|
||||
group: "{{ effective_user_group }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop: "{{ ubuntu_dotfiles | default([]) }}"
|
||||
loop_control:
|
||||
label: "{{ item.dest }}"
|
||||
|
||||
- name: Ensure architecture is supported for Google Chrome
|
||||
tags: [packages]
|
||||
ansible.builtin.fail:
|
||||
msg: "Unsupported architecture {{ ansible_facts['architecture'] }} for Google Chrome stable package"
|
||||
when:
|
||||
- workstation_manage_google_chrome | default(false)
|
||||
- ansible_facts['architecture'] != 'x86_64'
|
||||
|
||||
- name: Download Google Chrome Debian package
|
||||
tags: [packages]
|
||||
ansible.builtin.get_url:
|
||||
url: https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
dest: /tmp/google-chrome-stable_current_amd64.deb
|
||||
mode: "0644"
|
||||
when:
|
||||
- workstation_manage_google_chrome | default(false)
|
||||
- "'google-chrome-stable' not in (ansible_facts.packages | default({}))"
|
||||
|
||||
- name: Install Google Chrome from downloaded Debian package
|
||||
tags: [packages]
|
||||
ansible.builtin.apt:
|
||||
deb: /tmp/google-chrome-stable_current_amd64.deb
|
||||
state: present
|
||||
when:
|
||||
- workstation_manage_google_chrome | default(false)
|
||||
- "'google-chrome-stable' not in (ansible_facts.packages | default({}))"
|
||||
|
||||
- name: Remove downloaded Google Chrome Debian package
|
||||
tags: [packages]
|
||||
ansible.builtin.file:
|
||||
path: /tmp/google-chrome-stable_current_amd64.deb
|
||||
state: absent
|
||||
when:
|
||||
- workstation_manage_google_chrome | default(false)
|
||||
- "'google-chrome-stable' not in (ansible_facts.packages | default({}))"
|
||||
|
||||
- name: Download glab Debian package
|
||||
tags: [packages]
|
||||
ansible.builtin.get_url:
|
||||
url: >-
|
||||
https://gitlab.com/gitlab-org/cli/-/releases/v{{ workstation_glab_version }}/downloads/glab_{{ workstation_glab_version }}_linux_{{
|
||||
'amd64' if ansible_facts['architecture'] == 'x86_64'
|
||||
else 'arm64' if ansible_facts['architecture'] in ['aarch64', 'arm64']
|
||||
else ansible_facts['architecture']
|
||||
}}.deb
|
||||
dest: "/tmp/glab_{{ workstation_glab_version }}.deb"
|
||||
mode: "0644"
|
||||
when:
|
||||
- workstation_manage_glab | default(false)
|
||||
- ansible_facts.packages['glab'] is not defined or ansible_facts.packages['glab'][0].version != workstation_glab_version
|
||||
|
||||
- name: Install glab from downloaded Debian package
|
||||
tags: [packages]
|
||||
ansible.builtin.apt:
|
||||
deb: "/tmp/glab_{{ workstation_glab_version }}.deb"
|
||||
state: present
|
||||
when:
|
||||
- workstation_manage_glab | default(false)
|
||||
- ansible_facts.packages['glab'] is not defined or ansible_facts.packages['glab'][0].version != workstation_glab_version
|
||||
|
||||
- name: Remove downloaded glab Debian package
|
||||
tags: [packages]
|
||||
ansible.builtin.file:
|
||||
path: "/tmp/glab_{{ workstation_glab_version }}.deb"
|
||||
state: absent
|
||||
when:
|
||||
- workstation_manage_glab | default(false)
|
||||
- ansible_facts.packages['glab'] is not defined or ansible_facts.packages['glab'][0].version != workstation_glab_version
|
||||
|
||||
- name: Ensure Docker apt keyrings directory exists
|
||||
tags: [packages]
|
||||
ansible.builtin.file:
|
||||
path: /etc/apt/keyrings
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
when: ubuntu_manage_docker_repo | default(false)
|
||||
|
||||
- name: Download Docker apt repository signing key
|
||||
tags: [packages]
|
||||
ansible.builtin.get_url:
|
||||
url: https://download.docker.com/linux/ubuntu/gpg
|
||||
dest: /etc/apt/keyrings/docker.asc
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when: ubuntu_manage_docker_repo | default(false)
|
||||
|
||||
- name: Configure Docker apt repository
|
||||
tags: [packages]
|
||||
ansible.builtin.apt_repository:
|
||||
repo: >-
|
||||
deb [arch={{ 'amd64' if ansible_facts['architecture'] == 'x86_64'
|
||||
else 'arm64' if ansible_facts['architecture'] in ['aarch64', 'arm64']
|
||||
else ansible_facts['architecture'] }}
|
||||
signed-by=/etc/apt/keyrings/docker.asc]
|
||||
https://download.docker.com/linux/ubuntu
|
||||
{{ ansible_facts['distribution_release'] }} stable
|
||||
filename: docker
|
||||
state: present
|
||||
update_cache: true
|
||||
when: ubuntu_manage_docker_repo | default(false)
|
||||
|
||||
- name: Refresh apt package cache
|
||||
tags: [packages]
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
|
||||
- name: Install packages on Ubuntu
|
||||
tags: [packages]
|
||||
ansible.builtin.apt:
|
||||
name: >-
|
||||
{{
|
||||
(
|
||||
(common_packages | default([]))
|
||||
+ (ubuntu_packages_base | default([]))
|
||||
+ (ubuntu_docker_packages | default([]))
|
||||
+ (profile_packages | default([]))
|
||||
+ (workstation_dev_packages | default([]))
|
||||
+ (
|
||||
(workstation_host_linux_packages | default([]))
|
||||
if 'workstation_host_linux' in group_names
|
||||
else []
|
||||
)
|
||||
+ (
|
||||
(workstation_dev_wsl_packages | default([]))
|
||||
if 'workstation_dev_wsl' in group_names
|
||||
else []
|
||||
)
|
||||
+ (desktop_common_packages | default([]))
|
||||
+ (host_packages | default([]))
|
||||
)
|
||||
| difference(
|
||||
(workstation_dev_wsl_excluded_packages | default([]))
|
||||
if 'workstation_dev_wsl' in group_names
|
||||
else []
|
||||
)
|
||||
| unique
|
||||
}}
|
||||
state: present
|
||||
|
||||
- name: Add user to docker group
|
||||
tags: [packages]
|
||||
ansible.builtin.user:
|
||||
name: "{{ effective_username }}"
|
||||
groups: docker
|
||||
append: true
|
||||
when: (ubuntu_docker_packages | default([])) | length > 0
|
||||
|
||||
- name: Ensure architecture is supported for croc GitHub release
|
||||
tags: [packages]
|
||||
ansible.builtin.fail:
|
||||
msg: "Unsupported architecture {{ ansible_facts['architecture'] }} for croc GitHub release"
|
||||
when:
|
||||
- workstation_manage_croc | default(false)
|
||||
- ansible_facts['architecture'] not in ['x86_64', 'aarch64', 'arm64']
|
||||
|
||||
- name: Read installed croc version
|
||||
tags: [packages]
|
||||
ansible.builtin.command: /usr/local/bin/croc --version
|
||||
register: croc_version_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
when: workstation_manage_croc | default(false)
|
||||
|
||||
- name: Ensure temporary croc extraction directory is absent
|
||||
tags: [packages]
|
||||
ansible.builtin.file:
|
||||
path: "/tmp/croc_{{ workstation_croc_version }}"
|
||||
state: absent
|
||||
when:
|
||||
- workstation_manage_croc | default(false)
|
||||
- workstation_croc_version not in (croc_version_check.stdout | default(''))
|
||||
|
||||
- name: Create temporary croc extraction directory
|
||||
tags: [packages]
|
||||
ansible.builtin.file:
|
||||
path: "/tmp/croc_{{ workstation_croc_version }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
when:
|
||||
- workstation_manage_croc | default(false)
|
||||
- workstation_croc_version not in (croc_version_check.stdout | default(''))
|
||||
|
||||
- name: Download croc GitHub release archive
|
||||
tags: [packages]
|
||||
ansible.builtin.get_url:
|
||||
url: >-
|
||||
{{
|
||||
'https://github.com/schollz/croc/releases/download/'
|
||||
~ workstation_croc_version
|
||||
~ '/croc_'
|
||||
~ workstation_croc_version
|
||||
~ '_Linux-'
|
||||
~ (
|
||||
'64bit' if ansible_facts['architecture'] == 'x86_64'
|
||||
else 'ARM64' if ansible_facts['architecture'] in ['aarch64', 'arm64']
|
||||
else ansible_facts['architecture']
|
||||
)
|
||||
~ '.tar.gz'
|
||||
}}
|
||||
dest: "/tmp/croc_{{ workstation_croc_version }}.tar.gz"
|
||||
mode: "0644"
|
||||
when:
|
||||
- workstation_manage_croc | default(false)
|
||||
- workstation_croc_version not in (croc_version_check.stdout | default(''))
|
||||
|
||||
- name: Extract croc GitHub release archive
|
||||
tags: [packages]
|
||||
ansible.builtin.unarchive:
|
||||
src: "/tmp/croc_{{ workstation_croc_version }}.tar.gz"
|
||||
dest: "/tmp/croc_{{ workstation_croc_version }}"
|
||||
remote_src: true
|
||||
when:
|
||||
- workstation_manage_croc | default(false)
|
||||
- workstation_croc_version not in (croc_version_check.stdout | default(''))
|
||||
|
||||
- name: Install croc binary
|
||||
tags: [packages]
|
||||
ansible.builtin.copy:
|
||||
src: "/tmp/croc_{{ workstation_croc_version }}/croc"
|
||||
dest: /usr/local/bin/croc
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
remote_src: true
|
||||
when:
|
||||
- workstation_manage_croc | default(false)
|
||||
- workstation_croc_version not in (croc_version_check.stdout | default(''))
|
||||
|
||||
- name: Remove downloaded croc archive
|
||||
tags: [packages]
|
||||
ansible.builtin.file:
|
||||
path: "/tmp/croc_{{ workstation_croc_version }}.tar.gz"
|
||||
state: absent
|
||||
when:
|
||||
- workstation_manage_croc | default(false)
|
||||
- workstation_croc_version not in (croc_version_check.stdout | default(''))
|
||||
|
||||
- name: Remove temporary croc extraction directory
|
||||
tags: [packages]
|
||||
ansible.builtin.file:
|
||||
path: "/tmp/croc_{{ workstation_croc_version }}"
|
||||
state: absent
|
||||
when:
|
||||
- workstation_manage_croc | default(false)
|
||||
- workstation_croc_version not in (croc_version_check.stdout | default(''))
|
||||
|
||||
- name: Remove workstation snap packages
|
||||
tags: [packages]
|
||||
community.general.snap:
|
||||
name: "{{ item }}"
|
||||
state: absent
|
||||
loop: "{{ workstation_removed_snap_packages | default([]) }}"
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
when: (workstation_removed_snap_packages | default([])) | length > 0
|
||||
|
||||
- name: Install workstation snap packages
|
||||
tags: [packages]
|
||||
community.general.snap:
|
||||
name: "{{ item.name }}"
|
||||
classic: "{{ item.classic | default(false) }}"
|
||||
channel: "{{ item.channel | default(omit) }}"
|
||||
state: present
|
||||
loop: "{{ workstation_snap_packages | default([]) }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
when: (workstation_snap_packages | default([])) | length > 0
|
||||
@@ -51,15 +51,7 @@
|
||||
ansible.builtin.set_fact:
|
||||
packages_void_selected_desktop_packages: >-
|
||||
{{
|
||||
(
|
||||
(desktop_minimal_packages | default([]))
|
||||
if (desktop_environment | default('minimal')) == 'minimal'
|
||||
else (
|
||||
(desktop_xfce_packages | default([]))
|
||||
if (desktop_environment | default('minimal')) == 'xfce'
|
||||
else (desktop_kde_packages | default([]))
|
||||
)
|
||||
)
|
||||
(desktop_minimal_packages | default([]))
|
||||
+ (
|
||||
(desktop_sway_packages | default([]))
|
||||
if (
|
||||
@@ -68,14 +60,6 @@
|
||||
)
|
||||
else []
|
||||
)
|
||||
+ (
|
||||
(desktop_hyprland_packages | default([]))
|
||||
if (
|
||||
(desktop_environment | default('minimal')) == 'minimal'
|
||||
and 'hyprland' in (desktop_sessions_enabled | default([]))
|
||||
)
|
||||
else []
|
||||
)
|
||||
+ (
|
||||
(desktop_niri_packages | default([]))
|
||||
if (
|
||||
@@ -95,14 +79,11 @@
|
||||
(
|
||||
(desktop_minimal_packages | default([]))
|
||||
+ (desktop_sway_packages | default([]))
|
||||
+ (desktop_hyprland_packages | default([]))
|
||||
+ (desktop_niri_packages | default([]))
|
||||
+ (desktop_xfce_packages | default([]))
|
||||
+ (desktop_kde_packages | default([]))
|
||||
)
|
||||
| unique
|
||||
| difference(packages_void_selected_desktop_packages)
|
||||
| difference(desktop_display_manager_by_environment.values() | list)
|
||||
| difference(['emptty'])
|
||||
}}
|
||||
|
||||
- name: Install packages on Void Linux
|
||||
@@ -118,7 +99,6 @@
|
||||
+ (desktop_common_packages | default([]))
|
||||
+ ((desktop_emacs_packages | default([])) if (emacs_enabled | default(false) | bool) else [])
|
||||
+ (packages_void_selected_desktop_packages | default([]))
|
||||
+ ((void_lab_packages | default([])) if 'role_lab' in group_names else [])
|
||||
+ (host_packages | default([]))
|
||||
)
|
||||
| unique
|
||||
@@ -129,7 +109,7 @@
|
||||
- name: Check whether the selected display manager is active
|
||||
tags: [packages, display-manager]
|
||||
ansible.builtin.stat:
|
||||
path: "/var/service/{{ desktop_display_manager_by_environment[desktop_environment | default('minimal')] }}"
|
||||
path: /var/service/emptty
|
||||
register: packages_void_selected_display_manager
|
||||
|
||||
- name: Remove packages managed by the inactive desktop environment
|
||||
@@ -140,9 +120,7 @@
|
||||
update_cache: false
|
||||
when:
|
||||
- (packages_void_inactive_desktop_packages | default([])) | length > 0
|
||||
- >-
|
||||
packages_void_selected_display_manager.stat.exists
|
||||
or (desktop_allow_display_manager_switch | default(false))
|
||||
- packages_void_selected_display_manager.stat.exists
|
||||
|
||||
- name: Remove host-absent packages on Void Linux
|
||||
tags: [packages]
|
||||
|
||||
24
ansible/roles/profile_aegis/defaults/main.yml
Normal file
24
ansible/roles/profile_aegis/defaults/main.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
aegis_hostname: aegis
|
||||
aegis_layered_packages:
|
||||
- nfs-utils
|
||||
- wireguard-tools
|
||||
aegis_network_connection_name: ""
|
||||
aegis_network_connection_uuid: ""
|
||||
aegis_host_dns_servers: []
|
||||
aegis_host_dns_search_domains: []
|
||||
aegis_adguard_image: docker.io/adguard/adguardhome:latest
|
||||
aegis_icloudpd_image: docker.io/boredazfcuk/icloudpd:latest
|
||||
aegis_icloudpd_folder_structure: '{:%Y/%m/%d}'
|
||||
aegis_icloudpd_synchronisation_interval: 86400
|
||||
aegis_icloudpd_apple_id: ""
|
||||
aegis_ikaros_mac_address: aa:bb:cc:dd:ee:ff
|
||||
aegis_wol_port: 9
|
||||
|
||||
aegis_lan_subnet: CHANGEME_LAN_SUBNET
|
||||
aegis_firewalld_zone: public
|
||||
aegis_adguard_web_port: 80
|
||||
aegis_ssh_allowed_users:
|
||||
- "{{ ansible_user }}"
|
||||
aegis_ssh_user_home: "/var/home/{{ ansible_user }}"
|
||||
aegis_ssh_authorized_keys: []
|
||||
21
ansible/roles/profile_aegis/handlers/main.yml
Normal file
21
ansible/roles/profile_aegis/handlers/main.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
- name: Restart Aegis systemd-resolved
|
||||
ansible.builtin.systemd:
|
||||
name: systemd-resolved.service
|
||||
state: restarted
|
||||
|
||||
- name: Reload Aegis SSH
|
||||
ansible.builtin.systemd:
|
||||
name: sshd.service
|
||||
state: reloaded
|
||||
|
||||
- name: Restart Aegis Quadlet services
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
loop:
|
||||
- adguardhome.service
|
||||
- icloudpd.service
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
376
ansible/roles/profile_aegis/tasks/main.yml
Normal file
376
ansible/roles/profile_aegis/tasks/main.yml
Normal file
@@ -0,0 +1,376 @@
|
||||
---
|
||||
- name: Manage Aegis layered packages
|
||||
tags: [aegis, packages, nfs, wireguard]
|
||||
community.general.rpm_ostree_pkg:
|
||||
name: "{{ aegis_layered_packages }}"
|
||||
state: present
|
||||
register: aegis_layered_packages_result
|
||||
when: aegis_layered_packages | length > 0
|
||||
|
||||
- name: Report reboot required for Aegis layered packages
|
||||
tags: [aegis, packages, nfs, wireguard]
|
||||
ansible.builtin.debug:
|
||||
msg: Reboot Aegis to activate the newly layered packages, then rerun the playbook.
|
||||
when: aegis_layered_packages_result.needs_reboot | default(false)
|
||||
|
||||
- name: Require Aegis iCloudPD Apple ID
|
||||
tags: [aegis, icloudpd]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- aegis_icloudpd_apple_id | length > 0
|
||||
fail_msg: Define vault_aegis_icloudpd_apple_id before applying the Aegis profile.
|
||||
no_log: true
|
||||
|
||||
- name: Require completed Aegis network placeholders
|
||||
tags: [aegis, dns, firewall, network, services]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- aegis_lan_subnet != 'CHANGEME_LAN_SUBNET'
|
||||
- aegis_firewalld_zone | length > 0
|
||||
- aegis_adguard_web_port | int > 0
|
||||
- aegis_adguard_web_port | int < 65536
|
||||
- aegis_network_connection_name | length > 0
|
||||
- aegis_network_connection_uuid | length > 0
|
||||
- aegis_host_dns_servers | length > 0
|
||||
- ansible_facts["default_ipv4"]["address"] not in aegis_host_dns_servers
|
||||
- aegis_ssh_allowed_users | length > 0
|
||||
fail_msg: >-
|
||||
Define the Aegis LAN subnet, firewalld zone, AdGuard web port, independent host DNS,
|
||||
NetworkManager connection, and SSH users. Aegis must not use its own address as upstream DNS.
|
||||
|
||||
- name: Verify the declared Aegis NetworkManager connection exists
|
||||
tags: [aegis, dns, network, services]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- nmcli
|
||||
- --get-values
|
||||
- connection.id
|
||||
- connection
|
||||
- show
|
||||
- uuid
|
||||
- "{{ aegis_network_connection_uuid }}"
|
||||
register: aegis_network_connection
|
||||
changed_when: false
|
||||
failed_when: >-
|
||||
aegis_network_connection.rc != 0
|
||||
or aegis_network_connection.stdout != aegis_network_connection_name
|
||||
|
||||
- name: Read the current Aegis host DNS configuration
|
||||
tags: [aegis, dns, network, services]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- nmcli
|
||||
- --get-values
|
||||
- ipv4.ignore-auto-dns,ipv4.dns,ipv4.dns-search,ipv6.ignore-auto-dns
|
||||
- connection
|
||||
- show
|
||||
- uuid
|
||||
- "{{ aegis_network_connection_uuid }}"
|
||||
register: aegis_host_dns_current
|
||||
changed_when: false
|
||||
|
||||
- name: Configure independent upstream DNS for the Aegis host
|
||||
tags: [aegis, dns, network, services]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- nmcli
|
||||
- connection
|
||||
- modify
|
||||
- uuid
|
||||
- "{{ aegis_network_connection_uuid }}"
|
||||
- ipv4.ignore-auto-dns
|
||||
- "yes"
|
||||
- ipv4.dns
|
||||
- "{{ aegis_host_dns_servers | join(',') }}"
|
||||
- ipv4.dns-search
|
||||
- "{{ aegis_host_dns_search_domains | join(',') }}"
|
||||
- ipv6.ignore-auto-dns
|
||||
- "yes"
|
||||
register: aegis_host_dns_profile
|
||||
when: >-
|
||||
aegis_host_dns_current.stdout_lines !=
|
||||
[
|
||||
'yes',
|
||||
aegis_host_dns_servers | join(','),
|
||||
aegis_host_dns_search_domains | join(','),
|
||||
'yes'
|
||||
]
|
||||
changed_when: true
|
||||
|
||||
- name: Report Aegis reboot required for host DNS changes
|
||||
tags: [aegis, dns, network, services]
|
||||
ansible.builtin.debug:
|
||||
msg: Reboot Aegis to activate its independent upstream DNS before testing another OS update.
|
||||
when: aegis_host_dns_profile.changed | default(false)
|
||||
|
||||
- name: Set Aegis hostname
|
||||
tags: [aegis, services]
|
||||
ansible.builtin.hostname:
|
||||
name: "{{ aegis_hostname }}"
|
||||
|
||||
- name: Create Aegis persistent container directories
|
||||
tags: [aegis, containers]
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
loop:
|
||||
- /var/lib/adguard/work
|
||||
- /var/lib/adguard/conf
|
||||
- /var/lib/icloudpd/data
|
||||
- /var/lib/icloudpd/config
|
||||
|
||||
- name: Create Quadlet configuration directory
|
||||
tags: [aegis, containers]
|
||||
ansible.builtin.file:
|
||||
path: /etc/containers/systemd
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Render Aegis Quadlet units
|
||||
tags: [aegis, containers]
|
||||
ansible.builtin.template:
|
||||
src: "{{ item.src }}"
|
||||
dest: "/etc/containers/systemd/{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
loop:
|
||||
- src: adguardhome.container.j2
|
||||
dest: adguardhome.container
|
||||
- src: icloudpd.container.j2
|
||||
dest: icloudpd.container
|
||||
loop_control:
|
||||
label: "{{ item.dest }}"
|
||||
no_log: "{{ item.dest == 'icloudpd.container' }}"
|
||||
notify: Restart Aegis Quadlet services
|
||||
|
||||
- name: Create Aegis systemd-resolved configuration directory
|
||||
tags: [aegis, adguard, dns, services]
|
||||
ansible.builtin.file:
|
||||
path: /etc/systemd/resolved.conf.d
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Disable Aegis systemd-resolved DNS stub listener
|
||||
tags: [aegis, adguard, dns, services]
|
||||
ansible.builtin.template:
|
||||
src: 10-adguard-dns.conf.j2
|
||||
dest: /etc/systemd/resolved.conf.d/10-adguard-dns.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify:
|
||||
- Restart Aegis systemd-resolved
|
||||
- Restart Aegis Quadlet services
|
||||
|
||||
- name: Point Aegis resolver at the full systemd-resolved configuration
|
||||
tags: [aegis, adguard, dns, services]
|
||||
ansible.builtin.file:
|
||||
src: ../run/systemd/resolve/resolv.conf
|
||||
dest: /etc/resolv.conf
|
||||
state: link
|
||||
force: true
|
||||
notify: Restart Aegis systemd-resolved
|
||||
|
||||
- name: Enable Aegis firewalld
|
||||
tags: [aegis, firewall, services]
|
||||
ansible.builtin.systemd:
|
||||
name: firewalld.service
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Render Aegis AdGuard web firewalld service
|
||||
tags: [aegis, firewall]
|
||||
ansible.builtin.template:
|
||||
src: aegis-adguard-web.xml.j2
|
||||
dest: /etc/firewalld/services/aegis-adguard-web.xml
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
- name: Reload firewalld custom services
|
||||
tags: [aegis, firewall]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- firewall-cmd
|
||||
- --reload
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Remove unrestricted Aegis services from firewalld zone
|
||||
tags: [aegis, firewall]
|
||||
ansible.posix.firewalld:
|
||||
service: "{{ item }}"
|
||||
permanent: true
|
||||
immediate: true
|
||||
state: disabled
|
||||
zone: "{{ aegis_firewalld_zone }}"
|
||||
loop:
|
||||
- ssh
|
||||
- dns
|
||||
- http
|
||||
- aegis-adguard-web
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
|
||||
- name: Remove retired Aegis TLS access rule
|
||||
tags: [aegis, firewall]
|
||||
ansible.posix.firewalld:
|
||||
rich_rule: 'rule family="ipv4" source address="{{ aegis_lan_subnet }}" port port="853" protocol="tcp" accept'
|
||||
permanent: true
|
||||
immediate: true
|
||||
state: disabled
|
||||
zone: "{{ aegis_firewalld_zone }}"
|
||||
|
||||
- name: Allow Aegis services from the LAN only
|
||||
tags: [aegis, firewall]
|
||||
ansible.posix.firewalld:
|
||||
rich_rule: "{{ item }}"
|
||||
permanent: true
|
||||
immediate: true
|
||||
state: enabled
|
||||
zone: "{{ aegis_firewalld_zone }}"
|
||||
loop:
|
||||
- 'rule family="ipv4" source address="{{ aegis_lan_subnet }}" service name="ssh" accept'
|
||||
- 'rule family="ipv4" source address="{{ aegis_lan_subnet }}" service name="dns" accept'
|
||||
- 'rule family="ipv4" source address="{{ aegis_lan_subnet }}" service name="aegis-adguard-web" accept'
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
|
||||
- name: Check the standard Aegis SSH authorized keys file
|
||||
tags: [aegis, ssh, services]
|
||||
ansible.builtin.stat:
|
||||
path: "{{ aegis_ssh_user_home }}/.ssh/authorized_keys"
|
||||
register: aegis_authorized_keys
|
||||
|
||||
- name: Ensure Aegis SSH authorized key fragments directory exists
|
||||
tags: [aegis, ssh, services]
|
||||
ansible.builtin.file:
|
||||
path: "{{ aegis_ssh_user_home }}/.ssh/authorized_keys.d"
|
||||
state: directory
|
||||
owner: "{{ ansible_user }}"
|
||||
group: "{{ ansible_user }}"
|
||||
mode: "0700"
|
||||
when: aegis_ssh_authorized_keys | length > 0
|
||||
|
||||
- name: Manage Aegis SSH authorized key fragments
|
||||
tags: [aegis, ssh, services]
|
||||
ansible.builtin.copy:
|
||||
content: "{{ item.key }}\n"
|
||||
dest: "{{ aegis_ssh_user_home }}/.ssh/authorized_keys.d/{{ item.name }}"
|
||||
owner: "{{ ansible_user }}"
|
||||
group: "{{ ansible_user }}"
|
||||
mode: "0600"
|
||||
loop: "{{ aegis_ssh_authorized_keys }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
when: aegis_ssh_authorized_keys | length > 0
|
||||
|
||||
- name: Find Aegis SSH authorized key fragments
|
||||
tags: [aegis, ssh, services]
|
||||
ansible.builtin.find:
|
||||
paths: "{{ aegis_ssh_user_home }}/.ssh/authorized_keys.d"
|
||||
file_type: file
|
||||
recurse: false
|
||||
register: aegis_authorized_key_fragments
|
||||
|
||||
- name: Require an Aegis SSH authorized key before hardening
|
||||
tags: [aegis, ssh, services]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
(aegis_ssh_authorized_keys | length > 0)
|
||||
or (aegis_authorized_keys.stat.exists and aegis_authorized_keys.stat.size | int > 0)
|
||||
or aegis_authorized_key_fragments.matched | int > 0
|
||||
fail_msg: Add a public key for the Ansible SSH user before disabling password authentication.
|
||||
|
||||
- name: Ensure Aegis SSH configuration drop-in directory exists
|
||||
tags: [aegis, ssh, services]
|
||||
ansible.builtin.file:
|
||||
path: /etc/ssh/sshd_config.d
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Ensure Aegis SSH drop-ins are loaded before other settings
|
||||
tags: [aegis, ssh, services]
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/ssh/sshd_config
|
||||
regexp: '^\s*Include\s+/etc/ssh/sshd_config\.d/\*\.conf\s*$'
|
||||
line: Include /etc/ssh/sshd_config.d/*.conf
|
||||
insertbefore: BOF
|
||||
state: present
|
||||
validate: "sshd -t -f %s"
|
||||
|
||||
- name: Render Aegis SSH hardening drop-in
|
||||
tags: [aegis, ssh, services]
|
||||
ansible.builtin.template:
|
||||
src: 00-aegis-hardening.conf.j2
|
||||
dest: /etc/ssh/sshd_config.d/00-aegis-hardening.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
notify: Reload Aegis SSH
|
||||
|
||||
- name: Validate Aegis SSH configuration
|
||||
tags: [aegis, ssh, services]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- sshd
|
||||
- -t
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Read effective Aegis SSH daemon configuration
|
||||
tags: [aegis, ssh, services]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- sshd
|
||||
- -T
|
||||
- -C
|
||||
- "user={{ ansible_user }},host={{ aegis_hostname }},addr=127.0.0.1"
|
||||
register: aegis_sshd_effective_configuration
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Verify effective Aegis SSH hardening
|
||||
tags: [aegis, ssh, services]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'permitrootlogin no' in aegis_sshd_effective_configuration.stdout_lines"
|
||||
- "'pubkeyauthentication yes' in aegis_sshd_effective_configuration.stdout_lines"
|
||||
- "'passwordauthentication no' in aegis_sshd_effective_configuration.stdout_lines"
|
||||
- "'kbdinteractiveauthentication no' in aegis_sshd_effective_configuration.stdout_lines"
|
||||
- "'allowusers ' + (aegis_ssh_allowed_users | join(' ')) in aegis_sshd_effective_configuration.stdout_lines"
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Install Wake-on-LAN helper for Ikaros
|
||||
tags: [aegis, wol]
|
||||
ansible.builtin.template:
|
||||
src: wake-ikaros.j2
|
||||
dest: /usr/local/bin/wake-ikaros
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Enable Aegis Quadlet services and automatic updates
|
||||
tags: [aegis, containers, services]
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
loop:
|
||||
- adguardhome.service
|
||||
- icloudpd.service
|
||||
- podman-auto-update.timer
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
@@ -0,0 +1,6 @@
|
||||
# Managed by Ansible. Do not edit manually.
|
||||
PermitRootLogin no
|
||||
PubkeyAuthentication yes
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
AllowUsers {{ aegis_ssh_allowed_users | join(' ') }}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Managed by Ansible. Do not edit manually.
|
||||
[Resolve]
|
||||
DNSStubListener=no
|
||||
@@ -0,0 +1,18 @@
|
||||
# Managed by Ansible. Do not edit manually.
|
||||
[Unit]
|
||||
Description=AdGuard Home DNS sinkhole
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Container]
|
||||
Image={{ aegis_adguard_image }}
|
||||
Network=host
|
||||
Volume=/var/lib/adguard/work:/opt/adguardhome/work:Z
|
||||
Volume=/var/lib/adguard/conf:/opt/adguardhome/conf:Z
|
||||
AutoUpdate=registry
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<service>
|
||||
<short>Aegis AdGuard Home web interface</short>
|
||||
<description>AdGuard Home administrative web interface on Aegis.</description>
|
||||
<port protocol="tcp" port="{{ aegis_adguard_web_port }}"/>
|
||||
</service>
|
||||
20
ansible/roles/profile_aegis/templates/icloudpd.container.j2
Normal file
20
ansible/roles/profile_aegis/templates/icloudpd.container.j2
Normal file
@@ -0,0 +1,20 @@
|
||||
# Managed by Ansible. Do not edit manually.
|
||||
[Unit]
|
||||
Description=iCloud Photos Downloader
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Container]
|
||||
Image={{ aegis_icloudpd_image }}
|
||||
Environment=apple_id={{ aegis_icloudpd_apple_id }}
|
||||
Environment=folder_structure={{ aegis_icloudpd_folder_structure }}
|
||||
Environment=synchronisation_interval={{ aegis_icloudpd_synchronisation_interval }}
|
||||
Volume=/var/lib/icloudpd/data:/home/root/iCloud:Z
|
||||
Volume=/var/lib/icloudpd/config:/config:Z
|
||||
AutoUpdate=registry
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
21
ansible/roles/profile_aegis/templates/wake-ikaros.j2
Normal file
21
ansible/roles/profile_aegis/templates/wake-ikaros.j2
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Managed by Ansible. Do not edit manually.
|
||||
set -euo pipefail
|
||||
|
||||
mac='{{ aegis_ikaros_mac_address }}'
|
||||
port='{{ aegis_wol_port }}'
|
||||
mac_hex="${mac//:/}"
|
||||
|
||||
if [[ ! $mac_hex =~ ^[[:xdigit:]]{12}$ ]]; then
|
||||
printf 'Indirizzo MAC non valido: %s\n' "$mac" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
packet='\xFF\xFF\xFF\xFF\xFF\xFF'
|
||||
for _ in {1..16}; do
|
||||
packet+="$(printf '\\x%s\\x%s\\x%s\\x%s\\x%s\\x%s' \
|
||||
"${mac_hex:0:2}" "${mac_hex:2:2}" "${mac_hex:4:2}" \
|
||||
"${mac_hex:6:2}" "${mac_hex:8:2}" "${mac_hex:10:2}")"
|
||||
done
|
||||
|
||||
printf '%b' "$packet" > "/dev/udp/255.255.255.255/${port}"
|
||||
212
ansible/roles/profile_atlas/defaults/main.yml
Normal file
212
ansible/roles/profile_atlas/defaults/main.yml
Normal file
@@ -0,0 +1,212 @@
|
||||
---
|
||||
atlas_manage_storage: false
|
||||
atlas_manage_sharing: false
|
||||
# Destructive first-boot action; normally false once the pool exists.
|
||||
atlas_create_pool: false
|
||||
atlas_zpool_disks: []
|
||||
atlas_admin_username: CHANGEME_ATLAS_ADMIN
|
||||
atlas_admin_group: "{{ atlas_admin_username }}"
|
||||
atlas_admin_home: "/home/{{ atlas_admin_username }}"
|
||||
atlas_admin_uid: 1000
|
||||
atlas_admin_gid: 1000
|
||||
atlas_admin_ssh_keys: []
|
||||
atlas_admin_ssh_key_directory: "{{ atlas_admin_home }}/.ssh/authorized_keys.d"
|
||||
atlas_admin_password_hash: "!"
|
||||
# Local forwarding permits an administrator to tunnel a private service without allowing remote forwards.
|
||||
atlas_ssh_allow_tcp_forwarding: local
|
||||
|
||||
atlas_immich_username: immich
|
||||
atlas_immich_group: immich
|
||||
atlas_immich_uid: 1100
|
||||
atlas_immich_gid: 1100
|
||||
atlas_immich_supplementary_groups:
|
||||
- video
|
||||
- render
|
||||
|
||||
atlas_lan_subnet: CHANGEME_LAN_SUBNET
|
||||
atlas_aegis_ip: CHANGEME_AEGIS_IP
|
||||
atlas_manage_firewall: false
|
||||
atlas_firewalld_zone: public
|
||||
atlas_hardening_sysctls:
|
||||
net.ipv4.conf.all.accept_redirects: "0"
|
||||
net.ipv4.conf.default.accept_redirects: "0"
|
||||
net.ipv4.conf.all.send_redirects: "0"
|
||||
net.ipv4.conf.default.send_redirects: "0"
|
||||
net.ipv4.conf.all.accept_source_route: "0"
|
||||
net.ipv4.conf.default.accept_source_route: "0"
|
||||
net.ipv4.conf.all.log_martians: "1"
|
||||
net.ipv4.conf.default.log_martians: "1"
|
||||
net.ipv4.conf.all.rp_filter: "2"
|
||||
net.ipv4.conf.default.rp_filter: "2"
|
||||
net.ipv4.tcp_syncookies: "1"
|
||||
net.ipv4.icmp_echo_ignore_broadcasts: "1"
|
||||
net.ipv4.icmp_ignore_bogus_error_responses: "1"
|
||||
net.ipv4.ip_forward: "0"
|
||||
net.ipv6.conf.all.accept_redirects: "0"
|
||||
net.ipv6.conf.default.accept_redirects: "0"
|
||||
net.ipv6.conf.all.accept_source_route: "0"
|
||||
net.ipv6.conf.default.accept_source_route: "0"
|
||||
atlas_zfs_pool: CHANGEME_ZFS_POOL
|
||||
atlas_zfs_dataset_archive: archive
|
||||
atlas_zfs_dataset_services: services
|
||||
atlas_zfs_dataset_app_data: services/data
|
||||
atlas_zfs_dataset_navidrome: services/data/navidrome
|
||||
atlas_zfs_dataset_syncthing: services/data/syncthing
|
||||
atlas_zfs_dataset_media: media
|
||||
atlas_zfs_dataset_music: media/music
|
||||
atlas_zfs_dataset_backup: backup
|
||||
atlas_zfs_dataset_host_backups: backup/hosts
|
||||
atlas_zfs_dataset_backup_prometheus: backup/hosts/prometheus
|
||||
atlas_zfs_backup_reservation: 500G
|
||||
atlas_zfs_dataset_photobook: media/photobook
|
||||
atlas_mount_root: /CHANGEME_ATLAS_MOUNT_ROOT
|
||||
|
||||
atlas_manage_zfs_snapshots: false
|
||||
atlas_zfs_snapshot_prefix: atlas-auto
|
||||
atlas_zfs_snapshot_policies: []
|
||||
atlas_manage_zfs_scrub: false
|
||||
atlas_zfs_scrub_calendar: ""
|
||||
|
||||
atlas_manage_borg_backup: false
|
||||
atlas_borg_username: borg
|
||||
atlas_borg_group: borg
|
||||
atlas_borg_home: /var/lib/atlas-borg
|
||||
atlas_borg_repository_host: CHANGEME_BORG_HOST
|
||||
atlas_borg_repository_user: CHANGEME_BORG_USER
|
||||
atlas_borg_repository_port: 23
|
||||
atlas_borg_repository_path: ./borg-data
|
||||
atlas_borg_remote_path: borg-1.4
|
||||
atlas_borg_host_key: ""
|
||||
atlas_borg_ssh_private_key_path: /etc/atlas-borg/id_ed25519
|
||||
atlas_borg_known_hosts_path: /etc/atlas-borg/known_hosts
|
||||
atlas_borg_passphrase_path: /etc/atlas-borg/passphrase
|
||||
atlas_borg_ssh_wrapper_path: /usr/local/libexec/atlas-borg-ssh
|
||||
atlas_borg_passphrase: "{{ vault_atlas_borg_passphrase | default('') }}"
|
||||
atlas_borg_encryption_mode: repokey
|
||||
atlas_borg_archive_prefix: atlas
|
||||
atlas_borg_snapshot_prefix: atlas-borg
|
||||
atlas_borg_compression: auto,zstd,3
|
||||
atlas_borg_backup_calendar: ""
|
||||
atlas_borg_check_calendar: ""
|
||||
atlas_borg_randomized_delay: 30m
|
||||
atlas_borg_keep_daily: 30
|
||||
atlas_borg_keep_weekly: 8
|
||||
atlas_borg_keep_monthly: 12
|
||||
atlas_borg_config_dir: /var/lib/atlas-borg
|
||||
atlas_borg_cache_dir: /var/cache/atlas-borg
|
||||
atlas_borg_lock_path: /var/lib/atlas-borg/backup.lock
|
||||
atlas_borg_recovery_export_path: "{{ playbook_dir }}/../secrets/recovery/atlas-borg-repokey.export"
|
||||
|
||||
# Manual-only offline backup. No USB device is formatted or mounted by Ansible.
|
||||
atlas_manage_usb_backup: false
|
||||
atlas_usb_backup_luks_uuid: ""
|
||||
atlas_usb_backup_fs_uuid: ""
|
||||
atlas_usb_backup_mapper_name: atlas-usb-backup
|
||||
atlas_usb_backup_min_free_bytes: 10737418240
|
||||
atlas_usb_backup_snapshot_prefix: atlas-usb
|
||||
atlas_manage_usb_reminder: false
|
||||
atlas_usb_reminder_calendar: ""
|
||||
atlas_usb_reminder_notifier: /opt/45drives/houston/houston-notify
|
||||
|
||||
# Read-only health probes and 45Drives Alerts; disabled outside Atlas host vars.
|
||||
atlas_manage_monitoring: false
|
||||
atlas_monitor_calendar: "*:0/30"
|
||||
atlas_monitor_notifier: "{{ atlas_usb_reminder_notifier }}"
|
||||
atlas_monitor_smart_devices: []
|
||||
atlas_monitor_timers: []
|
||||
atlas_monitor_failure_units: []
|
||||
atlas_monitor_remote_capacity: {}
|
||||
atlas_monitor_pool_warning_percent: 80
|
||||
atlas_monitor_pool_critical_percent: 90
|
||||
atlas_monitor_root_warning_percent: 80
|
||||
atlas_monitor_root_critical_percent: 90
|
||||
atlas_monitor_snapshot_warning_percent: 10
|
||||
atlas_monitor_snapshot_critical_percent: 20
|
||||
atlas_monitor_snapshot_growth_warning_gib_day: 100
|
||||
atlas_monitor_backup_growth_warning_gib_day: 100
|
||||
atlas_monitor_cpu_warning_c: 85
|
||||
atlas_monitor_cpu_critical_c: 95
|
||||
atlas_monitor_borg_max_runtime_days: 14
|
||||
|
||||
# Explicit post-restore relabeling only; never relabel datasets during ordinary runs.
|
||||
atlas_restorecon_paths: []
|
||||
|
||||
atlas_archive_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_archive }}"
|
||||
atlas_services_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_services }}"
|
||||
atlas_app_data_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_app_data }}"
|
||||
atlas_navidrome_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_navidrome }}"
|
||||
atlas_syncthing_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_syncthing }}"
|
||||
atlas_media_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_media }}"
|
||||
atlas_music_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_music }}"
|
||||
atlas_backup_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_backup }}"
|
||||
atlas_host_backups_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_host_backups }}"
|
||||
atlas_backup_prometheus_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_backup_prometheus }}"
|
||||
atlas_photobook_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_photobook }}"
|
||||
|
||||
atlas_45drives_repo_url: https://repo.45drives.com/repofiles/rocky/45drives-enterprise.repo
|
||||
atlas_45drives_repo_file: /etc/yum.repos.d/45drives-enterprise.repo
|
||||
atlas_45drives_packages:
|
||||
- cockpit-file-sharing
|
||||
- cockpit-navigator
|
||||
- cockpit-identities
|
||||
- cockpit-zfs
|
||||
- cockpit-scheduler
|
||||
|
||||
atlas_nfs_exports:
|
||||
- path: "{{ atlas_photobook_mountpoint }}"
|
||||
client: "{{ atlas_aegis_ip }}"
|
||||
options:
|
||||
- rw
|
||||
- sync
|
||||
- no_subtree_check
|
||||
- all_squash
|
||||
- "anonuid={{ atlas_immich_uid }}"
|
||||
- "anongid={{ atlas_immich_gid }}"
|
||||
atlas_samba_share_name: Archive
|
||||
atlas_samba_workgroup: WORKGROUP
|
||||
# Keep encryption mandatory unless a verified client-compatibility exception is explicitly required.
|
||||
atlas_samba_encryption: required
|
||||
atlas_samba_valid_users: []
|
||||
atlas_samba_accounts: []
|
||||
atlas_samba_password_marker_dir: /var/lib/samba/private/ansible-passwords
|
||||
atlas_firewalld_rich_rules: []
|
||||
atlas_firewalld_restricted_services:
|
||||
- ssh
|
||||
- cockpit
|
||||
- nfs
|
||||
- samba
|
||||
- http
|
||||
- https
|
||||
atlas_selinux_booleans:
|
||||
- samba_export_all_rw
|
||||
- nfs_export_all_rw
|
||||
|
||||
atlas_manage_media_stack: false
|
||||
atlas_container_quadlet_dir: /etc/containers/systemd
|
||||
atlas_container_data_root: /var/lib/atlas-containers
|
||||
atlas_npm_data_dir: "{{ atlas_container_data_root }}/npm/data"
|
||||
atlas_npm_letsencrypt_dir: "{{ atlas_container_data_root }}/npm/letsencrypt"
|
||||
atlas_npm_admin_bind_address: 127.0.0.1
|
||||
atlas_timezone: Europe/Rome
|
||||
atlas_immich_upload_dir: "{{ atlas_container_data_root }}/immich/upload"
|
||||
atlas_immich_model_cache_dir: "{{ atlas_container_data_root }}/immich/model-cache"
|
||||
atlas_immich_machine_learning_config_dir: "{{ atlas_container_data_root }}/immich/machine-learning-config"
|
||||
atlas_immich_machine_learning_cache_dir: "{{ atlas_container_data_root }}/immich/machine-learning-cache"
|
||||
atlas_immich_redis_data_dir: "{{ atlas_container_data_root }}/immich/redis"
|
||||
atlas_immich_postgres_data_dir: "{{ atlas_container_data_root }}/immich/postgres"
|
||||
|
||||
atlas_npm_image: docker.io/jc21/nginx-proxy-manager:latest
|
||||
atlas_immich_version: release
|
||||
atlas_immich_server_image: "ghcr.io/immich-app/immich-server:{{ atlas_immich_version }}"
|
||||
atlas_immich_machine_learning_image: "ghcr.io/immich-app/immich-machine-learning:{{ atlas_immich_version }}-openvino"
|
||||
atlas_immich_redis_image: docker.io/valkey/valkey:9
|
||||
atlas_immich_postgres_image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
|
||||
atlas_immich_db_username: postgres
|
||||
atlas_immich_db_name: immich
|
||||
atlas_immich_db_password: "{{ vault_atlas_immich_db_password | default('') }}"
|
||||
atlas_media_quadlet_services:
|
||||
- atlas-immich-redis.service
|
||||
- atlas-immich-postgres.service
|
||||
- atlas-immich-machine-learning.service
|
||||
- atlas-immich-server.service
|
||||
- atlas-npm.service
|
||||
59
ansible/roles/profile_atlas/files/atlas-borg-progress.py
Normal file
59
ansible/roles/profile_atlas/files/atlas-borg-progress.py
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Turn Borg's JSON progress stream into bounded, readable journal entries."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def size(value):
|
||||
if not isinstance(value, (int, float)):
|
||||
return "unknown"
|
||||
return f"{value / (1024 ** 3):.2f} GiB"
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--estimated-total-bytes", type=int, required=True)
|
||||
args = parser.parse_args()
|
||||
if args.estimated_total_bytes <= 0:
|
||||
parser.error("estimated total must be positive")
|
||||
|
||||
last_progress = 0.0
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
print(line.rstrip(), flush=True)
|
||||
continue
|
||||
|
||||
kind = event.get("type")
|
||||
if kind == "archive_progress":
|
||||
now = time.monotonic()
|
||||
if now - last_progress < 60 and not event.get("finished"):
|
||||
continue
|
||||
path = event.get("path") or ""
|
||||
parts = path.split("/")
|
||||
dataset = parts[1] if len(parts) > 1 and parts[0] == "source" else "unknown"
|
||||
original_size = event.get("original_size")
|
||||
if isinstance(original_size, (int, float)) and original_size >= 0:
|
||||
percent = original_size / args.estimated_total_bytes * 100
|
||||
estimated_progress = (
|
||||
f"{percent:.1f}%" if percent < 100 else ">=100% (ZFS estimate exceeded)"
|
||||
)
|
||||
else:
|
||||
estimated_progress = "unknown"
|
||||
print(
|
||||
"Borg create progress: "
|
||||
f"estimated={estimated_progress} dataset={dataset} "
|
||||
f"files={event.get('nfiles', 'unknown')} "
|
||||
f"original={size(original_size)} "
|
||||
f"compressed={size(event.get('compressed_size'))} "
|
||||
f"deduplicated={size(event.get('deduplicated_size'))}",
|
||||
flush=True,
|
||||
)
|
||||
last_progress = now
|
||||
elif kind == "log_message":
|
||||
print(f"Borg {event.get('levelname', 'INFO')}: {event.get('message', '')}", flush=True)
|
||||
elif kind == "progress_message" and event.get("message"):
|
||||
print(f"Borg: {event['message']}", flush=True)
|
||||
396
ansible/roles/profile_atlas/files/atlas-health-monitor.py
Normal file
396
ansible/roles/profile_atlas/files/atlas-health-monitor.py
Normal file
@@ -0,0 +1,396 @@
|
||||
#!/usr/bin/python3
|
||||
"""Read-only Atlas health probes with deduplicated 45Drives Alerts."""
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONFIG_PATH = Path("/etc/atlas-health-monitor.json")
|
||||
STATE_DIR = Path("/var/lib/atlas-health-monitor")
|
||||
STATE_PATH = STATE_DIR / "state.json"
|
||||
GIB = 1024**3
|
||||
|
||||
|
||||
def run(*argv, timeout=40):
|
||||
return subprocess.run(argv, capture_output=True, text=True, timeout=timeout, check=False)
|
||||
|
||||
|
||||
def issue(issues, key, severity, message):
|
||||
issues[key] = {"severity": severity, "message": message}
|
||||
|
||||
|
||||
def notify(config, event, severity, subject, message):
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"timestamp": now.isoformat(timespec="seconds"),
|
||||
"unixtime": int(now.timestamp()),
|
||||
"event": event,
|
||||
"severity": severity,
|
||||
"subject": subject,
|
||||
"email_message": message,
|
||||
}
|
||||
result = run(config["notifier"], json.dumps(payload, ensure_ascii=False), timeout=30)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"45Drives notifier exited {result.returncode}: {result.stderr.strip()}")
|
||||
|
||||
|
||||
def parse_fields(text):
|
||||
return dict(line.split("=", 1) for line in text.splitlines() if "=" in line)
|
||||
|
||||
|
||||
def systemd_fields(unit, *properties):
|
||||
result = run("systemctl", "show", unit, *(f"-p{item}" for item in properties))
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"systemctl show {unit} exited {result.returncode}")
|
||||
return parse_fields(result.stdout)
|
||||
|
||||
|
||||
def unix_time(text):
|
||||
if not text or text == "n/a":
|
||||
return None
|
||||
result = run("date", "-d", text, "+%s")
|
||||
if result.returncode:
|
||||
raise ValueError(f"Cannot parse systemd timestamp: {text}")
|
||||
return int(result.stdout.strip())
|
||||
|
||||
|
||||
def check_pool(config, issues, measurements):
|
||||
pool = config["pool"]
|
||||
listing = run("zpool", "list", "-H", "-p", "-o", "size,alloc,capacity,health", pool)
|
||||
if listing.returncode:
|
||||
issue(issues, "pool.probe", "critical", f"Cannot query ZFS pool {pool}")
|
||||
return
|
||||
try:
|
||||
size, alloc, capacity, health = listing.stdout.strip().split("\t")
|
||||
size, alloc, capacity = int(size), int(alloc), int(capacity)
|
||||
except (ValueError, TypeError):
|
||||
issue(issues, "pool.probe", "critical", "Invalid ZFS pool capacity response")
|
||||
return
|
||||
measurements.update(pool_size_bytes=size, pool_alloc_bytes=alloc, pool_capacity_percent=capacity)
|
||||
if health != "ONLINE":
|
||||
issue(issues, "pool.health", "critical", f"ZFS pool {pool} state is {health}")
|
||||
if capacity >= config["pool_critical_percent"]:
|
||||
issue(issues, "pool.capacity", "critical", f"ZFS pool {pool} is {capacity}% full")
|
||||
elif capacity >= config["pool_warning_percent"]:
|
||||
issue(issues, "pool.capacity", "warning", f"ZFS pool {pool} is {capacity}% full")
|
||||
|
||||
status = run("zpool", "status", "-P", pool)
|
||||
if status.returncode:
|
||||
issue(issues, "pool.status", "critical", f"Cannot query detailed ZFS status for {pool}")
|
||||
return
|
||||
bad_vdevs = []
|
||||
for line in status.stdout.splitlines():
|
||||
match = re.match(r"^\s*(\S+)\s+(ONLINE|DEGRADED|FAULTED|OFFLINE|UNAVAIL|REMOVED)\s+(\d+)\s+(\d+)\s+(\d+)", line)
|
||||
if match:
|
||||
name, state, reads, writes, checksums = match.groups()
|
||||
if state != "ONLINE" or any(int(value) for value in (reads, writes, checksums)):
|
||||
bad_vdevs.append(f"{name}: {state}, READ={reads}, WRITE={writes}, CKSUM={checksums}")
|
||||
if bad_vdevs:
|
||||
issue(issues, "pool.vdevs", "critical", "ZFS vdev errors: " + "; ".join(bad_vdevs))
|
||||
errors = re.search(r"^errors:\s*(.*)$", status.stdout, re.MULTILINE)
|
||||
if not errors or errors.group(1).strip() != "No known data errors":
|
||||
issue(issues, "pool.data_errors", "critical", "ZFS status reports data errors; inspect zpool status -v")
|
||||
if re.search(r"^\s*scan:\s*resilver in progress", status.stdout, re.MULTILINE | re.IGNORECASE):
|
||||
issue(issues, "pool.resilver", "warning", "ZFS resilver is in progress; inspect zpool status")
|
||||
scan = re.search(r"^\s*scan:\s*(.*)$", status.stdout, re.MULTILINE)
|
||||
if scan and re.search(r"\bwith [1-9][0-9]* errors\b", scan.group(1)):
|
||||
issue(issues, "pool.scan_errors", "critical", f"ZFS scan reported errors: {scan.group(1)}")
|
||||
|
||||
|
||||
def check_capacity(config, issues, measurements):
|
||||
pool = config["pool"]
|
||||
listing = run("zfs", "list", "-H", "-p", "-o", "name,usedbysnapshots", "-r", pool)
|
||||
if listing.returncode:
|
||||
issue(issues, "snapshot.probe", "warning", "Cannot query ZFS snapshot space")
|
||||
else:
|
||||
try:
|
||||
snapshots = sum(int(line.split("\t")[1]) for line in listing.stdout.splitlines())
|
||||
measurements["snapshots_bytes"] = snapshots
|
||||
size = measurements.get("pool_size_bytes")
|
||||
if size:
|
||||
percent = snapshots * 100 // size
|
||||
measurements["snapshots_percent"] = percent
|
||||
if percent >= config["snapshot_critical_percent"]:
|
||||
issue(issues, "snapshot.capacity", "critical", f"Snapshots use {percent}% of pool size")
|
||||
elif percent >= config["snapshot_warning_percent"]:
|
||||
issue(issues, "snapshot.capacity", "warning", f"Snapshots use {percent}% of pool size")
|
||||
except (ValueError, IndexError):
|
||||
issue(issues, "snapshot.probe", "warning", "Invalid ZFS snapshot-space response")
|
||||
backup = run("zfs", "list", "-H", "-p", "-o", "used", config["backup_dataset"])
|
||||
if backup.returncode:
|
||||
issue(issues, "backup.capacity_probe", "warning", "Cannot query local backup dataset space")
|
||||
else:
|
||||
try:
|
||||
measurements["backup_bytes"] = int(backup.stdout.strip())
|
||||
except ValueError:
|
||||
issue(issues, "backup.capacity_probe", "warning", "Invalid local backup space response")
|
||||
|
||||
try:
|
||||
filesystem = os.statvfs("/")
|
||||
total = filesystem.f_blocks * filesystem.f_frsize
|
||||
available = filesystem.f_bavail * filesystem.f_frsize
|
||||
used_percent = (total - available) * 100 // total
|
||||
measurements["root_capacity_percent"] = used_percent
|
||||
if used_percent >= config["root_critical_percent"]:
|
||||
issue(issues, "root.capacity", "critical", f"Atlas system filesystem is {used_percent}% full")
|
||||
elif used_percent >= config["root_warning_percent"]:
|
||||
issue(issues, "root.capacity", "warning", f"Atlas system filesystem is {used_percent}% full")
|
||||
except (OSError, ZeroDivisionError):
|
||||
issue(issues, "root.capacity_probe", "warning", "Cannot query Atlas system filesystem space")
|
||||
|
||||
|
||||
def check_remote_capacity(config, issues, measurements):
|
||||
"""Query only the Storage Box quota; do not open or inspect the Borg repository."""
|
||||
remote = config["remote_capacity"]
|
||||
try:
|
||||
result = run("runuser", "-u", remote["run_as"], "--", remote["ssh_wrapper"],
|
||||
f"{remote['user']}@{remote['host']}", "df", "-m", timeout=65)
|
||||
if result.returncode:
|
||||
raise ValueError(f"SSH df exited {result.returncode}")
|
||||
lines = result.stdout.strip().splitlines()
|
||||
if len(lines) != 2:
|
||||
raise ValueError("Unexpected Storage Box df output")
|
||||
fields = lines[1].split()
|
||||
if len(fields) < 5:
|
||||
raise ValueError("Incomplete Storage Box df output")
|
||||
total_mib, used_mib, available_mib = (int(value) for value in fields[1:4])
|
||||
percent = int(fields[4].rstrip("%"))
|
||||
if total_mib <= 0 or not 0 <= percent <= 100 or available_mib < 0:
|
||||
raise ValueError("Invalid Storage Box quota values")
|
||||
except (OSError, ValueError, subprocess.TimeoutExpired):
|
||||
issue(issues, "remote.capacity_probe", "warning", "Cannot query Hetzner Storage Box quota via pinned-key SSH")
|
||||
return
|
||||
measurements.update(remote_capacity_percent=percent, remote_bytes=used_mib * 1024**2,
|
||||
remote_available_bytes=available_mib * 1024**2)
|
||||
if percent >= remote["critical_percent"]:
|
||||
issue(issues, "remote.capacity", "critical", f"Hetzner Storage Box quota is {percent}% full")
|
||||
elif percent >= remote["warning_percent"]:
|
||||
issue(issues, "remote.capacity", "warning", f"Hetzner Storage Box quota is {percent}% full")
|
||||
|
||||
|
||||
def check_smart(config, issues, measurements):
|
||||
for device in config["smart_devices"]:
|
||||
name, path = device["name"], device["path"]
|
||||
try:
|
||||
result = run("smartctl", "-j", "-a", path, timeout=60)
|
||||
data = json.loads(result.stdout)
|
||||
status = int(data.get("smartctl", {}).get("exit_status", result.returncode))
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, ValueError) as exc:
|
||||
issue(issues, f"smart.{name}.probe", "critical", f"SMART probe failed for {name}: {type(exc).__name__}")
|
||||
continue
|
||||
if status:
|
||||
severity = "critical" if status & 0b00001111 else "warning"
|
||||
issue(issues, f"smart.{name}.status", severity, f"SMART reported exit status {status} for {name}")
|
||||
passed = data.get("smart_status", {}).get("passed")
|
||||
if passed is False:
|
||||
issue(issues, f"smart.{name}.health", "critical", f"SMART self-assessment failed for {name}")
|
||||
elif passed is None:
|
||||
issue(issues, f"smart.{name}.health", "warning", f"SMART self-assessment unavailable for {name}")
|
||||
temperature = data.get("temperature", {}).get("current")
|
||||
if isinstance(temperature, (int, float)):
|
||||
measurements[f"smart_{name}_c"] = temperature
|
||||
if temperature >= device["critical_c"]:
|
||||
issue(issues, f"smart.{name}.temperature", "critical", f"{name} temperature is {temperature} C")
|
||||
elif temperature >= device["warning_c"]:
|
||||
issue(issues, f"smart.{name}.temperature", "warning", f"{name} temperature is {temperature} C")
|
||||
else:
|
||||
issue(issues, f"smart.{name}.temperature", "warning", f"Temperature unavailable for {name}")
|
||||
for attribute in data.get("ata_smart_attributes", {}).get("table", []):
|
||||
attribute_id = attribute.get("id")
|
||||
if attribute_id in (5, 187, 197, 198):
|
||||
raw = attribute.get("raw", {}).get("value", 0)
|
||||
if isinstance(raw, int) and raw > 0:
|
||||
severity = "critical" if attribute_id in (197, 198) else "warning"
|
||||
issue(issues, f"smart.{name}.ata_{attribute_id}", severity,
|
||||
f"{name} SMART attribute {attribute_id} raw count is {raw}")
|
||||
nvme = data.get("nvme_smart_health_information_log", {})
|
||||
if isinstance(nvme, dict):
|
||||
if int(nvme.get("critical_warning", 0)):
|
||||
issue(issues, f"smart.{name}.nvme_warning", "critical", f"{name} NVMe critical warning is nonzero")
|
||||
if int(nvme.get("media_errors", 0)):
|
||||
issue(issues, f"smart.{name}.nvme_media", "critical", f"{name} NVMe media errors are nonzero")
|
||||
|
||||
|
||||
def check_cpu(config, issues, measurements):
|
||||
sensors = []
|
||||
for hwmon in Path("/sys/class/hwmon").glob("hwmon*"):
|
||||
try:
|
||||
if (hwmon / "name").read_text().strip() != "coretemp":
|
||||
continue
|
||||
sensors.extend(int(path.read_text().strip()) / 1000 for path in hwmon.glob("temp*_input"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if not sensors:
|
||||
issue(issues, "cpu.temperature_probe", "warning", "CPU temperature sensors are unavailable")
|
||||
return
|
||||
hottest = max(sensors)
|
||||
measurements["cpu_max_c"] = hottest
|
||||
if hottest >= config["cpu_critical_c"]:
|
||||
issue(issues, "cpu.temperature", "critical", f"CPU temperature is {hottest:g} C")
|
||||
elif hottest >= config["cpu_warning_c"]:
|
||||
issue(issues, "cpu.temperature", "warning", f"CPU temperature is {hottest:g} C")
|
||||
|
||||
|
||||
def check_jobs(config, issues, measurements, now):
|
||||
for timer in config["timers"]:
|
||||
name = timer["name"]
|
||||
try:
|
||||
fields = systemd_fields(name, "ActiveState", "UnitFileState", "LastTriggerUSec", "ActiveEnterTimestamp")
|
||||
if fields.get("ActiveState") != "active" or fields.get("UnitFileState") != "enabled":
|
||||
issue(issues, f"timer.{name}", "critical", f"Timer {name} is not active and enabled")
|
||||
max_age = int(timer["max_age_hours"]) * 3600
|
||||
if max_age:
|
||||
last = unix_time(fields.get("LastTriggerUSec"))
|
||||
if last is None:
|
||||
last = unix_time(fields.get("ActiveEnterTimestamp"))
|
||||
if last is not None and now - last > max_age:
|
||||
issue(issues, f"timer.{name}.stale", "warning",
|
||||
f"Timer {name} has not fired in {int((now-last)/3600)} hours")
|
||||
except (RuntimeError, ValueError, subprocess.TimeoutExpired):
|
||||
issue(issues, f"timer.{name}.probe", "warning", f"Cannot query timer {name}")
|
||||
for unit in config["failure_units"]:
|
||||
if unit.endswith("@.service"):
|
||||
continue
|
||||
try:
|
||||
fields = systemd_fields(unit, "ActiveState", "Result", "ExecMainStartTimestamp")
|
||||
state = fields.get("ActiveState")
|
||||
if state == "failed" or (state == "inactive" and fields.get("Result") not in (None, "", "success")):
|
||||
issue(issues, f"service.{unit}", "critical", f"Service {unit} failed: {fields.get('Result')}")
|
||||
if unit == "atlas-borg-backup.service" and fields.get("ActiveState") == "activating":
|
||||
started = unix_time(fields.get("ExecMainStartTimestamp"))
|
||||
if started is not None and now - started > config["borg_max_runtime_days"] * 86400:
|
||||
issue(issues, "backup.borg_long_running", "warning",
|
||||
"Borg has run longer than its configured limit")
|
||||
except (RuntimeError, ValueError, subprocess.TimeoutExpired):
|
||||
issue(issues, f"service.{unit}.probe", "warning", f"Cannot query service {unit}")
|
||||
|
||||
|
||||
def check_growth(config, issues, measurements, samples, now):
|
||||
previous = [sample for sample in samples if 20 * 3600 <= now - sample.get("time", now) <= 48 * 3600]
|
||||
if previous:
|
||||
baseline = min(previous, key=lambda sample: abs(now - sample["time"] - 86400))
|
||||
days = (now - baseline["time"]) / 86400
|
||||
for name, threshold in (("snapshots", config["snapshot_growth_warning_gib_day"]),
|
||||
("backup", config["backup_growth_warning_gib_day"]),
|
||||
("remote", config["remote_capacity"]["growth_warning_gib_day"])):
|
||||
current, old = measurements.get(f"{name}_bytes"), baseline.get(f"{name}_bytes")
|
||||
if isinstance(current, int) and isinstance(old, int) and days > 0:
|
||||
growth_gib_day = (current - old) / GIB / days
|
||||
measurements[f"{name}_growth_gib_day"] = round(growth_gib_day, 1)
|
||||
if growth_gib_day >= threshold:
|
||||
issue(issues, f"{name}.growth", "warning",
|
||||
f"Local {name} usage grew {growth_gib_day:.1f} GiB/day over {days:.1f} days")
|
||||
|
||||
|
||||
def allowed_failure_unit(config, unit):
|
||||
for allowed in config["failure_units"]:
|
||||
if allowed == unit:
|
||||
return True
|
||||
if allowed.endswith("@.service") and unit.startswith(allowed[:-9] + "@") and unit.endswith(".service"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def load_state():
|
||||
if not STATE_PATH.exists():
|
||||
return {"active": {}, "samples": []}
|
||||
with STATE_PATH.open(encoding="utf-8") as stream:
|
||||
state = json.load(stream)
|
||||
if not isinstance(state.get("active"), dict) or not isinstance(state.get("samples"), list):
|
||||
raise ValueError("Invalid Atlas monitor state; refusing to overwrite it")
|
||||
return state
|
||||
|
||||
|
||||
def save_state(state):
|
||||
with tempfile.NamedTemporaryFile("w", dir=STATE_DIR, prefix=".state-", delete=False,
|
||||
encoding="utf-8") as stream:
|
||||
path = Path(stream.name)
|
||||
os.chmod(path, 0o600)
|
||||
json.dump(state, stream, sort_keys=True)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(path, STATE_PATH)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dry-run", action="store_true", help="probe without notifications or state changes")
|
||||
parser.add_argument("--test-notification", action="store_true", help="submit a labelled test alert")
|
||||
parser.add_argument("--job-failed", metavar="UNIT", help="notify about a failed configured service")
|
||||
args = parser.parse_args()
|
||||
with CONFIG_PATH.open(encoding="utf-8") as stream:
|
||||
config = json.load(stream)
|
||||
if args.test_notification:
|
||||
notify(config, "atlas_monitor_test", "warning", "Test monitoraggio Atlas",
|
||||
"Notifica di prova: il monitoraggio Atlas raggiunge 45Drives Alerts. Non conferma l'invio email.")
|
||||
print("Atlas monitor test submitted to 45Drives Alerts; email delivery is not verified.")
|
||||
return 0
|
||||
if args.job_failed:
|
||||
if not allowed_failure_unit(config, args.job_failed):
|
||||
raise ValueError("Unconfigured Atlas failure unit")
|
||||
notify(config, "atlas_job_failed", "critical", f"Job Atlas fallito: {args.job_failed}",
|
||||
f"Il servizio {args.job_failed} e' fallito. Controlla: "
|
||||
f"sudo journalctl -u {args.job_failed} -n 100 --no-pager")
|
||||
print(f"Atlas job failure submitted to 45Drives Alerts: {args.job_failed}")
|
||||
return 0
|
||||
|
||||
now = int(time.time())
|
||||
issues, measurements = {}, {}
|
||||
check_pool(config, issues, measurements)
|
||||
check_capacity(config, issues, measurements)
|
||||
check_remote_capacity(config, issues, measurements)
|
||||
check_smart(config, issues, measurements)
|
||||
check_cpu(config, issues, measurements)
|
||||
check_jobs(config, issues, measurements, now)
|
||||
if args.dry_run:
|
||||
print(json.dumps({"issues": issues, "measurements": measurements}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
STATE_DIR.mkdir(mode=0o700, exist_ok=True)
|
||||
with (STATE_DIR / "monitor.lock").open("w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
state = load_state()
|
||||
check_growth(config, issues, measurements, state["samples"], now)
|
||||
active, failed_notifications = state["active"], []
|
||||
for key, details in issues.items():
|
||||
old = active.get(key)
|
||||
if old is None or old.get("severity") != details["severity"]:
|
||||
try:
|
||||
notify(config, "atlas_health_issue", details["severity"],
|
||||
f"Atlas: {key}", details["message"])
|
||||
active[key] = details
|
||||
print(f"ALERT {details['severity']} {key}: {details['message']}", flush=True)
|
||||
except (RuntimeError, subprocess.TimeoutExpired) as exc:
|
||||
failed_notifications.append(key)
|
||||
print(f"NOTIFICATION FAILED {key}: {exc}", file=sys.stderr, flush=True)
|
||||
for key in set(active) - set(issues):
|
||||
print(f"RECOVERED {key}", flush=True)
|
||||
del active[key]
|
||||
state["samples"] = [sample for sample in state["samples"] if now - sample.get("time", 0) < 48 * 3600]
|
||||
state["samples"].append({"time": now, **{key: value for key, value in measurements.items()
|
||||
if key in ("snapshots_bytes", "backup_bytes", "remote_bytes")}})
|
||||
save_state(state)
|
||||
print(f"Atlas health: issues={len(issues)} notifications_failed={len(failed_notifications)} "
|
||||
f"pool={measurements.get('pool_capacity_percent', 'unknown')}% "
|
||||
f"remote={measurements.get('remote_capacity_percent', 'unknown')}% "
|
||||
f"snapshots={measurements.get('snapshots_bytes', 'unknown')} bytes "
|
||||
f"backup={measurements.get('backup_bytes', 'unknown')} bytes", flush=True)
|
||||
return 1 if failed_notifications else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except (OSError, RuntimeError, ValueError, subprocess.TimeoutExpired) as error:
|
||||
print(f"Atlas health monitor failed: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
53
ansible/roles/profile_atlas/handlers/main.yml
Normal file
53
ansible/roles/profile_atlas/handlers/main.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
- name: Reload SSH service
|
||||
ansible.builtin.systemd:
|
||||
name: sshd
|
||||
state: reloaded
|
||||
|
||||
- name: Reload NFS exports
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- exportfs
|
||||
- -ra
|
||||
changed_when: true
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Restart NFS server
|
||||
ansible.builtin.systemd:
|
||||
name: nfs-server
|
||||
state: restarted
|
||||
|
||||
- name: Restart NFS mount daemon
|
||||
ansible.builtin.systemd:
|
||||
name: nfs-mountd
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
|
||||
- name: Restart Samba service
|
||||
ansible.builtin.systemd:
|
||||
name: smb
|
||||
state: restarted
|
||||
|
||||
- name: Restart Atlas Borg timers
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
loop:
|
||||
- atlas-borg-backup.timer
|
||||
- atlas-borg-check.timer
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Restart Atlas media Quadlets
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
loop: "{{ atlas_media_quadlet_services }}"
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
when:
|
||||
- atlas_manage_media_stack | bool
|
||||
- not ansible_check_mode
|
||||
180
ansible/roles/profile_atlas/tasks/account.yml
Normal file
180
ansible/roles/profile_atlas/tasks/account.yml
Normal file
@@ -0,0 +1,180 @@
|
||||
---
|
||||
- name: Reject incomplete Atlas account configuration
|
||||
tags: [atlas, services]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_admin_username != 'CHANGEME_ATLAS_ADMIN'
|
||||
- (atlas_admin_ssh_keys | default([])) | length > 0
|
||||
- atlas_admin_password_hash not in ['', '!', '*']
|
||||
- atlas_ssh_allow_tcp_forwarding in ['no', 'yes', 'local', 'remote', 'all']
|
||||
- "'wheel' not in atlas_immich_supplementary_groups"
|
||||
fail_msg: >-
|
||||
Define atlas_admin_username, atlas_admin_ssh_keys and vault_atlas_admin_password_hash
|
||||
before applying the Atlas profile.
|
||||
no_log: true
|
||||
|
||||
- name: Create Atlas administrator group
|
||||
tags: [atlas, services]
|
||||
ansible.builtin.group:
|
||||
name: "{{ atlas_admin_group }}"
|
||||
gid: "{{ atlas_admin_gid }}"
|
||||
state: present
|
||||
|
||||
- name: Create Atlas administrator account
|
||||
tags: [atlas, services]
|
||||
ansible.builtin.user:
|
||||
name: "{{ atlas_admin_username }}"
|
||||
uid: "{{ atlas_admin_uid }}"
|
||||
group: "{{ atlas_admin_group }}"
|
||||
home: "{{ atlas_admin_home }}"
|
||||
shell: /bin/bash
|
||||
password: "{{ atlas_admin_password_hash }}"
|
||||
create_home: true
|
||||
state: present
|
||||
no_log: true
|
||||
|
||||
- name: Create Immich primary group
|
||||
tags: [atlas, accounts, containers, immich]
|
||||
ansible.builtin.group:
|
||||
name: "{{ atlas_immich_group }}"
|
||||
gid: "{{ atlas_immich_gid }}"
|
||||
state: present
|
||||
|
||||
- name: Ensure Immich hardware-access groups exist
|
||||
tags: [atlas, accounts, containers, immich]
|
||||
ansible.builtin.group:
|
||||
name: "{{ item }}"
|
||||
state: present
|
||||
loop: "{{ atlas_immich_supplementary_groups }}"
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
|
||||
- name: Create unprivileged Immich account
|
||||
tags: [atlas, accounts, containers, immich]
|
||||
ansible.builtin.user:
|
||||
name: "{{ atlas_immich_username }}"
|
||||
uid: "{{ atlas_immich_uid }}"
|
||||
group: "{{ atlas_immich_group }}"
|
||||
groups: "{{ atlas_immich_supplementary_groups }}"
|
||||
append: false
|
||||
home: /nonexistent
|
||||
create_home: false
|
||||
shell: /sbin/nologin
|
||||
system: true
|
||||
state: present
|
||||
|
||||
- name: Read Immich hardware-access group IDs
|
||||
tags: [atlas, accounts, containers, immich]
|
||||
ansible.builtin.getent:
|
||||
database: group
|
||||
|
||||
- name: Grant Atlas administrator passwordless sudo
|
||||
tags: [atlas, services]
|
||||
ansible.builtin.copy:
|
||||
content: "{{ atlas_admin_username }} ALL=(ALL) NOPASSWD: ALL\n"
|
||||
dest: "/etc/sudoers.d/{{ atlas_admin_username }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0440"
|
||||
validate: "visudo -cf %s"
|
||||
|
||||
- name: Ensure Atlas administrator SSH authorized key fragments directory exists
|
||||
tags: [atlas, services]
|
||||
ansible.builtin.file:
|
||||
path: "{{ atlas_admin_ssh_key_directory }}"
|
||||
state: directory
|
||||
owner: "{{ atlas_admin_username }}"
|
||||
group: "{{ atlas_admin_group }}"
|
||||
mode: "0700"
|
||||
|
||||
- name: Manage Atlas administrator SSH authorized key fragments
|
||||
tags: [atlas, services]
|
||||
ansible.builtin.copy:
|
||||
content: "{{ item.key }}\n"
|
||||
dest: "{{ atlas_admin_ssh_key_directory }}/{{ item.name }}"
|
||||
owner: "{{ atlas_admin_username }}"
|
||||
group: "{{ atlas_admin_group }}"
|
||||
mode: "0600"
|
||||
loop: "{{ atlas_admin_ssh_keys }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
|
||||
- name: Check whether the Atlas SSH host key exists
|
||||
tags: [atlas, services]
|
||||
ansible.builtin.stat:
|
||||
path: /etc/ssh/ssh_host_ed25519_key
|
||||
register: atlas_ssh_host_ed25519_key
|
||||
|
||||
- name: Generate missing Atlas SSH host keys
|
||||
tags: [atlas, services]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- ssh-keygen
|
||||
- -A
|
||||
changed_when: true
|
||||
when: not atlas_ssh_host_ed25519_key.stat.exists
|
||||
|
||||
- name: Ensure Atlas SSH configuration drop-in directory exists
|
||||
tags: [atlas, services]
|
||||
ansible.builtin.file:
|
||||
path: /etc/ssh/sshd_config.d
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Ensure Atlas SSH drop-ins are loaded before other settings
|
||||
tags: [atlas, services]
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/ssh/sshd_config
|
||||
regexp: '^\s*Include\s+/etc/ssh/sshd_config\.d/\*\.conf\s*$'
|
||||
line: Include /etc/ssh/sshd_config.d/*.conf
|
||||
insertbefore: BOF
|
||||
state: present
|
||||
validate: "sshd -t -f %s"
|
||||
|
||||
- name: Render Atlas SSH hardening drop-in
|
||||
tags: [atlas, services]
|
||||
ansible.builtin.template:
|
||||
src: 00-atlas-hardening.conf.j2
|
||||
dest: /etc/ssh/sshd_config.d/00-atlas-hardening.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
notify: Reload SSH service
|
||||
|
||||
- name: Read effective Atlas SSH daemon configuration
|
||||
tags: [atlas, services]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- sshd
|
||||
- -T
|
||||
- -C
|
||||
- "user={{ atlas_admin_username }},host=atlas,addr=127.0.0.1"
|
||||
register: atlas_sshd_effective_configuration
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Verify effective Atlas SSH hardening
|
||||
tags: [atlas, services]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'permitrootlogin no' in atlas_sshd_effective_configuration.stdout_lines"
|
||||
- "'pubkeyauthentication yes' in atlas_sshd_effective_configuration.stdout_lines"
|
||||
- "'passwordauthentication no' in atlas_sshd_effective_configuration.stdout_lines"
|
||||
- "'kbdinteractiveauthentication no' in atlas_sshd_effective_configuration.stdout_lines"
|
||||
- "'authenticationmethods publickey' in atlas_sshd_effective_configuration.stdout_lines"
|
||||
- "'x11forwarding no' in atlas_sshd_effective_configuration.stdout_lines"
|
||||
- "('allowtcpforwarding ' ~ atlas_ssh_allow_tcp_forwarding) in atlas_sshd_effective_configuration.stdout_lines"
|
||||
- "'allowagentforwarding no' in atlas_sshd_effective_configuration.stdout_lines"
|
||||
- "'gatewayports no' in atlas_sshd_effective_configuration.stdout_lines"
|
||||
- "'permittunnel no' in atlas_sshd_effective_configuration.stdout_lines"
|
||||
- "'permituserenvironment no' in atlas_sshd_effective_configuration.stdout_lines"
|
||||
- "'maxauthtries 3' in atlas_sshd_effective_configuration.stdout_lines"
|
||||
- "'logingracetime 30' in atlas_sshd_effective_configuration.stdout_lines"
|
||||
- "'clientaliveinterval 300' in atlas_sshd_effective_configuration.stdout_lines"
|
||||
- "'clientalivecountmax 2' in atlas_sshd_effective_configuration.stdout_lines"
|
||||
- "'loglevel VERBOSE' in atlas_sshd_effective_configuration.stdout_lines"
|
||||
- "('allowusers ' ~ atlas_admin_username) in atlas_sshd_effective_configuration.stdout_lines"
|
||||
fail_msg: The effective Atlas SSH configuration does not match the required hardening.
|
||||
when: not ansible_check_mode
|
||||
538
ansible/roles/profile_atlas/tasks/borg_backup.yml
Normal file
538
ansible/roles/profile_atlas/tasks/borg_backup.yml
Normal file
@@ -0,0 +1,538 @@
|
||||
---
|
||||
- name: Validate Atlas Borg backup configuration
|
||||
tags: [atlas, storage, backup, borg, borg_key]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_manage_storage | bool
|
||||
- atlas_zfs_pool != 'CHANGEME_ZFS_POOL'
|
||||
- atlas_mount_root.startswith('/')
|
||||
- atlas_borg_username is match('^[a-z_][a-z0-9_-]*$')
|
||||
- atlas_borg_group is match('^[a-z_][a-z0-9_-]*$')
|
||||
- atlas_borg_username not in ['root', atlas_admin_username]
|
||||
- atlas_borg_group != 'wheel'
|
||||
- atlas_borg_home.startswith('/var/lib/')
|
||||
- atlas_borg_repository_host is match('^[A-Za-z0-9.-]+$')
|
||||
- atlas_borg_repository_user is match('^[A-Za-z0-9_-]+$')
|
||||
- atlas_borg_repository_port | int > 0
|
||||
- atlas_borg_repository_port | int < 65536
|
||||
- atlas_borg_repository_path is match('^\./[A-Za-z0-9][A-Za-z0-9._/-]*$')
|
||||
- "'/../' not in ('/' ~ atlas_borg_repository_path ~ '/')"
|
||||
- atlas_borg_remote_path is match('^borg-[0-9]+\.[0-9]+$')
|
||||
- atlas_borg_host_key.startswith(
|
||||
'[' ~ atlas_borg_repository_host ~ ']:' ~ (atlas_borg_repository_port | string) ~ ' ssh-ed25519 '
|
||||
)
|
||||
- atlas_borg_ssh_private_key_path.startswith('/etc/atlas-borg/')
|
||||
- atlas_borg_known_hosts_path.startswith('/etc/atlas-borg/')
|
||||
- atlas_borg_passphrase_path.startswith('/etc/atlas-borg/')
|
||||
- atlas_borg_ssh_wrapper_path.startswith('/usr/local/libexec/')
|
||||
- atlas_borg_encryption_mode == 'repokey'
|
||||
- atlas_borg_archive_prefix is match('^[a-z0-9][a-z0-9_-]*$')
|
||||
- atlas_borg_snapshot_prefix is match('^[a-z0-9][a-z0-9_-]*$')
|
||||
- atlas_borg_keep_daily | int > 0
|
||||
- atlas_borg_keep_weekly | int > 0
|
||||
- atlas_borg_keep_monthly | int > 0
|
||||
fail_msg: >-
|
||||
Atlas Borg needs a safe relative repository path, a pinned ED25519 host
|
||||
key, positive retention counts, and valid dedicated SSH settings.
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Create the Atlas Borg system group
|
||||
tags: [atlas, storage, backup, borg, borg_key]
|
||||
ansible.builtin.group:
|
||||
name: "{{ atlas_borg_group }}"
|
||||
system: true
|
||||
state: present
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Create the least-privilege Atlas Borg account
|
||||
tags: [atlas, storage, backup, borg, borg_key]
|
||||
ansible.builtin.user:
|
||||
name: "{{ atlas_borg_username }}"
|
||||
group: "{{ atlas_borg_group }}"
|
||||
groups: []
|
||||
append: false
|
||||
comment: Atlas Borg backup service
|
||||
home: "{{ atlas_borg_home }}"
|
||||
create_home: false
|
||||
shell: /sbin/nologin
|
||||
password_lock: true
|
||||
system: true
|
||||
state: present
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Read Atlas Borg account group membership
|
||||
tags: [atlas, storage, backup, borg, borg_key]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- id
|
||||
- -nG
|
||||
- "{{ atlas_borg_username }}"
|
||||
register: atlas_borg_account_groups
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Require the Atlas Borg account to have no supplementary groups
|
||||
tags: [atlas, storage, backup, borg, borg_key]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_borg_account_groups.stdout.split() == [atlas_borg_group]
|
||||
fail_msg: >-
|
||||
The Atlas Borg service account must belong only to its private primary
|
||||
group and must never receive wheel or other supplementary membership.
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Validate Atlas Borg systemd calendars
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- systemd-analyze
|
||||
- calendar
|
||||
- "{{ item }}"
|
||||
loop:
|
||||
- "{{ atlas_borg_backup_calendar }}"
|
||||
- "{{ atlas_borg_check_calendar }}"
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Create Atlas Borg configuration directory
|
||||
tags: [atlas, storage, backup, borg, borg_key]
|
||||
ansible.builtin.file:
|
||||
path: /etc/atlas-borg
|
||||
state: directory
|
||||
owner: root
|
||||
group: "{{ atlas_borg_group }}"
|
||||
mode: "0750"
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Generate the dedicated Atlas Borg SSH identity
|
||||
tags: [atlas, storage, backup, borg, borg_key]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- ssh-keygen
|
||||
- -q
|
||||
- -t
|
||||
- ed25519
|
||||
- -N
|
||||
- ""
|
||||
- -C
|
||||
- atlas-borg@atlas
|
||||
- -f
|
||||
- "{{ atlas_borg_ssh_private_key_path }}"
|
||||
creates: "{{ atlas_borg_ssh_private_key_path }}"
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Protect the Atlas Borg private SSH identity
|
||||
tags: [atlas, storage, backup, borg, borg_key]
|
||||
ansible.builtin.file:
|
||||
path: "{{ atlas_borg_ssh_private_key_path }}"
|
||||
owner: "{{ atlas_borg_username }}"
|
||||
group: "{{ atlas_borg_group }}"
|
||||
mode: "0600"
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Set permissions on the Atlas Borg public SSH identity
|
||||
tags: [atlas, storage, backup, borg, borg_key]
|
||||
ansible.builtin.file:
|
||||
path: "{{ atlas_borg_ssh_private_key_path }}.pub"
|
||||
owner: "{{ atlas_borg_username }}"
|
||||
group: "{{ atlas_borg_group }}"
|
||||
mode: "0644"
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Read the dedicated Atlas Borg public SSH identity
|
||||
tags: [atlas, storage, backup, borg, borg_key]
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ atlas_borg_ssh_private_key_path }}.pub"
|
||||
register: atlas_borg_public_key
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Report the public SSH identity to install in the Hetzner sub-account
|
||||
tags: [atlas, storage, backup, borg, borg_key]
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ atlas_borg_public_key.content | b64decode | trim }}"
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Pin the Hetzner Storage Box SSH host key
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.copy:
|
||||
content: "{{ atlas_borg_host_key }}\n"
|
||||
dest: "{{ atlas_borg_known_hosts_path }}"
|
||||
owner: "{{ atlas_borg_username }}"
|
||||
group: "{{ atlas_borg_group }}"
|
||||
mode: "0600"
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Create Atlas Borg state directories
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: "{{ atlas_borg_username }}"
|
||||
group: "{{ atlas_borg_group }}"
|
||||
mode: "0700"
|
||||
loop:
|
||||
- "{{ atlas_borg_config_dir }}"
|
||||
- "{{ atlas_borg_cache_dir }}"
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Create the shared Atlas Borg operation lock
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.copy:
|
||||
content: ""
|
||||
dest: "{{ atlas_borg_lock_path }}"
|
||||
owner: "{{ atlas_borg_username }}"
|
||||
group: "{{ atlas_borg_group }}"
|
||||
mode: "0600"
|
||||
force: false
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Require the Atlas Borg encryption passphrase from Vault
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_borg_passphrase | length >= 20
|
||||
fail_msg: >-
|
||||
Define vault_atlas_borg_passphrase with a strong unique value in the
|
||||
encrypted Vault before activating the Borg repository.
|
||||
no_log: true
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Install the Atlas Borg passphrase
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.copy:
|
||||
content: "{{ atlas_borg_passphrase }}\n"
|
||||
dest: "{{ atlas_borg_passphrase_path }}"
|
||||
owner: "{{ atlas_borg_username }}"
|
||||
group: "{{ atlas_borg_group }}"
|
||||
mode: "0600"
|
||||
diff: false
|
||||
no_log: true
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Install the Atlas Borg backup helper
|
||||
tags: [atlas, storage, backup, borg, borg_logging]
|
||||
ansible.builtin.template:
|
||||
src: atlas-borg-backup.sh.j2
|
||||
dest: /usr/local/sbin/atlas-borg-backup
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Install the Atlas Borg snapshot cleanup helper
|
||||
tags: [atlas, storage, backup, borg, borg_logging]
|
||||
ansible.builtin.template:
|
||||
src: atlas-borg-snapshot-cleanup.sh.j2
|
||||
dest: /usr/local/sbin/atlas-borg-snapshot-cleanup
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Install the Atlas Borg check helper
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.template:
|
||||
src: atlas-borg-check.sh.j2
|
||||
dest: /usr/local/sbin/atlas-borg-check
|
||||
owner: root
|
||||
group: "{{ atlas_borg_group }}"
|
||||
mode: "0750"
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Create the local libexec directory for the Atlas Borg SSH wrapper
|
||||
tags: [atlas, storage, backup, borg, borg_logging]
|
||||
ansible.builtin.file:
|
||||
path: "{{ atlas_borg_ssh_wrapper_path | dirname }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Install the Atlas Borg progress formatter
|
||||
tags: [atlas, storage, backup, borg, borg_logging]
|
||||
ansible.builtin.copy:
|
||||
src: atlas-borg-progress.py
|
||||
dest: /usr/local/libexec/atlas-borg-progress
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Install the capability-dropping Atlas Borg SSH wrapper
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.template:
|
||||
src: atlas-borg-ssh.sh.j2
|
||||
dest: "{{ atlas_borg_ssh_wrapper_path }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Install Atlas Borg systemd units
|
||||
tags: [atlas, storage, backup, borg, borg_logging]
|
||||
ansible.builtin.template:
|
||||
src: "{{ item }}.j2"
|
||||
dest: "/etc/systemd/system/{{ item }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
loop:
|
||||
- atlas-borg-backup.service
|
||||
- atlas-borg-backup.timer
|
||||
- atlas-borg-check.service
|
||||
- atlas-borg-check.timer
|
||||
notify: Restart Atlas Borg timers
|
||||
when: atlas_manage_borg_backup | bool
|
||||
|
||||
- name: Verify dedicated SSH access to the Hetzner Storage Box
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- ssh
|
||||
- -T
|
||||
- -i
|
||||
- "{{ atlas_borg_ssh_private_key_path }}"
|
||||
- -p
|
||||
- "{{ atlas_borg_repository_port | string }}"
|
||||
- -o
|
||||
- BatchMode=yes
|
||||
- -o
|
||||
- IdentitiesOnly=yes
|
||||
- -o
|
||||
- StrictHostKeyChecking=yes
|
||||
- -o
|
||||
- "UserKnownHostsFile={{ atlas_borg_known_hosts_path }}"
|
||||
- "{{ atlas_borg_repository_user }}@{{ atlas_borg_repository_host }}"
|
||||
- pwd
|
||||
register: atlas_borg_ssh_probe
|
||||
become: true
|
||||
become_user: "{{ atlas_borg_username }}"
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Require the dedicated public key on the Hetzner sub-account
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_borg_ssh_probe.rc == 0
|
||||
fail_msg: >-
|
||||
Install the reported Atlas Borg public key in the Hetzner sub-account
|
||||
before rerunning the Borg tasks. Password authentication is never used.
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Probe the remote Atlas Borg repository path
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- ssh
|
||||
- -T
|
||||
- -i
|
||||
- "{{ atlas_borg_ssh_private_key_path }}"
|
||||
- -p
|
||||
- "{{ atlas_borg_repository_port | string }}"
|
||||
- -o
|
||||
- BatchMode=yes
|
||||
- -o
|
||||
- IdentitiesOnly=yes
|
||||
- -o
|
||||
- StrictHostKeyChecking=yes
|
||||
- -o
|
||||
- "UserKnownHostsFile={{ atlas_borg_known_hosts_path }}"
|
||||
- "{{ atlas_borg_repository_user }}@{{ atlas_borg_repository_host }}"
|
||||
- stat
|
||||
- "{{ atlas_borg_repository_path }}"
|
||||
register: atlas_borg_repository_path_probe
|
||||
become: true
|
||||
become_user: "{{ atlas_borg_username }}"
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Probe the Atlas Borg repository
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/borg
|
||||
- --remote-path
|
||||
- "{{ atlas_borg_remote_path }}"
|
||||
- info
|
||||
- >-
|
||||
ssh://{{ atlas_borg_repository_user }}@{{ atlas_borg_repository_host }}:
|
||||
{{- atlas_borg_repository_port }}/{{ atlas_borg_repository_path }}
|
||||
environment:
|
||||
BORG_CACHE_DIR: "{{ atlas_borg_cache_dir }}"
|
||||
BORG_CONFIG_DIR: "{{ atlas_borg_config_dir }}"
|
||||
BORG_PASSCOMMAND: "cat {{ atlas_borg_passphrase_path }}"
|
||||
BORG_RSH: "{{ atlas_borg_ssh_wrapper_path }}"
|
||||
register: atlas_borg_repository_probe
|
||||
become: true
|
||||
become_user: "{{ atlas_borg_username }}"
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
- atlas_borg_repository_path_probe.rc == 0
|
||||
|
||||
- name: Reject an existing path that is not the configured Borg repository
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_borg_repository_probe.rc == 0
|
||||
fail_msg: >-
|
||||
The remote repository path already exists but Borg could not open it.
|
||||
Refusing to initialize over existing data; verify the path, passphrase,
|
||||
and repository state manually.
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
- atlas_borg_repository_path_probe.rc == 0
|
||||
|
||||
- name: Initialize the encrypted Atlas Borg repository
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/borg
|
||||
- --remote-path
|
||||
- "{{ atlas_borg_remote_path }}"
|
||||
- init
|
||||
- --encryption
|
||||
- "{{ atlas_borg_encryption_mode }}"
|
||||
- >-
|
||||
ssh://{{ atlas_borg_repository_user }}@{{ atlas_borg_repository_host }}:
|
||||
{{- atlas_borg_repository_port }}/{{ atlas_borg_repository_path }}
|
||||
environment:
|
||||
BORG_CACHE_DIR: "{{ atlas_borg_cache_dir }}"
|
||||
BORG_CONFIG_DIR: "{{ atlas_borg_config_dir }}"
|
||||
BORG_PASSCOMMAND: "cat {{ atlas_borg_passphrase_path }}"
|
||||
BORG_RSH: "{{ atlas_borg_ssh_wrapper_path }}"
|
||||
become: true
|
||||
become_user: "{{ atlas_borg_username }}"
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
- atlas_borg_repository_path_probe.rc != 0
|
||||
|
||||
- name: Verify the encrypted Atlas Borg repository
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/borg
|
||||
- --remote-path
|
||||
- "{{ atlas_borg_remote_path }}"
|
||||
- info
|
||||
- >-
|
||||
ssh://{{ atlas_borg_repository_user }}@{{ atlas_borg_repository_host }}:
|
||||
{{- atlas_borg_repository_port }}/{{ atlas_borg_repository_path }}
|
||||
environment:
|
||||
BORG_CACHE_DIR: "{{ atlas_borg_cache_dir }}"
|
||||
BORG_CONFIG_DIR: "{{ atlas_borg_config_dir }}"
|
||||
BORG_PASSCOMMAND: "cat {{ atlas_borg_passphrase_path }}"
|
||||
BORG_RSH: "{{ atlas_borg_ssh_wrapper_path }}"
|
||||
become: true
|
||||
become_user: "{{ atlas_borg_username }}"
|
||||
changed_when: false
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Check for the local Atlas Borg recovery-key export
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.stat:
|
||||
path: "{{ atlas_borg_recovery_export_path }}"
|
||||
register: atlas_borg_recovery_export
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Export the Atlas Borg recovery key for offline preservation
|
||||
tags: [atlas, storage, backup, borg]
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
- not atlas_borg_recovery_export.stat.exists
|
||||
no_log: true
|
||||
block:
|
||||
- name: Create the local recovery-material directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ atlas_borg_recovery_export_path | dirname }}"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
|
||||
- name: Export the encrypted Borg repository key on Atlas
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/borg
|
||||
- --remote-path
|
||||
- "{{ atlas_borg_remote_path }}"
|
||||
- key
|
||||
- export
|
||||
- >-
|
||||
ssh://{{ atlas_borg_repository_user }}@{{ atlas_borg_repository_host }}:
|
||||
{{- atlas_borg_repository_port }}/{{ atlas_borg_repository_path }}
|
||||
- "{{ atlas_borg_config_dir }}/atlas-borg-repokey.export"
|
||||
environment:
|
||||
BORG_CACHE_DIR: "{{ atlas_borg_cache_dir }}"
|
||||
BORG_CONFIG_DIR: "{{ atlas_borg_config_dir }}"
|
||||
BORG_PASSCOMMAND: "cat {{ atlas_borg_passphrase_path }}"
|
||||
BORG_RSH: "{{ atlas_borg_ssh_wrapper_path }}"
|
||||
become: true
|
||||
become_user: "{{ atlas_borg_username }}"
|
||||
|
||||
- name: Fetch the encrypted Borg recovery key from Atlas
|
||||
ansible.builtin.fetch:
|
||||
src: "{{ atlas_borg_config_dir }}/atlas-borg-repokey.export"
|
||||
dest: "{{ atlas_borg_recovery_export_path }}"
|
||||
flat: true
|
||||
|
||||
- name: Protect the local Borg recovery-key export
|
||||
ansible.builtin.file:
|
||||
path: "{{ atlas_borg_recovery_export_path }}"
|
||||
mode: "0600"
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
always:
|
||||
- name: Remove the temporary recovery-key export from Atlas
|
||||
ansible.builtin.file:
|
||||
path: "{{ atlas_borg_config_dir }}/atlas-borg-repokey.export"
|
||||
state: absent
|
||||
|
||||
- name: Enable Atlas Borg backup and check timers
|
||||
tags: [atlas, storage, backup, borg]
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
loop:
|
||||
- atlas-borg-backup.timer
|
||||
- atlas-borg-check.timer
|
||||
when:
|
||||
- atlas_manage_borg_backup | bool
|
||||
- not ansible_check_mode
|
||||
15
ansible/roles/profile_atlas/tasks/cockpit.yml
Normal file
15
ansible/roles/profile_atlas/tasks/cockpit.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
- name: Configure the official 45Drives repository
|
||||
tags: [atlas, packages]
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ atlas_45drives_repo_url }}"
|
||||
dest: "{{ atlas_45drives_repo_file }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
- name: Install 45Drives Cockpit plugins
|
||||
tags: [atlas, packages]
|
||||
ansible.builtin.dnf:
|
||||
name: "{{ atlas_45drives_packages }}"
|
||||
state: present
|
||||
140
ansible/roles/profile_atlas/tasks/containers.yml
Normal file
140
ansible/roles/profile_atlas/tasks/containers.yml
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
- name: Require completed Atlas media-stack configuration
|
||||
tags: [atlas, containers, immich]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_manage_storage | bool
|
||||
- atlas_zfs_pool != 'CHANGEME_ZFS_POOL'
|
||||
- atlas_mount_root != '/CHANGEME_ATLAS_MOUNT_ROOT'
|
||||
- atlas_immich_db_password | length > 0
|
||||
- atlas_npm_admin_bind_address == '127.0.0.1'
|
||||
fail_msg: >-
|
||||
Enable and configure Atlas storage, provide vault_atlas_immich_db_password,
|
||||
and keep the NPM administration port bound to loopback before enabling the media stack.
|
||||
no_log: true
|
||||
when: atlas_manage_media_stack | bool
|
||||
|
||||
- name: Check Atlas Intel graphics device
|
||||
tags: [atlas, containers, immich]
|
||||
ansible.builtin.stat:
|
||||
path: /dev/dri
|
||||
register: atlas_dri_device
|
||||
when: atlas_manage_media_stack | bool
|
||||
|
||||
- name: Require Atlas Intel graphics device
|
||||
tags: [atlas, containers, immich]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_dri_device.stat.isdir | default(false)
|
||||
fail_msg: /dev/dri is required for Immich QuickSync and OpenVINO acceleration.
|
||||
when: atlas_manage_media_stack | bool
|
||||
|
||||
- name: Create Atlas rootful Quadlet directory
|
||||
tags: [atlas, containers]
|
||||
ansible.builtin.file:
|
||||
path: "{{ atlas_container_quadlet_dir }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
when: atlas_manage_media_stack | bool
|
||||
|
||||
- name: Create Atlas Immich configuration directory
|
||||
tags: [atlas, containers, immich]
|
||||
ansible.builtin.file:
|
||||
path: /etc/immich
|
||||
state: directory
|
||||
owner: root
|
||||
group: "{{ atlas_immich_group }}"
|
||||
mode: "0750"
|
||||
when: atlas_manage_media_stack | bool
|
||||
|
||||
- name: Create Atlas Immich persistent directories
|
||||
tags: [atlas, containers, immich]
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: "{{ atlas_immich_username }}"
|
||||
group: "{{ atlas_immich_group }}"
|
||||
mode: "0750"
|
||||
loop:
|
||||
- "{{ atlas_immich_upload_dir }}"
|
||||
- "{{ atlas_immich_model_cache_dir }}"
|
||||
- "{{ atlas_immich_machine_learning_config_dir }}"
|
||||
- "{{ atlas_immich_machine_learning_cache_dir }}"
|
||||
- "{{ atlas_immich_redis_data_dir }}"
|
||||
- "{{ atlas_immich_postgres_data_dir }}"
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
when: atlas_manage_media_stack | bool
|
||||
|
||||
- name: Create Atlas NPM persistent directories
|
||||
tags: [atlas, containers, npm]
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
loop:
|
||||
- "{{ atlas_npm_data_dir }}"
|
||||
- "{{ atlas_npm_letsencrypt_dir }}"
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
when: atlas_manage_media_stack | bool
|
||||
|
||||
- name: Allow confined containers to use Atlas graphics devices
|
||||
tags: [atlas, containers, immich]
|
||||
ansible.posix.seboolean:
|
||||
name: container_use_devices
|
||||
state: true
|
||||
persistent: true
|
||||
when:
|
||||
- atlas_manage_media_stack | bool
|
||||
- (ansible_facts['selinux'] | default({})).get('status', 'disabled') == 'enabled'
|
||||
|
||||
- name: Render Vault-backed Immich environment
|
||||
tags: [atlas, containers, immich]
|
||||
ansible.builtin.template:
|
||||
src: immich.env.j2
|
||||
dest: /etc/immich/immich.env
|
||||
owner: root
|
||||
group: "{{ atlas_immich_group }}"
|
||||
mode: "0640"
|
||||
no_log: true
|
||||
diff: false
|
||||
notify: Restart Atlas media Quadlets
|
||||
when: atlas_manage_media_stack | bool
|
||||
|
||||
- name: Render Atlas media Quadlets
|
||||
tags: [atlas, containers]
|
||||
ansible.builtin.template:
|
||||
src: "{{ item }}.j2"
|
||||
dest: "{{ atlas_container_quadlet_dir }}/{{ item }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
loop:
|
||||
- atlas-media.network
|
||||
- atlas-immich-redis.container
|
||||
- atlas-immich-postgres.container
|
||||
- atlas-immich-machine-learning.container
|
||||
- atlas-immich-server.container
|
||||
- atlas-npm.container
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
notify: Restart Atlas media Quadlets
|
||||
when: atlas_manage_media_stack | bool
|
||||
|
||||
- name: Start Atlas media Quadlet services
|
||||
tags: [atlas, containers, services]
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: started
|
||||
daemon_reload: true
|
||||
loop: "{{ atlas_media_quadlet_services }}"
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
when:
|
||||
- atlas_manage_media_stack | bool
|
||||
- not ansible_check_mode
|
||||
45
ansible/roles/profile_atlas/tasks/hardening.yml
Normal file
45
ansible/roles/profile_atlas/tasks/hardening.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
- name: Enforce targeted SELinux on Atlas
|
||||
tags: [atlas, hardening, security]
|
||||
ansible.posix.selinux:
|
||||
policy: targeted
|
||||
state: enforcing
|
||||
update_kernel_param: true
|
||||
register: atlas_selinux_enforcement
|
||||
|
||||
- name: Report when Atlas requires a reboot for SELinux enforcement
|
||||
tags: [atlas, hardening, security]
|
||||
ansible.builtin.debug:
|
||||
msg: Reboot Atlas before relying on SELinux enforcement.
|
||||
when: atlas_selinux_enforcement.reboot_required | default(false)
|
||||
|
||||
- name: Apply Atlas network-kernel hardening
|
||||
tags: [atlas, hardening, security]
|
||||
ansible.posix.sysctl:
|
||||
name: "{{ item.key }}"
|
||||
value: "{{ item.value }}"
|
||||
state: present
|
||||
sysctl_set: true
|
||||
reload: true
|
||||
loop: "{{ atlas_hardening_sysctls | dict2items }}"
|
||||
loop_control:
|
||||
label: "{{ item.key }}={{ item.value }}"
|
||||
|
||||
- name: Read active Atlas SELinux mode
|
||||
tags: [atlas, hardening, security]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- getenforce
|
||||
register: atlas_selinux_mode
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Require active SELinux enforcement on Atlas
|
||||
tags: [atlas, hardening, security]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_selinux_mode.stdout == 'Enforcing'
|
||||
fail_msg: >-
|
||||
Atlas SELinux is not enforcing. Reboot if requested by the preceding
|
||||
SELinux task, then rerun the hardening role before enabling services.
|
||||
when: not ansible_check_mode
|
||||
36
ansible/roles/profile_atlas/tasks/main.yml
Normal file
36
ansible/roles/profile_atlas/tasks/main.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
- name: Import Atlas account tasks
|
||||
ansible.builtin.import_tasks: account.yml
|
||||
|
||||
- name: Import Atlas 45Drives tasks
|
||||
ansible.builtin.import_tasks: cockpit.yml
|
||||
|
||||
- name: Import Atlas network hardening tasks
|
||||
ansible.builtin.import_tasks: hardening.yml
|
||||
|
||||
- name: Import Atlas pool bootstrap tasks
|
||||
ansible.builtin.import_tasks: pool.yml
|
||||
|
||||
- name: Import Atlas storage tasks
|
||||
ansible.builtin.import_tasks: storage.yml
|
||||
|
||||
- name: Import Atlas ZFS maintenance tasks
|
||||
ansible.builtin.import_tasks: zfs_maintenance.yml
|
||||
|
||||
- name: Import Atlas Borg backup tasks
|
||||
ansible.builtin.import_tasks: borg_backup.yml
|
||||
|
||||
- name: Import Atlas offline USB backup tasks
|
||||
ansible.builtin.import_tasks: usb_backup.yml
|
||||
|
||||
- name: Import Atlas health monitoring tasks
|
||||
ansible.builtin.import_tasks: monitoring.yml
|
||||
|
||||
- name: Import Atlas post-restore SELinux relabeling tasks
|
||||
ansible.builtin.import_tasks: restorecon.yml
|
||||
|
||||
- name: Import Atlas file sharing tasks
|
||||
ansible.builtin.import_tasks: sharing.yml
|
||||
|
||||
- name: Import Atlas media-container tasks
|
||||
ansible.builtin.import_tasks: containers.yml
|
||||
201
ansible/roles/profile_atlas/tasks/monitoring.yml
Normal file
201
ansible/roles/profile_atlas/tasks/monitoring.yml
Normal file
@@ -0,0 +1,201 @@
|
||||
---
|
||||
- name: Validate Atlas health monitoring policy
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_manage_storage | bool
|
||||
- atlas_zfs_pool != 'CHANGEME_ZFS_POOL'
|
||||
- atlas_monitor_calendar | length > 0
|
||||
- atlas_monitor_smart_devices | length > 0
|
||||
- atlas_monitor_timers | length > 0
|
||||
- atlas_monitor_failure_units | length > 0
|
||||
- atlas_monitor_remote_capacity.user == atlas_borg_repository_user
|
||||
- atlas_monitor_remote_capacity.host == atlas_borg_repository_host
|
||||
- atlas_monitor_remote_capacity.run_as == atlas_borg_username
|
||||
- atlas_monitor_remote_capacity.ssh_wrapper == atlas_borg_ssh_wrapper_path
|
||||
- >-
|
||||
0 < atlas_monitor_remote_capacity.warning_percent | int
|
||||
< atlas_monitor_remote_capacity.critical_percent | int < 100
|
||||
- atlas_monitor_remote_capacity.growth_warning_gib_day | int > 0
|
||||
- atlas_monitor_notifier.startswith('/opt/45drives/houston/')
|
||||
- 0 < atlas_monitor_pool_warning_percent | int < atlas_monitor_pool_critical_percent | int < 100
|
||||
- 0 < atlas_monitor_root_warning_percent | int < atlas_monitor_root_critical_percent | int < 100
|
||||
- 0 < atlas_monitor_snapshot_warning_percent | int < atlas_monitor_snapshot_critical_percent | int < 100
|
||||
- atlas_monitor_snapshot_growth_warning_gib_day | int > 0
|
||||
- atlas_monitor_backup_growth_warning_gib_day | int > 0
|
||||
- 0 < atlas_monitor_cpu_warning_c | int < atlas_monitor_cpu_critical_c | int
|
||||
- atlas_monitor_borg_max_runtime_days | int > 0
|
||||
fail_msg: >-
|
||||
Atlas health monitoring needs real devices, job units, a valid calendar,
|
||||
positive ordered thresholds, and the existing Houston notifier.
|
||||
when: atlas_manage_monitoring | bool
|
||||
|
||||
- name: Validate monitored Atlas SMART devices
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item.name is match('^[a-z0-9][a-z0-9_-]*$')
|
||||
- item.path.startswith('/dev/disk/by-id/')
|
||||
- 0 < item.warning_c | int < item.critical_c | int
|
||||
fail_msg: "Every monitored disk needs a stable by-id path and ordered temperature thresholds."
|
||||
loop: "{{ atlas_monitor_smart_devices }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
when: atlas_manage_monitoring | bool
|
||||
|
||||
- name: Validate monitored Atlas timer names and age thresholds
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item.name is match('^[a-zA-Z0-9@_.-]+\\.timer$')
|
||||
- item.max_age_hours | int >= 0
|
||||
loop: "{{ atlas_monitor_timers }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
when: atlas_manage_monitoring | bool
|
||||
|
||||
- name: Validate monitored Atlas failure unit names
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item is match('^[a-zA-Z0-9@_.-]+\\.service$')
|
||||
loop: "{{ atlas_monitor_failure_units }}"
|
||||
when: atlas_manage_monitoring | bool
|
||||
|
||||
- name: Validate Atlas health monitor calendar
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.command:
|
||||
argv: [systemd-analyze, calendar, "{{ atlas_monitor_calendar }}"]
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
when: atlas_manage_monitoring | bool
|
||||
|
||||
- name: Install SMART tooling for Atlas health checks
|
||||
tags: [atlas, monitoring, packages]
|
||||
ansible.builtin.dnf:
|
||||
name: smartmontools
|
||||
state: present
|
||||
when: atlas_manage_monitoring | bool
|
||||
|
||||
- name: Inspect the existing 45Drives notifier for monitoring
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.stat:
|
||||
path: "{{ atlas_monitor_notifier }}"
|
||||
register: atlas_monitor_notifier_file
|
||||
when: atlas_manage_monitoring | bool
|
||||
|
||||
- name: Require the existing 45Drives notifier for monitoring
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_monitor_notifier_file.stat.executable | default(false)
|
||||
fail_msg: "The existing 45Drives Houston notifier must be executable."
|
||||
when: atlas_manage_monitoring | bool
|
||||
|
||||
- name: Create private Atlas health monitor state directory
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.file:
|
||||
path: /var/lib/atlas-health-monitor
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
when: atlas_manage_monitoring | bool
|
||||
|
||||
- name: Install Atlas health monitor configuration
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.template:
|
||||
src: atlas-health-monitor.json.j2
|
||||
dest: /etc/atlas-health-monitor.json
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
when: atlas_manage_monitoring | bool
|
||||
|
||||
- name: Install Atlas health monitor helper
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.copy:
|
||||
src: atlas-health-monitor.py
|
||||
dest: /usr/local/libexec/atlas-health-monitor
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
when: atlas_manage_monitoring | bool
|
||||
|
||||
- name: Install Atlas health monitoring units
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.template:
|
||||
src: "{{ item }}.j2"
|
||||
dest: "/etc/systemd/system/{{ item }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
loop:
|
||||
- atlas-health-monitor.service
|
||||
- atlas-health-monitor.timer
|
||||
- atlas-monitor-failure@.service
|
||||
when: atlas_manage_monitoring | bool
|
||||
|
||||
- name: Create failure hook directories for monitored Atlas jobs
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.file:
|
||||
path: "/etc/systemd/system/{{ item }}.d"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
loop: "{{ atlas_monitor_failure_units }}"
|
||||
when: atlas_manage_monitoring | bool
|
||||
|
||||
- name: Notify 45Drives Alerts when an Atlas job fails
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.template:
|
||||
src: atlas-monitor-failure.conf.j2
|
||||
dest: "/etc/systemd/system/{{ item }}.d/atlas-monitor.conf"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
loop: "{{ atlas_monitor_failure_units }}"
|
||||
when: atlas_manage_monitoring | bool
|
||||
|
||||
- name: Reload systemd after installing Atlas monitoring
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
when:
|
||||
- atlas_manage_monitoring | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Enable the Atlas health monitoring timer
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.systemd:
|
||||
name: atlas-health-monitor.timer
|
||||
enabled: true
|
||||
state: started
|
||||
when:
|
||||
- atlas_manage_monitoring | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Validate the deployed Atlas health monitoring units
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- systemd-analyze
|
||||
- verify
|
||||
- atlas-health-monitor.service
|
||||
- atlas-health-monitor.timer
|
||||
- atlas-monitor-failure@.service
|
||||
changed_when: false
|
||||
when:
|
||||
- atlas_manage_monitoring | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Probe Atlas health without sending notifications
|
||||
tags: [atlas, monitoring]
|
||||
ansible.builtin.command:
|
||||
argv: [/usr/local/libexec/atlas-health-monitor, --dry-run]
|
||||
register: atlas_monitor_dry_run
|
||||
changed_when: false
|
||||
when:
|
||||
- atlas_manage_monitoring | bool
|
||||
- not ansible_check_mode
|
||||
57
ansible/roles/profile_atlas/tasks/pool.yml
Normal file
57
ansible/roles/profile_atlas/tasks/pool.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
- name: Bootstrap Atlas ZFS pool
|
||||
tags: [atlas, storage, pool]
|
||||
when: atlas_create_pool | bool
|
||||
block:
|
||||
- name: Validate Atlas pool bootstrap inputs
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_zpool_disks | length == 4
|
||||
- atlas_zpool_disks | unique | length == 4
|
||||
- atlas_zpool_disks | select('match', '^/dev/disk/by-id/') | list | length == 4
|
||||
fail_msg: >-
|
||||
Set exactly four distinct persistent /dev/disk/by-id paths in
|
||||
atlas_zpool_disks before creating the RAIDZ2 pool.
|
||||
|
||||
- name: Inspect declared Atlas pool disks
|
||||
ansible.builtin.stat:
|
||||
path: "{{ item }}"
|
||||
follow: true
|
||||
loop: "{{ atlas_zpool_disks }}"
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
register: atlas_zpool_disk_stats
|
||||
|
||||
- name: Require every declared Atlas pool disk
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item.stat.exists
|
||||
- item.stat.isblk | default(false)
|
||||
fail_msg: "Declared Atlas pool disk is unavailable or is not a block device: {{ item.item }}"
|
||||
loop: "{{ atlas_zpool_disk_stats.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item }}"
|
||||
|
||||
- name: Check whether the Atlas ZFS pool already exists
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- zpool
|
||||
- list
|
||||
- -H
|
||||
- -o
|
||||
- name
|
||||
- "{{ atlas_zfs_pool }}"
|
||||
register: atlas_zpool_bootstrap_check
|
||||
changed_when: false
|
||||
failed_when: atlas_zpool_bootstrap_check.rc not in [0, 1]
|
||||
|
||||
- name: Create the Atlas RAIDZ2 pool when absent
|
||||
community.general.zpool:
|
||||
name: "{{ atlas_zfs_pool }}"
|
||||
state: present
|
||||
mountpoint: "{{ atlas_mount_root }}"
|
||||
force: false
|
||||
vdevs:
|
||||
- type: raidz2
|
||||
disks: "{{ atlas_zpool_disks }}"
|
||||
when: atlas_zpool_bootstrap_check.rc == 1
|
||||
27
ansible/roles/profile_atlas/tasks/restorecon.yml
Normal file
27
ansible/roles/profile_atlas/tasks/restorecon.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
- name: Validate requested Atlas post-restore relabel paths
|
||||
tags: [atlas, restorecon, recovery]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item is string
|
||||
- item.startswith(atlas_mount_root ~ '/')
|
||||
- item != atlas_mount_root
|
||||
fail_msg: >-
|
||||
Post-restore relabeling accepts only explicit paths below the Atlas pool
|
||||
mount root. Do not relabel the whole pool during routine provisioning.
|
||||
loop: "{{ atlas_restorecon_paths }}"
|
||||
when: atlas_restorecon_paths | length > 0
|
||||
|
||||
- name: Restore SELinux labels on explicitly restored Atlas paths
|
||||
tags: [atlas, restorecon, recovery]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- restorecon
|
||||
- -RFv
|
||||
- "{{ item }}"
|
||||
register: atlas_restorecon_result
|
||||
changed_when: atlas_restorecon_result.stdout | length > 0
|
||||
loop: "{{ atlas_restorecon_paths }}"
|
||||
when:
|
||||
- atlas_restorecon_paths | length > 0
|
||||
- not ansible_check_mode
|
||||
64
ansible/roles/profile_atlas/tasks/samba_account.yml
Normal file
64
ansible/roles/profile_atlas/tasks/samba_account.yml
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
- name: Require an existing Unix account for Atlas Samba
|
||||
ansible.builtin.getent:
|
||||
database: passwd
|
||||
key: "{{ atlas_samba_account.username }}"
|
||||
|
||||
- name: Read Atlas Samba account
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pdbedit
|
||||
- --list
|
||||
- --user
|
||||
- "{{ atlas_samba_account.username }}"
|
||||
register: atlas_samba_account_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Check Atlas Samba password marker
|
||||
ansible.builtin.stat:
|
||||
path: "{{ atlas_samba_password_marker_dir }}/{{ atlas_samba_account.username }}.sha256"
|
||||
register: atlas_samba_password_marker_stat
|
||||
|
||||
- name: Read Atlas Samba password marker
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ atlas_samba_password_marker_dir }}/{{ atlas_samba_account.username }}.sha256"
|
||||
register: atlas_samba_password_marker_content
|
||||
no_log: true
|
||||
when: atlas_samba_password_marker_stat.stat.exists
|
||||
|
||||
- name: Determine whether Atlas Samba credentials must change
|
||||
ansible.builtin.set_fact:
|
||||
atlas_samba_password_digest: "{{ atlas_samba_account.password | hash('sha256') }}"
|
||||
atlas_samba_password_update_required: >-
|
||||
{{
|
||||
atlas_samba_account_check.rc != 0
|
||||
or not atlas_samba_password_marker_stat.stat.exists
|
||||
or (
|
||||
atlas_samba_password_marker_content.content | default('')
|
||||
| b64decode | trim
|
||||
) != (atlas_samba_account.password | hash('sha256'))
|
||||
}}
|
||||
no_log: true
|
||||
|
||||
- name: Set Atlas Samba account password
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- smbpasswd
|
||||
- -s
|
||||
- -a
|
||||
- "{{ atlas_samba_account.username }}"
|
||||
stdin: "{{ atlas_samba_account.password }}\n{{ atlas_samba_account.password }}"
|
||||
changed_when: true
|
||||
no_log: true
|
||||
when: atlas_samba_password_update_required | bool
|
||||
|
||||
- name: Record managed Atlas Samba password digest
|
||||
ansible.builtin.copy:
|
||||
content: "{{ atlas_samba_password_digest }}\n"
|
||||
dest: "{{ atlas_samba_password_marker_dir }}/{{ atlas_samba_account.username }}.sha256"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
when: atlas_samba_password_update_required | bool
|
||||
271
ansible/roles/profile_atlas/tasks/sharing.yml
Normal file
271
ansible/roles/profile_atlas/tasks/sharing.yml
Normal file
@@ -0,0 +1,271 @@
|
||||
- name: Require completed Atlas firewall placeholders
|
||||
tags: [atlas, sharing, services]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_lan_subnet != 'CHANGEME_LAN_SUBNET'
|
||||
- atlas_aegis_ip != 'CHANGEME_AEGIS_IP'
|
||||
- atlas_firewalld_zone | length > 0
|
||||
- ansible_facts.default_ipv4.interface | default('') | length > 0
|
||||
fail_msg: Replace the Atlas LAN, Aegis and firewall-zone placeholders.
|
||||
when: atlas_manage_firewall | bool
|
||||
|
||||
- name: Apply Atlas firewalld rich rules
|
||||
tags: [atlas, sharing, services]
|
||||
ansible.posix.firewalld:
|
||||
rich_rule: "{{ item }}"
|
||||
permanent: true
|
||||
immediate: true
|
||||
state: enabled
|
||||
zone: "{{ atlas_firewalld_zone }}"
|
||||
loop: "{{ atlas_firewalld_rich_rules }}"
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
when: atlas_manage_firewall | bool
|
||||
|
||||
- name: Assign primary Atlas LAN interface to managed firewalld zone
|
||||
tags: [atlas, sharing, services]
|
||||
ansible.posix.firewalld:
|
||||
interface: "{{ ansible_facts.default_ipv4.interface }}"
|
||||
zone: "{{ atlas_firewalld_zone }}"
|
||||
state: enabled
|
||||
permanent: true
|
||||
immediate: true
|
||||
when: atlas_manage_firewall | bool
|
||||
|
||||
- name: Remove unrestricted Atlas services from firewalld zone
|
||||
tags: [atlas, sharing, services]
|
||||
ansible.posix.firewalld:
|
||||
service: "{{ item }}"
|
||||
permanent: true
|
||||
immediate: true
|
||||
state: disabled
|
||||
zone: "{{ atlas_firewalld_zone }}"
|
||||
loop: "{{ atlas_firewalld_restricted_services }}"
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
when: atlas_manage_firewall | bool
|
||||
|
||||
- name: Render Atlas NFS exports
|
||||
tags: [atlas, sharing]
|
||||
ansible.builtin.template:
|
||||
src: atlas.exports.j2
|
||||
dest: /etc/exports.d/atlas.exports
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload NFS exports
|
||||
when: atlas_manage_sharing | bool
|
||||
|
||||
- name: Ensure Atlas NFS configuration drop-in directory exists
|
||||
tags: [atlas, sharing]
|
||||
ansible.builtin.file:
|
||||
path: /etc/nfs.conf.d
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
when: atlas_manage_sharing | bool
|
||||
|
||||
- name: Configure Atlas NFSv4-only service
|
||||
tags: [atlas, sharing]
|
||||
ansible.builtin.template:
|
||||
src: atlas-nfs.conf.j2
|
||||
dest: /etc/nfs.conf.d/atlas.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Restart NFS server
|
||||
when: atlas_manage_sharing | bool
|
||||
|
||||
- name: Mask Atlas NFSv3 RPC services
|
||||
tags: [atlas, sharing, services]
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
enabled: false
|
||||
state: stopped
|
||||
masked: true
|
||||
loop:
|
||||
- rpc-statd.service
|
||||
- rpcbind.service
|
||||
- rpcbind.socket
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
when: atlas_manage_sharing | bool
|
||||
|
||||
- name: Ensure Atlas NFS mount daemon drop-in directory exists
|
||||
tags: [atlas, sharing, services]
|
||||
ansible.builtin.file:
|
||||
path: /etc/systemd/system/nfs-mountd.service.d
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
when: atlas_manage_sharing | bool
|
||||
|
||||
- name: Disable Atlas NFSv3 mount daemon listeners
|
||||
tags: [atlas, sharing, services]
|
||||
ansible.builtin.template:
|
||||
src: nfs-mountd-v4only.conf.j2
|
||||
dest: /etc/systemd/system/nfs-mountd.service.d/v4only.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Restart NFS mount daemon
|
||||
when: atlas_manage_sharing | bool
|
||||
|
||||
- name: Enable SELinux access for Atlas file sharing
|
||||
tags: [atlas, sharing, services]
|
||||
ansible.posix.seboolean:
|
||||
name: "{{ item }}"
|
||||
state: true
|
||||
persistent: true
|
||||
loop: "{{ atlas_selinux_booleans }}"
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
when:
|
||||
- atlas_manage_sharing | bool
|
||||
- (ansible_facts['selinux'] | default({})).get('status', 'disabled') == 'enabled'
|
||||
|
||||
- name: Render Atlas Samba configuration
|
||||
tags: [atlas, sharing]
|
||||
ansible.builtin.template:
|
||||
src: smb.conf.j2
|
||||
dest: /etc/samba/smb.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
validate: "testparm --suppress-prompt %s"
|
||||
notify: Restart Samba service
|
||||
when: atlas_manage_sharing | bool
|
||||
|
||||
- name: Require Vault-backed Atlas Samba accounts
|
||||
tags: [atlas, sharing]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_samba_encryption in ['required', 'desired']
|
||||
- atlas_samba_accounts | length > 0
|
||||
- >-
|
||||
atlas_samba_accounts | map(attribute='username') | list
|
||||
| difference(atlas_samba_valid_users) | length == 0
|
||||
- >-
|
||||
atlas_samba_valid_users
|
||||
| difference(atlas_samba_accounts | map(attribute='username') | list) | length == 0
|
||||
- atlas_samba_accounts | selectattr('password', 'equalto', '') | list | length == 0
|
||||
fail_msg: Define every authorized Samba account and its Vault-backed password.
|
||||
no_log: true
|
||||
when: atlas_manage_sharing | bool
|
||||
|
||||
- name: Ensure Atlas Samba private state directory exists
|
||||
tags: [atlas, sharing]
|
||||
ansible.builtin.file:
|
||||
path: "{{ atlas_samba_password_marker_dir }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
when: atlas_manage_sharing | bool
|
||||
|
||||
- name: Manage Vault-backed Atlas Samba credentials
|
||||
tags: [atlas, sharing]
|
||||
ansible.builtin.include_tasks: samba_account.yml
|
||||
loop: "{{ atlas_samba_accounts }}"
|
||||
loop_control:
|
||||
loop_var: atlas_samba_account
|
||||
label: "{{ atlas_samba_account.username }}"
|
||||
no_log: true
|
||||
when: atlas_manage_sharing | bool
|
||||
|
||||
- name: Enable Atlas file-sharing services
|
||||
tags: [atlas, sharing, services]
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
enabled: true
|
||||
state: started
|
||||
loop:
|
||||
- nfs-server.service
|
||||
- smb.service
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
when: atlas_manage_sharing | bool
|
||||
|
||||
- name: Gather effective Atlas firewalld zone configuration
|
||||
tags: [atlas, sharing, services, security]
|
||||
ansible.posix.firewalld_info:
|
||||
zones:
|
||||
- "{{ atlas_firewalld_zone }}"
|
||||
register: atlas_firewalld_info
|
||||
when:
|
||||
- atlas_manage_firewall | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Verify effective Atlas firewalld restrictions
|
||||
tags: [atlas, sharing, services, security]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_firewalld_zone in atlas_firewalld_info.firewalld_info.zones
|
||||
- >-
|
||||
ansible_facts.default_ipv4.interface
|
||||
in atlas_firewalld_info.firewalld_info.zones[atlas_firewalld_zone].interfaces
|
||||
- >-
|
||||
atlas_firewalld_restricted_services
|
||||
| intersect(atlas_firewalld_info.firewalld_info.zones[atlas_firewalld_zone].services)
|
||||
| length == 0
|
||||
fail_msg: >-
|
||||
The effective Atlas firewalld zone does not restrict the primary LAN
|
||||
interface and unmanaged service exposure as declared.
|
||||
when:
|
||||
- atlas_manage_firewall | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Apply pending Atlas sharing handlers before verification
|
||||
tags: [atlas, sharing, services, security]
|
||||
ansible.builtin.meta: flush_handlers
|
||||
when:
|
||||
- atlas_manage_sharing | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Check active Atlas file-sharing services
|
||||
tags: [atlas, sharing, services, security]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- systemctl
|
||||
- is-active
|
||||
- --quiet
|
||||
- "{{ item }}"
|
||||
loop:
|
||||
- nfs-server.service
|
||||
- smb.service
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
register: atlas_file_sharing_service_activity
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when:
|
||||
- atlas_manage_sharing | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Require active Atlas file-sharing services
|
||||
tags: [atlas, sharing, services, security]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_file_sharing_service_activity.results | map(attribute='rc') | list == [0, 0]
|
||||
fail_msg: Atlas NFSv4 or SMB3 did not start after its managed configuration was applied.
|
||||
when:
|
||||
- atlas_manage_sharing | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Require Atlas file-sharing TCP listeners
|
||||
tags: [atlas, sharing, services, security]
|
||||
ansible.builtin.wait_for:
|
||||
host: 127.0.0.1
|
||||
port: "{{ item }}"
|
||||
state: started
|
||||
timeout: 15
|
||||
loop:
|
||||
- 2049
|
||||
- 445
|
||||
loop_control:
|
||||
label: "{{ item }}/tcp"
|
||||
when:
|
||||
- atlas_manage_sharing | bool
|
||||
- not ansible_check_mode
|
||||
146
ansible/roles/profile_atlas/tasks/storage.yml
Normal file
146
ansible/roles/profile_atlas/tasks/storage.yml
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
- name: Define Atlas ZFS datasets
|
||||
tags: [atlas, storage]
|
||||
ansible.builtin.set_fact:
|
||||
atlas_zfs_datasets:
|
||||
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_archive }}"
|
||||
mountpoint: "{{ atlas_archive_mountpoint }}"
|
||||
owner: "{{ atlas_admin_username }}"
|
||||
group: "{{ atlas_admin_group }}"
|
||||
properties:
|
||||
compression: zstd
|
||||
mountpoint: "{{ atlas_archive_mountpoint }}"
|
||||
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_services }}"
|
||||
mountpoint: "{{ atlas_services_mountpoint }}"
|
||||
owner: "{{ atlas_admin_username }}"
|
||||
group: "{{ atlas_admin_group }}"
|
||||
properties:
|
||||
compression: zstd
|
||||
mountpoint: "{{ atlas_services_mountpoint }}"
|
||||
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_app_data }}"
|
||||
mountpoint: "{{ atlas_app_data_mountpoint }}"
|
||||
owner: "{{ atlas_admin_username }}"
|
||||
group: "{{ atlas_admin_group }}"
|
||||
properties:
|
||||
compression: zstd
|
||||
mountpoint: "{{ atlas_app_data_mountpoint }}"
|
||||
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_navidrome }}"
|
||||
mountpoint: "{{ atlas_navidrome_mountpoint }}"
|
||||
owner: "{{ atlas_admin_username }}"
|
||||
group: "{{ atlas_admin_group }}"
|
||||
properties:
|
||||
compression: zstd
|
||||
mountpoint: "{{ atlas_navidrome_mountpoint }}"
|
||||
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_syncthing }}"
|
||||
mountpoint: "{{ atlas_syncthing_mountpoint }}"
|
||||
owner: "{{ atlas_admin_username }}"
|
||||
group: "{{ atlas_admin_group }}"
|
||||
properties:
|
||||
compression: lz4
|
||||
mountpoint: "{{ atlas_syncthing_mountpoint }}"
|
||||
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_media }}"
|
||||
mountpoint: "{{ atlas_media_mountpoint }}"
|
||||
owner: "{{ atlas_admin_username }}"
|
||||
group: "{{ atlas_admin_group }}"
|
||||
properties:
|
||||
compression: lz4
|
||||
mountpoint: "{{ atlas_media_mountpoint }}"
|
||||
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_music }}"
|
||||
mountpoint: "{{ atlas_music_mountpoint }}"
|
||||
owner: "{{ atlas_admin_username }}"
|
||||
group: "{{ atlas_admin_group }}"
|
||||
properties:
|
||||
compression: lz4
|
||||
mountpoint: "{{ atlas_music_mountpoint }}"
|
||||
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_backup }}"
|
||||
mountpoint: "{{ atlas_backup_mountpoint }}"
|
||||
owner: "{{ atlas_admin_username }}"
|
||||
group: "{{ atlas_admin_group }}"
|
||||
properties:
|
||||
compression: lz4
|
||||
mountpoint: "{{ atlas_backup_mountpoint }}"
|
||||
reservation: "{{ atlas_zfs_backup_reservation }}"
|
||||
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_host_backups }}"
|
||||
mountpoint: "{{ atlas_host_backups_mountpoint }}"
|
||||
owner: "{{ atlas_admin_username }}"
|
||||
group: "{{ atlas_admin_group }}"
|
||||
properties:
|
||||
compression: lz4
|
||||
mountpoint: "{{ atlas_host_backups_mountpoint }}"
|
||||
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_backup_prometheus }}"
|
||||
mountpoint: "{{ atlas_backup_prometheus_mountpoint }}"
|
||||
owner: "{{ atlas_admin_username }}"
|
||||
group: "{{ atlas_admin_group }}"
|
||||
properties:
|
||||
compression: lz4
|
||||
mountpoint: "{{ atlas_backup_prometheus_mountpoint }}"
|
||||
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_photobook }}"
|
||||
mountpoint: "{{ atlas_photobook_mountpoint }}"
|
||||
owner: "{{ atlas_immich_username }}"
|
||||
group: "{{ atlas_immich_group }}"
|
||||
properties:
|
||||
compression: lz4
|
||||
mountpoint: "{{ atlas_photobook_mountpoint }}"
|
||||
|
||||
- name: Require completed Atlas storage placeholders
|
||||
tags: [atlas, storage]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_zfs_pool != 'CHANGEME_ZFS_POOL'
|
||||
- atlas_mount_root != '/CHANGEME_ATLAS_MOUNT_ROOT'
|
||||
- atlas_mount_root.startswith('/')
|
||||
- (atlas_zfs_datasets | map(attribute='name') | unique | list | length) == (atlas_zfs_datasets | length)
|
||||
- (atlas_zfs_datasets | map(attribute='mountpoint') | unique | list | length) == (atlas_zfs_datasets | length)
|
||||
fail_msg: >-
|
||||
Replace the Atlas ZFS pool and mount-root placeholders, then set
|
||||
atlas_manage_storage to true before managing datasets.
|
||||
when: atlas_manage_storage | bool
|
||||
|
||||
- name: Check whether the configured Atlas ZFS pool exists
|
||||
tags: [atlas, storage]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- zpool
|
||||
- list
|
||||
- -H
|
||||
- -o
|
||||
- name
|
||||
- "{{ atlas_zfs_pool }}"
|
||||
register: atlas_zfs_pool_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: atlas_manage_storage | bool
|
||||
|
||||
- name: Require the configured Atlas ZFS pool
|
||||
tags: [atlas, storage]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_zfs_pool_check.rc == 0
|
||||
fail_msg: >-
|
||||
The configured Atlas ZFS pool does not exist. The Atlas profile only
|
||||
manages child datasets and never creates pools.
|
||||
when: atlas_manage_storage | bool
|
||||
|
||||
- name: Manage Atlas ZFS datasets declaratively
|
||||
tags: [atlas, storage]
|
||||
community.general.zfs:
|
||||
name: "{{ item.name }}"
|
||||
state: present
|
||||
extra_zfs_properties: "{{ item.properties }}"
|
||||
loop: "{{ atlas_zfs_datasets }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
when: atlas_manage_storage | bool
|
||||
|
||||
- name: Set Atlas dataset ownership
|
||||
tags: [atlas, storage]
|
||||
ansible.builtin.file:
|
||||
path: "{{ item.mountpoint }}"
|
||||
state: directory
|
||||
owner: "{{ item.owner }}"
|
||||
group: "{{ item.group }}"
|
||||
mode: "0770"
|
||||
loop: "{{ atlas_zfs_datasets }}"
|
||||
loop_control:
|
||||
label: "{{ item.mountpoint }}"
|
||||
when: atlas_manage_storage | bool
|
||||
145
ansible/roles/profile_atlas/tasks/usb_backup.yml
Normal file
145
ansible/roles/profile_atlas/tasks/usb_backup.yml
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
- name: Validate Atlas offline USB backup configuration
|
||||
tags: [atlas, storage, backup, usb_backup]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_manage_storage | bool
|
||||
- atlas_zfs_pool != 'CHANGEME_ZFS_POOL'
|
||||
- atlas_mount_root.startswith('/')
|
||||
- atlas_usb_backup_luks_uuid is match('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')
|
||||
- atlas_usb_backup_fs_uuid is match('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')
|
||||
- atlas_usb_backup_luks_uuid != atlas_usb_backup_fs_uuid
|
||||
- atlas_usb_backup_mapper_name is match('^[a-z][a-z0-9_-]*$')
|
||||
- atlas_usb_backup_min_free_bytes | int > 0
|
||||
- atlas_usb_backup_snapshot_prefix is match('^[a-z0-9][a-z0-9_-]*$')
|
||||
- atlas_usb_backup_snapshot_prefix != atlas_borg_snapshot_prefix
|
||||
- atlas_usb_backup_snapshot_prefix != atlas_zfs_snapshot_prefix
|
||||
fail_msg: >-
|
||||
The manual Atlas USB backup needs verified LUKS and ext4 UUIDs, a safe
|
||||
mapper name, positive free-space reserve, and a unique snapshot prefix.
|
||||
when: atlas_manage_usb_backup | bool
|
||||
|
||||
- name: Install rsync for the Atlas offline USB backup
|
||||
tags: [atlas, storage, backup, usb_backup]
|
||||
ansible.builtin.dnf:
|
||||
name: rsync
|
||||
state: present
|
||||
when: atlas_manage_usb_backup | bool
|
||||
|
||||
- name: Install the manual Atlas offline USB backup helper
|
||||
tags: [atlas, storage, backup, usb_backup]
|
||||
ansible.builtin.template:
|
||||
src: atlas-usb-backup.sh.j2
|
||||
dest: /usr/local/sbin/atlas-usb-backup
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
when: atlas_manage_usb_backup | bool
|
||||
|
||||
- name: Install the Atlas USB snapshot cleanup helper
|
||||
tags: [atlas, storage, backup, usb_backup]
|
||||
ansible.builtin.template:
|
||||
src: atlas-usb-snapshot-cleanup.sh.j2
|
||||
dest: /usr/local/sbin/atlas-usb-snapshot-cleanup
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
when: atlas_manage_usb_backup | bool
|
||||
|
||||
- name: Install the manual Atlas offline USB backup service
|
||||
tags: [atlas, storage, backup, usb_backup]
|
||||
ansible.builtin.template:
|
||||
src: atlas-usb-backup.service.j2
|
||||
dest: /etc/systemd/system/atlas-usb-backup.service
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when: atlas_manage_usb_backup | bool
|
||||
|
||||
- name: Reload systemd for the Atlas offline USB backup service
|
||||
tags: [atlas, storage, backup, usb_backup]
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
when:
|
||||
- atlas_manage_usb_backup | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Validate the 45Drives Atlas USB reminder configuration
|
||||
tags: [atlas, backup, usb_reminder]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_manage_usb_backup | bool
|
||||
- atlas_usb_reminder_calendar | length > 0
|
||||
- atlas_usb_reminder_notifier.startswith('/opt/45drives/houston/')
|
||||
fail_msg: >-
|
||||
Enable the manual USB backup and declare a systemd calendar before
|
||||
enabling its 45Drives Alerts reminder.
|
||||
when: atlas_manage_usb_reminder | bool
|
||||
|
||||
- name: Validate the Atlas USB reminder calendar
|
||||
tags: [atlas, backup, usb_reminder]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- systemd-analyze
|
||||
- calendar
|
||||
- "{{ atlas_usb_reminder_calendar }}"
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
when: atlas_manage_usb_reminder | bool
|
||||
|
||||
- name: Inspect the existing 45Drives notifier
|
||||
tags: [atlas, backup, usb_reminder]
|
||||
ansible.builtin.stat:
|
||||
path: "{{ atlas_usb_reminder_notifier }}"
|
||||
register: atlas_usb_reminder_notifier_file
|
||||
when: atlas_manage_usb_reminder | bool
|
||||
|
||||
- name: Require the configured 45Drives notifier for USB reminders
|
||||
tags: [atlas, backup, usb_reminder]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_usb_reminder_notifier_file.stat.executable | default(false)
|
||||
fail_msg: >-
|
||||
The existing 45Drives Houston notifier must be executable.
|
||||
when: atlas_manage_usb_reminder | bool
|
||||
|
||||
- name: Install the 45Drives Atlas USB reminder helper
|
||||
tags: [atlas, backup, usb_reminder]
|
||||
ansible.builtin.template:
|
||||
src: atlas-usb-reminder.py.j2
|
||||
dest: /usr/local/libexec/atlas-usb-reminder
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
when: atlas_manage_usb_reminder | bool
|
||||
|
||||
- name: Install the 45Drives Atlas USB reminder service
|
||||
tags: [atlas, backup, usb_reminder]
|
||||
ansible.builtin.template:
|
||||
src: atlas-usb-reminder.service.j2
|
||||
dest: /etc/systemd/system/atlas-usb-reminder.service
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when: atlas_manage_usb_reminder | bool
|
||||
|
||||
- name: Install the 45Drives Atlas USB reminder timer
|
||||
tags: [atlas, backup, usb_reminder]
|
||||
ansible.builtin.template:
|
||||
src: atlas-usb-reminder.timer.j2
|
||||
dest: /etc/systemd/system/atlas-usb-reminder.timer
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when: atlas_manage_usb_reminder | bool
|
||||
|
||||
- name: Enable only the Atlas USB notification reminder timer
|
||||
tags: [atlas, backup, usb_reminder]
|
||||
ansible.builtin.systemd:
|
||||
name: atlas-usb-reminder.timer
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
when:
|
||||
- atlas_manage_usb_reminder | bool
|
||||
- not ansible_check_mode
|
||||
175
ansible/roles/profile_atlas/tasks/zfs_maintenance.yml
Normal file
175
ansible/roles/profile_atlas/tasks/zfs_maintenance.yml
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
- name: Validate Atlas ZFS snapshot policy
|
||||
tags: [atlas, storage, snapshots]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_manage_storage | bool
|
||||
- atlas_zfs_pool != 'CHANGEME_ZFS_POOL'
|
||||
- atlas_zfs_snapshot_prefix is match('^[a-z0-9][a-z0-9_-]*$')
|
||||
- atlas_zfs_snapshot_policies | length > 0
|
||||
- >-
|
||||
(atlas_zfs_snapshot_policies | map(attribute='name') | unique | list | length)
|
||||
== (atlas_zfs_snapshot_policies | length)
|
||||
fail_msg: >-
|
||||
Enable Atlas storage and declare a non-empty snapshot policy with a safe
|
||||
prefix and unique policy names before managing automatic snapshots.
|
||||
when: atlas_manage_zfs_snapshots | bool
|
||||
|
||||
- name: Validate Atlas ZFS snapshot policy entries
|
||||
tags: [atlas, storage, snapshots]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item.name is match('^[a-z][a-z0-9_-]*$')
|
||||
- item.keep | int > 0
|
||||
- item.calendar | length > 0
|
||||
fail_msg: >-
|
||||
Every Atlas snapshot policy needs a safe name, a positive retention
|
||||
count, and a systemd calendar expression.
|
||||
loop: "{{ atlas_zfs_snapshot_policies }}"
|
||||
loop_control:
|
||||
label: "{{ item.name | default('unnamed') }}"
|
||||
when: atlas_manage_zfs_snapshots | bool
|
||||
|
||||
- name: Validate Atlas ZFS snapshot calendars
|
||||
tags: [atlas, storage, snapshots]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- systemd-analyze
|
||||
- calendar
|
||||
- "{{ item.calendar }}"
|
||||
loop: "{{ atlas_zfs_snapshot_policies }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}: {{ item.calendar }}"
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
when: atlas_manage_zfs_snapshots | bool
|
||||
|
||||
- name: Install Atlas ZFS snapshot and retention helper
|
||||
tags: [atlas, storage, snapshots]
|
||||
ansible.builtin.template:
|
||||
src: atlas-zfs-snapshot.sh.j2
|
||||
dest: /usr/local/sbin/atlas-zfs-snapshot
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
when: atlas_manage_zfs_snapshots | bool
|
||||
|
||||
- name: Install Atlas ZFS snapshot systemd service
|
||||
tags: [atlas, storage, snapshots]
|
||||
ansible.builtin.template:
|
||||
src: atlas-zfs-snapshot@.service.j2
|
||||
dest: /etc/systemd/system/atlas-zfs-snapshot@.service
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when: atlas_manage_zfs_snapshots | bool
|
||||
|
||||
- name: Install Atlas ZFS snapshot systemd timers
|
||||
tags: [atlas, storage, snapshots]
|
||||
ansible.builtin.template:
|
||||
src: atlas-zfs-snapshot.timer.j2
|
||||
dest: "/etc/systemd/system/atlas-zfs-snapshot-{{ item.name }}.timer"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
loop: "{{ atlas_zfs_snapshot_policies }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
when: atlas_manage_zfs_snapshots | bool
|
||||
|
||||
- name: Enable Atlas ZFS snapshot systemd timers
|
||||
tags: [atlas, storage, snapshots]
|
||||
ansible.builtin.systemd:
|
||||
name: "atlas-zfs-snapshot-{{ item.name }}.timer"
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
loop: "{{ atlas_zfs_snapshot_policies }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
when:
|
||||
- atlas_manage_zfs_snapshots | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Validate Atlas ZFS scrub policy
|
||||
tags: [atlas, storage, scrub]
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- atlas_manage_storage | bool
|
||||
- atlas_zfs_pool != 'CHANGEME_ZFS_POOL'
|
||||
- atlas_zfs_scrub_calendar | length > 0
|
||||
fail_msg: >-
|
||||
Enable Atlas storage and declare a systemd calendar expression before
|
||||
managing periodic ZFS scrubs.
|
||||
when: atlas_manage_zfs_scrub | bool
|
||||
|
||||
- name: Validate Atlas ZFS scrub calendar
|
||||
tags: [atlas, storage, scrub]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- systemd-analyze
|
||||
- calendar
|
||||
- "{{ atlas_zfs_scrub_calendar }}"
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
when: atlas_manage_zfs_scrub | bool
|
||||
|
||||
- name: Require OpenZFS scrub systemd units
|
||||
tags: [atlas, storage, scrub]
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- systemctl
|
||||
- cat
|
||||
- "{{ item }}"
|
||||
loop:
|
||||
- "zfs-scrub@{{ atlas_zfs_pool }}.service"
|
||||
- "zfs-scrub-monthly@{{ atlas_zfs_pool }}.timer"
|
||||
- "zfs-scrub-weekly@{{ atlas_zfs_pool }}.timer"
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
when: atlas_manage_zfs_scrub | bool
|
||||
|
||||
- name: Create Atlas ZFS scrub timer override directory
|
||||
tags: [atlas, storage, scrub]
|
||||
ansible.builtin.file:
|
||||
path: "/etc/systemd/system/zfs-scrub-monthly@{{ atlas_zfs_pool }}.timer.d"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
when: atlas_manage_zfs_scrub | bool
|
||||
|
||||
- name: Configure Atlas ZFS monthly scrub schedule
|
||||
tags: [atlas, storage, scrub]
|
||||
ansible.builtin.template:
|
||||
src: atlas-zfs-scrub-timer.conf.j2
|
||||
dest: >-
|
||||
/etc/systemd/system/zfs-scrub-monthly@{{ atlas_zfs_pool }}.timer.d/override.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when: atlas_manage_zfs_scrub | bool
|
||||
|
||||
- name: Disable the conflicting weekly OpenZFS scrub timer
|
||||
tags: [atlas, storage, scrub]
|
||||
ansible.builtin.systemd:
|
||||
name: "zfs-scrub-weekly@{{ atlas_zfs_pool }}.timer"
|
||||
enabled: false
|
||||
state: stopped
|
||||
daemon_reload: true
|
||||
when:
|
||||
- atlas_manage_zfs_scrub | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Enable the Atlas monthly OpenZFS scrub timer
|
||||
tags: [atlas, storage, scrub]
|
||||
ansible.builtin.systemd:
|
||||
name: "zfs-scrub-monthly@{{ atlas_zfs_pool }}.timer"
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
when:
|
||||
- atlas_manage_zfs_scrub | bool
|
||||
- not ansible_check_mode
|
||||
@@ -0,0 +1,24 @@
|
||||
# Managed by Ansible. Do not edit manually.
|
||||
PermitRootLogin no
|
||||
PubkeyAuthentication yes
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
AuthenticationMethods publickey
|
||||
AuthorizedKeysFile {{
|
||||
atlas_admin_ssh_keys
|
||||
| map(attribute='name')
|
||||
| map('regex_replace', '^', '%h/.ssh/authorized_keys.d/')
|
||||
| join(' ')
|
||||
}}
|
||||
X11Forwarding no
|
||||
AllowTcpForwarding {{ atlas_ssh_allow_tcp_forwarding }}
|
||||
AllowAgentForwarding no
|
||||
GatewayPorts no
|
||||
PermitTunnel no
|
||||
PermitUserEnvironment no
|
||||
MaxAuthTries 3
|
||||
LoginGraceTime 30
|
||||
ClientAliveInterval 300
|
||||
ClientAliveCountMax 2
|
||||
LogLevel VERBOSE
|
||||
AllowUsers {{ atlas_admin_username }}
|
||||
@@ -0,0 +1,41 @@
|
||||
[Unit]
|
||||
Description=Back up Atlas ZFS datasets to the encrypted Borg repository
|
||||
Documentation=man:borg-create(1) man:borg-prune(1) man:borg-compact(1)
|
||||
Requires=zfs.target
|
||||
Wants=network-online.target
|
||||
After=zfs.target network-online.target
|
||||
StartLimitIntervalSec=6h
|
||||
StartLimitBurst=3
|
||||
ConditionFileIsExecutable=/usr/local/sbin/atlas-borg-backup
|
||||
ConditionPathExists={{ atlas_borg_passphrase_path }}
|
||||
ConditionPathExists={{ atlas_borg_ssh_private_key_path }}
|
||||
ConditionPathExists={{ atlas_borg_known_hosts_path }}
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/atlas-borg-backup
|
||||
ExecStopPost=+/usr/local/sbin/atlas-borg-snapshot-cleanup
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
SuccessExitStatus=1
|
||||
Restart=on-failure
|
||||
RestartSec=30m
|
||||
TimeoutStartSec=infinity
|
||||
RuntimeDirectory=atlas-borg
|
||||
RuntimeDirectoryMode=0750
|
||||
Nice=15
|
||||
IOSchedulingClass=best-effort
|
||||
IOSchedulingPriority=7
|
||||
NoNewPrivileges=true
|
||||
PrivateMounts=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths={{ atlas_borg_cache_dir }} {{ atlas_borg_config_dir }} /run/atlas-borg /run/lock
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
212
ansible/roles/profile_atlas/templates/atlas-borg-backup.sh.j2
Normal file
212
ansible/roles/profile_atlas/templates/atlas-borg-backup.sh.j2
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
export LC_ALL=C.utf8
|
||||
export PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
export BORG_CACHE_DIR={{ atlas_borg_cache_dir | quote }}
|
||||
export BORG_CONFIG_DIR={{ atlas_borg_config_dir | quote }}
|
||||
export BORG_PASSCOMMAND={{ ('cat ' ~ atlas_borg_passphrase_path) | quote }}
|
||||
export BORG_RSH={{ atlas_borg_ssh_wrapper_path | quote }}
|
||||
|
||||
readonly pool={{ atlas_zfs_pool | quote }}
|
||||
readonly mount_root={{ atlas_mount_root | quote }}
|
||||
readonly repository={{ ('ssh://' ~ atlas_borg_repository_user ~ '@' ~ atlas_borg_repository_host
|
||||
~ ':' ~ (atlas_borg_repository_port | string) ~ '/' ~ atlas_borg_repository_path) | quote }}
|
||||
readonly remote_path={{ atlas_borg_remote_path | quote }}
|
||||
readonly archive_prefix={{ atlas_borg_archive_prefix | quote }}
|
||||
readonly snapshot_prefix={{ atlas_borg_snapshot_prefix | quote }}
|
||||
readonly compression={{ atlas_borg_compression | quote }}
|
||||
readonly stage=/run/atlas-borg/source
|
||||
readonly snapshot_marker=/run/atlas-borg/snapshot-name
|
||||
readonly borg_user={{ atlas_borg_username | quote }}
|
||||
readonly borg_group={{ atlas_borg_group | quote }}
|
||||
readonly borg_home={{ atlas_borg_home | quote }}
|
||||
readonly borg_lock={{ atlas_borg_lock_path | quote }}
|
||||
readonly progress_filter=/usr/local/libexec/atlas-borg-progress
|
||||
|
||||
snapshot_name=""
|
||||
mounted_targets=()
|
||||
|
||||
# Invoked through the EXIT trap below.
|
||||
# shellcheck disable=SC2329
|
||||
cleanup() {
|
||||
local status=$?
|
||||
local cleanup_status=0
|
||||
local index
|
||||
local source_mount_failed=false
|
||||
trap - EXIT HUP INT TERM
|
||||
set +e
|
||||
|
||||
{% raw %}
|
||||
for ((index = ${#mounted_targets[@]} - 1; index >= 0; index--)); do
|
||||
{% endraw %}
|
||||
if mountpoint -q "${mounted_targets[$index]}" && ! umount -R "${mounted_targets[$index]}"; then
|
||||
printf 'Source snapshot mount cleanup failed: %s\n' "${mounted_targets[$index]}" >&2
|
||||
source_mount_failed=true
|
||||
fi
|
||||
if mountpoint -q "${mounted_targets[$index]}"; then
|
||||
printf 'Source snapshot mount is still active: %s\n' "${mounted_targets[$index]}" >&2
|
||||
source_mount_failed=true
|
||||
else
|
||||
rmdir -- "${mounted_targets[$index]}" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [[ "$source_mount_failed" == false ]]; then
|
||||
if [[ -d "$stage" ]]; then
|
||||
rmdir -- "$stage" 2>/dev/null || cleanup_status=2
|
||||
fi
|
||||
else
|
||||
cleanup_status=2
|
||||
printf 'Source bind mount cleanup failed; keeping the snapshot for recovery\n' >&2
|
||||
fi
|
||||
|
||||
if ((status == 0 && cleanup_status != 0)); then
|
||||
status=$cleanup_status
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
trap 'exit 143' HUP INT TERM
|
||||
|
||||
run_as_borg() {
|
||||
setpriv \
|
||||
--reuid "$borg_user" \
|
||||
--regid "$borg_group" \
|
||||
--clear-groups \
|
||||
--inh-caps=-all,+dac_read_search \
|
||||
--ambient-caps=-all,+dac_read_search \
|
||||
--bounding-set=-all,+dac_read_search \
|
||||
-- env HOME="$borg_home" USER="$borg_user" LOGNAME="$borg_user" "$@"
|
||||
}
|
||||
|
||||
exec 8>"$borg_lock"
|
||||
flock 8
|
||||
exec 9>/run/lock/atlas-zfs-snapshot.lock
|
||||
|
||||
zpool list -H -o name "$pool" >/dev/null
|
||||
rm -rf "$stage"
|
||||
mkdir -p "$stage"
|
||||
chown root:"$borg_group" /run/atlas-borg "$stage"
|
||||
chmod 0750 /run/atlas-borg "$stage"
|
||||
|
||||
flock 9
|
||||
while IFS= read -r stale_snapshot; do
|
||||
stale_suffix="${stale_snapshot#"${pool}@${snapshot_prefix}-"}"
|
||||
if [[ "$stale_suffix" =~ ^[0-9]{8}T[0-9]{6}Z$ ]]; then
|
||||
zfs destroy -r "$stale_snapshot"
|
||||
printf 'Removed stale Borg source snapshot %s\n' "$stale_snapshot"
|
||||
fi
|
||||
done < <(
|
||||
zfs list -H -t snapshot -o name -r "$pool" |
|
||||
grep -E "^${pool}@${snapshot_prefix}-[0-9]{8}T[0-9]{6}Z$" || true
|
||||
)
|
||||
|
||||
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
readonly timestamp
|
||||
snapshot_name="${snapshot_prefix}-${timestamp}"
|
||||
readonly snapshot_name
|
||||
printf '%s\n' "$snapshot_name" >"$snapshot_marker"
|
||||
zfs snapshot -r "${pool}@${snapshot_name}"
|
||||
flock -u 9
|
||||
printf 'Created recursive Borg source snapshot %s@%s\n' "$pool" "$snapshot_name"
|
||||
|
||||
while IFS=$'\t' read -r dataset dataset_mountpoint mounted; do
|
||||
if [[ "$mounted" != yes ]]; then
|
||||
printf 'Dataset %s is not mounted; refusing an incomplete backup\n' "$dataset" >&2
|
||||
exit 65
|
||||
fi
|
||||
if [[ "$dataset_mountpoint" != "$mount_root" && "$dataset_mountpoint" != "$mount_root/"* ]]; then
|
||||
printf 'Dataset %s has unexpected mountpoint %s\n' "$dataset" "$dataset_mountpoint" >&2
|
||||
exit 65
|
||||
fi
|
||||
|
||||
dataset_suffix="${dataset#"$pool"}"
|
||||
source_path="${dataset_mountpoint}/.zfs/snapshot/${snapshot_name}"
|
||||
target_path="${stage}${dataset_suffix}"
|
||||
mkdir -p "$target_path"
|
||||
mount --bind "$source_path" "$target_path"
|
||||
mounted_targets+=("$target_path")
|
||||
mount -o remount,bind,ro "$target_path"
|
||||
done < <(zfs list -H -o name,mountpoint,mounted -s name -r "$pool")
|
||||
|
||||
estimated_source_bytes=0
|
||||
while IFS=$'\t' read -r source_snapshot logical_bytes; do
|
||||
if [[ "$source_snapshot" == *"@${snapshot_name}" ]]; then
|
||||
[[ "$logical_bytes" =~ ^[0-9]+$ ]] || {
|
||||
printf 'Invalid logical size for Borg source snapshot %s\n' "$source_snapshot" >&2
|
||||
exit 74
|
||||
}
|
||||
estimated_source_bytes=$((estimated_source_bytes + logical_bytes))
|
||||
fi
|
||||
done < <(zfs list -H -p -t snapshot -o name,logicalreferenced -r "$pool")
|
||||
((estimated_source_bytes > 0)) || {
|
||||
printf 'Could not estimate the Borg source snapshot size\n' >&2
|
||||
exit 74
|
||||
}
|
||||
printf 'Estimated Borg source logical size: %s bytes (ZFS; progress percentage is approximate)\n' \
|
||||
"$estimated_source_bytes"
|
||||
|
||||
archive="${archive_prefix}-${timestamp}"
|
||||
readonly archive
|
||||
borg_status=0
|
||||
|
||||
printf 'Starting Borg archive %s from snapshot %s@%s\n' "$archive" "$pool" "$snapshot_name"
|
||||
set +e
|
||||
(
|
||||
cd /run/atlas-borg
|
||||
run_as_borg borg --remote-path "$remote_path" --lock-wait 600 --log-json --progress create \
|
||||
--show-rc \
|
||||
--stats \
|
||||
--checkpoint-interval 900 \
|
||||
--compression "$compression" \
|
||||
"${repository}::${archive}" \
|
||||
source 2>&1
|
||||
) | /usr/bin/python3 -u "$progress_filter" --estimated-total-bytes "$estimated_source_bytes"
|
||||
create_pipeline_status=("${PIPESTATUS[@]}")
|
||||
set -e
|
||||
create_status=${create_pipeline_status[0]}
|
||||
if ((create_pipeline_status[1] != 0)); then
|
||||
printf 'Borg progress logging failed with status %s\n' "${create_pipeline_status[1]}" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ((create_status >= 2)); then
|
||||
exit "$create_status"
|
||||
fi
|
||||
borg_status=$create_status
|
||||
|
||||
printf 'Borg archive %s created; applying retention\n' "$archive"
|
||||
set +e
|
||||
run_as_borg borg --remote-path "$remote_path" --lock-wait 600 prune \
|
||||
--show-rc \
|
||||
--list \
|
||||
--glob-archives "${archive_prefix}-*" \
|
||||
--keep-daily {{ atlas_borg_keep_daily | int }} \
|
||||
--keep-weekly {{ atlas_borg_keep_weekly | int }} \
|
||||
--keep-monthly {{ atlas_borg_keep_monthly | int }} \
|
||||
"$repository"
|
||||
prune_status=$?
|
||||
set -e
|
||||
if ((prune_status >= 2)); then
|
||||
exit "$prune_status"
|
||||
fi
|
||||
if ((prune_status > borg_status)); then
|
||||
borg_status=$prune_status
|
||||
fi
|
||||
|
||||
printf 'Borg retention complete; compacting repository\n'
|
||||
set +e
|
||||
run_as_borg borg --remote-path "$remote_path" --lock-wait 600 compact \
|
||||
--show-rc \
|
||||
"$repository"
|
||||
compact_status=$?
|
||||
set -e
|
||||
if ((compact_status >= 2)); then
|
||||
exit "$compact_status"
|
||||
fi
|
||||
if ((compact_status > borg_status)); then
|
||||
borg_status=$compact_status
|
||||
fi
|
||||
|
||||
printf 'Borg backup %s completed with status %s\n' "$archive" "$borg_status"
|
||||
exit "$borg_status"
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Schedule the encrypted Atlas Borg backup
|
||||
|
||||
[Timer]
|
||||
OnCalendar={{ atlas_borg_backup_calendar }}
|
||||
Persistent=true
|
||||
RandomizedDelaySec={{ atlas_borg_randomized_delay }}
|
||||
AccuracySec=1min
|
||||
Unit=atlas-borg-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,32 @@
|
||||
[Unit]
|
||||
Description=Check the encrypted Atlas Borg repository
|
||||
Documentation=man:borg-check(1)
|
||||
Wants=network-online.target
|
||||
After=network-online.target atlas-borg-backup.service
|
||||
ConditionFileIsExecutable=/usr/local/sbin/atlas-borg-check
|
||||
ConditionPathExists={{ atlas_borg_passphrase_path }}
|
||||
ConditionPathExists={{ atlas_borg_ssh_private_key_path }}
|
||||
ConditionPathExists={{ atlas_borg_known_hosts_path }}
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/atlas-borg-check
|
||||
User={{ atlas_borg_username }}
|
||||
Group={{ atlas_borg_group }}
|
||||
UMask=0077
|
||||
SuccessExitStatus=1
|
||||
TimeoutStartSec=infinity
|
||||
Nice=15
|
||||
IOSchedulingClass=best-effort
|
||||
IOSchedulingPriority=7
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths={{ atlas_borg_cache_dir }} {{ atlas_borg_config_dir }}
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
23
ansible/roles/profile_atlas/templates/atlas-borg-check.sh.j2
Normal file
23
ansible/roles/profile_atlas/templates/atlas-borg-check.sh.j2
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
export LC_ALL=C
|
||||
export PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
export BORG_CACHE_DIR={{ atlas_borg_cache_dir | quote }}
|
||||
export BORG_CONFIG_DIR={{ atlas_borg_config_dir | quote }}
|
||||
export BORG_PASSCOMMAND={{ ('cat ' ~ atlas_borg_passphrase_path) | quote }}
|
||||
export BORG_RSH={{ atlas_borg_ssh_wrapper_path | quote }}
|
||||
|
||||
readonly repository={{ ('ssh://' ~ atlas_borg_repository_user ~ '@' ~ atlas_borg_repository_host
|
||||
~ ':' ~ (atlas_borg_repository_port | string) ~ '/' ~ atlas_borg_repository_path) | quote }}
|
||||
readonly remote_path={{ atlas_borg_remote_path | quote }}
|
||||
readonly archive_prefix={{ atlas_borg_archive_prefix | quote }}
|
||||
readonly borg_lock={{ atlas_borg_lock_path | quote }}
|
||||
|
||||
exec 8>"$borg_lock"
|
||||
flock 8
|
||||
|
||||
exec borg --remote-path "$remote_path" --lock-wait 600 check \
|
||||
--show-rc \
|
||||
--glob-archives "${archive_prefix}-*" \
|
||||
"$repository"
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Schedule checks of the encrypted Atlas Borg repository
|
||||
|
||||
[Timer]
|
||||
OnCalendar={{ atlas_borg_check_calendar }}
|
||||
Persistent=true
|
||||
RandomizedDelaySec={{ atlas_borg_randomized_delay }}
|
||||
AccuracySec=1min
|
||||
Unit=atlas-borg-check.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
export PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
readonly pool={{ atlas_zfs_pool | quote }}
|
||||
readonly mount_root={{ atlas_mount_root | quote }}
|
||||
readonly snapshot_prefix={{ atlas_borg_snapshot_prefix | quote }}
|
||||
readonly marker=/run/atlas-borg/snapshot-name
|
||||
|
||||
[[ -e "$marker" ]] || exit 0
|
||||
[[ -f "$marker" && ! -L "$marker" ]] || {
|
||||
printf 'Unsafe Atlas Borg snapshot marker; leaving snapshots unchanged\n' >&2
|
||||
exit 2
|
||||
}
|
||||
IFS= read -r snapshot_name <"$marker"
|
||||
[[ "$snapshot_name" =~ ^${snapshot_prefix}-[0-9]{8}T[0-9]{6}Z$ ]] || {
|
||||
printf 'Invalid Atlas Borg snapshot marker; leaving snapshots unchanged\n' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
exec 9>/run/lock/atlas-zfs-snapshot.lock
|
||||
flock 9
|
||||
if zfs list -H -t snapshot -o name "${pool}@${snapshot_name}" >/dev/null 2>&1; then
|
||||
# The private bind mounts are gone, but ZFS may leave its on-demand
|
||||
# .zfs/snapshot mounts in the host namespace until explicitly unmounted.
|
||||
snapshot_mounts=()
|
||||
snapshot_sources=()
|
||||
while IFS=$'\t' read -r dataset dataset_mountpoint; do
|
||||
[[ "$dataset_mountpoint" == "$mount_root" || "$dataset_mountpoint" == "$mount_root/"* ]] || continue
|
||||
snapshot_mounts+=("${dataset_mountpoint}/.zfs/snapshot/${snapshot_name}")
|
||||
snapshot_sources+=("${dataset}@${snapshot_name}")
|
||||
done < <(zfs list -H -o name,mountpoint -s name -r "$pool")
|
||||
|
||||
{% raw %}
|
||||
for ((index = ${#snapshot_mounts[@]} - 1; index >= 0; index--)); do
|
||||
{% endraw %}
|
||||
mounted_source="$(findmnt -rn -M "${snapshot_mounts[$index]}" -o SOURCE || true)"
|
||||
[[ -n "$mounted_source" ]] || continue
|
||||
[[ "$mounted_source" == "${snapshot_sources[$index]}" ]] || {
|
||||
printf 'Unexpected source on Atlas Borg snapshot mount: %s\n' \
|
||||
"${snapshot_mounts[$index]}" >&2
|
||||
exit 2
|
||||
}
|
||||
umount "${snapshot_mounts[$index]}"
|
||||
done
|
||||
|
||||
zfs destroy -r "${pool}@${snapshot_name}"
|
||||
printf 'Removed recursive Atlas Borg source snapshot %s@%s after backup exit\n' \
|
||||
"$pool" "$snapshot_name"
|
||||
fi
|
||||
19
ansible/roles/profile_atlas/templates/atlas-borg-ssh.sh.j2
Normal file
19
ansible/roles/profile_atlas/templates/atlas-borg-ssh.sh.j2
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Borg receives CAP_DAC_READ_SEARCH only for local snapshot traversal. Drop it
|
||||
# before starting the network transport so SSH runs as the plain service user.
|
||||
exec setpriv \
|
||||
--inh-caps=-all \
|
||||
--ambient-caps=-all \
|
||||
-- /usr/bin/ssh \
|
||||
-i {{ atlas_borg_ssh_private_key_path | quote }} \
|
||||
-p {{ atlas_borg_repository_port | int }} \
|
||||
-o BatchMode=yes \
|
||||
-o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile={{ atlas_borg_known_hosts_path | quote }} \
|
||||
-o ConnectTimeout=30 \
|
||||
-o ServerAliveInterval=60 \
|
||||
-o ServerAliveCountMax=3 \
|
||||
"$@"
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"pool": {{ atlas_zfs_pool | to_json }},
|
||||
"backup_dataset": {{ (atlas_zfs_pool ~ '/' ~ atlas_zfs_dataset_backup) | to_json }},
|
||||
"notifier": {{ atlas_monitor_notifier | to_json }},
|
||||
"smart_devices": {{ atlas_monitor_smart_devices | to_json }},
|
||||
"timers": {{ atlas_monitor_timers | to_json }},
|
||||
"failure_units": {{ atlas_monitor_failure_units | to_json }},
|
||||
"remote_capacity": {{ atlas_monitor_remote_capacity | to_json }},
|
||||
"pool_warning_percent": {{ atlas_monitor_pool_warning_percent | int }},
|
||||
"pool_critical_percent": {{ atlas_monitor_pool_critical_percent | int }},
|
||||
"root_warning_percent": {{ atlas_monitor_root_warning_percent | int }},
|
||||
"root_critical_percent": {{ atlas_monitor_root_critical_percent | int }},
|
||||
"snapshot_warning_percent": {{ atlas_monitor_snapshot_warning_percent | int }},
|
||||
"snapshot_critical_percent": {{ atlas_monitor_snapshot_critical_percent | int }},
|
||||
"snapshot_growth_warning_gib_day": {{ atlas_monitor_snapshot_growth_warning_gib_day | int }},
|
||||
"backup_growth_warning_gib_day": {{ atlas_monitor_backup_growth_warning_gib_day | int }},
|
||||
"cpu_warning_c": {{ atlas_monitor_cpu_warning_c | int }},
|
||||
"cpu_critical_c": {{ atlas_monitor_cpu_critical_c | int }},
|
||||
"borg_max_runtime_days": {{ atlas_monitor_borg_max_runtime_days | int }}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[Unit]
|
||||
Description=Check Atlas pool, disks, capacity, temperatures and maintenance jobs
|
||||
Wants=houston-dbus.service network-online.target
|
||||
After=zfs.target houston-dbus.service network-online.target
|
||||
ConditionFileIsExecutable=/usr/local/libexec/atlas-health-monitor
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/libexec/atlas-health-monitor
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
StateDirectory=atlas-health-monitor
|
||||
StateDirectoryMode=0700
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/lib/atlas-health-monitor
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Schedule Atlas health checks
|
||||
|
||||
[Timer]
|
||||
OnCalendar={{ atlas_monitor_calendar }}
|
||||
Persistent=true
|
||||
RandomizedDelaySec=5min
|
||||
Unit=atlas-health-monitor.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,27 @@
|
||||
# Managed by Ansible. Do not edit manually.
|
||||
[Unit]
|
||||
Description=Atlas Immich machine learning
|
||||
|
||||
[Container]
|
||||
ContainerName=atlas-immich-machine-learning
|
||||
Image={{ atlas_immich_machine_learning_image }}
|
||||
User={{ atlas_immich_uid }}:{{ atlas_immich_gid }}
|
||||
{% for group_name in atlas_immich_supplementary_groups %}
|
||||
GroupAdd={{ ansible_facts['getent_group'][group_name][1] }}
|
||||
{% endfor %}
|
||||
AddDevice=/dev/dri
|
||||
Network=atlas-media.network
|
||||
NetworkAlias=atlas-immich-machine-learning
|
||||
Environment=TZ={{ atlas_timezone }}
|
||||
Volume={{ atlas_immich_model_cache_dir }}:/cache:Z
|
||||
Volume={{ atlas_immich_machine_learning_config_dir }}:/.config:Z
|
||||
Volume={{ atlas_immich_machine_learning_cache_dir }}:/.cache:Z
|
||||
NoNewPrivileges=true
|
||||
DropCapability=NET_RAW
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
TimeoutStartSec=900
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,22 @@
|
||||
# Managed by Ansible. Do not edit manually.
|
||||
[Unit]
|
||||
Description=Atlas Immich PostgreSQL database
|
||||
|
||||
[Container]
|
||||
ContainerName=atlas-immich-postgres
|
||||
Image={{ atlas_immich_postgres_image }}
|
||||
User={{ atlas_immich_uid }}:{{ atlas_immich_gid }}
|
||||
Network=atlas-media.network
|
||||
NetworkAlias=atlas-immich-postgres
|
||||
EnvironmentFile=/etc/immich/immich.env
|
||||
Volume={{ atlas_immich_postgres_data_dir }}:/var/lib/postgresql/data:Z
|
||||
ShmSize=128m
|
||||
NoNewPrivileges=true
|
||||
DropCapability=NET_RAW
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
TimeoutStartSec=900
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,20 @@
|
||||
# Managed by Ansible. Do not edit manually.
|
||||
[Unit]
|
||||
Description=Atlas Immich Redis-compatible cache
|
||||
|
||||
[Container]
|
||||
ContainerName=atlas-immich-redis
|
||||
Image={{ atlas_immich_redis_image }}
|
||||
User={{ atlas_immich_uid }}:{{ atlas_immich_gid }}
|
||||
Network=atlas-media.network
|
||||
NetworkAlias=atlas-immich-redis
|
||||
Volume={{ atlas_immich_redis_data_dir }}:/data:Z
|
||||
NoNewPrivileges=true
|
||||
DropCapability=NET_RAW
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
TimeoutStartSec=900
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,29 @@
|
||||
# Managed by Ansible. Do not edit manually.
|
||||
[Unit]
|
||||
Description=Atlas Immich server
|
||||
Requires=atlas-immich-redis.service atlas-immich-postgres.service atlas-immich-machine-learning.service
|
||||
After=atlas-immich-redis.service atlas-immich-postgres.service atlas-immich-machine-learning.service
|
||||
|
||||
[Container]
|
||||
ContainerName=atlas-immich-server
|
||||
Image={{ atlas_immich_server_image }}
|
||||
User={{ atlas_immich_uid }}:{{ atlas_immich_gid }}
|
||||
{% for group_name in atlas_immich_supplementary_groups %}
|
||||
GroupAdd={{ ansible_facts['getent_group'][group_name][1] }}
|
||||
{% endfor %}
|
||||
AddDevice=/dev/dri
|
||||
Network=atlas-media.network
|
||||
NetworkAlias=atlas-immich-server
|
||||
EnvironmentFile=/etc/immich/immich.env
|
||||
Volume={{ atlas_immich_upload_dir }}:/data:Z
|
||||
Volume={{ atlas_photobook_mountpoint }}:/external/photobook:ro,Z
|
||||
Volume=/etc/localtime:/etc/localtime:ro
|
||||
NoNewPrivileges=true
|
||||
DropCapability=NET_RAW
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
TimeoutStartSec=900
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,3 @@
|
||||
# Managed by Ansible. Do not edit manually.
|
||||
[Network]
|
||||
NetworkName=atlas-media
|
||||
@@ -0,0 +1,2 @@
|
||||
[Unit]
|
||||
OnFailure=atlas-monitor-failure@%n.service
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Submit a 45Drives Alert for failed Atlas job %I
|
||||
Requires=houston-dbus.service
|
||||
After=houston-dbus.service
|
||||
ConditionFileIsExecutable=/usr/local/libexec/atlas-health-monitor
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/libexec/atlas-health-monitor --job-failed %I
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
3
ansible/roles/profile_atlas/templates/atlas-nfs.conf.j2
Normal file
3
ansible/roles/profile_atlas/templates/atlas-nfs.conf.j2
Normal file
@@ -0,0 +1,3 @@
|
||||
# Managed by Ansible. Do not edit manually.
|
||||
[nfsd]
|
||||
vers3 = n
|
||||
21
ansible/roles/profile_atlas/templates/atlas-npm.container.j2
Normal file
21
ansible/roles/profile_atlas/templates/atlas-npm.container.j2
Normal file
@@ -0,0 +1,21 @@
|
||||
# Managed by Ansible. Do not edit manually.
|
||||
[Unit]
|
||||
Description=Atlas Nginx Proxy Manager
|
||||
|
||||
[Container]
|
||||
ContainerName=atlas-npm
|
||||
Image={{ atlas_npm_image }}
|
||||
Network=atlas-media.network
|
||||
NetworkAlias=atlas-npm
|
||||
PublishPort=80:80
|
||||
PublishPort=443:443
|
||||
PublishPort={{ atlas_npm_admin_bind_address }}:81:81
|
||||
Volume={{ atlas_npm_data_dir }}:/data:Z
|
||||
Volume={{ atlas_npm_letsencrypt_dir }}:/etc/letsencrypt:Z
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
TimeoutStartSec=900
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
Description=Run a manual, UUID-bound offline USB backup of Atlas ZFS datasets
|
||||
Requires=zfs.target
|
||||
After=zfs.target
|
||||
ConditionFileIsExecutable=/usr/local/sbin/atlas-usb-backup
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/atlas-usb-backup
|
||||
ExecStopPost=+/usr/local/sbin/atlas-usb-snapshot-cleanup
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
TimeoutStartSec=infinity
|
||||
RuntimeDirectory=atlas-usb-backup
|
||||
RuntimeDirectoryMode=0700
|
||||
Nice=15
|
||||
IOSchedulingClass=best-effort
|
||||
IOSchedulingPriority=7
|
||||
PrivateMounts=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/run/atlas-usb-backup /run/lock
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
242
ansible/roles/profile_atlas/templates/atlas-usb-backup.sh.j2
Normal file
242
ansible/roles/profile_atlas/templates/atlas-usb-backup.sh.j2
Normal file
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
export LC_ALL=C.utf8
|
||||
export PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
readonly pool={{ atlas_zfs_pool | quote }}
|
||||
readonly mount_root={{ atlas_mount_root | quote }}
|
||||
readonly luks_uuid={{ atlas_usb_backup_luks_uuid | quote }}
|
||||
readonly fs_uuid={{ atlas_usb_backup_fs_uuid | quote }}
|
||||
readonly mapper_name={{ atlas_usb_backup_mapper_name | quote }}
|
||||
readonly snapshot_prefix={{ atlas_usb_backup_snapshot_prefix | quote }}
|
||||
readonly min_free_bytes={{ atlas_usb_backup_min_free_bytes | int }}
|
||||
readonly mapper="/dev/mapper/${mapper_name}"
|
||||
readonly outer="/dev/disk/by-uuid/${luks_uuid}"
|
||||
readonly runtime_dir=/run/atlas-usb-backup
|
||||
readonly snapshot_marker="${runtime_dir}/snapshot-name"
|
||||
readonly source_dir="${runtime_dir}/source"
|
||||
readonly usb_mount="${runtime_dir}/target"
|
||||
readonly backup_root="${usb_mount}/atlas"
|
||||
|
||||
snapshot_name=""
|
||||
mapper_opened_by_script=false
|
||||
usb_mounted=false
|
||||
published=false
|
||||
partial=""
|
||||
mounted_targets=()
|
||||
|
||||
# shellcheck disable=SC2329
|
||||
cleanup() {
|
||||
local status=$?
|
||||
local cleanup_status=0
|
||||
local index
|
||||
local source_mount_failed=false
|
||||
trap - EXIT HUP INT TERM
|
||||
set +e
|
||||
|
||||
if [[ -n "$partial" && "$published" == false && "$usb_mounted" == true ]]; then
|
||||
rm -rf -- "$partial" || cleanup_status=2
|
||||
fi
|
||||
if [[ "$usb_mounted" == true ]]; then
|
||||
umount "$usb_mount" || cleanup_status=2
|
||||
fi
|
||||
|
||||
{% raw %}
|
||||
for ((index = ${#mounted_targets[@]} - 1; index >= 0; index--)); do
|
||||
{% endraw %}
|
||||
if mountpoint -q "${mounted_targets[$index]}" && ! umount -R "${mounted_targets[$index]}"; then
|
||||
printf 'Source snapshot mount cleanup failed: %s\n' "${mounted_targets[$index]}" >&2
|
||||
source_mount_failed=true
|
||||
fi
|
||||
if mountpoint -q "${mounted_targets[$index]}"; then
|
||||
printf 'Source snapshot mount is still active: %s\n' "${mounted_targets[$index]}" >&2
|
||||
source_mount_failed=true
|
||||
else
|
||||
rmdir -- "${mounted_targets[$index]}" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [[ "$source_mount_failed" == false ]]; then
|
||||
rmdir -- "$source_dir" 2>/dev/null || true
|
||||
else
|
||||
cleanup_status=2
|
||||
printf 'Source bind mount cleanup failed; keeping the snapshot for recovery\n' >&2
|
||||
fi
|
||||
|
||||
if [[ "$usb_mounted" == true || "$mapper_opened_by_script" == true ]] &&
|
||||
! mountpoint -q "$usb_mount" &&
|
||||
! findmnt -rn -S "$mapper" >/dev/null; then
|
||||
cryptsetup close "$mapper_name" || cleanup_status=2
|
||||
fi
|
||||
rmdir -- "$usb_mount" 2>/dev/null || true
|
||||
|
||||
if ((status == 0 && cleanup_status != 0)); then
|
||||
status=$cleanup_status
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
trap 'exit 143' HUP INT TERM
|
||||
|
||||
exec 8>/run/lock/atlas-usb-backup.lock
|
||||
flock -n 8 || { printf 'Atlas USB backup is already running\n' >&2; exit 75; }
|
||||
exec 9>/run/lock/atlas-zfs-snapshot.lock
|
||||
|
||||
zpool list -H -o name "$pool" >/dev/null
|
||||
[[ -b "$outer" ]] || { printf 'Configured LUKS UUID is not connected\n' >&2; exit 66; }
|
||||
[[ "$(blkid -s TYPE -o value "$outer")" == crypto_LUKS ]] || {
|
||||
printf 'Configured outer UUID is not a LUKS container\n' >&2
|
||||
exit 65
|
||||
}
|
||||
[[ "$(blkid -s UUID -o value "$outer")" == "$luks_uuid" ]] || exit 65
|
||||
if ! cryptsetup status "$mapper_name" >/dev/null; then
|
||||
printf 'Requesting the LUKS passphrase for the configured USB disk\n'
|
||||
systemd-ask-password -n --no-tty --timeout=300 \
|
||||
--id="atlas-usb-backup:${luks_uuid}" \
|
||||
'Atlas offline USB backup LUKS passphrase:' |
|
||||
cryptsetup open --type luks2 --key-file - "$outer" "$mapper_name"
|
||||
mapper_opened_by_script=true
|
||||
fi
|
||||
backing_device="$(cryptsetup status "$mapper_name" | awk '$1 == "device:" { print $2 }')"
|
||||
[[ -n "$backing_device" && "$(readlink -f "$backing_device")" == "$(readlink -f "$outer")" ]] || {
|
||||
printf 'The unlocked mapper does not belong to the configured LUKS UUID\n' >&2
|
||||
exit 65
|
||||
}
|
||||
[[ "$(blkid -s TYPE -o value "$mapper")" == ext4 ]] || {
|
||||
printf 'The unlocked USB filesystem is not ext4\n' >&2
|
||||
exit 65
|
||||
}
|
||||
[[ "$(blkid -s UUID -o value "$mapper")" == "$fs_uuid" ]] || {
|
||||
printf 'The unlocked USB filesystem UUID does not match\n' >&2
|
||||
exit 65
|
||||
}
|
||||
if findmnt -rn -S "$mapper" >/dev/null; then
|
||||
printf 'The USB filesystem is already mounted elsewhere\n' >&2
|
||||
exit 65
|
||||
fi
|
||||
[[ ! -e "$source_dir" && ! -e "$usb_mount" ]] || {
|
||||
printf 'USB backup staging directories already exist; inspect them manually\n' >&2
|
||||
exit 65
|
||||
}
|
||||
|
||||
mkdir -m 0700 "$usb_mount"
|
||||
mount -t ext4 -o nodev,nosuid,noexec "$mapper" "$usb_mount"
|
||||
usb_mounted=true
|
||||
[[ "$(readlink -f "$(findmnt -nro SOURCE --target "$usb_mount")")" == "$(readlink -f "$mapper")" ]] || {
|
||||
printf 'Mounted USB source does not match the verified mapper\n' >&2
|
||||
exit 65
|
||||
}
|
||||
|
||||
for path in "$backup_root" "$backup_root/snapshots"; do
|
||||
[[ ! -L "$path" ]] || { printf 'Unsafe symlink in USB backup destination\n' >&2; exit 65; }
|
||||
mkdir -p -- "$path"
|
||||
[[ -d "$path" ]] || exit 65
|
||||
chown root:root -- "$path"
|
||||
chmod 0700 -- "$path"
|
||||
done
|
||||
|
||||
free_bytes="$(df -B1 --output=avail "$usb_mount" | tail -n 1 | tr -d ' ')"
|
||||
if ((free_bytes < min_free_bytes)); then
|
||||
printf 'USB free space (%s bytes) is below the required reserve (%s bytes)\n' \
|
||||
"$free_bytes" "$min_free_bytes" >&2
|
||||
exit 73
|
||||
fi
|
||||
|
||||
mkdir -m 0700 "$source_dir"
|
||||
flock 9
|
||||
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
snapshot_name="${snapshot_prefix}-${timestamp}-$$"
|
||||
printf '%s\n' "$snapshot_name" >"$snapshot_marker"
|
||||
zfs snapshot -r "${pool}@${snapshot_name}"
|
||||
flock -u 9
|
||||
printf 'Created recursive USB source snapshot %s@%s\n' "$pool" "$snapshot_name"
|
||||
|
||||
while IFS=$'\t' read -r dataset dataset_mountpoint mounted; do
|
||||
if [[ "$mounted" != yes ]]; then
|
||||
printf 'Dataset %s is not mounted; refusing an incomplete backup\n' "$dataset" >&2
|
||||
exit 65
|
||||
fi
|
||||
if [[ "$dataset_mountpoint" != "$mount_root" && "$dataset_mountpoint" != "$mount_root/"* ]]; then
|
||||
printf 'Dataset %s has unexpected mountpoint %s\n' "$dataset" "$dataset_mountpoint" >&2
|
||||
exit 65
|
||||
fi
|
||||
dataset_suffix="${dataset#"$pool"}"
|
||||
source_path="${dataset_mountpoint}/.zfs/snapshot/${snapshot_name}"
|
||||
target_path="${source_dir}${dataset_suffix}"
|
||||
mkdir -p "$target_path"
|
||||
mount --bind "$source_path" "$target_path"
|
||||
mounted_targets+=("$target_path")
|
||||
mount -o remount,bind,ro "$target_path"
|
||||
done < <(zfs list -H -o name,mountpoint,mounted -s name -r "$pool")
|
||||
|
||||
previous=""
|
||||
if [[ -e "$backup_root/latest" || -L "$backup_root/latest" ]]; then
|
||||
[[ -L "$backup_root/latest" ]] || { printf 'latest is not a symlink\n' >&2; exit 65; }
|
||||
previous="$(readlink -e "$backup_root/latest")"
|
||||
[[ -n "$previous" && "$previous" == "$backup_root/snapshots/"* && -d "$previous" ]] || {
|
||||
printf 'latest does not point to a complete snapshot on the USB disk\n' >&2
|
||||
exit 65
|
||||
}
|
||||
fi
|
||||
|
||||
backup_name="${timestamp}-$$"
|
||||
candidate_partial="${backup_root}/snapshots/.incomplete-${backup_name}"
|
||||
complete="${backup_root}/snapshots/${backup_name}"
|
||||
[[ ! -e "$candidate_partial" && ! -L "$candidate_partial" && ! -e "$complete" && ! -L "$complete" ]] || exit 65
|
||||
mkdir -m 0700 "$candidate_partial"
|
||||
partial="$candidate_partial"
|
||||
|
||||
printf 'Copying the consistent pool tree to USB backup %s\n' "$backup_name"
|
||||
# Preserve POSIX ACLs, ownership, modes, timestamps, hard links, and sparse
|
||||
# files. Do not preserve generic xattrs: Rocky 9's rsync 3.2.7 fails when
|
||||
# combining xattrs with --link-dest, while SELinux labels were intentionally
|
||||
# excluded because restores must relabel for their destination host.
|
||||
rsync_args=(-aHAS --numeric-ids "--info=progress2,stats2")
|
||||
estimate_args=(-aHAS --numeric-ids --dry-run --stats)
|
||||
if [[ -n "$previous" ]]; then
|
||||
rsync_args+=("--link-dest=$previous")
|
||||
estimate_args+=("--link-dest=$previous")
|
||||
fi
|
||||
|
||||
# The rsync dry run estimates changed file bytes after link-dest deduplication.
|
||||
# Metadata and filesystem allocation still require the separate free-space reserve.
|
||||
estimate="$(rsync "${estimate_args[@]}" "${source_dir}/" "${partial}/")"
|
||||
transfer_bytes="$(printf '%s\n' "$estimate" | awk -F: \
|
||||
'/^Total transferred file size:/ { gsub(/[^0-9]/, "", $2); print $2 }')"
|
||||
[[ "$transfer_bytes" =~ ^[0-9]+$ ]] || {
|
||||
printf 'Could not determine the USB transfer size\n' >&2
|
||||
exit 74
|
||||
}
|
||||
if ((free_bytes - transfer_bytes < min_free_bytes)); then
|
||||
printf 'Insufficient USB space: %s bytes free, %s estimated transfer, %s reserved\n' \
|
||||
"$free_bytes" "$transfer_bytes" "$min_free_bytes" >&2
|
||||
exit 73
|
||||
fi
|
||||
|
||||
rsync "${rsync_args[@]}" "${source_dir}/" "${partial}/"
|
||||
|
||||
printf 'Verifying USB backup %s with a checksum-based dry run\n' "$backup_name"
|
||||
verification="${runtime_dir}/verification.out"
|
||||
rsync -aHAS --numeric-ids \
|
||||
--checksum --dry-run --delete --itemize-changes \
|
||||
"${source_dir}/" "${partial}/" >"$verification"
|
||||
if [[ -s "$verification" ]]; then
|
||||
printf 'USB verification found mismatches; refusing to publish the backup\n' >&2
|
||||
exit 74
|
||||
fi
|
||||
|
||||
free_bytes="$(df -B1 --output=avail "$usb_mount" | tail -n 1 | tr -d ' ')"
|
||||
if ((free_bytes < min_free_bytes)); then
|
||||
printf 'USB backup completed below the free-space reserve; refusing to publish it\n' >&2
|
||||
exit 73
|
||||
fi
|
||||
|
||||
mv -- "$partial" "$complete"
|
||||
partial=""
|
||||
ln -s "snapshots/${backup_name}" "${backup_root}/.latest-${backup_name}"
|
||||
mv -Tf -- "${backup_root}/.latest-${backup_name}" "${backup_root}/latest"
|
||||
published=true
|
||||
sync -f "$complete"
|
||||
sync -f "$backup_root"
|
||||
printf 'USB backup %s verified and published; unmounting and closing LUKS\n' "$backup_name"
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/python3
|
||||
"""Submit a manual-backup reminder through Atlas' existing Houston notifier."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
message = {
|
||||
"timestamp": now.isoformat(timespec="seconds"),
|
||||
"unixtime": int(now.timestamp()),
|
||||
"event": "atlas_usb_backup_reminder",
|
||||
"severity": "warning",
|
||||
"subject": "Promemoria backup USB offline Atlas",
|
||||
"email_message": (
|
||||
"Collega il disco USB di backup ad Atlas ed esegui manualmente il backup offline.\n"
|
||||
"Il promemoria non avvia il backup. Controlla che il disco non sia\n"
|
||||
"montato; poi esegui:\n\n"
|
||||
" sudo systemctl start atlas-usb-backup.service\n\n"
|
||||
"Verifica l'esito con:\n"
|
||||
" sudo journalctl -u atlas-usb-backup.service -n 100 --no-pager\n\n"
|
||||
"Dopo la riuscita, scollega fisicamente il disco."
|
||||
),
|
||||
}
|
||||
|
||||
subprocess.run(
|
||||
[{{ atlas_usb_reminder_notifier | to_json }}, json.dumps(message)],
|
||||
check=True,
|
||||
)
|
||||
print("Atlas USB backup reminder submitted to 45Drives Alerts; email delivery is not verified.", flush=True)
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=45Drives Alerts reminder to run the manual Atlas offline USB backup
|
||||
Requires=houston-dbus.service
|
||||
After=houston-dbus.service
|
||||
ConditionFileIsExecutable=/usr/local/libexec/atlas-usb-reminder
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/libexec/atlas-usb-reminder
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Remind the administrator to run the manual Atlas offline USB backup
|
||||
|
||||
[Timer]
|
||||
OnCalendar={{ atlas_usb_reminder_calendar }}
|
||||
Persistent=true
|
||||
Unit=atlas-usb-reminder.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
export PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
readonly pool={{ atlas_zfs_pool | quote }}
|
||||
readonly mount_root={{ atlas_mount_root | quote }}
|
||||
readonly snapshot_prefix={{ atlas_usb_backup_snapshot_prefix | quote }}
|
||||
readonly marker=/run/atlas-usb-backup/snapshot-name
|
||||
|
||||
[[ -e "$marker" ]] || exit 0
|
||||
[[ -f "$marker" && ! -L "$marker" ]] || {
|
||||
printf 'Unsafe Atlas USB snapshot marker; leaving snapshots unchanged\n' >&2
|
||||
exit 2
|
||||
}
|
||||
IFS= read -r snapshot_name <"$marker"
|
||||
[[ "$snapshot_name" =~ ^${snapshot_prefix}-[0-9]{8}T[0-9]{6}Z-[0-9]+$ ]] || {
|
||||
printf 'Invalid Atlas USB snapshot marker; leaving snapshots unchanged\n' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
exec 9>/run/lock/atlas-zfs-snapshot.lock
|
||||
flock 9
|
||||
if zfs list -H -t snapshot -o name "${pool}@${snapshot_name}" >/dev/null 2>&1; then
|
||||
# ZFS can leave its on-demand .zfs/snapshot mounts in the host namespace
|
||||
# even after the backup's private bind mounts and process have exited.
|
||||
snapshot_mounts=()
|
||||
snapshot_sources=()
|
||||
while IFS=$'\t' read -r dataset dataset_mountpoint; do
|
||||
[[ "$dataset_mountpoint" == "$mount_root" || "$dataset_mountpoint" == "$mount_root/"* ]] || continue
|
||||
snapshot_mounts+=("${dataset_mountpoint}/.zfs/snapshot/${snapshot_name}")
|
||||
snapshot_sources+=("${dataset}@${snapshot_name}")
|
||||
done < <(zfs list -H -o name,mountpoint -s name -r "$pool")
|
||||
|
||||
{% raw %}
|
||||
for ((index = ${#snapshot_mounts[@]} - 1; index >= 0; index--)); do
|
||||
{% endraw %}
|
||||
mounted_source="$(findmnt -rn -M "${snapshot_mounts[$index]}" -o SOURCE || true)"
|
||||
[[ -n "$mounted_source" ]] || continue
|
||||
[[ "$mounted_source" == "${snapshot_sources[$index]}" ]] || {
|
||||
printf 'Unexpected source on Atlas USB snapshot mount: %s\n' \
|
||||
"${snapshot_mounts[$index]}" >&2
|
||||
exit 2
|
||||
}
|
||||
umount "${snapshot_mounts[$index]}"
|
||||
done
|
||||
|
||||
zfs destroy -r "${pool}@${snapshot_name}"
|
||||
printf 'Removed recursive Atlas USB source snapshot %s@%s after backup exit\n' \
|
||||
"$pool" "$snapshot_name"
|
||||
fi
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user