Compare commits
1
Commits
main
..
932e6c9ab1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
932e6c9ab1 |
No files matched your search
+1
-31
@@ -11,7 +11,7 @@ keys:
|
|||||||
- &system_brain age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm # cspell:disable-line
|
- &system_brain age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm # cspell:disable-line
|
||||||
- &system_jeeves age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv # cspell:disable-line
|
- &system_jeeves age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv # cspell:disable-line
|
||||||
- &system_rhapsody age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4 # cspell:disable-line
|
- &system_rhapsody age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4 # cspell:disable-line
|
||||||
- &system_portal_1 age1cm7ujz4t9cyc3nyy0w30l2addggk0gy8h3shd9yq47jg845tndnsa3utfl # cspell:disable-line
|
- &system_portal_1 age1vyav6kxtvt3z4vtnkkjj38eu8hlts5m7ygyckhskvalg2gpjk52su53d0a # cspell:disable-line
|
||||||
|
|
||||||
creation_rules:
|
creation_rules:
|
||||||
- path_regex: users/secrets\.yaml$
|
- path_regex: users/secrets\.yaml$
|
||||||
@@ -23,33 +23,3 @@ creation_rules:
|
|||||||
- *system_jeeves
|
- *system_jeeves
|
||||||
- *system_rhapsody
|
- *system_rhapsody
|
||||||
- *system_portal_1
|
- *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
|
|
||||||
Generated
-163
@@ -290,28 +290,6 @@ dependencies = [
|
|||||||
"log",
|
"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]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -403,12 +381,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.17.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -641,16 +613,6 @@ dependencies = [
|
|||||||
"icu_properties",
|
"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]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.1"
|
version = "2.12.1"
|
||||||
@@ -722,12 +684,6 @@ version = "0.2.189"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "linux-raw-sys"
|
|
||||||
version = "0.12.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -1015,19 +971,6 @@ version = "2.1.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
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]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.43"
|
version = "0.23.43"
|
||||||
@@ -1118,15 +1061,6 @@ dependencies = [
|
|||||||
"zmij",
|
"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]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -1145,15 +1079,6 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "signal"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"libc",
|
|
||||||
"serde_json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -1166,22 +1091,6 @@ version = "1.15.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "snapshot-manager"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"chrono",
|
|
||||||
"clap",
|
|
||||||
"env_logger",
|
|
||||||
"log",
|
|
||||||
"serde",
|
|
||||||
"signal",
|
|
||||||
"tempfile",
|
|
||||||
"toml",
|
|
||||||
"zfs",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.5"
|
version = "0.6.5"
|
||||||
@@ -1252,19 +1161,6 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.20"
|
version = "2.0.20"
|
||||||
@@ -1334,45 +1230,6 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -1488,7 +1345,6 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"log",
|
"log",
|
||||||
"num-traits",
|
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1734,18 +1590,6 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
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]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -1835,13 +1679,6 @@ dependencies = [
|
|||||||
"syn 3.0.3",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zfs"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|||||||
-30
@@ -2,33 +2,3 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
members = ["rust/*"]
|
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"
|
|
||||||
@@ -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
|
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
boot = {
|
boot = {
|
||||||
tmp.useTmpfs = lib.mkDefault true;
|
tmp.useTmpfs = lib.mkDefault true;
|
||||||
kernelPackages = lib.mkDefault pkgs.linuxPackages_6_18;
|
kernelPackages = lib.mkDefault pkgs.linuxPackages_6_12;
|
||||||
};
|
};
|
||||||
|
|
||||||
hardware.enableRedistributableFirmware = true;
|
hardware.enableRedistributableFirmware = true;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 = "";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,6 +1,7 @@
|
|||||||
{ pkgs, ... }:
|
{ pkgs, ... }:
|
||||||
{
|
{
|
||||||
environment.systemPackages = with pkgs; [
|
environment.systemPackages = with pkgs; [
|
||||||
|
mangohud
|
||||||
steam-run
|
steam-run
|
||||||
];
|
];
|
||||||
hardware.steam-hardware.enable = true;
|
hardware.steam-hardware.enable = true;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Package patches
|
||||||
|
|
||||||
|
Each package follows the [GnuTLS layout](gnutls/README.md):
|
||||||
|
|
||||||
|
- `default.nix` applies the patch through the package overlay.
|
||||||
|
- A descriptive `.patch` file contains the standalone upstream source change.
|
||||||
|
- `README.md` explains the problem, scope, reproduction, upstream status,
|
||||||
|
Nix integration, and recorded validation limits.
|
||||||
|
- Companion `verify-*` tools live beside the patch when needed; otherwise
|
||||||
|
the README gives commands for the package's existing tests.
|
||||||
|
|
||||||
|
Keep package-specific evidence in its directory. Patch headers explain the
|
||||||
|
change independently of Nix, and `default.nix` preserves existing patches.
|
||||||
|
|
||||||
|
| Package | Repair |
|
||||||
|
| --- | --- |
|
||||||
|
| [Abseil](abseil/README.md) | Public BMI2 header in Electron, Deno, and Signal's vendored copies |
|
||||||
|
| [GnuTLS](gnutls/README.md) | Wait for the UDP server socket before connecting |
|
||||||
|
| [Prometheus](prometheus/README.md) | Complete parsing before inspecting the test editor state |
|
||||||
|
| [pytest-xdist](pytest-xdist/README.md) | Check worker replacements and allow startup on loaded builders |
|
||||||
|
| [SciPy](scipy/README.md) | Account for floating-point rounding in STFT tests |
|
||||||
|
|
||||||
|
## Local NixOS integration
|
||||||
|
|
||||||
|
The [`x86-64-v3` optional module](../default.nix)
|
||||||
|
imports this directory's [`default.nix`](default.nix) directly, so the patch
|
||||||
|
overlay applies only to hosts using that package set. Prometheus patches its
|
||||||
|
separate assets derivation; Python packages use `pythonPackagesExtensions`.
|
||||||
|
|
||||||
|
The [pytest-xdist directory](pytest-xdist/README.md) also owns its outer-worker
|
||||||
|
limit and remote-worker event timeout. These package overrides add no skipped
|
||||||
|
tests. Existing nixpkgs exclusions remain separate from these repairs.
|
||||||
|
|
||||||
|
The review used Python 3.14.7 and the pinned x86-64-v3 package set. Host-flake
|
||||||
|
evaluation verified patch wiring, Python install checks, and Prometheus's
|
||||||
|
reference to the patched assets. No complete NixOS rebuild was performed.
|
||||||
|
Individual READMEs distinguish package builds, focused tests, and checks that
|
||||||
|
have not been run.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Abseil BMI2 public header
|
||||||
|
|
||||||
|
Vendored Abseil includes `bmi2intrin.h` directly when `__BMI2__` is enabled.
|
||||||
|
Compilers reject that internal header without the umbrella-header setup.
|
||||||
|
`bmi2-public-header.patch` includes `immintrin.h` instead, allowing builds
|
||||||
|
that enable BMI2 through `-march=x86-64-v3`.
|
||||||
|
|
||||||
|
## Scope and behavior
|
||||||
|
|
||||||
|
The patch changes one include in
|
||||||
|
`third_party/abseil-cpp/absl/container/internal/raw_hash_set.h`.
|
||||||
|
`default.nix` applies it to Electron 43's unwrapped package, Deno's
|
||||||
|
`librusty_v8`, and Signal's WebRTC dependency. It also supplies the patched
|
||||||
|
Electron package to Signal. These overrides apply only to `x86-64-v3`.
|
||||||
|
|
||||||
|
The shared file path is relative to each vendoring project's source root,
|
||||||
|
not the root of a standalone Abseil checkout. No hash-table algorithm or
|
||||||
|
test exclusion changes.
|
||||||
|
|
||||||
|
## Reproduction and focused checks
|
||||||
|
|
||||||
|
From this directory, check and apply the patch to each vendored source tree:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
patch --dry-run --fuzz=0 -d /path/to/vendor-source -p1 < bmi2-public-header.patch
|
||||||
|
patch --fuzz=0 -d /path/to/vendor-source -p1 < bmi2-public-header.patch
|
||||||
|
```
|
||||||
|
|
||||||
|
A small compiler check isolates the header requirement. With GCC or Clang
|
||||||
|
on x86-64, compile `#include <bmi2intrin.h>` using `-march=x86-64-v3`; the
|
||||||
|
compiler rejects the direct include. Changing it to `#include <immintrin.h>`
|
||||||
|
should compile. The full consumer builds below check integration with their
|
||||||
|
actual toolchains.
|
||||||
|
|
||||||
|
## Upstream status
|
||||||
|
|
||||||
|
Abseil addressed this issue through
|
||||||
|
[PR #2071](https://github.com/abseil/abseil-cpp/pull/2071), imported by its
|
||||||
|
upstream workflow. That change uses `x86gprintrin.h`; this local variant uses
|
||||||
|
the public `immintrin.h` umbrella header for the vendored toolchains.
|
||||||
|
Keep the workaround until all three bundled copies include a compatible fix.
|
||||||
|
This file is a local adaptation, not a verbatim copy of the upstream diff.
|
||||||
|
|
||||||
|
## Local NixOS integration and build results
|
||||||
|
|
||||||
|
[`../default.nix`](../default.nix) merges this directory's overlay fragment
|
||||||
|
because it repairs multiple packages. From the repository root, the consumer
|
||||||
|
build commands are:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build --no-link -L .#nixosConfigurations.jeeves.pkgs.deno
|
||||||
|
nix build --no-link -L .#nixosConfigurations.jeeves.pkgs.electron_43
|
||||||
|
nix build --no-link -L .#nixosConfigurations.jeeves.pkgs.signal-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
The earlier extraction checked the vendored header snapshots and evaluated
|
||||||
|
all three patch attachments. Those records do not establish successful full
|
||||||
|
consumer rebuilds. No new compiler or consumer build was run for the layout
|
||||||
|
change; the patch and override are unchanged.
|
||||||
@@ -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 ];
|
|
||||||
})
|
|
||||||
@@ -3,12 +3,9 @@ _final: prev:
|
|||||||
// {
|
// {
|
||||||
gnutls = import ./gnutls { inherit (prev) gnutls; };
|
gnutls = import ./gnutls { inherit (prev) gnutls; };
|
||||||
prometheus = import ./prometheus { inherit (prev) prometheus; };
|
prometheus = import ./prometheus { inherit (prev) prometheus; };
|
||||||
zopfli = import ./zopfli { inherit (prev) zopfli; };
|
|
||||||
|
|
||||||
pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [
|
pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [
|
||||||
(_pythonFinal: pythonPrev: {
|
(_pythonFinal: pythonPrev: {
|
||||||
afdko = import ./afdko { inherit (pythonPrev) afdko; };
|
|
||||||
psutil = import ./psutil { inherit (pythonPrev) psutil; };
|
|
||||||
pytest-xdist = import ./pytest-xdist { inherit (pythonPrev) pytest-xdist; };
|
pytest-xdist = import ./pytest-xdist { inherit (pythonPrev) pytest-xdist; };
|
||||||
scipy = import ./scipy { inherit (pythonPrev) scipy; };
|
scipy = import ./scipy { inherit (pythonPrev) scipy; };
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# GnuTLS UDP server readiness
|
||||||
|
|
||||||
|
Under load, the test client can start before `gnutls-serv` binds its UDP
|
||||||
|
socket, and the first handshake fails with `Connection refused`.
|
||||||
|
`serv-udp.sh` currently waits a fixed four seconds; elapsed time does not
|
||||||
|
establish server readiness. `udp-server-readiness.patch` replaces that wait
|
||||||
|
with polling for the local IPv4 UDP endpoint.
|
||||||
|
|
||||||
|
## Scope and waiting behavior
|
||||||
|
|
||||||
|
The patch changes the existing `wait_udp_server()` and adds a new
|
||||||
|
`check_if_udp_port_bound()` beside it in `tests/scripts/common.sh`.
|
||||||
|
`serv-udp.sh` is its only caller in 3.8.13. The TCP helpers `wait_server()`
|
||||||
|
and `wait_for_port()`, including their existing sleeps, are unchanged.
|
||||||
|
Both original DTLS handshake checks remain unchanged.
|
||||||
|
|
||||||
|
Each iteration checks process liveness and the socket **before sleeping**.
|
||||||
|
A ready socket returns immediately. An unsuccessful check sleeps two
|
||||||
|
seconds only if another attempt remains: at most 90 attempts, consistent
|
||||||
|
with the existing `wait_server()` budget implemented by `wait_for_port()`,
|
||||||
|
with no sleep after the final check. Server exit fails early; exhausting the
|
||||||
|
budget fails and terminates the server. No handshake is retried, and no
|
||||||
|
protocol timeout is changed. Once bound, the kernel can queue datagrams
|
||||||
|
while the server is scheduled; the probe itself sends no packets.
|
||||||
|
|
||||||
|
The existing `have_port_finder()` prefers `ss`, then `netstat`. If neither
|
||||||
|
exists, it prints `neither ss nor netstat found` and exits **77 (skip)**.
|
||||||
|
In the normal test flow, port selection calls it before launching a server.
|
||||||
|
The probe runs in a subshell so that, even if this skip occurs after launch,
|
||||||
|
the waiting helper can terminate and reap the server before exiting 77.
|
||||||
|
|
||||||
|
## Why an IPv4 socket is expected
|
||||||
|
|
||||||
|
This is specific to the server used by this test, not a general rule that
|
||||||
|
IPv6 sockets cannot serve IPv4 clients. The client explicitly uses
|
||||||
|
`127.0.0.1`. The server's `--udp` path calls `udp_server()`, which calls
|
||||||
|
`listen_socket(..., SOCK_DGRAM)`. That function iterates the wildcard
|
||||||
|
addresses returned by `getaddrinfo(NULL, ..., AI_PASSIVE)`:
|
||||||
|
|
||||||
|
| Server build / Linux setting | Binding behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| IPv6 enabled, `net.ipv6.bindv6only=0` | Requests `IPV6_V6ONLY=1` on the IPv6 socket, binds `[::]:PORT`, and separately binds `0.0.0.0:PORT`. It overrides the system's dual-stack default. |
|
||||||
|
| IPv6 enabled, `net.ipv6.bindv6only=1` | The same explicit socket option and separate IPv4/IPv6 binds. |
|
||||||
|
| `HAVE_IPV6` undefined | Skips every address family except `AF_INET`; only the IPv4 wildcard is attempted. |
|
||||||
|
|
||||||
|
`udp_server()` uses `wait_for_connection()`, which puts **every listener**
|
||||||
|
from that list into `select()` and returns a readable socket for `recvfrom()`;
|
||||||
|
it does not permanently choose one socket based on `getaddrinfo()` order.
|
||||||
|
|
||||||
|
The first two cases were traced with the actual GnuTLS 3.8.13 binary in
|
||||||
|
separate Linux network namespaces: `setsockopt(IPV6_V6ONLY, [1])` and both
|
||||||
|
UDP binds returned success under each setting. The no-IPv6 case was checked
|
||||||
|
in source, not by building a second binary. The same bind implementation
|
||||||
|
was checked directly on GitLab master.
|
||||||
|
|
||||||
|
Thus, successful normal startup for this invocation provides an explicit
|
||||||
|
IPv4 socket; a lone IPv6 wildcard is not the expected success path.
|
||||||
|
There is one portability caveat: upstream discards the return value of
|
||||||
|
`setsockopt(IPV6_V6ONLY)`. On a platform where that call fails and the server
|
||||||
|
ends up with only a dual-stack socket, this helper would time out despite
|
||||||
|
IPv4 reachability. Such a platform needs additional handling before this
|
||||||
|
patch can claim support. Blindly accepting every IPv6 wildcard would also
|
||||||
|
accept IPv6-only sockets before the separate IPv4 bind finishes.
|
||||||
|
|
||||||
|
Source: [`src/serv.c`, `listen_socket()`](https://gitlab.com/gnutls/gnutls/-/blob/master/src/serv.c#L937),
|
||||||
|
[`src/udp-serv.c`](https://gitlab.com/gnutls/gnutls/-/blob/master/src/udp-serv.c),
|
||||||
|
and [`tests/serv-udp.sh`](https://gitlab.com/gnutls/gnutls/-/blob/master/tests/serv-udp.sh).
|
||||||
|
|
||||||
|
## Port matching and ownership limit
|
||||||
|
|
||||||
|
Only `-an` is passed to the socket-listing tool: BSD `netstat -u` selects
|
||||||
|
Unix-domain sockets, whereas Linux `netstat -u` selects UDP. The parser
|
||||||
|
handles the extra state column in `ss`, Linux colon-separated endpoints,
|
||||||
|
and BSD dot-separated endpoints, including `*.PORT`. It matches the full
|
||||||
|
local port and rejects TCP, IPv6 entries, peer ports, and longer numbers.
|
||||||
|
|
||||||
|
A live PID plus a bound port does **not** prove that PID owns the socket.
|
||||||
|
Existing `GETPORT` selection checks for an unused port and uses a test
|
||||||
|
port-lock directory; `launch_bare_server()` also calls
|
||||||
|
`wait_for_free_port()` before starting the process. These are advisory:
|
||||||
|
the launcher does not enforce the latter's result, and another process
|
||||||
|
can bind between the check and launch. The patch does not close that race
|
||||||
|
or add nonportable PID parsing. An unrelated process can satisfy the
|
||||||
|
socket check; the real handshakes remain the functional check and may
|
||||||
|
fail (or reach the wrong server). This is a startup-order fix, not a
|
||||||
|
socket-ownership guarantee.
|
||||||
|
|
||||||
|
## Reproduction and focused checks
|
||||||
|
|
||||||
|
Apply the patch to an unpacked source tree, then run the companion checks
|
||||||
|
with Python's standard library and a shell:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
patch --fuzz=0 -d /path/to/gnutls -p1 < udp-server-readiness.patch
|
||||||
|
SHELL=/bin/sh python3 verify-readiness.py /path/to/gnutls/tests/scripts/common.sh -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `NETSTAT=/path/to/netstat` to exercise one outside `PATH`. The checks
|
||||||
|
cover Linux/BSD output samples, false matches, immediate readiness,
|
||||||
|
missing tools, process exit, timeout cleanup, and real IPv4 UDP sockets
|
||||||
|
whose bind is delayed six seconds. The missing-tools fixture is skipped
|
||||||
|
if an absolute fallback `ss` path cannot be hidden with `PATH`. Native
|
||||||
|
BSD execution remains untested.
|
||||||
|
|
||||||
|
To reproduce with GnuTLS itself, run `tests/serv-udp.sh` with `SERV` pointing
|
||||||
|
to a wrapper that sleeps six seconds, then `exec`s `gnutls-serv` with all
|
||||||
|
arguments. Set `CLI` to the matching `gnutls-cli`, `srcdir` to the source
|
||||||
|
`tests` directory, and `abs_top_builddir` to a writable build directory.
|
||||||
|
With GnuTLS 3.8.13, the original helper failed the first handshake with
|
||||||
|
`Connection refused`; the patched helper passed both with the same binaries.
|
||||||
|
|
||||||
|
## GnuTLS submission
|
||||||
|
|
||||||
|
Development and merge requests are on [GitLab](https://gitlab.com/gnutls/gnutls).
|
||||||
|
[`CONTRIBUTING.md` on master](https://gitlab.com/gnutls/gnutls/-/blob/master/CONTRIBUTING.md)
|
||||||
|
was read directly for this review. It requires the contributor's DCO
|
||||||
|
`Signed-off-by`, successful and failure test coverage, consistent coding
|
||||||
|
style, and adequate documentation; GitLab CI runs for merge requests.
|
||||||
|
Its commenting guidance asks for comments explaining non-obvious behavior
|
||||||
|
or protocol expectations. It does not prescribe an additional special
|
||||||
|
test-suite comment. The patch now explains its IPv4 binding assumption
|
||||||
|
next to the probe.
|
||||||
|
|
||||||
|
The submission will contain the shell patch, without the Python verifier
|
||||||
|
or a new Python test dependency. The existing `serv-udp.sh` supplies the
|
||||||
|
functional success check. Running it through the six-second startup
|
||||||
|
wrapper supplies a reproducible regression case: it fails before the fix
|
||||||
|
and passes after it. The local verifier was used to validate socket-output
|
||||||
|
parsing and the helper's success, process-exit, skip-cleanup, and timeout
|
||||||
|
branches. Those branch checks are local evidence, not new automated
|
||||||
|
coverage in the upstream suite; the MR must state that distinction.
|
||||||
|
|
||||||
|
No dedicated unit-test harness for these shell helpers was found in the
|
||||||
|
3.8.13 tests inspected. That does not establish that Python cannot be used
|
||||||
|
upstream; keeping this submission dependency-free is a scope choice. Use
|
||||||
|
the existing test and before/after reproduction as the submission's
|
||||||
|
coverage argument, retaining the platform limitations above. Apply the
|
||||||
|
patch in an upstream checkout and include those results with the
|
||||||
|
contributor's own sign-off. No MR or sign-off has been created.
|
||||||
|
|
||||||
|
## Local NixOS integration and build results
|
||||||
|
|
||||||
|
`overlays/default.nix` imports the `overlays/patches` overlay, which loads
|
||||||
|
`gnutls/default.nix` to apply the patch and keep `serv-udp.sh` enabled.
|
||||||
|
The patch itself has no Nix dependencies and applies to 3.8.13 and GitLab
|
||||||
|
master without fuzz.
|
||||||
|
|
||||||
|
The final patch was rebuilt with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build --no-link -L .#nixosConfigurations.jeeves.pkgs.gnutls
|
||||||
|
```
|
||||||
|
|
||||||
|
That x86-64-v3 build passed: 927 tests, 796 passes, 131 existing skips,
|
||||||
|
zero failures/errors, and `PASS: serv-udp.sh`. The patch bytes in the built
|
||||||
|
derivation were compared with the repository artifact; both have SHA-256
|
||||||
|
`59013d47fd446f2dd065012a2259ccc1898fedc8a053a630e13efa0076368760`.
|
||||||
|
All seven local checks passed, including skip cleanup and exactly 90
|
||||||
|
probes with 89 sleeps on timeout. The six-second before/after reproduction
|
||||||
|
was also repeated successfully with the final helper.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Prometheus complete test parsing
|
||||||
|
|
||||||
|
CodeMirror gives editor-state creation a 20 ms synchronous parsing budget.
|
||||||
|
The shared `createEditorState()` test helper can therefore return an
|
||||||
|
incomplete syntax tree when the process is descheduled. The completion and
|
||||||
|
vector-matching tests immediately inspect that tree.
|
||||||
|
|
||||||
|
## Scope and behavior
|
||||||
|
|
||||||
|
`complete-test-parsing.patch` changes only
|
||||||
|
`module/codemirror-promql/src/test/utils-test.ts` inside `web/ui`. It completes
|
||||||
|
the small test expression with `ensureSyntaxTree(..., Infinity)` and publishes
|
||||||
|
the completed parse through an empty transaction so `syntaxTree(state)` sees
|
||||||
|
it. Failure to obtain a tree raises an error.
|
||||||
|
|
||||||
|
The original assertions remain enabled, including `autocomplete topk params 2`
|
||||||
|
and `foo * on(test,blub) bar`. The unlimited budget applies to the test helper;
|
||||||
|
production editor parsing budgets are unchanged.
|
||||||
|
|
||||||
|
## Reproduction and focused checks
|
||||||
|
|
||||||
|
Use a disposable Prometheus 3.14.0 checkout. The patch root is `web/ui`, matching
|
||||||
|
the Nix assets derivation. From this directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
patch --fuzz=0 -d /path/to/prometheus/web/ui -p1 < complete-test-parsing.patch
|
||||||
|
cd /path/to/prometheus/web/ui
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm --filter @prometheus-io/lezer-promql build
|
||||||
|
pnpm --filter @prometheus-io/codemirror-promql test
|
||||||
|
```
|
||||||
|
|
||||||
|
To force the scheduling condition, temporarily append this clock to
|
||||||
|
`module/codemirror-promql/setupJest.cjs` in the disposable checkout:
|
||||||
|
|
||||||
|
```js
|
||||||
|
let parseClock = 0;
|
||||||
|
Date.now = () => (parseClock += 25);
|
||||||
|
```
|
||||||
|
|
||||||
|
Each clock read crosses the editor's initial parsing budget. Against the
|
||||||
|
original helper, the hybrid and vector suites have 186 failures, including
|
||||||
|
both locally excluded cases. With the patch, all 386 CodeMirror tests pass
|
||||||
|
under that same clock. Remove the injected clock before normal builds.
|
||||||
|
|
||||||
|
## Upstream status
|
||||||
|
|
||||||
|
This is a standalone test-helper patch for Prometheus 3.14.0. No upstream
|
||||||
|
submission was made during this work. Recheck the helper when updating
|
||||||
|
Prometheus or CodeMirror, including how an ensured parse becomes visible
|
||||||
|
through the editor state.
|
||||||
|
|
||||||
|
## Local NixOS integration and build results
|
||||||
|
|
||||||
|
[`../default.nix`](../default.nix) loads `default.nix`, which patches the
|
||||||
|
separate assets derivation. It updates both `passthru.assets` and the main
|
||||||
|
Prometheus build's reference to those assets. From the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build --no-link -L .#nixosConfigurations.jeeves.pkgs.prometheus.assets
|
||||||
|
```
|
||||||
|
|
||||||
|
The full x86-64-v3 assets build passed with the normal clock, including the
|
||||||
|
CodeMirror and UI suites. Host-flake evaluation confirmed that the main
|
||||||
|
Prometheus derivation refers to these patched assets. The Go server package
|
||||||
|
was not rebuilt for this test-helper change.
|
||||||
@@ -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
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# pytest-xdist test fixes
|
||||||
|
|
||||||
|
With two workers and a restart limit of three, the fourth worker crash
|
||||||
|
requests shutdown while another test can still be running. That test may
|
||||||
|
also crash. The original queued-work test requires exactly four failures,
|
||||||
|
even though five failures can occur without exceeding the replacement limit.
|
||||||
|
|
||||||
|
## Scope and behavior
|
||||||
|
|
||||||
|
`concurrent-worker-crashes.patch` changes the assertions in
|
||||||
|
`TestNodeFailure.test_max_worker_restart_tests_queued` in
|
||||||
|
`testing/acceptance_test.py`. It requires exactly three replacements, four or
|
||||||
|
five failed tests, the failed-tests exit status, the limit message, and no
|
||||||
|
internal error. It retains the two-worker workload and ten queued tests.
|
||||||
|
|
||||||
|
`worker-startup-timeout.patch` changes the remote-test helper's event timeout
|
||||||
|
from 10 to 60 seconds so loaded builders have time to start workers. The
|
||||||
|
helper returns immediately when an event arrives and still has a bounded wait.
|
||||||
|
|
||||||
|
The existing nixpkgs pytest-9 compatibility patches remain in place.
|
||||||
|
Production scheduling and worker-restart behavior are unchanged.
|
||||||
|
|
||||||
|
## Reproduction and focused checks
|
||||||
|
|
||||||
|
Use a disposable pytest-xdist 3.8.0 checkout with its test dependencies and
|
||||||
|
the nixpkgs pytest-9 compatibility patches where required. From this directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
patch --fuzz=0 -d /path/to/pytest-xdist -p1 < concurrent-worker-crashes.patch
|
||||||
|
patch --fuzz=0 -d /path/to/pytest-xdist -p1 < worker-startup-timeout.patch
|
||||||
|
cd /path/to/pytest-xdist
|
||||||
|
python -m pytest testing/acceptance_test.py \
|
||||||
|
-k test_max_worker_restart_tests_queued -q
|
||||||
|
python -m pytest testing/test_remote.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Twenty unmodified runs passed during the review. To force the failing
|
||||||
|
schedule, modify the generated crashing test in a disposable checkout to
|
||||||
|
accept `worker_id`: make `gw3` wait for a marker created by `gw4`, and make
|
||||||
|
`gw4` pause 0.1 seconds after creating the marker. Then both have in-flight
|
||||||
|
tests when shutdown starts. Bound the marker wait so a reproduction failure
|
||||||
|
cannot hang the suite. The original assertion fails on five reported
|
||||||
|
failures; the patched test passes.
|
||||||
|
|
||||||
|
## Worker startup and outer concurrency
|
||||||
|
|
||||||
|
[`default.nix`](default.nix) runs the outer suite with one worker to limit
|
||||||
|
nested process pools. This is a Nix test-runner setting; the source timeout
|
||||||
|
change lives in [`worker-startup-timeout.patch`](worker-startup-timeout.patch).
|
||||||
|
|
||||||
|
A separate reproduction inserts an 11-second `pytest_sessionstart` delay
|
||||||
|
into the child created by `test_basic_collect_and_runtests` in
|
||||||
|
`testing/test_remote.py`. The original 10-second channel wait fails; the
|
||||||
|
60-second wait passes. This bounds waits for test worker events, including
|
||||||
|
startup, rather than changing a product deadline.
|
||||||
|
|
||||||
|
## Upstream status
|
||||||
|
|
||||||
|
These are standalone test patches for pytest-xdist 3.8.0. No upstream submission
|
||||||
|
was made during this work. Recheck the allowed in-flight failures, replacement
|
||||||
|
count, and remote-test wait when updating the scheduler or worker behavior.
|
||||||
|
|
||||||
|
## Local NixOS integration and build results
|
||||||
|
|
||||||
|
[`../default.nix`](../default.nix) loads `default.nix` through
|
||||||
|
`pythonPackagesExtensions`. From the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build --no-link -L .#nixosConfigurations.jeeves.pkgs.python314Packages.pytest-xdist
|
||||||
|
```
|
||||||
|
|
||||||
|
After consolidating the settings in this directory, the full x86-64-v3 package
|
||||||
|
build passed 185 tests, with 6 existing skips and 10 expected failures. Nix
|
||||||
|
evaluation confirmed the same outer-worker limit and preserved existing
|
||||||
|
patches, with the timeout now applied as a source patch.
|
||||||
|
|
||||||
|
The earlier forced concurrent-crash and delayed-startup reproductions passed
|
||||||
|
after their fixes; the focused crash test also passed after formatting its
|
||||||
|
assertion.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# SciPy STFT test tolerances
|
||||||
|
|
||||||
|
The x86-64-v3 build can produce small floating-point residuals in inverse-STFT
|
||||||
|
comparisons and scaling round trips. The original bounds reject these results,
|
||||||
|
including residuals around `4e-17` where a round trip expects zero for a signal
|
||||||
|
with amplitude 2.
|
||||||
|
|
||||||
|
## Scope and behavior
|
||||||
|
|
||||||
|
`stft-test-tolerances.patch` changes only the signal tests:
|
||||||
|
|
||||||
|
- The inverse-STFT comparison in `_scipy_spectral_test_shim.py` uses
|
||||||
|
`max(1e-7, 2 * np.finfo(x.dtype).eps)` as its relative tolerance. Float64
|
||||||
|
keeps the original bound, and the existing i686 override remains.
|
||||||
|
- Three scaling round trips in `test_spectral.py` gain an absolute tolerance
|
||||||
|
of one epsilon for the input dtype, allowing small residuals near zero.
|
||||||
|
|
||||||
|
The tests remain enabled, and the production STFT implementation is unchanged.
|
||||||
|
|
||||||
|
## Reproduction and focused checks
|
||||||
|
|
||||||
|
From this directory, apply the patch to a disposable SciPy 1.18.0 checkout:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
patch --fuzz=0 -d /path/to/scipy -p1 < stft-test-tolerances.patch
|
||||||
|
```
|
||||||
|
|
||||||
|
Build and install that tree with SciPy's test dependencies. From outside the
|
||||||
|
source directory, run the installed tests:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m pytest --pyargs scipy.signal.tests.test_spectral \
|
||||||
|
-k 'roundtrip_float32 or roundtrip_scaling' -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the same compiler flags and numerical libraries for before/after runs.
|
||||||
|
The earlier reproduction called `TestSTFT.test_roundtrip_float32` and
|
||||||
|
`TestSTFT.test_roundtrip_scaling` against the x86-64-v3 libraries, then loaded
|
||||||
|
patched copies of the test modules. Both failed with the original bounds
|
||||||
|
and passed with the adjusted bounds.
|
||||||
|
|
||||||
|
## Upstream status
|
||||||
|
|
||||||
|
[SciPy issue #25488](https://github.com/scipy/scipy/issues/25488) records
|
||||||
|
related test failures with architecture-specific compiler flags. It is
|
||||||
|
context for the local tolerance repair; this exact patch has not been
|
||||||
|
submitted upstream during this work.
|
||||||
|
|
||||||
|
## Local NixOS integration and build results
|
||||||
|
|
||||||
|
[`../default.nix`](../default.nix) loads [`default.nix`](default.nix) through
|
||||||
|
`pythonPackagesExtensions`, preserving the package's existing patches.
|
||||||
|
The override also covers SciPy used to test other Python dependencies,
|
||||||
|
including pgvector in portal's shared Python environment.
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build --no-link -L .#nixosConfigurations.portal-1.pkgs.python314Packages.scipy
|
||||||
|
```
|
||||||
|
|
||||||
|
The original remote build of patched SciPy 1.18.0 passed 87,723 tests, with
|
||||||
|
8,342 skips, 300 expected failures, and 22 unexpected passes. The patch and
|
||||||
|
override have been restored byte-for-byte from commit `24cbf74f`; those counts
|
||||||
|
describe the earlier full build.
|
||||||
|
|
||||||
|
Restoration checks confirmed that the patch applies to the pinned source
|
||||||
|
without fuzz, portal's evaluated SciPy retains its existing patch and install
|
||||||
|
checks, and pgvector uses the patched SciPy. A full package or system rebuild
|
||||||
|
was not repeated for this restoration.
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
inputs,
|
||||||
lib,
|
lib,
|
||||||
pkgs,
|
pkgs,
|
||||||
...
|
...
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
services = {
|
services = {
|
||||||
snapshot_manager = {
|
snapshot_manager = {
|
||||||
enable = lib.mkDefault true;
|
enable = lib.mkDefault true;
|
||||||
|
PYTHONPATH = "${inputs.self}/";
|
||||||
};
|
};
|
||||||
|
|
||||||
zfs = {
|
zfs = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
inputs,
|
||||||
pkgs,
|
pkgs,
|
||||||
lib,
|
lib,
|
||||||
config,
|
config,
|
||||||
@@ -6,18 +7,23 @@
|
|||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
cfg = config.services.snapshot_manager;
|
cfg = config.services.snapshot_manager;
|
||||||
snapshotManager = pkgs.callPackage ../../../rust/snapshot_manager/package.nix { };
|
snapshotManagerPackages = ps: with ps; [ typer ];
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options = {
|
options = {
|
||||||
services.snapshot_manager = {
|
services.snapshot_manager = {
|
||||||
enable = lib.mkEnableOption "ZFS snapshot manager";
|
enable = lib.mkEnableOption "ZFS snapshot manager";
|
||||||
signalAlerts = lib.mkEnableOption "Signal failure alerts for the snapshot manager";
|
|
||||||
path = lib.mkOption {
|
path = lib.mkOption {
|
||||||
type = lib.types.path;
|
type = lib.types.path;
|
||||||
default = ./snapshot_config.toml;
|
default = ./snapshot_config.toml;
|
||||||
description = "Path to the snapshot_manager TOML config.";
|
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.
|
||||||
|
'';
|
||||||
|
};
|
||||||
EnvironmentFile = lib.mkOption {
|
EnvironmentFile = lib.mkOption {
|
||||||
type = lib.types.nullOr (lib.types.coercedTo lib.types.path toString lib.types.str);
|
type = lib.types.nullOr (lib.types.coercedTo lib.types.path toString lib.types.str);
|
||||||
default = null;
|
default = null;
|
||||||
@@ -30,7 +36,40 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
system.checks = [ snapshotManager ];
|
nixpkgs.overlays = [
|
||||||
|
(final: _prev: {
|
||||||
|
snapshot_manager_python = final.python314.withPackages snapshotManagerPackages;
|
||||||
|
snapshot_manager_test_python = final.python314.withPackages (
|
||||||
|
ps:
|
||||||
|
snapshotManagerPackages ps
|
||||||
|
++ (with ps; [
|
||||||
|
pyfakefs
|
||||||
|
pytest
|
||||||
|
pytest-asyncio
|
||||||
|
pytest-mock
|
||||||
|
pytest-xdist
|
||||||
|
])
|
||||||
|
);
|
||||||
|
snapshot_manager_tests =
|
||||||
|
final.runCommand "snapshot-manager-tests"
|
||||||
|
{
|
||||||
|
nativeBuildInputs = [ final.snapshot_manager_test_python ];
|
||||||
|
}
|
||||||
|
''
|
||||||
|
export HOME="$TMPDIR"
|
||||||
|
cd ${inputs.self}
|
||||||
|
pytest \
|
||||||
|
-o cache_dir="$TMPDIR/pytest-cache" \
|
||||||
|
tests/test_common.py \
|
||||||
|
tests/test_signal_alert.py \
|
||||||
|
tests/test_snapshot_manager.py \
|
||||||
|
tests/test_zfs.py
|
||||||
|
touch "$out"
|
||||||
|
'';
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
system.checks = [ pkgs.snapshot_manager_tests ];
|
||||||
|
|
||||||
systemd = {
|
systemd = {
|
||||||
services.snapshot_manager = {
|
services.snapshot_manager = {
|
||||||
@@ -38,9 +77,12 @@ in
|
|||||||
requires = [ "zfs-import.target" ];
|
requires = [ "zfs-import.target" ];
|
||||||
after = [ "zfs-import.target" ];
|
after = [ "zfs-import.target" ];
|
||||||
path = [ pkgs.zfs ];
|
path = [ pkgs.zfs ];
|
||||||
|
environment = {
|
||||||
|
PYTHONPATH = cfg.PYTHONPATH;
|
||||||
|
};
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
ExecStart = "${snapshotManager}/bin/snapshot-manager ${lib.escapeShellArg cfg.path} --signal-alerts ${lib.boolToString cfg.signalAlerts}";
|
ExecStart = "${pkgs.snapshot_manager_python}/bin/python -m python.tools.snapshot_manager ${lib.escapeShellArg cfg.path}";
|
||||||
}
|
}
|
||||||
// lib.optionalAttrs (cfg.EnvironmentFile != null) {
|
// lib.optionalAttrs (cfg.EnvironmentFile != null) {
|
||||||
EnvironmentFile = cfg.EnvironmentFile;
|
EnvironmentFile = cfg.EnvironmentFile;
|
||||||
|
|||||||
Generated
+23
-100
@@ -7,11 +7,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1789770686,
|
"lastModified": 1781152676,
|
||||||
"narHash": "sha256-uZkBR7yHdIKUFB5SZdfgh1qkGfI3XmYmI/lTiquxbck=",
|
"narHash": "sha256-RxWs5ND31KzTG7wvMM+PMfUjyNpmIEr999lqNARaM5o=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "disko",
|
"repo": "disko",
|
||||||
"rev": "725ea35e410ad83be4931d1bff7e090eacaf3563",
|
"rev": "ff8702b4de27f72b4c78573dfb89ec74e36abdf1",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -20,24 +20,6 @@
|
|||||||
"type": "github"
|
"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"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"home-manager": {
|
"home-manager": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
@@ -45,11 +27,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1791293500,
|
"lastModified": 1788651960,
|
||||||
"narHash": "sha256-3hwJd2/vG/VYV1P/g7oDgdbNsWODhN5F//Od+hPMm2k=",
|
"narHash": "sha256-v9wJd32eZ2bvhBzVOd7TIjLQd011P7nwOhjKtWlci5I=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "0e11b7bcba1d74ef8ea10bfdfb1e1a5811a21087",
|
"rev": "2c0350c759688177331b8f5242311fae8877bdb3",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -58,38 +40,16 @@
|
|||||||
"type": "github"
|
"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": {
|
"nixos-hardware": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1791128201,
|
"lastModified": 1788860136,
|
||||||
"narHash": "sha256-rlsYY/Dg/LhbIj51Aa6TLvRpZeb3KZgUTd7llQ5XcJE=",
|
"narHash": "sha256-MhPMOFV4pVkygWEbQ8t1De/uQ9cWF1u++tRe2L5tG48=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixos-hardware",
|
"repo": "nixos-hardware",
|
||||||
"rev": "31cc5f4d9b9ba601071e8b8504601b9b176e2756",
|
"rev": "62173785b9a18c78b4a15aca2623d02bceb9d077",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -101,11 +61,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1789546076,
|
"lastModified": 1767892417,
|
||||||
"narHash": "sha256-vWkSk5bbfTqdtMoSgD9FshACO8JCvXTFi+3cqEp0mH0=",
|
"narHash": "sha256-8bW3q88CEg2u4hSP66Vf4lpbLonHz7hqDNBMcCY7E9U=",
|
||||||
"rev": "b1b875982b17dabde9b4a37f3e229e74913e6db3",
|
"rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba",
|
||||||
"type": "tarball",
|
"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": {
|
"original": {
|
||||||
"type": "tarball",
|
"type": "tarball",
|
||||||
@@ -114,11 +74,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-master": {
|
"nixpkgs-master": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1791296764,
|
"lastModified": 1788892992,
|
||||||
"narHash": "sha256-N2b6Uxdr1MFhscEPqDyntTTtGZASgq3XYE0ujRzfeNI=",
|
"narHash": "sha256-cIMFh9gyU4/aLeB3JCcsWM3tTAvD9pAq9Smr1Wa8aIU=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "a15afac8691bd24f746255ffbd76306693e4cf7a",
|
"rev": "dff6994123e257ec9901c271bc2b52e64d7c8f05",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -146,11 +106,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1791260994,
|
"lastModified": 1788752844,
|
||||||
"narHash": "sha256-Miqqk/ammqnTUxaoCyvtwoPeLbI1ksFyLxi/CazZpWY=",
|
"narHash": "sha256-VaWGJ6+cIYN2erfSecbRV+4ljI185Ty2wUrXyvQbgOw=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "151fa4e8ddfdd8dd25d945ad94ed54a13de9f6e4",
|
"rev": "dc5d91f840324650bac8c379428c7037a416959a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -164,34 +124,12 @@
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"disko": "disko",
|
"disko": "disko",
|
||||||
"home-manager": "home-manager",
|
"home-manager": "home-manager",
|
||||||
"jellyswarrm": "jellyswarrm",
|
|
||||||
"nixos-hardware": "nixos-hardware",
|
"nixos-hardware": "nixos-hardware",
|
||||||
"nixpkgs": "nixpkgs_2",
|
"nixpkgs": "nixpkgs_2",
|
||||||
"nixpkgs-master": "nixpkgs-master",
|
"nixpkgs-master": "nixpkgs-master",
|
||||||
"nixpkgs-stable": "nixpkgs-stable",
|
"nixpkgs-stable": "nixpkgs-stable",
|
||||||
"sops-nix": "sops-nix",
|
"sops-nix": "sops-nix",
|
||||||
"systems": "systems_2"
|
"systems": "systems"
|
||||||
}
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sops-nix": {
|
"sops-nix": {
|
||||||
@@ -201,11 +139,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1791103873,
|
"lastModified": 1788337237,
|
||||||
"narHash": "sha256-nFxM+pKoZ8LJAEnUXARyCaOAloWgaW9kZQOSjWzKcTE=",
|
"narHash": "sha256-gkSH8VUtCo6hnysNmb9DbTuDepH2t5pv+QWjP75xKAk=",
|
||||||
"owner": "Mic92",
|
"owner": "Mic92",
|
||||||
"repo": "sops-nix",
|
"repo": "sops-nix",
|
||||||
"rev": "dcd241ba97088c22569d1573286e1b9daad340c0",
|
"rev": "fbf759290e0cb0a98dfc813a4eb7d53ad1dacb57",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -215,21 +153,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems": {
|
"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": {
|
"locked": {
|
||||||
"lastModified": 1689347949,
|
"lastModified": 1689347949,
|
||||||
"narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=",
|
"narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=",
|
||||||
|
|||||||
@@ -21,11 +21,6 @@
|
|||||||
|
|
||||||
nixos-hardware.url = "github:nixos/nixos-hardware/master";
|
nixos-hardware.url = "github:nixos/nixos-hardware/master";
|
||||||
|
|
||||||
jellyswarrm = {
|
|
||||||
url = "github:LLukas22/Jellyswarrm";
|
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
};
|
|
||||||
|
|
||||||
home-manager = {
|
home-manager = {
|
||||||
url = "github:nix-community/home-manager";
|
url = "github:nix-community/home-manager";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
+19
-27
@@ -5,28 +5,23 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
|
from os import getenv
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SignalRPCError(RuntimeError):
|
def signal_alert(body: str) -> None:
|
||||||
"""signal-cli returned an error or closed without responding."""
|
"""Send a signal alert.
|
||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
body: The body of the alert.
|
body (str): 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.
|
|
||||||
"""
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
signal_rpc_id = "signal-alert"
|
signal_rpc_id = "signal-alert"
|
||||||
request = {
|
request = {
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -41,7 +36,7 @@ def signal_alert(body: str, from_phone: str, to_phone: str, *, timeout: float =
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
||||||
client.settimeout(timeout)
|
client.settimeout(4.0)
|
||||||
client.connect("/run/signal-cli/socket")
|
client.connect("/run/signal-cli/socket")
|
||||||
client.sendall(json.dumps(request).encode() + b"\n")
|
client.sendall(json.dumps(request).encode() + b"\n")
|
||||||
|
|
||||||
@@ -51,14 +46,11 @@ def signal_alert(body: str, from_phone: str, to_phone: str, *, timeout: float =
|
|||||||
while b"\n" in buffer:
|
while b"\n" in buffer:
|
||||||
line, buffer = buffer.split(b"\n", maxsplit=1)
|
line, buffer = buffer.split(b"\n", maxsplit=1)
|
||||||
response = json.loads(line)
|
response = json.loads(line)
|
||||||
if response.get("id") != signal_rpc_id:
|
if response.get("id") == signal_rpc_id:
|
||||||
continue
|
if error := response.get("error"):
|
||||||
if rpc_error := response.get("error"):
|
logger.error("Signal RPC returned an error: %s", error)
|
||||||
error = f"Signal RPC returned an error: {rpc_error}"
|
return
|
||||||
raise SignalRPCError(error)
|
|
||||||
return
|
logger.error("Signal RPC socket closed before returning a response")
|
||||||
error = "Signal RPC socket closed before returning a response"
|
except OSError, json.JSONDecodeError:
|
||||||
raise SignalRPCError(error)
|
logger.exception("Unable to communicate with signal-cli")
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
error = f"Signal RPC failed: {exc!r}"
|
|
||||||
raise SignalRPCError(error) from exc
|
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""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")
|
||||||
|
|
||||||
|
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)
|
||||||
|
continue
|
||||||
|
count_lookup = get_count_lookup(config_file, dataset.name)
|
||||||
|
logger.info(f"using {count_lookup} for {dataset.name}")
|
||||||
|
get_snapshots_to_delete(dataset, count_lookup)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("snapshot_manager failed")
|
||||||
|
signal_alert("snapshot_manager failed")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
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],
|
||||||
|
) -> None:
|
||||||
|
"""Get snapshots to delete.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dataset (Dataset): the dataset
|
||||||
|
count_lookup (dict[str, int]): the count lookup
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{ lib }:
|
|
||||||
lib.fileset.toSource {
|
|
||||||
root = ../.;
|
|
||||||
fileset = lib.fileset.unions [
|
|
||||||
../Cargo.toml
|
|
||||||
../Cargo.lock
|
|
||||||
../clippy.toml
|
|
||||||
./.
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
|
|
||||||
@@ -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
@@ -6,15 +6,16 @@ description = "Fetch privacy-masked weather for a van and publish it to Home Ass
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow = "1.0"
|
||||||
chrono.workspace = true
|
chrono = "0.4"
|
||||||
clap = { workspace = true, features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
env_logger.workspace = true
|
env_logger = "0.11"
|
||||||
log.workspace = true
|
log = "0.4"
|
||||||
num-traits = "0.2"
|
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
all = "deny"
|
||||||
|
pedantic = "deny"
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
@@ -1,18 +1,10 @@
|
|||||||
{ lib, rustPlatform }:
|
{ rustPlatform }:
|
||||||
rustPlatform.buildRustPackage {
|
rustPlatform.buildRustPackage {
|
||||||
pname = "van-weather";
|
pname = "van-weather";
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
src = import ../rust-source.nix { inherit lib; };
|
src = ./.;
|
||||||
|
|
||||||
cargoLock.lockFile = ../../Cargo.lock;
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
cargoBuildFlags = [
|
|
||||||
"-p"
|
|
||||||
"van-weather"
|
|
||||||
];
|
|
||||||
cargoTestFlags = [
|
|
||||||
"-p"
|
|
||||||
"van-weather"
|
|
||||||
];
|
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
description = "Privacy-masked van weather publisher for Home Assistant";
|
description = "Privacy-masked van weather publisher for Home Assistant";
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ use anyhow::{Context, Result, bail};
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use log::{error, info};
|
use log::{error, info};
|
||||||
use num_traits::ToPrimitive;
|
|
||||||
use reqwest::{
|
use reqwest::{
|
||||||
StatusCode, Url,
|
StatusCode, Url,
|
||||||
blocking::{Client, ClientBuilder},
|
blocking::{Client, ClientBuilder},
|
||||||
@@ -26,7 +25,7 @@ const fn decimal_factor(decimals: u32) -> f64 {
|
|||||||
let mut remaining = decimals;
|
let mut remaining = decimals;
|
||||||
while remaining > 0 {
|
while remaining > 0 {
|
||||||
factor *= 10.0;
|
factor *= 10.0;
|
||||||
remaining = remaining.saturating_sub(1);
|
remaining -= 1;
|
||||||
}
|
}
|
||||||
factor
|
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)> {
|
fn sensor(entity_id: &str, state: Option<Value>, attributes: Value) -> Option<(String, Value)> {
|
||||||
let state = state?;
|
state.map(|state| {
|
||||||
let mut payload = serde_json::Map::new();
|
let mut payload = serde_json::Map::new();
|
||||||
payload.insert("state".to_owned(), state);
|
payload.insert("state".to_owned(), state);
|
||||||
payload.insert("attributes".to_owned(), attributes);
|
payload.insert("attributes".to_owned(), attributes);
|
||||||
Some((entity_id.to_owned(), Value::Object(payload)))
|
(entity_id.to_owned(), Value::Object(payload))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
fn percent(value: Option<f64>) -> i64 {
|
fn percent(value: Option<f64>) -> i64 {
|
||||||
// Preserve Python's int(probability * 100) behavior for Home Assistant.
|
// Preserve Python's int(probability * 100) behavior for Home Assistant.
|
||||||
let value = value.unwrap_or_default() * 100.0;
|
(value.unwrap_or_default() * 100.0) as i64
|
||||||
value.to_i64().unwrap_or_else(|| {
|
|
||||||
if value.is_nan() {
|
|
||||||
0
|
|
||||||
} else if value.is_sign_negative() {
|
|
||||||
i64::MIN
|
|
||||||
} else {
|
|
||||||
i64::MAX
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -570,21 +562,4 @@ mod tests {
|
|||||||
assert!((mask_coordinate(37.7749) - 37.8).abs() < f64::EPSILON);
|
assert!((mask_coordinate(37.7749) - 37.8).abs() < f64::EPSILON);
|
||||||
assert!((mask_coordinate(-122.4194) - (-122.4)).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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
@@ -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."
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"${inputs.self}/common/optional/monitoring-agent.nix"
|
"${inputs.self}/common/optional/monitoring-agent.nix"
|
||||||
"${inputs.self}/common/optional/nvidia.nix"
|
"${inputs.self}/common/optional/nvidia.nix"
|
||||||
"${inputs.self}/common/optional/scanner.nix"
|
"${inputs.self}/common/optional/scanner.nix"
|
||||||
"${inputs.self}/common/optional/signal-cli.nix"
|
"${inputs.self}/common/optional/steam.nix"
|
||||||
"${inputs.self}/common/optional/syncthing_base.nix"
|
"${inputs.self}/common/optional/syncthing_base.nix"
|
||||||
"${inputs.self}/common/optional/systemd-boot.nix"
|
"${inputs.self}/common/optional/systemd-boot.nix"
|
||||||
"${inputs.self}/common/optional/tailscale.nix"
|
"${inputs.self}/common/optional/tailscale.nix"
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
"${inputs.self}/users/richie"
|
"${inputs.self}/users/richie"
|
||||||
"${inputs.self}/common/global"
|
"${inputs.self}/common/global"
|
||||||
"${inputs.self}/common/optional/docker.nix"
|
"${inputs.self}/common/optional/docker.nix"
|
||||||
"${inputs.self}/common/optional/signal-cli.nix"
|
|
||||||
"${inputs.self}/common/optional/ssh_decrypt.nix"
|
"${inputs.self}/common/optional/ssh_decrypt.nix"
|
||||||
"${inputs.self}/common/optional/syncthing_base.nix"
|
"${inputs.self}/common/optional/syncthing_base.nix"
|
||||||
"${inputs.self}/common/optional/systemd-boot.nix"
|
"${inputs.self}/common/optional/systemd-boot.nix"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# docker_networks
|
||||||
|
|
||||||
|
docker network create -d bridge web
|
||||||
@@ -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
|
|
||||||
@@ -13,10 +13,6 @@
|
|||||||
services = {
|
services = {
|
||||||
home-assistant = {
|
home-assistant = {
|
||||||
enable = true;
|
enable = true;
|
||||||
# Keep Home Assistant's Python dependencies on baseline x86-64.
|
|
||||||
package = pkgs.x86-v1.home-assistant.overrideAttrs (_: {
|
|
||||||
doInstallCheck = false;
|
|
||||||
});
|
|
||||||
config = {
|
config = {
|
||||||
homeassistant = {
|
homeassistant = {
|
||||||
time_zone = "America/New_York";
|
time_zone = "America/New_York";
|
||||||
@@ -83,7 +79,7 @@
|
|||||||
"isal"
|
"isal"
|
||||||
"modbus" # for victron modbus integration
|
"modbus" # for victron modbus integration
|
||||||
];
|
];
|
||||||
customComponents = with pkgs.x86-v1.home-assistant-custom-components; [
|
customComponents = with pkgs.home-assistant-custom-components; [
|
||||||
garmin_connect
|
garmin_connect
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ in
|
|||||||
"${inputs.self}/common/global"
|
"${inputs.self}/common/global"
|
||||||
"${inputs.self}/common/optional/docker.nix"
|
"${inputs.self}/common/optional/docker.nix"
|
||||||
"${inputs.self}/common/optional/monitoring-agent.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/ssh_decrypt.nix"
|
||||||
|
"${inputs.self}/common/optional/signal-cli.nix"
|
||||||
"${inputs.self}/common/optional/syncthing_base.nix"
|
"${inputs.self}/common/optional/syncthing_base.nix"
|
||||||
"${inputs.self}/common/optional/tailscale.nix"
|
"${inputs.self}/common/optional/tailscale.nix"
|
||||||
"${inputs.self}/common/optional/update.nix"
|
"${inputs.self}/common/optional/update.nix"
|
||||||
@@ -38,5 +38,10 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
users.groups = {
|
||||||
|
nornsight = { };
|
||||||
|
nornsight-admin = { };
|
||||||
|
};
|
||||||
|
|
||||||
system.stateVersion = "24.05";
|
system.stateVersion = "24.05";
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Monitoring
|
||||||
|
|
||||||
|
## Vultr API metrics
|
||||||
|
|
||||||
|
The `vultr-exporter` service reads its API token from:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/zfs/storage/secrets/services/vultr-exporter
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the file on Jeeves as root with the following contents:
|
||||||
|
|
||||||
|
```text
|
||||||
|
API_KEY=<Vultr API token>
|
||||||
|
```
|
||||||
|
|
||||||
|
The token needs read access to the Vultr Account and Billing APIs. Unrelated
|
||||||
|
resource collectors are disabled in the packaged exporter.
|
||||||
|
|
||||||
|
Restrict the file to root and ensure the public egress IP used by Jeeves is
|
||||||
|
allowed for the token in the Vultr API settings:
|
||||||
|
|
||||||
|
```console
|
||||||
|
sudo chown root:root /zfs/storage/secrets/services/vultr-exporter
|
||||||
|
sudo chmod 600 /zfs/storage/secrets/services/vultr-exporter
|
||||||
|
```
|
||||||
|
|
||||||
|
The exporter listens on `127.0.0.1:9188`; it is scraped by the local
|
||||||
|
`prometheus-main` service every five minutes and is not exposed through the
|
||||||
|
host firewall.
|
||||||
|
|
||||||
|
Portal-1 exposes its node exporter only through `tailscale0` on port `9100`.
|
||||||
|
Jeeves reaches it using the Portal-1 Tailscale hostname.
|
||||||
@@ -7,8 +7,8 @@ let
|
|||||||
gitea_ssh = 2223;
|
gitea_ssh = 2223;
|
||||||
grafana = 3000;
|
grafana = 3000;
|
||||||
jellyfin_http = 8096;
|
jellyfin_http = 8096;
|
||||||
jellyswarrm = 3100;
|
|
||||||
nix_binary_cache = 5000;
|
nix_binary_cache = 5000;
|
||||||
|
nornsight = 8001;
|
||||||
ollama = 11434;
|
ollama = 11434;
|
||||||
open_webui = 8080;
|
open_webui = 8080;
|
||||||
postgresql = 5432;
|
postgresql = 5432;
|
||||||
@@ -27,8 +27,8 @@ in
|
|||||||
ports.audiobookshelf
|
ports.audiobookshelf
|
||||||
ports.gems
|
ports.gems
|
||||||
ports.jellyfin_http
|
ports.jellyfin_http
|
||||||
ports.jellyswarrm
|
|
||||||
ports.nix_binary_cache
|
ports.nix_binary_cache
|
||||||
|
ports.nornsight
|
||||||
ports.share
|
ports.share
|
||||||
];
|
];
|
||||||
allowedTCPPorts = [
|
allowedTCPPorts = [
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ let
|
|||||||
nixos-rebuild
|
nixos-rebuild
|
||||||
nodejs
|
nodejs
|
||||||
ruff
|
ruff
|
||||||
rustfmt
|
|
||||||
treefmt
|
treefmt
|
||||||
wget
|
wget
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
system_token: ENC[AES256_GCM,data:aDNBcQuFE3ImFMOs83cmyeWcANavFoYFy564zSq8XcgFL2VUho2D8UtW/e+hTdJBEjeNTd7gSrtFd/Ved6QgZHgPCyxUmNkPcr6XqTe6a+AFWCx7tVSWYSweKt7BI5GcZ4oA7oeOJ+/9Tefbo5Yg0QI1HgKcS3D7BHEdr5iQi4E=,iv:lvBbttBqzIMvsFobUZQfbaYCyDrjztB/cb3r2P1TtwA=,tag:AEI1Rj6/A6f0K9MBx9Ai0w==,type:str]
|
|
||||||
sops:
|
|
||||||
age:
|
|
||||||
- enc: |
|
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJQnVqUkxRUm5PQ21lV01o
|
|
||||||
OEJuMWtSWlE1Z09RaUxNeHpycU1DcnM1dGlVCmEvUUlmcmxnWHRYS016U3RhYkw1
|
|
||||||
enZQcnJrSUwybGpKWkx4bm9ra2lVWGcKLS0tIDRDKzl0bEp6YnhweTN4YW9LU2hI
|
|
||||||
alF5V3BIQ0R5MXZFSmt6dnRXanp0Ym8KQhrtXzQLEG2pXzKs1Z8i9DPigWKpiovZ
|
|
||||||
dfAw2Tzp4B8kTHuo4PuYU/S37HoYYhf5ZV5vRiUoNYhrr6Q8wwu1Iw==
|
|
||||||
-----END AGE ENCRYPTED FILE-----
|
|
||||||
recipient: age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c
|
|
||||||
- enc: |
|
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBod2dvaVhxNW1tWld0eW1x
|
|
||||||
TWZuNFJWWndQSDVKWU9WdjlTWW13RU9RZ2gwCjJPb0VUTmlMWGFJTEFFdE1jbHRT
|
|
||||||
YllSQXBNeUFFSk9JYmdJSGVKQVhSU0EKLS0tIG16T2R6MzJKVTE3ZDhCVU93WXlB
|
|
||||||
QW13QVc5dUpVTFFMZ0l3dFlPY2NOYXcK1IdjpBBhT2l5FvFNwLR+XH3AqbUivYNd
|
|
||||||
pQwOr6T7ysB70h12EKZ5V4Ti4BT8ETeQUgFC2JbX2OZe0cTrxtacQw==
|
|
||||||
-----END AGE ENCRYPTED FILE-----
|
|
||||||
recipient: age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv
|
|
||||||
lastmodified: "2026-10-14T02:08:46Z"
|
|
||||||
mac: ENC[AES256_GCM,data:4eVms5du2CbLqdY+kQYo6kcDRIZD+h950tInjESug9kjkSBQhWCpJLrIM1Ud9FYtqPE59rsd4s9wlDRY1+hpvBg3YgdcKjSBR9sNXL6D5cCE+2aYfTrkVgkcEwWeFc2OauXjkrqR2QXpq5ltq/3He6Hxdr08kC37GunvZEfJwzg=,iv:3qkVvRPUP/C3s5+a7dpOmoqaXbnWKxgM7OF8noxxMpw=,tag:jUJsLpLr8rxii0Hh6E9bXw==,type:str]
|
|
||||||
unencrypted_suffix: _unencrypted
|
|
||||||
version: 3.13.3
|
|
||||||
@@ -23,6 +23,7 @@ in
|
|||||||
};
|
};
|
||||||
service.DISABLE_REGISTRATION = true;
|
service.DISABLE_REGISTRATION = true;
|
||||||
server = {
|
server = {
|
||||||
|
DOMAIN = "gitea.tmmworkshop.com";
|
||||||
ROOT_URL = "https://gitea.tmmworkshop.com/";
|
ROOT_URL = "https://gitea.tmmworkshop.com/";
|
||||||
HTTP_PORT = 6443;
|
HTTP_PORT = 6443;
|
||||||
BUILTIN_SSH_SERVER_USER = "gitea";
|
BUILTIN_SSH_SERVER_USER = "gitea";
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
{ inputs, ... }:
|
|
||||||
let
|
|
||||||
vars = import ../vars.nix;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
imports = [ inputs.jellyswarrm.nixosModules.default ];
|
|
||||||
|
|
||||||
services.jellyswarrm = {
|
|
||||||
enable = true;
|
|
||||||
host = "0.0.0.0";
|
|
||||||
port = 3100;
|
|
||||||
dataDir = "${vars.services}/jellyswarrm";
|
|
||||||
username = "admin";
|
|
||||||
passwordFile = "${vars.secrets}/services/jellyswarrm-password";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{ pkgs, ... }:
|
||||||
|
let
|
||||||
|
vars = import ../vars.nix;
|
||||||
|
stateDir = "${vars.services}/nornsight";
|
||||||
|
appDir = "${stateDir}/app";
|
||||||
|
binPath = pkgs.lib.makeBinPath [
|
||||||
|
pkgs.binutils
|
||||||
|
pkgs.libpq
|
||||||
|
pkgs.postgresql
|
||||||
|
pkgs.stdenv.cc
|
||||||
|
];
|
||||||
|
libraryPath = pkgs.lib.makeLibraryPath [
|
||||||
|
pkgs.libpq
|
||||||
|
pkgs.postgresql.lib
|
||||||
|
];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d ${stateDir} 0750 nornsight nornsight - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
users.users.nornsight = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "nornsight";
|
||||||
|
home = stateDir;
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.nornsight = {
|
||||||
|
description = "Norn Sight";
|
||||||
|
after = [ "network-online.target" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
HOME = stateDir;
|
||||||
|
UV_CACHE_DIR = "${stateDir}/.cache/uv";
|
||||||
|
UV_PROJECT_ENVIRONMENT = "${appDir}/.venv";
|
||||||
|
UV_PYTHON = "${pkgs.python313}/bin/python3.13";
|
||||||
|
UV_PYTHON_DOWNLOADS = "never";
|
||||||
|
LD_LIBRARY_PATH = libraryPath;
|
||||||
|
LIBRARY_PATH = libraryPath;
|
||||||
|
PSYCOPG_IMPL = "python";
|
||||||
|
};
|
||||||
|
|
||||||
|
path = with pkgs; [
|
||||||
|
bash
|
||||||
|
coreutils
|
||||||
|
git
|
||||||
|
uv
|
||||||
|
];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = "nornsight";
|
||||||
|
Group = "nornsight";
|
||||||
|
EnvironmentFile = "-${vars.secrets}/services/nornsight";
|
||||||
|
WorkingDirectory = stateDir;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "5s";
|
||||||
|
StandardOutput = "journal";
|
||||||
|
StandardError = "journal";
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
ProtectHome = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ReadWritePaths = [ stateDir ];
|
||||||
|
};
|
||||||
|
|
||||||
|
script = ''
|
||||||
|
set -eu
|
||||||
|
export PATH="${binPath}:$PATH"
|
||||||
|
export LD_LIBRARY_PATH="${libraryPath}:''${LD_LIBRARY_PATH:-}"
|
||||||
|
export LIBRARY_PATH="${libraryPath}:''${LIBRARY_PATH:-}"
|
||||||
|
|
||||||
|
: "''${NORN_SIGHT_REPO_URL:?NORN_SIGHT_REPO_URL is required}"
|
||||||
|
branch="''${NORN_SIGHT_BRANCH:-main}"
|
||||||
|
|
||||||
|
if [ -d "${appDir}/.git" ]; then
|
||||||
|
current_origin="$(git -C "${appDir}" remote get-url origin)"
|
||||||
|
if [ "$current_origin" != "$NORN_SIGHT_REPO_URL" ]; then
|
||||||
|
rm -rf "${appDir}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "${appDir}/.git" ]; then
|
||||||
|
git clone --branch "$branch" "$NORN_SIGHT_REPO_URL" "${appDir}"
|
||||||
|
else
|
||||||
|
cd "${appDir}"
|
||||||
|
git fetch origin "$branch"
|
||||||
|
git checkout "$branch"
|
||||||
|
git pull --ff-only origin "$branch"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${appDir}"
|
||||||
|
uv sync --upgrade
|
||||||
|
uv run python - <<'PY'
|
||||||
|
import ctypes.util
|
||||||
|
import os
|
||||||
|
|
||||||
|
print(f"LD_LIBRARY_PATH={os.environ.get('LD_LIBRARY_PATH')}")
|
||||||
|
print(f"LIBRARY_PATH={os.environ.get('LIBRARY_PATH')}")
|
||||||
|
print(f"libpq={ctypes.util.find_library('pq')}")
|
||||||
|
PY
|
||||||
|
exec uv run uvicorn pipelines.web.main:app --host 0.0.0.0 --port 8001
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# portal_1
|
||||||
|
|
||||||
|
Minimal NixOS target for a Vultr VM, installed with nixos-anywhere. The Nix
|
||||||
|
flake target is `portal_1`; the machine hostname is `portal-1` because DNS
|
||||||
|
hostnames cannot contain underscores.
|
||||||
|
|
||||||
|
## Before deploying
|
||||||
|
|
||||||
|
1. Confirm the VM's system disk is `/dev/vda`. If it is not, update both
|
||||||
|
references in `disk-config.nix`.
|
||||||
|
2. Confirm the SSH public key in `default.nix` is the key that should have
|
||||||
|
administrator access.
|
||||||
|
3. Boot the VM into a NixOS installer or another nixos-anywhere-compatible
|
||||||
|
Linux rescue environment with root SSH access. Keep this environment
|
||||||
|
running while completing the SOPS bootstrap below.
|
||||||
|
|
||||||
|
## Bootstrap SOPS
|
||||||
|
|
||||||
|
Use the rescue environment's SSH host key as the permanent portal identity.
|
||||||
|
Replace `VM_IP` below:
|
||||||
|
|
||||||
|
```console
|
||||||
|
ssh root@VM_IP 'cat /etc/ssh/ssh_host_ed25519_key.pub' | \
|
||||||
|
nix shell nixpkgs#ssh-to-age --command ssh-to-age
|
||||||
|
```
|
||||||
|
|
||||||
|
This prints an `age1...` recipient; it does not copy the private key. Add the
|
||||||
|
recipient to `.sops.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- &system_portal_1 age1...
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add `*system_portal_1` to the age recipients for
|
||||||
|
`users/secrets.yaml`. Re-encrypt the existing file for the new recipient and
|
||||||
|
add the Tailscale key:
|
||||||
|
|
||||||
|
```console
|
||||||
|
nix shell nixpkgs#sops --command sops updatekeys users/secrets.yaml
|
||||||
|
nix shell nixpkgs#sops --command sops users/secrets.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the OAuth client secret from the `Auth Keys: Write` credential in the SOPS
|
||||||
|
editor and save it:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tailscale_auth_key: tskey-client-...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
From the repository root, replace `VM_IP` with the VM's public IP:
|
||||||
|
|
||||||
|
```console
|
||||||
|
nix run github:nix-community/nixos-anywhere -- \
|
||||||
|
--copy-host-keys --flake .#portal_1 root@VM_IP
|
||||||
|
```
|
||||||
|
|
||||||
|
This repartitions `/dev/vda`, so anything already on that disk is erased. The
|
||||||
|
layout reserves 8 GiB for swap and assigns the remaining space to the root
|
||||||
|
filesystem.
|
||||||
|
|
||||||
|
`--copy-host-keys` preserves the same private SSH host key at
|
||||||
|
`/etc/ssh/ssh_host_ed25519_key` on the installed system. SOPS-Nix converts that
|
||||||
|
key to an age identity during activation. After the reboot, connect as
|
||||||
|
`richie` and verify that automatic Tailscale enrollment succeeded:
|
||||||
|
|
||||||
|
```console
|
||||||
|
ssh -p 278 richie@VM_IP
|
||||||
|
sudo tailscale status
|
||||||
|
```
|
||||||
|
|
||||||
|
The installed OpenSSH service listens on port 278. Port 22 is served by
|
||||||
|
Endlessh and will not provide an SSH login.
|
||||||
|
|
||||||
|
HAProxy uses the same frontend, routing, and rate-limiting configuration as
|
||||||
|
Jeeves. Portal manages the ACME certificates for the existing public domains;
|
||||||
|
their DNS records must resolve to Portal for HTTP-01 issuance and renewal.
|
||||||
|
|
||||||
|
The application backends still use Jeeves' original `127.0.0.1` addresses.
|
||||||
|
Replace them with the corresponding Tailscale addresses before directing
|
||||||
|
application traffic through Portal. Ports 80 and 443 are allowed through the
|
||||||
|
firewall.
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
inputs.disko.nixosModules.disko
|
inputs.disko.nixosModules.disko
|
||||||
"${inputs.self}/users/richie"
|
"${inputs.self}/users/richie"
|
||||||
"${inputs.self}/common/global"
|
"${inputs.self}/common/global"
|
||||||
"${inputs.self}/common/optional/signal-cli.nix"
|
|
||||||
"${inputs.self}/common/optional/tailscale.nix"
|
"${inputs.self}/common/optional/tailscale.nix"
|
||||||
"${inputs.self}/common/optional/x86-64-v3"
|
"${inputs.self}/common/optional/x86-64-v3"
|
||||||
./disk-config.nix
|
./disk-config.nix
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ let
|
|||||||
"jellyfin"
|
"jellyfin"
|
||||||
"share"
|
"share"
|
||||||
];
|
];
|
||||||
|
extraDomains = [ "www.norn-sight.com" ];
|
||||||
|
|
||||||
makeCert = name: {
|
makeCert = name: {
|
||||||
name = "${name}.tmmworkshop.com";
|
name = "${name}.tmmworkshop.com";
|
||||||
value = {
|
value = {
|
||||||
@@ -16,7 +18,18 @@ let
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
acmeServices = map (domain: "acme-${domain}.tmmworkshop.com.service") domains;
|
makeExtraCert = name: {
|
||||||
|
inherit name;
|
||||||
|
value = {
|
||||||
|
webroot = "/var/lib/acme/.challenges";
|
||||||
|
group = "acme";
|
||||||
|
reloadServices = [ "haproxy.service" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
acmeServices =
|
||||||
|
map (domain: "acme-${domain}.tmmworkshop.com.service") domains
|
||||||
|
++ map (domain: "acme-${domain}.service") extraDomains;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
networking.firewall.allowedTCPPorts = [
|
networking.firewall.allowedTCPPorts = [
|
||||||
@@ -52,7 +65,7 @@ in
|
|||||||
security.acme = {
|
security.acme = {
|
||||||
acceptTerms = true;
|
acceptTerms = true;
|
||||||
defaults.email = "Richie@tmmworkshop.com";
|
defaults.email = "Richie@tmmworkshop.com";
|
||||||
certs = builtins.listToAttrs ((map makeCert domains));
|
certs = builtins.listToAttrs ((map makeCert domains) ++ (map makeExtraCert extraDomains));
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd = {
|
systemd = {
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
global
|
global
|
||||||
log stdout format raw local0
|
log stdout format raw local0
|
||||||
# Structured HTTP log fields, grouped so the format remains editable here.
|
|
||||||
setenv HAPROXY_JSON_CLIENT "%(client_ip)ci %(client_port)cp"
|
|
||||||
setenv HAPROXY_JSON_ROUTING "%(frontend_name)ft %(backend_name)b %(server_name)s"
|
|
||||||
setenv HAPROXY_JSON_HTTP "%(http_method)HM %(request_path)HP %(http_version)HV %(status_code)ST %(bytes_sent)B"
|
|
||||||
setenv HAPROXY_JSON_TIMINGS "%(time_request_ms)TR %(time_wait_ms)Tw %(time_connect_ms)Tc %(time_response_ms)Tr %(time_total_ms)Ta"
|
|
||||||
setenv HAPROXY_JSON_RESULT "%(termination_state)tsc %(retries)rc"
|
|
||||||
setenv HAPROXY_JSON_CONNECTIONS "%(active_connections)ac %(frontend_connections)fc %(backend_connections)bc %(server_connections)sc %(server_queue)sq %(backend_queue)bq"
|
|
||||||
setenv HAPROXY_JSON_TLS "%(tls_sni)[ssl_fc_sni] %(tls_version)sslv %(tls_cipher)sslc %(tls_resumed)[ssl_fc_is_resumed]"
|
|
||||||
setenv HAPROXY_JSON_LOG "${HAPROXY_JSON_CLIENT} ${HAPROXY_JSON_ROUTING} ${HAPROXY_JSON_HTTP} ${HAPROXY_JSON_TIMINGS} ${HAPROXY_JSON_RESULT} ${HAPROXY_JSON_CONNECTIONS} ${HAPROXY_JSON_TLS}"
|
|
||||||
# stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
|
# stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
|
||||||
stats timeout 30s
|
stats timeout 30s
|
||||||
|
|
||||||
defaults
|
defaults
|
||||||
log global
|
log global
|
||||||
mode http
|
mode http
|
||||||
log-format "%{+json}o ${HAPROXY_JSON_LOG}"
|
option httplog
|
||||||
retries 3
|
retries 3
|
||||||
maxconn 2000
|
maxconn 2000
|
||||||
timeout connect 5s
|
timeout connect 5s
|
||||||
@@ -32,7 +23,7 @@ defaults
|
|||||||
#Application Setup
|
#Application Setup
|
||||||
frontend ContentSwitching
|
frontend ContentSwitching
|
||||||
bind [::]:80 v4v6
|
bind [::]:80 v4v6
|
||||||
bind [::]:443 v4v6 ssl crt /var/lib/acme/audiobookshelf.tmmworkshop.com/full.pem crt /var/lib/acme/cache.tmmworkshop.com/full.pem crt /var/lib/acme/gems.tmmworkshop.com/full.pem crt /var/lib/acme/jellyfin.tmmworkshop.com/full.pem crt /var/lib/acme/share.tmmworkshop.com/full.pem crt /var/lib/acme/gitea.tmmworkshop.com/full.pem crt
|
bind [::]:443 v4v6 ssl crt /var/lib/acme/audiobookshelf.tmmworkshop.com/full.pem crt /var/lib/acme/cache.tmmworkshop.com/full.pem crt /var/lib/acme/gems.tmmworkshop.com/full.pem crt /var/lib/acme/jellyfin.tmmworkshop.com/full.pem crt /var/lib/acme/share.tmmworkshop.com/full.pem crt /var/lib/acme/gitea.tmmworkshop.com/full.pem crt /var/lib/acme/www.norn-sight.com/full.pem
|
||||||
mode http
|
mode http
|
||||||
|
|
||||||
# ACME challenge routing (must be first)
|
# ACME challenge routing (must be first)
|
||||||
@@ -45,6 +36,7 @@ frontend ContentSwitching
|
|||||||
acl host_share hdr(host) -i share.tmmworkshop.com
|
acl host_share hdr(host) -i share.tmmworkshop.com
|
||||||
acl host_gitea hdr(host) -i gitea.tmmworkshop.com
|
acl host_gitea hdr(host) -i gitea.tmmworkshop.com
|
||||||
acl host_gems hdr(host) -i gems.tmmworkshop.com
|
acl host_gems hdr(host) -i gems.tmmworkshop.com
|
||||||
|
acl host_norn_sight hdr(host) -i www.norn-sight.com
|
||||||
|
|
||||||
# --- Request logging ---
|
# --- Request logging ---
|
||||||
# Capture the Host header and User-Agent so the httplog shows who is
|
# Capture the Host header and User-Agent so the httplog shows who is
|
||||||
@@ -116,6 +108,7 @@ frontend ContentSwitching
|
|||||||
use_backend share_nodes if host_share
|
use_backend share_nodes if host_share
|
||||||
use_backend gitea if host_gitea
|
use_backend gitea if host_gitea
|
||||||
use_backend gems if host_gems
|
use_backend gems if host_gems
|
||||||
|
use_backend norn_sight if host_norn_sight
|
||||||
|
|
||||||
# Proxy Gitea's built-in SSH server over Tailscale to Jeeves. Port 22 remains
|
# Proxy Gitea's built-in SSH server over Tailscale to Jeeves. Port 22 remains
|
||||||
# available to Endlessh on Portal.
|
# available to Endlessh on Portal.
|
||||||
@@ -167,3 +160,7 @@ backend gems
|
|||||||
option forwardfor
|
option forwardfor
|
||||||
timeout server 1h
|
timeout server 1h
|
||||||
server gems jeeves:8002
|
server gems jeeves:8002
|
||||||
|
|
||||||
|
backend norn_sight
|
||||||
|
mode http
|
||||||
|
server server jeeves:8001
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# install
|
|
||||||
|
|
||||||
## preinstall steps
|
|
||||||
|
|
||||||
remove old tailscale entry
|
|
||||||
|
|
||||||
## commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
scp root@149.28.55.47:/etc/ssh/ssh_host_ed25519_key.pub ./temp.pub
|
|
||||||
nix-shell -p ssh-to-age --run 'cat temp.pub | ssh-to-age'
|
|
||||||
rm temp.pub
|
|
||||||
nix-shell -p sops --run "sops updatekeys users/secrets.yaml"
|
|
||||||
nix run github:nix-community/nixos-anywhere -- --ssh-port 22 --copy-host-keys --build-on local --flake .#portal-1 root@149.28.55.47
|
|
||||||
ssh richie@149.28.55.47 -p 278
|
|
||||||
kitty +kitten ssh richie@149.28.55.47 -p 278
|
|
||||||
```
|
|
||||||
|
|
||||||
## Small VM
|
|
||||||
|
|
||||||
This was required for a 2gb vm
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo tee /etc/default/grub.d/99-nixos-anywhere-disable-kho.cfg >/dev/null <<'EOF'
|
|
||||||
GRUB_CMDLINE_LINUX_DEFAULT="${GRUB_CMDLINE_LINUX_DEFAULT} kho=off"
|
|
||||||
EOF
|
|
||||||
|
|
||||||
sudo update-grub && sudo reboot
|
|
||||||
```
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
system_token: ENC[AES256_GCM,data:Yc6m0q2xX3n+dcMZ7NMn4c4UPBmGK0EjL6E4fMdxnNHcELpc+BEywCLOECJQUNw1jgMb35P6uuyEXrT+CsW9WKzbXE07T9clWo4sMkaxLugb/AklXCvMt7Ci0ObIaUZuoeKOs685rMUZLS6/er8dS0kWoBvuWfCq6pzfmC61wOQ=,iv:YfdJ1bvLsqzFloDyHG6+Epcf6jRMv6FAJSZRSHfAG6k=,tag:BlGBjxCXGZUd/+XKQ1mAkA==,type:str]
|
|
||||||
sops:
|
|
||||||
age:
|
|
||||||
- enc: |
|
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBYaXFMVDBDV1lCYThDY1dh
|
|
||||||
U1VWbDFvd2huc04yQXF3RDl5TkE4UTR2RjFJCkJDbkdoWFJDVVlubWZTQVpGK09W
|
|
||||||
WkRJL0F3cjdNUmVQSlZiSE5QUmMzRk0KLS0tIFBST1JxdHpRSjlTU3c2UGNUNzJO
|
|
||||||
YlpJbENvV21GeEJtSm1PMVExK0JlV28KGMo+3gMjtR7722AWZpT8YuEJT0cxdQXR
|
|
||||||
Jf9ZCzmY54mBolyUeh6iBfNqdP6Ao3TeOGQcKFO1d0mLAWfAqdaCVw==
|
|
||||||
-----END AGE ENCRYPTED FILE-----
|
|
||||||
recipient: age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c
|
|
||||||
- enc: |
|
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBaOTlxZjdHdnZwVGlRY2FW
|
|
||||||
WlU5R09LaW1rbzNmRUFWSkhtcHoyQWpJdGh3CkdFTUNBZFZJY0FjTVcxV0QvYWc2
|
|
||||||
SjI5Mkwwa3lTQTRTOWtXNXpaWHRITEEKLS0tIDlOS0tKNVRZOXdKRTNKa1pOZ2d3
|
|
||||||
MlEyZTZ0MXNkWXlSN2JlUHpKanpETkkKzWULPeeI6VH1GjKm/V/kEAc7DL5TzDbM
|
|
||||||
M700P1MqiCAyki6gO5BhmjdPspQFrc24z+hXL1WXJgKny7EmT9hUhw==
|
|
||||||
-----END AGE ENCRYPTED FILE-----
|
|
||||||
recipient: age1cm7ujz4t9cyc3nyy0w30l2addggk0gy8h3shd9yq47jg845tndnsa3utfl
|
|
||||||
lastmodified: "2026-10-14T02:09:14Z"
|
|
||||||
mac: ENC[AES256_GCM,data:s6KwNHefexYx1kFBOIg1fA8gMFkZFPDAS2ElvjEMM1Yfv6Sf8E3+opq5TWxAzm5i9+wR7/0QjmGVxHmtHqUxhO+TWke4te4HPf3etgGFHOZtVApc3DBGywO+g78XbyjbvoOALQmCTNrm7hHkhPbHFJWap/4DhChK4GEDUJHi8Zs=,iv:eCNA1ZWoLHWVeQi2o3maILQgoc18OrGnOHJDvPHUrfA=,tag:1YNztKsLAFtn5pkGMLueHA==,type:str]
|
|
||||||
unencrypted_suffix: _unencrypted
|
|
||||||
version: 3.13.3
|
|
||||||
@@ -3,15 +3,17 @@
|
|||||||
imports = [
|
imports = [
|
||||||
"${inputs.self}/users/richie"
|
"${inputs.self}/users/richie"
|
||||||
"${inputs.self}/common/global"
|
"${inputs.self}/common/global"
|
||||||
"${inputs.self}/common/optional/desktop"
|
"${inputs.self}/common/optional/desktop.nix"
|
||||||
"${inputs.self}/common/optional/docker.nix"
|
"${inputs.self}/common/optional/docker.nix"
|
||||||
"${inputs.self}/common/optional/steam.nix"
|
"${inputs.self}/common/optional/steam.nix"
|
||||||
"${inputs.self}/common/optional/syncthing_base.nix"
|
"${inputs.self}/common/optional/syncthing_base.nix"
|
||||||
"${inputs.self}/common/optional/systemd-boot.nix"
|
"${inputs.self}/common/optional/systemd-boot.nix"
|
||||||
"${inputs.self}/common/optional/tailscale.nix"
|
"${inputs.self}/common/optional/tailscale.nix"
|
||||||
|
"${inputs.self}/common/optional/yubikey.nix"
|
||||||
"${inputs.self}/common/optional/zfs"
|
"${inputs.self}/common/optional/zfs"
|
||||||
./hardware.nix
|
./hardware.nix
|
||||||
./programs.nix
|
./programs.nix
|
||||||
|
./qmk.nix
|
||||||
./syncthing.nix
|
./syncthing.nix
|
||||||
inputs.nixos-hardware.nixosModules.framework-13-7040-amd
|
inputs.nixos-hardware.nixosModules.framework-13-7040-amd
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
system_token: ENC[AES256_GCM,data:785g7cl+2IOLFnx0b4m+Eh2OyjPoKMDOHc4L9bI7t1JdRZgyeeGPuEvwI8Pd8NCpZV7vlibxT3RE5+7fjmUakRn6lTbTQ/U1rKIvKxPSS5ocPC6WXZLx4QRBvKzD6I8FFO45CkZ7GeVJ69j3wXWkyteHcCF0R+8r/VAAPTfBZG4=,iv:iO8oCSPEKMvLMRMNrGfgpwCBuMKx/cffksEGjoAlh4g=,tag:y8j4MPK0tjVcUkH0xJxEzQ==,type:str]
|
|
||||||
sops:
|
|
||||||
age:
|
|
||||||
- enc: |
|
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIMmdsaWhPeHBTUWRqVWMz
|
|
||||||
aVB4RmRJRWFqSTdIYTMxc0hpeFV6UlFvM2dNCjdkTEhXeVFhcERxd2x0OUZTZTQz
|
|
||||||
QitzMjJ1UHFPMmlqUGdNd2FYRWEvWFUKLS0tIDVicThCam9hM0tSOE5hc1JlVEtZ
|
|
||||||
Wis4TTlqV1hiWXpuNjNnQ0w5aWVOOEUKzYOt27fcJ1OcdJNGMHnbHfhNd5KhCWsm
|
|
||||||
Bc3o9js8QxZYHGgiyq9RFenwpT1HSQp7Oj4clwLv9JtCCrTXpJXq5g==
|
|
||||||
-----END AGE ENCRYPTED FILE-----
|
|
||||||
recipient: age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c
|
|
||||||
- enc: |
|
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBaTTI5aTZQaTlqVzBWNjc1
|
|
||||||
dmFXV3VyanFvVjh3cmpDVEFrTFUvNmxPcXgwCmhtSmE3bHJLWTNHNVQyQlBBUTVG
|
|
||||||
TzdveVNYSWFMZ3k2NjdVQ1VqcjFCajQKLS0tIG11VnNlVElUN3RjNmtsZjRIL1h5
|
|
||||||
MGgwbHZnWWVkSEFyYWd6ajgyWVVVM1EKd0SlmGctAWcxYn03cqM752vzmZdeg63O
|
|
||||||
N8kb1I+QaeTeUrYrCCfTl2iEspyJx1G9QPEfdvbHYKpgNfdJBDUyHQ==
|
|
||||||
-----END AGE ENCRYPTED FILE-----
|
|
||||||
recipient: age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4
|
|
||||||
lastmodified: "2026-10-14T02:09:45Z"
|
|
||||||
mac: ENC[AES256_GCM,data:NHkNpGH1b3srra8cc4WAq+npFIMuXi+2JNMquYNVoOS9VTyDXfOxSeMVAHjpWErRnDo+wPU26RHQXapsNa+MdrVYzCbhSib5tRNZCMysnl7l8nTGpeNAITCF+45++AaWhC9iljVkB8zQaFFZak8KHH4EXih8yHuojadVMwZZuEs=,iv:wG+H+s+vbN+l4ojrw/+aU6o9tS6UMIE674OHOI2e5eM=,tag:XRUi0fqlCWMaJmiLFheKDA==,type:str]
|
|
||||||
unencrypted_suffix: _unencrypted
|
|
||||||
version: 3.13.3
|
|
||||||
+36
-10
@@ -4,23 +4,25 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
|
from os import environ
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
from python.signal_alert import signal_alert
|
||||||
|
|
||||||
from python.signal_alert import SignalRPCError, signal_alert
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
|
||||||
def test_signal_alert(mocker: MockerFixture) -> None:
|
def test_signal_alert(mocker: MockerFixture) -> None:
|
||||||
|
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
|
||||||
|
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
|
||||||
|
|
||||||
mock_logger = mocker.patch("python.signal_alert.logger")
|
mock_logger = mocker.patch("python.signal_alert.logger")
|
||||||
mock_socket = mocker.patch("python.signal_alert.socket.socket")
|
mock_socket = mocker.patch("python.signal_alert.socket.socket")
|
||||||
client = mock_socket.return_value.__enter__.return_value
|
client = mock_socket.return_value.__enter__.return_value
|
||||||
client.recv.side_effect = [b'{"jsonrpc":"2.0","result":{},"id":"signal-alert"}\n']
|
client.recv.side_effect = [b'{"jsonrpc":"2.0","result":{},"id":"signal-alert"}\n']
|
||||||
|
|
||||||
assert signal_alert("test", "1234567890", "0987654321") is None
|
signal_alert("test")
|
||||||
|
|
||||||
mock_logger.info.assert_not_called()
|
mock_logger.info.assert_not_called()
|
||||||
mock_socket.assert_called_once_with(socket.AF_UNIX, socket.SOCK_STREAM)
|
mock_socket.assert_called_once_with(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
@@ -39,31 +41,55 @@ def test_signal_alert(mocker: MockerFixture) -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_alert_no_phones(mocker: MockerFixture) -> None:
|
||||||
|
environ.pop("SIGNAL_ALERT_FROM_PHONE", None)
|
||||||
|
environ.pop("SIGNAL_ALERT_TO_PHONE", None)
|
||||||
|
mock_logger = mocker.patch("python.signal_alert.logger")
|
||||||
|
|
||||||
|
signal_alert("test")
|
||||||
|
|
||||||
|
mock_logger.info.assert_called_once_with("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set")
|
||||||
|
|
||||||
|
|
||||||
def test_signal_alert_socket_error(mocker: MockerFixture) -> None:
|
def test_signal_alert_socket_error(mocker: MockerFixture) -> None:
|
||||||
|
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
|
||||||
|
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
|
||||||
|
|
||||||
|
mock_logger = mocker.patch("python.signal_alert.logger")
|
||||||
mocker.patch("python.signal_alert.socket.socket", side_effect=ConnectionError("connection failed"))
|
mocker.patch("python.signal_alert.socket.socket", side_effect=ConnectionError("connection failed"))
|
||||||
|
|
||||||
with pytest.raises(SignalRPCError, match="Signal RPC failed") as exc_info:
|
signal_alert("test")
|
||||||
signal_alert("test", "1234567890", "0987654321")
|
|
||||||
|
|
||||||
assert isinstance(exc_info.value.__cause__, ConnectionError)
|
mock_logger.exception.assert_called_once_with("Unable to communicate with signal-cli")
|
||||||
|
|
||||||
|
|
||||||
def test_signal_alert_rpc_error(mocker: MockerFixture) -> None:
|
def test_signal_alert_rpc_error(mocker: MockerFixture) -> None:
|
||||||
|
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
|
||||||
|
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
|
||||||
|
|
||||||
|
mock_logger = mocker.patch("python.signal_alert.logger")
|
||||||
mock_socket = mocker.patch("python.signal_alert.socket.socket")
|
mock_socket = mocker.patch("python.signal_alert.socket.socket")
|
||||||
client = mock_socket.return_value.__enter__.return_value
|
client = mock_socket.return_value.__enter__.return_value
|
||||||
client.recv.side_effect = [b'{"jsonrpc":"2.0","error":{"code":-1,"message":"failed"},"id":"signal-alert"}\n']
|
client.recv.side_effect = [b'{"jsonrpc":"2.0","error":{"code":-1,"message":"failed"},"id":"signal-alert"}\n']
|
||||||
|
|
||||||
with pytest.raises(SignalRPCError, match=r"Signal RPC returned an error:.*failed"):
|
signal_alert("test")
|
||||||
signal_alert("test", "1234567890", "0987654321")
|
|
||||||
|
mock_logger.error.assert_called_once_with(
|
||||||
|
"Signal RPC returned an error: %s",
|
||||||
|
{"code": -1, "message": "failed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_signal_alert_ignores_notifications(mocker: MockerFixture) -> None:
|
def test_signal_alert_ignores_notifications(mocker: MockerFixture) -> None:
|
||||||
|
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
|
||||||
|
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
|
||||||
|
|
||||||
mock_socket = mocker.patch("python.signal_alert.socket.socket")
|
mock_socket = mocker.patch("python.signal_alert.socket.socket")
|
||||||
client = mock_socket.return_value.__enter__.return_value
|
client = mock_socket.return_value.__enter__.return_value
|
||||||
client.recv.side_effect = [
|
client.recv.side_effect = [
|
||||||
b'{"jsonrpc":"2.0","method":"receive"}\n{"jsonrpc":"2.0","result":{},"id":"signal-alert"}\n'
|
b'{"jsonrpc":"2.0","method":"receive"}\n{"jsonrpc":"2.0","result":{},"id":"signal-alert"}\n'
|
||||||
]
|
]
|
||||||
|
|
||||||
assert signal_alert("test", "1234567890", "0987654321") is None
|
signal_alert("test")
|
||||||
|
|
||||||
assert client.recv.call_count == 1
|
assert client.recv.call_count == 1
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""test_snapshot_manager."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from python.tools.snapshot_manager import get_snapshots_to_delete, get_time_stamp, load_config_data, main
|
||||||
|
from python.zfs.dataset import Dataset, Snapshot
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfakefs.fake_filesystem import FakeFilesystem
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
SNAPSHOT_MANAGER = "python.tools.snapshot_manager"
|
||||||
|
|
||||||
|
|
||||||
|
def patch_utcnow(mocker: MockerFixture, datetime_value: datetime) -> None:
|
||||||
|
"""patch_utcnow."""
|
||||||
|
mocker.patch("python.tools.snapshot_manager.utcnow", return_value=datetime_value)
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_snapshot(mocker: MockerFixture, name: str) -> Snapshot:
|
||||||
|
"""create_mock_snapshot."""
|
||||||
|
mock_snapshot = mocker.MagicMock(spec=Snapshot)
|
||||||
|
mock_snapshot.name = name
|
||||||
|
|
||||||
|
return mock_snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def test_main(mocker: MockerFixture, fs: FakeFilesystem) -> None:
|
||||||
|
"""Test main."""
|
||||||
|
load_config_data.cache_clear()
|
||||||
|
|
||||||
|
mocker.patch(f"{SNAPSHOT_MANAGER}.get_time_stamp", return_value="2023-01-01T00:00:00")
|
||||||
|
|
||||||
|
mock_dataset = mocker.MagicMock(spec=Dataset)
|
||||||
|
mock_dataset.name = "test_dataset"
|
||||||
|
mock_dataset.create_snapshot.return_value = "snapshot created"
|
||||||
|
mock_get_datasets = mocker.patch(f"{SNAPSHOT_MANAGER}.get_datasets", return_value=(mock_dataset,))
|
||||||
|
|
||||||
|
mock_get_snapshots_to_delete = mocker.patch(f"{SNAPSHOT_MANAGER}.get_snapshots_to_delete")
|
||||||
|
mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert")
|
||||||
|
mock_snapshot_config_toml = '["default"]\n15_min = 8\nhourly = 24\ndaily = 0\nmonthly = 0\n'
|
||||||
|
fs.create_file("/mock_snapshot_config.toml", contents=mock_snapshot_config_toml)
|
||||||
|
main(Path("/mock_snapshot_config.toml"))
|
||||||
|
|
||||||
|
mock_signal_alert.assert_not_called()
|
||||||
|
mock_get_datasets.assert_called_once()
|
||||||
|
mock_get_snapshots_to_delete.assert_called_once_with(
|
||||||
|
mock_dataset,
|
||||||
|
{
|
||||||
|
"15_min": 8,
|
||||||
|
"hourly": 24,
|
||||||
|
"daily": 0,
|
||||||
|
"monthly": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_create_snapshot_failure(mocker: MockerFixture, fs: FakeFilesystem) -> None:
|
||||||
|
"""Test main."""
|
||||||
|
load_config_data.cache_clear()
|
||||||
|
|
||||||
|
mocker.patch(f"{SNAPSHOT_MANAGER}.get_time_stamp", return_value="2023-01-01T00:00:00")
|
||||||
|
|
||||||
|
mock_dataset = mocker.MagicMock(spec=Dataset)
|
||||||
|
mock_dataset.name = "test_dataset"
|
||||||
|
mock_dataset.create_snapshot.return_value = "snapshot not created"
|
||||||
|
mock_get_datasets = mocker.patch(f"{SNAPSHOT_MANAGER}.get_datasets", return_value=(mock_dataset,))
|
||||||
|
|
||||||
|
mock_get_snapshots_to_delete = mocker.patch(f"{SNAPSHOT_MANAGER}.get_snapshots_to_delete")
|
||||||
|
mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert")
|
||||||
|
mock_snapshot_config_toml = '["default"]\n15_min = 8\nhourly = 24\ndaily = 0\nmonthly = 0\n'
|
||||||
|
fs.create_file("/mock_snapshot_config.toml", contents=mock_snapshot_config_toml)
|
||||||
|
main(Path("/mock_snapshot_config.toml"))
|
||||||
|
|
||||||
|
mock_signal_alert.assert_called_once_with("test_dataset failed to create snapshot 2023-01-01T00:00:00")
|
||||||
|
mock_get_datasets.assert_called_once()
|
||||||
|
mock_get_snapshots_to_delete.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_exception(mocker: MockerFixture, fs: FakeFilesystem) -> None:
|
||||||
|
"""Test main."""
|
||||||
|
load_config_data.cache_clear()
|
||||||
|
|
||||||
|
mocker.patch(f"{SNAPSHOT_MANAGER}.get_time_stamp", return_value="2023-01-01T00:00:00")
|
||||||
|
|
||||||
|
mock_dataset = mocker.MagicMock(spec=Dataset)
|
||||||
|
mock_dataset.name = "test_dataset"
|
||||||
|
mock_dataset.create_snapshot.return_value = "snapshot created"
|
||||||
|
mock_get_datasets = mocker.patch(f"{SNAPSHOT_MANAGER}.get_datasets", side_effect=Exception("test"))
|
||||||
|
|
||||||
|
mock_get_snapshots_to_delete = mocker.patch(f"{SNAPSHOT_MANAGER}.get_snapshots_to_delete")
|
||||||
|
mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert")
|
||||||
|
mock_snapshot_config_toml = '["default"]\n15_min = 8\nhourly = 24\ndaily = 0\nmonthly = 0\n'
|
||||||
|
fs.create_file("/mock_snapshot_config.toml", contents=mock_snapshot_config_toml)
|
||||||
|
with pytest.raises(SystemExit) as pytest_wrapped_e:
|
||||||
|
main(Path("/mock_snapshot_config.toml"))
|
||||||
|
|
||||||
|
assert isinstance(pytest_wrapped_e.value, SystemExit)
|
||||||
|
assert pytest_wrapped_e.value.code == 1
|
||||||
|
mock_signal_alert.assert_called_once_with("snapshot_manager failed")
|
||||||
|
mock_get_datasets.assert_called_once()
|
||||||
|
mock_get_snapshots_to_delete.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_snapshots_to_delete(mocker: MockerFixture) -> None:
|
||||||
|
"""test_get_snapshots_to_delete."""
|
||||||
|
mock_snapshot_0 = create_mock_snapshot(mocker, "auto_202509150415")
|
||||||
|
mock_snapshot_1 = create_mock_snapshot(mocker, "auto_202509150415")
|
||||||
|
|
||||||
|
mock_dataset = mocker.MagicMock(spec=Dataset)
|
||||||
|
mock_dataset.name = "test_dataset"
|
||||||
|
mock_dataset.get_snapshots.return_value = (mock_snapshot_0, mock_snapshot_1)
|
||||||
|
mock_dataset.delete_snapshot.return_value = None
|
||||||
|
|
||||||
|
mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert")
|
||||||
|
|
||||||
|
get_snapshots_to_delete(mock_dataset, {"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0})
|
||||||
|
|
||||||
|
mock_signal_alert.assert_not_called()
|
||||||
|
mock_dataset.delete_snapshot.assert_called_once_with("auto_202509150415")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_snapshots_to_delete_no_snapshot(mocker: MockerFixture) -> None:
|
||||||
|
"""test_get_snapshots_to_delete_no_snapshot."""
|
||||||
|
mock_dataset = mocker.MagicMock(spec=Dataset)
|
||||||
|
mock_dataset.name = "test_dataset"
|
||||||
|
mock_dataset.get_snapshots.return_value = ()
|
||||||
|
mock_dataset.delete_snapshot.return_value = None
|
||||||
|
|
||||||
|
mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert")
|
||||||
|
|
||||||
|
get_snapshots_to_delete(mock_dataset, {"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0})
|
||||||
|
|
||||||
|
mock_signal_alert.assert_not_called()
|
||||||
|
mock_dataset.delete_snapshot.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_snapshots_to_delete_errored(mocker: MockerFixture) -> None:
|
||||||
|
"""test_get_snapshots_to_delete_errored."""
|
||||||
|
mock_snapshot_0 = create_mock_snapshot(mocker, "auto_202509150415")
|
||||||
|
mock_snapshot_1 = create_mock_snapshot(mocker, "auto_202509150415")
|
||||||
|
|
||||||
|
mock_dataset = mocker.MagicMock(spec=Dataset)
|
||||||
|
mock_dataset.name = "test_dataset"
|
||||||
|
mock_dataset.get_snapshots.return_value = (mock_snapshot_0, mock_snapshot_1)
|
||||||
|
mock_dataset.delete_snapshot.return_value = "snapshot has dependent clones"
|
||||||
|
|
||||||
|
mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert")
|
||||||
|
|
||||||
|
get_snapshots_to_delete(mock_dataset, {"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0})
|
||||||
|
|
||||||
|
mock_signal_alert.assert_called_once_with(
|
||||||
|
"test_dataset@auto_202509150415 failed to delete: snapshot has dependent clones"
|
||||||
|
)
|
||||||
|
mock_dataset.delete_snapshot.assert_called_once_with("auto_202509150415")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_time_stamp(mocker: MockerFixture) -> None:
|
||||||
|
"""Test get_time_stamp."""
|
||||||
|
patch_utcnow(mocker, datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC))
|
||||||
|
assert get_time_stamp() == "auto_202301010000"
|
||||||
@@ -13,11 +13,6 @@ command = "nixfmt"
|
|||||||
# Glob pattern of files to include
|
# Glob pattern of files to include
|
||||||
includes = ["*.nix"]
|
includes = ["*.nix"]
|
||||||
|
|
||||||
[formatter.rustfmt]
|
|
||||||
command = "rustfmt"
|
|
||||||
options = ["--edition", "2024"]
|
|
||||||
includes = ["*.rs"]
|
|
||||||
|
|
||||||
[formatter.ruff-format]
|
[formatter.ruff-format]
|
||||||
command = "ruff"
|
command = "ruff"
|
||||||
options = ["format"]
|
options = ["format"]
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ in
|
|||||||
"hass"
|
"hass"
|
||||||
"libvirtd"
|
"libvirtd"
|
||||||
"networkmanager"
|
"networkmanager"
|
||||||
|
"nornsight"
|
||||||
|
"nornsight-admin"
|
||||||
"plugdev"
|
"plugdev"
|
||||||
"scanner"
|
"scanner"
|
||||||
"transmission"
|
"transmission"
|
||||||
|
|||||||
@@ -36,10 +36,11 @@ in
|
|||||||
"hass"
|
"hass"
|
||||||
"libvirtd"
|
"libvirtd"
|
||||||
"networkmanager"
|
"networkmanager"
|
||||||
|
"nornsight"
|
||||||
|
"nornsight-admin"
|
||||||
"ollama"
|
"ollama"
|
||||||
"plugdev"
|
"plugdev"
|
||||||
"scanner"
|
"scanner"
|
||||||
"signal-cli"
|
|
||||||
"transmission"
|
"transmission"
|
||||||
"uaccess"
|
"uaccess"
|
||||||
"uucp"
|
"uucp"
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
vlc
|
vlc
|
||||||
# browser
|
# browser
|
||||||
brave
|
brave
|
||||||
firefox
|
|
||||||
# dev tools
|
# dev tools
|
||||||
gparted
|
gparted
|
||||||
jetbrains.datagrip
|
jetbrains.datagrip
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
{ pkgs, ... }:
|
{ pkgs, ... }:
|
||||||
{
|
{
|
||||||
home.packages = [
|
home.packages = [
|
||||||
|
pkgs.master.claude-code
|
||||||
pkgs.master.codex
|
pkgs.master.codex
|
||||||
pkgs.master.opencode
|
pkgs.master.opencode
|
||||||
|
pkgs.master.pi-coding-agent
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -42,9 +42,6 @@
|
|||||||
rustfmt
|
rustfmt
|
||||||
trunk
|
trunk
|
||||||
wasm-pack
|
wasm-pack
|
||||||
# protobuf
|
|
||||||
protobuf
|
|
||||||
buf
|
|
||||||
# nix
|
# nix
|
||||||
nix-init
|
nix-init
|
||||||
nix-output-monitor
|
nix-output-monitor
|
||||||
|
|||||||
+31
-31
@@ -6,58 +6,58 @@ sops:
|
|||||||
age:
|
age:
|
||||||
- enc: |
|
- enc: |
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArMVJ6Rm1QQlAvR2FKRmFu
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBEMjQvSzZSY08vRlI0dzBP
|
||||||
dlBianhsU051dnh4Y3Zqdkt2RHVQTy9KVVZFClVCMXZmR3pSR0hxNzB2RnFuS2Fs
|
YytpVlpPSGFHaTI5b2xzUkFFdEY0bVhQdGgwClM3UmM1OElpcEZTcTB0NHpSZ2d6
|
||||||
OGZCQjdJTmhTa1NDZ1B4L2tJSnFmdmcKLS0tIEhTQTRNZUdlNy9XcC9EbnVrcmZQ
|
aVBpcjVIaDk3Tko1cm4xYjdkYVQ1L1EKLS0tIE1aQW95U0JIVGc4MGloSXRXc3ZS
|
||||||
Tzh5SUt0K3hUVll2dDZNR1ZnbzVPRFUKxTLOmsr5zMKCuH7KDqs1itkPLn+2kzSh
|
a3EvU1dJdmxOeVBIS28yT3dHd2ZEYWcKv5TIqRRQs3AXER1WHEx8wotyXr2Ktxsu
|
||||||
0s5C5XOIsMwUrkA/uIFrOWaP57YBVWPM2Ry+GAp4Bbf+y8C1en5HUw==
|
dwldwEHyDNqwf7UgGOZu7JueeZ7y2KC8KZ7ixIdrhVaoxuLEvCg7VA==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
recipient: age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c
|
recipient: age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c
|
||||||
- enc: |
|
- enc: |
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmaTd1dU0zUGZVSkJJOUQw
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByaE5jb3p3VzZ1dE05Wmlx
|
||||||
OThIMDVSM1FKdVptaDNsMlFOYThpVmFnN1JvCmpqZlBrY2hVQ2grWkJOZTJkay9T
|
c1ZoWGhTcTRHRE9oMWtkaCtBY1l1a2QyWWlvCk9MM2FmcEZubXR1REpOcHZ6aHVE
|
||||||
Q0VBVzRVYk5vU1BGMFhOckl3TDJDNjQKLS0tIDVqQTlJaHBuY0tHLzEwKzh6QStZ
|
UEkzbllZdkxPYzlTMHNod3VWNnhoaTgKLS0tIGVCdEpqUzhFM0swRjVjb0Z6ckFN
|
||||||
a0ZqZmdOTnNEdEp6Nk5sN0NyT05MWmMKgP43hrlXzBpp+hELBaOJdYdsU3AmDvPf
|
UzlYb1U4UGJqWE9oczdlNVh1S1lqL3cKSKxaJFhun2ee45I1jC55YKNdfYB8MsS+
|
||||||
FOh89Fn9Zp+Lm1shQTF6GcACTAr04+7MPfLveyUFt4vbHxr9ISYaOg==
|
dgFOFCvn/4uEcUyKZbUf0STjbIZ5eMHl8NVRTupI4tftGiE559M5Wg==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
recipient: age1q47vup0tjhulkg7d6xwmdsgrw64h4ax3la3evzqpxyy4adsmk9fs56qz3y
|
recipient: age1q47vup0tjhulkg7d6xwmdsgrw64h4ax3la3evzqpxyy4adsmk9fs56qz3y
|
||||||
- enc: |
|
- enc: |
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvWUZRME43WmxLd1BHaHFm
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2U01jTVZQT1RRRU1nQmVv
|
||||||
dXBib3NLcnI2K3M0cnl3NHJxSXU4Q253cW1ZCmlva2RReGcyTFN0TURCbkJFcHRW
|
Z0lnOGJLN1c0UkNLbnJjV29na3Rub0xzbXhNCjY0aFNuUnFDUnZheDRHT3R5L3Fv
|
||||||
Skd6aVVhcFlOYXkvTVVBRzZaYUpSUHcKLS0tIHlxMHQ1d21TSDBHMmtraUM3S0F2
|
ZkJ3QkF2RENKRVdUYjRzRXMrNklvN2cKLS0tIFJzK2o1MmVTWHZMWmVDWlgzeGY5
|
||||||
VUUwOFJtSkM3V2tESnExR1hUSCt0TjgKOnhAVyikMjsc/lTKJrkbhQCw/EgiKuUg
|
VDg4OGl0NGFYY2VNZGY5UkR1cVdWY0UK1qa3swdfgUw1tnnThRf1sl9f2zTP/d/d
|
||||||
DmzoBvVYmBfAJR74VVOdex2t0Ieg9SuHtCO7dQ7h82woY9Z8UudWDQ==
|
iwcenOEryXatVMJjZVWrLC5C7MwtKz+pIBXa98yaSpCDLqjAMwOCPg==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
recipient: age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm
|
recipient: age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm
|
||||||
- enc: |
|
- enc: |
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMazg0K1o0WkU0ZHRZdWpV
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTREI2UEZwS0Y0SmEvK1F4
|
||||||
UGxMV0cwaW9ZOG5PWVI4ZXZrSldIc2xuMDNzCmlhYjUwMXF5U1g0ODlMZmVVVXdj
|
Y1hZZjRVWWxMT29mc3ZXcHFDclRUYi96LzA4CkIwZ2h4T2JQRW1OL2hmRTFHWkFY
|
||||||
K3hOM1Arc3VNTHNKMkozRXpYSG5RQWcKLS0tIEhrZFF6K1ZpYjNiMEFyYUpGejNM
|
RHZqQUFqU3hvNlRBZHRiSThPVUE3TG8KLS0tIHh0Z2tlNXREczJsa1g2ekZyZjBt
|
||||||
Q3Q2cFhaVHhoOTZzM3pKeDBFam9PUVEK4XPeeoMYFWCr/LBTA+ZrnMqz+mE3IaKo
|
dW41aVA3VEVqa1NDMHM0T3FKZFNTN0kKGQPRUMSRtibFVFRZR/igsjgxgDo4ogga
|
||||||
OQNVGW84fk7SZ4lWWU8l6jZmmX1Sf9aqyFad1hoI9N3AEZmhiiyhlA==
|
dUHBRU65r41er+Wk3lKdtbXJX1g83K8ay7540hNXheOhxFi57trrzA==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
recipient: age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv
|
recipient: age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv
|
||||||
- enc: |
|
- enc: |
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBoRHF5R2lWYWF1dUM3a3ZJ
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrV1Z2MGNPK28wTStRSUZW
|
||||||
RWZDVFBjRVhRaE5PRGtlbmROZm5IOWdNQkYwCldSWisvaVZDLzdwRDFCRkprM1Vo
|
dmFMaUt5MENHeUpVcDVxQnZhd1o5RmJwOWwwCittaGNrcW1hV25VaGJaTEhZRFBJ
|
||||||
NnFXUU1FWWIwZ3Nja3Mya2l3U1lROGcKLS0tIGRlZmhFQlBCZEJxYnRTUkcrSlVq
|
Y2toWEpFWFZYT0plb2lkTlJCS2Mwb3MKLS0tIHlkVjVESWFsbWlVQ0xVQ0llclF2
|
||||||
RC9jY0NINCtKTHRmMGRmd0N1WUVHWWcKrBjys6OKDuAELbpFpLOvV5GOXuVW9STX
|
L1FMbC91YjJsR3BGRjJ0QVQ1MDdBbEUKNnOI9+H1LJcvVcwl9uQMmFp35y5RB+Zl
|
||||||
ndHNw1rI14QJ7lZ54gAfdAtNFl1NzHDsaUuv/HIW9i2jhkv+APKtuA==
|
Mo3z3unweQtS+jiUn7RyVs5daG1E2tfihu2CQDWaktuCuunMpt3Wew==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
recipient: age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4
|
recipient: age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4
|
||||||
- enc: |
|
- enc: |
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA5Ti9laG9aTlpSSFR5Vmk0
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqR0hUT3hxTzRwTVhuTm9E
|
||||||
MEVFY2pJdDJaK0RZQ1dUd2dRMDBSOVNNWEhBCmdsSDl3a1RPTE9xSlRSZTBhNFVB
|
TUFtNFFCYWNUNUNVbHNaNXQ0aEVBa04vSEU0CkRPVzhuYUsrM0wxZjRDdkxvTVA2
|
||||||
bzFoVlZzQjQ3QWJoZkwrUWFucTlRN1kKLS0tIEozRUwwTnlpMHg4eGdLa2h5U3hF
|
UW9CSjlIQW5yenhaUi9mbEdLYlhWYk0KLS0tIGRXMm9Wa3l4UWxqcGl6ZlBZV3E0
|
||||||
ekEwYTBKWmRiK29JMXVqbU1DWVVWNGcKY6kkh7poDQHDdZdFdqzEI12P9UMq2saQ
|
Z3YrbUYrSmV0NHNTeTdhMkJEdytwSmMKGLpe5+p4FGJNHy6SVgnsDbEsiP0J8lW/
|
||||||
gQXljjqcamqZ96KwFmuZm9RB/6OJJAPY8MBSqlrPTSjcnAqikXWpEA==
|
0+3X3LQoL0f1S2L5DjexF/H0NGvWjAPaGzDNB6V50VxGDabEIVqjvQ==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
recipient: age1cm7ujz4t9cyc3nyy0w30l2addggk0gy8h3shd9yq47jg845tndnsa3utfl
|
recipient: age1vyav6kxtvt3z4vtnkkjj38eu8hlts5m7ygyckhskvalg2gpjk52su53d0a
|
||||||
lastmodified: "2026-08-25T15:35:53Z"
|
lastmodified: "2026-08-25T15:35:53Z"
|
||||||
mac: ENC[AES256_GCM,data:4/WuSmUb2ONlB56dgbwwekZ8h7E1vvPzPxYCvNM8N68nt1nb9JxoMeAX9PktmljAarYLQJd1O5+49ZEph3Gzx9LrwvWnm9asq0UeeJi08x7wdQDcawNFlBFzXF2qlgxU1JssNHZZOB+5nYH4t8vFzPTq/9BqoifPR2+pLBYhUQI=,iv:N/gVgeWT2/1bfHuR6TanyDBijWmXAqyACXpnwfwDuVA=,tag:pQ4ct4G7SARz2wyjOc7AKQ==,type:str]
|
mac: ENC[AES256_GCM,data:4/WuSmUb2ONlB56dgbwwekZ8h7E1vvPzPxYCvNM8N68nt1nb9JxoMeAX9PktmljAarYLQJd1O5+49ZEph3Gzx9LrwvWnm9asq0UeeJi08x7wdQDcawNFlBFzXF2qlgxU1JssNHZZOB+5nYH4t8vFzPTq/9BqoifPR2+pLBYhUQI=,iv:N/gVgeWT2/1bfHuR6TanyDBijWmXAqyACXpnwfwDuVA=,tag:pQ4ct4G7SARz2wyjOc7AKQ==,type:str]
|
||||||
unencrypted_suffix: _unencrypted
|
unencrypted_suffix: _unencrypted
|
||||||
|
|||||||
Reference in new issue
Block a user