Compare commits

..
Author SHA1 Message Date
Richie ef4ebc6cdf feat(zfs): add declarative management for ZFS datasets and integration tests
treefmt / nix fmt (pull_request) Successful in 5s
pytest / pytest (pull_request) Successful in 31s
test ebook search / test-ebook-search (pull_request) Successful in 36s
build_systems / build-bob (pull_request) Successful in 49s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m2s
build_systems / build-brain (pull_request) Successful in 49s
build_systems / build-jeeves (pull_request) Successful in 2m18s
zfs integration / zfs-integration (pull_request) Successful in 1m9s
2026-08-19 22:17:13 -04:00
Richie 0398e5e878 Implement ZFS dataset management and snapshot configuration
- Introduced a new `datasets.nix` file to define ZFS datasets and their properties, allowing for centralized management of dataset configurations.
- Updated `default.nix` to import and enable the `zfs_manager` service, integrating it into the system configuration.
- Refactored `zfs.sh` to remove dataset creation commands, delegating dataset management to the `zfs_manager` service.
- Removed the legacy `snapshot_config.toml` file, as snapshot configurations are now handled within `datasets.nix`.
- Modified `vars.nix` to derive mountpoint paths from `datasets.nix`, ensuring consistency across the configuration.
- Created a new `zfs.nix` file to define the `zfs_manager` service and its dependencies.
- Added comprehensive tests for the `zfs_manager` functionality, covering dataset creation, property management, and error handling.
2026-08-19 22:17:13 -04:00
196 changed files with 4522 additions and 5962 deletions

No files matched your search

-16
View File
@@ -8,23 +8,8 @@ on:
- cron: "0 22 * * *"
jobs:
prebuild-common:
name: prebuild-common-x86-64-v3
runs-on: nix-cache-builder
steps:
- uses: actions/checkout@v4
# portal-1 is the smallest system closure: 95% of its derivations are
# shared by all five systems, so it is a maintainable common cache seed.
# Keep going so one failing package does not stop unrelated cache entries
# from being built.
- name: Build common packages
run: nixos-rebuild build --keep-going --accept-flake-config --flake ./#portal-1
- name: Copy common packages to nix-cache
run: nix copy --accept-flake-config --to unix:///host-nix/var/nix/daemon-socket/socket .#nixosConfigurations.portal-1.config.system.build.toplevel
build:
name: build-${{ matrix.system }}
needs: prebuild-common
runs-on: self-hosted
strategy:
matrix:
@@ -33,7 +18,6 @@ jobs:
- "brain"
- "jeeves"
- "rhapsody-in-green"
- "portal-1"
continue-on-error: true
steps:
- uses: actions/checkout@v4
@@ -15,8 +15,9 @@ jobs:
steps:
- name: merge_flake_lock_update
run: >-
nix develop .#devShells.x86_64-linux.default -c
python -m python.gitea_flake_lock merge
--repo "${{ github.repository }}"
env:
JEEVES_BOT_TOKEN: ${{ secrets.JEEVES_BOT_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: https://gitea.tmmworkshop.com
+19
View File
@@ -0,0 +1,19 @@
name: pytest
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
pytest:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Run tests
run: nix develop .#devShells.x86_64-linux.default -c pytest tests
+2 -1
View File
@@ -18,8 +18,9 @@ jobs:
run: nix flake update
- name: Create or update flake.lock PR
env:
JEEVES_BOT_TOKEN: ${{ secrets.JEEVES_BOT_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: https://gitea.tmmworkshop.com
run: >-
nix develop .#devShells.x86_64-linux.default -c
python -m python.gitea_flake_lock update
--repo "${{ github.repository }}"
+51
View File
@@ -0,0 +1,51 @@
name: zfs integration
on:
workflow_dispatch:
pull_request:
branches: [main]
paths:
- ".github/workflows/zfs-integration.yml"
- "flake.nix"
- "flake.lock"
- "overlays/default.nix"
- "common/global/snapshot_manager.nix"
- "common/optional/zfs_manager.nix"
- "python/signal_alert.py"
- "python/tools/snapshot_manager.py"
- "python/tools/zfs_manager.py"
- "python/zfs/**"
- "systems/jeeves/datasets.nix"
- "systems/jeeves/scripts/zfs.sh"
- "systems/jeeves/zfs.nix"
- "tests/zfs_integration.py"
- "tests/zfs-integration.nix"
push:
branches: [main]
paths:
- ".github/workflows/zfs-integration.yml"
- "flake.nix"
- "flake.lock"
- "overlays/default.nix"
- "common/global/snapshot_manager.nix"
- "common/optional/zfs_manager.nix"
- "python/signal_alert.py"
- "python/tools/snapshot_manager.py"
- "python/tools/zfs_manager.py"
- "python/zfs/**"
- "systems/jeeves/datasets.nix"
- "systems/jeeves/scripts/zfs.sh"
- "systems/jeeves/zfs.nix"
- "tests/zfs_integration.py"
- "tests/zfs-integration.nix"
jobs:
zfs-integration:
runs-on: self-hosted
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Build and run ZFS integration VM
run: >-
nix build --accept-flake-config --print-build-logs
.#packages.x86_64-linux.zfs-integration
-35
View File
@@ -1,9 +1,6 @@
# Generate AGE keys from SSH keys with:
# ssh-keygen -A
# nix-shell -p ssh-to-age --run 'cat /etc/ssh/ssh_host_ed25519_key.pub | ssh-to-age'
# update keys after addin/removing a key
# nix-shell -p sops --run "sops updatekeys users/secrets.yaml" users/secrets.yaml
keys:
- &admin_richie age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c # cspell:disable-line
@@ -11,7 +8,6 @@ keys:
- &system_brain age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm # cspell:disable-line
- &system_jeeves age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv # cspell:disable-line
- &system_rhapsody age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4 # cspell:disable-line
- &system_portal_1 age1cm7ujz4t9cyc3nyy0w30l2addggk0gy8h3shd9yq47jg845tndnsa3utfl # cspell:disable-line
creation_rules:
- path_regex: users/secrets\.yaml$
@@ -22,34 +18,3 @@ creation_rules:
- *system_brain
- *system_jeeves
- *system_rhapsody
- *system_portal_1
- path_regex: systems/bob/secrets\.yaml$
key_groups:
- age:
- *admin_richie
- *system_bob
- path_regex: systems/brain/secrets\.yaml$
key_groups:
- age:
- *admin_richie
- *system_brain
- path_regex: systems/jeeves/secrets\.yaml$
key_groups:
- age:
- *admin_richie
- *system_jeeves
- path_regex: systems/rhapsody-in-green/secrets\.yaml$
key_groups:
- age:
- *admin_richie
- *system_rhapsody
- path_regex: systems/portal-1/secrets\.yaml$
key_groups:
- age:
- *admin_richie
- *system_portal_1
+3
View File
@@ -10,6 +10,7 @@
"aiounifi",
"alsa",
"apiclient",
"apscheduler",
"archlinux",
"ashift",
"asrouter",
@@ -336,6 +337,8 @@
"yubioath",
"yzhang",
"zeroconf",
"zerotier",
"zerotierone",
"zoxide",
"zram",
"zstd"
Generated
-163
View File
@@ -290,28 +290,6 @@ dependencies = [
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
@@ -403,12 +381,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
@@ -641,16 +613,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "ipnet"
version = "2.12.1"
@@ -722,12 +684,6 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.3"
@@ -1015,19 +971,6 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.43"
@@ -1118,15 +1061,6 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -1145,15 +1079,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal"
version = "0.1.0"
dependencies = [
"anyhow",
"libc",
"serde_json",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -1166,22 +1091,6 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "snapshot-manager"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"env_logger",
"log",
"serde",
"signal",
"tempfile",
"toml",
"zfs",
]
[[package]]
name = "socket2"
version = "0.6.5"
@@ -1252,19 +1161,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "2.0.20"
@@ -1334,45 +1230,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow 1.0.4",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tower"
version = "0.5.3"
@@ -1488,7 +1345,6 @@ dependencies = [
"clap",
"env_logger",
"log",
"num-traits",
"reqwest",
"serde",
"serde_json",
@@ -1734,18 +1590,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
[[package]]
name = "writeable"
version = "0.6.4"
@@ -1835,13 +1679,6 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "zfs"
version = "0.1.0"
dependencies = [
"anyhow",
]
[[package]]
name = "zmij"
version = "1.0.23"
-30
View File
@@ -2,33 +2,3 @@
resolver = "2"
members = ["rust/*"]
[workspace.dependencies]
anyhow = "1.0"
chrono = "0.4"
clap = "4.5"
env_logger = "0.11"
log = "0.4"
serde = "1.0"
serde_json = "1.0"
signal = { path = "rust/signal" }
zfs = { path = "rust/zfs" }
[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
pedantic = { level = "deny", priority = -1 } # UM, ACTUALLY
nursery = { level = "deny", priority = -1 } # BETA LINTS
# DENY PANICS
unwrap_used = "deny"
expect_used = "deny"
indexing_slicing = "deny"
arithmetic_side_effects = "deny"
unreachable = "deny"
unimplemented = "deny"
unchecked_time_subtraction = "deny"
todo = "deny"
string_slice = "deny"
panic_in_result_fn = "deny"
panic = "deny"
exit = "deny"
as_conversions = "deny"
-4
View File
@@ -1,4 +0,0 @@
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true
+20 -2
View File
@@ -17,11 +17,16 @@
./nix.nix
./programs.nix
./ssh.nix
./snapshot_manager.nix
];
boot = {
tmp.useTmpfs = lib.mkDefault true;
kernelPackages = lib.mkDefault pkgs.linuxPackages_6_18;
tmp.useTmpfs = true;
kernelPackages = lib.mkDefault pkgs.linuxPackages_6_12;
zfs = {
package = lib.mkDefault pkgs.zfs_2_4;
forceImportRoot = lib.mkDefault false;
};
};
hardware.enableRedistributableFirmware = true;
@@ -37,6 +42,9 @@
overlays = builtins.attrValues outputs.overlays;
config = {
allowUnfree = true;
permittedInsecurePackages = [
"openssl-1.1.1w" # This is for discord-canary
];
};
};
@@ -45,6 +53,16 @@
# firmware update
fwupd.enable = true;
snapshot_manager = {
enable = lib.mkDefault true;
PYTHONPATH = "${inputs.self}/";
};
zfs = {
trim.enable = lib.mkDefault true;
autoScrub.enable = lib.mkDefault true;
};
};
powerManagement.powertop.enable = lib.mkDefault true;
-4
View File
@@ -31,10 +31,6 @@ in
"flakes"
"ca-derivations"
];
system-features = lib.mkAfter [
"gccarch-x86-64-v2"
"gccarch-x86-64-v3"
];
warn-dirty = false;
flake-registry = ""; # disable global flake registries
connect-timeout = 10;
+1
View File
@@ -2,5 +2,6 @@
{
environment.systemPackages = with pkgs; [
git
my_python
];
}
@@ -22,12 +22,6 @@ hourly = 0
daily = 0
monthly = 0
["root_pool/nix_build"]
15_min = 1
hourly = 0
daily = 0
monthly = 0
["root_pool/var"]
15_min = 8
hourly = 24
@@ -6,18 +6,27 @@
}:
let
cfg = config.services.snapshot_manager;
snapshotManager = pkgs.callPackage ../../../rust/snapshot_manager/package.nix { };
in
{
options = {
services.snapshot_manager = {
enable = lib.mkEnableOption "ZFS snapshot manager";
signalAlerts = lib.mkEnableOption "Signal failure alerts for the snapshot manager";
path = lib.mkOption {
type = lib.types.path;
default = ./snapshot_config.toml;
description = "Path to the snapshot_manager TOML config.";
};
PYTHONPATH = lib.mkOption {
type = lib.types.str;
description = ''
the PYTHONPATH to use for the snapshot_manager service.
'';
};
package = lib.mkOption {
type = lib.types.package;
default = pkgs.my_python;
description = "Python environment used to run snapshot_manager.";
};
EnvironmentFile = lib.mkOption {
type = lib.types.nullOr (lib.types.coercedTo lib.types.path toString lib.types.str);
default = null;
@@ -30,17 +39,18 @@ in
};
config = lib.mkIf cfg.enable {
system.checks = [ snapshotManager ];
systemd = {
services.snapshot_manager = {
description = "ZFS Snapshot Manager";
requires = [ "zfs-import.target" ];
after = [ "zfs-import.target" ];
path = [ pkgs.zfs ];
environment = {
PYTHONPATH = cfg.PYTHONPATH;
};
serviceConfig = {
Type = "oneshot";
ExecStart = "${snapshotManager}/bin/snapshot-manager ${lib.escapeShellArg cfg.path} --signal-alerts ${lib.boolToString cfg.signalAlerts}";
ExecStart = "${cfg.package}/bin/python -m python.tools.snapshot_manager ${lib.escapeShellArg cfg.path}";
}
// lib.optionalAttrs (cfg.EnvironmentFile != null) {
EnvironmentFile = cfg.EnvironmentFile;
+42
View File
@@ -0,0 +1,42 @@
{ pkgs, ... }:
{
boot = {
kernelPackages = pkgs.linuxPackages_6_18;
zfs.package = pkgs.zfs_2_4;
};
hardware.bluetooth = {
enable = true;
powerOnBoot = true;
};
# rtkit is optional but recommended for pipewire
security.rtkit.enable = true;
services = {
displayManager.sddm = {
enable = true;
wayland.enable = true;
};
desktopManager.plasma6.enable = true;
xserver = {
enable = true;
xkb = {
layout = "us";
variant = "";
};
};
pulseaudio.enable = false;
pipewire = {
enable = true;
alsa.enable = true;
alsa.support32Bit = true;
pulse.enable = true;
wireplumber.enable = true;
};
};
}
-17
View File
@@ -1,17 +0,0 @@
{ pkgs, ... }:
{
imports = [
./kde.nix
./pipewire.nix
];
boot = {
kernelPackages = pkgs.linuxPackages_6_18;
zfs.package = pkgs.zfs_2_4;
};
hardware.bluetooth = {
enable = true;
powerOnBoot = true;
};
}
-45
View File
@@ -1,45 +0,0 @@
{ pkgs, ... }:
{
environment.plasma6.excludePackages = with pkgs.kdePackages; [
aurorae # theme
elisa # music player
kate # text editor
kconfig # config editor
khelpcenter # help center
kinfocenter # system info
konsole # terminal
krdp # remote desktop
ktexteditor # thing for kate
okular # pdf reader
plasma-keyboard # used by plasma-keyboard KCM
plasma-sdk # plasma development tools
plasma-workspace-wallpapers # wallpapers
qrca # qr code scanner
qtvirtualkeyboard # virtual keyboard
union # theme
];
# disable KDE PIM (Personal Information Management) applications
programs.kde-pim.enable = false;
services = {
# removes screen reader and speech dispatcher from the system
orca.enable = false;
speechd.enable = false;
displayManager.sddm = {
enable = true;
wayland.enable = true;
};
desktopManager.plasma6.enable = true;
xserver = {
enable = true;
xkb = {
layout = "us";
variant = "";
};
};
};
}
-16
View File
@@ -1,16 +0,0 @@
{
# rtkit is optional but recommended for pipewire
security.rtkit.enable = true;
services = {
pulseaudio.enable = false;
pipewire = {
enable = true;
alsa.enable = true;
alsa.support32Bit = true;
pulse.enable = true;
wireplumber.enable = true;
};
};
}
-1
View File
@@ -17,7 +17,6 @@
logDriver = "local";
storageDriver = "overlay2";
daemon.settings = {
live-restore = false;
experimental = true;
exec-opts = [ "native.cgroupdriver=systemd" ];
log-opts = {
+1 -1
View File
@@ -5,7 +5,7 @@
...
}:
let
monitoringInterface = "tailscale0";
monitoringInterface = "ztwfunumly";
nodeTextfileDir = "/var/lib/prometheus-node-exporter-textfile";
mkProcessNameTemplate =
-46
View File
@@ -1,46 +0,0 @@
{ pkgs, ... }:
{
environment.systemPackages = [
pkgs.signal-cli
];
users = {
groups.signal-cli = { };
users.signal-cli = {
isSystemUser = true;
group = "signal-cli";
home = "/var/lib/signal-cli";
};
};
systemd.tmpfiles.rules = [
"d /var/lib/signal-cli 0700 signal-cli signal-cli - -"
"Z /var/lib/signal-cli - signal-cli signal-cli - -"
];
systemd.services.signal-cli = {
description = "Signal CLI JSON-RPC service";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
unitConfig.RequiresMountsFor = [ "/var/lib/signal-cli" ];
serviceConfig = {
Type = "simple";
User = "signal-cli";
Group = "signal-cli";
ExecStart = "${pkgs.signal-cli}/bin/signal-cli --data-dir /var/lib/signal-cli daemon --socket /run/signal-cli/socket";
Restart = "on-failure";
RestartSec = "5s";
SuccessExitStatus = 143;
RuntimeDirectory = "signal-cli";
RuntimeDirectoryMode = "0750";
UMask = "0007";
NoNewPrivileges = true;
PrivateTmp = true;
ProtectHome = true;
ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/signal-cli" ];
};
};
}
+1
View File
@@ -1,6 +1,7 @@
{ pkgs, ... }:
{
environment.systemPackages = with pkgs; [
mangohud
steam-run
];
hardware.steam-hardware.enable = true;
-37
View File
@@ -1,37 +0,0 @@
{
config,
inputs,
...
}:
{
nix.settings = {
trusted-substituters = [ "http://jeeves:5000" ];
substituters = [ "http://jeeves:5000/?priority=1&want-mass-query=true" ];
trusted-public-keys = [ "cache.tmmworkshop.com:jHffkpgbmEdstQPoihJPYW9TQe6jnQbWR2LqkNGV3iA=" ];
};
services.tailscale = {
enable = true;
openFirewall = true;
authKeyFile = config.sops.secrets.tailscale_auth_key.path;
# OAuth client secrets create ephemeral nodes by default. NixOS machines
# are persistent and should enroll without interactive device approval.
authKeyParameters = {
ephemeral = false;
preauthorized = true;
};
extraUpFlags = [ "--advertise-tags=tag:nixos" ];
};
sops = {
age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
secrets.tailscale_auth_key = {
sopsFile = "${inputs.self}/users/secrets.yaml";
owner = "root";
mode = "0400";
};
};
}
-15
View File
@@ -1,15 +0,0 @@
{
nixpkgs = {
hostPlatform = {
system = "x86_64-linux";
gcc = {
arch = "x86-64-v3";
tune = "generic";
};
};
# These patches repair tests and bundled dependencies that are sensitive
# to the compiler flags used by the x86-64-v3 package set.
overlays = [ (import ./patches) ];
};
}
@@ -1,20 +0,0 @@
Subject: [PATCH] abseil: include BMI2 intrinsics through the public header
GCC and Clang reject direct inclusion of bmi2intrin.h. Include immintrin.h
instead so that the compiler supplies the required intrinsic setup when
BMI2 is enabled, including builds targeting x86-64-v3.
This patch is shared by the vendored Abseil copies in Electron, rusty_v8
(Deno), and Signal's WebRTC build.
--- a/third_party/abseil-cpp/absl/container/internal/raw_hash_set.h
+++ b/third_party/abseil-cpp/absl/container/internal/raw_hash_set.h
@@ -226,7 +226,7 @@
#endif
#ifdef __BMI2__
-#include <bmi2intrin.h>
+#include <immintrin.h>
#endif // __BMI2__
namespace absl {
@@ -1,38 +0,0 @@
# Abseil accepted the upstream fix: https://github.com/abseil/abseil-cpp/pull/2071
# Keep this workaround until Electron, Deno's rusty_v8, and Signal's WebRTC
# update their bundled Abseil copies to include it.
{ prev }:
let
patchAbseilBmi2Include =
package:
package.overrideAttrs (old: {
# GCC and Clang require the public umbrella header for BMI2 intrinsics.
patches = (old.patches or [ ]) ++ [ ./bmi2-public-header.patch ];
});
electron43Unwrapped = patchAbseilBmi2Include prev.electron_43.unwrapped;
electron43 = prev.electron_43.override {
electron-unwrapped = electron43Unwrapped;
};
signalCallPackage =
path: args:
let
package = prev.callPackage path args;
in
if builtins.baseNameOf path == "webrtc.nix" then patchAbseilBmi2Include package else package;
in
prev.lib.optionalAttrs ((prev.stdenv.hostPlatform.gcc.arch or null) == "x86-64-v3") {
deno =
let
librusty_v8 = patchAbseilBmi2Include prev.deno.passthru.librusty_v8;
in
prev.deno.override { inherit librusty_v8; };
electron_43 = electron43;
signal-desktop = prev.signal-desktop.override {
electron_43 = electron43;
callPackage = signalCallPackage;
};
}
@@ -1,19 +0,0 @@
Subject: [PATCH] Compare font content in the overlap-removal regression
PFA_SKIP ends with a separator, producing an empty prefix that matches every
line. Remove the empty prefix for this test so glyph differences are checked,
and assert that tx exits successfully before inspecting the output.
--- a/tests/tx_test.py
+++ b/tests/tx_test.py
@@ -1064,6 +1064,8 @@
output_path = get_temp_file_path()
args = [TOOL, '-t1', '+V', '-o', output_path, input_path]
- subprocess.call(args)
- assert differ([expected_path, output_path, '-s', PFA_SKIP[0]])
+ subprocess.check_call(args)
+ # An empty skip prefix matches every line and hides font differences.
+ skip_headers = SPLIT_MARKER.join(filter(None, PFA_SKIP[0].split(SPLIT_MARKER)))
+ assert differ([expected_path, output_path, '-s', skip_headers])
@@ -1,10 +0,0 @@
{ afdko }:
afdko.overridePythonAttrs (old: {
# FMA changes overlap-removal coordinates by 0.01 units on x86-64-v3.
# Separate multiply/add rounding reproduces the reference Type 1 font exactly.
env = (old.env or { }) // {
NIX_CFLAGS_COMPILE = (old.env.NIX_CFLAGS_COMPILE or "") + " -ffp-contract=off";
};
patches = (old.patches or [ ]) ++ [ ./check-overlap-font-content.patch ];
})
@@ -1,16 +0,0 @@
_final: prev:
(import ./abseil { inherit prev; })
// {
gnutls = import ./gnutls { inherit (prev) gnutls; };
prometheus = import ./prometheus { inherit (prev) prometheus; };
zopfli = import ./zopfli { inherit (prev) zopfli; };
pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [
(_pythonFinal: pythonPrev: {
afdko = import ./afdko { inherit (pythonPrev) afdko; };
psutil = import ./psutil { inherit (pythonPrev) psutil; };
pytest-xdist = import ./pytest-xdist { inherit (pythonPrev) pytest-xdist; };
scipy = import ./scipy { inherit (pythonPrev) scipy; };
})
];
}
@@ -1,6 +0,0 @@
{ gnutls }:
gnutls.overrideAttrs (old: {
# Keep the UDP handshake test enabled on loaded builders by waiting for
# the server to bind its socket. Kept as a standalone patch for upstream.
patches = (old.patches or [ ]) ++ [ ./udp-server-readiness.patch ];
})
@@ -1,70 +0,0 @@
Subject: [PATCH] tests: wait for the UDP server socket before connecting
A fixed four-second sleep does not guarantee that gnutls-serv has bound
its UDP socket on a busy builder. Poll the local IPv4 UDP endpoint using
the existing ss/netstat discovery, with the same retry budget as the TCP
helper. Fail early if the server exits, and retain the original handshake
checks in serv-udp.sh.
Use flags common to ss and BSD/Linux netstat. Match the local endpoint
and complete port number, excluding TCP, IPv6-only and peer endpoints.
--- a/tests/scripts/common.sh
+++ b/tests/scripts/common.sh
@@ -185,10 +185,55 @@
fi
}
+check_if_udp_port_bound() {
+ local PORT=$1
+ have_port_finder
+ # Use only -an, which is shared by ss and BSD/Linux netstat. UDP has
+ # no LISTEN state. Match the local IPv4 endpoint, not a peer port or
+ # a longer port number. serv-udp.sh connects to 127.0.0.1;
+ # listen_socket() in serv.c binds IPv4 separately and requests
+ # IPV6_V6ONLY=1 for its IPv6 socket.
+ $PFCMD -an | awk -v port="$PORT" '
+ $1 == "udp" || $1 == "udp4" {
+ # ss includes a state column; netstat does not.
+ address = ($2 == "UNCONN" || $2 == "ESTAB") ? $5 : $4
+ if (address ~ ("^[0-9.]+[.:]" port "$") ||
+ address == "*." port)
+ found = 1
+ }
+ END { exit !found }
+ '
+}
+
wait_udp_server() {
local PID=$1
+ local ret
trap "test -n \"${PID}\" && kill ${PID};exit 1" 1 15 2
- sleep 4
+ local i=0
+ # Use the same retry budget as wait_for_port(), but also stop if the
+ # server exits before binding its socket.
+ while test $i -lt 90; do
+ if ! kill -0 "$PID" 2>/dev/null; then
+ fail "" "UDP server $PID exited before binding port $PORT"
+ fi
+ # Contain have_port_finder's exit so a skip also stops the server.
+ if (check_if_udp_port_bound "$PORT"); then
+ return 0
+ else
+ ret=$?
+ if test "$ret" = 77; then
+ kill "$PID" 2>/dev/null || :
+ wait "$PID" 2>/dev/null || :
+ exit 77
+ fi
+ fi
+ i=$((i + 1))
+ if test $i -lt 90; then
+ echo "try $i: waiting for UDP port $PORT"
+ sleep 2
+ fi
+ done
+ fail "$PID" "UDP server $PORT did not come up"
}
create_testdir() {
@@ -1,180 +0,0 @@
#!/usr/bin/env python3
"""Exercise patched common.sh without building GnuTLS (Python standard library only).
Usage: python3 verify-readiness.py /path/to/patched/tests/scripts/common.sh
Set SHELL to test another shell, and NETSTAT to test a netstat outside PATH.
"""
# Use unittest so this upstream companion tool needs no pytest installation.
# ruff: noqa: PT009
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
COMMON = str(Path(sys.argv.pop(1)).resolve())
SHELL = os.environ.get("SHELL", "/bin/sh")
class ReadinessTests(unittest.TestCase):
"""Check endpoint parsing and the server startup lifecycle."""
def setUp(self) -> None:
"""Create a socket-listing fixture for each check."""
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.fixture = self.root / "sockets"
self.fixture.write_text("")
self.finder = self.root / "port-finder"
self.finder.write_text('#!/bin/sh\ncat "$SOCKET_FIXTURE"\n')
self.finder.chmod(0o755)
def run_shell(self, body: str, **env: str) -> subprocess.CompletedProcess[str]:
"""Source the actual helper and run a shell scenario."""
return subprocess.run(
[SHELL, "-c", '. "$COMMON"\n' + body],
env={
**os.environ,
"COMMON": COMMON,
"SOCKET_FIXTURE": str(self.fixture),
"PFCMD": str(self.finder),
"PORT": "12345",
**env,
},
capture_output=True,
text=True,
timeout=20,
check=False,
)
def test_socket_formats_and_false_matches(self) -> None:
"""Accept IPv4 UDP local endpoints and reject unrelated sockets."""
cases = [
("udp UNCONN 0 0 0.0.0.0:12345 0.0.0.0:*", True),
("udp UNCONN 0 0 127.0.0.1:12345 0.0.0.0:*", True),
("udp 0 0 0.0.0.0:12345 0.0.0.0:*", True),
("udp4 0 0 *.12345 *.*", True),
("udp 0 0 127.0.0.1.12345 *.*", True),
("udp 0 0 *.12345 *.*", True),
("udp UNCONN 0 0 0.0.0.0:123456 0.0.0.0:*", False),
("udp 0 0 0.0.0.0:123456 0.0.0.0:*", False),
("udp ESTAB 0 0 127.0.0.1:54321 127.0.0.1:12345", False),
("udp 0 0 127.0.0.1:54321 127.0.0.1:12345", False),
("tcp LISTEN 0 128 0.0.0.0:12345 0.0.0.0:*", False),
("tcp 0 0 0.0.0.0:12345 0.0.0.0:* LISTEN", False),
("udp UNCONN 0 0 [::]:12345 [::]:*", False),
("udp UNCONN 0 0 *:12345 *:*", False),
("udp6 0 0 :::12345 :::*", False),
("udp6 0 0 *.12345 *.*", False),
("", False),
]
for row, ready in cases:
with self.subTest(row=row):
self.fixture.write_text(row + "\n")
result = self.run_shell('check_if_udp_port_bound "$PORT"')
self.assertEqual(result.returncode, 0 if ready else 1, result.stderr)
def test_exited_server_fails_immediately(self) -> None:
"""Fail without sleeping when the server has already exited."""
result = self.run_shell(
'true &\npid=$!\nwait "$pid"\nsleep() { echo "unexpected sleep" >&2; }\nwait_udp_server "$pid"'
)
self.assertEqual(result.returncode, 1)
self.assertIn("exited before binding", result.stderr)
self.assertNotIn("unexpected sleep", result.stderr)
def test_ready_socket_does_not_sleep(self) -> None:
"""Check readiness before the first sleep."""
self.fixture.write_text("udp UNCONN 0 0 0.0.0.0:12345 0.0.0.0:*\n")
result = self.run_shell('sleep() { echo "unexpected sleep" >&2; }\nwait_udp_server "$$"')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn("unexpected sleep", result.stderr)
def test_missing_port_finders_skip(self) -> None:
"""Skip and stop the live server when no finder is available."""
# have_port_finder also tries these paths independently of PATH.
if any(os.access(f"{directory}/ss", os.X_OK) for directory in ("/sbin", "/usr/sbin", "/usr/local/sbin")):
self.skipTest("an absolute ss path cannot be hidden by this PATH-only fixture")
with subprocess.Popen(["sleep", "60"]) as server:
try:
result = self.run_shell(
'unset PFCMD\nPATH=/nonexistent\nwait_udp_server "$SERVER_PID"',
SERVER_PID=str(server.pid),
)
self.assertEqual(result.returncode, 77)
self.assertIn("neither ss nor netstat found", result.stderr)
server.wait(timeout=3)
self.assertLess(server.returncode, 0)
finally:
if server.poll() is None:
server.kill()
def test_timeout_is_bounded_and_cleans_up(self) -> None:
"""Stop polling after the retry budget and terminate the server."""
# Only accelerate the polling delay; keep a real live server process.
self.finder.write_text('#!/bin/sh\necho probe >&2\ncat "$SOCKET_FIXTURE"\n')
with subprocess.Popen(["sleep", "60"]) as server:
try:
result = self.run_shell(
'sleep() { echo polling-sleep; }\nwait_udp_server "$SERVER_PID"',
SERVER_PID=str(server.pid),
)
self.assertEqual(result.returncode, 1)
self.assertIn("did not come up", result.stderr)
self.assertEqual(result.stderr.count("probe\n"), 90)
self.assertEqual(result.stdout.count("polling-sleep"), 89)
server.wait(timeout=3)
self.assertLess(server.returncode, 0)
finally:
if server.poll() is None:
server.kill()
def test_server_exits_while_waiting(self) -> None:
"""Detect a startup failure that happens after polling begins."""
result = self.run_shell('sleep 1 &\npid=$!\nwait_udp_server "$pid"')
self.assertEqual(result.returncode, 1)
self.assertIn("exited before binding", result.stderr)
self.assertIn("waiting for UDP port", result.stdout)
def test_real_socket_delayed_beyond_four_seconds(self) -> None:
"""Wait for a real delayed bind with each installed port finder."""
finders = [shutil.which("ss"), os.environ.get("NETSTAT") or shutil.which("netstat")]
finders = [finder for finder in finders if finder]
if not finders:
self.skipTest("neither ss nor netstat available")
for finder in finders:
with self.subTest(finder=finder):
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
code = (
"import socket,time,sys; time.sleep(6); "
"s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); "
"s.bind(('127.0.0.1',int(sys.argv[1]))); time.sleep(30)"
)
with subprocess.Popen([sys.executable, "-c", code, str(port)]) as server:
try:
started = time.monotonic()
result = self.run_shell(
'wait_udp_server "$SERVER_PID"',
SERVER_PID=str(server.pid),
PORT=str(port),
PFCMD=finder,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertGreaterEqual(time.monotonic() - started, 6)
self.assertIsNone(server.poll())
finally:
server.terminate()
server.wait(timeout=3)
if __name__ == "__main__":
unittest.main()
@@ -1,36 +0,0 @@
Subject: [PATCH] tests: finish parsing before inspecting editor state
EditorState creation has a 20 ms parsing budget. A descheduled test can
therefore observe an incomplete tree. Finish these small test documents
without an interactive deadline and publish the result with a transaction.
Keep the original completion and vector-matching assertions enabled.
--- a/module/codemirror-promql/src/test/utils-test.ts
+++ b/module/codemirror-promql/src/test/utils-test.ts
@@ -13,7 +13,7 @@
import { parser } from '@prometheus-io/lezer-promql';
import { EditorState } from '@codemirror/state';
-import { LRLanguage } from '@codemirror/language';
+import { ensureSyntaxTree, LRLanguage } from '@codemirror/language';
import nock from 'nock';
import path from 'path';
import { fileURLToPath } from 'url';
@@ -23,10 +23,16 @@
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export function createEditorState(expr: string): EditorState {
- return EditorState.create({
+ const state = EditorState.create({
doc: expr,
extensions: lightPromQLSyntax,
});
+ // These tests need a complete tree, independent of the editor's time budget.
+ if (!ensureSyntaxTree(state, state.doc.length, Infinity)) {
+ throw new Error('Unable to parse the test expression');
+ }
+ // Publish the completed parse so syntaxTree(state) sees it too.
+ return state.update({}).state;
}
export function mockPrometheusServer(): void {
@@ -1,17 +0,0 @@
{ prometheus }:
prometheus.overrideAttrs (
old:
let
assets = old.passthru.assets.overrideAttrs (assetsOld: {
patches = (assetsOld.patches or [ ]) ++ [ ./complete-test-parsing.patch ];
});
in
{
postPatch = builtins.replaceStrings [ "${old.passthru.assets}" ] [ "${assets}" ] (
builtins.unsafeDiscardStringContext old.postPatch
);
passthru = old.passthru // {
inherit assets;
};
}
)
@@ -1,4 +0,0 @@
{ psutil }:
psutil.overridePythonAttrs (old: {
patches = (old.patches or [ ]) ++ [ ./heap-info-zero-mmap.patch ];
})
@@ -1,17 +0,0 @@
Subject: [PATCH] Allow an empty mmap allocation total in heap_info
The allocator may satisfy all live allocations from the heap. A zero
mmap_used value is valid and depends on the worker allocation history.
Keep the heap and platform checks without requiring an mmap allocation.
--- a/tests/test_system.py
+++ b/tests/test_system.py
@@ -267,6 +267,7 @@
if MACOS:
assert m.mmap_used == 0 # not supported
else:
- assert m.mmap_used > 0
+ # A process can have no live mmap-backed malloc allocations.
+ assert m.mmap_used >= 0
if WINDOWS:
assert m.heap_count >= 0
@@ -1,29 +0,0 @@
Subject: [PATCH] tests: count replacements when checking the worker restart limit
With two workers, another in-flight test may crash after the fourth
crash requests shutdown. Either four or five failed tests is valid.
Require exactly three replacements and the failed-tests exit status,
while preserving the queued-work and no-internal-error assertions.
--- a/testing/acceptance_test.py
+++ b/testing/acceptance_test.py
@@ -1011,9 +1011,18 @@
"worker*crashed while running*",
"worker*crashed while running*",
"* xdist: maximum crashed workers reached: 3 *",
- "* 4 failed in *",
]
)
+ # A second in-flight test may crash after shutdown is requested.
+ # The restart limit constrains replacements, not concurrent failures.
+ replacements = sum(
+ line.startswith("replacing crashed worker ") for line in res.stdout.lines
+ )
+ assert replacements == 3
+ failed = res.parseoutcomes()["failed"]
+ assert failed in (4, 5)
+ res.assert_outcomes(failed=failed)
+ assert res.ret == pytest.ExitCode.TESTS_FAILED
assert "INTERNALERROR" not in res.stdout.str()
def test_max_worker_restart_die(self, pytester: pytest.Pytester) -> None:
@@ -1,12 +0,0 @@
{ pytest-xdist }:
pytest-xdist.overridePythonAttrs (old: {
patches = (old.patches or [ ]) ++ [
./concurrent-worker-crashes.patch
./worker-startup-timeout.patch
];
# The suite exercises its own worker pools. Limit the outer suite to one worker.
preCheck = builtins.replaceStrings [ "--numprocesses=$NIX_BUILD_CORES" ] [ "--numprocesses=1" ] (
old.preCheck or ""
);
})
@@ -1,19 +0,0 @@
Subject: [PATCH] tests: allow more time for remote worker events
Worker startup can exceed ten seconds on heavily loaded builders. Allow
the remote-test helper to wait up to sixty seconds for worker events.
The wait still returns as soon as an event arrives and remains bounded.
Production worker timeouts and test assertions are unchanged.
--- a/testing/test_remote.py
+++ b/testing/test_remote.py
@@ -17,7 +17,8 @@
from xdist.workermanage import WorkerController
-WAIT_TIMEOUT = 10.0
+# Allow worker events extra time on heavily loaded builders.
+WAIT_TIMEOUT = 60.0
def check_marshallable(d: object) -> None:
@@ -1,5 +0,0 @@
{ scipy }:
scipy.overridePythonAttrs (old: {
# Keep the STFT tests enabled with tolerances for x86-64-v3 rounding.
patches = (old.patches or [ ]) ++ [ ./stft-test-tolerances.patch ];
})
@@ -1,51 +0,0 @@
Subject: [PATCH] signal: allow floating-point rounding in STFT tests
Keep the STFT tests enabled for x86-64-v3 builds. Allow two float32
epsilons of relative error when comparing inverse-STFT implementations;
float64 and the existing i686 override remain unchanged. Allow one
float64 epsilon of absolute error in all three scaling round trips,
which otherwise require exact zeros (observed residual: 4e-17 for a
signal with amplitude 2).
Upstream issue: https://github.com/scipy/scipy/issues/25488
--- a/scipy/signal/tests/_scipy_spectral_test_shim.py
+++ b/scipy/signal/tests/_scipy_spectral_test_shim.py
@@ -294,7 +294,7 @@
# Adapted tolerances to account for resolution loss:
atol = np.finfo(x.dtype).resolution*2 # instead of default atol = 0
- rtol = 1e-7 # default for np.allclose()
+ rtol = max(1e-7, 2 * np.finfo(x.dtype).eps)
# Relax atol on 32-Bit platforms a bit to pass CI tests.
# - Not clear why there are discrepancies (in the FFT maybe?)
--- a/scipy/signal/tests/test_spectral.py
+++ b/scipy/signal/tests/test_spectral.py
@@ -2044,7 +2044,7 @@
# Test round trip:
x1 = istft(Zs, boundary=True, scaling='spectrum')[1]
- assert_allclose(x1, x)
+ assert_allclose(x1, x, atol=np.finfo(x.dtype).eps)
# For a Hann-windowed 256 sample length FFT, we expect a peak at
# frequency 64 (since it is 1/4 the length of X) with a height of 1
@@ -2074,7 +2074,7 @@
# Test round trip:
x1 = istft(Zp, input_onesided=False, boundary=True, scaling='psd')[1]
- assert_allclose(x1, x)
+ assert_allclose(x1, x, atol=np.finfo(x.dtype).eps)
# The power of the one-sided psd-scaled STFT can be determined
# analogously (note that the two sides are not of equal shape):
@@ -2094,7 +2094,7 @@
# Test round trip:
x1 = istft(Zp0, input_onesided=True, boundary=True, scaling='psd')[1]
- assert_allclose(x1, x)
+ assert_allclose(x1, x, atol=np.finfo(x.dtype).eps)
class TestSampledSpectralRepresentations:
@@ -1,4 +0,0 @@
{ zopfli }:
zopfli.overrideAttrs (old: {
patches = (old.patches or [ ]) ++ [ ./unaligned-match-loads.patch ];
})
@@ -1,32 +0,0 @@
Subject: [PATCH] Read unaligned match buffers with memcpy
Byte buffers need not satisfy integer alignment or aliasing requirements.
GCC 16 with x86-64-v3 vectorizes the cast loads using aligned AVX reads,
causing a segmentation fault. Copy into local integers instead.
--- a/src/zopfli/lz77.c
+++ b/src/zopfli/lz77.c
@@ -302,13 +302,20 @@
if (sizeof(size_t) == 8) {
/* 8 checks at once per array bounds check (size_t is 64-bit). */
- while (scan < safe_end && *((size_t*)scan) == *((size_t*)match)) {
+ while (scan < safe_end) {
+ size_t scan_word, match_word;
+ memcpy(&scan_word, scan, sizeof(scan_word));
+ memcpy(&match_word, match, sizeof(match_word));
+ if (scan_word != match_word) break;
scan += 8;
match += 8;
}
} else if (sizeof(unsigned int) == 4) {
/* 4 checks at once per array bounds check (unsigned int is 32-bit). */
- while (scan < safe_end
- && *((unsigned int*)scan) == *((unsigned int*)match)) {
+ while (scan < safe_end) {
+ unsigned int scan_word, match_word;
+ memcpy(&scan_word, scan, sizeof(scan_word));
+ memcpy(&match_word, match, sizeof(match_word));
+ if (scan_word != match_word) break;
scan += 4;
match += 4;
}
+11
View File
@@ -0,0 +1,11 @@
{
services.zerotierone = {
enable = true;
joinNetworks = [ "e4da7455b2ae64ca" ];
};
nix.settings = {
trusted-substituters = [ "http://192.168.90.40:5000" ];
substituters = [ "http://192.168.90.40:5000/?priority=1&want-mass-query=true" ];
trusted-public-keys = [ "cache.tmmworkshop.com:jHffkpgbmEdstQPoihJPYW9TQe6jnQbWR2LqkNGV3iA=" ];
};
}
-24
View File
@@ -1,24 +0,0 @@
{
lib,
pkgs,
...
}:
{
imports = [ ./snapshot.nix ];
boot.zfs = {
package = lib.mkDefault pkgs.zfs_2_4;
forceImportRoot = lib.mkDefault false;
};
services = {
snapshot_manager = {
enable = lib.mkDefault true;
};
zfs = {
trim.enable = lib.mkDefault true;
autoScrub.enable = lib.mkDefault true;
};
};
}
+191
View File
@@ -0,0 +1,191 @@
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.zfs_manager;
snapshotOptions = {
options = {
"15_min" = lib.mkOption {
type = lib.types.ints.unsigned;
default = 0;
description = "How many 15 minute snapshots to keep.";
};
hourly = lib.mkOption {
type = lib.types.ints.unsigned;
default = 0;
description = "How many hourly snapshots to keep.";
};
daily = lib.mkOption {
type = lib.types.ints.unsigned;
default = 0;
description = "How many daily snapshots to keep.";
};
monthly = lib.mkOption {
type = lib.types.ints.unsigned;
default = 0;
description = "How many monthly snapshots to keep.";
};
};
};
datasetOptions = {
options = {
manageProperties = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether zfs_manager owns this dataset's properties. When false the
dataset only contributes its snapshot retention, which is how
root_pool datasets are declared.
'';
};
createIfMissing = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether zfs_manager may create this dataset when it is absent.
Set it false for a dataset that has to be provisioned by hand, such
as an encryption root: encryption is fixed at creation time and
cannot be expressed here, so creating it automatically would silently
produce an unencrypted dataset where an encrypted one was intended.
The dataset is still property checked, and its absence is reported as
a failure rather than quietly fixed.
'';
};
properties = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
description = ''
The zfs properties this dataset should have. Values are compared
against the live dataset and corrected when they differ.
'';
};
snapshots = lib.mkOption {
type = lib.types.submodule snapshotOptions;
default = cfg.defaultSnapshots;
description = ''
Snapshot retention for this dataset. Defaults to defaultSnapshots.
'';
};
};
};
# snapshot_manager.py only ever walks datasets below a pool root, so pool
# roots are left out of the retention table. It also indexes the table
# directly, which is why every entry carries all four keys.
snapshotTable = lib.mapAttrs (_: dataset: dataset.snapshots) (
lib.filterAttrs (name: _: lib.hasInfix "/" name) cfg.datasets
);
snapshotConfig = (pkgs.formats.toml { }).generate "snapshot_config.toml" (
snapshotTable // { default = cfg.defaultSnapshots; }
);
# Every declared dataset is emitted, including the ones whose properties are
# not managed, so the tool can tell "deliberately hands off" apart from
# "nobody has written this down yet".
datasetConfig = (pkgs.formats.json { }).generate "zfs_datasets.json" {
datasets = lib.mapAttrs (_: dataset: {
inherit (dataset) manageProperties createIfMissing properties;
}) cfg.datasets;
};
in
{
options = {
services.zfs_manager = {
enable = lib.mkEnableOption "declarative ZFS dataset management";
datasets = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule datasetOptions);
default = { };
example = lib.literalExpression ''
{
"media/temp".properties = {
sync = "disabled";
redundant_metadata = "none";
};
}
'';
description = ''
The datasets to manage, keyed by full dataset name. Missing datasets
are created and drifted properties are corrected. Nothing is ever
destroyed, and datasets that are not declared are left alone.
A name without a "/" is a pool root filesystem. Its properties are
managed but it is never created, pool creation stays manual.
'';
};
defaultSnapshots = lib.mkOption {
type = lib.types.submodule snapshotOptions;
default = { };
description = ''
Retention for undeclared datasets and for declared datasets that do
not override their snapshots. Emitted as the "default" table of the
snapshot config.
'';
};
dryRun = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Log every change that would be made without touching zfs. Use this to
validate a new or heavily edited declaration before applying it.
'';
};
PYTHONPATH = lib.mkOption {
type = lib.types.str;
description = ''
the PYTHONPATH to use for the zfs_manager service.
'';
};
package = lib.mkOption {
type = lib.types.package;
default = pkgs.my_python;
description = "Python environment used to run zfs_manager.";
};
EnvironmentFile = lib.mkOption {
type = lib.types.nullOr (lib.types.coercedTo lib.types.path toString lib.types.str);
default = null;
description = ''
Single environment file for the service (e.g. /etc/zfs-manager/env).
Use a leading "-" to ignore if missing (systemd feature).
'';
};
};
};
config = lib.mkIf cfg.enable {
services.snapshot_manager.path = snapshotConfig;
systemd = {
services.zfs_manager = {
description = "ZFS Dataset Manager";
requires = [ "zfs-import.target" ];
after = [
"zfs-import.target"
"zfs-mount.service"
];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.zfs ];
# Re-run on nixos-rebuild switch whenever the declaration changes.
restartTriggers = [ datasetConfig ];
environment = {
PYTHONPATH = cfg.PYTHONPATH;
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${cfg.package}/bin/python -m python.tools.zfs_manager ${lib.escapeShellArg datasetConfig}${lib.optionalString cfg.dryRun " --dry-run"}";
}
// lib.optionalAttrs (cfg.EnvironmentFile != null) {
EnvironmentFile = cfg.EnvironmentFile;
};
};
};
};
}
Generated
+33 -108
View File
@@ -1,41 +1,25 @@
{
"nodes": {
"disko": {
"firefox-addons": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1789770686,
"narHash": "sha256-uZkBR7yHdIKUFB5SZdfgh1qkGfI3XmYmI/lTiquxbck=",
"owner": "nix-community",
"repo": "disko",
"rev": "725ea35e410ad83be4931d1bff7e090eacaf3563",
"type": "github"
"dir": "pkgs/firefox-addons",
"lastModified": 1787025780,
"narHash": "sha256-NhyLP9G4DFOn/7aYr7K/D7hWrzEGr5EgUBV+lpdmJ24=",
"owner": "rycee",
"repo": "nur-expressions",
"rev": "5ad360b6d3cb0aa1b61f9cb27fef113ca9117c37",
"type": "gitlab"
},
"original": {
"owner": "nix-community",
"repo": "disko",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
"dir": "pkgs/firefox-addons",
"owner": "rycee",
"repo": "nur-expressions",
"type": "gitlab"
}
},
"home-manager": {
@@ -45,11 +29,11 @@
]
},
"locked": {
"lastModified": 1791293500,
"narHash": "sha256-3hwJd2/vG/VYV1P/g7oDgdbNsWODhN5F//Od+hPMm2k=",
"lastModified": 1786999651,
"narHash": "sha256-MTGMFlLDTklsXhCp4r5GXB4VAVadPdalXLvUjd/K7h0=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "0e11b7bcba1d74ef8ea10bfdfb1e1a5811a21087",
"rev": "353742587cbaf079b3caee743115d037bc51fea6",
"type": "github"
},
"original": {
@@ -58,38 +42,16 @@
"type": "github"
}
},
"jellyswarrm": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
],
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1791127101,
"narHash": "sha256-SmrBBhcv9iNHA2vlafi7Zpe8bkcsMjVNSmCCVf5/jrQ=",
"owner": "LLukas22",
"repo": "Jellyswarrm",
"rev": "0b4301c6c74efff9c3634296913032579c98faf6",
"type": "github"
},
"original": {
"owner": "LLukas22",
"repo": "Jellyswarrm",
"type": "github"
}
},
"nixos-hardware": {
"inputs": {
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1791128201,
"narHash": "sha256-rlsYY/Dg/LhbIj51Aa6TLvRpZeb3KZgUTd7llQ5XcJE=",
"lastModified": 1786867632,
"narHash": "sha256-ez+ubZlA1RtdjCB18a6zJ9M4u8qoPDy08EcnsW5M3Xw=",
"owner": "nixos",
"repo": "nixos-hardware",
"rev": "31cc5f4d9b9ba601071e8b8504601b9b176e2756",
"rev": "ff17823245ab9ff7bcae6acf950bd89cba82c38c",
"type": "github"
},
"original": {
@@ -101,11 +63,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1789546076,
"narHash": "sha256-vWkSk5bbfTqdtMoSgD9FshACO8JCvXTFi+3cqEp0mH0=",
"rev": "b1b875982b17dabde9b4a37f3e229e74913e6db3",
"lastModified": 1767892417,
"narHash": "sha256-8bW3q88CEg2u4hSP66Vf4lpbLonHz7hqDNBMcCY7E9U=",
"rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba",
"type": "tarball",
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.11pre1074753.b1b875982b17/nixexprs.tar.xz"
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre924538.3497aa5c9457/nixexprs.tar.xz"
},
"original": {
"type": "tarball",
@@ -114,11 +76,11 @@
},
"nixpkgs-master": {
"locked": {
"lastModified": 1791296764,
"narHash": "sha256-N2b6Uxdr1MFhscEPqDyntTTtGZASgq3XYE0ujRzfeNI=",
"lastModified": 1787081018,
"narHash": "sha256-K0uwZBtZsbBigHAMQW7YWti3gPe6a5ct5bcOw5F+Q9Y=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "a15afac8691bd24f746255ffbd76306693e4cf7a",
"rev": "cacac5ac351a010599d9f9d106acfed25a8e4c77",
"type": "github"
},
"original": {
@@ -146,11 +108,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1791260994,
"narHash": "sha256-Miqqk/ammqnTUxaoCyvtwoPeLbI1ksFyLxi/CazZpWY=",
"lastModified": 1787001381,
"narHash": "sha256-Ue1Yo8gfHdD4TMtNewhA4tkSYeFqXThju0nCyJc3ALo=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "151fa4e8ddfdd8dd25d945ad94ed54a13de9f6e4",
"rev": "ec2d622de0773551768cf98f3fc50cbcc003b9c5",
"type": "github"
},
"original": {
@@ -162,36 +124,14 @@
},
"root": {
"inputs": {
"disko": "disko",
"firefox-addons": "firefox-addons",
"home-manager": "home-manager",
"jellyswarrm": "jellyswarrm",
"nixos-hardware": "nixos-hardware",
"nixpkgs": "nixpkgs_2",
"nixpkgs-master": "nixpkgs-master",
"nixpkgs-stable": "nixpkgs-stable",
"sops-nix": "sops-nix",
"systems": "systems_2"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"jellyswarrm",
"nixpkgs"
]
},
"locked": {
"lastModified": 1779419951,
"narHash": "sha256-dMX0PUslUHPajP6o8FEoRdFv9afq/dec4POR0vVfjK4=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "5b5c521d6cae9ef4aa32f888eb2c0ce595c9be52",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
"systems": "systems"
}
},
"sops-nix": {
@@ -201,11 +141,11 @@
]
},
"locked": {
"lastModified": 1791103873,
"narHash": "sha256-nFxM+pKoZ8LJAEnUXARyCaOAloWgaW9kZQOSjWzKcTE=",
"lastModified": 1786629091,
"narHash": "sha256-gkig4nPi1CWc4Z50GBsjE4ygSE7hMpl/TwID2an2Cck=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "dcd241ba97088c22569d1573286e1b9daad340c0",
"rev": "a8627b21b9107c5711c96b84f32a9a4b3d45295f",
"type": "github"
},
"original": {
@@ -215,21 +155,6 @@
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1689347949,
"narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=",
+5 -9
View File
@@ -21,23 +21,18 @@
nixos-hardware.url = "github:nixos/nixos-hardware/master";
jellyswarrm = {
url = "github:LLukas22/Jellyswarrm";
inputs.nixpkgs.follows = "nixpkgs";
};
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
sops-nix = {
url = "github:Mic92/sops-nix";
firefox-addons = {
url = "gitlab:rycee/nur-expressions?dir=pkgs/firefox-addons";
inputs.nixpkgs.follows = "nixpkgs";
};
disko = {
url = "github:nix-community/disko";
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
@@ -82,6 +77,7 @@
}
// lib.optionalAttrs (pkgs.stdenv.hostPlatform.system == "x86_64-linux") {
iso = self.nixosConfigurations.iso.config.system.build.isoImage;
zfs-integration = pkgs.testers.runNixOSTest (import ./tests/zfs-integration.nix { inherit self; });
}
);
apps = forEachSystem (
+32 -9
View File
@@ -1,13 +1,13 @@
{ inputs, ... }:
{
# Additional package sets are accessible through `pkgs.<name>`.
# When applied, the stable nixpkgs set (declared in the flake inputs) will be accessible through 'pkgs.stable'
stable = final: _prev: {
stable = import inputs.nixpkgs-stable {
system = final.stdenv.hostPlatform.system;
config.allowUnfree = true;
};
};
# When applied, the master nixpkgs set (declared in the flake inputs) will be accessible through 'pkgs.master'
master = final: _prev: {
master = import inputs.nixpkgs-master {
system = final.stdenv.hostPlatform.system;
@@ -15,12 +15,35 @@
};
};
# Baseline x86-64 (v1) packages for prebuilt applications that should not
# inherit an x86-64-v3 host platform.
x86-v1 = final: _prev: {
x86-v1 = import inputs.nixpkgs {
system = final.stdenv.hostPlatform.system;
config.allowUnfree = true;
};
python-env = final: _prev: {
my_python = final.python314.withPackages (
ps: with ps; [
alembic
apprise
apscheduler
fastapi
fastapi-cli
httpx
jinja2
mypy
pgvector
psycopg
pydantic
pyfakefs
pytest
pytest-cov
pytest-mock
pytest-xdist
python-multipart
pydantic-settings
ruff
sqlalchemy
tenacity
tinytuya
typer
uvicorn
websockets
]
);
};
}
+45
View File
@@ -6,6 +6,51 @@ authors = [{ name = "Richie Cahill", email = "richie@tmmworkshop.com" }]
requires-python = "~=3.14.0"
readme = "README.md"
license = "MIT"
# these dependencies are a best effort and aren't guaranteed to work
# for up-to-date dependencies, see overlays/default.nix
dependencies = [
"alembic",
"apprise",
"apscheduler",
"beautifulsoup4",
"bm25s",
"ebooklib",
"fastapi",
"fastapi-cli",
"httpx",
"jinja2",
"pgvector",
"polars",
"psycopg[binary]",
"pydantic",
"pydantic-settings",
"python-multipart",
"sqlalchemy[asyncio]",
"tenacity",
"tiktoken",
"tinytuya",
"typer",
"uvicorn",
"websockets",
"yake",
]
[project.scripts]
database = "python.database_cli:app"
whisper-transcribe = "python.tools.whisper.transcribe:main"
[dependency-groups]
dev = [
"aiosqlite",
"mypy",
"pyfakefs",
"pytest-asyncio",
"pytest-cov",
"pytest-mock",
"pytest-xdist",
"pytest",
"ruff",
]
[tool.ruff]
+2 -2
View File
@@ -37,7 +37,7 @@ class RerankConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="EBOOK_SEARCH_RERANK_", frozen=True, protected_namespaces=())
enabled: bool = True
base_url: str = "http://bob:8001"
base_url: str = "http://192.168.90.25:8001"
model: str = "qwen3-reranker-06b"
candidates: int = 24
timeout_seconds: float = 30.0
@@ -67,7 +67,7 @@ class EbookSearchConfig(BaseSettings):
)
chat_model: str = "deepseek-v4-flash"
answer_enabled: bool = True
embedding_base_url: str = "http://bob:8000/v1"
embedding_base_url: str = "http://192.168.90.25:8000/v1"
embedding_api_key: str = "not-needed"
embedding_model: str = "qwen3-embedding-0.6b"
embedding_batch_size: int = 32
+77
View File
@@ -0,0 +1,77 @@
# Ebook Search Docker
Run the EPUB search app against the existing Postgres database on `jeeves`:
```sh
python -m python.ebook_search.docker.containers start --library-path /path/to/epubs --build
```
All ebook-search Docker files live in this directory:
- `Dockerfile` — multi-stage: `test` (runs pytest) and `runtime` (default target, the app image)
- `docker-compose.yml`
- `containers.py` — Typer lifecycle CLI
- `pyproject.toml` / `uv.lock` — the container's uv-locked dependencies
The app listens on `http://localhost:8070`.
Useful lifecycle commands:
```sh
python -m python.ebook_search.docker.containers build
python -m python.ebook_search.docker.containers start --library-path /path/to/epubs
python -m python.ebook_search.docker.containers test
python -m python.ebook_search.docker.containers logs
python -m python.ebook_search.docker.containers ps
python -m python.ebook_search.docker.containers stop
```
Direct compose usage from the repo root:
```sh
docker compose -f python/ebook_search/docker/docker-compose.yml ps
```
## Dependencies
The image builds its environment with uv from `pyproject.toml` + `uv.lock` in this
directory — this is the source of truth for the container's dependencies. To add or
update a dependency, edit `pyproject.toml` here and regenerate the lock (uv is
available in the `ebook-search` dev shell):
```sh
nix develop .#ebook-search -c uv lock --project python/ebook_search/docker
```
## Tests
The main pytest suite excludes `tests/ebook_search` (its dependencies are no longer
in the nix dev shell). The `test ebook search` CI workflow runs them in a uv env
built from the lockfile in this directory — same commands work locally from the
repo root (the `--override-ini` drops the main suite's ignore):
```sh
uv sync --locked --project python/ebook_search/docker
uv run --project python/ebook_search/docker --no-sync pytest tests/ebook_search --override-ini addopts="-n auto -ra"
```
They can also run inside the Docker `test` image, which validates the image itself:
```sh
python -m python.ebook_search.docker.containers test
```
or the raw docker equivalent:
```sh
docker build --file python/ebook_search/docker/Dockerfile --target test --tag ebook-search:test .
docker run --rm ebook-search:test
```
## Configuration
The compose service loads the repo root `.env` into the container via `env_file`.
Mount your EPUB directory by setting `EBOOK_LIBRARY_HOST_PATH` in an env file or on the command line. The container sees it as `/library`, and `EBOOK_SEARCH_LIBRARY_PATHS` is set to `/library` inside the container.
Database connection settings are controlled by `RICHIE_DB`, `RICHIE_HOST`, `RICHIE_PORT`, `RICHIE_USER`, and `RICHIE_PASSWORD`. The default host is `jeeves`.
@@ -9,6 +9,8 @@ services:
restart: unless-stopped
ports:
- "${EBOOK_SEARCH_PORT:-8070}:8070"
extra_hosts:
- "jeeves:192.168.90.40"
env_file:
- ../../../.env
environment:
+54
View File
@@ -0,0 +1,54 @@
# Gems
Gems is a server-rendered, turn-based resource-engine game for one to four human or AI players. It uses FastAPI,
Jinja, HTMX, server-sent events, and SQLite.
The application deliberately contains no playable card deck, patron/governor set, objective set, official artwork,
or copied rulebook text. A room host must upload a content pack they are entitled to use before starting a game.
## Run locally
```shell
uv run gems --host 127.0.0.1 --port 8082
```
The default database and installation key are created under `.gems/`. The following environment variables override
runtime behavior:
- `GEMS_DATABASE_PATH`
- `GEMS_KEY_PATH`
- `GEMS_PUBLIC_ORIGIN`
- `GEMS_SECURE_COOKIES`
- `GEMS_HOST`
- `GEMS_PORT`
## Content packs
The current schema is available from a running server at `/schemas/content-pack-v1.json`. A pack defines exactly
five normal resources, one wild resource, cards, and optional patrons, objectives, and outposts. `patrons` is the
canonical field name; `governors` is accepted as an input alias.
Cards may use only the built-in, bounded effect vocabulary:
- `none`
- `virtual_wild`
- `copy_bonus`
- `copy_and_claim`
- `multi_bonus`
- `claim_free`
- an optional discard-cards alternate cost
Unknown fields, resource references, executable expressions, HTML, artwork URLs, and files larger than 512 KiB are
rejected. The normalized pack is private to its room and becomes immutable when play starts.
## Neutral module mapping
Gems calls the four optional mechanics Objectives, Outposts, Eastern Decks, and Fortifications. Lobby presets combine
these mechanics into the familiar base, objective-race, objective-plus-outpost, eastern-plus-fortification, and
all-module configurations. Component identities and values always come from the uploaded pack.
## Jeeves
The NixOS module runs one Uvicorn worker on `127.0.0.1:8002`, stores state in
`/zfs/media/services/gems`, and publishes it through HAProxy at `https://gems.tmmworkshop.com`. The DNS record must
point to Jeeves before ACME can issue the certificate.
+6 -6
View File
@@ -14,7 +14,7 @@ DEFAULT_BASE_BRANCH = "main"
DEFAULT_BRANCH = "automation/update-flake-lock"
DEFAULT_GITEA_URL = "https://gitea.tmmworkshop.com"
PR_LABELS = ["dependencies", "automated", "flake_lock_update"]
PR_CHECK_WORKFLOWS = ["build_systems.yml", "treefmt.yml"]
PR_CHECK_WORKFLOWS = ["build_systems.yml", "treefmt.yml", "pytest.yml"]
PR_TITLE = "Update flake.lock"
PR_BODY = "Automated flake.lock update."
@@ -84,13 +84,13 @@ def push_branch(*, branch: str) -> None:
run_cmd(["git", "push", "origin", f"HEAD:{branch}", "--force"])
def _required_jeeves_bot_token() -> str:
def _required_gitea_token() -> str:
"""Read the required Gitea token from the environment."""
token = getenv("JEEVES_BOT_TOKEN")
token = getenv("GITEA_TOKEN")
if token:
return token
msg = "JEEVES_BOT_TOKEN environment variable is required"
msg = "GITEA_TOKEN environment variable is required"
raise RuntimeError(msg)
@@ -111,7 +111,7 @@ def update(
owner, repo_name = split_repo_name(repo)
with GiteaClient(
base_url=getenv("GITEA_URL", DEFAULT_GITEA_URL),
token=_required_jeeves_bot_token(),
token=_required_gitea_token(),
) as client:
pull_request = ensure_flake_lock_pull_request(
client,
@@ -134,7 +134,7 @@ def merge(
owner, repo_name = split_repo_name(repo)
with GiteaClient(
base_url=getenv("GITEA_URL", DEFAULT_GITEA_URL),
token=_required_jeeves_bot_token(),
token=_required_gitea_token(),
) as client:
pull_request = find_flake_lock_pull_request(client, owner=owner, repo=repo_name)
if not pull_request:
-18
View File
@@ -176,21 +176,6 @@ def create_zfs_datasets() -> None:
run_output(("zfs", "create", "root_pool/home"))
run_output(("zfs", "create", "-o", "reservation=1G", "root_pool/var"))
run_output(("zfs", "create", "-o", "compression=zstd-9", "-o", "reservation=10G", "root_pool/nix"))
run_output(
(
"zfs",
"create",
"-o",
"sync=disabled",
"-o",
"redundant_metadata=some",
"-o",
"normalization=none",
"-o",
"utf8only=off",
"root_pool/nix_build",
)
)
datasets = run_output(("zfs", "list", "-o", "name"))
expected_datasets = {
@@ -198,7 +183,6 @@ def create_zfs_datasets() -> None:
"root_pool/home",
"root_pool/var",
"root_pool/nix",
"root_pool/nix_build",
}
missing_datasets = expected_datasets.difference(datasets.splitlines())
if missing_datasets:
@@ -264,7 +248,6 @@ def create_nix_hardware_file(mnt_dir: str, disks: Sequence[str], encrypt: str |
' "/home" = {\n device = "root_pool/home";\n fsType = "zfs";\n };\n\n'
' "/var" = {\n device = "root_pool/var";\n fsType = "zfs";\n };\n\n'
' "/nix" = {\n device = "root_pool/nix";\n fsType = "zfs";\n };\n\n'
' "/nix/var/nix/builds" = {\n device = "root_pool/nix_build";\n fsType = "zfs";\n };\n\n'
' "/boot" = {\n'
f' device = "/dev/disk/by-uuid/{get_boot_drive_id(disks[0])}";\n'
' fsType = "vfat";\n options = [\n "fmask=0077"\n'
@@ -287,7 +270,6 @@ def install_nixos(mnt_dir: str, disks: Sequence[str], encrypt: str | None) -> No
run_output(("mount", "-o", "X-mount.mkdir", "-t", "zfs", "root_pool/home", f"{mnt_dir}/home"))
run_output(("mount", "-o", "X-mount.mkdir", "-t", "zfs", "root_pool/var", f"{mnt_dir}/var"))
run_output(("mount", "-o", "X-mount.mkdir", "-t", "zfs", "root_pool/nix", f"{mnt_dir}/nix"))
run_output(("mount", "-o", "X-mount.mkdir", "-t", "zfs", "root_pool/nix_build", f"{mnt_dir}/nix/var/nix/builds"))
for disk in disks:
run_output(("mkfs.vfat", "-n", "EFI", f"{disk}-part1"))
+16 -50
View File
@@ -2,63 +2,29 @@
from __future__ import annotations
import json
import logging
import socket
from os import getenv
from apprise import Apprise
logger = logging.getLogger(__name__)
class SignalRPCError(RuntimeError):
"""signal-cli returned an error or closed without responding."""
def signal_alert(body: str, from_phone: str, to_phone: str, *, timeout: float = 4.0) -> None:
"""Send a Signal alert through the local signal-cli daemon.
def signal_alert(body: str, title: str = "") -> None:
"""Send a signal alert.
Args:
body: The body of the alert.
from_phone: The Signal account sending the alert.
to_phone: The Signal account receiving the alert.
timeout: Seconds to wait on each socket operation.
Raises:
SignalRPCError: If signal-cli returns an error or closes the
connection before responding.
OSError: If the socket is unreachable or an operation times out.
body (str): The body of the alert.
title (str, optional): The title of the alert. Defaults to "".
"""
signal_rpc_id = "signal-alert"
request = {
"jsonrpc": "2.0",
"method": "send",
"params": {
"account": from_phone,
"recipient": [to_phone],
"message": body,
},
"id": signal_rpc_id,
}
apprise_client = Apprise()
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(timeout)
client.connect("/run/signal-cli/socket")
client.sendall(json.dumps(request).encode() + b"\n")
from_phone = getenv("SIGNAL_ALERT_FROM_PHONE")
to_phone = getenv("SIGNAL_ALERT_TO_PHONE")
if not from_phone or not to_phone:
logger.info("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set")
return
buffer = b""
while chunk := client.recv(65536):
buffer += chunk
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", maxsplit=1)
response = json.loads(line)
if response.get("id") != signal_rpc_id:
continue
if rpc_error := response.get("error"):
error = f"Signal RPC returned an error: {rpc_error}"
raise SignalRPCError(error)
return
error = "Signal RPC socket closed before returning a response"
raise SignalRPCError(error)
except (OSError, json.JSONDecodeError) as exc:
error = f"Signal RPC failed: {exc!r}"
raise SignalRPCError(error) from exc
apprise_client.add(f"signal://localhost:8989/{from_phone}/{to_phone}")
apprise_client.notify(title=title, body=body)
+1
View File
@@ -0,0 +1 @@
"""system_tests."""
+99
View File
@@ -0,0 +1,99 @@
"""Validate Jeeves."""
from __future__ import annotations
import logging
from copy import copy
from re import search
from time import sleep
from typing import TYPE_CHECKING
from python.common import bash_wrapper
from python.zfs import Zpool
if TYPE_CHECKING:
from collections.abc import Sequence
logger = logging.getLogger(__name__)
def zpool_tests(pool_names: Sequence[str], zpool_capacity_threshold: int = 90) -> list[str] | None:
"""Check the zpool health and capacity.
Args:
pool_names (Sequence[str]): A list of pool names to test.
zpool_capacity_threshold (int, optional): The threshold for the zpool capacity. Defaults to 90.
Returns:
list[str] | None: A list of errors if any.
"""
logger.info("Testing zpool")
errors: list[str] = []
for pool_name in pool_names:
pool = Zpool(pool_name)
if pool.health != "ONLINE":
errors.append(f"{pool.name} is {pool.health}")
if pool.capacity >= zpool_capacity_threshold:
errors.append(f"{pool.name} is low on space")
upgrade_status, _ = bash_wrapper("zpool upgrade")
if not search(r"Every feature flags pool has all supported and requested features enabled.", upgrade_status):
errors.append("ZPool out of date run `sudo zpool upgrade -a`")
return errors
def systemd_tests(
service_names: Sequence[str],
max_retries: int = 30,
retry_delay_secs: int = 1,
retryable_statuses: Sequence[str] | None = None,
valid_statuses: Sequence[str] | None = None,
) -> list[str] | None:
"""Tests a systemd services.
Args:
service_names (Sequence[str]): A list of service names to test.
max_retries (int, optional): The maximum number of retries. Defaults to 30.
minimum value is 1.
retry_delay_secs (int, optional): The delay between retries in seconds. Defaults to 1.
minimum value is 1.
retryable_statuses (Sequence[str] | None, optional): A list of retryable statuses. Defaults to None.
valid_statuses (Sequence[str] | None, optional): A list of valid statuses. Defaults to None.
Returns:
list[str] | None: A list of errors if any.
"""
logger.info("Testing systemd service")
max_retries = max(max_retries, 1)
retry_delay_secs = max(retry_delay_secs, 1)
last_try = max_retries - 1
if retryable_statuses is None:
retryable_statuses = ("inactive\n", "activating\n")
if valid_statuses is None:
valid_statuses = ("active\n",)
service_names_set = set(service_names)
errors: set[str] = set()
for retry in range(max_retries):
if not service_names_set:
break
logger.info(f"Testing systemd service in {retry + 1} of {max_retries}")
service_names_to_test = copy(service_names_set)
for service_name in service_names_to_test:
service_status, _ = bash_wrapper(f"systemctl is-active {service_name}")
if service_status in valid_statuses:
service_names_set.remove(service_name)
continue
if service_status in retryable_statuses and retry < last_try:
continue
errors.add(f"{service_name} is {service_status.strip()}")
sleep(retry_delay_secs)
return list(errors)
+67
View File
@@ -0,0 +1,67 @@
"""Validate {server_name}."""
import logging
import sys
import tomllib
from os import environ
from pathlib import Path # noqa: TC003 This is required for the typer CLI
from socket import gethostname
import typer
from python.common import configure_logger
from python.signal_alert import signal_alert
from python.system_tests.components import systemd_tests, zpool_tests
logger = logging.getLogger(__name__)
def load_config_data(config_file: Path) -> dict[str, list[str]]:
"""Load a TOML configuration file.
Args:
config_file (Path): The path to the configuration file.
Returns:
dict: The configuration data.
"""
return tomllib.loads(config_file.read_text())
def main(config_file: Path) -> None:
"""Main."""
configure_logger(level=environ.get("LOG_LEVEL", "INFO"))
server_name = gethostname()
logger.info(f"Starting {server_name} validation")
config_data = load_config_data(config_file)
errors: list[str] = []
try:
if config_data.get("zpools") and (zpool_errors := zpool_tests(config_data["zpools"])):
errors.extend(zpool_errors)
if config_data.get("services") and (systemd_errors := systemd_tests(config_data["services"])):
errors.extend(systemd_errors)
except Exception as error:
logger.exception(f"{server_name} validation failed")
errors.append(f"{server_name} validation failed: {error}")
if errors:
logger.error(f"{server_name} validation failed: \n{'\n'.join(errors)}")
signal_alert(f"{server_name} validation failed {errors}")
sys.exit(1)
logger.info(f"{server_name} validation passed")
def cli() -> None:
"""CLI."""
typer.run(main)
if __name__ == "__main__":
cli()
+168
View File
@@ -0,0 +1,168 @@
"""snapshot_manager."""
from __future__ import annotations
import logging
import sys
import tomllib
from functools import cache
from pathlib import Path # noqa: TC003 This is required for the typer CLI
from re import compile as re_compile
from re import search
import typer
from python.common import configure_logger, utcnow
from python.signal_alert import signal_alert
from python.zfs import Dataset, get_datasets
logger = logging.getLogger(__name__)
def main(config_file: Path) -> None:
"""Main."""
configure_logger(level="DEBUG")
logger.info("Starting snapshot_manager")
failures: list[str] = []
try:
time_stamp = get_time_stamp()
for dataset in get_datasets():
status = dataset.create_snapshot(time_stamp)
logger.debug(f"{status=}")
if status != "snapshot created":
msg = f"{dataset.name} failed to create snapshot {time_stamp}"
logger.error(msg)
signal_alert(msg)
failures.append(msg)
continue
count_lookup = get_count_lookup(config_file, dataset.name)
logger.info(f"using {count_lookup} for {dataset.name}")
failures.extend(get_snapshots_to_delete(dataset, count_lookup))
except Exception:
logger.exception("snapshot_manager failed")
signal_alert("snapshot_manager failed")
sys.exit(1)
if failures:
logger.error(f"snapshot_manager completed with {len(failures)} errors")
for failure in failures:
logger.error(f" {failure}")
sys.exit(1)
logger.info("snapshot_manager completed")
def get_count_lookup(config_file: Path, dataset_name: str) -> dict[str, int]:
"""Get the count lookup.
Args:
config_file (Path): The path to the configuration file.
dataset_name (str): The name of the dataset.
Returns:
dict[str, int]: The count lookup.
"""
config_data = load_config_data(config_file)
return config_data.get(dataset_name, get_default_config(config_data))
def get_default_config(config_data: dict[str, dict[str, int]]) -> dict[str, int]:
"""Get the default configuration.
Args:
config_data (dict[str, dict[str, int]]): The configuration data.
Returns:
dict[str, int]: The default configuration.
"""
return config_data.get(
"default",
{"15_min": 4, "hourly": 12, "daily": 0, "monthly": 0},
)
@cache
def load_config_data(config_file: Path) -> dict[str, dict[str, int]]:
"""Load a TOML configuration file.
Args:
config_file (Path): The path to the configuration file.
Returns:
dict: The configuration data.
"""
return tomllib.loads(config_file.read_text())
def get_snapshots_to_delete(
dataset: Dataset,
count_lookup: dict[str, int],
) -> list[str]:
"""Get snapshots to delete.
Args:
dataset (Dataset): the dataset
count_lookup (dict[str, int]): the count lookup
Returns:
list[str]: Snapshot deletion failures encountered while pruning.
"""
for retention_class in ("15_min", "hourly", "daily", "monthly"):
count = count_lookup.get(retention_class)
if not isinstance(count, int) or isinstance(count, bool) or count < 0:
error = f"{retention_class} retention must be a non-negative integer, got {count!r}"
raise ValueError(error)
failures: list[str] = []
snapshots = dataset.get_snapshots()
logger.info(f"calculating snapshots for {dataset.name} to be deleted")
if not snapshots:
logger.info(f"{dataset.name} has no snapshots")
return failures
filters = (
("15_min", re_compile(r"auto_\d{10}(?:15|30|45)")),
("hourly", re_compile(r"auto_\d{8}(?!00)\d{2}00")),
("daily", re_compile(r"auto_\d{6}(?!01)\d{2}0000")),
("monthly", re_compile(r"auto_\d{6}010000")),
)
for filter_name, snapshot_filter in filters:
logger.debug(f"{filter_name=}\n{snapshot_filter=}")
filtered_snapshots = sorted(snapshot.name for snapshot in snapshots if search(snapshot_filter, snapshot.name))
logger.debug(f"{filtered_snapshots=}")
snapshots_wanted = count_lookup[filter_name]
snapshots_being_deleted = filtered_snapshots[:-snapshots_wanted] if snapshots_wanted > 0 else filtered_snapshots
logger.info(f"{snapshots_being_deleted} are being deleted")
for snapshot in snapshots_being_deleted:
if error := dataset.delete_snapshot(snapshot):
error_message = f"{dataset.name}@{snapshot} failed to delete: {error}"
signal_alert(error_message)
logger.error(error_message)
failures.append(error_message)
return failures
def get_time_stamp() -> str:
"""Get the time stamp."""
now = utcnow()
nearest_15_min = now.replace(minute=(now.minute - (now.minute % 15)))
return nearest_15_min.strftime("auto_%Y%m%d%H%M")
def cli() -> None:
"""CLI."""
typer.run(main)
if __name__ == "__main__":
cli()
+340
View File
@@ -0,0 +1,340 @@
"""zfs_manager.
Reconciles the live zfs datasets against a declaration generated by
common/optional/zfs_manager.nix. Datasets are created and properties are
corrected, but nothing is ever destroyed or renamed.
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path # noqa: TC003 This is required for the typer CLI
import typer
from python.common import configure_logger
from python.signal_alert import signal_alert
from python.zfs import create_dataset, get_properties, list_dataset_names, set_property
logger = logging.getLogger(__name__)
# Properties that can only be chosen at creation time. Attempting to zfs set
# these fails on every run, so a mismatch is reported instead of retried.
CREATE_ONLY_PROPERTIES = frozenset(
{
"casesensitivity",
"encryption",
"keyformat",
"normalization",
"utf8only",
"volblocksize",
},
)
# Properties whose values zfs reports in bytes but which are conventionally
# declared with a size suffix, so "16k" and "16384" mean the same thing.
SIZE_PROPERTIES = frozenset(
{
"quota",
"recordsize",
"refquota",
"refreservation",
"reservation",
"special_small_blocks",
"volblocksize",
"volsize",
},
)
SIZE_SUFFIXES = {"b": 1, "k": 1024, "m": 1024**2, "g": 1024**3, "t": 1024**4, "p": 1024**5}
# Sources that mean the value was deliberately put on this dataset rather than
# inherited from a parent or left at the zfs default.
LOCAL_SOURCES = ("local", "received")
class ReconciliationError(RuntimeError):
"""One or more datasets could not be brought in line with the declaration."""
def __init__(self, failures: list[str]) -> None:
"""Record the individual failures behind this run's exit code."""
self.failures = failures
super().__init__(f"ZFS reconciliation failed with {len(failures)} errors")
def main(config_file: Path, *, dry_run: bool = False) -> None:
"""Main.
Args:
config_file (Path): The path to the generated dataset declaration.
dry_run (bool): Log the changes that would be made without making them.
"""
configure_logger(level="DEBUG")
logger.info(f"Starting zfs_manager {dry_run=}")
try:
reconcile(config_file, dry_run=dry_run)
except ReconciliationError as error:
summary = error
except Exception:
logger.exception("zfs_manager failed")
signal_alert("zfs_manager failed")
sys.exit(1)
else:
logger.info("zfs_manager completed")
return
# Each failure was logged and alerted as it happened. Repeating them
# together puts the whole picture at the end of the journal, which is what
# systemctl status shows. No traceback: this is an expected outcome, not a
# crash, and a stack trace would only bury the list.
logger.error(str(summary))
for failure in summary.failures:
logger.error(f" {failure}")
sys.exit(1)
def reconcile(config_file: Path, *, dry_run: bool) -> None:
"""Bring every declared dataset in line, collecting problems as it goes.
One bad dataset must not hide the state of the others, so everything is
checked before anything is raised.
Args:
config_file (Path): The path to the generated dataset declaration.
dry_run (bool): Log the changes without making them.
Raises:
ReconciliationError: If anything could not be reconciled.
"""
declared = json.loads(config_file.read_text())["datasets"]
existing = set(list_dataset_names())
unusable: set[str] = set()
failures: list[str] = []
# Parents before children so a newly created parent exists by the time its
# children are reconciled.
for name in sorted(declared, key=lambda name: (name.count("/"), name)):
entry = declared[name]
# Declared purely to record retention, its properties belong to
# whoever set them.
if not entry.get("manageProperties", True):
logger.debug(f"{name} is declared but its properties are not managed")
continue
if has_unusable_parent(name, unusable):
failures.append(fail(f"cannot reconcile {name}, its parent is missing"))
continue
if name in existing:
failures.extend(reconcile_dataset(name, entry["properties"], dry_run=dry_run))
continue
created, failure = handle_missing_dataset(name, entry, dry_run=dry_run)
if failure is not None:
failures.append(failure)
if created:
existing.add(name)
elif not dry_run:
unusable.add(name)
report_undeclared_datasets(existing, declared)
if failures:
raise ReconciliationError(failures)
def fail(message: str) -> str:
"""Log and alert a problem, and hand it back for the failure tally.
Args:
message (str): What went wrong.
Returns:
str: The same message, so the caller can collect it.
"""
logger.error(message)
signal_alert(message)
return message
def has_unusable_parent(name: str, unusable: set[str]) -> bool:
"""Check whether an ancestor of a dataset is missing.
Args:
name (str): The name of the dataset.
unusable (set[str]): The datasets that do not exist and were not created.
Returns:
bool: True if any ancestor is unusable.
"""
parts = name.split("/")
return any("/".join(parts[:depth]) in unusable for depth in range(1, len(parts)))
def handle_missing_dataset(name: str, entry: dict, *, dry_run: bool) -> tuple[bool, str | None]:
"""Deal with a declared dataset that is not on the system.
Pool roots are never created, and neither is anything the declaration marks
as provisioned outside of nix, such as an encryption root whose key
settings cannot be reproduced from the declaration.
Args:
name (str): The name of the dataset.
entry (dict): The declaration for this dataset.
dry_run (bool): Log the change without making it.
Returns:
tuple[bool, str | None]: Whether the dataset now exists, and a failure
message if there was one.
"""
properties = entry["properties"]
if "/" not in name:
return False, fail(f"pool {name} is declared but does not exist, zfs_manager does not create pools")
if not entry.get("createIfMissing", True):
return False, fail(
f"{name} is declared but does not exist, and is marked as created outside of nix. "
"It has to be made by hand, see systems/jeeves/scripts/zfs.sh.",
)
if dry_run:
logger.info(f"would create {name} with {properties}")
return False, None
logger.info(f"creating {name} with {properties}")
if error := create_dataset(name, properties):
return False, fail(error)
return True, None
def reconcile_dataset(name: str, properties: dict[str, str], *, dry_run: bool) -> list[str]:
"""Bring an existing dataset in line with its declared properties.
Args:
name (str): The name of the dataset.
properties (dict[str, str]): The declared properties.
dry_run (bool): Log the changes without making them.
Returns:
list[str]: Anything that could not be put right.
"""
failures: list[str] = []
current = get_properties(name)
for key, wanted in sorted(properties.items()):
current_value, _ = current.get(key, ("-", "-"))
if values_match(key, wanted, current_value):
continue
if key in CREATE_ONLY_PROPERTIES:
# Nothing can put this right while the dataset exists, so it is a
# hard failure rather than a warning that repeats unnoticed.
failures.append(
fail(
f"{name} {key} is {current_value} but {wanted} is declared, "
f"{key} can only be set when the dataset is created",
),
)
continue
if dry_run:
logger.info(f"would set {key}={wanted} on {name}, currently {current_value}")
continue
logger.info(f"setting {key}={wanted} on {name}, was {current_value}")
if error := set_property(name, key, wanted):
failures.append(fail(error))
report_undeclared_properties(name, properties, current)
return failures
def report_undeclared_properties(name: str, properties: dict[str, str], current: dict[str, tuple[str, str]]) -> None:
"""Warn about properties set on the dataset but absent from the declaration.
Inherited and default values are silent, they are not drift. A locally set
value that nix does not know about was changed outside of this tool and
will be lost the next time the dataset is recreated, so it is worth saying.
Args:
name (str): The name of the dataset.
properties (dict[str, str]): The declared properties.
current (dict[str, tuple[str, str]]): The live properties keyed to (value, source).
"""
for key, (value, source) in sorted(current.items()):
# User properties such as nixos:shutdown-time are written by other
# tools and are not something a dataset declaration should own.
if key in properties or ":" in key or source not in LOCAL_SOURCES:
continue
logger.warning(f"{name} has {key}={value} set outside of nix")
signal_alert(f"{name} has {key}={value} set outside of nix")
def report_undeclared_datasets(existing: set[str], declared: dict[str, dict]) -> None:
"""Warn about datasets that exist but are not declared.
These are left completely alone. They still get snapshots through the
default retention table.
Args:
existing (set[str]): The names of every live dataset.
declared (dict[str, dict]): The declaration.
"""
for name in sorted(existing - set(declared)):
logger.warning(f"{name} exists but is not declared in nix")
def values_match(key: str, wanted: str, current: str) -> bool:
"""Compare a declared property value against the live one.
Args:
key (str): The property name.
wanted (str): The declared value.
current (str): The live value.
Returns:
bool: True if the two values mean the same thing.
"""
if key in SIZE_PROPERTIES:
wanted_size = parse_size(wanted)
current_size = parse_size(current)
if wanted_size is not None and current_size is not None:
return wanted_size == current_size
return wanted == current
def parse_size(value: str) -> int | None:
"""Convert a zfs size such as 16k or 1M into bytes.
Args:
value (str): The size to convert.
Returns:
int | None: The size in bytes, or None if it is not a size.
"""
value = value.strip()
if value.isdigit():
return int(value)
number, suffix = value[:-1], value[-1:].lower()
if suffix in SIZE_SUFFIXES and number.isdigit():
return int(number) * SIZE_SUFFIXES[suffix]
return None
def cli() -> None:
"""CLI."""
typer.run(main)
if __name__ == "__main__":
cli()
+1
View File
@@ -0,0 +1 @@
"""Van weather service - fetches weather with masked GPS location."""
+293
View File
@@ -0,0 +1,293 @@
"""Van weather service - fetches weather with masked GPS for privacy."""
import logging
from datetime import UTC, datetime
from typing import Annotated, Any
import httpx
import typer
from apscheduler.schedulers.blocking import BlockingScheduler
from tenacity import before_sleep_log, retry, stop_after_attempt, wait_fixed
from python.common import configure_logger
from python.van_weather.models import Config, DailyForecast, HourlyForecast, Weather
# Map Pirate Weather icons to Home Assistant conditions
CONDITION_MAP = {
"clear-day": "sunny",
"clear-night": "clear-night",
"rain": "rainy",
"snow": "snowy",
"sleet": "snowy-rainy",
"wind": "windy",
"fog": "fog",
"cloudy": "cloudy",
"partly-cloudy-day": "partlycloudy",
"partly-cloudy-night": "partlycloudy",
}
logger = logging.getLogger(__name__)
@retry(
stop=stop_after_attempt(3),
wait=wait_fixed(5),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
def get_ha_state(url: str, token: str, entity_id: str) -> float:
"""Get numeric state from Home Asasistant entity."""
response = httpx.get(
f"{url}/api/states/{entity_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
response.raise_for_status()
state = response.json()["state"]
if state in ("unavailable", "unknown"):
error = f"{entity_id} is {state}"
raise ValueError(error)
return float(state)
def parse_daily_forecast(data: dict[str, dict[str, Any]]) -> list[DailyForecast]:
"""Parse daily forecast from Pirate Weather API."""
daily = data.get("daily", {}).get("data", [])
daily_forecasts = []
for day in daily[:8]: # Up to 8 days
time_stamp = day.get("time")
if time_stamp:
date_time = datetime.fromtimestamp(time_stamp, tz=UTC).isoformat()
daily_forecasts.append(
DailyForecast(
date_time=date_time,
condition=CONDITION_MAP.get(day.get("icon", ""), "cloudy"),
temperature=day.get("temperatureHigh"),
templow=day.get("temperatureLow"),
precipitation_probability=day.get("precipProbability"),
moon_phase=day.get("moonPhase"),
wind_gust=day.get("windGust"),
cloud_cover=day.get("cloudCover"),
)
)
return daily_forecasts
def parse_hourly_forecast(data: dict[str, dict[str, Any]]) -> list[HourlyForecast]:
"""Parse hourly forecast from Pirate Weather API."""
hourly = data.get("hourly", {}).get("data", [])
hourly_forecasts = []
for hour in hourly[:48]: # Up to 48 hours
time_stamp = hour.get("time")
if time_stamp:
date_time = datetime.fromtimestamp(time_stamp, tz=UTC).isoformat()
hourly_forecasts.append(
HourlyForecast(
date_time=date_time,
condition=CONDITION_MAP.get(hour.get("icon", ""), "cloudy"),
temperature=hour.get("temperature"),
precipitation_probability=hour.get("precipProbability"),
)
)
return hourly_forecasts
@retry(
stop=stop_after_attempt(3),
wait=wait_fixed(5),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
def fetch_weather(api_key: str, lat: float, lon: float) -> Weather:
"""Fetch weather from Pirate Weather API."""
url = f"https://api.pirateweather.net/forecast/{api_key}/{lat},{lon}"
response = httpx.get(url, params={"units": "us"}, timeout=30)
response.raise_for_status()
data = response.json()
daily_forecasts = parse_daily_forecast(data)
hourly_forecasts = parse_hourly_forecast(data)
current = data.get("currently", {})
icon = current.get("icon", "")
return Weather(
temperature=current.get("temperature"),
feels_like=current.get("apparentTemperature"),
humidity=current.get("humidity"),
wind_speed=current.get("windSpeed"),
wind_bearing=current.get("windBearing"),
condition=CONDITION_MAP.get(icon, "cloudy"),
summary=current.get("summary"),
pressure=current.get("pressure"),
visibility=current.get("visibility"),
uv_index=current.get("uvIndex"),
ozone=current.get("ozone"),
nearest_storm_distance=current.get("nearestStormDistance"),
nearest_storm_bearing=current.get("nearestStormBearing"),
precip_probability=current.get("precipProbability"),
cloud_cover=current.get("cloudCover"),
daily_forecasts=daily_forecasts,
hourly_forecasts=hourly_forecasts,
)
@retry(
stop=stop_after_attempt(3),
wait=wait_fixed(5),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
def post_to_ha(url: str, token: str, weather: Weather) -> None:
"""Post weather data to Home Assistant as sensor entities."""
headers = {"Authorization": f"Bearer {token}"}
# Post current weather as individual sensors
sensors = {
"sensor.van_weather_condition": {
"state": weather.condition or "unknown",
"attributes": {"friendly_name": "Van Weather Condition"},
},
"sensor.van_weather_temperature": {
"state": weather.temperature,
"attributes": {"unit_of_measurement": "°F", "device_class": "temperature"},
},
"sensor.van_weather_apparent_temperature": {
"state": weather.feels_like,
"attributes": {"unit_of_measurement": "°F", "device_class": "temperature"},
},
"sensor.van_weather_humidity": {
"state": int((weather.humidity or 0) * 100),
"attributes": {"unit_of_measurement": "%", "device_class": "humidity"},
},
"sensor.van_weather_pressure": {
"state": weather.pressure,
"attributes": {"unit_of_measurement": "mbar", "device_class": "pressure"},
},
"sensor.van_weather_wind_speed": {
"state": weather.wind_speed,
"attributes": {"unit_of_measurement": "mph", "device_class": "wind_speed"},
},
"sensor.van_weather_wind_bearing": {
"state": weather.wind_bearing,
"attributes": {"unit_of_measurement": "°"},
},
"sensor.van_weather_visibility": {
"state": weather.visibility,
"attributes": {"unit_of_measurement": "mi"},
},
"sensor.van_weather_uv_index": {
"state": weather.uv_index,
"attributes": {"friendly_name": "Van Weather UV Index", "icon": "mdi:sun-wireless"},
},
"sensor.van_weather_ozone": {
"state": weather.ozone,
"attributes": {"unit_of_measurement": "DU", "icon": "mdi:earth"},
},
"sensor.van_weather_nearest_storm_distance": {
"state": weather.nearest_storm_distance,
"attributes": {"unit_of_measurement": "mi", "icon": "mdi:weather-lightning"},
},
"sensor.van_weather_nearest_storm_bearing": {
"state": weather.nearest_storm_bearing,
"attributes": {"unit_of_measurement": "°", "icon": "mdi:weather-lightning"},
},
"sensor.van_weather_precip_probability": {
"state": int((weather.precip_probability or 0) * 100),
"attributes": {"unit_of_measurement": "%", "icon": "mdi:weather-rainy"},
},
"sensor.van_weather_cloud_cover": {
"state": int((weather.cloud_cover or 0) * 100),
"attributes": {"unit_of_measurement": "%", "icon": "mdi:weather-cloudy"},
},
}
for entity_id, data in sensors.items():
if data["state"] is not None:
response = httpx.post(f"{url}/api/states/{entity_id}", headers=headers, json=data, timeout=30)
response.raise_for_status()
# Post daily forecast as JSON attribute sensor
daily_forecast = [
{
"datetime": daily_forecast.date_time.isoformat(),
"condition": daily_forecast.condition,
"temperature": daily_forecast.temperature,
"templow": daily_forecast.templow,
"precipitation_probability": int((daily_forecast.precipitation_probability or 0) * 100),
}
for daily_forecast in weather.daily_forecasts
]
response = httpx.post(
f"{url}/api/states/sensor.van_weather_forecast_daily",
headers=headers,
json={"state": len(daily_forecast), "attributes": {"forecast": daily_forecast}},
timeout=30,
)
response.raise_for_status()
# Post hourly forecast as JSON attribute sensor
hourly_forecast = [
{
"datetime": hourly_forecast.date_time.isoformat(),
"condition": hourly_forecast.condition,
"temperature": hourly_forecast.temperature,
"precipitation_probability": int((hourly_forecast.precipitation_probability or 0) * 100),
}
for hourly_forecast in weather.hourly_forecasts
]
response = httpx.post(
f"{url}/api/states/sensor.van_weather_forecast_hourly",
headers=headers,
json={"state": len(hourly_forecast), "attributes": {"forecast": hourly_forecast}},
timeout=30,
)
response.raise_for_status()
def update_weather(config: Config) -> None:
"""Fetch weather using last-known location, post to HA."""
lat = get_ha_state(config.ha_url, config.ha_token, config.lat_entity)
lon = get_ha_state(config.ha_url, config.ha_token, config.lon_entity)
masked_lat = round(lat, config.mask_decimals)
masked_lon = round(lon, config.mask_decimals)
logger.info(f"Masked location: {masked_lat}, {masked_lon}")
weather = fetch_weather(config.pirate_weather_api_key, masked_lat, masked_lon)
logger.info(f"Weather: {weather.temperature}°F, {weather.condition}")
post_to_ha(config.ha_url, config.ha_token, weather)
logger.info("Posted weather to HA")
def main(
ha_url: Annotated[str, typer.Option(envvar="HA_URL")],
ha_token: Annotated[str, typer.Option(envvar="HA_TOKEN")],
api_key: Annotated[str, typer.Option(envvar="PIRATE_WEATHER_API_KEY")],
interval: Annotated[int, typer.Option(help="Poll interval in seconds")] = 900,
log_level: Annotated[str, typer.Option()] = "INFO",
) -> None:
"""Fetch weather for van using masked GPS location."""
configure_logger(log_level)
config = Config(ha_url=ha_url, ha_token=ha_token, pirate_weather_api_key=api_key)
logger.info(f"Starting van weather service, polling every {interval}s")
scheduler = BlockingScheduler()
scheduler.add_job(
update_weather,
"interval",
seconds=interval,
args=[config],
next_run_time=datetime.now(UTC),
)
scheduler.start()
if __name__ == "__main__":
typer.run(main)
+72
View File
@@ -0,0 +1,72 @@
"""Models for van weather service."""
from __future__ import annotations
from datetime import datetime # noqa: TC003 This is required for pydantic
from pydantic import BaseModel, field_serializer
class Config(BaseModel):
"""Service configuration."""
ha_url: str
ha_token: str
pirate_weather_api_key: str
lat_entity: str = "sensor.van_last_known_latitude"
lon_entity: str = "sensor.van_last_known_longitude"
mask_decimals: int = 1 # ~11km accuracy
class DailyForecast(BaseModel):
"""Daily forecast entry."""
date_time: datetime
condition: str | None = None
temperature: float | None = None # High
templow: float | None = None # Low
precipitation_probability: float | None = None
moon_phase: float | None = None
wind_gust: float | None = None
cloud_cover: float | None = None
@field_serializer("date_time")
def serialize_date_time(self, date_time: datetime) -> str:
"""Serialize datetime to ISO format."""
return date_time.isoformat()
class HourlyForecast(BaseModel):
"""Hourly forecast entry."""
date_time: datetime
condition: str | None = None
temperature: float | None = None
precipitation_probability: float | None = None
@field_serializer("date_time")
def serialize_date_time(self, date_time: datetime) -> str:
"""Serialize datetime to ISO format."""
return date_time.isoformat()
class Weather(BaseModel):
"""Weather data from Pirate Weather."""
temperature: float | None = None
feels_like: float | None = None
humidity: float | None = None
wind_speed: float | None = None
wind_bearing: float | None = None
condition: str | None = None
summary: str | None = None
pressure: float | None = None
visibility: float | None = None
uv_index: float | None = None
ozone: float | None = None
nearest_storm_distance: float | None = None
nearest_storm_bearing: float | None = None
precip_probability: float | None = None
cloud_cover: float | None = None
daily_forecasts: list[DailyForecast] = []
hourly_forecasts: list[HourlyForecast] = []
-10
View File
@@ -1,10 +0,0 @@
{ lib }:
lib.fileset.toSource {
root = ../.;
fileset = lib.fileset.unions [
../Cargo.toml
../Cargo.lock
../clippy.toml
./.
];
}
-17
View File
@@ -1,17 +0,0 @@
[package]
name = "signal"
version = "0.1.0"
edition = "2024"
description = "Send Signal alerts through the local signal-cli JSON-RPC daemon"
license = "MIT"
publish = false
[dependencies]
anyhow.workspace = true
serde_json.workspace = true
[target.'cfg(target_os = "linux")'.dev-dependencies]
libc = "0.2"
[lints]
workspace = true
-78
View File
@@ -1,78 +0,0 @@
//! Send alerts through the local signal-cli daemon on Unix systems.
//!
//! ```no_run
//! use signal::Signal;
//!
//! let client = Signal {
//! from_phone: "+15555550100".into(),
//! };
//! client.send("+15555550101", "Service failed")?;
//! client.send("+15555550102", "Service recovered")?;
//! # Ok::<(), anyhow::Error>(())
//! ```
use std::{
io::{BufRead, BufReader, Write},
os::unix::net::UnixStream,
time::Duration,
};
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
/// A sending account for alerts through `/run/signal-cli/socket`.
pub struct Signal {
/// The phone number of an account registered with the local daemon.
pub from_phone: String,
}
impl Signal {
/// Send a message to `to_phone` and wait for the corresponding JSON-RPC response.
///
/// Socket reads and writes use a four-second timeout.
///
/// # Errors
///
/// Returns an error if connecting, writing, or reading fails, the daemon
/// returns invalid JSON or an RPC error, or it closes before responding.
pub fn send(&self, to_phone: &str, body: &str) -> Result<()> {
let mut stream = UnixStream::connect("/run/signal-cli/socket")
.context("Failed to connect to Signal RPC socket")?;
let timeout = Duration::from_secs(4);
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout))?;
let request = json!({
"jsonrpc": "2.0",
"method": "send",
"params": {
"account": self.from_phone,
"recipient": [to_phone],
"message": body,
},
"id": "signal-alert",
});
writeln!(stream, "{request}").context("Failed to send Signal RPC request")?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
loop {
line.clear();
if reader
.read_line(&mut line)
.context("Failed to read Signal RPC response")?
== 0
|| !line.ends_with('\n')
{
bail!("Signal RPC socket closed before returning a response");
}
let response: Value =
serde_json::from_str(&line).context("Invalid Signal RPC response")?;
if response.get("id").and_then(Value::as_str) != Some("signal-alert") {
continue;
}
if let Some(error) = response.get("error").filter(|value| !value.is_null()) {
bail!("Signal RPC returned an error: {error}");
}
return Ok(());
}
}
}
-147
View File
@@ -1,147 +0,0 @@
#![cfg(test)]
#![cfg(target_os = "linux")]
use std::{
cell::RefCell,
io::{self, BufRead, BufReader, Write},
os::{fd::AsRawFd, unix::net::UnixStream},
thread,
time::{Duration, Instant},
};
use serde_json::{Value, json};
use signal::Signal;
thread_local! {
static CONNECTION: RefCell<Option<UnixStream>> = const { RefCell::new(None) };
}
// Patch libc's connect only in this integration-test executable. Each test
// supplies its own socket pair; no call can reach the real Signal daemon.
// SAFETY: This symbol uses libc's connect ABI and preserves its return/errno
// convention. It never delegates to a real connection attempt.
#[unsafe(no_mangle)]
unsafe extern "C" fn connect(
socket: libc::c_int,
address: *const libc::sockaddr,
length: libc::socklen_t,
) -> libc::c_int {
let path_offset = std::mem::offset_of!(libc::sockaddr_un, sun_path);
let expected = b"/run/signal-cli/socket\0";
if !address.is_null()
&& let Some(path_end) = path_offset.checked_add(expected.len())
&& usize::try_from(length).is_ok_and(|length| length >= path_end)
{
// SAFETY: connect's caller supplies an address valid for length bytes.
// The bounds check above covers the entire pathname slice.
let path = unsafe {
std::slice::from_raw_parts(address.cast::<u8>().add(path_offset), expected.len())
};
if path == expected
&& let Some(stream) = CONNECTION.with_borrow_mut(Option::take)
{
// SAFETY: Both descriptors are live and owned by this thread.
// dup2 replaces the unconnected socket with the test endpoint.
return if unsafe { libc::dup2(stream.as_raw_fd(), socket) } < 0 {
-1
} else {
0
};
}
}
// SAFETY: __errno_location returns this thread's valid errno pointer.
unsafe { *libc::__errno_location() = libc::ECONNREFUSED };
-1
}
fn test_connection() -> UnixStream {
let (client, server) = UnixStream::pair().unwrap();
CONNECTION.with_borrow_mut(|connection| assert!(connection.replace(client).is_none()));
server
}
fn signal() -> Signal {
Signal {
from_phone: "1234567890".into(),
}
}
#[test]
fn reuses_sender_for_different_recipients_and_ignores_unrelated_responses() {
let client = signal();
for (to_phone, body) in [
("0987654321", "failure\nwith details"),
("+15555550102", "another failure"),
] {
let server = test_connection();
let task = thread::spawn(move || {
let mut reader = BufReader::new(server);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let request: Value = serde_json::from_str(&line).unwrap();
assert_eq!(
request,
json!({
"jsonrpc": "2.0", "method": "send", "id": "signal-alert",
"params": { "account": "1234567890", "recipient": [to_phone], "message": body },
})
);
reader
.get_mut()
.write_all(b"{\"method\":\"receive\"}\n{\"id\":\"other\"}\n{\"id\":\"signal-")
.unwrap();
reader
.get_mut()
.write_all(b"alert\",\"result\":{}}\n")
.unwrap();
});
client.send(to_phone, body).unwrap();
task.join().unwrap();
}
}
#[test]
fn rejects_rpc_errors_invalid_json_and_premature_close() {
for (reply, expected) in [
(
"{\"id\":\"signal-alert\",\"error\":{\"code\":-1}}\n",
"Signal RPC returned an error",
),
("invalid\n", "Invalid Signal RPC response"),
("", "socket closed"),
("{\"id\":\"signal-alert\"}", "socket closed"),
] {
let server = test_connection();
let task = thread::spawn(move || {
let mut reader = BufReader::new(server);
reader.read_line(&mut String::new()).unwrap();
reader.get_mut().write_all(reply.as_bytes()).unwrap();
});
let error = signal().send("0987654321", "failure").unwrap_err();
assert!(format!("{error:#}").contains(expected), "{error:#}");
task.join().unwrap();
}
}
#[test]
fn reports_connection_failure() {
let error = signal().send("0987654321", "failure").unwrap_err();
assert_eq!(error.to_string(), "Failed to connect to Signal RPC socket");
assert_eq!(
error.downcast_ref::<io::Error>().unwrap().kind(),
io::ErrorKind::ConnectionRefused
);
}
#[test]
fn unresponsive_server_times_out() {
let _server = test_connection();
let start = Instant::now();
let error = signal().send("0987654321", "failure").unwrap_err();
assert_eq!(error.to_string(), "Failed to read Signal RPC response");
assert!(matches!(
error.downcast_ref::<io::Error>().unwrap().kind(),
io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
));
assert!(start.elapsed() >= Duration::from_secs(4));
}
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "snapshot-manager"
version = "0.1.0"
edition = "2024"
description = "Create and rotate ZFS snapshots with Signal failure alerts"
license = "MIT"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap = { workspace = true, features = ["derive", "env"] }
env_logger.workspace = true
log.workspace = true
serde = { workspace = true, features = ["derive"] }
signal.workspace = true
toml = "0.9"
zfs.workspace = true
[dev-dependencies]
tempfile = "3"
[lints]
workspace = true
-25
View File
@@ -1,25 +0,0 @@
{ lib, rustPlatform }:
rustPlatform.buildRustPackage {
pname = "snapshot-manager";
version = "0.1.0";
src = import ../rust-source.nix { inherit lib; };
cargoLock.lockFile = ../../Cargo.lock;
cargoBuildFlags = [
"-p"
"snapshot-manager"
];
cargoTestFlags = [
"-p"
"snapshot-manager"
"-p"
"signal"
"-p"
"zfs"
];
meta = {
description = "ZFS snapshot manager with Signal failure alerts";
mainProgram = "snapshot-manager";
};
}
-383
View File
@@ -1,383 +0,0 @@
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
process::ExitCode,
};
use anyhow::{Context, Result};
use chrono::{DateTime, Datelike, NaiveDate, NaiveTime, Timelike, Utc};
use clap::Parser;
use log::{error, info};
use serde::Deserialize;
use signal::Signal;
#[derive(Parser)]
#[command(version, about)]
struct Args {
/// TOML file containing default and per-dataset snapshot retention counts.
config_file: PathBuf,
/// Send Signal failure alerts (requires both phone numbers).
#[arg(long, env = "SIGNAL_ALERTS", default_value_t = false, action = clap::ArgAction::Set)]
signal_alerts: bool,
/// Signal account sending failure alerts.
#[arg(
long,
env = "SIGNAL_ALERT_FROM_PHONE",
hide_env_values = true,
required_if_eq("signal_alerts", "true"),
value_parser = clap::builder::NonEmptyStringValueParser::new()
)]
signal_alert_from_phone: Option<String>,
/// Signal recipient for failure alerts.
#[arg(
long,
env = "SIGNAL_ALERT_TO_PHONE",
hide_env_values = true,
required_if_eq("signal_alerts", "true"),
value_parser = clap::builder::NonEmptyStringValueParser::new()
)]
signal_alert_to_phone: Option<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct Retention {
#[serde(rename = "15_min")]
quarter_hourly: usize,
hourly: usize,
daily: usize,
monthly: usize,
}
impl Default for Retention {
fn default() -> Self {
Self {
quarter_hourly: 4,
hourly: 12,
daily: 0,
monthly: 0,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(transparent)]
struct Config(HashMap<String, Retention>);
impl Config {
fn load(path: &Path) -> Result<Self> {
let contents = fs::read_to_string(path)
.with_context(|| format!("Failed to read configuration {}", path.display()))?;
toml::from_str(&contents)
.with_context(|| format!("Invalid configuration {}", path.display()))
}
fn retention_for(&self, dataset: &str) -> Retention {
self.0
.get(dataset)
.or_else(|| self.0.get("default"))
.copied()
.unwrap_or_default()
}
}
fn main() -> ExitCode {
let args = Args::parse();
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
info!("Starting snapshot_manager");
let signal = if args.signal_alerts {
args.signal_alert_from_phone
.map(|from_phone| Signal { from_phone })
} else {
None
};
let alerts = signal.as_ref().zip(args.signal_alert_to_phone.as_deref());
match run(&args.config_file, alerts) {
Ok(()) => {
info!("snapshot_manager completed");
ExitCode::SUCCESS
}
Err(error) => {
error!("{error:#}");
ExitCode::FAILURE
}
}
}
fn run(config_file: &Path, alerts: Option<(&Signal, &str)>) -> Result<()> {
let result = (|| {
// Validate all retention settings before making any changes to ZFS.
let config = Config::load(config_file)?;
let snapshot_time = timestamp(Utc::now());
for dataset_name in zfs::list_datasets()? {
if let Err(error) = zfs::create_snapshot(&dataset_name, &snapshot_time) {
let message = format!("{dataset_name} failed to create snapshot {snapshot_time}");
error!("{message}: {error:#}");
send_alert(alerts, &message)?;
continue;
}
remove_old_snapshots(&config, alerts, &dataset_name)
.context("Failed to remove old snapshots")?;
}
Ok(())
})();
if let Err(error) = &result {
error!("snapshot_manager failed: {error:#}");
if let Err(alert_error) = send_alert(alerts, "snapshot_manager failed") {
error!("Failed to send failure alert: {alert_error:#}");
}
}
result
}
fn timestamp(now: DateTime<Utc>) -> String {
let minute = now.minute().saturating_sub(now.minute() % 15);
format!("auto_{}{minute:02}", now.format("%Y%m%d%H"))
}
fn remove_old_snapshots(
config: &Config,
alerts: Option<(&Signal, &str)>,
dataset_name: &str,
) -> Result<()> {
let retention = config.retention_for(dataset_name);
info!("Using {retention:?} for {dataset_name}");
let snapshots = zfs::list_snapshots(dataset_name)?;
for snapshot in snapshots_to_delete(&snapshots, retention) {
info!("Deleting {dataset_name}@{snapshot}");
if let Some(error) = zfs::delete_snapshot(dataset_name, snapshot)? {
let message = format!("{dataset_name}@{snapshot} failed to delete: {error}");
error!("{message}");
send_alert(alerts, &message)?;
}
}
Ok(())
}
fn snapshots_to_delete(snapshots: &[String], retention: Retention) -> Vec<&str> {
let mut tiers: [Vec<&str>; 4] = Default::default();
for snapshot in snapshots {
if let Some(index) = bucket(snapshot)
&& let Some(tier) = tiers.get_mut(index)
{
tier.push(snapshot);
}
}
let counts = [
retention.quarter_hourly,
retention.hourly,
retention.daily,
retention.monthly,
];
let mut deletions = Vec::new();
for (mut tier, count) in tiers.into_iter().zip(counts) {
tier.sort_unstable();
let delete_count = tier.len().saturating_sub(count);
deletions.extend(tier.into_iter().take(delete_count));
}
deletions
}
fn bucket(name: &str) -> Option<usize> {
let digits = name.strip_prefix("auto_")?;
if digits.len() != 12 || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
let date = NaiveDate::from_ymd_opt(
digits.get(..4)?.parse().ok()?,
digits.get(4..6)?.parse().ok()?,
digits.get(6..8)?.parse().ok()?,
)?;
let time = NaiveTime::from_hms_opt(
digits.get(8..10)?.parse().ok()?,
digits.get(10..)?.parse().ok()?,
0,
)?;
// The tiers are disjoint: midnight belongs to daily, except on the first
// of the month, when it belongs to monthly.
match (time.minute(), time.hour(), date.day()) {
(15 | 30 | 45, _, _) => Some(0),
(0, 1..=23, _) => Some(1),
(0, 0, 2..=31) => Some(2),
(0, 0, 1) => Some(3),
_ => None,
}
}
fn send_alert(alerts: Option<(&Signal, &str)>, body: &str) -> Result<()> {
if let Some((signal, to_phone)) = alerts {
signal.send(to_phone, body)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use chrono::TimeZone;
use super::*;
#[test]
fn dataset_overrides_default_and_other_datasets_fall_back() {
let config: Config = toml::from_str(
r#"
[default]
15_min = 8
hourly = 24
daily = 0
monthly = 0
["pool/home"]
15_min = 2
hourly = 3
daily = 4
monthly = 5
"#,
)
.unwrap();
assert_eq!(
config.retention_for("pool/home"),
Retention {
quarter_hourly: 2,
hourly: 3,
daily: 4,
monthly: 5,
}
);
assert_eq!(config.retention_for("pool/other").hourly, 24);
assert_eq!(config.retention_for("pool/home/child").quarter_hourly, 8);
}
#[test]
fn absent_default_uses_original_builtin_counts() {
let config: Config = toml::from_str("").unwrap();
assert_eq!(config.retention_for("pool/home"), Retention::default());
}
#[test]
fn rejects_incomplete_negative_and_unknown_retention_settings() {
for settings in [
"15_min = 1\nhourly = 2\ndaily = 3",
"15_min = -1\nhourly = 2\ndaily = 3\nmonthly = 4",
"15_min = 1\nhourly = 2\ndaily = 3\nmonthly = 4\nweekly = 5",
"15_min = 1.5\nhourly = 2\ndaily = 3\nmonthly = 4",
] {
assert!(toml::from_str::<Config>(&format!("[default]\n{settings}")).is_err());
}
}
#[test]
fn timestamps_round_down_at_quarter_hour_boundaries() {
for (minute, expected) in [
(0, "00"),
(14, "00"),
(15, "15"),
(29, "15"),
(30, "30"),
(44, "30"),
(45, "45"),
(59, "45"),
] {
let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, minute, 59).unwrap();
assert_eq!(timestamp(now), format!("auto_2026010100{expected}"));
}
}
#[test]
fn tiers_are_disjoint_at_day_and_month_boundaries() {
for (name, expected) in [
("auto_202602010015", 0),
("auto_202602010030", 0),
("auto_202602010045", 0),
("auto_202602010100", 1),
("auto_202602012300", 1),
("auto_202602020000", 2),
("auto_202402290000", 2),
("auto_202602010000", 3),
("auto_202601010000", 3),
] {
assert_eq!(bucket(name), Some(expected), "{name}");
}
}
#[test]
fn only_exact_valid_manager_names_are_eligible() {
for name in [
"manual",
"manual_auto_202602010015",
"auto_202602010015_backup",
"auto_20260201001",
"auto_202602300000",
"auto_202602012400",
"auto_202602010060",
"auto_202602010005",
"auto_202602010015",
] {
assert_eq!(bucket(name), None, "{name}");
}
}
#[test]
fn keeps_newest_in_each_tier_regardless_of_input_order() {
let snapshots = [
"auto_202601010000",
"auto_202602020000",
"auto_202602020115",
"auto_202602020215",
"auto_202602020200",
"auto_202602020100",
"auto_202602030000",
"auto_202602010000",
"manual",
]
.map(String::from);
let retention = Retention {
quarter_hourly: 1,
hourly: 1,
daily: 1,
monthly: 1,
};
assert_eq!(
snapshots_to_delete(&snapshots, retention),
[
"auto_202602020115",
"auto_202602020100",
"auto_202602020000",
"auto_202601010000",
]
);
}
#[test]
fn zero_counts_delete_all_managed_snapshots_and_large_counts_keep_them() {
let snapshots = [
"auto_202601010015",
"auto_202601010100",
"auto_202601020000",
"auto_202601010000",
"manual",
]
.map(String::from);
let none = Retention {
quarter_hourly: 0,
hourly: 0,
daily: 0,
monthly: 0,
};
assert_eq!(snapshots_to_delete(&snapshots, none).len(), 4);
let all = Retention {
quarter_hourly: 100,
hourly: 100,
daily: 100,
monthly: 100,
};
assert!(snapshots_to_delete(&snapshots, all).is_empty());
assert!(snapshots_to_delete(&[], none).is_empty());
}
}
-202
View File
@@ -1,202 +0,0 @@
#![cfg(test)]
use std::{fs, os::unix::fs::symlink, path::Path, process::Command};
use tempfile::tempdir;
fn command() -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_snapshot-manager"));
command
.env_remove("SIGNAL_ALERTS")
.env_remove("SIGNAL_ALERT_FROM_PHONE")
.env_remove("SIGNAL_ALERT_TO_PHONE")
.env_remove("SNAPSHOT_TEST_SCRIPT")
.env_remove("RUST_LOG");
command
}
#[test]
fn help_works_without_phone_numbers() {
let output = command().arg("--help").output().unwrap();
assert!(output.status.success());
assert!(
String::from_utf8(output.stdout)
.unwrap()
.contains("<CONFIG_FILE>")
);
}
#[test]
fn help_documents_environment_variables_without_exposing_phone_numbers() {
let output = command()
.env("SIGNAL_ALERT_FROM_PHONE", "+15555550101")
.env("SIGNAL_ALERT_TO_PHONE", "+15555550102")
.arg("--help")
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
for name in [
"SIGNAL_ALERTS",
"SIGNAL_ALERT_FROM_PHONE",
"SIGNAL_ALERT_TO_PHONE",
] {
assert!(stdout.contains(name), "{stdout}");
}
assert!(!stdout.contains("+15555550101"), "{stdout}");
assert!(!stdout.contains("+15555550102"), "{stdout}");
}
#[test]
fn config_argument_is_required() {
let output = command().output().unwrap();
assert_eq!(output.status.code(), Some(2));
}
#[test]
fn enabled_alerts_require_phone_numbers_before_running() {
for use_env in [false, true] {
for (from, to, expected) in [
(None, None, "--signal-alert-from-phone"),
(Some(""), None, "--signal-alert-from-phone"),
(Some("+15555550101"), None, "--signal-alert-to-phone"),
(Some("+15555550101"), Some(""), "--signal-alert-to-phone"),
(None, Some("+15555550102"), "--signal-alert-from-phone"),
] {
let mut command = command();
if use_env {
command.env("SIGNAL_ALERTS", "true");
} else {
command.args(["--signal-alerts", "true"]);
}
if let Some(from) = from {
command.env("SIGNAL_ALERT_FROM_PHONE", from);
}
if let Some(to) = to {
command.env("SIGNAL_ALERT_TO_PHONE", to);
}
let output = command.arg("unused.toml").output().unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
assert_eq!(output.status.code(), Some(2), "{stderr}");
assert!(stderr.contains(expected), "{stderr}");
assert!(!stderr.contains("Starting snapshot_manager"), "{stderr}");
}
}
}
#[test]
fn enabled_alerts_accept_environment_variables_and_cli_overrides() {
let dir = tempdir().unwrap();
let config = dir.path().join("config.toml");
let scenario = dir.path().join("scenario");
fs::write(&config, "").unwrap();
// An empty dataset list completes successfully without sending an alert.
fs::write(&scenario, "").unwrap();
symlink(
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/zfs"),
dir.path().join("zfs"),
)
.unwrap();
for use_cli in [false, true] {
let mut command = command();
if use_cli {
command
.env("SIGNAL_ALERTS", "invalid")
.env("SIGNAL_ALERT_FROM_PHONE", "")
.env("SIGNAL_ALERT_TO_PHONE", "")
.args([
"--signal-alerts",
"true",
"--signal-alert-from-phone",
"+15555550101",
"--signal-alert-to-phone",
"+15555550102",
]);
} else {
command
.env("SIGNAL_ALERTS", "true")
.env("SIGNAL_ALERT_FROM_PHONE", "+15555550101")
.env("SIGNAL_ALERT_TO_PHONE", "+15555550102");
}
let output = command
.current_dir(dir.path())
.env("PATH", dir.path())
.env("SNAPSHOT_TEST_SCRIPT", &scenario)
.arg(&config)
.output()
.unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(output.status.success(), "{stderr}");
assert!(stderr.contains("snapshot_manager completed"), "{stderr}");
}
}
#[test]
fn disabled_alerts_allow_snapshot_failures_without_phone_numbers() {
let dir = tempdir().unwrap();
let config = dir.path().join("config.toml");
fs::write(&config, "").unwrap();
symlink(
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/zfs"),
dir.path().join("zfs"),
)
.unwrap();
for (env_value, args) in [
(None, vec![]),
(Some("false"), vec![]),
(None, vec!["--signal-alerts", "false"]),
(Some("true"), vec!["--signal-alerts", "false"]),
] {
let mut command = command();
if let Some(value) = env_value {
command.env("SIGNAL_ALERTS", value);
}
let output = command
.env("PATH", dir.path())
.args(args)
.arg(&config)
.output()
.unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(output.status.success(), "{stderr}");
assert!(
stderr.contains("pool/home failed to create snapshot"),
"{stderr}"
);
assert!(stderr.contains("snapshot_manager completed"), "{stderr}");
assert!(!stderr.contains("Signal"), "{stderr}");
}
}
#[test]
fn disabled_alerts_preserve_fatal_errors_without_phone_numbers() {
let dir = tempdir().unwrap();
for args in [vec![], vec!["--signal-alerts", "false"]] {
let output = command()
.args(args)
.arg(dir.path().join("missing.toml"))
.output()
.unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
assert_eq!(output.status.code(), Some(1), "{stderr}");
assert!(stderr.contains("Failed to read configuration"), "{stderr}");
assert!(!stderr.contains("SIGNAL_ALERT_"), "{stderr}");
assert!(!stderr.contains("Failed to send failure alert"), "{stderr}");
}
}
#[test]
fn signal_alerts_requires_a_boolean_value() {
for use_env in [false, true] {
let mut command = command();
if use_env {
command.env("SIGNAL_ALERTS", "invalid");
} else {
command.args(["--signal-alerts", "invalid"]);
}
let output = command.arg("unused.toml").output().unwrap();
assert_eq!(output.status.code(), Some(2));
}
}
-12
View File
@@ -1,12 +0,0 @@
#!/bin/sh
if [ -n "${SNAPSHOT_TEST_SCRIPT:-}" ]; then
printf '%s\n' "$*" >> calls
. "$SNAPSHOT_TEST_SCRIPT"
exit
fi
case "$*" in
'list -Hp -t filesystem -o name') printf '%s\n' pool/home ;;
'snapshot pool/home@'*) printf '%s\n' 'permission denied' >&2; exit 1 ;;
*) exit 99 ;;
esac
-290
View File
@@ -1,290 +0,0 @@
#![cfg(test)]
use std::{
fs,
os::unix::fs::symlink,
path::{Path, PathBuf},
process::{Command, Output},
};
use tempfile::{TempDir, tempdir};
struct Fixture {
dir: TempDir,
config: PathBuf,
}
impl Fixture {
fn new(script: &str) -> Self {
let dir = tempdir().unwrap();
// Only the child process sees this PATH. The real zfs cannot be found.
symlink(
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/zfs"),
dir.path().join("zfs"),
)
.unwrap();
// The executable is a checked-in fixture; scenario files are read by
// its shell, so parallel tests never execute a file being written.
fs::write(dir.path().join("scenario"), script).unwrap();
let config = dir.path().join("config.toml");
fs::write(
&config,
"[default]\n15_min = 1\nhourly = 0\ndaily = 0\nmonthly = 0\n",
)
.unwrap();
Self { dir, config }
}
fn calls(&self) -> Vec<String> {
fs::read_to_string(self.dir.path().join("calls"))
.unwrap_or_default()
.lines()
.map(String::from)
.collect()
}
fn normalized_calls(&self) -> Vec<String> {
self.calls()
.into_iter()
.map(|call| {
if call.starts_with("snapshot ") {
let (dataset, name) = call.split_once('@').unwrap();
assert!(name.starts_with("auto_"), "{call}");
format!("{dataset}@<timestamp>")
} else {
call
}
})
.collect()
}
fn run(&self) -> Output {
Command::new(env!("CARGO_BIN_EXE_snapshot-manager"))
.current_dir(self.dir.path())
.env("PATH", self.dir.path())
.env("SNAPSHOT_TEST_SCRIPT", self.dir.path().join("scenario"))
.env_remove("SIGNAL_ALERTS")
.env_remove("SIGNAL_ALERT_FROM_PHONE")
.env_remove("SIGNAL_ALERT_TO_PHONE")
.env_remove("RUST_LOG")
.arg(&self.config)
.output()
.unwrap()
}
}
fn stderr(output: &Output) -> &str {
std::str::from_utf8(&output.stderr).unwrap()
}
#[test]
fn creates_then_prunes_only_this_datasets_managed_snapshots() {
let fixture = Fixture::new(
r#"
case "$*" in
'list -Hp -t filesystem -o name')
printf '%s\n' pool pool/home
printf '%s\n' 'warning on stderr' >&2 ;;
'snapshot pool/home@auto_'*) ;;
'list -Hp -t snapshot -o name -d 1 pool/home')
printf '%s\n' pool/home@auto_200002020145 pool/home@auto_200002020115 pool/home@manual pool/home@manual_auto_200001010015 pool/home/child@auto_200001010015 pool/other@auto_200001010015 ;;
'destroy pool/home@auto_200002020115') ;;
*) exit 99 ;;
esac
"#,
);
let output = fixture.run();
assert!(output.status.success(), "{}", stderr(&output));
assert_eq!(
fixture.normalized_calls(),
[
"list -Hp -t filesystem -o name",
"snapshot pool/home@<timestamp>",
"list -Hp -t snapshot -o name -d 1 pool/home",
"destroy pool/home@auto_200002020115",
]
);
}
#[test]
fn failed_or_duplicate_creation_skips_pruning_but_continues() {
for error in ["snapshot already exists", "permission denied"] {
let fixture = Fixture::new(&format!(
r#"
case "$*" in
'list -Hp -t filesystem -o name') printf '%s\n' pool/home pool/other ;;
'snapshot pool/home@auto_'*) printf '%s\n' '{error}' >&2; exit 1 ;;
'snapshot pool/other@auto_'*) ;;
'list -Hp -t snapshot -o name -d 1 pool/other') ;;
*) exit 99 ;;
esac
"#
));
let output = fixture.run();
assert!(output.status.success(), "{}", stderr(&output));
assert!(stderr(&output).contains("pool/home failed to create snapshot auto_"));
assert!(stderr(&output).contains(error));
assert_eq!(
fixture.normalized_calls(),
[
"list -Hp -t filesystem -o name",
"snapshot pool/home@<timestamp>",
"snapshot pool/other@<timestamp>",
"list -Hp -t snapshot -o name -d 1 pool/other",
]
);
let calls = fixture.calls();
assert_eq!(
calls[1].split_once('@').unwrap().1,
calls[2].split_once('@').unwrap().1
);
}
}
#[test]
fn dependent_clone_failure_is_reported_and_remaining_deletions_continue() {
let fixture = Fixture::new(
r#"
case "$*" in
'list -Hp -t filesystem -o name') printf '%s\n' pool/home ;;
'snapshot pool/home@auto_'*) ;;
'list -Hp -t snapshot -o name -d 1 pool/home')
printf '%s\n' pool/home@auto_200002020115 pool/home@auto_200002020130 pool/home@auto_200002020145 ;;
'destroy pool/home@auto_200002020115')
printf '%s\n' "cannot destroy 'pool/home@auto_200002020115': snapshot has dependent clones" >&2; exit 1 ;;
'destroy pool/home@auto_200002020130') ;;
*) exit 99 ;;
esac
"#,
);
let output = fixture.run();
assert!(output.status.success(), "{}", stderr(&output));
assert!(
stderr(&output).contains(
"pool/home@auto_200002020115 failed to delete: snapshot has dependent clones"
)
);
assert_eq!(
fixture.calls().last().unwrap(),
"destroy pool/home@auto_200002020130"
);
}
#[test]
fn unexpected_destroy_failure_aborts_and_reports_the_error() {
let fixture = Fixture::new(
r#"
case "$1" in
list)
if [ "$4" = filesystem ]; then
printf '%s\n' pool/home
else
printf '%s\n' pool/home@auto_200002020115 pool/home@auto_200002020130 pool/home@auto_200002020145
fi ;;
snapshot) ;;
destroy) printf '%s\n' 'permission denied' >&2; exit 1 ;;
esac
"#,
);
let output = fixture.run();
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(stderr(&output).contains("permission denied"));
assert!(stderr(&output).contains("snapshot_manager failed"));
assert_eq!(
fixture.calls().last().unwrap(),
"destroy pool/home@auto_200002020115"
);
}
#[test]
fn failed_list_never_uses_partial_output() {
for stream in ["", " >&2"] {
let fixture = Fixture::new(&format!(
"printf '%s\\n' pool/home\nprintf '%s\\n' 'cannot list datasets'{stream}\nexit 1"
));
let output = fixture.run();
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(stderr(&output).contains("cannot list datasets"));
assert!(stderr(&output).contains("snapshot_manager failed"));
assert_eq!(fixture.calls(), ["list -Hp -t filesystem -o name"]);
}
}
#[test]
fn failed_snapshot_listing_aborts_without_destroying_anything() {
let fixture = Fixture::new(
r#"
case "$*" in
'list -Hp -t filesystem -o name') printf '%s\n' pool/home ;;
'snapshot pool/home@auto_'*) ;;
*) printf '%s\n' pool/home@auto_200002020115; exit 1 ;;
esac
"#,
);
let output = fixture.run();
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(stderr(&output).contains("snapshot_manager failed"));
assert_eq!(fixture.calls().len(), 3);
}
#[test]
fn invalid_config_stops_before_any_zfs_commands() {
let fixture = Fixture::new("exit 99");
fs::write(
&fixture.config,
"[default]\n15_min = -1\nhourly = 1\ndaily = 0\nmonthly = 0",
)
.unwrap();
let output = fixture.run();
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(stderr(&output).contains("Invalid configuration"));
assert!(fixture.calls().is_empty());
}
#[test]
fn missing_zfs_executable_fails_and_reports_the_error() {
let fixture = Fixture::new("exit 99");
fs::remove_file(fixture.dir.path().join("zfs")).unwrap();
let output = fixture.run();
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(stderr(&output).contains("Failed to execute zfs"));
assert!(stderr(&output).contains("snapshot_manager failed"));
assert!(fixture.calls().is_empty());
}
#[test]
fn dataset_names_are_passed_as_one_argument_without_a_shell() {
let fixture = Fixture::new(
r#"
case "$1" in
list)
[ "$*" = 'list -Hp -t filesystem -o name' ] || exit 98
printf '%s\n' 'pool/two words;touch injected' ;;
snapshot)
[ "$#" -eq 2 ] || exit 97
case "$2" in
'pool/two words;touch injected@auto_'*) printf '%s\n' 'argument preserved' >&2; exit 1 ;;
*) exit 96 ;;
esac ;;
*) exit 99 ;;
esac
"#,
);
let output = fixture.run();
assert!(output.status.success(), "{}", stderr(&output));
assert!(stderr(&output).contains("argument preserved"));
assert_eq!(
fixture.normalized_calls(),
[
"list -Hp -t filesystem -o name",
"snapshot pool/two words;touch injected@<timestamp>"
]
);
assert!(fs::read_dir(fixture.dir.path()).unwrap().all(|entry| {
!entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with("injected")
}));
}
+11 -10
View File
@@ -6,15 +6,16 @@ description = "Fetch privacy-masked weather for a van and publish it to Home Ass
license = "MIT"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap = { workspace = true, features = ["derive", "env"] }
env_logger.workspace = true
log.workspace = true
num-traits = "0.2"
anyhow = "1.0"
chrono = "0.4"
clap = { version = "4.5", features = ["derive", "env"] }
env_logger = "0.11"
log = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[lints.clippy]
all = "deny"
pedantic = "deny"
[lints]
workspace = true
+3 -11
View File
@@ -1,18 +1,10 @@
{ lib, rustPlatform }:
{ rustPlatform }:
rustPlatform.buildRustPackage {
pname = "van-weather";
version = "0.1.0";
src = import ../rust-source.nix { inherit lib; };
src = ./.;
cargoLock.lockFile = ../../Cargo.lock;
cargoBuildFlags = [
"-p"
"van-weather"
];
cargoTestFlags = [
"-p"
"van-weather"
];
cargoLock.lockFile = ./Cargo.lock;
meta = {
description = "Privacy-masked van weather publisher for Home Assistant";
+9 -34
View File
@@ -4,7 +4,6 @@ use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use clap::Parser;
use log::{error, info};
use num_traits::ToPrimitive;
use reqwest::{
StatusCode, Url,
blocking::{Client, ClientBuilder},
@@ -26,7 +25,7 @@ const fn decimal_factor(decimals: u32) -> f64 {
let mut remaining = decimals;
while remaining > 0 {
factor *= 10.0;
remaining = remaining.saturating_sub(1);
remaining -= 1;
}
factor
}
@@ -469,25 +468,18 @@ fn weather_updates(weather: &Weather) -> Vec<(String, Value)> {
}
fn sensor(entity_id: &str, state: Option<Value>, attributes: Value) -> Option<(String, Value)> {
let state = state?;
let mut payload = serde_json::Map::new();
payload.insert("state".to_owned(), state);
payload.insert("attributes".to_owned(), attributes);
Some((entity_id.to_owned(), Value::Object(payload)))
state.map(|state| {
let mut payload = serde_json::Map::new();
payload.insert("state".to_owned(), state);
payload.insert("attributes".to_owned(), attributes);
(entity_id.to_owned(), Value::Object(payload))
})
}
#[allow(clippy::cast_possible_truncation)]
fn percent(value: Option<f64>) -> i64 {
// Preserve Python's int(probability * 100) behavior for Home Assistant.
let value = value.unwrap_or_default() * 100.0;
value.to_i64().unwrap_or_else(|| {
if value.is_nan() {
0
} else if value.is_sign_negative() {
i64::MIN
} else {
i64::MAX
}
})
(value.unwrap_or_default() * 100.0) as i64
}
#[cfg(test)]
@@ -570,21 +562,4 @@ mod tests {
assert!((mask_coordinate(37.7749) - 37.8).abs() < f64::EPSILON);
assert!((mask_coordinate(-122.4194) - (-122.4)).abs() < f64::EPSILON);
}
#[test]
fn percentages_truncate_and_preserve_saturation_for_extreme_values() {
for (value, expected) in [
(None, 0),
(Some(0.129), 12),
(Some(-0.129), -12),
(Some(1.0), 100),
(Some(f64::NAN), 0),
(Some(f64::MAX), i64::MAX),
(Some(f64::MIN), i64::MIN),
(Some(f64::INFINITY), i64::MAX),
(Some(f64::NEG_INFINITY), i64::MIN),
] {
assert_eq!(percent(value), expected);
}
}
}
-13
View File
@@ -1,13 +0,0 @@
[package]
name = "zfs"
version = "0.1.0"
edition = "2024"
description = "List ZFS datasets and manage snapshots through the zfs executable"
license = "MIT"
publish = false
[dependencies]
anyhow.workspace = true
[lints]
workspace = true
-100
View File
@@ -1,100 +0,0 @@
//! Manage ZFS datasets and snapshots using the `zfs` executable on `PATH`.
//!
//! ```no_run
//! for snapshot in zfs::list_snapshots("pool/home")? {
//! println!("{snapshot}");
//! }
//! # Ok::<(), anyhow::Error>(())
//! ```
use std::process::Command;
use anyhow::{Context, Result, bail};
const ZFS_EXECUTABLE: &str = "zfs";
fn zfs_command(args: &[&str]) -> Result<String> {
let output = Command::new(ZFS_EXECUTABLE)
.args(args)
.env("LC_ALL", "C")
.output()
.context("Failed to execute zfs")?;
if !output.status.success() {
let message = if output.stderr.is_empty() {
&output.stdout
} else {
&output.stderr
};
bail!(
"zfs {} failed ({}): {}",
args.join(" "),
output.status,
String::from_utf8_lossy(message).trim()
);
}
String::from_utf8(output.stdout).context("zfs returned invalid UTF-8")
}
/// List filesystem datasets, excluding pool roots.
///
/// # Errors
///
/// Returns an error if executing ZFS or decoding its output fails.
pub fn list_datasets() -> Result<Vec<String>> {
let output = zfs_command(&["list", "-Hp", "-t", "filesystem", "-o", "name"])?;
Ok(output
.lines()
.filter(|name| name.contains('/'))
.map(String::from)
.collect())
}
/// List snapshot names belonging directly to `dataset`, excluding child datasets.
///
/// Returned names omit the `dataset@` prefix.
///
/// # Errors
///
/// Returns an error if executing ZFS or decoding its output fails.
pub fn list_snapshots(dataset: &str) -> Result<Vec<String>> {
let output = zfs_command(&[
"list", "-Hp", "-t", "snapshot", "-o", "name", "-d", "1", dataset,
])?;
Ok(output
.lines()
.filter_map(|name| {
let (owner, snapshot) = name.split_once('@')?;
(owner == dataset).then(|| snapshot.to_owned())
})
.collect())
}
/// Create `dataset@snapshot`.
///
/// # Errors
///
/// Returns an error if executing ZFS or decoding its output fails.
pub fn create_snapshot(dataset: &str, snapshot: &str) -> Result<()> {
zfs_command(&["snapshot", &format!("{dataset}@{snapshot}")])?;
Ok(())
}
/// Delete `dataset@snapshot`.
///
/// Returns `None` on success, or `Some("snapshot has dependent clones")` when
/// dependent clones prevent deletion.
///
/// # Errors
///
/// Returns an error for other ZFS failures, execution failures, or invalid UTF-8 output.
pub fn delete_snapshot(dataset: &str, snapshot: &str) -> Result<Option<&'static str>> {
if let Err(error) = zfs_command(&["destroy", &format!("{dataset}@{snapshot}")]) {
if error.to_string().contains(&format!(
"cannot destroy '{dataset}@{snapshot}': snapshot has dependent clones"
)) {
return Ok(Some("snapshot has dependent clones"));
}
return Err(error);
}
Ok(None)
}
-37
View File
@@ -1,37 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ "$(hostname)" != "jeeves" ]]; then
echo "Signal device authorization must run on jeeves." >&2
exit 1
fi
if [[ "$EUID" -ne 0 ]]; then
echo "Signal device authorization must run as root." >&2
exit 1
fi
if [[ "$#" -ne 1 ]]; then
echo "Usage: signal_authorize.sh DEVICE_URI" >&2
exit 2
fi
if [[ "$1" != sgnl://linkdevice\?* ]]; then
echo "Invalid Signal device link URI." >&2
exit 2
fi
service_name="signal-cli"
data_dir="/var/lib/signal-cli"
device_uri="$1"
restart_service=false
cleanup() {
if [[ "$restart_service" == true ]]; then
systemctl start "$service_name"
fi
}
trap cleanup EXIT
restart_service=true
systemctl stop "$service_name"
runuser -u signal-cli -- \
signal-cli --data-dir "$data_dir" -a +12016554378 addDevice --uri "$device_uri"
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
host_name="$(hostname)"
if [[ "$#" -ne 0 ]]; then
echo "Usage: signal_link.sh" >&2
exit 2
fi
service_name="signal-cli"
data_dir="/var/lib/signal-cli"
link_pid=""
temp_dir=""
restart_service=false
cleanup() {
if [[ -n "$link_pid" ]] && kill -0 "$link_pid" 2>/dev/null; then
kill "$link_pid" 2>/dev/null || true
wait "$link_pid" 2>/dev/null || true
fi
if [[ -n "$temp_dir" ]]; then
rm -f -- "$temp_dir/link-uri"
rmdir -- "$temp_dir"
fi
if [[ "$restart_service" == true ]]; then
sudo systemctl start "$service_name"
fi
}
trap cleanup EXIT
restart_service=true
sudo systemctl stop "$service_name"
temp_dir="$(mktemp -d)"
link_fifo="$temp_dir/link-uri"
mkfifo "$link_fifo"
sudo -u signal-cli \
signal-cli --data-dir "$data_dir" link --name "$host_name" >"$link_fifo" &
link_pid="$!"
if ! IFS= read -r device_uri <"$link_fifo"; then
wait "$link_pid"
exit 1
fi
if [[ "$device_uri" != sgnl://linkdevice\?* ]]; then
echo "signal-cli returned an invalid device link URI." >&2
exit 1
fi
echo "Run this command manually on jeeves in another dotfiles dir:"
printf "sudo ./scripts/signal/authorize.sh %q\n" "$device_uri"
echo "Waiting for jeeves to authorize this device..."
wait "$link_pid"
link_pid=""
echo "$host_name is now linked to the Signal account on jeeves."
+2 -1
View File
@@ -8,7 +8,7 @@
# loader path, which NixOS does not provide globally.
ebook-search = pkgs.mkShell {
nativeBuildInputs = with pkgs; [
python314
my_python
uv
];
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
@@ -23,6 +23,7 @@
nix
home-manager
git
my_python
ssh-to-age
gnupg
+6 -6
View File
@@ -3,18 +3,18 @@
imports = [
"${inputs.self}/users/math"
"${inputs.self}/users/richie"
"${inputs.self}/users/steve"
"${inputs.self}/common/global"
"${inputs.self}/common/optional/docker.nix"
"${inputs.self}/common/optional/monitoring-agent.nix"
"${inputs.self}/common/optional/nvidia.nix"
"${inputs.self}/common/optional/scanner.nix"
"${inputs.self}/common/optional/signal-cli.nix"
"${inputs.self}/common/optional/monitoring-agent.nix"
"${inputs.self}/common/optional/steam.nix"
"${inputs.self}/common/optional/syncthing_base.nix"
"${inputs.self}/common/optional/systemd-boot.nix"
"${inputs.self}/common/optional/tailscale.nix"
"${inputs.self}/common/optional/update.nix"
"${inputs.self}/common/optional/x86-64-v3"
"${inputs.self}/common/optional/zfs"
"${inputs.self}/common/optional/yubikey.nix"
"${inputs.self}/common/optional/zerotier.nix"
"${inputs.self}/common/optional/nvidia.nix"
./hardware.nix
./syncthing.nix
./llms.nix
-5
View File
@@ -50,11 +50,6 @@
fsType = "zfs";
};
"/nix/var/nix/builds" = {
device = "root_pool/nix_build";
fsType = "zfs";
};
"/var" = {
device = "root_pool/var";
fsType = "zfs";
-25
View File
@@ -1,25 +0,0 @@
system_token: ENC[AES256_GCM,data:zpyosU2oJTljDBbjeQs79gpzPtfL6Y41nZ3X1d+IgdI1fczx/F6MUOaBN6xVkVGYNHhaaCKaPmS/X7J8TDJHQGWPxM0ZsPluGGCnxhQjsqyY4oc3jyBkye7IbnnWmj1yhisq2V1ti5W7uewCQBioGqJYCNo4PfEjp6ipcgaI5no=,iv:Ee6nw88SUL8+80RR0YA4vAAGilGf7bY6EPhE81jD1Ic=,tag:NtRwWqCntdQN9BlakxvslQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtdjNXQ2VpUlBYMjZJbmRT
ajk2WEY5VndyU0o0aWdYbXRUSnE0QWxGNlYwCnBkOVd1VkMweTdIZHZBTmp5akMv
TE5SRStHYndXWm0wOG82TDlRa2dHV1kKLS0tIEwveXkxMkpULzhBZWQ5WXJQOGFm
TjVmQUkzQmJHYXgwSjF1T1dqMlNUVUEKCz8bv8tdtNPRLSXCNoyEY9yTdpFBg1uG
EpZ/VgsBV0LktJkqkv6ZSRWp2HiEC61305GEFxrE3u4w7ha6FLJMrQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrLzVQSFZQWTZ2ZnVLdEta
S0tkMElVMzZJQ2xSeElQL3N3dy9hQWxzVld3ClcySjhvT0RyUUk4SlNPd2Jpdzhv
QU5samVSQnFEdk80M3diazVEVUVFT1UKLS0tIEpLdE82dUFuT0t2NGxqYmxSbis1
MFAxN3BtRHVKMWxTeVBpblJDTUcvK0UKZTfYCDTAijn0UPK+Z7UjWOoODPf746Q9
ya085jHs39Z1597tgJzWIgydkHl6x9VYUkDu+1Yq4LOeyHFscELnJQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1q47vup0tjhulkg7d6xwmdsgrw64h4ax3la3evzqpxyy4adsmk9fs56qz3y
lastmodified: "2026-10-14T02:07:23Z"
mac: ENC[AES256_GCM,data:o21tNt4Cs5DpCMcWCFjCQ3DRZZBCftFgm2P80y849sFF8rfFVZy99Rda0fiPpohRs45GsvDxPaDAxcCJl2N31pQ3Zg8VCKNS1LbBoDI3iqhuUtmKeudGfOUY6Gd1+j/E4ML29Cfrzm6r3WiXNPa7q8Jmq/kspwoubpQF2q9xp3o=,iv:6hbw+cRlEAdSaPvfh1sMoORI1uhQkqmpOK0aIhL+f+c=,tag:wTa/39ToJSOmn7b31+m34Q==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3
-6
View File
@@ -22,12 +22,6 @@ hourly = 0
daily = 0
monthly = 0
["root_pool/nix_build"]
15_min = 1
hourly = 0
daily = 0
monthly = 0
["root_pool/models"]
15_min = 4
hourly = 24
+1 -4
View File
@@ -4,14 +4,11 @@
"${inputs.self}/users/richie"
"${inputs.self}/common/global"
"${inputs.self}/common/optional/docker.nix"
"${inputs.self}/common/optional/signal-cli.nix"
"${inputs.self}/common/optional/ssh_decrypt.nix"
"${inputs.self}/common/optional/syncthing_base.nix"
"${inputs.self}/common/optional/systemd-boot.nix"
"${inputs.self}/common/optional/tailscale.nix"
"${inputs.self}/common/optional/update.nix"
"${inputs.self}/common/optional/x86-64-v3"
"${inputs.self}/common/optional/zfs"
"${inputs.self}/common/optional/zerotier.nix"
./docker
./hardware.nix
./programs.nix
+3
View File
@@ -0,0 +1,3 @@
# docker_networks
docker network create -d bridge web
-5
View File
@@ -53,11 +53,6 @@
fsType = "zfs";
};
"/nix/var/nix/builds" = {
device = "root_pool/nix_build";
fsType = "zfs";
};
"/boot" = {
device = "/dev/disk/by-uuid/12CE-A600";
fsType = "vfat";
-25
View File
@@ -1,25 +0,0 @@
system_token: ENC[AES256_GCM,data:qub1LgQ89K1EmKk8y/sE1OcPdEB5Zu/S/iZENBA+5d3NQUxJwhX3f3xTo4syBsYFk3b4X1AVx8X0JK27fyr36f9QKPc8IR3Kt4foNp+96gJs9wzskJ188yGFGn1Tle9BLkF1/l5Pzt8Xwe4VCOGuT9uh74q2sCUgcC52u+aez6E=,iv:AbAunuRTY9lhcnd2MwOyVv4in0MfyxwH+LsNK0DoMbQ=,tag:VAmVTSpPh734cZt5Vy0mTg==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjWGNFR01XRXBJUDZ0cFd6
dzdWVUlHT1NQU2VrV3VRSlF2bThYdWRtTERJClI1cFFTZzRNSlc1QmlnQUNxSHZB
dVhqejBMcDZVVXdIeU0yM3NxelBhMTgKLS0tIFJWRHdqTW5UY3ZOZUZ5dXJKRity
YXhHOWl0Y0dQOS9uN0VCSmJHWkxXd0EKV5LbdNZ5oujX+rey8i/XUxoOKw99JKJJ
N1sIIc4LmXrqeqRRhXAakQYoWEqQa12Vu3OZrPXkvoQ0KQHKcKbPPA==
-----END AGE ENCRYPTED FILE-----
recipient: age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBhVkNnUkZEbk8rcm90dFAy
Ty9vYWN4YkIxdVUyTlJCak4yMmx2dkZXUFFRCmZTRkJReGRwTXB1ZThYSjVmUC9D
L1JkVEhHUjRjenpBS0hkQWRiVXY1dEEKLS0tIGM1S2VYbHBjbDU2RG5MUC9HcDNW
VEFCR3ZXVzNOMVY3Q0VEM0Y4dnROZUEKKpULO8QALBkItUgkT7IQgPsC/3gQKfid
1dgv+CpFGLXDtS8Ocm/r0y6WL2ds1E5rwoQ4UphnxWJGLIAo8cs36g==
-----END AGE ENCRYPTED FILE-----
recipient: age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm
lastmodified: "2026-10-14T02:08:28Z"
mac: ENC[AES256_GCM,data:fBhvyzmnzQtLZ7QNELrJvFfnfymLjfzb7Bi61+6dCqNNgrMnYI4gU+9xDSepHskWQ+9ZS3fHZ8ZQCMC+xjhEvI8PWVe4nQYFKHkNOOrfZSBb8osWyzChbQCQXJtK1zalfo/Yk9gvOzeGhqM+XOqMt15Wr56eETSPpPNwVdG2QPY=,iv:TRCrl90dFGJiP0RsYpEIuvknCh8tslIVWTu+Loeklbw=,tag:LkyzzSEnytMkz40IPeBM6g==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3
+1 -15
View File
@@ -4,20 +4,6 @@
...
}:
{
nixpkgs.overlays = [
(final: _prev: {
heater_python = final.python314.withPackages (
ps: with ps; [
fastapi
pydantic
tinytuya
typer
uvicorn
]
);
})
];
networking.firewall.allowedTCPPorts = [ 8124 ];
systemd.services.heater-api = {
@@ -31,7 +17,7 @@
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.heater_python}/bin/python -m python.heater.main --host 0.0.0.0 --port 8124";
ExecStart = "${pkgs.my_python}/bin/python -m python.heater.main --host 0.0.0.0 --port 8124";
EnvironmentFile = "/etc/heater.env";
Restart = "on-failure";
RestartSec = "5s";
+5 -11
View File
@@ -13,10 +13,6 @@
services = {
home-assistant = {
enable = true;
# Keep Home Assistant's Python dependencies on baseline x86-64.
package = pkgs.x86-v1.home-assistant.overrideAttrs (_: {
doInstallCheck = false;
});
config = {
homeassistant = {
time_zone = "America/New_York";
@@ -25,7 +21,6 @@
packages = {
victron_modbuss = "!include ${./home_assistant/victron_modbuss.yaml}";
battery_sensors = "!include ${./home_assistant/battery_sensors.yaml}";
energy_meters = "!include ${./home_assistant/energy_meters.yaml}";
gps_location = "!include ${./home_assistant/gps_location.yaml}";
heater = "!include ${./home_assistant/heater.yaml}";
van_weather = "!include ${./home_assistant/van_weather_template.yaml}";
@@ -72,6 +67,7 @@
paho-mqtt # for mqtt
psycopg2 # for postgresql
py-improv-ble-client # for esphome
pymodbus # for modbus
pyopenweathermap # for weather
pymetno # for met.no weather
uiprotect # for ubiquiti integration
@@ -79,19 +75,17 @@
jsonpath # for rest sensors
monarchmoneycommunity # for monarch
];
extraComponents = [
"isal"
"modbus" # for victron modbus integration
];
customComponents = with pkgs.x86-v1.home-assistant-custom-components; [
extraComponents = [ "isal" ];
customComponents = with pkgs.home-assistant-custom-components; [
garmin_connect
pirate-weather
];
};
esphome = {
enable = true;
openFirewall = true;
address = "0.0.0.0";
address = "192.168.90.35";
};
};
}
@@ -1,71 +0,0 @@
template:
- sensor:
- name: "JK0 Energy In Clean"
unique_id: jk0_energy_in_clean
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
availability: >-
{{ is_number(states('sensor.jk0_energy_in')) }}
state: >-
{{ states('sensor.jk0_energy_in') | float }}
- name: "JK0 Energy Out Clean"
unique_id: jk0_energy_out_clean
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
availability: >-
{{ is_number(states('sensor.jk0_energy_out')) }}
state: >-
{{ states('sensor.jk0_energy_out') | float }}
- name: "JK1 Energy In Clean"
unique_id: jk1_energy_in_clean
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
availability: >-
{{ is_number(states('sensor.jk1_energy_in')) }}
state: >-
{{ states('sensor.jk1_energy_in') | float }}
- name: "JK1 Energy Out Clean"
unique_id: jk1_energy_out_clean
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
availability: >-
{{ is_number(states('sensor.jk1_energy_out')) }}
state: >-
{{ states('sensor.jk1_energy_out') | float }}
- name: "Solar Yield Clean"
unique_id: solar_yield_clean
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
availability: >-
{{ is_number(states('sensor.solar_yield_daily')) }}
state: >-
{{ states('sensor.solar_yield_daily') | float }}
- name: "DC Load Energy Clean"
unique_id: dc_load_energy_clean
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
availability: >-
{{ is_number(states('sensor.dc_load_energy_daily')) }}
state: >-
{{ states('sensor.dc_load_energy_daily') | float }}
- name: "DC Charger Energy Clean"
unique_id: dc_charger_energy_clean
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
availability: >-
{{ is_number(states('sensor.dc_charger_energy')) }}
state: >-
{{ states('sensor.dc_charger_energy') | float }}
+2 -2
View File
@@ -19,7 +19,7 @@
local all richie trust
host all richie 127.0.0.1/32 trust
host all richie ::1/128 trust
host all richie 100.64.0.0/10 trust
host all richie 192.168.90.1/24 trust
host all richie 192.168.99.1/24 trust
local vaninventory vaninventory trust
@@ -28,7 +28,7 @@
local hass hass trust
# ipv4
host hass hass 100.64.0.0/10 trust
host hass hass 192.168.90.1/24 trust
host hass hass 127.0.0.1/32 trust
# ipv6
+1 -1
View File
@@ -3,7 +3,7 @@
services.syncthing = {
overrideFolders = false;
guiAddress = "0.0.0.0:8384";
guiAddress = "192.168.90.35:8384";
settings = {
"dotfiles" = {
path = "/home/richie/dotfiles";
-71
View File
@@ -1,71 +0,0 @@
{ pkgs, ... }:
let
vars = import ./vars.nix;
in
{
virtualisation.docker.daemon.settings."data-root" = "${vars.containers}/docker";
# nixos-container hardcodes its state directory to /var/lib/nixos-containers,
# so route it to the shared container dataset with a bind mount.
fileSystems."/var/lib/nixos-containers" = {
device = "${vars.containers}/nixos-containers";
fsType = "none";
options = [ "bind" ];
};
systemd.services = {
prepare-docker-storage = {
description = "Create Docker storage directory";
requiredBy = [ "docker.service" ];
before = [ "docker.service" ];
requires = [ "zfs-mount.service" ];
after = [ "zfs-mount.service" ];
path = [
pkgs.coreutils
pkgs.util-linux
];
unitConfig = {
DefaultDependencies = false;
RequiresMountsFor = [ "/nix" ];
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
mountpoint -q ${vars.containers}
install -d -m 0710 ${vars.containers}/docker
'';
};
prepare-nixos-container-storage = {
description = "Create NixOS container storage directory";
requiredBy = [ "var-lib-nixos\\x2dcontainers.mount" ];
before = [ "var-lib-nixos\\x2dcontainers.mount" ];
requires = [ "zfs-mount.service" ];
after = [ "zfs-mount.service" ];
path = [
pkgs.coreutils
pkgs.util-linux
];
unitConfig = {
DefaultDependencies = false;
RequiresMountsFor = [ "/nix" ];
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
mountpoint -q ${vars.containers}
install -d -m 0755 ${vars.containers}/nixos-containers
'';
};
};
}
+362
View File
@@ -0,0 +1,362 @@
# Dataset declarations for jeeves, kept as plain data rather than inside
# zfs.nix so the dataset tree stays separate from the NixOS service wiring.
# Datasets are nested the way zfs nests them: a pool holds datasets, which can
# hold datasets of their own. The tree is flattened into "pool/parent/child"
# names below, which is what zfs and services.zfs_manager work in.
#
# Consumed by ./zfs.nix, which feeds it to services.zfs_manager.
let
# Every pool on jeeves was created with the same -O options.
poolDefaults = mountpoint: {
inherit mountpoint;
acltype = "posix"; # zfs reports posixacl back as posix
atime = "off";
compression = "zstd";
dnodesize = "auto";
xattr = "sa";
};
zfsKey = "file:///root/zfs.key";
# What a dataset gets when it is not called out below, kept identical to the
# "default" table so the datasets that used to fall through are unchanged.
standard = {
"15_min" = 8;
hourly = 24;
};
disabledSnapshots = {
"15_min" = 0;
hourly = 0;
daily = 0;
monthly = 0;
};
pools = {
# root_pool: retention only, its properties are not managed yet.
root_pool = {
manageProperties = false;
datasets = {
home = {
manageProperties = false;
snapshots = {
"15_min" = 8;
hourly = 24;
daily = 14;
};
};
root = {
manageProperties = false;
snapshots = standard;
};
nix = {
manageProperties = false;
snapshots."15_min" = 4;
};
var = {
manageProperties = false;
snapshots = {
"15_min" = 8;
hourly = 24;
daily = 30;
monthly = 6;
};
};
};
};
media = {
properties = poolDefaults "/zfs/media";
datasets = {
temp = {
properties = {
redundant_metadata = "none";
sync = "disabled";
};
snapshots."15_min" = 2;
};
secure = {
# An encryption root provisioned by scripts/zfs.sh. Its create-only
# properties are declared for verification, but zfs_manager must
# never create it automatically.
createIfMissing = true;
properties = {
encryption = "aes-256-gcm";
keyformat = "hex";
keylocation = zfsKey;
};
snapshots = disabledSnapshots;
datasets = {
docker = {
properties = {
mountpoint = "/zfs/media/docker";
compression = "zstd-9";
};
snapshots = {
"15_min" = 3;
hourly = 12;
daily = 14;
monthly = 2;
};
};
"github-runners" = {
properties = {
mountpoint = "/zfs/media/github-runners";
compression = "zstd-9";
sync = "disabled";
};
snapshots = {
"15_min" = 6;
hourly = 2;
daily = 1;
};
};
home_assistant = {
properties = {
mountpoint = "/zfs/media/home_assistant";
compression = "zstd-19";
};
snapshots = standard;
};
important = {
properties = {
compression = "zstd-9";
copies = "2";
};
snapshots = standard;
};
notes = {
properties = {
mountpoint = "/zfs/media/notes";
copies = "2";
};
snapshots = {
"15_min" = 8;
hourly = 24;
daily = 30;
monthly = 12;
};
};
postgres = {
properties = {
mountpoint = "/zfs/media/database/postgres";
primarycache = "metadata";
recordsize = "16K";
};
snapshots = {
"15_min" = 8;
hourly = 24;
daily = 7;
};
};
"postgres-wal" = {
properties = {
compression = "lz4";
logbias = "latency";
mountpoint = "/zfs/media/database/postgres-wal";
primarycache = "metadata";
recordsize = "32K";
secondarycache = "none";
special_small_blocks = "32K";
};
snapshots = {
"15_min" = 4;
hourly = 2;
};
};
prometheus = {
properties = {
mountpoint = "/zfs/media/database/prometheus";
compression = "lz4";
};
snapshots = standard;
};
services = {
properties = {
mountpoint = "/zfs/media/services";
compression = "zstd-9";
};
snapshots = standard;
};
share = {
properties = {
mountpoint = "/zfs/media/share";
exec = "off";
};
snapshots."15_min" = 4;
};
};
};
};
};
storage = {
properties = poolDefaults "/zfs/storage";
datasets = {
nomad = {
properties = {
mountpoint = "/zfs/storage/nomad";
compression = "zstd-9";
};
snapshots = standard;
};
ollama = {
properties = {
compression = "zstd-19";
recordsize = "1M";
sync = "disabled";
};
snapshots."15_min" = 2;
};
secure = {
# An encryption root provisioned by scripts/zfs.sh. Its create-only
# properties are declared for verification, but zfs_manager must
# never create it automatically.
createIfMissing = false;
properties = {
encryption = "aes-256-gcm";
keyformat = "hex";
keylocation = zfsKey;
};
snapshots = disabledSnapshots;
datasets = {
archive = {
properties = {
compression = "zstd-19";
mountpoint = "/zfs/storage/archive";
recordsize = "1M";
};
snapshots = standard;
};
important = {
properties = {
compression = "zstd-19";
copies = "2";
mountpoint = "/zfs/storage/important";
};
snapshots = standard;
};
library = {
properties = {
compression = "zstd-19";
mountpoint = "/zfs/storage/library";
recordsize = "1M";
};
snapshots = standard;
};
main = {
properties = {
compression = "zstd-19";
mountpoint = "/zfs/storage/main";
};
snapshots = standard;
};
photos = {
properties = {
compression = "zstd-19";
copies = "2";
mountpoint = "/zfs/storage/photos";
recordsize = "16K";
};
snapshots = standard;
};
plex = {
properties = {
compression = "zstd-19";
mountpoint = "/zfs/storage/plex";
recordsize = "1M";
};
snapshots = {
"15_min" = 6;
hourly = 2;
daily = 1;
};
};
secrets = {
properties = {
compression = "zstd-19";
copies = "3";
mountpoint = "/zfs/storage/secrets";
};
snapshots = {
"15_min" = 8;
hourly = 24;
daily = 30;
monthly = 12;
};
};
syncthing = {
properties = {
compression = "zstd-19";
mountpoint = "/zfs/storage/syncthing";
};
snapshots = standard;
};
transmission = {
properties = {
compression = "zstd-9";
exec = "off";
mountpoint = "/zfs/storage/transmission";
recordsize = "1M";
sync = "disabled";
};
snapshots."15_min" = 4;
};
};
};
};
};
scratch = {
properties = poolDefaults "/zfs/scratch" // {
encryption = "aes-256-gcm";
keyformat = "hex";
keylocation = zfsKey;
};
datasets = {
kafka = {
properties = {
mountpoint = "/zfs/scratch/kafka";
recordsize = "1M";
};
snapshots = standard;
};
kestra = {
properties = {
mountpoint = "/zfs/scratch/kestra";
sync = "disabled";
};
snapshots = standard;
};
transmission = {
properties = {
mountpoint = "/zfs/scratch/transmission";
recordsize = "16K";
sync = "disabled";
};
snapshots."15_min" = 2;
};
uv_cache = {
properties.mountpoint = "/zfs/scratch/uv_cache";
snapshots."15_min" = 2;
};
};
};
};
# Collapse the tree into the flat "pool/parent/child" names zfs uses. Each
# node keeps everything except its children.
flatten =
name: node:
builtins.foldl' (result: child: result // flatten "${name}/${child}" node.datasets.${child}) {
${name} = builtins.removeAttrs node [ "datasets" ];
} (builtins.attrNames (node.datasets or { }));
datasets = builtins.foldl' (result: pool: result // flatten pool pools.${pool}) { } (
builtins.attrNames pools
);
in
{
inherit datasets;
defaultSnapshots = standard;
}
+13 -13
View File
@@ -1,30 +1,28 @@
{ inputs, ... }:
let
vars = import ./vars.nix;
in
{
imports = [
"${inputs.self}/users/dov"
"${inputs.self}/users/math"
"${inputs.self}/users/richie"
"${inputs.self}/users/steve"
"${inputs.self}/common/global"
"${inputs.self}/common/optional/docker.nix"
"${inputs.self}/common/optional/monitoring-agent.nix"
"${inputs.self}/common/optional/signal-cli.nix"
"${inputs.self}/common/optional/ssh_decrypt.nix"
"${inputs.self}/common/optional/syncthing_base.nix"
"${inputs.self}/common/optional/tailscale.nix"
"${inputs.self}/common/optional/update.nix"
"${inputs.self}/common/optional/x86-64-v3"
"${inputs.self}/common/optional/zfs"
"${inputs.self}/common/optional/zerotier.nix"
"${inputs.self}/common/optional/zfs_manager.nix"
./monitoring
./docker
./services
./containers.nix
./web_services
./hardware.nix
./networking
./networking.nix
./programs.nix
./runners
./syncthing.nix
./zfs.nix
];
services = {
@@ -32,10 +30,12 @@ in
smartd.enable = true;
snapshot_manager = {
path = ./snapshot_config.toml;
EnvironmentFile = "${vars.secrets}/services/snapshot_manager";
};
zerotierone.joinNetworks = [ "a09acf02330d37b9" ];
};
users.groups = {
nornsight = { };
nornsight-admin = { };
};
system.stateVersion = "24.05";
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more